1package middleware
2
3import (
4 "net"
5 "net/http"
6 "net/url"
7 "strings"
8 "time"
9
10 "github.com/bnema/zerowrap"
11
12 "github.com/bnema/gordon/internal/adapters/in/http/httphelper"
13 out "github.com/bnema/gordon/internal/boundaries/out"
14)
15
16// AccessLogger is a middleware that writes one access-log entry per HTTP request
17// to the provided writer. It runs alongside (not instead of) RequestLogger.
18//
19// When excludeHealthChecks is true, requests from the Gordon health prober
20// (UA prefix out.HealthCheckUserAgentPrefix) or from loopback IPs are not logged.
21//
22// Write failures are reported as warnings through the application logger and
23// never fail the HTTP response.
24func AccessLogger(writer out.AccessLogWriter, excludeHealthChecks bool, log zerowrap.Logger, trustedNets []*net.IPNet) func(http.Handler) http.Handler {
25 return func(next http.Handler) http.Handler {
26 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
27 start := time.Now()
28
29 // Ensure a request ID exists before calling inner handlers so both
30 // this middleware and RequestLogger share the same value.
31 requestID, r := ensureRequestID(w, r)
32
33 // Wrap the response writer to capture status code and bytes written.
34 rw := NewResponseWriter(w)
35
36 // Resolve client IP before calling the inner handler so we see the
37 // original remote address before any mutation.
38 clientIP := GetClientIP(r, trustedNets)
39
40 next.ServeHTTP(rw, r)
41
42 // Apply health-check exclusion after the response so the request
43 // always completes — we just skip writing the log entry.
44 // UA check uses the shared domain constant so the prober and this
45 // filter can never drift. Loopback check uses the shared helper list.
46 if excludeHealthChecks {
47 if strings.HasPrefix(r.UserAgent(), out.HealthCheckUserAgentPrefix) ||
48 httphelper.IsTrustedProxy(clientIP, httphelper.LocalhostNets) {
49 return
50 }
51 }
52
53 // Capture end time once; use it for both duration and entry timestamp
54 // so the two values are derived from the same instant.
55 end := time.Now()
56 durationMS := float64(end.Sub(start).Microseconds()) / 1000.0
57
58 entry := out.AccessLogEntry{
59 Time: end.UTC(),
60 ClientIP: clientIP,
61 Method: r.Method,
62 Host: r.Host,
63 Path: r.URL.Path,
64 Query: sanitizeLoggedQuery(r.URL.RawQuery),
65 Status: rw.StatusCode(),
66 BytesSent: rw.BytesWritten(),
67 DurationMS: durationMS,
68 UserAgent: r.UserAgent(),
69 Referer: r.Referer(),
70 RequestID: requestID,
71 Proto: r.Proto,
72 }
73
74 if err := writer.Write(entry); err != nil {
75 log.Warn().
76 Str(zerowrap.FieldLayer, "adapter").
77 Str(zerowrap.FieldAdapter, "http").
78 Str(zerowrap.FieldRequestID, requestID).
79 Str(zerowrap.FieldMethod, r.Method).
80 Str(zerowrap.FieldPath, r.URL.Path).
81 Int(zerowrap.FieldStatus, rw.StatusCode()).
82 Str(zerowrap.FieldClientIP, clientIP).
83 Err(err).
84 Msg("access log write failed")
85 }
86 })
87 }
88}
89
90func sanitizeLoggedQuery(rawQuery string) string {
91 if rawQuery == "" {
92 return ""
93 }
94
95 parts := strings.Split(rawQuery, "&")
96 for i, part := range parts {
97 if part == "" {
98 continue
99 }
100
101 key, _, found := strings.Cut(part, "=")
102 decodedKey, err := url.QueryUnescape(key)
103 if err != nil {
104 decodedKey = key
105 }
106
107 if !isSensitiveQueryKey(decodedKey) {
108 continue
109 }
110
111 if found {
112 parts[i] = key + "=[REDACTED]"
113 } else {
114 parts[i] = key
115 }
116 }
117
118 return strings.Join(parts, "&")
119}
120
121func isSensitiveQueryKey(key string) bool {
122 switch strings.ToLower(key) {
123 case "access_token", "api_key", "apikey", "auth", "bearer", "code", "credential", "credentials", "key", "password", "private_key", "refresh_token", "reset", "secret", "session", "session_id", "sig", "signature", "token":
124 return true
125 default:
126 return false
127 }
128}