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
176 lines
5.8 KiB
Go
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
|
|
}
|