45 Commits
m2 ... main

Author SHA1 Message Date
Vibe Coder
3a6afdda08 Fix two integration test failures seen in CI
Some checks failed
ci / lint (push) Successful in 1m19s
ci / openapi (push) Successful in 28s
ci / postgres (push) Successful in 3m32s
ci / test (push) Failing after 8m34s
TestRunReturnsStartError expected a start with no TLS flags to fail;
since the first-install wizard that start succeeds (install mode with
a self-signed certificate) and Run blocked until the 10 min timeout.
Use --tls-cert without --tls-key, which is always refused.

TestICMPStateMachine could advance the fake clock twice in one step on
a slow runner, firing an extra probe ([1 1 1 0 0 1 1]). Advance once,
only after the ICMP loop has re-armed its timer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TuKgyKve2XLFtrrXWMi2w
2026-10-25 21:30:41 +00:00
Vibe Coder
4ae44e36fc Record the distributed poller as a deferred v2 item
Some checks failed
ci / lint (push) Successful in 1m16s
ci / openapi (push) Successful in 30s
ci / postgres (push) Successful in 3m38s
ci / test (push) Has been cancelled
The owner asked whether remote pollers could work per site over an
outbound-only MQTT link, keep polling while the master is down, and
receive device credentials sealed to their own key. The assessment
found it feasible but deferred it to v2, so the requirements, the gaps
in the current code and the main risks are written down for later.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:26:26 +00:00
Vibe Coder
26fdcdc124 Match LibreNMS port graphs: totals, max, aggregate 95th, compare, utilisation
Some checks failed
ci / lint (push) Successful in 1m25s
ci / openapi (push) Successful in 29s
ci / postgres (push) Successful in 3m32s
ci / test (push) Failing after 17m53s
Operators coming from LibreNMS expect the same figures under a port
graph. Add them:

- Totals: bytes transferred in and out over the selected range
  (GraphResponse.totals), integrated from the average rate across the
  stitched raw and hourly tiers without double counting; partial
  coverage is flagged.
- Summaries read Now / Avg / Max / 95th / Total per direction, plus an
  aggregate (in+out) 95th (p95.aggregate_bps, hot store only).
- Utilisation as a percentage of port speed, and a "% of speed" axis.
- "Compare previous period" overlay on the graph view and expanded row.
- Errors and discards graphs (GET /api/v1/graph/counters), hot-store
  range only since they have no rollups. Packet and broadcast/multicast
  graphs stay disabled: those counters are not polled (DEVIATIONS 56).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TuKgyKve2XLFtrrXWMi2w
2026-10-25 21:02:32 +00:00
Vibe Coder
f31fb3712a Add settings forms, first-install wizard, webhook presets, help and poll tools
Some checks failed
ci / lint (push) Successful in 4m19s
ci / openapi (push) Successful in 29s
ci / postgres (push) Successful in 4m55s
ci / test (push) Failing after 19m48s
Make nmon easier to install and run without editing YAML by hand:

- Settings: point-and-click forms over config.yaml (GET/PUT
  /api/v1/settings/form); the YAML editor moves to /settings/yaml.
- First install: with no sign-in configured nmon starts in install
  mode, serves only the setup wizard (sign-in, basics, devices,
  alerting) and closes it for good once sign-in is saved. A
  self-signed certificate is generated when no TLS source is given.
  poller.interval becomes a config setting.
- Reverse proxies: opt-in --http-listen plain-HTTP listener beside
  HTTPS; sign-in stays required.
- Webhooks: Generic, Teams Adaptive Card, Slack and Discord presets,
  template preview and test send.
- Help: the operations, coverage and profile docs served at /help.
- Polling: per-device "Poll now", a dry-run test poll returning an
  SNMP trace, and time-limited in-memory SNMP debug recording, in the
  UI and as `nmon device poll`.
- Ansible: deploys into install mode when no sign-in is set, and no
  longer overwrites the config.yaml the wizard wrote.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TuKgyKve2XLFtrrXWMi2w
