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
188 lines
7.3 KiB
Go
188 lines
7.3 KiB
Go
package api_test
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/v10networks/nmon/internal/api"
|
|
"github.com/v10networks/nmon/internal/auth"
|
|
"github.com/v10networks/nmon/internal/auth/authtest"
|
|
"github.com/v10networks/nmon/internal/clock"
|
|
"github.com/v10networks/nmon/internal/ingest"
|
|
"github.com/v10networks/nmon/internal/metrics"
|
|
"github.com/v10networks/nmon/internal/poller"
|
|
"github.com/v10networks/nmon/internal/selfmon"
|
|
"github.com/v10networks/nmon/internal/testutil"
|
|
)
|
|
|
|
// authedEnv is the API as production composes it: the auth middleware over a mux of the API, push ingest and
|
|
// the /auth/ handlers (internal/app builds the same shape in topHandler).
|
|
type authedEnv struct {
|
|
*env
|
|
auth *auth.Auth
|
|
top http.Handler
|
|
}
|
|
|
|
func newAuthedEnv(t *testing.T, mutate func(*api.Deps)) *authedEnv {
|
|
t.Helper()
|
|
clk := clock.NewFake(time.Date(2026, 10, 22, 12, 0, 0, 0, time.UTC))
|
|
st := testutil.OpenStore(t, clk)
|
|
a, err := auth.New(context.Background(), auth.Options{
|
|
Store: st, Clock: clk, IngestPrefixes: []string{"/write", "/api/v1/write"}, Policy: auth.NewPolicy(),
|
|
BreakGlass: auth.BreakGlassConfig{},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
api.DeclarePolicy(a.Policy())
|
|
a.Policy().Public("GET /healthz")
|
|
e := &env{clk: clk, st: st, q: &querier{body: []byte(`{"status":"success","data":{"resultType":"vector","result":[]}}`)},
|
|
ref: &refresher{}, reg: selfmon.New()}
|
|
pushTokens := ingest.NewTokens(st, clk)
|
|
d := api.Deps{Store: st, Querier: e.q, Poller: e.ref, Clock: clk, Registry: e.reg, DiscoveryTimeout: time.Second,
|
|
PollerStatus: func() []poller.DeviceStatus { return nil }, Tokens: pushTokens, APITokens: a.Tokens,
|
|
}
|
|
if mutate != nil {
|
|
mutate(&d)
|
|
}
|
|
e.h = api.New(d)
|
|
g, err := ingest.New(ingest.Config{
|
|
Tokens: pushTokens, Clock: clk, Registry: selfmon.New(),
|
|
Writer: metrics.WriterFunc(func(context.Context, []metrics.Sample) error { return nil }),
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("GET /healthz", func(http.ResponseWriter, *http.Request) {})
|
|
mux.Handle("/auth/", a.Handler())
|
|
g.Mount(mux)
|
|
mux.Handle("/", e.h)
|
|
return &authedEnv{env: e, auth: a, top: a.Middleware(mux)}
|
|
}
|
|
|
|
var pathParam = regexp.MustCompile(`\{[^}]+\}`)
|
|
|
|
// concrete turns a route pattern into a path a request can use.
|
|
func concrete(path string) string { return pathParam.ReplaceAllString(path, "fuzz") }
|
|
|
|
// TestViewerCannotMutate is the M6 gate's core: every state-changing route of the real router refuses a viewer
|
|
// session (with a valid CSRF token) and a viewer API token with 403, refuses a cookie request without the CSRF
|
|
// token, and still works for an admin so the check cannot pass by refusing everything.
|
|
func TestViewerCannotMutate(t *testing.T) {
|
|
e := newAuthedEnv(t, nil)
|
|
viewerSess, viewerTok := authtest.Mint(t, e.auth, auth.RoleViewer)
|
|
adminSess, _ := authtest.Mint(t, e.auth, auth.RoleAdmin)
|
|
|
|
readOnly := map[string]bool{}
|
|
for _, p := range api.ReadOnlyPosts() {
|
|
readOnly[p] = true
|
|
}
|
|
var routes []authtest.Route
|
|
for _, r := range api.Routes() {
|
|
routes = append(routes, authtest.Route{Method: r.Method, Path: concrete(r.Path), ReadOnly: readOnly[r.Pattern]})
|
|
}
|
|
mutating := 0
|
|
for _, r := range routes {
|
|
if !auth.IsSafeMethod(r.Method) && !r.ReadOnly {
|
|
mutating++
|
|
}
|
|
}
|
|
if mutating < 25 {
|
|
t.Fatalf("only %d state-changing routes were enumerated from the huma API; the enumeration is broken", mutating)
|
|
}
|
|
authtest.CheckViewerCannotMutate(t, e.top, routes, viewerSess, viewerTok, &adminSess)
|
|
}
|
|
|
|
// TestViewerAndPushCredentialsOnIngestAndAuthRoutes covers the routes outside huma: push ingest accepts only a
|
|
// push token (a viewer session or an API token is refused, 401 or 403), and the /auth/ endpoints that change
|
|
// state either need no credentials by design (login) or only act on the caller's own session (logout).
|
|
func TestViewerAndPushCredentialsOnIngestAndAuthRoutes(t *testing.T) {
|
|
e := newAuthedEnv(t, nil)
|
|
viewerSess, viewerTok := authtest.Mint(t, e.auth, auth.RoleViewer)
|
|
for _, path := range []string{"/write", "/api/v1/write?format=influx"} {
|
|
for name, c := range map[string]authtest.Creds{"viewer session": viewerSess, "viewer token": viewerTok} {
|
|
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader("m v=1\n"))
|
|
if c.Token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+c.Token)
|
|
}
|
|
if c.Cookie != nil {
|
|
req.AddCookie(c.Cookie)
|
|
req.Header.Set(auth.CSRFHeader, c.CSRF)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
e.top.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized && rec.Code != http.StatusForbidden {
|
|
t.Errorf("POST %s with a %s: status %d, want 401 or 403", path, name, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
// Login endpoints are public by design; with nothing configured they say so instead of logging anyone in.
|
|
for _, path := range []string{"/auth/breakglass", "/auth/login"} {
|
|
method := http.MethodPost
|
|
if path == "/auth/login" {
|
|
method = http.MethodGet
|
|
}
|
|
req := httptest.NewRequest(method, path, strings.NewReader(`{"username":"x","password":"y"}`))
|
|
rec := httptest.NewRecorder()
|
|
e.top.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("%s %s with no provider configured: status %d, want 404", method, path, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestEveryRouteIsEitherPublicOrNeedsACredential: no declared API route answers an anonymous caller with data.
|
|
func TestEveryRouteIsEitherPublicOrNeedsACredential(t *testing.T) {
|
|
e := newAuthedEnv(t, nil)
|
|
for _, r := range api.Routes() {
|
|
if r.Pattern == "GET /api/v1/setup" {
|
|
continue // public on purpose: the UI asks it before anyone can sign in (TestSetupStateIsPublicAndCarriesNoSecrets)
|
|
}
|
|
req := httptest.NewRequest(r.Method, concrete(r.Path), strings.NewReader("{}"))
|
|
rec := httptest.NewRecorder()
|
|
e.top.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Errorf("%s without credentials: status %d, want 401", r.Pattern, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPageLoadsRedirectToSignInAndAPICallsGet401(t *testing.T) {
|
|
e := newAuthedEnv(t, nil)
|
|
get := func(path string, hdr ...string) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
|
for i := 0; i+1 < len(hdr); i += 2 {
|
|
req.Header.Set(hdr[i], hdr[i+1])
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
e.top.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
rec := get("/devices/sw1?tab=ifaces")
|
|
// No IdP is configured in this environment, so the redirect goes to the break-glass prompt.
|
|
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/auth/breakglass?return=%2Fdevices%2Fsw1%3Ftab%3Difaces" {
|
|
t.Fatalf("page load: %d Location %q", rec.Code, rec.Header().Get("Location"))
|
|
}
|
|
for _, p := range []string{"/api/v1/devices", "/metrics", "/openapi.json"} {
|
|
if rec := get(p); rec.Code != http.StatusUnauthorized {
|
|
t.Errorf("%s without credentials: %d, want 401 (no redirect)", p, rec.Code)
|
|
}
|
|
}
|
|
if rec := get("/", "Authorization", "Bearer nma_1_nope"); rec.Code != http.StatusUnauthorized {
|
|
t.Errorf("a request with a bad bearer token is an API client: %d, want 401", rec.Code)
|
|
}
|
|
if rec := get("/auth/signedout"); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Signed out") {
|
|
t.Errorf("/auth/signedout: %d", rec.Code)
|
|
}
|
|
rec = get("/auth/breakglass")
|
|
if rec.Code != http.StatusNotFound && rec.Code != http.StatusUnauthorized {
|
|
t.Errorf("/auth/breakglass with no break-glass configured: %d", rec.Code)
|
|
}
|
|
}
|