Files
nmon/internal/auth/auth.go
Vibe Coder f31fb3712a
Some checks failed
ci / lint (push) Successful in 4m19s
ci / openapi (push) Successful in 29s
ci / postgres (push) Successful in 4m55s
ci / test (push) Failing after 19m48s
Add settings forms, first-install wizard, webhook presets, help and poll tools
Make nmon easier to install and run without editing YAML by hand:

- Settings: point-and-click forms over config.yaml (GET/PUT
  /api/v1/settings/form); the YAML editor moves to /settings/yaml.
- First install: with no sign-in configured nmon starts in install
  mode, serves only the setup wizard (sign-in, basics, devices,
  alerting) and closes it for good once sign-in is saved. A
  self-signed certificate is generated when no TLS source is given.
  poller.interval becomes a config setting.
- Reverse proxies: opt-in --http-listen plain-HTTP listener beside
  HTTPS; sign-in stays required.
- Webhooks: Generic, Teams Adaptive Card, Slack and Discord presets,
  template preview and test send.
- Help: the operations, coverage and profile docs served at /help.
- Polling: per-device "Poll now", a dry-run test poll returning an
  SNMP trace, and time-limited in-memory SNMP debug recording, in the
  UI and as `nmon device poll`.
- Ansible: deploys into install mode when no sign-in is set, and no
  longer overwrites the config.yaml the wizard wrote.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TuKgyKve2XLFtrrXWMi2w
2026-10-25 06:31:12 +00:00

176 lines
5.8 KiB
Go