2026-10-25 06:31:12 +00:00
Vibe Coder
b89224254e Ignore the nmon-dev binary
It was committed by accident in the M3 work and removed from the remote;
ignoring it keeps a local build from being added again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TuKgyKve2XLFtrrXWMi2w
2026-10-24 19:05:09 +00:00
e60ae0a850 Delete nmon-dev
All checks were successful
ci / lint (push) Successful in 4m14s
ci / postgres (push) Successful in 4m13s
ci / openapi (push) Successful in 27s
ci / test (push) Successful in 14m22s
2026-10-24 19:04:01 +00:00
Vibe Coder
dbfeeeebc7 Merge v1.4: port graph ranges and p95, per-device poll interval (300 s default)
Some checks failed
ci / lint (push) Successful in 4m8s
ci / postgres (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / test (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TuKgyKve2XLFtrrXWMi2w
2026-10-24 18:56:16 +00:00
Vibe Coder
273c6b541b Run CI on the Gitea debian-13 runner
The remote is a Gitea server whose only runner label is debian-13, so the
ubuntu-latest jobs never got picked up. The arm64 test leg is dropped
because no arm runner exists there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TuKgyKve2XLFtrrXWMi2w
2026-10-24 18:55:43 +00:00
Vibe Coder
881f0ae3b1 Poll every 300 s by default, with a per-device poll interval
The owner wants LibreNMS-like 5-minute polling by default and the option
to poll a given device faster or slower. Devices gain poll_interval_s
(new migration; 0 means the default, otherwise 30 to 3600 s), settable in
the device edit dialog and through PUT /api/v1/devices/{slug}; the
poller picks it up on the next inventory refresh.

Everything that assumed one-minute samples now follows the device's
interval: graph and p95 rate windows are at least two polls, the max
series uses irate, the up-series lookback, the no_data warning, the
"unheard of for three polls" fallback and the rollup's thin-window check.
interface_errors widens to 10 m. profiletest keeps 60 s because the
expect budgets are keyed by the 1m group. Recorded as DEVIATIONS 49.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TuKgyKve2XLFtrrXWMi2w
2026-10-24 18:55:43 +00:00
Vibe Coder
55265698e9 Add range, p95 and a summary line to the port graph grid and expanded rows
The Ports grid only drew a fixed 24 h graph per port, and an expanded row
was fixed at 24 h too, so there was no way to look at 1 h, 7 d or 30 d
the way LibreNMS allows, and the grid never showed p95. The grid now has
one range picker (1 h to 1 y) and a p95 toggle shared by every card, and
each card shows current, average and p95 per direction under the graph.
An expanded row gets its own range picker and p95 toggle, and "Open graph
view" keeps the chosen range.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TuKgyKve2XLFtrrXWMi2w
2026-10-24 18:55:43 +00:00
Vibe Coder
33948d3ee8 Ignore the local MCP vector-search index
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 .mcp-vector-search/ directory is a per-machine semantic search index,
not source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4PdJUPrakM8fHvN8jYv2x
2026-10-24 06:16:52 +00:00
Vibe Coder
674816c98c Merge v1.3: BGP peers, MAC/ARP/FDB lookup and OUI vendors
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
Brings in the L2 table job, Find host, the L2 and Routing tabs, the BGP page,
bgp_peer_down and the optional IEEE vendor registry on top of v1.2 custom
alert rules. Conflicts were the shared backup table list, DEVIATIONS rows
(v1.3's 50-55 renumbered to 43-48) and the generated openapi.json and web
schema, which were regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4PdJUPrakM8fHvN8jYv2x
2026-10-24 01:44:15 +00:00
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
b529423324 Add custom alert rules built from metrics and inventory fields
The predefined rules are too constraining: an ISP cannot say "CPU over
80% on servers for 15 minutes" or "uplink without a description", and
every new need meant editing config.yaml. Admins now build their own
rules; the shipped ones stay as read-only built-in macros with their
names unchanged, so v1.1 policies and alert history keep working.

Custom rules live in a new alert_rules table (migration 13) and are
builder-only. A metric rule names a catalogue metric (every pollable
profile metric plus push-ingested names) with a transform (value, rate,
percent_of), window, function, aggregate, comparison and threshold; the
pure internal/alertrule package compiles it to MetricsQL from fixed
templates, so no user text reaches a query. An inventory rule ANDs one to
five conditions over whitelisted device or port fields and is evaluated
in Go over store rows. The evaluator takes its rule set from a function
each cycle, so edits apply live, and both kinds use the existing state
machine, policies, suppression and notifications. Comparisons now cover
> >= < <= == != everywhere. utilisation is not offered: port speed is not
a stored series. config.yaml rules still work but are deprecated.

API (additive): rule list gains id/kind/editable/spec/expr, plus get,
create, update, delete, catalogue, test (viewer, read-only) and copy of
a built-in (cpu_high, ups_on_battery, printer_supply_empty) to a custom
rule. Deleting or renaming a rule that a policy names is a 409. The UI
gets an Alert rules tab with a builder dialog, a test button and the
generated query, and the policy dialog lists custom rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4PdJUPrakM8fHvN8jYv2x
2026-10-24 00:17:48 +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
1048 changed files with 113776 additions and 2685 deletions

View File

@@ -3,7 +3,7 @@ on: [push, pull_request]
jobs:
lint:
# Hard rule (docs/MAINTAINABILITY.md): any finding fails the build.
runs-on: ubuntu-latest
runs-on: debian-13
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
@@ -13,10 +13,7 @@ jobs:
- run: cd web && npm ci
- run: make lint
test:
strategy:
matrix:
arch: [ubuntu-latest, ubuntu-24.04-arm]
runs-on: ${{ matrix.arch }}
runs-on: debian-13
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
@@ -30,14 +27,14 @@ jobs:
- run: make test-v6only
- run: make build && file nmon | grep -q 'statically linked'
postgres:
runs-on: ubuntu-latest
runs-on: debian-13
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version: stable }
- run: make test-pg
openapi:
runs-on: ubuntu-latest
runs-on: debian-13
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }

10
.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,12 @@ 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
# MCP Vector Search index directory
.mcp-vector-search/
/nmon-dev

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

98
cmd/nmon/devicepoll.go Normal file
View File

@@ -0,0 +1,98 @@
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net/http"
"net/url"
"os"
"github.com/v10networks/nmon/internal/cliout"
"github.com/v10networks/nmon/internal/poller"
)
func init() {
register("device", "operate on one device of a running nmon: nmon device poll <slug> [--test] [--json]", deviceCmd)
}
const devicePollUsage = "usage: nmon device poll <slug> [--test [--interval SECONDS]] [--json] --api URL --token TOKEN"
// deviceCmd is "nmon device poll": ask the running nmon to poll one device now, or with --test to run a traced
// test poll (the same as the device page's Test poll). The test never writes metrics.
func deviceCmd(ctx context.Context, args []string) error {
if len(args) == 0 || args[0] != "poll" {
return errors.New(devicePollUsage)
}
slug, rest, err := leadingArg(args[1:])
if err != nil || slug == "" {
return errors.New(devicePollUsage)
}
var af apiFlags
var test, asJSON bool
var interval int
fs := flag.NewFlagSet("device poll", flag.ContinueOnError)
af.register(fs)
fs.BoolVar(&test, "test", false, "run a traced test poll now and print it; nothing is stored")
fs.IntVar(&interval, "interval", 0, "with --test: only the interval group with this cadence in seconds (default every group)")
fs.BoolVar(&asJSON, "json", false, "with --test: print the trace as JSON")
if err := fs.Parse(rest); err != nil {
return flagErr(err)
}
if err := af.validate(); err != nil {
return err
}
base := "/api/v1/devices/" + url.PathEscape(slug)
if !test {
resp, err := af.call(ctx, http.MethodPost, base+"/poll-now", nil)
if err != nil {
return err
}
_ = resp.Body.Close() // 202 with a one-field body that says nothing more
fmt.Printf("poll queued: %s will be polled now\n", slug)
return nil
}
resp, err := af.call(ctx, http.MethodPost, fmt.Sprintf("%s/poll-test?interval_seconds=%d", base, interval), nil)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }() // read in full below
if asJSON {
_, err = io.Copy(os.Stdout, resp.Body)
return err
}
var tr poller.Trace
if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil {
return fmt.Errorf("decode the trace: %w", err)
}
return printTrace(os.Stdout, &tr)
}
// printTrace writes the summary, the requests and every OID that is not ok; --json has the full list.
func printTrace(out io.Writer, tr *poller.Trace) error {
w := cliout.New(out)
w.Printf("test poll of %s: %s, SNMP %s, profile %s, %d ms\n", tr.Device, tr.Target, tr.SNMPVersion, tr.Profile, tr.DurationMS)
w.Printf(" requested %d, returned %d, missing %d, skipped %d\n", tr.Requested, tr.Returned, tr.Missing, tr.Skipped)
for _, g := range tr.Groups {
w.Printf("group %d s: %d ms, requested %d, returned %d, missing %d, would store %d\n",
g.IntervalSeconds, g.DurationMS, g.Requested, g.Returned, g.Missing, g.Samples)
if g.Error != "" {
w.Printf(" error: %s\n", g.Error)
}
for _, r := range g.Requests {
w.Printf(" request %d: %d varbinds from %s, %d ms, %s %s\n", r.Seq, r.Varbinds, r.FirstOID, r.DurationMS, r.Outcome, r.Error)
}
for _, r := range g.Results {
if r.Status != poller.StatusOK {
w.Printf(" %-18s %s %s %s %s\n", r.Status, r.OID, r.Series, r.Label, r.Detail)
}
}
if g.Truncated {
w.Printf(" (only %d of %d rows kept; use --json for the full list)\n", len(g.Results), g.ResultsTotal)
}
}
return w.Err()
}

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,19 +51,39 @@ 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 | --http-listen ADDR | --insecure-http] [flags]")
storeFlags(fs, &f)
fs.StringVar(&f.SecretKeyFile, "secret-key-file", app.DefaultSecretKeyFile,
"SNMP credential key file; created on first run; must be outside --data-dir")
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.HTTPListen, "http-listen", "",
"opt-in plain-HTTP listener for running behind a reverse proxy, e.g. [::1]:8080 (any interface allowed; empty = off); "+
"sign-in stays required (a fresh install opens the setup wizard first). Runs beside HTTPS, or alone when no TLS "+
"is configured. List the proxy in auth.trusted_proxies. "+
"Cannot be combined with --insecure-http")
fs.StringVar(&f.TLSCert, "tls-cert", "", "PEM certificate chain for HTTPS (reloaded on SIGHUP); needs --tls-key. "+
"With no TLS flag at all nmon serves HTTPS with a self-signed certificate kept in <data-dir>/tls")
fs.StringVar(&f.TLSKey, "tls-key", "", "PEM private key for --tls-cert")
fs.Func("acme-host", "obtain a Let's Encrypt certificate for this host name (repeatable); needs port 80 reachable", func(v string) error {
f.ACMEHosts = append(f.ACMEHosts, v)
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.DurationVar(&f.PollInterval, "poll-interval", 0, "dev: default SNMP poll interval (default 300s); a device's own interval overrides it")
fs.IntVar(&f.SNMPPort, "snmp-port", 0, "dev: UDP port of every device's agent (default 161)")
if err := fs.Parse(args); err != nil {
return flagErr(err)

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

@@ -0,0 +1,51 @@
# Deploying nmon with Ansible
Installs nmon as a systemd service on one Linux host (amd64 or arm64). Tested against Debian 13.
## Quick start
cd deploy/ansible
cp inventory.example.ini inventory.ini # your host
cp vars.example.yml vars.yml # no sign-in set = the setup wizard runs on first open
ansible-playbook site.yml -e @vars.yml
The first play builds the release binary on your machine (`make build build-arm64`: needs Go, Node and network).
Pass `-e nmon_build=false` to reuse an existing `./nmon`. The second play installs it on the hosts in group `nmon`:
- `/usr/local/bin/nmon`, user `nmon`, data in `/var/lib/nmon`, key and TLS files in `/etc/nmon`, backups in `/var/backups/nmon`
- the MIB sidecar in `/usr/share/nmon/mibs`, used only by `nmon profile new --mib`
- optionally the IEEE vendor registry CSVs in `/usr/share/nmon/oui`, loaded with `nmon oui import` (`nmon_oui_download`, `nmon_oui_csv_dir`; by default nmon fetches the registry itself)
- `config.yaml` rendered from your vars, and a hardened systemd unit (`systemctl reload nmon` sends SIGHUP to reload config and certificates)
- `net.ipv4.ping_group_range` opened so nmon can ping without root
Then open `https://<host>:8443/`. With no sign-in in vars, nmon starts in install mode and the setup wizard asks for one
(anyone who can reach the port can run it until it is done). Otherwise, without OIDC you are sent to `/auth/breakglass`.
## TLS (`nmon_tls_mode`)
| Mode | What it does |
|---|---|
| `selfsigned` (default) | Generates a cert on the host. Good for trying it out; browsers will warn. |
| `files` | Copies `nmon_tls_cert_src` / `nmon_tls_key_src` from the control machine. |
| `acme` | Let's Encrypt for `nmon_server_name`; port 80 must reach the host. |
| `insecure` | Plain HTTP. With no sign-in configured, nmon runs in dev-open mode where everyone is admin. Never expose this. |
**Reverse proxy:** nmon can sit behind one. Keep the proxy-to-nmon hop on HTTPS (`selfsigned` is fine there), set
`nmon_trusted_proxies` to the proxy's addresses, and set the OIDC `redirect_url` to the public URL. Traps (UDP 162) and
Graphite (TCP 2003) go to nmon directly, not through the HTTP proxy.
## Sign-in
- **Setup wizard:** leave `nmon_breakglass_password` and `nmon_oidc` unset. The play then writes `config.yaml` only
if it is missing, so later runs keep the sign-in the wizard saved (and ignore other config vars; edit those in the UI).
- **OIDC:** set `nmon_oidc` (issuer, client_id, client_secret, admin_group). The secret is stored encrypted with
`nmon secret set` and never written to `config.yaml`. Browsers go straight to the IdP; there is no login page.
- **Break-glass:** `nmon_breakglass_password` is bcrypt-hashed on the host. Use it when the IdP is down. Five wrong
attempts lock the source IP for 15 min; run `nmon unlock all --data-dir /var/lib/nmon` to clear.
## After installing
- **Back up `/etc/nmon/secret.key`** somewhere safe. Without it, a restore has no SNMP credentials.
- Add devices in the UI or with `POST /api/v1/devices`, and mint API or push tokens under Settings.
- The playbook owns `config.yaml`: edits made in the UI are overwritten on the next run, so put lasting settings in vars.
- More: `docs/operations.md`.

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,46 @@
nmon_user: nmon
nmon_data_dir: /var/lib/nmon
nmon_etc_dir: /etc/nmon
nmon_key_file: "{{ nmon_etc_dir }}/secret.key"
nmon_backup_dir: /var/backups/nmon
nmon_mibs_dir: /usr/share/nmon/mibs
nmon_install_mibs: true
# MAC vendors (optional IEEE registry). nmon downloads it itself unless nmon_oui_fetch is false; to ship it instead,
# set nmon_oui_download (the host downloads the CSVs) or nmon_oui_csv_dir (a directory of the three CSVs on the control
# machine). Either way they are kept next to the MIB sidecar and loaded with `nmon oui import`.
nmon_oui_dir: /usr/share/nmon/oui
nmon_oui_fetch: true
nmon_oui_download: false
nmon_oui_csv_dir: ""
nmon_oui_urls:
- https://standards-oui.ieee.org/oui/oui.csv
- https://standards-oui.ieee.org/oui28/mam.csv
- https://standards-oui.ieee.org/oui36/oui36.csv
# MAC/ARP table job (see docs/operations.md); empty uses the defaults.
nmon_l2: {}
nmon_listen: "[::]:8443"
nmon_trap_listen: "[::]:162"
nmon_graphite_listen: "[::]:2003"
nmon_icmp: auto
nmon_timezone: UTC
nmon_tls_mode: selfsigned # selfsigned | files | acme | insecure | none (none: plain HTTP only, needs nmon_http_listen)
# Opt-in plain-HTTP listener for a reverse proxy, e.g. "[::1]:8080"; sign-in stays required. Empty = off.
# Runs beside HTTPS, or alone with nmon_tls_mode=none. Not allowed with nmon_tls_mode=insecure.
nmon_http_listen: ""
nmon_server_name: "{{ inventory_hostname }}"
nmon_acme_email: ""
nmon_tls_extra_names: [] # more DNS names / IPs for the self-signed cert
nmon_breakglass_user: admin
nmon_breakglass_password: "" # empty (and no OIDC) = nmon starts in install mode: the setup wizard sets sign-in
nmon_oidc: {}
nmon_alerting: {}
nmon_trusted_proxies: []
nmon_backup_at: "02:30"
nmon_backup_keep: 7
nmon_retention:
hot: 31d
fiveminute: 120d
long: 2y

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,190 @@
- name: --http-listen and TLS mode agree
ansible.builtin.assert:
that:
- not (nmon_http_listen | length > 0 and nmon_tls_mode == 'insecure')
- nmon_tls_mode != 'none' or nmon_http_listen | length > 0
fail_msg: "nmon_http_listen cannot be combined with nmon_tls_mode=insecure, and nmon_tls_mode=none needs nmon_http_listen"
- name: Check the platform (nmon ships linux/amd64 and linux/arm64)
ansible.builtin.assert:
that:
- ansible_system == "Linux"
- ansible_architecture in ["x86_64", "aarch64"]
fail_msg: "nmon v1 supports linux/amd64 and linux/arm64 only"
- name: No sign-in in vars means first-install mode (the setup wizard picks the sign-in)
ansible.builtin.set_fact:
nmon_install_wizard: "{{ nmon_tls_mode != 'insecure' and nmon_breakglass_password | length == 0 and not nmon_oidc }}"
- name: Install host packages (openssl for the self-signed cert, htpasswd for bcrypt, acl for become_user)
ansible.builtin.package:
name: "{{ ['openssl', 'apache2-utils', 'acl', 'procps'] if ansible_os_family == 'Debian' else ['openssl', 'httpd-tools', 'acl', 'procps-ng'] }}"
state: present
- name: Create the nmon system user
ansible.builtin.user:
name: "{{ nmon_user }}"
system: true
shell: /usr/sbin/nologin
home: "{{ nmon_data_dir }}"
create_home: false
- name: Create directories
ansible.builtin.file:
path: "{{ item.path }}"
state: directory
owner: "{{ nmon_user }}"
group: "{{ nmon_user }}"
mode: "{{ item.mode }}"
loop:
- { path: "{{ nmon_data_dir }}", mode: "0750" }
- { path: "{{ nmon_etc_dir }}", mode: "0750" }
- { path: "{{ nmon_backup_dir }}", mode: "0750" }
- name: Ensure /etc/sysctl.d exists (minimal images lack it)
ansible.builtin.file:
path: /etc/sysctl.d
state: directory
mode: "0755"
- name: Allow unprivileged ICMP echo (nmon pings without root)
ansible.builtin.copy:
content: "net.ipv4.ping_group_range = 0 2147483647\n"
dest: /etc/sysctl.d/60-nmon.conf
mode: "0644"
notify: Apply sysctl
- name: Install the nmon binary
ansible.builtin.copy:
src: "{{ playbook_dir }}/../../{{ 'nmon' if ansible_architecture == 'x86_64' else 'nmon-arm64' }}"
dest: /usr/local/bin/nmon
mode: "0755"
notify: Restart nmon
- name: Install the MIB sidecar
when: nmon_install_mibs | bool
block:
- name: Create the MIB directory
ansible.builtin.file:
path: "{{ nmon_mibs_dir }}"
state: directory
mode: "0755"
- name: Unpack MIBs
ansible.builtin.unarchive:
src: "{{ playbook_dir }}/.mibs.tar.gz"
dest: "{{ nmon_mibs_dir }}"
creates: "{{ nmon_mibs_dir }}/IF-MIB"
- name: Ship the IEEE vendor registry (optional)
when: nmon_oui_download | bool or nmon_oui_csv_dir | length > 0
block:
- name: Create the registry directory
ansible.builtin.file:
path: "{{ nmon_oui_dir }}"
state: directory
mode: "0755"
- name: Download the registry CSVs on the host
when: nmon_oui_download | bool
ansible.builtin.get_url:
url: "{{ item }}"
dest: "{{ nmon_oui_dir }}/{{ item | basename }}"
mode: "0644"
timeout: 60
loop: "{{ nmon_oui_urls }}"
register: nmon_oui_fetched
- name: Copy the registry CSVs from the control machine
when: nmon_oui_csv_dir | length > 0
ansible.builtin.copy:
src: "{{ nmon_oui_csv_dir }}/"
dest: "{{ nmon_oui_dir }}/"
mode: "0644"
register: nmon_oui_copied
- name: Load the registry into nmon's data directory
when: (nmon_oui_fetched is changed) or (nmon_oui_copied is changed)
become_user: "{{ nmon_user }}"
ansible.builtin.command:
argv: [/usr/local/bin/nmon, oui, import, "{{ nmon_oui_dir }}", --data-dir, "{{ nmon_data_dir }}"]
changed_when: true
- name: TLS certificate
ansible.builtin.include_tasks: tls.yml
when: nmon_tls_mode in ["selfsigned", "files"]
- name: Break-glass admin (bcrypt hash; the password never lands on disk)
when: nmon_breakglass_password | length > 0
block:
- name: Hash the break-glass password
ansible.builtin.command:
argv: [htpasswd, -nbBC, "12", "{{ nmon_breakglass_user }}", "{{ nmon_breakglass_password }}"]
register: nmon_bg_hash
changed_when: false
no_log: true
- name: Write the break-glass file
ansible.builtin.copy:
content: "{{ nmon_bg_hash.stdout | trim }}\n"
dest: "{{ nmon_etc_dir }}/breakglass"
owner: "{{ nmon_user }}"
group: "{{ nmon_user }}"
mode: "0600"
no_log: true
# Re-hashing gives a new salt each run; only replace the file when it is missing.
args:
force: false
- name: Store the OIDC client secret encrypted (creates the key file on a new install)
ansible.builtin.shell:
cmd: >-
set -o pipefail;
printf '%s\n' "$NMON_OIDC_SECRET" |
/usr/local/bin/nmon secret set oidc-client --data-dir {{ nmon_data_dir }} --secret-key-file {{ nmon_key_file }}
executable: /bin/bash
environment:
NMON_OIDC_SECRET: "{{ nmon_oidc.client_secret }}"
become: true
become_user: "{{ nmon_user }}"
when: nmon_oidc.client_secret is defined
changed_when: true
no_log: true
notify: Restart nmon
- name: Write config.yaml
ansible.builtin.template:
src: config.yaml.j2
dest: "{{ nmon_data_dir }}/config.yaml"
owner: "{{ nmon_user }}"
group: "{{ nmon_user }}"
mode: "0640"
# The wizard writes its sign-in into config.yaml; never overwrite that with a template that has none.
force: "{{ not nmon_install_wizard | bool }}"
notify: Reload nmon
- name: Install the systemd unit
ansible.builtin.template:
src: nmon.service.j2
dest: /etc/systemd/system/nmon.service
mode: "0644"
notify: Restart nmon
- name: Enable and start nmon
ansible.builtin.systemd_service:
name: nmon
enabled: true
state: started
daemon_reload: true
- name: Wait for nmon to answer
ansible.builtin.uri:
url: "{{ 'http' if nmon_tls_mode in ['insecure', 'none'] else 'https' }}://localhost:{{ (nmon_http_listen if nmon_tls_mode == 'none' else nmon_listen) | regex_replace('^.*:', '') }}/healthz"
validate_certs: false
status_code: 200
register: nmon_health
retries: 30
delay: 2
until: nmon_health.status == 200
- name: Where to go next
ansible.builtin.debug:
msg:
- "nmon is up: {{ 'http' if nmon_tls_mode in ['insecure', 'none'] else 'https' }}://{{ nmon_server_name }}:{{ (nmon_http_listen if nmon_tls_mode == 'none' else nmon_listen) | regex_replace('^.*:', '') }}/"
- "{{ 'Signs in via your IdP; break-glass at /auth/breakglass' if nmon_oidc else ('Open it to run the setup wizard (first install), or sign in as configured there' if nmon_install_wizard | bool else 'Sign in at /auth/breakglass as ' + nmon_breakglass_user) }}"
- "Back up the key file {{ nmon_key_file }} somewhere safe; without it a restore has no SNMP credentials."

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,52 @@
# Managed by Ansible (deploy/ansible).
[Unit]
Description=nmon network monitor
Documentation=https://claude.ai/artifact/7FTnfA5hReTjY6cfArfJ2Z
After=network-online.target
Wants=network-online.target
[Service]
User={{ nmon_user }}
Group={{ nmon_user }}
ExecStart=/usr/local/bin/nmon serve \
--data-dir {{ nmon_data_dir }} \
--secret-key-file {{ nmon_key_file }} \
--listen {{ nmon_listen }} \
--trap-listen {{ nmon_trap_listen }} \
--graphite-listen {{ nmon_graphite_listen }} \
--icmp {{ nmon_icmp }} \
{% if nmon_http_listen | length > 0 %}
--http-listen {{ nmon_http_listen }} \
{% endif %}
{% if nmon_tls_mode == 'insecure' %}
--insecure-http
{% elif nmon_tls_mode == 'none' %}
{# plain HTTP only, behind a reverse proxy: --http-listen above is the only listener #}
{% elif nmon_tls_mode == 'acme' %}
--acme-host {{ nmon_server_name }} --acme-email {{ nmon_acme_email }}
{% else %}
--tls-cert {{ nmon_etc_dir }}/tls.crt --tls-key {{ nmon_etc_dir }}/tls.key
{% endif %}
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=5
TimeoutStopSec=90
LimitNOFILE=1048576
# Ports below 1024: SNMP traps (162) and the ACME challenge (80).
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
LockPersonality=true
ReadWritePaths={{ nmon_data_dir }} {{ nmon_etc_dir }} {{ nmon_backup_dir }}
[Install]
WantedBy=multi-user.target

26
deploy/ansible/site.yml Normal file
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,43 @@
# Copy to vars.yml (keep it out of git, or encrypt with ansible-vault) and pass with -e @vars.yml.
# TLS: selfsigned (quick start), files (your own cert), acme (Let's Encrypt), or insecure (plain HTTP, dev only).
nmon_tls_mode: selfsigned
nmon_server_name: "{{ inventory_hostname }}" # a DNS name or an IP; it goes in the self-signed cert
# nmon_tls_extra_names: ["10.0.0.5", "nmon.lan"]
# nmon_tls_cert_src: files/nmon.crt # for tls_mode=files (paths on the control machine)
# nmon_tls_key_src: files/nmon.key
# nmon_acme_email: noc@example.net # for tls_mode=acme; nmon answers http-01 on :80
# Behind a reverse proxy: plain-HTTP listener (sign-in stays on). Add the proxy to auth.trusted_proxies (see docs/operations.md).
# nmon_http_listen: "[::1]:8080" # beside HTTPS; set nmon_tls_mode: none to serve only this listener
# Break-glass local admin: the way in when the IdP is down (and the only sign-in until OIDC is set).
# The role writes this before nmon first starts, so the install is already "set up": nmon never enters install
# mode (the first-install wizard that is open to anyone who can reach it). Leave it out only if you want the wizard.
# nmon_breakglass_user: admin
# nmon_breakglass_password: "change-me-please"
# OIDC (optional). Unauthenticated browsers are redirected to the IdP; there is no login page.
# nmon_oidc:
# issuer: https://idp.example.net/realms/net
# client_id: nmon
# client_secret: "s3cret" # stored encrypted via `nmon secret set`, never in config.yaml
# groups_claim: groups
# admin_group: nmon-admins
# Alert receivers (optional): webhook and/or SMTP, plus the outbound allow-list they need.
# nmon_alerting:
# receivers:
# - name: noc-webhook
# kind: webhook
# url: https://hooks.example.net/nmon
# outbound_allow: ["hooks.example.net"]
nmon_timezone: UTC
# MAC/ARP table job and optional IEEE vendor registry (see docs/operations.md).
# nmon_l2: {interval: 15m, keep: 90d, edge_max_macs: 8}
# nmon_oui_fetch: false # never let nmon download the registry...
# nmon_oui_download: true # ...have this play download the CSVs on the host instead,
# nmon_oui_csv_dir: files/oui # ...or ship the three CSVs from the control machine
nmon_icmp: auto # off = decide reachability by SNMP alone

View File

@@ -7,10 +7,57 @@ conscious, reviewed exception.
|---|---|---|---|---|
| 1 | Fixtures run on snmpsim (Python) via compose | `internal/simagent`, a Go agent that serves standard `.snmprec` files (v1/v2c/v3, dynamic counters, slow/flaky/toobig). `deploy/snmpsim-compose.yml` is kept for anyone using the reference simulator. | 2,000 agents for the loadtest in one process; no Python in CI; same file format, so real snmpsim recordings still work. | M1 |
| 2 | "Two VictoriaMetrics executables are embedded" | One VictoriaMetrics single-node executable per architecture is embedded and run as two processes (hot and long) with different `-retentionPeriod`. | Both stores are the same program; embedding it twice would double the binary size for no gain. | M1 |
| 3 | Libraries: huma, gosnmp, gnmic, go-oidc, bun; standard library elsewhere | Also: `oklog/ulid` (ULIDs), `gopkg.in/yaml.v3` (profiles, config), `golang.org/x/net` (unprivileged ICMP), `golang.org/x/crypto` (bcrypt, ACME), bun's SQLite (`modernc.org/sqlite`) and Postgres (`pgdriver`) drivers, `golang/snappy` + protobuf (remote write). | Each one fills a gap the standard library doesn't cover; each is small and widely used. | M1+ |
| 3 | Libraries: huma, gosnmp, gnmic, go-oidc, bun; standard library elsewhere | Also: `oklog/ulid` (ULIDs), `gopkg.in/yaml.v3` (profiles, config), `golang.org/x/net` (unprivileged ICMP), `golang.org/x/crypto` (bcrypt, ACME), bun's SQLite (`modernc.org/sqlite`) and Postgres (`pgdriver`) drivers, `golang/snappy` + protobuf (remote write). Web: `react-markdown` + `remark-gfm` (the in-app `/help` page renders `docs/*.md` with tables and no raw HTML; hand-rolling a safe GFM renderer would be more code than the lazy chunk it replaces). | Each one fills a gap the standard library doesn't cover; each is small and widely used. | M1+ |
| 4 | Integration tests use a VictoriaMetrics container | Integration tests run the VictoriaMetrics binary that `make fetch-vm` downloaded (the same pinned, checksummed one that is embedded). | Faster, and it tests the exact binary that ships. | M1 |
| 5 | (not specified) M1 has no auth | Until M6, `nmon serve` requires `--insecure-http` and has no auth. | Auth is M6 in the build order. | M1–M5 |
| 6 | "No MIB file is ever shipped in the repo or the binary"; vendor MIBs are the operator's to fetch | MIBs are bundled as a **sidecar** (user decision, 2026-10-22). All 4,865 LibreNMS MIBs are vendored under `mibs/` and shipped in the release tarball (installed to `/usr/share/nmon/mibs`). **None is embedded in the binary**, not even the IETF/IANA set. They are read via `--mibs-dir` (default `<data-dir>/mibs`, then `/usr/share/nmon/mibs`). The poller never reads a MIB. | Operators get `nmon profile new --mib` working out of the box while the binary stays small. Each MIB keeps its publisher's terms (see `mibs/README.md`). | M2 |
| 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 |
| 42 | Alert rules are defined in code and `config.yaml` (spec §Alerting) | Rules are also defined in the database (`alert_rules`, migration 13) and edited through `/api/v1/alert-rules` and the Alerts > Alert rules tab, applied live on the next cycle. A custom rule is **builder only**: a metric rule names a catalogue metric (every pollable profile metric plus push-ingested names) with a transform, window, function, aggregate, comparison and threshold, and nmon generates the MetricsQL from fixed templates; an inventory rule ANDs one to five conditions over whitelisted device or port fields, evaluated in Go over store rows. No user text ever reaches a query. The shipped rules keep their names and become read-only "built-in" macros (three can be copied into the builder: `cpu_high`, `ups_on_battery`, `printer_supply_empty`). Comparison operators are now `> >= < <= == !=` everywhere. `alerting.rules` in `config.yaml` still works but is deprecated (the startup log says so); a custom rule cannot take the name of a built-in or config rule. `utilisation` (octets as a share of port speed) is not offered, because the port speed is not a stored series. | Owner decision 2026-10-23: the predefined rules are too constraining; admins need rules of their own from metrics and inventory fields. | post-v1 (v1.2) |
| 43 | The spec's collection table lists BGP-4-MIB but no profile fills it, and it names no MAC/ARP lookup | v1.3 (owner request 2026-10-24) adds both: BGP peers in the `routing` category (CISCO-BGP4 for cisco-ios/cisco-nxos, Juniper BGP4-V2, Arista BGP4V2, BGP4-MIB for the IPv4-only profiles that answer it) with a Routing tab, a BGP page, `GET /api/v1/bgp/peers` and the built-in `bgp_peer_down` rule; and a MAC/ARP/FDB lookup with a Find host page. Additive API: `/l2/search`, `/devices/{slug}/{l2,fdb,arp,l2/ports}`, `/interfaces/{id}/macs`, `/bgp/peers`; `/poller/health` gains `l2` and `notes`. | The owner wants to find where a host is plugged in and to see BGP sessions. | v1.3 |
| 44 | (not specified) table walks run in discovery or not at all; the poll path never walks | A separate **L2 table job** (`internal/l2`, SNMP path label `l2` in `nmon_pdus_total`) walks BRIDGE-MIB, Q-BRIDGE-MIB, CISCO-VTP-MIB (per-VLAN context, profile flag `l2.per_vlan_context`, set on cisco-ios only) and IP-MIB neighbour tables every `l2.interval` (default 15m, minimum 5m) with its own concurrency cap (`l2.concurrency`, 4), a per-device deadline (`l2.device_timeout`, 2m) and the rollup job's yield rule (it pauses while the hot write queue is over half full). A device that times out or fails is skipped for the cycle, recorded on `/poller/health`, and never delays the others. Rows live in the inventory database (migration 14: `fdb_entries`, `arp_entries`, `vlans`, and `l2_walks`, one row per device holding the time of its last walk, which is how a row is "current"), are in `nmon backup` (JSONL export too) and are pruned daily by `last_seen` against `l2.keep` (90d). `interface_id` is the empty string, not NULL, when a bridge port has no ifIndex, because the unique key must not contain NULL. | The hot poll path must not walk tables; the history behind "where was this MAC last week" is inventory, not a metric. | v1.3 |
| 45 | The component label of a table metric is a column of the table | Profiles may label rows by decoding the **row index** (`index_label.layout`, tokens `skip`, `skipinet`, `inet`, `skipaddr`, `addr`, `ipv4`, `peerindex`, `afisafi`), because BGP peer tables are indexed by an address. The label is the peer address (IPv6 compressed); a per-address-family prefix count is labelled `<peer>\|<afi>-<safi>`, for example `2001:db8::1\|ipv6-unicast`. Juniper's prefix table is keyed by a peer index, so its rows resolve through the `peer_index` attribute of the peer table (`index_label.via`). Peer AS, local AS, router id and description are component attributes (migration 11), never labels, so the SNMP label contract (`device` + `component`) is unchanged. Other profile additions: `enable_metrics` (switch on optional parent metrics), `wire: counter32` (CISCO-BGP4 declares accepted prefixes as a Counter32 but they are a count), attribute `format: ipbytes`, and `l2.per_vlan_context`. | A BGP peer is identified by its address, which only the index carries; the alternatives (a label column that does not exist, or labelling by row number) would break "each peer appears once" and the alert text. | v1.3 |
| 46 | `bgp_peer_down` summary names the peer, AS and description | The rule's own summary names the device and the peer. The AS, router id and description are appended to the **notification** (Summary, Description and labels) when it is delivered, from the component attributes; the stored alert row keeps the plain summary. | The metric label contract allows only `device` and `component`, and the rule templates see only those; extending the evaluator's template data belongs to the v1.2 rules plumbing. | v1.3 |
| 47 | (not specified) vendor of a MAC | An optional IEEE OUI registry (MA-L, MA-M, MA-S) shows the vendor of each MAC and flags locally administered (`local`, shown "Private / randomised") and multicast addresses. It is fetched weekly through the outbound allow-list (`oui.fetch: false` turns it off; `nmon oui import DIR` loads the CSVs offline), kept as `<data-dir>/oui/registry.gz` (checksummed, written atomically, in `nmon backup`), and looked up at query time by the longest prefix, so a refresh relabels history. Without a registry the vendor is empty, nothing fails, and `/poller/health` carries an `oui_unavailable` note (never an alert). The CSVs are public IEEE data and are not in the repository. | Owner request 2026-10-24: nice to have, never required. | v1.3 |
| 48 | Lookup ranks edge ports "the port with the fewest MACs, then the most recent last_seen" | Within a MAC's locations the order is: edge ports before uplinks, then rows the device's last walk still saw before history, then fewest MACs, then most recent. A port is an uplink above `l2.edge_max_macs` MACs (8), with an LLDP neighbour (the lldpRemTable local port number is taken to be the ifIndex), or as a LAG (ifType 161). A port's MAC count is the MACs on it at or after the row's `last_seen`, so for a current row it is the port's MACs in the last walk. | A host that moved should show its new port first even when the old, now empty, port had fewer MACs. | v1.3 |
| 49 | SNMP interface counters are polled every 60 s; rate windows, rollup gap detection and reachability timers assume it | The default SNMP poll interval is **300 s** (LibreNMS-like; `store.DefaultPollInterval`), and an admin can set a different one per device (`poll_interval_s`, 0 or 30..3600, migration 15; empty = the default). The profile's 1m metrics follow it (halved on a device with unreliable 32-bit counters); 5 m and 15 m groups are unchanged and merge with a 300 s primary. Everything that was tied to the cadence follows the device's effective interval: graph, overview and p95 rate windows are two polls wide (at least 2 min; live rates use two default polls), the rollup's lookback and "thin window" test (a cadence of 5 min or slower predicts at most one sample per window, so a window is never thin, and a window with no sample is skipped as before), the `no_data` warning (silent for max(5 min, two polls)), the "unheard of for three polls is down" fallback, the up-series lookback (11 min), the dependency hold, and the `interface_errors` rule (10 min window). ICMP stays at 15 s. `nmon serve --poll-interval` still overrides the default for dev and tests. | Owner request 2026-10-24: 60 s polling of every port is more load than ISP-size fleets need; a per-device override keeps 1 min where it is wanted. Cost: a rollup window of a device polled slower than 5 min often holds no sample and is left empty, and graphs at the default have one point per 5 min. | post-v1.3 |
| 50 | The API is served over HTTPS only; plain HTTP exists only as `--insecure-http` development mode (no sign-in) | An opt-in `--http-listen ADDR` serves the same UI and API over **plain HTTP with sign-in required**, beside HTTPS or alone when no TLS is configured. It is mutually exclusive with `--insecure-http`, never enables dev-open mode, and refuses to start with no OIDC or break-glass. Cookies are `Secure` on HTTPS and, on this listener, only behind a trusted proxy that sent `X-Forwarded-Proto: https`; client addresses come from `X-Forwarded-For` only from `auth.trusted_proxies`, and an empty list logs a WARN. | Owner request 2026-10-24: deployments sit behind a TLS-terminating reverse proxy in a DMZ or internal network, and double TLS to the backend adds nothing there. Cost: nmon cannot itself check the network between proxy and listener is private; the operator binds it to loopback or an internal address. | post-v1.4 |
| 51 | Sign-in is configured before first start; nmon refuses to serve over TLS or `--http-listen` with none (Auth, DEVIATIONS 31 and 50) | **Install mode**: with no `auth.oidc`, no `auth.break_glass_file` and setup never completed, nmon serves a first-install wizard at `/setup` to **anyone who can reach it, unauthenticated, as an admin**. There is no setup token and no localhost restriction. Only the setup routes and what the wizard needs are served (UI, `GET /api/v1/setup`, settings form, notify presets/preview/test, device add/import and list, alert policies, profile list, secrets PUT, meta); every other API route answers 503 "setup not finished". A WARN at start and every 10 minutes says setup is open. Completing setup stores a one-way flag in the inventory (`setup_state`); it ends install mode permanently, switches authentication on without a restart, and cannot be undone from the web. An existing install with a sign-in method is marked completed at its first start, and deleting the break-glass file later does not reopen setup (the old refusal applies). `--insecure-http` dev-open mode is unchanged and never enters install mode. | Owner decision 2026-10-24: nmon is deployed in a DMZ or an internal network, and a token or localhost check would make a first install harder than the threat justifies. Cost: until setup completes, whoever reaches the port can create the administrator, so the port must not face an untrusted network before then. Ansible, which pre-configures sign-in, never enters install mode. | post-v1.4 |
| 52 | Without a certificate flag nmon refuses to start ("HTTPS by default and has no certificate") | With none of `--tls-cert`/`--tls-key`, `--acme-host` or `--http-listen`, nmon generates a **self-signed** ECDSA P-256 certificate (names: host name, every non-loopback interface address incl. IPv6, `localhost`, `::1`, `127.0.0.1`; about 2 years; key 0600) in `<data-dir>/tls/`, reuses it on later starts, replaces it within 30 days of expiry, serves HTTPS on `--listen` and logs a WARN saying how to replace it. This applies to every start without TLS config, not only the first. User-provided certificates still reload on SIGHUP. `--http-listen` alone stays plain HTTP with no certificate. | Owner decision 2026-10-24: a first install must come up without a PKI. A browser warning is the price; the WARN names `--tls-cert`/`--tls-key` and `--acme-host` as the fix. | post-v1.4 |
| 53 | (not specified) the default poll interval is a dev flag (`--poll-interval`) | `poller.interval` in `config.yaml` (Go duration, 30s to 1h, default 5m) sets the default SNMP poll interval; Settings, General edits it, and the first-install wizard asks for it. A change applies without a restart to the poller (devices replan), reachability, the rollup and the API through one shared value (`store.PollCadence`). A device's own `poll_interval_s` still wins, and `--poll-interval`, when given, still overrides the setting (dev and tests). | Owner request 2026-10-24: the interval is an operator choice (DEVIATIONS 49) and belongs where the other settings are. The one cost is a replan of every device on change. | post-v1.4 |
| 54 | (not specified) an operator cannot poll one device on demand or see the SNMP exchange | Three admin tools on the device page and API: `poll-now` (wraps the poller's existing kick), `poll-test` (one synchronous, traced cycle of the real poll plan through the poller's own fetch path; no metrics written, no poller state changed; one per device, four overall, 60 s, 5,000 OID rows) and `snmp-debug` (per-device recording of scheduled cycles, at most 60 minutes, last 20 cycles in memory only, cleared on restart, plus one INFO log line per cycle). Credentials never appear in a trace or log. Also `nmon device poll <slug> [--test] [--json]`. | Owner request 2026-10-25: "there's no snmp debug options or an option to individually poll a device for testing purposes." Cost: recordings are lost on restart by design, and a request's duration includes the SNMP library's own retries. | post-v1.4 |
| 55 | (not specified) a port graph shows no cumulative traffic | `GET /api/v1/graph` adds `totals` (`in_bytes`, `out_bytes`, `covered_seconds`, `range_seconds`): the average-rate series integrated into bytes over the range, per port only. Each tier counts its own step, gaps count nothing, and unreliable ports get none. The UI shows it as "total" in the port summary and a Total column in the full graph, in decimal units. | Owner request 2026-10-25: show bytes transferred per port over the selected timespan, as LibreNMS does. Additive API change. Cost: a total is only as complete as the data, so a partial one is marked. | post-v1.4 |
| 56 | (not specified) port graphs show bits only, with in/out p95 | LibreNMS parity, additive API only: `p95.aggregate_bps` (95th of in+out per 5-minute slot, both directions present); the UI adds Max and the aggregate to the port summary, utilisation percentages and a "% of speed" axis (from `iface.speed_bps`, hidden when unknown), and a "Compare previous period" overlay (a second `GET /api/v1/graph` over the preceding same-length range, shifted client-side). New `GET /api/v1/graph/counters` serves **errors** and **discards** rates from the hot store **only**: those counters have no rollup (rollup metric names are frozen), so they reach back over hot retention at most and the response and UI say when a range was cut. **Packets and broadcast/multicast graphs are not offered** (shown disabled): the generic profile does not poll the `ifHC*UcastPkts`/`*MulticastPkts`/`*BroadcastPkts` counters, and adding them would add three OIDs per port to every device's poll and change every fixture. Error and discard counters are 32-bit by definition, but the unreliable rule is about octet wrap on fast links and does not apply to them (VictoriaMetrics `rate()` absorbs a counter reset). | Owner request 2026-10-25: "let's match what libre does" for per-port graphs. Cost: the aggregate is live-only (a month older than hot retention has none), a previous period far back falls to hourly rollups and fills only some slots of a finer current axis, and packet-level graphs need a separate polling decision. | post-v1.4 |

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

65
docs/dev.md Normal file
View File

@@ -0,0 +1,65 @@
# 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.
## In-app help (`/help`)
The operator docs are shown at `/help` and `/help/<doc>#<heading>`. The markdown stays in `docs/` and is imported raw into the web build (`web/src/pages/help/docs.ts`, `?raw` imports), so there is no copy to keep in sync and the text always matches the binary. The page is a lazy chunk (react-markdown + remark-gfm), loaded on first visit. Raw HTML in the markdown is not rendered.
To add a doc: write `docs/<id>.md` (h1 title, h2/h3 sections; the h2/h3 headings become the table of contents and search targets), add one entry to `DOCS` in `docs.ts`, and link to other docs as `docs/<id>.md#heading` (links to files that are not in `DOCS`, such as `docs/spec/`, render as plain text). To give a Settings section a "?", add `section id: heading slug` to `web/src/pages/help/helpLinks.ts`. Heading slugs are GitHub-style (`Behind a reverse proxy` is `behind-a-reverse-proxy`). Developer-only docs (this file, milestones, spec, DEVIATIONS, MAINTAINABILITY) are deliberately not listed. `/help` is an SPA route; `/docs` stays the Swagger UI.
## OpenAPI compatibility
`scripts/openapi-check.sh` compares the current spec (`nmon openapi`, else `openapi.json`) with
`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,18 @@ 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.
- **Distributed poller (v2)**: remote pollers per site or rule that spool to disk while the master is down, send signed chunks over an outbound-only link (MQTT through a broker), and receive device credentials sealed to their own key. Assessed feasible on 2026-10-25 and deferred to v2 by the owner. Requirements, gaps and risks: [v2-distributed-poller.md](v2-distributed-poller.md).
- **BMC support**: SNMP profiles for Dell iDRAC, HPE iLO and Supermicro (health, temperatures, fans, PSUs, power, RAID) feeding the Health/Power/Environment tabs; a native Redfish collector later. Today, Redfish works only via an exporter pushing with a push token. Owner: "good enough for now" (2026-10-23).
## Post-v1 additions
- **Device management UI** (2026-10-23): the UI had no way to add a device. Added an admin-only "Add device" dialog on the device list (IPv6/IPv4 addresses, SNMP v1/v2c/v3, synchronous discovery with the reattach 409 flow) and an Actions menu on the device page (Edit with parents and SNMP replacement, Rediscover now, Retire; Purge now asks for the device name). New API route `POST /api/v1/devices/{slug}/retire` (admin, audited, additive). The SNMP fields are one shared component (`web/src/components/SnmpFields.tsx`). Screenshots: `docs/screenshots/devices-mgmt/`.
- **Profiles, profile pinning and row navigation** (2026-10-23): new shipped profiles `ubiquiti-edgeswitch`, `ubiquiti-unifi-switch`, `ubiquiti-unifi-ap` and `vyos`; discovery re-matches unpinned devices every run (and at startup) and an admin can pin a profile from the Edit dialog (DEVIATIONS 39, 40); the device header explains why a device is generic. On the device list, the interface search results and the poller health table, a click anywhere in a row (or Enter on a focused row) opens the device or graph; the name stays a real link. Screenshots: `docs/screenshots/v1.0.1/`.
## Review artifacts
- From M3 on, each UI milestone's gate captures Playwright screenshots of every page into `docs/screenshots/<milestone>/` for the user to review.

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,202 @@
# v1.2: built-in rule macros and custom alert rules
Owner request (2026-10-23), following v1.1 (alert policies): the shipped rules are too constraining. Keep them as
well-known **built-in macros**, and let admins create their own rules from the data nmon already holds. The owner's
decisions:
- Custom rules can test **metrics** (anything in the hot store) **and inventory fields** (device and interface rows).
- **Builder only.** Admins compose rules from whitelisted fields, never by writing MetricsQL or SQL. The generated query
is shown read-only.
## Rules after this change
| Kind | Where defined | Editable | Notes |
|---|---|---|---|
| Built-in macro | `alert.DefaultRules()` | no; "Copy to custom" clones one into an editable builder rule where the builder can express it, otherwise refuses with a reason | Names are unchanged (`interface_errors`, `disk_full`, …), so v1.1 policies, alert history and series keys stay valid. The UI labels them "Built-in". |
| Config rule | `config.yaml` `alerting.rules` | file only | It still works but is deprecated, and the startup log says so once. A config rule cannot take a custom rule's name. |
| Custom metric rule | DB `alert_rules`, `kind: metric` | API/UI, live | A builder spec is compiled to MetricsQL. |
| Custom inventory rule | DB `alert_rules`, `kind: inventory` | API/UI, live | A builder spec is evaluated in Go over store rows. |
Every rule, of whatever kind, is alerted only through policies (v1.1), and uses the same state machine (pending/`for`,
firing, resolved, stale, flapping), suppression, maintenance and notifications. A rule name is unique across all kinds,
and names reserved by reach and system alarms stay refused. Deleting a custom rule that a policy references is a 409
naming those policies. Disabling a rule retires its open rows silently.
## Builder: metric rules (`kind: metric`)
| Field | Values |
|---|---|
| `metric` | from the catalogue: every metric of every loaded profile (name, label, unit, type, category, `of`, enum), the interface metrics (`ifHC*Octets`, `if*Errors`, `if*Discards`, `ifOperStatus`, `ifAdminStatus`, and whatever else the generic profile polls), and push-ingested metric names read from the hot store's `__name__` values (marked `source: push`) |
| `transform` | `value`, `rate` (counter types only; per second), `percent_of` (only when the metric has `of`), `utilisation` (interface octet counters only: 100 × 8 × rate / speed). Offer `utilisation` only if the port speed is available in the hot store as a series; if it isn't, leave it out and record why in the gate log. Don't invent a join. |
| `window` | duration, default 5m, minimum 2× the poll interval |
| `func` | `last`, `avg`, `max`, `min` over the window |
| `aggregate` | component-level metrics only: `each` (default), or `max`/`min`/`avg`/`sum` per device |
| `op` | `>`, `>=`, `<`, `<=`, `==`, `!=` |
| `threshold` | number; for enum/status metrics the UI offers the enum names and stores the number |
| `for`, `severity`, `summary`, `description` | as on today's rules; the templates are validated and a default summary is generated |
The level (device, interface or component) follows from the metric: `iface` label → interface, index label → component,
scalar → device. It decides which policy matchers apply, exactly as in v1.1. The MetricsQL is generated from fixed
templates, and every name and value is checked against the catalogue or a numeric parse, so nothing a user types reaches
the query text unescaped. Label values are not user input in the builder.
The evaluator's comparison gains `>=`, `<=`, `==` and `!=` (`Rule.breach`; validation accepts all six).
## Builder: inventory rules (`kind: inventory`)
Evaluated every cycle from rows the store already returns (devices and interfaces, which are not retired). Add a store
method only if no existing one fits; still no SQL outside `internal/store` and no dynamic SQL anywhere. Field whitelist:
- **Device:** `last_seen_age` (duration), `enabled`, `profile`, `profile_pinned`, `site`, `role`, `kind`, `sys_descr`,
`hw_serial`, `mgmt_ip`, `parent_count`, `snmp_failing` (if it is cheaply derivable, otherwise leave it out).
- **Interface:** `if_speed_mbps`, `admin_status`, `oper_status`, `if_name`, `if_alias`, `description`, `unreliable`,
`has_hc_counters`, `excluded`, `counter_discontinuity`, `last_discovered_age`.
Ops by type:
| Type | Ops |
|---|---|
| number | `> >= < <= == !=` |
| bool | `is true`, `is false` |
| string | `equals`, `not equals`, `matches` (RE2, compiled at save), `empty`, `not empty` |
| duration | `older than`, `newer than` |
A rule has one to five conditions, ANDed, all on device fields or all on interface fields; that decides the level. The
reported value is 1 when the conditions hold, and an entity whose conditions don't hold is "below". Examples:
- "Uplink without description": interface, `if_alias empty` AND `if_speed_mbps >= 10000`.
- "Device not seen for 1 h": device, `last_seen_age older than 1h`.
## Architecture
- New pure package `internal/alertrule`:
- spec types and `Validate`;
- `CompileMetric(spec, catalogue) (expr, level, error)`;
- `EvalInventory(spec, row) bool` over a plain row struct;
- the field whitelist and the catalogue types.
- Unit tests carry a table of specs → expected MetricsQL, and of rows → verdicts.
- `internal/alert`:
- The evaluator reads its rule set from a `Rules func() []Rule` (or a `SetRules`) so DB edits apply on the next cycle
without a restart.
- A rule gets a `Source`: `metric` (today's querier path) or `inventory`, an interface the app supplies that returns
readings for a rule at `now`.
- The state machine is shared and unchanged.
- `internal/store`:
- migration `20261023000013_alert_rules.go` (frozen struct; spec as JSON text);
- `AlertRules` sub-interface in a new file, plus one embed line;
- the table is added to `backupTables`.
- `internal/app`:
- the rule registry: built-ins, then config, then DB;
- a 30 s cache invalidated on write;
- the metric catalogue from the profile set plus the hot store's metric names (cached);
- the inventory source, built on TTL-cached store reads.
- `internal/api`, all additive. Admin unless noted; regenerate `openapi.json` and the web schema and run
`scripts/openapi-check.sh`.
- `GET /api/v1/alert-rules` keeps its fields and adds `id`, `kind` (`builtin`, `config`, `metric`, `inventory`),
`editable`, `spec` and `expr`.
- `GET /api/v1/alert-rules/{id}`, plus `POST`, `PUT` and `DELETE` for custom rules only (a built-in is 409).
- `GET /api/v1/alert-rules/catalogue` (viewer) returns the metrics and the inventory fields with their types and ops.
- `POST /api/v1/alert-rules/test` (viewer; declare it read-only in the auth policy) compiles a draft and runs it once.
It returns the generated expr, the series or entity count, the number breaching now, and up to 20 samples
(device, iface, component, value).
- `POST /api/v1/alert-rules/{id}/copy` clones a built-in into a custom rule, or returns 422 with the reason when the
builder can't express it.
- `web/`:
- An **Alert rules** tab next to Alert policies. The list shows the kind badge, level, condition text, policy count and
enabled state.
- A builder dialog:
- pick metric or inventory;
- searchable catalogue picker grouped by category;
- dependent fields (transform shown only where valid, enum picker for status metrics);
- generated query shown read-only;
- a **Test** button showing "N breaching now" with samples;
- **Copy to custom** on built-ins.
- The policy dialog's rule dropdown lists custom rules too.
- Mock handlers and an e2e spec. Keep web files ≤ 400 lines; split the builder.
## Docs
- DEVIATIONS: a row noting that rules are now also defined in the database, with a builder only and inventory
conditions (owner decision 2026-10-23), and that config rules are deprecated.
- `docs/operations.md`: extend the alert section with the two inventory examples above and a metric example ("CPU over
80 % on role=server for 15 min"), and with how a custom rule and a policy combine.
## Gate
- `make lint` is clean. All of these pass:
- `go test -race ./...`
- `go test -race -tags 'integration vmembed'` for `internal/alert`, `internal/app` and `internal/store`, where every
builder template and every built-in is run against a real VictoriaMetrics.
- `./scripts/test-pg.sh` and `scripts/openapi-check.sh`
- web lint, test and build, plus the new and changed e2e specs only. A full Playwright run isn't needed; revert any
screenshots it rewrites.
- Tests prove the following:
- A custom metric rule, bound by a policy, fires and resolves.
- A custom inventory rule fires for a matching port and not for others.
- A rule edit applies on the next cycle without a restart.
- Deleting a referenced rule is a 409.
- Built-ins cannot be edited.
- A builder spec with an unknown metric or field is a 422.
- Hostile input in a string field (quotes, braces, `"} or vector(1)`) never changes the generated query's structure.
## Gate log
2026-10-24, run by the implementing agent:
- Decisions where this spec was silent:
- **`utilisation` is not offered.** No profile polls `ifHighSpeed` or `ifSpeed` as a series (checked with a grep of
`profiles/*.yaml`); port speed lives only in the inventory table. Joining the two would be inventing a join, so the
transforms are `value`, `rate` and `percent_of`. A test (`TestCatalogueHasProfileAndPushMetrics`) fails if
`ifHighSpeed` ever becomes a series, as a prompt to revisit this.
- **Counters allow `rate` only** (a raw counter is never alertable), and `rate` always averages over the window
(`func` must be `avg` or empty). The default window is the larger of 5 m and twice the metric's own poll interval
(so a 5 m SNMP metric defaults to 10 m); an explicit window below twice the interval, below 1 m or above 24 h is a
422. A `rate` of a `counter32` interface metric sets `SkipUnreliable`.
- **Inventory fields kept:** `last_seen_age`, `profile`, `profile_pinned`, `site`, `role`, `kind`, `sys_descr`,
`hw_serial`, `mgmt_ip`, `parent_count`, `snmp_failing` (true while the poller reports `degraded` or `backoff`),
`if_speed_mbps` (ifHighSpeed, else ifSpeed / 10^6), `admin_status`, `oper_status` (numbers, with enum names for
the UI), `if_name`, `if_alias`, `description`, `unreliable`, `has_hc_counters`, `excluded`, `counter_discontinuity`,
`last_discovered_age`. **Dropped: `enabled`.** Disabled, retired and purged devices are never evaluated (their
alerts close as retired through `Active`), so `enabled is false` could never match. A device never seen has its age
counted from `created_at`. Excluded ports are evaluated (so `excluded` is a usable condition). A new store method,
`ListAllInterfaces` (one query, sub-interface `InterfaceInventory`), avoids one query per device.
- **Built-ins that can be copied to custom:** `cpu_high` (compiles to the identical expression),
`ups_on_battery` (`upsOutputSource == 5`) and `printer_supply_empty` (`prtMarkerSuppliesLevel == 0`). The other
seven (and the reachability and system rules) join, filter or union series, so `POST /alert-rules/{id}/copy` answers
422 with the reason. The copy is created at once as `<name>_custom` (or the name in the body), keeping the
built-in's severity, hold time and texts.
- Rules are classed `builtin` when the name and expression match the shipped rule (a config patch of thresholds
keeps it `builtin`), and `config` otherwise. A custom rule that cannot compile at runtime (a pushed metric briefly
missing from the hot store) keeps its last good form instead of retiring its alerts. The rule list grows `policy_count`
and `copyable`; deleting a rule, renaming one, or changing its level while a policy names it is a 409. Editing
`GET /alert-rules/{id}` is viewer-level like the list; `POST /alert-rules/test` is the only read-only POST.
- Type names in the OpenAPI schema are `RuleSpec`, `InventoryField` and `InventoryCondition` (not the bare words).
- `make lint`: 0 Go issues, filesize script clean, ESLint `--max-warnings 0` clean (see the final run below).
- `go test -race ./...`: all 44 packages ok (the new `internal/alertrule` included). An earlier run found a real
deadlock in the first version of rule update (a second store call inside a SQLite transaction); fixed by reading
the old level before the transaction, covered by `TestDeletingOrReshapingAReferencedRuleIs409`.
- `go test -race -tags 'integration vmembed' ./internal/alert/... ./internal/app/... ./internal/store/...`: ok. The new
`TestIntegrationBuilderTemplatesAgainstRealStore` compiles every allowed transform x function x aggregate for
gauge, status, counter32, counter64, percent_of and push metrics and runs each query on a real VictoriaMetrics; the
existing test still runs every built-in there.
- `./scripts/test-pg.sh` (store suite on Postgres 16, includes migration 13 and the rule and bulk-interface queries): ok.
- `scripts/openapi-check.sh`: compatible with m6 (additive; the `op` enum of `AlertRuleInfo` only gained values).
`openapi.json` and `web/src/api/schema.d.ts` regenerated.
- Web: `npm run lint`, `npm run test` (146 tests) and `npm run build` pass. Playwright, only the added or changed specs
(`E2E_PORT=4795`): `alert-rules.spec.ts` (7 tests) and `alert-policies.spec.ts` (4, its mock now shares the rule store)
pass. The full Playwright suite was not run; `docs/screenshots` was reverted and no preview server was left running.
- The tests that prove the gate items:
- Custom metric rule bound by a policy fires and resolves, and a rule edit (new threshold) applies on the next
cycle with no restart, and disabling retires silently:
`internal/app` `TestCustomMetricRuleBoundByAPolicyFiresResolvesAndEditsApplyLive`; the evaluator side,
`internal/alert` `TestRuleSetChangesApplyOnTheNextCycle`.
- Inventory rule fires for the matching port only: `internal/app` `TestInventoryRuleFiresForTheMatchingPortOnly`.
- Deleting a referenced rule is 409 (naming the policies): `internal/api` `TestDeletingOrReshapingAReferencedRuleIs409`.
- Built-ins cannot be edited or deleted, and their names cannot be taken: `TestBuiltinRulesCannotBeEditedOrDeleted`,
`TestCustomRuleNamesCannotShadowBuiltinsOrConfig`.
- An unknown metric or field is a 422: `TestBuilderSpecWithUnknownMetricOrFieldIs422`, `alertrule` `TestCompileMetricRefusals`,
`TestInventoryRefusals`.
- Hostile input never changes the generated query: `alertrule` `TestHostileTextNeverReachesTheQuery` (quotes, braces,
`"} or vector(1)`, backticks and template braces in name, summary and description leave the expression identical or
are refused; a pushed metric name that is not a plain identifier never enters the catalogue); the `window`
is parsed and re-printed, and `TestCompileMetricTemplates` pins the exact MetricsQL of every template.

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 48).
- **Per-VLAN fallback.** If the first VLAN context does not answer, the walker falls back to the plain tables instead of waiting out a timeout per VLAN.
- **`bgp_peer_down` text.** AS and description are added to the notification, not the stored alert row (DEVIATIONS 46).
- **BGP per profile.** See `docs/coverage.md`. Nothing for mikrotik-routeros (no BGP objects in MIKROTIK-MIB or the recordings) and the non-router profiles; cisco-nxos is unverified against a recording; VyOS is covered through BGP4-MIB on the strength of the FRR recording only.
- **Open item for v1.2.** The prefix-drop example in `docs/operations.md` is to be added once custom rules land.

View File

@@ -0,0 +1,72 @@
# v2: distributed poller (feasibility, deferred)
Status: deferred to v2 by the owner on 2026-10-25. This is a feasibility assessment, not a plan; nothing is built.
## Requirements (owner)
1. A remote poller polls the devices of a site, or of a predefined rule.
2. It sends its data to the master only as chunked payloads.
3. While the master is down it keeps polling, and delivers when the master is back.
4. Each poller has its own private key; the master sends the target device credentials sealed to it.
5. It works over an outbound-only link (MQTT pub/sub through a broker). Neither side accepts a connection from
the other. Not a data diode yet, but the design should not rule one out.
## Verdict
Feasible, about one milestone. The poller is already injectable: `poller.Options` (`internal/poller/poller.go`)
takes the store, the metric writer, the reachability observer and the SNMP dialer, so the polling code can be
reused unchanged. The work is in what sits around it: where targets come from, where samples go, and how
alerting treats data that arrives late.
## What exists and what is missing
| # | Exists | Missing |
|---|---|---|
| 1 | `devices.site`, `store.DeviceFilter.Site`; the alert-policy matcher (`alertpolicy.Device{Slug,Profile,Role,Site,Kind}`) already selects devices by rule | a `pollers` table and assignment rules; reuse the policy matcher rather than a second rule engine |
| 2 | `metrics.Sample`; the vmsup queue batches 50k samples per POST | a chunk format (poller id, sequence, signature). Push ingest is **not** reusable: it strips `device`/`iface`, refuses `if*`/`nmon_` names and rejects timestamps more than 1 h from now. Poller data needs its own trusted path into the hot queue |
| 3 | the hot queue buffers in memory only (60 s / 2M samples, then drops) | a disk spool on the poller (append-only segments, bounded by size), and a local SQLite snapshot of its targets so it restarts without the master |
| 4 | symmetric AES-256-GCM keyring with versions (`internal/secrets`, `store/cred.go` `seal`); rotation in `internal/keyrotate` | a key pair per poller (X25519 + Ed25519): the master seals the credential bundle to the poller's public key (HPKE) and signs it; the poller signs its chunks; rotation follows the keyrotate pattern |
| 5 | none | an MQTT transport behind an interface (see below), with outbound HTTPS chunk upload as a second, brokerless option |
## Transport (requirement 5)
- Data up: `nmon/poller/<id>/data`, QoS 1. The master holds a persistent session, so the broker keeps chunks
while the master is down.
- Config and credentials down: a retained, sealed message on `nmon/poller/<id>/config`, read on every connect.
- An MQTT client library (e.g. paho.golang) departs from the spec's "standard library elsewhere" and needs a
DEVIATIONS row.
Rules that keep a later data diode possible:
1. The data path is already one-way. Chunks are self-describing, signed and sequence-numbered, and the master
drops duplicates by (poller, seq), so a resend is harmless. The only signal the poller waits for is the
broker's PUBACK, which frees spool space. Under a diode, spool release becomes age/size bound plus a periodic
resend window; the format does not change.
2. One downlink only: the sealed, signed config bundle. Make it a file format that arrives either as the
retained message or by hand (`nmon poller import bundle.sealed`). A diode drops the subscription and keeps
the import.
3. Interactive tools (poll-now, test poll, SNMP debug recording: `PollTools`) need a request and a reply. Over
the broker they work through a request topic with seconds of latency. Under a diode they are unavailable,
and the UI must say so for remote devices.
## Risks
1. **Late data looks like an outage.** `reach.Evaluator` runs on the master and expects live
`ObserveICMP`/`ObserveSNMP` (`internal/reach/types.go`); the API marks a device down after three silent
intervals. A site link drop would page every device behind it. Fix: model the poller as a dependency parent
of its devices (dependency suppression already exists) and replay spooled probes as history, never as live
state transitions. Needs a DEVIATIONS row.
2. **Inventory writes from the poller.** The poller writes poll state (`devloop.persistState` →
`SetPollState`), component rows and discontinuity bases. Remotely these go to the local snapshot and are
sent up as events.
3. **Discovery must run remotely.** The master usually cannot reach a site's devices. Interface ULIDs are the
frozen `iface` label; the poller can mint them (ULIDs are globally unique) and the master accepts them.
Traps, L2 tables and checks need the same scope decision up front.
4. **Late writes.** VictoriaMetrics accepts old timestamps within retention; dedup by (poller, seq) keeps a
resent chunk from being written twice.
## Codebase constraints
- A new package (e.g. `internal/remotepoll`) and an `nmon poller` subcommand wired in `internal/app`.
- Collection paths still do not import each other; time comes from `internal/clock`.
- API routes additive only (`scripts/openapi-check.sh`); new tables through a new migration.

261
docs/operations.md Normal file
View File

@@ -0,0 +1,261 @@
# Operating nmon
How to run, secure, back up and maintain a production nmon. Design background: `docs/spec/spec.md` ("Auth", "Operations").
This page, [Adding a device profile](docs/profiles.md) and [Collection coverage](docs/coverage.md) are also built into the binary and shown in the UI under **Help** (`/help`, any signed-in user, no network needed). The help page always matches the running version; the Settings forms link their section's "?" to the matching heading here.
## Starting
nmon serve --data-dir /var/lib/nmon --secret-key-file /etc/nmon/secret.key \
--tls-cert /etc/nmon/tls.crt --tls-key /etc/nmon/tls.key
- **HTTPS is the default.** Pass a certificate pair (`--tls-cert`, `--tls-key`; `SIGHUP` reloads them without dropping connections) or Let's Encrypt (`--acme-host nmon.example.net` repeatable, `--acme-email you@example.net`; nmon answers the http-01 challenge on `--acme-listen`, default `:80`, certificates are cached in `<data-dir>/acme`). With neither (and no `--http-listen`) nmon generates a self-signed certificate, see [The self-signed certificate](#the-self-signed-certificate). `--insecure-http` is for development: plain HTTP, cookies without `Secure`.
- **A sign-in method is required** over TLS (`auth.oidc` and/or `auth.break_glass_file` in `config.yaml`). On a first start with none configured nmon opens the [setup wizard](#installing-nmon-first-start) instead of refusing. With `--insecure-http` and neither configured nmon runs in *dev-open mode* (everyone is admin `dev`; a banner says so); never expose that port.
- Behind a reverse proxy, see [Behind a reverse proxy](#behind-a-reverse-proxy): `--http-listen ADDR` adds an opt-in plain-HTTP listener, and the proxy goes in `auth.trusted_proxies`; `X-Forwarded-*` is ignored from anyone else.
## Installing nmon / first start
On a fresh install nmon needs no `config.yaml`: start it, open `https://<host>:8443/` and the browser lands on `/setup`, a five-step wizard (sign-in, basics, first devices, alerting, finish). Steps 2 to 4 can be skipped and done later in Settings.
nmon serve --data-dir /var/lib/nmon --secret-key-file /etc/nmon/secret.key
### Install mode
Install mode is on only while **no sign-in method is configured** (no `auth.oidc`, no `auth.break_glass_file`) **and setup has never been completed**. While it is on:
- **Anyone who can reach nmon can run setup**, as an admin. There is no setup token and no localhost restriction: nmon is meant to sit in a DMZ or an internal network (`docs/DEVIATIONS.md` row 51). Finish setup right after starting, or keep the port firewalled until you have.
- nmon logs a `WARN` at start and every 10 minutes: `INSTALL MODE: setup is open to anyone who can reach this address`.
- Only the wizard works. The UI, `GET /api/v1/setup`, the settings form, webhook presets/preview/test, adding devices (and CSV import), alert policies, the profile list, secrets and `GET /api/v1/meta` are served; **every other API route answers `503` "setup not finished"** until setup completes.
Install mode ends **for good** when setup completes (`POST /api/v1/setup/complete`, the wizard's last step). The completion is stored in the inventory database and nothing on the web can undo it. If you later delete the break-glass file or the `auth:` block, nmon does not reopen setup: it refuses to start with "no way to sign in", as before. An existing install that already has a sign-in method is marked completed the first time it starts on this version, so an upgrade can never open install mode. `--insecure-http` (development) never enters install mode.
To skip install mode altogether, configure sign-in before the first start, as the Ansible role does (`nmon_break_glass_file` / `nmon_oidc_*`): such an install is treated as already set up.
### Setup wizard: sign-in
Required. Pick the break-glass administrator (default `admin`) and a password of at least 12 characters. nmon hashes it with bcrypt, writes `<data-dir>/breakglass` (mode 0600, `user:hash`) and sets `auth.break_glass_file`. Optionally add an OpenID Connect provider: issuer, client id, redirect URL (prefilled from the address you are using plus `/auth/callback`), groups claim, admin group and the client secret, which is stored encrypted as the secret `oidc-client`. nmon checks the fields now and contacts the provider at the first sign-in. Sign-in to the web UI has no login page: OIDC redirects the browser to the provider, and the break-glass admin signs in at `/auth/breakglass` (HTTP Basic).
### Setup wizard: basics
The time zone (site default for p95 months), retention (raw samples `retention.hot` 30d, five-minute rates 120d, hourly rates 2y) and the default poll interval (`poller.interval`, 30s to 1h, default 5m). All of them are also in Settings, General and Retention.
### Setup wizard: first devices
Choose an SNMP credential (v2c community, or v3 user with auth and privacy), then paste IP addresses (one per line, IPv6 or IPv4) or upload a CSV. Each address is added like the Add device dialog does it, so a device that does not answer SNMP is reported and not added. Host names are not resolved; use the address. The step shows each device's discovery state live.
### Setup wizard: alerting
Add one receiver, a webhook (with the Teams, Slack, Discord and generic JSON presets, a live preview and Send test) or email, and optionally turn on the recommended alert policies: `device_down`, `device_warning`, `interface_errors`, `cpu_high`, `memory_high` and `disk_full`, each for all devices. Receivers get every notification, so the policies need no binding to the new receiver. Running the step again creates nothing twice. Interface-down alerts stay opt-in (see below).
### Setup wizard: finish
The wizard summarises what it set up and calls `POST /api/v1/setup/complete`. nmon checks that a sign-in method now exists (otherwise `409`), stores the completed flag, and switches authentication on **without a restart**: the live auth service is replaced by one built from the new configuration, and install mode closes. The browser is then sent to sign in.
### The self-signed certificate
When none of `--tls-cert`/`--tls-key`, `--acme-host` or `--http-listen` is given, nmon serves HTTPS with a self-signed ECDSA P-256 certificate kept in `<data-dir>/tls/` (`cert.pem`, `key.pem` mode 0600). It is created once and reused on every start; it is valid for about two years and replaced within 30 days of expiry. Its names are the host name, `localhost`, `::1`, `127.0.0.1` and every non-loopback interface address (IPv6 included), as they were when it was created: delete the two files to make a new one after the host's addresses change. Browsers warn about it, and nmon logs a `WARN` at each start. Replace it with `--tls-cert`/`--tls-key` (reloaded on `SIGHUP`) or `--acme-host`. With `--http-listen` alone nmon serves plain HTTP only and creates no certificate.
## Behind a reverse proxy
nmon serve --data-dir /var/lib/nmon --secret-key-file /etc/nmon/secret.key --http-listen [::1]:8080
`--http-listen ADDR` (default off; any interface is allowed, but loopback or an internal address is the point) adds a **plain-HTTP listener** that serves the same UI and API. HTTPS stays the default and is unchanged.
- Beside HTTPS: with `--tls-cert/--tls-key` or `--acme-host` too, nmon serves HTTPS on `--listen` and plain HTTP on `--http-listen`, so the proxy may reach it either way. Alone: with no TLS configured nmon starts with only the HTTP listener (`--listen` is not bound) instead of refusing. SIGHUP still reloads the certificate for the HTTPS listener.
- **Sign-in stays on** over this listener (OIDC, break-glass, API tokens), exactly as on HTTPS. It is not dev-open mode: with no `auth.oidc`/`auth.break_glass_file` nmon refuses to start. `--http-listen` and `--insecure-http` are mutually exclusive (`--insecure-http` is development mode with no sign-in).
- List the proxy in `auth.trusted_proxies` (addresses or CIDRs, live-reloadable). Only a request from a trusted proxy has its headers honoured: `X-Forwarded-For` gives the client address used by the audit log and the break-glass lockout (the rightmost hop that is not itself a trusted proxy), and `X-Forwarded-Proto: https` marks the browser connection as HTTPS. From anyone else those headers are ignored and removed, so they cannot be spoofed. With the list empty nmon logs a WARN at startup: every client is seen as the proxy's address, so the lockout is per-proxy.
- **Cookies:** the session and sign-in-flow cookies are always `Secure` on the HTTPS listener. On the HTTP listener they are `Secure` only when a trusted proxy sent `X-Forwarded-Proto: https`; a direct plain-HTTP client gets them without `Secure` (a browser would drop them otherwise).
- **OIDC:** `auth.oidc.redirect_url` is configured explicitly and is never derived from the request, so set it to the public URL (`https://nmon.example.net/auth/callback`). CSRF uses a per-session token, not the Origin header, so a different public host or scheme needs nothing else.
- nmon uses no websockets or long-lived streams; the proxy needs no upgrade headers. Raise the proxy's body-size limit if you import large CSV files.
nginx, proxying to the plain-HTTP listener:
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name nmon.example.net;
ssl_certificate /etc/ssl/nmon.example.net.crt;
ssl_certificate_key /etc/ssl/nmon.example.net.key;
client_max_body_size 64m;
location / {
proxy_pass http://[::1]:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
}
}
with `auth: {trusted_proxies: ["::1"]}` in `config.yaml`. To talk TLS to nmon instead, run nmon with its normal `--tls-cert/--tls-key` (no `--http-listen` needed), use `proxy_pass https://[::1]:8443;` with `proxy_ssl_verify off;` (or `proxy_ssl_trusted_certificate` for a CA-signed pair), and the same `proxy_set_header` lines; the listener is HTTPS, so cookies are `Secure`. Ansible: set `nmon_http_listen: "[::1]:8080"` (and `nmon_tls_mode: none` to serve only that listener).
## Sign-in
`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
Settings (admin) is point-and-click: a section list (General, Retention, Sites, Notifications, Authentication, Backup, Disk guard, L2 and OUI, Proxmox, Checks, Alerting) with a form per section, its own Save and Revert, and the applied / restart-required result under it. Unset fields show the default as a placeholder; durations take `30d`, `12h`, `5m`. A form that refers to a secret (OIDC client secret, Proxmox token) takes the secret's name and has a "Set secret value" button that stores the value write-only through `PUT /api/v1/secrets/{name}`; it is never shown again. The same sections are available as typed JSON: `GET /api/v1/settings/form` and `PUT /api/v1/settings/form` (each section present in the body replaces that section; the others are untouched). Alert rules and policies are not here, they are under Alerts; `alerting.rules` in `config.yaml` is preserved by the forms. The Advanced (YAML) tab and `PUT /api/v1/settings` still edit the whole document, and `config.yaml` + `SIGHUP` still works; all three go through the same validation. Saving rewrites `config.yaml` from the parsed configuration, so comments in the file are not kept. An invalid document is rejected and the running configuration stays. Alert receivers, the outbound allow-list, trusted proxies, the backup schedule, disk thresholds and the purge period apply live; everything else is listed as `restart_required` until the next restart. `disk: {alert_used: 0.8, admit_used: 0.9, reserve_free: 0.05}`: alert at 80% used, no new series at 90%, ingest throttled / rollup paused / backups refused below 5% free.
## Metric stores
The supervisor restarts a crashed store; after three restarts in ten minutes it raises `store_failed` and stops. `nmon store restart [hot|long] --api URL --token T` (or `SIGHUP`) resumes it.
## Interface-down alerts are opt-in
Since v1.1 the bell and the group switch only **mark** ports: `interface_down` alerts for them only where an alert policy for `interface_down` uses the `alert_down` matcher (template "Bell-marked ports: interface_down"). Mark ports in one at a time (the bell on the device page's interface table), all at once (the "Alert on down" control in the interfaces card header, or `POST /api/v1/devices/{slug}/interfaces/alert-down`), or through a group (the group's "Alert when member ports go down" switch; `PUT /api/v1/interfaces/{id}` with `{"alert_down": true}` for one port). A port in a group with the switch on alerts for as long as its membership is in force. Changes reach the evaluator at once; opting a port out closes its open alert silently as `retired`.
## Alert policies: nothing alerts until you add one
Alerts fire only where an **alert policy** binds a rule to a scope. A fresh install has no policies and records no alerts at all: no metric rule, no `device_down` or `device_warning`, no system alarm (ingest caps, late p95, rollup, disk guard, break-glass, backup). The startup log says so once ("no alert policies: nothing will alert"). Upgrading from v1.0 seeds nothing: the first cycle closes every open alert that no policy binds, silently, as `retired` (no "resolved" notices go out).
Manage policies under Alerts > Alert policies (admins edit, viewers read) or with `GET/POST /api/v1/alert-policies` and `GET/PUT/DELETE /api/v1/alert-policies/{id}`. `GET /api/v1/alert-rules` lists the rule names. `POST /api/v1/alert-policies/preview` counts the devices and ports a draft scope covers. Changes apply on the next cycle with no restart. Removing or disabling a policy retires its open alerts quietly.
A policy has a rule (a metric rule, `device_down`, `device_warning`, or `system`), an action (`alert` or `ignore`), a priority, optional `severity`, `threshold` and `for` overrides, and a scope. Scope fields are ANDed and any entry of a list may match: `all`, `groups` (a port is in force as a member now; a device matches if any port does), `devices` (`slugs` globs, `profiles`, `roles`, `sites`, `kinds`) and `interfaces` (`if_types`, `if_name` and `if_alias` as RE2, `min_speed_mbps`, `max_speed_mbps`, `alert_down`). For one entity and rule the matching policy with the highest priority decides; on a tie `ignore` beats `alert`, then the lowest name. A policy threshold beats a rule's group override, the profile threshold and the default. Interface matchers are refused on device-level rules, and a `system` policy takes only `all: true`.
Fiber and uplinks alert on any error, WiFi never alerts on errors:
```
{"name": "Fiber + uplinks: errors", "rule": "interface_errors", "action": "alert", "threshold": 0,
"scope": {"interfaces": {"if_types": [6], "if_alias": "(?i)fib|uplink|core"}}}
{"name": "WiFi: no error alerts", "rule": "interface_errors", "action": "ignore", "priority": 10,
"scope": {"interfaces": {"if_types": [71]}}}
```
## Alert rules: built-in and custom
The shipped rules (`interface_down`, `interface_errors`, `cpu_high`, `memory_high`, `disk_full`, `ups_on_battery`, `ups_runtime_low`, `printer_supply_low`, `printer_supply_empty`, `temp_high`) are read-only **built-in** rules, listed with their generated query under Alerts > Alert rules. `cpu_high`, `ups_on_battery` and `printer_supply_empty` have **Copy to custom**, which clones them into an editable builder rule; the others join, filter or union series and say why they cannot be copied. Rules in `config.yaml` (`alerting.rules`) still work but are deprecated, and the startup log says so; define rules in the UI instead. A rule name is unique across all kinds, and `device_down`, `device_warning`, `system` and the subsystem alarm names stay reserved.
Admins add their own rules with the builder (UI or `POST /api/v1/alert-rules`). There is no free-form query: nmon builds it from whitelisted parts, and the generated MetricsQL is shown read-only. **Test** runs the draft once and shows how many entities breach now. Edits apply on the next cycle; disabling a rule or deleting it retires its open alerts silently, and deleting a rule that a policy still names is refused (409, naming the policies).
- Metric rule, "CPU over 80 % on role=server for 15 min": metric `hrProcessorLoad`, aggregate `max` (one value per device), op `>`, threshold `80`, hold time `15m`; then a policy for this rule with `devices.roles: [server]`. Transforms are `value`, `rate` (counters, per second) and `percent_of` (metrics with an `of`, such as `hrStorageUsed`); the window defaults to 5 min (at least twice the poll interval). Push-ingested metrics are device-level.
- Inventory rule, "Uplink without description": interface fields `if_alias empty` AND `if_speed_mbps >= 10000`.
- Inventory rule, "Device not seen for 1 h": device field `last_seen_age older_than 1h`.
A custom rule, like a built-in, alerts nowhere until an alert policy binds it: the rule defines **what** is measured and its default threshold and hold time, and the policy defines **where** it applies and may override severity, threshold and `for`. Several policies can bind one rule to different scopes.
## Webhook templates
Each webhook receiver (Settings > Notifications) has a Go `text/template` that renders the request body. It must produce one valid JSON document (max 256 KB); it is sent as `application/json`. Empty means the default, `{{json .}}`, the whole notification. SMTP receivers use the same editor for `Subject: ...`, a blank line and the body.
**Fields** (a template sees nothing else): `.IncidentID`, `.AlertID`, `.Rule`, `.State` (`firing`, `resolved`, `flapping`, `stale`, `suppressed_summary`), `.Severity` (`critical`, `warning`, `info`), `.Device.Slug`, `.Device.Name`, `.Interface.ID`, `.Interface.Name`, `.Interface.Alias` (empty for device-level alerts), `.Summary`, `.Description`, `.FiredAt`, `.ClearedAt` (nil until resolved), `.Labels`, `.Parents`, `.SuppressedChildren`, `.URL` (link to the device in nmon).
**Functions:** `json`, `upper`, `lower`, `join`, `timefmt` (`{{timefmt .FiredAt "2006-01-02 15:04:05 MST"}}`, UTC), plus the standard `printf`, `eq`, `or`, `if`.
**The `{{json}}` rule:** wrap every value you put inside a JSON string or value in `{{json ...}}` (`"text": {{json .Summary}}`, never `"text": "{{.Summary}}"`). `json` adds the quotes and escapes quotes, newlines and control characters, so an alert text can never break the document. Secret-looking keys and `key=value` pairs are redacted from the rendered payload before it is stored or sent.
**Presets:** "Start from preset" in the receiver form fills the template (it asks before replacing a non-empty one). Built in, also at `GET /api/v1/notify/presets`:
| Preset | For |
|---|---|
| Generic JSON | the default; the whole notification |
| Microsoft Teams (Workflows) | Adaptive Card 1.4 for the Workflows trigger "When a Teams webhook request is received" |
| Slack | incoming webhook: text fallback and a coloured attachment with blocks |
| Discord | webhook with one embed, coloured by state and severity |
Teams: Microsoft is retiring the Office 365 connectors ("Incoming Webhook" in a channel's Connectors). Create a Workflow from the template "Post to a channel when a webhook request is received" and paste its URL. The preset is an Adaptive Card message and will not work with a legacy connector URL.
**Preview and test:** the editor shows the server-rendered payload for a sample notification in any state as you type, or the template or JSON error. "Send test" delivers one sample immediately (no retry queue; nothing stored except an `notify.test` audit row) and shows the HTTP status, a response excerpt (4 KB, redacted), the duration and any error. Blank header values and a blank SMTP password use the stored receiver of the same name, so you can test a saved receiver without retyping credentials. Admin only; at most two tests run at once. `POST /api/v1/notify/preview` and `POST /api/v1/notify/test` take `{"receiver": {...form shape...}, "state": "firing"}`.
**Outbound allow-list:** test sends and real deliveries go through the same gate. A webhook to a private, loopback or link-local address is refused unless it matches `alerting.outbound_allow` (Settings > Notifications), and the test result says so.
## Retired devices
`POST /api/v1/devices/{slug}/purge` (admin) deletes a retired device's series from both stores once it has been retired for `retired_purge_after` (default 1y).
## CSV import
Devices (`name,address,site,role,credential,parents,...`) and port descriptions (`device,ifname,alias`) from Settings or `POST /api/v1/devices/import`. Port descriptions are stored in the operator-owned `description` field, which discovery never overwrites. A dependency cycle is rejected with its path; nothing is written unless the whole import is valid.
## Find host: MAC, ARP and FDB lookup
The **L2 table job** walks the bridge forwarding tables, VLAN names and ARP / IPv6 neighbour tables of every SNMP device every `l2.interval` and keeps them with first and last seen in the inventory, so the **Find host** page (and the header search, for a MAC, IP, CIDR or `vendor:name`) answers "which switch port is this host plugged in at, in which VLAN, and when did we last see it", with the uplinks that also saw it collapsed underneath. A MAC is seen on every switch it passes through, so the answer is the **edge** port: one that carries at most `l2.edge_max_macs` MACs, has no LLDP neighbour and is not a LAG.
```yaml
l2:
interval: 15m # default 15m, minimum 5m
keep: 90d # history kept by last_seen, pruned daily
edge_max_macs: 8 # a port with more MACs is an uplink
concurrency: 4 # devices walked at once
device_timeout: 2m # a slower device is skipped for the cycle
oui:
fetch: true # IEEE vendor registry; false never touches the network
refresh: 7d
```
All of these apply from the next cycle after a reload (Settings save or SIGHUP) with no restart. The job never runs on the poll path and yields to the poller the way the rollup job does. A device that does not answer in time is skipped for that cycle and named on the Poller page (`l2` status and an `l2_devices_skipped` note); it never delays the other devices. A Cisco IOS switch keeps one bridge table per VLAN: the `cisco-ios` profile sets `l2.per_vlan_context: true`, which lists the VLANs from CISCO-VTP-MIB and walks each with community `<community>@<vlan>` (v3: context `vlan-<vlan>`). Other devices use Q-BRIDGE-MIB, then BRIDGE-MIB (VLAN unknown).
API: `GET /api/v1/l2/search?q=`, `GET /api/v1/devices/{slug}/{l2,fdb,arp,l2/ports}`, `GET /api/v1/interfaces/{id}/macs`. On a device, the **L2** tab (MAC table with a VLAN filter, ARP / ND, VLAN list) appears once the job has read it, and the Ports tab shows each port's MAC count linking to that port's MACs.
## MAC vendors (optional IEEE registry)
If a registry is loaded, each MAC shows its vendor, a locally administered address (typical of randomised phone and VM MACs) shows a "private" badge, and `vendor:ubiquiti` lists the current MACs of a vendor. nmon works fully without it: the vendor is simply empty, and the Poller page notes `oui_unavailable` (not an alert).
The registry is the IEEE's public data: MA-L (`https://standards-oui.ieee.org/oui/oui.csv`), MA-M (`.../oui28/mam.csv`) and MA-S (`.../oui36/oui36.csv`), downloaded weekly through the outbound allow-list (`standards-oui.ieee.org` must be allowed when an allow-list is configured) with timeouts, a size cap and a row-count sanity check, so a truncated or wrong download keeps the previous file. The compact result is `<data-dir>/oui/registry.gz` (checksummed, written atomically, included in `nmon backup`). Where nmon may not fetch, set `oui.fetch: false` and load the CSVs by hand: `nmon oui import <dir-or-file> --data-dir DIR` (a running nmon notices within an hour). The CSVs are not part of this repository.
## BGP
Router profiles collect BGP session state, uptime, accepted prefixes per address family and update rates every 5 minutes (see [Collection coverage](docs/coverage.md) for which profiles and MIBs). The **BGP** page lists every session on every device, the ones that are down first, filterable by state, AS, family and text; a device's **Routing** tab shows its sessions with click-to-graph on prefixes and updates.
The built-in rule `bgp_peer_down` (critical, state not established for 2 minutes) fires only where a policy binds it, like every rule: use the template "Routers: BGP peer down" under Alerts > Alert policies. Its notification names the peer and, from discovery, the peer's AS and description. A prefix-drop alert is a custom rule (v1.2); an example will be added here once custom rules are in.
## Poll interval
SNMP interface counters are polled every 300 s by default. An admin can set a different interval for one device (Device settings, "Poll interval", 30 to 3600 s; blank returns it to the default) or through `PUT /api/v1/devices/{slug}` with `poll_interval_s`. The poller picks it up at once, no restart. Graph rate windows, reachability fallbacks and the rollup follow the device's own interval (see `docs/DEVIATIONS.md` row 49). ICMP is still probed every 15 s.
**Port totals.** A port graph (`GET /api/v1/graph`) also returns `totals`: bytes in and out over the requested range, the average-rate series integrated over each point's own step (hourly and raw data stitched without double counting). `covered_seconds` against `range_seconds` says how much of the range had data; the UI marks a total with `*` when coverage is below 99.5%. Unreliable (32-bit fast link) ports get none. The port grid shows `total` beside avg and p95; the full graph has a Total column. Per port only.
**Port graph parity with LibreNMS.** The port summary and the full graph show, per direction, Now / Avg / Max / 95th / Total. Max is the highest *plotted* point (with `max=true` the finer "peak within a step" is a separate Peak column). The aggregate 95th (`p95.aggregate_bps`) is the 95th percentile of in+out per 5-minute slot, counted over slots where both directions have a sample; it is live from the hot store (also next to a materialised monthly figure) and is absent when only one direction was asked for or the range has no raw data. When the port speed is known, the full legend prints each rate's share of that speed, and a toggle plots the y axis as percent of speed (hidden for unknown or zero speed). "Compare previous period" (device port row and graph view) fetches the same-length range just before the current one with the ordinary `from`/`to` of `GET /api/v1/graph`, shifts it forward onto the current axis and draws it faded and dashed, with its own avg / max / 95th / total rows in the legend; it is off while a p95 month is selected.
The graph-type selector offers **Bits**, **Errors** and **Discards**. Errors and discards come from `GET /api/v1/graph/counters?iface=&kind=errors|discards`, the per-second rate of `ifInErrors`/`ifOutErrors` or `ifInDiscards`/`ifOutDiscards` with the same step and rate-window rules as bits. These counters have **no long-store rollup**, so the endpoint answers from the hot store only; a range that starts before hot retention is cut to its start (`clamped: true`, and the UI says so). **Packets** (unicast pps) and **Broadcast/Multicast** appear disabled: the generic profile does not poll `ifHC*UcastPkts`, `ifHC*MulticastPkts` or `ifHC*BroadcastPkts`. Adding them means polling three more columns on every port of every device and regenerating every fixture, so it is a separate decision (DEVIATIONS 56).
The default itself is `poller.interval` in `config.yaml` (Settings, General, "Poll interval"; 30s to 1h, default 5m). A change applies without a restart to the poller, reachability, the rollup and the API. The dev flag `nmon serve --poll-interval`, when given, wins over the setting.
## Testing and debugging a device's polling
Three admin tools answer "why is this device not polling right", from the device page (Actions menu) or the API. None of them shows or logs a credential: the trace names the SNMP version, never the community or the v3 secrets.
- **Poll now** (`POST /api/v1/devices/{slug}/poll-now`, 202) runs the device's primary group right away, outside its schedule. The result lands in the normal places (graphs, reachability). It answers 409 when the device is retired, disabled, has no SNMP credential, or is not loaded by the poller yet.
- **Test poll** (`POST /api/v1/devices/{slug}/poll-test`, optional `?interval_seconds=300` for one interval group) runs one synchronous cycle of the device's real poll plan: the stored credential, the resolved profile, the address and port, and the same fetch code the poller uses (the varbind count starts at the default and halves on tooBig or a lost response, exactly as in polling, but is forgotten afterwards). It writes **no metrics** and changes no poller state, so it cannot cause a reboot event, a backoff or a down alert. The answer lists each request (the session, number of varbinds, first OID, start and duration in ms, outcome `ok`, `timeout`, `too_big`, `snmp_error`, `dial_error` or `error`, and whether it was a retry in smaller pieces) and every requested OID with the series it feeds, its label (interface ULID or component), the SNMP type, the raw value and what the poller would store, or why not: `no_such_object`, `no_such_instance`, `end_of_mib`, `skipped` (text, type mismatch, not available), `not_fetched` (its request failed) or `oid_mismatch`. The `duration_ms` of a request includes the SNMP library's own retries; they are not listed separately. Limits: one test per device at a time (409), four at once overall (429), 60 s at most, 5,000 OID rows at most (`truncated: true`; failures and misses are kept first). Every test is audited as `device.poll_test`.
- **SNMP debug** (`PUT /api/v1/devices/{slug}/snmp-debug` with `{"minutes": N}`; 0 turns it off, 60 at most; `GET` reads it) records every scheduled cycle of one device while it is on: the same request and OID detail as a test poll, for the last 20 cycles (500 OID rows each, failures first), newest first. It also logs one INFO line per cycle (`snmp debug cycle`, with the counts, duration and error). It switches off by itself when the minutes are up. **Recordings are kept in memory only** and are cleared on restart; starting a new recording drops the previous one, and switching off keeps what was captured so you can still read it. Turning it on or off is audited as `device.snmp_debug`. Use Poll now while it is on to get a cycle at once.
From a shell: `nmon device poll <slug> --api URL --token TOKEN` queues a poll now; add `--test` for the traced test poll (`--interval SECONDS` for one group, `--json` for the full trace).
Reading a trace: a request that times out with every OID `not_fetched` is a reachability, community or ACL problem (check the target address, the SNMP version and the device's allowed managers). A `too_big` request followed by `retry` requests means the device cannot answer 40 varbinds at once; the poller adapts, so this is only a note. Many `no_such_instance` rows for interface OIDs mean the stored interfaces no longer match the device: run Rediscover.

67
docs/profiles.md Normal file
View File

@@ -0,0 +1,67 @@
# Adding a device profile
A **profile** tells nmon what to collect from one kind of device. nmon ships profiles for common gear (see [Collection coverage](/help/coverage)). When your device is only partly covered, or not at all, write one. Profiles are YAML files; nmon never reads a MIB while polling, so every metric is a numeric OID.
## Where profiles live
Shipped profiles are built into the binary. Your own go in `<data-dir>/profiles/*.yaml`; they are loaded over the shipped ones, and a profile with the same `name` replaces the shipped one. Restart `nmon serve` after changing a file, and run `nmon profile test` first to catch mistakes: a profile that fails validation is refused with an error naming the file.
## Basics
name: acme-switch
extends: generic
priority: 20
match:
sysObjectID: 1.3.6.1.4.1.99999
sysDescr: '(?i)^AcmeOS'
interfaces:
exclude_ifname: ['^Vlan', '^Tunnel']
metrics:
- name: acmeCpuLoad
category: health
label: "CPU load (5 min)"
oid: 1.3.6.1.4.1.99999.1.1.5
type: gauge
unit: percent
interval: 5m
index_label: { column: acmeCpuIndex, as: component }
- **extends: generic** inherits the standard metrics (interfaces, sysUpTime, host resources, entity sensors, LLDP). Always extend `generic`; your file only adds what is specific to the device.
- **match** picks the profile for a device at discovery. `sysObjectID` is an OID prefix (the vendor's enterprise number, or a product branch of it). `sysDescr` is a regular expression on the system description. Give either or both; both must match when both are present.
- **priority**: when several profiles match, the highest priority wins. `generic` is 0. Give a model-specific profile a higher number than the vendor-wide one, and watch overlaps: many devices run Linux and share its `sysDescr`, and several products share one enterprise number.
- **metrics**: use numeric OIDs, taken from the vendor MIB, and keep a comment naming where each came from.
## Metric fields
| Field | Meaning |
|---|---|
| `name` | Series name stored in the metric store. |
| `oid` | Numeric OID of the column or scalar. |
| `type` | `counter64`, `counter32`, `gauge`, `status` or `text`. |
| `unit` | `percent`, `celsius`, `octets`, `bytes`, `enum`, `count` and so on. |
| `interval` | How often to poll, for example `60s` or `5m`. |
| `category` | Where it shows on the device page, for example `health`, `routing`. Every metric that is not an interface metric needs one. |
| `label` | The human-readable name shown in the UI. |
| `index_label` | Which table column identifies the row, and the label it becomes (`component`, or `iface` for interfaces). |
| `scale` | Multiplier for the raw value, for example `1024` to turn kilobytes into bytes. |
| `enum` | For `status` metrics, the value-to-name map. |
Every port is polled. Only `interfaces.exclude_ifname` (a list of regular expressions on the interface name) drops one. 32-bit counters on fast links are flagged unreliable and left out of totals and rollups.
## Testing a profile
nmon profile test 2001:db8::10 --community public --data-dir /var/lib/nmon
`profile test` runs discovery and one poll against a live device with the matching profile and prints every metric with its resolved unit, so you can check the match, the OIDs and the units before the device is added. Use `--version 3 --v3-user USER ...` for SNMPv3 (`nmon profile test --help` lists every option). The `--data-dir` flag makes it load your profiles from `<data-dir>/profiles`.
## Recording a device
nmon profile record 2001:db8::10 --community public --out acme.snmprec
`profile record` does a read-only SNMP walk and writes a `.snmprec` file with communities, `sysContact` and `sysLocation` redacted. Keep it: it is the best bug report for a device nmon does not understand, and it lets a profile be tested without the hardware.
## Drafting metrics from a MIB
nmon profile new --mib ACME-SYSTEM-MIB
`profile new` compiles objects from a MIB into draft metric entries with the OID, type and enum filled in, leaving `unit`, `category`, `label` and `interval` for you. nmon ships the standard MIBs; drop a vendor MIB into the `mibs/` folder (the `--mibs-dir` setting, by default `<data-dir>/mibs`) first. Review the draft, then test it with `nmon profile test`.

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

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