ZDDC/zddc/internal/config/config.go
ZDDC 4ede42010a feat(zddc-server): CLI flags, --version, CWD-default ZDDC_ROOT
Adds command-line flags to zddc-server alongside the existing env vars.
Each setting can be set via --<flag-name> or ZDDC_<NAME>; the flag wins
on conflict, the env var wins over the hard-coded default.

  --root          / ZDDC_ROOT          (now defaults to CWD if both unset)
  --addr          / ZDDC_ADDR          (:8443)
  --tls-cert      / ZDDC_TLS_CERT      ("none" / empty / path)
  --tls-key       / ZDDC_TLS_KEY
  --log-level     / ZDDC_LOG_LEVEL     (info)
  --index-path    / ZDDC_INDEX_PATH    (.archive)
  --email-header  / ZDDC_EMAIL_HEADER  (X-Auth-Request-Email)
  --cors-origin   / ZDDC_CORS_ORIGIN   (https://zddc.varasys.io; "" disables)
  --insecure-direct / ZDDC_INSECURE_DIRECT (false)
  --help          (prints flag list to stderr, exits 0)
  --version       (prints binary + embedded tool versions, exits 0)

So an operator can `cd /srv/zddc && zddc-server` with zero config — the
served root defaults to the current directory, and TLS defaults to a
self-signed cert. config.Load now takes []string (test-friendly: nil
skips flag parsing entirely; tests pass an empty slice for env-only
loads).

Adds a `version` package-level var in main.go injected at link time via
`-ldflags="-X main.version=..."`. The build.sh runs git describe against
zddc-server-v* tags; for in-flight commits between releases it produces
e.g. zddc-server-v0.0.7-19-gadb6904-dirty.

Adds an embedded versions manifest:
  - Each tool's compute_build_label (in shared/build-lib.sh) writes a
    sidecar <tool>.label to $BUILD_LABELS_DIR if that env var is set.
  - Top-level build.sh sets BUILD_LABELS_DIR before running each tool's
    build, then assembles zddc/internal/apps/embedded/versions.txt as
    one `<app>=<build label>` line per app.
  - apps.EmbeddedVersions() loads the manifest at runtime.
  - main.go logs a compact summary on every startup; --version dumps
    the full per-app label.

Removes the old cfg.BuildVersion field — the X-ZDDC-Source: embedded
header now uses the package-level main.version directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:43:31 -05:00

246 lines
9.3 KiB
Go

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 (--<name>) or environment variable (ZDDC_<NAME>);
// 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
}
// 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).")
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),
}
// 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.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
}