Silent self-update, active sessions with real client IPs, genre/year browsing, handoff fix #119
@@ -6,6 +6,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
// requestLog is an slog-based access log middleware. chi ships
|
// requestLog is an slog-based access log middleware. chi ships
|
||||||
@@ -18,7 +20,21 @@ import (
|
|||||||
//
|
//
|
||||||
// Severity is keyed off the response status so 4xx/5xx surface even when
|
// Severity is keyed off the response status so 4xx/5xx surface even when
|
||||||
// the operator's logger level is set above Info.
|
// the operator's logger level is set above Info.
|
||||||
func requestLog(logger *slog.Logger) func(http.Handler) http.Handler {
|
//
|
||||||
|
// The `remote` attribute holds the address resolved through the operator's
|
||||||
|
// configured reverse-proxy depth, NOT the raw socket peer (#2453). Behind a
|
||||||
|
// proxy — the normal deployment for anything public — the socket peer is the
|
||||||
|
// proxy, so every line would have carried the same useless address, and the
|
||||||
|
// access log would have disagreed with the Active-sessions surface about who
|
||||||
|
// connected. The attribute key is unchanged so existing log greps keep
|
||||||
|
// working; only its accuracy improved.
|
||||||
|
//
|
||||||
|
// trustedHops is a func because this middleware is constructed at boot while
|
||||||
|
// the value is operator-editable at runtime, and — since Router() registers
|
||||||
|
// this before it builds the settings service — because it lets the accessor
|
||||||
|
// be wired before the thing it reads exists. auth.ClientIP tolerates a depth
|
||||||
|
// of 0, which is what a nil service reports.
|
||||||
|
func requestLog(logger *slog.Logger, trustedHops func() int) func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path == "/healthz" {
|
if r.URL.Path == "/healthz" {
|
||||||
@@ -29,13 +45,17 @@ func requestLog(logger *slog.Logger) func(http.Handler) http.Handler {
|
|||||||
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||||
next.ServeHTTP(ww, r)
|
next.ServeHTTP(ww, r)
|
||||||
status := ww.Status()
|
status := ww.Status()
|
||||||
|
hops := 0
|
||||||
|
if trustedHops != nil {
|
||||||
|
hops = trustedHops()
|
||||||
|
}
|
||||||
attrs := []any{
|
attrs := []any{
|
||||||
"method", r.Method,
|
"method", r.Method,
|
||||||
"path", r.URL.Path,
|
"path", r.URL.Path,
|
||||||
"status", status,
|
"status", status,
|
||||||
"duration_ms", time.Since(start).Milliseconds(),
|
"duration_ms", time.Since(start).Milliseconds(),
|
||||||
"request_id", middleware.GetReqID(r.Context()),
|
"request_id", middleware.GetReqID(r.Context()),
|
||||||
"remote", r.RemoteAddr,
|
"remote", auth.ClientIP(r, hops),
|
||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
case status >= 500:
|
case status >= 500:
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ func TestRequestLog_StatusToSeverity(t *testing.T) {
|
|||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
logger, records := newCaptureLogger()
|
logger, records := newCaptureLogger()
|
||||||
h := requestLog(logger)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
h := requestLog(logger, nil)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.WriteHeader(tc.status)
|
w.WriteHeader(tc.status)
|
||||||
}))
|
}))
|
||||||
req := httptest.NewRequest(http.MethodGet, "/something", nil)
|
req := httptest.NewRequest(http.MethodGet, "/something", nil)
|
||||||
@@ -75,7 +75,7 @@ func TestRequestLog_StatusToSeverity(t *testing.T) {
|
|||||||
|
|
||||||
func TestRequestLog_SkipsHealthz(t *testing.T) {
|
func TestRequestLog_SkipsHealthz(t *testing.T) {
|
||||||
logger, records := newCaptureLogger()
|
logger, records := newCaptureLogger()
|
||||||
h := requestLog(logger)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
h := requestLog(logger, nil)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}))
|
}))
|
||||||
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||||
@@ -92,7 +92,7 @@ func TestRequestLog_AttributesPresent(t *testing.T) {
|
|||||||
// formatter (catches WithAttrs/WithGroup integration regressions).
|
// formatter (catches WithAttrs/WithGroup integration regressions).
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
logger := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
logger := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||||
h := requestLog(logger)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
h := requestLog(logger, nil)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}))
|
}))
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/something", strings.NewReader(""))
|
req := httptest.NewRequest(http.MethodPost, "/api/something", strings.NewReader(""))
|
||||||
@@ -102,9 +102,72 @@ func TestRequestLog_AttributesPresent(t *testing.T) {
|
|||||||
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
|
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
|
||||||
t.Fatalf("decode log line: %v\nraw: %s", err, buf.String())
|
t.Fatalf("decode log line: %v\nraw: %s", err, buf.String())
|
||||||
}
|
}
|
||||||
for _, key := range []string{"method", "path", "status", "duration_ms"} {
|
for _, key := range []string{"method", "path", "status", "duration_ms", "remote"} {
|
||||||
if _, ok := got[key]; !ok {
|
if _, ok := got[key]; !ok {
|
||||||
t.Errorf("expected key %q in log entry, got %v", key, got)
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -113,8 +113,20 @@ func New(logger *slog.Logger, pool *pgxpool.Pool, scanner ScanTrigger, subCfg su
|
|||||||
|
|
||||||
func (s *Server) Router() http.Handler {
|
func (s *Server) Router() http.Handler {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
// Built before the router because the access log needs it, and the access
|
||||||
|
// log covers /healthz and the SPA — which exist whether or not there's a
|
||||||
|
// pool. netsettings.New handles a nil pool by returning a default-valued
|
||||||
|
// service, so this needs no branch and no later reassignment; hoisting it
|
||||||
|
// here keeps the accessor a plain method value instead of a closure over
|
||||||
|
// a variable mutated after the middleware is already registered.
|
||||||
|
netSettings, nsErr := netsettings.New(context.Background(), s.Pool, s.Logger)
|
||||||
|
if nsErr != nil {
|
||||||
|
s.Logger.Error("server: netsettings boot failed, using default hops", "err", nsErr)
|
||||||
|
}
|
||||||
|
|
||||||
r.Use(middleware.RequestID)
|
r.Use(middleware.RequestID)
|
||||||
r.Use(requestLog(s.Logger))
|
r.Use(requestLog(s.Logger, netSettings.Hops))
|
||||||
r.Use(middleware.Recoverer)
|
r.Use(middleware.Recoverer)
|
||||||
|
|
||||||
r.Get("/healthz", s.handleHealthz)
|
r.Get("/healthz", s.handleHealthz)
|
||||||
@@ -165,14 +177,6 @@ func (s *Server) Router() http.Handler {
|
|||||||
s.Logger.Error("server: recsettings boot failed", "err", err)
|
s.Logger.Error("server: recsettings boot failed", "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Cached trusted-proxy depth (#2453). Constructed here rather than in
|
|
||||||
// main.go because nothing else needs it at boot, and New always hands
|
|
||||||
// back a usable service — a DB hiccup degrades to the default rather
|
|
||||||
// than breaking the authenticated request path that reads it.
|
|
||||||
netSettings, err := netsettings.New(context.Background(), s.Pool, s.Logger)
|
|
||||||
if err != nil {
|
|
||||||
s.Logger.Error("server: netsettings boot failed, using default hops", "err", err)
|
|
||||||
}
|
|
||||||
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, recSettings, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.TagSettings, s.LibraryScanner, s.ScanCfg, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret, netSettings)
|
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, recSettings, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.TagSettings, s.LibraryScanner, s.ScanCfg, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret, netSettings)
|
||||||
// /api/admin/scan is the only admin route owned by the server package
|
// /api/admin/scan is the only admin route owned by the server package
|
||||||
// (it needs the Scanner). Register it as a single inline-middleware
|
// (it needs the Scanner). Register it as a single inline-middleware
|
||||||
|
|||||||
Reference in New Issue
Block a user