0.3.49
This commit is contained in:
@@ -4,6 +4,7 @@ package config
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -85,6 +86,17 @@ type Config struct {
|
||||
|
||||
UpstreamTimeout time.Duration
|
||||
|
||||
// TrustedProxies are the reverse proxies whose X-Forwarded-For and X-Real-IP
|
||||
// headers the gateway will believe when working out where a request originated.
|
||||
// A forwarded header is only honoured when the immediate peer is one of these, so
|
||||
// a client reaching the gateway directly cannot spoof its address by setting one.
|
||||
//
|
||||
// Empty (MEMBY_TRUSTED_PROXIES unset) trusts loopback and the private/unique-local
|
||||
// ranges — every reverse proxy a home deployment puts in front of Memby sits in one
|
||||
// of those. Set it to a comma-separated list of addresses or CIDR ranges to narrow
|
||||
// or widen that, or to "none" to trust no proxy at all.
|
||||
TrustedProxies []netip.Prefix
|
||||
|
||||
// SlowRequestThreshold is how long a request has to take before its log line
|
||||
// carries a stage breakdown. Fast requests deliberately carry none: it is the one
|
||||
// field on the line that varies in width, and a column of them on every /v1/status
|
||||
@@ -225,6 +237,10 @@ func Load() (Config, error) {
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
trusted, err := trustedProxies(os.Getenv("MEMBY_TRUSTED_PROXIES"))
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("MEMBY_TRUSTED_PROXIES: %w", err)
|
||||
}
|
||||
c := Config{
|
||||
ListenAddr: env("MEMBY_LISTEN_ADDR", ":8080"),
|
||||
EmbyURL: strings.TrimRight(os.Getenv("MEMBY_EMBY_URL"), "/"),
|
||||
@@ -261,6 +277,7 @@ func Load() (Config, error) {
|
||||
SyncAPIKey: strings.TrimSpace(os.Getenv("MEMBY_SYNC_API_KEY")),
|
||||
AnalyticsRetention: duration("MEMBY_ANALYTICS_RETENTION", 90*24*time.Hour),
|
||||
UpstreamTimeout: duration("MEMBY_UPSTREAM_TIMEOUT", 20*time.Second),
|
||||
TrustedProxies: trusted,
|
||||
SlowRequestThreshold: duration("MEMBY_SLOW_REQUEST_THRESHOLD", 500*time.Millisecond),
|
||||
EmbyHealthInterval: duration("MEMBY_EMBY_HEALTH_INTERVAL", 60*time.Second),
|
||||
SonarrURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_SONARR_URL")), "/"),
|
||||
@@ -408,6 +425,61 @@ func duration(key string, fallback time.Duration) time.Duration {
|
||||
return fallback
|
||||
}
|
||||
|
||||
// DefaultTrustedProxyRanges is what MEMBY_TRUSTED_PROXIES falls back to: loopback and
|
||||
// the private and unique-local ranges. It is exported so the request layer can use the
|
||||
// same set when it is handed a nil list.
|
||||
func DefaultTrustedProxyRanges() []netip.Prefix {
|
||||
return parsePrefixes(
|
||||
"127.0.0.0/8", "::1/128",
|
||||
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
|
||||
"169.254.0.0/16", "fe80::/10", "fc00::/7",
|
||||
)
|
||||
}
|
||||
|
||||
func parsePrefixes(values ...string) []netip.Prefix {
|
||||
out := make([]netip.Prefix, 0, len(values))
|
||||
for _, value := range values {
|
||||
if prefix, err := netip.ParsePrefix(value); err == nil {
|
||||
out = append(out, prefix.Masked())
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// trustedProxies reads the MEMBY_TRUSTED_PROXIES list. Blank means the defaults, the
|
||||
// literal "none" means an empty (but non-nil) set, and every other token is an address
|
||||
// or a CIDR range — with "private" as a shorthand for the default ranges.
|
||||
func trustedProxies(raw string) ([]netip.Prefix, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
switch {
|
||||
case raw == "":
|
||||
return DefaultTrustedProxyRanges(), nil
|
||||
case strings.EqualFold(raw, "none"):
|
||||
return []netip.Prefix{}, nil
|
||||
}
|
||||
out := []netip.Prefix{}
|
||||
for _, token := range strings.Split(raw, ",") {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(token, "private") {
|
||||
out = append(out, DefaultTrustedProxyRanges()...)
|
||||
continue
|
||||
}
|
||||
if prefix, err := netip.ParsePrefix(token); err == nil {
|
||||
out = append(out, prefix.Masked())
|
||||
continue
|
||||
}
|
||||
addr, err := netip.ParseAddr(token)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%q is not an IP address or CIDR range", token)
|
||||
}
|
||||
out = append(out, netip.PrefixFrom(addr, addr.BitLen()))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func integer(key string, fallback int) int {
|
||||
raw := strings.TrimSpace(os.Getenv(key))
|
||||
if raw == "" {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -89,6 +90,57 @@ func TestForYouRebuildHourIsValidated(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrustedProxiesDefaultToLoopbackAndPrivateRanges(t *testing.T) {
|
||||
t.Setenv("MEMBY_EMBY_URL", "http://emby")
|
||||
t.Setenv("MEMBY_DATABASE_URL", "postgres://memby")
|
||||
t.Setenv("MEMBY_TRUSTED_PROXIES", "")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
trusts := func(ip string) bool {
|
||||
addr := netip.MustParseAddr(ip)
|
||||
for _, prefix := range cfg.TrustedProxies {
|
||||
if prefix.Contains(addr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
for _, want := range []string{"127.0.0.1", "10.0.0.2", "192.168.1.5"} {
|
||||
if !trusts(want) {
|
||||
t.Fatalf("%s should be trusted by default", want)
|
||||
}
|
||||
}
|
||||
if trusts("203.0.113.9") {
|
||||
t.Fatal("a public address must not be trusted by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrustedProxiesNoneClearsTheList(t *testing.T) {
|
||||
t.Setenv("MEMBY_EMBY_URL", "http://emby")
|
||||
t.Setenv("MEMBY_DATABASE_URL", "postgres://memby")
|
||||
t.Setenv("MEMBY_TRUSTED_PROXIES", "none")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.TrustedProxies == nil || len(cfg.TrustedProxies) != 0 {
|
||||
t.Fatalf("trusted proxies = %v, want an empty non-nil list", cfg.TrustedProxies)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrustedProxiesRejectGarbage(t *testing.T) {
|
||||
t.Setenv("MEMBY_EMBY_URL", "http://emby")
|
||||
t.Setenv("MEMBY_DATABASE_URL", "postgres://memby")
|
||||
t.Setenv("MEMBY_TRUSTED_PROXIES", "10.0.0.0/8, not-an-address")
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatal("expected an unparseable trusted proxy entry to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendationWeightsMustBeJSON(t *testing.T) {
|
||||
t.Setenv("MEMBY_EMBY_URL", "http://emby")
|
||||
t.Setenv("MEMBY_DATABASE_URL", "postgres://memby")
|
||||
|
||||
@@ -82,18 +82,29 @@ type RemotePageConfig struct {
|
||||
// RemoteSectionDefinition is the stable composition contract. Clients render only
|
||||
// known component types and ignore definitions introduced by newer gateways.
|
||||
type RemoteSectionDefinition struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Position int `json:"position"`
|
||||
DataSource string `json:"dataSource"`
|
||||
Component string `json:"component"`
|
||||
MaxItems int `json:"maxItems,omitempty"`
|
||||
Destination string `json:"destination,omitempty"`
|
||||
Settings map[string]any `json:"settings,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Position int `json:"position"`
|
||||
DataSource string `json:"dataSource"`
|
||||
Component string `json:"component"`
|
||||
MaxItems int `json:"maxItems,omitempty"`
|
||||
Destination string `json:"destination,omitempty"`
|
||||
// Layout overrides the card shape a mediaRow draws: "poster" forces upright poster
|
||||
// cards, "thumb" forces the wide landscape cards Continue Watching uses. Empty leaves
|
||||
// the client's automatic choice (episodes landscape, everything else poster) alone.
|
||||
Layout string `json:"layout,omitempty"`
|
||||
Settings map[string]any `json:"settings,omitempty"`
|
||||
}
|
||||
|
||||
// SectionLayoutPoster and SectionLayoutThumb are the two explicit card shapes an operator
|
||||
// can pin a row to; anything else means "let the client decide".
|
||||
const (
|
||||
SectionLayoutPoster = "poster"
|
||||
SectionLayoutThumb = "thumb"
|
||||
)
|
||||
|
||||
type RemoteContinueWatching struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
IncludeNextUp bool `json:"includeNextUp"`
|
||||
@@ -321,6 +332,9 @@ func validateRemoteConfigSections(document RemoteConfig) error {
|
||||
if section.Position < 0 || section.MaxItems < 0 || section.MaxItems > 500 {
|
||||
return fmt.Errorf("remote configuration section has an invalid position or item limit")
|
||||
}
|
||||
if section.Layout != "" && section.Layout != SectionLayoutPoster && section.Layout != SectionLayoutThumb {
|
||||
return fmt.Errorf("remote configuration section %q has an invalid layout", section.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user