feat(server): HTTP timeouts + audit log default-on with hostname tagging

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
This commit is contained in:
ZDDC 2026-05-04 07:44:35 -05:00
parent b8192c5d7a
commit df1c32ff54
2 changed files with 69 additions and 18 deletions

View file

@ -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_ROOT>/.zddc.d/logs/access-<host>.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.

View file

@ -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_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.")
@ -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 <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":
@ -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_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()