// Package config loads server configuration from env vars (the // canonical source) with optional YAML overlay. Documented vars are // listed in spec.md §4.1. package config import ( "fmt" "net" "net/netip" "os" "strings" "gopkg.in/yaml.v3" ) // Config holds runtime parameters resolved from env + (optionally) a // YAML file. Env wins over YAML so operators can tweak a single var // without rewriting the file. // // The server is HTTP-only by design: the expected deployment fronts it // with a TLS-terminating reverse proxy (Caddy/Traefik/nginx). See // spec.md §11 for the rationale. type Config struct { Listen string `yaml:"listen"` DataDir string `yaml:"data_dir"` BaseURL string `yaml:"base_url"` SecretKeyFile string `yaml:"secret_key_file"` TrustedProxies []string `yaml:"trusted_proxies"` // CookieSecure controls the Secure attribute on session cookies. // Defaults to true. Set RM_COOKIE_SECURE=false only for local HTTP // testing — production deployments are always behind a TLS proxy // and the cookie must be Secure. CookieSecure bool `yaml:"cookie_secure"` } // Load resolves config in this order: // 1. defaults // 2. YAML at the given path (if non-empty and exists) // 3. environment variables (RM_LISTEN, RM_DATA_DIR, …) // // The result is validated; a zero-error return means the server is // safe to start. func Load(yamlPath string) (Config, error) { c := Config{ Listen: ":8080", DataDir: "/data", CookieSecure: true, } if yamlPath != "" { body, err := os.ReadFile(yamlPath) if err != nil && !os.IsNotExist(err) { return c, fmt.Errorf("config: read %q: %w", yamlPath, err) } if err == nil { if err := yaml.Unmarshal(body, &c); err != nil { return c, fmt.Errorf("config: parse %q: %w", yamlPath, err) } } } if v, ok := os.LookupEnv("RM_LISTEN"); ok { c.Listen = v } if v, ok := os.LookupEnv("RM_DATA_DIR"); ok { c.DataDir = v } if v, ok := os.LookupEnv("RM_BASE_URL"); ok { c.BaseURL = v } if v, ok := os.LookupEnv("RM_SECRET_KEY_FILE"); ok { c.SecretKeyFile = v } if v, ok := os.LookupEnv("RM_COOKIE_SECURE"); ok { // Anything other than "false"/"0" leaves the safe default. if v == "false" || v == "0" { c.CookieSecure = false } else { c.CookieSecure = true } } if v, ok := os.LookupEnv("RM_TRUSTED_PROXY"); ok { // Comma-separated CIDRs; allow whitespace for readability. parts := strings.Split(v, ",") c.TrustedProxies = c.TrustedProxies[:0] for _, p := range parts { p = strings.TrimSpace(p) if p != "" { c.TrustedProxies = append(c.TrustedProxies, p) } } } return c, c.validate() } func (c *Config) validate() error { if c.Listen == "" { return fmt.Errorf("config: RM_LISTEN must be set") } if _, _, err := net.SplitHostPort(c.Listen); err != nil { return fmt.Errorf("config: RM_LISTEN %q invalid: %w", c.Listen, err) } if c.DataDir == "" { return fmt.Errorf("config: RM_DATA_DIR must be set") } if c.SecretKeyFile == "" { // Default to data dir. c.SecretKeyFile = c.DataDir + "/secret.key" } for _, cidr := range c.TrustedProxies { if _, err := netip.ParsePrefix(cidr); err != nil { return fmt.Errorf("config: RM_TRUSTED_PROXY entry %q is not a valid CIDR: %w", cidr, err) } } return nil }