Files
memby/server/internal/config/config.go
T

494 lines
22 KiB
Go
Raw Normal View History

// Package config loads the gateway's settings from the environment.
package config
import (
2026-08-02 22:10:19 +12:00
"encoding/json"
"fmt"
2026-08-28 23:00:02 +12:00
"net/netip"
"os"
"strconv"
"strings"
"time"
2026-07-27 21:06:51 +12:00
_ "time/tzdata"
2026-08-12 13:08:53 +12:00
"github.com/ponzischeme89/memby/server/internal/emby"
)
type Config struct {
ListenAddr string
// EmbyURL is how the gateway itself reaches Emby (may be a private/docker address).
EmbyURL string
// EmbyPublicURL is the address handed to TV clients for direct video playback.
// Defaults to EmbyURL; set it when the gateway talks to Emby over a network the
// TVs cannot reach.
EmbyPublicURL string
// EmbyMediaURL is the address the gateway reads *media bytes* from, which is a
// different question from how it reads metadata and is the only place the difference
// costs anything.
//
// Credits detection is the one thing on the server side that opens a media file, and it
// does so with ranged reads over HTTP. A metadata call is a few kilobytes and does not
// care which way it is routed; a scan is megabytes, and where EmbyURL is a public
// hostname — which it legitimately may be, since the gateway and Emby need not share a
// network — every one of those ranged reads leaves the host for the internet-facing
// edge, pays TLS, and comes back in through a reverse proxy that is free to buffer the
// response and discard the range economy entirely.
//
// So this is stated separately rather than inferred: there is no way to look at a URL
// and tell whether it happens to resolve locally. Unset, it falls back to EmbyURL and
// nothing changes.
EmbyMediaURL string
DatabaseURL string
RedisURL string
// ClientName is reported to Emby in the X-Emby-Authorization header, so sessions
2026-08-06 22:33:56 +12:00
// show up as this in Emby's dashboard. It is deliberately not the app's own name:
// the header travels to whatever Emby does with its logs, and the product name has
// no business being the thing that identifies a client to a third party.
ClientName string
2026-08-27 07:31:57 +12:00
// IgnoredClientName records a legacy MEMBY_CLIENT_NAME override that no longer takes
// effect. It is logged at start-up so a stale deployment can be corrected deliberately.
IgnoredClientName string
2026-08-12 13:08:53 +12:00
// GatewayClientName is reported instead for a request the gateway makes on its own
// behalf — the library sync, the health probe, device cleanup, and an operator
// signing into the admin console or the web installer. Those are the server asking,
// and reporting them as a television made Emby's device list claim a set that does
// not exist in the house.
GatewayClientName string
HomeTTL time.Duration
ItemTTL time.Duration
SearchTTL time.Duration
ScreensaverTTL time.Duration
SessionTTL time.Duration
// SessionIdleExpiry retires gateway tokens that go unused for this long.
SessionIdleExpiry time.Duration
// RecommendTTL is how long computed recommendation rows stay warm. Long, because
// taste moves slowly and each rebuild costs several Emby queries.
RecommendTTL time.Duration
// RecommendTimeout bounds a background rebuild, which fans out further than a
// normal request and so needs more headroom than UpstreamTimeout.
RecommendTimeout time.Duration
2026-08-17 13:13:10 +12:00
// MagicPoolTTL is how long Magic's scored pool stays warm. Shorter than
// RecommendTTL, which is a daily rotation nobody is waiting on: this one is
// rebuilt in front of somebody standing at a player with the film paused
// behind a loading panel, and a household that has just acquired something
// should be able to be handed it the same evening.
MagicPoolTTL time.Duration
2026-08-02 22:10:19 +12:00
// RecommendationWeights is an optional JSON overlay on the weighted defaults.
RecommendationWeights string
2026-08-12 09:57:56 +12:00
// RemoteConfig is the complete, validated presentation document served to TVs.
// It is app-scoped and intentionally contains no account, playback or routing state.
RemoteConfig RemoteConfig
UpstreamTimeout time.Duration
2026-08-28 23:00:02 +12:00
// 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
2026-08-19 18:08:00 +12:00
// 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
// poll would make the thing it exists to reveal harder to find, not easier.
SlowRequestThreshold time.Duration
2026-08-02 22:10:19 +12:00
// EmbyHealthInterval paces the reachability probe behind the "server not responding"
// and "back online" banners. One probe per gateway, not per TV. Zero disables it.
EmbyHealthInterval time.Duration
// AdminToken guards the operator interface. Empty disables /admin entirely, so an
// unconfigured deployment cannot leave it exposed.
AdminToken string
2026-08-14 09:40:03 +12:00
// AdminUIURL is where the console's own container serves its built assets. It is an
// internal compose address: memby-admin is never published, and the gateway proxies
// /admin to it so the console shares this origin, this cookie and this ingress.
// Blank disables the console while leaving the /admin API intact for automation.
AdminUIURL string
2026-07-27 21:06:51 +12:00
// PublicURL is the externally reachable Memby gateway address used in update links.
PublicURL string
// ReleaseDir persists signed APKs published by CI.
ReleaseDir string
// ReleasePublishToken authorizes the CI-only release upload endpoint. It is separate
// from AdminToken so a compromised build runner cannot change maintenance settings.
ReleasePublishToken string
// SyncInterval is how often the library import runs. Zero disables the schedule.
SyncInterval time.Duration
// SyncTimeout bounds one import; a full pass over a large library is slow.
SyncTimeout time.Duration
// SyncOnStart triggers an incremental import at boot.
SyncOnStart bool
// SyncUserID / SyncAPIKey are an optional Emby service account for imports. Without
// them the newest TV session is borrowed instead.
SyncUserID string
SyncAPIKey string
// AnalyticsRetention is how long raw row and journey events are kept before pruning.
// Load enforces a 30-day floor so the per-user history promise cannot be configured away.
AnalyticsRetention time.Duration
2026-07-27 21:06:51 +12:00
// Sonarr is optional. When configured, its local calendar supplies the informational
2026-08-02 22:10:19 +12:00
// Five-day "Shows airing" home row. The API key never leaves this server.
2026-07-27 21:06:51 +12:00
SonarrURL string
SonarrAPIKey string
SonarrTTL time.Duration
SonarrLocation *time.Location
2026-07-29 15:26:27 +12:00
// SonarrAlertWindow is how long after an episode airs the "aired, coming soon"
// banner keeps being offered to clients. Zero turns the banners off without
2026-08-02 22:10:19 +12:00
// touching the five-day schedule row.
2026-07-29 15:26:27 +12:00
SonarrAlertWindow time.Duration
2026-08-18 14:59:29 +12:00
// SonarrWebhookToken guards the Sonarr import/upgrade/rename/delete webhook. Empty
// means the hook 404s, the stance the Radarr one takes.
SonarrWebhookToken string
// IngestSettleDelay is how long after a webhook the gateway first looks for the file
// in Emby. Sonarr fires the moment it has moved the file into place and Emby has not
// scanned it yet, so asking immediately spends a request to learn nothing.
IngestSettleDelay time.Duration
2026-08-02 22:10:19 +12:00
// Radarr is optional. Its calendar supplies the five-day digital movie release row.
RadarrURL string
RadarrAPIKey string
RadarrTTL time.Duration
RadarrLocation *time.Location
// RadarrWebhookToken guards the Radarr "on import" webhook. Empty means the hook
// 404s, so an unconfigured deployment cannot be fed alerts by anyone who finds it.
RadarrWebhookToken string
// RadarrAlertWindow is how long an imported movie keeps being announced to clients.
// It is a window rather than a one-shot push because the gateway has no connection
// to a TV that is switched off: a set turned on inside the window still gets the
// news, and each TV dedupes by alert id. Zero turns the banners off.
RadarrAlertWindow time.Duration
2026-08-06 22:33:56 +12:00
// Bazarr is optional and is what lets a viewer fetch a subtitle a title does not have.
// It writes the file beside the media, so Emby serves the result and the gateway needs
// no storage of its own. Unset means the player simply never offers the option.
BazarrURL string
BazarrAPIKey string
// BazarrTTL is how long the movie/series/episode listings are cached. They exist only
// to turn an Emby item into the *arr id Bazarr keys on, and a household's library does
// not change between one subtitle search and the next.
BazarrTTL time.Duration
// BazarrTimeout bounds a manual search, which queries live subtitle providers and is
// legitimately slow. It is separate from BazarrTTL because a short HTTP timeout here
// shows up as "no subtitles found" rather than as an error.
BazarrTimeout time.Duration
2026-07-29 15:26:27 +12:00
// Tracearr is an optional, read-only source of completion, session-length and
// direct-play signals for the per-user For You area. The public API key stays in
// the gateway and is never returned to a TV.
TracearrURL string
TracearrAPIKey string
TracearrServerID string
2026-08-15 09:23:26 +12:00
// Credits detection discovers where an episode's closing credits begin, for the small
// number of episodes the household is about to watch. It is demand-driven — Tracearr
// says what is worth scanning — so these settings shape how far ahead of a viewer it
// prepares, never how much of the library it reads.
//
// CreditsEnabled is off by default: it is the only thing in the gateway that reads media
// bytes, and switching that on is an operator's decision rather than a default.
CreditsEnabled bool
// CreditsFFmpeg is the decoder. Absent, the subsystem still runs and still writes
// markers, on behavioural evidence alone — which on a well-watched show is the better
// signal anyway.
CreditsFFmpeg string
2026-08-16 12:13:51 +12:00
// CreditsPrefetchEpisodes is the first-run look-ahead for an ordinary viewer; velocity
// moves the actual depth either side of it, and CreditsMaxPrefetch is the ceiling nothing
// exceeds. The admin console persists later operator choices in app_settings.
2026-08-15 09:23:26 +12:00
CreditsPrefetchEpisodes int
CreditsMaxPrefetch int
2026-08-16 12:13:51 +12:00
// CreditsQueueLimit is the first-run bound on pending candidates. Past it, low-priority
// speculation is discarded rather than queued.
2026-08-15 09:23:26 +12:00
CreditsQueueLimit int
2026-07-29 15:26:27 +12:00
// TracearrSyncInterval imports recent changed sessions. FullInterval reconciles
// late/out-of-order updates and deletions without needing a source cursor.
TracearrSyncInterval time.Duration
TracearrFullInterval time.Duration
2026-08-02 22:10:19 +12:00
// Prepared pools rebuild daily at RebuildHour in the configured timezone. The age
// settings are safety bounds for manual/background fallback paths.
2026-07-29 15:26:27 +12:00
ForYouMinRebuildAge time.Duration
ForYouRefreshInterval time.Duration
2026-08-02 22:10:19 +12:00
ForYouRebuildHour int
}
func Load() (Config, error) {
2026-08-12 09:57:56 +12:00
remoteConfig, err := loadRemoteConfig(os.Getenv("MEMBY_REMOTE_CONFIG_JSON"))
if err != nil {
return Config{}, err
}
2026-08-15 09:23:26 +12:00
releasePublishToken, err := secret("MEMBY_RELEASE_PUBLISH_TOKEN")
if err != nil {
return Config{}, err
}
2026-08-28 23:00:02 +12:00
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"), "/"),
EmbyPublicURL: strings.TrimRight(os.Getenv("MEMBY_EMBY_PUBLIC_URL"), "/"),
EmbyMediaURL: strings.TrimRight(os.Getenv("MEMBY_EMBY_MEDIA_URL"), "/"),
DatabaseURL: os.Getenv("MEMBY_DATABASE_URL"),
RedisURL: env("MEMBY_REDIS_URL", "redis://localhost:6379/0"),
2026-08-27 07:31:57 +12:00
ClientName: emby.DefaultClientName,
IgnoredClientName: ignoredClientName(),
2026-08-12 13:08:53 +12:00
GatewayClientName: env("MEMBY_GATEWAY_CLIENT_NAME", emby.DefaultGatewayClientName),
HomeTTL: duration("MEMBY_HOME_TTL", 60*time.Second),
ItemTTL: duration("MEMBY_ITEM_TTL", 10*time.Minute),
SearchTTL: duration("MEMBY_SEARCH_TTL", 5*time.Minute),
ScreensaverTTL: duration("MEMBY_SCREENSAVER_TTL", 10*time.Minute),
SessionTTL: duration("MEMBY_SESSION_CACHE_TTL", 5*time.Minute),
SessionIdleExpiry: duration("MEMBY_SESSION_IDLE_EXPIRY", 90*24*time.Hour),
2026-08-02 22:10:19 +12:00
RecommendTTL: duration("MEMBY_RECOMMEND_TTL", 24*time.Hour),
RecommendTimeout: duration("MEMBY_RECOMMEND_TIMEOUT", 60*time.Second),
2026-08-17 13:13:10 +12:00
MagicPoolTTL: duration("MEMBY_MAGIC_POOL_TTL", 2*time.Hour),
2026-08-02 22:10:19 +12:00
RecommendationWeights: strings.TrimSpace(
os.Getenv("MEMBY_RECOMMENDATION_WEIGHTS"),
),
2026-08-12 09:57:56 +12:00
RemoteConfig: remoteConfig,
2026-08-15 09:23:26 +12:00
AdminToken: strings.TrimSpace(os.Getenv("MEMBY_ADMIN_TOKEN")),
AdminUIURL: env("MEMBY_ADMIN_UI_URL", "http://memby-admin:80"),
PublicURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_PUBLIC_URL")), "/"),
ReleaseDir: env("MEMBY_RELEASE_DIR", "/data/releases"),
ReleasePublishToken: releasePublishToken,
SyncInterval: duration("MEMBY_SYNC_INTERVAL", time.Hour),
SyncTimeout: duration("MEMBY_SYNC_TIMEOUT", 30*time.Minute),
SyncOnStart: boolean("MEMBY_SYNC_ON_START", false),
SyncUserID: strings.TrimSpace(os.Getenv("MEMBY_SYNC_USER_ID")),
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),
2026-08-28 23:00:02 +12:00
TrustedProxies: trusted,
2026-08-19 18:08:00 +12:00
SlowRequestThreshold: duration("MEMBY_SLOW_REQUEST_THRESHOLD", 500*time.Millisecond),
2026-08-15 09:23:26 +12:00
EmbyHealthInterval: duration("MEMBY_EMBY_HEALTH_INTERVAL", 60*time.Second),
SonarrURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_SONARR_URL")), "/"),
SonarrAPIKey: strings.TrimSpace(os.Getenv("MEMBY_SONARR_API_KEY")),
SonarrTTL: duration("MEMBY_SONARR_TTL", 5*time.Minute),
SonarrAlertWindow: duration("MEMBY_SONARR_ALERT_WINDOW", 3*time.Hour),
2026-08-18 14:59:29 +12:00
SonarrWebhookToken: strings.TrimSpace(os.Getenv("MEMBY_SONARR_WEBHOOK_TOKEN")),
IngestSettleDelay: duration("MEMBY_ARR_INGEST_SETTLE", time.Minute),
2026-08-15 09:23:26 +12:00
RadarrURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_RADARR_URL")), "/"),
RadarrAPIKey: strings.TrimSpace(os.Getenv("MEMBY_RADARR_API_KEY")),
RadarrTTL: duration("MEMBY_RADARR_TTL", 5*time.Minute),
RadarrWebhookToken: strings.TrimSpace(os.Getenv("MEMBY_RADARR_WEBHOOK_TOKEN")),
RadarrAlertWindow: duration("MEMBY_RADARR_ALERT_WINDOW", 3*time.Hour),
BazarrURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_BAZARR_URL")), "/"),
BazarrAPIKey: strings.TrimSpace(os.Getenv("MEMBY_BAZARR_API_KEY")),
BazarrTTL: duration("MEMBY_BAZARR_TTL", 5*time.Minute),
BazarrTimeout: duration("MEMBY_BAZARR_TIMEOUT", 45*time.Second),
CreditsEnabled: boolean("MEMBY_CREDITS_ENABLED", false),
CreditsFFmpeg: strings.TrimSpace(os.Getenv("MEMBY_CREDITS_FFMPEG")),
CreditsPrefetchEpisodes: integer("MEMBY_CREDITS_PREFETCH_EPISODES", 3),
CreditsMaxPrefetch: integer("MEMBY_CREDITS_MAX_PREFETCH", 5),
CreditsQueueLimit: integer("MEMBY_CREDITS_QUEUE_LIMIT", 20),
TracearrURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_TRACEARR_URL")), "/"),
TracearrAPIKey: strings.TrimSpace(os.Getenv("MEMBY_TRACEARR_API_KEY")),
TracearrServerID: strings.TrimSpace(os.Getenv("MEMBY_TRACEARR_SERVER_ID")),
TracearrSyncInterval: duration("MEMBY_TRACEARR_SYNC_INTERVAL", 5*time.Minute),
TracearrFullInterval: duration("MEMBY_TRACEARR_FULL_INTERVAL", 24*time.Hour),
ForYouMinRebuildAge: duration("MEMBY_FOR_YOU_MIN_REBUILD_AGE", 24*time.Hour),
ForYouRefreshInterval: duration("MEMBY_FOR_YOU_REFRESH_INTERVAL", 24*time.Hour),
ForYouRebuildHour: integer("MEMBY_FOR_YOU_REBUILD_HOUR", 4),
}
2026-08-22 12:38:26 +12:00
// Integration capabilities are server facts, not secrets. Publish only whether each
// service is configured; API keys and URLs remain gateway-only. An operator can still
// disable the corresponding feature flag in the document without exposing credentials.
c.RemoteConfig.Integrations = RemoteIntegrations{
Tracearr: c.TracearrURL != "" && c.TracearrAPIKey != "",
Sonarr: c.SonarrURL != "" && c.SonarrAPIKey != "",
Radarr: c.RadarrURL != "" && c.RadarrAPIKey != "",
}
if c.AnalyticsRetention < 30*24*time.Hour {
c.AnalyticsRetention = 30 * 24 * time.Hour
}
if c.EmbyURL == "" {
return c, fmt.Errorf("MEMBY_EMBY_URL is required")
}
if c.DatabaseURL == "" {
return c, fmt.Errorf("MEMBY_DATABASE_URL is required")
}
if c.EmbyPublicURL == "" {
c.EmbyPublicURL = c.EmbyURL
}
if c.EmbyMediaURL == "" {
c.EmbyMediaURL = c.EmbyURL
}
2026-07-27 21:06:51 +12:00
if c.ReleasePublishToken != "" && c.PublicURL == "" {
return c, fmt.Errorf("MEMBY_PUBLIC_URL is required when release publishing is enabled")
}
if (c.SonarrURL == "") != (c.SonarrAPIKey == "") {
return c, fmt.Errorf("MEMBY_SONARR_URL and MEMBY_SONARR_API_KEY must be set together")
}
2026-08-02 22:10:19 +12:00
if (c.RadarrURL == "") != (c.RadarrAPIKey == "") {
return c, fmt.Errorf("MEMBY_RADARR_URL and MEMBY_RADARR_API_KEY must be set together")
}
2026-08-06 22:33:56 +12:00
if (c.BazarrURL == "") != (c.BazarrAPIKey == "") {
return c, fmt.Errorf("MEMBY_BAZARR_URL and MEMBY_BAZARR_API_KEY must be set together")
}
2026-07-29 15:26:27 +12:00
if (c.TracearrURL == "") != (c.TracearrAPIKey == "") {
return c, fmt.Errorf("MEMBY_TRACEARR_URL and MEMBY_TRACEARR_API_KEY must be set together")
}
2026-08-02 22:10:19 +12:00
if c.ForYouRebuildHour < 0 || c.ForYouRebuildHour > 23 {
return c, fmt.Errorf("MEMBY_FOR_YOU_REBUILD_HOUR must be between 0 and 23")
}
if c.RecommendationWeights != "" && !json.Valid([]byte(c.RecommendationWeights)) {
return c, fmt.Errorf("MEMBY_RECOMMENDATION_WEIGHTS must be valid JSON")
}
2026-07-27 21:06:51 +12:00
location, err := time.LoadLocation(env("MEMBY_TIMEZONE", "Pacific/Auckland"))
if err != nil {
return c, fmt.Errorf("MEMBY_TIMEZONE: %w", err)
}
c.SonarrLocation = location
2026-08-02 22:10:19 +12:00
c.RadarrLocation = location
return c, nil
}
func env(key, fallback string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
}
return fallback
}
2026-08-15 09:23:26 +12:00
// secret reads a Docker/Kubernetes-style file-backed secret when KEY_FILE is set,
// falling back to KEY for existing non-Compose deployments. The file's contents are
// never included in an error, and Compose uses only the file form so `docker inspect`
// cannot reveal the release-publish credential.
func secret(key string) (string, error) {
path := strings.TrimSpace(os.Getenv(key + "_FILE"))
if path == "" {
return strings.TrimSpace(os.Getenv(key)), nil
}
value, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("%s_FILE: %w", key, err)
}
trimmed := strings.TrimSpace(string(value))
if trimmed == "" {
return "", fmt.Errorf("%s_FILE is empty", key)
}
return trimmed, nil
}
2026-08-27 07:31:57 +12:00
func ignoredClientName() string {
raw := strings.TrimSpace(os.Getenv("MEMBY_CLIENT_NAME"))
if raw == "" || raw == emby.DefaultClientName {
return ""
}
return raw
}
func boolean(key string, fallback bool) bool {
raw := strings.TrimSpace(os.Getenv(key))
if raw == "" {
return fallback
}
value, err := strconv.ParseBool(raw)
if err != nil {
return fallback
}
return value
}
func duration(key string, fallback time.Duration) time.Duration {
raw := strings.TrimSpace(os.Getenv(key))
if raw == "" {
return fallback
}
if d, err := time.ParseDuration(raw); err == nil {
return d
}
// Bare numbers are read as seconds, which is friendlier in a compose file.
if secs, err := strconv.Atoi(raw); err == nil {
return time.Duration(secs) * time.Second
}
return fallback
}
2026-07-27 21:06:51 +12:00
2026-08-28 23:00:02 +12:00
// 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
}
2026-07-27 21:06:51 +12:00
func integer(key string, fallback int) int {
raw := strings.TrimSpace(os.Getenv(key))
if raw == "" {
return fallback
}
value, err := strconv.Atoi(raw)
if err != nil {
return fallback
}
return value
}