Merge pull request #155 from bnema/feat/dedicated-access-log
feat: dedicated access log for security tooling (CrowdSec, fail2ban, GoAccess)
20 files · +1475 -64
M go.mod +2 -1
| @@ -7,6 +7,7 @@ require ( | |||
| 7 | 7 | github.com/charmbracelet/bubbles v1.0.0 | |
| 8 | 8 | github.com/charmbracelet/bubbletea v1.3.10 | |
| 9 | 9 | github.com/charmbracelet/lipgloss v1.1.0 | |
| 10 | + | github.com/coreos/go-systemd/v22 v22.7.0 | |
| 10 | 11 | github.com/docker/docker v28.5.2+incompatible | |
| 11 | 12 | github.com/docker/go-connections v0.6.0 | |
| 12 | 13 | github.com/golang-jwt/jwt/v5 v5.3.1 | |
| @@ -25,6 +26,7 @@ require ( | |||
| 25 | 26 | go.opentelemetry.io/otel/sdk/log v0.18.0 | |
| 26 | 27 | go.opentelemetry.io/otel/sdk/metric v1.42.0 | |
| 27 | 28 | golang.org/x/mod v0.34.0 | |
| 29 | + | golang.org/x/sync v0.20.0 | |
| 28 | 30 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 | |
| 29 | 31 | ) | |
| 30 | 32 | ||
| @@ -63,7 +65,6 @@ require ( | |||
| 63 | 65 | go.opentelemetry.io/proto/otlp v1.9.0 // indirect | |
| 64 | 66 | go.yaml.in/yaml/v3 v3.0.4 // indirect | |
| 65 | 67 | golang.org/x/net v0.52.0 // indirect | |
| 66 | - | golang.org/x/sync v0.20.0 // indirect | |
| 67 | 68 | google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect | |
| 68 | 69 | google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect | |
| 69 | 70 | google.golang.org/grpc v1.79.3 // indirect | |
| @@ -7,6 +7,7 @@ require ( | |||||
| 7 | github.com/charmbracelet/bubbles v1.0.0 | 7 | github.com/charmbracelet/bubbles v1.0.0 | ||
| 8 | github.com/charmbracelet/bubbletea v1.3.10 | 8 | github.com/charmbracelet/bubbletea v1.3.10 | ||
| 9 | github.com/charmbracelet/lipgloss v1.1.0 | 9 | github.com/charmbracelet/lipgloss v1.1.0 | ||
| 10 | + | github.com/coreos/go-systemd/v22 v22.7.0 | |||
| 10 | github.com/docker/docker v28.5.2+incompatible | 11 | github.com/docker/docker v28.5.2+incompatible | ||
| 11 | github.com/docker/go-connections v0.6.0 | 12 | github.com/docker/go-connections v0.6.0 | ||
| 12 | github.com/golang-jwt/jwt/v5 v5.3.1 | 13 | github.com/golang-jwt/jwt/v5 v5.3.1 | ||
| @@ -25,6 +26,7 @@ require ( | |||||
| 25 | go.opentelemetry.io/otel/sdk/log v0.18.0 | 26 | go.opentelemetry.io/otel/sdk/log v0.18.0 | ||
| 26 | go.opentelemetry.io/otel/sdk/metric v1.42.0 | 27 | go.opentelemetry.io/otel/sdk/metric v1.42.0 | ||
| 27 | golang.org/x/mod v0.34.0 | 28 | golang.org/x/mod v0.34.0 | ||
| 29 | + | golang.org/x/sync v0.20.0 | |||
| 28 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 | 30 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 | ||
| 29 | ) | 31 | ) | ||
| 30 | 32 | ||||
| @@ -63,7 +65,6 @@ require ( | |||||
| 63 | go.opentelemetry.io/proto/otlp v1.9.0 // indirect | 65 | go.opentelemetry.io/proto/otlp v1.9.0 // indirect | ||
| 64 | go.yaml.in/yaml/v3 v3.0.4 // indirect | 66 | go.yaml.in/yaml/v3 v3.0.4 // indirect | ||
| 65 | golang.org/x/net v0.52.0 // indirect | 67 | golang.org/x/net v0.52.0 // indirect | ||
| 66 | - | golang.org/x/sync v0.20.0 // indirect | |||
| 67 | google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect | 68 | google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect | ||
| 68 | google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect | 69 | google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect | ||
| 69 | google.golang.org/grpc v1.79.3 // indirect | 70 | google.golang.org/grpc v1.79.3 // indirect | ||
M go.sum +2 -0
| @@ -45,6 +45,8 @@ github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3 | |||
| 45 | 45 | github.com/containerd/stargz-snapshotter/estargz v0.18.2 h1:yXkZFYIzz3eoLwlTUZKz2iQ4MrckBxJjkmD16ynUTrw= | |
| 46 | 46 | github.com/containerd/stargz-snapshotter/estargz v0.18.2/go.mod h1:XyVU5tcJ3PRpkA9XS2T5us6Eg35yM0214Y+wvrZTBrY= | |
| 47 | 47 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= | |
| 48 | + | github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA= | |
| 49 | + | github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= | |
| 48 | 50 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= | |
| 49 | 51 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= | |
| 50 | 52 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | |
| @@ -45,6 +45,8 @@ github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3 | |||||
| 45 | github.com/containerd/stargz-snapshotter/estargz v0.18.2 h1:yXkZFYIzz3eoLwlTUZKz2iQ4MrckBxJjkmD16ynUTrw= | 45 | github.com/containerd/stargz-snapshotter/estargz v0.18.2 h1:yXkZFYIzz3eoLwlTUZKz2iQ4MrckBxJjkmD16ynUTrw= | ||
| 46 | github.com/containerd/stargz-snapshotter/estargz v0.18.2/go.mod h1:XyVU5tcJ3PRpkA9XS2T5us6Eg35yM0214Y+wvrZTBrY= | 46 | github.com/containerd/stargz-snapshotter/estargz v0.18.2/go.mod h1:XyVU5tcJ3PRpkA9XS2T5us6Eg35yM0214Y+wvrZTBrY= | ||
| 47 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= | 47 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= | ||
| 48 | + | github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA= | |||
| 49 | + | github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= | |||
| 48 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= | 50 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= | ||
| 49 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= | 51 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= | ||
| 50 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | 52 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | ||
A internal/adapters/in/http/middleware/accesslog.go +87 -0
| @@ -0,0 +1,87 @@ | |||
| 1 | + | package middleware | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "net" | |
| 5 | + | "net/http" | |
| 6 | + | "strings" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/zerowrap" | |
| 10 | + | ||
| 11 | + | out "github.com/bnema/gordon/internal/boundaries/out" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // AccessLogger is a middleware that writes one access-log entry per HTTP request | |
| 15 | + | // to the provided writer. It runs alongside (not instead of) RequestLogger. | |
| 16 | + | // | |
| 17 | + | // When excludeHealthChecks is true, requests from the Gordon health prober | |
| 18 | + | // (UA prefix out.HealthCheckUserAgentPrefix) or from loopback IPs are not logged. | |
| 19 | + | // | |
| 20 | + | // Write failures are reported as warnings through the application logger and | |
| 21 | + | // never fail the HTTP response. | |
| 22 | + | func AccessLogger(writer out.AccessLogWriter, excludeHealthChecks bool, log zerowrap.Logger, trustedNets []*net.IPNet) func(http.Handler) http.Handler { | |
| 23 | + | return func(next http.Handler) http.Handler { | |
| 24 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 25 | + | start := time.Now() | |
| 26 | + | ||
| 27 | + | // Ensure a request ID exists before calling inner handlers so both | |
| 28 | + | // this middleware and RequestLogger share the same value. | |
| 29 | + | requestID, r := ensureRequestID(w, r) | |
| 30 | + | ||
| 31 | + | // Wrap the response writer to capture status code and bytes written. | |
| 32 | + | rw := NewResponseWriter(w) | |
| 33 | + | ||
| 34 | + | // Resolve client IP before calling the inner handler so we see the | |
| 35 | + | // original remote address before any mutation. | |
| 36 | + | clientIP := GetClientIP(r, trustedNets) | |
| 37 | + | ||
| 38 | + | next.ServeHTTP(rw, r) | |
| 39 | + | ||
| 40 | + | // Apply health-check exclusion after the response so the request | |
| 41 | + | // always completes — we just skip writing the log entry. | |
| 42 | + | // UA check uses the shared domain constant so the prober and this | |
| 43 | + | // filter can never drift. Loopback check reuses localhostNets from | |
| 44 | + | // cidr.go (same package) — no separate loopback definition needed. | |
| 45 | + | if excludeHealthChecks { | |
| 46 | + | if strings.HasPrefix(r.UserAgent(), out.HealthCheckUserAgentPrefix) || | |
| 47 | + | IsTrustedProxy(clientIP, localhostNets) { | |
| 48 | + | return | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // Capture end time once; use it for both duration and entry timestamp | |
| 53 | + | // so the two values are derived from the same instant. | |
| 54 | + | end := time.Now() | |
| 55 | + | durationMS := float64(end.Sub(start).Microseconds()) / 1000.0 | |
| 56 | + | ||
| 57 | + | entry := out.AccessLogEntry{ | |
| 58 | + | Time: end.UTC(), | |
| 59 | + | ClientIP: clientIP, | |
| 60 | + | Method: r.Method, | |
| 61 | + | Host: r.Host, | |
| 62 | + | Path: r.URL.Path, | |
| 63 | + | Query: r.URL.RawQuery, | |
| 64 | + | Status: rw.StatusCode(), | |
| 65 | + | BytesSent: rw.BytesWritten(), | |
| 66 | + | DurationMS: durationMS, | |
| 67 | + | UserAgent: r.UserAgent(), | |
| 68 | + | Referer: r.Referer(), | |
| 69 | + | RequestID: requestID, | |
| 70 | + | Proto: r.Proto, | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | if err := writer.Write(entry); err != nil { | |
| 74 | + | log.Warn(). | |
| 75 | + | Str(zerowrap.FieldLayer, "adapter"). | |
| 76 | + | Str(zerowrap.FieldAdapter, "http"). | |
| 77 | + | Str(zerowrap.FieldRequestID, requestID). | |
| 78 | + | Str(zerowrap.FieldMethod, r.Method). | |
| 79 | + | Str(zerowrap.FieldPath, r.URL.Path). | |
| 80 | + | Int(zerowrap.FieldStatus, rw.StatusCode()). | |
| 81 | + | Str(zerowrap.FieldClientIP, clientIP). | |
| 82 | + | Err(err). | |
| 83 | + | Msg("access log write failed") | |
| 84 | + | } | |
| 85 | + | }) | |
| 86 | + | } | |
| 87 | + | } | |
| @@ -0,0 +1,87 @@ | |||||
| 1 | + | package middleware | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "net" | |||
| 5 | + | "net/http" | |||
| 6 | + | "strings" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/zerowrap" | |||
| 10 | + | ||||
| 11 | + | out "github.com/bnema/gordon/internal/boundaries/out" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // AccessLogger is a middleware that writes one access-log entry per HTTP request | |||
| 15 | + | // to the provided writer. It runs alongside (not instead of) RequestLogger. | |||
| 16 | + | // | |||
| 17 | + | // When excludeHealthChecks is true, requests from the Gordon health prober | |||
| 18 | + | // (UA prefix out.HealthCheckUserAgentPrefix) or from loopback IPs are not logged. | |||
| 19 | + | // | |||
| 20 | + | // Write failures are reported as warnings through the application logger and | |||
| 21 | + | // never fail the HTTP response. | |||
| 22 | + | func AccessLogger(writer out.AccessLogWriter, excludeHealthChecks bool, log zerowrap.Logger, trustedNets []*net.IPNet) func(http.Handler) http.Handler { | |||
| 23 | + | return func(next http.Handler) http.Handler { | |||
| 24 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 25 | + | start := time.Now() | |||
| 26 | + | ||||
| 27 | + | // Ensure a request ID exists before calling inner handlers so both | |||
| 28 | + | // this middleware and RequestLogger share the same value. | |||
| 29 | + | requestID, r := ensureRequestID(w, r) | |||
| 30 | + | ||||
| 31 | + | // Wrap the response writer to capture status code and bytes written. | |||
| 32 | + | rw := NewResponseWriter(w) | |||
| 33 | + | ||||
| 34 | + | // Resolve client IP before calling the inner handler so we see the | |||
| 35 | + | // original remote address before any mutation. | |||
| 36 | + | clientIP := GetClientIP(r, trustedNets) | |||
| 37 | + | ||||
| 38 | + | next.ServeHTTP(rw, r) | |||
| 39 | + | ||||
| 40 | + | // Apply health-check exclusion after the response so the request | |||
| 41 | + | // always completes — we just skip writing the log entry. | |||
| 42 | + | // UA check uses the shared domain constant so the prober and this | |||
| 43 | + | // filter can never drift. Loopback check reuses localhostNets from | |||
| 44 | + | // cidr.go (same package) — no separate loopback definition needed. | |||
| 45 | + | if excludeHealthChecks { | |||
| 46 | + | if strings.HasPrefix(r.UserAgent(), out.HealthCheckUserAgentPrefix) || | |||
| 47 | + | IsTrustedProxy(clientIP, localhostNets) { | |||
| 48 | + | return | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | // Capture end time once; use it for both duration and entry timestamp | |||
| 53 | + | // so the two values are derived from the same instant. | |||
| 54 | + | end := time.Now() | |||
| 55 | + | durationMS := float64(end.Sub(start).Microseconds()) / 1000.0 | |||
| 56 | + | ||||
| 57 | + | entry := out.AccessLogEntry{ | |||
| 58 | + | Time: end.UTC(), | |||
| 59 | + | ClientIP: clientIP, | |||
| 60 | + | Method: r.Method, | |||
| 61 | + | Host: r.Host, | |||
| 62 | + | Path: r.URL.Path, | |||
| 63 | + | Query: r.URL.RawQuery, | |||
| 64 | + | Status: rw.StatusCode(), | |||
| 65 | + | BytesSent: rw.BytesWritten(), | |||
| 66 | + | DurationMS: durationMS, | |||
| 67 | + | UserAgent: r.UserAgent(), | |||
| 68 | + | Referer: r.Referer(), | |||
| 69 | + | RequestID: requestID, | |||
| 70 | + | Proto: r.Proto, | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | if err := writer.Write(entry); err != nil { | |||
| 74 | + | log.Warn(). | |||
| 75 | + | Str(zerowrap.FieldLayer, "adapter"). | |||
| 76 | + | Str(zerowrap.FieldAdapter, "http"). | |||
| 77 | + | Str(zerowrap.FieldRequestID, requestID). | |||
| 78 | + | Str(zerowrap.FieldMethod, r.Method). | |||
| 79 | + | Str(zerowrap.FieldPath, r.URL.Path). | |||
| 80 | + | Int(zerowrap.FieldStatus, rw.StatusCode()). | |||
| 81 | + | Str(zerowrap.FieldClientIP, clientIP). | |||
| 82 | + | Err(err). | |||
| 83 | + | Msg("access log write failed") | |||
| 84 | + | } | |||
| 85 | + | }) | |||
| 86 | + | } | |||
| 87 | + | } | |||
A internal/adapters/in/http/middleware/accesslog_test.go +170 -0
| @@ -0,0 +1,170 @@ | |||
| 1 | + | package middleware | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "net/http" | |
| 6 | + | "net/http/httptest" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | out "github.com/bnema/gordon/internal/boundaries/out" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // mockAccessLogWriter records written entries and optionally returns an error. | |
| 16 | + | type mockAccessLogWriter struct { | |
| 17 | + | entries []out.AccessLogEntry | |
| 18 | + | err error | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func (m *mockAccessLogWriter) Write(entry out.AccessLogEntry) error { | |
| 22 | + | m.entries = append(m.entries, entry) | |
| 23 | + | return m.err | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func TestAccessLogger_EmitsEntryPerRequest(t *testing.T) { | |
| 27 | + | mock := &mockAccessLogWriter{} | |
| 28 | + | log := testLogger() | |
| 29 | + | ||
| 30 | + | handler := AccessLogger(mock, false, log, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 31 | + | w.WriteHeader(http.StatusOK) | |
| 32 | + | _, _ = w.Write([]byte("hello")) | |
| 33 | + | })) | |
| 34 | + | ||
| 35 | + | req := httptest.NewRequest(http.MethodGet, "/some/path?foo=bar", nil) | |
| 36 | + | req.RemoteAddr = "192.168.1.100:12345" | |
| 37 | + | req.Header.Set("User-Agent", "test-agent") | |
| 38 | + | req.Header.Set("Referer", "https://example.com") | |
| 39 | + | req.RequestURI = "/some/path?foo=bar" | |
| 40 | + | req.Host = "example.com" | |
| 41 | + | req.Proto = "HTTP/1.1" | |
| 42 | + | rw := httptest.NewRecorder() | |
| 43 | + | ||
| 44 | + | handler.ServeHTTP(rw, req) | |
| 45 | + | ||
| 46 | + | require.Len(t, mock.entries, 1) | |
| 47 | + | e := mock.entries[0] | |
| 48 | + | assert.Equal(t, http.MethodGet, e.Method) | |
| 49 | + | assert.Equal(t, "/some/path", e.Path) | |
| 50 | + | assert.Equal(t, "foo=bar", e.Query) | |
| 51 | + | assert.Equal(t, "example.com", e.Host) | |
| 52 | + | assert.Equal(t, http.StatusOK, e.Status) | |
| 53 | + | assert.Equal(t, 5, e.BytesSent) | |
| 54 | + | assert.Equal(t, "test-agent", e.UserAgent) | |
| 55 | + | assert.Equal(t, "https://example.com", e.Referer) | |
| 56 | + | assert.Equal(t, "HTTP/1.1", e.Proto) | |
| 57 | + | assert.NotEmpty(t, e.RequestID) | |
| 58 | + | assert.Equal(t, "192.168.1.100", e.ClientIP) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func TestAccessLogger_RequestIDReused(t *testing.T) { | |
| 62 | + | mock := &mockAccessLogWriter{} | |
| 63 | + | log := testLogger() | |
| 64 | + | ||
| 65 | + | handler := AccessLogger(mock, false, log, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 66 | + | w.WriteHeader(http.StatusOK) | |
| 67 | + | })) | |
| 68 | + | ||
| 69 | + | req := httptest.NewRequest(http.MethodGet, "/", nil) | |
| 70 | + | req.Header.Set("X-Request-ID", "fixed-id-123") | |
| 71 | + | rw := httptest.NewRecorder() | |
| 72 | + | ||
| 73 | + | handler.ServeHTTP(rw, req) | |
| 74 | + | ||
| 75 | + | require.Len(t, mock.entries, 1) | |
| 76 | + | assert.Equal(t, "fixed-id-123", mock.entries[0].RequestID) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func TestAccessLogger_ExcludeHealthCheckUA(t *testing.T) { | |
| 80 | + | mock := &mockAccessLogWriter{} | |
| 81 | + | log := testLogger() | |
| 82 | + | ||
| 83 | + | handler := AccessLogger(mock, true, log, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 84 | + | w.WriteHeader(http.StatusOK) | |
| 85 | + | })) | |
| 86 | + | ||
| 87 | + | req := httptest.NewRequest(http.MethodGet, "/health", nil) | |
| 88 | + | req.Header.Set("User-Agent", "Gordon-HealthCheck/1.0") | |
| 89 | + | rw := httptest.NewRecorder() | |
| 90 | + | ||
| 91 | + | handler.ServeHTTP(rw, req) | |
| 92 | + | ||
| 93 | + | assert.Empty(t, mock.entries, "health-check request should not be logged") | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func TestAccessLogger_ExcludeLoopbackIP(t *testing.T) { | |
| 97 | + | mock := &mockAccessLogWriter{} | |
| 98 | + | log := testLogger() | |
| 99 | + | ||
| 100 | + | handler := AccessLogger(mock, true, log, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 101 | + | w.WriteHeader(http.StatusOK) | |
| 102 | + | })) | |
| 103 | + | ||
| 104 | + | req := httptest.NewRequest(http.MethodGet, "/health", nil) | |
| 105 | + | req.RemoteAddr = "127.0.0.1:54321" | |
| 106 | + | rw := httptest.NewRecorder() | |
| 107 | + | ||
| 108 | + | handler.ServeHTTP(rw, req) | |
| 109 | + | ||
| 110 | + | assert.Empty(t, mock.entries, "loopback request should not be logged") | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func TestAccessLogger_NoExcludeWhenDisabled(t *testing.T) { | |
| 114 | + | mock := &mockAccessLogWriter{} | |
| 115 | + | log := testLogger() | |
| 116 | + | ||
| 117 | + | handler := AccessLogger(mock, false, log, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 118 | + | w.WriteHeader(http.StatusOK) | |
| 119 | + | })) | |
| 120 | + | ||
| 121 | + | req := httptest.NewRequest(http.MethodGet, "/health", nil) | |
| 122 | + | req.Header.Set("User-Agent", "Gordon-HealthCheck/1.0") | |
| 123 | + | rw := httptest.NewRecorder() | |
| 124 | + | ||
| 125 | + | handler.ServeHTTP(rw, req) | |
| 126 | + | ||
| 127 | + | assert.Len(t, mock.entries, 1, "health-check request should be logged when excludeHealthChecks=false") | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func TestAccessLogger_WriteFailureDoesNotAffectResponse(t *testing.T) { | |
| 131 | + | mock := &mockAccessLogWriter{err: fmt.Errorf("disk full")} | |
| 132 | + | log := testLogger() | |
| 133 | + | ||
| 134 | + | handler := AccessLogger(mock, false, log, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 135 | + | w.WriteHeader(http.StatusOK) | |
| 136 | + | _, _ = w.Write([]byte("ok")) | |
| 137 | + | })) | |
| 138 | + | ||
| 139 | + | req := httptest.NewRequest(http.MethodGet, "/", nil) | |
| 140 | + | rw := httptest.NewRecorder() | |
| 141 | + | ||
| 142 | + | // Should not panic or change the response. | |
| 143 | + | handler.ServeHTTP(rw, req) | |
| 144 | + | ||
| 145 | + | assert.Equal(t, http.StatusOK, rw.Code) | |
| 146 | + | assert.Equal(t, "ok", rw.Body.String()) | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func TestAccessLogger_PanicRecoveryChain_Captures500(t *testing.T) { | |
| 150 | + | mock := &mockAccessLogWriter{} | |
| 151 | + | log := testLogger() | |
| 152 | + | ||
| 153 | + | // Chain: AccessLogger (outer) → PanicRecovery → panicking handler | |
| 154 | + | panicHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 155 | + | panic("test panic") | |
| 156 | + | }) | |
| 157 | + | chain := AccessLogger(mock, false, log, nil)(PanicRecovery(log)(panicHandler)) | |
| 158 | + | ||
| 159 | + | req := httptest.NewRequest(http.MethodGet, "/crash", nil) | |
| 160 | + | rw := httptest.NewRecorder() | |
| 161 | + | ||
| 162 | + | chain.ServeHTTP(rw, req) | |
| 163 | + | ||
| 164 | + | // PanicRecovery should have written 500. | |
| 165 | + | assert.Equal(t, http.StatusInternalServerError, rw.Code) | |
| 166 | + | ||
| 167 | + | // AccessLogger must have captured the 500. | |
| 168 | + | require.Len(t, mock.entries, 1) | |
| 169 | + | assert.Equal(t, http.StatusInternalServerError, mock.entries[0].Status) | |
| 170 | + | } | |
| @@ -0,0 +1,170 @@ | |||||
| 1 | + | package middleware | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "net/http" | |||
| 6 | + | "net/http/httptest" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/stretchr/testify/assert" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ||||
| 12 | + | out "github.com/bnema/gordon/internal/boundaries/out" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // mockAccessLogWriter records written entries and optionally returns an error. | |||
| 16 | + | type mockAccessLogWriter struct { | |||
| 17 | + | entries []out.AccessLogEntry | |||
| 18 | + | err error | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func (m *mockAccessLogWriter) Write(entry out.AccessLogEntry) error { | |||
| 22 | + | m.entries = append(m.entries, entry) | |||
| 23 | + | return m.err | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func TestAccessLogger_EmitsEntryPerRequest(t *testing.T) { | |||
| 27 | + | mock := &mockAccessLogWriter{} | |||
| 28 | + | log := testLogger() | |||
| 29 | + | ||||
| 30 | + | handler := AccessLogger(mock, false, log, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 31 | + | w.WriteHeader(http.StatusOK) | |||
| 32 | + | _, _ = w.Write([]byte("hello")) | |||
| 33 | + | })) | |||
| 34 | + | ||||
| 35 | + | req := httptest.NewRequest(http.MethodGet, "/some/path?foo=bar", nil) | |||
| 36 | + | req.RemoteAddr = "192.168.1.100:12345" | |||
| 37 | + | req.Header.Set("User-Agent", "test-agent") | |||
| 38 | + | req.Header.Set("Referer", "https://example.com") | |||
| 39 | + | req.RequestURI = "/some/path?foo=bar" | |||
| 40 | + | req.Host = "example.com" | |||
| 41 | + | req.Proto = "HTTP/1.1" | |||
| 42 | + | rw := httptest.NewRecorder() | |||
| 43 | + | ||||
| 44 | + | handler.ServeHTTP(rw, req) | |||
| 45 | + | ||||
| 46 | + | require.Len(t, mock.entries, 1) | |||
| 47 | + | e := mock.entries[0] | |||
| 48 | + | assert.Equal(t, http.MethodGet, e.Method) | |||
| 49 | + | assert.Equal(t, "/some/path", e.Path) | |||
| 50 | + | assert.Equal(t, "foo=bar", e.Query) | |||
| 51 | + | assert.Equal(t, "example.com", e.Host) | |||
| 52 | + | assert.Equal(t, http.StatusOK, e.Status) | |||
| 53 | + | assert.Equal(t, 5, e.BytesSent) | |||
| 54 | + | assert.Equal(t, "test-agent", e.UserAgent) | |||
| 55 | + | assert.Equal(t, "https://example.com", e.Referer) | |||
| 56 | + | assert.Equal(t, "HTTP/1.1", e.Proto) | |||
| 57 | + | assert.NotEmpty(t, e.RequestID) | |||
| 58 | + | assert.Equal(t, "192.168.1.100", e.ClientIP) | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func TestAccessLogger_RequestIDReused(t *testing.T) { | |||
| 62 | + | mock := &mockAccessLogWriter{} | |||
| 63 | + | log := testLogger() | |||
| 64 | + | ||||
| 65 | + | handler := AccessLogger(mock, false, log, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 66 | + | w.WriteHeader(http.StatusOK) | |||
| 67 | + | })) | |||
| 68 | + | ||||
| 69 | + | req := httptest.NewRequest(http.MethodGet, "/", nil) | |||
| 70 | + | req.Header.Set("X-Request-ID", "fixed-id-123") | |||
| 71 | + | rw := httptest.NewRecorder() | |||
| 72 | + | ||||
| 73 | + | handler.ServeHTTP(rw, req) | |||
| 74 | + | ||||
| 75 | + | require.Len(t, mock.entries, 1) | |||
| 76 | + | assert.Equal(t, "fixed-id-123", mock.entries[0].RequestID) | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func TestAccessLogger_ExcludeHealthCheckUA(t *testing.T) { | |||
| 80 | + | mock := &mockAccessLogWriter{} | |||
| 81 | + | log := testLogger() | |||
| 82 | + | ||||
| 83 | + | handler := AccessLogger(mock, true, log, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 84 | + | w.WriteHeader(http.StatusOK) | |||
| 85 | + | })) | |||
| 86 | + | ||||
| 87 | + | req := httptest.NewRequest(http.MethodGet, "/health", nil) | |||
| 88 | + | req.Header.Set("User-Agent", "Gordon-HealthCheck/1.0") | |||
| 89 | + | rw := httptest.NewRecorder() | |||
| 90 | + | ||||
| 91 | + | handler.ServeHTTP(rw, req) | |||
| 92 | + | ||||
| 93 | + | assert.Empty(t, mock.entries, "health-check request should not be logged") | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | func TestAccessLogger_ExcludeLoopbackIP(t *testing.T) { | |||
| 97 | + | mock := &mockAccessLogWriter{} | |||
| 98 | + | log := testLogger() | |||
| 99 | + | ||||
| 100 | + | handler := AccessLogger(mock, true, log, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 101 | + | w.WriteHeader(http.StatusOK) | |||
| 102 | + | })) | |||
| 103 | + | ||||
| 104 | + | req := httptest.NewRequest(http.MethodGet, "/health", nil) | |||
| 105 | + | req.RemoteAddr = "127.0.0.1:54321" | |||
| 106 | + | rw := httptest.NewRecorder() | |||
| 107 | + | ||||
| 108 | + | handler.ServeHTTP(rw, req) | |||
| 109 | + | ||||
| 110 | + | assert.Empty(t, mock.entries, "loopback request should not be logged") | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func TestAccessLogger_NoExcludeWhenDisabled(t *testing.T) { | |||
| 114 | + | mock := &mockAccessLogWriter{} | |||
| 115 | + | log := testLogger() | |||
| 116 | + | ||||
| 117 | + | handler := AccessLogger(mock, false, log, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 118 | + | w.WriteHeader(http.StatusOK) | |||
| 119 | + | })) | |||
| 120 | + | ||||
| 121 | + | req := httptest.NewRequest(http.MethodGet, "/health", nil) | |||
| 122 | + | req.Header.Set("User-Agent", "Gordon-HealthCheck/1.0") | |||
| 123 | + | rw := httptest.NewRecorder() | |||
| 124 | + | ||||
| 125 | + | handler.ServeHTTP(rw, req) | |||
| 126 | + | ||||
| 127 | + | assert.Len(t, mock.entries, 1, "health-check request should be logged when excludeHealthChecks=false") | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | func TestAccessLogger_WriteFailureDoesNotAffectResponse(t *testing.T) { | |||
| 131 | + | mock := &mockAccessLogWriter{err: fmt.Errorf("disk full")} | |||
| 132 | + | log := testLogger() | |||
| 133 | + | ||||
| 134 | + | handler := AccessLogger(mock, false, log, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 135 | + | w.WriteHeader(http.StatusOK) | |||
| 136 | + | _, _ = w.Write([]byte("ok")) | |||
| 137 | + | })) | |||
| 138 | + | ||||
| 139 | + | req := httptest.NewRequest(http.MethodGet, "/", nil) | |||
| 140 | + | rw := httptest.NewRecorder() | |||
| 141 | + | ||||
| 142 | + | // Should not panic or change the response. | |||
| 143 | + | handler.ServeHTTP(rw, req) | |||
| 144 | + | ||||
| 145 | + | assert.Equal(t, http.StatusOK, rw.Code) | |||
| 146 | + | assert.Equal(t, "ok", rw.Body.String()) | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | func TestAccessLogger_PanicRecoveryChain_Captures500(t *testing.T) { | |||
| 150 | + | mock := &mockAccessLogWriter{} | |||
| 151 | + | log := testLogger() | |||
| 152 | + | ||||
| 153 | + | // Chain: AccessLogger (outer) → PanicRecovery → panicking handler | |||
| 154 | + | panicHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 155 | + | panic("test panic") | |||
| 156 | + | }) | |||
| 157 | + | chain := AccessLogger(mock, false, log, nil)(PanicRecovery(log)(panicHandler)) | |||
| 158 | + | ||||
| 159 | + | req := httptest.NewRequest(http.MethodGet, "/crash", nil) | |||
| 160 | + | rw := httptest.NewRecorder() | |||
| 161 | + | ||||
| 162 | + | chain.ServeHTTP(rw, req) | |||
| 163 | + | ||||
| 164 | + | // PanicRecovery should have written 500. | |||
| 165 | + | assert.Equal(t, http.StatusInternalServerError, rw.Code) | |||
| 166 | + | ||||
| 167 | + | // AccessLogger must have captured the 500. | |||
| 168 | + | require.Len(t, mock.entries, 1) | |||
| 169 | + | assert.Equal(t, http.StatusInternalServerError, mock.entries[0].Status) | |||
| 170 | + | } | |||
M internal/adapters/in/http/middleware/auth.go +13 -12
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "crypto/subtle" | |
| 6 | 6 | "encoding/json" | |
| 7 | 7 | "errors" | |
| 8 | + | "net" | |
| 8 | 9 | "net/http" | |
| 9 | 10 | "strings" | |
| 10 | 11 | ||
| @@ -54,7 +55,7 @@ type InternalRegistryAuth struct { | |||
| 54 | 55 | ||
| 55 | 56 | // RegistryAuthV2 middleware provides enhanced Docker Registry authentication | |
| 56 | 57 | // supporting both password and token-based authentication. | |
| 57 | - | func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, log zerowrap.Logger) func(http.Handler) http.Handler { | |
| 58 | + | func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler { | |
| 58 | 59 | return func(next http.Handler) http.Handler { | |
| 59 | 60 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 60 | 61 | // Allow localhost requests only with internal instance credentials. | |
| @@ -64,7 +65,7 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l | |||
| 64 | 65 | Str(zerowrap.FieldAdapter, "http"). | |
| 65 | 66 | Str(zerowrap.FieldMethod, r.Method). | |
| 66 | 67 | Str(zerowrap.FieldPath, r.URL.Path). | |
| 67 | - | Str(zerowrap.FieldClientIP, r.RemoteAddr). | |
| 68 | + | Str(zerowrap.FieldClientIP, GetClientIP(r, trustedNets)). | |
| 68 | 69 | Msg("localhost request with internal auth - skipping auth") | |
| 69 | 70 | next.ServeHTTP(w, r) | |
| 70 | 71 | return | |
| @@ -77,7 +78,7 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l | |||
| 77 | 78 | Str(zerowrap.FieldAdapter, "http"). | |
| 78 | 79 | Str(zerowrap.FieldMethod, r.Method). | |
| 79 | 80 | Str(zerowrap.FieldPath, r.URL.Path). | |
| 80 | - | Str(zerowrap.FieldClientIP, r.RemoteAddr). | |
| 81 | + | Str(zerowrap.FieldClientIP, GetClientIP(r, trustedNets)). | |
| 81 | 82 | Msg("registry auth over insecure HTTP connection") | |
| 82 | 83 | } | |
| 83 | 84 | ||
| @@ -96,9 +97,9 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l | |||
| 96 | 97 | ||
| 97 | 98 | if authErr != nil { | |
| 98 | 99 | if errors.Is(authErr, domain.ErrLongLivedToken) { | |
| 99 | - | sendUnauthorizedMsg(w, authSvc.GetAuthType(), r.Host, log, r, domain.ErrLongLivedToken.Error()) | |
| 100 | + | sendUnauthorizedMsg(w, authSvc.GetAuthType(), r.Host, log, trustedNets, r, domain.ErrLongLivedToken.Error()) | |
| 100 | 101 | } else { | |
| 101 | - | sendUnauthorized(w, authSvc.GetAuthType(), r.Host, log, r) | |
| 102 | + | sendUnauthorized(w, authSvc.GetAuthType(), r.Host, log, trustedNets, r) | |
| 102 | 103 | } | |
| 103 | 104 | return | |
| 104 | 105 | } | |
| @@ -106,7 +107,7 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l | |||
| 106 | 107 | // SECURITY: Check scopes for token auth (per-repo access control) | |
| 107 | 108 | if tokenClaims != nil { | |
| 108 | 109 | if !checkScopeAccess(r, tokenClaims, log) { | |
| 109 | - | sendForbidden(w, log, r) | |
| 110 | + | sendForbidden(w, log, trustedNets, r) | |
| 110 | 111 | return | |
| 111 | 112 | } | |
| 112 | 113 | // Store claims in context for downstream handlers that need access to token metadata | |
| @@ -290,7 +291,7 @@ func checkScopeAccess(r *http.Request, claims *domain.TokenClaims, log zerowrap. | |||
| 290 | 291 | } | |
| 291 | 292 | ||
| 292 | 293 | // sendForbidden sends an HTTP 403 response. | |
| 293 | - | func sendForbidden(w http.ResponseWriter, log zerowrap.Logger, r *http.Request) { | |
| 294 | + | func sendForbidden(w http.ResponseWriter, log zerowrap.Logger, trustedNets []*net.IPNet, r *http.Request) { | |
| 294 | 295 | w.Header().Set("Docker-Distribution-API-Version", "registry/2.0") | |
| 295 | 296 | w.Header().Set("Content-Type", "application/json") | |
| 296 | 297 | w.WriteHeader(http.StatusForbidden) | |
| @@ -301,12 +302,12 @@ func sendForbidden(w http.ResponseWriter, log zerowrap.Logger, r *http.Request) | |||
| 301 | 302 | Str(zerowrap.FieldAdapter, "http"). | |
| 302 | 303 | Str(zerowrap.FieldMethod, r.Method). | |
| 303 | 304 | Str(zerowrap.FieldPath, r.URL.Path). | |
| 304 | - | Str(zerowrap.FieldClientIP, r.RemoteAddr). | |
| 305 | + | Str(zerowrap.FieldClientIP, GetClientIP(r, trustedNets)). | |
| 305 | 306 | Msg("forbidden: insufficient scope for operation") | |
| 306 | 307 | } | |
| 307 | 308 | ||
| 308 | 309 | // sendUnauthorizedMsg sends an HTTP 401 response with a custom error message. | |
| 309 | - | func sendUnauthorizedMsg(w http.ResponseWriter, authType domain.AuthType, host string, log zerowrap.Logger, r *http.Request, msg string) { | |
| 310 | + | func sendUnauthorizedMsg(w http.ResponseWriter, authType domain.AuthType, host string, log zerowrap.Logger, trustedNets []*net.IPNet, r *http.Request, msg string) { | |
| 310 | 311 | w.Header().Set("Docker-Distribution-API-Version", "registry/2.0") | |
| 311 | 312 | ||
| 312 | 313 | switch authType { | |
| @@ -332,12 +333,12 @@ func sendUnauthorizedMsg(w http.ResponseWriter, authType domain.AuthType, host s | |||
| 332 | 333 | Str(zerowrap.FieldAdapter, "http"). | |
| 333 | 334 | Str(zerowrap.FieldMethod, r.Method). | |
| 334 | 335 | Str(zerowrap.FieldPath, r.URL.Path). | |
| 335 | - | Str(zerowrap.FieldClientIP, r.RemoteAddr). | |
| 336 | + | Str(zerowrap.FieldClientIP, GetClientIP(r, trustedNets)). | |
| 336 | 337 | Msg("unauthorized registry access attempt") | |
| 337 | 338 | } | |
| 338 | 339 | ||
| 339 | 340 | // sendUnauthorized sends an HTTP 401 response with appropriate headers. | |
| 340 | - | func sendUnauthorized(w http.ResponseWriter, authType domain.AuthType, host string, log zerowrap.Logger, r *http.Request) { | |
| 341 | + | func sendUnauthorized(w http.ResponseWriter, authType domain.AuthType, host string, log zerowrap.Logger, trustedNets []*net.IPNet, r *http.Request) { | |
| 341 | 342 | w.Header().Set("Docker-Distribution-API-Version", "registry/2.0") | |
| 342 | 343 | ||
| 343 | 344 | switch authType { | |
| @@ -375,6 +376,6 @@ func sendUnauthorized(w http.ResponseWriter, authType domain.AuthType, host stri | |||
| 375 | 376 | Str(zerowrap.FieldAdapter, "http"). | |
| 376 | 377 | Str(zerowrap.FieldMethod, r.Method). | |
| 377 | 378 | Str(zerowrap.FieldPath, r.URL.Path). | |
| 378 | - | Str(zerowrap.FieldClientIP, r.RemoteAddr). | |
| 379 | + | Str(zerowrap.FieldClientIP, GetClientIP(r, trustedNets)). | |
| 379 | 380 | Msg("unauthorized registry access attempt") | |
| 380 | 381 | } | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "crypto/subtle" | 5 | "crypto/subtle" | ||
| 6 | "encoding/json" | 6 | "encoding/json" | ||
| 7 | "errors" | 7 | "errors" | ||
| 8 | + | "net" | |||
| 8 | "net/http" | 9 | "net/http" | ||
| 9 | "strings" | 10 | "strings" | ||
| 10 | 11 | ||||
| @@ -54,7 +55,7 @@ type InternalRegistryAuth struct { | |||||
| 54 | 55 | ||||
| 55 | // RegistryAuthV2 middleware provides enhanced Docker Registry authentication | 56 | // RegistryAuthV2 middleware provides enhanced Docker Registry authentication | ||
| 56 | // supporting both password and token-based authentication. | 57 | // supporting both password and token-based authentication. | ||
| 57 | - | func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, log zerowrap.Logger) func(http.Handler) http.Handler { | 58 | + | func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler { |
| 58 | return func(next http.Handler) http.Handler { | 59 | return func(next http.Handler) http.Handler { | ||
| 59 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 60 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 60 | // Allow localhost requests only with internal instance credentials. | 61 | // Allow localhost requests only with internal instance credentials. | ||
| @@ -64,7 +65,7 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l | |||||
| 64 | Str(zerowrap.FieldAdapter, "http"). | 65 | Str(zerowrap.FieldAdapter, "http"). | ||
| 65 | Str(zerowrap.FieldMethod, r.Method). | 66 | Str(zerowrap.FieldMethod, r.Method). | ||
| 66 | Str(zerowrap.FieldPath, r.URL.Path). | 67 | Str(zerowrap.FieldPath, r.URL.Path). | ||
| 67 | - | Str(zerowrap.FieldClientIP, r.RemoteAddr). | 68 | + | Str(zerowrap.FieldClientIP, GetClientIP(r, trustedNets)). |
| 68 | Msg("localhost request with internal auth - skipping auth") | 69 | Msg("localhost request with internal auth - skipping auth") | ||
| 69 | next.ServeHTTP(w, r) | 70 | next.ServeHTTP(w, r) | ||
| 70 | return | 71 | return | ||
| @@ -77,7 +78,7 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l | |||||
| 77 | Str(zerowrap.FieldAdapter, "http"). | 78 | Str(zerowrap.FieldAdapter, "http"). | ||
| 78 | Str(zerowrap.FieldMethod, r.Method). | 79 | Str(zerowrap.FieldMethod, r.Method). | ||
| 79 | Str(zerowrap.FieldPath, r.URL.Path). | 80 | Str(zerowrap.FieldPath, r.URL.Path). | ||
| 80 | - | Str(zerowrap.FieldClientIP, r.RemoteAddr). | 81 | + | Str(zerowrap.FieldClientIP, GetClientIP(r, trustedNets)). |
| 81 | Msg("registry auth over insecure HTTP connection") | 82 | Msg("registry auth over insecure HTTP connection") | ||
| 82 | } | 83 | } | ||
| 83 | 84 | ||||
| @@ -96,9 +97,9 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l | |||||
| 96 | 97 | ||||
| 97 | if authErr != nil { | 98 | if authErr != nil { | ||
| 98 | if errors.Is(authErr, domain.ErrLongLivedToken) { | 99 | if errors.Is(authErr, domain.ErrLongLivedToken) { | ||
| 99 | - | sendUnauthorizedMsg(w, authSvc.GetAuthType(), r.Host, log, r, domain.ErrLongLivedToken.Error()) | 100 | + | sendUnauthorizedMsg(w, authSvc.GetAuthType(), r.Host, log, trustedNets, r, domain.ErrLongLivedToken.Error()) |
| 100 | } else { | 101 | } else { | ||
| 101 | - | sendUnauthorized(w, authSvc.GetAuthType(), r.Host, log, r) | 102 | + | sendUnauthorized(w, authSvc.GetAuthType(), r.Host, log, trustedNets, r) |
| 102 | } | 103 | } | ||
| 103 | return | 104 | return | ||
| 104 | } | 105 | } | ||
| @@ -106,7 +107,7 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l | |||||
| 106 | // SECURITY: Check scopes for token auth (per-repo access control) | 107 | // SECURITY: Check scopes for token auth (per-repo access control) | ||
| 107 | if tokenClaims != nil { | 108 | if tokenClaims != nil { | ||
| 108 | if !checkScopeAccess(r, tokenClaims, log) { | 109 | if !checkScopeAccess(r, tokenClaims, log) { | ||
| 109 | - | sendForbidden(w, log, r) | 110 | + | sendForbidden(w, log, trustedNets, r) |
| 110 | return | 111 | return | ||
| 111 | } | 112 | } | ||
| 112 | // Store claims in context for downstream handlers that need access to token metadata | 113 | // Store claims in context for downstream handlers that need access to token metadata | ||
| @@ -290,7 +291,7 @@ func checkScopeAccess(r *http.Request, claims *domain.TokenClaims, log zerowrap. | |||||
| 290 | } | 291 | } | ||
| 291 | 292 | ||||
| 292 | // sendForbidden sends an HTTP 403 response. | 293 | // sendForbidden sends an HTTP 403 response. | ||
| 293 | - | func sendForbidden(w http.ResponseWriter, log zerowrap.Logger, r *http.Request) { | 294 | + | func sendForbidden(w http.ResponseWriter, log zerowrap.Logger, trustedNets []*net.IPNet, r *http.Request) { |
| 294 | w.Header().Set("Docker-Distribution-API-Version", "registry/2.0") | 295 | w.Header().Set("Docker-Distribution-API-Version", "registry/2.0") | ||
| 295 | w.Header().Set("Content-Type", "application/json") | 296 | w.Header().Set("Content-Type", "application/json") | ||
| 296 | w.WriteHeader(http.StatusForbidden) | 297 | w.WriteHeader(http.StatusForbidden) | ||
| @@ -301,12 +302,12 @@ func sendForbidden(w http.ResponseWriter, log zerowrap.Logger, r *http.Request) | |||||
| 301 | Str(zerowrap.FieldAdapter, "http"). | 302 | Str(zerowrap.FieldAdapter, "http"). | ||
| 302 | Str(zerowrap.FieldMethod, r.Method). | 303 | Str(zerowrap.FieldMethod, r.Method). | ||
| 303 | Str(zerowrap.FieldPath, r.URL.Path). | 304 | Str(zerowrap.FieldPath, r.URL.Path). | ||
| 304 | - | Str(zerowrap.FieldClientIP, r.RemoteAddr). | 305 | + | Str(zerowrap.FieldClientIP, GetClientIP(r, trustedNets)). |
| 305 | Msg("forbidden: insufficient scope for operation") | 306 | Msg("forbidden: insufficient scope for operation") | ||
| 306 | } | 307 | } | ||
| 307 | 308 | ||||
| 308 | // sendUnauthorizedMsg sends an HTTP 401 response with a custom error message. | 309 | // sendUnauthorizedMsg sends an HTTP 401 response with a custom error message. | ||
| 309 | - | func sendUnauthorizedMsg(w http.ResponseWriter, authType domain.AuthType, host string, log zerowrap.Logger, r *http.Request, msg string) { | 310 | + | func sendUnauthorizedMsg(w http.ResponseWriter, authType domain.AuthType, host string, log zerowrap.Logger, trustedNets []*net.IPNet, r *http.Request, msg string) { |
| 310 | w.Header().Set("Docker-Distribution-API-Version", "registry/2.0") | 311 | w.Header().Set("Docker-Distribution-API-Version", "registry/2.0") | ||
| 311 | 312 | ||||
| 312 | switch authType { | 313 | switch authType { | ||
| @@ -332,12 +333,12 @@ func sendUnauthorizedMsg(w http.ResponseWriter, authType domain.AuthType, host s | |||||
| 332 | Str(zerowrap.FieldAdapter, "http"). | 333 | Str(zerowrap.FieldAdapter, "http"). | ||
| 333 | Str(zerowrap.FieldMethod, r.Method). | 334 | Str(zerowrap.FieldMethod, r.Method). | ||
| 334 | Str(zerowrap.FieldPath, r.URL.Path). | 335 | Str(zerowrap.FieldPath, r.URL.Path). | ||
| 335 | - | Str(zerowrap.FieldClientIP, r.RemoteAddr). | 336 | + | Str(zerowrap.FieldClientIP, GetClientIP(r, trustedNets)). |
| 336 | Msg("unauthorized registry access attempt") | 337 | Msg("unauthorized registry access attempt") | ||
| 337 | } | 338 | } | ||
| 338 | 339 | ||||
| 339 | // sendUnauthorized sends an HTTP 401 response with appropriate headers. | 340 | // sendUnauthorized sends an HTTP 401 response with appropriate headers. | ||
| 340 | - | func sendUnauthorized(w http.ResponseWriter, authType domain.AuthType, host string, log zerowrap.Logger, r *http.Request) { | 341 | + | func sendUnauthorized(w http.ResponseWriter, authType domain.AuthType, host string, log zerowrap.Logger, trustedNets []*net.IPNet, r *http.Request) { |
| 341 | w.Header().Set("Docker-Distribution-API-Version", "registry/2.0") | 342 | w.Header().Set("Docker-Distribution-API-Version", "registry/2.0") | ||
| 342 | 343 | ||||
| 343 | switch authType { | 344 | switch authType { | ||
| @@ -375,6 +376,6 @@ func sendUnauthorized(w http.ResponseWriter, authType domain.AuthType, host stri | |||||
| 375 | Str(zerowrap.FieldAdapter, "http"). | 376 | Str(zerowrap.FieldAdapter, "http"). | ||
| 376 | Str(zerowrap.FieldMethod, r.Method). | 377 | Str(zerowrap.FieldMethod, r.Method). | ||
| 377 | Str(zerowrap.FieldPath, r.URL.Path). | 378 | Str(zerowrap.FieldPath, r.URL.Path). | ||
| 378 | - | Str(zerowrap.FieldClientIP, r.RemoteAddr). | 379 | + | Str(zerowrap.FieldClientIP, GetClientIP(r, trustedNets)). |
| 379 | Msg("unauthorized registry access attempt") | 380 | Msg("unauthorized registry access attempt") | ||
| 380 | } | 381 | } | ||
M internal/adapters/in/http/middleware/auth_test.go +6 -6
| @@ -34,7 +34,7 @@ func TestRegistryAuthV2_LocalhostBypassRequiresInternalAuth(t *testing.T) { | |||
| 34 | 34 | Password: "secret", | |
| 35 | 35 | } | |
| 36 | 36 | ||
| 37 | - | handler := RegistryAuthV2(authSvc, internalAuth, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 37 | + | handler := RegistryAuthV2(authSvc, internalAuth, nil, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 38 | 38 | w.WriteHeader(http.StatusOK) | |
| 39 | 39 | })) | |
| 40 | 40 | ||
| @@ -65,7 +65,7 @@ func TestRegistryAuthV2_LocalhostWrongInternalAuthFallsBack(t *testing.T) { | |||
| 65 | 65 | Password: "secret", | |
| 66 | 66 | } | |
| 67 | 67 | ||
| 68 | - | handler := RegistryAuthV2(authSvc, internalAuth, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 68 | + | handler := RegistryAuthV2(authSvc, internalAuth, nil, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 69 | 69 | w.WriteHeader(http.StatusOK) | |
| 70 | 70 | })) | |
| 71 | 71 | ||
| @@ -97,7 +97,7 @@ func TestRegistryAuthV2_NonLocalhostIgnoresInternalAuth(t *testing.T) { | |||
| 97 | 97 | Password: "secret", | |
| 98 | 98 | } | |
| 99 | 99 | ||
| 100 | - | handler := RegistryAuthV2(authSvc, internalAuth, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 100 | + | handler := RegistryAuthV2(authSvc, internalAuth, nil, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 101 | 101 | w.WriteHeader(http.StatusOK) | |
| 102 | 102 | })) | |
| 103 | 103 | ||
| @@ -359,7 +359,7 @@ func TestRegistryAuthV2_RejectsLongLivedToken(t *testing.T) { | |||
| 359 | 359 | Password: "secret", | |
| 360 | 360 | } | |
| 361 | 361 | ||
| 362 | - | handler := RegistryAuthV2(authSvc, internalAuth, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 362 | + | handler := RegistryAuthV2(authSvc, internalAuth, nil, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 363 | 363 | w.WriteHeader(http.StatusOK) | |
| 364 | 364 | })) | |
| 365 | 365 | ||
| @@ -393,7 +393,7 @@ func TestRegistryAuthV2_AcceptsEphemeralToken(t *testing.T) { | |||
| 393 | 393 | } | |
| 394 | 394 | ||
| 395 | 395 | handlerCalled := false | |
| 396 | - | handler := RegistryAuthV2(authSvc, internalAuth, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 396 | + | handler := RegistryAuthV2(authSvc, internalAuth, nil, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 397 | 397 | handlerCalled = true | |
| 398 | 398 | w.WriteHeader(http.StatusOK) | |
| 399 | 399 | })) | |
| @@ -427,7 +427,7 @@ func TestRegistryAuthV2_RejectsLongLivedTokenAsPassword(t *testing.T) { | |||
| 427 | 427 | Password: "secret", | |
| 428 | 428 | } | |
| 429 | 429 | ||
| 430 | - | handler := RegistryAuthV2(authSvc, internalAuth, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 430 | + | handler := RegistryAuthV2(authSvc, internalAuth, nil, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 431 | 431 | w.WriteHeader(http.StatusOK) | |
| 432 | 432 | })) | |
| 433 | 433 | ||
| @@ -34,7 +34,7 @@ func TestRegistryAuthV2_LocalhostBypassRequiresInternalAuth(t *testing.T) { | |||||
| 34 | Password: "secret", | 34 | Password: "secret", | ||
| 35 | } | 35 | } | ||
| 36 | 36 | ||||
| 37 | - | handler := RegistryAuthV2(authSvc, internalAuth, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | 37 | + | handler := RegistryAuthV2(authSvc, internalAuth, nil, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { |
| 38 | w.WriteHeader(http.StatusOK) | 38 | w.WriteHeader(http.StatusOK) | ||
| 39 | })) | 39 | })) | ||
| 40 | 40 | ||||
| @@ -65,7 +65,7 @@ func TestRegistryAuthV2_LocalhostWrongInternalAuthFallsBack(t *testing.T) { | |||||
| 65 | Password: "secret", | 65 | Password: "secret", | ||
| 66 | } | 66 | } | ||
| 67 | 67 | ||||
| 68 | - | handler := RegistryAuthV2(authSvc, internalAuth, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | 68 | + | handler := RegistryAuthV2(authSvc, internalAuth, nil, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { |
| 69 | w.WriteHeader(http.StatusOK) | 69 | w.WriteHeader(http.StatusOK) | ||
| 70 | })) | 70 | })) | ||
| 71 | 71 | ||||
| @@ -97,7 +97,7 @@ func TestRegistryAuthV2_NonLocalhostIgnoresInternalAuth(t *testing.T) { | |||||
| 97 | Password: "secret", | 97 | Password: "secret", | ||
| 98 | } | 98 | } | ||
| 99 | 99 | ||||
| 100 | - | handler := RegistryAuthV2(authSvc, internalAuth, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | 100 | + | handler := RegistryAuthV2(authSvc, internalAuth, nil, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { |
| 101 | w.WriteHeader(http.StatusOK) | 101 | w.WriteHeader(http.StatusOK) | ||
| 102 | })) | 102 | })) | ||
| 103 | 103 | ||||
| @@ -359,7 +359,7 @@ func TestRegistryAuthV2_RejectsLongLivedToken(t *testing.T) { | |||||
| 359 | Password: "secret", | 359 | Password: "secret", | ||
| 360 | } | 360 | } | ||
| 361 | 361 | ||||
| 362 | - | handler := RegistryAuthV2(authSvc, internalAuth, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | 362 | + | handler := RegistryAuthV2(authSvc, internalAuth, nil, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { |
| 363 | w.WriteHeader(http.StatusOK) | 363 | w.WriteHeader(http.StatusOK) | ||
| 364 | })) | 364 | })) | ||
| 365 | 365 | ||||
| @@ -393,7 +393,7 @@ func TestRegistryAuthV2_AcceptsEphemeralToken(t *testing.T) { | |||||
| 393 | } | 393 | } | ||
| 394 | 394 | ||||
| 395 | handlerCalled := false | 395 | handlerCalled := false | ||
| 396 | - | handler := RegistryAuthV2(authSvc, internalAuth, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | 396 | + | handler := RegistryAuthV2(authSvc, internalAuth, nil, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { |
| 397 | handlerCalled = true | 397 | handlerCalled = true | ||
| 398 | w.WriteHeader(http.StatusOK) | 398 | w.WriteHeader(http.StatusOK) | ||
| 399 | })) | 399 | })) | ||
| @@ -427,7 +427,7 @@ func TestRegistryAuthV2_RejectsLongLivedTokenAsPassword(t *testing.T) { | |||||
| 427 | Password: "secret", | 427 | Password: "secret", | ||
| 428 | } | 428 | } | ||
| 429 | 429 | ||||
| 430 | - | handler := RegistryAuthV2(authSvc, internalAuth, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | 430 | + | handler := RegistryAuthV2(authSvc, internalAuth, nil, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { |
| 431 | w.WriteHeader(http.StatusOK) | 431 | w.WriteHeader(http.StatusOK) | ||
| 432 | })) | 432 | })) | ||
| 433 | 433 | ||||
M internal/adapters/in/http/middleware/logging.go +14 -7
| @@ -67,6 +67,19 @@ func (rw *ResponseWriter) Unwrap() http.ResponseWriter { | |||
| 67 | 67 | return rw.ResponseWriter | |
| 68 | 68 | } | |
| 69 | 69 | ||
| 70 | + | // ensureRequestID returns the X-Request-ID from the request if present, or generates | |
| 71 | + | // a new one. It sets X-Request-ID on both the request and response headers and returns | |
| 72 | + | // the ID along with the (potentially updated) request. | |
| 73 | + | func ensureRequestID(w http.ResponseWriter, r *http.Request) (string, *http.Request) { | |
| 74 | + | requestID := r.Header.Get("X-Request-ID") | |
| 75 | + | if requestID == "" { | |
| 76 | + | requestID = generateRequestID() | |
| 77 | + | r.Header.Set("X-Request-ID", requestID) | |
| 78 | + | } | |
| 79 | + | w.Header().Set("X-Request-ID", requestID) | |
| 80 | + | return requestID, r | |
| 81 | + | } | |
| 82 | + | ||
| 70 | 83 | // RequestLogger is a middleware that logs HTTP requests using zerowrap. | |
| 71 | 84 | // It also attaches the logger to the request context for downstream handlers. | |
| 72 | 85 | // The trustedNets parameter controls which proxy headers are trusted for IP extraction. | |
| @@ -81,13 +94,7 @@ func RequestLogger(log zerowrap.Logger, trustedNets ...[]*net.IPNet) func(http.H | |||
| 81 | 94 | start := time.Now() | |
| 82 | 95 | ||
| 83 | 96 | // Generate or reuse X-Request-ID for request tracing | |
| 84 | - | requestID := r.Header.Get("X-Request-ID") | |
| 85 | - | if requestID == "" { | |
| 86 | - | requestID = generateRequestID() | |
| 87 | - | // Set on the incoming request so downstream handlers can read it | |
| 88 | - | r.Header.Set("X-Request-ID", requestID) | |
| 89 | - | } | |
| 90 | - | w.Header().Set("X-Request-ID", requestID) | |
| 97 | + | requestID, r := ensureRequestID(w, r) | |
| 91 | 98 | ||
| 92 | 99 | // Wrap the response writer to capture status and bytes | |
| 93 | 100 | rw := NewResponseWriter(w) | |
| @@ -67,6 +67,19 @@ func (rw *ResponseWriter) Unwrap() http.ResponseWriter { | |||||
| 67 | return rw.ResponseWriter | 67 | return rw.ResponseWriter | ||
| 68 | } | 68 | } | ||
| 69 | 69 | ||||
| 70 | + | // ensureRequestID returns the X-Request-ID from the request if present, or generates | |||
| 71 | + | // a new one. It sets X-Request-ID on both the request and response headers and returns | |||
| 72 | + | // the ID along with the (potentially updated) request. | |||
| 73 | + | func ensureRequestID(w http.ResponseWriter, r *http.Request) (string, *http.Request) { | |||
| 74 | + | requestID := r.Header.Get("X-Request-ID") | |||
| 75 | + | if requestID == "" { | |||
| 76 | + | requestID = generateRequestID() | |||
| 77 | + | r.Header.Set("X-Request-ID", requestID) | |||
| 78 | + | } | |||
| 79 | + | w.Header().Set("X-Request-ID", requestID) | |||
| 80 | + | return requestID, r | |||
| 81 | + | } | |||
| 82 | + | ||||
| 70 | // RequestLogger is a middleware that logs HTTP requests using zerowrap. | 83 | // RequestLogger is a middleware that logs HTTP requests using zerowrap. | ||
| 71 | // It also attaches the logger to the request context for downstream handlers. | 84 | // It also attaches the logger to the request context for downstream handlers. | ||
| 72 | // The trustedNets parameter controls which proxy headers are trusted for IP extraction. | 85 | // The trustedNets parameter controls which proxy headers are trusted for IP extraction. | ||
| @@ -81,13 +94,7 @@ func RequestLogger(log zerowrap.Logger, trustedNets ...[]*net.IPNet) func(http.H | |||||
| 81 | start := time.Now() | 94 | start := time.Now() | ||
| 82 | 95 | ||||
| 83 | // Generate or reuse X-Request-ID for request tracing | 96 | // Generate or reuse X-Request-ID for request tracing | ||
| 84 | - | requestID := r.Header.Get("X-Request-ID") | 97 | + | requestID, r := ensureRequestID(w, r) |
| 85 | - | if requestID == "" { | |||
| 86 | - | requestID = generateRequestID() | |||
| 87 | - | // Set on the incoming request so downstream handlers can read it | |||
| 88 | - | r.Header.Set("X-Request-ID", requestID) | |||
| 89 | - | } | |||
| 90 | - | w.Header().Set("X-Request-ID", requestID) | |||
| 91 | 98 | ||||
| 92 | // Wrap the response writer to capture status and bytes | 99 | // Wrap the response writer to capture status and bytes | ||
| 93 | rw := NewResponseWriter(w) | 100 | rw := NewResponseWriter(w) | ||
M internal/adapters/in/http/proxy/forwarder_test.go +6 -6
| @@ -58,7 +58,7 @@ func TestForwardToTarget_H2CEndToEnd(t *testing.T) { | |||
| 58 | 58 | }, nil) | |
| 59 | 59 | proxySvc.EXPECT().TrackInFlight("grpc-1").Return(func() {}) | |
| 60 | 60 | ||
| 61 | - | handler := NewHandler(proxySvc, zerowrap.Default()) | |
| 61 | + | handler := NewHandler(proxySvc, nil, zerowrap.Default()) | |
| 62 | 62 | ||
| 63 | 63 | req := httptest.NewRequest(http.MethodGet, "http://grpc.example.com/test", nil) | |
| 64 | 64 | req.Host = "grpc.example.com" | |
| @@ -90,7 +90,7 @@ func TestForwardToTarget_HTTP1StillWorks(t *testing.T) { | |||
| 90 | 90 | }, nil) | |
| 91 | 91 | proxySvc.EXPECT().TrackInFlight("web-1").Return(func() {}) | |
| 92 | 92 | ||
| 93 | - | handler := NewHandler(proxySvc, zerowrap.Default()) | |
| 93 | + | handler := NewHandler(proxySvc, nil, zerowrap.Default()) | |
| 94 | 94 | ||
| 95 | 95 | req := httptest.NewRequest(http.MethodGet, "http://web.example.com/", nil) | |
| 96 | 96 | req.Host = "web.example.com" | |
| @@ -103,7 +103,7 @@ func TestForwardToTarget_HTTP1StillWorks(t *testing.T) { | |||
| 103 | 103 | } | |
| 104 | 104 | ||
| 105 | 105 | func TestTransportForTarget_SelectsCorrectTransport(t *testing.T) { | |
| 106 | - | handler := NewHandler(inmocks.NewMockProxyService(t), zerowrap.Default()) | |
| 106 | + | handler := NewHandler(inmocks.NewMockProxyService(t), nil, zerowrap.Default()) | |
| 107 | 107 | ||
| 108 | 108 | h2cTarget := &domain.ProxyTarget{Protocol: "h2c"} | |
| 109 | 109 | httpTarget := &domain.ProxyTarget{Protocol: ""} | |
| @@ -135,7 +135,7 @@ func TestForwardToTarget_OriginalHostPreserved(t *testing.T) { | |||
| 135 | 135 | }, nil) | |
| 136 | 136 | proxySvc.EXPECT().TrackInFlight("").Return(func() {}) | |
| 137 | 137 | ||
| 138 | - | handler := NewHandler(proxySvc, zerowrap.Default()) | |
| 138 | + | handler := NewHandler(proxySvc, nil, zerowrap.Default()) | |
| 139 | 139 | ||
| 140 | 140 | req := httptest.NewRequest(http.MethodGet, "http://external.example.com/", nil) | |
| 141 | 141 | req.Host = "external.example.com" | |
| @@ -159,7 +159,7 @@ func TestForwardToTarget_BackendDown_Returns503(t *testing.T) { | |||
| 159 | 159 | }, nil) | |
| 160 | 160 | proxySvc.EXPECT().TrackInFlight("c-down").Return(func() {}) | |
| 161 | 161 | ||
| 162 | - | handler := NewHandler(proxySvc, zerowrap.Default()) | |
| 162 | + | handler := NewHandler(proxySvc, nil, zerowrap.Default()) | |
| 163 | 163 | ||
| 164 | 164 | req := httptest.NewRequest(http.MethodGet, "http://down.example.com/", nil) | |
| 165 | 165 | req.Host = "down.example.com" | |
| @@ -189,7 +189,7 @@ func TestForwardToTarget_ProxyHeaderSet(t *testing.T) { | |||
| 189 | 189 | }, nil) | |
| 190 | 190 | proxySvc.EXPECT().TrackInFlight("c-1").Return(func() {}) | |
| 191 | 191 | ||
| 192 | - | handler := NewHandler(proxySvc, zerowrap.Default()) | |
| 192 | + | handler := NewHandler(proxySvc, nil, zerowrap.Default()) | |
| 193 | 193 | ||
| 194 | 194 | req := httptest.NewRequest(http.MethodGet, "http://app.example.com/", nil) | |
| 195 | 195 | req.Host = "app.example.com" | |
| @@ -58,7 +58,7 @@ func TestForwardToTarget_H2CEndToEnd(t *testing.T) { | |||||
| 58 | }, nil) | 58 | }, nil) | ||
| 59 | proxySvc.EXPECT().TrackInFlight("grpc-1").Return(func() {}) | 59 | proxySvc.EXPECT().TrackInFlight("grpc-1").Return(func() {}) | ||
| 60 | 60 | ||||
| 61 | - | handler := NewHandler(proxySvc, zerowrap.Default()) | 61 | + | handler := NewHandler(proxySvc, nil, zerowrap.Default()) |
| 62 | 62 | ||||
| 63 | req := httptest.NewRequest(http.MethodGet, "http://grpc.example.com/test", nil) | 63 | req := httptest.NewRequest(http.MethodGet, "http://grpc.example.com/test", nil) | ||
| 64 | req.Host = "grpc.example.com" | 64 | req.Host = "grpc.example.com" | ||
| @@ -90,7 +90,7 @@ func TestForwardToTarget_HTTP1StillWorks(t *testing.T) { | |||||
| 90 | }, nil) | 90 | }, nil) | ||
| 91 | proxySvc.EXPECT().TrackInFlight("web-1").Return(func() {}) | 91 | proxySvc.EXPECT().TrackInFlight("web-1").Return(func() {}) | ||
| 92 | 92 | ||||
| 93 | - | handler := NewHandler(proxySvc, zerowrap.Default()) | 93 | + | handler := NewHandler(proxySvc, nil, zerowrap.Default()) |
| 94 | 94 | ||||
| 95 | req := httptest.NewRequest(http.MethodGet, "http://web.example.com/", nil) | 95 | req := httptest.NewRequest(http.MethodGet, "http://web.example.com/", nil) | ||
| 96 | req.Host = "web.example.com" | 96 | req.Host = "web.example.com" | ||
| @@ -103,7 +103,7 @@ func TestForwardToTarget_HTTP1StillWorks(t *testing.T) { | |||||
| 103 | } | 103 | } | ||
| 104 | 104 | ||||
| 105 | func TestTransportForTarget_SelectsCorrectTransport(t *testing.T) { | 105 | func TestTransportForTarget_SelectsCorrectTransport(t *testing.T) { | ||
| 106 | - | handler := NewHandler(inmocks.NewMockProxyService(t), zerowrap.Default()) | 106 | + | handler := NewHandler(inmocks.NewMockProxyService(t), nil, zerowrap.Default()) |
| 107 | 107 | ||||
| 108 | h2cTarget := &domain.ProxyTarget{Protocol: "h2c"} | 108 | h2cTarget := &domain.ProxyTarget{Protocol: "h2c"} | ||
| 109 | httpTarget := &domain.ProxyTarget{Protocol: ""} | 109 | httpTarget := &domain.ProxyTarget{Protocol: ""} | ||
| @@ -135,7 +135,7 @@ func TestForwardToTarget_OriginalHostPreserved(t *testing.T) { | |||||
| 135 | }, nil) | 135 | }, nil) | ||
| 136 | proxySvc.EXPECT().TrackInFlight("").Return(func() {}) | 136 | proxySvc.EXPECT().TrackInFlight("").Return(func() {}) | ||
| 137 | 137 | ||||
| 138 | - | handler := NewHandler(proxySvc, zerowrap.Default()) | 138 | + | handler := NewHandler(proxySvc, nil, zerowrap.Default()) |
| 139 | 139 | ||||
| 140 | req := httptest.NewRequest(http.MethodGet, "http://external.example.com/", nil) | 140 | req := httptest.NewRequest(http.MethodGet, "http://external.example.com/", nil) | ||
| 141 | req.Host = "external.example.com" | 141 | req.Host = "external.example.com" | ||
| @@ -159,7 +159,7 @@ func TestForwardToTarget_BackendDown_Returns503(t *testing.T) { | |||||
| 159 | }, nil) | 159 | }, nil) | ||
| 160 | proxySvc.EXPECT().TrackInFlight("c-down").Return(func() {}) | 160 | proxySvc.EXPECT().TrackInFlight("c-down").Return(func() {}) | ||
| 161 | 161 | ||||
| 162 | - | handler := NewHandler(proxySvc, zerowrap.Default()) | 162 | + | handler := NewHandler(proxySvc, nil, zerowrap.Default()) |
| 163 | 163 | ||||
| 164 | req := httptest.NewRequest(http.MethodGet, "http://down.example.com/", nil) | 164 | req := httptest.NewRequest(http.MethodGet, "http://down.example.com/", nil) | ||
| 165 | req.Host = "down.example.com" | 165 | req.Host = "down.example.com" | ||
| @@ -189,7 +189,7 @@ func TestForwardToTarget_ProxyHeaderSet(t *testing.T) { | |||||
| 189 | }, nil) | 189 | }, nil) | ||
| 190 | proxySvc.EXPECT().TrackInFlight("c-1").Return(func() {}) | 190 | proxySvc.EXPECT().TrackInFlight("c-1").Return(func() {}) | ||
| 191 | 191 | ||||
| 192 | - | handler := NewHandler(proxySvc, zerowrap.Default()) | 192 | + | handler := NewHandler(proxySvc, nil, zerowrap.Default()) |
| 193 | 193 | ||||
| 194 | req := httptest.NewRequest(http.MethodGet, "http://app.example.com/", nil) | 194 | req := httptest.NewRequest(http.MethodGet, "http://app.example.com/", nil) | ||
| 195 | req.Host = "app.example.com" | 195 | req.Host = "app.example.com" | ||
M internal/adapters/in/http/proxy/handler.go +6 -2
| @@ -2,11 +2,13 @@ | |||
| 2 | 2 | package proxy | |
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | + | "net" | |
| 5 | 6 | "net/http" | |
| 6 | 7 | "sync/atomic" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/zerowrap" | |
| 9 | 10 | ||
| 11 | + | "github.com/bnema/gordon/internal/adapters/in/http/middleware" | |
| 10 | 12 | "github.com/bnema/gordon/internal/boundaries/in" | |
| 11 | 13 | ) | |
| 12 | 14 | ||
| @@ -17,6 +19,7 @@ import ( | |||
| 17 | 19 | type Handler struct { | |
| 18 | 20 | proxySvc in.ProxyService | |
| 19 | 21 | log zerowrap.Logger | |
| 22 | + | trustedNets []*net.IPNet | |
| 20 | 23 | appTransport http.RoundTripper | |
| 21 | 24 | h2cTransport http.RoundTripper | |
| 22 | 25 | registryTransport http.RoundTripper | |
| @@ -24,10 +27,11 @@ type Handler struct { | |||
| 24 | 27 | } | |
| 25 | 28 | ||
| 26 | 29 | // NewHandler creates a new proxy HTTP handler. | |
| 27 | - | func NewHandler(proxySvc in.ProxyService, log zerowrap.Logger) *Handler { | |
| 30 | + | func NewHandler(proxySvc in.ProxyService, trustedNets []*net.IPNet, log zerowrap.Logger) *Handler { | |
| 28 | 31 | return &Handler{ | |
| 29 | 32 | proxySvc: proxySvc, | |
| 30 | 33 | log: log, | |
| 34 | + | trustedNets: trustedNets, | |
| 31 | 35 | appTransport: newAppTransport(), | |
| 32 | 36 | h2cTransport: newH2CTransport(), | |
| 33 | 37 | registryTransport: newRegistryTransport(), | |
| @@ -61,7 +65,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||
| 61 | 65 | zerowrap.FieldMethod: r.Method, | |
| 62 | 66 | zerowrap.FieldPath: r.URL.Path, | |
| 63 | 67 | zerowrap.FieldHost: r.Host, | |
| 64 | - | zerowrap.FieldClientIP: r.RemoteAddr, | |
| 68 | + | zerowrap.FieldClientIP: middleware.GetClientIP(r, h.trustedNets), | |
| 65 | 69 | }) | |
| 66 | 70 | r = r.WithContext(ctx) | |
| 67 | 71 | log := zerowrap.FromCtx(ctx) | |
| @@ -2,11 +2,13 @@ | |||||
| 2 | package proxy | 2 | package proxy | ||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | + | "net" | |||
| 5 | "net/http" | 6 | "net/http" | ||
| 6 | "sync/atomic" | 7 | "sync/atomic" | ||
| 7 | 8 | ||||
| 8 | "github.com/bnema/zerowrap" | 9 | "github.com/bnema/zerowrap" | ||
| 9 | 10 | ||||
| 11 | + | "github.com/bnema/gordon/internal/adapters/in/http/middleware" | |||
| 10 | "github.com/bnema/gordon/internal/boundaries/in" | 12 | "github.com/bnema/gordon/internal/boundaries/in" | ||
| 11 | ) | 13 | ) | ||
| 12 | 14 | ||||
| @@ -17,6 +19,7 @@ import ( | |||||
| 17 | type Handler struct { | 19 | type Handler struct { | ||
| 18 | proxySvc in.ProxyService | 20 | proxySvc in.ProxyService | ||
| 19 | log zerowrap.Logger | 21 | log zerowrap.Logger | ||
| 22 | + | trustedNets []*net.IPNet | |||
| 20 | appTransport http.RoundTripper | 23 | appTransport http.RoundTripper | ||
| 21 | h2cTransport http.RoundTripper | 24 | h2cTransport http.RoundTripper | ||
| 22 | registryTransport http.RoundTripper | 25 | registryTransport http.RoundTripper | ||
| @@ -24,10 +27,11 @@ type Handler struct { | |||||
| 24 | } | 27 | } | ||
| 25 | 28 | ||||
| 26 | // NewHandler creates a new proxy HTTP handler. | 29 | // NewHandler creates a new proxy HTTP handler. | ||
| 27 | - | func NewHandler(proxySvc in.ProxyService, log zerowrap.Logger) *Handler { | 30 | + | func NewHandler(proxySvc in.ProxyService, trustedNets []*net.IPNet, log zerowrap.Logger) *Handler { |
| 28 | return &Handler{ | 31 | return &Handler{ | ||
| 29 | proxySvc: proxySvc, | 32 | proxySvc: proxySvc, | ||
| 30 | log: log, | 33 | log: log, | ||
| 34 | + | trustedNets: trustedNets, | |||
| 31 | appTransport: newAppTransport(), | 35 | appTransport: newAppTransport(), | ||
| 32 | h2cTransport: newH2CTransport(), | 36 | h2cTransport: newH2CTransport(), | ||
| 33 | registryTransport: newRegistryTransport(), | 37 | registryTransport: newRegistryTransport(), | ||
| @@ -61,7 +65,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||||
| 61 | zerowrap.FieldMethod: r.Method, | 65 | zerowrap.FieldMethod: r.Method, | ||
| 62 | zerowrap.FieldPath: r.URL.Path, | 66 | zerowrap.FieldPath: r.URL.Path, | ||
| 63 | zerowrap.FieldHost: r.Host, | 67 | zerowrap.FieldHost: r.Host, | ||
| 64 | - | zerowrap.FieldClientIP: r.RemoteAddr, | 68 | + | zerowrap.FieldClientIP: middleware.GetClientIP(r, h.trustedNets), |
| 65 | }) | 69 | }) | ||
| 66 | r = r.WithContext(ctx) | 70 | r = r.WithContext(ctx) | ||
| 67 | log := zerowrap.FromCtx(ctx) | 71 | log := zerowrap.FromCtx(ctx) | ||
M internal/adapters/in/http/proxy/handler_test.go +7 -7
| @@ -28,7 +28,7 @@ func TestHandler_ConcurrentConnectionLimit_503WhenFull(t *testing.T) { | |||
| 28 | 28 | MaxConcurrentConns: 1, | |
| 29 | 29 | }) | |
| 30 | 30 | ||
| 31 | - | handler := NewHandler(proxySvc, testLogger()) | |
| 31 | + | handler := NewHandler(proxySvc, nil, testLogger()) | |
| 32 | 32 | ||
| 33 | 33 | // Simulate an in-flight connection. | |
| 34 | 34 | handler.activeConns.Add(1) | |
| @@ -51,7 +51,7 @@ func TestHandler_RoutesToRegistry(t *testing.T) { | |||
| 51 | 51 | proxySvc.EXPECT().TrackRegistryRequest().Return() | |
| 52 | 52 | proxySvc.EXPECT().ReleaseRegistryRequest().Return() | |
| 53 | 53 | ||
| 54 | - | handler := NewHandler(proxySvc, testLogger()) | |
| 54 | + | handler := NewHandler(proxySvc, nil, testLogger()) | |
| 55 | 55 | ||
| 56 | 56 | req := httptest.NewRequest(http.MethodGet, "http://registry.example.com/v2/", nil) | |
| 57 | 57 | req.Host = "registry.example.com" | |
| @@ -68,7 +68,7 @@ func TestHandler_Returns404WhenNoTarget(t *testing.T) { | |||
| 68 | 68 | proxySvc.EXPECT().IsRegistryDomain("unknown.example.com").Return(false) | |
| 69 | 69 | proxySvc.EXPECT().GetTarget(mock.Anything, "unknown.example.com").Return(nil, domain.ErrNoTargetAvailable) | |
| 70 | 70 | ||
| 71 | - | handler := NewHandler(proxySvc, testLogger()) | |
| 71 | + | handler := NewHandler(proxySvc, nil, testLogger()) | |
| 72 | 72 | ||
| 73 | 73 | req := httptest.NewRequest(http.MethodGet, "http://unknown.example.com/", nil) | |
| 74 | 74 | req.Host = "unknown.example.com" | |
| @@ -101,7 +101,7 @@ func TestHandler_ProxiesToTarget(t *testing.T) { | |||
| 101 | 101 | proxySvc.EXPECT().GetTarget(mock.Anything, "app.example.com").Return(target, nil) | |
| 102 | 102 | proxySvc.EXPECT().TrackInFlight("c-1").Return(func() {}) | |
| 103 | 103 | ||
| 104 | - | handler := NewHandler(proxySvc, testLogger()) | |
| 104 | + | handler := NewHandler(proxySvc, nil, testLogger()) | |
| 105 | 105 | ||
| 106 | 106 | req := httptest.NewRequest(http.MethodGet, "http://app.example.com/", nil) | |
| 107 | 107 | req.Host = "app.example.com" | |
| @@ -135,7 +135,7 @@ func TestHandler_ReadsMaxBodySizeConfig(t *testing.T) { | |||
| 135 | 135 | }, nil) | |
| 136 | 136 | proxySvc.EXPECT().TrackInFlight("c-1").Return(func() {}) | |
| 137 | 137 | ||
| 138 | - | handler := NewHandler(proxySvc, testLogger()) | |
| 138 | + | handler := NewHandler(proxySvc, nil, testLogger()) | |
| 139 | 139 | ||
| 140 | 140 | // Send a body larger than the 1024-byte limit. | |
| 141 | 141 | largeBody := bytes.NewReader(make([]byte, 2048)) | |
| @@ -156,7 +156,7 @@ func TestHandler_UpdatedConfigReflected(t *testing.T) { | |||
| 156 | 156 | proxySvc.EXPECT().IsRegistryDomain("app.example.com").Return(false).Once() | |
| 157 | 157 | proxySvc.EXPECT().GetTarget(mock.Anything, "app.example.com").Return(nil, domain.ErrNoTargetAvailable).Once() | |
| 158 | 158 | ||
| 159 | - | handler := NewHandler(proxySvc, testLogger()) | |
| 159 | + | handler := NewHandler(proxySvc, nil, testLogger()) | |
| 160 | 160 | ||
| 161 | 161 | req := httptest.NewRequest(http.MethodGet, "http://app.example.com/", nil) | |
| 162 | 162 | req.Host = "app.example.com" | |
| @@ -199,7 +199,7 @@ func TestHandler_NoConcurrencyLimitWhenZero(t *testing.T) { | |||
| 199 | 199 | }, nil) | |
| 200 | 200 | proxySvc.EXPECT().TrackInFlight("c-1").Return(func() {}) | |
| 201 | 201 | ||
| 202 | - | handler := NewHandler(proxySvc, testLogger()) | |
| 202 | + | handler := NewHandler(proxySvc, nil, testLogger()) | |
| 203 | 203 | ||
| 204 | 204 | req := httptest.NewRequest(http.MethodGet, "http://app.example.com/", nil) | |
| 205 | 205 | req.Host = "app.example.com" | |
| @@ -28,7 +28,7 @@ func TestHandler_ConcurrentConnectionLimit_503WhenFull(t *testing.T) { | |||||
| 28 | MaxConcurrentConns: 1, | 28 | MaxConcurrentConns: 1, | ||
| 29 | }) | 29 | }) | ||
| 30 | 30 | ||||
| 31 | - | handler := NewHandler(proxySvc, testLogger()) | 31 | + | handler := NewHandler(proxySvc, nil, testLogger()) |
| 32 | 32 | ||||
| 33 | // Simulate an in-flight connection. | 33 | // Simulate an in-flight connection. | ||
| 34 | handler.activeConns.Add(1) | 34 | handler.activeConns.Add(1) | ||
| @@ -51,7 +51,7 @@ func TestHandler_RoutesToRegistry(t *testing.T) { | |||||
| 51 | proxySvc.EXPECT().TrackRegistryRequest().Return() | 51 | proxySvc.EXPECT().TrackRegistryRequest().Return() | ||
| 52 | proxySvc.EXPECT().ReleaseRegistryRequest().Return() | 52 | proxySvc.EXPECT().ReleaseRegistryRequest().Return() | ||
| 53 | 53 | ||||
| 54 | - | handler := NewHandler(proxySvc, testLogger()) | 54 | + | handler := NewHandler(proxySvc, nil, testLogger()) |
| 55 | 55 | ||||
| 56 | req := httptest.NewRequest(http.MethodGet, "http://registry.example.com/v2/", nil) | 56 | req := httptest.NewRequest(http.MethodGet, "http://registry.example.com/v2/", nil) | ||
| 57 | req.Host = "registry.example.com" | 57 | req.Host = "registry.example.com" | ||
| @@ -68,7 +68,7 @@ func TestHandler_Returns404WhenNoTarget(t *testing.T) { | |||||
| 68 | proxySvc.EXPECT().IsRegistryDomain("unknown.example.com").Return(false) | 68 | proxySvc.EXPECT().IsRegistryDomain("unknown.example.com").Return(false) | ||
| 69 | proxySvc.EXPECT().GetTarget(mock.Anything, "unknown.example.com").Return(nil, domain.ErrNoTargetAvailable) | 69 | proxySvc.EXPECT().GetTarget(mock.Anything, "unknown.example.com").Return(nil, domain.ErrNoTargetAvailable) | ||
| 70 | 70 | ||||
| 71 | - | handler := NewHandler(proxySvc, testLogger()) | 71 | + | handler := NewHandler(proxySvc, nil, testLogger()) |
| 72 | 72 | ||||
| 73 | req := httptest.NewRequest(http.MethodGet, "http://unknown.example.com/", nil) | 73 | req := httptest.NewRequest(http.MethodGet, "http://unknown.example.com/", nil) | ||
| 74 | req.Host = "unknown.example.com" | 74 | req.Host = "unknown.example.com" | ||
| @@ -101,7 +101,7 @@ func TestHandler_ProxiesToTarget(t *testing.T) { | |||||
| 101 | proxySvc.EXPECT().GetTarget(mock.Anything, "app.example.com").Return(target, nil) | 101 | proxySvc.EXPECT().GetTarget(mock.Anything, "app.example.com").Return(target, nil) | ||
| 102 | proxySvc.EXPECT().TrackInFlight("c-1").Return(func() {}) | 102 | proxySvc.EXPECT().TrackInFlight("c-1").Return(func() {}) | ||
| 103 | 103 | ||||
| 104 | - | handler := NewHandler(proxySvc, testLogger()) | 104 | + | handler := NewHandler(proxySvc, nil, testLogger()) |
| 105 | 105 | ||||
| 106 | req := httptest.NewRequest(http.MethodGet, "http://app.example.com/", nil) | 106 | req := httptest.NewRequest(http.MethodGet, "http://app.example.com/", nil) | ||
| 107 | req.Host = "app.example.com" | 107 | req.Host = "app.example.com" | ||
| @@ -135,7 +135,7 @@ func TestHandler_ReadsMaxBodySizeConfig(t *testing.T) { | |||||
| 135 | }, nil) | 135 | }, nil) | ||
| 136 | proxySvc.EXPECT().TrackInFlight("c-1").Return(func() {}) | 136 | proxySvc.EXPECT().TrackInFlight("c-1").Return(func() {}) | ||
| 137 | 137 | ||||
| 138 | - | handler := NewHandler(proxySvc, testLogger()) | 138 | + | handler := NewHandler(proxySvc, nil, testLogger()) |
| 139 | 139 | ||||
| 140 | // Send a body larger than the 1024-byte limit. | 140 | // Send a body larger than the 1024-byte limit. | ||
| 141 | largeBody := bytes.NewReader(make([]byte, 2048)) | 141 | largeBody := bytes.NewReader(make([]byte, 2048)) | ||
| @@ -156,7 +156,7 @@ func TestHandler_UpdatedConfigReflected(t *testing.T) { | |||||
| 156 | proxySvc.EXPECT().IsRegistryDomain("app.example.com").Return(false).Once() | 156 | proxySvc.EXPECT().IsRegistryDomain("app.example.com").Return(false).Once() | ||
| 157 | proxySvc.EXPECT().GetTarget(mock.Anything, "app.example.com").Return(nil, domain.ErrNoTargetAvailable).Once() | 157 | proxySvc.EXPECT().GetTarget(mock.Anything, "app.example.com").Return(nil, domain.ErrNoTargetAvailable).Once() | ||
| 158 | 158 | ||||
| 159 | - | handler := NewHandler(proxySvc, testLogger()) | 159 | + | handler := NewHandler(proxySvc, nil, testLogger()) |
| 160 | 160 | ||||
| 161 | req := httptest.NewRequest(http.MethodGet, "http://app.example.com/", nil) | 161 | req := httptest.NewRequest(http.MethodGet, "http://app.example.com/", nil) | ||
| 162 | req.Host = "app.example.com" | 162 | req.Host = "app.example.com" | ||
| @@ -199,7 +199,7 @@ func TestHandler_NoConcurrencyLimitWhenZero(t *testing.T) { | |||||
| 199 | }, nil) | 199 | }, nil) | ||
| 200 | proxySvc.EXPECT().TrackInFlight("c-1").Return(func() {}) | 200 | proxySvc.EXPECT().TrackInFlight("c-1").Return(func() {}) | ||
| 201 | 201 | ||||
| 202 | - | handler := NewHandler(proxySvc, testLogger()) | 202 | + | handler := NewHandler(proxySvc, nil, testLogger()) |
| 203 | 203 | ||||
| 204 | req := httptest.NewRequest(http.MethodGet, "http://app.example.com/", nil) | 204 | req := httptest.NewRequest(http.MethodGet, "http://app.example.com/", nil) | ||
| 205 | req.Host = "app.example.com" | 205 | req.Host = "app.example.com" | ||
A internal/adapters/out/accesslog/formatter.go +141 -0
| @@ -0,0 +1,141 @@ | |||
| 1 | + | package accesslog | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "fmt" | |
| 6 | + | "strings" | |
| 7 | + | ||
| 8 | + | out "github.com/bnema/gordon/internal/boundaries/out" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // jsonEntry is the stable JSON schema for access log entries. | |
| 12 | + | // Field order is controlled by the struct definition. | |
| 13 | + | type jsonEntry struct { | |
| 14 | + | Time string `json:"time"` | |
| 15 | + | ClientIP string `json:"client_ip"` | |
| 16 | + | Method string `json:"method"` | |
| 17 | + | Host string `json:"host"` | |
| 18 | + | Path string `json:"path"` | |
| 19 | + | Query string `json:"query"` | |
| 20 | + | Status int `json:"status"` | |
| 21 | + | BytesSent int `json:"bytes_sent"` | |
| 22 | + | DurationMS float64 `json:"duration_ms"` | |
| 23 | + | UserAgent string `json:"user_agent"` | |
| 24 | + | Referer string `json:"referer"` | |
| 25 | + | RequestID string `json:"request_id"` | |
| 26 | + | Proto string `json:"proto"` | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // clfTimestamp is the standard Common Log Format timestamp layout. | |
| 30 | + | const clfTimestamp = "02/Jan/2006:15:04:05 -0700" | |
| 31 | + | ||
| 32 | + | // jsonTimestamp is the access log JSON timestamp layout: RFC3339 with | |
| 33 | + | // millisecond precision in UTC. | |
| 34 | + | const jsonTimestamp = "2006-01-02T15:04:05.000Z07:00" | |
| 35 | + | ||
| 36 | + | // formatJSON serializes entry as a single-line JSON object. | |
| 37 | + | // Timestamps are RFC3339 with millisecond precision in UTC. | |
| 38 | + | // time.Time's default JSON marshaling is NOT used to ensure stable precision. | |
| 39 | + | func formatJSON(e out.AccessLogEntry) (string, error) { | |
| 40 | + | je := jsonEntry{ | |
| 41 | + | Time: e.Time.UTC().Format(jsonTimestamp), | |
| 42 | + | ClientIP: e.ClientIP, | |
| 43 | + | Method: e.Method, | |
| 44 | + | Host: e.Host, | |
| 45 | + | Path: e.Path, | |
| 46 | + | Query: e.Query, | |
| 47 | + | Status: e.Status, | |
| 48 | + | BytesSent: e.BytesSent, | |
| 49 | + | DurationMS: e.DurationMS, | |
| 50 | + | UserAgent: e.UserAgent, | |
| 51 | + | Referer: e.Referer, | |
| 52 | + | RequestID: e.RequestID, | |
| 53 | + | Proto: e.Proto, | |
| 54 | + | } | |
| 55 | + | b, err := json.Marshal(je) | |
| 56 | + | if err != nil { | |
| 57 | + | return "", fmt.Errorf("formatJSON: %w", err) | |
| 58 | + | } | |
| 59 | + | return string(b), nil | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // formatCLF serializes entry in Common Log Format (NCSA). | |
| 63 | + | // | |
| 64 | + | // <ip> - - [timestamp] "METHOD target PROTO" status bytes | |
| 65 | + | func formatCLF(e out.AccessLogEntry) (string, error) { | |
| 66 | + | ts := e.Time.UTC().Format(clfTimestamp) | |
| 67 | + | target := buildRequestTarget(e.Path, e.Query) | |
| 68 | + | return fmt.Sprintf(`%s - - [%s] "%s %s %s" %d %d`, | |
| 69 | + | e.ClientIP, | |
| 70 | + | ts, | |
| 71 | + | escapeQuoted(e.Method), | |
| 72 | + | escapeQuoted(target), | |
| 73 | + | escapeQuoted(e.Proto), | |
| 74 | + | e.Status, | |
| 75 | + | e.BytesSent, | |
| 76 | + | ), nil | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // formatCombined serializes entry in Combined Log Format (CLF + referer + UA). | |
| 80 | + | // | |
| 81 | + | // <ip> - - [timestamp] "METHOD target PROTO" status bytes "referer" "ua" | |
| 82 | + | func formatCombined(e out.AccessLogEntry) (string, error) { | |
| 83 | + | ts := e.Time.UTC().Format(clfTimestamp) | |
| 84 | + | target := buildRequestTarget(e.Path, e.Query) | |
| 85 | + | referer := e.Referer | |
| 86 | + | if referer == "" { | |
| 87 | + | referer = "-" | |
| 88 | + | } | |
| 89 | + | ua := e.UserAgent | |
| 90 | + | if ua == "" { | |
| 91 | + | ua = "-" | |
| 92 | + | } | |
| 93 | + | return fmt.Sprintf(`%s - - [%s] "%s %s %s" %d %d "%s" "%s"`, | |
| 94 | + | e.ClientIP, | |
| 95 | + | ts, | |
| 96 | + | escapeQuoted(e.Method), | |
| 97 | + | escapeQuoted(target), | |
| 98 | + | escapeQuoted(e.Proto), | |
| 99 | + | e.Status, | |
| 100 | + | e.BytesSent, | |
| 101 | + | escapeQuoted(referer), | |
| 102 | + | escapeQuoted(ua), | |
| 103 | + | ), nil | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | // buildRequestTarget builds the request target portion of a CLF/Combined line. | |
| 107 | + | // When query is empty only the path is used; otherwise "path?query". | |
| 108 | + | func buildRequestTarget(path, query string) string { | |
| 109 | + | if query == "" { | |
| 110 | + | return path | |
| 111 | + | } | |
| 112 | + | return path + "?" + query | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // escapeQuoted escapes backslashes, double-quotes, and control characters inside | |
| 116 | + | // a value that will be enclosed in double-quotes in the log line. | |
| 117 | + | // All ASCII control characters (0x00-0x1F) are escaped to prevent log-injection | |
| 118 | + | // via user-controlled fields such as User-Agent or Referer. | |
| 119 | + | func escapeQuoted(s string) string { | |
| 120 | + | var b strings.Builder | |
| 121 | + | b.Grow(len(s)) | |
| 122 | + | for _, r := range s { | |
| 123 | + | switch { | |
| 124 | + | case r == '\\': | |
| 125 | + | b.WriteString(`\\`) | |
| 126 | + | case r == '"': | |
| 127 | + | b.WriteString(`\"`) | |
| 128 | + | case r == '\n': | |
| 129 | + | b.WriteString(`\n`) | |
| 130 | + | case r == '\r': | |
| 131 | + | b.WriteString(`\r`) | |
| 132 | + | case r == '\t': | |
| 133 | + | b.WriteString(`\t`) | |
| 134 | + | case r < 0x20: | |
| 135 | + | fmt.Fprintf(&b, `\x%02x`, r) | |
| 136 | + | default: | |
| 137 | + | b.WriteRune(r) | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | return b.String() | |
| 141 | + | } | |
| @@ -0,0 +1,141 @@ | |||||
| 1 | + | package accesslog | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/json" | |||
| 5 | + | "fmt" | |||
| 6 | + | "strings" | |||
| 7 | + | ||||
| 8 | + | out "github.com/bnema/gordon/internal/boundaries/out" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // jsonEntry is the stable JSON schema for access log entries. | |||
| 12 | + | // Field order is controlled by the struct definition. | |||
| 13 | + | type jsonEntry struct { | |||
| 14 | + | Time string `json:"time"` | |||
| 15 | + | ClientIP string `json:"client_ip"` | |||
| 16 | + | Method string `json:"method"` | |||
| 17 | + | Host string `json:"host"` | |||
| 18 | + | Path string `json:"path"` | |||
| 19 | + | Query string `json:"query"` | |||
| 20 | + | Status int `json:"status"` | |||
| 21 | + | BytesSent int `json:"bytes_sent"` | |||
| 22 | + | DurationMS float64 `json:"duration_ms"` | |||
| 23 | + | UserAgent string `json:"user_agent"` | |||
| 24 | + | Referer string `json:"referer"` | |||
| 25 | + | RequestID string `json:"request_id"` | |||
| 26 | + | Proto string `json:"proto"` | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | // clfTimestamp is the standard Common Log Format timestamp layout. | |||
| 30 | + | const clfTimestamp = "02/Jan/2006:15:04:05 -0700" | |||
| 31 | + | ||||
| 32 | + | // jsonTimestamp is the access log JSON timestamp layout: RFC3339 with | |||
| 33 | + | // millisecond precision in UTC. | |||
| 34 | + | const jsonTimestamp = "2006-01-02T15:04:05.000Z07:00" | |||
| 35 | + | ||||
| 36 | + | // formatJSON serializes entry as a single-line JSON object. | |||
| 37 | + | // Timestamps are RFC3339 with millisecond precision in UTC. | |||
| 38 | + | // time.Time's default JSON marshaling is NOT used to ensure stable precision. | |||
| 39 | + | func formatJSON(e out.AccessLogEntry) (string, error) { | |||
| 40 | + | je := jsonEntry{ | |||
| 41 | + | Time: e.Time.UTC().Format(jsonTimestamp), | |||
| 42 | + | ClientIP: e.ClientIP, | |||
| 43 | + | Method: e.Method, | |||
| 44 | + | Host: e.Host, | |||
| 45 | + | Path: e.Path, | |||
| 46 | + | Query: e.Query, | |||
| 47 | + | Status: e.Status, | |||
| 48 | + | BytesSent: e.BytesSent, | |||
| 49 | + | DurationMS: e.DurationMS, | |||
| 50 | + | UserAgent: e.UserAgent, | |||
| 51 | + | Referer: e.Referer, | |||
| 52 | + | RequestID: e.RequestID, | |||
| 53 | + | Proto: e.Proto, | |||
| 54 | + | } | |||
| 55 | + | b, err := json.Marshal(je) | |||
| 56 | + | if err != nil { | |||
| 57 | + | return "", fmt.Errorf("formatJSON: %w", err) | |||
| 58 | + | } | |||
| 59 | + | return string(b), nil | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | // formatCLF serializes entry in Common Log Format (NCSA). | |||
| 63 | + | // | |||
| 64 | + | // <ip> - - [timestamp] "METHOD target PROTO" status bytes | |||
| 65 | + | func formatCLF(e out.AccessLogEntry) (string, error) { | |||
| 66 | + | ts := e.Time.UTC().Format(clfTimestamp) | |||
| 67 | + | target := buildRequestTarget(e.Path, e.Query) | |||
| 68 | + | return fmt.Sprintf(`%s - - [%s] "%s %s %s" %d %d`, | |||
| 69 | + | e.ClientIP, | |||
| 70 | + | ts, | |||
| 71 | + | escapeQuoted(e.Method), | |||
| 72 | + | escapeQuoted(target), | |||
| 73 | + | escapeQuoted(e.Proto), | |||
| 74 | + | e.Status, | |||
| 75 | + | e.BytesSent, | |||
| 76 | + | ), nil | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | // formatCombined serializes entry in Combined Log Format (CLF + referer + UA). | |||
| 80 | + | // | |||
| 81 | + | // <ip> - - [timestamp] "METHOD target PROTO" status bytes "referer" "ua" | |||
| 82 | + | func formatCombined(e out.AccessLogEntry) (string, error) { | |||
| 83 | + | ts := e.Time.UTC().Format(clfTimestamp) | |||
| 84 | + | target := buildRequestTarget(e.Path, e.Query) | |||
| 85 | + | referer := e.Referer | |||
| 86 | + | if referer == "" { | |||
| 87 | + | referer = "-" | |||
| 88 | + | } | |||
| 89 | + | ua := e.UserAgent | |||
| 90 | + | if ua == "" { | |||
| 91 | + | ua = "-" | |||
| 92 | + | } | |||
| 93 | + | return fmt.Sprintf(`%s - - [%s] "%s %s %s" %d %d "%s" "%s"`, | |||
| 94 | + | e.ClientIP, | |||
| 95 | + | ts, | |||
| 96 | + | escapeQuoted(e.Method), | |||
| 97 | + | escapeQuoted(target), | |||
| 98 | + | escapeQuoted(e.Proto), | |||
| 99 | + | e.Status, | |||
| 100 | + | e.BytesSent, | |||
| 101 | + | escapeQuoted(referer), | |||
| 102 | + | escapeQuoted(ua), | |||
| 103 | + | ), nil | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | // buildRequestTarget builds the request target portion of a CLF/Combined line. | |||
| 107 | + | // When query is empty only the path is used; otherwise "path?query". | |||
| 108 | + | func buildRequestTarget(path, query string) string { | |||
| 109 | + | if query == "" { | |||
| 110 | + | return path | |||
| 111 | + | } | |||
| 112 | + | return path + "?" + query | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | // escapeQuoted escapes backslashes, double-quotes, and control characters inside | |||
| 116 | + | // a value that will be enclosed in double-quotes in the log line. | |||
| 117 | + | // All ASCII control characters (0x00-0x1F) are escaped to prevent log-injection | |||
| 118 | + | // via user-controlled fields such as User-Agent or Referer. | |||
| 119 | + | func escapeQuoted(s string) string { | |||
| 120 | + | var b strings.Builder | |||
| 121 | + | b.Grow(len(s)) | |||
| 122 | + | for _, r := range s { | |||
| 123 | + | switch { | |||
| 124 | + | case r == '\\': | |||
| 125 | + | b.WriteString(`\\`) | |||
| 126 | + | case r == '"': | |||
| 127 | + | b.WriteString(`\"`) | |||
| 128 | + | case r == '\n': | |||
| 129 | + | b.WriteString(`\n`) | |||
| 130 | + | case r == '\r': | |||
| 131 | + | b.WriteString(`\r`) | |||
| 132 | + | case r == '\t': | |||
| 133 | + | b.WriteString(`\t`) | |||
| 134 | + | case r < 0x20: | |||
| 135 | + | fmt.Fprintf(&b, `\x%02x`, r) | |||
| 136 | + | default: | |||
| 137 | + | b.WriteRune(r) | |||
| 138 | + | } | |||
| 139 | + | } | |||
| 140 | + | return b.String() | |||
| 141 | + | } | |||
A internal/adapters/out/accesslog/formatter_test.go +214 -0
| @@ -0,0 +1,214 @@ | |||
| 1 | + | package accesslog | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | out "github.com/bnema/gordon/internal/boundaries/out" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | var testTime = time.Date(2026, 4, 3, 14, 20, 54, 167_000_000, time.UTC) | |
| 16 | + | ||
| 17 | + | var baseEntry = out.AccessLogEntry{ | |
| 18 | + | Time: testTime, | |
| 19 | + | ClientIP: "135.181.213.219", | |
| 20 | + | Method: "GET", | |
| 21 | + | Host: "traefik.example.dev", | |
| 22 | + | Path: "/robots.txt", | |
| 23 | + | Query: "", | |
| 24 | + | Status: 404, | |
| 25 | + | BytesSent: 19, | |
| 26 | + | DurationMS: 0.167, | |
| 27 | + | UserAgent: "MJ12bot/v1.4.8", | |
| 28 | + | Referer: "", | |
| 29 | + | RequestID: "95126efcb65d4df81f7ae633e2a712cd", | |
| 30 | + | Proto: "HTTP/1.1", | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // --- JSON formatter --- | |
| 34 | + | ||
| 35 | + | func TestFormatJSON_AllFields(t *testing.T) { | |
| 36 | + | line, err := formatJSON(baseEntry) | |
| 37 | + | require.NoError(t, err) | |
| 38 | + | ||
| 39 | + | var got map[string]any | |
| 40 | + | require.NoError(t, json.Unmarshal([]byte(line), &got)) | |
| 41 | + | ||
| 42 | + | assert.Equal(t, "2026-04-03T14:20:54.167Z", got["time"]) | |
| 43 | + | assert.Equal(t, "135.181.213.219", got["client_ip"]) | |
| 44 | + | assert.Equal(t, "GET", got["method"]) | |
| 45 | + | assert.Equal(t, "traefik.example.dev", got["host"]) | |
| 46 | + | assert.Equal(t, "/robots.txt", got["path"]) | |
| 47 | + | assert.Equal(t, "", got["query"]) | |
| 48 | + | assert.InDelta(t, 404, got["status"], 0) | |
| 49 | + | assert.InDelta(t, 19, got["bytes_sent"], 0) | |
| 50 | + | assert.InDelta(t, 0.167, got["duration_ms"], 0.0001) | |
| 51 | + | assert.Equal(t, "MJ12bot/v1.4.8", got["user_agent"]) | |
| 52 | + | assert.Equal(t, "", got["referer"]) | |
| 53 | + | assert.Equal(t, "95126efcb65d4df81f7ae633e2a712cd", got["request_id"]) | |
| 54 | + | assert.Equal(t, "HTTP/1.1", got["proto"]) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func TestFormatJSON_OneLinePerEntry(t *testing.T) { | |
| 58 | + | line, err := formatJSON(baseEntry) | |
| 59 | + | require.NoError(t, err) | |
| 60 | + | assert.False(t, strings.Contains(line, "\n"), "JSON line must not contain newline") | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func TestFormatJSON_TimestampMillisecondPrecision(t *testing.T) { | |
| 64 | + | // Ensure we get .167Z and not RFC3339Nano (which would be .167000000Z) | |
| 65 | + | line, err := formatJSON(baseEntry) | |
| 66 | + | require.NoError(t, err) | |
| 67 | + | assert.Contains(t, line, `"2026-04-03T14:20:54.167Z"`) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func TestFormatJSON_ClientIPNoPort(t *testing.T) { | |
| 71 | + | e := baseEntry | |
| 72 | + | e.ClientIP = "10.0.0.1" // ensure no port | |
| 73 | + | line, err := formatJSON(e) | |
| 74 | + | require.NoError(t, err) | |
| 75 | + | assert.Contains(t, line, `"client_ip":"10.0.0.1"`) | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func TestFormatJSON_EmptyOptionalFields(t *testing.T) { | |
| 79 | + | e := baseEntry | |
| 80 | + | e.Query = "" | |
| 81 | + | e.Referer = "" | |
| 82 | + | e.UserAgent = "" | |
| 83 | + | line, err := formatJSON(e) | |
| 84 | + | require.NoError(t, err) | |
| 85 | + | ||
| 86 | + | var got map[string]any | |
| 87 | + | require.NoError(t, json.Unmarshal([]byte(line), &got)) | |
| 88 | + | assert.Equal(t, "", got["query"]) | |
| 89 | + | assert.Equal(t, "", got["referer"]) | |
| 90 | + | assert.Equal(t, "", got["user_agent"]) | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func TestFormatJSON_WithQuery(t *testing.T) { | |
| 94 | + | e := baseEntry | |
| 95 | + | e.Query = "foo=bar&baz=qux" | |
| 96 | + | line, err := formatJSON(e) | |
| 97 | + | require.NoError(t, err) | |
| 98 | + | assert.Contains(t, line, `"query":"foo=bar\u0026baz=qux"`) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // --- CLF formatter --- | |
| 102 | + | ||
| 103 | + | func TestFormatCLF_BasicLine(t *testing.T) { | |
| 104 | + | line, err := formatCLF(baseEntry) | |
| 105 | + | require.NoError(t, err) | |
| 106 | + | assert.Equal(t, `135.181.213.219 - - [03/Apr/2026:14:20:54 +0000] "GET /robots.txt HTTP/1.1" 404 19`, line) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func TestFormatCLF_WithQuery(t *testing.T) { | |
| 110 | + | e := baseEntry | |
| 111 | + | e.Query = "page=2" | |
| 112 | + | line, err := formatCLF(e) | |
| 113 | + | require.NoError(t, err) | |
| 114 | + | assert.Contains(t, line, `"GET /robots.txt?page=2 HTTP/1.1"`) | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | func TestFormatCLF_NoRefererOrUA(t *testing.T) { | |
| 118 | + | // CLF format should not include referer or UA | |
| 119 | + | line, err := formatCLF(baseEntry) | |
| 120 | + | require.NoError(t, err) | |
| 121 | + | assert.NotContains(t, line, "MJ12bot") | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func TestFormatCLF_EscapesQuotesInTarget(t *testing.T) { | |
| 125 | + | e := baseEntry | |
| 126 | + | e.Path = `/path/"quoted"` | |
| 127 | + | line, err := formatCLF(e) | |
| 128 | + | require.NoError(t, err) | |
| 129 | + | // Forward slashes do not need escaping; only backslashes and double-quotes do. | |
| 130 | + | assert.Contains(t, line, `/path/\"quoted\"`) | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | // --- Combined formatter --- | |
| 134 | + | ||
| 135 | + | func TestFormatCombined_BasicLine(t *testing.T) { | |
| 136 | + | line, err := formatCombined(baseEntry) | |
| 137 | + | require.NoError(t, err) | |
| 138 | + | assert.Equal(t, | |
| 139 | + | `135.181.213.219 - - [03/Apr/2026:14:20:54 +0000] "GET /robots.txt HTTP/1.1" 404 19 "-" "MJ12bot/v1.4.8"`, | |
| 140 | + | line, | |
| 141 | + | ) | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func TestFormatCombined_EmptyRefererIsDash(t *testing.T) { | |
| 145 | + | e := baseEntry | |
| 146 | + | e.Referer = "" | |
| 147 | + | line, err := formatCombined(e) | |
| 148 | + | require.NoError(t, err) | |
| 149 | + | assert.Contains(t, line, `"-"`) | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | func TestFormatCombined_EmptyUAIsDash(t *testing.T) { | |
| 153 | + | e := baseEntry | |
| 154 | + | e.UserAgent = "" | |
| 155 | + | line, err := formatCombined(e) | |
| 156 | + | require.NoError(t, err) | |
| 157 | + | // last quoted field should be "-" | |
| 158 | + | assert.True(t, strings.HasSuffix(line, `"-"`)) | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | func TestFormatCombined_WithReferer(t *testing.T) { | |
| 162 | + | e := baseEntry | |
| 163 | + | e.Referer = "https://example.com/" | |
| 164 | + | line, err := formatCombined(e) | |
| 165 | + | require.NoError(t, err) | |
| 166 | + | assert.Contains(t, line, `"https://example.com/"`) | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | func TestFormatCombined_EscapesQuotesInUA(t *testing.T) { | |
| 170 | + | e := baseEntry | |
| 171 | + | e.UserAgent = `bot/"evil"` | |
| 172 | + | line, err := formatCombined(e) | |
| 173 | + | require.NoError(t, err) | |
| 174 | + | // Forward slashes don't need escaping; only backslashes and double-quotes do. | |
| 175 | + | assert.Contains(t, line, `"bot/\"evil\""`) | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | // --- buildRequestTarget --- | |
| 179 | + | ||
| 180 | + | func TestBuildRequestTarget_NoQuery(t *testing.T) { | |
| 181 | + | assert.Equal(t, "/foo", buildRequestTarget("/foo", "")) | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | func TestBuildRequestTarget_WithQuery(t *testing.T) { | |
| 185 | + | assert.Equal(t, "/foo?bar=1", buildRequestTarget("/foo", "bar=1")) | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | // --- escapeQuoted --- | |
| 189 | + | ||
| 190 | + | func TestEscapeQuoted(t *testing.T) { | |
| 191 | + | assert.Equal(t, `hello`, escapeQuoted(`hello`)) | |
| 192 | + | assert.Equal(t, `say \"hi\"`, escapeQuoted(`say "hi"`)) | |
| 193 | + | assert.Equal(t, `back\\slash`, escapeQuoted(`back\slash`)) | |
| 194 | + | assert.Equal(t, `\"\\\"`, escapeQuoted(`"\"`)) | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func TestEscapeQuoted_ControlCharacters(t *testing.T) { | |
| 198 | + | // Named control characters with dedicated escapes | |
| 199 | + | assert.Equal(t, `line\nbreak`, escapeQuoted("line\nbreak")) | |
| 200 | + | assert.Equal(t, `cr\rreturn`, escapeQuoted("cr\rreturn")) | |
| 201 | + | assert.Equal(t, `a\ttab`, escapeQuoted("a\ttab")) | |
| 202 | + | ||
| 203 | + | // NUL, BEL, ESC — hex-escaped | |
| 204 | + | assert.Equal(t, `\x00`, escapeQuoted("\x00")) | |
| 205 | + | assert.Equal(t, `\x07`, escapeQuoted("\x07")) | |
| 206 | + | assert.Equal(t, `\x1b`, escapeQuoted("\x1b")) | |
| 207 | + | ||
| 208 | + | // All control characters 0x00-0x1F should be escaped (none pass through raw) | |
| 209 | + | for b := byte(0); b < 0x20; b++ { | |
| 210 | + | result := escapeQuoted(string([]byte{b})) | |
| 211 | + | assert.NotEqual(t, string([]byte{b}), result, | |
| 212 | + | "control byte 0x%02x should be escaped", b) | |
| 213 | + | } | |
| 214 | + | } | |
| @@ -0,0 +1,214 @@ | |||||
| 1 | + | package accesslog | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/json" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/stretchr/testify/assert" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ||||
| 12 | + | out "github.com/bnema/gordon/internal/boundaries/out" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | var testTime = time.Date(2026, 4, 3, 14, 20, 54, 167_000_000, time.UTC) | |||
| 16 | + | ||||
| 17 | + | var baseEntry = out.AccessLogEntry{ | |||
| 18 | + | Time: testTime, | |||
| 19 | + | ClientIP: "135.181.213.219", | |||
| 20 | + | Method: "GET", | |||
| 21 | + | Host: "traefik.example.dev", | |||
| 22 | + | Path: "/robots.txt", | |||
| 23 | + | Query: "", | |||
| 24 | + | Status: 404, | |||
| 25 | + | BytesSent: 19, | |||
| 26 | + | DurationMS: 0.167, | |||
| 27 | + | UserAgent: "MJ12bot/v1.4.8", | |||
| 28 | + | Referer: "", | |||
| 29 | + | RequestID: "95126efcb65d4df81f7ae633e2a712cd", | |||
| 30 | + | Proto: "HTTP/1.1", | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | // --- JSON formatter --- | |||
| 34 | + | ||||
| 35 | + | func TestFormatJSON_AllFields(t *testing.T) { | |||
| 36 | + | line, err := formatJSON(baseEntry) | |||
| 37 | + | require.NoError(t, err) | |||
| 38 | + | ||||
| 39 | + | var got map[string]any | |||
| 40 | + | require.NoError(t, json.Unmarshal([]byte(line), &got)) | |||
| 41 | + | ||||
| 42 | + | assert.Equal(t, "2026-04-03T14:20:54.167Z", got["time"]) | |||
| 43 | + | assert.Equal(t, "135.181.213.219", got["client_ip"]) | |||
| 44 | + | assert.Equal(t, "GET", got["method"]) | |||
| 45 | + | assert.Equal(t, "traefik.example.dev", got["host"]) | |||
| 46 | + | assert.Equal(t, "/robots.txt", got["path"]) | |||
| 47 | + | assert.Equal(t, "", got["query"]) | |||
| 48 | + | assert.InDelta(t, 404, got["status"], 0) | |||
| 49 | + | assert.InDelta(t, 19, got["bytes_sent"], 0) | |||
| 50 | + | assert.InDelta(t, 0.167, got["duration_ms"], 0.0001) | |||
| 51 | + | assert.Equal(t, "MJ12bot/v1.4.8", got["user_agent"]) | |||
| 52 | + | assert.Equal(t, "", got["referer"]) | |||
| 53 | + | assert.Equal(t, "95126efcb65d4df81f7ae633e2a712cd", got["request_id"]) | |||
| 54 | + | assert.Equal(t, "HTTP/1.1", got["proto"]) | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func TestFormatJSON_OneLinePerEntry(t *testing.T) { | |||
| 58 | + | line, err := formatJSON(baseEntry) | |||
| 59 | + | require.NoError(t, err) | |||
| 60 | + | assert.False(t, strings.Contains(line, "\n"), "JSON line must not contain newline") | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | func TestFormatJSON_TimestampMillisecondPrecision(t *testing.T) { | |||
| 64 | + | // Ensure we get .167Z and not RFC3339Nano (which would be .167000000Z) | |||
| 65 | + | line, err := formatJSON(baseEntry) | |||
| 66 | + | require.NoError(t, err) | |||
| 67 | + | assert.Contains(t, line, `"2026-04-03T14:20:54.167Z"`) | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func TestFormatJSON_ClientIPNoPort(t *testing.T) { | |||
| 71 | + | e := baseEntry | |||
| 72 | + | e.ClientIP = "10.0.0.1" // ensure no port | |||
| 73 | + | line, err := formatJSON(e) | |||
| 74 | + | require.NoError(t, err) | |||
| 75 | + | assert.Contains(t, line, `"client_ip":"10.0.0.1"`) | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func TestFormatJSON_EmptyOptionalFields(t *testing.T) { | |||
| 79 | + | e := baseEntry | |||
| 80 | + | e.Query = "" | |||
| 81 | + | e.Referer = "" | |||
| 82 | + | e.UserAgent = "" | |||
| 83 | + | line, err := formatJSON(e) | |||
| 84 | + | require.NoError(t, err) | |||
| 85 | + | ||||
| 86 | + | var got map[string]any | |||
| 87 | + | require.NoError(t, json.Unmarshal([]byte(line), &got)) | |||
| 88 | + | assert.Equal(t, "", got["query"]) | |||
| 89 | + | assert.Equal(t, "", got["referer"]) | |||
| 90 | + | assert.Equal(t, "", got["user_agent"]) | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func TestFormatJSON_WithQuery(t *testing.T) { | |||
| 94 | + | e := baseEntry | |||
| 95 | + | e.Query = "foo=bar&baz=qux" | |||
| 96 | + | line, err := formatJSON(e) | |||
| 97 | + | require.NoError(t, err) | |||
| 98 | + | assert.Contains(t, line, `"query":"foo=bar\u0026baz=qux"`) | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | // --- CLF formatter --- | |||
| 102 | + | ||||
| 103 | + | func TestFormatCLF_BasicLine(t *testing.T) { | |||
| 104 | + | line, err := formatCLF(baseEntry) | |||
| 105 | + | require.NoError(t, err) | |||
| 106 | + | assert.Equal(t, `135.181.213.219 - - [03/Apr/2026:14:20:54 +0000] "GET /robots.txt HTTP/1.1" 404 19`, line) | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func TestFormatCLF_WithQuery(t *testing.T) { | |||
| 110 | + | e := baseEntry | |||
| 111 | + | e.Query = "page=2" | |||
| 112 | + | line, err := formatCLF(e) | |||
| 113 | + | require.NoError(t, err) | |||
| 114 | + | assert.Contains(t, line, `"GET /robots.txt?page=2 HTTP/1.1"`) | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | func TestFormatCLF_NoRefererOrUA(t *testing.T) { | |||
| 118 | + | // CLF format should not include referer or UA | |||
| 119 | + | line, err := formatCLF(baseEntry) | |||
| 120 | + | require.NoError(t, err) | |||
| 121 | + | assert.NotContains(t, line, "MJ12bot") | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | func TestFormatCLF_EscapesQuotesInTarget(t *testing.T) { | |||
| 125 | + | e := baseEntry | |||
| 126 | + | e.Path = `/path/"quoted"` | |||
| 127 | + | line, err := formatCLF(e) | |||
| 128 | + | require.NoError(t, err) | |||
| 129 | + | // Forward slashes do not need escaping; only backslashes and double-quotes do. | |||
| 130 | + | assert.Contains(t, line, `/path/\"quoted\"`) | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | // --- Combined formatter --- | |||
| 134 | + | ||||
| 135 | + | func TestFormatCombined_BasicLine(t *testing.T) { | |||
| 136 | + | line, err := formatCombined(baseEntry) | |||
| 137 | + | require.NoError(t, err) | |||
| 138 | + | assert.Equal(t, | |||
| 139 | + | `135.181.213.219 - - [03/Apr/2026:14:20:54 +0000] "GET /robots.txt HTTP/1.1" 404 19 "-" "MJ12bot/v1.4.8"`, | |||
| 140 | + | line, | |||
| 141 | + | ) | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | func TestFormatCombined_EmptyRefererIsDash(t *testing.T) { | |||
| 145 | + | e := baseEntry | |||
| 146 | + | e.Referer = "" | |||
| 147 | + | line, err := formatCombined(e) | |||
| 148 | + | require.NoError(t, err) | |||
| 149 | + | assert.Contains(t, line, `"-"`) | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | func TestFormatCombined_EmptyUAIsDash(t *testing.T) { | |||
| 153 | + | e := baseEntry | |||
| 154 | + | e.UserAgent = "" | |||
| 155 | + | line, err := formatCombined(e) | |||
| 156 | + | require.NoError(t, err) | |||
| 157 | + | // last quoted field should be "-" | |||
| 158 | + | assert.True(t, strings.HasSuffix(line, `"-"`)) | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | func TestFormatCombined_WithReferer(t *testing.T) { | |||
| 162 | + | e := baseEntry | |||
| 163 | + | e.Referer = "https://example.com/" | |||
| 164 | + | line, err := formatCombined(e) | |||
| 165 | + | require.NoError(t, err) | |||
| 166 | + | assert.Contains(t, line, `"https://example.com/"`) | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | func TestFormatCombined_EscapesQuotesInUA(t *testing.T) { | |||
| 170 | + | e := baseEntry | |||
| 171 | + | e.UserAgent = `bot/"evil"` | |||
| 172 | + | line, err := formatCombined(e) | |||
| 173 | + | require.NoError(t, err) | |||
| 174 | + | // Forward slashes don't need escaping; only backslashes and double-quotes do. | |||
| 175 | + | assert.Contains(t, line, `"bot/\"evil\""`) | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | // --- buildRequestTarget --- | |||
| 179 | + | ||||
| 180 | + | func TestBuildRequestTarget_NoQuery(t *testing.T) { | |||
| 181 | + | assert.Equal(t, "/foo", buildRequestTarget("/foo", "")) | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | func TestBuildRequestTarget_WithQuery(t *testing.T) { | |||
| 185 | + | assert.Equal(t, "/foo?bar=1", buildRequestTarget("/foo", "bar=1")) | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | // --- escapeQuoted --- | |||
| 189 | + | ||||
| 190 | + | func TestEscapeQuoted(t *testing.T) { | |||
| 191 | + | assert.Equal(t, `hello`, escapeQuoted(`hello`)) | |||
| 192 | + | assert.Equal(t, `say \"hi\"`, escapeQuoted(`say "hi"`)) | |||
| 193 | + | assert.Equal(t, `back\\slash`, escapeQuoted(`back\slash`)) | |||
| 194 | + | assert.Equal(t, `\"\\\"`, escapeQuoted(`"\"`)) | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | func TestEscapeQuoted_ControlCharacters(t *testing.T) { | |||
| 198 | + | // Named control characters with dedicated escapes | |||
| 199 | + | assert.Equal(t, `line\nbreak`, escapeQuoted("line\nbreak")) | |||
| 200 | + | assert.Equal(t, `cr\rreturn`, escapeQuoted("cr\rreturn")) | |||
| 201 | + | assert.Equal(t, `a\ttab`, escapeQuoted("a\ttab")) | |||
| 202 | + | ||||
| 203 | + | // NUL, BEL, ESC — hex-escaped | |||
| 204 | + | assert.Equal(t, `\x00`, escapeQuoted("\x00")) | |||
| 205 | + | assert.Equal(t, `\x07`, escapeQuoted("\x07")) | |||
| 206 | + | assert.Equal(t, `\x1b`, escapeQuoted("\x1b")) | |||
| 207 | + | ||||
| 208 | + | // All control characters 0x00-0x1F should be escaped (none pass through raw) | |||
| 209 | + | for b := byte(0); b < 0x20; b++ { | |||
| 210 | + | result := escapeQuoted(string([]byte{b})) | |||
| 211 | + | assert.NotEqual(t, string([]byte{b}), result, | |||
| 212 | + | "control byte 0x%02x should be escaped", b) | |||
| 213 | + | } | |||
| 214 | + | } | |||
A internal/adapters/out/accesslog/journald.go +48 -0
| @@ -0,0 +1,48 @@ | |||
| 1 | + | package accesslog | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "strings" | |
| 6 | + | ||
| 7 | + | "github.com/coreos/go-systemd/v22/journal" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // journalEnabledFunc and journalSendFunc are package-level variables so that | |
| 11 | + | // tests can replace them with stubs without requiring a live systemd journal. | |
| 12 | + | var ( | |
| 13 | + | journalEnabledFunc = journal.Enabled | |
| 14 | + | journalSendFunc = func(msg string, priority journal.Priority, vars map[string]string) error { | |
| 15 | + | return journal.Send(msg, priority, vars) | |
| 16 | + | } | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // journaldSink writes access log lines directly to the systemd journal with a | |
| 20 | + | // distinct SYSLOG_IDENTIFIER so security tools (CrowdSec, fail2ban) can filter | |
| 21 | + | // on the identity without catching application logs. | |
| 22 | + | type journaldSink struct { | |
| 23 | + | // vars is built once at construction and reused on every write to avoid a | |
| 24 | + | // per-request map allocation. | |
| 25 | + | vars map[string]string | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func newJournaldSink(cfg Config) (*journaldSink, error) { | |
| 29 | + | id := strings.TrimSpace(cfg.SyslogIdentifier) | |
| 30 | + | if id == "" { | |
| 31 | + | return nil, fmt.Errorf("accesslog: journald output requires a non-empty syslog_identifier") | |
| 32 | + | } | |
| 33 | + | if !journalEnabledFunc() { | |
| 34 | + | return nil, fmt.Errorf("accesslog: journald output requested but journald is not available on this system") | |
| 35 | + | } | |
| 36 | + | return &journaldSink{ | |
| 37 | + | vars: map[string]string{"SYSLOG_IDENTIFIER": id}, | |
| 38 | + | }, nil | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func (s *journaldSink) WriteLine(line string) error { | |
| 42 | + | if err := journalSendFunc(line, journal.PriInfo, s.vars); err != nil { | |
| 43 | + | return fmt.Errorf("accesslog: journald write: %w", err) | |
| 44 | + | } | |
| 45 | + | return nil | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func (s *journaldSink) Close() error { return nil } | |
| @@ -0,0 +1,48 @@ | |||||
| 1 | + | package accesslog | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "strings" | |||
| 6 | + | ||||
| 7 | + | "github.com/coreos/go-systemd/v22/journal" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // journalEnabledFunc and journalSendFunc are package-level variables so that | |||
| 11 | + | // tests can replace them with stubs without requiring a live systemd journal. | |||
| 12 | + | var ( | |||
| 13 | + | journalEnabledFunc = journal.Enabled | |||
| 14 | + | journalSendFunc = func(msg string, priority journal.Priority, vars map[string]string) error { | |||
| 15 | + | return journal.Send(msg, priority, vars) | |||
| 16 | + | } | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | // journaldSink writes access log lines directly to the systemd journal with a | |||
| 20 | + | // distinct SYSLOG_IDENTIFIER so security tools (CrowdSec, fail2ban) can filter | |||
| 21 | + | // on the identity without catching application logs. | |||
| 22 | + | type journaldSink struct { | |||
| 23 | + | // vars is built once at construction and reused on every write to avoid a | |||
| 24 | + | // per-request map allocation. | |||
| 25 | + | vars map[string]string | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func newJournaldSink(cfg Config) (*journaldSink, error) { | |||
| 29 | + | id := strings.TrimSpace(cfg.SyslogIdentifier) | |||
| 30 | + | if id == "" { | |||
| 31 | + | return nil, fmt.Errorf("accesslog: journald output requires a non-empty syslog_identifier") | |||
| 32 | + | } | |||
| 33 | + | if !journalEnabledFunc() { | |||
| 34 | + | return nil, fmt.Errorf("accesslog: journald output requested but journald is not available on this system") | |||
| 35 | + | } | |||
| 36 | + | return &journaldSink{ | |||
| 37 | + | vars: map[string]string{"SYSLOG_IDENTIFIER": id}, | |||
| 38 | + | }, nil | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func (s *journaldSink) WriteLine(line string) error { | |||
| 42 | + | if err := journalSendFunc(line, journal.PriInfo, s.vars); err != nil { | |||
| 43 | + | return fmt.Errorf("accesslog: journald write: %w", err) | |||
| 44 | + | } | |||
| 45 | + | return nil | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func (s *journaldSink) Close() error { return nil } | |||
A internal/adapters/out/accesslog/journald_test.go +137 -0
| @@ -0,0 +1,137 @@ | |||
| 1 | + | package accesslog | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/coreos/go-systemd/v22/journal" | |
| 8 | + | "github.com/stretchr/testify/assert" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // capturedJournalCall records a single call to the stubbed journalSendFunc. | |
| 13 | + | type capturedJournalCall struct { | |
| 14 | + | msg string | |
| 15 | + | priority journal.Priority | |
| 16 | + | vars map[string]string | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // withJournalStub replaces journalEnabledFunc and journalSendFunc for the | |
| 20 | + | // duration of a test, restoring them afterwards. | |
| 21 | + | func withJournalStub(t *testing.T, enabled bool, captures *[]capturedJournalCall) { | |
| 22 | + | t.Helper() | |
| 23 | + | origEnabled := journalEnabledFunc | |
| 24 | + | origSend := journalSendFunc | |
| 25 | + | ||
| 26 | + | journalEnabledFunc = func() bool { return enabled } | |
| 27 | + | journalSendFunc = func(msg string, priority journal.Priority, vars map[string]string) error { | |
| 28 | + | *captures = append(*captures, capturedJournalCall{msg: msg, priority: priority, vars: vars}) | |
| 29 | + | return nil | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | t.Cleanup(func() { | |
| 33 | + | journalEnabledFunc = origEnabled | |
| 34 | + | journalSendFunc = origSend | |
| 35 | + | }) | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func TestNewJournaldSink_JournaldUnavailable(t *testing.T) { | |
| 39 | + | var calls []capturedJournalCall | |
| 40 | + | withJournalStub(t, false, &calls) | |
| 41 | + | ||
| 42 | + | _, err := New(Config{ | |
| 43 | + | Format: "json", | |
| 44 | + | Output: "journald", | |
| 45 | + | SyslogIdentifier: "gordon-access", | |
| 46 | + | }) | |
| 47 | + | require.Error(t, err) | |
| 48 | + | assert.Contains(t, err.Error(), "journald is not available") | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func TestNewJournaldSink_EmptySyslogIdentifier(t *testing.T) { | |
| 52 | + | var calls []capturedJournalCall | |
| 53 | + | withJournalStub(t, true, &calls) | |
| 54 | + | ||
| 55 | + | _, err := New(Config{ | |
| 56 | + | Format: "json", | |
| 57 | + | Output: "journald", | |
| 58 | + | SyslogIdentifier: "", | |
| 59 | + | }) | |
| 60 | + | require.Error(t, err) | |
| 61 | + | assert.Contains(t, err.Error(), "syslog_identifier") | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func TestNewJournaldSink_WhitespaceSyslogIdentifier(t *testing.T) { | |
| 65 | + | var calls []capturedJournalCall | |
| 66 | + | withJournalStub(t, true, &calls) | |
| 67 | + | ||
| 68 | + | _, err := New(Config{ | |
| 69 | + | Format: "json", | |
| 70 | + | Output: "journald", | |
| 71 | + | SyslogIdentifier: " ", | |
| 72 | + | }) | |
| 73 | + | require.Error(t, err) | |
| 74 | + | assert.Contains(t, err.Error(), "syslog_identifier") | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func TestJournaldSink_Write_CallsJournalSend(t *testing.T) { | |
| 78 | + | var calls []capturedJournalCall | |
| 79 | + | withJournalStub(t, true, &calls) | |
| 80 | + | ||
| 81 | + | writer, err := New(Config{ | |
| 82 | + | Format: "json", | |
| 83 | + | Output: "journald", | |
| 84 | + | SyslogIdentifier: "gordon-access", | |
| 85 | + | }) | |
| 86 | + | require.NoError(t, err) | |
| 87 | + | ||
| 88 | + | require.NoError(t, writer.Write(sampleEntry)) | |
| 89 | + | require.NoError(t, writer.Close()) | |
| 90 | + | ||
| 91 | + | require.Len(t, calls, 1) | |
| 92 | + | assert.Equal(t, journal.PriInfo, calls[0].priority) | |
| 93 | + | assert.Equal(t, "gordon-access", calls[0].vars["SYSLOG_IDENTIFIER"]) | |
| 94 | + | assert.NotEmpty(t, calls[0].msg, "message must be the serialized log line") | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func TestJournaldSink_Write_SendsCustomIdentifier(t *testing.T) { | |
| 98 | + | var calls []capturedJournalCall | |
| 99 | + | withJournalStub(t, true, &calls) | |
| 100 | + | ||
| 101 | + | writer, err := New(Config{ | |
| 102 | + | Format: "clf", | |
| 103 | + | Output: "journald", | |
| 104 | + | SyslogIdentifier: "my-app-access", | |
| 105 | + | }) | |
| 106 | + | require.NoError(t, err) | |
| 107 | + | ||
| 108 | + | require.NoError(t, writer.Write(sampleEntry)) | |
| 109 | + | ||
| 110 | + | require.Len(t, calls, 1) | |
| 111 | + | assert.Equal(t, "my-app-access", calls[0].vars["SYSLOG_IDENTIFIER"]) | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func TestJournaldSink_Write_ReturnsErrorOnSendFailure(t *testing.T) { | |
| 115 | + | origEnabled := journalEnabledFunc | |
| 116 | + | origSend := journalSendFunc | |
| 117 | + | t.Cleanup(func() { | |
| 118 | + | journalEnabledFunc = origEnabled | |
| 119 | + | journalSendFunc = origSend | |
| 120 | + | }) | |
| 121 | + | ||
| 122 | + | journalEnabledFunc = func() bool { return true } | |
| 123 | + | journalSendFunc = func(_ string, _ journal.Priority, _ map[string]string) error { | |
| 124 | + | return errors.New("journal socket closed") | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | writer, err := New(Config{ | |
| 128 | + | Format: "json", | |
| 129 | + | Output: "journald", | |
| 130 | + | SyslogIdentifier: "gordon-access", | |
| 131 | + | }) | |
| 132 | + | require.NoError(t, err) | |
| 133 | + | ||
| 134 | + | err = writer.Write(sampleEntry) | |
| 135 | + | require.Error(t, err) | |
| 136 | + | assert.Contains(t, err.Error(), "journal socket closed") | |
| 137 | + | } | |
| @@ -0,0 +1,137 @@ | |||||
| 1 | + | package accesslog | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/coreos/go-systemd/v22/journal" | |||
| 8 | + | "github.com/stretchr/testify/assert" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // capturedJournalCall records a single call to the stubbed journalSendFunc. | |||
| 13 | + | type capturedJournalCall struct { | |||
| 14 | + | msg string | |||
| 15 | + | priority journal.Priority | |||
| 16 | + | vars map[string]string | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | // withJournalStub replaces journalEnabledFunc and journalSendFunc for the | |||
| 20 | + | // duration of a test, restoring them afterwards. | |||
| 21 | + | func withJournalStub(t *testing.T, enabled bool, captures *[]capturedJournalCall) { | |||
| 22 | + | t.Helper() | |||
| 23 | + | origEnabled := journalEnabledFunc | |||
| 24 | + | origSend := journalSendFunc | |||
| 25 | + | ||||
| 26 | + | journalEnabledFunc = func() bool { return enabled } | |||
| 27 | + | journalSendFunc = func(msg string, priority journal.Priority, vars map[string]string) error { | |||
| 28 | + | *captures = append(*captures, capturedJournalCall{msg: msg, priority: priority, vars: vars}) | |||
| 29 | + | return nil | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | t.Cleanup(func() { | |||
| 33 | + | journalEnabledFunc = origEnabled | |||
| 34 | + | journalSendFunc = origSend | |||
| 35 | + | }) | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func TestNewJournaldSink_JournaldUnavailable(t *testing.T) { | |||
| 39 | + | var calls []capturedJournalCall | |||
| 40 | + | withJournalStub(t, false, &calls) | |||
| 41 | + | ||||
| 42 | + | _, err := New(Config{ | |||
| 43 | + | Format: "json", | |||
| 44 | + | Output: "journald", | |||
| 45 | + | SyslogIdentifier: "gordon-access", | |||
| 46 | + | }) | |||
| 47 | + | require.Error(t, err) | |||
| 48 | + | assert.Contains(t, err.Error(), "journald is not available") | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func TestNewJournaldSink_EmptySyslogIdentifier(t *testing.T) { | |||
| 52 | + | var calls []capturedJournalCall | |||
| 53 | + | withJournalStub(t, true, &calls) | |||
| 54 | + | ||||
| 55 | + | _, err := New(Config{ | |||
| 56 | + | Format: "json", | |||
| 57 | + | Output: "journald", | |||
| 58 | + | SyslogIdentifier: "", | |||
| 59 | + | }) | |||
| 60 | + | require.Error(t, err) | |||
| 61 | + | assert.Contains(t, err.Error(), "syslog_identifier") | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func TestNewJournaldSink_WhitespaceSyslogIdentifier(t *testing.T) { | |||
| 65 | + | var calls []capturedJournalCall | |||
| 66 | + | withJournalStub(t, true, &calls) | |||
| 67 | + | ||||
| 68 | + | _, err := New(Config{ | |||
| 69 | + | Format: "json", | |||
| 70 | + | Output: "journald", | |||
| 71 | + | SyslogIdentifier: " ", | |||
| 72 | + | }) | |||
| 73 | + | require.Error(t, err) | |||
| 74 | + | assert.Contains(t, err.Error(), "syslog_identifier") | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func TestJournaldSink_Write_CallsJournalSend(t *testing.T) { | |||
| 78 | + | var calls []capturedJournalCall | |||
| 79 | + | withJournalStub(t, true, &calls) | |||
| 80 | + | ||||
| 81 | + | writer, err := New(Config{ | |||
| 82 | + | Format: "json", | |||
| 83 | + | Output: "journald", | |||
| 84 | + | SyslogIdentifier: "gordon-access", | |||
| 85 | + | }) | |||
| 86 | + | require.NoError(t, err) | |||
| 87 | + | ||||
| 88 | + | require.NoError(t, writer.Write(sampleEntry)) | |||
| 89 | + | require.NoError(t, writer.Close()) | |||
| 90 | + | ||||
| 91 | + | require.Len(t, calls, 1) | |||
| 92 | + | assert.Equal(t, journal.PriInfo, calls[0].priority) | |||
| 93 | + | assert.Equal(t, "gordon-access", calls[0].vars["SYSLOG_IDENTIFIER"]) | |||
| 94 | + | assert.NotEmpty(t, calls[0].msg, "message must be the serialized log line") | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func TestJournaldSink_Write_SendsCustomIdentifier(t *testing.T) { | |||
| 98 | + | var calls []capturedJournalCall | |||
| 99 | + | withJournalStub(t, true, &calls) | |||
| 100 | + | ||||
| 101 | + | writer, err := New(Config{ | |||
| 102 | + | Format: "clf", | |||
| 103 | + | Output: "journald", | |||
| 104 | + | SyslogIdentifier: "my-app-access", | |||
| 105 | + | }) | |||
| 106 | + | require.NoError(t, err) | |||
| 107 | + | ||||
| 108 | + | require.NoError(t, writer.Write(sampleEntry)) | |||
| 109 | + | ||||
| 110 | + | require.Len(t, calls, 1) | |||
| 111 | + | assert.Equal(t, "my-app-access", calls[0].vars["SYSLOG_IDENTIFIER"]) | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func TestJournaldSink_Write_ReturnsErrorOnSendFailure(t *testing.T) { | |||
| 115 | + | origEnabled := journalEnabledFunc | |||
| 116 | + | origSend := journalSendFunc | |||
| 117 | + | t.Cleanup(func() { | |||
| 118 | + | journalEnabledFunc = origEnabled | |||
| 119 | + | journalSendFunc = origSend | |||
| 120 | + | }) | |||
| 121 | + | ||||
| 122 | + | journalEnabledFunc = func() bool { return true } | |||
| 123 | + | journalSendFunc = func(_ string, _ journal.Priority, _ map[string]string) error { | |||
| 124 | + | return errors.New("journal socket closed") | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | writer, err := New(Config{ | |||
| 128 | + | Format: "json", | |||
| 129 | + | Output: "journald", | |||
| 130 | + | SyslogIdentifier: "gordon-access", | |||
| 131 | + | }) | |||
| 132 | + | require.NoError(t, err) | |||
| 133 | + | ||||
| 134 | + | err = writer.Write(sampleEntry) | |||
| 135 | + | require.Error(t, err) | |||
| 136 | + | assert.Contains(t, err.Error(), "journal socket closed") | |||
| 137 | + | } | |||
A internal/adapters/out/accesslog/writer.go +167 -0
| @@ -0,0 +1,167 @@ | |||
| 1 | + | // Package accesslog provides a dedicated HTTP access log writer for security | |
| 2 | + | // and observability tooling. It supports JSON, CLF, and Combined formats with | |
| 3 | + | // stdout, file, and journald output backends. | |
| 4 | + | package accesslog | |
| 5 | + | ||
| 6 | + | import ( | |
| 7 | + | "fmt" | |
| 8 | + | "os" | |
| 9 | + | "path/filepath" | |
| 10 | + | "sync" | |
| 11 | + | ||
| 12 | + | "gopkg.in/natefinch/lumberjack.v2" | |
| 13 | + | ||
| 14 | + | out "github.com/bnema/gordon/internal/boundaries/out" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // Config holds configuration for the access log writer. | |
| 18 | + | type Config struct { | |
| 19 | + | // Format is the output format: "json", "clf", or "combined". | |
| 20 | + | Format string | |
| 21 | + | // Output is the output backend: "stdout", "file", or "journald". | |
| 22 | + | Output string | |
| 23 | + | // FilePath is the path to the log file (only used when Output == "file"). | |
| 24 | + | // If empty, defaults to {data_dir}/logs/access.log (caller must resolve). | |
| 25 | + | FilePath string | |
| 26 | + | // MaxSize is the maximum size in MB before rotation (only used when Output == "file"). | |
| 27 | + | MaxSize int | |
| 28 | + | // MaxBackups is the number of old log files to retain (only used when Output == "file"). | |
| 29 | + | MaxBackups int | |
| 30 | + | // MaxAge is the maximum number of days to retain old log files (only used when Output == "file"). | |
| 31 | + | MaxAge int | |
| 32 | + | // SyslogIdentifier is the journald SYSLOG_IDENTIFIER (only used when Output == "journald"). | |
| 33 | + | SyslogIdentifier string | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // sink is the internal interface for output backends. | |
| 37 | + | type sink interface { | |
| 38 | + | WriteLine(line string) error | |
| 39 | + | Close() error | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // Writer serializes access log entries and writes them to a configured sink. | |
| 43 | + | // It is safe for concurrent use. It implements out.AccessLogWriter. | |
| 44 | + | type Writer struct { | |
| 45 | + | formatter func(out.AccessLogEntry) (string, error) | |
| 46 | + | sink sink | |
| 47 | + | mu sync.Mutex | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // New creates a new Writer from cfg. Returns an error if the configuration is | |
| 51 | + | // invalid or the output backend cannot be initialized. | |
| 52 | + | func New(cfg Config) (*Writer, error) { | |
| 53 | + | formatter, err := newFormatter(cfg.Format) | |
| 54 | + | if err != nil { | |
| 55 | + | return nil, fmt.Errorf("failed to create formatter: %w", err) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | s, err := newSink(cfg) | |
| 59 | + | if err != nil { | |
| 60 | + | return nil, fmt.Errorf("failed to create sink: %w", err) | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | return &Writer{ | |
| 64 | + | formatter: formatter, | |
| 65 | + | sink: s, | |
| 66 | + | }, nil | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // Write serializes entry and writes it to the configured output. It is safe | |
| 70 | + | // for concurrent use. A write failure is returned as an error; callers should | |
| 71 | + | // log the error and continue — Write never panics. | |
| 72 | + | func (w *Writer) Write(entry out.AccessLogEntry) error { | |
| 73 | + | line, err := w.formatter(entry) | |
| 74 | + | if err != nil { | |
| 75 | + | return fmt.Errorf("accesslog: format entry: %w", err) | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | w.mu.Lock() | |
| 79 | + | defer w.mu.Unlock() | |
| 80 | + | ||
| 81 | + | if err := w.sink.WriteLine(line); err != nil { | |
| 82 | + | return fmt.Errorf("accesslog: write line: %w", err) | |
| 83 | + | } | |
| 84 | + | return nil | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // Close releases any resources held by the writer (e.g. open file handles). | |
| 88 | + | func (w *Writer) Close() error { | |
| 89 | + | w.mu.Lock() | |
| 90 | + | defer w.mu.Unlock() | |
| 91 | + | return w.sink.Close() | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // newFormatter returns the format function for the given format name. | |
| 95 | + | func newFormatter(format string) (func(out.AccessLogEntry) (string, error), error) { | |
| 96 | + | switch format { | |
| 97 | + | case "json": | |
| 98 | + | return formatJSON, nil | |
| 99 | + | case "clf": | |
| 100 | + | return formatCLF, nil | |
| 101 | + | case "combined": | |
| 102 | + | return formatCombined, nil | |
| 103 | + | default: | |
| 104 | + | return nil, fmt.Errorf("invalid logging.access_log.format: %q (must be json, clf, or combined)", format) | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // newSink creates the output sink for the given config. | |
| 109 | + | func newSink(cfg Config) (sink, error) { | |
| 110 | + | switch cfg.Output { | |
| 111 | + | case "stdout", "": | |
| 112 | + | return &stdoutSink{}, nil | |
| 113 | + | case "file": | |
| 114 | + | return newFileSink(cfg) | |
| 115 | + | case "journald": | |
| 116 | + | return newJournaldSink(cfg) | |
| 117 | + | default: | |
| 118 | + | return nil, fmt.Errorf("invalid logging.access_log.output: %q (must be stdout, file, or journald)", cfg.Output) | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | // stdoutSink writes lines to os.Stdout. | |
| 123 | + | type stdoutSink struct{} | |
| 124 | + | ||
| 125 | + | func (s *stdoutSink) WriteLine(line string) error { | |
| 126 | + | _, err := fmt.Fprintln(os.Stdout, line) | |
| 127 | + | return err | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func (s *stdoutSink) Close() error { return nil } | |
| 131 | + | ||
| 132 | + | // fileSink writes lines to a rotating file via lumberjack. | |
| 133 | + | type fileSink struct { | |
| 134 | + | logger *lumberjack.Logger | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | func newFileSink(cfg Config) (*fileSink, error) { | |
| 138 | + | if cfg.FilePath == "" { | |
| 139 | + | return nil, fmt.Errorf("accesslog: file output requires a non-empty file_path") | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | // Ensure parent directory exists. | |
| 143 | + | if err := os.MkdirAll(filepath.Dir(cfg.FilePath), 0o700); err != nil { | |
| 144 | + | return nil, fmt.Errorf("accesslog: create log directory: %w", err) | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | // lumberjack defaults when zero: MaxSize=100MB, MaxBackups=keep all, MaxAge=no limit. | |
| 148 | + | // Config defaults in run.go set these to sensible values; zero here is valid. | |
| 149 | + | return &fileSink{ | |
| 150 | + | logger: &lumberjack.Logger{ | |
| 151 | + | Filename: cfg.FilePath, | |
| 152 | + | MaxSize: cfg.MaxSize, | |
| 153 | + | MaxBackups: cfg.MaxBackups, | |
| 154 | + | MaxAge: cfg.MaxAge, | |
| 155 | + | Compress: true, | |
| 156 | + | }, | |
| 157 | + | }, nil | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | func (s *fileSink) WriteLine(line string) error { | |
| 161 | + | _, err := fmt.Fprintln(s.logger, line) | |
| 162 | + | return err | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func (s *fileSink) Close() error { | |
| 166 | + | return s.logger.Close() | |
| 167 | + | } | |
| @@ -0,0 +1,167 @@ | |||||
| 1 | + | // Package accesslog provides a dedicated HTTP access log writer for security | |||
| 2 | + | // and observability tooling. It supports JSON, CLF, and Combined formats with | |||
| 3 | + | // stdout, file, and journald output backends. | |||
| 4 | + | package accesslog | |||
| 5 | + | ||||
| 6 | + | import ( | |||
| 7 | + | "fmt" | |||
| 8 | + | "os" | |||
| 9 | + | "path/filepath" | |||
| 10 | + | "sync" | |||
| 11 | + | ||||
| 12 | + | "gopkg.in/natefinch/lumberjack.v2" | |||
| 13 | + | ||||
| 14 | + | out "github.com/bnema/gordon/internal/boundaries/out" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // Config holds configuration for the access log writer. | |||
| 18 | + | type Config struct { | |||
| 19 | + | // Format is the output format: "json", "clf", or "combined". | |||
| 20 | + | Format string | |||
| 21 | + | // Output is the output backend: "stdout", "file", or "journald". | |||
| 22 | + | Output string | |||
| 23 | + | // FilePath is the path to the log file (only used when Output == "file"). | |||
| 24 | + | // If empty, defaults to {data_dir}/logs/access.log (caller must resolve). | |||
| 25 | + | FilePath string | |||
| 26 | + | // MaxSize is the maximum size in MB before rotation (only used when Output == "file"). | |||
| 27 | + | MaxSize int | |||
| 28 | + | // MaxBackups is the number of old log files to retain (only used when Output == "file"). | |||
| 29 | + | MaxBackups int | |||
| 30 | + | // MaxAge is the maximum number of days to retain old log files (only used when Output == "file"). | |||
| 31 | + | MaxAge int | |||
| 32 | + | // SyslogIdentifier is the journald SYSLOG_IDENTIFIER (only used when Output == "journald"). | |||
| 33 | + | SyslogIdentifier string | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // sink is the internal interface for output backends. | |||
| 37 | + | type sink interface { | |||
| 38 | + | WriteLine(line string) error | |||
| 39 | + | Close() error | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // Writer serializes access log entries and writes them to a configured sink. | |||
| 43 | + | // It is safe for concurrent use. It implements out.AccessLogWriter. | |||
| 44 | + | type Writer struct { | |||
| 45 | + | formatter func(out.AccessLogEntry) (string, error) | |||
| 46 | + | sink sink | |||
| 47 | + | mu sync.Mutex | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // New creates a new Writer from cfg. Returns an error if the configuration is | |||
| 51 | + | // invalid or the output backend cannot be initialized. | |||
| 52 | + | func New(cfg Config) (*Writer, error) { | |||
| 53 | + | formatter, err := newFormatter(cfg.Format) | |||
| 54 | + | if err != nil { | |||
| 55 | + | return nil, fmt.Errorf("failed to create formatter: %w", err) | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | s, err := newSink(cfg) | |||
| 59 | + | if err != nil { | |||
| 60 | + | return nil, fmt.Errorf("failed to create sink: %w", err) | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | return &Writer{ | |||
| 64 | + | formatter: formatter, | |||
| 65 | + | sink: s, | |||
| 66 | + | }, nil | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | // Write serializes entry and writes it to the configured output. It is safe | |||
| 70 | + | // for concurrent use. A write failure is returned as an error; callers should | |||
| 71 | + | // log the error and continue — Write never panics. | |||
| 72 | + | func (w *Writer) Write(entry out.AccessLogEntry) error { | |||
| 73 | + | line, err := w.formatter(entry) | |||
| 74 | + | if err != nil { | |||
| 75 | + | return fmt.Errorf("accesslog: format entry: %w", err) | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | w.mu.Lock() | |||
| 79 | + | defer w.mu.Unlock() | |||
| 80 | + | ||||
| 81 | + | if err := w.sink.WriteLine(line); err != nil { | |||
| 82 | + | return fmt.Errorf("accesslog: write line: %w", err) | |||
| 83 | + | } | |||
| 84 | + | return nil | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | // Close releases any resources held by the writer (e.g. open file handles). | |||
| 88 | + | func (w *Writer) Close() error { | |||
| 89 | + | w.mu.Lock() | |||
| 90 | + | defer w.mu.Unlock() | |||
| 91 | + | return w.sink.Close() | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | // newFormatter returns the format function for the given format name. | |||
| 95 | + | func newFormatter(format string) (func(out.AccessLogEntry) (string, error), error) { | |||
| 96 | + | switch format { | |||
| 97 | + | case "json": | |||
| 98 | + | return formatJSON, nil | |||
| 99 | + | case "clf": | |||
| 100 | + | return formatCLF, nil | |||
| 101 | + | case "combined": | |||
| 102 | + | return formatCombined, nil | |||
| 103 | + | default: | |||
| 104 | + | return nil, fmt.Errorf("invalid logging.access_log.format: %q (must be json, clf, or combined)", format) | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | // newSink creates the output sink for the given config. | |||
| 109 | + | func newSink(cfg Config) (sink, error) { | |||
| 110 | + | switch cfg.Output { | |||
| 111 | + | case "stdout", "": | |||
| 112 | + | return &stdoutSink{}, nil | |||
| 113 | + | case "file": | |||
| 114 | + | return newFileSink(cfg) | |||
| 115 | + | case "journald": | |||
| 116 | + | return newJournaldSink(cfg) | |||
| 117 | + | default: | |||
| 118 | + | return nil, fmt.Errorf("invalid logging.access_log.output: %q (must be stdout, file, or journald)", cfg.Output) | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | // stdoutSink writes lines to os.Stdout. | |||
| 123 | + | type stdoutSink struct{} | |||
| 124 | + | ||||
| 125 | + | func (s *stdoutSink) WriteLine(line string) error { | |||
| 126 | + | _, err := fmt.Fprintln(os.Stdout, line) | |||
| 127 | + | return err | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | func (s *stdoutSink) Close() error { return nil } | |||
| 131 | + | ||||
| 132 | + | // fileSink writes lines to a rotating file via lumberjack. | |||
| 133 | + | type fileSink struct { | |||
| 134 | + | logger *lumberjack.Logger | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | func newFileSink(cfg Config) (*fileSink, error) { | |||
| 138 | + | if cfg.FilePath == "" { | |||
| 139 | + | return nil, fmt.Errorf("accesslog: file output requires a non-empty file_path") | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | // Ensure parent directory exists. | |||
| 143 | + | if err := os.MkdirAll(filepath.Dir(cfg.FilePath), 0o700); err != nil { | |||
| 144 | + | return nil, fmt.Errorf("accesslog: create log directory: %w", err) | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | // lumberjack defaults when zero: MaxSize=100MB, MaxBackups=keep all, MaxAge=no limit. | |||
| 148 | + | // Config defaults in run.go set these to sensible values; zero here is valid. | |||
| 149 | + | return &fileSink{ | |||
| 150 | + | logger: &lumberjack.Logger{ | |||
| 151 | + | Filename: cfg.FilePath, | |||
| 152 | + | MaxSize: cfg.MaxSize, | |||
| 153 | + | MaxBackups: cfg.MaxBackups, | |||
| 154 | + | MaxAge: cfg.MaxAge, | |||
| 155 | + | Compress: true, | |||
| 156 | + | }, | |||
| 157 | + | }, nil | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | func (s *fileSink) WriteLine(line string) error { | |||
| 161 | + | _, err := fmt.Fprintln(s.logger, line) | |||
| 162 | + | return err | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | func (s *fileSink) Close() error { | |||
| 166 | + | return s.logger.Close() | |||
| 167 | + | } | |||
A internal/adapters/out/accesslog/writer_test.go +208 -0
| @@ -0,0 +1,208 @@ | |||
| 1 | + | package accesslog | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "encoding/json" | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "strings" | |
| 10 | + | "sync" | |
| 11 | + | "testing" | |
| 12 | + | "time" | |
| 13 | + | ||
| 14 | + | "github.com/stretchr/testify/assert" | |
| 15 | + | "github.com/stretchr/testify/require" | |
| 16 | + | ||
| 17 | + | out "github.com/bnema/gordon/internal/boundaries/out" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | var sampleEntry = out.AccessLogEntry{ | |
| 21 | + | Time: time.Date(2026, 4, 3, 14, 20, 54, 167_000_000, time.UTC), | |
| 22 | + | ClientIP: "10.0.0.1", | |
| 23 | + | Method: "POST", | |
| 24 | + | Host: "api.example.com", | |
| 25 | + | Path: "/api/v1/data", | |
| 26 | + | Query: "limit=10", | |
| 27 | + | Status: 200, | |
| 28 | + | BytesSent: 512, | |
| 29 | + | DurationMS: 3.5, | |
| 30 | + | UserAgent: "curl/7.88", | |
| 31 | + | Referer: "https://example.com", | |
| 32 | + | RequestID: "abc123", | |
| 33 | + | Proto: "HTTP/2.0", | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // --- New() validation --- | |
| 37 | + | ||
| 38 | + | func TestNew_InvalidFormat(t *testing.T) { | |
| 39 | + | _, err := New(Config{Format: "xml", Output: "stdout"}) | |
| 40 | + | require.Error(t, err) | |
| 41 | + | assert.Contains(t, err.Error(), "invalid logging.access_log.format") | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func TestNew_InvalidOutput(t *testing.T) { | |
| 45 | + | _, err := New(Config{Format: "json", Output: "kafka"}) | |
| 46 | + | require.Error(t, err) | |
| 47 | + | assert.Contains(t, err.Error(), "invalid logging.access_log.output") | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func TestNew_FileOutput_EmptyPath(t *testing.T) { | |
| 51 | + | _, err := New(Config{Format: "json", Output: "file", FilePath: ""}) | |
| 52 | + | require.Error(t, err) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func TestNew_FileOutput_CreatesDirectory(t *testing.T) { | |
| 56 | + | dir := t.TempDir() | |
| 57 | + | path := filepath.Join(dir, "sub", "access.log") | |
| 58 | + | w, err := New(Config{ | |
| 59 | + | Format: "json", | |
| 60 | + | Output: "file", | |
| 61 | + | FilePath: path, | |
| 62 | + | MaxSize: 10, | |
| 63 | + | }) | |
| 64 | + | require.NoError(t, err) | |
| 65 | + | defer w.Close() | |
| 66 | + | ||
| 67 | + | assert.DirExists(t, filepath.Join(dir, "sub")) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // --- stdout sink --- | |
| 71 | + | ||
| 72 | + | func TestWriter_Stdout_JSON(t *testing.T) { | |
| 73 | + | // Replace stdout to capture output. | |
| 74 | + | orig := os.Stdout | |
| 75 | + | r, w, _ := os.Pipe() | |
| 76 | + | os.Stdout = w | |
| 77 | + | ||
| 78 | + | writer, err := New(Config{Format: "json", Output: "stdout"}) | |
| 79 | + | require.NoError(t, err) | |
| 80 | + | ||
| 81 | + | require.NoError(t, writer.Write(sampleEntry)) | |
| 82 | + | require.NoError(t, writer.Close()) | |
| 83 | + | ||
| 84 | + | w.Close() | |
| 85 | + | os.Stdout = orig | |
| 86 | + | ||
| 87 | + | var buf bytes.Buffer | |
| 88 | + | _, _ = buf.ReadFrom(r) | |
| 89 | + | line := strings.TrimSpace(buf.String()) | |
| 90 | + | ||
| 91 | + | var got map[string]any | |
| 92 | + | require.NoError(t, json.Unmarshal([]byte(line), &got), "output must be valid JSON: %s", line) | |
| 93 | + | assert.Equal(t, "10.0.0.1", got["client_ip"]) | |
| 94 | + | assert.Equal(t, "POST", got["method"]) | |
| 95 | + | assert.InDelta(t, 200, got["status"], 0) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func TestWriter_Stdout_CLF(t *testing.T) { | |
| 99 | + | orig := os.Stdout | |
| 100 | + | r, w, _ := os.Pipe() | |
| 101 | + | os.Stdout = w | |
| 102 | + | ||
| 103 | + | writer, err := New(Config{Format: "clf", Output: "stdout"}) | |
| 104 | + | require.NoError(t, err) | |
| 105 | + | require.NoError(t, writer.Write(sampleEntry)) | |
| 106 | + | require.NoError(t, writer.Close()) | |
| 107 | + | ||
| 108 | + | w.Close() | |
| 109 | + | os.Stdout = orig | |
| 110 | + | ||
| 111 | + | var buf bytes.Buffer | |
| 112 | + | _, _ = buf.ReadFrom(r) | |
| 113 | + | line := strings.TrimSpace(buf.String()) | |
| 114 | + | ||
| 115 | + | assert.True(t, strings.HasPrefix(line, "10.0.0.1 - - [")) | |
| 116 | + | assert.Contains(t, line, `"POST /api/v1/data?limit=10 HTTP/2.0"`) | |
| 117 | + | assert.Contains(t, line, " 200 512") | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func TestWriter_Stdout_Combined(t *testing.T) { | |
| 121 | + | orig := os.Stdout | |
| 122 | + | r, w, _ := os.Pipe() | |
| 123 | + | os.Stdout = w | |
| 124 | + | ||
| 125 | + | writer, err := New(Config{Format: "combined", Output: "stdout"}) | |
| 126 | + | require.NoError(t, err) | |
| 127 | + | require.NoError(t, writer.Write(sampleEntry)) | |
| 128 | + | require.NoError(t, writer.Close()) | |
| 129 | + | ||
| 130 | + | w.Close() | |
| 131 | + | os.Stdout = orig | |
| 132 | + | ||
| 133 | + | var buf bytes.Buffer | |
| 134 | + | _, _ = buf.ReadFrom(r) | |
| 135 | + | line := strings.TrimSpace(buf.String()) | |
| 136 | + | ||
| 137 | + | assert.Contains(t, line, `"https://example.com"`) | |
| 138 | + | assert.Contains(t, line, `"curl/7.88"`) | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | // --- file sink --- | |
| 142 | + | ||
| 143 | + | func TestWriter_File_WritesLines(t *testing.T) { | |
| 144 | + | dir := t.TempDir() | |
| 145 | + | path := filepath.Join(dir, "access.log") | |
| 146 | + | ||
| 147 | + | writer, err := New(Config{ | |
| 148 | + | Format: "json", | |
| 149 | + | Output: "file", | |
| 150 | + | FilePath: path, | |
| 151 | + | MaxSize: 10, | |
| 152 | + | MaxBackups: 1, | |
| 153 | + | }) | |
| 154 | + | require.NoError(t, err) | |
| 155 | + | ||
| 156 | + | require.NoError(t, writer.Write(sampleEntry)) | |
| 157 | + | require.NoError(t, writer.Write(sampleEntry)) | |
| 158 | + | require.NoError(t, writer.Close()) | |
| 159 | + | ||
| 160 | + | data, err := os.ReadFile(path) | |
| 161 | + | require.NoError(t, err) | |
| 162 | + | ||
| 163 | + | lines := strings.Split(strings.TrimSpace(string(data)), "\n") | |
| 164 | + | assert.Len(t, lines, 2) | |
| 165 | + | for _, l := range lines { | |
| 166 | + | var obj map[string]any | |
| 167 | + | assert.NoError(t, json.Unmarshal([]byte(l), &obj), "each line must be valid JSON") | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | // --- concurrency --- | |
| 172 | + | ||
| 173 | + | func TestWriter_ConcurrentWrites(t *testing.T) { | |
| 174 | + | dir := t.TempDir() | |
| 175 | + | path := filepath.Join(dir, "concurrent.log") | |
| 176 | + | ||
| 177 | + | writer, err := New(Config{ | |
| 178 | + | Format: "json", | |
| 179 | + | Output: "file", | |
| 180 | + | FilePath: path, | |
| 181 | + | MaxSize: 100, | |
| 182 | + | }) | |
| 183 | + | require.NoError(t, err) | |
| 184 | + | defer writer.Close() | |
| 185 | + | ||
| 186 | + | const goroutines = 50 | |
| 187 | + | var wg sync.WaitGroup | |
| 188 | + | wg.Add(goroutines) | |
| 189 | + | for i := range goroutines { | |
| 190 | + | go func(n int) { | |
| 191 | + | defer wg.Done() | |
| 192 | + | e := sampleEntry | |
| 193 | + | e.RequestID = fmt.Sprintf("req-%d", n) | |
| 194 | + | assert.NoError(t, writer.Write(e)) | |
| 195 | + | }(i) | |
| 196 | + | } | |
| 197 | + | wg.Wait() | |
| 198 | + | ||
| 199 | + | data, err := os.ReadFile(path) | |
| 200 | + | require.NoError(t, err) | |
| 201 | + | ||
| 202 | + | lines := strings.Split(strings.TrimSpace(string(data)), "\n") | |
| 203 | + | assert.Len(t, lines, goroutines, "each goroutine should produce exactly one line") | |
| 204 | + | for _, l := range lines { | |
| 205 | + | var obj map[string]any | |
| 206 | + | assert.NoError(t, json.Unmarshal([]byte(l), &obj), "each line must be valid JSON") | |
| 207 | + | } | |
| 208 | + | } | |
| @@ -0,0 +1,208 @@ | |||||
| 1 | + | package accesslog | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "fmt" | |||
| 7 | + | "os" | |||
| 8 | + | "path/filepath" | |||
| 9 | + | "strings" | |||
| 10 | + | "sync" | |||
| 11 | + | "testing" | |||
| 12 | + | "time" | |||
| 13 | + | ||||
| 14 | + | "github.com/stretchr/testify/assert" | |||
| 15 | + | "github.com/stretchr/testify/require" | |||
| 16 | + | ||||
| 17 | + | out "github.com/bnema/gordon/internal/boundaries/out" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | var sampleEntry = out.AccessLogEntry{ | |||
| 21 | + | Time: time.Date(2026, 4, 3, 14, 20, 54, 167_000_000, time.UTC), | |||
| 22 | + | ClientIP: "10.0.0.1", | |||
| 23 | + | Method: "POST", | |||
| 24 | + | Host: "api.example.com", | |||
| 25 | + | Path: "/api/v1/data", | |||
| 26 | + | Query: "limit=10", | |||
| 27 | + | Status: 200, | |||
| 28 | + | BytesSent: 512, | |||
| 29 | + | DurationMS: 3.5, | |||
| 30 | + | UserAgent: "curl/7.88", | |||
| 31 | + | Referer: "https://example.com", | |||
| 32 | + | RequestID: "abc123", | |||
| 33 | + | Proto: "HTTP/2.0", | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // --- New() validation --- | |||
| 37 | + | ||||
| 38 | + | func TestNew_InvalidFormat(t *testing.T) { | |||
| 39 | + | _, err := New(Config{Format: "xml", Output: "stdout"}) | |||
| 40 | + | require.Error(t, err) | |||
| 41 | + | assert.Contains(t, err.Error(), "invalid logging.access_log.format") | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func TestNew_InvalidOutput(t *testing.T) { | |||
| 45 | + | _, err := New(Config{Format: "json", Output: "kafka"}) | |||
| 46 | + | require.Error(t, err) | |||
| 47 | + | assert.Contains(t, err.Error(), "invalid logging.access_log.output") | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func TestNew_FileOutput_EmptyPath(t *testing.T) { | |||
| 51 | + | _, err := New(Config{Format: "json", Output: "file", FilePath: ""}) | |||
| 52 | + | require.Error(t, err) | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func TestNew_FileOutput_CreatesDirectory(t *testing.T) { | |||
| 56 | + | dir := t.TempDir() | |||
| 57 | + | path := filepath.Join(dir, "sub", "access.log") | |||
| 58 | + | w, err := New(Config{ | |||
| 59 | + | Format: "json", | |||
| 60 | + | Output: "file", | |||
| 61 | + | FilePath: path, | |||
| 62 | + | MaxSize: 10, | |||
| 63 | + | }) | |||
| 64 | + | require.NoError(t, err) | |||
| 65 | + | defer w.Close() | |||
| 66 | + | ||||
| 67 | + | assert.DirExists(t, filepath.Join(dir, "sub")) | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | // --- stdout sink --- | |||
| 71 | + | ||||
| 72 | + | func TestWriter_Stdout_JSON(t *testing.T) { | |||
| 73 | + | // Replace stdout to capture output. | |||
| 74 | + | orig := os.Stdout | |||
| 75 | + | r, w, _ := os.Pipe() | |||
| 76 | + | os.Stdout = w | |||
| 77 | + | ||||
| 78 | + | writer, err := New(Config{Format: "json", Output: "stdout"}) | |||
| 79 | + | require.NoError(t, err) | |||
| 80 | + | ||||
| 81 | + | require.NoError(t, writer.Write(sampleEntry)) | |||
| 82 | + | require.NoError(t, writer.Close()) | |||
| 83 | + | ||||
| 84 | + | w.Close() | |||
| 85 | + | os.Stdout = orig | |||
| 86 | + | ||||
| 87 | + | var buf bytes.Buffer | |||
| 88 | + | _, _ = buf.ReadFrom(r) | |||
| 89 | + | line := strings.TrimSpace(buf.String()) | |||
| 90 | + | ||||
| 91 | + | var got map[string]any | |||
| 92 | + | require.NoError(t, json.Unmarshal([]byte(line), &got), "output must be valid JSON: %s", line) | |||
| 93 | + | assert.Equal(t, "10.0.0.1", got["client_ip"]) | |||
| 94 | + | assert.Equal(t, "POST", got["method"]) | |||
| 95 | + | assert.InDelta(t, 200, got["status"], 0) | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | func TestWriter_Stdout_CLF(t *testing.T) { | |||
| 99 | + | orig := os.Stdout | |||
| 100 | + | r, w, _ := os.Pipe() | |||
| 101 | + | os.Stdout = w | |||
| 102 | + | ||||
| 103 | + | writer, err := New(Config{Format: "clf", Output: "stdout"}) | |||
| 104 | + | require.NoError(t, err) | |||
| 105 | + | require.NoError(t, writer.Write(sampleEntry)) | |||
| 106 | + | require.NoError(t, writer.Close()) | |||
| 107 | + | ||||
| 108 | + | w.Close() | |||
| 109 | + | os.Stdout = orig | |||
| 110 | + | ||||
| 111 | + | var buf bytes.Buffer | |||
| 112 | + | _, _ = buf.ReadFrom(r) | |||
| 113 | + | line := strings.TrimSpace(buf.String()) | |||
| 114 | + | ||||
| 115 | + | assert.True(t, strings.HasPrefix(line, "10.0.0.1 - - [")) | |||
| 116 | + | assert.Contains(t, line, `"POST /api/v1/data?limit=10 HTTP/2.0"`) | |||
| 117 | + | assert.Contains(t, line, " 200 512") | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func TestWriter_Stdout_Combined(t *testing.T) { | |||
| 121 | + | orig := os.Stdout | |||
| 122 | + | r, w, _ := os.Pipe() | |||
| 123 | + | os.Stdout = w | |||
| 124 | + | ||||
| 125 | + | writer, err := New(Config{Format: "combined", Output: "stdout"}) | |||
| 126 | + | require.NoError(t, err) | |||
| 127 | + | require.NoError(t, writer.Write(sampleEntry)) | |||
| 128 | + | require.NoError(t, writer.Close()) | |||
| 129 | + | ||||
| 130 | + | w.Close() | |||
| 131 | + | os.Stdout = orig | |||
| 132 | + | ||||
| 133 | + | var buf bytes.Buffer | |||
| 134 | + | _, _ = buf.ReadFrom(r) | |||
| 135 | + | line := strings.TrimSpace(buf.String()) | |||
| 136 | + | ||||
| 137 | + | assert.Contains(t, line, `"https://example.com"`) | |||
| 138 | + | assert.Contains(t, line, `"curl/7.88"`) | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | // --- file sink --- | |||
| 142 | + | ||||
| 143 | + | func TestWriter_File_WritesLines(t *testing.T) { | |||
| 144 | + | dir := t.TempDir() | |||
| 145 | + | path := filepath.Join(dir, "access.log") | |||
| 146 | + | ||||
| 147 | + | writer, err := New(Config{ | |||
| 148 | + | Format: "json", | |||
| 149 | + | Output: "file", | |||
| 150 | + | FilePath: path, | |||
| 151 | + | MaxSize: 10, | |||
| 152 | + | MaxBackups: 1, | |||
| 153 | + | }) | |||
| 154 | + | require.NoError(t, err) | |||
| 155 | + | ||||
| 156 | + | require.NoError(t, writer.Write(sampleEntry)) | |||
| 157 | + | require.NoError(t, writer.Write(sampleEntry)) | |||
| 158 | + | require.NoError(t, writer.Close()) | |||
| 159 | + | ||||
| 160 | + | data, err := os.ReadFile(path) | |||
| 161 | + | require.NoError(t, err) | |||
| 162 | + | ||||
| 163 | + | lines := strings.Split(strings.TrimSpace(string(data)), "\n") | |||
| 164 | + | assert.Len(t, lines, 2) | |||
| 165 | + | for _, l := range lines { | |||
| 166 | + | var obj map[string]any | |||
| 167 | + | assert.NoError(t, json.Unmarshal([]byte(l), &obj), "each line must be valid JSON") | |||
| 168 | + | } | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | // --- concurrency --- | |||
| 172 | + | ||||
| 173 | + | func TestWriter_ConcurrentWrites(t *testing.T) { | |||
| 174 | + | dir := t.TempDir() | |||
| 175 | + | path := filepath.Join(dir, "concurrent.log") | |||
| 176 | + | ||||
| 177 | + | writer, err := New(Config{ | |||
| 178 | + | Format: "json", | |||
| 179 | + | Output: "file", | |||
| 180 | + | FilePath: path, | |||
| 181 | + | MaxSize: 100, | |||
| 182 | + | }) | |||
| 183 | + | require.NoError(t, err) | |||
| 184 | + | defer writer.Close() | |||
| 185 | + | ||||
| 186 | + | const goroutines = 50 | |||
| 187 | + | var wg sync.WaitGroup | |||
| 188 | + | wg.Add(goroutines) | |||
| 189 | + | for i := range goroutines { | |||
| 190 | + | go func(n int) { | |||
| 191 | + | defer wg.Done() | |||
| 192 | + | e := sampleEntry | |||
| 193 | + | e.RequestID = fmt.Sprintf("req-%d", n) | |||
| 194 | + | assert.NoError(t, writer.Write(e)) | |||
| 195 | + | }(i) | |||
| 196 | + | } | |||
| 197 | + | wg.Wait() | |||
| 198 | + | ||||
| 199 | + | data, err := os.ReadFile(path) | |||
| 200 | + | require.NoError(t, err) | |||
| 201 | + | ||||
| 202 | + | lines := strings.Split(strings.TrimSpace(string(data)), "\n") | |||
| 203 | + | assert.Len(t, lines, goroutines, "each goroutine should produce exactly one line") | |||
| 204 | + | for _, l := range lines { | |||
| 205 | + | var obj map[string]any | |||
| 206 | + | assert.NoError(t, json.Unmarshal([]byte(l), &obj), "each line must be valid JSON") | |||
| 207 | + | } | |||
| 208 | + | } | |||
M internal/adapters/out/httpprober/prober.go +4 -2
| @@ -7,6 +7,8 @@ import ( | |||
| 7 | 7 | "fmt" | |
| 8 | 8 | "net/http" | |
| 9 | 9 | "time" | |
| 10 | + | ||
| 11 | + | out "github.com/bnema/gordon/internal/boundaries/out" | |
| 10 | 12 | ) | |
| 11 | 13 | ||
| 12 | 14 | // DefaultTimeout is the default timeout for HTTP probes. | |
| @@ -76,8 +78,8 @@ func (p *Prober) Probe(ctx context.Context, url string) (int, int64, error) { | |||
| 76 | 78 | return 0, 0, fmt.Errorf("failed to create request: %w", err) | |
| 77 | 79 | } | |
| 78 | 80 | ||
| 79 | - | // Set a reasonable user agent | |
| 80 | - | req.Header.Set("User-Agent", "Gordon-HealthCheck/1.0") | |
| 81 | + | // Set a recognisable user agent so access-log filters can exclude probe traffic. | |
| 82 | + | req.Header.Set("User-Agent", out.HealthCheckUserAgentPrefix+"1.0") | |
| 81 | 83 | ||
| 82 | 84 | resp, err := p.client.Do(req) | |
| 83 | 85 | if err != nil { | |
| @@ -7,6 +7,8 @@ import ( | |||||
| 7 | "fmt" | 7 | "fmt" | ||
| 8 | "net/http" | 8 | "net/http" | ||
| 9 | "time" | 9 | "time" | ||
| 10 | + | ||||
| 11 | + | out "github.com/bnema/gordon/internal/boundaries/out" | |||
| 10 | ) | 12 | ) | ||
| 11 | 13 | ||||
| 12 | // DefaultTimeout is the default timeout for HTTP probes. | 14 | // DefaultTimeout is the default timeout for HTTP probes. | ||
| @@ -76,8 +78,8 @@ func (p *Prober) Probe(ctx context.Context, url string) (int, int64, error) { | |||||
| 76 | return 0, 0, fmt.Errorf("failed to create request: %w", err) | 78 | return 0, 0, fmt.Errorf("failed to create request: %w", err) | ||
| 77 | } | 79 | } | ||
| 78 | 80 | ||||
| 79 | - | // Set a reasonable user agent | 81 | + | // Set a recognisable user agent so access-log filters can exclude probe traffic. |
| 80 | - | req.Header.Set("User-Agent", "Gordon-HealthCheck/1.0") | 82 | + | req.Header.Set("User-Agent", out.HealthCheckUserAgentPrefix+"1.0") |
| 81 | 83 | ||||
| 82 | resp, err := p.client.Do(req) | 84 | resp, err := p.client.Do(req) | ||
| 83 | if err != nil { | 85 | if err != nil { | ||
M internal/app/run.go +91 -7
| @@ -25,6 +25,7 @@ import ( | |||
| 25 | 25 | "github.com/spf13/viper" | |
| 26 | 26 | ||
| 27 | 27 | // Adapters - Output | |
| 28 | + | "github.com/bnema/gordon/internal/adapters/out/accesslog" | |
| 28 | 29 | "github.com/bnema/gordon/internal/adapters/out/docker" | |
| 29 | 30 | "github.com/bnema/gordon/internal/adapters/out/domainsecrets" | |
| 30 | 31 | "github.com/bnema/gordon/internal/adapters/out/envloader" | |
| @@ -119,6 +120,17 @@ type Config struct { | |||
| 119 | 120 | MaxBackups int `mapstructure:"max_backups"` | |
| 120 | 121 | MaxAge int `mapstructure:"max_age"` | |
| 121 | 122 | } `mapstructure:"container_logs"` | |
| 123 | + | AccessLog struct { | |
| 124 | + | Enabled bool `mapstructure:"enabled"` | |
| 125 | + | Format string `mapstructure:"format"` | |
| 126 | + | Output string `mapstructure:"output"` | |
| 127 | + | FilePath string `mapstructure:"file_path"` | |
| 128 | + | MaxSize int `mapstructure:"max_size"` | |
| 129 | + | MaxBackups int `mapstructure:"max_backups"` | |
| 130 | + | MaxAge int `mapstructure:"max_age"` | |
| 131 | + | ExcludeHealthChecks bool `mapstructure:"exclude_health_checks"` | |
| 132 | + | SyslogIdentifier string `mapstructure:"syslog_identifier"` | |
| 133 | + | } `mapstructure:"access_log"` | |
| 122 | 134 | } `mapstructure:"logging"` | |
| 123 | 135 | ||
| 124 | 136 | Env struct { | |
| @@ -342,6 +354,43 @@ func initLogger(cfg Config) (zerowrap.Logger, func(), error) { | |||
| 342 | 354 | return log, cleanup, nil | |
| 343 | 355 | } | |
| 344 | 356 | ||
| 357 | + | // initAccessLog creates an access log writer when access logging is enabled. | |
| 358 | + | // Returns nil, nil when disabled — callers must treat nil writer as "disabled". | |
| 359 | + | func initAccessLog(cfg Config, log zerowrap.Logger) (*accesslog.Writer, error) { | |
| 360 | + | if !cfg.Logging.AccessLog.Enabled { | |
| 361 | + | return nil, nil | |
| 362 | + | } | |
| 363 | + | ||
| 364 | + | filePath := cfg.Logging.AccessLog.FilePath | |
| 365 | + | if filePath == "" && cfg.Logging.AccessLog.Output == "file" { | |
| 366 | + | dataDir := cfg.Server.DataDir | |
| 367 | + | if dataDir == "" { | |
| 368 | + | dataDir = DefaultDataDir() | |
| 369 | + | } | |
| 370 | + | filePath = filepath.Join(dataDir, "logs", "access.log") | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | writer, err := accesslog.New(accesslog.Config{ | |
| 374 | + | Format: cfg.Logging.AccessLog.Format, | |
| 375 | + | Output: cfg.Logging.AccessLog.Output, | |
| 376 | + | FilePath: filePath, | |
| 377 | + | MaxSize: cfg.Logging.AccessLog.MaxSize, | |
| 378 | + | MaxBackups: cfg.Logging.AccessLog.MaxBackups, | |
| 379 | + | MaxAge: cfg.Logging.AccessLog.MaxAge, | |
| 380 | + | SyslogIdentifier: cfg.Logging.AccessLog.SyslogIdentifier, | |
| 381 | + | }) | |
| 382 | + | if err != nil { | |
| 383 | + | return nil, fmt.Errorf("failed to initialize access log: %w", err) | |
| 384 | + | } | |
| 385 | + | ||
| 386 | + | log.Info(). | |
| 387 | + | Str("format", cfg.Logging.AccessLog.Format). | |
| 388 | + | Str("output", cfg.Logging.AccessLog.Output). | |
| 389 | + | Msg("access log enabled") | |
| 390 | + | ||
| 391 | + | return writer, nil | |
| 392 | + | } | |
| 393 | + | ||
| 345 | 394 | // serviceInit holds the shared context for service initialization helpers. | |
| 346 | 395 | type serviceInit struct { | |
| 347 | 396 | ctx context.Context | |
| @@ -1611,7 +1660,7 @@ func loopbackOnly(next http.Handler, log zerowrap.Logger) http.Handler { | |||
| 1611 | 1660 | ||
| 1612 | 1661 | // createHTTPHandlers creates HTTP handlers with middleware. | |
| 1613 | 1662 | // Returns three handlers: registry, HTTP proxy (with CIDR + onboarding), and HTTPS proxy. | |
| 1614 | - | func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Handler, http.Handler, http.Handler) { | |
| 1663 | + | func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWriter out.AccessLogWriter) (http.Handler, http.Handler, http.Handler) { | |
| 1615 | 1664 | // Parse trusted proxies once for all middleware chains. | |
| 1616 | 1665 | // This ensures consistent IP extraction across logging, rate limiting, and auth. | |
| 1617 | 1666 | trustedNets := middleware.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies) | |
| @@ -1632,7 +1681,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||
| 1632 | 1681 | registerAdminRoutes(registryMux, svc, cfg, trustedNets, log) | |
| 1633 | 1682 | ||
| 1634 | 1683 | // Proxy handler | |
| 1635 | - | proxyHandler := proxyadapter.NewHandler(svc.proxySvc, log) | |
| 1684 | + | proxyHandler := proxyadapter.NewHandler(svc.proxySvc, trustedNets, log) | |
| 1636 | 1685 | ||
| 1637 | 1686 | // HTTP proxy handler chain: HTTPS redirect for non-proxy clients, then CIDR allowlist | |
| 1638 | 1687 | proxyAllowedNets, proxyCIDRMiddleware := buildProxyCIDRAllowlistMiddleware(cfg, trustedNets, log) | |
| @@ -1703,7 +1752,18 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||
| 1703 | 1752 | ||
| 1704 | 1753 | httpsHandler := otelhttp.NewHandler(middleware.Chain(httpsProxyMiddlewares...)(httpsMux), "gordon.proxy.tls") | |
| 1705 | 1754 | ||
| 1706 | - | return registryMux, proxyMux, httpsHandler | |
| 1755 | + | // Wrap top-level handlers with access logging outside all gates | |
| 1756 | + | // (loopbackOnly, denyAllHandler, CIDR allowlist) so every request — | |
| 1757 | + | // including rejected probes — produces exactly one access-log line. | |
| 1758 | + | var registryOut, proxyOut, httpsOut http.Handler = registryMux, proxyMux, httpsHandler | |
| 1759 | + | if accessWriter != nil { | |
| 1760 | + | excludeHC := cfg.Logging.AccessLog.ExcludeHealthChecks | |
| 1761 | + | registryOut = middleware.AccessLogger(accessWriter, excludeHC, log, trustedNets)(registryOut) | |
| 1762 | + | proxyOut = middleware.AccessLogger(accessWriter, excludeHC, log, trustedNets)(proxyOut) | |
| 1763 | + | httpsOut = middleware.AccessLogger(accessWriter, excludeHC, log, trustedNets)(httpsOut) | |
| 1764 | + | } | |
| 1765 | + | ||
| 1766 | + | return registryOut, proxyOut, httpsOut | |
| 1707 | 1767 | } | |
| 1708 | 1768 | ||
| 1709 | 1769 | // directHTTPOnboardingGate returns an http.Handler that splits HTTP traffic | |
| @@ -1775,7 +1835,7 @@ func buildRegistryHandlerWithMiddleware( | |||
| 1775 | 1835 | rateLimitMiddleware := buildRegistryRateLimitMiddleware(cfg, log) | |
| 1776 | 1836 | registryMiddlewares = append(registryMiddlewares, rateLimitMiddleware) | |
| 1777 | 1837 | ||
| 1778 | - | appendRegistryAuthMiddleware(®istryMiddlewares, svc, cfg, log) | |
| 1838 | + | appendRegistryAuthMiddleware(®istryMiddlewares, svc, cfg, trustedNets, log) | |
| 1779 | 1839 | ||
| 1780 | 1840 | registryWithOtel := otelhttp.NewHandler( | |
| 1781 | 1841 | middleware.Chain(registryMiddlewares...)(registryHandler), | |
| @@ -1866,13 +1926,13 @@ func buildRegistryRateLimitMiddleware(cfg Config, log zerowrap.Logger) func(http | |||
| 1866 | 1926 | return registry.RateLimitMiddleware(nil, nil, nil, log) | |
| 1867 | 1927 | } | |
| 1868 | 1928 | ||
| 1869 | - | func appendRegistryAuthMiddleware(registryMiddlewares *[]func(http.Handler) http.Handler, svc *services, cfg Config, log zerowrap.Logger) { | |
| 1929 | + | func appendRegistryAuthMiddleware(registryMiddlewares *[]func(http.Handler) http.Handler, svc *services, cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) { | |
| 1870 | 1930 | if svc.authSvc != nil { | |
| 1871 | 1931 | internalAuth := middleware.InternalRegistryAuth{ | |
| 1872 | 1932 | Username: svc.internalRegUser, | |
| 1873 | 1933 | Password: svc.internalRegPass, | |
| 1874 | 1934 | } | |
| 1875 | - | *registryMiddlewares = append(*registryMiddlewares, middleware.RegistryAuthV2(svc.authSvc, internalAuth, log)) | |
| 1935 | + | *registryMiddlewares = append(*registryMiddlewares, middleware.RegistryAuthV2(svc.authSvc, internalAuth, trustedNets, log)) | |
| 1876 | 1936 | return | |
| 1877 | 1937 | } | |
| 1878 | 1938 | ||
| @@ -1984,6 +2044,21 @@ func registerAdminRoutes(registryMux *http.ServeMux, svc *services, cfg Config, | |||
| 1984 | 2044 | // The deferred signal.Stop calls ensure signal handlers are properly | |
| 1985 | 2045 | // cleaned up before program exit, preventing signal handler leaks. | |
| 1986 | 2046 | func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services, cleanupHandlers func(), log zerowrap.Logger) error { | |
| 2047 | + | // Initialize access log writer. Kept here (not in Run) to keep Run's cyclomatic | |
| 2048 | + | // complexity within the project limit of 15. | |
| 2049 | + | accessWriterConcrete, err := initAccessLog(cfg, log) | |
| 2050 | + | if err != nil { | |
| 2051 | + | return err | |
| 2052 | + | } | |
| 2053 | + | if accessWriterConcrete != nil { | |
| 2054 | + | defer accessWriterConcrete.Close() | |
| 2055 | + | } | |
| 2056 | + | // Convert to interface only when non-nil to avoid the Go nil-interface pitfall | |
| 2057 | + | // where a typed nil pointer becomes a non-nil interface value. | |
| 2058 | + | var accessWriter out.AccessLogWriter | |
| 2059 | + | if accessWriterConcrete != nil { | |
| 2060 | + | accessWriter = accessWriterConcrete | |
| 2061 | + | } | |
| 1987 | 2062 | ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) | |
| 1988 | 2063 | defer cancel() | |
| 1989 | 2064 | ||
| @@ -2001,7 +2076,7 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services, | |||
| 2001 | 2076 | ||
| 2002 | 2077 | errChan := make(chan error, 3) | |
| 2003 | 2078 | ||
| 2004 | - | registryHandler, httpProxyHandler, httpsProxyHandler := createHTTPHandlers(svc, cfg, log) | |
| 2079 | + | registryHandler, httpProxyHandler, httpsProxyHandler := createHTTPHandlers(svc, cfg, log, accessWriter) | |
| 2005 | 2080 | ||
| 2006 | 2081 | registryAddr := net.JoinHostPort(cfg.Server.RegistryListenAddr, strconv.Itoa(cfg.Server.RegistryPort)) | |
| 2007 | 2082 | registrySrv, registryReady := startServer(registryAddr, registryHandler, "registry", nil, errChan, log) | |
| @@ -2702,6 +2777,15 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||
| 2702 | 2777 | v.SetDefault("logging.container_logs.max_size", 100) | |
| 2703 | 2778 | v.SetDefault("logging.container_logs.max_backups", 3) | |
| 2704 | 2779 | v.SetDefault("logging.container_logs.max_age", 28) | |
| 2780 | + | v.SetDefault("logging.access_log.enabled", false) | |
| 2781 | + | v.SetDefault("logging.access_log.format", "json") | |
| 2782 | + | v.SetDefault("logging.access_log.output", "stdout") | |
| 2783 | + | v.SetDefault("logging.access_log.file_path", "") | |
| 2784 | + | v.SetDefault("logging.access_log.max_size", 100) | |
| 2785 | + | v.SetDefault("logging.access_log.max_backups", 3) | |
| 2786 | + | v.SetDefault("logging.access_log.max_age", 28) | |
| 2787 | + | v.SetDefault("logging.access_log.exclude_health_checks", true) | |
| 2788 | + | v.SetDefault("logging.access_log.syslog_identifier", "gordon-access") | |
| 2705 | 2789 | v.SetDefault("env.dir", "") // defaults to {data_dir}/env when empty | |
| 2706 | 2790 | v.SetDefault("auth.enabled", true) | |
| 2707 | 2791 | // Note: auth.type defaults to "token" (the only supported mode) | |
| @@ -25,6 +25,7 @@ import ( | |||||
| 25 | "github.com/spf13/viper" | 25 | "github.com/spf13/viper" | ||
| 26 | 26 | ||||
| 27 | // Adapters - Output | 27 | // Adapters - Output | ||
| 28 | + | "github.com/bnema/gordon/internal/adapters/out/accesslog" | |||
| 28 | "github.com/bnema/gordon/internal/adapters/out/docker" | 29 | "github.com/bnema/gordon/internal/adapters/out/docker" | ||
| 29 | "github.com/bnema/gordon/internal/adapters/out/domainsecrets" | 30 | "github.com/bnema/gordon/internal/adapters/out/domainsecrets" | ||
| 30 | "github.com/bnema/gordon/internal/adapters/out/envloader" | 31 | "github.com/bnema/gordon/internal/adapters/out/envloader" | ||
| @@ -119,6 +120,17 @@ type Config struct { | |||||
| 119 | MaxBackups int `mapstructure:"max_backups"` | 120 | MaxBackups int `mapstructure:"max_backups"` | ||
| 120 | MaxAge int `mapstructure:"max_age"` | 121 | MaxAge int `mapstructure:"max_age"` | ||
| 121 | } `mapstructure:"container_logs"` | 122 | } `mapstructure:"container_logs"` | ||
| 123 | + | AccessLog struct { | |||
| 124 | + | Enabled bool `mapstructure:"enabled"` | |||
| 125 | + | Format string `mapstructure:"format"` | |||
| 126 | + | Output string `mapstructure:"output"` | |||
| 127 | + | FilePath string `mapstructure:"file_path"` | |||
| 128 | + | MaxSize int `mapstructure:"max_size"` | |||
| 129 | + | MaxBackups int `mapstructure:"max_backups"` | |||
| 130 | + | MaxAge int `mapstructure:"max_age"` | |||
| 131 | + | ExcludeHealthChecks bool `mapstructure:"exclude_health_checks"` | |||
| 132 | + | SyslogIdentifier string `mapstructure:"syslog_identifier"` | |||
| 133 | + | } `mapstructure:"access_log"` | |||
| 122 | } `mapstructure:"logging"` | 134 | } `mapstructure:"logging"` | ||
| 123 | 135 | ||||
| 124 | Env struct { | 136 | Env struct { | ||
| @@ -342,6 +354,43 @@ func initLogger(cfg Config) (zerowrap.Logger, func(), error) { | |||||
| 342 | return log, cleanup, nil | 354 | return log, cleanup, nil | ||
| 343 | } | 355 | } | ||
| 344 | 356 | ||||
| 357 | + | // initAccessLog creates an access log writer when access logging is enabled. | |||
| 358 | + | // Returns nil, nil when disabled — callers must treat nil writer as "disabled". | |||
| 359 | + | func initAccessLog(cfg Config, log zerowrap.Logger) (*accesslog.Writer, error) { | |||
| 360 | + | if !cfg.Logging.AccessLog.Enabled { | |||
| 361 | + | return nil, nil | |||
| 362 | + | } | |||
| 363 | + | ||||
| 364 | + | filePath := cfg.Logging.AccessLog.FilePath | |||
| 365 | + | if filePath == "" && cfg.Logging.AccessLog.Output == "file" { | |||
| 366 | + | dataDir := cfg.Server.DataDir | |||
| 367 | + | if dataDir == "" { | |||
| 368 | + | dataDir = DefaultDataDir() | |||
| 369 | + | } | |||
| 370 | + | filePath = filepath.Join(dataDir, "logs", "access.log") | |||
| 371 | + | } | |||
| 372 | + | ||||
| 373 | + | writer, err := accesslog.New(accesslog.Config{ | |||
| 374 | + | Format: cfg.Logging.AccessLog.Format, | |||
| 375 | + | Output: cfg.Logging.AccessLog.Output, | |||
| 376 | + | FilePath: filePath, | |||
| 377 | + | MaxSize: cfg.Logging.AccessLog.MaxSize, | |||
| 378 | + | MaxBackups: cfg.Logging.AccessLog.MaxBackups, | |||
| 379 | + | MaxAge: cfg.Logging.AccessLog.MaxAge, | |||
| 380 | + | SyslogIdentifier: cfg.Logging.AccessLog.SyslogIdentifier, | |||
| 381 | + | }) | |||
| 382 | + | if err != nil { | |||
| 383 | + | return nil, fmt.Errorf("failed to initialize access log: %w", err) | |||
| 384 | + | } | |||
| 385 | + | ||||
| 386 | + | log.Info(). | |||
| 387 | + | Str("format", cfg.Logging.AccessLog.Format). | |||
| 388 | + | Str("output", cfg.Logging.AccessLog.Output). | |||
| 389 | + | Msg("access log enabled") | |||
| 390 | + | ||||
| 391 | + | return writer, nil | |||
| 392 | + | } | |||
| 393 | + | ||||
| 345 | // serviceInit holds the shared context for service initialization helpers. | 394 | // serviceInit holds the shared context for service initialization helpers. | ||
| 346 | type serviceInit struct { | 395 | type serviceInit struct { | ||
| 347 | ctx context.Context | 396 | ctx context.Context | ||
| @@ -1611,7 +1660,7 @@ func loopbackOnly(next http.Handler, log zerowrap.Logger) http.Handler { | |||||
| 1611 | 1660 | ||||
| 1612 | // createHTTPHandlers creates HTTP handlers with middleware. | 1661 | // createHTTPHandlers creates HTTP handlers with middleware. | ||
| 1613 | // Returns three handlers: registry, HTTP proxy (with CIDR + onboarding), and HTTPS proxy. | 1662 | // Returns three handlers: registry, HTTP proxy (with CIDR + onboarding), and HTTPS proxy. | ||
| 1614 | - | func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Handler, http.Handler, http.Handler) { | 1663 | + | func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWriter out.AccessLogWriter) (http.Handler, http.Handler, http.Handler) { |
| 1615 | // Parse trusted proxies once for all middleware chains. | 1664 | // Parse trusted proxies once for all middleware chains. | ||
| 1616 | // This ensures consistent IP extraction across logging, rate limiting, and auth. | 1665 | // This ensures consistent IP extraction across logging, rate limiting, and auth. | ||
| 1617 | trustedNets := middleware.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies) | 1666 | trustedNets := middleware.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies) | ||
| @@ -1632,7 +1681,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||||
| 1632 | registerAdminRoutes(registryMux, svc, cfg, trustedNets, log) | 1681 | registerAdminRoutes(registryMux, svc, cfg, trustedNets, log) | ||
| 1633 | 1682 | ||||
| 1634 | // Proxy handler | 1683 | // Proxy handler | ||
| 1635 | - | proxyHandler := proxyadapter.NewHandler(svc.proxySvc, log) | 1684 | + | proxyHandler := proxyadapter.NewHandler(svc.proxySvc, trustedNets, log) |
| 1636 | 1685 | ||||
| 1637 | // HTTP proxy handler chain: HTTPS redirect for non-proxy clients, then CIDR allowlist | 1686 | // HTTP proxy handler chain: HTTPS redirect for non-proxy clients, then CIDR allowlist | ||
| 1638 | proxyAllowedNets, proxyCIDRMiddleware := buildProxyCIDRAllowlistMiddleware(cfg, trustedNets, log) | 1687 | proxyAllowedNets, proxyCIDRMiddleware := buildProxyCIDRAllowlistMiddleware(cfg, trustedNets, log) | ||
| @@ -1703,7 +1752,18 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||||
| 1703 | 1752 | ||||
| 1704 | httpsHandler := otelhttp.NewHandler(middleware.Chain(httpsProxyMiddlewares...)(httpsMux), "gordon.proxy.tls") | 1753 | httpsHandler := otelhttp.NewHandler(middleware.Chain(httpsProxyMiddlewares...)(httpsMux), "gordon.proxy.tls") | ||
| 1705 | 1754 | ||||
| 1706 | - | return registryMux, proxyMux, httpsHandler | 1755 | + | // Wrap top-level handlers with access logging outside all gates |
| 1756 | + | // (loopbackOnly, denyAllHandler, CIDR allowlist) so every request — | |||
| 1757 | + | // including rejected probes — produces exactly one access-log line. | |||
| 1758 | + | var registryOut, proxyOut, httpsOut http.Handler = registryMux, proxyMux, httpsHandler | |||
| 1759 | + | if accessWriter != nil { | |||
| 1760 | + | excludeHC := cfg.Logging.AccessLog.ExcludeHealthChecks | |||
| 1761 | + | registryOut = middleware.AccessLogger(accessWriter, excludeHC, log, trustedNets)(registryOut) | |||
| 1762 | + | proxyOut = middleware.AccessLogger(accessWriter, excludeHC, log, trustedNets)(proxyOut) | |||
| 1763 | + | httpsOut = middleware.AccessLogger(accessWriter, excludeHC, log, trustedNets)(httpsOut) | |||
| 1764 | + | } | |||
| 1765 | + | ||||
| 1766 | + | return registryOut, proxyOut, httpsOut | |||
| 1707 | } | 1767 | } | ||
| 1708 | 1768 | ||||
| 1709 | // directHTTPOnboardingGate returns an http.Handler that splits HTTP traffic | 1769 | // directHTTPOnboardingGate returns an http.Handler that splits HTTP traffic | ||
| @@ -1775,7 +1835,7 @@ func buildRegistryHandlerWithMiddleware( | |||||
| 1775 | rateLimitMiddleware := buildRegistryRateLimitMiddleware(cfg, log) | 1835 | rateLimitMiddleware := buildRegistryRateLimitMiddleware(cfg, log) | ||
| 1776 | registryMiddlewares = append(registryMiddlewares, rateLimitMiddleware) | 1836 | registryMiddlewares = append(registryMiddlewares, rateLimitMiddleware) | ||
| 1777 | 1837 | ||||
| 1778 | - | appendRegistryAuthMiddleware(®istryMiddlewares, svc, cfg, log) | 1838 | + | appendRegistryAuthMiddleware(®istryMiddlewares, svc, cfg, trustedNets, log) |
| 1779 | 1839 | ||||
| 1780 | registryWithOtel := otelhttp.NewHandler( | 1840 | registryWithOtel := otelhttp.NewHandler( | ||
| 1781 | middleware.Chain(registryMiddlewares...)(registryHandler), | 1841 | middleware.Chain(registryMiddlewares...)(registryHandler), | ||
| @@ -1866,13 +1926,13 @@ func buildRegistryRateLimitMiddleware(cfg Config, log zerowrap.Logger) func(http | |||||
| 1866 | return registry.RateLimitMiddleware(nil, nil, nil, log) | 1926 | return registry.RateLimitMiddleware(nil, nil, nil, log) | ||
| 1867 | } | 1927 | } | ||
| 1868 | 1928 | ||||
| 1869 | - | func appendRegistryAuthMiddleware(registryMiddlewares *[]func(http.Handler) http.Handler, svc *services, cfg Config, log zerowrap.Logger) { | 1929 | + | func appendRegistryAuthMiddleware(registryMiddlewares *[]func(http.Handler) http.Handler, svc *services, cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) { |
| 1870 | if svc.authSvc != nil { | 1930 | if svc.authSvc != nil { | ||
| 1871 | internalAuth := middleware.InternalRegistryAuth{ | 1931 | internalAuth := middleware.InternalRegistryAuth{ | ||
| 1872 | Username: svc.internalRegUser, | 1932 | Username: svc.internalRegUser, | ||
| 1873 | Password: svc.internalRegPass, | 1933 | Password: svc.internalRegPass, | ||
| 1874 | } | 1934 | } | ||
| 1875 | - | *registryMiddlewares = append(*registryMiddlewares, middleware.RegistryAuthV2(svc.authSvc, internalAuth, log)) | 1935 | + | *registryMiddlewares = append(*registryMiddlewares, middleware.RegistryAuthV2(svc.authSvc, internalAuth, trustedNets, log)) |
| 1876 | return | 1936 | return | ||
| 1877 | } | 1937 | } | ||
| 1878 | 1938 | ||||
| @@ -1984,6 +2044,21 @@ func registerAdminRoutes(registryMux *http.ServeMux, svc *services, cfg Config, | |||||
| 1984 | // The deferred signal.Stop calls ensure signal handlers are properly | 2044 | // The deferred signal.Stop calls ensure signal handlers are properly | ||
| 1985 | // cleaned up before program exit, preventing signal handler leaks. | 2045 | // cleaned up before program exit, preventing signal handler leaks. | ||
| 1986 | func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services, cleanupHandlers func(), log zerowrap.Logger) error { | 2046 | func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services, cleanupHandlers func(), log zerowrap.Logger) error { | ||
| 2047 | + | // Initialize access log writer. Kept here (not in Run) to keep Run's cyclomatic | |||
| 2048 | + | // complexity within the project limit of 15. | |||
| 2049 | + | accessWriterConcrete, err := initAccessLog(cfg, log) | |||
| 2050 | + | if err != nil { | |||
| 2051 | + | return err | |||
| 2052 | + | } | |||
| 2053 | + | if accessWriterConcrete != nil { | |||
| 2054 | + | defer accessWriterConcrete.Close() | |||
| 2055 | + | } | |||
| 2056 | + | // Convert to interface only when non-nil to avoid the Go nil-interface pitfall | |||
| 2057 | + | // where a typed nil pointer becomes a non-nil interface value. | |||
| 2058 | + | var accessWriter out.AccessLogWriter | |||
| 2059 | + | if accessWriterConcrete != nil { | |||
| 2060 | + | accessWriter = accessWriterConcrete | |||
| 2061 | + | } | |||
| 1987 | ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) | 2062 | ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) | ||
| 1988 | defer cancel() | 2063 | defer cancel() | ||
| 1989 | 2064 | ||||
| @@ -2001,7 +2076,7 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services, | |||||
| 2001 | 2076 | ||||
| 2002 | errChan := make(chan error, 3) | 2077 | errChan := make(chan error, 3) | ||
| 2003 | 2078 | ||||
| 2004 | - | registryHandler, httpProxyHandler, httpsProxyHandler := createHTTPHandlers(svc, cfg, log) | 2079 | + | registryHandler, httpProxyHandler, httpsProxyHandler := createHTTPHandlers(svc, cfg, log, accessWriter) |
| 2005 | 2080 | ||||
| 2006 | registryAddr := net.JoinHostPort(cfg.Server.RegistryListenAddr, strconv.Itoa(cfg.Server.RegistryPort)) | 2081 | registryAddr := net.JoinHostPort(cfg.Server.RegistryListenAddr, strconv.Itoa(cfg.Server.RegistryPort)) | ||
| 2007 | registrySrv, registryReady := startServer(registryAddr, registryHandler, "registry", nil, errChan, log) | 2082 | registrySrv, registryReady := startServer(registryAddr, registryHandler, "registry", nil, errChan, log) | ||
| @@ -2702,6 +2777,15 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||||
| 2702 | v.SetDefault("logging.container_logs.max_size", 100) | 2777 | v.SetDefault("logging.container_logs.max_size", 100) | ||
| 2703 | v.SetDefault("logging.container_logs.max_backups", 3) | 2778 | v.SetDefault("logging.container_logs.max_backups", 3) | ||
| 2704 | v.SetDefault("logging.container_logs.max_age", 28) | 2779 | v.SetDefault("logging.container_logs.max_age", 28) | ||
| 2780 | + | v.SetDefault("logging.access_log.enabled", false) | |||
| 2781 | + | v.SetDefault("logging.access_log.format", "json") | |||
| 2782 | + | v.SetDefault("logging.access_log.output", "stdout") | |||
| 2783 | + | v.SetDefault("logging.access_log.file_path", "") | |||
| 2784 | + | v.SetDefault("logging.access_log.max_size", 100) | |||
| 2785 | + | v.SetDefault("logging.access_log.max_backups", 3) | |||
| 2786 | + | v.SetDefault("logging.access_log.max_age", 28) | |||
| 2787 | + | v.SetDefault("logging.access_log.exclude_health_checks", true) | |||
| 2788 | + | v.SetDefault("logging.access_log.syslog_identifier", "gordon-access") | |||
| 2705 | v.SetDefault("env.dir", "") // defaults to {data_dir}/env when empty | 2789 | v.SetDefault("env.dir", "") // defaults to {data_dir}/env when empty | ||
| 2706 | v.SetDefault("auth.enabled", true) | 2790 | v.SetDefault("auth.enabled", true) | ||
| 2707 | // Note: auth.type defaults to "token" (the only supported mode) | 2791 | // Note: auth.type defaults to "token" (the only supported mode) | ||
M internal/app/run_http_handlers_test.go +118 -14
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "net/http/httptest" | |
| 8 | 8 | "os" | |
| 9 | 9 | "path/filepath" | |
| 10 | + | "sync" | |
| 10 | 11 | "testing" | |
| 11 | 12 | ||
| 12 | 13 | "github.com/bnema/zerowrap" | |
| @@ -15,8 +16,30 @@ import ( | |||
| 15 | 16 | ||
| 16 | 17 | adminhttp "github.com/bnema/gordon/internal/adapters/in/http/admin" | |
| 17 | 18 | pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki" | |
| 19 | + | out "github.com/bnema/gordon/internal/boundaries/out" | |
| 18 | 20 | ) | |
| 19 | 21 | ||
| 22 | + | // testAccessLogWriter is a thread-safe mock AccessLogWriter for tests. | |
| 23 | + | type testAccessLogWriter struct { | |
| 24 | + | mu sync.Mutex | |
| 25 | + | entries []out.AccessLogEntry | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func (w *testAccessLogWriter) Write(entry out.AccessLogEntry) error { | |
| 29 | + | w.mu.Lock() | |
| 30 | + | defer w.mu.Unlock() | |
| 31 | + | w.entries = append(w.entries, entry) | |
| 32 | + | return nil | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func (w *testAccessLogWriter) snapshot() []out.AccessLogEntry { | |
| 36 | + | w.mu.Lock() | |
| 37 | + | defer w.mu.Unlock() | |
| 38 | + | result := make([]out.AccessLogEntry, len(w.entries)) | |
| 39 | + | copy(result, w.entries) | |
| 40 | + | return result | |
| 41 | + | } | |
| 42 | + | ||
| 20 | 43 | func TestCreateAuthService_Disabled_ReturnsNilServices(t *testing.T) { | |
| 21 | 44 | t.Parallel() | |
| 22 | 45 | ||
| @@ -42,7 +65,7 @@ func TestCreateHTTPHandlers_LocalMode_DisablesAdminRoutes(t *testing.T) { | |||
| 42 | 65 | cfg.Auth.Enabled = false | |
| 43 | 66 | ||
| 44 | 67 | svc := &services{adminHandler: &adminhttp.Handler{}} | |
| 45 | - | registryHandler, _, _ := createHTTPHandlers(svc, cfg, zerowrap.Default()) | |
| 68 | + | registryHandler, _, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | |
| 46 | 69 | ||
| 47 | 70 | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | |
| 48 | 71 | req.RemoteAddr = "192.0.2.10:12345" | |
| @@ -61,7 +84,7 @@ func TestCreateHTTPHandlers_LocalMode_RestrictsRegistryToLoopback(t *testing.T) | |||
| 61 | 84 | cfg := Config{} | |
| 62 | 85 | cfg.Auth.Enabled = false | |
| 63 | 86 | ||
| 64 | - | registryHandler, _, _ := createHTTPHandlers(&services{}, cfg, zerowrap.Default()) | |
| 87 | + | registryHandler, _, _ := createHTTPHandlers(&services{}, cfg, zerowrap.Default(), nil) | |
| 65 | 88 | ||
| 66 | 89 | req := httptest.NewRequest(http.MethodGet, "/v2/test/manifests/latest", nil) | |
| 67 | 90 | req.RemoteAddr = "192.0.2.20:12345" | |
| @@ -103,7 +126,7 @@ func TestCreateHTTPHandlers_DirectHTTPRoot_ServesOnboarding(t *testing.T) { | |||
| 103 | 126 | cfg := newOnboardingConfig() | |
| 104 | 127 | svc := &services{caAdapter: ca} | |
| 105 | 128 | ||
| 106 | - | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default()) | |
| 129 | + | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | |
| 107 | 130 | ||
| 108 | 131 | req := httptest.NewRequest(http.MethodGet, "/", nil) | |
| 109 | 132 | req.RemoteAddr = directAddr | |
| @@ -122,7 +145,7 @@ func TestCreateHTTPHandlers_DirectHTTPCertPath_ServesCACert(t *testing.T) { | |||
| 122 | 145 | cfg := newOnboardingConfig() | |
| 123 | 146 | svc := &services{caAdapter: ca} | |
| 124 | 147 | ||
| 125 | - | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default()) | |
| 148 | + | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | |
| 126 | 149 | ||
| 127 | 150 | req := httptest.NewRequest(http.MethodGet, "/ca.crt", nil) | |
| 128 | 151 | req.RemoteAddr = directAddr | |
| @@ -141,7 +164,7 @@ func TestCreateHTTPHandlers_DirectHTTPUnknownPath_ReturnsForbidden(t *testing.T) | |||
| 141 | 164 | cfg := newOnboardingConfig() | |
| 142 | 165 | svc := &services{caAdapter: ca} | |
| 143 | 166 | ||
| 144 | - | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default()) | |
| 167 | + | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | |
| 145 | 168 | ||
| 146 | 169 | req := httptest.NewRequest(http.MethodGet, "/web", nil) | |
| 147 | 170 | req.RemoteAddr = directAddr | |
| @@ -159,7 +182,7 @@ func TestCreateHTTPHandlers_TrustedProxyHTTP_DoesNotServeOnboarding(t *testing.T | |||
| 159 | 182 | cfg := newOnboardingConfig() | |
| 160 | 183 | svc := &services{caAdapter: ca} | |
| 161 | 184 | ||
| 162 | - | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default()) | |
| 185 | + | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | |
| 163 | 186 | ||
| 164 | 187 | req := httptest.NewRequest(http.MethodGet, "/", nil) | |
| 165 | 188 | req.RemoteAddr = trustedProxyAddr | |
| @@ -179,7 +202,7 @@ func TestCreateHTTPHandlers_TrustedProxyCACertPath_DoesNotServeOnboardingResourc | |||
| 179 | 202 | cfg := newOnboardingConfig() | |
| 180 | 203 | svc := &services{caAdapter: ca} | |
| 181 | 204 | ||
| 182 | - | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default()) | |
| 205 | + | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | |
| 183 | 206 | ||
| 184 | 207 | req := httptest.NewRequest(http.MethodGet, "/ca.crt", nil) | |
| 185 | 208 | req.RemoteAddr = trustedProxyAddr | |
| @@ -199,7 +222,7 @@ func TestCreateHTTPHandlers_ForceHTTPSRedirect_DoesNotBypassDirectOnboarding(t * | |||
| 199 | 222 | cfg.Server.ForceHTTPSRedirect = true | |
| 200 | 223 | svc := &services{caAdapter: ca} | |
| 201 | 224 | ||
| 202 | - | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default()) | |
| 225 | + | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | |
| 203 | 226 | ||
| 204 | 227 | req := httptest.NewRequest(http.MethodGet, "/", nil) | |
| 205 | 228 | req.RemoteAddr = directAddr | |
| @@ -218,7 +241,7 @@ func TestCreateHTTPHandlers_HTTPSOnboarding_RemainsAvailable(t *testing.T) { | |||
| 218 | 241 | cfg := newOnboardingConfig() | |
| 219 | 242 | svc := &services{caAdapter: ca} | |
| 220 | 243 | ||
| 221 | - | _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default()) | |
| 244 | + | _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | |
| 222 | 245 | ||
| 223 | 246 | req := httptest.NewRequest(http.MethodGet, "/ca", nil) | |
| 224 | 247 | req.RemoteAddr = directAddr | |
| @@ -236,7 +259,7 @@ func TestCreateHTTPHandlers_HTTPSOnboarding_HEADRoutesRemainAvailable(t *testing | |||
| 236 | 259 | cfg := newOnboardingConfig() | |
| 237 | 260 | svc := &services{caAdapter: ca} | |
| 238 | 261 | ||
| 239 | - | _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default()) | |
| 262 | + | _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | |
| 240 | 263 | ||
| 241 | 264 | // Use httptest.NewServer so Go's net/http server strips the body for HEAD. | |
| 242 | 265 | // httptest.NewRecorder does not apply server-level HEAD body stripping. | |
| @@ -262,7 +285,7 @@ func TestCreateHTTPHandlers_DirectHTTPOnboarding_HEADRoutesRemainAvailable(t *te | |||
| 262 | 285 | cfg := newOnboardingConfig() | |
| 263 | 286 | svc := &services{caAdapter: ca} | |
| 264 | 287 | ||
| 265 | - | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default()) | |
| 288 | + | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | |
| 266 | 289 | ||
| 267 | 290 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 268 | 291 | r.RemoteAddr = directAddr | |
| @@ -297,7 +320,7 @@ func TestCreateHTTPHandlers_DirectHTTPForbiddenHEAD_ReturnsForbidden(t *testing. | |||
| 297 | 320 | cfg := newOnboardingConfig() | |
| 298 | 321 | svc := &services{caAdapter: ca} | |
| 299 | 322 | ||
| 300 | - | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default()) | |
| 323 | + | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | |
| 301 | 324 | ||
| 302 | 325 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 303 | 326 | r.RemoteAddr = directAddr | |
| @@ -323,7 +346,7 @@ func TestCreateHTTPHandlers_DirectHTTPACMEChallenge_Returns404(t *testing.T) { | |||
| 323 | 346 | cfg := newOnboardingConfig() | |
| 324 | 347 | svc := &services{caAdapter: ca} | |
| 325 | 348 | ||
| 326 | - | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default()) | |
| 349 | + | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | |
| 327 | 350 | ||
| 328 | 351 | req := httptest.NewRequest(http.MethodGet, "/.well-known/acme-challenge/test-token", nil) | |
| 329 | 352 | req.RemoteAddr = directAddr | |
| @@ -341,7 +364,7 @@ func TestCreateHTTPHandlers_TLSDisabled_DoesNotServeHTTPOnboarding(t *testing.T) | |||
| 341 | 364 | cfg.Server.TLSPort = 0 // TLS disabled | |
| 342 | 365 | svc := &services{caAdapter: ca} | |
| 343 | 366 | ||
| 344 | - | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default()) | |
| 367 | + | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | |
| 345 | 368 | ||
| 346 | 369 | req := httptest.NewRequest(http.MethodGet, "/", nil) | |
| 347 | 370 | req.RemoteAddr = directAddr | |
| @@ -399,3 +422,84 @@ func TestBuildRegistryCIDRAllowlistMiddleware_InvalidEntries_DenyAll(t *testing. | |||
| 399 | 422 | t.Fatalf("expected status %d, got %d", http.StatusForbidden, rec.Code) | |
| 400 | 423 | } | |
| 401 | 424 | } | |
| 425 | + | ||
| 426 | + | // TestAccessLog_AdminRejectedByLoopbackOnly_IsLogged verifies that requests | |
| 427 | + | // to /admin/ blocked by loopbackOnly() still produce an access-log entry. | |
| 428 | + | // This exercises the outer-deny path: AccessLogger wraps the top-level | |
| 429 | + | // handler so even gates that run before any inner middleware are logged. | |
| 430 | + | func TestAccessLog_AdminRejectedByLoopbackOnly_IsLogged(t *testing.T) { | |
| 431 | + | t.Parallel() | |
| 432 | + | ||
| 433 | + | cfg := Config{} | |
| 434 | + | cfg.Auth.Enabled = true // required for admin routes to be registered | |
| 435 | + | ||
| 436 | + | svc := &services{adminHandler: &adminhttp.Handler{}} | |
| 437 | + | aw := &testAccessLogWriter{} | |
| 438 | + | registryHandler, _, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), aw) | |
| 439 | + | ||
| 440 | + | // Non-loopback IP — loopbackOnly() will reject this with 403 before any | |
| 441 | + | // inner middleware runs. | |
| 442 | + | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | |
| 443 | + | req.RemoteAddr = "192.0.2.10:12345" | |
| 444 | + | ||
| 445 | + | rec := httptest.NewRecorder() | |
| 446 | + | registryHandler.ServeHTTP(rec, req) | |
| 447 | + | ||
| 448 | + | require.Equal(t, http.StatusForbidden, rec.Code) | |
| 449 | + | entries := aw.snapshot() | |
| 450 | + | require.Len(t, entries, 1, "access log must capture the loopback-rejected admin request") | |
| 451 | + | assert.Equal(t, http.StatusForbidden, entries[0].Status) | |
| 452 | + | assert.Equal(t, "/admin/status", entries[0].Path) | |
| 453 | + | } | |
| 454 | + | ||
| 455 | + | // TestAccessLog_RegistryRejectedByLoopbackOnly_IsLogged verifies that requests | |
| 456 | + | // to /v2/ blocked by loopbackOnly() in local mode still produce an access-log entry. | |
| 457 | + | func TestAccessLog_RegistryRejectedByLoopbackOnly_IsLogged(t *testing.T) { | |
| 458 | + | t.Parallel() | |
| 459 | + | ||
| 460 | + | cfg := Config{} | |
| 461 | + | cfg.Auth.Enabled = false // local mode: /v2/ is wrapped by loopbackOnly() | |
| 462 | + | ||
| 463 | + | svc := &services{} | |
| 464 | + | aw := &testAccessLogWriter{} | |
| 465 | + | registryHandler, _, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), aw) | |
| 466 | + | ||
| 467 | + | // Non-loopback IP — loopbackOnly() rejects with 403. | |
| 468 | + | req := httptest.NewRequest(http.MethodGet, "/v2/", nil) | |
| 469 | + | req.RemoteAddr = "192.0.2.20:12345" | |
| 470 | + | ||
| 471 | + | rec := httptest.NewRecorder() | |
| 472 | + | registryHandler.ServeHTTP(rec, req) | |
| 473 | + | ||
| 474 | + | require.Equal(t, http.StatusForbidden, rec.Code) | |
| 475 | + | entries := aw.snapshot() | |
| 476 | + | require.Len(t, entries, 1, "access log must capture the loopback-rejected registry request") | |
| 477 | + | assert.Equal(t, http.StatusForbidden, entries[0].Status) | |
| 478 | + | } | |
| 479 | + | ||
| 480 | + | // TestAccessLog_DenyAllProxy_IsLogged verifies that requests rejected by the | |
| 481 | + | // fail-closed denyAllHandler (triggered by an all-invalid proxy_allowed_ips | |
| 482 | + | // config) still produce an access-log entry. | |
| 483 | + | func TestAccessLog_DenyAllProxy_IsLogged(t *testing.T) { | |
| 484 | + | t.Parallel() | |
| 485 | + | ||
| 486 | + | cfg := Config{} | |
| 487 | + | // All-invalid entries cause buildProxyCIDRAllowlistMiddleware to return | |
| 488 | + | // denyAllHandler, which wraps the proxy mux unconditionally with 403. | |
| 489 | + | cfg.Server.ProxyAllowedIPs = []string{"not-a-valid-ip"} | |
| 490 | + | ||
| 491 | + | svc := &services{} | |
| 492 | + | aw := &testAccessLogWriter{} | |
| 493 | + | _, httpProxyHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), aw) | |
| 494 | + | ||
| 495 | + | req := httptest.NewRequest(http.MethodGet, "/", nil) | |
| 496 | + | req.RemoteAddr = "203.0.113.5:54321" | |
| 497 | + | ||
| 498 | + | rec := httptest.NewRecorder() | |
| 499 | + | httpProxyHandler.ServeHTTP(rec, req) | |
| 500 | + | ||
| 501 | + | require.Equal(t, http.StatusForbidden, rec.Code) | |
| 502 | + | entries := aw.snapshot() | |
| 503 | + | require.Len(t, entries, 1, "access log must capture the fail-closed proxy denial") | |
| 504 | + | assert.Equal(t, http.StatusForbidden, entries[0].Status) | |
| 505 | + | } | |
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | "net/http/httptest" | 7 | "net/http/httptest" | ||
| 8 | "os" | 8 | "os" | ||
| 9 | "path/filepath" | 9 | "path/filepath" | ||
| 10 | + | "sync" | |||
| 10 | "testing" | 11 | "testing" | ||
| 11 | 12 | ||||
| 12 | "github.com/bnema/zerowrap" | 13 | "github.com/bnema/zerowrap" | ||
| @@ -15,8 +16,30 @@ import ( | |||||
| 15 | 16 | ||||
| 16 | adminhttp "github.com/bnema/gordon/internal/adapters/in/http/admin" | 17 | adminhttp "github.com/bnema/gordon/internal/adapters/in/http/admin" | ||
| 17 | pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki" | 18 | pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki" | ||
| 19 | + | out "github.com/bnema/gordon/internal/boundaries/out" | |||
| 18 | ) | 20 | ) | ||
| 19 | 21 | ||||
| 22 | + | // testAccessLogWriter is a thread-safe mock AccessLogWriter for tests. | |||
| 23 | + | type testAccessLogWriter struct { | |||
| 24 | + | mu sync.Mutex | |||
| 25 | + | entries []out.AccessLogEntry | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func (w *testAccessLogWriter) Write(entry out.AccessLogEntry) error { | |||
| 29 | + | w.mu.Lock() | |||
| 30 | + | defer w.mu.Unlock() | |||
| 31 | + | w.entries = append(w.entries, entry) | |||
| 32 | + | return nil | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func (w *testAccessLogWriter) snapshot() []out.AccessLogEntry { | |||
| 36 | + | w.mu.Lock() | |||
| 37 | + | defer w.mu.Unlock() | |||
| 38 | + | result := make([]out.AccessLogEntry, len(w.entries)) | |||
| 39 | + | copy(result, w.entries) | |||
| 40 | + | return result | |||
| 41 | + | } | |||
| 42 | + | ||||
| 20 | func TestCreateAuthService_Disabled_ReturnsNilServices(t *testing.T) { | 43 | func TestCreateAuthService_Disabled_ReturnsNilServices(t *testing.T) { | ||
| 21 | t.Parallel() | 44 | t.Parallel() | ||
| 22 | 45 | ||||
| @@ -42,7 +65,7 @@ func TestCreateHTTPHandlers_LocalMode_DisablesAdminRoutes(t *testing.T) { | |||||
| 42 | cfg.Auth.Enabled = false | 65 | cfg.Auth.Enabled = false | ||
| 43 | 66 | ||||
| 44 | svc := &services{adminHandler: &adminhttp.Handler{}} | 67 | svc := &services{adminHandler: &adminhttp.Handler{}} | ||
| 45 | - | registryHandler, _, _ := createHTTPHandlers(svc, cfg, zerowrap.Default()) | 68 | + | registryHandler, _, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) |
| 46 | 69 | ||||
| 47 | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | 70 | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | ||
| 48 | req.RemoteAddr = "192.0.2.10:12345" | 71 | req.RemoteAddr = "192.0.2.10:12345" | ||
| @@ -61,7 +84,7 @@ func TestCreateHTTPHandlers_LocalMode_RestrictsRegistryToLoopback(t *testing.T) | |||||
| 61 | cfg := Config{} | 84 | cfg := Config{} | ||
| 62 | cfg.Auth.Enabled = false | 85 | cfg.Auth.Enabled = false | ||
| 63 | 86 | ||||
| 64 | - | registryHandler, _, _ := createHTTPHandlers(&services{}, cfg, zerowrap.Default()) | 87 | + | registryHandler, _, _ := createHTTPHandlers(&services{}, cfg, zerowrap.Default(), nil) |
| 65 | 88 | ||||
| 66 | req := httptest.NewRequest(http.MethodGet, "/v2/test/manifests/latest", nil) | 89 | req := httptest.NewRequest(http.MethodGet, "/v2/test/manifests/latest", nil) | ||
| 67 | req.RemoteAddr = "192.0.2.20:12345" | 90 | req.RemoteAddr = "192.0.2.20:12345" | ||
| @@ -103,7 +126,7 @@ func TestCreateHTTPHandlers_DirectHTTPRoot_ServesOnboarding(t *testing.T) { | |||||
| 103 | cfg := newOnboardingConfig() | 126 | cfg := newOnboardingConfig() | ||
| 104 | svc := &services{caAdapter: ca} | 127 | svc := &services{caAdapter: ca} | ||
| 105 | 128 | ||||
| 106 | - | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default()) | 129 | + | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) |
| 107 | 130 | ||||
| 108 | req := httptest.NewRequest(http.MethodGet, "/", nil) | 131 | req := httptest.NewRequest(http.MethodGet, "/", nil) | ||
| 109 | req.RemoteAddr = directAddr | 132 | req.RemoteAddr = directAddr | ||
| @@ -122,7 +145,7 @@ func TestCreateHTTPHandlers_DirectHTTPCertPath_ServesCACert(t *testing.T) { | |||||
| 122 | cfg := newOnboardingConfig() | 145 | cfg := newOnboardingConfig() | ||
| 123 | svc := &services{caAdapter: ca} | 146 | svc := &services{caAdapter: ca} | ||
| 124 | 147 | ||||
| 125 | - | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default()) | 148 | + | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) |
| 126 | 149 | ||||
| 127 | req := httptest.NewRequest(http.MethodGet, "/ca.crt", nil) | 150 | req := httptest.NewRequest(http.MethodGet, "/ca.crt", nil) | ||
| 128 | req.RemoteAddr = directAddr | 151 | req.RemoteAddr = directAddr | ||
| @@ -141,7 +164,7 @@ func TestCreateHTTPHandlers_DirectHTTPUnknownPath_ReturnsForbidden(t *testing.T) | |||||
| 141 | cfg := newOnboardingConfig() | 164 | cfg := newOnboardingConfig() | ||
| 142 | svc := &services{caAdapter: ca} | 165 | svc := &services{caAdapter: ca} | ||
| 143 | 166 | ||||
| 144 | - | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default()) | 167 | + | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) |
| 145 | 168 | ||||
| 146 | req := httptest.NewRequest(http.MethodGet, "/web", nil) | 169 | req := httptest.NewRequest(http.MethodGet, "/web", nil) | ||
| 147 | req.RemoteAddr = directAddr | 170 | req.RemoteAddr = directAddr | ||
| @@ -159,7 +182,7 @@ func TestCreateHTTPHandlers_TrustedProxyHTTP_DoesNotServeOnboarding(t *testing.T | |||||
| 159 | cfg := newOnboardingConfig() | 182 | cfg := newOnboardingConfig() | ||
| 160 | svc := &services{caAdapter: ca} | 183 | svc := &services{caAdapter: ca} | ||
| 161 | 184 | ||||
| 162 | - | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default()) | 185 | + | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) |
| 163 | 186 | ||||
| 164 | req := httptest.NewRequest(http.MethodGet, "/", nil) | 187 | req := httptest.NewRequest(http.MethodGet, "/", nil) | ||
| 165 | req.RemoteAddr = trustedProxyAddr | 188 | req.RemoteAddr = trustedProxyAddr | ||
| @@ -179,7 +202,7 @@ func TestCreateHTTPHandlers_TrustedProxyCACertPath_DoesNotServeOnboardingResourc | |||||
| 179 | cfg := newOnboardingConfig() | 202 | cfg := newOnboardingConfig() | ||
| 180 | svc := &services{caAdapter: ca} | 203 | svc := &services{caAdapter: ca} | ||
| 181 | 204 | ||||
| 182 | - | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default()) | 205 | + | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) |
| 183 | 206 | ||||
| 184 | req := httptest.NewRequest(http.MethodGet, "/ca.crt", nil) | 207 | req := httptest.NewRequest(http.MethodGet, "/ca.crt", nil) | ||
| 185 | req.RemoteAddr = trustedProxyAddr | 208 | req.RemoteAddr = trustedProxyAddr | ||
| @@ -199,7 +222,7 @@ func TestCreateHTTPHandlers_ForceHTTPSRedirect_DoesNotBypassDirectOnboarding(t * | |||||
| 199 | cfg.Server.ForceHTTPSRedirect = true | 222 | cfg.Server.ForceHTTPSRedirect = true | ||
| 200 | svc := &services{caAdapter: ca} | 223 | svc := &services{caAdapter: ca} | ||
| 201 | 224 | ||||
| 202 | - | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default()) | 225 | + | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) |
| 203 | 226 | ||||
| 204 | req := httptest.NewRequest(http.MethodGet, "/", nil) | 227 | req := httptest.NewRequest(http.MethodGet, "/", nil) | ||
| 205 | req.RemoteAddr = directAddr | 228 | req.RemoteAddr = directAddr | ||
| @@ -218,7 +241,7 @@ func TestCreateHTTPHandlers_HTTPSOnboarding_RemainsAvailable(t *testing.T) { | |||||
| 218 | cfg := newOnboardingConfig() | 241 | cfg := newOnboardingConfig() | ||
| 219 | svc := &services{caAdapter: ca} | 242 | svc := &services{caAdapter: ca} | ||
| 220 | 243 | ||||
| 221 | - | _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default()) | 244 | + | _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) |
| 222 | 245 | ||||
| 223 | req := httptest.NewRequest(http.MethodGet, "/ca", nil) | 246 | req := httptest.NewRequest(http.MethodGet, "/ca", nil) | ||
| 224 | req.RemoteAddr = directAddr | 247 | req.RemoteAddr = directAddr | ||
| @@ -236,7 +259,7 @@ func TestCreateHTTPHandlers_HTTPSOnboarding_HEADRoutesRemainAvailable(t *testing | |||||
| 236 | cfg := newOnboardingConfig() | 259 | cfg := newOnboardingConfig() | ||
| 237 | svc := &services{caAdapter: ca} | 260 | svc := &services{caAdapter: ca} | ||
| 238 | 261 | ||||
| 239 | - | _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default()) | 262 | + | _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) |
| 240 | 263 | ||||
| 241 | // Use httptest.NewServer so Go's net/http server strips the body for HEAD. | 264 | // Use httptest.NewServer so Go's net/http server strips the body for HEAD. | ||
| 242 | // httptest.NewRecorder does not apply server-level HEAD body stripping. | 265 | // httptest.NewRecorder does not apply server-level HEAD body stripping. | ||
| @@ -262,7 +285,7 @@ func TestCreateHTTPHandlers_DirectHTTPOnboarding_HEADRoutesRemainAvailable(t *te | |||||
| 262 | cfg := newOnboardingConfig() | 285 | cfg := newOnboardingConfig() | ||
| 263 | svc := &services{caAdapter: ca} | 286 | svc := &services{caAdapter: ca} | ||
| 264 | 287 | ||||
| 265 | - | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default()) | 288 | + | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) |
| 266 | 289 | ||||
| 267 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 290 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 268 | r.RemoteAddr = directAddr | 291 | r.RemoteAddr = directAddr | ||
| @@ -297,7 +320,7 @@ func TestCreateHTTPHandlers_DirectHTTPForbiddenHEAD_ReturnsForbidden(t *testing. | |||||
| 297 | cfg := newOnboardingConfig() | 320 | cfg := newOnboardingConfig() | ||
| 298 | svc := &services{caAdapter: ca} | 321 | svc := &services{caAdapter: ca} | ||
| 299 | 322 | ||||
| 300 | - | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default()) | 323 | + | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) |
| 301 | 324 | ||||
| 302 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 325 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 303 | r.RemoteAddr = directAddr | 326 | r.RemoteAddr = directAddr | ||
| @@ -323,7 +346,7 @@ func TestCreateHTTPHandlers_DirectHTTPACMEChallenge_Returns404(t *testing.T) { | |||||
| 323 | cfg := newOnboardingConfig() | 346 | cfg := newOnboardingConfig() | ||
| 324 | svc := &services{caAdapter: ca} | 347 | svc := &services{caAdapter: ca} | ||
| 325 | 348 | ||||
| 326 | - | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default()) | 349 | + | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) |
| 327 | 350 | ||||
| 328 | req := httptest.NewRequest(http.MethodGet, "/.well-known/acme-challenge/test-token", nil) | 351 | req := httptest.NewRequest(http.MethodGet, "/.well-known/acme-challenge/test-token", nil) | ||
| 329 | req.RemoteAddr = directAddr | 352 | req.RemoteAddr = directAddr | ||
| @@ -341,7 +364,7 @@ func TestCreateHTTPHandlers_TLSDisabled_DoesNotServeHTTPOnboarding(t *testing.T) | |||||
| 341 | cfg.Server.TLSPort = 0 // TLS disabled | 364 | cfg.Server.TLSPort = 0 // TLS disabled | ||
| 342 | svc := &services{caAdapter: ca} | 365 | svc := &services{caAdapter: ca} | ||
| 343 | 366 | ||||
| 344 | - | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default()) | 367 | + | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) |
| 345 | 368 | ||||
| 346 | req := httptest.NewRequest(http.MethodGet, "/", nil) | 369 | req := httptest.NewRequest(http.MethodGet, "/", nil) | ||
| 347 | req.RemoteAddr = directAddr | 370 | req.RemoteAddr = directAddr | ||
| @@ -399,3 +422,84 @@ func TestBuildRegistryCIDRAllowlistMiddleware_InvalidEntries_DenyAll(t *testing. | |||||
| 399 | t.Fatalf("expected status %d, got %d", http.StatusForbidden, rec.Code) | 422 | t.Fatalf("expected status %d, got %d", http.StatusForbidden, rec.Code) | ||
| 400 | } | 423 | } | ||
| 401 | } | 424 | } | ||
| 425 | + | ||||
| 426 | + | // TestAccessLog_AdminRejectedByLoopbackOnly_IsLogged verifies that requests | |||
| 427 | + | // to /admin/ blocked by loopbackOnly() still produce an access-log entry. | |||
| 428 | + | // This exercises the outer-deny path: AccessLogger wraps the top-level | |||
| 429 | + | // handler so even gates that run before any inner middleware are logged. | |||
| 430 | + | func TestAccessLog_AdminRejectedByLoopbackOnly_IsLogged(t *testing.T) { | |||
| 431 | + | t.Parallel() | |||
| 432 | + | ||||
| 433 | + | cfg := Config{} | |||
| 434 | + | cfg.Auth.Enabled = true // required for admin routes to be registered | |||
| 435 | + | ||||
| 436 | + | svc := &services{adminHandler: &adminhttp.Handler{}} | |||
| 437 | + | aw := &testAccessLogWriter{} | |||
| 438 | + | registryHandler, _, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), aw) | |||
| 439 | + | ||||
| 440 | + | // Non-loopback IP — loopbackOnly() will reject this with 403 before any | |||
| 441 | + | // inner middleware runs. | |||
| 442 | + | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | |||
| 443 | + | req.RemoteAddr = "192.0.2.10:12345" | |||
| 444 | + | ||||
| 445 | + | rec := httptest.NewRecorder() | |||
| 446 | + | registryHandler.ServeHTTP(rec, req) | |||
| 447 | + | ||||
| 448 | + | require.Equal(t, http.StatusForbidden, rec.Code) | |||
| 449 | + | entries := aw.snapshot() | |||
| 450 | + | require.Len(t, entries, 1, "access log must capture the loopback-rejected admin request") | |||
| 451 | + | assert.Equal(t, http.StatusForbidden, entries[0].Status) | |||
| 452 | + | assert.Equal(t, "/admin/status", entries[0].Path) | |||
| 453 | + | } | |||
| 454 | + | ||||
| 455 | + | // TestAccessLog_RegistryRejectedByLoopbackOnly_IsLogged verifies that requests | |||
| 456 | + | // to /v2/ blocked by loopbackOnly() in local mode still produce an access-log entry. | |||
| 457 | + | func TestAccessLog_RegistryRejectedByLoopbackOnly_IsLogged(t *testing.T) { | |||
| 458 | + | t.Parallel() | |||
| 459 | + | ||||
| 460 | + | cfg := Config{} | |||
| 461 | + | cfg.Auth.Enabled = false // local mode: /v2/ is wrapped by loopbackOnly() | |||
| 462 | + | ||||
| 463 | + | svc := &services{} | |||
| 464 | + | aw := &testAccessLogWriter{} | |||
| 465 | + | registryHandler, _, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), aw) | |||
| 466 | + | ||||
| 467 | + | // Non-loopback IP — loopbackOnly() rejects with 403. | |||
| 468 | + | req := httptest.NewRequest(http.MethodGet, "/v2/", nil) | |||
| 469 | + | req.RemoteAddr = "192.0.2.20:12345" | |||
| 470 | + | ||||
| 471 | + | rec := httptest.NewRecorder() | |||
| 472 | + | registryHandler.ServeHTTP(rec, req) | |||
| 473 | + | ||||
| 474 | + | require.Equal(t, http.StatusForbidden, rec.Code) | |||
| 475 | + | entries := aw.snapshot() | |||
| 476 | + | require.Len(t, entries, 1, "access log must capture the loopback-rejected registry request") | |||
| 477 | + | assert.Equal(t, http.StatusForbidden, entries[0].Status) | |||
| 478 | + | } | |||
| 479 | + | ||||
| 480 | + | // TestAccessLog_DenyAllProxy_IsLogged verifies that requests rejected by the | |||
| 481 | + | // fail-closed denyAllHandler (triggered by an all-invalid proxy_allowed_ips | |||
| 482 | + | // config) still produce an access-log entry. | |||
| 483 | + | func TestAccessLog_DenyAllProxy_IsLogged(t *testing.T) { | |||
| 484 | + | t.Parallel() | |||
| 485 | + | ||||
| 486 | + | cfg := Config{} | |||
| 487 | + | // All-invalid entries cause buildProxyCIDRAllowlistMiddleware to return | |||
| 488 | + | // denyAllHandler, which wraps the proxy mux unconditionally with 403. | |||
| 489 | + | cfg.Server.ProxyAllowedIPs = []string{"not-a-valid-ip"} | |||
| 490 | + | ||||
| 491 | + | svc := &services{} | |||
| 492 | + | aw := &testAccessLogWriter{} | |||
| 493 | + | _, httpProxyHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), aw) | |||
| 494 | + | ||||
| 495 | + | req := httptest.NewRequest(http.MethodGet, "/", nil) | |||
| 496 | + | req.RemoteAddr = "203.0.113.5:54321" | |||
| 497 | + | ||||
| 498 | + | rec := httptest.NewRecorder() | |||
| 499 | + | httpProxyHandler.ServeHTTP(rec, req) | |||
| 500 | + | ||||
| 501 | + | require.Equal(t, http.StatusForbidden, rec.Code) | |||
| 502 | + | entries := aw.snapshot() | |||
| 503 | + | require.Len(t, entries, 1, "access log must capture the fail-closed proxy denial") | |||
| 504 | + | assert.Equal(t, http.StatusForbidden, entries[0].Status) | |||
| 505 | + | } | |||
A internal/boundaries/out/accesslog.go +34 -0
| @@ -0,0 +1,34 @@ | |||
| 1 | + | package out | |
| 2 | + | ||
| 3 | + | import "time" | |
| 4 | + | ||
| 5 | + | // AccessLogEntry represents a single HTTP access log record. | |
| 6 | + | // It is the neutral data contract between the access log middleware (adapters/in) | |
| 7 | + | // and the access log writer (adapters/out). Keeping it in the boundaries layer | |
| 8 | + | // prevents direct adapter-to-adapter coupling. | |
| 9 | + | type AccessLogEntry struct { | |
| 10 | + | Time time.Time | |
| 11 | + | ClientIP string | |
| 12 | + | Method string | |
| 13 | + | Host string | |
| 14 | + | Path string | |
| 15 | + | Query string | |
| 16 | + | Status int | |
| 17 | + | BytesSent int | |
| 18 | + | DurationMS float64 | |
| 19 | + | UserAgent string | |
| 20 | + | Referer string | |
| 21 | + | RequestID string | |
| 22 | + | Proto string | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // AccessLogWriter writes HTTP access log entries to a configured sink. | |
| 26 | + | // *accesslog.Writer in internal/adapters/out/accesslog implements this interface. | |
| 27 | + | type AccessLogWriter interface { | |
| 28 | + | Write(entry AccessLogEntry) error | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // HealthCheckUserAgentPrefix is the User-Agent prefix set by Gordon's internal | |
| 32 | + | // health-check prober. Access log middleware uses this to filter out health-check | |
| 33 | + | // noise; the prober uses it to construct the full User-Agent string. | |
| 34 | + | const HealthCheckUserAgentPrefix = "Gordon-HealthCheck/" | |
| @@ -0,0 +1,34 @@ | |||||
| 1 | + | package out | |||
| 2 | + | ||||
| 3 | + | import "time" | |||
| 4 | + | ||||
| 5 | + | // AccessLogEntry represents a single HTTP access log record. | |||
| 6 | + | // It is the neutral data contract between the access log middleware (adapters/in) | |||
| 7 | + | // and the access log writer (adapters/out). Keeping it in the boundaries layer | |||
| 8 | + | // prevents direct adapter-to-adapter coupling. | |||
| 9 | + | type AccessLogEntry struct { | |||
| 10 | + | Time time.Time | |||
| 11 | + | ClientIP string | |||
| 12 | + | Method string | |||
| 13 | + | Host string | |||
| 14 | + | Path string | |||
| 15 | + | Query string | |||
| 16 | + | Status int | |||
| 17 | + | BytesSent int | |||
| 18 | + | DurationMS float64 | |||
| 19 | + | UserAgent string | |||
| 20 | + | Referer string | |||
| 21 | + | RequestID string | |||
| 22 | + | Proto string | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // AccessLogWriter writes HTTP access log entries to a configured sink. | |||
| 26 | + | // *accesslog.Writer in internal/adapters/out/accesslog implements this interface. | |||
| 27 | + | type AccessLogWriter interface { | |||
| 28 | + | Write(entry AccessLogEntry) error | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // HealthCheckUserAgentPrefix is the User-Agent prefix set by Gordon's internal | |||
| 32 | + | // health-check prober. Access log middleware uses this to filter out health-check | |||
| 33 | + | // noise; the prober uses it to construct the full User-Agent string. | |||
| 34 | + | const HealthCheckUserAgentPrefix = "Gordon-HealthCheck/" | |||