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
149 lines
4.9 KiB
Go
149 lines
4.9 KiB
Go
package auth
|
|
|
|
import (
|
|
"crypto/subtle"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
)
|
|
|
|
// CSRFHeader carries the per-session CSRF token on state-changing cookie requests.
|
|
const CSRFHeader = "X-CSRF-Token"
|
|
|
|
// Middleware authenticates and authorises every request, then passes the Principal down in the context.
|
|
// In order it: resolves the client address (honouring X-Forwarded-* only from a trusted proxy); lets
|
|
// ingest paths through untouched (ingest authenticates push tokens itself); lets Public routes through;
|
|
// authenticates a bearer API token or a cookie session; requires the CSRF token on a state-changing
|
|
// cookie request; and checks the route Policy's minimum role. Push tokens get 403 here.
|
|
func (a *Auth) Middleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
client := a.proxies.Load().Resolve(r)
|
|
if !client.ViaProxy {
|
|
scrub(r)
|
|
}
|
|
r = r.WithContext(withClient(r.Context(), client))
|
|
if hasPrefix(r.URL.Path, a.opts.IngestPrefixes) {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
ru := a.opts.Policy.lookup(r)
|
|
if ru.public {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
if a.opts.Install {
|
|
a.serveInstall(w, r, ru, next)
|
|
return
|
|
}
|
|
if a.opts.DevOpen {
|
|
next.ServeHTTP(w, r.WithContext(WithPrincipal(r.Context(), devPrincipal())))
|
|
return
|
|
}
|
|
p, err := a.authenticate(r)
|
|
if err != nil {
|
|
a.deny(w, r, err)
|
|
return
|
|
}
|
|
if !p.Role.AtLeast(ru.min) {
|
|
writeProblem(w, http.StatusForbidden, "requires role "+string(ru.min))
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r.WithContext(WithPrincipal(r.Context(), p)))
|
|
})
|
|
}
|
|
|
|
// serveInstall is the install-mode gate: the setup routes and the browser pages (the UI that hosts the wizard) are
|
|
// served to anyone as an admin; every other API route answers 503 with a problem, so a half-installed nmon never
|
|
// exposes the rest of the API without a sign-in.
|
|
func (a *Auth) serveInstall(w http.ResponseWriter, r *http.Request, ru rule, next http.Handler) {
|
|
if !ru.install && !a.isNavigation(r) {
|
|
w.Header().Set("Retry-After", "60")
|
|
writeProblem(w, http.StatusServiceUnavailable, "setup not finished")
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r.WithContext(WithPrincipal(r.Context(), installPrincipal())))
|
|
}
|
|
|
|
// authenticate resolves the caller from a bearer token or a cookie session and applies CSRF.
|
|
func (a *Auth) authenticate(r *http.Request) (*Principal, error) {
|
|
if secret, ok := bearer(r); ok {
|
|
return a.Tokens.Authenticate(r.Context(), secret)
|
|
}
|
|
p, err := a.Sessions.Load(r.Context(), r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !IsSafeMethod(r.Method) && !validCSRF(p.CSRF, r.Header.Get(CSRFHeader)) {
|
|
return nil, ErrCSRF
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
func validCSRF(want, got string) bool {
|
|
return want != "" && subtle.ConstantTimeCompare([]byte(want), []byte(got)) == 1
|
|
}
|
|
|
|
func (a *Auth) deny(w http.ResponseWriter, r *http.Request, err error) {
|
|
switch {
|
|
case errors.Is(err, ErrForbidden):
|
|
writeProblem(w, http.StatusForbidden, "this token is not valid for this API")
|
|
case errors.Is(err, ErrCSRF):
|
|
writeProblem(w, http.StatusForbidden, "missing or wrong "+CSRFHeader)
|
|
case errors.Is(err, ErrUnauthenticated):
|
|
if a.isNavigation(r) {
|
|
http.Redirect(w, r, a.signInURL(r), http.StatusFound)
|
|
return
|
|
}
|
|
w.Header().Set("WWW-Authenticate", `Bearer realm="nmon"`)
|
|
writeProblem(w, http.StatusUnauthorized, "authentication required")
|
|
default:
|
|
a.opts.Logger.Error("authenticate", slog.Any("err", err))
|
|
writeProblem(w, http.StatusInternalServerError, "authentication failed")
|
|
}
|
|
}
|
|
|
|
// bearer extracts a token from "Authorization: Bearer x" or the password of Basic auth (what
|
|
// Prometheus-style clients send). Anything else is not a token request.
|
|
func bearer(r *http.Request) (string, bool) {
|
|
h := r.Header.Get("Authorization")
|
|
if v, ok := strings.CutPrefix(h, "Bearer "); ok {
|
|
return strings.TrimSpace(v), true
|
|
}
|
|
if strings.HasPrefix(h, "Basic ") {
|
|
if _, pw, ok := r.BasicAuth(); ok {
|
|
return pw, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// isNavigation reports whether r is a browser page load (the SPA, its files, /docs) rather than an API call: those
|
|
// are sent to sign in, while API calls get a 401 problem. A request that carried a bearer token is an API client.
|
|
func (a *Auth) isNavigation(r *http.Request) bool {
|
|
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
|
return false
|
|
}
|
|
if _, ok := bearer(r); ok {
|
|
return false
|
|
}
|
|
p := r.URL.Path
|
|
for _, api := range []string{"/api/", "/auth/", "/metrics", "/openapi", "/schemas"} {
|
|
if strings.HasPrefix(p, api) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// signInURL is where an unauthenticated page load goes: the IdP flow, or the break-glass prompt when no IdP is
|
|
// configured. The original URL travels in ?return= and is validated as a same-site path when it is used.
|
|
func (a *Auth) signInURL(r *http.Request) string {
|
|
target := "/auth/login"
|
|
if a.OIDC == nil {
|
|
target = "/auth/breakglass"
|
|
}
|
|
return target + "?return=" + url.QueryEscape(r.URL.RequestURI())
|
|
}
|