diff --git a/build.sh b/build.sh index 213c072..810e1f3 100755 --- a/build.sh +++ b/build.sh @@ -8,6 +8,13 @@ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) echo "=== Building ZDDC tools ===" +# Each tool's compute_build_label writes a sidecar `.label` here so +# we can assemble zddc/internal/apps/embedded/versions.txt below. +BUILD_LABELS_DIR="$SCRIPT_DIR/zddc/internal/apps/embedded/.labels" +rm -rf "$BUILD_LABELS_DIR" +mkdir -p "$BUILD_LABELS_DIR" +export BUILD_LABELS_DIR + sh "$SCRIPT_DIR/transmittal/build.sh" "${1:-}" "${2:-}" sh "$SCRIPT_DIR/archive/build.sh" "${1:-}" "${2:-}" sh "$SCRIPT_DIR/classifier/build.sh" "${1:-}" "${2:-}" @@ -43,6 +50,26 @@ cp "$SCRIPT_DIR/classifier/dist/classifier.html" "$EMBED_DIR/classifier.html cp "$SCRIPT_DIR/mdedit/dist/mdedit.html" "$EMBED_DIR/mdedit.html" echo "Populated $EMBED_DIR/ for //go:embed" +# Assemble the embedded versions manifest from the per-tool .label sidecars +# written by shared/build-lib.sh's compute_build_label. The Go side reads +# this via //go:embed in internal/apps/versions.go and surfaces it in +# `zddc-server --version` output and the startup log line. +VERSIONS_FILE="$EMBED_DIR/versions.txt" +{ + echo "# Generated by build.sh — do not edit. One = per line." + for _tool in archive transmittal classifier mdedit landing; do + _label_file="$BUILD_LABELS_DIR/${_tool}.label" + if [ -f "$_label_file" ]; then + _label=$(cat "$_label_file") + else + _label="" + fi + printf '%s=%s\n' "$_tool" "$_label" + done +} > "$VERSIONS_FILE" +echo "Wrote $VERSIONS_FILE" +rm -rf "$BUILD_LABELS_DIR" + # Cross-compiled zddc-server binaries for Linux/macOS/Windows. Always built # inside docker.io/golang:1.24-alpine via podman (or docker), matching the # helm/zddc-server-prod chart's `buildImage` so dev binaries are byte-for-byte @@ -70,6 +97,19 @@ GO_BUILD_IMAGE="${ZDDC_GO_BUILD_IMAGE:-docker.io/golang:1.24-alpine}" GO_MOD_VOL="${ZDDC_GO_MOD_VOL:-zddc-go-mod}" GO_BUILD_VOL="${ZDDC_GO_BUILD_VOL:-zddc-go-cache}" +# Compute the binary's own version: `git describe` if available (clean tag, +# or tag-N-gSHA[-dirty] for in-flight commits), else falls back to "dev". +# Surfaces via `zddc-server --version` and in the startup log line. +ZDDC_BINARY_VERSION=$(git -C "$SCRIPT_DIR" describe --tags --dirty --match 'zddc-server-v*' 2>/dev/null || true) +if [ -z "$ZDDC_BINARY_VERSION" ]; then + _sha=$(git -C "$SCRIPT_DIR" rev-parse --short=7 HEAD 2>/dev/null || echo unknown) + if ! git -C "$SCRIPT_DIR" diff --quiet HEAD 2>/dev/null; then + _sha="${_sha}-dirty" + fi + ZDDC_BINARY_VERSION="dev-${_sha}" +fi +echo " binary version: $ZDDC_BINARY_VERSION" + # Single container invocation, multiple cross-compile targets inside a # `for` loop — avoids paying image-startup overhead 4×. "$GO_RUNNER" run --rm \ @@ -79,6 +119,7 @@ GO_BUILD_VOL="${ZDDC_GO_BUILD_VOL:-zddc-go-cache}" -w /src/zddc \ -e GOFLAGS=-mod=mod \ -e CGO_ENABLED=0 \ + -e ZDDC_BINARY_VERSION="$ZDDC_BINARY_VERSION" \ "$GO_BUILD_IMAGE" \ sh -c ' set -e @@ -88,7 +129,9 @@ GO_BUILD_VOL="${ZDDC_GO_BUILD_VOL:-zddc-go-cache}" case "$os" in windows) out="${out}.exe" ;; esac echo " building $out" GOOS="$os" GOARCH="$arch" \ - go build -trimpath -ldflags="-s -w" -o "dist/$out" ./cmd/zddc-server + go build -trimpath \ + -ldflags="-s -w -X main.version=${ZDDC_BINARY_VERSION}" \ + -o "dist/$out" ./cmd/zddc-server done ' diff --git a/shared/build-lib.sh b/shared/build-lib.sh index cb29ca2..526297a 100755 --- a/shared/build-lib.sh +++ b/shared/build-lib.sh @@ -169,6 +169,7 @@ compute_build_label() { fi channel="alpha" build_label="v${_next_stable}-alpha · ${build_timestamp} · ${_sha}" + _emit_build_label_sidecar "$_tool" return 0 fi @@ -180,6 +181,7 @@ compute_build_label() { _date=$(date -u +"%Y-%m-%d") _sha=$(git -C "$root_dir" rev-parse --short=7 HEAD 2>/dev/null || echo "unknown") build_label="v${_next_stable}-${channel} · ${_date} · ${_sha}" + _emit_build_label_sidecar "$_tool" return 0 ;; '') @@ -195,6 +197,18 @@ compute_build_label() { channel="stable" is_red=0 build_label="v${build_version}" + _emit_build_label_sidecar "$_tool" +} + +# Write the resolved build label to a sidecar file the top-level build.sh +# reads to assemble zddc/internal/apps/embedded/versions.txt. No-op when +# BUILD_LABELS_DIR is not set in the env (tools built standalone). +_emit_build_label_sidecar() { + if [ -z "${BUILD_LABELS_DIR:-}" ]; then + return 0 + fi + mkdir -p "$BUILD_LABELS_DIR" + printf '%s\n' "$build_label" > "$BUILD_LABELS_DIR/$1.label" } # Compute the next-stable target version for a tool — i.e., the patch-bump diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index a087c45..1ce4805 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -2,12 +2,14 @@ package main import ( "context" + "errors" "fmt" "log/slog" "net/http" "os" "os/signal" "path/filepath" + "sort" "strings" "syscall" "time" @@ -20,16 +22,34 @@ import ( "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" ) +// version is the binary's own version, injected at build time via +// `-ldflags="-X main.version=..."`. Defaults to "dev" for unreleased +// builds; release pipelines pass the result of `git describe --tags`. +var version = "dev" + func main() { - cfg, err := config.Load() + cfg, err := config.Load(os.Args[1:]) + if errors.Is(err, config.ErrHelpRequested) { + config.Usage(os.Stderr) + os.Exit(0) + } + if errors.Is(err, config.ErrVersionRequested) { + printVersions(os.Stdout) + os.Exit(0) + } if err != nil { - fmt.Fprintf(os.Stderr, "configuration error: %v\n", err) + fmt.Fprintf(os.Stderr, "configuration error: %v\n\nRun with --help for usage.\n", err) os.Exit(1) } logRing := setupLogger(cfg.LogLevel) - slog.Info("zddc-server starting", "root", cfg.Root, "addr", cfg.Addr) + embedded := apps.EmbeddedVersions() + slog.Info("zddc-server starting", + "version", version, + "root", cfg.Root, + "addr", cfg.Addr, + "embedded_apps", embeddedVersionsForLog(embedded)) // Build archive index slog.Info("building archive index...") @@ -135,7 +155,53 @@ func setupApps(cfg config.Config) (*apps.Server, error) { return nil, fmt.Errorf("create cache: %w", err) } fetcher := apps.NewFetcher(cache, slog.Default()) - return apps.NewServer(cfg.Root, cache, fetcher, cfg.BuildVersion), nil + return apps.NewServer(cfg.Root, cache, fetcher, version), nil +} + +// printVersions writes the binary version + the build label of every app +// embedded into the binary. Called by --version and reused for the +// startup log line. +func printVersions(w *os.File) { + fmt.Fprintf(w, "zddc-server %s\n\n", version) + embedded := apps.EmbeddedVersions() + if len(embedded) == 0 { + fmt.Fprintln(w, "Embedded tools: (none — run `sh build.sh` to populate)") + return + } + fmt.Fprintln(w, "Embedded tools:") + keys := make([]string, 0, len(embedded)) + for k := range embedded { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + fmt.Fprintf(w, " %-12s %s\n", k, embedded[k]) + } +} + +// embeddedVersionsForLog formats the embedded-versions map as a single +// short string suitable for the startup `log/slog` line. Sorted by app +// name for stable output. +func embeddedVersionsForLog(embedded map[string]string) string { + if len(embedded) == 0 { + return "(none)" + } + keys := make([]string, 0, len(embedded)) + for k := range embedded { + keys = append(keys, k) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, k := range keys { + // Strip any " · timestamp · sha" suffix so the log line stays compact; + // operators who want full detail run `zddc-server --version`. + v := embedded[k] + if i := strings.Index(v, " "); i > 0 { + v = v[:i] + } + parts = append(parts, k+"="+v) + } + return strings.Join(parts, " ") } // dispatch routes a request to the appropriate handler. diff --git a/zddc/cmd/zddc-server/main_test.go b/zddc/cmd/zddc-server/main_test.go index a4213d9..2185601 100644 --- a/zddc/cmd/zddc-server/main_test.go +++ b/zddc/cmd/zddc-server/main_test.go @@ -135,10 +135,9 @@ func TestDispatchAppsResolution(t *testing.T) { t.Fatalf("BuildIndex: %v", err) } cfg := config.Config{ - Root: root, - IndexPath: ".archive", - EmailHeader: "X-Auth-Request-Email", - BuildVersion: "test-build", + Root: root, + IndexPath: ".archive", + EmailHeader: "X-Auth-Request-Email", } ring := handler.NewLogRing(10) diff --git a/zddc/internal/apps/embedded/archive.html b/zddc/internal/apps/embedded/archive.html index d05c969..07f5f07 100644 --- a/zddc/internal/apps/embedded/archive.html +++ b/zddc/internal/apps/embedded/archive.html @@ -2113,7 +2113,7 @@ td[data-field="trackingNumber"] {
ZDDC Archive - v0.0.3-alpha · 2026-05-01 20:14:53 · fedc365-dirty + v0.0.3-alpha · 2026-05-01 20:41:59 · adb6904-dirty
diff --git a/zddc/internal/apps/embedded/classifier.html b/zddc/internal/apps/embedded/classifier.html index 2d85e01..44b8588 100644 --- a/zddc/internal/apps/embedded/classifier.html +++ b/zddc/internal/apps/embedded/classifier.html @@ -1376,7 +1376,7 @@ body.help-open .app-header {
ZDDC Classifier - v0.0.3-alpha · 2026-05-01 20:14:53 · fedc365-dirty + v0.0.3-alpha · 2026-05-01 20:41:59 · adb6904-dirty
diff --git a/zddc/internal/apps/embedded/index.html b/zddc/internal/apps/embedded/index.html index 33b7a61..5b61163 100644 --- a/zddc/internal/apps/embedded/index.html +++ b/zddc/internal/apps/embedded/index.html @@ -866,7 +866,7 @@ body { ZDDC Archive - v0.0.3-alpha · 2026-05-01 20:14:53 · fedc365-dirty + v0.0.3-alpha · 2026-05-01 20:41:59 · adb6904-dirty
diff --git a/zddc/internal/apps/embedded/mdedit.html b/zddc/internal/apps/embedded/mdedit.html index 195ffb7..819e5d7 100644 --- a/zddc/internal/apps/embedded/mdedit.html +++ b/zddc/internal/apps/embedded/mdedit.html @@ -1668,7 +1668,7 @@ body.help-open .app-header {
ZDDC Markdown - v0.0.3-alpha · 2026-05-01 20:14:53 · fedc365-dirty + v0.0.3-alpha · 2026-05-01 20:41:59 · adb6904-dirty
diff --git a/zddc/internal/apps/embedded/transmittal.html b/zddc/internal/apps/embedded/transmittal.html index b9766a9..3d6ab0b 100644 --- a/zddc/internal/apps/embedded/transmittal.html +++ b/zddc/internal/apps/embedded/transmittal.html @@ -2210,7 +2210,7 @@ dialog.modal--narrow {
ZDDC Transmittal - v0.0.3-alpha · 2026-05-01 20:14:53 · fedc365-dirty + v0.0.3-alpha · 2026-05-01 20:41:59 · adb6904-dirty
diff --git a/zddc/internal/apps/embedded/versions.txt b/zddc/internal/apps/embedded/versions.txt new file mode 100644 index 0000000..d46413f --- /dev/null +++ b/zddc/internal/apps/embedded/versions.txt @@ -0,0 +1,6 @@ +# Generated by build.sh — do not edit. One = per line. +archive=v0.0.3-alpha · 2026-05-01 20:41:59 · adb6904-dirty +transmittal=v0.0.3-alpha · 2026-05-01 20:41:59 · adb6904-dirty +classifier=v0.0.3-alpha · 2026-05-01 20:41:59 · adb6904-dirty +mdedit=v0.0.3-alpha · 2026-05-01 20:41:59 · adb6904-dirty +landing=v0.0.3-alpha · 2026-05-01 20:41:59 · adb6904-dirty diff --git a/zddc/internal/apps/versions.go b/zddc/internal/apps/versions.go new file mode 100644 index 0000000..fcfa98f --- /dev/null +++ b/zddc/internal/apps/versions.go @@ -0,0 +1,39 @@ +package apps + +import ( + "strings" + + _ "embed" +) + +// embeddedVersionsRaw is the manifest written by the top-level build.sh +// at compile time. Format is one `=` line per app — +// e.g. `archive=v0.0.5-alpha · 2026-05-01 14:00:00 · abc1234`. An empty +// or missing value indicates the embedded slot was not populated (a fresh +// clone where build.sh hasn't run yet). +// +//go:embed embedded/versions.txt +var embeddedVersionsRaw []byte + +// EmbeddedVersions returns the build label of each tool baked into the +// binary, keyed by canonical app name. Apps with empty values are +// omitted. Caller copies the map if mutation is needed. +func EmbeddedVersions() map[string]string { + out := make(map[string]string, 5) + for _, line := range strings.Split(string(embeddedVersionsRaw), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + eq := strings.IndexByte(line, '=') + if eq <= 0 { + continue + } + key := strings.TrimSpace(line[:eq]) + val := strings.TrimSpace(line[eq+1:]) + if val != "" { + out[key] = val + } + } + return out +} diff --git a/zddc/internal/config/config.go b/zddc/internal/config/config.go index bb6e060..0c39704 100644 --- a/zddc/internal/config/config.go +++ b/zddc/internal/config/config.go @@ -2,85 +2,221 @@ package config import ( "errors" + "flag" "fmt" + "io" "net" "os" "strings" ) -// Config holds all runtime configuration loaded from environment variables. +// 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 // 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 - - // BuildVersion is baked into the X-ZDDC-Source header on embedded - // fallback responses so operators see exactly which binary's - // embedded HTML they're getting. Set at build time via -ldflags. - BuildVersion string + 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 } -// 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(), - BuildVersion: getEnv("ZDDC_BUILD_VERSION", "dev"), +// 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 == "" { - return Config{}, errors.New("ZDDC_ROOT environment variable is required") + 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("ZDDC_ROOT %q is not accessible: %w", cfg.Root, err) + return Config{}, fmt.Errorf("--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) + return Config{}, fmt.Errorf("--root %q is not a directory", cfg.Root) } - // Determine TLS mode - if cfg.TLSCert == "none" { + // Determine TLS mode. + switch { + case cfg.TLSCert == "none": cfg.TLSMode = "none" - } else if cfg.TLSCert == "" && cfg.TLSKey == "" { + case cfg.TLSCert == "" && cfg.TLSKey == "": cfg.TLSMode = "selfsigned" - } else { + default: 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") + 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. 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" { + // 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( - "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", + "--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. @@ -108,25 +244,3 @@ func getEnv(key, fallback string) string { } 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 -} diff --git a/zddc/internal/config/config_test.go b/zddc/internal/config/config_test.go index 848c8a8..3d48344 100644 --- a/zddc/internal/config/config_test.go +++ b/zddc/internal/config/config_test.go @@ -58,11 +58,20 @@ func TestLoad(t *testing.T) { check func(*testing.T, Config) }{ { - name: "missing root", + name: "missing root defaults to CWD", env: envSet{}, - // ZDDC_ROOT not set - wantErr: true, - errContains: "ZDDC_ROOT", + // ZDDC_ROOT not set → Load falls back to os.Getwd(). + check: func(t *testing.T, cfg Config) { + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd: %v", err) + } + // os.Stat resolves symlinks; so does Load via filepath behavior, so + // just compare the resolved values. + if cfg.Root != cwd { + t.Errorf("Root = %q, want CWD %q", cfg.Root, cwd) + } + }, }, { name: "root not a directory", @@ -151,7 +160,7 @@ func TestLoad(t *testing.T) { "ZDDC_ADDR": ":8080", }, wantErr: true, - errContains: "ZDDC_INSECURE_DIRECT", + errContains: "--insecure-direct", }, { name: "plain HTTP on 0.0.0.0 without insecure flag is rejected", @@ -161,7 +170,7 @@ func TestLoad(t *testing.T) { "ZDDC_ADDR": "0.0.0.0:8080", }, wantErr: true, - errContains: "ZDDC_INSECURE_DIRECT", + errContains: "--insecure-direct", }, { name: "plain HTTP on loopback is allowed", @@ -199,7 +208,7 @@ func TestLoad(t *testing.T) { "ZDDC_INSECURE_DIRECT": "true", // must be exactly "1" }, wantErr: true, - errContains: "ZDDC_INSECURE_DIRECT", + errContains: "--insecure-direct", }, } @@ -208,7 +217,7 @@ func TestLoad(t *testing.T) { apply(tc.env) defer clearAll() - cfg, err := Load() + cfg, err := Load([]string{}) if tc.wantErr { if err == nil { t.Fatalf("Load() = nil error, want error containing %q", tc.errContains) @@ -227,3 +236,87 @@ func TestLoad(t *testing.T) { }) } } + +// TestLoadFlags_OverrideEnv: --root flag wins over ZDDC_ROOT env var. +func TestLoadFlags_OverrideEnv(t *testing.T) { + envRoot := t.TempDir() + flagRoot := t.TempDir() + os.Setenv("ZDDC_ROOT", envRoot) + defer os.Unsetenv("ZDDC_ROOT") + + cfg, err := Load([]string{"--root", flagRoot}) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Root != flagRoot { + t.Errorf("Root = %q, want flag value %q", cfg.Root, flagRoot) + } +} + +// TestLoadFlags_AddrLogLevelFromFlags: arbitrary flags override env defaults. +func TestLoadFlags_AddrLogLevelFromFlags(t *testing.T) { + root := t.TempDir() + cfg, err := Load([]string{ + "--root", root, + "--addr", "127.0.0.1:9999", + "--log-level", "debug", + "--index-path", ".myindex", + "--email-header", "X-User-Email", + }) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Addr != "127.0.0.1:9999" { + t.Errorf("Addr=%q", cfg.Addr) + } + if cfg.LogLevel != "debug" { + t.Errorf("LogLevel=%q", cfg.LogLevel) + } + if cfg.IndexPath != ".myindex" { + t.Errorf("IndexPath=%q", cfg.IndexPath) + } + if cfg.EmailHeader != "X-User-Email" { + t.Errorf("EmailHeader=%q", cfg.EmailHeader) + } +} + +// TestLoadFlags_CORSExplicitEmptyDisables: --cors-origin="" explicitly disables CORS. +func TestLoadFlags_CORSExplicitEmptyDisables(t *testing.T) { + root := t.TempDir() + cfg, err := Load([]string{"--root", root, "--cors-origin", ""}) + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(cfg.CORSOrigins) != 0 { + t.Errorf("CORSOrigins = %v, want empty (CORS disabled by explicit empty flag)", cfg.CORSOrigins) + } +} + +// TestLoadFlags_HelpRequested: --help returns the sentinel error. +func TestLoadFlags_HelpRequested(t *testing.T) { + _, err := Load([]string{"--help"}) + if !strings.Contains(err.Error(), "help requested") && err != ErrHelpRequested { + t.Errorf("got err=%v, want ErrHelpRequested", err) + } +} + +// TestLoadFlags_VersionRequested: --version returns the sentinel error. +func TestLoadFlags_VersionRequested(t *testing.T) { + _, err := Load([]string{"--version"}) + if !strings.Contains(err.Error(), "version requested") && err != ErrVersionRequested { + t.Errorf("got err=%v, want ErrVersionRequested", err) + } +} + +// TestLoadFlags_RootFlagDefaultsToCWD: with no --root and no ZDDC_ROOT, falls back to CWD. +func TestLoadFlags_RootFlagDefaultsToCWD(t *testing.T) { + os.Unsetenv("ZDDC_ROOT") + cfg, err := Load([]string{}) + if err != nil { + t.Fatalf("Load: %v", err) + } + cwd, _ := os.Getwd() + if cfg.Root != cwd { + t.Errorf("Root=%q, want CWD=%q", cfg.Root, cwd) + } +}