package config import ( "errors" "flag" "fmt" "io" "net" "os" "strings" ) // Config holds all runtime configuration. Each field can be set via a // command-line flag (--) or environment variable (ZDDC_); // the flag takes precedence when both are present. type Config struct { Root string // --root / ZDDC_ROOT — served file tree (default: CWD) Addr string // --addr / ZDDC_ADDR — bind address (default :8443) TLSCert string // --tls-cert / ZDDC_TLS_CERT — PEM cert path; "none" = plain HTTP; empty = self-signed TLSKey string // --tls-key / ZDDC_TLS_KEY — PEM key path TLSMode string // computed: none/selfsigned/provided LogLevel string // --log-level / ZDDC_LOG_LEVEL — debug/info/warn/error (default info) IndexPath string // --index-path / ZDDC_INDEX_PATH — virtual archive prefix (default .archive) EmailHeader string // --email-header / ZDDC_EMAIL_HEADER — auth header name (default X-Auth-Request-Email) CORSOrigins []string // --cors-origin / ZDDC_CORS_ORIGIN — comma-separated allowlist; default https://zddc.varasys.io; empty disables AccessLog string // --access-log / ZDDC_ACCESS_LOG — file path for tee'd JSON access log; empty = stderr only } // ErrHelpRequested is returned by Load when --help is passed; the caller // should print Usage() to stderr and exit 0. var ErrHelpRequested = errors.New("help requested") // ErrVersionRequested is returned by Load when --version is passed; the // caller should print version info and exit 0. var ErrVersionRequested = errors.New("version requested") // Load reads configuration from CLI flags + environment variables. // // Precedence (highest → lowest): command-line flag, environment variable, // hard-coded default. Special-cases: // - --root / ZDDC_ROOT default to the current working directory if both // are unset, so an operator can `cd /srv/zddc && zddc-server` with // zero config. // - --version and --help return distinguished sentinel errors; the caller // handles printing and exit. Pass nil for args to skip flag parsing // entirely (used by tests that set state via env vars only). // // Standard usage from main.go: // // cfg, err := config.Load(os.Args[1:]) // if errors.Is(err, config.ErrHelpRequested) { os.Exit(0) } // if errors.Is(err, config.ErrVersionRequested) { /* print versions */ ; os.Exit(0) } // if err != nil { ... } func Load(args []string) (Config, error) { fs := flag.NewFlagSet("zddc-server", flag.ContinueOnError) // Discard flag's own error output; we wrap and return our own. fs.SetOutput(io.Discard) rootFlag := fs.String("root", os.Getenv("ZDDC_ROOT"), "Path to the served file tree. Default: ZDDC_ROOT or the current directory.") addrFlag := fs.String("addr", getEnv("ZDDC_ADDR", ":8443"), "Listen address (host:port). Default: ZDDC_ADDR or :8443.") tlsCertFlag := fs.String("tls-cert", os.Getenv("ZDDC_TLS_CERT"), "Path to a PEM TLS certificate. \"none\" disables TLS (plain HTTP). Empty means self-signed.") tlsKeyFlag := fs.String("tls-key", os.Getenv("ZDDC_TLS_KEY"), "Path to the matching PEM TLS private key.") logLevelFlag := fs.String("log-level", getEnv("ZDDC_LOG_LEVEL", "info"), "Log level: debug, info, warn, error.") indexPathFlag := fs.String("index-path", getEnv("ZDDC_INDEX_PATH", ".archive"), "URL segment that triggers the virtual archive index (default \".archive\").") emailHeaderFlag := fs.String("email-header", getEnv("ZDDC_EMAIL_HEADER", "X-Auth-Request-Email"), "HTTP header carrying the authenticated user's email.") corsOriginFlag := fs.String("cors-origin", "", "Comma-separated CORS allowlist. Empty = CORS disabled. Default: ZDDC_CORS_ORIGIN or https://zddc.varasys.io.") insecureDirectFlag := fs.Bool("insecure-direct", os.Getenv("ZDDC_INSECURE_DIRECT") == "1", "Allow plain HTTP on non-loopback addresses (only safe behind an authenticating proxy).") accessLogFlag := fs.String("access-log", os.Getenv("ZDDC_ACCESS_LOG"), "Tee structured access logs to this file (JSON, size-rotated). Empty = stderr only.") helpFlag := fs.Bool("help", false, "Print this help and exit.") versionFlag := fs.Bool("version", false, "Print version info and exit.") if args != nil { if err := fs.Parse(args); err != nil { if errors.Is(err, flag.ErrHelp) { return Config{}, ErrHelpRequested } return Config{}, err } } if *helpFlag { return Config{}, ErrHelpRequested } if *versionFlag { return Config{}, ErrVersionRequested } // CORS has special semantics: "unset" → default origin list; "set to // empty" → CORS disabled. The flag default is "" so we can't tell unset // from explicit-empty via the flag alone — fs.Visit catches explicit // flag use, and os.LookupEnv catches explicit env-var use. corsFlagSet := false if args != nil { fs.Visit(func(f *flag.Flag) { if f.Name == "cors-origin" { corsFlagSet = true } }) } cfg := Config{ Root: *rootFlag, Addr: *addrFlag, TLSCert: *tlsCertFlag, TLSKey: *tlsKeyFlag, LogLevel: *logLevelFlag, IndexPath: *indexPathFlag, EmailHeader: *emailHeaderFlag, CORSOrigins: resolveCORS(corsFlagSet, *corsOriginFlag), AccessLog: *accessLogFlag, } // Default Root to the current working directory. if cfg.Root == "" { cwd, err := os.Getwd() if err != nil { return Config{}, fmt.Errorf("--root not set and could not determine current directory: %w", err) } cfg.Root = cwd } info, err := os.Stat(cfg.Root) if err != nil { return Config{}, fmt.Errorf("--root %q is not accessible: %w", cfg.Root, err) } if !info.IsDir() { return Config{}, fmt.Errorf("--root %q is not a directory", cfg.Root) } // Determine TLS mode. switch { case cfg.TLSCert == "none": cfg.TLSMode = "none" case cfg.TLSCert == "" && cfg.TLSKey == "": cfg.TLSMode = "selfsigned" default: cfg.TLSMode = "provided" } if cfg.TLSMode == "provided" && (cfg.TLSCert == "") != (cfg.TLSKey == "") { return Config{}, errors.New("--tls-cert and --tls-key must both be set or both be empty") } // Plain HTTP mode trusts the email header from any client. Only safe // behind an authenticating reverse proxy. Refuse to start when binding // plain HTTP to a non-loopback interface unless the operator has // explicitly acknowledged the deployment shape. if cfg.TLSMode == "none" && !isLoopbackAddr(cfg.Addr) && !*insecureDirectFlag { return Config{}, fmt.Errorf( "--tls-cert=none binds plain HTTP to %q which trusts %s headers from any client; "+ "either use TLS (omit --tls-cert or supply a cert), bind to loopback (127.0.0.1: or [::1]:), "+ "or pass --insecure-direct to confirm an authenticating reverse proxy is in front", cfg.Addr, cfg.EmailHeader) } return cfg, nil } // Usage prints the flag list to w (stderr is the conventional caller). // Returned format mirrors `flag.PrintDefaults` plus a one-line summary. func Usage(w io.Writer) { fmt.Fprintln(w, "Usage: zddc-server [flags]") fmt.Fprintln(w, "") fmt.Fprintln(w, "Each flag has an equivalent ZDDC_* environment variable; the flag wins on conflict.") fmt.Fprintln(w, "ZDDC_ROOT defaults to the current working directory.") fmt.Fprintln(w, "") fmt.Fprintln(w, "Flags:") fs := flag.NewFlagSet("zddc-server", flag.ContinueOnError) fs.SetOutput(w) // Re-register flags to populate Usage output (we discard the values). fs.String("root", "", "Path to the served file tree. Default: ZDDC_ROOT or the current directory.") fs.String("addr", ":8443", "Listen address (host:port). Default: ZDDC_ADDR or :8443.") fs.String("tls-cert", "", "Path to a PEM TLS certificate. \"none\" disables TLS. Empty = self-signed.") fs.String("tls-key", "", "Path to the matching PEM TLS private key.") fs.String("log-level", "info", "Log level: debug, info, warn, error.") fs.String("index-path", ".archive", "URL segment for the virtual archive index.") fs.String("email-header", "X-Auth-Request-Email", "HTTP header carrying the authenticated user's email.") fs.String("cors-origin", "", "Comma-separated CORS allowlist. Empty = CORS disabled.") fs.Bool("insecure-direct", false, "Allow plain HTTP on non-loopback addresses.") fs.String("access-log", "", "Tee structured access logs to this file (JSON, size-rotated). Empty = stderr only.") fs.Bool("help", false, "Print this help and exit.") fs.Bool("version", false, "Print version info and exit.") fs.PrintDefaults() } // resolveCORS implements the precedence rules for the CORS allowlist: // - flag explicitly set → use flag value (empty = disabled) // - else env var explicitly set → use env value (empty = disabled) // - else → default to the canonical upstream func resolveCORS(flagSet bool, flagValue string) []string { if flagSet { return parseCSV(flagValue) } if v, ok := os.LookupEnv("ZDDC_CORS_ORIGIN"); ok { return parseCSV(v) } return []string{"https://zddc.varasys.io"} } // parseCSV splits a comma-separated list and trims whitespace. Empty // returns nil (which the middleware treats as "CORS disabled"). func parseCSV(s string) []string { if s == "" { return nil } parts := strings.Split(s, ",") out := make([]string, 0, len(parts)) for _, p := range parts { if t := strings.TrimSpace(p); t != "" { out = append(out, t) } } return out } // 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 }