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
9.4 KiB
9.4 KiB
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)
docs/spec/spec.mdanddocs/spec/build-detail.md: the owner's spec. The contract.docs/DEVIATIONS.md: every approved departure from the spec, with the reason. Check it before "fixing" something to match the spec.docs/MAINTAINABILITY.md: hard rule, enforced by lint. Read it before writing code.docs/milestones/: per-milestone scope, decisions and gate logs (M1–M6,v1.0.1-device-tabs.md), plus deferred items and post-v1 additions inREADME.md.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 lintmust be clean, Go (golangci-lint v2.14.0 via.golangci.yml, plusscripts/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.
//nolintonly with the linter's name and a reason.
- Layering (depguard):
- No SQL or bun outside
internal/store. - Only
cmd/*importsinternal/app, and onlyinternal/appimportsinternal/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/nmonmust not link dev/test machinery (simagent,fixturegen,fixtures,librenms,testutil);TestNmonDoesNotLinkDevMachineryenforces this.
- No SQL or bun outside
- Time comes only from
internal/clock(forbidigo banstime.Now/Sleep/After… outsideclock,cmd/and tests). Tests useclock.NewFake. - Frozen contracts:
- Metric names and labels: SNMP series carry only
device=<slug>andiface=<ULID>/component. Long-store rollups usev(highest wins; read viainternal/longq.LatestVersion). - API routes:
scripts/openapi-check.shfails on incompatible changes vs the last tag. Additive changes only.
- Metric names and labels: SNMP series carry only
- Migrations are frozen: never edit an applied migration. Add
internal/store/<YYYYMMDDNNNNNN>_<name>.gowith 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 instore.go. - API types in the UI are generated, never hand-written:
go run ./cmd/nmon openapi > openapi.jsoncd web && npm run gen:api(writesweb/src/api/schema.d.ts)- Use
client.GET/POST.
- Only
web/src/components/TrafficGraph.tsximports uPlot. Single-series graphs use itsformatprop. - IPv6 first. Everything works on v6 and v4; tests default to
::1. - Every port is polled. Only a profile's
exclude_ifnamedrops one. 32-bit counters on fast links are flaggedunreliableand 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>.yamlthatextends: generic, withmatch(sysObjectID prefix and/or sysDescr regex) andpriority. 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 acategory; the orphan test enforces it. - Then run
go run ./testdata/genfor 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.
- Add
- 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).TestViewerCannotMutatewalks every route automatically. - Regenerate
openapi.jsonand the web schema, then runscripts/openapi-check.sh.
- Put the handler in a new file in
- A UI page:
- Use the generated client in
web/src/api/*Hooks.ts,NavRowfor clickable rows, andRequireAdminor role checks for mutating controls. - Mock handlers and fixtures live in
web/e2e/mock/for the Playwright mock suite.
- Use the generated client in
- A deviation from the spec: add a row to
docs/DEVIATIONS.mdexplaining what and why.
Gotchas
- Concurrent agents:
- Stage only your own paths, never
git add -A. openapi.jsonandweb/src/api/schema.d.tsare shared, so regenerate them just before committing.- Two agents writing a migration must agree on numbers first.
- Stage only your own paths, never
- 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_PORTif avite previewmay already be running, and kill any preview server you start.
- Full Playwright runs rewrite the committed screenshots under
- Gate scripts:
- Gate scripts run
make lintfirst; a lint finding anywhere fails every gate. --insecure-httpwith no sign-in configured runs dev-open mode (everyone is admin). The gates rely on it; never ship it.
- Gate scripts run
- Ingest: reserved metric prefixes (
nmon_,if,hr,sys+ uppercase/digit) and the labelsdevice/iface/v/pushare stripped or refused. - ICMP:
nmon serve --icmp off, or an unsupported unprivileged ICMP socket, makes SNMP alone decide reachability, with anicmp_unsupportedwarning.
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.