package auth
import (
"context"
"errors"
"fmt"
"log/slog"
"sync/atomic"
"time"
"github.com/v10networks/nmon/internal/clock"
)
// Options builds an Auth.
type Options struct {
Store Store
Clock clock.Clock // default clock.Real()
Alarms Alarms // security events (break-glass); may be nil
Logger *slog.Logger
Session SessionConfig
// OIDC is nil when no IDP is configured; only break-glass and API tokens work then.
OIDC *OIDCConfig
// BreakGlass.File empty disables the local admin.
BreakGlass BreakGlassConfig
// TrustedProxies are the CIDRs or addresses whose X-Forwarded-* headers are honoured.
TrustedProxies []string
// IngestPrefixes are request paths Middleware passes through untouched because ingest authenticates
// them itself with push tokens ("/write", "/api/v1/write", ...).
IngestPrefixes []string
// Policy declares per-route minimum roles; nil means defaults only.
Policy *Policy
// DevOpen turns authentication off: every request outside ingest is an admin Principal named "dev".
// It exists for `serve --insecure-http` with no OIDC or break-glass configured (development only).
DevOpen bool
// Install is first-install mode: no sign-in is configured yet, so only the routes the Policy marks with
// AllowInstall (and the browser pages) are served, to anyone, as an admin; the rest answer 503. The owner
// accepted this for DMZ / internal deployments; it ends for good when setup completes.
Install bool
// KeyRotateEvery is how often the cookie-signing key rotates; default 30 days.
KeyRotateEvery time.Duration
}
// DefaultKeyRotateEvery is the signing key's life before nmon rotates it.
const DefaultKeyRotateEvery = 30 * 24 * time.Hour
// Auth is the assembled authentication service.
type Auth struct {
opts Options
proxies atomic.Pointer[Proxies]
signer *signer
Sessions *Sessions
Tokens *Tokens
OIDC *OIDC // nil when not configured
BreakGlass *BreakGlass // always set; Login returns ErrBreakGlassDisabled without a file
}
// New builds the service, creating the first signing key on a fresh database. It does not contact the IDP.
func New(ctx context.Context, opts Options) (*Auth, error) {
if opts.Store == nil {
return nil, errors.New("auth: a store is required")
}
if opts.Clock == nil {
opts.Clock = clock.Real()
}
if opts.Logger == nil {
opts.Logger = slog.Default()
}
if opts.KeyRotateEvery <= 0 {
opts.KeyRotateEvery = DefaultKeyRotateEvery
}
proxies, err := ParseProxies(opts.TrustedProxies)
if err != nil {
return nil, err
}
sg, err := newSigner(ctx, opts.Store, opts.Clock)
if err != nil {
return nil, err
}
a := &Auth{opts: opts, signer: sg}
a.proxies.Store(proxies)
if a.opts.Policy == nil {
a.opts.Policy = NewPolicy()
}
a.declareOwnRoutes()
if err := a.build(); err != nil {
return nil, err
}
return a, nil
}
// build creates the parts that depend on the signer.
func (a *Auth) build() error {
o := a.opts
var refresher Refresher
if o.OIDC != nil {
// The OIDC needs the sessions and the sessions the refresher; break the cycle with a late binding.
a.Sessions = NewSessions(o.Store, a.signer, o.Clock, o.Session, nil)
oc, err := NewOIDC(*o.OIDC, a.signer, a.Sessions, o.Clock, o.Session.InsecureCookies)
if err != nil {
return err
}
a.OIDC, refresher = oc, oc
a.Sessions.refresher = refresher
} else {
a.Sessions = NewSessions(o.Store, a.signer, o.Clock, o.Session, nil)
}
a.Tokens = NewTokens(o.Store, o.Clock)
a.BreakGlass = NewBreakGlass(o.BreakGlass, o.Store, o.Clock, a.Sessions, o.Alarms, o.Logger)
return nil
}
// declareOwnRoutes registers the /auth/ endpoints in the policy: login, callback and break-glass need no
// credentials; logout and the session probe need any authenticated user.
func (a *Auth) declareOwnRoutes() {
a.opts.Policy.Public("GET /auth/login", "GET /auth/callback", "GET /auth/breakglass", "POST /auth/breakglass", "GET /auth/signedout")
a.opts.Policy.Require("POST /auth/logout", RoleViewer)
a.opts.Policy.Require("GET /auth/session", RoleViewer)
}
// SetTrustedProxies replaces the trusted-proxy list (config reload). An invalid list changes nothing.
func (a *Auth) SetTrustedProxies(cidrs []string) error {
p, err := ParseProxies(cidrs)
if err != nil {
return err
}
a.proxies.Store(p)
return nil
}
// InstallMode reports whether the service is in first-install mode.
func (a *Auth) InstallMode() bool { return a.opts.Install }
// DevOpen reports whether authentication is off.
func (a *Auth) DevOpen() bool { return a.opts.DevOpen }
// OIDCEnabled reports whether an identity provider is configured.
func (a *Auth) OIDCEnabled() bool { return a.OIDC != nil }
// Policy returns the route policy, for the API to declare its routes on.
func (a *Auth) Policy() *Policy { return a.opts.Policy }
// Run keeps house until ctx ends: it rotates the signing key when it is old and deletes dead sessions.
func (a *Auth) Run(ctx context.Context) {
tick := a.opts.Clock.NewTicker(time.Hour)
defer tick.Stop()
for {
a.housekeeping(ctx)
select {
case <-ctx.Done():
return
case <-tick.C():
}
}
}
func (a *Auth) housekeeping(ctx context.Context) {
keep := a.Sessions.cfg.Absolute
if rotated, err := a.signer.rotateIfOld(ctx, a.opts.KeyRotateEvery, keep); err != nil {
a.opts.Logger.Error("rotate session signing key", slog.Any("err", err))
} else if rotated {
a.opts.Logger.Info("session signing key rotated")
}
if _, err := a.opts.Store.DeleteDeadSessions(ctx, a.opts.Clock.Now().Add(-keep)); err != nil {
a.opts.Logger.Error("delete dead sessions", slog.Any("err", err))
}
}
// RotateSigningKey rotates the cookie-signing key now. Existing sessions keep working.
func (a *Auth) RotateSigningKey(ctx context.Context) error {
if err := a.signer.rotate(ctx, a.Sessions.cfg.Absolute); err != nil {
return fmt.Errorf("rotate signing key: %w", err)
}
return nil
}