Two related operational improvements: 1. HTTP timeouts on http.Server (ReadHeaderTimeout 10s, ReadTimeout + WriteTimeout 60s, IdleTimeout 120s). Caps slow-client connection hold time; closes the slowloris vector. Listing + tool-HTML responses complete in milliseconds even with gzip, so 60s is generous for legit traffic. 2. --access-log defaults to <ZDDC_ROOT>/.zddc.d/logs/access-<host>.log instead of stderr-only. The server auto-creates the parent tree (mode 0750), so a fresh deployment gets an audit trail without operator setup. Every JSON record carries a `host` field (from os.Hostname) — multi-replica deployments share the .zddc.d/logs/ directory but write to per-host filenames, and downstream aggregators can disambiguate via the host field. Opt-out: --access-log= (explicit empty). Distinguishing "unset" from "set to empty" follows the same pattern config.go already uses for --cors-origin. Live verification: $ zddc-server -root /tmp/r -addr 127.0.0.1:8765 -tls-cert none -insecure-direct $ curl http://127.0.0.1:8765/ $ ls /tmp/r/.zddc.d/logs/ access-bizon.log $ tail -1 /tmp/r/.zddc.d/logs/access-bizon.log {"time":...,"level":"INFO","msg":"access","host":"bizon",...,"email":"anonymous","method":"GET","path":"/","status":200,...} $ zddc-server -root /tmp/r ... -access-log= # opt-out $ ls /tmp/r/.zddc.d/ # empty: no logs/ created
278 lines
11 KiB
Go
278 lines
11 KiB
Go
package config
|
|
|
|
import (
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"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
|
|
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). "+
|
|
"Default: <ZDDC_ROOT>/.zddc.d/logs/access-<hostname>.log. "+
|
|
"Set explicitly to empty (--access-log=) to disable.")
|
|
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 + AccessLog both have "unset → default; explicit-empty →
|
|
// disabled" semantics. The flag default is "" in both cases 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
|
|
accessLogFlagSet := false
|
|
if args != nil {
|
|
fs.Visit(func(f *flag.Flag) {
|
|
switch f.Name {
|
|
case "cors-origin":
|
|
corsFlagSet = true
|
|
case "access-log":
|
|
accessLogFlagSet = true
|
|
}
|
|
})
|
|
}
|
|
_, accessLogEnvSet := os.LookupEnv("ZDDC_ACCESS_LOG")
|
|
accessLogExplicit := accessLogFlagSet || accessLogEnvSet
|
|
|
|
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)
|
|
}
|
|
|
|
// Audit-log default: if neither flag nor env was explicitly set,
|
|
// default to <Root>/.zddc.d/logs/access-<hostname>.log so the
|
|
// server captures an audit trail by default. Setting the flag/env
|
|
// to empty (--access-log=) is the explicit opt-out. Hostname is
|
|
// in the filename because operators typically run multiple zddc-
|
|
// server replicas against the same dataset (the .zddc.d directory
|
|
// is shared FS), and per-host filenames keep the JSON streams
|
|
// separable for downstream auditors.
|
|
if !accessLogExplicit {
|
|
host, herr := os.Hostname()
|
|
if herr != nil || host == "" {
|
|
host = "unknown"
|
|
}
|
|
cfg.AccessLog = filepath.Join(cfg.Root, ".zddc.d", "logs",
|
|
"access-"+host+".log")
|
|
}
|
|
|
|
// 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). Default <ZDDC_ROOT>/.zddc.d/logs/access-<hostname>.log; --access-log= disables.")
|
|
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
|
|
}
|