// Package netsettings holds the DB-backed network settings the request path // needs. Today that's the trusted reverse-proxy depth used to pull a real // client address out of X-Forwarded-For (#2453). // // Values are cached under an RWMutex and refreshed on write. That isn't an // optimisation: auth.ClientIP runs in the RequireUser middleware for every // authenticated request, so a per-request query here would put the database // on the critical path of the entire API. package netsettings import ( "context" "errors" "log/slog" "sync" "github.com/jackc/pgx/v5/pgxpool" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) const ( // DefaultTrustedProxyHops mirrors migration 0053's column default. One // proxy, because anything publicly reachable needs a TLS terminator in // front of it. DefaultTrustedProxyHops = 1 // MaxTrustedProxyHops mirrors the CHECK in migration 0053. MaxTrustedProxyHops = 10 ) // ErrHopsOutOfRange is returned by SetHops for values the CHECK would reject, // so the API layer can answer 400 instead of surfacing a constraint violation. var ErrHopsOutOfRange = errors.New("trusted proxy hops must be between 0 and 10") // Service caches the network settings and owns their persistence. type Service struct { pool *pgxpool.Pool logger *slog.Logger mu sync.RWMutex hops int } // New loads the settings once and caches them. // // It ALWAYS returns a usable Service, even alongside a non-nil error. The // value it holds sits on the authenticated request path, so a boot-time // database hiccup must degrade to the default rather than take every request // down with it (rule #131). The error is returned so the caller can log that // the cache holds a default rather than stored state. func New(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger) (*Service, error) { s := &Service{pool: pool, logger: logger, hops: DefaultTrustedProxyHops} if pool == nil { return s, nil } row, err := dbq.New(pool).GetNetworkSettings(ctx) if err != nil { return s, err } s.hops = int(row.TrustedProxyHops) return s, nil } // Hops returns the cached trusted-proxy depth. // // Nil-safe: test contexts construct routers without this service, and a // missing setting should mean "trust nothing" rather than a panic in // middleware. func (s *Service) Hops() int { if s == nil { return 0 } s.mu.RLock() defer s.mu.RUnlock() return s.hops } // SetHops persists a new depth and refreshes the cache, so an admin change // takes effect on the next request with no restart (rule #25). func (s *Service) SetHops(ctx context.Context, hops int) error { // Range first, availability second. The argument is wrong regardless of // whether the database is reachable, and the distinction is user-visible: // this ordering answers 400 for a bad value, where the reverse would // report 500 and blame the server for the caller's input. if hops < 0 || hops > MaxTrustedProxyHops { return ErrHopsOutOfRange } if s == nil || s.pool == nil { // Mirrors Hops()'s nil-tolerance: handlers can be constructed without // this service in tests, and a write attempt there should be an error // rather than a panic in an HTTP handler. return errors.New("network settings unavailable") } row, err := dbq.New(s.pool).UpdateTrustedProxyHops(ctx, int32(hops)) if err != nil { return err } s.mu.Lock() s.hops = int(row.TrustedProxyHops) s.mu.Unlock() // Worth a line in the log: this changes how much of a client-supplied // header the server believes, so an operator debugging odd addresses in // the sessions list wants to see when it last moved. if s.logger != nil { s.logger.Info("netsettings: trusted proxy hops updated", "hops", hops) } return nil }