detached d913a6e

Merge pull request #155 from bnema/feat/dedicated-access-log

feat: dedicated access log for security tooling (CrowdSec, fail2ban, GoAccess)
commit
d913a6ea68e2ec06748567f2b8112fe2fa40d8f6
author
bnema · · 2026-04-03 17:54 UTC
committer
GitHub · · 2026-04-03 17:54 UTC
merge of
18b726b + 3865a9d · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
20 files · +1475 -64
M go.mod +2 -1
@@ -7,6 +7,7 @@ require (
77 github.com/charmbracelet/bubbles v1.0.0
88 github.com/charmbracelet/bubbletea v1.3.10
99 github.com/charmbracelet/lipgloss v1.1.0
10+ github.com/coreos/go-systemd/v22 v22.7.0
1011 github.com/docker/docker v28.5.2+incompatible
1112 github.com/docker/go-connections v0.6.0
1213 github.com/golang-jwt/jwt/v5 v5.3.1
@@ -25,6 +26,7 @@ require (
2526 go.opentelemetry.io/otel/sdk/log v0.18.0
2627 go.opentelemetry.io/otel/sdk/metric v1.42.0
2728 golang.org/x/mod v0.34.0
29+ golang.org/x/sync v0.20.0
2830 gopkg.in/natefinch/lumberjack.v2 v2.2.1
2931 )
3032
@@ -63,7 +65,6 @@ require (
6365 go.opentelemetry.io/proto/otlp v1.9.0 // indirect
6466 go.yaml.in/yaml/v3 v3.0.4 // indirect
6567 golang.org/x/net v0.52.0 // indirect
66- golang.org/x/sync v0.20.0 // indirect
6768 google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect
6869 google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect
6970 google.golang.org/grpc v1.79.3 // indirect
@@ -7,6 +7,7 @@ require (
7 github.com/charmbracelet/bubbles v1.0.07 github.com/charmbracelet/bubbles v1.0.0
8 github.com/charmbracelet/bubbletea v1.3.108 github.com/charmbracelet/bubbletea v1.3.10
9 github.com/charmbracelet/lipgloss v1.1.09 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+incompatible11 github.com/docker/docker v28.5.2+incompatible
11 github.com/docker/go-connections v0.6.012 github.com/docker/go-connections v0.6.0
12 github.com/golang-jwt/jwt/v5 v5.3.113 github.com/golang-jwt/jwt/v5 v5.3.1
@@ -25,6 +26,7 @@ require (
25 go.opentelemetry.io/otel/sdk/log v0.18.026 go.opentelemetry.io/otel/sdk/log v0.18.0
26 go.opentelemetry.io/otel/sdk/metric v1.42.027 go.opentelemetry.io/otel/sdk/metric v1.42.0
27 golang.org/x/mod v0.34.028 golang.org/x/mod v0.34.0
29+ golang.org/x/sync v0.20.0
28 gopkg.in/natefinch/lumberjack.v2 v2.2.130 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 // indirect65 go.opentelemetry.io/proto/otlp v1.9.0 // indirect
64 go.yaml.in/yaml/v3 v3.0.4 // indirect66 go.yaml.in/yaml/v3 v3.0.4 // indirect
65 golang.org/x/net v0.52.0 // indirect67 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 // indirect68 google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect
68 google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect69 google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect
69 google.golang.org/grpc v1.79.3 // indirect70 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
4545 github.com/containerd/stargz-snapshotter/estargz v0.18.2 h1:yXkZFYIzz3eoLwlTUZKz2iQ4MrckBxJjkmD16ynUTrw=
4646 github.com/containerd/stargz-snapshotter/estargz v0.18.2/go.mod h1:XyVU5tcJ3PRpkA9XS2T5us6Eg35yM0214Y+wvrZTBrY=
4747 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=
4850 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
4951 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
5052 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 (
55 "crypto/subtle"
66 "encoding/json"
77 "errors"
8+ "net"
89 "net/http"
910 "strings"
1011
@@ -54,7 +55,7 @@ type InternalRegistryAuth struct {
5455
5556 // RegistryAuthV2 middleware provides enhanced Docker Registry authentication
5657 // 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 {
5859 return func(next http.Handler) http.Handler {
5960 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
6061 // Allow localhost requests only with internal instance credentials.
@@ -64,7 +65,7 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l
6465 Str(zerowrap.FieldAdapter, "http").
6566 Str(zerowrap.FieldMethod, r.Method).
6667 Str(zerowrap.FieldPath, r.URL.Path).
67- Str(zerowrap.FieldClientIP, r.RemoteAddr).
68+ Str(zerowrap.FieldClientIP, GetClientIP(r, trustedNets)).
6869 Msg("localhost request with internal auth - skipping auth")
6970 next.ServeHTTP(w, r)
7071 return
@@ -77,7 +78,7 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l
7778 Str(zerowrap.FieldAdapter, "http").
7879 Str(zerowrap.FieldMethod, r.Method).
7980 Str(zerowrap.FieldPath, r.URL.Path).
80- Str(zerowrap.FieldClientIP, r.RemoteAddr).
81+ Str(zerowrap.FieldClientIP, GetClientIP(r, trustedNets)).
8182 Msg("registry auth over insecure HTTP connection")
8283 }
8384
@@ -96,9 +97,9 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l
9697
9798 if authErr != nil {
9899 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())
100101 } else {
101- sendUnauthorized(w, authSvc.GetAuthType(), r.Host, log, r)
102+ sendUnauthorized(w, authSvc.GetAuthType(), r.Host, log, trustedNets, r)
102103 }
103104 return
104105 }
@@ -106,7 +107,7 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l
106107 // SECURITY: Check scopes for token auth (per-repo access control)
107108 if tokenClaims != nil {
108109 if !checkScopeAccess(r, tokenClaims, log) {
109- sendForbidden(w, log, r)
110+ sendForbidden(w, log, trustedNets, r)
110111 return
111112 }
112113 // 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.
290291 }
291292
292293 // 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) {
294295 w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
295296 w.Header().Set("Content-Type", "application/json")
296297 w.WriteHeader(http.StatusForbidden)
@@ -301,12 +302,12 @@ func sendForbidden(w http.ResponseWriter, log zerowrap.Logger, r *http.Request)
301302 Str(zerowrap.FieldAdapter, "http").
302303 Str(zerowrap.FieldMethod, r.Method).
303304 Str(zerowrap.FieldPath, r.URL.Path).
304- Str(zerowrap.FieldClientIP, r.RemoteAddr).
305+ Str(zerowrap.FieldClientIP, GetClientIP(r, trustedNets)).
305306 Msg("forbidden: insufficient scope for operation")
306307 }
307308
308309 // 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) {
310311 w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
311312
312313 switch authType {
@@ -332,12 +333,12 @@ func sendUnauthorizedMsg(w http.ResponseWriter, authType domain.AuthType, host s
332333 Str(zerowrap.FieldAdapter, "http").
333334 Str(zerowrap.FieldMethod, r.Method).
334335 Str(zerowrap.FieldPath, r.URL.Path).
335- Str(zerowrap.FieldClientIP, r.RemoteAddr).
336+ Str(zerowrap.FieldClientIP, GetClientIP(r, trustedNets)).
336337 Msg("unauthorized registry access attempt")
337338 }
338339
339340 // 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) {
341342 w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
342343
343344 switch authType {
@@ -375,6 +376,6 @@ func sendUnauthorized(w http.ResponseWriter, authType domain.AuthType, host stri
375376 Str(zerowrap.FieldAdapter, "http").
376377 Str(zerowrap.FieldMethod, r.Method).
377378 Str(zerowrap.FieldPath, r.URL.Path).
378- Str(zerowrap.FieldClientIP, r.RemoteAddr).
379+ Str(zerowrap.FieldClientIP, GetClientIP(r, trustedNets)).
379380 Msg("unauthorized registry access attempt")
380381 }
@@ -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 authentication56 // 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 return71 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 return104 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 return111 return
111 }112 }
112 // Store claims in context for downstream handlers that need access to token metadata113 // 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) {
3434 Password: "secret",
3535 }
3636
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) {
3838 w.WriteHeader(http.StatusOK)
3939 }))
4040
@@ -65,7 +65,7 @@ func TestRegistryAuthV2_LocalhostWrongInternalAuthFallsBack(t *testing.T) {
6565 Password: "secret",
6666 }
6767
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) {
6969 w.WriteHeader(http.StatusOK)
7070 }))
7171
@@ -97,7 +97,7 @@ func TestRegistryAuthV2_NonLocalhostIgnoresInternalAuth(t *testing.T) {
9797 Password: "secret",
9898 }
9999
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) {
101101 w.WriteHeader(http.StatusOK)
102102 }))
103103
@@ -359,7 +359,7 @@ func TestRegistryAuthV2_RejectsLongLivedToken(t *testing.T) {
359359 Password: "secret",
360360 }
361361
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) {
363363 w.WriteHeader(http.StatusOK)
364364 }))
365365
@@ -393,7 +393,7 @@ func TestRegistryAuthV2_AcceptsEphemeralToken(t *testing.T) {
393393 }
394394
395395 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) {
397397 handlerCalled = true
398398 w.WriteHeader(http.StatusOK)
399399 }))
@@ -427,7 +427,7 @@ func TestRegistryAuthV2_RejectsLongLivedTokenAsPassword(t *testing.T) {
427427 Password: "secret",
428428 }
429429
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) {
431431 w.WriteHeader(http.StatusOK)
432432 }))
433433
@@ -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 := false395 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 = true397 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 {
6767 return rw.ResponseWriter
6868 }
6969
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+
7083 // RequestLogger is a middleware that logs HTTP requests using zerowrap.
7184 // It also attaches the logger to the request context for downstream handlers.
7285 // 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
8194 start := time.Now()
8295
8396 // 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)
9198
9299 // Wrap the response writer to capture status and bytes
93100 rw := NewResponseWriter(w)
@@ -67,6 +67,19 @@ func (rw *ResponseWriter) Unwrap() http.ResponseWriter {
67 return rw.ResponseWriter67 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 tracing96 // 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 bytes99 // 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) {
5858 }, nil)
5959 proxySvc.EXPECT().TrackInFlight("grpc-1").Return(func() {})
6060
61- handler := NewHandler(proxySvc, zerowrap.Default())
61+ handler := NewHandler(proxySvc, nil, zerowrap.Default())
6262
6363 req := httptest.NewRequest(http.MethodGet, "http://grpc.example.com/test", nil)
6464 req.Host = "grpc.example.com"
@@ -90,7 +90,7 @@ func TestForwardToTarget_HTTP1StillWorks(t *testing.T) {
9090 }, nil)
9191 proxySvc.EXPECT().TrackInFlight("web-1").Return(func() {})
9292
93- handler := NewHandler(proxySvc, zerowrap.Default())
93+ handler := NewHandler(proxySvc, nil, zerowrap.Default())
9494
9595 req := httptest.NewRequest(http.MethodGet, "http://web.example.com/", nil)
9696 req.Host = "web.example.com"
@@ -103,7 +103,7 @@ func TestForwardToTarget_HTTP1StillWorks(t *testing.T) {
103103 }
104104
105105 func TestTransportForTarget_SelectsCorrectTransport(t *testing.T) {
106- handler := NewHandler(inmocks.NewMockProxyService(t), zerowrap.Default())
106+ handler := NewHandler(inmocks.NewMockProxyService(t), nil, zerowrap.Default())
107107
108108 h2cTarget := &domain.ProxyTarget{Protocol: "h2c"}
109109 httpTarget := &domain.ProxyTarget{Protocol: ""}
@@ -135,7 +135,7 @@ func TestForwardToTarget_OriginalHostPreserved(t *testing.T) {
135135 }, nil)
136136 proxySvc.EXPECT().TrackInFlight("").Return(func() {})
137137
138- handler := NewHandler(proxySvc, zerowrap.Default())
138+ handler := NewHandler(proxySvc, nil, zerowrap.Default())
139139
140140 req := httptest.NewRequest(http.MethodGet, "http://external.example.com/", nil)
141141 req.Host = "external.example.com"
@@ -159,7 +159,7 @@ func TestForwardToTarget_BackendDown_Returns503(t *testing.T) {
159159 }, nil)
160160 proxySvc.EXPECT().TrackInFlight("c-down").Return(func() {})
161161
162- handler := NewHandler(proxySvc, zerowrap.Default())
162+ handler := NewHandler(proxySvc, nil, zerowrap.Default())
163163
164164 req := httptest.NewRequest(http.MethodGet, "http://down.example.com/", nil)
165165 req.Host = "down.example.com"
@@ -189,7 +189,7 @@ func TestForwardToTarget_ProxyHeaderSet(t *testing.T) {
189189 }, nil)
190190 proxySvc.EXPECT().TrackInFlight("c-1").Return(func() {})
191191
192- handler := NewHandler(proxySvc, zerowrap.Default())
192+ handler := NewHandler(proxySvc, nil, zerowrap.Default())
193193
194194 req := httptest.NewRequest(http.MethodGet, "http://app.example.com/", nil)
195195 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 @@
22 package proxy
33
44 import (
5+ "net"
56 "net/http"
67 "sync/atomic"
78
89 "github.com/bnema/zerowrap"
910
11+ "github.com/bnema/gordon/internal/adapters/in/http/middleware"
1012 "github.com/bnema/gordon/internal/boundaries/in"
1113 )
1214
@@ -17,6 +19,7 @@ import (
1719 type Handler struct {
1820 proxySvc in.ProxyService
1921 log zerowrap.Logger
22+ trustedNets []*net.IPNet
2023 appTransport http.RoundTripper
2124 h2cTransport http.RoundTripper
2225 registryTransport http.RoundTripper
@@ -24,10 +27,11 @@ type Handler struct {
2427 }
2528
2629 // 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 {
2831 return &Handler{
2932 proxySvc: proxySvc,
3033 log: log,
34+ trustedNets: trustedNets,
3135 appTransport: newAppTransport(),
3236 h2cTransport: newH2CTransport(),
3337 registryTransport: newRegistryTransport(),
@@ -61,7 +65,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
6165 zerowrap.FieldMethod: r.Method,
6266 zerowrap.FieldPath: r.URL.Path,
6367 zerowrap.FieldHost: r.Host,
64- zerowrap.FieldClientIP: r.RemoteAddr,
68+ zerowrap.FieldClientIP: middleware.GetClientIP(r, h.trustedNets),
6569 })
6670 r = r.WithContext(ctx)
6771 log := zerowrap.FromCtx(ctx)
@@ -2,11 +2,13 @@
2 package proxy2 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.ProxyService20 proxySvc in.ProxyService
19 log zerowrap.Logger21 log zerowrap.Logger
22+ trustedNets []*net.IPNet
20 appTransport http.RoundTripper23 appTransport http.RoundTripper
21 h2cTransport http.RoundTripper24 h2cTransport http.RoundTripper
22 registryTransport http.RoundTripper25 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) {
2828 MaxConcurrentConns: 1,
2929 })
3030
31- handler := NewHandler(proxySvc, testLogger())
31+ handler := NewHandler(proxySvc, nil, testLogger())
3232
3333 // Simulate an in-flight connection.
3434 handler.activeConns.Add(1)
@@ -51,7 +51,7 @@ func TestHandler_RoutesToRegistry(t *testing.T) {
5151 proxySvc.EXPECT().TrackRegistryRequest().Return()
5252 proxySvc.EXPECT().ReleaseRegistryRequest().Return()
5353
54- handler := NewHandler(proxySvc, testLogger())
54+ handler := NewHandler(proxySvc, nil, testLogger())
5555
5656 req := httptest.NewRequest(http.MethodGet, "http://registry.example.com/v2/", nil)
5757 req.Host = "registry.example.com"
@@ -68,7 +68,7 @@ func TestHandler_Returns404WhenNoTarget(t *testing.T) {
6868 proxySvc.EXPECT().IsRegistryDomain("unknown.example.com").Return(false)
6969 proxySvc.EXPECT().GetTarget(mock.Anything, "unknown.example.com").Return(nil, domain.ErrNoTargetAvailable)
7070
71- handler := NewHandler(proxySvc, testLogger())
71+ handler := NewHandler(proxySvc, nil, testLogger())
7272
7373 req := httptest.NewRequest(http.MethodGet, "http://unknown.example.com/", nil)
7474 req.Host = "unknown.example.com"
@@ -101,7 +101,7 @@ func TestHandler_ProxiesToTarget(t *testing.T) {
101101 proxySvc.EXPECT().GetTarget(mock.Anything, "app.example.com").Return(target, nil)
102102 proxySvc.EXPECT().TrackInFlight("c-1").Return(func() {})
103103
104- handler := NewHandler(proxySvc, testLogger())
104+ handler := NewHandler(proxySvc, nil, testLogger())
105105
106106 req := httptest.NewRequest(http.MethodGet, "http://app.example.com/", nil)
107107 req.Host = "app.example.com"
@@ -135,7 +135,7 @@ func TestHandler_ReadsMaxBodySizeConfig(t *testing.T) {
135135 }, nil)
136136 proxySvc.EXPECT().TrackInFlight("c-1").Return(func() {})
137137
138- handler := NewHandler(proxySvc, testLogger())
138+ handler := NewHandler(proxySvc, nil, testLogger())
139139
140140 // Send a body larger than the 1024-byte limit.
141141 largeBody := bytes.NewReader(make([]byte, 2048))
@@ -156,7 +156,7 @@ func TestHandler_UpdatedConfigReflected(t *testing.T) {
156156 proxySvc.EXPECT().IsRegistryDomain("app.example.com").Return(false).Once()
157157 proxySvc.EXPECT().GetTarget(mock.Anything, "app.example.com").Return(nil, domain.ErrNoTargetAvailable).Once()
158158
159- handler := NewHandler(proxySvc, testLogger())
159+ handler := NewHandler(proxySvc, nil, testLogger())
160160
161161 req := httptest.NewRequest(http.MethodGet, "http://app.example.com/", nil)
162162 req.Host = "app.example.com"
@@ -199,7 +199,7 @@ func TestHandler_NoConcurrencyLimitWhenZero(t *testing.T) {
199199 }, nil)
200200 proxySvc.EXPECT().TrackInFlight("c-1").Return(func() {})
201201
202- handler := NewHandler(proxySvc, testLogger())
202+ handler := NewHandler(proxySvc, nil, testLogger())
203203
204204 req := httptest.NewRequest(http.MethodGet, "http://app.example.com/", nil)
205205 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 (
77 "fmt"
88 "net/http"
99 "time"
10+
11+ out "github.com/bnema/gordon/internal/boundaries/out"
1012 )
1113
1214 // DefaultTimeout is the default timeout for HTTP probes.
@@ -76,8 +78,8 @@ func (p *Prober) Probe(ctx context.Context, url string) (int, int64, error) {
7678 return 0, 0, fmt.Errorf("failed to create request: %w", err)
7779 }
7880
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")
8183
8284 resp, err := p.client.Do(req)
8385 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 agent81+ // 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 (
2525 "github.com/spf13/viper"
2626
2727 // Adapters - Output
28+ "github.com/bnema/gordon/internal/adapters/out/accesslog"
2829 "github.com/bnema/gordon/internal/adapters/out/docker"
2930 "github.com/bnema/gordon/internal/adapters/out/domainsecrets"
3031 "github.com/bnema/gordon/internal/adapters/out/envloader"
@@ -119,6 +120,17 @@ type Config struct {
119120 MaxBackups int `mapstructure:"max_backups"`
120121 MaxAge int `mapstructure:"max_age"`
121122 } `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"`
122134 } `mapstructure:"logging"`
123135
124136 Env struct {
@@ -342,6 +354,43 @@ func initLogger(cfg Config) (zerowrap.Logger, func(), error) {
342354 return log, cleanup, nil
343355 }
344356
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+
345394 // serviceInit holds the shared context for service initialization helpers.
346395 type serviceInit struct {
347396 ctx context.Context
@@ -1611,7 +1660,7 @@ func loopbackOnly(next http.Handler, log zerowrap.Logger) http.Handler {
16111660
16121661 // createHTTPHandlers creates HTTP handlers with middleware.
16131662 // 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) {
16151664 // Parse trusted proxies once for all middleware chains.
16161665 // This ensures consistent IP extraction across logging, rate limiting, and auth.
16171666 trustedNets := middleware.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies)
@@ -1632,7 +1681,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
16321681 registerAdminRoutes(registryMux, svc, cfg, trustedNets, log)
16331682
16341683 // Proxy handler
1635- proxyHandler := proxyadapter.NewHandler(svc.proxySvc, log)
1684+ proxyHandler := proxyadapter.NewHandler(svc.proxySvc, trustedNets, log)
16361685
16371686 // HTTP proxy handler chain: HTTPS redirect for non-proxy clients, then CIDR allowlist
16381687 proxyAllowedNets, proxyCIDRMiddleware := buildProxyCIDRAllowlistMiddleware(cfg, trustedNets, log)
@@ -1703,7 +1752,18 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
17031752
17041753 httpsHandler := otelhttp.NewHandler(middleware.Chain(httpsProxyMiddlewares...)(httpsMux), "gordon.proxy.tls")
17051754
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
17071767 }
17081768
17091769 // directHTTPOnboardingGate returns an http.Handler that splits HTTP traffic
@@ -1775,7 +1835,7 @@ func buildRegistryHandlerWithMiddleware(
17751835 rateLimitMiddleware := buildRegistryRateLimitMiddleware(cfg, log)
17761836 registryMiddlewares = append(registryMiddlewares, rateLimitMiddleware)
17771837
1778- appendRegistryAuthMiddleware(&registryMiddlewares, svc, cfg, log)
1838+ appendRegistryAuthMiddleware(&registryMiddlewares, svc, cfg, trustedNets, log)
17791839
17801840 registryWithOtel := otelhttp.NewHandler(
17811841 middleware.Chain(registryMiddlewares...)(registryHandler),
@@ -1866,13 +1926,13 @@ func buildRegistryRateLimitMiddleware(cfg Config, log zerowrap.Logger) func(http
18661926 return registry.RateLimitMiddleware(nil, nil, nil, log)
18671927 }
18681928
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) {
18701930 if svc.authSvc != nil {
18711931 internalAuth := middleware.InternalRegistryAuth{
18721932 Username: svc.internalRegUser,
18731933 Password: svc.internalRegPass,
18741934 }
1875- *registryMiddlewares = append(*registryMiddlewares, middleware.RegistryAuthV2(svc.authSvc, internalAuth, log))
1935+ *registryMiddlewares = append(*registryMiddlewares, middleware.RegistryAuthV2(svc.authSvc, internalAuth, trustedNets, log))
18761936 return
18771937 }
18781938
@@ -1984,6 +2044,21 @@ func registerAdminRoutes(registryMux *http.ServeMux, svc *services, cfg Config,
19842044 // The deferred signal.Stop calls ensure signal handlers are properly
19852045 // cleaned up before program exit, preventing signal handler leaks.
19862046 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+ }
19872062 ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
19882063 defer cancel()
19892064
@@ -2001,7 +2076,7 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services,
20012076
20022077 errChan := make(chan error, 3)
20032078
2004- registryHandler, httpProxyHandler, httpsProxyHandler := createHTTPHandlers(svc, cfg, log)
2079+ registryHandler, httpProxyHandler, httpsProxyHandler := createHTTPHandlers(svc, cfg, log, accessWriter)
20052080
20062081 registryAddr := net.JoinHostPort(cfg.Server.RegistryListenAddr, strconv.Itoa(cfg.Server.RegistryPort))
20072082 registrySrv, registryReady := startServer(registryAddr, registryHandler, "registry", nil, errChan, log)
@@ -2702,6 +2777,15 @@ func loadConfig(v *viper.Viper, configPath string) error {
27022777 v.SetDefault("logging.container_logs.max_size", 100)
27032778 v.SetDefault("logging.container_logs.max_backups", 3)
27042779 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")
27052789 v.SetDefault("env.dir", "") // defaults to {data_dir}/env when empty
27062790 v.SetDefault("auth.enabled", true)
27072791 // 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 - Output27 // 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, nil354 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.Context396 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 handler1683 // 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 allowlist1686 // 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, httpsHandler1755+ // 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 traffic1769 // 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(&registryMiddlewares, svc, cfg, log)1838+ appendRegistryAuthMiddleware(&registryMiddlewares, 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 return1936 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 properly2044 // 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 empty2789 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 (
77 "net/http/httptest"
88 "os"
99 "path/filepath"
10+ "sync"
1011 "testing"
1112
1213 "github.com/bnema/zerowrap"
@@ -15,8 +16,30 @@ import (
1516
1617 adminhttp "github.com/bnema/gordon/internal/adapters/in/http/admin"
1718 pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
19+ out "github.com/bnema/gordon/internal/boundaries/out"
1820 )
1921
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+
2043 func TestCreateAuthService_Disabled_ReturnsNilServices(t *testing.T) {
2144 t.Parallel()
2245
@@ -42,7 +65,7 @@ func TestCreateHTTPHandlers_LocalMode_DisablesAdminRoutes(t *testing.T) {
4265 cfg.Auth.Enabled = false
4366
4467 svc := &services{adminHandler: &adminhttp.Handler{}}
45- registryHandler, _, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
68+ registryHandler, _, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
4669
4770 req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
4871 req.RemoteAddr = "192.0.2.10:12345"
@@ -61,7 +84,7 @@ func TestCreateHTTPHandlers_LocalMode_RestrictsRegistryToLoopback(t *testing.T)
6184 cfg := Config{}
6285 cfg.Auth.Enabled = false
6386
64- registryHandler, _, _ := createHTTPHandlers(&services{}, cfg, zerowrap.Default())
87+ registryHandler, _, _ := createHTTPHandlers(&services{}, cfg, zerowrap.Default(), nil)
6588
6689 req := httptest.NewRequest(http.MethodGet, "/v2/test/manifests/latest", nil)
6790 req.RemoteAddr = "192.0.2.20:12345"
@@ -103,7 +126,7 @@ func TestCreateHTTPHandlers_DirectHTTPRoot_ServesOnboarding(t *testing.T) {
103126 cfg := newOnboardingConfig()
104127 svc := &services{caAdapter: ca}
105128
106- _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
129+ _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
107130
108131 req := httptest.NewRequest(http.MethodGet, "/", nil)
109132 req.RemoteAddr = directAddr
@@ -122,7 +145,7 @@ func TestCreateHTTPHandlers_DirectHTTPCertPath_ServesCACert(t *testing.T) {
122145 cfg := newOnboardingConfig()
123146 svc := &services{caAdapter: ca}
124147
125- _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
148+ _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
126149
127150 req := httptest.NewRequest(http.MethodGet, "/ca.crt", nil)
128151 req.RemoteAddr = directAddr
@@ -141,7 +164,7 @@ func TestCreateHTTPHandlers_DirectHTTPUnknownPath_ReturnsForbidden(t *testing.T)
141164 cfg := newOnboardingConfig()
142165 svc := &services{caAdapter: ca}
143166
144- _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
167+ _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
145168
146169 req := httptest.NewRequest(http.MethodGet, "/web", nil)
147170 req.RemoteAddr = directAddr
@@ -159,7 +182,7 @@ func TestCreateHTTPHandlers_TrustedProxyHTTP_DoesNotServeOnboarding(t *testing.T
159182 cfg := newOnboardingConfig()
160183 svc := &services{caAdapter: ca}
161184
162- _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
185+ _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
163186
164187 req := httptest.NewRequest(http.MethodGet, "/", nil)
165188 req.RemoteAddr = trustedProxyAddr
@@ -179,7 +202,7 @@ func TestCreateHTTPHandlers_TrustedProxyCACertPath_DoesNotServeOnboardingResourc
179202 cfg := newOnboardingConfig()
180203 svc := &services{caAdapter: ca}
181204
182- _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
205+ _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
183206
184207 req := httptest.NewRequest(http.MethodGet, "/ca.crt", nil)
185208 req.RemoteAddr = trustedProxyAddr
@@ -199,7 +222,7 @@ func TestCreateHTTPHandlers_ForceHTTPSRedirect_DoesNotBypassDirectOnboarding(t *
199222 cfg.Server.ForceHTTPSRedirect = true
200223 svc := &services{caAdapter: ca}
201224
202- _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
225+ _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
203226
204227 req := httptest.NewRequest(http.MethodGet, "/", nil)
205228 req.RemoteAddr = directAddr
@@ -218,7 +241,7 @@ func TestCreateHTTPHandlers_HTTPSOnboarding_RemainsAvailable(t *testing.T) {
218241 cfg := newOnboardingConfig()
219242 svc := &services{caAdapter: ca}
220243
221- _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default())
244+ _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
222245
223246 req := httptest.NewRequest(http.MethodGet, "/ca", nil)
224247 req.RemoteAddr = directAddr
@@ -236,7 +259,7 @@ func TestCreateHTTPHandlers_HTTPSOnboarding_HEADRoutesRemainAvailable(t *testing
236259 cfg := newOnboardingConfig()
237260 svc := &services{caAdapter: ca}
238261
239- _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default())
262+ _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
240263
241264 // Use httptest.NewServer so Go's net/http server strips the body for HEAD.
242265 // httptest.NewRecorder does not apply server-level HEAD body stripping.
@@ -262,7 +285,7 @@ func TestCreateHTTPHandlers_DirectHTTPOnboarding_HEADRoutesRemainAvailable(t *te
262285 cfg := newOnboardingConfig()
263286 svc := &services{caAdapter: ca}
264287
265- _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
288+ _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
266289
267290 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
268291 r.RemoteAddr = directAddr
@@ -297,7 +320,7 @@ func TestCreateHTTPHandlers_DirectHTTPForbiddenHEAD_ReturnsForbidden(t *testing.
297320 cfg := newOnboardingConfig()
298321 svc := &services{caAdapter: ca}
299322
300- _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
323+ _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
301324
302325 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
303326 r.RemoteAddr = directAddr
@@ -323,7 +346,7 @@ func TestCreateHTTPHandlers_DirectHTTPACMEChallenge_Returns404(t *testing.T) {
323346 cfg := newOnboardingConfig()
324347 svc := &services{caAdapter: ca}
325348
326- _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
349+ _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
327350
328351 req := httptest.NewRequest(http.MethodGet, "/.well-known/acme-challenge/test-token", nil)
329352 req.RemoteAddr = directAddr
@@ -341,7 +364,7 @@ func TestCreateHTTPHandlers_TLSDisabled_DoesNotServeHTTPOnboarding(t *testing.T)
341364 cfg.Server.TLSPort = 0 // TLS disabled
342365 svc := &services{caAdapter: ca}
343366
344- _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
367+ _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
345368
346369 req := httptest.NewRequest(http.MethodGet, "/", nil)
347370 req.RemoteAddr = directAddr
@@ -399,3 +422,84 @@ func TestBuildRegistryCIDRAllowlistMiddleware_InvalidEntries_DenyAll(t *testing.
399422 t.Fatalf("expected status %d, got %d", http.StatusForbidden, rec.Code)
400423 }
401424 }
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 = false65 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 = false85 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 = directAddr132 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 = directAddr151 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 = directAddr170 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 = trustedProxyAddr188 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 = trustedProxyAddr208 req.RemoteAddr = trustedProxyAddr
@@ -199,7 +222,7 @@ func TestCreateHTTPHandlers_ForceHTTPSRedirect_DoesNotBypassDirectOnboarding(t *
199 cfg.Server.ForceHTTPSRedirect = true222 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 = directAddr228 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 = directAddr247 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 = directAddr291 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 = directAddr326 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 = directAddr352 req.RemoteAddr = directAddr
@@ -341,7 +364,7 @@ func TestCreateHTTPHandlers_TLSDisabled_DoesNotServeHTTPOnboarding(t *testing.T)
341 cfg.Server.TLSPort = 0 // TLS disabled364 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 = directAddr370 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/"

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close