Files
minstrel/internal/server/server.go
T
bvandeusen 381e9cedb7
test-go / test (push) Failing after 50s
test-web / test (push) Failing after 50s
test-go / integration (push) Failing after 2m19s
feat(net): trusted-proxy depth so real client IPs survive a proxy — #2453
Fixes the defect the operator spotted in #370 immediately after it shipped:
auth.ClientIP ignored X-Forwarded-For whenever RemoteAddr was public, so a
proxy on a public address — a separate host, or a CDN, i.e. anyone running
this publicly, since public means TLS means a proxy — recorded the PROXY for
every session. created_ip and last_ip were then always equal and the
"Address changed" signal could never fire. The feature looked like it worked
and reported nothing.

Replaced with the standard trusted-hop model (Rails, Caddy, Traefik, nginx).
XFF grows left-to-right as each proxy appends the peer it received from, so
for client -> CDN -> own-proxy -> app the app sees [client, CDN] with
RemoteAddr = own-proxy, and the client sits at XFF[len - hops]:

  0  RemoteAddr, XFF ignored — no proxy
  1  the address your own proxy observed
  2  through a CDN in front of your proxy

Default 1, per the operator: publicly reachable means a TLS terminator in
front.

The cost is real and stated rather than hidden. hops >= 1 DECLARES that a
proxy exists; set it with no proxy, or deeper than the actual chain, and the
index reaches attacker-supplied entries, letting a visitor choose which
address their own session shows — defeating exactly the detection #370 is
for. That's inherent to the model, which is why 0 is a first-class value and
the admin card says "count your proxies, don't guess high" instead of just
exposing a number. Both mis-set shapes are pinned by tests so they stay known
consequences rather than surprises.

