This commit is contained in:
ponzischeme89
2026-08-28 23:00:02 +12:00
parent 3e89036f7b
commit d5632e844a
66 changed files with 2870 additions and 689 deletions
+72
View File
@@ -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 == "" {
+52
View File
@@ -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")
+24 -10
View File
@@ -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