Closes the disagreement left open by #2453: requestlog.go logged raw r.RemoteAddr while the Active-sessions surface resolved through the operator's configured proxy depth. Behind a proxy — the normal deployment for anything public — every access-log line carried the same useless proxy address, and the two surfaces contradicted each other about who connected. Logs and UI disagreeing is worse than either being wrong alone, because it costs you trust in both. `remote` now holds auth.ClientIP(r, hops). The attribute KEY is deliberately unchanged so existing log greps keep working; only its accuracy improved. Wiring note. The access log covers /healthz and the SPA, so it's registered before the pool-bearing branch that used to build the settings service. Rather than close over a variable reassigned later — which works, but leaves a mutable-after-registration seam and an awkward question about races — I hoisted netsettings.New above the router entirely. It already handles a nil pool by returning a default-valued service, so no branch is needed and the accessor stays a plain method value. Applied the lesson from the last three CI failures BEFORE pushing this time: a bare-identifier grep for `requestLog(` found three call sites in requestlog_test.go that a qualified pattern could never have matched, since the function is package-private and its tests are in-package. Also swept netsettings.New and ClientIP the same way. Tests: the behaviour change gets its own table — nil accessor and depth 0 log the socket peer, depth 1 through a PUBLIC-addressed proxy logs the client (the exact case the old heuristic got wrong forever), depth 2 reaches through a CDN. Added `remote` to the required-keys assertion so the attribute can't quietly disappear.
174 lines
5.5 KiB
Go
174 lines
5.5 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// captureLogger is a slog.Handler that records each Record emitted, so
|
|
// requestLog tests can assert level + attrs without parsing JSON.
|
|
type capturedRecord struct {
|
|
Level slog.Level
|
|
Msg string
|
|
Attrs map[string]any
|
|
}
|
|
|
|
type captureHandler struct {
|
|
records *[]capturedRecord
|
|
}
|
|
|
|
func (h *captureHandler) Enabled(_ context.Context, _ slog.Level) bool { return true }
|
|
|
|
func (h *captureHandler) Handle(_ context.Context, r slog.Record) error {
|
|
rec := capturedRecord{Level: r.Level, Msg: r.Message, Attrs: map[string]any{}}
|
|
r.Attrs(func(a slog.Attr) bool { rec.Attrs[a.Key] = a.Value.Any(); return true })
|
|
*h.records = append(*h.records, rec)
|
|
return nil
|
|
}
|
|
|
|
func (h *captureHandler) WithAttrs(_ []slog.Attr) slog.Handler { return h }
|
|
func (h *captureHandler) WithGroup(_ string) slog.Handler { return h }
|
|
|
|
func newCaptureLogger() (*slog.Logger, *[]capturedRecord) {
|
|
records := &[]capturedRecord{}
|
|
return slog.New(&captureHandler{records: records}), records
|
|
}
|
|
|
|
func TestRequestLog_StatusToSeverity(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
status int
|
|
wantLevel slog.Level
|
|
}{
|
|
{"2xx is Info", http.StatusOK, slog.LevelInfo},
|
|
{"4xx is Warn", http.StatusNotFound, slog.LevelWarn},
|
|
{"5xx is Error", http.StatusInternalServerError, slog.LevelError},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
logger, records := newCaptureLogger()
|
|
h := requestLog(logger, nil)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(tc.status)
|
|
}))
|
|
req := httptest.NewRequest(http.MethodGet, "/something", nil)
|
|
h.ServeHTTP(httptest.NewRecorder(), req)
|
|
|
|
if len(*records) != 1 {
|
|
t.Fatalf("len(records) = %d, want 1", len(*records))
|
|
}
|
|
rec := (*records)[0]
|
|
if rec.Level != tc.wantLevel {
|
|
t.Errorf("level = %v, want %v", rec.Level, tc.wantLevel)
|
|
}
|
|
if rec.Attrs["status"] != int64(tc.status) {
|
|
t.Errorf("status = %v, want %d", rec.Attrs["status"], tc.status)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRequestLog_SkipsHealthz(t *testing.T) {
|
|
logger, records := newCaptureLogger()
|
|
h := requestLog(logger, nil)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
|
h.ServeHTTP(httptest.NewRecorder(), req)
|
|
|
|
if len(*records) != 0 {
|
|
t.Errorf("expected /healthz to be skipped, got %d records", len(*records))
|
|
}
|
|
}
|
|
|
|
// Sanity that other paths produce a useful structured payload.
|
|
func TestRequestLog_AttributesPresent(t *testing.T) {
|
|
// Use a real slog text handler buffer so we also exercise the
|
|
// formatter (catches WithAttrs/WithGroup integration regressions).
|
|
var buf bytes.Buffer
|
|
logger := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
|
h := requestLog(logger, nil)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
req := httptest.NewRequest(http.MethodPost, "/api/something", strings.NewReader(""))
|
|
h.ServeHTTP(httptest.NewRecorder(), req)
|
|
|
|
var got map[string]any
|
|
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
|
|
t.Fatalf("decode log line: %v\nraw: %s", err, buf.String())
|
|
}
|
|
for _, key := range []string{"method", "path", "status", "duration_ms", "remote"} {
|
|
if _, ok := got[key]; !ok {
|
|
t.Errorf("expected key %q in log entry, got %v", key, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The point of routing #2453 through the access log: behind a proxy, `remote`
|
|
// must be the client rather than the proxy, and must agree with what the
|
|
// Active-sessions surface records for the same request. Logs and UI
|
|
// disagreeing about who connected is worse than either being wrong alone.
|
|
func TestRequestLog_RemoteHonoursTrustedProxyDepth(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
hops func() int
|
|
remoteAddr string
|
|
forwarded string
|
|
want string
|
|
}{
|
|
{
|
|
name: "nil accessor falls back to the socket peer",
|
|
hops: nil,
|
|
remoteAddr: "203.0.113.200:40000",
|
|
forwarded: "198.51.100.7",
|
|
want: "203.0.113.200",
|
|
},
|
|
{
|
|
name: "depth 0 ignores a forwarded header",
|
|
hops: func() int { return 0 },
|
|
remoteAddr: "203.0.113.200:40000",
|
|
forwarded: "198.51.100.7",
|
|
want: "203.0.113.200",
|
|
},
|
|
{
|
|
// The case that motivated the change: proxy on a PUBLIC address,
|
|
// which the pre-#2453 heuristic logged as the proxy forever.
|
|
name: "depth 1 through a public-addressed proxy logs the client",
|
|
hops: func() int { return 1 },
|
|
remoteAddr: "203.0.113.200:40000",
|
|
forwarded: "198.51.100.7",
|
|
want: "198.51.100.7",
|
|
},
|
|
{
|
|
name: "depth 2 reaches through a cdn to the client",
|
|
hops: func() int { return 2 },
|
|
remoteAddr: "172.18.0.1:40000",
|
|
forwarded: "198.51.100.7, 203.0.113.50",
|
|
want: "198.51.100.7",
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
logger, records := newCaptureLogger()
|
|
h := requestLog(logger, tc.hops)(http.HandlerFunc(
|
|
func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }))
|
|
req := httptest.NewRequest(http.MethodGet, "/api/something", nil)
|
|
req.RemoteAddr = tc.remoteAddr
|
|
req.Header.Set("X-Forwarded-For", tc.forwarded)
|
|
h.ServeHTTP(httptest.NewRecorder(), req)
|
|
|
|
if len(*records) != 1 {
|
|
t.Fatalf("len(records) = %d, want 1", len(*records))
|
|
}
|
|
if got := (*records)[0].Attrs["remote"]; got != tc.want {
|
|
t.Errorf("remote = %v, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|