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:
parent
b8192c5d7a
commit
df1c32ff54
2 changed files with 69 additions and 18 deletions
|
|
@ -124,6 +124,16 @@ func main() {
|
||||||
Addr: cfg.Addr,
|
Addr: cfg.Addr,
|
||||||
Handler: gzWrapper(mux),
|
Handler: gzWrapper(mux),
|
||||||
TLSConfig: tlsCfg,
|
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
|
// Serve in goroutine
|
||||||
|
|
@ -158,25 +168,34 @@ func main() {
|
||||||
|
|
||||||
// setupAccessAuditLog constructs a slog.Logger writing JSON lines to a
|
// setupAccessAuditLog constructs a slog.Logger writing JSON lines to a
|
||||||
// size-rotated file at the operator-configured path. Returns nil if no
|
// size-rotated file at the operator-configured path. Returns nil if no
|
||||||
// path is configured — AccessLogMiddleware then logs only to stderr
|
// path is configured (operator opted out via --access-log=) —
|
||||||
// (existing behavior).
|
// AccessLogMiddleware then logs only to stderr.
|
||||||
//
|
//
|
||||||
// Rotation is via lumberjack: 100 MB per file, 10 backups, 90-day max
|
// Auto-creates the parent directory (mode 0750) if missing, so the
|
||||||
// age, gzip compression on rotated files. Tuning is fixed (not exposed
|
// default path of <ZDDC_ROOT>/.zddc.d/logs/access-<host>.log "just
|
||||||
// as flags) — these defaults match what an audit-trail use case needs;
|
// works" on a fresh deployment without operator setup.
|
||||||
// operators wanting stricter retention can wire up logrotate against
|
//
|
||||||
// the rotated files themselves.
|
// 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
|
// File-permission posture: lumberjack creates new logs with mode 0600
|
||||||
// (running user only). For multi-user audit access, the operator should
|
// (running user only). For multi-user audit access, the operator should
|
||||||
// use group-readable parent directory permissions and either chmod the
|
// use group-readable parent directory permissions and either chmod the
|
||||||
// log out-of-band or run a forwarder that has its own read access.
|
// 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 {
|
func setupAccessAuditLog(path string) *slog.Logger {
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return nil
|
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{
|
rotator := &lumberjack.Logger{
|
||||||
Filename: path,
|
Filename: path,
|
||||||
MaxSize: 100, // megabytes per file before rotation
|
MaxSize: 100, // megabytes per file before rotation
|
||||||
|
|
@ -184,13 +203,18 @@ func setupAccessAuditLog(path string) *slog.Logger {
|
||||||
MaxAge: 90, // days
|
MaxAge: 90, // days
|
||||||
Compress: true,
|
Compress: true,
|
||||||
}
|
}
|
||||||
|
host, _ := os.Hostname()
|
||||||
|
if host == "" {
|
||||||
|
host = "unknown"
|
||||||
|
}
|
||||||
// JSON handler — line-delimited JSON is the format every standard
|
// JSON handler — line-delimited JSON is the format every standard
|
||||||
// log shipper (Vector, Loki promtail, fluentbit, journalbeat) parses
|
// log shipper (Vector, Loki promtail, fluentbit, journalbeat) parses
|
||||||
// natively, and stays grep-friendly for ad-hoc inspection.
|
// natively, and stays grep-friendly for ad-hoc inspection.
|
||||||
h := slog.NewJSONHandler(rotator, &slog.HandlerOptions{Level: slog.LevelInfo})
|
h := slog.NewJSONHandler(rotator, &slog.HandlerOptions{Level: slog.LevelInfo})
|
||||||
slog.Info("access log file enabled",
|
slog.Info("access log file enabled",
|
||||||
"path", path, "max_size_mb", 100, "max_backups", 10, "max_age_days", 90)
|
"path", path, "host", host,
|
||||||
return slog.New(h)
|
"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.
|
// newGzipWrapper builds the gzip middleware applied to the entire mux.
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -75,7 +76,9 @@ func Load(args []string) (Config, error) {
|
||||||
insecureDirectFlag := fs.Bool("insecure-direct", os.Getenv("ZDDC_INSECURE_DIRECT") == "1",
|
insecureDirectFlag := fs.Bool("insecure-direct", os.Getenv("ZDDC_INSECURE_DIRECT") == "1",
|
||||||
"Allow plain HTTP on non-loopback addresses (only safe behind an authenticating proxy).")
|
"Allow plain HTTP on non-loopback addresses (only safe behind an authenticating proxy).")
|
||||||
accessLogFlag := fs.String("access-log", os.Getenv("ZDDC_ACCESS_LOG"),
|
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.")
|
helpFlag := fs.Bool("help", false, "Print this help and exit.")
|
||||||
versionFlag := fs.Bool("version", false, "Print version info 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
|
return Config{}, ErrVersionRequested
|
||||||
}
|
}
|
||||||
|
|
||||||
// CORS has special semantics: "unset" → default origin list; "set to
|
// CORS + AccessLog both have "unset → default; explicit-empty →
|
||||||
// empty" → CORS disabled. The flag default is "" so we can't tell unset
|
// disabled" semantics. The flag default is "" in both cases so we
|
||||||
// from explicit-empty via the flag alone — fs.Visit catches explicit
|
// can't tell unset from explicit-empty via the flag alone —
|
||||||
// flag use, and os.LookupEnv catches explicit env-var use.
|
// fs.Visit catches explicit flag use, and os.LookupEnv catches
|
||||||
|
// explicit env-var use.
|
||||||
corsFlagSet := false
|
corsFlagSet := false
|
||||||
|
accessLogFlagSet := false
|
||||||
if args != nil {
|
if args != nil {
|
||||||
fs.Visit(func(f *flag.Flag) {
|
fs.Visit(func(f *flag.Flag) {
|
||||||
if f.Name == "cors-origin" {
|
switch f.Name {
|
||||||
|
case "cors-origin":
|
||||||
corsFlagSet = true
|
corsFlagSet = true
|
||||||
|
case "access-log":
|
||||||
|
accessLogFlagSet = true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
_, accessLogEnvSet := os.LookupEnv("ZDDC_ACCESS_LOG")
|
||||||
|
accessLogExplicit := accessLogFlagSet || accessLogEnvSet
|
||||||
|
|
||||||
cfg := Config{
|
cfg := Config{
|
||||||
Root: *rootFlag,
|
Root: *rootFlag,
|
||||||
|
|
@ -136,6 +146,23 @@ func Load(args []string) (Config, error) {
|
||||||
return Config{}, fmt.Errorf("--root %q is not a directory", cfg.Root)
|
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.
|
// Determine TLS mode.
|
||||||
switch {
|
switch {
|
||||||
case cfg.TLSCert == "none":
|
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("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.String("cors-origin", "", "Comma-separated CORS allowlist. Empty = CORS disabled.")
|
||||||
fs.Bool("insecure-direct", false, "Allow plain HTTP on non-loopback addresses.")
|
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("help", false, "Print this help and exit.")
|
||||||
fs.Bool("version", false, "Print version info and exit.")
|
fs.Bool("version", false, "Print version info and exit.")
|
||||||
fs.PrintDefaults()
|
fs.PrintDefaults()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue