Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a6afdda08 | ||
|
|
4ae44e36fc | ||
|
|
26fdcdc124 | ||
|
|
f31fb3712a | ||
|
|
b89224254e | ||
| e60ae0a850 | |||
|
|
dbfeeeebc7 | ||
|
|
273c6b541b | ||
|
|
881f0ae3b1 | ||
|
|
55265698e9 | ||
|
|
33948d3ee8 | ||
|
|
674816c98c | ||
|
|
61b5341774 | ||
|
|
7173360346 | ||
|
|
c4d14a67d1 | ||
|
|
b529423324 | ||
|
|
06a3c85c69 | ||
|
|
ddc8863a67 | ||
| c4f5ee916b | |||
| 992f09b97a | |||
| d92c6e1e08 | |||
| 9b4ab7ccbb | |||
| 457191accd | |||
| 61274c4f55 | |||
| fe53998aa6 | |||
| e25143881c | |||
| ad220eae58 | |||
| 345daaa773 | |||
| bf6dbd4923 | |||
| d97dd1d44a | |||
| 28f5444860 | |||
| 6ae7729107 | |||
| faa0fa7505 | |||
| 4b7849c45b | |||
| 0754268e9e | |||
| a768a15fb6 | |||
| d7df853a35 | |||
| 81162b57b5 | |||
| fe4db76a6e | |||
| 4a57eac1ed | |||
| b3aae800bf | |||
| ba960474bc | |||
| d3ec85a19c | |||
| b740611136 | |||
| f85c3ef781 |
11
.github/workflows/ci.yml
vendored
@@ -3,7 +3,7 @@ on: [push, pull_request]
|
||||
jobs:
|
||||
lint:
|
||||
# Hard rule (docs/MAINTAINABILITY.md): any finding fails the build.
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: debian-13
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
@@ -13,10 +13,7 @@ jobs:
|
||||
- run: cd web && npm ci
|
||||
- run: make lint
|
||||
test:
|
||||
strategy:
|
||||
matrix:
|
||||
arch: [ubuntu-latest, ubuntu-24.04-arm]
|
||||
runs-on: ${{ matrix.arch }}
|
||||
runs-on: debian-13
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with: { fetch-depth: 0 }
|
||||
@@ -30,14 +27,14 @@ jobs:
|
||||
- run: make test-v6only
|
||||
- run: make build && file nmon | grep -q 'statically linked'
|
||||
postgres:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: debian-13
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with: { go-version: stable }
|
||||
- run: make test-pg
|
||||
openapi:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: debian-13
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with: { fetch-depth: 0 }
|
||||
|
||||
10
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
23
Makefile
@@ -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
|
||||
|
||||
162
cmd/nmon-devtools/backfill.go
Normal 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())
|
||||
}
|
||||
80
cmd/nmon-devtools/backfilllong.go
Normal 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
@@ -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
|
||||
}
|
||||
163
cmd/nmon-devtools/gatedump.go
Normal 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
|
||||
}
|
||||
59
cmd/nmon-devtools/m5_test.go
Normal 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
@@ -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
|
||||
}
|
||||
}
|
||||
102
cmd/nmon-devtools/oidcmock.go
Normal 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
|
||||
}
|
||||
69
cmd/nmon-devtools/pattern.go
Normal 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
|
||||
}
|
||||
56
cmd/nmon-devtools/pattern_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
75
cmd/nmon-devtools/proxmoxmock.go
Normal 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)
|
||||
}
|
||||
95
cmd/nmon-devtools/pushreplay.go
Normal 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
@@ -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)
|
||||
}
|
||||
84
cmd/nmon-devtools/store.go
Normal 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
|
||||
}
|
||||
219
cmd/nmon-devtools/topology.go
Normal 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
@@ -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
@@ -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
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,47 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/v10networks/nmon/internal/config"
|
||||
"github.com/v10networks/nmon/internal/oui"
|
||||
)
|
||||
|
||||
func init() {
|
||||
register("oui", "load the IEEE vendor registry from disk: nmon oui import DIR|FILE", ouiCmd)
|
||||
}
|
||||
|
||||
const ouiUsage = "usage: nmon oui import <dir-or-file> --data-dir DIR\n\n" +
|
||||
"Loads the IEEE MAC registry CSVs (oui.csv for MA-L, mam.csv for MA-M, oui36.csv for MA-S, downloaded from\n" +
|
||||
"https://standards-oui.ieee.org/) into <data-dir>/oui/registry.gz, where a running nmon picks it up within an hour.\n" +
|
||||
"Use it where nmon may not fetch (oui.fetch: false). The CSVs are public IEEE data and are not kept after import."
|
||||
|
||||
// ouiCmd is "nmon oui import": the offline way to give nmon its vendor registry.
|
||||
func ouiCmd(_ context.Context, args []string) error {
|
||||
if len(args) == 0 || args[0] != "import" {
|
||||
return fmt.Errorf("%s", ouiUsage)
|
||||
}
|
||||
var f config.Flags
|
||||
fs := newFlagSet("oui import", "oui import <dir-or-file> --data-dir DIR")
|
||||
fs.StringVar(&f.DataDir, "data-dir", "", "nmon's data directory")
|
||||
pos, err := parseInterspersed(fs, args[1:])
|
||||
if err != nil {
|
||||
return flagErr(err)
|
||||
}
|
||||
if len(pos) != 1 || f.DataDir == "" {
|
||||
return fmt.Errorf("%s", ouiUsage)
|
||||
}
|
||||
// Only the MA-L registry is required: it holds nearly every vendor. MA-M and MA-S refine it when present.
|
||||
reg, p, err := oui.Import(pos[0], oui.Minimums{MAL: oui.MinMAL})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dst := filepath.Join(config.OUIDir(f.DataDir), oui.FileName)
|
||||
if err := oui.Write(dst, reg); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("oui import: %d MA-L, %d MA-M and %d MA-S assignments written to %s\n", p.MAL, p.MAM, p.MAS, dst)
|
||||
return nil
|
||||
}
|
||||
53
cmd/nmon/oui_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/v10networks/nmon/internal/config"
|
||||
"github.com/v10networks/nmon/internal/oui"
|
||||
)
|
||||
|
||||
func TestOuiImportWritesTheRegistryFileANmonLoads(t *testing.T) {
|
||||
src, data := t.TempDir(), t.TempDir()
|
||||
var b strings.Builder
|
||||
b.WriteString("Registry,Assignment,Organization Name,Organization Address\n")
|
||||
for i := 0; i < oui.MinMAL; i++ {
|
||||
fmt.Fprintf(&b, "MA-L,%06X,Vendor %d,Somewhere\n", 0x100000+i, i)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(src, "oui.csv"), []byte(b.String()), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ouiCmd(context.Background(), []string{"import", src, "--data-dir", data}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg, err := oui.Load(filepath.Join(config.OUIDir(data), oui.FileName))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v, ok := reg.Lookup("10:00:05:00:00:01"); !ok || v != "Vendor 5" {
|
||||
t.Errorf("lookup = %q %v", v, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOuiImportRefusesATruncatedDirectoryAndUsageErrors(t *testing.T) {
|
||||
src, data := t.TempDir(), t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(src, "oui.csv"), []byte("Registry,Assignment,Organization Name,Organization Address\nMA-L,AABBCC,One,x\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ouiCmd(context.Background(), []string{"import", src, "--data-dir", data}); err == nil {
|
||||
t.Error("a registry with one row was imported")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(config.OUIDir(data), oui.FileName)); err == nil {
|
||||
t.Error("a refused import wrote a file")
|
||||
}
|
||||
for _, args := range [][]string{nil, {"fetch"}, {"import"}, {"import", src}} {
|
||||
if err := ouiCmd(context.Background(), args); err == nil {
|
||||
t.Errorf("args %v accepted", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -276,7 +276,7 @@ func renderDraft(spec draftSpec, m *mib.Module, metrics []draftMetric) string {
|
||||
var b strings.Builder
|
||||
name := "draft-" + strings.ToLower(spec.Module)
|
||||
fmt.Fprintf(&b, "# DRAFT compiled by `nmon profile new` from %s (%s).\n", spec.Module, filepath.Base(m.File))
|
||||
fmt.Fprintf(&b, "# Save as %s.yaml. Replace every TODO (unit, interval, index_label) and add a match: section.\n", name)
|
||||
fmt.Fprintf(&b, "# Save as %s.yaml. Replace every TODO (unit, interval, category, index_label) and add a match: section.\n", name)
|
||||
b.WriteString("# The loader rejects TODO on purpose, so an unreviewed draft can never be polled.\n")
|
||||
fmt.Fprintf(&b, "name: %s\nextends: generic\nprovenance: %q\nmetrics:\n", name,
|
||||
"drafted by nmon profile new from "+spec.Module+"; review before use")
|
||||
@@ -462,7 +462,8 @@ func writeDraftMetric(b *strings.Builder, dm draftMetric) {
|
||||
}
|
||||
fmt.Fprintf(b, " # %s\n", d)
|
||||
}
|
||||
fmt.Fprintf(b, " - name: %s\n oid: %s\n type: %s\n unit: %s\n interval: TODO\n", o.Name, oid, dm.typ, dm.unit)
|
||||
fmt.Fprintf(b, " - name: %s\n oid: %s\n type: %s\n unit: %s\n", o.Name, oid, dm.typ, dm.unit)
|
||||
b.WriteString(" interval: TODO\n category: TODO\n")
|
||||
if dm.scale != 0 {
|
||||
fmt.Fprintf(b, " scale: %s\n", strconv.FormatFloat(dm.scale, 'f', -1, 64))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,51 @@
|
||||
# Deploying nmon with Ansible
|
||||
|
||||
Installs nmon as a systemd service on one Linux host (amd64 or arm64). Tested against Debian 13.
|
||||
|
||||
## Quick start
|
||||
|
||||
cd deploy/ansible
|
||||
cp inventory.example.ini inventory.ini # your host
|
||||
cp vars.example.yml vars.yml # no sign-in set = the setup wizard runs on first open
|
||||
ansible-playbook site.yml -e @vars.yml
|
||||
|
||||
The first play builds the release binary on your machine (`make build build-arm64`: needs Go, Node and network).
|
||||
Pass `-e nmon_build=false` to reuse an existing `./nmon`. The second play installs it on the hosts in group `nmon`:
|
||||
|
||||
- `/usr/local/bin/nmon`, user `nmon`, data in `/var/lib/nmon`, key and TLS files in `/etc/nmon`, backups in `/var/backups/nmon`
|
||||
- the MIB sidecar in `/usr/share/nmon/mibs`, used only by `nmon profile new --mib`
|
||||
- optionally the IEEE vendor registry CSVs in `/usr/share/nmon/oui`, loaded with `nmon oui import` (`nmon_oui_download`, `nmon_oui_csv_dir`; by default nmon fetches the registry itself)
|
||||
- `config.yaml` rendered from your vars, and a hardened systemd unit (`systemctl reload nmon` sends SIGHUP to reload config and certificates)
|
||||
- `net.ipv4.ping_group_range` opened so nmon can ping without root
|
||||
|
||||
Then open `https://<host>:8443/`. With no sign-in in vars, nmon starts in install mode and the setup wizard asks for one
|
||||
(anyone who can reach the port can run it until it is done). Otherwise, without OIDC you are sent to `/auth/breakglass`.
|
||||
|
||||
## TLS (`nmon_tls_mode`)
|
||||
|
||||
| Mode | What it does |
|
||||
|---|---|
|
||||
| `selfsigned` (default) | Generates a cert on the host. Good for trying it out; browsers will warn. |
|
||||
| `files` | Copies `nmon_tls_cert_src` / `nmon_tls_key_src` from the control machine. |
|
||||
| `acme` | Let's Encrypt for `nmon_server_name`; port 80 must reach the host. |
|
||||
| `insecure` | Plain HTTP. With no sign-in configured, nmon runs in dev-open mode where everyone is admin. Never expose this. |
|
||||
|
||||
**Reverse proxy:** nmon can sit behind one. Keep the proxy-to-nmon hop on HTTPS (`selfsigned` is fine there), set
|
||||
`nmon_trusted_proxies` to the proxy's addresses, and set the OIDC `redirect_url` to the public URL. Traps (UDP 162) and
|
||||
Graphite (TCP 2003) go to nmon directly, not through the HTTP proxy.
|
||||
|
||||
## Sign-in
|
||||
|
||||
- **Setup wizard:** leave `nmon_breakglass_password` and `nmon_oidc` unset. The play then writes `config.yaml` only
|
||||
if it is missing, so later runs keep the sign-in the wizard saved (and ignore other config vars; edit those in the UI).
|
||||
- **OIDC:** set `nmon_oidc` (issuer, client_id, client_secret, admin_group). The secret is stored encrypted with
|
||||
`nmon secret set` and never written to `config.yaml`. Browsers go straight to the IdP; there is no login page.
|
||||
- **Break-glass:** `nmon_breakglass_password` is bcrypt-hashed on the host. Use it when the IdP is down. Five wrong
|
||||
attempts lock the source IP for 15 min; run `nmon unlock all --data-dir /var/lib/nmon` to clear.
|
||||
|
||||
## After installing
|
||||
|
||||
- **Back up `/etc/nmon/secret.key`** somewhere safe. Without it, a restore has no SNMP credentials.
|
||||
- Add devices in the UI or with `POST /api/v1/devices`, and mint API or push tokens under Settings.
|
||||
- The playbook owns `config.yaml`: edits made in the UI are overwritten on the next run, so put lasting settings in vars.
|
||||
- More: `docs/operations.md`.
|
||||
5
deploy/ansible/ansible.cfg
Normal file
@@ -0,0 +1,5 @@
|
||||
[defaults]
|
||||
inventory = inventory.ini
|
||||
roles_path = roles
|
||||
callback_result_format = yaml
|
||||
host_key_checking = True
|
||||
3
deploy/ansible/inventory.example.ini
Normal file
@@ -0,0 +1,3 @@
|
||||
# Copy to inventory.ini and edit. One nmon host is the design point.
|
||||
[nmon]
|
||||
nmon1.example.net ansible_user=admin ansible_become=true
|
||||
46
deploy/ansible/roles/nmon/defaults/main.yml
Normal file
@@ -0,0 +1,46 @@
|
||||
nmon_user: nmon
|
||||
nmon_data_dir: /var/lib/nmon
|
||||
nmon_etc_dir: /etc/nmon
|
||||
nmon_key_file: "{{ nmon_etc_dir }}/secret.key"
|
||||
nmon_backup_dir: /var/backups/nmon
|
||||
nmon_mibs_dir: /usr/share/nmon/mibs
|
||||
nmon_install_mibs: true
|
||||
# MAC vendors (optional IEEE registry). nmon downloads it itself unless nmon_oui_fetch is false; to ship it instead,
|
||||
# set nmon_oui_download (the host downloads the CSVs) or nmon_oui_csv_dir (a directory of the three CSVs on the control
|
||||
# machine). Either way they are kept next to the MIB sidecar and loaded with `nmon oui import`.
|
||||
nmon_oui_dir: /usr/share/nmon/oui
|
||||
nmon_oui_fetch: true
|
||||
nmon_oui_download: false
|
||||
nmon_oui_csv_dir: ""
|
||||
nmon_oui_urls:
|
||||
- https://standards-oui.ieee.org/oui/oui.csv
|
||||
- https://standards-oui.ieee.org/oui28/mam.csv
|
||||
- https://standards-oui.ieee.org/oui36/oui36.csv
|
||||
# MAC/ARP table job (see docs/operations.md); empty uses the defaults.
|
||||
nmon_l2: {}
|
||||
nmon_listen: "[::]:8443"
|
||||
nmon_trap_listen: "[::]:162"
|
||||
nmon_graphite_listen: "[::]:2003"
|
||||
nmon_icmp: auto
|
||||
nmon_timezone: UTC
|
||||
|
||||
nmon_tls_mode: selfsigned # selfsigned | files | acme | insecure | none (none: plain HTTP only, needs nmon_http_listen)
|
||||
# Opt-in plain-HTTP listener for a reverse proxy, e.g. "[::1]:8080"; sign-in stays required. Empty = off.
|
||||
# Runs beside HTTPS, or alone with nmon_tls_mode=none. Not allowed with nmon_tls_mode=insecure.
|
||||
nmon_http_listen: ""
|
||||
nmon_server_name: "{{ inventory_hostname }}"
|
||||
nmon_acme_email: ""
|
||||
nmon_tls_extra_names: [] # more DNS names / IPs for the self-signed cert
|
||||
|
||||
nmon_breakglass_user: admin
|
||||
nmon_breakglass_password: "" # empty (and no OIDC) = nmon starts in install mode: the setup wizard sets sign-in
|
||||
nmon_oidc: {}
|
||||
nmon_alerting: {}
|
||||
nmon_trusted_proxies: []
|
||||
|
||||
nmon_backup_at: "02:30"
|
||||
nmon_backup_keep: 7
|
||||
nmon_retention:
|
||||
hot: 31d
|
||||
fiveminute: 120d
|
||||
long: 2y
|
||||
14
deploy/ansible/roles/nmon/handlers/main.yml
Normal file
@@ -0,0 +1,14 @@
|
||||
- name: Restart nmon
|
||||
ansible.builtin.systemd_service:
|
||||
name: nmon
|
||||
state: restarted
|
||||
daemon_reload: true
|
||||
|
||||
- name: Reload nmon
|
||||
ansible.builtin.systemd_service:
|
||||
name: nmon
|
||||
state: reloaded
|
||||
|
||||
- name: Apply sysctl
|
||||
ansible.builtin.command: sysctl -p /etc/sysctl.d/60-nmon.conf
|
||||
changed_when: true
|
||||
190
deploy/ansible/roles/nmon/tasks/main.yml
Normal file
@@ -0,0 +1,190 @@
|
||||
- name: --http-listen and TLS mode agree
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- not (nmon_http_listen | length > 0 and nmon_tls_mode == 'insecure')
|
||||
- nmon_tls_mode != 'none' or nmon_http_listen | length > 0
|
||||
fail_msg: "nmon_http_listen cannot be combined with nmon_tls_mode=insecure, and nmon_tls_mode=none needs nmon_http_listen"
|
||||
|
||||
- name: Check the platform (nmon ships linux/amd64 and linux/arm64)
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- ansible_system == "Linux"
|
||||
- ansible_architecture in ["x86_64", "aarch64"]
|
||||
fail_msg: "nmon v1 supports linux/amd64 and linux/arm64 only"
|
||||
|
||||
- name: No sign-in in vars means first-install mode (the setup wizard picks the sign-in)
|
||||
ansible.builtin.set_fact:
|
||||
nmon_install_wizard: "{{ nmon_tls_mode != 'insecure' and nmon_breakglass_password | length == 0 and not nmon_oidc }}"
|
||||
|
||||
- name: Install host packages (openssl for the self-signed cert, htpasswd for bcrypt, acl for become_user)
|
||||
ansible.builtin.package:
|
||||
name: "{{ ['openssl', 'apache2-utils', 'acl', 'procps'] if ansible_os_family == 'Debian' else ['openssl', 'httpd-tools', 'acl', 'procps-ng'] }}"
|
||||
state: present
|
||||
|
||||
- name: Create the nmon system user
|
||||
ansible.builtin.user:
|
||||
name: "{{ nmon_user }}"
|
||||
system: true
|
||||
shell: /usr/sbin/nologin
|
||||
home: "{{ nmon_data_dir }}"
|
||||
create_home: false
|
||||
|
||||
- name: Create directories
|
||||
ansible.builtin.file:
|
||||
path: "{{ item.path }}"
|
||||
state: directory
|
||||
owner: "{{ nmon_user }}"
|
||||
group: "{{ nmon_user }}"
|
||||
mode: "{{ item.mode }}"
|
||||
loop:
|
||||
- { path: "{{ nmon_data_dir }}", mode: "0750" }
|
||||
- { path: "{{ nmon_etc_dir }}", mode: "0750" }
|
||||
- { path: "{{ nmon_backup_dir }}", mode: "0750" }
|
||||
|
||||
- name: Ensure /etc/sysctl.d exists (minimal images lack it)
|
||||
ansible.builtin.file:
|
||||
path: /etc/sysctl.d
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
- name: Allow unprivileged ICMP echo (nmon pings without root)
|
||||
ansible.builtin.copy:
|
||||
content: "net.ipv4.ping_group_range = 0 2147483647\n"
|
||||
dest: /etc/sysctl.d/60-nmon.conf
|
||||
mode: "0644"
|
||||
notify: Apply sysctl
|
||||
|
||||
- name: Install the nmon binary
|
||||
ansible.builtin.copy:
|
||||
src: "{{ playbook_dir }}/../../{{ 'nmon' if ansible_architecture == 'x86_64' else 'nmon-arm64' }}"
|
||||
dest: /usr/local/bin/nmon
|
||||
mode: "0755"
|
||||
notify: Restart nmon
|
||||
|
||||
- name: Install the MIB sidecar
|
||||
when: nmon_install_mibs | bool
|
||||
block:
|
||||
- name: Create the MIB directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ nmon_mibs_dir }}"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
- name: Unpack MIBs
|
||||
ansible.builtin.unarchive:
|
||||
src: "{{ playbook_dir }}/.mibs.tar.gz"
|
||||
dest: "{{ nmon_mibs_dir }}"
|
||||
creates: "{{ nmon_mibs_dir }}/IF-MIB"
|
||||
|
||||
- name: Ship the IEEE vendor registry (optional)
|
||||
when: nmon_oui_download | bool or nmon_oui_csv_dir | length > 0
|
||||
block:
|
||||
- name: Create the registry directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ nmon_oui_dir }}"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
- name: Download the registry CSVs on the host
|
||||
when: nmon_oui_download | bool
|
||||
ansible.builtin.get_url:
|
||||
url: "{{ item }}"
|
||||
dest: "{{ nmon_oui_dir }}/{{ item | basename }}"
|
||||
mode: "0644"
|
||||
timeout: 60
|
||||
loop: "{{ nmon_oui_urls }}"
|
||||
register: nmon_oui_fetched
|
||||
- name: Copy the registry CSVs from the control machine
|
||||
when: nmon_oui_csv_dir | length > 0
|
||||
ansible.builtin.copy:
|
||||
src: "{{ nmon_oui_csv_dir }}/"
|
||||
dest: "{{ nmon_oui_dir }}/"
|
||||
mode: "0644"
|
||||
register: nmon_oui_copied
|
||||
- name: Load the registry into nmon's data directory
|
||||
when: (nmon_oui_fetched is changed) or (nmon_oui_copied is changed)
|
||||
become_user: "{{ nmon_user }}"
|
||||
ansible.builtin.command:
|
||||
argv: [/usr/local/bin/nmon, oui, import, "{{ nmon_oui_dir }}", --data-dir, "{{ nmon_data_dir }}"]
|
||||
changed_when: true
|
||||
|
||||
- name: TLS certificate
|
||||
ansible.builtin.include_tasks: tls.yml
|
||||
when: nmon_tls_mode in ["selfsigned", "files"]
|
||||
|
||||
- name: Break-glass admin (bcrypt hash; the password never lands on disk)
|
||||
when: nmon_breakglass_password | length > 0
|
||||
block:
|
||||
- name: Hash the break-glass password
|
||||
ansible.builtin.command:
|
||||
argv: [htpasswd, -nbBC, "12", "{{ nmon_breakglass_user }}", "{{ nmon_breakglass_password }}"]
|
||||
register: nmon_bg_hash
|
||||
changed_when: false
|
||||
no_log: true
|
||||
- name: Write the break-glass file
|
||||
ansible.builtin.copy:
|
||||
content: "{{ nmon_bg_hash.stdout | trim }}\n"
|
||||
dest: "{{ nmon_etc_dir }}/breakglass"
|
||||
owner: "{{ nmon_user }}"
|
||||
group: "{{ nmon_user }}"
|
||||
mode: "0600"
|
||||
no_log: true
|
||||
# Re-hashing gives a new salt each run; only replace the file when it is missing.
|
||||
args:
|
||||
force: false
|
||||
|
||||
- name: Store the OIDC client secret encrypted (creates the key file on a new install)
|
||||
ansible.builtin.shell:
|
||||
cmd: >-
|
||||
set -o pipefail;
|
||||
printf '%s\n' "$NMON_OIDC_SECRET" |
|
||||
/usr/local/bin/nmon secret set oidc-client --data-dir {{ nmon_data_dir }} --secret-key-file {{ nmon_key_file }}
|
||||
executable: /bin/bash
|
||||
environment:
|
||||
NMON_OIDC_SECRET: "{{ nmon_oidc.client_secret }}"
|
||||
become: true
|
||||
become_user: "{{ nmon_user }}"
|
||||
when: nmon_oidc.client_secret is defined
|
||||
changed_when: true
|
||||
no_log: true
|
||||
notify: Restart nmon
|
||||
|
||||
- name: Write config.yaml
|
||||
ansible.builtin.template:
|
||||
src: config.yaml.j2
|
||||
dest: "{{ nmon_data_dir }}/config.yaml"
|
||||
owner: "{{ nmon_user }}"
|
||||
group: "{{ nmon_user }}"
|
||||
mode: "0640"
|
||||
# The wizard writes its sign-in into config.yaml; never overwrite that with a template that has none.
|
||||
force: "{{ not nmon_install_wizard | bool }}"
|
||||
notify: Reload nmon
|
||||
|
||||
- name: Install the systemd unit
|
||||
ansible.builtin.template:
|
||||
src: nmon.service.j2
|
||||
dest: /etc/systemd/system/nmon.service
|
||||
mode: "0644"
|
||||
notify: Restart nmon
|
||||
|
||||
- name: Enable and start nmon
|
||||
ansible.builtin.systemd_service:
|
||||
name: nmon
|
||||
enabled: true
|
||||
state: started
|
||||
daemon_reload: true
|
||||
|
||||
- name: Wait for nmon to answer
|
||||
ansible.builtin.uri:
|
||||
url: "{{ 'http' if nmon_tls_mode in ['insecure', 'none'] else 'https' }}://localhost:{{ (nmon_http_listen if nmon_tls_mode == 'none' else nmon_listen) | regex_replace('^.*:', '') }}/healthz"
|
||||
validate_certs: false
|
||||
status_code: 200
|
||||
register: nmon_health
|
||||
retries: 30
|
||||
delay: 2
|
||||
until: nmon_health.status == 200
|
||||
|
||||
- name: Where to go next
|
||||
ansible.builtin.debug:
|
||||
msg:
|
||||
- "nmon is up: {{ 'http' if nmon_tls_mode in ['insecure', 'none'] else 'https' }}://{{ nmon_server_name }}:{{ (nmon_http_listen if nmon_tls_mode == 'none' else nmon_listen) | regex_replace('^.*:', '') }}/"
|
||||
- "{{ 'Signs in via your IdP; break-glass at /auth/breakglass' if nmon_oidc else ('Open it to run the setup wizard (first install), or sign in as configured there' if nmon_install_wizard | bool else 'Sign in at /auth/breakglass as ' + nmon_breakglass_user) }}"
|
||||
- "Back up the key file {{ nmon_key_file }} somewhere safe; without it a restore has no SNMP credentials."
|
||||
51
deploy/ansible/roles/nmon/tasks/tls.yml
Normal file
@@ -0,0 +1,51 @@
|
||||
- name: Work out the certificate's names (IP addresses need IP:, names need DNS:)
|
||||
ansible.builtin.set_fact:
|
||||
nmon_tls_sans: >-
|
||||
{{ (([nmon_server_name] + nmon_tls_extra_names) | unique
|
||||
| map('regex_replace', '^(?=[0-9.]+$|.*:)', 'IP:')
|
||||
| map('regex_replace', '^(?!IP:)', 'DNS:') | list)
|
||||
+ ['DNS:localhost', 'IP:127.0.0.1', 'IP:::1'] }}
|
||||
when: nmon_tls_mode == "selfsigned"
|
||||
|
||||
- name: Generate a self-signed certificate (quick start; replace for production)
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- openssl
|
||||
- req
|
||||
- -x509
|
||||
- -newkey
|
||||
- ec
|
||||
- -pkeyopt
|
||||
- ec_paramgen_curve:prime256v1
|
||||
- -nodes
|
||||
- -days
|
||||
- "825"
|
||||
- -subj
|
||||
- "/CN={{ nmon_server_name }}"
|
||||
- -addext
|
||||
- "subjectAltName={{ nmon_tls_sans | join(',') }}"
|
||||
- -keyout
|
||||
- "{{ nmon_etc_dir }}/tls.key"
|
||||
- -out
|
||||
- "{{ nmon_etc_dir }}/tls.crt"
|
||||
creates: "{{ nmon_etc_dir }}/tls.crt"
|
||||
when: nmon_tls_mode == "selfsigned"
|
||||
notify: Restart nmon
|
||||
|
||||
- name: Install the provided certificate and key
|
||||
ansible.builtin.copy:
|
||||
src: "{{ item.src }}"
|
||||
dest: "{{ nmon_etc_dir }}/{{ item.dest }}"
|
||||
mode: "{{ item.mode }}"
|
||||
loop:
|
||||
- { src: "{{ nmon_tls_cert_src }}", dest: tls.crt, mode: "0644" }
|
||||
- { src: "{{ nmon_tls_key_src }}", dest: tls.key, mode: "0600" }
|
||||
when: nmon_tls_mode == "files"
|
||||
notify: Reload nmon
|
||||
|
||||
- name: Certificate ownership
|
||||
ansible.builtin.file:
|
||||
path: "{{ nmon_etc_dir }}/{{ item }}"
|
||||
owner: "{{ nmon_user }}"
|
||||
group: "{{ nmon_user }}"
|
||||
loop: [tls.crt, tls.key]
|
||||
38
deploy/ansible/roles/nmon/templates/config.yaml.j2
Normal file
@@ -0,0 +1,38 @@
|
||||
# Managed by Ansible (deploy/ansible). Admin edits in the UI are overwritten on the next run.
|
||||
retention:
|
||||
hot: {{ nmon_retention.hot }}
|
||||
fiveminute: {{ nmon_retention.fiveminute }}
|
||||
long: {{ nmon_retention.long }}
|
||||
timezone: {{ nmon_timezone }}
|
||||
{% if nmon_l2 %}
|
||||
l2:
|
||||
{{ nmon_l2 | to_nice_yaml(indent=2) | indent(2) }}
|
||||
{% endif %}
|
||||
{% if not nmon_oui_fetch | bool %}
|
||||
oui:
|
||||
fetch: false
|
||||
{% endif %}
|
||||
{% if nmon_alerting %}
|
||||
alerting:
|
||||
{{ nmon_alerting | to_nice_yaml(indent=2) | indent(2) }}
|
||||
{% endif %}
|
||||
auth:
|
||||
{% if nmon_oidc %}
|
||||
oidc:
|
||||
issuer: {{ nmon_oidc.issuer }}
|
||||
client_id: {{ nmon_oidc.client_id }}
|
||||
client_secret_name: oidc-client
|
||||
redirect_url: {{ nmon_oidc.redirect_url | default('https://' + nmon_server_name + ':' + (nmon_listen | regex_replace('^.*:', '')) + '/auth/callback') }}
|
||||
groups_claim: {{ nmon_oidc.groups_claim | default('groups') }}
|
||||
admin_group: {{ nmon_oidc.admin_group | default('nmon-admins') }}
|
||||
{% endif %}
|
||||
{% if nmon_breakglass_password | length > 0 %}
|
||||
break_glass_file: {{ nmon_etc_dir }}/breakglass
|
||||
{% endif %}
|
||||
{% if nmon_trusted_proxies %}
|
||||
trusted_proxies: {{ nmon_trusted_proxies | to_json }}
|
||||
{% endif %}
|
||||
backup:
|
||||
at: "{{ nmon_backup_at }}"
|
||||
dir: {{ nmon_backup_dir }}
|
||||
keep: {{ nmon_backup_keep }}
|
||||
52
deploy/ansible/roles/nmon/templates/nmon.service.j2
Normal file
@@ -0,0 +1,52 @@
|
||||
# Managed by Ansible (deploy/ansible).
|
||||
[Unit]
|
||||
Description=nmon network monitor
|
||||
Documentation=https://claude.ai/artifact/7FTnfA5hReTjY6cfArfJ2Z
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
User={{ nmon_user }}
|
||||
Group={{ nmon_user }}
|
||||
ExecStart=/usr/local/bin/nmon serve \
|
||||
--data-dir {{ nmon_data_dir }} \
|
||||
--secret-key-file {{ nmon_key_file }} \
|
||||
--listen {{ nmon_listen }} \
|
||||
--trap-listen {{ nmon_trap_listen }} \
|
||||
--graphite-listen {{ nmon_graphite_listen }} \
|
||||
--icmp {{ nmon_icmp }} \
|
||||
{% if nmon_http_listen | length > 0 %}
|
||||
--http-listen {{ nmon_http_listen }} \
|
||||
{% endif %}
|
||||
{% if nmon_tls_mode == 'insecure' %}
|
||||
--insecure-http
|
||||
{% elif nmon_tls_mode == 'none' %}
|
||||
{# plain HTTP only, behind a reverse proxy: --http-listen above is the only listener #}
|
||||
{% elif nmon_tls_mode == 'acme' %}
|
||||
--acme-host {{ nmon_server_name }} --acme-email {{ nmon_acme_email }}
|
||||
{% else %}
|
||||
--tls-cert {{ nmon_etc_dir }}/tls.crt --tls-key {{ nmon_etc_dir }}/tls.key
|
||||
{% endif %}
|
||||
ExecReload=/bin/kill -HUP $MAINPID
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
TimeoutStopSec=90
|
||||
LimitNOFILE=1048576
|
||||
|
||||
# Ports below 1024: SNMP traps (162) and the ACME challenge (80).
|
||||
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
RestrictSUIDSGID=true
|
||||
LockPersonality=true
|
||||
ReadWritePaths={{ nmon_data_dir }} {{ nmon_etc_dir }} {{ nmon_backup_dir }}
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
26
deploy/ansible/site.yml
Normal file
@@ -0,0 +1,26 @@
|
||||
# Deploys nmon to the hosts in group "nmon". Run from the repo root's deploy/ansible:
|
||||
# ansible-playbook site.yml -e @vars.yml
|
||||
- name: Build nmon locally
|
||||
hosts: localhost
|
||||
connection: local
|
||||
gather_facts: false
|
||||
tags: [build]
|
||||
tasks:
|
||||
- name: Build the release binary (UI and VictoriaMetrics embedded)
|
||||
ansible.builtin.command:
|
||||
cmd: make build build-arm64
|
||||
chdir: "{{ playbook_dir }}/../.."
|
||||
changed_when: true
|
||||
when: nmon_build | default(true) | bool
|
||||
|
||||
- name: Pack the MIB sidecar # noqa: command-instead-of-module (archive needs community.general; plain tar keeps this collection-free)
|
||||
ansible.builtin.command:
|
||||
cmd: tar -czf {{ playbook_dir }}/.mibs.tar.gz -C {{ playbook_dir }}/../../mibs .
|
||||
creates: "{{ playbook_dir }}/.mibs.tar.gz"
|
||||
when: nmon_install_mibs | default(true) | bool
|
||||
|
||||
- name: Install and run nmon
|
||||
hosts: nmon
|
||||
become: true
|
||||
roles:
|
||||
- nmon
|
||||
43
deploy/ansible/vars.example.yml
Normal file
@@ -0,0 +1,43 @@
|
||||
# Copy to vars.yml (keep it out of git, or encrypt with ansible-vault) and pass with -e @vars.yml.
|
||||
|
||||
# TLS: selfsigned (quick start), files (your own cert), acme (Let's Encrypt), or insecure (plain HTTP, dev only).
|
||||
nmon_tls_mode: selfsigned
|
||||
nmon_server_name: "{{ inventory_hostname }}" # a DNS name or an IP; it goes in the self-signed cert
|
||||
# nmon_tls_extra_names: ["10.0.0.5", "nmon.lan"]
|
||||
# nmon_tls_cert_src: files/nmon.crt # for tls_mode=files (paths on the control machine)
|
||||
# nmon_tls_key_src: files/nmon.key
|
||||
# nmon_acme_email: noc@example.net # for tls_mode=acme; nmon answers http-01 on :80
|
||||
|
||||
# Behind a reverse proxy: plain-HTTP listener (sign-in stays on). Add the proxy to auth.trusted_proxies (see docs/operations.md).
|
||||
# nmon_http_listen: "[::1]:8080" # beside HTTPS; set nmon_tls_mode: none to serve only this listener
|
||||
|
||||
# Break-glass local admin: the way in when the IdP is down (and the only sign-in until OIDC is set).
|
||||
# The role writes this before nmon first starts, so the install is already "set up": nmon never enters install
|
||||
# mode (the first-install wizard that is open to anyone who can reach it). Leave it out only if you want the wizard.
|
||||
# nmon_breakglass_user: admin
|
||||
# nmon_breakglass_password: "change-me-please"
|
||||
|
||||
# OIDC (optional). Unauthenticated browsers are redirected to the IdP; there is no login page.
|
||||
# nmon_oidc:
|
||||
# issuer: https://idp.example.net/realms/net
|
||||
# client_id: nmon
|
||||
# client_secret: "s3cret" # stored encrypted via `nmon secret set`, never in config.yaml
|
||||
# groups_claim: groups
|
||||
# admin_group: nmon-admins
|
||||
|
||||
# Alert receivers (optional): webhook and/or SMTP, plus the outbound allow-list they need.
|
||||
# nmon_alerting:
|
||||
# receivers:
|
||||
# - name: noc-webhook
|
||||
# kind: webhook
|
||||
# url: https://hooks.example.net/nmon
|
||||
# outbound_allow: ["hooks.example.net"]
|
||||
|
||||
nmon_timezone: UTC
|
||||
|
||||
# MAC/ARP table job and optional IEEE vendor registry (see docs/operations.md).
|
||||
# nmon_l2: {interval: 15m, keep: 90d, edge_max_macs: 8}
|
||||
# nmon_oui_fetch: false # never let nmon download the registry...
|
||||
# nmon_oui_download: true # ...have this play download the CSVs on the host instead,
|
||||
# nmon_oui_csv_dir: files/oui # ...or ship the three CSVs from the control machine
|
||||
nmon_icmp: auto # off = decide reachability by SNMP alone
|
||||
@@ -7,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
@@ -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
@@ -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/`.
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -44,4 +44,13 @@ Alerting: all *Must* and *Must not* lines. Adversarial: redundant parents failin
|
||||
|
||||
## Gate log
|
||||
|
||||
_(filled in by the supervisor when the gate passes)_
|
||||
**Passed 2026-10-23**, run by the supervisor (Opus).
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| `make lint` (Go + web) | 0 findings |
|
||||
| `go test -race ./...`, `-tags 'integration vmembed'`, `test-pg.sh` | all ok |
|
||||
| gates m1, m2 (LibreNMS recordings), m3, m4, m5 in one sequential run | all PASS |
|
||||
| `make gate-m4` | PASS: redundant topology (2 cores, 2 dual-homed distribution switches, 20 access switches) with `--icmp off`; (a) one core down → exactly one notice, nothing suppressed; (b) **both cores and all 22 children stopped at the same instant → exactly ONE notification** naming both cores and 22 suppressed children, zero per-child notices; (c) restart re-sends nothing; (d) core-1 back → one resolution, dist switches announced with their suppressed children; deliveries logged with redacted payloads; incident IDs stable |
|
||||
|
||||
**Decisions in M4:** a dependency hold delays a child's notice while its parents are unsettled (proven over 100 randomised timing seeds), so simultaneous outages group correctly. Unsupported ICMP falls back to SNMP alone and shows an `icmp_unsupported` device warning. Single-signal warnings are held 5 min. `--icmp auto|off`. `alerts?state=open` (open alerts, acked or not) is used on device pages. That fix came from the gate's screenshot step, which caught an acked outage vanishing from its device page.
|
||||
|
||||
@@ -45,4 +45,13 @@ Storage and rollups: all lines except the loadtest disk budget (M6). Ingest: all
|
||||
|
||||
## Gate log
|
||||
|
||||
_(filled in by the supervisor when the gate passes)_
|
||||
**Passed 2026-10-23**, run by the supervisor (Opus).
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| `make lint` (Go + web) | 0 findings |
|
||||
| `go test -race ./...`, `-tags 'integration vmembed'`, `test-pg.sh` | all ok |
|
||||
| gates m1, m2 (LibreNMS recordings), m3, m4, m5 in one sequential run | all PASS |
|
||||
| `make gate-m5` | PASS: mock Proxmox synced (node pve1 + guests with hosted_on); `proxmox.influx` replayed via a push token → 93 series under pve1; **2 y graph hits the 1 h tier** (tiers `1h`+`raw`, `stitched_at`, long points exactly the back-filled values); p95 with `source`; group graph is the oriented sum; HTTP check up; ingest negatives (401s, reserved prefix dropped, series-cap alert deduped) |
|
||||
|
||||
**Decisions in M5:** see DEVIATIONS rows 18–30 (push token format, rollup `pending` status and `v` encoding, gauge rollup names, month-partitioned 5-min retention, acked synchronous long writes, unreliable interfaces never rolled up, group `coverage` label, check `check` label, Proxmox node rule, `push="1"` label, system alarms, no inline Proxmox secret). One `internal/longq` package now owns the "highest version wins" query; this fixed M3's long-tier graph read.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
70
docs/milestones/v1.0.1-device-tabs.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# v1.0.1: device tabs (Health, Wireless, Printer, …) and a printer fleet view
|
||||
|
||||
Owner request (2026-10-23): non-interface metrics (CPU, memory, storage, sensors, UPS, wireless, printer supplies) are
|
||||
collected but not shown anywhere. Build LibreNMS-style device tabs before production, driven by profile metadata so
|
||||
that any device class shows up without per-vendor UI code.
|
||||
|
||||
## Profile schema additions (internal/profile)
|
||||
|
||||
Per metric, all optional except `category`, which is **required for every pollable metric** (enforced by a test over all
|
||||
shipped profiles and by the loader for new ones; `imported: librenms` drafts default to `health`):
|
||||
|
||||
- `category`: `system | health | power | environment | wireless | printer | routing | optics | firewall`
|
||||
- `label`: human name ("CPU load", "Toner level"); defaults to the metric name
|
||||
- `of: <metric>`: the capacity/total this metric is a fraction of (hrStorageUsed `of: hrStorageSize`,
|
||||
prtMarkerSuppliesLevel `of: prtMarkerSuppliesMaxCapacity`, memAvailReal `of: memTotalReal`). The UI shows a percent bar,
|
||||
computed at query time (raw stays raw, per the spec).
|
||||
- `display`: `gauge` (default for gauge), `percent` (implied by `of`), `state` (implied by `type: status`), `rate`
|
||||
(implied by counter types), `text`
|
||||
- `scale_by: <metric>` (optional): multiply by another column, e.g. hrStorageUsed × hrStorageAllocationUnits for bytes
|
||||
- `sentinels: {<value>: <text>}` (optional): values shown as words instead of numbers, e.g. printer `-3: "some remaining"`,
|
||||
`-2: "unknown"`. These are polled and stored (not dropped as not_available).
|
||||
|
||||
Every shipped profile is annotated. Printer gains `prtMarkerSuppliesType` (enum) and `prtMarkerColorantValue`, collected at
|
||||
discovery as component attributes, plus the paper trays (`prtInputCurrentLevel` `of: prtInputMaxCapacity`, label
|
||||
`prtInputName`). Brother and Ricoh quirks go in small vendor profiles extending `printer` (verified against the LibreNMS
|
||||
recordings where they exist).
|
||||
|
||||
## API (additive)
|
||||
|
||||
- `GET /api/v1/devices/{slug}/metrics`
|
||||
```
|
||||
{ categories: [ { name: "health", label: "Health",
|
||||
groups: [ { metric: "hrProcessorLoad", label: "CPU load", unit: "percent", display: "gauge",
|
||||
components: [ { component: "CPU 0", value: 12.0, max: null, text: null, state: null, ts: "…" } ] } ] } ] }
|
||||
```
|
||||
Latest values from hot (one batched query per device). `max` is set for `of` metrics, `state` is `ok|warn|crit|unknown`
|
||||
for enums (mapping in the profile `enum_state` or a default by name), and `text` is used for sentinels and text displays.
|
||||
Categories appear only when the device has data in them, in the order system, health, power, environment, wireless, printer, routing, optics, firewall.
|
||||
- `GET /api/v1/devices/{slug}/metrics/graph?metric=&component=&from=&to=&step=` returns `{unit, display, series: [[unix_s, value|null]], max_series?}`
|
||||
for gauges (raw), with counters as a rate, using the same step rules as `/graph`. Percent displays return percent.
|
||||
- `GET /api/v1/printers?page=` (viewer): the fleet view, with
|
||||
`items: [{device{slug,name,site}, reachability, pages_total, supplies: [{component, type, color, percent|null, text|null, state}], trays: [...]}]`,
|
||||
sorted by the lowest supply percent first.
|
||||
- Alerts: add `printer_supply_empty` (critical, level 0 or "empty"). Record a "supply replaced" audit event when a supply's
|
||||
percent rises by 20 points or more between polls.
|
||||
|
||||
## UI
|
||||
|
||||
Device page tabs: **Overview** (header, small CPU/memory/storage summary, active alerts) · **Ports** (today's table) ·
|
||||
then one tab per category the device has (Health, Power, Environment, Wireless, Printer, Routing, Optics, Firewall).
|
||||
|
||||
- Generic renderers, chosen by `display`:
|
||||
- gauge: value, unit and a sparkline
|
||||
- percent: bar with value/max
|
||||
- state: coloured badge, also shown by icon or shape so it doesn't rely on colour alone
|
||||
- rate: value per second
|
||||
- text: plain text
|
||||
|
||||
Clicking a row opens the metric graph, a TrafficGraph-like component for single series. Components are grouped by metric.
|
||||
- Bespoke panels:
|
||||
- **Printer**: a card per supply with a colour swatch (C/M/Y/K from colorant), a percent bar and "some remaining" text,
|
||||
paper trays, and a page count graph.
|
||||
- **Wireless**: per-SSID table (clients, channel, tx power) with totals, and per-radio utilisation bars.
|
||||
- New nav page **Printers** (fleet), sorted by the lowest supply.
|
||||
- Viewers see everything; nothing here mutates.
|
||||
|
||||
## Guards
|
||||
|
||||
- **Orphan test:** every pollable metric in every shipped profile has a category, and its unit and display have a renderer.
|
||||
- **Coverage matrix:** `docs/coverage.md` maps the spec's "What to collect" table to what each profile collects today, with gaps listed plainly.
|
||||
113
docs/milestones/v1.1-alert-policies.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# v1.1: alert policies (alerts fire only where a policy says so)
|
||||
|
||||
Owner request (2026-10-23): preprod alerts on everything. Rules exist, but each one applies to every device and port,
|
||||
so a WiFi port that always sees errors and a fiber uplink, where one error matters, are judged by the same rule.
|
||||
The owner's decisions:
|
||||
|
||||
- **Nothing fires without a policy.** This covers metric rules, reachability (`device_down`, `device_warning`) and
|
||||
nmon's own system alarms. A fresh install records no alerts until an admin adds policies.
|
||||
- **Scope is groups + matchers.** Matchers pick ports and devices by attribute (so new ports are covered
|
||||
automatically), and groups handle the hand-curated exceptions.
|
||||
- **Policies live in the database** (a new bun migration) and are edited through the API and UI. They apply live, with no
|
||||
restart. Rule *definitions* (the MetricsQL, templates) stay in config as today.
|
||||
|
||||
## Model
|
||||
|
||||
A **policy** binds one rule to a scope:
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `id` | ULID |
|
||||
| `name` | unique, human ("Fiber uplinks: errors") |
|
||||
| `rule` | a metric rule name (`interface_errors`, …), `device_down`, `device_warning`, or `system` (all subsystem alarms, `alert.SystemRule`) |
|
||||
| `action` | `alert` or `ignore`. An `ignore` policy that matches wins over any `alert` policy of the same priority or lower. |
|
||||
| `priority` | int, default 0. For one entity and rule, the matching policy with the highest priority decides; on a tie `ignore` beats `alert`, then the lowest name wins. |
|
||||
| `enabled` | bool |
|
||||
| `severity`, `threshold`, `for` | optional overrides (null = the rule's). Threshold resolution: policy > the rule's group override > profile `ThresholdFor` > rule default. |
|
||||
| `scope` | see below; must be non-empty (`all: true` is the explicit "everything") |
|
||||
|
||||
**Scope** fields are ANDed together; within a list, any entry may match. An empty field is unconstrained.
|
||||
|
||||
- `all: bool`
|
||||
- `groups: [group id]`. A port matches if it is a member now (`GroupMember.Covers`); a device matches if any of its ports is.
|
||||
- `devices`: `slugs` (globs), `profiles`, `roles`, `sites`, `kinds`
|
||||
- `interfaces`: `if_types` (ints, e.g. 6 ethernetCsmacd, 71 ieee80211), `if_name` (RE2), `if_alias` (RE2),
|
||||
`min_speed_mbps`/`max_speed_mbps`, `alert_down` (true = the port's bell or a group's alert_down is on; this keeps the
|
||||
existing opt-in UI meaningful)
|
||||
|
||||
An interface matcher on a device-level rule (`cpu_high`, `device_down`) never matches, and validation refuses it. A
|
||||
`system` policy accepts only `all: true`, because system alarms have no device.
|
||||
|
||||
The examples the owner gave:
|
||||
|
||||
```
|
||||
{name: "Fiber + uplinks: errors", rule: interface_errors, action: alert, threshold: 0,
|
||||
scope: {interfaces: {if_types: [6], if_alias: "(?i)fib|uplink|core"}}}
|
||||
{name: "WiFi: no error alerts", rule: interface_errors, action: ignore, priority: 10,
|
||||
scope: {interfaces: {if_types: [71]}}}
|
||||
```
|
||||
|
||||
## Packages
|
||||
|
||||
- `internal/alertpolicy` (new, pure: no store, no SQL): `Policy`, `Scope`, `Validate`, `Subject` (device attrs, iface
|
||||
attrs, group ids) and `Decide(policies, rule, Subject) (Binding, bool)`. Unit tests carry the precedence table.
|
||||
- `internal/store`: migration `20261023000012_alert_policies.go` (table `alert_policies`, scope as JSON text, frozen
|
||||
struct copy), plus a sub-interface `AlertPolicies` in `alert_policies.go` (List/Get/Create/Update/Delete; unique name ->
|
||||
ErrConflict) and one embed line in `store.go`.
|
||||
- `internal/alert`: `Options.Policy func(Entity) (alertpolicy.Binding, bool)`. A series without a binding is skipped,
|
||||
and its open row closes silently as `retired`, like the existing opt-in. The binding's threshold, severity and for
|
||||
apply. `Rule.OptIn` and `Options.OptedIn` are superseded by the `alert_down` matcher. Keep the yaml field so old
|
||||
configs still parse, but make it a no-op and say so in the doc comment.
|
||||
- `internal/reach`: `Options.Wants func(rule, slug string) bool`. No binding means no new row, and an open row closes
|
||||
quietly.
|
||||
- `internal/app`: a policy cache, loaded at start, refreshed every 30 s and invalidated on every API write. It builds the
|
||||
`Subject` from TTL-cached device and interface lookups and gates `sysAlarms` on a `system` policy.
|
||||
- `internal/api`, in a new file:
|
||||
- `GET/POST /api/v1/alert-policies` and `GET/PUT/DELETE /api/v1/alert-policies/{id}` (admin).
|
||||
- `POST /api/v1/alert-policies/preview` (viewer, read-only) returns the counts of devices and ports a draft scope covers,
|
||||
with up to 20 samples of each.
|
||||
- `GET /api/v1/alert-rules` (viewer) lists the rule names, their level (`device`, `interface`, `component`, `system`),
|
||||
default threshold, op and severity, for the UI dropdown.
|
||||
- All additive; regenerate `openapi.json` and the web schema.
|
||||
- `web/`: an **Alert policies** page under Alerts (list, create and edit form with a live preview count, enable toggle;
|
||||
`RequireAdmin` for mutation). It has a "Start from template" menu with UI-side constants: all devices `device_down`,
|
||||
bell-opted ports `interface_down`, uplinks `interface_errors`, wireless ignore `interface_errors`, `system` all. The
|
||||
bell tooltip changes to say that a policy using the `alert_down` matcher is needed. Add a mock and an e2e test.
|
||||
|
||||
## Upgrade behaviour
|
||||
|
||||
No policies are seeded. On the first cycle after upgrade, every open alert without a binding closes silently as
|
||||
`retired` and sends no "resolved" notifications, which is how the preprod flood clears. The startup log warns once when
|
||||
the policy table is empty ("no alert policies: nothing will alert").
|
||||
|
||||
## Docs
|
||||
|
||||
- DEVIATIONS: a new row, "Default rules apply everywhere; spec §Alerting", which this change replaces with policy-gated
|
||||
rules (owner decision 2026-10-23). It supersedes #38's mechanism but keeps the bell.
|
||||
- `docs/operations.md`: an alert policies section with the two examples above.
|
||||
|
||||
## Gate
|
||||
|
||||
- `make lint` is clean, and `go test -race ./...`, `-tags 'integration vmembed'` for `internal/alert`, `internal/reach`,
|
||||
`internal/app` and `internal/store` all pass, plus `./scripts/test-pg.sh`, `scripts/openapi-check.sh` and the web
|
||||
lint/test/build/e2e.
|
||||
- Tests prove the following:
|
||||
- With no policies, nothing is recorded (metric, reach and system).
|
||||
- The WiFi ignore and fiber alert pair decides a 71 port and a 6 port correctly.
|
||||
- Removing a policy retires open rows silently.
|
||||
- Policy threshold beats group override.
|
||||
- A policy write takes effect on the next cycle without a restart.
|
||||
|
||||
## Gate log
|
||||
|
||||
2026-10-23, run by the implementing agent:
|
||||
|
||||
- `make lint`: 0 Go issues, filesize script clean, ESLint `--max-warnings 0` clean.
|
||||
- `go test -race ./...`: all 43 packages ok.
|
||||
- `go test -race -tags 'integration vmembed' ./internal/alert/... ./internal/reach/... ./internal/app/... ./internal/store/...`: ok.
|
||||
- `./scripts/test-pg.sh` (store suite on Postgres 16, includes migration 12): ok.
|
||||
- `scripts/openapi-check.sh`: compatible with m6 (additive only). `openapi.json` and `web/src/api/schema.d.ts` regenerated.
|
||||
- Web: `npm run lint`, `npm run test` (140 tests) and `npm run build` pass. `E2E_PORT=4791 npm run e2e`: 149 passed, 6 failed. The 6 failures are device-page specs (`device-tabs`, `devices-mgmt` edit, `profiles-nav` pin, `screenshots-v101` overview, `screenshots` 32-bit warning, `ui` warning explanation) that look for the `summary-tile`, `device-header`, `generic-hint` and `device-warnings` test ids, which no longer exist in `web/src` since commit 992f09b (Overview tab). They are not caused by this change and were left alone. The new `alert-policies.spec.ts` (4 tests) and the updated `alert-down.spec.ts` pass. Screenshot dirs were reverted.
|
||||
- `make gate-m4`: the whole backend run passes (45 PASS, including device_down, suppression, restart, ack and maintenance with the two gate-created policies). It then fails in its UI steps for the same stale test ids as above (mock UI specs, plus the live `device-down` screenshot waiting for `device-header`). The first run of the gate caught a missing `Wants:` wiring in `internal/app/alerting.go` (no device_down was recorded); fixed.
|
||||
- `gate-m5.sh` and `gate-m6.sh` now create a `system` policy before the alarms they expect; only `bash -n` was run on them (not the full ~10 min gates).
|
||||
- The 2,000-device load test and `make gate-m5`/`gate-m6` were not run.
|
||||
202
docs/milestones/v1.2-custom-alert-rules.md
Normal file
@@ -0,0 +1,202 @@
|
||||
# v1.2: built-in rule macros and custom alert rules
|
||||
|
||||
Owner request (2026-10-23), following v1.1 (alert policies): the shipped rules are too constraining. Keep them as
|
||||
well-known **built-in macros**, and let admins create their own rules from the data nmon already holds. The owner's
|
||||
decisions:
|
||||
|
||||
- Custom rules can test **metrics** (anything in the hot store) **and inventory fields** (device and interface rows).
|
||||
- **Builder only.** Admins compose rules from whitelisted fields, never by writing MetricsQL or SQL. The generated query
|
||||
is shown read-only.
|
||||
|
||||
## Rules after this change
|
||||
|
||||
| Kind | Where defined | Editable | Notes |
|
||||
|---|---|---|---|
|
||||
| Built-in macro | `alert.DefaultRules()` | no; "Copy to custom" clones one into an editable builder rule where the builder can express it, otherwise refuses with a reason | Names are unchanged (`interface_errors`, `disk_full`, …), so v1.1 policies, alert history and series keys stay valid. The UI labels them "Built-in". |
|
||||
| Config rule | `config.yaml` `alerting.rules` | file only | It still works but is deprecated, and the startup log says so once. A config rule cannot take a custom rule's name. |
|
||||
| Custom metric rule | DB `alert_rules`, `kind: metric` | API/UI, live | A builder spec is compiled to MetricsQL. |
|
||||
| Custom inventory rule | DB `alert_rules`, `kind: inventory` | API/UI, live | A builder spec is evaluated in Go over store rows. |
|
||||
|
||||
Every rule, of whatever kind, is alerted only through policies (v1.1), and uses the same state machine (pending/`for`,
|
||||
firing, resolved, stale, flapping), suppression, maintenance and notifications. A rule name is unique across all kinds,
|
||||
and names reserved by reach and system alarms stay refused. Deleting a custom rule that a policy references is a 409
|
||||
naming those policies. Disabling a rule retires its open rows silently.
|
||||
|
||||
## Builder: metric rules (`kind: metric`)
|
||||
|
||||
| Field | Values |
|
||||
|---|---|
|
||||
| `metric` | from the catalogue: every metric of every loaded profile (name, label, unit, type, category, `of`, enum), the interface metrics (`ifHC*Octets`, `if*Errors`, `if*Discards`, `ifOperStatus`, `ifAdminStatus`, and whatever else the generic profile polls), and push-ingested metric names read from the hot store's `__name__` values (marked `source: push`) |
|
||||
| `transform` | `value`, `rate` (counter types only; per second), `percent_of` (only when the metric has `of`), `utilisation` (interface octet counters only: 100 × 8 × rate / speed). Offer `utilisation` only if the port speed is available in the hot store as a series; if it isn't, leave it out and record why in the gate log. Don't invent a join. |
|
||||
| `window` | duration, default 5m, minimum 2× the poll interval |
|
||||
| `func` | `last`, `avg`, `max`, `min` over the window |
|
||||
| `aggregate` | component-level metrics only: `each` (default), or `max`/`min`/`avg`/`sum` per device |
|
||||
| `op` | `>`, `>=`, `<`, `<=`, `==`, `!=` |
|
||||
| `threshold` | number; for enum/status metrics the UI offers the enum names and stores the number |
|
||||
| `for`, `severity`, `summary`, `description` | as on today's rules; the templates are validated and a default summary is generated |
|
||||
|
||||
The level (device, interface or component) follows from the metric: `iface` label → interface, index label → component,
|
||||
scalar → device. It decides which policy matchers apply, exactly as in v1.1. The MetricsQL is generated from fixed
|
||||
templates, and every name and value is checked against the catalogue or a numeric parse, so nothing a user types reaches
|
||||
the query text unescaped. Label values are not user input in the builder.
|
||||
|
||||
The evaluator's comparison gains `>=`, `<=`, `==` and `!=` (`Rule.breach`; validation accepts all six).
|
||||
|
||||
## Builder: inventory rules (`kind: inventory`)
|
||||
|
||||
Evaluated every cycle from rows the store already returns (devices and interfaces, which are not retired). Add a store
|
||||
method only if no existing one fits; still no SQL outside `internal/store` and no dynamic SQL anywhere. Field whitelist:
|
||||
|
||||
- **Device:** `last_seen_age` (duration), `enabled`, `profile`, `profile_pinned`, `site`, `role`, `kind`, `sys_descr`,
|
||||
`hw_serial`, `mgmt_ip`, `parent_count`, `snmp_failing` (if it is cheaply derivable, otherwise leave it out).
|
||||
- **Interface:** `if_speed_mbps`, `admin_status`, `oper_status`, `if_name`, `if_alias`, `description`, `unreliable`,
|
||||
`has_hc_counters`, `excluded`, `counter_discontinuity`, `last_discovered_age`.
|
||||
|
||||
Ops by type:
|
||||
|
||||
| Type | Ops |
|
||||
|---|---|
|
||||
| number | `> >= < <= == !=` |
|
||||
| bool | `is true`, `is false` |
|
||||
| string | `equals`, `not equals`, `matches` (RE2, compiled at save), `empty`, `not empty` |
|
||||
| duration | `older than`, `newer than` |
|
||||
|
||||
A rule has one to five conditions, ANDed, all on device fields or all on interface fields; that decides the level. The
|
||||
reported value is 1 when the conditions hold, and an entity whose conditions don't hold is "below". Examples:
|
||||
|
||||
- "Uplink without description": interface, `if_alias empty` AND `if_speed_mbps >= 10000`.
|
||||
- "Device not seen for 1 h": device, `last_seen_age older than 1h`.
|
||||
|
||||
## Architecture
|
||||
|
||||
- New pure package `internal/alertrule`:
|
||||
- spec types and `Validate`;
|
||||
- `CompileMetric(spec, catalogue) (expr, level, error)`;
|
||||
- `EvalInventory(spec, row) bool` over a plain row struct;
|
||||
- the field whitelist and the catalogue types.
|
||||
- Unit tests carry a table of specs → expected MetricsQL, and of rows → verdicts.
|
||||
- `internal/alert`:
|
||||
- The evaluator reads its rule set from a `Rules func() []Rule` (or a `SetRules`) so DB edits apply on the next cycle
|
||||
without a restart.
|
||||
- A rule gets a `Source`: `metric` (today's querier path) or `inventory`, an interface the app supplies that returns
|
||||
readings for a rule at `now`.
|
||||
- The state machine is shared and unchanged.
|
||||
- `internal/store`:
|
||||
- migration `20261023000013_alert_rules.go` (frozen struct; spec as JSON text);
|
||||
- `AlertRules` sub-interface in a new file, plus one embed line;
|
||||
- the table is added to `backupTables`.
|
||||
- `internal/app`:
|
||||
- the rule registry: built-ins, then config, then DB;
|
||||
- a 30 s cache invalidated on write;
|
||||
- the metric catalogue from the profile set plus the hot store's metric names (cached);
|
||||
- the inventory source, built on TTL-cached store reads.
|
||||
- `internal/api`, all additive. Admin unless noted; regenerate `openapi.json` and the web schema and run
|
||||
`scripts/openapi-check.sh`.
|
||||
- `GET /api/v1/alert-rules` keeps its fields and adds `id`, `kind` (`builtin`, `config`, `metric`, `inventory`),
|
||||
`editable`, `spec` and `expr`.
|
||||
- `GET /api/v1/alert-rules/{id}`, plus `POST`, `PUT` and `DELETE` for custom rules only (a built-in is 409).
|
||||
- `GET /api/v1/alert-rules/catalogue` (viewer) returns the metrics and the inventory fields with their types and ops.
|
||||
- `POST /api/v1/alert-rules/test` (viewer; declare it read-only in the auth policy) compiles a draft and runs it once.
|
||||
It returns the generated expr, the series or entity count, the number breaching now, and up to 20 samples
|
||||
(device, iface, component, value).
|
||||
- `POST /api/v1/alert-rules/{id}/copy` clones a built-in into a custom rule, or returns 422 with the reason when the
|
||||
builder can't express it.
|
||||
- `web/`:
|
||||
- An **Alert rules** tab next to Alert policies. The list shows the kind badge, level, condition text, policy count and
|
||||
enabled state.
|
||||
- A builder dialog:
|
||||
- pick metric or inventory;
|
||||
- searchable catalogue picker grouped by category;
|
||||
- dependent fields (transform shown only where valid, enum picker for status metrics);
|
||||
- generated query shown read-only;
|
||||
- a **Test** button showing "N breaching now" with samples;
|
||||
- **Copy to custom** on built-ins.
|
||||
- The policy dialog's rule dropdown lists custom rules too.
|
||||
- Mock handlers and an e2e spec. Keep web files ≤ 400 lines; split the builder.
|
||||
|
||||
## Docs
|
||||
|
||||
- DEVIATIONS: a row noting that rules are now also defined in the database, with a builder only and inventory
|
||||
conditions (owner decision 2026-10-23), and that config rules are deprecated.
|
||||
- `docs/operations.md`: extend the alert section with the two inventory examples above and a metric example ("CPU over
|
||||
80 % on role=server for 15 min"), and with how a custom rule and a policy combine.
|
||||
|
||||
## Gate
|
||||
|
||||
- `make lint` is clean. All of these pass:
|
||||
- `go test -race ./...`
|
||||
- `go test -race -tags 'integration vmembed'` for `internal/alert`, `internal/app` and `internal/store`, where every
|
||||
builder template and every built-in is run against a real VictoriaMetrics.
|
||||
- `./scripts/test-pg.sh` and `scripts/openapi-check.sh`
|
||||
- web lint, test and build, plus the new and changed e2e specs only. A full Playwright run isn't needed; revert any
|
||||
screenshots it rewrites.
|
||||
- Tests prove the following:
|
||||
- A custom metric rule, bound by a policy, fires and resolves.
|
||||
- A custom inventory rule fires for a matching port and not for others.
|
||||
- A rule edit applies on the next cycle without a restart.
|
||||
- Deleting a referenced rule is a 409.
|
||||
- Built-ins cannot be edited.
|
||||
- A builder spec with an unknown metric or field is a 422.
|
||||
- Hostile input in a string field (quotes, braces, `"} or vector(1)`) never changes the generated query's structure.
|
||||
|
||||
## Gate log
|
||||
|
||||
2026-10-24, run by the implementing agent:
|
||||
|
||||
- Decisions where this spec was silent:
|
||||
- **`utilisation` is not offered.** No profile polls `ifHighSpeed` or `ifSpeed` as a series (checked with a grep of
|
||||
`profiles/*.yaml`); port speed lives only in the inventory table. Joining the two would be inventing a join, so the
|
||||
transforms are `value`, `rate` and `percent_of`. A test (`TestCatalogueHasProfileAndPushMetrics`) fails if
|
||||
`ifHighSpeed` ever becomes a series, as a prompt to revisit this.
|
||||
- **Counters allow `rate` only** (a raw counter is never alertable), and `rate` always averages over the window
|
||||
(`func` must be `avg` or empty). The default window is the larger of 5 m and twice the metric's own poll interval
|
||||
(so a 5 m SNMP metric defaults to 10 m); an explicit window below twice the interval, below 1 m or above 24 h is a
|
||||
422. A `rate` of a `counter32` interface metric sets `SkipUnreliable`.
|
||||
- **Inventory fields kept:** `last_seen_age`, `profile`, `profile_pinned`, `site`, `role`, `kind`, `sys_descr`,
|
||||
`hw_serial`, `mgmt_ip`, `parent_count`, `snmp_failing` (true while the poller reports `degraded` or `backoff`),
|
||||
`if_speed_mbps` (ifHighSpeed, else ifSpeed / 10^6), `admin_status`, `oper_status` (numbers, with enum names for
|
||||
the UI), `if_name`, `if_alias`, `description`, `unreliable`, `has_hc_counters`, `excluded`, `counter_discontinuity`,
|
||||
`last_discovered_age`. **Dropped: `enabled`.** Disabled, retired and purged devices are never evaluated (their
|
||||
alerts close as retired through `Active`), so `enabled is false` could never match. A device never seen has its age
|
||||
counted from `created_at`. Excluded ports are evaluated (so `excluded` is a usable condition). A new store method,
|
||||
`ListAllInterfaces` (one query, sub-interface `InterfaceInventory`), avoids one query per device.
|
||||
- **Built-ins that can be copied to custom:** `cpu_high` (compiles to the identical expression),
|
||||
`ups_on_battery` (`upsOutputSource == 5`) and `printer_supply_empty` (`prtMarkerSuppliesLevel == 0`). The other
|
||||
seven (and the reachability and system rules) join, filter or union series, so `POST /alert-rules/{id}/copy` answers
|
||||
422 with the reason. The copy is created at once as `<name>_custom` (or the name in the body), keeping the
|
||||
built-in's severity, hold time and texts.
|
||||
- Rules are classed `builtin` when the name and expression match the shipped rule (a config patch of thresholds
|
||||
keeps it `builtin`), and `config` otherwise. A custom rule that cannot compile at runtime (a pushed metric briefly
|
||||
missing from the hot store) keeps its last good form instead of retiring its alerts. The rule list grows `policy_count`
|
||||
and `copyable`; deleting a rule, renaming one, or changing its level while a policy names it is a 409. Editing
|
||||
`GET /alert-rules/{id}` is viewer-level like the list; `POST /alert-rules/test` is the only read-only POST.
|
||||
- Type names in the OpenAPI schema are `RuleSpec`, `InventoryField` and `InventoryCondition` (not the bare words).
|
||||
- `make lint`: 0 Go issues, filesize script clean, ESLint `--max-warnings 0` clean (see the final run below).
|
||||
- `go test -race ./...`: all 44 packages ok (the new `internal/alertrule` included). An earlier run found a real
|
||||
deadlock in the first version of rule update (a second store call inside a SQLite transaction); fixed by reading
|
||||
the old level before the transaction, covered by `TestDeletingOrReshapingAReferencedRuleIs409`.
|
||||
- `go test -race -tags 'integration vmembed' ./internal/alert/... ./internal/app/... ./internal/store/...`: ok. The new
|
||||
`TestIntegrationBuilderTemplatesAgainstRealStore` compiles every allowed transform x function x aggregate for
|
||||
gauge, status, counter32, counter64, percent_of and push metrics and runs each query on a real VictoriaMetrics; the
|
||||
existing test still runs every built-in there.
|
||||
- `./scripts/test-pg.sh` (store suite on Postgres 16, includes migration 13 and the rule and bulk-interface queries): ok.
|
||||
- `scripts/openapi-check.sh`: compatible with m6 (additive; the `op` enum of `AlertRuleInfo` only gained values).
|
||||
`openapi.json` and `web/src/api/schema.d.ts` regenerated.
|
||||
- Web: `npm run lint`, `npm run test` (146 tests) and `npm run build` pass. Playwright, only the added or changed specs
|
||||
(`E2E_PORT=4795`): `alert-rules.spec.ts` (7 tests) and `alert-policies.spec.ts` (4, its mock now shares the rule store)
|
||||
pass. The full Playwright suite was not run; `docs/screenshots` was reverted and no preview server was left running.
|
||||
- The tests that prove the gate items:
|
||||
- Custom metric rule bound by a policy fires and resolves, and a rule edit (new threshold) applies on the next
|
||||
cycle with no restart, and disabling retires silently:
|
||||
`internal/app` `TestCustomMetricRuleBoundByAPolicyFiresResolvesAndEditsApplyLive`; the evaluator side,
|
||||
`internal/alert` `TestRuleSetChangesApplyOnTheNextCycle`.
|
||||
- Inventory rule fires for the matching port only: `internal/app` `TestInventoryRuleFiresForTheMatchingPortOnly`.
|
||||
- Deleting a referenced rule is 409 (naming the policies): `internal/api` `TestDeletingOrReshapingAReferencedRuleIs409`.
|
||||
- Built-ins cannot be edited or deleted, and their names cannot be taken: `TestBuiltinRulesCannotBeEditedOrDeleted`,
|
||||
`TestCustomRuleNamesCannotShadowBuiltinsOrConfig`.
|
||||
- An unknown metric or field is a 422: `TestBuilderSpecWithUnknownMetricOrFieldIs422`, `alertrule` `TestCompileMetricRefusals`,
|
||||
`TestInventoryRefusals`.
|
||||
- Hostile input never changes the generated query: `alertrule` `TestHostileTextNeverReachesTheQuery` (quotes, braces,
|
||||
`"} or vector(1)`, backticks and template braces in name, summary and description leave the expression identical or
|
||||
are refused; a pushed metric name that is not a plain identifier never enters the catalogue); the `window`
|
||||
is parsed and re-printed, and `TestCompileMetricTemplates` pins the exact MetricsQL of every template.
|
||||
229
docs/milestones/v1.3-bgp-and-l2-tables.md
Normal file
@@ -0,0 +1,229 @@
|
||||
# v1.3: BGP peers and MAC/ARP/FDB lookup
|
||||
|
||||
Owner request (2026-10-24), after the LibreNMS comparison: **MAC/ARP/FDB lookup is a must, and BGP peer monitoring is
|
||||
wanted.** WISP radio profiles are nice-to-have and not in this milestone.
|
||||
|
||||
Parts of this go beyond the spec. The spec lists BGP-4-MIB in the collection table but no profile fills it, and it
|
||||
names no MAC/ARP lookup feature. Add one DEVIATIONS row per feature.
|
||||
|
||||
## Part A: MAC / ARP / FDB ("where is this host plugged in?")
|
||||
|
||||
### Collection
|
||||
A new **L2 table job**, separate from the 60 s poll path (the spec forbids table walks on the hot path) and from the
|
||||
twice-daily discovery:
|
||||
|
||||
- It walks every enabled SNMP device every `l2.interval` (default 15 min, minimum 5 min, in `config.yaml`; applied on
|
||||
SIGHUP like the other job settings, or `restart_required` if that is simpler, and say which).
|
||||
- It has its own concurrency cap and a per-device deadline, and yields to the poller under the same admission policy as
|
||||
rollup.
|
||||
- A device that times out is skipped for that cycle, with a counter and a poller-health note; it never blocks the job.
|
||||
|
||||
Tables, all numeric OIDs verified against `mibs/`, each with a provenance comment:
|
||||
|
||||
| What | Source | Notes |
|
||||
|---|---|---|
|
||||
| Bridge port → ifIndex | BRIDGE-MIB `dot1dBasePortIfIndex` | needed for every FDB row |
|
||||
| FDB, VLAN-aware | Q-BRIDGE-MIB `dot1qTpFdbPort` (index: fdbId.mac) | Map fdbId to the VLAN with `dot1qVlanFdbId` where present; otherwise fdbId = VLAN id. |
|
||||
| FDB, fallback | BRIDGE-MIB `dot1dTpFdbPort` | used when Q-BRIDGE returns nothing; the VLAN is unknown (0) |
|
||||
| Cisco per-VLAN FDB | BRIDGE-MIB per VLAN | IOS only: list VLANs from CISCO-VTP-MIB `vtpVlanState`, then walk with community `<community>@<vlan>` (v2c) or context `vlan-<vlan>` (v3). Turn it on with a profile flag (`l2.per_vlan_context: true` on cisco-ios), not by detection. |
|
||||
| VLAN names | Q-BRIDGE-MIB `dot1qVlanStaticName`, CISCO-VTP-MIB `vtpVlanName` | shown in the UI |
|
||||
| ARP and IPv6 ND | IP-MIB `ipNetToPhysicalPhysAddress` (both IPv4 and IPv6, IPv6 first) | This is the primary source. Fall back to `ipNetToMediaPhysAddress` (IPv4 only) when it is empty. Skip entries of type invalid or incomplete. |
|
||||
|
||||
Recordings to verify against: `/home/svcuser/src/librenms/tests/snmpsim` (pick ones with these tables for cisco-ios,
|
||||
junos, arista, edgeswitch, unifi-switch, mikrotik and a Linux bridge). Read them as reference data only; copy no code or
|
||||
YAML from that GPL repository. Add `.snmprec` fixtures under `testdata/snmprec/` produced by our own generator (extend
|
||||
`internal/fixturegen`), including one with Q-BRIDGE, one with only BRIDGE-MIB, one Cisco per-VLAN, and an ARP table with
|
||||
IPv6 and IPv4 entries.
|
||||
|
||||
### Storage (inventory DB, not the metric stores)
|
||||
Use migration `20261024000014_l2_tables.go`. Number 13 is taken by the concurrent v1.2 work; take 14 and nothing else.
|
||||
|
||||
- `fdb_entries`: device_id, interface_id (ULID, nullable when the bridge port has no ifIndex), vlan, mac (normalised
|
||||
lower-case `aa:bb:cc:dd:ee:ff`), first_seen, last_seen.
|
||||
- Unique on (device_id, interface_id, vlan, mac).
|
||||
- A row seen again only bumps last_seen.
|
||||
- Use batched upserts: a 48-port switch can carry thousands of MACs.
|
||||
- `arp_entries`: device_id, interface_id (nullable), ip (canonical text, v6 compressed), mac, first_seen, last_seen.
|
||||
Unique on (device_id, ip, mac).
|
||||
- `vlans`: device_id, vlan, name. Replaced per walk.
|
||||
- Retention is `l2.keep` (default 90 d) on last_seen, pruned daily. This history is what lets you ask "where was this MAC
|
||||
last week".
|
||||
- Add the new tables to `backupTables`, or mark them deliberately transient with a reason. Back them up.
|
||||
- Only `internal/store` touches SQL. Add a store sub-interface in a new file plus one embed line in `store.go`. Every
|
||||
query must be indexed: lookups are by mac, by ip, by interface and by device.
|
||||
|
||||
### Trunk awareness
|
||||
A MAC is seen on every switch it passes through. For each FDB row, compute at query time the **MAC count of its port**
|
||||
(per walk), and mark a port an **uplink** when it has more than `l2.edge_max_macs` MACs (default 8), when it is an LLDP
|
||||
neighbour link, or when it is a LAG/parent. The lookup ranks **edge ports first**: the port with the fewest MACs, then
|
||||
the most recent last_seen. That is where the host is plugged in.
|
||||
|
||||
### API (additive)
|
||||
- `GET /api/v1/l2/search?q=` (viewer):
|
||||
- `q` is a MAC in any common format (`aabb.ccdd.eeff`, `aa-bb-…`, `aabbccddeeff`, partial prefixes of at least 6 hex
|
||||
digits), an IPv6 or IPv4 address, or a CIDR.
|
||||
- It returns grouped hits:
|
||||
`{ mac, ips: [{ip, device, last_seen}], locations: [{device, iface, vlan, vlan_name, edge: bool, port_mac_count, first_seen, last_seen}] }`,
|
||||
edge locations first, paged like the other routes.
|
||||
- `GET /api/v1/devices/{slug}/fdb` and `GET /api/v1/devices/{slug}/arp` (viewer): the paged tables, filterable by VLAN,
|
||||
interface and `current` (seen in the last walk) or with history.
|
||||
- `GET /api/v1/interfaces/{id}/macs` (viewer): the MACs on a port, current and history, each with its IPs from any ARP
|
||||
table.
|
||||
|
||||
### UI
|
||||
- A global search box (existing header search, or a new **Find host** page): paste a MAC or IP and get the answer
|
||||
"device X port Y, VLAN Z, last seen 3 min ago", with the full path (uplinks) collapsed below it.
|
||||
- On the device page, a new **L2** tab (FDB with a VLAN filter, ARP, VLAN list), shown only when the device has rows.
|
||||
- On the port, a MAC count on the Ports tab row with a link to that port's MACs.
|
||||
|
||||
### OUI vendor lookup (optional; nmon must work fully without it)
|
||||
Owner request (2026-10-24): show the vendor of each MAC, from the IEEE registry where it can be fetched. It is nice to
|
||||
have and never required.
|
||||
|
||||
- **Source:** the IEEE public registries MA-L (`https://standards-oui.ieee.org/oui/oui.csv`), MA-M (`…/oui28/mam.csv`) and
|
||||
MA-S (`…/oui36/oui36.csv`). Check the current URLs and CSV columns before coding against them.
|
||||
- **Fetch:**
|
||||
- a background job at start (if no database is present) and then every `oui.refresh` (default 7 d);
|
||||
- through `internal/netpolicy`, so the outbound allow-list applies;
|
||||
- with timeouts, a size cap, and a parse sanity check (a minimum row count) before replacing the current file.
|
||||
- `oui.fetch: false` turns fetching off.
|
||||
- **Offline:** `nmon oui import <dir-or-file>` loads the same CSVs from disk. The Ansible role can optionally download
|
||||
and ship them next to the MIB sidecar.
|
||||
- **Storage:** a compact file in the data dir (e.g. `oui/registry.bin`, or a gzipped normalised CSV), written atomically
|
||||
with a checksum, and included in `nmon backup`. It is not in the inventory DB, since it is reference data. Loaded into
|
||||
memory as three prefix maps (24-, 28- and 36-bit); a lookup takes the longest match.
|
||||
- **Missing or stale data never fails anything.** With no file, vendor is empty and a `oui_unavailable` note appears on
|
||||
poller health. There is no alert.
|
||||
- **Vendor flag:**
|
||||
- computed at query time and never stored per FDB or ARP row, so a refresh relabels history;
|
||||
- API hits and table rows gain `vendor` (string, empty when unknown) and `mac_kind`: `global`, `local` (the
|
||||
locally-administered bit, typical of randomised phone and VM MACs, with vendor shown as "Private / randomised"),
|
||||
or `multicast`.
|
||||
- **Search:** `q` may also be a vendor name substring (`vendor:cisco`). It returns the current MACs of that vendor,
|
||||
paged, so "find every Ubiquiti device on VLAN 20" works.
|
||||
- **UI:** a vendor column on the L2 tab, the port MAC list and the search results, plus a small "private" badge for
|
||||
`local`.
|
||||
- **Licensing:** the IEEE registry is public data. Note its source in `docs/operations.md`, and do not commit the CSVs
|
||||
to the repo. Tests use a tiny generated fixture registry.
|
||||
- **Tests:**
|
||||
- the longest prefix wins (an MA-S entry beats MA-L);
|
||||
- the local and multicast bits are detected;
|
||||
- a truncated or garbage download keeps the previous file;
|
||||
- no network and no file still lets the L2 job and search work, with an empty vendor.
|
||||
|
||||
## Part B: BGP peers
|
||||
|
||||
### Collection (profiles, 5 min interval; metrics in the `routing` category)
|
||||
The component label is the peer address (canonical, IPv6 compressed). Peer AS, local AS, description/remote router ID
|
||||
and AFI/SAFI are **component attributes** (migration 11's mechanism) collected at discovery, never metric labels. The
|
||||
SNMP label contract stays `device` + `component` only. Where a vendor reports prefixes per address family, the
|
||||
component is `<peer>|<afi>` for the prefix metrics only; document it.
|
||||
|
||||
| Source | Gives | Profiles |
|
||||
|---|---|---|
|
||||
| BGP4-MIB `bgpPeerState`, `bgpPeerFsmEstablishedTime`, `bgpPeerInUpdates`/`OutUpdates`, `bgpPeerRemoteAs` | IPv4 peers only | generic (optional, enabled on router profiles) |
|
||||
| CISCO-BGP4-MIB `cbgpPeer2State`, `cbgpPeer2FsmEstablishedTime`, `cbgpPeer2AcceptedPrefixes` (per AFI/SAFI), `cbgpPeer2RemoteAs` | IPv4 and IPv6 | cisco-ios, cisco-nxos |
|
||||
| BGP4-V2-MIB-JUNIPER `jnxBgpM2PeerState`, `jnxBgpM2PeerFsmEstablishedTime`, `jnxBgpM2PrefixInPrefixesAccepted` | IPv4 and IPv6 | juniper-junos |
|
||||
| ARISTA-BGP4V2-MIB `aristaBgp4V2PeerState`, `…PrefixInPrefixesAccepted` | IPv4 and IPv6 | arista-eos |
|
||||
|
||||
- `state` is a `status` metric with enum `idle connect active opensent openconfirm established`;
|
||||
`enum_state: established ok, others crit`.
|
||||
- Use the vendor table where one exists, and drop the BGP4-MIB duplicate on that profile, so each peer appears once.
|
||||
- VyOS/FRR, MikroTik, FortiGate and EdgeOS: check `mibs/` and the recordings (`cumulus_bgp`, `dnos_bgp`, vyos and
|
||||
mikrotik ones). Add BGP only where the device actually answers. State per profile in `docs/coverage.md` what is
|
||||
collected, and where nothing is, say so plainly.
|
||||
- Verify against the LibreNMS recordings (`arista_eos*.snmprec`, `cumulus_bgp`, `dnos_bgp`, ios and junos ones) and add
|
||||
our own generated fixtures with IPv6 peers.
|
||||
|
||||
### UI
|
||||
- Device **Routing** tab:
|
||||
- a peers table with peer, AS, description, state badge, uptime, prefixes accepted per AFI, and update rate;
|
||||
- a click-to-graph on the prefixes and updates;
|
||||
- built from the existing metrics-tab renderers where they fit.
|
||||
- A **BGP** page in the main nav listing every peer on every device, with filters for state, AS and device, so you can
|
||||
see which sessions are down right now.
|
||||
- Back it with `GET /api/v1/bgp/peers` (viewer, paged; latest values from hot, with attributes from the inventory).
|
||||
|
||||
### Alerts
|
||||
- New built-in rule `bgp_peer_down`:
|
||||
- component-level;
|
||||
- condition: state is not established for `for: 2m`;
|
||||
- severity critical;
|
||||
- its summary names the peer, AS and description.
|
||||
- Like every rule, it fires only where a policy binds it (v1.1). Add a template "Routers: BGP peer down" to the policy
|
||||
templates.
|
||||
- A prefix-drop alert is left to the custom rule builder (v1.2). Add it to the operations doc as an example once v1.2 is
|
||||
in.
|
||||
|
||||
## Constraints
|
||||
- **v1.2 is being built concurrently.** It owns `internal/alert` rules plumbing, `internal/alertrule`, migration 13, and
|
||||
the alert-rules API and UI.
|
||||
- Do not edit those files, except to append `bgp_peer_down` to `DefaultRules()` in `internal/alert/rules.go` plus its
|
||||
integration test row, and to add the policy template constant in `web/src/lib/policyModel.ts`.
|
||||
- Rebase onto whatever v1.2 commits first, if it lands before you.
|
||||
- Regenerate `openapi.json` and `web/src/api/schema.d.ts` just before committing.
|
||||
- Every port is still polled the same way; this adds a job and does not change the poller.
|
||||
- IPv6 first: ND and IPv6 BGP peers are first-class, and tests default to `::1`.
|
||||
|
||||
## Gate
|
||||
- `make lint` clean. All of these pass:
|
||||
- `go test -race ./...`
|
||||
- `go test -race -tags 'integration vmembed'` for the touched packages
|
||||
- `./scripts/test-pg.sh` and `scripts/openapi-check.sh`
|
||||
- `NMON_LIBRENMS_DIR=/home/svcuser/src/librenms go test ./internal/profile/... ./internal/fixtures/...`
|
||||
- `go run ./testdata/gen` with no drift
|
||||
- web lint, test and build, plus only the new and changed Playwright specs
|
||||
- Tests prove the following:
|
||||
- MAC normalisation accepts every format above and refuses garbage.
|
||||
- A MAC seen on an access port and on two uplinks resolves to the access port first.
|
||||
- The Cisco per-VLAN context walk is used only when the profile flag is set.
|
||||
- IPv6 ND entries are stored and searchable.
|
||||
- Retention prunes by last_seen.
|
||||
- An IPv6 BGP peer from a vendor table shows once (no BGP4-MIB duplicate).
|
||||
- `bgp_peer_down` fires through a policy and resolves on established.
|
||||
- The L2 job never runs on the poll path, and a slow device does not delay the others.
|
||||
|
||||
## Gate log
|
||||
|
||||
Built on branch `worktree-agent-a86e97dea1f9eae40` (from main at `06a3c85`), 2026-10-24. Run by the implementing agent; the
|
||||
supervisor reruns the gates after merging with v1.2.
|
||||
|
||||
### Verification
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| `make lint` (golangci-lint v2.14.0, `scripts/lint-filesize.sh`, ESLint `--max-warnings 0`) | 0 issues |
|
||||
| `go test -race ./...` | all packages pass |
|
||||
| `go test -race -tags 'integration vmembed'` on alert, api, app, backup, l2, discovery, fixtures, profile, vmsup | pass (the alert integration test runs `bgp_peer_down` against a real VictoriaMetrics: fires for the active IPv6 peer, not for the established one) |
|
||||
| `./scripts/test-pg.sh` (Postgres 16, store suite incl. the L2 queries, upserts and backup/restore table list) | pass |
|
||||
| `scripts/openapi-check.sh` | compatible with m6 (additive only) |
|
||||
| `NMON_LIBRENMS_DIR=/home/svcuser/src/librenms go test ./internal/profile/... ./internal/fixtures/... ./internal/l2` | pass: real recordings produce each BGP peer exactly once (IOS XE and XR with IPv6 peers, Junos MX, Arista, EdgeOS, FortiGate, FRR on pfSense), no type mismatches; the L2 walker reads FDB and ARP/ND (IPv6 from `arista_eos_7280r` and `dell-os10`) from the Arista, Junos, IOS, UniFi/EdgeSwitch and RouterOS recordings |
|
||||
| `go run ./testdata/gen` | no drift (`TestCommittedFixturesAreCurrent` passes); 9 expect files gained only BGP series, PDU budgets unchanged |
|
||||
| `cd web && npm run lint && npm run test && npm run build` | clean; 165 tests pass |
|
||||
| Playwright, only the new `e2e/specs/l2-bgp.spec.ts` (12 tests), plus `device-tabs` and `profiles-nav` as a regression check of the pages this touches | 27 passed; `docs/screenshots` reverted; no preview server left running |
|
||||
|
||||
Not run: the `make gate-m*` scripts, the Ansible tasks (no Ansible here; the YAML parses), and the full Playwright suite.
|
||||
|
||||
### What proves each gate line
|
||||
|
||||
| Line | Test |
|
||||
|---|---|
|
||||
| MAC normalisation accepts every format and refuses garbage | `internal/hostaddr` `TestParseMACAcceptsEveryCommonFormat`, `TestParseMACRefusesGarbage`, `TestClassifySortsEveryQueryShape`; API `TestSearchAcceptsEveryMACFormatAndPrefixAndRefusesGarbage` |
|
||||
| A MAC on an access port and two uplinks resolves to the access port first | `internal/api` `TestAMACSeenOnAnAccessPortAndTwoUplinksResolvesToTheAccessPortFirst` (uplinks by MAC count and by LLDP), `TestEdgeLimitComesFromTheLiveSetting` |
|
||||
| The Cisco per-VLAN walk is used only with the profile flag | `internal/l2` `TestCiscoPerVLANContextIsUsedOnlyWhenTheProfileFlagIsSet` (dial log: no `@vlan` community without the flag); `internal/profile` `TestOnlyIOSWalksBridgeTablesPerVLAN` |
|
||||
| IPv6 ND entries are stored and searchable | `internal/l2` `TestIPv6NeighboursAreStoredAndSearchable`; store `TestL2SearchFindsMACsByPrefixIPv6AndCIDR`; API `TestIPv6AndIPv4AndCIDRSearchFindTheMAC` |
|
||||
| Retention prunes by last_seen | store `TestPruneL2DeletesByLastSeen`; l2 `TestRetentionPrunesByLastSeenOnTheDailyPass` |
|
||||
| An IPv6 BGP peer from a vendor table shows once | `internal/profile` `TestVendorBGPTablesReplaceTheBGP4MIBOnesSoAPeerAppearsOnce`; fixtures (every vendor fixture lists `2001:db8::2` once); the real-recording `checkPeers` |
|
||||
| `bgp_peer_down` fires through a policy and resolves on established | `internal/alert` `TestBGPPeerDownFiresThroughAPolicyAndResolvesOnEstablished`, `TestBGPPeerDownFiresNowhereWithoutAPolicy`; integration row |
|
||||
| The L2 job never runs on the poll path; a slow device does not delay the others | `internal/l2` (every dial is path `l2`; a poll session refuses walks, `internal/snmp`), `TestSlowDeviceTimesOutWithoutDelayingTheOthers`, `TestJobYieldsWhileTheWriteQueueIsMoreThanHalfFull` |
|
||||
| OUI: longest prefix wins; local and multicast bits; truncated or garbage download keeps the previous file; no network and no file still works | `internal/oui` `TestLongestPrefixWins`, `TestVendorFlagsLocalAndMulticast`, `TestTruncatedOrGarbageDownloadKeepsThePreviousFile`, `TestMissingRegistryNeverBreaksALookup`; API `TestVendorSearchListsCurrentMACsAndWorksEmptyWithoutARegistry` |
|
||||
|
||||
### Decisions where the spec was silent
|
||||
|
||||
- **L2 settings and reload.** `l2.*` and `oui.*` apply from the next cycle after a reload (SIGHUP or a Settings save) with no restart, and are reported as `applied`. The job re-reads `l2.interval` every minute while waiting, so a shorter interval takes effect within a minute and a longer one on the wait in progress. Test: `TestRunStartsACycleAtOnceThenEveryIntervalAndReadsTheIntervalAgain`.
|
||||
- **Ranking.** Edge before uplink, then rows the last walk still sees before history, then fewest MACs, then most recent (DEVIATIONS 48).
|
||||
- **Per-VLAN fallback.** If the first VLAN context does not answer, the walker falls back to the plain tables instead of waiting out a timeout per VLAN.
|
||||
- **`bgp_peer_down` text.** AS and description are added to the notification, not the stored alert row (DEVIATIONS 46).
|
||||
- **BGP per profile.** See `docs/coverage.md`. Nothing for mikrotik-routeros (no BGP objects in MIKROTIK-MIB or the recordings) and the non-router profiles; cisco-nxos is unverified against a recording; VyOS is covered through BGP4-MIB on the strength of the FRR recording only.
|
||||
- **Open item for v1.2.** The prefix-drop example in `docs/operations.md` is to be added once custom rules land.
|
||||
|
||||
72
docs/milestones/v2-distributed-poller.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# v2: distributed poller (feasibility, deferred)
|
||||
|
||||
Status: deferred to v2 by the owner on 2026-10-25. This is a feasibility assessment, not a plan; nothing is built.
|
||||
|
||||
## Requirements (owner)
|
||||
|
||||
1. A remote poller polls the devices of a site, or of a predefined rule.
|
||||
2. It sends its data to the master only as chunked payloads.
|
||||
3. While the master is down it keeps polling, and delivers when the master is back.
|
||||
4. Each poller has its own private key; the master sends the target device credentials sealed to it.
|
||||
5. It works over an outbound-only link (MQTT pub/sub through a broker). Neither side accepts a connection from
|
||||
the other. Not a data diode yet, but the design should not rule one out.
|
||||
|
||||
## Verdict
|
||||
|
||||
Feasible, about one milestone. The poller is already injectable: `poller.Options` (`internal/poller/poller.go`)
|
||||
takes the store, the metric writer, the reachability observer and the SNMP dialer, so the polling code can be
|
||||
reused unchanged. The work is in what sits around it: where targets come from, where samples go, and how
|
||||
alerting treats data that arrives late.
|
||||
|
||||
## What exists and what is missing
|
||||
|
||||
| # | Exists | Missing |
|
||||
|---|---|---|
|
||||
| 1 | `devices.site`, `store.DeviceFilter.Site`; the alert-policy matcher (`alertpolicy.Device{Slug,Profile,Role,Site,Kind}`) already selects devices by rule | a `pollers` table and assignment rules; reuse the policy matcher rather than a second rule engine |
|
||||
| 2 | `metrics.Sample`; the vmsup queue batches 50k samples per POST | a chunk format (poller id, sequence, signature). Push ingest is **not** reusable: it strips `device`/`iface`, refuses `if*`/`nmon_` names and rejects timestamps more than 1 h from now. Poller data needs its own trusted path into the hot queue |
|
||||
| 3 | the hot queue buffers in memory only (60 s / 2M samples, then drops) | a disk spool on the poller (append-only segments, bounded by size), and a local SQLite snapshot of its targets so it restarts without the master |
|
||||
| 4 | symmetric AES-256-GCM keyring with versions (`internal/secrets`, `store/cred.go` `seal`); rotation in `internal/keyrotate` | a key pair per poller (X25519 + Ed25519): the master seals the credential bundle to the poller's public key (HPKE) and signs it; the poller signs its chunks; rotation follows the keyrotate pattern |
|
||||
| 5 | none | an MQTT transport behind an interface (see below), with outbound HTTPS chunk upload as a second, brokerless option |
|
||||
|
||||
## Transport (requirement 5)
|
||||
|
||||
- Data up: `nmon/poller/<id>/data`, QoS 1. The master holds a persistent session, so the broker keeps chunks
|
||||
while the master is down.
|
||||
- Config and credentials down: a retained, sealed message on `nmon/poller/<id>/config`, read on every connect.
|
||||
- An MQTT client library (e.g. paho.golang) departs from the spec's "standard library elsewhere" and needs a
|
||||
DEVIATIONS row.
|
||||
|
||||
Rules that keep a later data diode possible:
|
||||
|
||||
1. The data path is already one-way. Chunks are self-describing, signed and sequence-numbered, and the master
|
||||
drops duplicates by (poller, seq), so a resend is harmless. The only signal the poller waits for is the
|
||||
broker's PUBACK, which frees spool space. Under a diode, spool release becomes age/size bound plus a periodic
|
||||
resend window; the format does not change.
|
||||
2. One downlink only: the sealed, signed config bundle. Make it a file format that arrives either as the
|
||||
retained message or by hand (`nmon poller import bundle.sealed`). A diode drops the subscription and keeps
|
||||
the import.
|
||||
3. Interactive tools (poll-now, test poll, SNMP debug recording: `PollTools`) need a request and a reply. Over
|
||||
the broker they work through a request topic with seconds of latency. Under a diode they are unavailable,
|
||||
and the UI must say so for remote devices.
|
||||
|
||||
## Risks
|
||||
|
||||
1. **Late data looks like an outage.** `reach.Evaluator` runs on the master and expects live
|
||||
`ObserveICMP`/`ObserveSNMP` (`internal/reach/types.go`); the API marks a device down after three silent
|
||||
intervals. A site link drop would page every device behind it. Fix: model the poller as a dependency parent
|
||||
of its devices (dependency suppression already exists) and replay spooled probes as history, never as live
|
||||
state transitions. Needs a DEVIATIONS row.
|
||||
2. **Inventory writes from the poller.** The poller writes poll state (`devloop.persistState` →
|
||||
`SetPollState`), component rows and discontinuity bases. Remotely these go to the local snapshot and are
|
||||
sent up as events.
|
||||
3. **Discovery must run remotely.** The master usually cannot reach a site's devices. Interface ULIDs are the
|
||||
frozen `iface` label; the poller can mint them (ULIDs are globally unique) and the master accepts them.
|
||||
Traps, L2 tables and checks need the same scope decision up front.
|
||||
4. **Late writes.** VictoriaMetrics accepts old timestamps within retention; dedup by (poller, seq) keeps a
|
||||
resent chunk from being written twice.
|
||||
|
||||
## Codebase constraints
|
||||
|
||||
- A new package (e.g. `internal/remotepoll`) and an `nmon poller` subcommand wired in `internal/app`.
|
||||
- Collection paths still do not import each other; time comes from `internal/clock`.
|
||||
- API routes additive only (`scripts/openapi-check.sh`); new tables through a new migration.
|
||||
261
docs/operations.md
Normal 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
@@ -0,0 +1,67 @@
|
||||
# Adding a device profile
|
||||
|
||||
A **profile** tells nmon what to collect from one kind of device. nmon ships profiles for common gear (see [Collection coverage](/help/coverage)). When your device is only partly covered, or not at all, write one. Profiles are YAML files; nmon never reads a MIB while polling, so every metric is a numeric OID.
|
||||
|
||||
## Where profiles live
|
||||
|
||||
Shipped profiles are built into the binary. Your own go in `<data-dir>/profiles/*.yaml`; they are loaded over the shipped ones, and a profile with the same `name` replaces the shipped one. Restart `nmon serve` after changing a file, and run `nmon profile test` first to catch mistakes: a profile that fails validation is refused with an error naming the file.
|
||||
|
||||
## Basics
|
||||
|
||||
name: acme-switch
|
||||
extends: generic
|
||||
priority: 20
|
||||
match:
|
||||
sysObjectID: 1.3.6.1.4.1.99999
|
||||
sysDescr: '(?i)^AcmeOS'
|
||||
interfaces:
|
||||
exclude_ifname: ['^Vlan', '^Tunnel']
|
||||
metrics:
|
||||
- name: acmeCpuLoad
|
||||
category: health
|
||||
label: "CPU load (5 min)"
|
||||
oid: 1.3.6.1.4.1.99999.1.1.5
|
||||
type: gauge
|
||||
unit: percent
|
||||
interval: 5m
|
||||
index_label: { column: acmeCpuIndex, as: component }
|
||||
|
||||
- **extends: generic** inherits the standard metrics (interfaces, sysUpTime, host resources, entity sensors, LLDP). Always extend `generic`; your file only adds what is specific to the device.
|
||||
- **match** picks the profile for a device at discovery. `sysObjectID` is an OID prefix (the vendor's enterprise number, or a product branch of it). `sysDescr` is a regular expression on the system description. Give either or both; both must match when both are present.
|
||||
- **priority**: when several profiles match, the highest priority wins. `generic` is 0. Give a model-specific profile a higher number than the vendor-wide one, and watch overlaps: many devices run Linux and share its `sysDescr`, and several products share one enterprise number.
|
||||
- **metrics**: use numeric OIDs, taken from the vendor MIB, and keep a comment naming where each came from.
|
||||
|
||||
## Metric fields
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `name` | Series name stored in the metric store. |
|
||||
| `oid` | Numeric OID of the column or scalar. |
|
||||
| `type` | `counter64`, `counter32`, `gauge`, `status` or `text`. |
|
||||
| `unit` | `percent`, `celsius`, `octets`, `bytes`, `enum`, `count` and so on. |
|
||||
| `interval` | How often to poll, for example `60s` or `5m`. |
|
||||
| `category` | Where it shows on the device page, for example `health`, `routing`. Every metric that is not an interface metric needs one. |
|
||||
| `label` | The human-readable name shown in the UI. |
|
||||
| `index_label` | Which table column identifies the row, and the label it becomes (`component`, or `iface` for interfaces). |
|
||||
| `scale` | Multiplier for the raw value, for example `1024` to turn kilobytes into bytes. |
|
||||
| `enum` | For `status` metrics, the value-to-name map. |
|
||||
|
||||
Every port is polled. Only `interfaces.exclude_ifname` (a list of regular expressions on the interface name) drops one. 32-bit counters on fast links are flagged unreliable and left out of totals and rollups.
|
||||
|
||||
## Testing a profile
|
||||
|
||||
nmon profile test 2001:db8::10 --community public --data-dir /var/lib/nmon
|
||||
|
||||
`profile test` runs discovery and one poll against a live device with the matching profile and prints every metric with its resolved unit, so you can check the match, the OIDs and the units before the device is added. Use `--version 3 --v3-user USER ...` for SNMPv3 (`nmon profile test --help` lists every option). The `--data-dir` flag makes it load your profiles from `<data-dir>/profiles`.
|
||||
|
||||
## Recording a device
|
||||
|
||||
nmon profile record 2001:db8::10 --community public --out acme.snmprec
|
||||
|
||||
`profile record` does a read-only SNMP walk and writes a `.snmprec` file with communities, `sysContact` and `sysLocation` redacted. Keep it: it is the best bug report for a device nmon does not understand, and it lets a profile be tested without the hardware.
|
||||
|
||||
## Drafting metrics from a MIB
|
||||
|
||||
nmon profile new --mib ACME-SYSTEM-MIB
|
||||
|
||||
`profile new` compiles objects from a MIB into draft metric entries with the OID, type and enum filled in, leaving `unit`, `category`, `label` and `interval` for you. nmon ships the standard MIBs; drop a vendor MIB into the `mibs/` folder (the `--mibs-dir` setting, by default `<data-dir>/mibs`) first. Review the draft, then test it with `nmon profile test`.
|
||||
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 581 KiB After Width: | Height: | Size: 607 KiB |
|
Before Width: | Height: | Size: 153 KiB After Width: | Height: | Size: 153 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 167 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 167 KiB |
|
Before Width: | Height: | Size: 183 KiB After Width: | Height: | Size: 198 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 196 KiB After Width: | Height: | Size: 206 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 130 KiB |
BIN
docs/screenshots/M3/device-32bit-warning.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
docs/screenshots/M3/device-48-graphs-full.png
Normal file
|
After Width: | Height: | Size: 600 KiB |
BIN
docs/screenshots/M3/device-48-graphs.png
Normal file
|
After Width: | Height: | Size: 164 KiB |
BIN
docs/screenshots/M3/device-light.png
Normal file
|
After Width: | Height: | Size: 125 KiB |
BIN
docs/screenshots/M3/device-unresponsive.png
Normal file
|
After Width: | Height: | Size: 156 KiB |
BIN
docs/screenshots/M3/device.png
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
docs/screenshots/M3/devices-light.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
docs/screenshots/M3/devices.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
docs/screenshots/M3/graph-1h.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
docs/screenshots/M3/graph-24h-light.png
Normal file
|
After Width: | Height: | Size: 113 KiB |
BIN
docs/screenshots/M3/graph-24h.png
Normal file
|
After Width: | Height: | Size: 113 KiB |
BIN
docs/screenshots/M3/graph-30d.png
Normal file
|
After Width: | Height: | Size: 158 KiB |
BIN
docs/screenshots/M3/graph-unreliable.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
docs/screenshots/M3/interfaces.png
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
docs/screenshots/M3/phone-device.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
docs/screenshots/M3/phone-devices.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
docs/screenshots/M3/phone-graph-24h.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
docs/screenshots/M3/poller.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
docs/screenshots/M4-mock/ack-dialog-light.png
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
docs/screenshots/M4-mock/ack-dialog.png
Normal file
|
After Width: | Height: | Size: 111 KiB |
BIN
docs/screenshots/M4-mock/alerts-acked.png
Normal file
|
After Width: | Height: | Size: 53 KiB |