Compare commits
35 Commits
m6-integra
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a6afdda08 | ||
|
|
4ae44e36fc | ||
|
|
26fdcdc124 | ||
|
|
f31fb3712a | ||
|
|
b89224254e | ||
| e60ae0a850 | |||
|
|
dbfeeeebc7 | ||
|
|
273c6b541b | ||
|
|
881f0ae3b1 | ||
|
|
55265698e9 | ||
|
|
33948d3ee8 | ||
|
|
674816c98c | ||
|
|
61b5341774 | ||
|
|
7173360346 | ||
|
|
c4d14a67d1 | ||
|
|
b529423324 | ||
|
|
06a3c85c69 | ||
|
|
ddc8863a67 | ||
| c4f5ee916b | |||
| 992f09b97a | |||
| d92c6e1e08 | |||
| 9b4ab7ccbb | |||
| 457191accd | |||
| 61274c4f55 | |||
| fe53998aa6 | |||
| e25143881c | |||
| ad220eae58 | |||
| 345daaa773 | |||
| bf6dbd4923 | |||
| d97dd1d44a | |||
| 28f5444860 | |||
| 6ae7729107 | |||
| faa0fa7505 | |||
| 4b7849c45b | |||
| 4a57eac1ed |
11
.github/workflows/ci.yml
vendored
@@ -3,7 +3,7 @@ on: [push, pull_request]
|
||||
jobs:
|
||||
lint:
|
||||
# Hard rule (docs/MAINTAINABILITY.md): any finding fails the build.
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: debian-13
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
@@ -13,10 +13,7 @@ jobs:
|
||||
- run: cd web && npm ci
|
||||
- run: make lint
|
||||
test:
|
||||
strategy:
|
||||
matrix:
|
||||
arch: [ubuntu-latest, ubuntu-24.04-arm]
|
||||
runs-on: ${{ matrix.arch }}
|
||||
runs-on: debian-13
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with: { fetch-depth: 0 }
|
||||
@@ -30,14 +27,14 @@ jobs:
|
||||
- run: make test-v6only
|
||||
- run: make build && file nmon | grep -q 'statically linked'
|
||||
postgres:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: debian-13
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with: { go-version: stable }
|
||||
- run: make test-pg
|
||||
openapi:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: debian-13
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with: { fetch-depth: 0 }
|
||||
|
||||
7
.gitignore
vendored
@@ -11,3 +11,10 @@ coverage.out
|
||||
/nmon-librenms-import
|
||||
/internal/webui/dist/*
|
||||
!/internal/webui/dist/.gitkeep
|
||||
deploy/ansible/.mibs.tar.gz
|
||||
deploy/ansible/inventory.ini
|
||||
deploy/ansible/vars.yml
|
||||
|
||||
# MCP Vector Search index directory
|
||||
.mcp-vector-search/
|
||||
/nmon-dev
|
||||
|
||||
@@ -100,6 +100,7 @@ linters:
|
||||
- "**/internal/checks/**"
|
||||
- "**/internal/rollup/**"
|
||||
- "**/internal/alert/**"
|
||||
- "**/internal/l2/**"
|
||||
deny:
|
||||
- pkg: github.com/v10networks/nmon/internal/poller
|
||||
desc: collection paths share code via a lower package, not each other
|
||||
@@ -107,6 +108,8 @@ linters:
|
||||
desc: collection paths share code via a lower package, not each other
|
||||
- pkg: github.com/v10networks/nmon/internal/traps
|
||||
desc: collection paths share code via a lower package, not each other
|
||||
- pkg: github.com/v10networks/nmon/internal/l2
|
||||
desc: collection paths share code via a lower package, not each other
|
||||
devtools-not-in-nmon:
|
||||
# The product binary never links dev/test machinery. `nmon profile record` needs only the
|
||||
# .snmprec format, which lives in internal/snmprec.
|
||||
|
||||
120
CLAUDE.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# nmon: agent onboarding
|
||||
|
||||
nmon is a lightweight LibreNMS replacement for ISPs and IT teams. It is one static Go binary that polls devices
|
||||
(SNMP, ICMP, traps, push ingest, Proxmox, checks), stores metrics in two embedded VictoriaMetrics child processes
|
||||
(hot: raw 30 d; long: rollups up to 10 y), keeps inventory in SQLite or Postgres via bun, alerts with dependency
|
||||
suppression, and serves a React UI and JSON API from the same binary. Module: `github.com/v10networks/nmon`, Go 1.27.
|
||||
|
||||
## Read first (source of truth, in this order)
|
||||
|
||||
1. `docs/spec/spec.md` and `docs/spec/build-detail.md`: the owner's spec. The contract.
|
||||
2. `docs/DEVIATIONS.md`: every approved departure from the spec, with the reason. Check it before "fixing" something to match the spec.
|
||||
3. `docs/MAINTAINABILITY.md`: **hard rule**, enforced by lint. Read it before writing code.
|
||||
4. `docs/milestones/`: per-milestone scope, decisions and gate logs (M1–M6, `v1.0.1-device-tabs.md`), plus deferred items and post-v1 additions in `README.md`.
|
||||
5. `docs/operations.md` (running nmon), `docs/dev.md` (UI embedding, openapi check, devtools), `docs/coverage.md` (what each profile collects vs. the spec).
|
||||
|
||||
## Non-negotiables
|
||||
|
||||
- **`make lint` must be clean**, Go (golangci-lint v2.14.0 via `.golangci.yml`, plus `scripts/lint-filesize.sh`) and web (ESLint, `--max-warnings 0`).
|
||||
- Size limits: functions ≤ 80 lines, cyclomatic ≤ 15, cognitive ≤ 20; Go files ≤ 600 lines, web files ≤ 400.
|
||||
- `//nolint` only with the linter's name and a reason.
|
||||
- **Layering (depguard):**
|
||||
- No SQL or bun outside `internal/store`.
|
||||
- Only `cmd/*` imports `internal/app`, and only `internal/app` imports `internal/api`.
|
||||
- Collection paths don't import each other: `poller`, `discovery`, `traps`, `ingest`, `checks`, `rollup`, `alert`, `l2`. Shared code goes in a lower package (e.g. `snmptarget`, `longq`, `snmprec`).
|
||||
- `cmd/nmon` must not link dev/test machinery (`simagent`, `fixturegen`, `fixtures`, `librenms`, `testutil`); `TestNmonDoesNotLinkDevMachinery` enforces this.
|
||||
- **Time** comes only from `internal/clock` (forbidigo bans `time.Now`/`Sleep`/`After`… outside `clock`, `cmd/` and tests). Tests use `clock.NewFake`.
|
||||
- **Frozen contracts:**
|
||||
- Metric names and labels: SNMP series carry only `device=<slug>` and `iface=<ULID>`/`component`. Long-store rollups use `v` (highest wins; read via `internal/longq.LatestVersion`).
|
||||
- API routes: `scripts/openapi-check.sh` fails on incompatible changes vs the last tag. Additive changes only.
|
||||
- **Migrations are frozen:** never edit an applied migration. Add `internal/store/<YYYYMMDDNNNNNN>_<name>.go` with the next number, using frozen struct copies (see existing files). Add new store methods as a sub-interface in a new file, plus one embed line in `store.go`.
|
||||
- **API types in the UI are generated, never hand-written:**
|
||||
1. `go run ./cmd/nmon openapi > openapi.json`
|
||||
2. `cd web && npm run gen:api` (writes `web/src/api/schema.d.ts`)
|
||||
3. Use `client.GET/POST`.
|
||||
- **Only `web/src/components/TrafficGraph.tsx` imports uPlot.** Single-series graphs use its `format` prop.
|
||||
- **IPv6 first.** Everything works on v6 and v4; tests default to `::1`.
|
||||
- **Every port is polled.** Only a profile's `exclude_ifname` drops one. 32-bit counters on fast links are flagged `unreliable` and excluded from sums, p95 and rollups, never graphed as trustworthy.
|
||||
- **Rollups store rates, never raw counters.** A grep test enforces this.
|
||||
|
||||
## Layout
|
||||
|
||||
| Path | What |
|
||||
|---|---|
|
||||
| `cmd/nmon` | the product binary (`serve`, `migrate`, `discover`, `profile test/record/new`, `backup`, `restore`, `key rotate`, `unlock`, `store restart`, `secret set`, `rollup repair`, `openapi`) |
|
||||
| `cmd/nmon-devtools` | dev-only binary (backfill, seed-demo, mocks for OIDC/Proxmox, webhook sink); never shipped |
|
||||
| `cmd/nmon-librenms-import` | offline tool: LibreNMS definitions → draft profiles |
|
||||
| `internal/app` | wiring: one `start*` per subsystem, stops in reverse order, SIGHUP reload |
|
||||
| `internal/api` | huma v2 handlers, RFC 9457 errors, paging (default 100, max 1,000); slugs and ULIDs only, never numeric ids or secrets |
|
||||
| `internal/auth` | OIDC (no login UI: browsers 302 to the IdP), sessions + CSRF, API tokens, break-glass (HTTP Basic on `/auth/breakglass`), route policy |
|
||||
| `internal/store` | bun models, migrations, every SQL query |
|
||||
| `internal/vmsup` | embedded VictoriaMetrics: extract, checksum, supervise, write queue, querier |
|
||||
| `internal/{poller,discovery,traps,ingest,checks,proxmox,rollup,p95,groups,reach,deps,alert,notify}` | collection, storage jobs, reachability, alerting |
|
||||
| `internal/{l2,hostaddr,oui}` | the MAC/ARP/FDB table job, MAC and IP parsing, the optional IEEE vendor registry |
|
||||
| `internal/profile` + `profiles/*.yaml` | device profiles (YAML, numeric OIDs, `category`/`label`/`of`/`display` per metric) |
|
||||
| `internal/mib` | SMI parser; MIBs are a sidecar in `mibs/`, never embedded |
|
||||
| `internal/{simagent,snmprec,fixturegen,fixtures,profiletest}` | the SNMP simulator, `.snmprec` format, fixture generator, expectation harness |
|
||||
| `web/` | React 19 + TS + Vite + Tailwind/shadcn + TanStack Query/Router; built into `internal/webui` |
|
||||
| `testdata/snmprec/` | per-profile and behaviour fixtures, each with `*.expect.yaml` and optional `*.agent.yaml` |
|
||||
| `deploy/ansible/` | playbook (systemd, TLS modes, break-glass/OIDC, MIB sidecar) |
|
||||
|
||||
## Commands
|
||||
|
||||
```
|
||||
make fetch-vm # once: download and checksum the VictoriaMetrics binaries (needed for vmembed and integration)
|
||||
make build # web build + static nmon with the UI and VictoriaMetrics embedded
|
||||
make devtools importer # the dev and import binaries
|
||||
make lint # Go + web; must be clean
|
||||
go test -race ./... # unit tests (fast)
|
||||
go test -race -tags 'integration vmembed' ./... # real VictoriaMetrics children
|
||||
./scripts/test-pg.sh # store suite on a throwaway Postgres 16 (Docker; safe to run concurrently)
|
||||
NMON_LIBRENMS_DIR=/home/svcuser/src/librenms go test ./internal/profile/... ./internal/fixtures/... # real-device recordings
|
||||
go run ./testdata/gen # regenerate fixtures (a test fails on drift)
|
||||
scripts/openapi-check.sh # API compatibility vs the last tag
|
||||
make gate-m1 … gate-m6 # end-to-end gates (slow, ~10 min each)
|
||||
cd web && npm run lint && npm run test && npm run build && npm run e2e # e2e = Playwright on a mocked API
|
||||
```
|
||||
|
||||
## Adding things
|
||||
|
||||
- **A device profile:**
|
||||
- Add `profiles/<name>.yaml` that `extends: generic`, with `match` (sysObjectID prefix and/or sysDescr regex) and `priority`. Higher wins; watch overlaps such as Linux-based sysDescrs and shared enterprise numbers.
|
||||
- Use numeric OIDs, verified against `mibs/`, with a provenance comment. Every pollable non-interface metric needs a `category`; the orphan test enforces it.
|
||||
- Then run `go run ./testdata/gen` for the fixture and expect file, add matcher tests, and check against LibreNMS recordings in `/home/svcuser/src/librenms/tests/snmpsim`.
|
||||
- That repo is GPL and read-only reference material. Never copy code or YAML from it.
|
||||
- **An API route:**
|
||||
- Put the handler in a new file in `internal/api`. State-changing routes are admin by default; declare read-only POSTs as viewer on the auth policy (`internal/app/authwire.go`). `TestViewerCannotMutate` walks every route automatically.
|
||||
- Regenerate `openapi.json` and the web schema, then run `scripts/openapi-check.sh`.
|
||||
- **A UI page:**
|
||||
- Use the generated client in `web/src/api/*Hooks.ts`, `NavRow` for clickable rows, and `RequireAdmin` or role checks for mutating controls.
|
||||
- Mock handlers and fixtures live in `web/e2e/mock/` for the Playwright mock suite.
|
||||
- **A deviation from the spec:** add a row to `docs/DEVIATIONS.md` explaining what and why.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Concurrent agents:**
|
||||
- Stage only your own paths, never `git add -A`.
|
||||
- `openapi.json` and `web/src/api/schema.d.ts` are shared, so regenerate them just before committing.
|
||||
- Two agents writing a migration must agree on numbers first.
|
||||
- **Screenshots and Playwright:**
|
||||
- Full Playwright runs rewrite the committed screenshots under `docs/screenshots/*`. Revert the ones you didn't mean to change (`git checkout -- docs/screenshots/<dir>`).
|
||||
- Use a non-default `E2E_PORT` if a `vite preview` may already be running, and kill any preview server you start.
|
||||
- **Gate scripts:**
|
||||
- Gate scripts run `make lint` first; a lint finding anywhere fails every gate.
|
||||
- `--insecure-http` with no sign-in configured runs **dev-open mode** (everyone is admin). The gates rely on it; never ship it.
|
||||
- **Ingest:** reserved metric prefixes (`nmon_`, `if`, `hr`, `sys` + uppercase/digit) and the labels `device`/`iface`/`v`/`push` are stripped or refused.
|
||||
- **ICMP:** `nmon serve --icmp off`, or an unsupported unprivileged ICMP socket, makes SNMP alone decide reachability, with an `icmp_unsupported` warning.
|
||||
|
||||
## Working style expected here
|
||||
|
||||
- Commit messages: imperative summary, a body explaining why, and the trailers the session asks for.
|
||||
- Don't claim something passed unless you ran it. Report skipped checks explicitly.
|
||||
- Owner preferences in force:
|
||||
- Maintainability is a hard rule.
|
||||
- MIBs are a sidecar.
|
||||
- The LibreNMS importer and devtools are separate binaries.
|
||||
- No login UI (OIDC redirect only).
|
||||
- Interface-down alerts are opt-in (per port or via groups).
|
||||
- The 2,000-device load test is skipped (approved deviation).
|
||||
- Web push is v2.
|
||||
- BMC profiles are deferred.
|
||||
98
cmd/nmon/devicepoll.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
|
||||
"github.com/v10networks/nmon/internal/cliout"
|
||||
"github.com/v10networks/nmon/internal/poller"
|
||||
)
|
||||
|
||||
func init() {
|
||||
register("device", "operate on one device of a running nmon: nmon device poll <slug> [--test] [--json]", deviceCmd)
|
||||
}
|
||||
|
||||
const devicePollUsage = "usage: nmon device poll <slug> [--test [--interval SECONDS]] [--json] --api URL --token TOKEN"
|
||||
|
||||
// deviceCmd is "nmon device poll": ask the running nmon to poll one device now, or with --test to run a traced
|
||||
// test poll (the same as the device page's Test poll). The test never writes metrics.
|
||||
func deviceCmd(ctx context.Context, args []string) error {
|
||||
if len(args) == 0 || args[0] != "poll" {
|
||||
return errors.New(devicePollUsage)
|
||||
}
|
||||
slug, rest, err := leadingArg(args[1:])
|
||||
if err != nil || slug == "" {
|
||||
return errors.New(devicePollUsage)
|
||||
}
|
||||
var af apiFlags
|
||||
var test, asJSON bool
|
||||
var interval int
|
||||
fs := flag.NewFlagSet("device poll", flag.ContinueOnError)
|
||||
af.register(fs)
|
||||
fs.BoolVar(&test, "test", false, "run a traced test poll now and print it; nothing is stored")
|
||||
fs.IntVar(&interval, "interval", 0, "with --test: only the interval group with this cadence in seconds (default every group)")
|
||||
fs.BoolVar(&asJSON, "json", false, "with --test: print the trace as JSON")
|
||||
if err := fs.Parse(rest); err != nil {
|
||||
return flagErr(err)
|
||||
}
|
||||
if err := af.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
base := "/api/v1/devices/" + url.PathEscape(slug)
|
||||
if !test {
|
||||
resp, err := af.call(ctx, http.MethodPost, base+"/poll-now", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = resp.Body.Close() // 202 with a one-field body that says nothing more
|
||||
fmt.Printf("poll queued: %s will be polled now\n", slug)
|
||||
return nil
|
||||
}
|
||||
resp, err := af.call(ctx, http.MethodPost, fmt.Sprintf("%s/poll-test?interval_seconds=%d", base, interval), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }() // read in full below
|
||||
if asJSON {
|
||||
_, err = io.Copy(os.Stdout, resp.Body)
|
||||
return err
|
||||
}
|
||||
var tr poller.Trace
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil {
|
||||
return fmt.Errorf("decode the trace: %w", err)
|
||||
}
|
||||
return printTrace(os.Stdout, &tr)
|
||||
}
|
||||
|
||||
// printTrace writes the summary, the requests and every OID that is not ok; --json has the full list.
|
||||
func printTrace(out io.Writer, tr *poller.Trace) error {
|
||||
w := cliout.New(out)
|
||||
w.Printf("test poll of %s: %s, SNMP %s, profile %s, %d ms\n", tr.Device, tr.Target, tr.SNMPVersion, tr.Profile, tr.DurationMS)
|
||||
w.Printf(" requested %d, returned %d, missing %d, skipped %d\n", tr.Requested, tr.Returned, tr.Missing, tr.Skipped)
|
||||
for _, g := range tr.Groups {
|
||||
w.Printf("group %d s: %d ms, requested %d, returned %d, missing %d, would store %d\n",
|
||||
g.IntervalSeconds, g.DurationMS, g.Requested, g.Returned, g.Missing, g.Samples)
|
||||
if g.Error != "" {
|
||||
w.Printf(" error: %s\n", g.Error)
|
||||
}
|
||||
for _, r := range g.Requests {
|
||||
w.Printf(" request %d: %d varbinds from %s, %d ms, %s %s\n", r.Seq, r.Varbinds, r.FirstOID, r.DurationMS, r.Outcome, r.Error)
|
||||
}
|
||||
for _, r := range g.Results {
|
||||
if r.Status != poller.StatusOK {
|
||||
w.Printf(" %-18s %s %s %s %s\n", r.Status, r.OID, r.Series, r.Label, r.Detail)
|
||||
}
|
||||
}
|
||||
if g.Truncated {
|
||||
w.Printf(" (only %d of %d rows kept; use --json for the full list)\n", len(g.Results), g.ResultsTotal)
|
||||
}
|
||||
}
|
||||
return w.Err()
|
||||
}
|
||||
47
cmd/nmon/oui.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/v10networks/nmon/internal/config"
|
||||
"github.com/v10networks/nmon/internal/oui"
|
||||
)
|
||||
|
||||
func init() {
|
||||
register("oui", "load the IEEE vendor registry from disk: nmon oui import DIR|FILE", ouiCmd)
|
||||
}
|
||||
|
||||
const ouiUsage = "usage: nmon oui import <dir-or-file> --data-dir DIR\n\n" +
|
||||
"Loads the IEEE MAC registry CSVs (oui.csv for MA-L, mam.csv for MA-M, oui36.csv for MA-S, downloaded from\n" +
|
||||
"https://standards-oui.ieee.org/) into <data-dir>/oui/registry.gz, where a running nmon picks it up within an hour.\n" +
|
||||
"Use it where nmon may not fetch (oui.fetch: false). The CSVs are public IEEE data and are not kept after import."
|
||||
|
||||
// ouiCmd is "nmon oui import": the offline way to give nmon its vendor registry.
|
||||
func ouiCmd(_ context.Context, args []string) error {
|
||||
if len(args) == 0 || args[0] != "import" {
|
||||
return fmt.Errorf("%s", ouiUsage)
|
||||
}
|
||||
var f config.Flags
|
||||
fs := newFlagSet("oui import", "oui import <dir-or-file> --data-dir DIR")
|
||||
fs.StringVar(&f.DataDir, "data-dir", "", "nmon's data directory")
|
||||
pos, err := parseInterspersed(fs, args[1:])
|
||||
if err != nil {
|
||||
return flagErr(err)
|
||||
}
|
||||
if len(pos) != 1 || f.DataDir == "" {
|
||||
return fmt.Errorf("%s", ouiUsage)
|
||||
}
|
||||
// Only the MA-L registry is required: it holds nearly every vendor. MA-M and MA-S refine it when present.
|
||||
reg, p, err := oui.Import(pos[0], oui.Minimums{MAL: oui.MinMAL})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dst := filepath.Join(config.OUIDir(f.DataDir), oui.FileName)
|
||||
if err := oui.Write(dst, reg); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("oui import: %d MA-L, %d MA-M and %d MA-S assignments written to %s\n", p.MAL, p.MAM, p.MAS, dst)
|
||||
return nil
|
||||
}
|
||||
53
cmd/nmon/oui_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/v10networks/nmon/internal/config"
|
||||
"github.com/v10networks/nmon/internal/oui"
|
||||
)
|
||||
|
||||
func TestOuiImportWritesTheRegistryFileANmonLoads(t *testing.T) {
|
||||
src, data := t.TempDir(), t.TempDir()
|
||||
var b strings.Builder
|
||||
b.WriteString("Registry,Assignment,Organization Name,Organization Address\n")
|
||||
for i := 0; i < oui.MinMAL; i++ {
|
||||
fmt.Fprintf(&b, "MA-L,%06X,Vendor %d,Somewhere\n", 0x100000+i, i)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(src, "oui.csv"), []byte(b.String()), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ouiCmd(context.Background(), []string{"import", src, "--data-dir", data}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg, err := oui.Load(filepath.Join(config.OUIDir(data), oui.FileName))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v, ok := reg.Lookup("10:00:05:00:00:01"); !ok || v != "Vendor 5" {
|
||||
t.Errorf("lookup = %q %v", v, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOuiImportRefusesATruncatedDirectoryAndUsageErrors(t *testing.T) {
|
||||
src, data := t.TempDir(), t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(src, "oui.csv"), []byte("Registry,Assignment,Organization Name,Organization Address\nMA-L,AABBCC,One,x\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ouiCmd(context.Background(), []string{"import", src, "--data-dir", data}); err == nil {
|
||||
t.Error("a registry with one row was imported")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(config.OUIDir(data), oui.FileName)); err == nil {
|
||||
t.Error("a refused import wrote a file")
|
||||
}
|
||||
for _, args := range [][]string{nil, {"fetch"}, {"import"}, {"import", src}} {
|
||||
if err := ouiCmd(context.Background(), args); err == nil {
|
||||
t.Errorf("args %v accepted", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -276,7 +276,7 @@ func renderDraft(spec draftSpec, m *mib.Module, metrics []draftMetric) string {
|
||||
var b strings.Builder
|
||||
name := "draft-" + strings.ToLower(spec.Module)
|
||||
fmt.Fprintf(&b, "# DRAFT compiled by `nmon profile new` from %s (%s).\n", spec.Module, filepath.Base(m.File))
|
||||
fmt.Fprintf(&b, "# Save as %s.yaml. Replace every TODO (unit, interval, index_label) and add a match: section.\n", name)
|
||||
fmt.Fprintf(&b, "# Save as %s.yaml. Replace every TODO (unit, interval, category, index_label) and add a match: section.\n", name)
|
||||
b.WriteString("# The loader rejects TODO on purpose, so an unreviewed draft can never be polled.\n")
|
||||
fmt.Fprintf(&b, "name: %s\nextends: generic\nprovenance: %q\nmetrics:\n", name,
|
||||
"drafted by nmon profile new from "+spec.Module+"; review before use")
|
||||
@@ -462,7 +462,8 @@ func writeDraftMetric(b *strings.Builder, dm draftMetric) {
|
||||
}
|
||||
fmt.Fprintf(b, " # %s\n", d)
|
||||
}
|
||||
fmt.Fprintf(b, " - name: %s\n oid: %s\n type: %s\n unit: %s\n interval: TODO\n", o.Name, oid, dm.typ, dm.unit)
|
||||
fmt.Fprintf(b, " - name: %s\n oid: %s\n type: %s\n unit: %s\n", o.Name, oid, dm.typ, dm.unit)
|
||||
b.WriteString(" interval: TODO\n category: TODO\n")
|
||||
if dm.scale != 0 {
|
||||
fmt.Fprintf(b, " scale: %s\n", strconv.FormatFloat(dm.scale, 'f', -1, 64))
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ func bundledMIBs(t *testing.T) string {
|
||||
}
|
||||
|
||||
func fillTODOs(s string) string {
|
||||
s = strings.ReplaceAll(s, "category: TODO", "category: health")
|
||||
s = strings.ReplaceAll(s, "interval: TODO", "interval: 5m")
|
||||
return strings.ReplaceAll(s, "unit: TODO", "unit: count")
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ func storeFlags(fs *flag.FlagSet, f *config.Flags) {
|
||||
|
||||
func serve(ctx context.Context, args []string) error {
|
||||
var f config.Flags
|
||||
fs := newFlagSet("serve", "serve --data-dir DIR (--tls-cert F --tls-key F | --acme-host H | --insecure-http) [flags]")
|
||||
fs := newFlagSet("serve", "serve --data-dir DIR [--tls-cert F --tls-key F | --acme-host H | --http-listen ADDR | --insecure-http] [flags]")
|
||||
storeFlags(fs, &f)
|
||||
fs.StringVar(&f.SecretKeyFile, "secret-key-file", app.DefaultSecretKeyFile,
|
||||
"SNMP credential key file; created on first run; must be outside --data-dir")
|
||||
@@ -59,7 +59,13 @@ func serve(ctx context.Context, args []string) error {
|
||||
fs.BoolVar(&f.InsecureHTTP, "insecure-http", false,
|
||||
"development only: serve plain HTTP (insecure cookies); with no auth.oidc or auth.break_glass_file configured, "+
|
||||
"authentication is off and every request is an admin")
|
||||
fs.StringVar(&f.TLSCert, "tls-cert", "", "PEM certificate chain for HTTPS (reloaded on SIGHUP); needs --tls-key")
|
||||
fs.StringVar(&f.HTTPListen, "http-listen", "",
|
||||
"opt-in plain-HTTP listener for running behind a reverse proxy, e.g. [::1]:8080 (any interface allowed; empty = off); "+
|
||||
"sign-in stays required (a fresh install opens the setup wizard first). Runs beside HTTPS, or alone when no TLS "+
|
||||
"is configured. List the proxy in auth.trusted_proxies. "+
|
||||
"Cannot be combined with --insecure-http")
|
||||
fs.StringVar(&f.TLSCert, "tls-cert", "", "PEM certificate chain for HTTPS (reloaded on SIGHUP); needs --tls-key. "+
|
||||
"With no TLS flag at all nmon serves HTTPS with a self-signed certificate kept in <data-dir>/tls")
|
||||
fs.StringVar(&f.TLSKey, "tls-key", "", "PEM private key for --tls-cert")
|
||||
fs.Func("acme-host", "obtain a Let's Encrypt certificate for this host name (repeatable); needs port 80 reachable", func(v string) error {
|
||||
f.ACMEHosts = append(f.ACMEHosts, v)
|
||||
@@ -77,7 +83,7 @@ func serve(ctx context.Context, args []string) error {
|
||||
fs.StringVar(&f.ICMP, "icmp", config.ICMPAuto,
|
||||
"ICMP probing: auto, or off for hosts where ICMP is not permitted (SNMP alone then decides if a device is down)")
|
||||
fs.IntVar(&f.MaxInflight, "max-inflight", 512, "most SNMP polls in flight at once")
|
||||
fs.DurationVar(&f.PollInterval, "poll-interval", 0, "dev: SNMP poll interval (default 60s)")
|
||||
fs.DurationVar(&f.PollInterval, "poll-interval", 0, "dev: default SNMP poll interval (default 300s); a device's own interval overrides it")
|
||||
fs.IntVar(&f.SNMPPort, "snmp-port", 0, "dev: UDP port of every device's agent (default 161)")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return flagErr(err)
|
||||
|
||||
51
deploy/ansible/README.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Deploying nmon with Ansible
|
||||
|
||||
Installs nmon as a systemd service on one Linux host (amd64 or arm64). Tested against Debian 13.
|
||||
|
||||
## Quick start
|
||||
|
||||
cd deploy/ansible
|
||||
cp inventory.example.ini inventory.ini # your host
|
||||
cp vars.example.yml vars.yml # no sign-in set = the setup wizard runs on first open
|
||||
ansible-playbook site.yml -e @vars.yml
|
||||
|
||||
The first play builds the release binary on your machine (`make build build-arm64`: needs Go, Node and network).
|
||||
Pass `-e nmon_build=false` to reuse an existing `./nmon`. The second play installs it on the hosts in group `nmon`:
|
||||
|
||||
- `/usr/local/bin/nmon`, user `nmon`, data in `/var/lib/nmon`, key and TLS files in `/etc/nmon`, backups in `/var/backups/nmon`
|
||||
- the MIB sidecar in `/usr/share/nmon/mibs`, used only by `nmon profile new --mib`
|
||||
- optionally the IEEE vendor registry CSVs in `/usr/share/nmon/oui`, loaded with `nmon oui import` (`nmon_oui_download`, `nmon_oui_csv_dir`; by default nmon fetches the registry itself)
|
||||
- `config.yaml` rendered from your vars, and a hardened systemd unit (`systemctl reload nmon` sends SIGHUP to reload config and certificates)
|
||||
- `net.ipv4.ping_group_range` opened so nmon can ping without root
|
||||
|
||||
Then open `https://<host>:8443/`. With no sign-in in vars, nmon starts in install mode and the setup wizard asks for one
|
||||
(anyone who can reach the port can run it until it is done). Otherwise, without OIDC you are sent to `/auth/breakglass`.
|
||||
|
||||
## TLS (`nmon_tls_mode`)
|
||||
|
||||
| Mode | What it does |
|
||||
|---|---|
|
||||
| `selfsigned` (default) | Generates a cert on the host. Good for trying it out; browsers will warn. |
|
||||
| `files` | Copies `nmon_tls_cert_src` / `nmon_tls_key_src` from the control machine. |
|
||||
| `acme` | Let's Encrypt for `nmon_server_name`; port 80 must reach the host. |
|
||||
| `insecure` | Plain HTTP. With no sign-in configured, nmon runs in dev-open mode where everyone is admin. Never expose this. |
|
||||
|
||||
**Reverse proxy:** nmon can sit behind one. Keep the proxy-to-nmon hop on HTTPS (`selfsigned` is fine there), set
|
||||
`nmon_trusted_proxies` to the proxy's addresses, and set the OIDC `redirect_url` to the public URL. Traps (UDP 162) and
|
||||
Graphite (TCP 2003) go to nmon directly, not through the HTTP proxy.
|
||||
|
||||
## Sign-in
|
||||
|
||||
- **Setup wizard:** leave `nmon_breakglass_password` and `nmon_oidc` unset. The play then writes `config.yaml` only
|
||||
if it is missing, so later runs keep the sign-in the wizard saved (and ignore other config vars; edit those in the UI).
|
||||
- **OIDC:** set `nmon_oidc` (issuer, client_id, client_secret, admin_group). The secret is stored encrypted with
|
||||
`nmon secret set` and never written to `config.yaml`. Browsers go straight to the IdP; there is no login page.
|
||||
- **Break-glass:** `nmon_breakglass_password` is bcrypt-hashed on the host. Use it when the IdP is down. Five wrong
|
||||
attempts lock the source IP for 15 min; run `nmon unlock all --data-dir /var/lib/nmon` to clear.
|
||||
|
||||
## After installing
|
||||
|
||||
- **Back up `/etc/nmon/secret.key`** somewhere safe. Without it, a restore has no SNMP credentials.
|
||||
- Add devices in the UI or with `POST /api/v1/devices`, and mint API or push tokens under Settings.
|
||||
- The playbook owns `config.yaml`: edits made in the UI are overwritten on the next run, so put lasting settings in vars.
|
||||
- More: `docs/operations.md`.
|
||||
5
deploy/ansible/ansible.cfg
Normal file
@@ -0,0 +1,5 @@
|
||||
[defaults]
|
||||
inventory = inventory.ini
|
||||
roles_path = roles
|
||||
callback_result_format = yaml
|
||||
host_key_checking = True
|
||||
3
deploy/ansible/inventory.example.ini
Normal file
@@ -0,0 +1,3 @@
|
||||
# Copy to inventory.ini and edit. One nmon host is the design point.
|
||||
[nmon]
|
||||
nmon1.example.net ansible_user=admin ansible_become=true
|
||||
46
deploy/ansible/roles/nmon/defaults/main.yml
Normal file
@@ -0,0 +1,46 @@
|
||||
nmon_user: nmon
|
||||
nmon_data_dir: /var/lib/nmon
|
||||
nmon_etc_dir: /etc/nmon
|
||||
nmon_key_file: "{{ nmon_etc_dir }}/secret.key"
|
||||
nmon_backup_dir: /var/backups/nmon
|
||||
nmon_mibs_dir: /usr/share/nmon/mibs
|
||||
nmon_install_mibs: true
|
||||
# MAC vendors (optional IEEE registry). nmon downloads it itself unless nmon_oui_fetch is false; to ship it instead,
|
||||
# set nmon_oui_download (the host downloads the CSVs) or nmon_oui_csv_dir (a directory of the three CSVs on the control
|
||||
# machine). Either way they are kept next to the MIB sidecar and loaded with `nmon oui import`.
|
||||
nmon_oui_dir: /usr/share/nmon/oui
|
||||
nmon_oui_fetch: true
|
||||
nmon_oui_download: false
|
||||
nmon_oui_csv_dir: ""
|
||||
nmon_oui_urls:
|
||||
- https://standards-oui.ieee.org/oui/oui.csv
|
||||
- https://standards-oui.ieee.org/oui28/mam.csv
|
||||
- https://standards-oui.ieee.org/oui36/oui36.csv
|
||||
# MAC/ARP table job (see docs/operations.md); empty uses the defaults.
|
||||
nmon_l2: {}
|
||||
nmon_listen: "[::]:8443"
|
||||
nmon_trap_listen: "[::]:162"
|
||||
nmon_graphite_listen: "[::]:2003"
|
||||
nmon_icmp: auto
|
||||
nmon_timezone: UTC
|
||||
|
||||
nmon_tls_mode: selfsigned # selfsigned | files | acme | insecure | none (none: plain HTTP only, needs nmon_http_listen)
|
||||
# Opt-in plain-HTTP listener for a reverse proxy, e.g. "[::1]:8080"; sign-in stays required. Empty = off.
|
||||
# Runs beside HTTPS, or alone with nmon_tls_mode=none. Not allowed with nmon_tls_mode=insecure.
|
||||
nmon_http_listen: ""
|
||||
nmon_server_name: "{{ inventory_hostname }}"
|
||||
nmon_acme_email: ""
|
||||
nmon_tls_extra_names: [] # more DNS names / IPs for the self-signed cert
|
||||
|
||||
nmon_breakglass_user: admin
|
||||
nmon_breakglass_password: "" # empty (and no OIDC) = nmon starts in install mode: the setup wizard sets sign-in
|
||||
nmon_oidc: {}
|
||||
nmon_alerting: {}
|
||||
nmon_trusted_proxies: []
|
||||
|
||||
nmon_backup_at: "02:30"
|
||||
nmon_backup_keep: 7
|
||||
nmon_retention:
|
||||
hot: 31d
|
||||
fiveminute: 120d
|
||||
long: 2y
|
||||
14
deploy/ansible/roles/nmon/handlers/main.yml
Normal file
@@ -0,0 +1,14 @@
|
||||
- name: Restart nmon
|
||||
ansible.builtin.systemd_service:
|
||||
name: nmon
|
||||
state: restarted
|
||||
daemon_reload: true
|
||||
|
||||
- name: Reload nmon
|
||||
ansible.builtin.systemd_service:
|
||||
name: nmon
|
||||
state: reloaded
|
||||
|
||||
- name: Apply sysctl
|
||||
ansible.builtin.command: sysctl -p /etc/sysctl.d/60-nmon.conf
|
||||
changed_when: true
|
||||
190
deploy/ansible/roles/nmon/tasks/main.yml
Normal file
@@ -0,0 +1,190 @@
|
||||
- name: --http-listen and TLS mode agree
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- not (nmon_http_listen | length > 0 and nmon_tls_mode == 'insecure')
|
||||
- nmon_tls_mode != 'none' or nmon_http_listen | length > 0
|
||||
fail_msg: "nmon_http_listen cannot be combined with nmon_tls_mode=insecure, and nmon_tls_mode=none needs nmon_http_listen"
|
||||
|
||||
- name: Check the platform (nmon ships linux/amd64 and linux/arm64)
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- ansible_system == "Linux"
|
||||
- ansible_architecture in ["x86_64", "aarch64"]
|
||||
fail_msg: "nmon v1 supports linux/amd64 and linux/arm64 only"
|
||||
|
||||
- name: No sign-in in vars means first-install mode (the setup wizard picks the sign-in)
|
||||
ansible.builtin.set_fact:
|
||||
nmon_install_wizard: "{{ nmon_tls_mode != 'insecure' and nmon_breakglass_password | length == 0 and not nmon_oidc }}"
|
||||
|
||||
- name: Install host packages (openssl for the self-signed cert, htpasswd for bcrypt, acl for become_user)
|
||||
ansible.builtin.package:
|
||||
name: "{{ ['openssl', 'apache2-utils', 'acl', 'procps'] if ansible_os_family == 'Debian' else ['openssl', 'httpd-tools', 'acl', 'procps-ng'] }}"
|
||||
state: present
|
||||
|
||||
- name: Create the nmon system user
|
||||
ansible.builtin.user:
|
||||
name: "{{ nmon_user }}"
|
||||
system: true
|
||||
shell: /usr/sbin/nologin
|
||||
home: "{{ nmon_data_dir }}"
|
||||
create_home: false
|
||||
|
||||
- name: Create directories
|
||||
ansible.builtin.file:
|
||||
path: "{{ item.path }}"
|
||||
state: directory
|
||||
owner: "{{ nmon_user }}"
|
||||
group: "{{ nmon_user }}"
|
||||
mode: "{{ item.mode }}"
|
||||
loop:
|
||||
- { path: "{{ nmon_data_dir }}", mode: "0750" }
|
||||
- { path: "{{ nmon_etc_dir }}", mode: "0750" }
|
||||
- { path: "{{ nmon_backup_dir }}", mode: "0750" }
|
||||
|
||||
- name: Ensure /etc/sysctl.d exists (minimal images lack it)
|
||||
ansible.builtin.file:
|
||||
path: /etc/sysctl.d
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
- name: Allow unprivileged ICMP echo (nmon pings without root)
|
||||
ansible.builtin.copy:
|
||||
content: "net.ipv4.ping_group_range = 0 2147483647\n"
|
||||
dest: /etc/sysctl.d/60-nmon.conf
|
||||
mode: "0644"
|
||||
notify: Apply sysctl
|
||||
|
||||
- name: Install the nmon binary
|
||||
ansible.builtin.copy:
|
||||
src: "{{ playbook_dir }}/../../{{ 'nmon' if ansible_architecture == 'x86_64' else 'nmon-arm64' }}"
|
||||
dest: /usr/local/bin/nmon
|
||||
mode: "0755"
|
||||
notify: Restart nmon
|
||||
|
||||
- name: Install the MIB sidecar
|
||||
when: nmon_install_mibs | bool
|
||||
block:
|
||||
- name: Create the MIB directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ nmon_mibs_dir }}"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
- name: Unpack MIBs
|
||||
ansible.builtin.unarchive:
|
||||
src: "{{ playbook_dir }}/.mibs.tar.gz"
|
||||
dest: "{{ nmon_mibs_dir }}"
|
||||
creates: "{{ nmon_mibs_dir }}/IF-MIB"
|
||||
|
||||
- name: Ship the IEEE vendor registry (optional)
|
||||
when: nmon_oui_download | bool or nmon_oui_csv_dir | length > 0
|
||||
block:
|
||||
- name: Create the registry directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ nmon_oui_dir }}"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
- name: Download the registry CSVs on the host
|
||||
when: nmon_oui_download | bool
|
||||
ansible.builtin.get_url:
|
||||
url: "{{ item }}"
|
||||
dest: "{{ nmon_oui_dir }}/{{ item | basename }}"
|
||||
mode: "0644"
|
||||
timeout: 60
|
||||
loop: "{{ nmon_oui_urls }}"
|
||||
register: nmon_oui_fetched
|
||||
- name: Copy the registry CSVs from the control machine
|
||||
when: nmon_oui_csv_dir | length > 0
|
||||
ansible.builtin.copy:
|
||||
src: "{{ nmon_oui_csv_dir }}/"
|
||||
dest: "{{ nmon_oui_dir }}/"
|
||||
mode: "0644"
|
||||
register: nmon_oui_copied
|
||||
- name: Load the registry into nmon's data directory
|
||||
when: (nmon_oui_fetched is changed) or (nmon_oui_copied is changed)
|
||||
become_user: "{{ nmon_user }}"
|
||||
ansible.builtin.command:
|
||||
argv: [/usr/local/bin/nmon, oui, import, "{{ nmon_oui_dir }}", --data-dir, "{{ nmon_data_dir }}"]
|
||||
changed_when: true
|
||||
|
||||
- name: TLS certificate
|
||||
ansible.builtin.include_tasks: tls.yml
|
||||
when: nmon_tls_mode in ["selfsigned", "files"]
|
||||
|
||||
- name: Break-glass admin (bcrypt hash; the password never lands on disk)
|
||||
when: nmon_breakglass_password | length > 0
|
||||
block:
|
||||
- name: Hash the break-glass password
|
||||
ansible.builtin.command:
|
||||
argv: [htpasswd, -nbBC, "12", "{{ nmon_breakglass_user }}", "{{ nmon_breakglass_password }}"]
|
||||
register: nmon_bg_hash
|
||||
changed_when: false
|
||||
no_log: true
|
||||
- name: Write the break-glass file
|
||||
ansible.builtin.copy:
|
||||
content: "{{ nmon_bg_hash.stdout | trim }}\n"
|
||||
dest: "{{ nmon_etc_dir }}/breakglass"
|
||||
owner: "{{ nmon_user }}"
|
||||
group: "{{ nmon_user }}"
|
||||
mode: "0600"
|
||||
no_log: true
|
||||
# Re-hashing gives a new salt each run; only replace the file when it is missing.
|
||||
args:
|
||||
force: false
|
||||
|
||||
- name: Store the OIDC client secret encrypted (creates the key file on a new install)
|
||||
ansible.builtin.shell:
|
||||
cmd: >-
|
||||
set -o pipefail;
|
||||
printf '%s\n' "$NMON_OIDC_SECRET" |
|
||||
/usr/local/bin/nmon secret set oidc-client --data-dir {{ nmon_data_dir }} --secret-key-file {{ nmon_key_file }}
|
||||
executable: /bin/bash
|
||||
environment:
|
||||
NMON_OIDC_SECRET: "{{ nmon_oidc.client_secret }}"
|
||||
become: true
|
||||
become_user: "{{ nmon_user }}"
|
||||
when: nmon_oidc.client_secret is defined
|
||||
changed_when: true
|
||||
no_log: true
|
||||
notify: Restart nmon
|
||||
|
||||
- name: Write config.yaml
|
||||
ansible.builtin.template:
|
||||
src: config.yaml.j2
|
||||
dest: "{{ nmon_data_dir }}/config.yaml"
|
||||
owner: "{{ nmon_user }}"
|
||||
group: "{{ nmon_user }}"
|
||||
mode: "0640"
|
||||
# The wizard writes its sign-in into config.yaml; never overwrite that with a template that has none.
|
||||
force: "{{ not nmon_install_wizard | bool }}"
|
||||
notify: Reload nmon
|
||||
|
||||
- name: Install the systemd unit
|
||||
ansible.builtin.template:
|
||||
src: nmon.service.j2
|
||||
dest: /etc/systemd/system/nmon.service
|
||||
mode: "0644"
|
||||
notify: Restart nmon
|
||||
|
||||
- name: Enable and start nmon
|
||||
ansible.builtin.systemd_service:
|
||||
name: nmon
|
||||
enabled: true
|
||||
state: started
|
||||
daemon_reload: true
|
||||
|
||||
- name: Wait for nmon to answer
|
||||
ansible.builtin.uri:
|
||||
url: "{{ 'http' if nmon_tls_mode in ['insecure', 'none'] else 'https' }}://localhost:{{ (nmon_http_listen if nmon_tls_mode == 'none' else nmon_listen) | regex_replace('^.*:', '') }}/healthz"
|
||||
validate_certs: false
|
||||
status_code: 200
|
||||
register: nmon_health
|
||||
retries: 30
|
||||
delay: 2
|
||||
until: nmon_health.status == 200
|
||||
|
||||
- name: Where to go next
|
||||
ansible.builtin.debug:
|
||||
msg:
|
||||
- "nmon is up: {{ 'http' if nmon_tls_mode in ['insecure', 'none'] else 'https' }}://{{ nmon_server_name }}:{{ (nmon_http_listen if nmon_tls_mode == 'none' else nmon_listen) | regex_replace('^.*:', '') }}/"
|
||||
- "{{ 'Signs in via your IdP; break-glass at /auth/breakglass' if nmon_oidc else ('Open it to run the setup wizard (first install), or sign in as configured there' if nmon_install_wizard | bool else 'Sign in at /auth/breakglass as ' + nmon_breakglass_user) }}"
|
||||
- "Back up the key file {{ nmon_key_file }} somewhere safe; without it a restore has no SNMP credentials."
|
||||
51
deploy/ansible/roles/nmon/tasks/tls.yml
Normal file
@@ -0,0 +1,51 @@
|
||||
- name: Work out the certificate's names (IP addresses need IP:, names need DNS:)
|
||||
ansible.builtin.set_fact:
|
||||
nmon_tls_sans: >-
|
||||
{{ (([nmon_server_name] + nmon_tls_extra_names) | unique
|
||||
| map('regex_replace', '^(?=[0-9.]+$|.*:)', 'IP:')
|
||||
| map('regex_replace', '^(?!IP:)', 'DNS:') | list)
|
||||
+ ['DNS:localhost', 'IP:127.0.0.1', 'IP:::1'] }}
|
||||
when: nmon_tls_mode == "selfsigned"
|
||||
|
||||
- name: Generate a self-signed certificate (quick start; replace for production)
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- openssl
|
||||
- req
|
||||
- -x509
|
||||
- -newkey
|
||||
- ec
|
||||
- -pkeyopt
|
||||
- ec_paramgen_curve:prime256v1
|
||||
- -nodes
|
||||
- -days
|
||||
- "825"
|
||||
- -subj
|
||||
- "/CN={{ nmon_server_name }}"
|
||||
- -addext
|
||||
- "subjectAltName={{ nmon_tls_sans | join(',') }}"
|
||||
- -keyout
|
||||
- "{{ nmon_etc_dir }}/tls.key"
|
||||
- -out
|
||||
- "{{ nmon_etc_dir }}/tls.crt"
|
||||
creates: "{{ nmon_etc_dir }}/tls.crt"
|
||||
when: nmon_tls_mode == "selfsigned"
|
||||
notify: Restart nmon
|
||||
|
||||
- name: Install the provided certificate and key
|
||||
ansible.builtin.copy:
|
||||
src: "{{ item.src }}"
|
||||
dest: "{{ nmon_etc_dir }}/{{ item.dest }}"
|
||||
mode: "{{ item.mode }}"
|
||||
loop:
|
||||
- { src: "{{ nmon_tls_cert_src }}", dest: tls.crt, mode: "0644" }
|
||||
- { src: "{{ nmon_tls_key_src }}", dest: tls.key, mode: "0600" }
|
||||
when: nmon_tls_mode == "files"
|
||||
notify: Reload nmon
|
||||
|
||||
- name: Certificate ownership
|
||||
ansible.builtin.file:
|
||||
path: "{{ nmon_etc_dir }}/{{ item }}"
|
||||
owner: "{{ nmon_user }}"
|
||||
group: "{{ nmon_user }}"
|
||||
loop: [tls.crt, tls.key]
|
||||
38
deploy/ansible/roles/nmon/templates/config.yaml.j2
Normal file
@@ -0,0 +1,38 @@
|
||||
# Managed by Ansible (deploy/ansible). Admin edits in the UI are overwritten on the next run.
|
||||
retention:
|
||||
hot: {{ nmon_retention.hot }}
|
||||
fiveminute: {{ nmon_retention.fiveminute }}
|
||||
long: {{ nmon_retention.long }}
|
||||
timezone: {{ nmon_timezone }}
|
||||
{% if nmon_l2 %}
|
||||
l2:
|
||||
{{ nmon_l2 | to_nice_yaml(indent=2) | indent(2) }}
|
||||
{% endif %}
|
||||
{% if not nmon_oui_fetch | bool %}
|
||||
oui:
|
||||
fetch: false
|
||||
{% endif %}
|
||||
{% if nmon_alerting %}
|
||||
alerting:
|
||||
{{ nmon_alerting | to_nice_yaml(indent=2) | indent(2) }}
|
||||
{% endif %}
|
||||
auth:
|
||||
{% if nmon_oidc %}
|
||||
oidc:
|
||||
issuer: {{ nmon_oidc.issuer }}
|
||||
client_id: {{ nmon_oidc.client_id }}
|
||||
client_secret_name: oidc-client
|
||||
redirect_url: {{ nmon_oidc.redirect_url | default('https://' + nmon_server_name + ':' + (nmon_listen | regex_replace('^.*:', '')) + '/auth/callback') }}
|
||||
groups_claim: {{ nmon_oidc.groups_claim | default('groups') }}
|
||||
admin_group: {{ nmon_oidc.admin_group | default('nmon-admins') }}
|
||||
{% endif %}
|
||||
{% if nmon_breakglass_password | length > 0 %}
|
||||
break_glass_file: {{ nmon_etc_dir }}/breakglass
|
||||
{% endif %}
|
||||
{% if nmon_trusted_proxies %}
|
||||
trusted_proxies: {{ nmon_trusted_proxies | to_json }}
|
||||
{% endif %}
|
||||
backup:
|
||||
at: "{{ nmon_backup_at }}"
|
||||
dir: {{ nmon_backup_dir }}
|
||||
keep: {{ nmon_backup_keep }}
|
||||
52
deploy/ansible/roles/nmon/templates/nmon.service.j2
Normal file
@@ -0,0 +1,52 @@
|
||||
# Managed by Ansible (deploy/ansible).
|
||||
[Unit]
|
||||
Description=nmon network monitor
|
||||
Documentation=https://claude.ai/artifact/7FTnfA5hReTjY6cfArfJ2Z
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
User={{ nmon_user }}
|
||||
Group={{ nmon_user }}
|
||||
ExecStart=/usr/local/bin/nmon serve \
|
||||
--data-dir {{ nmon_data_dir }} \
|
||||
--secret-key-file {{ nmon_key_file }} \
|
||||
--listen {{ nmon_listen }} \
|
||||
--trap-listen {{ nmon_trap_listen }} \
|
||||
--graphite-listen {{ nmon_graphite_listen }} \
|
||||
--icmp {{ nmon_icmp }} \
|
||||
{% if nmon_http_listen | length > 0 %}
|
||||
--http-listen {{ nmon_http_listen }} \
|
||||
{% endif %}
|
||||
{% if nmon_tls_mode == 'insecure' %}
|
||||
--insecure-http
|
||||
{% elif nmon_tls_mode == 'none' %}
|
||||
{# plain HTTP only, behind a reverse proxy: --http-listen above is the only listener #}
|
||||
{% elif nmon_tls_mode == 'acme' %}
|
||||
--acme-host {{ nmon_server_name }} --acme-email {{ nmon_acme_email }}
|
||||
{% else %}
|
||||
--tls-cert {{ nmon_etc_dir }}/tls.crt --tls-key {{ nmon_etc_dir }}/tls.key
|
||||
{% endif %}
|
||||
ExecReload=/bin/kill -HUP $MAINPID
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
TimeoutStopSec=90
|
||||
LimitNOFILE=1048576
|
||||
|
||||
# Ports below 1024: SNMP traps (162) and the ACME challenge (80).
|
||||
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
RestrictSUIDSGID=true
|
||||
LockPersonality=true
|
||||
ReadWritePaths={{ nmon_data_dir }} {{ nmon_etc_dir }} {{ nmon_backup_dir }}
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
26
deploy/ansible/site.yml
Normal file
@@ -0,0 +1,26 @@
|
||||
# Deploys nmon to the hosts in group "nmon". Run from the repo root's deploy/ansible:
|
||||
# ansible-playbook site.yml -e @vars.yml
|
||||
- name: Build nmon locally
|
||||
hosts: localhost
|
||||
connection: local
|
||||
gather_facts: false
|
||||
tags: [build]
|
||||
tasks:
|
||||
- name: Build the release binary (UI and VictoriaMetrics embedded)
|
||||
ansible.builtin.command:
|
||||
cmd: make build build-arm64
|
||||
chdir: "{{ playbook_dir }}/../.."
|
||||
changed_when: true
|
||||
when: nmon_build | default(true) | bool
|
||||
|
||||
- name: Pack the MIB sidecar # noqa: command-instead-of-module (archive needs community.general; plain tar keeps this collection-free)
|
||||
ansible.builtin.command:
|
||||
cmd: tar -czf {{ playbook_dir }}/.mibs.tar.gz -C {{ playbook_dir }}/../../mibs .
|
||||
creates: "{{ playbook_dir }}/.mibs.tar.gz"
|
||||
when: nmon_install_mibs | default(true) | bool
|
||||
|
||||
- name: Install and run nmon
|
||||
hosts: nmon
|
||||
become: true
|
||||
roles:
|
||||
- nmon
|
||||
43
deploy/ansible/vars.example.yml
Normal file
@@ -0,0 +1,43 @@
|
||||
# Copy to vars.yml (keep it out of git, or encrypt with ansible-vault) and pass with -e @vars.yml.
|
||||
|
||||
# TLS: selfsigned (quick start), files (your own cert), acme (Let's Encrypt), or insecure (plain HTTP, dev only).
|
||||
nmon_tls_mode: selfsigned
|
||||
nmon_server_name: "{{ inventory_hostname }}" # a DNS name or an IP; it goes in the self-signed cert
|
||||
# nmon_tls_extra_names: ["10.0.0.5", "nmon.lan"]
|
||||
# nmon_tls_cert_src: files/nmon.crt # for tls_mode=files (paths on the control machine)
|
||||
# nmon_tls_key_src: files/nmon.key
|
||||
# nmon_acme_email: noc@example.net # for tls_mode=acme; nmon answers http-01 on :80
|
||||
|
||||
# Behind a reverse proxy: plain-HTTP listener (sign-in stays on). Add the proxy to auth.trusted_proxies (see docs/operations.md).
|
||||
# nmon_http_listen: "[::1]:8080" # beside HTTPS; set nmon_tls_mode: none to serve only this listener
|
||||
|
||||
# Break-glass local admin: the way in when the IdP is down (and the only sign-in until OIDC is set).
|
||||
# The role writes this before nmon first starts, so the install is already "set up": nmon never enters install
|
||||
# mode (the first-install wizard that is open to anyone who can reach it). Leave it out only if you want the wizard.
|
||||
# nmon_breakglass_user: admin
|
||||
# nmon_breakglass_password: "change-me-please"
|
||||
|
||||
# OIDC (optional). Unauthenticated browsers are redirected to the IdP; there is no login page.
|
||||
# nmon_oidc:
|
||||
# issuer: https://idp.example.net/realms/net
|
||||
# client_id: nmon
|
||||
# client_secret: "s3cret" # stored encrypted via `nmon secret set`, never in config.yaml
|
||||
# groups_claim: groups
|
||||
# admin_group: nmon-admins
|
||||
|
||||
# Alert receivers (optional): webhook and/or SMTP, plus the outbound allow-list they need.
|
||||
# nmon_alerting:
|
||||
# receivers:
|
||||
# - name: noc-webhook
|
||||
# kind: webhook
|
||||
# url: https://hooks.example.net/nmon
|
||||
# outbound_allow: ["hooks.example.net"]
|
||||
|
||||
nmon_timezone: UTC
|
||||
|
||||
# MAC/ARP table job and optional IEEE vendor registry (see docs/operations.md).
|
||||
# nmon_l2: {interval: 15m, keep: 90d, edge_max_macs: 8}
|
||||
# nmon_oui_fetch: false # never let nmon download the registry...
|
||||
# nmon_oui_download: true # ...have this play download the CSVs on the host instead,
|
||||
# nmon_oui_csv_dir: files/oui # ...or ship the three CSVs from the control machine
|
||||
nmon_icmp: auto # off = decide reachability by SNMP alone
|
||||
@@ -7,7 +7,7 @@ conscious, reviewed exception.
|
||||
|---|---|---|---|---|
|
||||
| 1 | Fixtures run on snmpsim (Python) via compose | `internal/simagent`, a Go agent that serves standard `.snmprec` files (v1/v2c/v3, dynamic counters, slow/flaky/toobig). `deploy/snmpsim-compose.yml` is kept for anyone using the reference simulator. | 2,000 agents for the loadtest in one process; no Python in CI; same file format, so real snmpsim recordings still work. | M1 |
|
||||
| 2 | "Two VictoriaMetrics executables are embedded" | One VictoriaMetrics single-node executable per architecture is embedded and run as two processes (hot and long) with different `-retentionPeriod`. | Both stores are the same program; embedding it twice would double the binary size for no gain. | M1 |
|
||||
| 3 | Libraries: huma, gosnmp, gnmic, go-oidc, bun; standard library elsewhere | Also: `oklog/ulid` (ULIDs), `gopkg.in/yaml.v3` (profiles, config), `golang.org/x/net` (unprivileged ICMP), `golang.org/x/crypto` (bcrypt, ACME), bun's SQLite (`modernc.org/sqlite`) and Postgres (`pgdriver`) drivers, `golang/snappy` + protobuf (remote write). | Each one fills a gap the standard library doesn't cover; each is small and widely used. | M1+ |
|
||||
| 3 | Libraries: huma, gosnmp, gnmic, go-oidc, bun; standard library elsewhere | Also: `oklog/ulid` (ULIDs), `gopkg.in/yaml.v3` (profiles, config), `golang.org/x/net` (unprivileged ICMP), `golang.org/x/crypto` (bcrypt, ACME), bun's SQLite (`modernc.org/sqlite`) and Postgres (`pgdriver`) drivers, `golang/snappy` + protobuf (remote write). Web: `react-markdown` + `remark-gfm` (the in-app `/help` page renders `docs/*.md` with tables and no raw HTML; hand-rolling a safe GFM renderer would be more code than the lazy chunk it replaces). | Each one fills a gap the standard library doesn't cover; each is small and widely used. | M1+ |
|
||||
| 4 | Integration tests use a VictoriaMetrics container | Integration tests run the VictoriaMetrics binary that `make fetch-vm` downloaded (the same pinned, checksummed one that is embedded). | Faster, and it tests the exact binary that ships. | M1 |
|
||||
| 5 | (not specified) M1 has no auth | Until M6, `nmon serve` requires `--insecure-http` and has no auth. | Auth is M6 in the build order. | M1–M5 |
|
||||
| 6 | "No MIB file is ever shipped in the repo or the binary"; vendor MIBs are the operator's to fetch | MIBs are bundled as a **sidecar** (user decision, 2026-10-22). All 4,865 LibreNMS MIBs are vendored under `mibs/` and shipped in the release tarball (installed to `/usr/share/nmon/mibs`). **None is embedded in the binary**, not even the IETF/IANA set. They are read via `--mibs-dir` (default `<data-dir>/mibs`, then `/usr/share/nmon/mibs`). The poller never reads a MIB. | Operators get `nmon profile new --mib` working out of the box while the binary stays small. Each MIB keeps its publisher's terms (see `mibs/README.md`). | M2 |
|
||||
@@ -42,3 +42,22 @@ conscious, reviewed exception.
|
||||
| 35 | Port descriptions imported from CSV | Interfaces have a new operator-owned `description` column (migration 8). The CSV import and the API write it; discovery rewrites `ifalias` from the device on every run and never touches `description`. Interface search `q` matches ifname, ifalias and description. | A description typed into `ifalias` would be overwritten at the next discovery. | M6 |
|
||||
| 36 | OIDC login with a browser flow; break-glass as a local login | **No login UI** (owner decision). Every route except `/auth/*`, `/healthz` and push ingest needs a credential, the SPA's files included: an unauthenticated page load is a 302 to `/auth/login?return=<url>` (the OIDC flow; the return target must be a same-site path), an API call without credentials is a 401 problem+json. Break-glass is `GET /auth/breakglass` with HTTP Basic, so the browser's own prompt appears; success sets a session and redirects to `return` or `/` (`POST /auth/breakglass` with JSON stays for scripts). When no IdP is configured the redirect goes to the break-glass prompt. Logout is `POST /auth/logout`, then `/auth/signedout` (static HTML). `/docs` and `/openapi*` need the viewer role. A missing OIDC client secret at start leaves OIDC off with a warning. | The IdP is the login page; a second one would be a second thing to secure. | M6 |
|
||||
| 37 | Disk guard: "ingest throttled below the reserve" | Throttle answers push ingest with 429 + Retry-After below the 5% reserve; the poller keeps writing (its volume is bounded by the inventory). Above 90% used, new series are dropped on the nmon side (poller, push and checks) while known series continue; disk alarms are re-raised each minute while the condition holds and resolve themselves 3 minutes after it clears. | VictoriaMetrics series limits are start-up flags; the guard is nmon-side admission. | M6 |
|
||||
| 38 | `interface_down` is a default rule that fires for every down port | `interface_down` is **opt-in**: it alerts only for ports whose own `alert_down` flag is on, or that belong to a group with `alert_down` on (membership in force, `GroupMember.Covers`). Migration 9 adds the columns, default off. Opting a port out closes its open alert silently as `retired` (never a resolved notification). On an access switch most unused ports are admin-up and oper-down, so the default flooded the owner with alerts. `interface_errors` is unchanged. | Owner feedback, first hands-on session. | post-v1 (v1.0.1) |
|
||||
| 39 | Spec: a profile that was matched stays ("non-generic is sticky"); no way to choose one by hand | Discovery re-matches **every unpinned device on every run**, so a newer or better profile is picked up, and at startup nmon queues a discovery for each unpinned device whose stored profile differs from what `Set.Match` picks now. An admin can pin a profile (`PUT /api/v1/devices/{slug}` with `profile`, or the Edit dialog); a pinned device keeps it, and a pinned profile that no longer exists gives generic plus the `profile_missing` warning as before. `profile: ""` returns to automatic. Migration 10 adds `devices.profile_pinned`; `GET /api/v1/profiles` lists the loaded profiles; `DeviceView` gains `profile_pinned` and `profile_match`. Profile changes are audited (`device.profile` from discovery, `device.edit` from the API). | The sticky rule meant a device matched once as generic stayed generic after an upgrade added its profile. | post-v1 (v1.0.1) |
|
||||
| 40 | Profiles shipped: the M2 set | Three more shipped profiles for the owner's gear: `ubiquiti-edgeswitch` (sysObjectID `1.3.6.1.4.1.4413` or sysDescr `^EdgeSwitch`, priority 30), `ubiquiti-unifi-switch` (sysDescr `USW`/`US-`/`USL`/`UniFi`, priority 40, so it beats edgeswitch for UniFi switches that report enterprise 4413), `vyos` (sysObjectID `1.3.6.1.4.1.44641` or sysDescr `VyOS`, extends `linux-netsnmp`, priority 20), and `ubiquiti-unifi-ap` (sysDescr `UAP`/`U6`/`U7`/`UAL`/`UK`/`E7`/`U-LTE`, UBNT-UniFi-MIB per-VAP clients, channel and tx power, per-radio channel utilisation; the MIB has no noise floor, and no per-radio channel or tx power, so those are per VAP). EdgeSwitch CPU is a text string and its temperature table has no label column, so neither is collected. | The owner's switches, routers and access points matched only `generic`. | post-v1 (v1.0.1) |
|
||||
| 41 | Default rules apply everywhere (spec §Alerting) | Alerts fire only where an **alert policy** binds a rule to a scope (groups plus device and port attribute matchers), stored in the `alert_policies` table (migration 12), edited through `/api/v1/alert-policies` and the Alerts > Alert policies tab, and applied live. With no policies nothing alerts: not metric rules, not `device_down`/`device_warning`, not system alarms (`system` policy, scope `all` only). An `ignore` policy beats an `alert` one at equal priority. Upgrading seeds nothing, so open alerts without a binding close silently as `retired` with no resolved notice. Supersedes #38's mechanism (the bell now only feeds the `alert_down` matcher) and makes `Rule.OptIn` a no-op. | Owner decision 2026-10-23: preprod alerted on everything, and a WiFi port that always sees errors is judged by the same rule as a fiber uplink where one error matters. | v1.1 |
|
||||
| 42 | Alert rules are defined in code and `config.yaml` (spec §Alerting) | Rules are also defined in the database (`alert_rules`, migration 13) and edited through `/api/v1/alert-rules` and the Alerts > Alert rules tab, applied live on the next cycle. A custom rule is **builder only**: a metric rule names a catalogue metric (every pollable profile metric plus push-ingested names) with a transform, window, function, aggregate, comparison and threshold, and nmon generates the MetricsQL from fixed templates; an inventory rule ANDs one to five conditions over whitelisted device or port fields, evaluated in Go over store rows. No user text ever reaches a query. The shipped rules keep their names and become read-only "built-in" macros (three can be copied into the builder: `cpu_high`, `ups_on_battery`, `printer_supply_empty`). Comparison operators are now `> >= < <= == !=` everywhere. `alerting.rules` in `config.yaml` still works but is deprecated (the startup log says so); a custom rule cannot take the name of a built-in or config rule. `utilisation` (octets as a share of port speed) is not offered, because the port speed is not a stored series. | Owner decision 2026-10-23: the predefined rules are too constraining; admins need rules of their own from metrics and inventory fields. | post-v1 (v1.2) |
|
||||
| 43 | The spec's collection table lists BGP-4-MIB but no profile fills it, and it names no MAC/ARP lookup | v1.3 (owner request 2026-10-24) adds both: BGP peers in the `routing` category (CISCO-BGP4 for cisco-ios/cisco-nxos, Juniper BGP4-V2, Arista BGP4V2, BGP4-MIB for the IPv4-only profiles that answer it) with a Routing tab, a BGP page, `GET /api/v1/bgp/peers` and the built-in `bgp_peer_down` rule; and a MAC/ARP/FDB lookup with a Find host page. Additive API: `/l2/search`, `/devices/{slug}/{l2,fdb,arp,l2/ports}`, `/interfaces/{id}/macs`, `/bgp/peers`; `/poller/health` gains `l2` and `notes`. | The owner wants to find where a host is plugged in and to see BGP sessions. | v1.3 |
|
||||
| 44 | (not specified) table walks run in discovery or not at all; the poll path never walks | A separate **L2 table job** (`internal/l2`, SNMP path label `l2` in `nmon_pdus_total`) walks BRIDGE-MIB, Q-BRIDGE-MIB, CISCO-VTP-MIB (per-VLAN context, profile flag `l2.per_vlan_context`, set on cisco-ios only) and IP-MIB neighbour tables every `l2.interval` (default 15m, minimum 5m) with its own concurrency cap (`l2.concurrency`, 4), a per-device deadline (`l2.device_timeout`, 2m) and the rollup job's yield rule (it pauses while the hot write queue is over half full). A device that times out or fails is skipped for the cycle, recorded on `/poller/health`, and never delays the others. Rows live in the inventory database (migration 14: `fdb_entries`, `arp_entries`, `vlans`, and `l2_walks`, one row per device holding the time of its last walk, which is how a row is "current"), are in `nmon backup` (JSONL export too) and are pruned daily by `last_seen` against `l2.keep` (90d). `interface_id` is the empty string, not NULL, when a bridge port has no ifIndex, because the unique key must not contain NULL. | The hot poll path must not walk tables; the history behind "where was this MAC last week" is inventory, not a metric. | v1.3 |
|
||||
| 45 | The component label of a table metric is a column of the table | Profiles may label rows by decoding the **row index** (`index_label.layout`, tokens `skip`, `skipinet`, `inet`, `skipaddr`, `addr`, `ipv4`, `peerindex`, `afisafi`), because BGP peer tables are indexed by an address. The label is the peer address (IPv6 compressed); a per-address-family prefix count is labelled `<peer>\|<afi>-<safi>`, for example `2001:db8::1\|ipv6-unicast`. Juniper's prefix table is keyed by a peer index, so its rows resolve through the `peer_index` attribute of the peer table (`index_label.via`). Peer AS, local AS, router id and description are component attributes (migration 11), never labels, so the SNMP label contract (`device` + `component`) is unchanged. Other profile additions: `enable_metrics` (switch on optional parent metrics), `wire: counter32` (CISCO-BGP4 declares accepted prefixes as a Counter32 but they are a count), attribute `format: ipbytes`, and `l2.per_vlan_context`. | A BGP peer is identified by its address, which only the index carries; the alternatives (a label column that does not exist, or labelling by row number) would break "each peer appears once" and the alert text. | v1.3 |
|
||||
| 46 | `bgp_peer_down` summary names the peer, AS and description | The rule's own summary names the device and the peer. The AS, router id and description are appended to the **notification** (Summary, Description and labels) when it is delivered, from the component attributes; the stored alert row keeps the plain summary. | The metric label contract allows only `device` and `component`, and the rule templates see only those; extending the evaluator's template data belongs to the v1.2 rules plumbing. | v1.3 |
|
||||
| 47 | (not specified) vendor of a MAC | An optional IEEE OUI registry (MA-L, MA-M, MA-S) shows the vendor of each MAC and flags locally administered (`local`, shown "Private / randomised") and multicast addresses. It is fetched weekly through the outbound allow-list (`oui.fetch: false` turns it off; `nmon oui import DIR` loads the CSVs offline), kept as `<data-dir>/oui/registry.gz` (checksummed, written atomically, in `nmon backup`), and looked up at query time by the longest prefix, so a refresh relabels history. Without a registry the vendor is empty, nothing fails, and `/poller/health` carries an `oui_unavailable` note (never an alert). The CSVs are public IEEE data and are not in the repository. | Owner request 2026-10-24: nice to have, never required. | v1.3 |
|
||||
| 48 | Lookup ranks edge ports "the port with the fewest MACs, then the most recent last_seen" | Within a MAC's locations the order is: edge ports before uplinks, then rows the device's last walk still saw before history, then fewest MACs, then most recent. A port is an uplink above `l2.edge_max_macs` MACs (8), with an LLDP neighbour (the lldpRemTable local port number is taken to be the ifIndex), or as a LAG (ifType 161). A port's MAC count is the MACs on it at or after the row's `last_seen`, so for a current row it is the port's MACs in the last walk. | A host that moved should show its new port first even when the old, now empty, port had fewer MACs. | v1.3 |
|
||||
| 49 | SNMP interface counters are polled every 60 s; rate windows, rollup gap detection and reachability timers assume it | The default SNMP poll interval is **300 s** (LibreNMS-like; `store.DefaultPollInterval`), and an admin can set a different one per device (`poll_interval_s`, 0 or 30..3600, migration 15; empty = the default). The profile's 1m metrics follow it (halved on a device with unreliable 32-bit counters); 5 m and 15 m groups are unchanged and merge with a 300 s primary. Everything that was tied to the cadence follows the device's effective interval: graph, overview and p95 rate windows are two polls wide (at least 2 min; live rates use two default polls), the rollup's lookback and "thin window" test (a cadence of 5 min or slower predicts at most one sample per window, so a window is never thin, and a window with no sample is skipped as before), the `no_data` warning (silent for max(5 min, two polls)), the "unheard of for three polls is down" fallback, the up-series lookback (11 min), the dependency hold, and the `interface_errors` rule (10 min window). ICMP stays at 15 s. `nmon serve --poll-interval` still overrides the default for dev and tests. | Owner request 2026-10-24: 60 s polling of every port is more load than ISP-size fleets need; a per-device override keeps 1 min where it is wanted. Cost: a rollup window of a device polled slower than 5 min often holds no sample and is left empty, and graphs at the default have one point per 5 min. | post-v1.3 |
|
||||
| 50 | The API is served over HTTPS only; plain HTTP exists only as `--insecure-http` development mode (no sign-in) | An opt-in `--http-listen ADDR` serves the same UI and API over **plain HTTP with sign-in required**, beside HTTPS or alone when no TLS is configured. It is mutually exclusive with `--insecure-http`, never enables dev-open mode, and refuses to start with no OIDC or break-glass. Cookies are `Secure` on HTTPS and, on this listener, only behind a trusted proxy that sent `X-Forwarded-Proto: https`; client addresses come from `X-Forwarded-For` only from `auth.trusted_proxies`, and an empty list logs a WARN. | Owner request 2026-10-24: deployments sit behind a TLS-terminating reverse proxy in a DMZ or internal network, and double TLS to the backend adds nothing there. Cost: nmon cannot itself check the network between proxy and listener is private; the operator binds it to loopback or an internal address. | post-v1.4 |
|
||||
| 51 | Sign-in is configured before first start; nmon refuses to serve over TLS or `--http-listen` with none (Auth, DEVIATIONS 31 and 50) | **Install mode**: with no `auth.oidc`, no `auth.break_glass_file` and setup never completed, nmon serves a first-install wizard at `/setup` to **anyone who can reach it, unauthenticated, as an admin**. There is no setup token and no localhost restriction. Only the setup routes and what the wizard needs are served (UI, `GET /api/v1/setup`, settings form, notify presets/preview/test, device add/import and list, alert policies, profile list, secrets PUT, meta); every other API route answers 503 "setup not finished". A WARN at start and every 10 minutes says setup is open. Completing setup stores a one-way flag in the inventory (`setup_state`); it ends install mode permanently, switches authentication on without a restart, and cannot be undone from the web. An existing install with a sign-in method is marked completed at its first start, and deleting the break-glass file later does not reopen setup (the old refusal applies). `--insecure-http` dev-open mode is unchanged and never enters install mode. | Owner decision 2026-10-24: nmon is deployed in a DMZ or an internal network, and a token or localhost check would make a first install harder than the threat justifies. Cost: until setup completes, whoever reaches the port can create the administrator, so the port must not face an untrusted network before then. Ansible, which pre-configures sign-in, never enters install mode. | post-v1.4 |
|
||||
| 52 | Without a certificate flag nmon refuses to start ("HTTPS by default and has no certificate") | With none of `--tls-cert`/`--tls-key`, `--acme-host` or `--http-listen`, nmon generates a **self-signed** ECDSA P-256 certificate (names: host name, every non-loopback interface address incl. IPv6, `localhost`, `::1`, `127.0.0.1`; about 2 years; key 0600) in `<data-dir>/tls/`, reuses it on later starts, replaces it within 30 days of expiry, serves HTTPS on `--listen` and logs a WARN saying how to replace it. This applies to every start without TLS config, not only the first. User-provided certificates still reload on SIGHUP. `--http-listen` alone stays plain HTTP with no certificate. | Owner decision 2026-10-24: a first install must come up without a PKI. A browser warning is the price; the WARN names `--tls-cert`/`--tls-key` and `--acme-host` as the fix. | post-v1.4 |
|
||||
| 53 | (not specified) the default poll interval is a dev flag (`--poll-interval`) | `poller.interval` in `config.yaml` (Go duration, 30s to 1h, default 5m) sets the default SNMP poll interval; Settings, General edits it, and the first-install wizard asks for it. A change applies without a restart to the poller (devices replan), reachability, the rollup and the API through one shared value (`store.PollCadence`). A device's own `poll_interval_s` still wins, and `--poll-interval`, when given, still overrides the setting (dev and tests). | Owner request 2026-10-24: the interval is an operator choice (DEVIATIONS 49) and belongs where the other settings are. The one cost is a replan of every device on change. | post-v1.4 |
|
||||
| 54 | (not specified) an operator cannot poll one device on demand or see the SNMP exchange | Three admin tools on the device page and API: `poll-now` (wraps the poller's existing kick), `poll-test` (one synchronous, traced cycle of the real poll plan through the poller's own fetch path; no metrics written, no poller state changed; one per device, four overall, 60 s, 5,000 OID rows) and `snmp-debug` (per-device recording of scheduled cycles, at most 60 minutes, last 20 cycles in memory only, cleared on restart, plus one INFO log line per cycle). Credentials never appear in a trace or log. Also `nmon device poll <slug> [--test] [--json]`. | Owner request 2026-10-25: "there's no snmp debug options or an option to individually poll a device for testing purposes." Cost: recordings are lost on restart by design, and a request's duration includes the SNMP library's own retries. | post-v1.4 |
|
||||
| 55 | (not specified) a port graph shows no cumulative traffic | `GET /api/v1/graph` adds `totals` (`in_bytes`, `out_bytes`, `covered_seconds`, `range_seconds`): the average-rate series integrated into bytes over the range, per port only. Each tier counts its own step, gaps count nothing, and unreliable ports get none. The UI shows it as "total" in the port summary and a Total column in the full graph, in decimal units. | Owner request 2026-10-25: show bytes transferred per port over the selected timespan, as LibreNMS does. Additive API change. Cost: a total is only as complete as the data, so a partial one is marked. | post-v1.4 |
|
||||
| 56 | (not specified) port graphs show bits only, with in/out p95 | LibreNMS parity, additive API only: `p95.aggregate_bps` (95th of in+out per 5-minute slot, both directions present); the UI adds Max and the aggregate to the port summary, utilisation percentages and a "% of speed" axis (from `iface.speed_bps`, hidden when unknown), and a "Compare previous period" overlay (a second `GET /api/v1/graph` over the preceding same-length range, shifted client-side). New `GET /api/v1/graph/counters` serves **errors** and **discards** rates from the hot store **only**: those counters have no rollup (rollup metric names are frozen), so they reach back over hot retention at most and the response and UI say when a range was cut. **Packets and broadcast/multicast graphs are not offered** (shown disabled): the generic profile does not poll the `ifHC*UcastPkts`/`*MulticastPkts`/`*BroadcastPkts` counters, and adding them would add three OIDs per port to every device's poll and change every fixture. Error and discard counters are 32-bit by definition, but the unreliable rule is about octet wrap on fast links and does not apply to them (VictoriaMetrics `rate()` absorbs a counter reset). | Owner request 2026-10-25: "let's match what libre does" for per-port graphs. Cost: the aggregate is live-only (a month older than hot retention has none), a previous period far back falls to hourly rollups and fills only some slots of a finer current axis, and packet-level graphs need a separate polling decision. | post-v1.4 |
|
||||
|
||||
91
docs/coverage.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# Collection coverage
|
||||
|
||||
What the spec's "What to collect, by device class" table (docs/spec/spec.md) asks for, against what the shipped
|
||||
profiles in `profiles/` collect today. A metric listed here is polled, stored raw and shown on a device tab (every
|
||||
non-interface metric names a `category`; the orphan test in `internal/profile/present_test.go` enforces it). Gaps are
|
||||
stated plainly; nothing here is promised that is not collected.
|
||||
|
||||
Legend: **yes** collected and shown; **partial** some of it; **no** not collected.
|
||||
|
||||
| Class | Spec item | Status | Profile and metric |
|
||||
|---|---|---|---|
|
||||
| Every device | sysUpTime | yes | generic: sysUpTime, hrSystemUptime |
|
||||
| Every device | hrProcessorLoad per core | yes | generic: hrProcessorLoad |
|
||||
| Every device | hrStorage size/used per filesystem and RAM | yes | generic: hrStorageUsed / hrStorageSize x hrStorageAllocationUnits (bytes, percent bar) |
|
||||
| Every device | memTotalReal / memAvailReal (UCD) | yes | generic: memTotalReal, memAvailReal |
|
||||
| Servers | UCD load average | yes | linux-netsnmp, ubiquiti-edgeos: laLoadInt |
|
||||
| Servers | swap | yes | linux-netsnmp: memTotalSwap, memAvailSwap |
|
||||
| Routers, switches | IF-MIB counters, ifOperStatus | yes | generic (Ports tab) |
|
||||
| Routers, switches | ENTITY-SENSOR-MIB temp/fan/PSU | partial | generic entPhySensorValue is the raw reading only: its type, scale and precision are not applied, so units are mixed (environment tab, labelled raw). No fan or PSU state. cisco-ios/cisco-nxos entSensorValue is the same |
|
||||
| Routers, switches | POWER-ETHERNET-MIB PoE per port | no | not in any profile |
|
||||
| Routers, switches | LLDP neighbours | yes | generic discovery table only (no metric) |
|
||||
| Routers, switches | BGP-4-MIB peer state and prefixes | partial | see "BGP peers" below: state, uptime, accepted prefixes per address family and update counters where the device answers a BGP MIB; nothing on MikroTik or Linux hosts |
|
||||
| Routers, switches | optics DOM | partial | juniper-junos jnxDom* are collected but are interface metrics, so they are not on a device tab (there is no optics tab data yet); cisco entSensor DOM is not separated from the raw sensor reading |
|
||||
| Routers, switches | STP topology changes | no | |
|
||||
| Routers, switches | MAC/ARP table size | yes | the tables themselves, not just their size: the L2 table job (`internal/l2`) reads FDB, VLAN names and ARP/ND from every SNMP device (see "MAC, ARP and FDB" below) |
|
||||
| Routers, switches | cpmCPUTotal, jnxOperatingCPU | yes | cisco-ios, cisco-nxos: cpmCPUTotal5minRev; juniper-junos: jnxOperatingCPU, jnxOperatingTemp, jnxOperatingBuffer |
|
||||
| Routers, switches | Cisco memory pools | yes | cisco-ios: ciscoMemoryPoolUsed / Free (not as a percent; there is no total) |
|
||||
| Switches (Ubiquiti) | memory | partial | ubiquiti-edgeswitch, ubiquiti-unifi-switch: free / available only; CPU is a text string in that MIB and is not collected |
|
||||
| Firewalls | session count | partial | fortigate: fgSysSesCount (no maximum); pfsense has no vendor metrics |
|
||||
| Firewalls | VPN tunnel state | no | |
|
||||
| Firewalls | HA state | partial | fortigate: fgHaSystemMode only |
|
||||
| Firewalls | policy throughput (fgSys, panSession, pfSense) | no | fortigate CPU and memory only |
|
||||
| APs, controllers | client count per AP and SSID | yes | ubiquiti-unifi-ap: unifiVapNumStations per SSID |
|
||||
| APs, controllers | channel utilization | yes | ubiquiti-unifi-ap: unifiRadioCu* per radio |
|
||||
| APs, controllers | noise floor | no | the UniFi MIB has no such object |
|
||||
| APs, controllers | tx power | yes | ubiquiti-unifi-ap: unifiVapTxPower |
|
||||
| APs, controllers | AP up/down from controller | no | no controller integration |
|
||||
| WISP radios, CPE | signal, SNR, modulation, link capacity | no | no Ubiquiti airMAX, Cambium or Mimosa profile |
|
||||
| Servers, hypervisors | Proxmox push | yes | push receiver and the proxmox integration (not a profile) |
|
||||
| Servers, hypervisors | node_exporter, Ceph, SMART | no | |
|
||||
| UPS, PDU | battery charge, runtime, input/output voltage, load, output status | yes | apc-ups: UPS-MIB and PowerNet upsAdv/upsBasic metrics (power tab) |
|
||||
| UPS, PDU | per-outlet current (PDU MIBs), generator status | no | |
|
||||
| Printers | supply levels per prtMarkerSupplies | yes | printer: prtMarkerSuppliesLevel of prtMarkerSuppliesMaxCapacity; -3 "some remaining" and -2 "unknown" are stored and shown as words |
|
||||
| Printers | supply type and colour | yes | printer: prtMarkerSuppliesType and prtMarkerColorantValue, read at discovery as component attributes |
|
||||
| Printers | page count | yes | printer: prtMarkerLifeCount |
|
||||
| Printers | status and alerts | partial | printer: prtAlertSeverityLevel (severity only, not the alert description or code) |
|
||||
| Printers | paper tray levels | yes | printer: prtInputCurrentLevel of prtInputMaxCapacity, labelled by prtInputName |
|
||||
| Printers | hp, Brother, Xerox serial and model | no | serial and model are not collected; no vendor quirk profiles (printer-brother, printer-ricoh) are shipped, see below |
|
||||
| Environment | ENTITY-SENSOR-MIB | partial | as for routers: raw reading only |
|
||||
| Environment | temperature, humidity, door, water (NetBotz, Raritan) | no | no profile |
|
||||
| Environment | MikroTik health | yes | mikrotik-routeros: voltage, board and CPU temperature |
|
||||
| Services | ping, HTTP, DNS, TLS expiry | yes | checks (not profiles) |
|
||||
|
||||
Profiles with no vendor metrics beyond generic: arista-eos, pfsense, vyos, windows-snmp.
|
||||
|
||||
## Alerts named in the spec
|
||||
|
||||
| Alert | Status |
|
||||
|---|---|
|
||||
| cpu_high, memory_high, disk_full | yes |
|
||||
| ups_on_battery, ups_runtime_low | yes |
|
||||
| printer_supply_low (< 10%) | yes |
|
||||
| printer_supply_empty (level 0, critical) | yes (added in v1.0.1) |
|
||||
| temp_high (per-profile threshold) | yes, for MikroTik, UPS battery and Junos sensors only |
|
||||
| "any prtAlert of severity critical" | no rule; prtAlertSeverityLevel is collected and shown, not alerted on |
|
||||
|
||||
## Printer quirk profiles (Brother, Ricoh)
|
||||
|
||||
Not shipped. The LibreNMS recordings (`tests/snmpsim/brother*.snmprec`, `ricoh*.snmprec`) were not used to derive a
|
||||
quirk set, so none is claimed. The generic `printer` profile matches Brother (2435) and Ricoh (367) by enterprise number.
|
||||
|
||||
## BGP peers (v1.3)
|
||||
|
||||
The component is the peer address (IPv6 compressed). Metrics: `bgp_peer_state` (idle, connect, active, opensent, openconfirm, established; anything but established is critical), `bgp_peer_uptime`, `bgp_peer_in_updates` / `bgp_peer_out_updates` (counters, shown as a rate) and, where the vendor reports prefixes per address family, `bgp_prefixes_accepted` with the component `<peer>|<afi>-<safi>`. Peer AS, local AS, router id (and the description, where the MIB has one) are component attributes from discovery. A peer is reported once: a profile with a vendor table replaces the BGP4-MIB metrics of the same names.
|
||||
|
||||
| Profile | Source | IPv6 peers | Prefixes | Evidence |
|
||||
|---|---|---|---|---|
|
||||
| cisco-ios | CISCO-BGP4-MIB cbgpPeer2Table and cbgpPeer2AddrFamilyPrefixTable | yes | per AFI/SAFI | IOS XE and IOS XR recordings (`iosxe`, `iosxr`, `iosxr_asr9904`) |
|
||||
| cisco-nxos | CISCO-BGP4-MIB, as cisco-ios | yes | per AFI/SAFI | **not verified**: the NX-OS recordings carry no BGP table; kept because Cisco documents the MIB on NX-OS, and a device that does not answer simply has no peers |
|
||||
| juniper-junos | BGP4-V2-MIB-JUNIPER jnxBgpM2PeerTable, jnxBgpM2PrefixCountersTable | yes | per AFI/SAFI (resolved through jnxBgpM2PeerIndex) | `junos_mx`, `junos_vmx`, `junos_rpm`; the peer index has no length octets, as Junos sends it |
|
||||
| arista-eos | ARISTA-BGP4V2-MIB aristaBgp4V2PeerTable, aristaBgp4V2PrefixTable | yes | per AFI/SAFI | `arista_eos`, `arista_eos_7280r`; description is collected |
|
||||
| ubiquiti-edgeos, fortigate, pfsense, vyos | BGP4-MIB bgpPeerTable (generic metrics switched on with `enable_metrics`) | no (RFC 4273 is IPv4 only) | no | `edgeos`, `fortigate_60f3g4g`, `pfsense_frr-bgp` (FRR's agentx BGP4-MIB); VyOS uses FRR the same way but has no recording |
|
||||
| generic and every other profile | BGP4-MIB metrics exist but are off (`optional`) | no | no | a router profile turns them on with `enable_metrics` |
|
||||
| mikrotik-routeros | **nothing** | | | MIKROTIK-MIB has no BGP objects and none of the RouterOS recordings answers a BGP table; RouterOS BGP is not visible over SNMP |
|
||||
| linux-netsnmp, windows-snmp, apc-ups, printer, ubiquiti switches and APs | **nothing** | | | not routers |
|
||||
|
||||
FRR on Cumulus (`cumulus_bgp`) and Dell OS (`dnos_bgp`) answer BGP4-MIB, but no shipped profile matches those devices, so their peers are collected only once a profile for them enables the generic metrics.
|
||||
|
||||
## MAC, ARP and FDB (v1.3)
|
||||
|
||||
Read by the L2 table job for every SNMP device, from BRIDGE-MIB (`dot1dBasePortIfIndex`, `dot1dTpFdbPort`), Q-BRIDGE-MIB (`dot1qTpFdbPort`, `dot1qVlanFdbId`, `dot1qVlanStaticName`), CISCO-VTP-MIB (`vtpVlanState`, `vtpVlanName`, only with the cisco-ios flag `l2.per_vlan_context`, walking the bridge table once per VLAN with community `<community>@<vlan>` or v3 context `vlan-<vlan>`) and IP-MIB (`ipNetToPhysicalPhysAddress` for IPv4 ARP and IPv6 ND, `ipNetToMediaPhysAddress` as the IPv4-only fallback). Devices without a bridge MIB (servers, firewalls without a switch, most APs) have no FDB rows. The FDB is VLAN-aware only through Q-BRIDGE or the Cisco per-VLAN walk; BRIDGE-MIB alone gives VLAN 0.
|
||||
@@ -11,6 +11,12 @@ nmon serves the UI at `/`. Unknown paths fall back to `index.html` for the clien
|
||||
`/api/`, `/docs`, `/openapi*`, `/metrics` and `/schemas`, which belong to the API. Files under `/assets/`
|
||||
are content-hashed and sent `immutable`; `index.html` is `no-cache`; text assets are served gzipped.
|
||||
|
||||
## In-app help (`/help`)
|
||||
|
||||
The operator docs are shown at `/help` and `/help/<doc>#<heading>`. The markdown stays in `docs/` and is imported raw into the web build (`web/src/pages/help/docs.ts`, `?raw` imports), so there is no copy to keep in sync and the text always matches the binary. The page is a lazy chunk (react-markdown + remark-gfm), loaded on first visit. Raw HTML in the markdown is not rendered.
|
||||
|
||||
To add a doc: write `docs/<id>.md` (h1 title, h2/h3 sections; the h2/h3 headings become the table of contents and search targets), add one entry to `DOCS` in `docs.ts`, and link to other docs as `docs/<id>.md#heading` (links to files that are not in `DOCS`, such as `docs/spec/`, render as plain text). To give a Settings section a "?", add `section id: heading slug` to `web/src/pages/help/helpLinks.ts`. Heading slugs are GitHub-style (`Behind a reverse proxy` is `behind-a-reverse-proxy`). Developer-only docs (this file, milestones, spec, DEVIATIONS, MAINTAINABILITY) are deliberately not listed. `/help` is an SPA route; `/docs` stays the Swagger UI.
|
||||
|
||||
## OpenAPI compatibility
|
||||
|
||||
`scripts/openapi-check.sh` compares the current spec (`nmon openapi`, else `openapi.json`) with
|
||||
|
||||
@@ -44,4 +44,13 @@ Alerting: all *Must* and *Must not* lines. Adversarial: redundant parents failin
|
||||
|
||||
## Gate log
|
||||
|
||||
_(filled in by the supervisor when the gate passes)_
|
||||
**Passed 2026-10-23**, run by the supervisor (Opus).
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| `make lint` (Go + web) | 0 findings |
|
||||
| `go test -race ./...`, `-tags 'integration vmembed'`, `test-pg.sh` | all ok |
|
||||
| gates m1, m2 (LibreNMS recordings), m3, m4, m5 in one sequential run | all PASS |
|
||||
| `make gate-m4` | PASS: redundant topology (2 cores, 2 dual-homed distribution switches, 20 access switches) with `--icmp off`; (a) one core down → exactly one notice, nothing suppressed; (b) **both cores and all 22 children stopped at the same instant → exactly ONE notification** naming both cores and 22 suppressed children, zero per-child notices; (c) restart re-sends nothing; (d) core-1 back → one resolution, dist switches announced with their suppressed children; deliveries logged with redacted payloads; incident IDs stable |
|
||||
|
||||
**Decisions in M4:** a dependency hold delays a child's notice while its parents are unsettled (proven over 100 randomised timing seeds), so simultaneous outages group correctly. Unsupported ICMP falls back to SNMP alone and shows an `icmp_unsupported` device warning. Single-signal warnings are held 5 min. `--icmp auto|off`. `alerts?state=open` (open alerts, acked or not) is used on device pages. That fix came from the gate's screenshot step, which caught an acked outage vanishing from its device page.
|
||||
|
||||
@@ -45,4 +45,13 @@ Storage and rollups: all lines except the loadtest disk budget (M6). Ingest: all
|
||||
|
||||
## Gate log
|
||||
|
||||
_(filled in by the supervisor when the gate passes)_
|
||||
**Passed 2026-10-23**, run by the supervisor (Opus).
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| `make lint` (Go + web) | 0 findings |
|
||||
| `go test -race ./...`, `-tags 'integration vmembed'`, `test-pg.sh` | all ok |
|
||||
| gates m1, m2 (LibreNMS recordings), m3, m4, m5 in one sequential run | all PASS |
|
||||
| `make gate-m5` | PASS: mock Proxmox synced (node pve1 + guests with hosted_on); `proxmox.influx` replayed via a push token → 93 series under pve1; **2 y graph hits the 1 h tier** (tiers `1h`+`raw`, `stitched_at`, long points exactly the back-filled values); p95 with `source`; group graph is the oriented sum; HTTP check up; ingest negatives (401s, reserved prefix dropped, series-cap alert deduped) |
|
||||
|
||||
**Decisions in M5:** see DEVIATIONS rows 18–30 (push token format, rollup `pending` status and `v` encoding, gauge rollup names, month-partitioned 5-min retention, acked synchronous long writes, unreliable interfaces never rolled up, group `coverage` label, check `check` label, Proxmox node rule, `push="1"` label, system alarms, no inline Proxmox secret). One `internal/longq` package now owns the "highest version wins" query; this fixed M3's long-tier graph read.
|
||||
|
||||
@@ -45,4 +45,13 @@ Auth: all lines. Operations: all lines. Collection: 2,000 devices within 40 s wi
|
||||
|
||||
## Gate log
|
||||
|
||||
_(filled in by the supervisor when the gate passes)_
|
||||
**Passed 2026-10-23**, run by the supervisor (Opus) on the merged main branch.
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| `make lint` (Go + web) | 0 findings |
|
||||
| `go test -race ./...`, `-tags 'integration vmembed'`, `test-pg.sh` | all ok |
|
||||
| gates m1 to m6 in one sequential run (m2 with LibreNMS recordings) | all PASS |
|
||||
| `make gate-m6` | PASS: unauthenticated pages 302 to the IdP (no login UI, owner decision); viewer gets 403 on PUT (session and token), admin 200; push token 403 on the API; revoked token 401; missing CSRF 403; X-Forwarded-* ignored from non-proxies; viewer-fuzz over every state-changing route; break-glass via HTTP Basic, lock on the 5th failure, `nmon unlock`, audited and alerted; backup during polling, then restore into a clean dir, verified and served; key rotate under TLS; settings reload (invalid gives 422 and no change); CSV import (cycle gives 422 and nothing written; port descriptions survive rediscovery); openapi additive |
|
||||
|
||||
**Skipped by approved deviation:** the 2,000-device loadtest (DEVIATIONS row 17). **Decisions:** DEVIATIONS rows 31 to 37 (dev-open mode, live-reload scope, extra admin routes and CLI, `nmon secret set`, and others).
|
||||
|
||||
@@ -15,9 +15,10 @@ its **Gate log** is filled in when the gate passes. A milestone is done only whe
|
||||
| M1 | Skeleton and fixtures | `nmon serve` polls the counters fixture and `/api/v1/query` returns rising ifHCInOctets | [M1](M1-skeleton-and-fixtures.md) | **passed**, tagged `m1` |
|
||||
| M2 | Discovery and profiles | every fixture's expect.yaml passes, including v1 and v3, and the PDU budget holds | [M2](M2-discovery-and-profiles.md) | **passed**, tagged `m2` |
|
||||
| M3 | Graphs and UI | a port's in/out graph renders at 1 h, 24 h and 30 d from the counters fixture | [M3](M3-graphs-and-ui.md) | **passed**, tagged `m3` |
|
||||
| M4 | Alerting | stopping a parent fixture sends one notification naming it and its children | [M4](M4-alerting.md) | in progress (WP1 reach and deps, WP2 rules, WP3 notify done) |
|
||||
| M5 | Push ingest, rollup, checks | a replayed Proxmox sample shows under its node and a 2 y query hits the 1 h tier | [M5](M5-ingest-rollup-checks.md) | planned |
|
||||
| M6 | Auth and ops | a viewer gets 403 on PUT (2,000-device loadtest skipped, approved deviation) | [M6](M6-auth-and-ops.md) | planned |
|
||||
| M4 | Alerting | stopping a parent fixture sends one notification naming it and its children | [M4](M4-alerting.md) | **passed**, tagged `m4` |
|
||||
| M5 | Push ingest, rollup, checks | a replayed Proxmox sample shows under its node and a 2 y query hits the 1 h tier | [M5](M5-ingest-rollup-checks.md) | **passed**, tagged `m5` |
|
||||
| M6 | Auth and ops | a viewer gets 403 on PUT (2,000-device loadtest skipped, approved deviation) | [M6](M6-auth-and-ops.md) | **passed**, tagged `m6` |
|
||||
| v1.3 | BGP peers and MAC/ARP/FDB lookup | MAC/IP lookup, BGP peers and `bgp_peer_down`, see the gate list in the doc | [v1.3](v1.3-bgp-and-l2-tables.md) | **built**, gate log filled in (not tagged) |
|
||||
| M7 | gNMI streaming | after v1; not built in this run | — | out of scope |
|
||||
|
||||
## How the work is run
|
||||
@@ -49,6 +50,16 @@ Departures from the spec are recorded in [`docs/DEVIATIONS.md`](../DEVIATIONS.md
|
||||
|
||||
- **Web push notifications (v2)**: a `webpush` receiver kind in `internal/notify` (RFC 8291 payload encryption, RFC 8292 VAPID, standard-library crypto), per-user subscriptions (needs M6 users), a service worker in the UI, and push endpoints in the outbound allow-list. Out of v1 per the spec's non-goals ("alerts go to webhooks and email only"). Owner deferred it to v2 on 2026-10-22. Until then, a webhook receiver pointed at ntfy or Gotify gives phone push.
|
||||
|
||||
- **Distributed poller (v2)**: remote pollers per site or rule that spool to disk while the master is down, send signed chunks over an outbound-only link (MQTT through a broker), and receive device credentials sealed to their own key. Assessed feasible on 2026-10-25 and deferred to v2 by the owner. Requirements, gaps and risks: [v2-distributed-poller.md](v2-distributed-poller.md).
|
||||
|
||||
- **BMC support**: SNMP profiles for Dell iDRAC, HPE iLO and Supermicro (health, temperatures, fans, PSUs, power, RAID) feeding the Health/Power/Environment tabs; a native Redfish collector later. Today, Redfish works only via an exporter pushing with a push token. Owner: "good enough for now" (2026-10-23).
|
||||
|
||||
## Post-v1 additions
|
||||
|
||||
- **Device management UI** (2026-10-23): the UI had no way to add a device. Added an admin-only "Add device" dialog on the device list (IPv6/IPv4 addresses, SNMP v1/v2c/v3, synchronous discovery with the reattach 409 flow) and an Actions menu on the device page (Edit with parents and SNMP replacement, Rediscover now, Retire; Purge now asks for the device name). New API route `POST /api/v1/devices/{slug}/retire` (admin, audited, additive). The SNMP fields are one shared component (`web/src/components/SnmpFields.tsx`). Screenshots: `docs/screenshots/devices-mgmt/`.
|
||||
|
||||
- **Profiles, profile pinning and row navigation** (2026-10-23): new shipped profiles `ubiquiti-edgeswitch`, `ubiquiti-unifi-switch`, `ubiquiti-unifi-ap` and `vyos`; discovery re-matches unpinned devices every run (and at startup) and an admin can pin a profile from the Edit dialog (DEVIATIONS 39, 40); the device header explains why a device is generic. On the device list, the interface search results and the poller health table, a click anywhere in a row (or Enter on a focused row) opens the device or graph; the name stays a real link. Screenshots: `docs/screenshots/v1.0.1/`.
|
||||
|
||||
## Review artifacts
|
||||
|
||||
- From M3 on, each UI milestone's gate captures Playwright screenshots of every page into `docs/screenshots/<milestone>/` for the user to review.
|
||||
|
||||
70
docs/milestones/v1.0.1-device-tabs.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# v1.0.1: device tabs (Health, Wireless, Printer, …) and a printer fleet view
|
||||
|
||||
Owner request (2026-10-23): non-interface metrics (CPU, memory, storage, sensors, UPS, wireless, printer supplies) are
|
||||
collected but not shown anywhere. Build LibreNMS-style device tabs before production, driven by profile metadata so
|
||||
that any device class shows up without per-vendor UI code.
|
||||
|
||||
## Profile schema additions (internal/profile)
|
||||
|
||||
Per metric, all optional except `category`, which is **required for every pollable metric** (enforced by a test over all
|
||||
shipped profiles and by the loader for new ones; `imported: librenms` drafts default to `health`):
|
||||
|
||||
- `category`: `system | health | power | environment | wireless | printer | routing | optics | firewall`
|
||||
- `label`: human name ("CPU load", "Toner level"); defaults to the metric name
|
||||
- `of: <metric>`: the capacity/total this metric is a fraction of (hrStorageUsed `of: hrStorageSize`,
|
||||
prtMarkerSuppliesLevel `of: prtMarkerSuppliesMaxCapacity`, memAvailReal `of: memTotalReal`). The UI shows a percent bar,
|
||||
computed at query time (raw stays raw, per the spec).
|
||||
- `display`: `gauge` (default for gauge), `percent` (implied by `of`), `state` (implied by `type: status`), `rate`
|
||||
(implied by counter types), `text`
|
||||
- `scale_by: <metric>` (optional): multiply by another column, e.g. hrStorageUsed × hrStorageAllocationUnits for bytes
|
||||
- `sentinels: {<value>: <text>}` (optional): values shown as words instead of numbers, e.g. printer `-3: "some remaining"`,
|
||||
`-2: "unknown"`. These are polled and stored (not dropped as not_available).
|
||||
|
||||
Every shipped profile is annotated. Printer gains `prtMarkerSuppliesType` (enum) and `prtMarkerColorantValue`, collected at
|
||||
discovery as component attributes, plus the paper trays (`prtInputCurrentLevel` `of: prtInputMaxCapacity`, label
|
||||
`prtInputName`). Brother and Ricoh quirks go in small vendor profiles extending `printer` (verified against the LibreNMS
|
||||
recordings where they exist).
|
||||
|
||||
## API (additive)
|
||||
|
||||
- `GET /api/v1/devices/{slug}/metrics`
|
||||
```
|
||||
{ categories: [ { name: "health", label: "Health",
|
||||
groups: [ { metric: "hrProcessorLoad", label: "CPU load", unit: "percent", display: "gauge",
|
||||
components: [ { component: "CPU 0", value: 12.0, max: null, text: null, state: null, ts: "…" } ] } ] } ] }
|
||||
```
|
||||
Latest values from hot (one batched query per device). `max` is set for `of` metrics, `state` is `ok|warn|crit|unknown`
|
||||
for enums (mapping in the profile `enum_state` or a default by name), and `text` is used for sentinels and text displays.
|
||||
Categories appear only when the device has data in them, in the order system, health, power, environment, wireless, printer, routing, optics, firewall.
|
||||
- `GET /api/v1/devices/{slug}/metrics/graph?metric=&component=&from=&to=&step=` returns `{unit, display, series: [[unix_s, value|null]], max_series?}`
|
||||
for gauges (raw), with counters as a rate, using the same step rules as `/graph`. Percent displays return percent.
|
||||
- `GET /api/v1/printers?page=` (viewer): the fleet view, with
|
||||
`items: [{device{slug,name,site}, reachability, pages_total, supplies: [{component, type, color, percent|null, text|null, state}], trays: [...]}]`,
|
||||
sorted by the lowest supply percent first.
|
||||
- Alerts: add `printer_supply_empty` (critical, level 0 or "empty"). Record a "supply replaced" audit event when a supply's
|
||||
percent rises by 20 points or more between polls.
|
||||
|
||||
## UI
|
||||
|
||||
Device page tabs: **Overview** (header, small CPU/memory/storage summary, active alerts) · **Ports** (today's table) ·
|
||||
then one tab per category the device has (Health, Power, Environment, Wireless, Printer, Routing, Optics, Firewall).
|
||||
|
||||
- Generic renderers, chosen by `display`:
|
||||
- gauge: value, unit and a sparkline
|
||||
- percent: bar with value/max
|
||||
- state: coloured badge, also shown by icon or shape so it doesn't rely on colour alone
|
||||
- rate: value per second
|
||||
- text: plain text
|
||||
|
||||
Clicking a row opens the metric graph, a TrafficGraph-like component for single series. Components are grouped by metric.
|
||||
- Bespoke panels:
|
||||
- **Printer**: a card per supply with a colour swatch (C/M/Y/K from colorant), a percent bar and "some remaining" text,
|
||||
paper trays, and a page count graph.
|
||||
- **Wireless**: per-SSID table (clients, channel, tx power) with totals, and per-radio utilisation bars.
|
||||
- New nav page **Printers** (fleet), sorted by the lowest supply.
|
||||
- Viewers see everything; nothing here mutates.
|
||||
|
||||
## Guards
|
||||
|
||||
- **Orphan test:** every pollable metric in every shipped profile has a category, and its unit and display have a renderer.
|
||||
- **Coverage matrix:** `docs/coverage.md` maps the spec's "What to collect" table to what each profile collects today, with gaps listed plainly.
|
||||
113
docs/milestones/v1.1-alert-policies.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# v1.1: alert policies (alerts fire only where a policy says so)
|
||||
|
||||
Owner request (2026-10-23): preprod alerts on everything. Rules exist, but each one applies to every device and port,
|
||||
so a WiFi port that always sees errors and a fiber uplink, where one error matters, are judged by the same rule.
|
||||
The owner's decisions:
|
||||
|
||||
- **Nothing fires without a policy.** This covers metric rules, reachability (`device_down`, `device_warning`) and
|
||||
nmon's own system alarms. A fresh install records no alerts until an admin adds policies.
|
||||
- **Scope is groups + matchers.** Matchers pick ports and devices by attribute (so new ports are covered
|
||||
automatically), and groups handle the hand-curated exceptions.
|
||||
- **Policies live in the database** (a new bun migration) and are edited through the API and UI. They apply live, with no
|
||||
restart. Rule *definitions* (the MetricsQL, templates) stay in config as today.
|
||||
|
||||
## Model
|
||||
|
||||
A **policy** binds one rule to a scope:
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `id` | ULID |
|
||||
| `name` | unique, human ("Fiber uplinks: errors") |
|
||||
| `rule` | a metric rule name (`interface_errors`, …), `device_down`, `device_warning`, or `system` (all subsystem alarms, `alert.SystemRule`) |
|
||||
| `action` | `alert` or `ignore`. An `ignore` policy that matches wins over any `alert` policy of the same priority or lower. |
|
||||
| `priority` | int, default 0. For one entity and rule, the matching policy with the highest priority decides; on a tie `ignore` beats `alert`, then the lowest name wins. |
|
||||
| `enabled` | bool |
|
||||
| `severity`, `threshold`, `for` | optional overrides (null = the rule's). Threshold resolution: policy > the rule's group override > profile `ThresholdFor` > rule default. |
|
||||
| `scope` | see below; must be non-empty (`all: true` is the explicit "everything") |
|
||||
|
||||
**Scope** fields are ANDed together; within a list, any entry may match. An empty field is unconstrained.
|
||||
|
||||
- `all: bool`
|
||||
- `groups: [group id]`. A port matches if it is a member now (`GroupMember.Covers`); a device matches if any of its ports is.
|
||||
- `devices`: `slugs` (globs), `profiles`, `roles`, `sites`, `kinds`
|
||||
- `interfaces`: `if_types` (ints, e.g. 6 ethernetCsmacd, 71 ieee80211), `if_name` (RE2), `if_alias` (RE2),
|
||||
`min_speed_mbps`/`max_speed_mbps`, `alert_down` (true = the port's bell or a group's alert_down is on; this keeps the
|
||||
existing opt-in UI meaningful)
|
||||
|
||||
An interface matcher on a device-level rule (`cpu_high`, `device_down`) never matches, and validation refuses it. A
|
||||
`system` policy accepts only `all: true`, because system alarms have no device.
|
||||
|
||||
The examples the owner gave:
|
||||
|
||||
```
|
||||
{name: "Fiber + uplinks: errors", rule: interface_errors, action: alert, threshold: 0,
|
||||
scope: {interfaces: {if_types: [6], if_alias: "(?i)fib|uplink|core"}}}
|
||||
{name: "WiFi: no error alerts", rule: interface_errors, action: ignore, priority: 10,
|
||||
scope: {interfaces: {if_types: [71]}}}
|
||||
```
|
||||
|
||||
## Packages
|
||||
|
||||
- `internal/alertpolicy` (new, pure: no store, no SQL): `Policy`, `Scope`, `Validate`, `Subject` (device attrs, iface
|
||||
attrs, group ids) and `Decide(policies, rule, Subject) (Binding, bool)`. Unit tests carry the precedence table.
|
||||
- `internal/store`: migration `20261023000012_alert_policies.go` (table `alert_policies`, scope as JSON text, frozen
|
||||
struct copy), plus a sub-interface `AlertPolicies` in `alert_policies.go` (List/Get/Create/Update/Delete; unique name ->
|
||||
ErrConflict) and one embed line in `store.go`.
|
||||
- `internal/alert`: `Options.Policy func(Entity) (alertpolicy.Binding, bool)`. A series without a binding is skipped,
|
||||
and its open row closes silently as `retired`, like the existing opt-in. The binding's threshold, severity and for
|
||||
apply. `Rule.OptIn` and `Options.OptedIn` are superseded by the `alert_down` matcher. Keep the yaml field so old
|
||||
configs still parse, but make it a no-op and say so in the doc comment.
|
||||
- `internal/reach`: `Options.Wants func(rule, slug string) bool`. No binding means no new row, and an open row closes
|
||||
quietly.
|
||||
- `internal/app`: a policy cache, loaded at start, refreshed every 30 s and invalidated on every API write. It builds the
|
||||
`Subject` from TTL-cached device and interface lookups and gates `sysAlarms` on a `system` policy.
|
||||
- `internal/api`, in a new file:
|
||||
- `GET/POST /api/v1/alert-policies` and `GET/PUT/DELETE /api/v1/alert-policies/{id}` (admin).
|
||||
- `POST /api/v1/alert-policies/preview` (viewer, read-only) returns the counts of devices and ports a draft scope covers,
|
||||
with up to 20 samples of each.
|
||||
- `GET /api/v1/alert-rules` (viewer) lists the rule names, their level (`device`, `interface`, `component`, `system`),
|
||||
default threshold, op and severity, for the UI dropdown.
|
||||
- All additive; regenerate `openapi.json` and the web schema.
|
||||
- `web/`: an **Alert policies** page under Alerts (list, create and edit form with a live preview count, enable toggle;
|
||||
`RequireAdmin` for mutation). It has a "Start from template" menu with UI-side constants: all devices `device_down`,
|
||||
bell-opted ports `interface_down`, uplinks `interface_errors`, wireless ignore `interface_errors`, `system` all. The
|
||||
bell tooltip changes to say that a policy using the `alert_down` matcher is needed. Add a mock and an e2e test.
|
||||
|
||||
## Upgrade behaviour
|
||||
|
||||
No policies are seeded. On the first cycle after upgrade, every open alert without a binding closes silently as
|
||||
`retired` and sends no "resolved" notifications, which is how the preprod flood clears. The startup log warns once when
|
||||
the policy table is empty ("no alert policies: nothing will alert").
|
||||
|
||||
## Docs
|
||||
|
||||
- DEVIATIONS: a new row, "Default rules apply everywhere; spec §Alerting", which this change replaces with policy-gated
|
||||
rules (owner decision 2026-10-23). It supersedes #38's mechanism but keeps the bell.
|
||||
- `docs/operations.md`: an alert policies section with the two examples above.
|
||||
|
||||
## Gate
|
||||
|
||||
- `make lint` is clean, and `go test -race ./...`, `-tags 'integration vmembed'` for `internal/alert`, `internal/reach`,
|
||||
`internal/app` and `internal/store` all pass, plus `./scripts/test-pg.sh`, `scripts/openapi-check.sh` and the web
|
||||
lint/test/build/e2e.
|
||||
- Tests prove the following:
|
||||
- With no policies, nothing is recorded (metric, reach and system).
|
||||
- The WiFi ignore and fiber alert pair decides a 71 port and a 6 port correctly.
|
||||
- Removing a policy retires open rows silently.
|
||||
- Policy threshold beats group override.
|
||||
- A policy write takes effect on the next cycle without a restart.
|
||||
|
||||
## Gate log
|
||||
|
||||
2026-10-23, run by the implementing agent:
|
||||
|
||||
- `make lint`: 0 Go issues, filesize script clean, ESLint `--max-warnings 0` clean.
|
||||
- `go test -race ./...`: all 43 packages ok.
|
||||
- `go test -race -tags 'integration vmembed' ./internal/alert/... ./internal/reach/... ./internal/app/... ./internal/store/...`: ok.
|
||||
- `./scripts/test-pg.sh` (store suite on Postgres 16, includes migration 12): ok.
|
||||
- `scripts/openapi-check.sh`: compatible with m6 (additive only). `openapi.json` and `web/src/api/schema.d.ts` regenerated.
|
||||
- Web: `npm run lint`, `npm run test` (140 tests) and `npm run build` pass. `E2E_PORT=4791 npm run e2e`: 149 passed, 6 failed. The 6 failures are device-page specs (`device-tabs`, `devices-mgmt` edit, `profiles-nav` pin, `screenshots-v101` overview, `screenshots` 32-bit warning, `ui` warning explanation) that look for the `summary-tile`, `device-header`, `generic-hint` and `device-warnings` test ids, which no longer exist in `web/src` since commit 992f09b (Overview tab). They are not caused by this change and were left alone. The new `alert-policies.spec.ts` (4 tests) and the updated `alert-down.spec.ts` pass. Screenshot dirs were reverted.
|
||||
- `make gate-m4`: the whole backend run passes (45 PASS, including device_down, suppression, restart, ack and maintenance with the two gate-created policies). It then fails in its UI steps for the same stale test ids as above (mock UI specs, plus the live `device-down` screenshot waiting for `device-header`). The first run of the gate caught a missing `Wants:` wiring in `internal/app/alerting.go` (no device_down was recorded); fixed.
|
||||
- `gate-m5.sh` and `gate-m6.sh` now create a `system` policy before the alarms they expect; only `bash -n` was run on them (not the full ~10 min gates).
|
||||
- The 2,000-device load test and `make gate-m5`/`gate-m6` were not run.
|
||||
202
docs/milestones/v1.2-custom-alert-rules.md
Normal file
@@ -0,0 +1,202 @@
|
||||
# v1.2: built-in rule macros and custom alert rules
|
||||
|
||||
Owner request (2026-10-23), following v1.1 (alert policies): the shipped rules are too constraining. Keep them as
|
||||
well-known **built-in macros**, and let admins create their own rules from the data nmon already holds. The owner's
|
||||
decisions:
|
||||
|
||||
- Custom rules can test **metrics** (anything in the hot store) **and inventory fields** (device and interface rows).
|
||||
- **Builder only.** Admins compose rules from whitelisted fields, never by writing MetricsQL or SQL. The generated query
|
||||
is shown read-only.
|
||||
|
||||
## Rules after this change
|
||||
|
||||
| Kind | Where defined | Editable | Notes |
|
||||
|---|---|---|---|
|
||||
| Built-in macro | `alert.DefaultRules()` | no; "Copy to custom" clones one into an editable builder rule where the builder can express it, otherwise refuses with a reason | Names are unchanged (`interface_errors`, `disk_full`, …), so v1.1 policies, alert history and series keys stay valid. The UI labels them "Built-in". |
|
||||
| Config rule | `config.yaml` `alerting.rules` | file only | It still works but is deprecated, and the startup log says so once. A config rule cannot take a custom rule's name. |
|
||||
| Custom metric rule | DB `alert_rules`, `kind: metric` | API/UI, live | A builder spec is compiled to MetricsQL. |
|
||||
| Custom inventory rule | DB `alert_rules`, `kind: inventory` | API/UI, live | A builder spec is evaluated in Go over store rows. |
|
||||
|
||||
Every rule, of whatever kind, is alerted only through policies (v1.1), and uses the same state machine (pending/`for`,
|
||||
firing, resolved, stale, flapping), suppression, maintenance and notifications. A rule name is unique across all kinds,
|
||||
and names reserved by reach and system alarms stay refused. Deleting a custom rule that a policy references is a 409
|
||||
naming those policies. Disabling a rule retires its open rows silently.
|
||||
|
||||
## Builder: metric rules (`kind: metric`)
|
||||
|
||||
| Field | Values |
|
||||
|---|---|
|
||||
| `metric` | from the catalogue: every metric of every loaded profile (name, label, unit, type, category, `of`, enum), the interface metrics (`ifHC*Octets`, `if*Errors`, `if*Discards`, `ifOperStatus`, `ifAdminStatus`, and whatever else the generic profile polls), and push-ingested metric names read from the hot store's `__name__` values (marked `source: push`) |
|
||||
| `transform` | `value`, `rate` (counter types only; per second), `percent_of` (only when the metric has `of`), `utilisation` (interface octet counters only: 100 × 8 × rate / speed). Offer `utilisation` only if the port speed is available in the hot store as a series; if it isn't, leave it out and record why in the gate log. Don't invent a join. |
|
||||
| `window` | duration, default 5m, minimum 2× the poll interval |
|
||||
| `func` | `last`, `avg`, `max`, `min` over the window |
|
||||
| `aggregate` | component-level metrics only: `each` (default), or `max`/`min`/`avg`/`sum` per device |
|
||||
| `op` | `>`, `>=`, `<`, `<=`, `==`, `!=` |
|
||||
| `threshold` | number; for enum/status metrics the UI offers the enum names and stores the number |
|
||||
| `for`, `severity`, `summary`, `description` | as on today's rules; the templates are validated and a default summary is generated |
|
||||
|
||||
The level (device, interface or component) follows from the metric: `iface` label → interface, index label → component,
|
||||
scalar → device. It decides which policy matchers apply, exactly as in v1.1. The MetricsQL is generated from fixed
|
||||
templates, and every name and value is checked against the catalogue or a numeric parse, so nothing a user types reaches
|
||||
the query text unescaped. Label values are not user input in the builder.
|
||||
|
||||
The evaluator's comparison gains `>=`, `<=`, `==` and `!=` (`Rule.breach`; validation accepts all six).
|
||||
|
||||
## Builder: inventory rules (`kind: inventory`)
|
||||
|
||||
Evaluated every cycle from rows the store already returns (devices and interfaces, which are not retired). Add a store
|
||||
method only if no existing one fits; still no SQL outside `internal/store` and no dynamic SQL anywhere. Field whitelist:
|
||||
|
||||
- **Device:** `last_seen_age` (duration), `enabled`, `profile`, `profile_pinned`, `site`, `role`, `kind`, `sys_descr`,
|
||||
`hw_serial`, `mgmt_ip`, `parent_count`, `snmp_failing` (if it is cheaply derivable, otherwise leave it out).
|
||||
- **Interface:** `if_speed_mbps`, `admin_status`, `oper_status`, `if_name`, `if_alias`, `description`, `unreliable`,
|
||||
`has_hc_counters`, `excluded`, `counter_discontinuity`, `last_discovered_age`.
|
||||
|
||||
Ops by type:
|
||||
|
||||
| Type | Ops |
|
||||
|---|---|
|
||||
| number | `> >= < <= == !=` |
|
||||
| bool | `is true`, `is false` |
|
||||
| string | `equals`, `not equals`, `matches` (RE2, compiled at save), `empty`, `not empty` |
|
||||
| duration | `older than`, `newer than` |
|
||||
|
||||
A rule has one to five conditions, ANDed, all on device fields or all on interface fields; that decides the level. The
|
||||
reported value is 1 when the conditions hold, and an entity whose conditions don't hold is "below". Examples:
|
||||
|
||||
- "Uplink without description": interface, `if_alias empty` AND `if_speed_mbps >= 10000`.
|
||||
- "Device not seen for 1 h": device, `last_seen_age older than 1h`.
|
||||
|
||||
## Architecture
|
||||
|
||||
- New pure package `internal/alertrule`:
|
||||
- spec types and `Validate`;
|
||||
- `CompileMetric(spec, catalogue) (expr, level, error)`;
|
||||
- `EvalInventory(spec, row) bool` over a plain row struct;
|
||||
- the field whitelist and the catalogue types.
|
||||
- Unit tests carry a table of specs → expected MetricsQL, and of rows → verdicts.
|
||||
- `internal/alert`:
|
||||
- The evaluator reads its rule set from a `Rules func() []Rule` (or a `SetRules`) so DB edits apply on the next cycle
|
||||
without a restart.
|
||||
- A rule gets a `Source`: `metric` (today's querier path) or `inventory`, an interface the app supplies that returns
|
||||
readings for a rule at `now`.
|
||||
- The state machine is shared and unchanged.
|
||||
- `internal/store`:
|
||||
- migration `20261023000013_alert_rules.go` (frozen struct; spec as JSON text);
|
||||
- `AlertRules` sub-interface in a new file, plus one embed line;
|
||||
- the table is added to `backupTables`.
|
||||
- `internal/app`:
|
||||
- the rule registry: built-ins, then config, then DB;
|
||||
- a 30 s cache invalidated on write;
|
||||
- the metric catalogue from the profile set plus the hot store's metric names (cached);
|
||||
- the inventory source, built on TTL-cached store reads.
|
||||
- `internal/api`, all additive. Admin unless noted; regenerate `openapi.json` and the web schema and run
|
||||
`scripts/openapi-check.sh`.
|
||||
- `GET /api/v1/alert-rules` keeps its fields and adds `id`, `kind` (`builtin`, `config`, `metric`, `inventory`),
|
||||
`editable`, `spec` and `expr`.
|
||||
- `GET /api/v1/alert-rules/{id}`, plus `POST`, `PUT` and `DELETE` for custom rules only (a built-in is 409).
|
||||
- `GET /api/v1/alert-rules/catalogue` (viewer) returns the metrics and the inventory fields with their types and ops.
|
||||
- `POST /api/v1/alert-rules/test` (viewer; declare it read-only in the auth policy) compiles a draft and runs it once.
|
||||
It returns the generated expr, the series or entity count, the number breaching now, and up to 20 samples
|
||||
(device, iface, component, value).
|
||||
- `POST /api/v1/alert-rules/{id}/copy` clones a built-in into a custom rule, or returns 422 with the reason when the
|
||||
builder can't express it.
|
||||
- `web/`:
|
||||
- An **Alert rules** tab next to Alert policies. The list shows the kind badge, level, condition text, policy count and
|
||||
enabled state.
|
||||
- A builder dialog:
|
||||
- pick metric or inventory;
|
||||
- searchable catalogue picker grouped by category;
|
||||
- dependent fields (transform shown only where valid, enum picker for status metrics);
|
||||
- generated query shown read-only;
|
||||
- a **Test** button showing "N breaching now" with samples;
|
||||
- **Copy to custom** on built-ins.
|
||||
- The policy dialog's rule dropdown lists custom rules too.
|
||||
- Mock handlers and an e2e spec. Keep web files ≤ 400 lines; split the builder.
|
||||
|
||||
## Docs
|
||||
|
||||
- DEVIATIONS: a row noting that rules are now also defined in the database, with a builder only and inventory
|
||||
conditions (owner decision 2026-10-23), and that config rules are deprecated.
|
||||
- `docs/operations.md`: extend the alert section with the two inventory examples above and a metric example ("CPU over
|
||||
80 % on role=server for 15 min"), and with how a custom rule and a policy combine.
|
||||
|
||||
## Gate
|
||||
|
||||
- `make lint` is clean. All of these pass:
|
||||
- `go test -race ./...`
|
||||
- `go test -race -tags 'integration vmembed'` for `internal/alert`, `internal/app` and `internal/store`, where every
|
||||
builder template and every built-in is run against a real VictoriaMetrics.
|
||||
- `./scripts/test-pg.sh` and `scripts/openapi-check.sh`
|
||||
- web lint, test and build, plus the new and changed e2e specs only. A full Playwright run isn't needed; revert any
|
||||
screenshots it rewrites.
|
||||
- Tests prove the following:
|
||||
- A custom metric rule, bound by a policy, fires and resolves.
|
||||
- A custom inventory rule fires for a matching port and not for others.
|
||||
- A rule edit applies on the next cycle without a restart.
|
||||
- Deleting a referenced rule is a 409.
|
||||
- Built-ins cannot be edited.
|
||||
- A builder spec with an unknown metric or field is a 422.
|
||||
- Hostile input in a string field (quotes, braces, `"} or vector(1)`) never changes the generated query's structure.
|
||||
|
||||
## Gate log
|
||||
|
||||
2026-10-24, run by the implementing agent:
|
||||
|
||||
- Decisions where this spec was silent:
|
||||
- **`utilisation` is not offered.** No profile polls `ifHighSpeed` or `ifSpeed` as a series (checked with a grep of
|
||||
`profiles/*.yaml`); port speed lives only in the inventory table. Joining the two would be inventing a join, so the
|
||||
transforms are `value`, `rate` and `percent_of`. A test (`TestCatalogueHasProfileAndPushMetrics`) fails if
|
||||
`ifHighSpeed` ever becomes a series, as a prompt to revisit this.
|
||||
- **Counters allow `rate` only** (a raw counter is never alertable), and `rate` always averages over the window
|
||||
(`func` must be `avg` or empty). The default window is the larger of 5 m and twice the metric's own poll interval
|
||||
(so a 5 m SNMP metric defaults to 10 m); an explicit window below twice the interval, below 1 m or above 24 h is a
|
||||
422. A `rate` of a `counter32` interface metric sets `SkipUnreliable`.
|
||||
- **Inventory fields kept:** `last_seen_age`, `profile`, `profile_pinned`, `site`, `role`, `kind`, `sys_descr`,
|
||||
`hw_serial`, `mgmt_ip`, `parent_count`, `snmp_failing` (true while the poller reports `degraded` or `backoff`),
|
||||
`if_speed_mbps` (ifHighSpeed, else ifSpeed / 10^6), `admin_status`, `oper_status` (numbers, with enum names for
|
||||
the UI), `if_name`, `if_alias`, `description`, `unreliable`, `has_hc_counters`, `excluded`, `counter_discontinuity`,
|
||||
`last_discovered_age`. **Dropped: `enabled`.** Disabled, retired and purged devices are never evaluated (their
|
||||
alerts close as retired through `Active`), so `enabled is false` could never match. A device never seen has its age
|
||||
counted from `created_at`. Excluded ports are evaluated (so `excluded` is a usable condition). A new store method,
|
||||
`ListAllInterfaces` (one query, sub-interface `InterfaceInventory`), avoids one query per device.
|
||||
- **Built-ins that can be copied to custom:** `cpu_high` (compiles to the identical expression),
|
||||
`ups_on_battery` (`upsOutputSource == 5`) and `printer_supply_empty` (`prtMarkerSuppliesLevel == 0`). The other
|
||||
seven (and the reachability and system rules) join, filter or union series, so `POST /alert-rules/{id}/copy` answers
|
||||
422 with the reason. The copy is created at once as `<name>_custom` (or the name in the body), keeping the
|
||||
built-in's severity, hold time and texts.
|
||||
- Rules are classed `builtin` when the name and expression match the shipped rule (a config patch of thresholds
|
||||
keeps it `builtin`), and `config` otherwise. A custom rule that cannot compile at runtime (a pushed metric briefly
|
||||
missing from the hot store) keeps its last good form instead of retiring its alerts. The rule list grows `policy_count`
|
||||
and `copyable`; deleting a rule, renaming one, or changing its level while a policy names it is a 409. Editing
|
||||
`GET /alert-rules/{id}` is viewer-level like the list; `POST /alert-rules/test` is the only read-only POST.
|
||||
- Type names in the OpenAPI schema are `RuleSpec`, `InventoryField` and `InventoryCondition` (not the bare words).
|
||||
- `make lint`: 0 Go issues, filesize script clean, ESLint `--max-warnings 0` clean (see the final run below).
|
||||
- `go test -race ./...`: all 44 packages ok (the new `internal/alertrule` included). An earlier run found a real
|
||||
deadlock in the first version of rule update (a second store call inside a SQLite transaction); fixed by reading
|
||||
the old level before the transaction, covered by `TestDeletingOrReshapingAReferencedRuleIs409`.
|
||||
- `go test -race -tags 'integration vmembed' ./internal/alert/... ./internal/app/... ./internal/store/...`: ok. The new
|
||||
`TestIntegrationBuilderTemplatesAgainstRealStore` compiles every allowed transform x function x aggregate for
|
||||
gauge, status, counter32, counter64, percent_of and push metrics and runs each query on a real VictoriaMetrics; the
|
||||
existing test still runs every built-in there.
|
||||
- `./scripts/test-pg.sh` (store suite on Postgres 16, includes migration 13 and the rule and bulk-interface queries): ok.
|
||||
- `scripts/openapi-check.sh`: compatible with m6 (additive; the `op` enum of `AlertRuleInfo` only gained values).
|
||||
`openapi.json` and `web/src/api/schema.d.ts` regenerated.
|
||||
- Web: `npm run lint`, `npm run test` (146 tests) and `npm run build` pass. Playwright, only the added or changed specs
|
||||
(`E2E_PORT=4795`): `alert-rules.spec.ts` (7 tests) and `alert-policies.spec.ts` (4, its mock now shares the rule store)
|
||||
pass. The full Playwright suite was not run; `docs/screenshots` was reverted and no preview server was left running.
|
||||
- The tests that prove the gate items:
|
||||
- Custom metric rule bound by a policy fires and resolves, and a rule edit (new threshold) applies on the next
|
||||
cycle with no restart, and disabling retires silently:
|
||||
`internal/app` `TestCustomMetricRuleBoundByAPolicyFiresResolvesAndEditsApplyLive`; the evaluator side,
|
||||
`internal/alert` `TestRuleSetChangesApplyOnTheNextCycle`.
|
||||
- Inventory rule fires for the matching port only: `internal/app` `TestInventoryRuleFiresForTheMatchingPortOnly`.
|
||||
- Deleting a referenced rule is 409 (naming the policies): `internal/api` `TestDeletingOrReshapingAReferencedRuleIs409`.
|
||||
- Built-ins cannot be edited or deleted, and their names cannot be taken: `TestBuiltinRulesCannotBeEditedOrDeleted`,
|
||||
`TestCustomRuleNamesCannotShadowBuiltinsOrConfig`.
|
||||
- An unknown metric or field is a 422: `TestBuilderSpecWithUnknownMetricOrFieldIs422`, `alertrule` `TestCompileMetricRefusals`,
|
||||
`TestInventoryRefusals`.
|
||||
- Hostile input never changes the generated query: `alertrule` `TestHostileTextNeverReachesTheQuery` (quotes, braces,
|
||||
`"} or vector(1)`, backticks and template braces in name, summary and description leave the expression identical or
|
||||
are refused; a pushed metric name that is not a plain identifier never enters the catalogue); the `window`
|
||||
is parsed and re-printed, and `TestCompileMetricTemplates` pins the exact MetricsQL of every template.
|
||||
229
docs/milestones/v1.3-bgp-and-l2-tables.md
Normal file
@@ -0,0 +1,229 @@
|
||||
# v1.3: BGP peers and MAC/ARP/FDB lookup
|
||||
|
||||
Owner request (2026-10-24), after the LibreNMS comparison: **MAC/ARP/FDB lookup is a must, and BGP peer monitoring is
|
||||
wanted.** WISP radio profiles are nice-to-have and not in this milestone.
|
||||
|
||||
Parts of this go beyond the spec. The spec lists BGP-4-MIB in the collection table but no profile fills it, and it
|
||||
names no MAC/ARP lookup feature. Add one DEVIATIONS row per feature.
|
||||
|
||||
## Part A: MAC / ARP / FDB ("where is this host plugged in?")
|
||||
|
||||
### Collection
|
||||
A new **L2 table job**, separate from the 60 s poll path (the spec forbids table walks on the hot path) and from the
|
||||
twice-daily discovery:
|
||||
|
||||
- It walks every enabled SNMP device every `l2.interval` (default 15 min, minimum 5 min, in `config.yaml`; applied on
|
||||
SIGHUP like the other job settings, or `restart_required` if that is simpler, and say which).
|
||||
- It has its own concurrency cap and a per-device deadline, and yields to the poller under the same admission policy as
|
||||
rollup.
|
||||
- A device that times out is skipped for that cycle, with a counter and a poller-health note; it never blocks the job.
|
||||
|
||||
Tables, all numeric OIDs verified against `mibs/`, each with a provenance comment:
|
||||
|
||||
| What | Source | Notes |
|
||||
|---|---|---|
|
||||
| Bridge port → ifIndex | BRIDGE-MIB `dot1dBasePortIfIndex` | needed for every FDB row |
|
||||
| FDB, VLAN-aware | Q-BRIDGE-MIB `dot1qTpFdbPort` (index: fdbId.mac) | Map fdbId to the VLAN with `dot1qVlanFdbId` where present; otherwise fdbId = VLAN id. |
|
||||
| FDB, fallback | BRIDGE-MIB `dot1dTpFdbPort` | used when Q-BRIDGE returns nothing; the VLAN is unknown (0) |
|
||||
| Cisco per-VLAN FDB | BRIDGE-MIB per VLAN | IOS only: list VLANs from CISCO-VTP-MIB `vtpVlanState`, then walk with community `<community>@<vlan>` (v2c) or context `vlan-<vlan>` (v3). Turn it on with a profile flag (`l2.per_vlan_context: true` on cisco-ios), not by detection. |
|
||||
| VLAN names | Q-BRIDGE-MIB `dot1qVlanStaticName`, CISCO-VTP-MIB `vtpVlanName` | shown in the UI |
|
||||
| ARP and IPv6 ND | IP-MIB `ipNetToPhysicalPhysAddress` (both IPv4 and IPv6, IPv6 first) | This is the primary source. Fall back to `ipNetToMediaPhysAddress` (IPv4 only) when it is empty. Skip entries of type invalid or incomplete. |
|
||||
|
||||
Recordings to verify against: `/home/svcuser/src/librenms/tests/snmpsim` (pick ones with these tables for cisco-ios,
|
||||
junos, arista, edgeswitch, unifi-switch, mikrotik and a Linux bridge). Read them as reference data only; copy no code or
|
||||
YAML from that GPL repository. Add `.snmprec` fixtures under `testdata/snmprec/` produced by our own generator (extend
|
||||
`internal/fixturegen`), including one with Q-BRIDGE, one with only BRIDGE-MIB, one Cisco per-VLAN, and an ARP table with
|
||||
IPv6 and IPv4 entries.
|
||||
|
||||
### Storage (inventory DB, not the metric stores)
|
||||
Use migration `20261024000014_l2_tables.go`. Number 13 is taken by the concurrent v1.2 work; take 14 and nothing else.
|
||||
|
||||
- `fdb_entries`: device_id, interface_id (ULID, nullable when the bridge port has no ifIndex), vlan, mac (normalised
|
||||
lower-case `aa:bb:cc:dd:ee:ff`), first_seen, last_seen.
|
||||
- Unique on (device_id, interface_id, vlan, mac).
|
||||
- A row seen again only bumps last_seen.
|
||||
- Use batched upserts: a 48-port switch can carry thousands of MACs.
|
||||
- `arp_entries`: device_id, interface_id (nullable), ip (canonical text, v6 compressed), mac, first_seen, last_seen.
|
||||
Unique on (device_id, ip, mac).
|
||||
- `vlans`: device_id, vlan, name. Replaced per walk.
|
||||
- Retention is `l2.keep` (default 90 d) on last_seen, pruned daily. This history is what lets you ask "where was this MAC
|
||||
last week".
|
||||
- Add the new tables to `backupTables`, or mark them deliberately transient with a reason. Back them up.
|
||||
- Only `internal/store` touches SQL. Add a store sub-interface in a new file plus one embed line in `store.go`. Every
|
||||
query must be indexed: lookups are by mac, by ip, by interface and by device.
|
||||
|
||||
### Trunk awareness
|
||||
A MAC is seen on every switch it passes through. For each FDB row, compute at query time the **MAC count of its port**
|
||||
(per walk), and mark a port an **uplink** when it has more than `l2.edge_max_macs` MACs (default 8), when it is an LLDP
|
||||
neighbour link, or when it is a LAG/parent. The lookup ranks **edge ports first**: the port with the fewest MACs, then
|
||||
the most recent last_seen. That is where the host is plugged in.
|
||||
|
||||
### API (additive)
|
||||
- `GET /api/v1/l2/search?q=` (viewer):
|
||||
- `q` is a MAC in any common format (`aabb.ccdd.eeff`, `aa-bb-…`, `aabbccddeeff`, partial prefixes of at least 6 hex
|
||||
digits), an IPv6 or IPv4 address, or a CIDR.
|
||||
- It returns grouped hits:
|
||||
`{ mac, ips: [{ip, device, last_seen}], locations: [{device, iface, vlan, vlan_name, edge: bool, port_mac_count, first_seen, last_seen}] }`,
|
||||
edge locations first, paged like the other routes.
|
||||
- `GET /api/v1/devices/{slug}/fdb` and `GET /api/v1/devices/{slug}/arp` (viewer): the paged tables, filterable by VLAN,
|
||||
interface and `current` (seen in the last walk) or with history.
|
||||
- `GET /api/v1/interfaces/{id}/macs` (viewer): the MACs on a port, current and history, each with its IPs from any ARP
|
||||
table.
|
||||
|
||||
### UI
|
||||
- A global search box (existing header search, or a new **Find host** page): paste a MAC or IP and get the answer
|
||||
"device X port Y, VLAN Z, last seen 3 min ago", with the full path (uplinks) collapsed below it.
|
||||
- On the device page, a new **L2** tab (FDB with a VLAN filter, ARP, VLAN list), shown only when the device has rows.
|
||||
- On the port, a MAC count on the Ports tab row with a link to that port's MACs.
|
||||
|
||||
### OUI vendor lookup (optional; nmon must work fully without it)
|
||||
Owner request (2026-10-24): show the vendor of each MAC, from the IEEE registry where it can be fetched. It is nice to
|
||||
have and never required.
|
||||
|
||||
- **Source:** the IEEE public registries MA-L (`https://standards-oui.ieee.org/oui/oui.csv`), MA-M (`…/oui28/mam.csv`) and
|
||||
MA-S (`…/oui36/oui36.csv`). Check the current URLs and CSV columns before coding against them.
|
||||
- **Fetch:**
|
||||
- a background job at start (if no database is present) and then every `oui.refresh` (default 7 d);
|
||||
- through `internal/netpolicy`, so the outbound allow-list applies;
|
||||
- with timeouts, a size cap, and a parse sanity check (a minimum row count) before replacing the current file.
|
||||
- `oui.fetch: false` turns fetching off.
|
||||
- **Offline:** `nmon oui import <dir-or-file>` loads the same CSVs from disk. The Ansible role can optionally download
|
||||
and ship them next to the MIB sidecar.
|
||||
- **Storage:** a compact file in the data dir (e.g. `oui/registry.bin`, or a gzipped normalised CSV), written atomically
|
||||
with a checksum, and included in `nmon backup`. It is not in the inventory DB, since it is reference data. Loaded into
|
||||
memory as three prefix maps (24-, 28- and 36-bit); a lookup takes the longest match.
|
||||
- **Missing or stale data never fails anything.** With no file, vendor is empty and a `oui_unavailable` note appears on
|
||||
poller health. There is no alert.
|
||||
- **Vendor flag:**
|
||||
- computed at query time and never stored per FDB or ARP row, so a refresh relabels history;
|
||||
- API hits and table rows gain `vendor` (string, empty when unknown) and `mac_kind`: `global`, `local` (the
|
||||
locally-administered bit, typical of randomised phone and VM MACs, with vendor shown as "Private / randomised"),
|
||||
or `multicast`.
|
||||
- **Search:** `q` may also be a vendor name substring (`vendor:cisco`). It returns the current MACs of that vendor,
|
||||
paged, so "find every Ubiquiti device on VLAN 20" works.
|
||||
- **UI:** a vendor column on the L2 tab, the port MAC list and the search results, plus a small "private" badge for
|
||||
`local`.
|
||||
- **Licensing:** the IEEE registry is public data. Note its source in `docs/operations.md`, and do not commit the CSVs
|
||||
to the repo. Tests use a tiny generated fixture registry.
|
||||
- **Tests:**
|
||||
- the longest prefix wins (an MA-S entry beats MA-L);
|
||||
- the local and multicast bits are detected;
|
||||
- a truncated or garbage download keeps the previous file;
|
||||
- no network and no file still lets the L2 job and search work, with an empty vendor.
|
||||
|
||||
## Part B: BGP peers
|
||||
|
||||
### Collection (profiles, 5 min interval; metrics in the `routing` category)
|
||||
The component label is the peer address (canonical, IPv6 compressed). Peer AS, local AS, description/remote router ID
|
||||
and AFI/SAFI are **component attributes** (migration 11's mechanism) collected at discovery, never metric labels. The
|
||||
SNMP label contract stays `device` + `component` only. Where a vendor reports prefixes per address family, the
|
||||
component is `<peer>|<afi>` for the prefix metrics only; document it.
|
||||
|
||||
| Source | Gives | Profiles |
|
||||
|---|---|---|
|
||||
| BGP4-MIB `bgpPeerState`, `bgpPeerFsmEstablishedTime`, `bgpPeerInUpdates`/`OutUpdates`, `bgpPeerRemoteAs` | IPv4 peers only | generic (optional, enabled on router profiles) |
|
||||
| CISCO-BGP4-MIB `cbgpPeer2State`, `cbgpPeer2FsmEstablishedTime`, `cbgpPeer2AcceptedPrefixes` (per AFI/SAFI), `cbgpPeer2RemoteAs` | IPv4 and IPv6 | cisco-ios, cisco-nxos |
|
||||
| BGP4-V2-MIB-JUNIPER `jnxBgpM2PeerState`, `jnxBgpM2PeerFsmEstablishedTime`, `jnxBgpM2PrefixInPrefixesAccepted` | IPv4 and IPv6 | juniper-junos |
|
||||
| ARISTA-BGP4V2-MIB `aristaBgp4V2PeerState`, `…PrefixInPrefixesAccepted` | IPv4 and IPv6 | arista-eos |
|
||||
|
||||
- `state` is a `status` metric with enum `idle connect active opensent openconfirm established`;
|
||||
`enum_state: established ok, others crit`.
|
||||
- Use the vendor table where one exists, and drop the BGP4-MIB duplicate on that profile, so each peer appears once.
|
||||
- VyOS/FRR, MikroTik, FortiGate and EdgeOS: check `mibs/` and the recordings (`cumulus_bgp`, `dnos_bgp`, vyos and
|
||||
mikrotik ones). Add BGP only where the device actually answers. State per profile in `docs/coverage.md` what is
|
||||
collected, and where nothing is, say so plainly.
|
||||
- Verify against the LibreNMS recordings (`arista_eos*.snmprec`, `cumulus_bgp`, `dnos_bgp`, ios and junos ones) and add
|
||||
our own generated fixtures with IPv6 peers.
|
||||
|
||||
### UI
|
||||
- Device **Routing** tab:
|
||||
- a peers table with peer, AS, description, state badge, uptime, prefixes accepted per AFI, and update rate;
|
||||
- a click-to-graph on the prefixes and updates;
|
||||
- built from the existing metrics-tab renderers where they fit.
|
||||
- A **BGP** page in the main nav listing every peer on every device, with filters for state, AS and device, so you can
|
||||
see which sessions are down right now.
|
||||
- Back it with `GET /api/v1/bgp/peers` (viewer, paged; latest values from hot, with attributes from the inventory).
|
||||
|
||||
### Alerts
|
||||
- New built-in rule `bgp_peer_down`:
|
||||
- component-level;
|
||||
- condition: state is not established for `for: 2m`;
|
||||
- severity critical;
|
||||
- its summary names the peer, AS and description.
|
||||
- Like every rule, it fires only where a policy binds it (v1.1). Add a template "Routers: BGP peer down" to the policy
|
||||
templates.
|
||||
- A prefix-drop alert is left to the custom rule builder (v1.2). Add it to the operations doc as an example once v1.2 is
|
||||
in.
|
||||
|
||||
## Constraints
|
||||
- **v1.2 is being built concurrently.** It owns `internal/alert` rules plumbing, `internal/alertrule`, migration 13, and
|
||||
the alert-rules API and UI.
|
||||
- Do not edit those files, except to append `bgp_peer_down` to `DefaultRules()` in `internal/alert/rules.go` plus its
|
||||
integration test row, and to add the policy template constant in `web/src/lib/policyModel.ts`.
|
||||
- Rebase onto whatever v1.2 commits first, if it lands before you.
|
||||
- Regenerate `openapi.json` and `web/src/api/schema.d.ts` just before committing.
|
||||
- Every port is still polled the same way; this adds a job and does not change the poller.
|
||||
- IPv6 first: ND and IPv6 BGP peers are first-class, and tests default to `::1`.
|
||||
|
||||
## Gate
|
||||
- `make lint` clean. All of these pass:
|
||||
- `go test -race ./...`
|
||||
- `go test -race -tags 'integration vmembed'` for the touched packages
|
||||
- `./scripts/test-pg.sh` and `scripts/openapi-check.sh`
|
||||
- `NMON_LIBRENMS_DIR=/home/svcuser/src/librenms go test ./internal/profile/... ./internal/fixtures/...`
|
||||
- `go run ./testdata/gen` with no drift
|
||||
- web lint, test and build, plus only the new and changed Playwright specs
|
||||
- Tests prove the following:
|
||||
- MAC normalisation accepts every format above and refuses garbage.
|
||||
- A MAC seen on an access port and on two uplinks resolves to the access port first.
|
||||
- The Cisco per-VLAN context walk is used only when the profile flag is set.
|
||||
- IPv6 ND entries are stored and searchable.
|
||||
- Retention prunes by last_seen.
|
||||
- An IPv6 BGP peer from a vendor table shows once (no BGP4-MIB duplicate).
|
||||
- `bgp_peer_down` fires through a policy and resolves on established.
|
||||
- The L2 job never runs on the poll path, and a slow device does not delay the others.
|
||||
|
||||
## Gate log
|
||||
|
||||
Built on branch `worktree-agent-a86e97dea1f9eae40` (from main at `06a3c85`), 2026-10-24. Run by the implementing agent; the
|
||||
supervisor reruns the gates after merging with v1.2.
|
||||
|
||||
### Verification
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| `make lint` (golangci-lint v2.14.0, `scripts/lint-filesize.sh`, ESLint `--max-warnings 0`) | 0 issues |
|
||||
| `go test -race ./...` | all packages pass |
|
||||
| `go test -race -tags 'integration vmembed'` on alert, api, app, backup, l2, discovery, fixtures, profile, vmsup | pass (the alert integration test runs `bgp_peer_down` against a real VictoriaMetrics: fires for the active IPv6 peer, not for the established one) |
|
||||
| `./scripts/test-pg.sh` (Postgres 16, store suite incl. the L2 queries, upserts and backup/restore table list) | pass |
|
||||
| `scripts/openapi-check.sh` | compatible with m6 (additive only) |
|
||||
| `NMON_LIBRENMS_DIR=/home/svcuser/src/librenms go test ./internal/profile/... ./internal/fixtures/... ./internal/l2` | pass: real recordings produce each BGP peer exactly once (IOS XE and XR with IPv6 peers, Junos MX, Arista, EdgeOS, FortiGate, FRR on pfSense), no type mismatches; the L2 walker reads FDB and ARP/ND (IPv6 from `arista_eos_7280r` and `dell-os10`) from the Arista, Junos, IOS, UniFi/EdgeSwitch and RouterOS recordings |
|
||||
| `go run ./testdata/gen` | no drift (`TestCommittedFixturesAreCurrent` passes); 9 expect files gained only BGP series, PDU budgets unchanged |
|
||||
| `cd web && npm run lint && npm run test && npm run build` | clean; 165 tests pass |
|
||||
| Playwright, only the new `e2e/specs/l2-bgp.spec.ts` (12 tests), plus `device-tabs` and `profiles-nav` as a regression check of the pages this touches | 27 passed; `docs/screenshots` reverted; no preview server left running |
|
||||
|
||||
Not run: the `make gate-m*` scripts, the Ansible tasks (no Ansible here; the YAML parses), and the full Playwright suite.
|
||||
|
||||
### What proves each gate line
|
||||
|
||||
| Line | Test |
|
||||
|---|---|
|
||||
| MAC normalisation accepts every format and refuses garbage | `internal/hostaddr` `TestParseMACAcceptsEveryCommonFormat`, `TestParseMACRefusesGarbage`, `TestClassifySortsEveryQueryShape`; API `TestSearchAcceptsEveryMACFormatAndPrefixAndRefusesGarbage` |
|
||||
| A MAC on an access port and two uplinks resolves to the access port first | `internal/api` `TestAMACSeenOnAnAccessPortAndTwoUplinksResolvesToTheAccessPortFirst` (uplinks by MAC count and by LLDP), `TestEdgeLimitComesFromTheLiveSetting` |
|
||||
| The Cisco per-VLAN walk is used only with the profile flag | `internal/l2` `TestCiscoPerVLANContextIsUsedOnlyWhenTheProfileFlagIsSet` (dial log: no `@vlan` community without the flag); `internal/profile` `TestOnlyIOSWalksBridgeTablesPerVLAN` |
|
||||
| IPv6 ND entries are stored and searchable | `internal/l2` `TestIPv6NeighboursAreStoredAndSearchable`; store `TestL2SearchFindsMACsByPrefixIPv6AndCIDR`; API `TestIPv6AndIPv4AndCIDRSearchFindTheMAC` |
|
||||
| Retention prunes by last_seen | store `TestPruneL2DeletesByLastSeen`; l2 `TestRetentionPrunesByLastSeenOnTheDailyPass` |
|
||||
| An IPv6 BGP peer from a vendor table shows once | `internal/profile` `TestVendorBGPTablesReplaceTheBGP4MIBOnesSoAPeerAppearsOnce`; fixtures (every vendor fixture lists `2001:db8::2` once); the real-recording `checkPeers` |
|
||||
| `bgp_peer_down` fires through a policy and resolves on established | `internal/alert` `TestBGPPeerDownFiresThroughAPolicyAndResolvesOnEstablished`, `TestBGPPeerDownFiresNowhereWithoutAPolicy`; integration row |
|
||||
| The L2 job never runs on the poll path; a slow device does not delay the others | `internal/l2` (every dial is path `l2`; a poll session refuses walks, `internal/snmp`), `TestSlowDeviceTimesOutWithoutDelayingTheOthers`, `TestJobYieldsWhileTheWriteQueueIsMoreThanHalfFull` |
|
||||
| OUI: longest prefix wins; local and multicast bits; truncated or garbage download keeps the previous file; no network and no file still works | `internal/oui` `TestLongestPrefixWins`, `TestVendorFlagsLocalAndMulticast`, `TestTruncatedOrGarbageDownloadKeepsThePreviousFile`, `TestMissingRegistryNeverBreaksALookup`; API `TestVendorSearchListsCurrentMACsAndWorksEmptyWithoutARegistry` |
|
||||
|
||||
### Decisions where the spec was silent
|
||||
|
||||
- **L2 settings and reload.** `l2.*` and `oui.*` apply from the next cycle after a reload (SIGHUP or a Settings save) with no restart, and are reported as `applied`. The job re-reads `l2.interval` every minute while waiting, so a shorter interval takes effect within a minute and a longer one on the wait in progress. Test: `TestRunStartsACycleAtOnceThenEveryIntervalAndReadsTheIntervalAgain`.
|
||||
- **Ranking.** Edge before uplink, then rows the last walk still sees before history, then fewest MACs, then most recent (DEVIATIONS 48).
|
||||
- **Per-VLAN fallback.** If the first VLAN context does not answer, the walker falls back to the plain tables instead of waiting out a timeout per VLAN.
|
||||
- **`bgp_peer_down` text.** AS and description are added to the notification, not the stored alert row (DEVIATIONS 46).
|
||||
- **BGP per profile.** See `docs/coverage.md`. Nothing for mikrotik-routeros (no BGP objects in MIKROTIK-MIB or the recordings) and the non-router profiles; cisco-nxos is unverified against a recording; VyOS is covered through BGP4-MIB on the strength of the FRR recording only.
|
||||
- **Open item for v1.2.** The prefix-drop example in `docs/operations.md` is to be added once custom rules land.
|
||||
|
||||
72
docs/milestones/v2-distributed-poller.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# v2: distributed poller (feasibility, deferred)
|
||||
|
||||
Status: deferred to v2 by the owner on 2026-10-25. This is a feasibility assessment, not a plan; nothing is built.
|
||||
|
||||
## Requirements (owner)
|
||||
|
||||
1. A remote poller polls the devices of a site, or of a predefined rule.
|
||||
2. It sends its data to the master only as chunked payloads.
|
||||
3. While the master is down it keeps polling, and delivers when the master is back.
|
||||
4. Each poller has its own private key; the master sends the target device credentials sealed to it.
|
||||
5. It works over an outbound-only link (MQTT pub/sub through a broker). Neither side accepts a connection from
|
||||
the other. Not a data diode yet, but the design should not rule one out.
|
||||
|
||||
## Verdict
|
||||
|
||||
Feasible, about one milestone. The poller is already injectable: `poller.Options` (`internal/poller/poller.go`)
|
||||
takes the store, the metric writer, the reachability observer and the SNMP dialer, so the polling code can be
|
||||
reused unchanged. The work is in what sits around it: where targets come from, where samples go, and how
|
||||
alerting treats data that arrives late.
|
||||
|
||||
## What exists and what is missing
|
||||
|
||||
| # | Exists | Missing |
|
||||
|---|---|---|
|
||||
| 1 | `devices.site`, `store.DeviceFilter.Site`; the alert-policy matcher (`alertpolicy.Device{Slug,Profile,Role,Site,Kind}`) already selects devices by rule | a `pollers` table and assignment rules; reuse the policy matcher rather than a second rule engine |
|
||||
| 2 | `metrics.Sample`; the vmsup queue batches 50k samples per POST | a chunk format (poller id, sequence, signature). Push ingest is **not** reusable: it strips `device`/`iface`, refuses `if*`/`nmon_` names and rejects timestamps more than 1 h from now. Poller data needs its own trusted path into the hot queue |
|
||||
| 3 | the hot queue buffers in memory only (60 s / 2M samples, then drops) | a disk spool on the poller (append-only segments, bounded by size), and a local SQLite snapshot of its targets so it restarts without the master |
|
||||
| 4 | symmetric AES-256-GCM keyring with versions (`internal/secrets`, `store/cred.go` `seal`); rotation in `internal/keyrotate` | a key pair per poller (X25519 + Ed25519): the master seals the credential bundle to the poller's public key (HPKE) and signs it; the poller signs its chunks; rotation follows the keyrotate pattern |
|
||||
| 5 | none | an MQTT transport behind an interface (see below), with outbound HTTPS chunk upload as a second, brokerless option |
|
||||
|
||||
## Transport (requirement 5)
|
||||
|
||||
- Data up: `nmon/poller/<id>/data`, QoS 1. The master holds a persistent session, so the broker keeps chunks
|
||||
while the master is down.
|
||||
- Config and credentials down: a retained, sealed message on `nmon/poller/<id>/config`, read on every connect.
|
||||
- An MQTT client library (e.g. paho.golang) departs from the spec's "standard library elsewhere" and needs a
|
||||
DEVIATIONS row.
|
||||
|
||||
Rules that keep a later data diode possible:
|
||||
|
||||
1. The data path is already one-way. Chunks are self-describing, signed and sequence-numbered, and the master
|
||||
drops duplicates by (poller, seq), so a resend is harmless. The only signal the poller waits for is the
|
||||
broker's PUBACK, which frees spool space. Under a diode, spool release becomes age/size bound plus a periodic
|
||||
resend window; the format does not change.
|
||||
2. One downlink only: the sealed, signed config bundle. Make it a file format that arrives either as the
|
||||
retained message or by hand (`nmon poller import bundle.sealed`). A diode drops the subscription and keeps
|
||||
the import.
|
||||
3. Interactive tools (poll-now, test poll, SNMP debug recording: `PollTools`) need a request and a reply. Over
|
||||
the broker they work through a request topic with seconds of latency. Under a diode they are unavailable,
|
||||
and the UI must say so for remote devices.
|
||||
|
||||
## Risks
|
||||
|
||||
1. **Late data looks like an outage.** `reach.Evaluator` runs on the master and expects live
|
||||
`ObserveICMP`/`ObserveSNMP` (`internal/reach/types.go`); the API marks a device down after three silent
|
||||
intervals. A site link drop would page every device behind it. Fix: model the poller as a dependency parent
|
||||
of its devices (dependency suppression already exists) and replay spooled probes as history, never as live
|
||||
state transitions. Needs a DEVIATIONS row.
|
||||
2. **Inventory writes from the poller.** The poller writes poll state (`devloop.persistState` →
|
||||
`SetPollState`), component rows and discontinuity bases. Remotely these go to the local snapshot and are
|
||||
sent up as events.
|
||||
3. **Discovery must run remotely.** The master usually cannot reach a site's devices. Interface ULIDs are the
|
||||
frozen `iface` label; the poller can mint them (ULIDs are globally unique) and the master accepts them.
|
||||
Traps, L2 tables and checks need the same scope decision up front.
|
||||
4. **Late writes.** VictoriaMetrics accepts old timestamps within retention; dedup by (poller, seq) keeps a
|
||||
resent chunk from being written twice.
|
||||
|
||||
## Codebase constraints
|
||||
|
||||
- A new package (e.g. `internal/remotepoll`) and an `nmon poller` subcommand wired in `internal/app`.
|
||||
- Collection paths still do not import each other; time comes from `internal/clock`.
|
||||
- API routes additive only (`scripts/openapi-check.sh`); new tables through a new migration.
|
||||
@@ -2,14 +2,93 @@
|
||||
|
||||
How to run, secure, back up and maintain a production nmon. Design background: `docs/spec/spec.md` ("Auth", "Operations").
|
||||
|
||||
This page, [Adding a device profile](docs/profiles.md) and [Collection coverage](docs/coverage.md) are also built into the binary and shown in the UI under **Help** (`/help`, any signed-in user, no network needed). The help page always matches the running version; the Settings forms link their section's "?" to the matching heading here.
|
||||
|
||||
## Starting
|
||||
|
||||
nmon serve --data-dir /var/lib/nmon --secret-key-file /etc/nmon/secret.key \
|
||||
--tls-cert /etc/nmon/tls.crt --tls-key /etc/nmon/tls.key
|
||||
|
||||
- **HTTPS is the default.** Pass a certificate pair (`--tls-cert`, `--tls-key`; `SIGHUP` reloads them without dropping connections) or Let's Encrypt (`--acme-host nmon.example.net` repeatable, `--acme-email you@example.net`; nmon answers the http-01 challenge on `--acme-listen`, default `:80`, certificates are cached in `<data-dir>/acme`). Without either, `serve` refuses to start and says so. `--insecure-http` is for development: plain HTTP, cookies without `Secure`.
|
||||
- **A sign-in method is required** over TLS (`auth.oidc` and/or `auth.break_glass_file` in `config.yaml`). With `--insecure-http` and neither configured nmon runs in *dev-open mode* (everyone is admin `dev`; a banner says so); never expose that port.
|
||||
- Behind a reverse proxy, list it in `auth.trusted_proxies`; `X-Forwarded-*` is ignored from anyone else.
|
||||
- **HTTPS is the default.** Pass a certificate pair (`--tls-cert`, `--tls-key`; `SIGHUP` reloads them without dropping connections) or Let's Encrypt (`--acme-host nmon.example.net` repeatable, `--acme-email you@example.net`; nmon answers the http-01 challenge on `--acme-listen`, default `:80`, certificates are cached in `<data-dir>/acme`). With neither (and no `--http-listen`) nmon generates a self-signed certificate, see [The self-signed certificate](#the-self-signed-certificate). `--insecure-http` is for development: plain HTTP, cookies without `Secure`.
|
||||
- **A sign-in method is required** over TLS (`auth.oidc` and/or `auth.break_glass_file` in `config.yaml`). On a first start with none configured nmon opens the [setup wizard](#installing-nmon-first-start) instead of refusing. With `--insecure-http` and neither configured nmon runs in *dev-open mode* (everyone is admin `dev`; a banner says so); never expose that port.
|
||||
- Behind a reverse proxy, see [Behind a reverse proxy](#behind-a-reverse-proxy): `--http-listen ADDR` adds an opt-in plain-HTTP listener, and the proxy goes in `auth.trusted_proxies`; `X-Forwarded-*` is ignored from anyone else.
|
||||
|
||||
## Installing nmon / first start
|
||||
|
||||
On a fresh install nmon needs no `config.yaml`: start it, open `https://<host>:8443/` and the browser lands on `/setup`, a five-step wizard (sign-in, basics, first devices, alerting, finish). Steps 2 to 4 can be skipped and done later in Settings.
|
||||
|
||||
nmon serve --data-dir /var/lib/nmon --secret-key-file /etc/nmon/secret.key
|
||||
|
||||
### Install mode
|
||||
|
||||
Install mode is on only while **no sign-in method is configured** (no `auth.oidc`, no `auth.break_glass_file`) **and setup has never been completed**. While it is on:
|
||||
|
||||
- **Anyone who can reach nmon can run setup**, as an admin. There is no setup token and no localhost restriction: nmon is meant to sit in a DMZ or an internal network (`docs/DEVIATIONS.md` row 51). Finish setup right after starting, or keep the port firewalled until you have.
|
||||
- nmon logs a `WARN` at start and every 10 minutes: `INSTALL MODE: setup is open to anyone who can reach this address`.
|
||||
- Only the wizard works. The UI, `GET /api/v1/setup`, the settings form, webhook presets/preview/test, adding devices (and CSV import), alert policies, the profile list, secrets and `GET /api/v1/meta` are served; **every other API route answers `503` "setup not finished"** until setup completes.
|
||||
|
||||
Install mode ends **for good** when setup completes (`POST /api/v1/setup/complete`, the wizard's last step). The completion is stored in the inventory database and nothing on the web can undo it. If you later delete the break-glass file or the `auth:` block, nmon does not reopen setup: it refuses to start with "no way to sign in", as before. An existing install that already has a sign-in method is marked completed the first time it starts on this version, so an upgrade can never open install mode. `--insecure-http` (development) never enters install mode.
|
||||
|
||||
To skip install mode altogether, configure sign-in before the first start, as the Ansible role does (`nmon_break_glass_file` / `nmon_oidc_*`): such an install is treated as already set up.
|
||||
|
||||
### Setup wizard: sign-in
|
||||
|
||||
Required. Pick the break-glass administrator (default `admin`) and a password of at least 12 characters. nmon hashes it with bcrypt, writes `<data-dir>/breakglass` (mode 0600, `user:hash`) and sets `auth.break_glass_file`. Optionally add an OpenID Connect provider: issuer, client id, redirect URL (prefilled from the address you are using plus `/auth/callback`), groups claim, admin group and the client secret, which is stored encrypted as the secret `oidc-client`. nmon checks the fields now and contacts the provider at the first sign-in. Sign-in to the web UI has no login page: OIDC redirects the browser to the provider, and the break-glass admin signs in at `/auth/breakglass` (HTTP Basic).
|
||||
|
||||
### Setup wizard: basics
|
||||
|
||||
The time zone (site default for p95 months), retention (raw samples `retention.hot` 30d, five-minute rates 120d, hourly rates 2y) and the default poll interval (`poller.interval`, 30s to 1h, default 5m). All of them are also in Settings, General and Retention.
|
||||
|
||||
### Setup wizard: first devices
|
||||
|
||||
Choose an SNMP credential (v2c community, or v3 user with auth and privacy), then paste IP addresses (one per line, IPv6 or IPv4) or upload a CSV. Each address is added like the Add device dialog does it, so a device that does not answer SNMP is reported and not added. Host names are not resolved; use the address. The step shows each device's discovery state live.
|
||||
|
||||
### Setup wizard: alerting
|
||||
|
||||
Add one receiver, a webhook (with the Teams, Slack, Discord and generic JSON presets, a live preview and Send test) or email, and optionally turn on the recommended alert policies: `device_down`, `device_warning`, `interface_errors`, `cpu_high`, `memory_high` and `disk_full`, each for all devices. Receivers get every notification, so the policies need no binding to the new receiver. Running the step again creates nothing twice. Interface-down alerts stay opt-in (see below).
|
||||
|
||||
### Setup wizard: finish
|
||||
|
||||
The wizard summarises what it set up and calls `POST /api/v1/setup/complete`. nmon checks that a sign-in method now exists (otherwise `409`), stores the completed flag, and switches authentication on **without a restart**: the live auth service is replaced by one built from the new configuration, and install mode closes. The browser is then sent to sign in.
|
||||
|
||||
### The self-signed certificate
|
||||
|
||||
When none of `--tls-cert`/`--tls-key`, `--acme-host` or `--http-listen` is given, nmon serves HTTPS with a self-signed ECDSA P-256 certificate kept in `<data-dir>/tls/` (`cert.pem`, `key.pem` mode 0600). It is created once and reused on every start; it is valid for about two years and replaced within 30 days of expiry. Its names are the host name, `localhost`, `::1`, `127.0.0.1` and every non-loopback interface address (IPv6 included), as they were when it was created: delete the two files to make a new one after the host's addresses change. Browsers warn about it, and nmon logs a `WARN` at each start. Replace it with `--tls-cert`/`--tls-key` (reloaded on `SIGHUP`) or `--acme-host`. With `--http-listen` alone nmon serves plain HTTP only and creates no certificate.
|
||||
|
||||
## Behind a reverse proxy
|
||||
|
||||
nmon serve --data-dir /var/lib/nmon --secret-key-file /etc/nmon/secret.key --http-listen [::1]:8080
|
||||
|
||||
`--http-listen ADDR` (default off; any interface is allowed, but loopback or an internal address is the point) adds a **plain-HTTP listener** that serves the same UI and API. HTTPS stays the default and is unchanged.
|
||||
|
||||
- Beside HTTPS: with `--tls-cert/--tls-key` or `--acme-host` too, nmon serves HTTPS on `--listen` and plain HTTP on `--http-listen`, so the proxy may reach it either way. Alone: with no TLS configured nmon starts with only the HTTP listener (`--listen` is not bound) instead of refusing. SIGHUP still reloads the certificate for the HTTPS listener.
|
||||
- **Sign-in stays on** over this listener (OIDC, break-glass, API tokens), exactly as on HTTPS. It is not dev-open mode: with no `auth.oidc`/`auth.break_glass_file` nmon refuses to start. `--http-listen` and `--insecure-http` are mutually exclusive (`--insecure-http` is development mode with no sign-in).
|
||||
- List the proxy in `auth.trusted_proxies` (addresses or CIDRs, live-reloadable). Only a request from a trusted proxy has its headers honoured: `X-Forwarded-For` gives the client address used by the audit log and the break-glass lockout (the rightmost hop that is not itself a trusted proxy), and `X-Forwarded-Proto: https` marks the browser connection as HTTPS. From anyone else those headers are ignored and removed, so they cannot be spoofed. With the list empty nmon logs a WARN at startup: every client is seen as the proxy's address, so the lockout is per-proxy.
|
||||
- **Cookies:** the session and sign-in-flow cookies are always `Secure` on the HTTPS listener. On the HTTP listener they are `Secure` only when a trusted proxy sent `X-Forwarded-Proto: https`; a direct plain-HTTP client gets them without `Secure` (a browser would drop them otherwise).
|
||||
- **OIDC:** `auth.oidc.redirect_url` is configured explicitly and is never derived from the request, so set it to the public URL (`https://nmon.example.net/auth/callback`). CSRF uses a per-session token, not the Origin header, so a different public host or scheme needs nothing else.
|
||||
- nmon uses no websockets or long-lived streams; the proxy needs no upgrade headers. Raise the proxy's body-size limit if you import large CSV files.
|
||||
|
||||
nginx, proxying to the plain-HTTP listener:
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
server_name nmon.example.net;
|
||||
ssl_certificate /etc/ssl/nmon.example.net.crt;
|
||||
ssl_certificate_key /etc/ssl/nmon.example.net.key;
|
||||
client_max_body_size 64m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://[::1]:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
}
|
||||
|
||||
with `auth: {trusted_proxies: ["::1"]}` in `config.yaml`. To talk TLS to nmon instead, run nmon with its normal `--tls-cert/--tls-key` (no `--http-listen` needed), use `proxy_pass https://[::1]:8443;` with `proxy_ssl_verify off;` (or `proxy_ssl_trusted_certificate` for a CA-signed pair), and the same `proxy_set_header` lines; the listener is HTTPS, so cookies are `Secure`. Ansible: set `nmon_http_listen: "[::1]:8080"` (and `nmon_tls_mode: none` to serve only that listener).
|
||||
|
||||
## Sign-in
|
||||
|
||||
@@ -53,12 +132,70 @@ It verifies inventory row counts and a sample of series before it says done. The
|
||||
|
||||
## Configuration
|
||||
|
||||
Edit in Settings (admin) or `config.yaml` + `SIGHUP`. An invalid document is rejected and the running configuration stays. Alert receivers, the outbound allow-list, trusted proxies, the backup schedule, disk thresholds and the purge period apply live; everything else is listed as `restart_required` until the next restart. `disk: {alert_used: 0.8, admit_used: 0.9, reserve_free: 0.05}`: alert at 80% used, no new series at 90%, ingest throttled / rollup paused / backups refused below 5% free.
|
||||
Settings (admin) is point-and-click: a section list (General, Retention, Sites, Notifications, Authentication, Backup, Disk guard, L2 and OUI, Proxmox, Checks, Alerting) with a form per section, its own Save and Revert, and the applied / restart-required result under it. Unset fields show the default as a placeholder; durations take `30d`, `12h`, `5m`. A form that refers to a secret (OIDC client secret, Proxmox token) takes the secret's name and has a "Set secret value" button that stores the value write-only through `PUT /api/v1/secrets/{name}`; it is never shown again. The same sections are available as typed JSON: `GET /api/v1/settings/form` and `PUT /api/v1/settings/form` (each section present in the body replaces that section; the others are untouched). Alert rules and policies are not here, they are under Alerts; `alerting.rules` in `config.yaml` is preserved by the forms. The Advanced (YAML) tab and `PUT /api/v1/settings` still edit the whole document, and `config.yaml` + `SIGHUP` still works; all three go through the same validation. Saving rewrites `config.yaml` from the parsed configuration, so comments in the file are not kept. An invalid document is rejected and the running configuration stays. Alert receivers, the outbound allow-list, trusted proxies, the backup schedule, disk thresholds and the purge period apply live; everything else is listed as `restart_required` until the next restart. `disk: {alert_used: 0.8, admit_used: 0.9, reserve_free: 0.05}`: alert at 80% used, no new series at 90%, ingest throttled / rollup paused / backups refused below 5% free.
|
||||
|
||||
## Metric stores
|
||||
|
||||
The supervisor restarts a crashed store; after three restarts in ten minutes it raises `store_failed` and stops. `nmon store restart [hot|long] --api URL --token T` (or `SIGHUP`) resumes it.
|
||||
|
||||
## Interface-down alerts are opt-in
|
||||
|
||||
Since v1.1 the bell and the group switch only **mark** ports: `interface_down` alerts for them only where an alert policy for `interface_down` uses the `alert_down` matcher (template "Bell-marked ports: interface_down"). Mark ports in one at a time (the bell on the device page's interface table), all at once (the "Alert on down" control in the interfaces card header, or `POST /api/v1/devices/{slug}/interfaces/alert-down`), or through a group (the group's "Alert when member ports go down" switch; `PUT /api/v1/interfaces/{id}` with `{"alert_down": true}` for one port). A port in a group with the switch on alerts for as long as its membership is in force. Changes reach the evaluator at once; opting a port out closes its open alert silently as `retired`.
|
||||
|
||||
## Alert policies: nothing alerts until you add one
|
||||
|
||||
Alerts fire only where an **alert policy** binds a rule to a scope. A fresh install has no policies and records no alerts at all: no metric rule, no `device_down` or `device_warning`, no system alarm (ingest caps, late p95, rollup, disk guard, break-glass, backup). The startup log says so once ("no alert policies: nothing will alert"). Upgrading from v1.0 seeds nothing: the first cycle closes every open alert that no policy binds, silently, as `retired` (no "resolved" notices go out).
|
||||
|
||||
Manage policies under Alerts > Alert policies (admins edit, viewers read) or with `GET/POST /api/v1/alert-policies` and `GET/PUT/DELETE /api/v1/alert-policies/{id}`. `GET /api/v1/alert-rules` lists the rule names. `POST /api/v1/alert-policies/preview` counts the devices and ports a draft scope covers. Changes apply on the next cycle with no restart. Removing or disabling a policy retires its open alerts quietly.
|
||||
|
||||
A policy has a rule (a metric rule, `device_down`, `device_warning`, or `system`), an action (`alert` or `ignore`), a priority, optional `severity`, `threshold` and `for` overrides, and a scope. Scope fields are ANDed and any entry of a list may match: `all`, `groups` (a port is in force as a member now; a device matches if any port does), `devices` (`slugs` globs, `profiles`, `roles`, `sites`, `kinds`) and `interfaces` (`if_types`, `if_name` and `if_alias` as RE2, `min_speed_mbps`, `max_speed_mbps`, `alert_down`). For one entity and rule the matching policy with the highest priority decides; on a tie `ignore` beats `alert`, then the lowest name. A policy threshold beats a rule's group override, the profile threshold and the default. Interface matchers are refused on device-level rules, and a `system` policy takes only `all: true`.
|
||||
|
||||
Fiber and uplinks alert on any error, WiFi never alerts on errors:
|
||||
|
||||
```
|
||||
{"name": "Fiber + uplinks: errors", "rule": "interface_errors", "action": "alert", "threshold": 0,
|
||||
"scope": {"interfaces": {"if_types": [6], "if_alias": "(?i)fib|uplink|core"}}}
|
||||
{"name": "WiFi: no error alerts", "rule": "interface_errors", "action": "ignore", "priority": 10,
|
||||
"scope": {"interfaces": {"if_types": [71]}}}
|
||||
```
|
||||
|
||||
## Alert rules: built-in and custom
|
||||
|
||||
The shipped rules (`interface_down`, `interface_errors`, `cpu_high`, `memory_high`, `disk_full`, `ups_on_battery`, `ups_runtime_low`, `printer_supply_low`, `printer_supply_empty`, `temp_high`) are read-only **built-in** rules, listed with their generated query under Alerts > Alert rules. `cpu_high`, `ups_on_battery` and `printer_supply_empty` have **Copy to custom**, which clones them into an editable builder rule; the others join, filter or union series and say why they cannot be copied. Rules in `config.yaml` (`alerting.rules`) still work but are deprecated, and the startup log says so; define rules in the UI instead. A rule name is unique across all kinds, and `device_down`, `device_warning`, `system` and the subsystem alarm names stay reserved.
|
||||
|
||||
Admins add their own rules with the builder (UI or `POST /api/v1/alert-rules`). There is no free-form query: nmon builds it from whitelisted parts, and the generated MetricsQL is shown read-only. **Test** runs the draft once and shows how many entities breach now. Edits apply on the next cycle; disabling a rule or deleting it retires its open alerts silently, and deleting a rule that a policy still names is refused (409, naming the policies).
|
||||
|
||||
- Metric rule, "CPU over 80 % on role=server for 15 min": metric `hrProcessorLoad`, aggregate `max` (one value per device), op `>`, threshold `80`, hold time `15m`; then a policy for this rule with `devices.roles: [server]`. Transforms are `value`, `rate` (counters, per second) and `percent_of` (metrics with an `of`, such as `hrStorageUsed`); the window defaults to 5 min (at least twice the poll interval). Push-ingested metrics are device-level.
|
||||
- Inventory rule, "Uplink without description": interface fields `if_alias empty` AND `if_speed_mbps >= 10000`.
|
||||
- Inventory rule, "Device not seen for 1 h": device field `last_seen_age older_than 1h`.
|
||||
|
||||
A custom rule, like a built-in, alerts nowhere until an alert policy binds it: the rule defines **what** is measured and its default threshold and hold time, and the policy defines **where** it applies and may override severity, threshold and `for`. Several policies can bind one rule to different scopes.
|
||||
|
||||
## Webhook templates
|
||||
|
||||
Each webhook receiver (Settings > Notifications) has a Go `text/template` that renders the request body. It must produce one valid JSON document (max 256 KB); it is sent as `application/json`. Empty means the default, `{{json .}}`, the whole notification. SMTP receivers use the same editor for `Subject: ...`, a blank line and the body.
|
||||
|
||||
**Fields** (a template sees nothing else): `.IncidentID`, `.AlertID`, `.Rule`, `.State` (`firing`, `resolved`, `flapping`, `stale`, `suppressed_summary`), `.Severity` (`critical`, `warning`, `info`), `.Device.Slug`, `.Device.Name`, `.Interface.ID`, `.Interface.Name`, `.Interface.Alias` (empty for device-level alerts), `.Summary`, `.Description`, `.FiredAt`, `.ClearedAt` (nil until resolved), `.Labels`, `.Parents`, `.SuppressedChildren`, `.URL` (link to the device in nmon).
|
||||
|
||||
**Functions:** `json`, `upper`, `lower`, `join`, `timefmt` (`{{timefmt .FiredAt "2006-01-02 15:04:05 MST"}}`, UTC), plus the standard `printf`, `eq`, `or`, `if`.
|
||||
|
||||
**The `{{json}}` rule:** wrap every value you put inside a JSON string or value in `{{json ...}}` (`"text": {{json .Summary}}`, never `"text": "{{.Summary}}"`). `json` adds the quotes and escapes quotes, newlines and control characters, so an alert text can never break the document. Secret-looking keys and `key=value` pairs are redacted from the rendered payload before it is stored or sent.
|
||||
|
||||
**Presets:** "Start from preset" in the receiver form fills the template (it asks before replacing a non-empty one). Built in, also at `GET /api/v1/notify/presets`:
|
||||
|
||||
| Preset | For |
|
||||
|---|---|
|
||||
| Generic JSON | the default; the whole notification |
|
||||
| Microsoft Teams (Workflows) | Adaptive Card 1.4 for the Workflows trigger "When a Teams webhook request is received" |
|
||||
| Slack | incoming webhook: text fallback and a coloured attachment with blocks |
|
||||
| Discord | webhook with one embed, coloured by state and severity |
|
||||
|
||||
Teams: Microsoft is retiring the Office 365 connectors ("Incoming Webhook" in a channel's Connectors). Create a Workflow from the template "Post to a channel when a webhook request is received" and paste its URL. The preset is an Adaptive Card message and will not work with a legacy connector URL.
|
||||
|
||||
**Preview and test:** the editor shows the server-rendered payload for a sample notification in any state as you type, or the template or JSON error. "Send test" delivers one sample immediately (no retry queue; nothing stored except an `notify.test` audit row) and shows the HTTP status, a response excerpt (4 KB, redacted), the duration and any error. Blank header values and a blank SMTP password use the stored receiver of the same name, so you can test a saved receiver without retyping credentials. Admin only; at most two tests run at once. `POST /api/v1/notify/preview` and `POST /api/v1/notify/test` take `{"receiver": {...form shape...}, "state": "firing"}`.
|
||||
|
||||
**Outbound allow-list:** test sends and real deliveries go through the same gate. A webhook to a private, loopback or link-local address is refused unless it matches `alerting.outbound_allow` (Settings > Notifications), and the test result says so.
|
||||
|
||||
## Retired devices
|
||||
|
||||
`POST /api/v1/devices/{slug}/purge` (admin) deletes a retired device's series from both stores once it has been retired for `retired_purge_after` (default 1y).
|
||||
@@ -66,3 +203,59 @@ The supervisor restarts a crashed store; after three restarts in ten minutes it
|
||||
## CSV import
|
||||
|
||||
Devices (`name,address,site,role,credential,parents,...`) and port descriptions (`device,ifname,alias`) from Settings or `POST /api/v1/devices/import`. Port descriptions are stored in the operator-owned `description` field, which discovery never overwrites. A dependency cycle is rejected with its path; nothing is written unless the whole import is valid.
|
||||
|
||||
## Find host: MAC, ARP and FDB lookup
|
||||
|
||||
The **L2 table job** walks the bridge forwarding tables, VLAN names and ARP / IPv6 neighbour tables of every SNMP device every `l2.interval` and keeps them with first and last seen in the inventory, so the **Find host** page (and the header search, for a MAC, IP, CIDR or `vendor:name`) answers "which switch port is this host plugged in at, in which VLAN, and when did we last see it", with the uplinks that also saw it collapsed underneath. A MAC is seen on every switch it passes through, so the answer is the **edge** port: one that carries at most `l2.edge_max_macs` MACs, has no LLDP neighbour and is not a LAG.
|
||||
|
||||
```yaml
|
||||
l2:
|
||||
interval: 15m # default 15m, minimum 5m
|
||||
keep: 90d # history kept by last_seen, pruned daily
|
||||
edge_max_macs: 8 # a port with more MACs is an uplink
|
||||
concurrency: 4 # devices walked at once
|
||||
device_timeout: 2m # a slower device is skipped for the cycle
|
||||
oui:
|
||||
fetch: true # IEEE vendor registry; false never touches the network
|
||||
refresh: 7d
|
||||
```
|
||||
|
||||
All of these apply from the next cycle after a reload (Settings save or SIGHUP) with no restart. The job never runs on the poll path and yields to the poller the way the rollup job does. A device that does not answer in time is skipped for that cycle and named on the Poller page (`l2` status and an `l2_devices_skipped` note); it never delays the other devices. A Cisco IOS switch keeps one bridge table per VLAN: the `cisco-ios` profile sets `l2.per_vlan_context: true`, which lists the VLANs from CISCO-VTP-MIB and walks each with community `<community>@<vlan>` (v3: context `vlan-<vlan>`). Other devices use Q-BRIDGE-MIB, then BRIDGE-MIB (VLAN unknown).
|
||||
|
||||
API: `GET /api/v1/l2/search?q=`, `GET /api/v1/devices/{slug}/{l2,fdb,arp,l2/ports}`, `GET /api/v1/interfaces/{id}/macs`. On a device, the **L2** tab (MAC table with a VLAN filter, ARP / ND, VLAN list) appears once the job has read it, and the Ports tab shows each port's MAC count linking to that port's MACs.
|
||||
|
||||
## MAC vendors (optional IEEE registry)
|
||||
|
||||
If a registry is loaded, each MAC shows its vendor, a locally administered address (typical of randomised phone and VM MACs) shows a "private" badge, and `vendor:ubiquiti` lists the current MACs of a vendor. nmon works fully without it: the vendor is simply empty, and the Poller page notes `oui_unavailable` (not an alert).
|
||||
|
||||
The registry is the IEEE's public data: MA-L (`https://standards-oui.ieee.org/oui/oui.csv`), MA-M (`.../oui28/mam.csv`) and MA-S (`.../oui36/oui36.csv`), downloaded weekly through the outbound allow-list (`standards-oui.ieee.org` must be allowed when an allow-list is configured) with timeouts, a size cap and a row-count sanity check, so a truncated or wrong download keeps the previous file. The compact result is `<data-dir>/oui/registry.gz` (checksummed, written atomically, included in `nmon backup`). Where nmon may not fetch, set `oui.fetch: false` and load the CSVs by hand: `nmon oui import <dir-or-file> --data-dir DIR` (a running nmon notices within an hour). The CSVs are not part of this repository.
|
||||
|
||||
## BGP
|
||||
|
||||
Router profiles collect BGP session state, uptime, accepted prefixes per address family and update rates every 5 minutes (see [Collection coverage](docs/coverage.md) for which profiles and MIBs). The **BGP** page lists every session on every device, the ones that are down first, filterable by state, AS, family and text; a device's **Routing** tab shows its sessions with click-to-graph on prefixes and updates.
|
||||
|
||||
The built-in rule `bgp_peer_down` (critical, state not established for 2 minutes) fires only where a policy binds it, like every rule: use the template "Routers: BGP peer down" under Alerts > Alert policies. Its notification names the peer and, from discovery, the peer's AS and description. A prefix-drop alert is a custom rule (v1.2); an example will be added here once custom rules are in.
|
||||
|
||||
## Poll interval
|
||||
|
||||
SNMP interface counters are polled every 300 s by default. An admin can set a different interval for one device (Device settings, "Poll interval", 30 to 3600 s; blank returns it to the default) or through `PUT /api/v1/devices/{slug}` with `poll_interval_s`. The poller picks it up at once, no restart. Graph rate windows, reachability fallbacks and the rollup follow the device's own interval (see `docs/DEVIATIONS.md` row 49). ICMP is still probed every 15 s.
|
||||
|
||||
**Port totals.** A port graph (`GET /api/v1/graph`) also returns `totals`: bytes in and out over the requested range, the average-rate series integrated over each point's own step (hourly and raw data stitched without double counting). `covered_seconds` against `range_seconds` says how much of the range had data; the UI marks a total with `*` when coverage is below 99.5%. Unreliable (32-bit fast link) ports get none. The port grid shows `total` beside avg and p95; the full graph has a Total column. Per port only.
|
||||
|
||||
**Port graph parity with LibreNMS.** The port summary and the full graph show, per direction, Now / Avg / Max / 95th / Total. Max is the highest *plotted* point (with `max=true` the finer "peak within a step" is a separate Peak column). The aggregate 95th (`p95.aggregate_bps`) is the 95th percentile of in+out per 5-minute slot, counted over slots where both directions have a sample; it is live from the hot store (also next to a materialised monthly figure) and is absent when only one direction was asked for or the range has no raw data. When the port speed is known, the full legend prints each rate's share of that speed, and a toggle plots the y axis as percent of speed (hidden for unknown or zero speed). "Compare previous period" (device port row and graph view) fetches the same-length range just before the current one with the ordinary `from`/`to` of `GET /api/v1/graph`, shifts it forward onto the current axis and draws it faded and dashed, with its own avg / max / 95th / total rows in the legend; it is off while a p95 month is selected.
|
||||
|
||||
The graph-type selector offers **Bits**, **Errors** and **Discards**. Errors and discards come from `GET /api/v1/graph/counters?iface=&kind=errors|discards`, the per-second rate of `ifInErrors`/`ifOutErrors` or `ifInDiscards`/`ifOutDiscards` with the same step and rate-window rules as bits. These counters have **no long-store rollup**, so the endpoint answers from the hot store only; a range that starts before hot retention is cut to its start (`clamped: true`, and the UI says so). **Packets** (unicast pps) and **Broadcast/Multicast** appear disabled: the generic profile does not poll `ifHC*UcastPkts`, `ifHC*MulticastPkts` or `ifHC*BroadcastPkts`. Adding them means polling three more columns on every port of every device and regenerating every fixture, so it is a separate decision (DEVIATIONS 56).
|
||||
|
||||
The default itself is `poller.interval` in `config.yaml` (Settings, General, "Poll interval"; 30s to 1h, default 5m). A change applies without a restart to the poller, reachability, the rollup and the API. The dev flag `nmon serve --poll-interval`, when given, wins over the setting.
|
||||
|
||||
## Testing and debugging a device's polling
|
||||
|
||||
Three admin tools answer "why is this device not polling right", from the device page (Actions menu) or the API. None of them shows or logs a credential: the trace names the SNMP version, never the community or the v3 secrets.
|
||||
|
||||
- **Poll now** (`POST /api/v1/devices/{slug}/poll-now`, 202) runs the device's primary group right away, outside its schedule. The result lands in the normal places (graphs, reachability). It answers 409 when the device is retired, disabled, has no SNMP credential, or is not loaded by the poller yet.
|
||||
- **Test poll** (`POST /api/v1/devices/{slug}/poll-test`, optional `?interval_seconds=300` for one interval group) runs one synchronous cycle of the device's real poll plan: the stored credential, the resolved profile, the address and port, and the same fetch code the poller uses (the varbind count starts at the default and halves on tooBig or a lost response, exactly as in polling, but is forgotten afterwards). It writes **no metrics** and changes no poller state, so it cannot cause a reboot event, a backoff or a down alert. The answer lists each request (the session, number of varbinds, first OID, start and duration in ms, outcome `ok`, `timeout`, `too_big`, `snmp_error`, `dial_error` or `error`, and whether it was a retry in smaller pieces) and every requested OID with the series it feeds, its label (interface ULID or component), the SNMP type, the raw value and what the poller would store, or why not: `no_such_object`, `no_such_instance`, `end_of_mib`, `skipped` (text, type mismatch, not available), `not_fetched` (its request failed) or `oid_mismatch`. The `duration_ms` of a request includes the SNMP library's own retries; they are not listed separately. Limits: one test per device at a time (409), four at once overall (429), 60 s at most, 5,000 OID rows at most (`truncated: true`; failures and misses are kept first). Every test is audited as `device.poll_test`.
|
||||
- **SNMP debug** (`PUT /api/v1/devices/{slug}/snmp-debug` with `{"minutes": N}`; 0 turns it off, 60 at most; `GET` reads it) records every scheduled cycle of one device while it is on: the same request and OID detail as a test poll, for the last 20 cycles (500 OID rows each, failures first), newest first. It also logs one INFO line per cycle (`snmp debug cycle`, with the counts, duration and error). It switches off by itself when the minutes are up. **Recordings are kept in memory only** and are cleared on restart; starting a new recording drops the previous one, and switching off keeps what was captured so you can still read it. Turning it on or off is audited as `device.snmp_debug`. Use Poll now while it is on to get a cycle at once.
|
||||
|
||||
From a shell: `nmon device poll <slug> --api URL --token TOKEN` queues a poll now; add `--test` for the traced test poll (`--interval SECONDS` for one group, `--json` for the full trace).
|
||||
|
||||
Reading a trace: a request that times out with every OID `not_fetched` is a reachability, community or ACL problem (check the target address, the SNMP version and the device's allowed managers). A `too_big` request followed by `retry` requests means the device cannot answer 40 varbinds at once; the poller adapts, so this is only a note. Many `no_such_instance` rows for interface OIDs mean the stored interfaces no longer match the device: run Rediscover.
|
||||
|
||||
67
docs/profiles.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Adding a device profile
|
||||
|
||||
A **profile** tells nmon what to collect from one kind of device. nmon ships profiles for common gear (see [Collection coverage](/help/coverage)). When your device is only partly covered, or not at all, write one. Profiles are YAML files; nmon never reads a MIB while polling, so every metric is a numeric OID.
|
||||
|
||||
## Where profiles live
|
||||
|
||||
Shipped profiles are built into the binary. Your own go in `<data-dir>/profiles/*.yaml`; they are loaded over the shipped ones, and a profile with the same `name` replaces the shipped one. Restart `nmon serve` after changing a file, and run `nmon profile test` first to catch mistakes: a profile that fails validation is refused with an error naming the file.
|
||||
|
||||
## Basics
|
||||
|
||||
name: acme-switch
|
||||
extends: generic
|
||||
priority: 20
|
||||
match:
|
||||
sysObjectID: 1.3.6.1.4.1.99999
|
||||
sysDescr: '(?i)^AcmeOS'
|
||||
interfaces:
|
||||
exclude_ifname: ['^Vlan', '^Tunnel']
|
||||
metrics:
|
||||
- name: acmeCpuLoad
|
||||
category: health
|
||||
label: "CPU load (5 min)"
|
||||
oid: 1.3.6.1.4.1.99999.1.1.5
|
||||
type: gauge
|
||||
unit: percent
|
||||
interval: 5m
|
||||
index_label: { column: acmeCpuIndex, as: component }
|
||||
|
||||
- **extends: generic** inherits the standard metrics (interfaces, sysUpTime, host resources, entity sensors, LLDP). Always extend `generic`; your file only adds what is specific to the device.
|
||||
- **match** picks the profile for a device at discovery. `sysObjectID` is an OID prefix (the vendor's enterprise number, or a product branch of it). `sysDescr` is a regular expression on the system description. Give either or both; both must match when both are present.
|
||||
- **priority**: when several profiles match, the highest priority wins. `generic` is 0. Give a model-specific profile a higher number than the vendor-wide one, and watch overlaps: many devices run Linux and share its `sysDescr`, and several products share one enterprise number.
|
||||
- **metrics**: use numeric OIDs, taken from the vendor MIB, and keep a comment naming where each came from.
|
||||
|
||||
## Metric fields
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `name` | Series name stored in the metric store. |
|
||||
| `oid` | Numeric OID of the column or scalar. |
|
||||
| `type` | `counter64`, `counter32`, `gauge`, `status` or `text`. |
|
||||
| `unit` | `percent`, `celsius`, `octets`, `bytes`, `enum`, `count` and so on. |
|
||||
| `interval` | How often to poll, for example `60s` or `5m`. |
|
||||
| `category` | Where it shows on the device page, for example `health`, `routing`. Every metric that is not an interface metric needs one. |
|
||||
| `label` | The human-readable name shown in the UI. |
|
||||
| `index_label` | Which table column identifies the row, and the label it becomes (`component`, or `iface` for interfaces). |
|
||||
| `scale` | Multiplier for the raw value, for example `1024` to turn kilobytes into bytes. |
|
||||
| `enum` | For `status` metrics, the value-to-name map. |
|
||||
|
||||
Every port is polled. Only `interfaces.exclude_ifname` (a list of regular expressions on the interface name) drops one. 32-bit counters on fast links are flagged unreliable and left out of totals and rollups.
|
||||
|
||||
## Testing a profile
|
||||
|
||||
nmon profile test 2001:db8::10 --community public --data-dir /var/lib/nmon
|
||||
|
||||
`profile test` runs discovery and one poll against a live device with the matching profile and prints every metric with its resolved unit, so you can check the match, the OIDs and the units before the device is added. Use `--version 3 --v3-user USER ...` for SNMPv3 (`nmon profile test --help` lists every option). The `--data-dir` flag makes it load your profiles from `<data-dir>/profiles`.
|
||||
|
||||
## Recording a device
|
||||
|
||||
nmon profile record 2001:db8::10 --community public --out acme.snmprec
|
||||
|
||||
`profile record` does a read-only SNMP walk and writes a `.snmprec` file with communities, `sysContact` and `sysLocation` redacted. Keep it: it is the best bug report for a device nmon does not understand, and it lets a profile be tested without the hardware.
|
||||
|
||||
## Drafting metrics from a MIB
|
||||
|
||||
nmon profile new --mib ACME-SYSTEM-MIB
|
||||
|
||||
`profile new` compiles objects from a MIB into draft metric entries with the OID, type and enum filled in, leaving `unit`, `category`, `label` and `interval` for you. nmon ships the standard MIBs; drop a vendor MIB into the `mibs/` folder (the `--mibs-dir` setting, by default `<data-dir>/mibs`) first. Review the draft, then test it with `nmon profile test`.
|
||||
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 603 KiB After Width: | Height: | Size: 607 KiB |
|
Before Width: | Height: | Size: 149 KiB After Width: | Height: | Size: 153 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 161 KiB After Width: | Height: | Size: 167 KiB |
|
Before Width: | Height: | Size: 161 KiB After Width: | Height: | Size: 167 KiB |
|
Before Width: | Height: | Size: 193 KiB After Width: | Height: | Size: 198 KiB |
|
Before Width: | Height: | Size: 194 KiB After Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 202 KiB After Width: | Height: | Size: 206 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 594 KiB After Width: | Height: | Size: 600 KiB |
|
Before Width: | Height: | Size: 161 KiB After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 158 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 149 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 195 KiB After Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 189 KiB After Width: | Height: | Size: 183 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 213 KiB After Width: | Height: | Size: 208 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 175 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 186 KiB After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 123 KiB |