Migration 0053 + internal/netsettings, cached under an RWMutex. That's not an
optimisation: ClientIP runs in RequireUser for every authenticated request, so
a per-request query would put the database on the critical path of the whole
API. New() always returns a usable service so a boot-time DB hiccup degrades
to the default instead of breaking that path (rule #131), and Hops() is
nil-safe because test routers construct middleware without it.

RequireUser now takes a func() int rather than an int — the value is
operator-editable at runtime while the middleware is built once at boot, and
reading it per request is what makes a save take effect with no restart
(rule #25).

The admin card is verifiable, not just configurable: it reports the address
the CURRENT setting resolves THIS request to, the raw forwarded chain, and the
socket peer — so you set the number, save, and confirm the address matches the
machine you're on. It also counts the arriving chain and says how many proxies
that implies. GET/PUT both return that payload, PUT recomputed under the new
value, so the effect is visible without a reload.

Also fixes styling in the #370 card that CI could not catch: text-destructive
and bg-destructive don't exist in this Tailwind config — the palette is
colors.action.destructive — so the "Address changed" warning and the
sign-out-others button were rendering unstyled. Both now use
text-action-destructive / bg-action-destructive / text-action-fg.

Not done here: requestlog.go still logs raw RemoteAddr and will disagree with
the sessions UI about who connected. Left for its own change.
2026-08-05 10:07:43 -04:00

227 lines
10 KiB
Go

package server
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/jackc/pgx/v5/pgxpool"
"time"
"git.fabledsword.com/bvandeusen/minstrel/internal/api"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
"git.fabledsword.com/bvandeusen/minstrel/internal/netsettings"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
"git.fabledsword.com/bvandeusen/minstrel/web"
)
// lidarrUnmonitorAdapter wires the per-call lidarrClientFn factory pattern
// (used elsewhere in this package so admin Lidarr config edits take effect
// without restart) into the tracks.LidarrUnmonitorer interface that
// internal/tracks expects. When the factory returns nil (Lidarr disabled)
// we surface a sentinel error — tracks.Service treats UnmonitorTrack
// failures as non-fatal, so this collapses to a `lidarr_unmonitor_failed`
// flag in the response and the destructive part still runs.
type lidarrUnmonitorAdapter struct {
fn func() *lidarr.Client
}
var errLidarrDisabled = errors.New("lidarr disabled")
func (a lidarrUnmonitorAdapter) UnmonitorTrack(ctx context.Context, trackMbid, albumMbid string) error {
c := a.fn()
if c == nil {
return errLidarrDisabled
}
return c.UnmonitorTrack(ctx, trackMbid, albumMbid)
}
// ScanTrigger is the subset of the scanner the HTTP handler needs. Kept as an
// interface so tests can stub it without touching the DB. The progressCb
// parameter (added in m7-scan-progress) lets the orchestrator drive partial-
// tally writes; the HTTP handler passes nil for fire-and-forget triggers.
type ScanTrigger interface {
Scan(ctx context.Context, progressCb func(library.Stats)) (library.Stats, error)
}
type Server struct {
Logger *slog.Logger
Pool *pgxpool.Pool
Scanner ScanTrigger
SubsonicCfg subsonic.Config
EventsCfg config.EventsConfig
RecommendationCfg config.RecommendationConfig
// DataDir is the on-disk root for cached artifacts (currently
// playlist cover collages under <DataDir>/playlist_covers/). Empty
// strings are tolerated by tests that don't exercise persisted-cover
// codepaths; production callers should pass a writable directory.
DataDir string
BrandingCfg config.BrandingConfig
CoverEnricher *coverart.Enricher
CoverSettings *coverart.SettingsService
TagSettings *tags.SettingsService
LibraryScanner *library.Scanner
ScanCfg library.RunScanConfig
// Bus is the live-event bus shared with background workers (the
// lidarr reconciler, scan workers) constructed in cmd/minstrel/main.go.
// When nil, Router() constructs a local fallback (test contexts).
Bus *eventbus.Bus
// PlaylistScheduler fires per-user daily system-playlist builds at
// 03:00 in each user's stored timezone (#392 Half B). Constructed
// in cmd/minstrel/main.go and threaded into the API handlers so
// PUT /api/me/timezone and POST /api/auth/register can call
// Refresh synchronously.
PlaylistScheduler *playlists.Scheduler
// RecSettings is the DB-backed recommendation tuning lab (#1250):
// scoring-weight profiles + taste-build knobs. Constructed in
// cmd/minstrel/main.go (it pushes daily-mix weights into package
// playlists at boot); the API layer reads radio weights per request
// and serves the admin tuning endpoints from it. Router() constructs
// a fallback when nil (tests).
RecSettings *recsettings.Service
// StreamSecret is the HMAC key used by /api/cast/stream-token to
// mint signed UPnP / Sonos stream URLs and by /api/tracks/{id}/stream
// to verify them. Sourced from config.Config.StreamSecret. Tests that
// leave it nil leave the cookie path intact and reject all signed
// tokens (HMAC of empty key matches nothing a client could mint).
StreamSecret []byte
}
func New(logger *slog.Logger, pool *pgxpool.Pool, scanner ScanTrigger, subCfg subsonic.Config, eventsCfg config.EventsConfig, recCfg config.RecommendationConfig, dataDir string, brandingCfg config.BrandingConfig, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, libraryScanner *library.Scanner, scanCfg library.RunScanConfig) *Server {
return &Server{Logger: logger, Pool: pool, Scanner: scanner, SubsonicCfg: subCfg, EventsCfg: eventsCfg, RecommendationCfg: recCfg, DataDir: dataDir, BrandingCfg: brandingCfg, CoverEnricher: coverEnricher, CoverSettings: coverSettings, LibraryScanner: libraryScanner, ScanCfg: scanCfg}
}
func (s *Server) Router() http.Handler {
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(requestLog(s.Logger))
r.Use(middleware.Recoverer)
r.Get("/healthz", s.handleHealthz)
if s.Pool != nil {
writer := playevents.NewWriter(
s.Pool, s.Logger,
time.Duration(s.EventsCfg.SessionTimeoutMinutes)*time.Minute,
s.EventsCfg.SkipMaxCompletionRatio,
s.EventsCfg.SkipMaxDurationPlayedMs,
)
lidarrCfg := lidarrconfig.New(s.Pool)
// Per-call client factory: re-reads config so an admin save in
// /admin/integrations takes effect immediately without restart.
// Returns nil when Lidarr is disabled, which the Service-layer
// methods translate to ErrLidarrDisabled.
lidarrClientFn := func() *lidarr.Client {
cfg, err := lidarrCfg.Get(context.Background())
if err != nil || !cfg.Enabled || cfg.BaseURL == "" || cfg.APIKey == "" {
return nil
}
return lidarr.NewClient(cfg.BaseURL, cfg.APIKey)
}
lidarrReqs := lidarrrequests.NewService(s.Pool, lidarrCfg, lidarrClientFn, nil)
lidarrQuar := lidarrquarantine.NewService(s.Pool, lidarrCfg, lidarrClientFn)
tracksSvc := tracks.NewService(s.Pool, s.Logger, lidarrUnmonitorAdapter{fn: lidarrClientFn}, s.DataDir)
playlistsSvc := playlists.NewService(s.Pool, s.Logger, s.DataDir)
smtpSender := mailer.NewSMTPSender(s.Pool, s.Logger.With("component", "mailer"))
// Live-event bus for SSE subscribers (#392). Constructed per-process;
// producers in playevents / lidarrrequests / scanner publish into
// the same instance. Background workers (lidarr reconciler, scan
// scheduler) live in cmd/minstrel/main.go where they're constructed
// before Router() runs; they read s.Bus directly so they share this
// process's bus. When Router() runs before main set the bus (tests,
// or future contexts) we fall back to a fresh local instance.
bus := s.Bus
if bus == nil {
bus = eventbus.New()
}
recSettings := s.RecSettings
if recSettings == nil {
// Test contexts construct Server directly without main.go's
// boot wiring; reconcile here so radio + the admin tuning
// endpoints work against the same pool.
var err error
recSettings, err = recsettings.New(context.Background(), s.Pool, s.Logger)
if err != nil {
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/admin/scan is the only admin route owned by the server package
// (it needs the Scanner). Register it as a single inline-middleware
// route — using r.Route("/api/admin", ...) here would create a second
// subtree that shadows every admin route registered by api.Mount.
if s.Scanner != nil {
r.With(auth.RequireUser(s.Pool, netSettings.Hops), auth.RequireAdmin()).
Post("/api/admin/scan", s.handleAdminScan)
}
subsonic.Mount(r, s.Pool, s.Logger, s.SubsonicCfg, writer)
}
spa := web.Handler(s.BrandingCfg)
r.NotFound(func(w http.ResponseWriter, req *http.Request) {
p := req.URL.Path
if strings.HasPrefix(p, "/api/") || strings.HasPrefix(p, "/rest/") {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"error":{"code":"not_found","message":"not found"}}`))
return
}
spa.ServeHTTP(w, req)
})
return r
}
func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
"version": ServerVersion,
"min_client_version": MinClientVersion,
})
}
// handleAdminScan runs a scan synchronously and returns the resulting stats.
// Kept synchronous for v1: libraries are small and the operator can tell when
// it's done. Async jobs with status polling are a later-milestone concern.
func (s *Server) handleAdminScan(w http.ResponseWriter, r *http.Request) {
stats, err := s.Scanner.Scan(r.Context(), nil)
w.Header().Set("Content-Type", "application/json")
if err != nil {
s.Logger.Error("admin scan failed", "err", err)
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]any{"error": err.Error(), "stats": stats})
return
}
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(stats)
}