diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index 76b544c..bfe0bcf 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -22,6 +22,7 @@ import ( "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" "github.com/klauspost/compress/gzhttp" + "gopkg.in/natefinch/lumberjack.v2" ) // version is the binary's own version, injected at build time via @@ -108,7 +109,8 @@ func main() { // the context the outer ACL middleware set. // CORSMiddleware — Origin / preflight handling. // dispatch — the actual request handler. - mux.Handle("/", handler.ACLMiddleware(cfg, handler.AccessLogMiddleware(handler.CORSMiddleware(cfg, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auditLogger := setupAccessAuditLog(cfg.AccessLog) + mux.Handle("/", handler.ACLMiddleware(cfg, handler.AccessLogMiddleware(auditLogger, handler.CORSMiddleware(cfg, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { dispatch(cfg, idx, logRing, appsServer, w, r) }))))) @@ -154,6 +156,43 @@ func main() { slog.Info("stopped") } +// 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). +// +// 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. +// +// 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 + } + rotator := &lumberjack.Logger{ + Filename: path, + MaxSize: 100, // megabytes per file before rotation + MaxBackups: 10, + MaxAge: 90, // days + Compress: true, + } + // 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) +} + // newGzipWrapper builds the gzip middleware applied to the entire mux. // MinSize(1024) skips compressing tiny responses where the framing // overhead exceeds the savings (304 Not Modified, error pages, small diff --git a/zddc/go.mod b/zddc/go.mod index 1f1a7fb..8210fbe 100644 --- a/zddc/go.mod +++ b/zddc/go.mod @@ -5,6 +5,7 @@ go 1.24 require ( github.com/fsnotify/fsnotify v1.9.0 github.com/klauspost/compress v1.18.6 + gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/zddc/go.sum b/zddc/go.sum index 131c3a4..be0d307 100644 --- a/zddc/go.sum +++ b/zddc/go.sum @@ -6,5 +6,7 @@ golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/zddc/internal/config/config.go b/zddc/internal/config/config.go index 0c39704..a870422 100644 --- a/zddc/internal/config/config.go +++ b/zddc/internal/config/config.go @@ -23,6 +23,7 @@ type Config struct { 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 @@ -73,6 +74,8 @@ func Load(args []string) (Config, error) { "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). Empty = stderr only.") helpFlag := fs.Bool("help", false, "Print this help and exit.") versionFlag := fs.Bool("version", false, "Print version info and exit.") @@ -113,6 +116,7 @@ func Load(args []string) (Config, error) { IndexPath: *indexPathFlag, EmailHeader: *emailHeaderFlag, CORSOrigins: resolveCORS(corsFlagSet, *corsOriginFlag), + AccessLog: *accessLogFlag, } // Default Root to the current working directory. @@ -182,6 +186,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.Bool("help", false, "Print this help and exit.") fs.Bool("version", false, "Print version info and exit.") fs.PrintDefaults() diff --git a/zddc/internal/handler/middleware.go b/zddc/internal/handler/middleware.go index d354301..4fccdb3 100644 --- a/zddc/internal/handler/middleware.go +++ b/zddc/internal/handler/middleware.go @@ -67,8 +67,19 @@ func (rw *responseWriter) Write(b []byte) (int, error) { return n, err } -// AccessLogMiddleware logs a structured line per HTTP request after the response is written. -func AccessLogMiddleware(next http.Handler) http.Handler { +// AccessLogMiddleware logs a structured line per HTTP request after the +// response is written. +// +// Always emits to slog.Default() (stderr) so server-lifecycle logs and +// access logs share an output stream by default. +// +// If `auditLogger` is non-nil, the same structured fields are also written +// to it. The intended caller wires up auditLogger with a JSON handler +// pointing at a rotating file (see cmd/zddc-server's setupAccessAuditLog), +// so an operator gets a persisted audit trail on disk in addition to the +// stderr stream — useful when stderr is not journald-captured (e.g. +// container logging where the orchestrator drops stderr after restarts). +func AccessLogMiddleware(auditLogger *slog.Logger, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Capture request start time start := time.Now() @@ -88,8 +99,7 @@ func AccessLogMiddleware(next http.Handler) http.Handler { email = "anonymous" } - // Log access - slog.Info("access", + args := []any{ "ts", start.Format(time.RFC3339), "email", email, "method", r.Method, @@ -97,6 +107,15 @@ func AccessLogMiddleware(next http.Handler) http.Handler { "status", wrapped.status, "bytes", wrapped.bytes, "duration_ms", durationMs, - ) + } + + // Stderr stream (existing behavior). + slog.Info("access", args...) + + // Audit file (when configured). Same fields, separate handler so + // the file can be JSON-formatted regardless of stderr's handler. + if auditLogger != nil { + auditLogger.Info("access", args...) + } }) } diff --git a/zddc/internal/handler/middleware_test.go b/zddc/internal/handler/middleware_test.go index cdaceb4..ab1a7de 100644 --- a/zddc/internal/handler/middleware_test.go +++ b/zddc/internal/handler/middleware_test.go @@ -32,7 +32,7 @@ func TestAccessLogReadsEmailFromACLContext(t *testing.T) { // Correct order: ACL is outer, AccessLog is inner. AccessLog reads // email from the context ACL populated. - chain := ACLMiddleware(cfg, AccessLogMiddleware(noop)) + chain := ACLMiddleware(cfg, AccessLogMiddleware(nil, noop)) req := httptest.NewRequest(http.MethodGet, "/foo", nil) req.Header.Set("X-Auth-Request-Email", "alice@example.com") @@ -60,7 +60,7 @@ func TestAccessLogAnonymousWhenNoEmail(t *testing.T) { w.WriteHeader(http.StatusOK) }) - chain := ACLMiddleware(cfg, AccessLogMiddleware(noop)) + chain := ACLMiddleware(cfg, AccessLogMiddleware(nil, noop)) req := httptest.NewRequest(http.MethodGet, "/foo", nil) // Note: no X-Auth-Request-Email header set. @@ -90,7 +90,7 @@ func TestAccessLogOuterDoesNotSeeInnerContext(t *testing.T) { }) // Inverted order — the ORIGINAL buggy chain. - chain := AccessLogMiddleware(ACLMiddleware(cfg, noop)) + chain := AccessLogMiddleware(nil, ACLMiddleware(cfg, noop)) req := httptest.NewRequest(http.MethodGet, "/foo", nil) req.Header.Set("X-Auth-Request-Email", "alice@example.com") @@ -104,3 +104,35 @@ func TestAccessLogOuterDoesNotSeeInnerContext(t *testing.T) { t.Errorf("expected the inverted (buggy) chain to fall back to email=anonymous, got: %s", got) } } + +// TestAccessLogMiddleware_AuditLoggerReceivesSameFields verifies the +// optional audit-logger argument: when non-nil, it gets a parallel copy +// of every access record. Used by main.go to tee access logs to a +// rotating file in addition to stderr. +func TestAccessLogMiddleware_AuditLoggerReceivesSameFields(t *testing.T) { + var auditBuf bytes.Buffer + auditLogger := slog.New(slog.NewJSONHandler(&auditBuf, &slog.HandlerOptions{Level: slog.LevelInfo})) + + noop := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusTeapot) + _, _ = w.Write([]byte("hi")) + }) + + cfg := config.Config{EmailHeader: "X-Auth-Request-Email"} + chain := ACLMiddleware(cfg, AccessLogMiddleware(auditLogger, noop)) + + req := httptest.NewRequest(http.MethodGet, "/some/path", nil) + req.Header.Set("X-Auth-Request-Email", "bob@example.com") + chain.ServeHTTP(httptest.NewRecorder(), req) + + out := auditBuf.String() + if !strings.Contains(out, `"email":"bob@example.com"`) { + t.Errorf("audit log missing email field; got: %s", out) + } + if !strings.Contains(out, `"path":"/some/path"`) { + t.Errorf("audit log missing path; got: %s", out) + } + if !strings.Contains(out, `"status":418`) { + t.Errorf("audit log missing status code; got: %s", out) + } +}