32 Commits

Author SHA1 Message Date
Vibe Coder
61b5341774 Add Find host, the L2 tab, the BGP page and the Routing tab
Some checks failed
ci / lint (push) Has been cancelled
ci / test (ubuntu-24.04-arm) (push) Has been cancelled
ci / test (ubuntu-latest) (push) Has been cancelled
ci / postgres (push) Has been cancelled
ci / openapi (push) Has been cancelled
The UI for the MAC/IP lookup and BGP monitoring.

Find host (/find, and the header search when the text is a MAC, IP, CIDR or
vendor: query; a customer name or circuit id still goes to the port search)
answers "device X port Y, VLAN Z, last seen 3 min ago" with the uplinks and
history collapsed underneath, IPv6 addresses first. A vendor column and a
"private" badge for locally administered addresses appear wherever a MAC does.
The device page gets an L2 tab only when the job holds rows (MAC table with a
VLAN filter and history toggle, ARP/ND, VLAN list), and the Ports tab shows
each port's MAC count linking to that port's MACs.

The BGP page lists every session with the down ones first and state, AS,
family and text filters; a device's Routing tab shows its sessions with
click-to-graph on accepted prefixes and update rates. The Poller page shows the
L2 job status and non-alert notes such as oui_unavailable. The policy template
"Routers: BGP peer down" binds the new rule. The mock API and a new Playwright
spec cover all of it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4PdJUPrakM8fHvN8jYv2x
2026-10-24 01:30:32 +00:00
Vibe Coder
7173360346 Add the MAC/ARP/FDB table job, the lookup API, BGP peer collection and bgp_peer_down
Owner request: finding where a host is plugged in from a MAC or IP is a must,
and BGP peer monitoring is wanted.

L2 job (internal/l2): a job of its own, off the 60 s poll path (its sessions
are path "l2"), walks BRIDGE-MIB, Q-BRIDGE-MIB, CISCO-VTP-MIB (a per-VLAN
context walk, enabled by the cisco-ios profile flag l2.per_vlan_context, not by
detection) and the IP-MIB neighbour tables (IPv4 ARP and IPv6 ND, IPv4-only
fallback) every l2.interval, with its own concurrency cap and a per-device
deadline. It yields to the poller like the rollup job, and a device that times
out is skipped for the cycle, counted and named on /poller/health. Migration 14
adds fdb_entries, arp_entries, vlans and l2_walks with first/last-seen history,
batched upserts, daily pruning by last_seen (l2.keep) and backup coverage.

API (additive): /l2/search ranks edge ports first (a port is an uplink above
l2.edge_max_macs MACs, with an LLDP neighbour, or as a LAG), plus the device
fdb/arp/l2/l2-ports tables and /interfaces/{id}/macs; vendor and mac_kind are
computed at query time from the optional registry.

BGP: profiles label peer rows by decoding the table index (index_label.layout),
so each peer, IPv4 or IPv6, is one component whatever MIB feeds it; AS, local
AS, router id and description are component attributes, never labels.
CISCO-BGP4 (cisco-ios, cisco-nxos), Juniper BGP4-V2 and Arista BGP4V2 replace
the optional generic BGP4-MIB metrics, which edgeos, fortigate, pfsense and vyos
switch on. /bgp/peers lists sessions down first. The built-in rule
bgp_peer_down (critical, not established for 2 minutes) fires only where a
policy binds it; its notification names the peer's AS and description.

Fixtures are generated (Q-BRIDGE with IPv4/IPv6 neighbours, BRIDGE-MIB only,
Cisco per-VLAN contexts via a new simagent contexts feature) and checked
against real LibreNMS recordings. DEVIATIONS 50-55 record the departures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4PdJUPrakM8fHvN8jYv2x
2026-10-24 01:30:32 +00:00
Vibe Coder
c4d14a67d1 Add the optional IEEE vendor registry for MAC addresses and the l2/oui settings
The owner asked to see the vendor of each MAC where the IEEE registry can be
fetched, and never to depend on it. internal/oui downloads MA-L, MA-M and MA-S
weekly through the outbound allow-list (timeouts, a size cap and a row-count
sanity check, so a truncated or wrong download keeps the previous file), stores
one checksummed, atomically written file in <data-dir>/oui that nmon backup
includes, and looks MACs up by the longest prefix. A missing registry only
leaves the vendor empty. `nmon oui import DIR|FILE` loads the CSVs offline, and
the Ansible role can ship them next to the MIB sidecar.

internal/hostaddr parses the MAC, prefix, IP and CIDR forms the lookup accepts
and classifies a MAC as global, local or multicast. config gains the l2 and oui
sections (validated here; the L2 job that reads l2 follows).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4PdJUPrakM8fHvN8jYv2x
2026-10-24 01:30:11 +00:00
Vibe Coder
06a3c85c69 e2e: point device-page specs at the Overview tab
992f09b removed DeviceHeader and OverviewSummary, but six mock specs and
the live gate screenshots still waited for their test ids (device-header,
summary-tile, generic-hint, device-warnings), so they failed and stopped
gate-m4 at its UI step. Assert on what the page shows now: the System
panel (role, profile, status), the Processors/Memory/Storage panels, and
the per-port unreliable flag on the Ports tab. The device-level warning
badge and the generic-profile hint no longer exist, so their assertions
are dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4PdJUPrakM8fHvN8jYv2x
2026-10-23 23:29:41 +00:00
Vibe Coder
ddc8863a67 Add alert policies: alerts fire only where a policy says so
Preprod fires everything: every default rule applied to every device and
port, so a WiFi port that always sees errors was judged like a fiber
uplink where one error matters. WiFi errors are acceptable, fiber errors
are not, and one global threshold cannot say both.

An alert policy (new alert_policies table, migration 12) binds one rule
to a scope: groups plus device and port attribute matchers (ifType,
name/alias regexes, speed, site, role, profile, kind, the alert_down
bell). The highest priority decides, ignore beats alert on a tie, and a
policy's severity/threshold/for override the rule's (its threshold also
beats group overrides). With no policy nothing alerts: not metric rules,
not device_down/device_warning, not system alarms. Nothing is seeded, so
on upgrade open alerts without a binding close silently as retired.

The pure internal/alertpolicy package makes the decision; alert, reach
and the system alarms take a gate (Options.Policy, Options.Wants, a
system policy) that the app backs with a cache refreshed every 30 s and
invalidated on every API write, so policy edits apply live. New API:
/alert-policies CRUD, a viewer-level preview of what a scope covers, and
/alert-rules. The UI gets an Alert policies tab with templates and a
live preview; the port bell now says it only marks the port. Gate
scripts create the policies they need. DEVIATIONS row 41 and the
operations guide describe the new default.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4PdJUPrakM8fHvN8jYv2x
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:41:25 +00:00
c4f5ee916b Add CLAUDE.md: agent onboarding (rules, layout, commands, how-tos, gotchas)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-23 17:16:56 +00:00
992f09b97a Overview tab: use generated overview types, drop dead DeviceHeader/OverviewSummary
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-23 14:38:16 +00:00
d92c6e1e08 Device overview endpoint and aggregate metric graphs
GET /api/v1/devices/{slug}/overview (traffic, ports, availability, rtt, events);
metrics/graph gains aggregate=avg|sum|max when component is omitted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-23 14:34:37 +00:00
9b4ab7ccbb Device Overview tab: LibreNMS-style panel dashboard (system, ports, availability, ping, processors, memory, storage, supplies, sensors, events)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-23 14:17:49 +00:00
457191accd Docs: BMC support deferred
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-23 05:59:11 +00:00
61274c4f55 Docs: v1.0.1 device tabs contract
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-23 05:46:26 +00:00
fe53998aa6 Device page tabs (Overview, Ports, per-category metrics, Printer and Wireless panels) and Printers fleet page
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-23 05:44:54 +00:00
e25143881c Device metrics API, printer fleet, profile presentation schema (v1.0.1 backend)
Profiles gain category/label/of/display/scale_by/sentinels/enum_state and component_attrs;
every pollable non-interface metric is annotated and an orphan test enforces it. New routes
GET /devices/{slug}/metrics, /metrics/graph and /printers; printer supply type/colour read at
discovery into device_components.attrs; printer_supply_empty alert; supply_replaced audit event;
docs/coverage.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-23 05:33:07 +00:00
ad220eae58 Interface-down alerts are opt-in: per port (bell toggle, bulk) and via groups
interfaces.alert_down / groups.alert_down (migration 9); interface_down rule OptIn; non-opted ports' open alerts close silently as retired.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-23 05:04:41 +00:00
345daaa773 Profiles for EdgeSwitch, UniFi switch/AP and VyOS; profile pinning and re-match; whole-row navigation
Discovery re-matches unpinned devices every run and at startup; an admin can pin a
profile (PUT /devices/{slug} profile, GET /profiles, DeviceView profile_pinned and
profile_match). Rows on the device list, interface search and poller health navigate on
click and Enter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-23 05:03:17 +00:00
bf6dbd4923 Add device management UI: add, edit, rediscover, retire
The UI had no way to add a device. Adds an admin-only Add device dialog
(IPv6/IPv4 addresses, SNMP v1/v2c/v3, synchronous discovery, reattach 409
flow) and a device-page Actions menu (Edit with parents and SNMP
replacement, Rediscover now, Retire). Purge now requires typing the name.

API: POST /api/v1/devices/{slug}/retire (admin, audited, 409 if already
retired). OpenAPI change is additive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-23 03:36:10 +00:00
d97dd1d44a Ignore local Ansible inventory and vars (vars.yml holds secrets)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-23 03:14:18 +00:00
28f5444860 Playbook: self-signed cert names IP addresses as IP SANs; nmon_tls_extra_names for more names/IPs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-23 03:14:12 +00:00
6ae7729107 Ansible deployment playbook; fix: SIGHUP during startup no longer kills nmon
The playbook (deploy/ansible) builds the release binary, installs nmon as a hardened systemd service with
self-signed/files/ACME/insecure TLS modes, break-glass and optional OIDC, MIB sidecar, ping sysctl and nightly backups.
Tested end to end in a Debian 13 systemd container, idempotent on a second run.

