package config import ( "errors" "fmt" "net" "os" "strings" ) // Config holds all runtime configuration loaded from environment variables. type Config struct { Root string // ZDDC_ROOT — absolute path to the served file tree Addr string // ZDDC_ADDR — bind address (default :8443) TLSCert string // ZDDC_TLS_CERT — path to PEM cert; empty = self-signed TLSKey string // ZDDC_TLS_KEY — path to PEM key; empty = self-signed TLSMode string // computed from TLSCert/TLSKey: none/selfsigned/provided LogLevel string // ZDDC_LOG_LEVEL — debug/info/warn/error (default info) IndexPath string // ZDDC_INDEX_PATH — virtual segment name (default .archive) EmailHeader string // ZDDC_EMAIL_HEADER — header name for user email (default X-Auth-Request-Email) CORSOrigins []string // ZDDC_CORS_ORIGIN — comma-separated CORS allowlist; default https://zddc.varasys.io; empty disables } // Load reads configuration from environment variables and validates required fields. func Load() (Config, error) { cfg := Config{ Addr: getEnv("ZDDC_ADDR", ":8443"), Root: os.Getenv("ZDDC_ROOT"), TLSCert: os.Getenv("ZDDC_TLS_CERT"), TLSKey: os.Getenv("ZDDC_TLS_KEY"), LogLevel: getEnv("ZDDC_LOG_LEVEL", "info"), IndexPath: getEnv("ZDDC_INDEX_PATH", ".archive"), EmailHeader: getEnv("ZDDC_EMAIL_HEADER", "X-Auth-Request-Email"), CORSOrigins: parseCORSOrigins(), } if cfg.Root == "" { return Config{}, errors.New("ZDDC_ROOT environment variable is required") } info, err := os.Stat(cfg.Root) if err != nil { return Config{}, fmt.Errorf("ZDDC_ROOT %q is not accessible: %w", cfg.Root, err) } if !info.IsDir() { return Config{}, fmt.Errorf("ZDDC_ROOT %q is not a directory", cfg.Root) } // Determine TLS mode if cfg.TLSCert == "none" { cfg.TLSMode = "none" } else if cfg.TLSCert == "" && cfg.TLSKey == "" { cfg.TLSMode = "selfsigned" } else { cfg.TLSMode = "provided" } // Cert and key must both be set or both be empty only when TLSMode == "provided" if cfg.TLSMode == "provided" && (cfg.TLSCert == "") != (cfg.TLSKey == "") { return Config{}, errors.New("ZDDC_TLS_CERT and ZDDC_TLS_KEY must both be set or both be empty") } // Plain HTTP mode trusts the email header from any client. That is only // safe behind an authenticating reverse proxy, so refuse to start when // binding plain HTTP to a non-loopback interface unless the operator has // explicitly acknowledged the deployment shape via ZDDC_INSECURE_DIRECT=1. if cfg.TLSMode == "none" && !isLoopbackAddr(cfg.Addr) && os.Getenv("ZDDC_INSECURE_DIRECT") != "1" { return Config{}, fmt.Errorf( "ZDDC_TLS_CERT=none binds plain HTTP to %q which trusts %s headers from any client; "+ "either use TLS (unset ZDDC_TLS_CERT or supply a cert), bind to loopback (127.0.0.1: or [::1]:), "+ "or set ZDDC_INSECURE_DIRECT=1 to confirm an authenticating reverse proxy is in front", cfg.Addr, cfg.EmailHeader) } return cfg, nil } // isLoopbackAddr reports whether addr binds only to a loopback interface. // addr is in net.Listen form: "host:port", ":port", or "[ipv6]:port". // ":port" means all interfaces, so it is NOT loopback. func isLoopbackAddr(addr string) bool { host, _, err := net.SplitHostPort(addr) if err != nil { return false } if host == "" { return false } if host == "localhost" { return true } ip := net.ParseIP(host) if ip == nil { return false } return ip.IsLoopback() } func getEnv(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } return fallback } // parseCORSOrigins reads ZDDC_CORS_ORIGIN as a comma-separated allowlist. // Unset → default to https://zddc.varasys.io. Empty string → CORS disabled. // Origins are not validated as URLs here; the middleware does an exact-match // comparison against the request's Origin header. func parseCORSOrigins() []string { v, ok := os.LookupEnv("ZDDC_CORS_ORIGIN") if !ok { return []string{"https://zddc.varasys.io"} } if v == "" { return nil } parts := strings.Split(v, ",") out := make([]string, 0, len(parts)) for _, p := range parts { if s := strings.TrimSpace(p); s != "" { out = append(out, s) } } return out }