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
33 KiB
33 KiB
Deviations from the spec
Every place the build departs from docs/spec/, with the reason. The spec is the contract; a line here is a
conscious, reviewed exception.
| # | Spec says | Build does | Why | Milestone |
|---|---|---|---|---|
| 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). 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 |