diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index bfe0bcf..f3df1fc 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -124,6 +124,16 @@ func main() { Addr: cfg.Addr, Handler: gzWrapper(mux), TLSConfig: tlsCfg, + // Conservative timeouts. ReadHeaderTimeout caps how long a slow + // client can hold the connection before sending request headers + // (the slowloris vector). Read/Write timeouts cap full-request + // processing — directory listings + tool HTML serving complete + // in milliseconds even with gzip, so 60s is generous. IdleTimeout + // is the keep-alive ceiling between requests on the same conn. + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 60 * time.Second, + WriteTimeout: 60 * time.Second, + IdleTimeout: 120 * time.Second, } // Serve in goroutine @@ -158,25 +168,34 @@ func main() { // setupAccessAuditLog constructs a slog.Logger writing JSON lines to a // size-rotated file at the operator-configured path. Returns nil if no -// path is configured — AccessLogMiddleware then logs only to stderr -// (existing behavior). +// path is configured (operator opted out via --access-log=) — +// AccessLogMiddleware then logs only to stderr. // -// Rotation is via lumberjack: 100 MB per file, 10 backups, 90-day max -// age, gzip compression on rotated files. Tuning is fixed (not exposed -// as flags) — these defaults match what an audit-trail use case needs; -// operators wanting stricter retention can wire up logrotate against -// the rotated files themselves. +// Auto-creates the parent directory (mode 0750) if missing, so the +// default path of /.zddc.d/logs/access-.log "just +// works" on a fresh deployment without operator setup. +// +// Every record is tagged with `host` (os.Hostname). When multiple +// zddc-server replicas serve the same dataset (and write to the same +// .zddc.d/logs/ directory via per-host filenames), the host field also +// makes downstream-aggregated streams disambiguable. +// +// Rotation: lumberjack — 100 MB per file, 10 backups, 90-day max age, +// gzip compression on rotated files. // // File-permission posture: lumberjack creates new logs with mode 0600 // (running user only). For multi-user audit access, the operator should // use group-readable parent directory permissions and either chmod the // log out-of-band or run a forwarder that has its own read access. -// Parent directory must already exist — this function does NOT mkdir, -// since we'd need to assume too much about umask/owner. func setupAccessAuditLog(path string) *slog.Logger { if path == "" { return nil } + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + slog.Error("could not create access-log directory; falling back to stderr-only", + "dir", filepath.Dir(path), "err", err) + return nil + } rotator := &lumberjack.Logger{ Filename: path, MaxSize: 100, // megabytes per file before rotation @@ -184,13 +203,18 @@ func setupAccessAuditLog(path string) *slog.Logger { MaxAge: 90, // days Compress: true, } + host, _ := os.Hostname() + if host == "" { + host = "unknown" + } // JSON handler — line-delimited JSON is the format every standard // log shipper (Vector, Loki promtail, fluentbit, journalbeat) parses // natively, and stays grep-friendly for ad-hoc inspection. h := slog.NewJSONHandler(rotator, &slog.HandlerOptions{Level: slog.LevelInfo}) slog.Info("access log file enabled", - "path", path, "max_size_mb", 100, "max_backups", 10, "max_age_days", 90) - return slog.New(h) + "path", path, "host", host, + "max_size_mb", 100, "max_backups", 10, "max_age_days", 90) + return slog.New(h).With("host", host) } // newGzipWrapper builds the gzip middleware applied to the entire mux. diff --git a/zddc/internal/config/config.go b/zddc/internal/config/config.go index a870422..21eaef1 100644 --- a/zddc/internal/config/config.go +++ b/zddc/internal/config/config.go @@ -7,6 +7,7 @@ import ( "io" "net" "os" + "path/filepath" "strings" ) @@ -75,7 +76,9 @@ func Load(args []string) (Config, error) { 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.") + "Tee structured access logs to this file (JSON, size-rotated). "+ + "Default: /.zddc.d/logs/access-.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.") @@ -94,18 +97,25 @@ func Load(args []string) (Config, error) { 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. + // 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) { - if f.Name == "cors-origin" { + 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, @@ -136,6 +146,23 @@ func Load(args []string) (Config, error) { 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 /.zddc.d/logs/access-.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": @@ -186,7 +213,7 @@ func Usage(w io.Writer) { 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.String("access-log", "", "Tee structured access logs to this file (JSON, size-rotated). Default /.zddc.d/logs/access-.log; --access-log= disables.") fs.Bool("help", false, "Print this help and exit.") fs.Bool("version", false, "Print version info and exit.") fs.PrintDefaults()