Bug found by that test: Run installed its SIGHUP handler only after Start, so a 'systemctl reload' during the
seconds-long startup hit Go's default action and exited nmon. The handler is now installed before Start; an early
SIGHUP is queued and applied once startup finishes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-23 03:07:56 +00:00
faa0fa7505 M6 gate passed; screenshots refreshed against the final UI
Some checks failed
ci / lint (push) Has been cancelled
ci / test (ubuntu-24.04-arm) (push) Has been cancelled
ci / test (ubuntu-latest) (push) Has been cancelled
ci / postgres (push) Has been cancelled
ci / openapi (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-23 02:54:51 +00:00
4b7849c45b Merge M6: auth (OIDC redirect, no login UI), TLS, tokens, backup/restore, key rotate, settings reload, CSV import, disk guard
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-23 02:16:29 +00:00
0754268e9e M6: gate-m6, operations guide, deviations, screenshots
Some checks failed
ci / lint (push) Has been cancelled
ci / test (ubuntu-24.04-arm) (push) Has been cancelled
ci / test (ubuntu-latest) (push) Has been cancelled
ci / postgres (push) Has been cancelled
ci / openapi (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-23 02:15:49 +00:00
a768a15fb6 M6: web UI (user menu, role-aware controls, settings, token kinds, CSV import, purge, descriptions, graph and device-down fixes)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-23 02:15:49 +00:00
d7df853a35 M6: CLI (backup, restore, key rotate, unlock, secret set, store restart), devtools oidc-mock and bcrypt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-23 02:15:49 +00:00
81162b57b5 M6: auth wiring (OIDC redirect flow, Basic break-glass, TLS/ACME, dev-open mode), admin API (tokens, settings, import, purge, backup, store restart, audit), disk guard, backups, reload
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-23 02:15:49 +00:00
fe4db76a6e M6: interface description field, config sections, netpolicy reload, disk-guard thresholds, ingest throttle, rollup pause, rollup_stalled test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-23 02:15:49 +00:00
4a57eac1ed M4 and M5 gates passed: gate logs
Some checks failed
ci / lint (push) Has been cancelled
ci / test (ubuntu-24.04-arm) (push) Has been cancelled
ci / test (ubuntu-latest) (push) Has been cancelled
ci / postgres (push) Has been cancelled
ci / openapi (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-23 00:39:45 +00:00
b3aae800bf M4 integration (alerting wired, alerts API/UI, maintenance, dependency hold, redundant-topology gate) + M5 (rollup, ingest, Proxmox, checks, p95, groups, longq, gate) + M6 library packages (auth, backup, diskguard, importcsv, keyrotate)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-23 00:10:25 +00:00
ba960474bc test-pg.sh: unique container and Docker-assigned port so concurrent runs never collide
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-22 21:55:03 +00:00
d3ec85a19c Approved deviation: skip the 2,000-device loadtest; M6 gate is the viewer-403 check
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-22 21:48:34 +00:00
b740611136 Docs: web push deferred to v2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-22 21:22:55 +00:00
f85c3ef781 M3: Go API per UI contract, embedded UI, openapi compat check, nmon-devtools, live gate + screenshots; M4 WP1 reachability and dependency suppression
Some checks failed
ci / lint (push) Has been cancelled
ci / test (ubuntu-24.04-arm) (push) Has been cancelled
ci / test (ubuntu-latest) (push) Has been cancelled
ci / postgres (push) Has been cancelled
ci / openapi (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-22 20:34:14 +00:00
884 changed files with 90714 additions and 2305 deletions

6
.gitignore vendored
View File

@@ -1,5 +1,6 @@
/nmon
/nmon-arm64
/nmon-devtools
/internal/vmsup/bin/*.tar.gz
/internal/vmsup/bin/victoria-metrics-*
web/node_modules/
@@ -8,3 +9,8 @@ web/dist/
*.test
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

View File

@@ -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,28 @@ 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.
files: ["**/cmd/nmon/**", "!$test"]
deny:
- pkg: github.com/v10networks/nmon/internal/simagent
desc: the simulator belongs to cmd/nmon-devtools and tests, never the shipped binary
- pkg: github.com/v10networks/nmon/internal/fixturegen
desc: the fixture generator belongs to cmd/nmon-devtools and tests, never the shipped binary
- pkg: github.com/v10networks/nmon/internal/fixtures
desc: fixtures are test data, never the shipped binary
- pkg: github.com/v10networks/nmon/internal/testutil
desc: test helpers are never in the shipped binary
devtools-no-core:
files: ["**/cmd/nmon-devtools/**"]
deny:
- pkg: github.com/v10networks/nmon/internal/app
desc: nmon-devtools talks to a running nmon over HTTP
- pkg: github.com/v10networks/nmon/internal/api
desc: nmon-devtools talks to a running nmon over HTTP
importer-not-in-nmon:
files: ["**/cmd/nmon/**"]
deny:

120
CLAUDE.md Normal file
View 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.

View File

@@ -1,24 +1,33 @@
GO ?= go
TAGS ?= vmembed
LDFLAGS := -s -w
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
BUILD ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)-$(shell date -u +%Y%m%d)
LDFLAGS := -s -w -X github.com/v10networks/nmon/internal/buildinfo.Version=$(VERSION) -X github.com/v10networks/nmon/internal/buildinfo.Build=$(BUILD)
BIN := nmon
PG_DSN ?= postgres://nmon:nmon@localhost:55432/nmon?sslmode=disable
.PHONY: all lint lint-go lint-web build importer build-arm64 build-noembed fetch-vm web test test-integration test-pg test-v6only vet gate-m1 gate-m2 gate-m3 gate-m4 gate-m5 gate-m6 clean
.PHONY: devtools all lint lint-go lint-web build importer build-arm64 build-noembed fetch-vm web test test-integration test-pg test-v6only vet gate-m1 gate-m2 gate-m3 gate-m4 gate-m5 gate-m6 clean
all: build
fetch-vm:
./scripts/fetch-vm.sh
# The UI is built first when web/ exists so embed.FS picks it up.
# The UI is built first, then copied into internal/webui/dist for go:embed (git-ignored).
web:
@if [ -f web/package.json ]; then cd web && npm ci --no-audit --no-fund && npm run build; fi
@if [ -f web/package.json ]; then \
cd web && { [ -d node_modules ] || npm ci --no-audit --no-fund; } && npm run build && \
cd .. && rm -rf internal/webui/dist/* && cp -r web/dist/. internal/webui/dist/ && touch internal/webui/dist/.gitkeep; \
fi
build: fetch-vm
build: fetch-vm web
CGO_ENABLED=0 $(GO) build -tags '$(TAGS)' -ldflags '$(LDFLAGS)' -o $(BIN) ./cmd/nmon
build-arm64: fetch-vm
# Developer tools (backfill, seed-demo) are a separate binary that is never shipped.
devtools:
CGO_ENABLED=0 $(GO) build -o nmon-devtools ./cmd/nmon-devtools
build-arm64: fetch-vm web
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 $(GO) build -tags '$(TAGS)' -ldflags '$(LDFLAGS)' -o $(BIN)-arm64 ./cmd/nmon
importer:
@@ -59,4 +68,4 @@ gate-m1 gate-m2 gate-m3 gate-m4 gate-m5 gate-m6: gate-m%:
./scripts/gate-m$*.sh
clean:
rm -f $(BIN) $(BIN)-arm64 nmon-librenms-import
rm -f $(BIN) $(BIN)-arm64 nmon-devtools nmon-librenms-import

View File

@@ -0,0 +1,162 @@
package main
import (
"bytes"
"context"
"errors"
"fmt"
"math"
"net/http"
"net/url"
"strings"
"time"
"github.com/v10networks/nmon/internal/metrics"
)
// backfillConfig is what one `devtools backfill` run writes, for every interface in ifaces.
type backfillConfig struct {
hotURL string
device string
ifaces []string
days int
step time.Duration
pattern string
meanBps float64 // bits/s; 0 derives the mean from the live rate, else a stable per-interface value
seed uint64
hc *http.Client
logf func(format string, a ...any)
}
func backfillCmd(ctx context.Context, args []string) error {
cfg := backfillConfig{hc: &http.Client{Timeout: 2 * time.Minute}, logf: func(f string, a ...any) { fmt.Printf(f+"\n", a...) }}
fs := devFlagSet("backfill")
var ifaces stringList
var mbps float64
fs.StringVar(&cfg.hotURL, "hot-url", "", "base URL of the hot VictoriaMetrics (required; see \"metric stores up\" in the serve log)")
fs.StringVar(&cfg.device, "device", "", "device slug (the series' device label)")
fs.Var(&ifaces, "iface", "interface ULID; repeat the flag or separate with commas")
fs.IntVar(&cfg.days, "days", 31, "days of history to write, ending where the live data begins")
fs.DurationVar(&cfg.step, "step", 60*time.Second, "sample spacing")
fs.StringVar(&cfg.pattern, "pattern", patternDiurnal, "traffic shape: diurnal or flat")
fs.Float64Var(&mbps, "mean-mbps", 0, "mean rate in Mbit/s; 0 follows the live rate when there is one")
fs.Uint64Var(&cfg.seed, "seed", 1, "noise seed")
if err := fs.Parse(args); err != nil {
return flagErr(err)
}
cfg.ifaces, cfg.meanBps = ifaces, mbps*1e6
if err := cfg.validate(); err != nil {
return err
}
for _, id := range cfg.ifaces {
if err := cfg.backfillIface(ctx, id); err != nil {
return fmt.Errorf("iface %s: %w", id, err)
}
}
return nil
}
func (c *backfillConfig) validate() error {
switch {
case c.hotURL == "":
return errors.New("--hot-url is required")
case c.device == "" || len(c.ifaces) == 0:
return errors.New("--device and at least one --iface are required")
case c.days < 1 || c.step < time.Second:
return errors.New("--days must be at least 1 and --step at least 1s")
case c.pattern != patternDiurnal && c.pattern != patternFlat:
return fmt.Errorf("unknown --pattern %q (diurnal or flat)", c.pattern)
}
c.hotURL = strings.TrimRight(c.hotURL, "/")
return nil
}
// liveSeries is what the hot store already holds for one series: the first
// sample (where history must end and which value it must join) and the live rate.
type liveSeries struct {
ts time.Time
val float64
rate float64 // units per second between the first two samples; 0 when unknown
ok bool
}
// backfillIface writes in/out octet counters and the oper status for one interface.
func (c *backfillConfig) backfillIface(ctx context.Context, iface string) error {
n := c.days * int(24*time.Hour/c.step)
var samples []metrics.Sample
var end time.Time
for d, dir := range []struct {
metric string
salt uint64
}{{"ifHCInOctets", 0x1111}, {"ifHCOutOctets", 0x2222}} {
live, err := c.probe(ctx, dir.metric, iface)
if err != nil {
return err
}
if d == 0 {
end = c.endTime(live)
}
samples = append(samples, c.counter(dir.metric, iface, end, n, live, c.seed^dir.salt)...)
}
status := 1.0
if live, err := c.probe(ctx, "ifOperStatus", iface); err == nil && live.ok {
status = live.val
}
for i := 0; i < n; i++ {
ts := end.Add(-time.Duration(n-1-i) * c.step)
samples = append(samples, metrics.New("ifOperStatus", status, ts, metrics.LabelDevice, c.device, metrics.LabelIface, iface))
}
if err := c.send(ctx, samples); err != nil {
return err
}
c.logf("backfilled %s/%s: %d days at %s (%d samples)", c.device, iface, c.days, c.step, len(samples))
return nil
}
// endTime is one step before the first live sample, or one step ago when there is none.
func (c *backfillConfig) endTime(live liveSeries) time.Time {
if live.ok {
return live.ts.Add(-c.step).Truncate(time.Millisecond)
}
return time.Now().Add(-c.step).Truncate(time.Millisecond)
}
// counter builds one direction's history ending at end. When the live counter
// is known and large enough, the history is shifted so its last value is where
// the live counter was one step earlier and the join shows no reset.
func (c *backfillConfig) counter(metric, iface string, end time.Time, n int, live liveSeries, salt uint64) []metrics.Sample {
mean := c.meanBps / 8
if mean == 0 && live.rate > 0 {
mean = live.rate
}
if mean == 0 {
mean = defaultMeanBytesPerSec(iface, salt)
}
start := end.Add(-time.Duration(n-1) * c.step)
vals := counterSeries(c.pattern, start, n, c.step, mean, salt)
var offset float64
if live.ok {
offset = live.val - mean*c.step.Seconds() - vals[n-1]
if offset < 0 {
c.logf("warning: %s %s: live counter %.3g is below the %.3g bytes of history; the join will look like a counter reset",
c.device, metric, live.val, vals[n-1])
offset = 0
}
}
out := make([]metrics.Sample, n)
for i, v := range vals {
ts := start.Add(time.Duration(i) * c.step)
out[i] = metrics.New(metric, math.Floor(v+offset), ts, metrics.LabelDevice, c.device, metrics.LabelIface, iface)
}
return out
}
// probe reads the earliest samples of metric{iface=...} from the hot store.
func (c *backfillConfig) probe(ctx context.Context, metric, iface string) (liveSeries, error) {
q := url.Values{"match[]": {fmt.Sprintf(`%s{iface=%q}`, metric, iface)}}
var raw bytes.Buffer
if err := c.get(ctx, c.hotURL+"/api/v1/export?"+q.Encode(), &raw); err != nil {
return liveSeries{}, err
}
return parseExport(raw.Bytes())
}

View File

@@ -0,0 +1,80 @@
package main
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/v10networks/nmon/internal/longq"
"github.com/v10networks/nmon/internal/metrics"
"github.com/v10networks/nmon/internal/rollup"
)
// backfillLongCmd writes rollup series straight into the long store, as if the rollup job had been running
// for years: hourly average and maximum rates for --months months and 5-minute rates for --days days, flat
// at the given rates so a gate can assert exact values. The data ends --end-offset ago, leaving the recent
// hours to the real rollup job (its repair writes higher versions, which win).
func backfillLongCmd(ctx context.Context, args []string) error {
fs := devFlagSet("backfill-long")
var ifaces stringList
long := fs.String("long-url", "", "base URL of the long VictoriaMetrics (see \"metric stores up\" in the serve log)")
device := fs.String("device", "", "device slug (the series' device label)")
fs.Var(&ifaces, "iface", "interface ULID; repeat the flag or separate with commas")
months := fs.Int("months", 25, "months of hourly rollups to write")
days := fs.Int("days", 70, "days of 5-minute rates to write")
offset := fs.Duration("end-offset", 3*time.Hour, "stop this long before now")
in := fs.Float64("in-bps", 8e6, "constant inbound rate")
out := fs.Float64("out-bps", 2e6, "constant outbound rate")
if err := fs.Parse(args); err != nil {
return flagErr(err)
}
if *long == "" || *device == "" || len(ifaces) == 0 {
return fmt.Errorf("backfill-long needs --long-url, --device and --iface: %w", errUsage)
}
c := &backfillConfig{hotURL: strings.TrimRight(*long, "/"), hc: &http.Client{Timeout: 5 * time.Minute}}
end := time.Now().Add(-*offset).Truncate(time.Hour)
for _, id := range ifaces {
s := longSamples(*device, id, end, *months, *days, map[string]float64{"in": *in, "out": *out})
if err := c.send(ctx, s); err != nil {
return errors.Join(fmt.Errorf("iface %s", id), err)
}
fmt.Printf("backfilled long %s/%s: %d months hourly, %d days 5m (%d samples)\n", *device, id, *months, *days, len(s))
}
return c.flush(ctx)
}
// flush makes what was just imported searchable at once: VictoriaMetrics shows new rows only after its
// buffers flush, and a gate that queries right after the import would otherwise see a partial history.
func (c *backfillConfig) flush(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.hotURL+"/internal/force_flush", http.NoBody)
if err != nil {
return err
}
resp, err := c.hc.Do(req)
if err != nil {
return err
}
_ = resp.Body.Close() // nothing in the reply matters
return nil
}
// longSamples builds the hourly series from end back months months and the 5-minute series from end back days.
func longSamples(device, iface string, end time.Time, months, days int, rate map[string]float64) []metrics.Sample {
var out []metrics.Sample
labels := func(dir string, ws time.Time) []string {
return []string{"device", device, "iface", iface, "dir", dir, "v", rollup.VersionLabel(ws, 1)}
}
for dir, bps := range rate {
for h := end; h.After(end.AddDate(0, -months, 0)); h = h.Add(-time.Hour) {
out = append(out, metrics.New(longq.MetricRateAvg1h, bps, h, labels(dir, h)...),
metrics.New(longq.MetricRateMax1h, bps*1.25, h, labels(dir, h)...))
}
for t := end; t.After(end.AddDate(0, 0, -days)); t = t.Add(-5 * time.Minute) {
out = append(out, metrics.New(longq.MetricRate5m, bps, t, labels(dir, t)...))
}
}
return out
}

130
cmd/nmon-devtools/fleet.go Normal file
View File

@@ -0,0 +1,130 @@
package main
import (
"fmt"
"strconv"
"strings"
"github.com/gosnmp/gosnmp"
"github.com/v10networks/nmon/internal/fixturegen"
"github.com/v10networks/nmon/internal/profile"
"github.com/v10networks/nmon/internal/snmprec"
)
// demoDevice is one simulated device of `devtools seed-demo`.
type demoDevice struct {
name, site, role string
kind string // counters, big, router, ups, v1, down: what the gate and screenshots look for
rec *snmprec.Recording
community string
version string // v2c unless set
hc bool // 64-bit counters, so history can be back-filled
}
// counterBase is added to every simulated 64-bit octet counter so that the
// back-filled history (about 3e13 bytes for 31 days at 100 Mbit/s) fits below
// the live counter and the join shows no reset.
const counterBase = 100_000_000_000_000
const (
oidHCIn = "1.3.6.1.2.1.31.1.1.1.6."
oidHCOut = "1.3.6.1.2.1.31.1.1.1.10."
oidUp = "1.3.6.1.2.1.1.3.0"
)
// animate returns a copy of rec whose 64-bit octet counters advance at a steady
// per-port rate from counterBase and whose uptime ticks, so successive polls see
// traffic. Counters that already advance keep their rate and only gain the base.
func animate(rec *snmprec.Recording) *snmprec.Recording {
recs := append([]snmprec.Record(nil), rec.Records()...)
for i := range recs {
r := &recs[i]
switch {
case r.Type == gosnmp.Counter64 && (strings.HasPrefix(r.OID, oidHCIn) || strings.HasPrefix(r.OID, oidHCOut)):
recs[i] = animateCounter(*r)
case r.OID == oidUp && r.Dyn == nil:
if v, ok := r.Value.(uint32); ok {
r.Dyn, r.Value = &snmprec.Dynamic{Rate: 100, Initial: uint64(v)}, nil
}
}
}
return snmprec.NewRecording(recs)
}
func animateCounter(r snmprec.Record) snmprec.Record {
if r.Dyn != nil {
d := *r.Dyn
d.Initial += counterBase
r.Dyn = &d
return r
}
k, _ := strconv.Atoi(r.OID[strings.LastIndexByte(r.OID, '.')+1:])
dir := 1
if strings.HasPrefix(r.OID, oidHCOut) {
dir = 2
}
mbps := 1 + (k*(31+7*dir))%90
r.Dyn = &snmprec.Dynamic{Rate: float64(mbps) * 125_000, Initial: counterBase + uint64(k)*1_000_000_000}
r.Value = nil
return r
}
// profileDevice builds a device that a shipped profile matches, with traffic.
func profileDevice(set *profile.Set, prof, name, site, role, kind string, ports int) (demoDevice, error) {
spec, ok := fixturegen.SpecFor(prof)
if !ok {
return demoDevice{}, fmt.Errorf("no fixture spec for profile %q", prof)
}
res, ok := set.Lookup(prof)
if !ok {
return demoDevice{}, fmt.Errorf("no profile %q", prof)
}
spec.SysName = name
if ports > 0 {
spec.Ports = ports
}
rec, err := fixturegen.FromProfile(res, spec)
if err != nil {
return demoDevice{}, fmt.Errorf("%s: %w", name, err)
}
return demoDevice{name: name, site: site, role: role, kind: kind, rec: animate(rec), community: "public", hc: true}, nil
}
// buildFleet is the small network used for live screenshots: a 48-port switch,
// routers, a firewall, a UPS, one 32-bit device, the counters fixture and one
// device whose agent is stopped after seeding, so it reads as down.
func buildFleet(set *profile.Set, countersRec string) ([]demoDevice, error) {
counters, err := snmprec.Load(countersRec)
if err != nil {
return nil, err
}
fleet := []demoDevice{{
name: "yyz1-edge1", site: "yyz1", role: "edge", kind: "counters", rec: animate(counters), community: "public", hc: true,
}}
for _, p := range []struct {
prof, name, site, role, kind string
ports int
}{
{"cisco-ios", "yyz1-acc01", "yyz1", "access", "big", 48},
{"cisco-ios", "yyz1-acc02", "yyz1", "access", "router", 24},
{"cisco-ios", "ott1-acc02", "ott1", "access", "down", 24}, // its agent is stopped once seeding is done
{"juniper-junos", "yyz1-core1", "yyz1", "core", "router", 0},
{"arista-eos", "yul1-core1", "yul1", "core", "router", 0},
{"mikrotik-routeros", "ott1-gw1", "ott1", "gateway", "router", 0},
{"fortigate", "yyz1-fw1", "yyz1", "firewall", "router", 0},
{"apc-ups", "yyz1-ups1", "yyz1", "power", "ups", 0},
} {
d, err := profileDevice(set, p.prof, p.name, p.site, p.role, p.kind, p.ports)
if err != nil {
return nil, err
}
fleet = append(fleet, d)
}
// 32-bit counters on 1 Gbit/s ports: the API flags them unreliable and the UI never graphs them.
v1 := fixturegen.Generic(fixturegen.Options{Ports: 12, SysName: "yul1-acc03", Counters: true, NamePattern: "Fa0/%d"})
fleet = append(fleet, demoDevice{
name: "yul1-acc03", site: "yul1", role: "access", kind: "v1", rec: v1, community: "nmon-v1", version: "v1",
})
return fleet, nil
}

View File

@@ -0,0 +1,163 @@
package main
import (
"bufio"
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"time"
)
// gateDumpCmd prints what the M4 gate asserts on, one line per item, so the shell
// script can count and match lines instead of parsing JSON.
//
// gate-dump sink --file F what the webhook sink received, in arrival order
// gate-dump alerts --api URL every alert, newest first
// gate-dump devices --api URL every device's reachability
func gateDumpCmd(ctx context.Context, args []string) error {
if len(args) == 0 {
return fmt.Errorf("gate-dump sink|alerts|devices")
}
fs := devFlagSet("gate-dump " + args[0])
file := fs.String("file", "", "sink file (sink)")
api := fs.String("api", "", "nmon API base URL (alerts, devices)")
mapFile := fs.String("map", "", "slug map written by topology-add; slugs are printed as topology names")
if err := fs.Parse(args[1:]); err != nil {
return flagErr(err)
}
names, err := readSlugMap(*mapFile)
if err != nil {
return err
}
switch args[0] {
case "sink":
return dumpSink(*file, names)
case "alerts":
return dumpAlerts(ctx, *api, names)
case "devices":
return dumpDevices(ctx, *api, names)
}
return fmt.Errorf("gate-dump: unknown %q", args[0])
}
func orDash(s []string) string {
if len(s) == 0 {
return "-"
}
return strings.Join(s, ",")
}
// dumpSink prints "rule state device parents=a,b children=N incident=ID" per request received.
func dumpSink(file string, names slugNames) error {
f, err := os.Open(file)
if err != nil {
return err
}
defer func() { _ = f.Close() }() // read only
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 1<<20), 1<<20)
for sc.Scan() {
var l sinkLine
var n struct {
Rule, State string
IncidentID string `json:"incident_id"`
Device struct{ Slug string }
Parents []string
SuppressedChildren int `json:"suppressed_children"`
}
if json.Unmarshal(sc.Bytes(), &l) != nil || json.Unmarshal(l.Body, &n) != nil {
fmt.Println("unparsable - - parents=- children=0 incident=-")
continue
}
fmt.Printf("%s %s %s parents=%s children=%d incident=%s\n", n.Rule, n.State, names.one(n.Device.Slug), orDash(names.of(n.Parents)),
n.SuppressedChildren, n.IncidentID)
}
return sc.Err()
}
func getJSON(ctx context.Context, api, path string, out any) error {
hc := &http.Client{Timeout: 30 * time.Second}
return httpJSON(ctx, hc, http.MethodGet, api+path, nil, out)
}
// dumpAlerts prints "id rule state device suppressed_by=a,b incident=ID acked=yes|no" per alert.
func dumpAlerts(ctx context.Context, api string, names slugNames) error {
var page struct {
Items []struct {
ID, Rule, State string
Device struct{ Slug string }
SuppressedBy []string `json:"suppressed_by"`
IncidentID string `json:"incident_id"`
Ack *struct{} `json:"ack"`
} `json:"items"`
}
if err := getJSON(ctx, api, "/api/v1/alerts?per_page=1000", &page); err != nil {
return err
}
for _, a := range page.Items {
acked := "no"
if a.Ack != nil {
acked = "yes"
}
fmt.Printf("%s %s %s %s suppressed_by=%s incident=%s acked=%s\n", a.ID, a.Rule, a.State, names.one(a.Device.Slug),
orDash(names.of(a.SuppressedBy)), orDash([]string{a.IncidentID}), acked)
}
return nil
}
// dumpDevices prints "slug reachability poll_state warnings=a,b" per device.
func dumpDevices(ctx context.Context, api string, names slugNames) error {
var page struct {
Items []struct {
Slug, Reachability string
PollState string `json:"poll_state"`
Warnings []string `json:"warnings"`
} `json:"items"`
}
if err := getJSON(ctx, api, "/api/v1/devices?per_page=1000", &page); err != nil {
return err
}
for _, d := range page.Items {
fmt.Printf("%s %s %s warnings=%s\n", url.PathEscape(names.one(d.Slug)), d.Reachability, d.PollState, orDash(d.Warnings))
}
return nil
}
// slugNames maps nmon slugs back to the names in the topology file.
type slugNames map[string]string
func readSlugMap(path string) (slugNames, error) {
m := slugNames{}
if path == "" {
return m, nil
}
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
for _, l := range strings.Split(string(b), "\n") {
if f := strings.Fields(l); len(f) == 2 {
m[f[0]] = f[1]
}
}
return m, nil
}
func (m slugNames) one(slug string) string {
if n, ok := m[slug]; ok {
return n
}
return slug
}
func (m slugNames) of(slugs []string) []string {
out := make([]string, len(slugs))
for i, s := range slugs {
out[i] = m.one(s)
}
return out
}

View File

@@ -0,0 +1,59 @@
package main
import (
"strings"
"testing"
"time"
)
func TestRebaseInfluxMakesTheNewestLineNowAndKeepsSpacing(t *testing.T) {
in := "m,host=a v=1 1000000000000000000\n# c\nm,host=a v=2 1000000060000000000\n"
now := time.Unix(2_000_000_000, 0)
out, n, err := rebaseInflux(in, now)
if err != nil || n != 2 {
t.Fatalf("n=%d err=%v", n, err)
}
want := "m,host=a v=1 1999999940000000000\nm,host=a v=2 2000000000000000000\n"
if out != want {
t.Fatalf("got %q want %q", out, want)
}
if _, _, err := rebaseInflux("\n# only comments\n", now); err == nil {
t.Fatal("empty input accepted")
}
if !strings.Contains(out, "host=a") {
t.Fatal("tags lost")
}
}
func TestProxmoxMockAuthShape(t *testing.T) {
for h, want := range map[string]bool{
"PVEAPIToken=root@pam!nmon=s3cret": true, "PVEAPIToken=root@pam!nmon=": false, "Bearer x": false, "": false,
} {
if validPVEAuth(h, "") != want {
t.Errorf("%q: want %v", h, want)
}
}
if validPVEAuth("PVEAPIToken=a@b!c=other", "s3cret") {
t.Error("wrong secret accepted")
}
}
func TestLongSamplesCarryVersionedHourlyAndFiveMinuteSeries(t *testing.T) {
end := time.Date(2026, 10, 20, 12, 0, 0, 0, time.UTC)
s := longSamples("d", "I", end, 1, 1, map[string]float64{"in": 8e6})
var h, f int
for _, x := range s {
switch x.Name {
case "nmon_if_rate_bps_5m":
f++
default:
h++
}
}
if f != 288 || h < 2*28*24 {
t.Fatalf("5m=%d hourly=%d", f, h)
}
if s[0].Labels[3].Name != "v" || s[0].Labels[3].Value != "202610001" {
t.Fatalf("labels %+v", s[0].Labels)
}
}

142
cmd/nmon-devtools/main.go Normal file
View File

@@ -0,0 +1,142 @@
// Command nmon-devtools holds developer helpers for the gates and for screenshots: backfill (synthetic
// history into the hot store) and seed-demo (a simulated network added to a running nmon). It talks to nmon
// only through its HTTP API and to the hot store through --hot-url, and is never shipped (docs/dev.md).
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
)
var errUsage = errors.New("usage: nmon-devtools backfill|seed-demo|topology-agents|topology-add|webhook-sink|gate-dump|" +
"proxmox-mock|push-replay|backfill-long|oidc-mock|bcrypt [flags]" +
" (see docs/dev.md)")
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := run(ctx, os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "nmon-devtools:", err)
os.Exit(1)
}
}
func run(ctx context.Context, args []string) error {
if len(args) == 0 {
return errUsage
}
switch args[0] {
case "backfill":
return backfillCmd(ctx, args[1:])
case "seed-demo":
return seedDemoCmd(ctx, args[1:])
case "topology-agents":
return topologyAgentsCmd(ctx, args[1:])
case "topology-add":
return topologyAddCmd(ctx, args[1:])
case "webhook-sink":
return webhookSinkCmd(ctx, args[1:])
case "gate-dump":
return gateDumpCmd(ctx, args[1:])
case "proxmox-mock":
return proxmoxMockCmd(ctx, args[1:])
case "push-replay":
return pushReplayCmd(ctx, args[1:])
case "backfill-long":
return backfillLongCmd(ctx, args[1:])
case "bcrypt":
return bcryptCmd(ctx, args[1:])
case "oidc-mock":
return oidcMockCmd(ctx, args[1:])
default:
return fmt.Errorf("unknown command %q: %w", args[0], errUsage)
}
}
// flagErr turns flag.ErrHelp into a clean exit.
func flagErr(err error) error {
if errors.Is(err, flag.ErrHelp) {
os.Exit(0)
}
return err
}
// stringList is a repeatable string flag.
type stringList []string
func (s *stringList) String() string { return strings.Join(*s, ",") }
func (s *stringList) Set(v string) error {
for _, p := range strings.Split(v, ",") {
if p = strings.TrimSpace(p); p != "" {
*s = append(*s, p)
}
}
return nil
}
// devFlagSet is a FlagSet that prints its own usage to stderr.
func devFlagSet(name string) *flag.FlagSet {
fs := flag.NewFlagSet("nmon-devtools "+name, flag.ContinueOnError)
fs.SetOutput(os.Stderr)
return fs
}
// httpJSON sends an optional JSON body (with the API token in NMON_TOKEN, if set) and decodes a 2xx reply into out (nil: discard).
func httpJSON(ctx context.Context, hc *http.Client, method, url string, in, out any) error {
var body io.Reader
if in != nil {
b, err := json.Marshal(in)
if err != nil {
return err
}
body = strings.NewReader(string(b))
}
req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil {
return err
}
if in != nil {
req.Header.Set("Content-Type", "application/json")
}
if tok := os.Getenv("NMON_TOKEN"); tok != "" { // an nmon with authentication on needs an admin API token
req.Header.Set("Authorization", "Bearer "+tok)
}
resp, err := hc.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }() // body read to the end below
data, err := io.ReadAll(io.LimitReader(resp.Body, 64<<20))
if err != nil {
return err
}
if resp.StatusCode/100 != 2 {
return fmt.Errorf("%s %s: %s: %s", method, url, resp.Status, strings.TrimSpace(string(data[:min(len(data), 300)])))
}
if out == nil {
return nil
}
return json.Unmarshal(data, out)
}
func sleepCtx(ctx context.Context, d time.Duration) bool {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return false
case <-t.C:
return true
}
}

View File

@@ -0,0 +1,102 @@
package main
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"os"
"strings"
"testing"
"time"
"golang.org/x/crypto/bcrypt"
"github.com/v10networks/nmon/internal/auth/oidctest"
)
// devTB lets the test-only mock issuer run outside a test binary: it supplies the three methods oidctest uses
// (Helper, Fatal, Cleanup) and leaves the rest of testing.TB nil.
type devTB struct {
testing.TB
cleanups []func()
}
func (*devTB) Helper() {}
func (*devTB) Fatal(args ...any) { panic(fmt.Sprint(args...)) }
func (d *devTB) Cleanup(f func()) { d.cleanups = append(d.cleanups, f) }
func (d *devTB) close() {
for i := len(d.cleanups) - 1; i >= 0; i-- {
d.cleanups[i]()
}
}
// oidcMockCmd runs internal/auth/oidctest as a standalone OpenID Connect issuer for the M6 gate and for
// screenshots. The issuer approves every login at once as the "current user"; a control listener changes who
// that is: GET /user?sub=ID&name=NAME&groups=a,b on --control. The issuer URL is written to --issuer-file.
func oidcMockCmd(ctx context.Context, args []string) error {
fs := devFlagSet("oidc-mock")
control := fs.String("control", "[::1]:0", "listen address of the control endpoint")
clientID := fs.String("client-id", "nmon", "OIDC client id the issuer accepts")
clientSecret := fs.String("client-secret", "s3cret", "OIDC client secret the issuer accepts")
groupsClaim := fs.String("groups-claim", "groups", "name of the groups claim")
issuerFile := fs.String("issuer-file", "", "file that receives the issuer URL")
portFile := fs.String("port-file", "", "file that receives the control port")
if err := fs.Parse(args); err != nil {
return flagErr(err)
}
tb := &devTB{}
defer tb.close()
srv := oidctest.New(tb)
srv.ClientID, srv.ClientSecret, srv.GroupsClaim = *clientID, *clientSecret, *groupsClaim
ln, err := net.Listen("tcp", *control)
if err != nil {
return err
}
if err := writePort(*portFile, ln); err != nil {
return err
}
if *issuerFile != "" {
if err := os.WriteFile(*issuerFile, []byte(srv.Issuer()), 0o600); err != nil {
return err
}
}
mux := http.NewServeMux()
mux.HandleFunc("GET /user", func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
u := oidctest.User{Sub: q.Get("sub"), Name: q.Get("name"), Email: q.Get("email")}
if g := q.Get("groups"); g != "" {
u.Groups = strings.Split(g, ",")
}
if u.Sub == "" {
http.Error(w, "sub is required", http.StatusBadRequest)
return
}
srv.SetUser(u)
w.WriteHeader(http.StatusNoContent)
})
hs := &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second}
go func() { <-ctx.Done(); _ = hs.Close() }()
fmt.Printf("oidc-mock issuer %s control %s\n", srv.Issuer(), ln.Addr())
if err := hs.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
}
// bcryptCmd prints the bcrypt hash of its argument: the content of a break-glass password file.
func bcryptCmd(_ context.Context, args []string) error {
if len(args) != 1 {
return errors.New("usage: nmon-devtools bcrypt PASSWORD")
}
h, err := bcrypt.GenerateFromPassword([]byte(args[0]), bcrypt.DefaultCost)
if err != nil {
return err
}
fmt.Println(string(h))
return nil
}

View File

@@ -0,0 +1,69 @@
package main
import (
"math"
"time"
)
// Synthetic traffic shapes for `nmon devtools backfill`. Everything here is a
// pure function of its inputs, so a back-fill is reproducible and testable.
const (
patternDiurnal = "diurnal"
patternFlat = "flat"
)
// splitmix64 is a tiny deterministic hash used for noise; math/rand would make
// two runs differ and a global source would be shared state.
func splitmix64(x uint64) uint64 {
x += 0x9e3779b97f4a7c15
x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9
x = (x ^ (x >> 27)) * 0x94d049bb133111eb
return x ^ (x >> 31)
}
// unit maps a hash to [0, 1).
func unit(h uint64) float64 { return float64(h>>11) / (1 << 53) }
// rateFactor is the multiple of the mean rate at t: 1 for "flat"; for
// "diurnal" an evening peak (about 20:00 UTC), an early-morning trough, quieter
// weekends, per-sample noise and the odd short burst.
func rateFactor(pattern string, t time.Time, salt uint64) float64 {
if pattern == patternFlat {
return 1
}
h := float64(t.Hour()) + float64(t.Minute())/60
f := 1 + 0.6*math.Cos(2*math.Pi*(h-20)/24)
if wd := t.Weekday(); wd == time.Saturday || wd == time.Sunday {
f *= 0.8
}
slot := uint64(t.Unix() / 60)
f *= 0.88 + 0.24*unit(splitmix64(salt^slot))
if unit(splitmix64(salt^(slot/4)^0xb5ad4eceda1ce2a9)) < 0.01 { // ~1% of 4-minute slots burst
f *= 2.5
}
return math.Max(f, 0.05)
}
// counterSeries returns n cumulative octet-counter values, one per step from
// start, beginning at 0 and never decreasing. mean is bytes per second.
func counterSeries(pattern string, start time.Time, n int, step time.Duration, mean float64, salt uint64) []float64 {
out := make([]float64, n)
var total float64
for i := 1; i < n; i++ {
t := start.Add(time.Duration(i) * step)
total += mean * rateFactor(pattern, t, salt) * step.Seconds()
out[i] = math.Floor(total)
}
return out
}
// defaultMeanBytesPerSec is a stable per-interface mean (2 to 60 Mbit/s) for when
// the live rate is unknown.
func defaultMeanBytesPerSec(iface string, salt uint64) float64 {
h := salt
for _, c := range []byte(iface) {
h = splitmix64(h ^ uint64(c))
}
return (2 + 58*unit(h)*unit(h>>7)) * 125_000
}

View File

@@ -0,0 +1,56 @@
package main
import (
"testing"
"time"
)
func TestCounterSeriesIsMonotonicAndDeterministic(t *testing.T) {
start := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
a := counterSeries(patternDiurnal, start, 3*24*60, time.Minute, 1_000_000, 7)
b := counterSeries(patternDiurnal, start, 3*24*60, time.Minute, 1_000_000, 7)
for i := range a {
if a[i] != b[i] {
t.Fatalf("not deterministic at %d", i)
}
if i > 0 && a[i] < a[i-1] {
t.Fatalf("decreased at %d", i)
}
}
if a[0] != 0 {
t.Fatalf("starts at %v, want 0", a[0])
}
meanRate := a[len(a)-1] / (float64(len(a)-1) * 60)
if meanRate < 0.8e6 || meanRate > 1.4e6 {
t.Fatalf("mean rate %.0f B/s drifted from 1e6", meanRate)
}
}
func TestDiurnalEveningBusierThanNight(t *testing.T) {
// Monday, so the weekend factor does not interfere. Averaged over many days the noise washes out.
var night, evening float64
for d := 0; d < 28; d++ {
day := time.Date(2026, 9, 7, 0, 0, 0, 0, time.UTC).AddDate(0, 0, d)
if wd := day.Weekday(); wd == time.Saturday || wd == time.Sunday {
continue
}
night += rateFactor(patternDiurnal, day.Add(5*time.Hour), 1)
evening += rateFactor(patternDiurnal, day.Add(20*time.Hour), 1)
}
if evening < 2*night {
t.Fatalf("evening %.2f is not clearly busier than night %.2f", evening, night)
}
}
func TestFlatPatternIsConstant(t *testing.T) {
if f := rateFactor(patternFlat, time.Now(), 3); f != 1 {
t.Fatalf("flat factor %v", f)
}
}
func TestDefaultMeanIsStableAndBounded(t *testing.T) {
m := defaultMeanBytesPerSec("01abc", 1)
if m != defaultMeanBytesPerSec("01abc", 1) || m < 2*125_000 || m > 60*125_000 {
t.Fatalf("mean %v", m)
}
}

View File

@@ -0,0 +1,75 @@
package main
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"os"
"strings"
"time"
)
// proxmoxMockCmd serves a recorded cluster/resources response the way a Proxmox VE API does, for the M5 gate:
// GET /api2/json/cluster/resources with "Authorization: PVEAPIToken=<id>=<secret>". Any other path is a 404 and
// a request without that header shape is a 401, so a client that forgot the secret fails like the real thing.
func proxmoxMockCmd(ctx context.Context, args []string) error {
fs := devFlagSet("proxmox-mock")
listen := fs.String("listen", "[::1]:0", "listen address")
file := fs.String("file", "", "recorded cluster/resources JSON (testdata/proxmox/cluster-resources.json)")
secret := fs.String("secret", "", "token secret to require (empty: any non-empty secret)")
portFile := fs.String("port-file", "", "file that receives the bound port")
if err := fs.Parse(args); err != nil {
return flagErr(err)
}
body, err := os.ReadFile(*file)
if err != nil || *file == "" {
return fmt.Errorf("proxmox-mock needs --file: %w", errors.Join(err, errUsage))
}
ln, err := net.Listen("tcp", *listen)
if err != nil {
return err
}
if err := writePort(*portFile, ln); err != nil {
return err
}
mux := http.NewServeMux()
mux.HandleFunc("GET /api2/json/cluster/resources", func(w http.ResponseWriter, r *http.Request) {
if !validPVEAuth(r.Header.Get("Authorization"), *secret) {
http.Error(w, `{"data":null}`, http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(body) // a client that hung up has nobody to tell
})
srv := &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second}
go func() { <-ctx.Done(); _ = srv.Close() }()
fmt.Printf("proxmox-mock listening on %s\n", ln.Addr())
if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
}
// validPVEAuth accepts "PVEAPIToken=user@realm!id=secret" with the wanted secret (or any, when want is empty).
func validPVEAuth(h, want string) bool {
rest, ok := strings.CutPrefix(h, "PVEAPIToken=")
if !ok {
return false
}
_, got, ok := strings.Cut(rest, "=")
return ok && got != "" && (want == "" || got == want)
}
// writePort records the bound port for a shell script to read.
func writePort(file string, ln net.Listener) error {
if file == "" {
return nil
}
tcp, ok := ln.Addr().(*net.TCPAddr)
if !ok {
return fmt.Errorf("listener is not TCP: %s", ln.Addr())
}
return os.WriteFile(file, []byte(fmt.Sprint(tcp.Port)), 0o600)
}

View File

@@ -0,0 +1,95 @@
package main
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
// pushReplayCmd sends a recorded Influx line-protocol file to a running nmon's POST /write, shifting every
// timestamp so the newest line is "now": a recording is days old by the time a gate replays it, and ingest
// refuses timestamps far from the clock.
func pushReplayCmd(ctx context.Context, args []string) error {
fs := devFlagSet("push-replay")
url := fs.String("url", "", "ingest URL, for example http://127.0.0.1:8443/write")
token := fs.String("token", "", "push token secret (omit to test the unauthenticated case)")
file := fs.String("file", "", "Influx line-protocol file")
extra := fs.String("append", "", "one more line to send after the file (a raw line, rebased like the others)")
if err := fs.Parse(args); err != nil {
return flagErr(err)
}
raw, err := os.ReadFile(*file)
if err != nil || *url == "" {
return fmt.Errorf("push-replay needs --url and --file: %w", errors.Join(err, errUsage))
}
body, n, err := rebaseInflux(string(raw)+"\n"+*extra, time.Now())
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, *url, bytes.NewReader([]byte(body)))
if err != nil {
return err
}
if *token != "" {
req.Header.Set("Authorization", "Bearer "+*token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }() // body read below
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 500))
fmt.Printf("%d lines -> HTTP %d %s\n", n, resp.StatusCode, strings.TrimSpace(string(msg)))
if resp.StatusCode/100 != 2 {
return fmt.Errorf("push refused: HTTP %d", resp.StatusCode)
}
return nil
}
// rebaseInflux shifts the nanosecond timestamp at the end of every line by the same amount so the newest one
// equals now. Lines without a timestamp, blank lines and comments pass through.
func rebaseInflux(text string, now time.Time) (string, int, error) {
lines := strings.Split(text, "\n")
var newest int64
for _, l := range lines {
if ts, ok := lineTS(l); ok {
newest = max(newest, ts)
}
}
shift := now.UnixNano() - newest
var out strings.Builder
n := 0
for _, l := range lines {
l = strings.TrimSpace(l)
if l == "" || strings.HasPrefix(l, "#") {
continue
}
n++
if ts, ok := lineTS(l); ok {
l = l[:strings.LastIndexByte(l, ' ')+1] + strconv.FormatInt(ts+shift, 10)
}
out.WriteString(l + "\n")
}
if n == 0 {
return "", 0, errors.New("no lines to send")
}
return out.String(), n, nil
}
// lineTS returns the trailing nanosecond timestamp of an Influx line, if it has one.
func lineTS(l string) (int64, bool) {
l = strings.TrimSpace(l)
i := strings.LastIndexByte(l, ' ')
if l == "" || strings.HasPrefix(l, "#") || i < 0 {
return 0, false
}
ts, err := strconv.ParseInt(l[i+1:], 10, 64)
return ts, err == nil && ts > 1e15
}

242
cmd/nmon-devtools/seed.go Normal file
View File

@@ -0,0 +1,242 @@
package main
import (
"bytes"
"context"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
"github.com/v10networks/nmon/internal/profile"
"github.com/v10networks/nmon/internal/simagent"
)
// seedConfig is the command line of `devtools seed-demo`.
type seedConfig struct {
api, hotURL string
days int
step time.Duration
countersRec string
envOut string
profilesDir string
workers int
hc *http.Client
}
// seeded is one device after it was added to nmon.
type seeded struct {
demoDevice
slug string
agent *simagent.Agent
ip string
ifaces []seedIface
}
type seedIface struct {
ID string `json:"id"`
Name string `json:"ifname"`
HC bool `json:"has_hc_counters"`
Excluded bool `json:"excluded"`
Unreliable bool `json:"unreliable"`
}
func (i seedIface) graphable() bool { return i.HC && !i.Excluded && !i.Unreliable }
func seedDemoCmd(ctx context.Context, args []string) error {
cfg := seedConfig{hc: &http.Client{Timeout: 3 * time.Minute}}
fs := devFlagSet("seed-demo")
fs.StringVar(&cfg.api, "api", "http://127.0.0.1:8443", "base URL of the running nmon (started with --insecure-http)")
fs.StringVar(&cfg.hotURL, "hot-url", "", "base URL of the hot VictoriaMetrics, for the back-fill (required)")
fs.IntVar(&cfg.days, "days", 31, "days of history to back-fill")
fs.DurationVar(&cfg.step, "step", 60*time.Second, "back-fill sample spacing")
fs.StringVar(&cfg.countersRec, "counters-rec", "testdata/snmprec/counters.snmprec", "the counters fixture recording")
fs.StringVar(&cfg.envOut, "env-out", "", "write NMON_DEVICE=<counters slug> and NMON_DEVICE_48=<48-port slug> to this file when ready")
fs.StringVar(&cfg.profilesDir, "profiles-dir", "", "operator profiles directory (shipped profiles are always loaded)")
fs.IntVar(&cfg.workers, "workers", 6, "parallel back-fill workers")
if err := fs.Parse(args); err != nil {
return flagErr(err)
}
if cfg.hotURL == "" {
return errors.New("--hot-url is required")
}
cfg.api, cfg.hotURL = strings.TrimRight(cfg.api, "/"), strings.TrimRight(cfg.hotURL, "/")
return cfg.run(ctx)
}
func (c *seedConfig) run(ctx context.Context) error {
set, err := profile.Load(c.profilesDir)
if err != nil {
return err
}
fleet, err := buildFleet(set, c.countersRec)
if err != nil {
return err
}
var pool simagent.Pool
defer func() { _ = pool.Close() }() // process is exiting
added, err := c.addAll(ctx, &pool, fleet)
if err != nil {
return err
}
if err := c.backfillAll(ctx, added); err != nil {
return err
}
if err := c.writeEnv(added); err != nil {
return err
}
fmt.Println("seed-demo ready: agents keep answering until interrupted")
<-ctx.Done()
return nil
}
// addAll starts one agent per device on [::1] with its own port and adds the
// device through the API with that port.
func (c *seedConfig) addAll(ctx context.Context, pool *simagent.Pool, fleet []demoDevice) ([]seeded, error) {
var out []seeded
for i, d := range fleet {
cfg := simagent.Config{Addr: "[::1]:0", Recording: d.rec, Community: d.community}
if err := pool.Start(ctx, []simagent.Config{cfg}); err != nil {
return nil, fmt.Errorf("%s: %w", d.name, err)
}
agent := pool.Agents()[i]
s, err := c.addDevice(ctx, d, "::1", int(agent.Addr().Port()))
s.agent = agent
if err != nil {
return nil, fmt.Errorf("add %s: %w", d.name, err)
}
fmt.Printf("added %-12s %-10s %s (%d interfaces)\n", d.name, d.kind, s.slug, len(s.ifaces))
out = append(out, s)
}
return out, nil
}
func (c *seedConfig) addDevice(ctx context.Context, d demoDevice, ip string, port int) (seeded, error) {
version := d.version
if version == "" {
version = "v2c"
}
body := map[string]any{
"name": d.name, "addresses": []string{ip}, "site": d.site, "role": d.role,
"snmp": map[string]any{"version": version, "community": d.community, "port": port},
}
var created struct {
Slug string `json:"slug"`
}
if err := httpJSON(ctx, c.hc, http.MethodPost, c.api+"/api/v1/devices", body, &created); err != nil {
return seeded{}, err
}
s := seeded{demoDevice: d, slug: created.Slug, ip: ip}
var got struct {
Interfaces []seedIface `json:"interfaces"`
}
if err := httpJSON(ctx, c.hc, http.MethodGet, c.api+"/api/v1/devices/"+url.PathEscape(created.Slug), nil, &got); err != nil {
return seeded{}, err
}
if len(got.Interfaces) == 0 {
return seeded{}, errors.New("discovery found no interfaces")
}
s.ifaces = got.Interfaces
return s, nil
}
// backfillAll waits for each device's first live samples, then writes history
// for every graphable interface in parallel.
func (c *seedConfig) backfillAll(ctx context.Context, devs []seeded) error {
type job struct{ dev, iface string }
var jobs []job
probe := &backfillConfig{hotURL: c.hotURL, hc: c.hc}
for _, d := range devs {
for _, i := range d.ifaces {
if d.hc && i.graphable() {
jobs = append(jobs, job{d.slug, i.ID})
}
}
if err := c.waitLive(ctx, probe, d); err != nil {
return err
}
if d.kind == "down" {
_ = d.agent.Close() // goes silent now, so it reads as down by the time the screenshots are taken
}
}
ch := make(chan job)
errs := make(chan error, len(jobs))
var wg sync.WaitGroup
for w := 0; w < max(1, c.workers); w++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := range ch {
cfg := &backfillConfig{hotURL: c.hotURL, device: j.dev, days: c.days, step: c.step, pattern: patternDiurnal,
seed: 1, hc: c.hc, logf: func(string, ...any) {}}
errs <- cfg.backfillIface(ctx, j.iface)
}
}()
}
for _, j := range jobs {
ch <- j
}
close(ch)
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
return err
}
}
fmt.Printf("back-filled %d interfaces, %d days at %s\n", len(jobs), c.days, c.step)
return nil
}
// waitLive blocks until the hot store holds two live samples of the device's
// first graphable interface, so the back-fill can end where they begin.
func (c *seedConfig) waitLive(ctx context.Context, probe *backfillConfig, d seeded) error {
if !d.hc {
return nil
}
var first string
for _, i := range d.ifaces {
if i.graphable() {
first = i.ID
break
}
}
if first == "" {
return nil
}
deadline := time.Now().Add(3 * time.Minute)
for time.Now().Before(deadline) {
live, err := probe.probe(ctx, "ifHCInOctets", first)
if err == nil && live.ok && live.rate > 0 {
return nil
}
if !sleepCtx(ctx, 2*time.Second) {
return ctx.Err()
}
}
return fmt.Errorf("%s: no live samples in the hot store after 3 minutes (is nmon polling? --poll-interval)", d.slug)
}
func (c *seedConfig) writeEnv(devs []seeded) error {
var b bytes.Buffer
for _, d := range devs {
switch d.kind {
case "counters":
fmt.Fprintf(&b, "NMON_DEVICE=%s\n", d.slug)
case "big":
fmt.Fprintf(&b, "NMON_DEVICE_48=%s\n", d.slug)
case "v1":
fmt.Fprintf(&b, "NMON_DEVICE_32BIT=%s\n", d.slug)
case "down":
fmt.Fprintf(&b, "NMON_DEVICE_DOWN=%s\n", d.slug)
}
}
if c.envOut == "" {
return nil
}
return os.WriteFile(c.envOut, b.Bytes(), 0o600)
}

View File

@@ -0,0 +1,84 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/v10networks/nmon/internal/metrics"
)
// parseExport reads VictoriaMetrics' /api/v1/export (JSON lines) and returns
// the earliest sample of the first series.
func parseExport(raw []byte) (liveSeries, error) {
line, _, _ := bytes.Cut(bytes.TrimSpace(raw), []byte("\n"))
if len(line) == 0 {
return liveSeries{}, nil
}
var s struct {
Values []float64 `json:"values"`
Timestamps []int64 `json:"timestamps"`
}
if err := json.Unmarshal(line, &s); err != nil {
return liveSeries{}, fmt.Errorf("decode export: %w", err)
}
if len(s.Values) == 0 || len(s.Values) != len(s.Timestamps) {
return liveSeries{}, nil
}
out := liveSeries{ts: time.UnixMilli(s.Timestamps[0]).UTC(), val: s.Values[0], ok: true}
if len(s.Values) > 1 && s.Timestamps[1] > s.Timestamps[0] {
out.rate = (s.Values[1] - s.Values[0]) / (float64(s.Timestamps[1]-s.Timestamps[0]) / 1000)
}
return out, nil
}
func (c *backfillConfig) get(ctx context.Context, u string, dst io.Writer) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return err
}
resp, err := c.hc.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }() // read to the end below
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 300))
return fmt.Errorf("GET %s: %s: %s", u, resp.Status, b)
}
_, err = io.Copy(dst, resp.Body)
return err
}
// importBatch is how many samples go in one POST (about 5 MB of text).
const importBatch = 50_000
// send writes samples through /api/v1/import/prometheus in batches.
func (c *backfillConfig) send(ctx context.Context, samples []metrics.Sample) error {
for len(samples) > 0 {
n := min(len(samples), importBatch)
body := make([]byte, 0, n*110)
for _, s := range samples[:n] {
body = metrics.AppendPromText(body, s)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.hotURL+"/api/v1/import/prometheus", bytes.NewReader(body))
if err != nil {
return err
}
resp, err := c.hc.Do(req)
if err != nil {
return err
}
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 300))
_ = resp.Body.Close() // reply already read
if resp.StatusCode/100 != 2 {
return fmt.Errorf("import: %s: %s", resp.Status, msg)
}
samples = samples[n:]
}
return nil
}

View File

@@ -0,0 +1,219 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/v10networks/nmon/internal/deps"
"github.com/v10networks/nmon/internal/fixturegen"
"github.com/v10networks/nmon/internal/simagent"
)
// Topology commands for the M4 gate: one simulated SNMP agent per device of a
// topology file (deps.Topology), the devices added to a running nmon with their
// parents, and a webhook receiver that records what nmon sends.
const agentCommunity = "public"
// topologyPort is the UDP port of the i-th device of a topology.
func topologyPort(base, i int) int { return base + i }
// topologyAgentsCmd runs one agent per device on [::1]:port-base+index. The
// agent of device X is down while the file <control-dir>/stop-X exists, so a
// shell script can take a device away and bring it back on the same port.
func topologyAgentsCmd(ctx context.Context, args []string) error {
fs := devFlagSet("topology-agents")
file := fs.String("topology", "", "topology file (testdata/topologies/*.yaml)")
base := fs.Int("port-base", 0, "UDP port of the first device; device i uses port-base+i")
ctl := fs.String("control-dir", "", "directory watched for stop-<slug> files")
if err := fs.Parse(args); err != nil {
return flagErr(err)
}
if *file == "" || *base == 0 || *ctl == "" {
return fmt.Errorf("topology-agents needs --topology, --port-base and --control-dir")
}
topo, err := deps.LoadTopology(*file)
if err != nil {
return err
}
f := &agentFleet{topo: topo, base: *base, ctl: *ctl, agents: map[string]*simagent.Agent{}}
defer f.closeAll()
for {
if err := f.reconcile(ctx); err != nil {
return err
}
if !f.announced {
f.announced = true
fmt.Printf("agents ready: %d devices on [::1]:%d-%d\n", len(topo.Devices), *base, topologyPort(*base, len(topo.Devices)-1))
}
if !sleepCtx(ctx, 200*time.Millisecond) {
return nil
}
}
}
type agentFleet struct {
topo *deps.Topology
base int
ctl string
agents map[string]*simagent.Agent
announced bool
}
// reconcile starts the agents whose stop file is gone and stops those whose stop file appeared.
func (f *agentFleet) reconcile(ctx context.Context) error {
for i, d := range f.topo.Devices {
_, stopped := os.Stat(filepath.Join(f.ctl, "stop-"+d.Slug))
want := stopped != nil // no stop file: the agent should run
a := f.agents[d.Slug]
switch {
case want && a == nil:
rec := fixturegen.Generic(fixturegen.Options{Ports: 4, HCCounters: true, Counters: true, SysName: d.Slug})
cfg := simagent.Config{Addr: fmt.Sprintf("[::1]:%d", topologyPort(f.base, i)), Recording: rec, Community: agentCommunity}
ag, err := simagent.Start(ctx, cfg)
if err != nil {
return fmt.Errorf("agent %s: %w", d.Slug, err)
}
f.agents[d.Slug] = ag
case !want && a != nil:
_ = a.Close() // best-effort: the port is released either way
delete(f.agents, d.Slug)
}
}
return nil
}
func (f *agentFleet) closeAll() {
for _, a := range f.agents {
_ = a.Close() // best-effort at exit
}
}
// topologyAddCmd adds every device of a topology to a running nmon, pointing at
// its agent, then sets the parents by slug.
func topologyAddCmd(ctx context.Context, args []string) error {
fs := devFlagSet("topology-add")
api := fs.String("api", "", "nmon API base URL")
file := fs.String("topology", "", "topology file")
base := fs.Int("port-base", 0, "UDP port of the first device's agent")
mapOut := fs.String("map-out", "", "file that receives one \"<nmon slug> <topology name>\" line per device")
if err := fs.Parse(args); err != nil {
return flagErr(err)
}
if *api == "" || *file == "" || *base == 0 {
return fmt.Errorf("topology-add needs --api, --topology and --port-base")
}
topo, err := deps.LoadTopology(*file)
if err != nil {
return err
}
hc := &http.Client{Timeout: 90 * time.Second}
slugOf := map[string]string{} // topology name -> the slug nmon generated
for i, d := range topo.Devices {
body := map[string]any{
"name": d.Slug, "addresses": []string{"::1"}, "role": d.Role, "kind": d.Kind,
"snmp": map[string]any{"version": "v2c", "community": agentCommunity, "port": topologyPort(*base, i)},
}
var got struct {
Slug string `json:"slug"`
}
if err := httpJSON(ctx, hc, http.MethodPost, *api+"/api/v1/devices", body, &got); err != nil {
return fmt.Errorf("add %s: %w", d.Slug, err)
}
slugOf[d.Slug] = got.Slug // nmon appends a random suffix to the slug it derives from the name
}
for _, d := range topo.Devices {
if len(d.Parents) == 0 {
continue
}
parents := make([]string, len(d.Parents))
for i, p := range d.Parents {
parents[i] = slugOf[p]
}
err := httpJSON(ctx, hc, http.MethodPut, *api+"/api/v1/devices/"+url.PathEscape(slugOf[d.Slug]), map[string]any{"parents": parents}, nil)
if err != nil {
return fmt.Errorf("parents of %s: %w", d.Slug, err)
}
}
fmt.Printf("topology-add ready: %d devices\n", len(topo.Devices))
return writeSlugMap(*mapOut, topo, slugOf)
}
// writeSlugMap records which nmon slug belongs to which topology name, for gate-dump --map and the gate script.
func writeSlugMap(path string, topo *deps.Topology, slugOf map[string]string) error {
if path == "" {
return nil
}
var b strings.Builder
for _, d := range topo.Devices {
fmt.Fprintf(&b, "%s %s\n", slugOf[d.Slug], d.Slug)
}
return os.WriteFile(path, []byte(b.String()), 0o600)
}
// sinkLine is one recorded request.
type sinkLine struct {
At string `json:"at"`
Path string `json:"path"`
Body json.RawMessage `json:"body"`
}
// webhookSinkCmd records every POST body as one JSON line in --out, answers 200,
// and writes the bound port to --port-file once it listens.
func webhookSinkCmd(ctx context.Context, args []string) error {
fs := devFlagSet("webhook-sink")
listen := fs.String("listen", "[::1]:0", "listen address")
out := fs.String("out", "", "file that receives one JSON line per request")
portFile := fs.String("port-file", "", "file that receives the bound port")
if err := fs.Parse(args); err != nil {
return flagErr(err)
}
if *out == "" {
return fmt.Errorf("webhook-sink needs --out")
}
f, err := os.OpenFile(*out, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return err
}
defer func() { _ = f.Close() }() // lines are written whole; nothing is buffered
ln, err := net.Listen("tcp", *listen)
if err != nil {
return err
}
if *portFile != "" {
port := ln.Addr().(*net.TCPAddr).Port
if err := os.WriteFile(*portFile, []byte(fmt.Sprint(port)), 0o600); err != nil {
return err
}
}
var mu sync.Mutex
srv := &http.Server{ReadHeaderTimeout: 10 * time.Second, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20))
raw := json.RawMessage(b)
if !json.Valid(b) {
raw, _ = json.Marshal(string(b))
}
line, _ := json.Marshal(sinkLine{At: time.Now().UTC().Format(time.RFC3339Nano), Path: r.URL.Path, Body: raw})
mu.Lock()
_, _ = f.Write(append(line, '\n'))
mu.Unlock()
w.WriteHeader(http.StatusOK)
})}
go func() { <-ctx.Done(); _ = srv.Close() }()
fmt.Printf("webhook-sink listening on %s\n", ln.Addr())
if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
}

90
cmd/nmon/apiclient.go Normal file
View File

@@ -0,0 +1,90 @@
package main
import (
"context"
"crypto/tls"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
// apiFlags are the flags of every command that talks to a running nmon.
type apiFlags struct {
URL, Token string
SkipVerify bool
}
func (f *apiFlags) register(fs *flag.FlagSet) {
fs.StringVar(&f.URL, "api", os.Getenv("NMON_API"), "base URL of the running nmon, such as https://nmon.example.net:8443 (or set NMON_API)")
fs.StringVar(&f.Token, "token", os.Getenv("NMON_TOKEN"), "admin API token, nma_... (or set NMON_TOKEN)")
fs.BoolVar(&f.SkipVerify, "tls-skip-verify", false,
"do not verify the server certificate (a self-signed certificate on a trusted network)")
}
func (f *apiFlags) validate() error {
if f.URL == "" {
return errors.New("--api is required (the URL of the running nmon)")
}
if f.Token == "" {
return errors.New("--token is required (an admin API token; create one in Settings, or POST /api/v1/tokens)")
}
return nil
}
// client returns an HTTP client without an overall timeout: a backup download can take minutes. ctx bounds each call.
func (f *apiFlags) client() *http.Client {
tr := http.DefaultTransport.(*http.Transport).Clone()
if f.SkipVerify {
tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS12} // opt-in flag for self-signed certificates
}
return &http.Client{Transport: tr, Timeout: 0}
}
// call sends one request and returns the open response when the status is 2xx; any other status is an error
// carrying the problem detail. The caller closes the body.
func (f *apiFlags) call(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) {
ctx, cancel := context.WithTimeout(ctx, 30*time.Minute)
req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(f.URL, "/")+path, body)
if err != nil {
cancel()
return nil, err
}
req.Header.Set("Authorization", "Bearer "+f.Token)
resp, err := f.client().Do(req)
if err != nil {
cancel()
return nil, err
}
if resp.StatusCode/100 != 2 {
defer cancel()
defer func() { _ = resp.Body.Close() }()
return nil, problemError(resp)
}
resp.Body = &cancelOnClose{ReadCloser: resp.Body, cancel: cancel}
return resp, nil
}
type cancelOnClose struct {
io.ReadCloser
cancel context.CancelFunc
}
func (c *cancelOnClose) Close() error { defer c.cancel(); return c.ReadCloser.Close() }
// problemError turns an RFC 9457 response into an error with the server's explanation.
func problemError(resp *http.Response) error {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
var p struct {
Title, Detail string
}
if json.Unmarshal(b, &p) == nil && (p.Title != "" || p.Detail != "") {
return fmt.Errorf("%s: %s", resp.Status, strings.TrimSpace(p.Title+": "+p.Detail))
}
return fmt.Errorf("%s", resp.Status)
}

94
cmd/nmon/backup.go Normal file
View File

@@ -0,0 +1,94 @@
package main
import (
"context"
"errors"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"strconv"
"github.com/v10networks/nmon/internal/backup"
)
func init() {
register("backup", "write a backup of inventory, metrics, config and profiles from a running nmon", backupCmd)
}
// backupCmd asks the running nmon to take its online backup and saves the stream. A running nmon owns the
// stores, so the work happens inside it (POST /api/v1/admin/backup); this command only downloads the result.
func backupCmd(ctx context.Context, args []string) error {
var af apiFlags
var includeKey bool
fs := newFlagSet("backup", "backup FILE --api URL --token TOKEN [--include-key]")
af.register(fs)
fs.BoolVar(&includeKey, "include-key", false, "put the encryption key file in the archive (protect the archive like the key)")
file, rest, err := leadingArg(args)
if err != nil {
return err
}
if err := fs.Parse(rest); err != nil {
return flagErr(err)
}
if file == "" && fs.NArg() == 1 {
file = fs.Arg(0)
}
if file == "" || fs.NArg() > 1 {
return errors.New("usage: nmon backup FILE --api URL --token TOKEN [--include-key]")
}
if err := af.validate(); err != nil {
return err
}
resp, err := af.call(ctx, "POST", "/api/v1/admin/backup?"+url.Values{"include_key": {strconv.FormatBool(includeKey)}}.Encode(), nil)
if err != nil {
return fmt.Errorf("backup: %w", err)
}
defer func() { _ = resp.Body.Close() }()
included := resp.Header.Get("X-Nmon-Key-Included") == "true"
n, err := saveAtomically(file, resp.Body)
if err != nil {
return fmt.Errorf("backup: %w", err)
}
fmt.Println(backup.KeyNotice(included))
fmt.Printf("backup: wrote %s (%d bytes)\n", file, n)
return nil
}
// leadingArg splits a positional first argument from the flags after it ("nmon backup FILE --api ...").
func leadingArg(args []string) (string, []string, error) {
if len(args) > 0 && len(args[0]) > 0 && args[0][0] != '-' {
return args[0], args[1:], nil
}
return "", args, nil
}
// saveAtomically writes r to a temp file beside path, syncs it and renames it, so an interrupted download never
// leaves a truncated file under the final name.
func saveAtomically(path string, r io.Reader) (n int64, err error) {
f, err := os.CreateTemp(filepath.Dir(path), ".nmon-backup-*")
if err != nil {
return 0, err
}
defer func() {
if err != nil {
_ = os.Remove(f.Name()) // best effort: the download error is the one to report
}
}()
if n, err = io.Copy(f, r); err != nil {
_ = f.Close() // best effort: the copy error is the one to report
return 0, err
}
if err = f.Sync(); err != nil {
_ = f.Close() // best effort: the sync error is the one to report
return 0, err
}
if err = f.Close(); err != nil {
return 0, err
}
if err = os.Chmod(f.Name(), 0o600); err != nil {
return 0, err
}
return n, os.Rename(f.Name(), path)
}

View File

@@ -31,8 +31,6 @@ func init() {
for name, summary := range map[string]string{
"discover": "run discovery for a device now",
"profile": "test, record and draft device profiles",
"backup": "write a backup of inventory, metrics, config and profiles",
"restore": "restore a backup into a clean data dir",
"loadtest": "run the scale test against simulated devices",
} {
if _, ok := commands[name]; !ok {

25
cmd/nmon/openapi.go Normal file
View File

@@ -0,0 +1,25 @@
package main
import (
"context"
"fmt"
"os"
"github.com/v10networks/nmon/internal/app"
)
func init() {
register("openapi", "print the generated OpenAPI 3.1 document (nmon openapi > openapi.json)", openapiCmd)
}
func openapiCmd(_ context.Context, args []string) error {
if len(args) > 0 {
return fmt.Errorf("openapi takes no arguments, got %q", args[0])
}
spec, err := app.OpenAPISpec()
if err != nil {
return fmt.Errorf("generate spec: %w", err)
}
_, err = fmt.Fprintf(os.Stdout, "%s\n", spec)
return err
}

229
cmd/nmon/ops.go Normal file
View File

@@ -0,0 +1,229 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"net/http"
"os"
"strings"
"github.com/v10networks/nmon/internal/app"
"github.com/v10networks/nmon/internal/auth"
"github.com/v10networks/nmon/internal/clock"
"github.com/v10networks/nmon/internal/config"
"github.com/v10networks/nmon/internal/keyrotate"
"github.com/v10networks/nmon/internal/secrets"
"github.com/v10networks/nmon/internal/store"
)
func init() {
register("key", "rotate the encryption key: nmon key rotate (nmon stopped)", keyCmd)
register("unlock", "clear break-glass lockouts: nmon unlock [IP|all]", unlockCmd)
register("secret", "store a named secret before the first start: nmon secret set NAME", secretCmd)
register("store", "operate the metric stores of a running nmon: nmon store restart [hot|long]", storeCmd)
}
// openInventory opens the inventory named by the shared store flags. keys may be nil for commands that never
// decrypt (unlock).
func openInventory(ctx context.Context, f *config.Flags, kr *secrets.Keyring) (store.Store, error) {
if f.DataDir == "" {
return nil, errors.New("--data-dir is required")
}
dsn := f.DBDSN
if dsn == "" {
if f.DBDriver != store.DriverSQLite {
return nil, fmt.Errorf("--db-dsn is required for --db-driver %s", f.DBDriver)
}
dsn = config.InventoryDB(f.DataDir)
}
return store.Open(ctx, f.DBDriver, dsn, kr, clock.Real())
}
// keyCmd is "nmon key rotate": a new key version for new data, every credential re-sealed under it, the old
// key kept in the file so older backups still decrypt. nmon must be stopped: a running process holds the old keys.
func keyCmd(ctx context.Context, args []string) error {
if len(args) == 0 || args[0] != "rotate" {
return errors.New("usage: nmon key rotate --data-dir DIR --secret-key-file FILE (stop nmon first)")
}
var f config.Flags
fs := newFlagSet("key rotate", "key rotate --data-dir DIR --secret-key-file FILE [flags]")
storeFlags(fs, &f)
fs.StringVar(&f.SecretKeyFile, "secret-key-file", app.DefaultSecretKeyFile, "the key file to rotate")
if err := fs.Parse(args[1:]); err != nil {
return flagErr(err)
}
res, err := keyrotate.Rotate(ctx, f.SecretKeyFile, func(ctx context.Context, kr *secrets.Keyring) (store.Store, error) {
st, err := openInventory(ctx, &f, kr)
if err != nil {
return nil, err
}
return st, st.Migrate(ctx)
})
if err != nil {
return err
}
fmt.Printf("key rotate: key version %d -> %d; re-encrypted %d SNMP credentials and %d secrets; "+
"the previous key stays in %s so older backups still restore\n",
res.OldVersion, res.NewVersion, res.Credentials, res.Secrets, f.SecretKeyFile)
return nil
}
// unlockCmd clears the break-glass lockout of one address, or of all. It works on the database, so it takes
// effect in a running nmon too.
func unlockCmd(ctx context.Context, args []string) error {
target, rest, err := leadingArg(args)
if err != nil {
return err
}
var f config.Flags
fs := newFlagSet("unlock", "unlock [IP|all] --data-dir DIR [flags]")
storeFlags(fs, &f)
if err := fs.Parse(rest); err != nil {
return flagErr(err)
}
if target == "" && fs.NArg() == 1 {
target = fs.Arg(0)
}
if target == "" {
target = "all"
}
st, err := openInventory(ctx, &f, nil)
if err != nil {
return err
}
defer func() { _ = st.Close() }() // the unlock is committed
if err := st.Migrate(ctx); err != nil {
return err
}
n, err := auth.Unlock(ctx, st, "console", target)
if err != nil {
return err
}
fmt.Printf("unlock: cleared %d lockout(s) for %s\n", n, target)
return nil
}
// storeCmd is "nmon store restart [hot|long]": resume a store the supervisor gave up on.
func storeCmd(ctx context.Context, args []string) error {
if len(args) == 0 || args[0] != "restart" {
return errors.New("usage: nmon store restart [hot|long] --api URL --token TOKEN")
}
name, rest, err := leadingArg(args[1:])
if err != nil {
return err
}
var af apiFlags
fs := flag.NewFlagSet("store restart", flag.ContinueOnError)
af.register(fs)
if err := fs.Parse(rest); err != nil {
return flagErr(err)
}
if err := af.validate(); err != nil {
return err
}
names := []string{"hot", "long"}
if name != "" {
names = []string{name}
}
var errs []error
for _, n := range names {
resp, err := af.call(ctx, http.MethodPost, "/api/v1/admin/stores/"+n+"/restart", nil)
if err != nil {
errs = append(errs, fmt.Errorf("%s: %w", n, err))
continue
}
_ = resp.Body.Close() // 204: nothing to read
fmt.Printf("store restart: %s store resumed\n", n)
}
return errors.Join(errs...)
}
// secretCmd is "nmon secret set NAME": it stores a named secret (the OIDC client secret, a Proxmox token) in the
// inventory without a running nmon, reading the value from --value-file or standard input. It exists because
// OIDC needs its client secret at start while the API that stores secrets needs a way to sign in.
func secretCmd(ctx context.Context, args []string) error {
if len(args) < 2 || args[0] != "set" {
return errors.New("usage: nmon secret set NAME --data-dir DIR --secret-key-file FILE [--value-file FILE] (value on stdin otherwise)")
}
name, rest, err := leadingArg(args[1:])
if err != nil || name == "" {
return errors.New("usage: nmon secret set NAME --data-dir DIR --secret-key-file FILE")
}
var f config.Flags
var valueFile string
fs := newFlagSet("secret set", "secret set NAME --data-dir DIR --secret-key-file FILE [flags]")
storeFlags(fs, &f)
fs.StringVar(&f.SecretKeyFile, "secret-key-file", app.DefaultSecretKeyFile, "the encryption key file")
fs.StringVar(&valueFile, "value-file", "", "read the secret from this file instead of standard input")
if err := fs.Parse(rest); err != nil {
return flagErr(err)
}
value, err := readSecretValue(valueFile)
if err != nil {
return err
}
kr, err := keyringForSecrets(&f)
if err != nil {
return err
}
st, err := openInventory(ctx, &f, kr)
if err != nil {
return err
}
defer func() { _ = st.Close() }() // the secret is committed
if err := st.Migrate(ctx); err != nil {
return err
}
if err := st.PutSecret(ctx, name, value); err != nil {
return err
}
if err := st.AppendAudit(ctx, "console", "secret.put", "secret:"+name, nil, nil); err != nil { // the value is never audited
return err
}
fmt.Printf("secret set: stored %q (encrypted with key version %d)\n", name, kr.CurrentVersion())
return nil
}
// readSecretValue reads the secret from a file or standard input, without its trailing newline.
func readSecretValue(valueFile string) (string, error) {
var src io.Reader = os.Stdin
if valueFile != "" {
vf, err := os.Open(valueFile)
if err != nil {
return "", err
}
defer func() { _ = vf.Close() }() // read-only
src = vf
}
raw, err := io.ReadAll(io.LimitReader(src, 4097))
if err != nil {
return "", err
}
value := strings.TrimRight(string(raw), "\r\n")
if value == "" || len(value) > 4096 {
return "", errors.New("the secret must be 1 to 4096 bytes")
}
return value, nil
}
// keyringForSecrets loads the key file. On a brand-new install (no key file and no inventory yet) it creates the
// key first, as `nmon serve` would, so the OIDC client secret can be stored before the first start.
func keyringForSecrets(f *config.Flags) (*secrets.Keyring, error) {
if _, err := os.Stat(f.SecretKeyFile); errors.Is(err, os.ErrNotExist) {
if _, err := os.Stat(config.InventoryDB(f.DataDir)); err == nil {
return nil, fmt.Errorf("secret key file %s is missing but the inventory exists; restore the key file", f.SecretKeyFile)
}
if err := secrets.GenerateKeyFile(f.SecretKeyFile); err != nil {
return nil, fmt.Errorf("generate secret key: %w", err)
}
fmt.Printf("secret set: created a new encryption key at %s; back it up now\n", f.SecretKeyFile)
}
kr, err := secrets.LoadKeyring(f.SecretKeyFile)
if err != nil {
return nil, fmt.Errorf("secret key: %w", err)
}
return kr, nil
}

47
cmd/nmon/oui.go Normal file
View 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
View 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)
}
}
}

View File

@@ -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))
}

View File

@@ -11,9 +11,8 @@ import (
"github.com/gosnmp/gosnmp"
"github.com/v10networks/nmon/internal/fixturegen"
"github.com/v10networks/nmon/internal/simagent"
"github.com/v10networks/nmon/internal/snmp"
"github.com/v10networks/nmon/internal/snmprec"
)
// defaultRecordSubtrees is what `profile record` walks when --subtree is not given:
@@ -130,7 +129,7 @@ func recordDevice(ctx context.Context, o recordOpts, w io.Writer) (int, error) {
return 0, err
}
defer func() { _ = s.Close() }() // read-only session
var recs []simagent.Record
var recs []snmprec.Record
for _, root := range o.Subtrees {
err := s.Walk(ctx, root, o.MaxReps, func(vb snmp.Varbind) error {
if vb.Exception() {
@@ -146,19 +145,19 @@ func recordDevice(ctx context.Context, o recordOpts, w io.Writer) (int, error) {
return 0, fmt.Errorf("walk %s: %w", root, err)
}
}
rec := simagent.NewRecording(recs)
rec := snmprec.NewRecording(recs)
if rec.Len() == 0 {
return 0, fmt.Errorf("the device returned nothing under %v", o.Subtrees)
}
if err := fixturegen.WriteSnmprec(w, rec); err != nil {
if err := snmprec.Write(w, rec); err != nil {
return 0, err
}
return rec.Len(), nil
}
// redact turns a varbind into a recording row, replacing secrets with REDACTED.
func redact(vb snmp.Varbind, community string) simagent.Record {
r := simagent.Record{OID: vb.OID, Type: vb.Type, Value: vb.Value}
func redact(vb snmp.Varbind, community string) snmprec.Record {
r := snmprec.Record{OID: vb.OID, Type: vb.Type, Value: vb.Value}
if redactedOIDs[vb.OID] {
r.Type, r.Value = gosnmp.OctetString, []byte(redacted)
return r

View File

@@ -14,6 +14,7 @@ import (
"github.com/v10networks/nmon/internal/profile"
"github.com/v10networks/nmon/internal/simagent"
"github.com/v10networks/nmon/internal/snmprec"
)
func bundledMIBs(t *testing.T) string {
@@ -26,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")
}
@@ -121,7 +123,7 @@ const recordSample = `1.3.6.1.2.1.1.1.0|4|Test device
`
func TestProfileRecordRedacts(t *testing.T) {
rec, err := simagent.ParseRecording(strings.NewReader(recordSample))
rec, err := snmprec.Parse(strings.NewReader(recordSample))
if err != nil {
t.Fatal(err)
}
@@ -148,7 +150,7 @@ func TestProfileRecordRedacts(t *testing.T) {
if n := strings.Count(s, "REDACTED"); n != 4 {
t.Errorf("v%s: want 4 REDACTED values, got %d:\n%s", ver, n, s)
}
got, err := simagent.ParseRecording(strings.NewReader(s))
got, err := snmprec.Parse(strings.NewReader(s))
if err != nil {
t.Fatalf("v%s: output does not parse: %v", ver, err)
}
@@ -188,7 +190,7 @@ func TestProfileTestArgs(t *testing.T) {
}
func TestProfileTestPrintsMetrics(t *testing.T) {
rec, err := simagent.LoadRecording("../../testdata/snmprec/juniper-junos.snmprec")
rec, err := snmprec.Load("../../testdata/snmprec/juniper-junos.snmprec")
if err != nil {
t.Fatal(err)
}
@@ -222,3 +224,20 @@ func TestNmonDoesNotImportLibreNMS(t *testing.T) {
}
}
}
// Dev and test machinery stays out of the product binary: nmon-devtools and tests own the simulator,
// the fixture generator and the fixtures. `nmon profile record` needs only internal/snmprec.
func TestNmonDoesNotLinkDevMachinery(t *testing.T) {
out, err := exec.Command("go", "list", "-deps", ".").CombinedOutput()
if err != nil {
t.Skipf("go list unavailable: %v\n%s", err, out)
}
banned := []string{"internal/simagent", "internal/fixturegen", "internal/fixtures", "internal/librenms", "internal/testutil"}
for _, pkg := range strings.Fields(string(out)) {
for _, b := range banned {
if strings.HasSuffix(pkg, "/"+b) {
t.Errorf("cmd/nmon depends on %s", pkg)
}
}
}
}

55
cmd/nmon/restore.go Normal file
View File

@@ -0,0 +1,55 @@
package main
import (
"context"
"errors"
"fmt"
"os"
"github.com/v10networks/nmon/internal/app"
"github.com/v10networks/nmon/internal/backup"
"github.com/v10networks/nmon/internal/config"
"github.com/v10networks/nmon/internal/vmsup"
)
func init() {
register("restore", "restore a backup into a clean data dir (nmon stopped)", restoreCmd)
}
// restoreCmd unpacks a backup into an empty data directory and verifies it before it reports success. It
// runs with nmon stopped, because it starts the restored metric stores itself to compare their series.
func restoreCmd(ctx context.Context, args []string) error {
var f config.Flags
fs := newFlagSet("restore", "restore FILE --data-dir CLEAN_DIR [--secret-key-file PATH]")
fs.StringVar(&f.DataDir, "data-dir", "", "the clean (absent or empty) data folder to restore into (required)")
fs.StringVar(&f.SecretKeyFile, "secret-key-file", "",
"where to write the key file if the backup contains one (default <data-dir>/secret.key; nmon serve wants it outside the data dir)")
file, rest, err := leadingArg(args)
if err != nil {
return err
}
if err := fs.Parse(rest); err != nil {
return flagErr(err)
}
if file == "" && fs.NArg() == 1 {
file = fs.Arg(0)
}
if file == "" || f.DataDir == "" || fs.NArg() > 1 {
return errors.New("usage: nmon restore FILE --data-dir CLEAN_DIR")
}
res, err := backup.Restore(ctx, backup.RestoreOptions{
File: file, DataDir: f.DataDir, KeyFile: f.SecretKeyFile, Log: os.Stdout,
Verifier: backup.NewVMVerifier(vmsup.Options{DataDir: f.DataDir}),
})
if err != nil {
return err
}
fmt.Printf("restore: done and verified: %d inventory rows and %d series matched the manifest\n", res.RowsVerified, res.SeriesVerified)
if res.KeyRestoredTo != "" {
fmt.Printf("restore: the encryption key was restored to %s\n", res.KeyRestoredTo)
} else {
fmt.Printf("restore: this backup has NO key file; start nmon with --secret-key-file pointing at the key that sealed it "+
"(default %s)\n", app.DefaultSecretKeyFile)
}
return nil
}

108
cmd/nmon/rollup.go Normal file
View File

@@ -0,0 +1,108 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/v10networks/nmon/internal/cliout"
"github.com/v10networks/nmon/internal/rollup"
)
func init() {
register("rollup", "repair rollup windows (asks a running nmon)", rollupCmd)
}
const rollupUsage = "usage: nmon rollup repair <from> <to> [--api URL] [--token TOKEN]\n\n" +
"Re-runs every rollup window overlapping [from, to) on the running nmon, writing a new version of\n" +
"each. The range must be inside hot retention (default 30 days). Times are RFC 3339 (2026-10-20T08:00:00Z)\n" +
"or a UTC date (2026-10-20); to is exclusive."
func rollupCmd(ctx context.Context, args []string) error {
if len(args) == 0 || args[0] != "repair" {
return fmt.Errorf("%s", rollupUsage)
}
fs := profileFlagSet("rollup repair", "rollup repair <from> <to> [--api URL]")
var api, token string
var timeout time.Duration
fs.StringVar(&api, "api", "http://[::1]:8443", "base URL of the running nmon")
fs.StringVar(&token, "token", os.Getenv("NMON_TOKEN"), "admin API token (default $NMON_TOKEN)")
fs.DurationVar(&timeout, "timeout", 30*time.Minute, "how long to wait for the repair")
pos, err := parseInterspersed(fs, args[1:])
if err != nil {
return flagErr(err)
}
if len(pos) != 2 {
return fmt.Errorf("%s", rollupUsage)
}
from, err := parseRepairTime(pos[0])
if err != nil {
return err
}
to, err := parseRepairTime(pos[1])
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
rep, err := requestRepair(ctx, api, token, rollup.RepairRequest{From: from, To: to})
if err != nil {
return err
}
w := cliout.New(os.Stdout)
w.Printf("repaired %s to %s: %d five-minute windows, %d hourly windows rewritten\n",
rep.From.Format(time.RFC3339), rep.To.Format(time.RFC3339), rep.FiveMinutes, rep.Hours)
return w.Err()
}
func parseRepairTime(s string) (time.Time, error) {
for _, layout := range []string{time.RFC3339, "2006-01-02T15:04", "2006-01-02"} {
if t, err := time.ParseInLocation(layout, s, time.UTC); err == nil {
return t.UTC(), nil
}
}
return time.Time{}, fmt.Errorf("cannot parse %q as RFC 3339 or YYYY-MM-DD", s)
}
// requestRepair calls POST /api/v1/rollup/repair, which internal/app mounts on
// rollup.Job.Repair.
func requestRepair(ctx context.Context, api, token string, rr rollup.RepairRequest) (*rollup.RepairReport, error) {
body, err := json.Marshal(rr)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(api, "/")+"/api/v1/rollup/repair", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("cannot reach nmon at %s (is it running? use --api): %w", api, err)
}
defer func() { _ = resp.Body.Close() }() // body fully read below; a close error changes nothing
out, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if resp.StatusCode != http.StatusOK {
var p struct{ Title, Detail string }
_ = json.Unmarshal(out, &p)
msg := p.Detail
if msg == "" {
msg = p.Title
}
return nil, fmt.Errorf("repair refused: %d %s", resp.StatusCode, msg)
}
var rep rollup.RepairReport
if err := json.Unmarshal(out, &rep); err != nil {
return nil, err
}
return &rep, nil
}

48
cmd/nmon/rollup_test.go Normal file
View File

@@ -0,0 +1,48 @@
package main
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/v10networks/nmon/internal/rollup"
)
func TestRollupRepairSendsRangeAndTokenToRunningNmon(t *testing.T) {
var got rollup.RepairRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/rollup/repair" || r.Header.Get("Authorization") != "Bearer tok" {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
_ = json.NewDecoder(r.Body).Decode(&got)
_ = json.NewEncoder(w).Encode(rollup.RepairReport{From: got.From, To: got.To, FiveMinutes: 12, Hours: 1})
}))
defer srv.Close()
from, to := time.Date(2026, 10, 20, 8, 0, 0, 0, time.UTC), time.Date(2026, 10, 20, 9, 0, 0, 0, time.UTC)
rep, err := requestRepair(context.Background(), srv.URL, "tok", rollup.RepairRequest{From: from, To: to})
if err != nil || rep.FiveMinutes != 12 || !got.From.Equal(from) || !got.To.Equal(to) {
t.Fatalf("rep=%+v got=%+v err=%v", rep, got, err)
}
if _, err := requestRepair(context.Background(), srv.URL, "wrong", rollup.RepairRequest{}); err == nil {
t.Error("a refused repair must be an error")
}
}
func TestParseRepairTimeAcceptsDatesAndRFC3339(t *testing.T) {
for in, want := range map[string]time.Time{
"2026-10-20": time.Date(2026, 10, 20, 0, 0, 0, 0, time.UTC),
"2026-10-20T08:30": time.Date(2026, 10, 20, 8, 30, 0, 0, time.UTC),
"2026-10-20T08:00:00Z": time.Date(2026, 10, 20, 8, 0, 0, 0, time.UTC),
} {
if got, err := parseRepairTime(in); err != nil || !got.Equal(want) {
t.Errorf("%s: %v %v", in, got, err)
}
}
if _, err := parseRepairTime("yesterday"); err == nil {
t.Error("want error")
}
}

View File

@@ -51,17 +51,31 @@ 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 --insecure-http [flags]")
fs := newFlagSet("serve", "serve --data-dir DIR (--tls-cert F --tls-key F | --acme-host H | --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")
fs.StringVar(&f.Listen, "listen", app.DefaultListen, "address for the API")
fs.BoolVar(&f.InsecureHTTP, "insecure-http", false, "serve plain HTTP with no authentication (required until TLS and auth arrive in M6)")
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.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)
return nil
})
fs.StringVar(&f.ACMEEmail, "acme-email", "", "contact address for the ACME account")
fs.StringVar(&f.ACMEListen, "acme-listen", app.DefaultACMEListen, "address of the ACME http-01 challenge listener")
fs.StringVar(&f.VMHotURL, "vm-hot-url", "", "use this VictoriaMetrics instance as the hot store instead of the embedded one")
fs.StringVar(&f.VMLongURL, "vm-long-url", "", "use this VictoriaMetrics instance as the long store instead of the embedded one")
fs.StringVar(&f.TrapListen, "trap-listen", app.DefaultTrapListen,
"UDP address for SNMP traps; empty disables the receiver "+
"(port 162 needs CAP_NET_BIND_SERVICE; without it traps are off and polling continues)")
fs.StringVar(&f.GraphiteListen, "graphite-listen", app.DefaultGraphiteListen,
"TCP address for Graphite plaintext push (first line \"token <secret>\"); empty disables the listener")
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.IntVar(&f.SNMPPort, "snmp-port", 0, "dev: UDP port of every device's agent (default 161)")

48
deploy/ansible/README.md Normal file
View File

@@ -0,0 +1,48 @@
# 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 # set nmon_breakglass_password at least
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/`. Without OIDC you are sent to `/auth/breakglass` (a browser password prompt; user `admin` by default).
## 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
- **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`.

View File

@@ -0,0 +1,5 @@
[defaults]
inventory = inventory.ini
roles_path = roles
callback_result_format = yaml
host_key_checking = True

View 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

View File

@@ -0,0 +1,43 @@
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
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 = no break-glass file (then OIDC must be configured)
nmon_oidc: {}
nmon_alerting: {}
nmon_trusted_proxies: []
nmon_backup_at: "02:30"
nmon_backup_keep: 7
nmon_retention:
hot: 31d
fiveminute: 120d
long: 2y

View 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

View File

@@ -0,0 +1,182 @@
- 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: Require a sign-in method when serving HTTPS (nmon refuses to start otherwise)
ansible.builtin.assert:
that: nmon_tls_mode == "insecure" or nmon_breakglass_password | length > 0 or nmon_oidc
fail_msg: "Set nmon_breakglass_password and/or nmon_oidc, or use nmon_tls_mode=insecure for a dev-open playground"
- 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"
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 == 'insecure' else 'https' }}://localhost:{{ 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 == 'insecure' else 'https' }}://{{ nmon_server_name }}:{{ nmon_listen | regex_replace('^.*:', '') }}/"
- "{{ 'Signs in via your IdP; break-glass at /auth/breakglass' if nmon_oidc 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."

View 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]

View 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 }}

View File

@@ -0,0 +1,47 @@
# 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_tls_mode == 'insecure' %}
--insecure-http
{% 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
View 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

View File

@@ -0,0 +1,38 @@
# 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
# Break-glass local admin: the way in when the IdP is down (and the only sign-in until OIDC is set).
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

View File

@@ -14,3 +14,41 @@ conscious, reviewed exception.
| 7 | `import-librenms` reads `includes/definitions/*.yaml` and `includes/definitions/discovery/*.yaml` | Reads `resources/definitions/os_detection/*.yaml` and `resources/definitions/os_discovery/*.yaml`, with the old paths as a fallback. | LibreNMS moved them; the spec's paths no longer exist upstream. | M2 |
| 8 | Real recordings replace synthetic fixtures when available | LibreNMS's `tests/snmpsim/*.snmprec` (2,054 real device recordings) are used by optional tests when `NMON_LIBRENMS_DIR` points at a checkout. They are never copied into this repo. | Real-device coverage without vendoring GPL test data into an Apache-2.0 repo. | M2 |
| 9 | `nmon profile import-librenms <repo-path>` is an nmon subcommand | A separate binary, `nmon-librenms-import` (`cmd/nmon-librenms-import`), run offline before nmon. It writes draft profiles that the operator reviews and copies into `<data-dir>/profiles/`. `nmon` neither links nor exposes it. | User decision (2026-10-22). It keeps the importer out of the product binary and makes "the converter never runs at serve time" structural. | M2 |
| 10 | `scripts/openapi-diff` fails on an incompatible change (M3 milestone) | The differ is the Go package `internal/openapidiff` (unit-tested, run with `go run ./internal/openapidiff/cmd/openapidiff`), driven by `scripts/openapi-check.sh`, which `make gate-m3` and the CI `openapi` job call. | A Go package is testable and linted like the rest of the code; the script only fetches the spec at the last `m*`/`v*` tag. No new dependency. | M3 |
| 11 | Built assets are embedded from `web/dist` into `internal/webui` | `make web` copies `web/dist` to `internal/webui/dist` (git-ignored, `.gitkeep` tracked); a committed `internal/webui/placeholder/index.html` ("UI not built") is served when `dist` has no `index.html`. | A tracked `dist/index.html` would be overwritten by every build and dirty the tree, and a built UI must never be committed. `go build` still works without node. | M3 |
| 12 | (not specified) | Developer tools (`backfill`, `seed-demo`) are a separate executable, `cmd/nmon-devtools` (`make devtools` builds `nmon-devtools`), never shipped. It talks to a running nmon over its HTTP API and to the hot store via `--hot-url`. `nmon` has no `devtools` command; depguard and a `go list -deps` test keep `cmd/nmon` from linking `internal/simagent`, `internal/fixturegen`, `internal/fixtures` or `internal/testutil`; `nmon profile record` uses only the `.snmprec` format in `internal/snmprec`. | The gate needs 30 days of history without waiting 30 days, and live screenshots need a simulated network. See `docs/dev.md`. | M3 |
| 13 | (not specified) `/docs` API reference | `/docs` serves a vendored, pinned swagger-ui-dist 5.17.14 (Apache-2.0, about 1.6 MB) through `embed.FS`. | huma's built-in renderers load their assets from a CDN, which an offline monitor cannot rely on. | M3 |
| 14 | M2 defined provisional `poller/health` and `identity-reviews` responses | Both were reshaped in M3 to the reviewed UI contract, before any `openapi.json` existed, so the route freeze starts at `m3`, the first tag with a checked-in spec. | The M2 shapes had no consumer. `scripts/openapi-check.sh` passes against `m2` with a note and diffs for real from `m3`. | M3 |
| 15 | A single-signal failure (ICMP only or SNMP only) is a warning | The warning is held as pending for 5 min before it notifies, and is closed silently if the device goes down meanwhile. | Otherwise a real outage whose second signal fails seconds later sends a spurious warning first. | M4 |
| 16 | An unsupported probe is unknown, never failed | When ICMP is unsupported (host can't open unprivileged ICMP, or per device), SNMP alone decides down, as with `icmp_blocked`, and a one-time `icmp_unsupported` device warning is raised. | Otherwise nmon could never declare a device down on such a host, and nobody would notice. | M4 |
| 17 | M6 gate: "the 2,000-device loadtest cycle finishes under 40 s"; the loadtest acceptance lines (200k interfaces in 40 s, memory stable over 30 min, peak RSS/disk/ingest/latency report, backup during load) | **Skipped** (approved by the owner, 2026-10-22: "skip the device load test… put this in prod asap"). The M6 gate checks only that a viewer gets 403 on PUT. Backup/restore is verified on a normal-size dataset instead of under load. Scale is unproven beyond the fixture and gate sizes (24 devices). | Time to production. | M6 |
| 18 | Push token format not specified | The secret is `nmp_<id>_<base32 of 160 random bits>`; the row stores a salted SHA-256 (`<salt>$<hash>`). | The id selects the row, so authentication is one primary-key lookup and never scans or compares other tokens. | M5 |
| 19 | `rollup_windows` status is `done` or `partial` | A third status, `pending`, is written before the long-store write begins. | A crash between the write and the commit still tells the next run which version number is taken, so it writes `v+1`. | M5 |
| 20 | `v` is a version number on rollup series | `v` is on `nmon_if_rate_bps_5m`, `_avg_1h`, `_max_1h` and the gauge series, encoded `YYYYMM*1000+n` (UTC month of the window, then the version). | Numeric, so MetricsQL can compare versions; the month prefix lets retention drop whole months of 5 m series. Readers use `internal/longq.LatestVersion`. | M5 |
| 21 | Gauge rollups: "a 5 m average, plus a 1 h avg, max and min" | Series are named `<name>_avg_5m`, `<name>_avg_1h`, `<name>_max_1h`, `<name>_min_1h`, with the source labels plus `v`. | The suffix keeps the source name searchable and cannot collide with a polled metric (such names are not rolled up). | M5 |
| 22 | 5 m series are deleted past `retention.fiveminute` | Whole calendar months are deleted with `delete_series` once they ended before `now - retention.fiveminute`, so 5 m data lives 4 to 5 months. | Open-source VictoriaMetrics has one retention per process and no time-ranged delete; the `v` prefix makes each 5 m series one month. | M5 |
| 23 | Rollup writes go through the write queue | Rollup long writes bypass the queue: a synchronous, acknowledged import, then `force_flush`, then a read-back count. | The queue is fire-and-forget (it can drop after 60 s); a window may be recorded done only once the store has it. | M5 |
| 24 | Unreliable (32-bit) interfaces are excluded from p95 and group sums | They are also never rolled up, and a graph of one returns no series. | A rate from a wrapping counter is wrong whichever tier stores it. | M5 |
| 25 | `nmon_group_p95_bps{group, dir, month, members_hash}` | Group (and interface) p95 records also carry a `coverage` label. | A figure below 90% coverage must never be shown without it. | M5 |
| 26 | Check samples labelled by device | `nmon_check_up`, `nmon_check_latency_seconds` and `nmon_tls_expiry_seconds` also carry `check` (the check id). | A device can have several checks. | M5 |
| 27 | Proxmox nodes are devices | A node is the existing SNMP device with that name (or sysName), else a new `check_only` device; its health is the status the API reports. Guests are `vm`/`container` devices keyed by `pve:<cluster>/<vmid>`. | `cluster/resources` has no addresses, so nothing but the name can match a node; a missing SNMP device must not orphan its guests. | M5 |
| 28 | Pushed series carry only `device` | Pushed series also carry `push="1"` (senders cannot set it). | The device page lists what a push sender wrote without guessing from metric names. | M5 |
| 29 | Ingest caps, late p95 months and a stalled rollup "raise an alert" | They are rows of the alerts table with rules `ingest_<kind>`, `p95_late`, `rollup_stalled` and no device, kept open until cleared (ingest alarms close 20 min after the last trip). | They are not metric rules and have no device; the metric-rule evaluator leaves these rule names alone. | M5 |
| 30 | Proxmox cluster config could carry the token secret | Only `secret_name` exists; the secret is stored encrypted via `PUT /api/v1/secrets/{name}` and cannot be read back. | `config.yaml` is plain text. | M5 |
| 31 | HTTPS by default; plain HTTP only with `--insecure-http` (Auth: OIDC only) | With `--insecure-http` **and** neither `auth.oidc` nor `auth.break_glass_file` configured, nmon runs in **dev-open mode**: every request is the admin principal `dev`, a loud warning is logged at start and the UI shows "Authentication disabled — development mode". Any start over TLS with no sign-in method configured refuses to start. If either sign-in method is configured, `--insecure-http` only means plain HTTP and cookies without `Secure`; authentication stays on. | The M1-M5 gates (and local development) run `nmon serve --insecure-http` with no IdP; M6 must not break them. The refusal for TLS-without-auth keeps the unsafe mode from ever being a production start. | M6 |
| 32 | Settings (retention, alert thresholds, IdP, receivers, outbound allow-list) are edited in the UI and reloaded | `PUT /api/v1/settings` and SIGHUP reload in place: alert receivers, the outbound allow-list, trusted proxies, the nightly backup schedule, disk thresholds and the retired-device purge period. Alert rules and their interval, retention, timezone and sites, Proxmox, checks, `auth.oidc`, break-glass, session lifetimes, listen address, TLS and the data dir are saved but reported as `restart_required`. An invalid document is a 422 and changes nothing; the file is rewritten from the validated structure, so comments in a hand-edited `config.yaml` are lost by a UI save. | Those parts are built once at start (stores, evaluators, the OIDC client); rebuilding them live is a larger change than the risk of a restart warrants for v1. | M6 |
| 33 | `nmon backup <file>` takes an online snapshot | `nmon backup` is a client of the running nmon: it calls `POST /api/v1/admin/backup` (admin token), nmon runs the backup in a scratch file under `<data-dir>/backups/.downloads/`, streams it and deletes it, and the client saves it atomically (temp file + rename). The key-included line is printed from the archive's own manifest. `nmon restore` runs offline and starts the restored stores for a moment to verify series. | Only the running process may touch the live stores and the database. | M6 |
| 34 | (not specified) | Beyond the spec, additive to the API/CLI: `GET /api/v1/me`, `GET /api/v1/auth/options` (public), `GET /api/v1/audit` (admin), `POST /api/v1/admin/backup`, `POST /api/v1/admin/stores/{name}/restart`, `POST /api/v1/devices/{slug}/purge`, `POST /api/v1/devices/import`, `GET/PUT /api/v1/settings`; `nmon secret set NAME` (stores the OIDC client secret before the first start; it also creates the key on a brand-new install); API tokens (viewer, admin) share `/api/v1/tokens` with push tokens and have ids like `a7` because the two kinds live in different tables. | OIDC needs its client secret at start, while the API that stores secrets needs a way to sign in: the CLI breaks the cycle. The audit list makes "every use is logged" checkable. | M6 |
| 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 |
| 50 | 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 |
| 51 | (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 |
| 52 | 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 |
| 53 | `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 |
| 54 | (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 |
| 55 | 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 |

91
docs/coverage.md Normal file
View 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.

59
docs/dev.md Normal file
View File

@@ -0,0 +1,59 @@
# Developer notes
## Building the UI into the binary
`make build` runs `make web` first: `npm run build` in `web/`, then `web/dist` is copied to
`internal/webui/dist`, which `//go:embed` packs into the binary. `internal/webui/dist` is git-ignored
(only `.gitkeep` is tracked), so a build never dirties the tree. A plain `go build` without `make web`
embeds a one-line "UI not built" page from `internal/webui/placeholder/`.
nmon serves the UI at `/`. Unknown paths fall back to `index.html` for the client-side router, except
`/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.
## OpenAPI compatibility
`scripts/openapi-check.sh` compares the current spec (`nmon openapi`, else `openapi.json`) with
`openapi.json` at the latest `m*`/`v*` tag and fails on a removed route or method, a removed response
field, a changed field type, a new required parameter or request field, or a removed enum value. A tag
without `openapi.json` passes with a note. `OPENAPI_NEW=file.json` compares that file instead and
`OPENAPI_BASE_TAG=m3` picks the base. The comparison is `internal/openapidiff`.
## `nmon-devtools` (separate dev binary)
Developer helpers in their own executable, `make devtools` -> `nmon-devtools`; the shipped `nmon` does not contain them and never links the simulator. They need a running `nmon serve --insecure-http`.
**backfill** writes synthetic history straight into the hot store so graphs have 30 days of data without
waiting 30 days:
nmon-devtools backfill --hot-url http://127.0.0.1:PORT --device SLUG --iface ULID [--iface ULID...] \
[--days 31] [--step 60s] [--pattern diurnal|flat] [--mean-mbps N]
Per interface it writes `ifHCInOctets`, `ifHCOutOctets` (monotonic, diurnal rate with noise and bursts) and
`ifOperStatus`, ending one step before the first live sample. When live samples exist the history is shifted
so its last value joins the live counter with no reset (this needs a live counter larger than the history,
about 3e13 bytes for 31 days at 100 Mbit/s; otherwise a warning says the join will look like a reset).
The hot URL is in the serve log (`metric stores up hot=...`). Samples older than hot retention are dropped
by the store.
**seed-demo** builds a small simulated network and back-fills it, for the gate and for screenshots:
nmon serve --data-dir D --insecure-http --poll-interval 10s ...
nmon-devtools seed-demo --api http://127.0.0.1:8443 --hot-url http://127.0.0.1:PORT \
[--env-out env] [--counters-rec testdata/snmprec/counters.snmprec]
It starts the simulated SNMP agents in-process on `[::1]`, each on its own port (passed to the API as `snmp.port`), adds the devices through the API (a 48-port switch, access switches, routers, a firewall, a UPS, the counters fixture, a v1 device with 32-bit counters, and one whose agent is stopped after seeding so it reads as down), waits for live polls, back-fills 31 days, writes `NMON_DEVICE`, `NMON_DEVICE_48`, `NMON_DEVICE_32BIT` and `NMON_DEVICE_DOWN` to `--env-out`, and keeps the agents answering until interrupted. The simulated 64-bit counters start at 1e14 so history fits below them.
**oidc-mock** runs `internal/auth/oidctest` as a standalone OpenID Connect issuer for the M6 gate and for screenshots:
nmon-devtools oidc-mock --issuer-file F --port-file P [--client-id nmon --client-secret s3cret]
curl 'http://[::1]:PORT/user?sub=viewer-1&name=Vera&groups=staff' # who the next login is
The issuer approves every login at once as the current user (the control endpoint changes it); `--issuer-file` receives the issuer URL for `auth.oidc.issuer`. **bcrypt PASSWORD** prints a bcrypt hash, the content of a break-glass file. Every devtools command that calls the API sends `Authorization: Bearer $NMON_TOKEN` when that variable is set (an nmon with authentication on needs an admin token).
## Gates
`make gate-m3` runs everything above end to end and writes live screenshots to `docs/screenshots/M3/`.
It needs Playwright's chromium: `cd web && npx playwright install chromium`.
`make gate-m6` starts nmon with a mock IdP and a break-glass file (authentication ON) and checks roles, tokens, break-glass lock-out, backup/restore, key rotation, settings reload, CSV import and the UI; its live screenshots go to `docs/screenshots/M6/`.

View File

@@ -33,7 +33,20 @@ API and UI: openapi.json generated with the TS client and the incompatible-diff
## Gate log
_(filled in by the supervisor when the gate passes)_
**Passed 2026-10-22**, run by the supervisor (Opus).
| Check | Result |
|---|---|
| `make lint` (Go + web) | 0 findings |
| `go test -race ./...`, `-tags 'integration vmembed'`, `test-pg.sh` | all ok |
| `make gate-m1`, `make gate-m2` (with LibreNMS recordings) | PASS |
| `make gate-m3` | PASS: UI embedded in the static binary; SPA fallback and cache headers; 10 simulated devices (`nmon-devtools seed-demo`) with 31 days of back-filled history; port graph renders in and out at 1 h, 24 h, 30 d; 48-port page < 2 s; openapi-check; web client regenerated from the live spec type-checks |
| UI ↔ API contract | `TestGeneratedSpecMatchesUIContract`: the generated `openapi.json` is compatible with the contract the UI was built against |
| Screenshots | `docs/screenshots/M3/` (real data, 18 files) and `docs/screenshots/M3-mock/` (mock data); reviewed by the supervisor and sent to the owner |
**Decisions in M3:** dev tooling is a separate `nmon-devtools` binary (owner's call), never shipped. `/docs` uses vendored Swagger UI. The two M2 provisional routes were reshaped before the route freeze, which starts at `m3`, the first tag with `openapi.json`. Graph stitching joins at now − hot retention; p95 is computed live until M5 materialises it.
**Carried forward:** a demo device that is genuinely Down needs an unreachable address; that comes with M4's reachability work (`device-unresponsive.png` shows Warning/degraded for now, which is correct per policy because ICMP still answers). `nmon profile record` still links the simulator package; to be moved to a shared `internal/snmprec` format package.
## API contract for M3 (written before the WPs so the UI and API are built in parallel)

View File

@@ -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.

View File

@@ -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.

View File

@@ -1,6 +1,6 @@
# M6: Auth and ops
**Gate:** a viewer gets 403 on PUT, and the 2,000-device loadtest cycle finishes under 40 s.
**Gate:** a viewer gets 403 on PUT. ~~and the 2,000-device loadtest cycle finishes under 40 s~~ (skipped: approved deviation, see `docs/DEVIATIONS.md`; the owner prioritised getting to production).
**Gate command:** `make gate-m6`. It runs the viewer-fuzz test (every state-changing route with a viewer session and a viewer token → 403) and then `nmon loadtest --devices 2000 --ports 100 --duration 30m`, which must report cycle p100 < 40 s, zero deadline misses, write-queue high-water < 50%, and stable RSS. It also prints the report metrics (peak RSS, steady and peak disk, ingest samples/s, p95 device-page latency, write-queue high-water, backlog recovery after a 5 min store outage). Finally it runs backup-restore during load.
## Scope
@@ -25,6 +25,11 @@
- `nmon loadtest`: in-process simagents (2,000 × 100 ports on `::1` with distinct ports), nmon serve in-process, traps plus push plus rollup running, metrics collected from `nmon_` series and `/proc/self/status`.
- Platforms: `make build-arm64` cross-compiles. Non-Linux builds compile without `vmembed` and require `--vm-*-url`.
### Decisions recorded during M6 (see `docs/DEVIATIONS.md` rows 31-37)
- **No login UI** (owner decision): page loads without a session redirect (302) to `/auth/login?return=...` (OIDC); API calls get 401; break-glass is `GET /auth/breakglass` with HTTP Basic; the SPA has a user menu (name, role, sign out) and role-aware controls but no login page. Dev-open mode is unchanged.
- Hot reload covers receivers, the outbound allow-list, trusted proxies, backup schedule, disk thresholds and the purge period; the rest is `restart_required`.
- Interfaces gained an operator-owned `description` (migration 8) that the CSV port import writes and discovery never overwrites.
## Work packages
| WP | Files | Depends on |
@@ -32,7 +37,7 @@
| WP1 OIDC, sessions, CSRF, tokens, break-glass, proxy, TLS | `internal/auth/**`, API middleware | M3 API |
| WP2 Backup, restore, key rotate, disk guard, store restart | `internal/backup/**`, `internal/diskguard/**`, `cmd/nmon/{backup,restore,key,store,unlock}.go` | M1 vmsup, store |
| WP3 Config reload, settings API and UI, CSV import, purge | `internal/config/**`, `internal/api/settings*.go`, `internal/importcsv/**`, `web/src/routes/settings*` | M4 deps |
| WP4 Loadtest, viewer fuzz, gate | `internal/loadtest/**`, `cmd/nmon/loadtest.go`, `scripts/gate-m6.sh` | all |
| WP4 Viewer fuzz, backup/restore check, gate (loadtest skipped, approved deviation) | `internal/loadtest/**`, `cmd/nmon/loadtest.go`, `scripts/gate-m6.sh` | all |
## Acceptance lines closed
@@ -40,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).

View File

@@ -14,10 +14,11 @@ 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) | in progress (web UI done on mock data) |
| M4 | Alerting | stopping a parent fixture sends one notification naming it and its children | [M4](M4-alerting.md) | in progress (WP2 rules and 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 and the 2,000-device loadtest cycle finishes under 40 s | [M6](M6-auth-and-ops.md) | planned |
| 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) | **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
@@ -47,6 +48,16 @@ Departures from the spec are recorded in [`docs/DEVIATIONS.md`](../DEVIATIONS.md
- **Deployment playbook**: install layout (binary, `/usr/share/nmon/mibs` sidecar, systemd unit granting port 162, key file location), TLS/ACME or proxy setup, IdP configuration, backups, and upgrades. Raised by the user on 2026-10-22; deliberately postponed.
- **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.
- **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.

View 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.

View 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.

View 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 55).
- **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 53).
- **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.

121
docs/operations.md Normal file
View File

@@ -0,0 +1,121 @@
# Operating nmon
How to run, secure, back up and maintain a production nmon. Design background: `docs/spec/spec.md` ("Auth", "Operations").
## 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.
## Sign-in
`config.yaml`:
auth:
oidc:
issuer: https://idp.example.net/realms/net
client_id: nmon
client_secret_name: oidc-client # the secret itself is never in this file
redirect_url: https://nmon.example.net/auth/callback
groups_claim: groups
admin_group: nmon-admins # members are admins; every other user is a viewer
break_glass_file: /etc/nmon/breakglass # bcrypt hash, "hash" or "user:hash", created by you
Store the client secret before the first start: `printf '%s\n' "$SECRET" | nmon secret set oidc-client --data-dir D --secret-key-file K` (it can also be set later with `PUT /api/v1/secrets/oidc-client`; OIDC stays off until the secret exists). The break-glass file holds one bcrypt hash (`$2a$`/`$2b$`), made with any bcrypt tool (the source tree's `nmon-devtools bcrypt PASSWORD` works); keep it readable by the nmon user only.
- There is no login page. Opening nmon without a session redirects to the IdP and back; API clients use a token. If the IdP is down, open `/auth/breakglass`: the browser asks for the local admin user and password (HTTP Basic), and a successful sign-in lands on the page you asked for.
- Break-glass: five wrong passwords lock the source address for 15 minutes; every use and every lock is audited and raises an alert. `nmon unlock [IP|all] --data-dir D` clears locks, also while nmon runs.
- API tokens: an admin mints viewer, admin or push tokens (`POST /api/v1/tokens`, or Settings, API tokens). Use `Authorization: Bearer nma_...`. A push token works for ingest only. Revoking takes effect on the next request.
- `GET /api/v1/audit` (admin) lists who did what.
## Backup and restore
A running nmon owns the stores, so copying the data folder is **not** a backup.
nmon backup /backups/nmon.tar --api https://nmon.example.net --token nma_... # key NOT included
nmon backup /backups/nmon.tar --include-key --api ... --token ... # key included
Every run prints whether the key file is in the archive. **Lose the key file and a restore has an inventory with no SNMP credentials.** The nightly schedule is `backup: {at: "02:30", dir: /var/backups/nmon, keep: 7}` (time in the configured timezone, default dir `<data-dir>/backups`); a failed run raises the `backup_failed` alert. A backup is refused when the disk is below its 5% reserve.
Restore with nmon stopped, into a clean directory:
nmon restore /backups/nmon.tar --data-dir /var/lib/nmon-restored --secret-key-file /etc/nmon/secret.key
It verifies inventory row counts and a sample of series before it says done. Then start nmon on that directory. Restore needs the embedded stores (the normal release build).
## Keys
`nmon key rotate --data-dir D --secret-key-file K` (nmon stopped) adds a new key version, re-encrypts every SNMP credential and secret under it, and keeps the old key in the file so older backups still restore. Remove old keys only after a release.
## 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.
## 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]}}}
```
## 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).
## 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 60 s 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 `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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 581 KiB

After

Width:  |  Height:  |  Size: 607 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 600 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Some files were not shown because too many files have changed in this diff Show More