// Package config loads and validates every runtime setting from the // environment. // // Configuration is environment-only on purpose. The Helm chart is the single // source of truth for how a deployment is configured; letting the binary also // read a config file would create a second source that operators forget about. // Every value has a safe default except the secrets and the OIDC issuer, which // must be supplied explicitly when auth is enabled. package config import ( "crypto/rand" "encoding/base64" "errors" "fmt" "net/url" "os" "strconv" "strings" "time" ) // Environment prefixes, tried in order. This lets the Helm chart mount a // ConfigMap under a distinct prefix without colliding with the pod's own // environment, which is what makes one image usable across environments. var envPrefixes = []string{"BANANAS_", "GO_BANANAS_"} // Config is the fully resolved runtime configuration. type Config struct { // Environment is the deployment name, used in logs and the admin UI. Environment string // LogLevel is one of debug, info, warn, error. LogLevel string // LogFormat is json or text. LogFormat string // HTTPAddr is the listen address, host:port. HTTPAddr string // PublicBaseURL is the externally reachable origin, without a trailing // slash. It is used to build absolute URLs in the runtime manifest and to // validate OIDC redirect targets. PublicBaseURL string // TrustedProxies lists the CIDRs or IPs whose X-Forwarded-For headers may be // believed. It is empty by default, which makes the service ignore // forwarding headers entirely and log the direct peer. TrustedProxies []string // ReadTimeout and WriteTimeout bound a single request. WriteTimeout is // generous because the runtime CSS endpoint is on the hot path of every page // load in every consuming application. ReadTimeout time.Duration WriteTimeout time.Duration IdleTimeout time.Duration // ShutdownTimeout is how long in-flight requests get to finish on SIGTERM. ShutdownTimeout time.Duration // CORSAllowedOrigins are the browser origins allowed to call the admin API // from a different origin. The runtime API is always public and is not // subject to this list. CORSAllowedOrigins []string // RateLimitPerMinute caps requests per client for the runtime API. RateLimitPerMinute int // DatabasePath is the SQLite file path. Use ":memory:" for tests. DatabasePath string // DatabaseBusyTimeout is how long a writer waits for the database lock // before failing. DatabaseBusyTimeout time.Duration // DatabaseMaxConns bounds the connection pool. SQLite serialises writers, so // a small pool with WAL is faster than a large one. DatabaseMaxConns int // AuthMode is oidc, dev, or none. AuthMode string // OIDCIssuer is the issuer URL, e.g. https://auth.lcl-urpi.be/realms/urpi. OIDCIssuer string // OIDCClientID is the client identifier registered with the provider. OIDCClientID string // OIDCClientSecret authenticates the confidential client. OIDCClientSecret string // OIDCScopes are requested in addition to openid. OIDCScopes []string // OIDCRedirectURL overrides the computed callback URL. It is only needed // when the public URL cannot be derived, for example behind a path-based // ingress. OIDCRedirectURL string // OIDCPostLogoutRedirectURL is where the provider sends the browser after // logout. It must be an allowed post-logout target on the provider side. OIDCPostLogoutRedirectURL string // OIDCAdminGroups are the group names that map to the admin role. OIDCAdminGroups []string // OIDCEditorGroups are the group names that map to the editor role. OIDCEditorGroups []string // OIDCViewerGroups are the group names that map to the viewer role. OIDCViewerGroups []string // OIDCSkipTLSVerify disables provider certificate verification. It exists // only so that a self-signed local provider can be used during development // and it is refused outside the development environment. OIDCSkipTLSVerify bool // SessionSecret signs the session cookie. It must be at least 32 bytes and // is required whenever auth is enabled. SessionSecret string // SessionCookieName is the cookie carrying the session. SessionCookieName string // SessionSecure marks the session cookie Secure. SessionSecure bool // SessionTTL is the session lifetime. SessionTTL time.Duration // SessionSameSite is Lax, Strict or None. SessionSameSite string // RuntimePublic controls whether the runtime API requires a credential. It // is enabled by default because a theming service's whole purpose is to be // reachable from browsers and CDN caches. RuntimePublic bool // RuntimeCacheControl is the Cache-Control header sent on runtime assets. // It is tuned for aggressive CDN caching with a revalidation window that // matches how often themes are published. RuntimeCacheControl string // RuntimeCDNBaseURL, when set, is used as the absolute base in the manifest // so consumers fetch assets from a CDN rather than from this service. RuntimeCDNBaseURL string // SeedOnStart reconciles the built-in themes at start-up. SeedOnStart bool // DevFallbackRole is the role granted by the development auth bypass. DevFallbackRole string // DevUsers maps a development email to a role, overriding DevFallbackRole. DevUsers map[string]string // EnableMetrics exposes /metrics in Prometheus format. EnableMetrics bool // EnableProfiler exposes the net/http/pprof handlers under /debug/pprof. EnableProfiler bool // StaticDir is the directory holding the built admin UI. When empty the UI // is not served. StaticDir string // SDKDir is the directory holding the built runtime SDK bundles. SDKDir string // BasePath prefixes every route, for deployments behind a path-based ingress. BasePath string // ephemeralSessionSecret records that the secret was generated rather than // supplied. It is not part of the configuration surface; the application reads // it only to decide whether to warn. ephemeralSessionSecret bool } // Auth mode values. const ( AuthModeOIDC = "oidc" AuthModeDev = "dev" AuthModeNone = "none" ) // Defaults returns a Config populated with production-safe defaults. Load // applies the environment on top of this value. func Defaults() Config { return Config{ Environment: "development", LogLevel: "info", LogFormat: "text", HTTPAddr: ":8080", PublicBaseURL: "http://localhost:8080", ReadTimeout: 15 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 120 * time.Second, ShutdownTimeout: 25 * time.Second, CORSAllowedOrigins: []string{}, RateLimitPerMinute: 3000, DatabasePath: "./data/go-bananas.db", DatabaseBusyTimeout: 5 * time.Second, DatabaseMaxConns: 8, AuthMode: AuthModeDev, OIDCScopes: []string{"profile", "email", "groups"}, SessionCookieName: "go-bananas-session", SessionSecure: false, SessionTTL: 8 * time.Hour, SessionSameSite: "Lax", RuntimePublic: true, RuntimeCacheControl: "public, max-age=60, s-maxage=300, stale-while-revalidate=86400", SeedOnStart: true, DevFallbackRole: "admin", DevUsers: map[string]string{}, EnableMetrics: true, EnableProfiler: false, StaticDir: "./web/dist", SDKDir: "./sdk/dist", } } // Load reads configuration from the environment and validates it. func Load() (Config, error) { return loadFrom(os.LookupEnv) } // loadFrom is the testable core of Load. func loadFrom(lookup func(string) (string, bool)) (Config, error) { cfg := Defaults() get := func(name string) (string, bool) { for _, prefix := range envPrefixes { if v, ok := lookup(prefix + name); ok { return v, true } } return "", false } str := func(name string, target *string) { if v, ok := get(name); ok { *target = strings.TrimSpace(v) } } cfg.Environment = firstNonEmpty(cfg.Environment, func() (string, bool) { return get("ENVIRONMENT") }, func() (string, bool) { return get("ENV") }, ) str("LOG_LEVEL", &cfg.LogLevel) str("LOG_FORMAT", &cfg.LogFormat) str("HTTP_ADDR", &cfg.HTTPAddr) str("PUBLIC_BASE_URL", &cfg.PublicBaseURL) str("BASE_PATH", &cfg.BasePath) str("STATIC_DIR", &cfg.StaticDir) str("SDK_DIR", &cfg.SDKDir) dur := func(name string, target *time.Duration) error { v, ok := get(name) if !ok { return nil } parsed, err := time.ParseDuration(strings.TrimSpace(v)) if err != nil { return fmt.Errorf("%s: %w", name, err) } *target = parsed return nil } num := func(name string, target *int) error { v, ok := get(name) if !ok { return nil } parsed, err := strconv.Atoi(strings.TrimSpace(v)) if err != nil { return fmt.Errorf("%s: %w", name, err) } *target = parsed return nil } boolean := func(name string, target *bool) error { v, ok := get(name) if !ok { return nil } parsed, err := strconv.ParseBool(strings.TrimSpace(v)) if err != nil { return fmt.Errorf("%s: %w", name, err) } *target = parsed return nil } list := func(name string, target *[]string) { if v, ok := get(name); ok { *target = splitList(v) } } var err error for _, f := range []func() error{ func() error { return dur("HTTP_READ_TIMEOUT", &cfg.ReadTimeout) }, func() error { return dur("HTTP_WRITE_TIMEOUT", &cfg.WriteTimeout) }, func() error { return dur("HTTP_IDLE_TIMEOUT", &cfg.IdleTimeout) }, func() error { return dur("SHUTDOWN_TIMEOUT", &cfg.ShutdownTimeout) }, func() error { return dur("SESSION_TTL", &cfg.SessionTTL) }, func() error { return dur("DB_BUSY_TIMEOUT", &cfg.DatabaseBusyTimeout) }, func() error { return num("RATE_LIMIT_PER_MINUTE", &cfg.RateLimitPerMinute) }, func() error { return num("DB_MAX_CONNS", &cfg.DatabaseMaxConns) }, func() error { return boolean("RUNTIME_PUBLIC", &cfg.RuntimePublic) }, func() error { return boolean("SEED_ON_START", &cfg.SeedOnStart) }, func() error { return boolean("ENABLE_METRICS", &cfg.EnableMetrics) }, func() error { return boolean("ENABLE_PROFILER", &cfg.EnableProfiler) }, func() error { return boolean("SESSION_SECURE", &cfg.SessionSecure) }, func() error { return boolean("OIDC_SKIP_TLS_VERIFY", &cfg.OIDCSkipTLSVerify) }, } { if err = f(); err != nil { return cfg, err } } list("TRUSTED_PROXIES", &cfg.TrustedProxies) list("CORS_ALLOWED_ORIGINS", &cfg.CORSAllowedOrigins) list("OIDC_SCOPES", &cfg.OIDCScopes) list("OIDC_ADMIN_GROUPS", &cfg.OIDCAdminGroups) list("OIDC_EDITOR_GROUPS", &cfg.OIDCEditorGroups) list("OIDC_VIEWER_GROUPS", &cfg.OIDCViewerGroups) str("AUTH_MODE", &cfg.AuthMode) str("OIDC_ISSUER", &cfg.OIDCIssuer) str("OIDC_CLIENT_ID", &cfg.OIDCClientID) str("OIDC_CLIENT_SECRET", &cfg.OIDCClientSecret) str("OIDC_REDIRECT_URL", &cfg.OIDCRedirectURL) str("OIDC_POST_LOGOUT_REDIRECT_URL", &cfg.OIDCPostLogoutRedirectURL) str("SESSION_SECRET", &cfg.SessionSecret) str("SESSION_COOKIE_NAME", &cfg.SessionCookieName) str("SESSION_SAMESITE", &cfg.SessionSameSite) str("DEV_FALLBACK_ROLE", &cfg.DevFallbackRole) str("RUNTIME_CACHE_CONTROL", &cfg.RuntimeCacheControl) str("RUNTIME_CDN_BASE_URL", &cfg.RuntimeCDNBaseURL) str("DATABASE_PATH", &cfg.DatabasePath) if v, ok := get("DEV_USERS"); ok { cfg.DevUsers = parseDevUsers(v) } // A development run should not require the operator to invent a 32-byte // secret before the first request. An ephemeral one is generated instead, // which is safe because nothing durable is signed with it: sessions simply do // not survive a restart, which a local run never notices. if cfg.AuthMode != AuthModeNone && cfg.SessionSecret == "" && cfg.IsDevelopment() { generated, genErr := randomSecret() if genErr != nil { return cfg, genErr } cfg.SessionSecret = generated cfg.ephemeralSessionSecret = true } return cfg, cfg.Validate() } // EphemeralSessionSecret reports whether the session secret was generated rather // than supplied. The caller logs it as a warning, because it means sessions // survive only until the process restarts. func (c Config) EphemeralSessionSecret() bool { return c.ephemeralSessionSecret } // randomSecret returns a 48-byte cryptographically random value, base64 encoded. func randomSecret() (string, error) { buf := make([]byte, 48) if _, err := rand.Read(buf); err != nil { return "", fmt.Errorf("generate session secret: %w", err) } return base64.RawURLEncoding.EncodeToString(buf), nil } // Validate checks the configuration for internal consistency. It returns every // problem it finds rather than only the first, because fixing configuration one // error per deploy is a miserable loop. func (c Config) Validate() error { var problems []string add := func(format string, args ...any) { problems = append(problems, fmt.Sprintf(format, args...)) } switch c.AuthMode { case AuthModeOIDC, AuthModeDev, AuthModeNone: default: add("AUTH_MODE %q must be one of oidc, dev or none", c.AuthMode) } if c.AuthMode == AuthModeOIDC { if c.OIDCIssuer == "" { add("OIDC_ISSUER is required when AUTH_MODE=oidc") } else if parsed, err := url.Parse(c.OIDCIssuer); err != nil { add("OIDC_ISSUER is not a valid URL: %v", err) } else if parsed.Scheme != "https" && !isLocalHost(parsed.Hostname()) { add("OIDC_ISSUER must use https outside local development, got %q", parsed.Scheme) } if c.OIDCClientID == "" { add("OIDC_CLIENT_ID is required when AUTH_MODE=oidc") } if c.OIDCClientSecret == "" { add("OIDC_CLIENT_SECRET is required when AUTH_MODE=oidc") } if c.OIDCSkipTLSVerify && !c.IsDevelopment() { add("OIDC_SKIP_TLS_VERIFY is refused outside development; it disables certificate verification on the identity provider connection") } } if c.AuthMode != AuthModeNone { if len(c.SessionSecret) < 32 { add("SESSION_SECRET must be at least 32 bytes when auth is enabled, got %d", len(c.SessionSecret)) } } if c.PublicBaseURL != "" { parsed, err := url.Parse(c.PublicBaseURL) switch { case err != nil: add("PUBLIC_BASE_URL is not a valid URL: %v", err) case parsed.Scheme == "": add("PUBLIC_BASE_URL must include a scheme, got %q", c.PublicBaseURL) case parsed.Host == "": add("PUBLIC_BASE_URL must include a host, got %q", c.PublicBaseURL) } } if c.BasePath != "" && !strings.HasPrefix(c.BasePath, "/") { add("BASE_PATH must start with a slash, got %q", c.BasePath) } switch strings.ToLower(c.SessionSameSite) { case "lax", "strict", "none": default: add("SESSION_SAMESITE %q must be lax, strict or none", c.SessionSameSite) } if strings.EqualFold(c.SessionSameSite, "none") && !c.SessionSecure { // Browsers reject SameSite=None without Secure, so this combination // silently produces a session that never survives a redirect. add("SESSION_SAMESITE=none requires SESSION_SECURE=true") } if !c.SessionSecure && !c.IsDevelopment() && c.PublicBaseURL != "" { if strings.HasPrefix(c.PublicBaseURL, "https://") { add("SESSION_SECURE must be true when PUBLIC_BASE_URL is https") } } if c.DatabasePath == "" { add("DATABASE_PATH must not be empty") } if c.DatabaseMaxConns < 1 { add("DB_MAX_CONNS must be at least 1, got %d", c.DatabaseMaxConns) } if c.RateLimitPerMinute < 0 { add("RATE_LIMIT_PER_MINUTE must not be negative") } if !c.IsDevelopment() && c.AuthMode == AuthModeDev { add("AUTH_MODE=dev is refused outside development; it grants admin to anyone who can reach the service") } if c.AuthMode == AuthModeNone && !c.IsDevelopment() { add("AUTH_MODE=none is refused outside development") } if len(problems) > 0 { return errors.New("invalid configuration:\n - " + strings.Join(problems, "\n - ")) } return nil } // IsDevelopment reports whether the service is running in development. func (c Config) IsDevelopment() bool { switch strings.ToLower(c.Environment) { case "development", "dev", "local", "test", "testing", "ci": return true default: return false } } // RedirectURL returns the OIDC callback URL, derived from the public base when // not overridden. func (c Config) RedirectURL() string { if c.OIDCRedirectURL != "" { return c.OIDCRedirectURL } return c.origin() + c.path("/auth/callback") } // PostLogoutRedirectURL returns the OIDC end-session target. func (c Config) PostLogoutRedirectURL() string { if c.OIDCPostLogoutRedirectURL != "" { return c.OIDCPostLogoutRedirectURL } return c.origin() + c.path("/") } // Origin returns the public origin with no trailing slash. func (c Config) Origin() string { return c.origin() } // origin normalises the public base URL. func (c Config) origin() string { return strings.TrimRight(c.PublicBaseURL, "/") } // path prefixes a route with the configured base path. func (c Config) path(route string) string { base := strings.TrimRight(c.BasePath, "/") if base == "" { return route } return base + route } // Path exposes base-path prefixing to the HTTP layer. func (c Config) Path(route string) string { return c.path(route) } // CDNBasis returns the absolute base that runtime asset URLs are built from, // honouring the CDN override. func (c Config) CDNBasis() string { if c.RuntimeCDNBaseURL != "" { return strings.TrimRight(c.RuntimeCDNBaseURL, "/") } return c.origin() + c.path("/rt/v1") } // splitList parses a comma or whitespace separated environment list. func splitList(raw string) []string { fields := strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == '\n' || r == '\t' || r == ' ' }) out := make([]string, 0, len(fields)) for _, f := range fields { if trimmed := strings.TrimSpace(f); trimmed != "" { out = append(out, trimmed) } } return out } // parseDevUsers parses the development role map in "email=role,email=role" form. func parseDevUsers(raw string) map[string]string { out := make(map[string]string) for _, pair := range splitList(raw) { email, role, ok := strings.Cut(pair, "=") if !ok { continue } email = strings.ToLower(strings.TrimSpace(email)) role = strings.ToLower(strings.TrimSpace(role)) if email == "" || role == "" { continue } out[email] = role } return out } // firstNonEmpty returns the first non-empty value from a default and a list of // candidate environment lookups. func firstNonEmpty(def string, lookups ...func() (string, bool)) string { for _, lookup := range lookups { if v, ok := lookup(); ok && strings.TrimSpace(v) != "" { return strings.TrimSpace(v) } } return def } // isLocalHost reports whether host refers to the local machine. func isLocalHost(host string) bool { switch strings.ToLower(host) { case "localhost", "127.0.0.1", "::1", "keycloak", "idp", "auth": return true default: return strings.HasSuffix(host, ".local") || strings.HasSuffix(host, ".localhost") } } // Redacted returns a copy of the configuration with secrets masked, suitable // for logging or for the diagnostics endpoint. func (c Config) Redacted() Config { mask := func(s string) string { if s == "" { return "" } return "***redacted***" } c.OIDCClientSecret = mask(c.OIDCClientSecret) c.SessionSecret = mask(c.SessionSecret) return c }