Compare commits
26 Commits
m5
...
worktree-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61b5341774 | ||
|
|
7173360346 | ||
|
|
c4d14a67d1 | ||
|
|
06a3c85c69 | ||
|
|
ddc8863a67 | ||
| c4f5ee916b | |||
| 992f09b97a | |||
| d92c6e1e08 | |||
| 9b4ab7ccbb | |||
| 457191accd | |||
| 61274c4f55 | |||
| fe53998aa6 | |||
| e25143881c | |||
| ad220eae58 | |||
| 345daaa773 | |||
| bf6dbd4923 | |||
| d97dd1d44a | |||
| 28f5444860 | |||
| 6ae7729107 | |||
| faa0fa7505 | |||
| 4b7849c45b | |||
| 0754268e9e | |||
| a768a15fb6 | |||
| d7df853a35 | |||
| 81162b57b5 | |||
| fe4db76a6e |
3
.gitignore
vendored
@@ -11,3 +11,6 @@ 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
|
||||
|
||||
@@ -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,8 @@ 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.
|
||||
|
||||
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.
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
)
|
||||
|
||||
var errUsage = errors.New("usage: nmon-devtools backfill|seed-demo|topology-agents|topology-add|webhook-sink|gate-dump|" +
|
||||
"proxmox-mock|push-replay|backfill-long [flags]" +
|
||||
"proxmox-mock|push-replay|backfill-long|oidc-mock|bcrypt [flags]" +
|
||||
" (see docs/dev.md)")
|
||||
|
||||
func main() {
|
||||
@@ -54,6 +54,10 @@ func run(ctx context.Context, args []string) error {
|
||||
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)
|
||||
}
|
||||
@@ -88,7 +92,7 @@ func devFlagSet(name string) *flag.FlagSet {
|
||||
return fs
|
||||
}
|
||||
|
||||
// httpJSON sends an optional JSON body and decodes a 2xx JSON reply into out (nil to discard it).
|
||||
// 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 {
|
||||
@@ -105,6 +109,9 @@ func httpJSON(ctx context.Context, hc *http.Client, method, url string, in, out
|
||||
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
|
||||
|
||||
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
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -27,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")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -51,12 +51,22 @@ func storeFlags(fs *flag.FlagSet, f *config.Flags) {
|
||||
|
||||
func serve(ctx context.Context, args []string) error {
|
||||
var f config.Flags
|
||||
fs := newFlagSet("serve", "serve --data-dir DIR --insecure-http [flags]")
|
||||
fs := newFlagSet("serve", "serve --data-dir DIR (--tls-cert F --tls-key F | --acme-host H | --insecure-http) [flags]")
|
||||
storeFlags(fs, &f)
|
||||
fs.StringVar(&f.SecretKeyFile, "secret-key-file", app.DefaultSecretKeyFile,
|
||||
"SNMP credential key file; created on first run; must be outside --data-dir")
|
||||
fs.StringVar(&f.Listen, "listen", app.DefaultListen, "address for the API")
|
||||
fs.BoolVar(&f.InsecureHTTP, "insecure-http", false, "serve plain HTTP with no authentication (required until TLS and auth arrive in M6)")
|
||||
fs.BoolVar(&f.InsecureHTTP, "insecure-http", false,
|
||||
"development only: serve plain HTTP (insecure cookies); with no auth.oidc or auth.break_glass_file configured, "+
|
||||
"authentication is off and every request is an admin")
|
||||
fs.StringVar(&f.TLSCert, "tls-cert", "", "PEM certificate chain for HTTPS (reloaded on SIGHUP); needs --tls-key")
|
||||
fs.StringVar(&f.TLSKey, "tls-key", "", "PEM private key for --tls-cert")
|
||||
fs.Func("acme-host", "obtain a Let's Encrypt certificate for this host name (repeatable); needs port 80 reachable", func(v string) error {
|
||||
f.ACMEHosts = append(f.ACMEHosts, v)
|
||||
return nil
|
||||
})
|
||||
fs.StringVar(&f.ACMEEmail, "acme-email", "", "contact address for the ACME account")
|
||||
fs.StringVar(&f.ACMEListen, "acme-listen", app.DefaultACMEListen, "address of the ACME http-01 challenge listener")
|
||||
fs.StringVar(&f.VMHotURL, "vm-hot-url", "", "use this VictoriaMetrics instance as the hot store instead of the embedded one")
|
||||
fs.StringVar(&f.VMLongURL, "vm-long-url", "", "use this VictoriaMetrics instance as the long store instead of the embedded one")
|
||||
fs.StringVar(&f.TrapListen, "trap-listen", app.DefaultTrapListen,
|
||||
|
||||
48
deploy/ansible/README.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Deploying nmon with Ansible
|
||||
|
||||
Installs nmon as a systemd service on one Linux host (amd64 or arm64). Tested against Debian 13.
|
||||
|
||||
## Quick start
|
||||
|
||||
cd deploy/ansible
|
||||
cp inventory.example.ini inventory.ini # your host
|
||||
cp vars.example.yml vars.yml # set nmon_breakglass_password at least
|
||||
ansible-playbook site.yml -e @vars.yml
|
||||
|
||||
The first play builds the release binary on your machine (`make build build-arm64`: needs Go, Node and network).
|
||||
Pass `-e nmon_build=false` to reuse an existing `./nmon`. The second play installs it on the hosts in group `nmon`:
|
||||
|
||||
- `/usr/local/bin/nmon`, user `nmon`, data in `/var/lib/nmon`, key and TLS files in `/etc/nmon`, backups in `/var/backups/nmon`
|
||||
- the MIB sidecar in `/usr/share/nmon/mibs`, used only by `nmon profile new --mib`
|
||||
- optionally the IEEE vendor registry CSVs in `/usr/share/nmon/oui`, loaded with `nmon oui import` (`nmon_oui_download`, `nmon_oui_csv_dir`; by default nmon fetches the registry itself)
|
||||
- `config.yaml` rendered from your vars, and a hardened systemd unit (`systemctl reload nmon` sends SIGHUP to reload config and certificates)
|
||||
- `net.ipv4.ping_group_range` opened so nmon can ping without root
|
||||
|
||||
Then open `https://<host>:8443/`. Without OIDC you are sent to `/auth/breakglass` (a browser password prompt; user `admin` by default).
|
||||
|
||||
## TLS (`nmon_tls_mode`)
|
||||
|
||||
| Mode | What it does |
|
||||
|---|---|
|
||||
| `selfsigned` (default) | Generates a cert on the host. Good for trying it out; browsers will warn. |
|
||||
| `files` | Copies `nmon_tls_cert_src` / `nmon_tls_key_src` from the control machine. |
|
||||
| `acme` | Let's Encrypt for `nmon_server_name`; port 80 must reach the host. |
|
||||
| `insecure` | Plain HTTP. With no sign-in configured, nmon runs in dev-open mode where everyone is admin. Never expose this. |
|
||||
|
||||
**Reverse proxy:** nmon can sit behind one. Keep the proxy-to-nmon hop on HTTPS (`selfsigned` is fine there), set
|
||||
`nmon_trusted_proxies` to the proxy's addresses, and set the OIDC `redirect_url` to the public URL. Traps (UDP 162) and
|
||||
Graphite (TCP 2003) go to nmon directly, not through the HTTP proxy.
|
||||
|
||||
## Sign-in
|
||||
|
||||
- **OIDC:** set `nmon_oidc` (issuer, client_id, client_secret, admin_group). The secret is stored encrypted with
|
||||
`nmon secret set` and never written to `config.yaml`. Browsers go straight to the IdP; there is no login page.
|
||||
- **Break-glass:** `nmon_breakglass_password` is bcrypt-hashed on the host. Use it when the IdP is down. Five wrong
|
||||
attempts lock the source IP for 15 min; run `nmon unlock all --data-dir /var/lib/nmon` to clear.
|
||||
|
||||
## After installing
|
||||
|
||||
- **Back up `/etc/nmon/secret.key`** somewhere safe. Without it, a restore has no SNMP credentials.
|
||||
- Add devices in the UI or with `POST /api/v1/devices`, and mint API or push tokens under Settings.
|
||||
- The playbook owns `config.yaml`: edits made in the UI are overwritten on the next run, so put lasting settings in vars.
|
||||
- More: `docs/operations.md`.
|
||||
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
|
||||
43
deploy/ansible/roles/nmon/defaults/main.yml
Normal file
@@ -0,0 +1,43 @@
|
||||
nmon_user: nmon
|
||||
nmon_data_dir: /var/lib/nmon
|
||||
nmon_etc_dir: /etc/nmon
|
||||
nmon_key_file: "{{ nmon_etc_dir }}/secret.key"
|
||||
nmon_backup_dir: /var/backups/nmon
|
||||
nmon_mibs_dir: /usr/share/nmon/mibs
|
||||
nmon_install_mibs: true
|
||||
# MAC vendors (optional IEEE registry). nmon downloads it itself unless nmon_oui_fetch is false; to ship it instead,
|
||||
# set nmon_oui_download (the host downloads the CSVs) or nmon_oui_csv_dir (a directory of the three CSVs on the control
|
||||
# machine). Either way they are kept next to the MIB sidecar and loaded with `nmon oui import`.
|
||||
nmon_oui_dir: /usr/share/nmon/oui
|
||||
nmon_oui_fetch: true
|
||||
nmon_oui_download: false
|
||||
nmon_oui_csv_dir: ""
|
||||
nmon_oui_urls:
|
||||
- https://standards-oui.ieee.org/oui/oui.csv
|
||||
- https://standards-oui.ieee.org/oui28/mam.csv
|
||||
- https://standards-oui.ieee.org/oui36/oui36.csv
|
||||
# MAC/ARP table job (see docs/operations.md); empty uses the defaults.
|
||||
nmon_l2: {}
|
||||
nmon_listen: "[::]:8443"
|
||||
nmon_trap_listen: "[::]:162"
|
||||
nmon_graphite_listen: "[::]:2003"
|
||||
nmon_icmp: auto
|
||||
nmon_timezone: UTC
|
||||
|
||||
nmon_tls_mode: selfsigned # selfsigned | files | acme | insecure
|
||||
nmon_server_name: "{{ inventory_hostname }}"
|
||||
nmon_acme_email: ""
|
||||
nmon_tls_extra_names: [] # more DNS names / IPs for the self-signed cert
|
||||
|
||||
nmon_breakglass_user: admin
|
||||
nmon_breakglass_password: "" # empty = no break-glass file (then OIDC must be configured)
|
||||
nmon_oidc: {}
|
||||
nmon_alerting: {}
|
||||
nmon_trusted_proxies: []
|
||||
|
||||
nmon_backup_at: "02:30"
|
||||
nmon_backup_keep: 7
|
||||
nmon_retention:
|
||||
hot: 31d
|
||||
fiveminute: 120d
|
||||
long: 2y
|
||||
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
|
||||
182
deploy/ansible/roles/nmon/tasks/main.yml
Normal file
@@ -0,0 +1,182 @@
|
||||
- name: Check the platform (nmon ships linux/amd64 and linux/arm64)
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- ansible_system == "Linux"
|
||||
- ansible_architecture in ["x86_64", "aarch64"]
|
||||
fail_msg: "nmon v1 supports linux/amd64 and linux/arm64 only"
|
||||
|
||||
- name: Require a sign-in method when serving HTTPS (nmon refuses to start otherwise)
|
||||
ansible.builtin.assert:
|
||||
that: nmon_tls_mode == "insecure" or nmon_breakglass_password | length > 0 or nmon_oidc
|
||||
fail_msg: "Set nmon_breakglass_password and/or nmon_oidc, or use nmon_tls_mode=insecure for a dev-open playground"
|
||||
|
||||
- name: Install host packages (openssl for the self-signed cert, htpasswd for bcrypt, acl for become_user)
|
||||
ansible.builtin.package:
|
||||
name: "{{ ['openssl', 'apache2-utils', 'acl', 'procps'] if ansible_os_family == 'Debian' else ['openssl', 'httpd-tools', 'acl', 'procps-ng'] }}"
|
||||
state: present
|
||||
|
||||
- name: Create the nmon system user
|
||||
ansible.builtin.user:
|
||||
name: "{{ nmon_user }}"
|
||||
system: true
|
||||
shell: /usr/sbin/nologin
|
||||
home: "{{ nmon_data_dir }}"
|
||||
create_home: false
|
||||
|
||||
- name: Create directories
|
||||
ansible.builtin.file:
|
||||
path: "{{ item.path }}"
|
||||
state: directory
|
||||
owner: "{{ nmon_user }}"
|
||||
group: "{{ nmon_user }}"
|
||||
mode: "{{ item.mode }}"
|
||||
loop:
|
||||
- { path: "{{ nmon_data_dir }}", mode: "0750" }
|
||||
- { path: "{{ nmon_etc_dir }}", mode: "0750" }
|
||||
- { path: "{{ nmon_backup_dir }}", mode: "0750" }
|
||||
|
||||
- name: Ensure /etc/sysctl.d exists (minimal images lack it)
|
||||
ansible.builtin.file:
|
||||
path: /etc/sysctl.d
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
- name: Allow unprivileged ICMP echo (nmon pings without root)
|
||||
ansible.builtin.copy:
|
||||
content: "net.ipv4.ping_group_range = 0 2147483647\n"
|
||||
dest: /etc/sysctl.d/60-nmon.conf
|
||||
mode: "0644"
|
||||
notify: Apply sysctl
|
||||
|
||||
- name: Install the nmon binary
|
||||
ansible.builtin.copy:
|
||||
src: "{{ playbook_dir }}/../../{{ 'nmon' if ansible_architecture == 'x86_64' else 'nmon-arm64' }}"
|
||||
dest: /usr/local/bin/nmon
|
||||
mode: "0755"
|
||||
notify: Restart nmon
|
||||
|
||||
- name: Install the MIB sidecar
|
||||
when: nmon_install_mibs | bool
|
||||
block:
|
||||
- name: Create the MIB directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ nmon_mibs_dir }}"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
- name: Unpack MIBs
|
||||
ansible.builtin.unarchive:
|
||||
src: "{{ playbook_dir }}/.mibs.tar.gz"
|
||||
dest: "{{ nmon_mibs_dir }}"
|
||||
creates: "{{ nmon_mibs_dir }}/IF-MIB"
|
||||
|
||||
- name: Ship the IEEE vendor registry (optional)
|
||||
when: nmon_oui_download | bool or nmon_oui_csv_dir | length > 0
|
||||
block:
|
||||
- name: Create the registry directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ nmon_oui_dir }}"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
- name: Download the registry CSVs on the host
|
||||
when: nmon_oui_download | bool
|
||||
ansible.builtin.get_url:
|
||||
url: "{{ item }}"
|
||||
dest: "{{ nmon_oui_dir }}/{{ item | basename }}"
|
||||
mode: "0644"
|
||||
timeout: 60
|
||||
loop: "{{ nmon_oui_urls }}"
|
||||
register: nmon_oui_fetched
|
||||
- name: Copy the registry CSVs from the control machine
|
||||
when: nmon_oui_csv_dir | length > 0
|
||||
ansible.builtin.copy:
|
||||
src: "{{ nmon_oui_csv_dir }}/"
|
||||
dest: "{{ nmon_oui_dir }}/"
|
||||
mode: "0644"
|
||||
register: nmon_oui_copied
|
||||
- name: Load the registry into nmon's data directory
|
||||
when: (nmon_oui_fetched is changed) or (nmon_oui_copied is changed)
|
||||
become_user: "{{ nmon_user }}"
|
||||
ansible.builtin.command:
|
||||
argv: [/usr/local/bin/nmon, oui, import, "{{ nmon_oui_dir }}", --data-dir, "{{ nmon_data_dir }}"]
|
||||
changed_when: true
|
||||
|
||||
- name: TLS certificate
|
||||
ansible.builtin.include_tasks: tls.yml
|
||||
when: nmon_tls_mode in ["selfsigned", "files"]
|
||||
|
||||
- name: Break-glass admin (bcrypt hash; the password never lands on disk)
|
||||
when: nmon_breakglass_password | length > 0
|
||||
block:
|
||||
- name: Hash the break-glass password
|
||||
ansible.builtin.command:
|
||||
argv: [htpasswd, -nbBC, "12", "{{ nmon_breakglass_user }}", "{{ nmon_breakglass_password }}"]
|
||||
register: nmon_bg_hash
|
||||
changed_when: false
|
||||
no_log: true
|
||||
- name: Write the break-glass file
|
||||
ansible.builtin.copy:
|
||||
content: "{{ nmon_bg_hash.stdout | trim }}\n"
|
||||
dest: "{{ nmon_etc_dir }}/breakglass"
|
||||
owner: "{{ nmon_user }}"
|
||||
group: "{{ nmon_user }}"
|
||||
mode: "0600"
|
||||
no_log: true
|
||||
# Re-hashing gives a new salt each run; only replace the file when it is missing.
|
||||
args:
|
||||
force: false
|
||||
|
||||
- name: Store the OIDC client secret encrypted (creates the key file on a new install)
|
||||
ansible.builtin.shell:
|
||||
cmd: >-
|
||||
set -o pipefail;
|
||||
printf '%s\n' "$NMON_OIDC_SECRET" |
|
||||
/usr/local/bin/nmon secret set oidc-client --data-dir {{ nmon_data_dir }} --secret-key-file {{ nmon_key_file }}
|
||||
executable: /bin/bash
|
||||
environment:
|
||||
NMON_OIDC_SECRET: "{{ nmon_oidc.client_secret }}"
|
||||
become: true
|
||||
become_user: "{{ nmon_user }}"
|
||||
when: nmon_oidc.client_secret is defined
|
||||
changed_when: true
|
||||
no_log: true
|
||||
notify: Restart nmon
|
||||
|
||||
- name: Write config.yaml
|
||||
ansible.builtin.template:
|
||||
src: config.yaml.j2
|
||||
dest: "{{ nmon_data_dir }}/config.yaml"
|
||||
owner: "{{ nmon_user }}"
|
||||
group: "{{ nmon_user }}"
|
||||
mode: "0640"
|
||||
notify: Reload nmon
|
||||
|
||||
- name: Install the systemd unit
|
||||
ansible.builtin.template:
|
||||
src: nmon.service.j2
|
||||
dest: /etc/systemd/system/nmon.service
|
||||
mode: "0644"
|
||||
notify: Restart nmon
|
||||
|
||||
- name: Enable and start nmon
|
||||
ansible.builtin.systemd_service:
|
||||
name: nmon
|
||||
enabled: true
|
||||
state: started
|
||||
daemon_reload: true
|
||||
|
||||
- name: Wait for nmon to answer
|
||||
ansible.builtin.uri:
|
||||
url: "{{ 'http' if nmon_tls_mode == 'insecure' else 'https' }}://localhost:{{ nmon_listen | regex_replace('^.*:', '') }}/healthz"
|
||||
validate_certs: false
|
||||
status_code: 200
|
||||
register: nmon_health
|
||||
retries: 30
|
||||
delay: 2
|
||||
until: nmon_health.status == 200
|
||||
|
||||
- name: Where to go next
|
||||
ansible.builtin.debug:
|
||||
msg:
|
||||
- "nmon is up: {{ 'http' if nmon_tls_mode == 'insecure' else 'https' }}://{{ nmon_server_name }}:{{ nmon_listen | regex_replace('^.*:', '') }}/"
|
||||
- "{{ 'Signs in via your IdP; break-glass at /auth/breakglass' if nmon_oidc else 'Sign in at /auth/breakglass as ' + nmon_breakglass_user }}"
|
||||
- "Back up the key file {{ nmon_key_file }} somewhere safe; without it a restore has no SNMP credentials."
|
||||
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 }}
|
||||
47
deploy/ansible/roles/nmon/templates/nmon.service.j2
Normal file
@@ -0,0 +1,47 @@
|
||||
# Managed by Ansible (deploy/ansible).
|
||||
[Unit]
|
||||
Description=nmon network monitor
|
||||
Documentation=https://claude.ai/artifact/7FTnfA5hReTjY6cfArfJ2Z
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
User={{ nmon_user }}
|
||||
Group={{ nmon_user }}
|
||||
ExecStart=/usr/local/bin/nmon serve \
|
||||
--data-dir {{ nmon_data_dir }} \
|
||||
--secret-key-file {{ nmon_key_file }} \
|
||||
--listen {{ nmon_listen }} \
|
||||
--trap-listen {{ nmon_trap_listen }} \
|
||||
--graphite-listen {{ nmon_graphite_listen }} \
|
||||
--icmp {{ nmon_icmp }} \
|
||||
{% if nmon_tls_mode == 'insecure' %}
|
||||
--insecure-http
|
||||
{% elif nmon_tls_mode == 'acme' %}
|
||||
--acme-host {{ nmon_server_name }} --acme-email {{ nmon_acme_email }}
|
||||
{% else %}
|
||||
--tls-cert {{ nmon_etc_dir }}/tls.crt --tls-key {{ nmon_etc_dir }}/tls.key
|
||||
{% endif %}
|
||||
ExecReload=/bin/kill -HUP $MAINPID
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
TimeoutStopSec=90
|
||||
LimitNOFILE=1048576
|
||||
|
||||
# Ports below 1024: SNMP traps (162) and the ACME challenge (80).
|
||||
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
RestrictSUIDSGID=true
|
||||
LockPersonality=true
|
||||
ReadWritePaths={{ nmon_data_dir }} {{ nmon_etc_dir }} {{ nmon_backup_dir }}
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
26
deploy/ansible/site.yml
Normal file
@@ -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
|
||||
38
deploy/ansible/vars.example.yml
Normal file
@@ -0,0 +1,38 @@
|
||||
# Copy to vars.yml (keep it out of git, or encrypt with ansible-vault) and pass with -e @vars.yml.
|
||||
|
||||
# TLS: selfsigned (quick start), files (your own cert), acme (Let's Encrypt), or insecure (plain HTTP, dev only).
|
||||
nmon_tls_mode: selfsigned
|
||||
nmon_server_name: "{{ inventory_hostname }}" # a DNS name or an IP; it goes in the self-signed cert
|
||||
# nmon_tls_extra_names: ["10.0.0.5", "nmon.lan"]
|
||||
# nmon_tls_cert_src: files/nmon.crt # for tls_mode=files (paths on the control machine)
|
||||
# nmon_tls_key_src: files/nmon.key
|
||||
# nmon_acme_email: noc@example.net # for tls_mode=acme; nmon answers http-01 on :80
|
||||
|
||||
# Break-glass local admin: the way in when the IdP is down (and the only sign-in until OIDC is set).
|
||||
nmon_breakglass_user: admin
|
||||
nmon_breakglass_password: "change-me-please"
|
||||
|
||||
# OIDC (optional). Unauthenticated browsers are redirected to the IdP; there is no login page.
|
||||
# nmon_oidc:
|
||||
# issuer: https://idp.example.net/realms/net
|
||||
# client_id: nmon
|
||||
# client_secret: "s3cret" # stored encrypted via `nmon secret set`, never in config.yaml
|
||||
# groups_claim: groups
|
||||
# admin_group: nmon-admins
|
||||
|
||||
# Alert receivers (optional): webhook and/or SMTP, plus the outbound allow-list they need.
|
||||
# nmon_alerting:
|
||||
# receivers:
|
||||
# - name: noc-webhook
|
||||
# kind: webhook
|
||||
# url: https://hooks.example.net/nmon
|
||||
# outbound_allow: ["hooks.example.net"]
|
||||
|
||||
nmon_timezone: UTC
|
||||
|
||||
# MAC/ARP table job and optional IEEE vendor registry (see docs/operations.md).
|
||||
# nmon_l2: {interval: 15m, keep: 90d, edge_max_macs: 8}
|
||||
# nmon_oui_fetch: false # never let nmon download the registry...
|
||||
# nmon_oui_download: true # ...have this play download the CSVs on the host instead,
|
||||
# nmon_oui_csv_dir: files/oui # ...or ship the three CSVs from the control machine
|
||||
nmon_icmp: auto # off = decide reachability by SNMP alone
|
||||
@@ -35,3 +35,20 @@ conscious, reviewed exception.
|
||||
| 28 | Pushed series carry only `device` | Pushed series also carry `push="1"` (senders cannot set it). | The device page lists what a push sender wrote without guessing from metric names. | M5 |
|
||||
| 29 | Ingest caps, late p95 months and a stalled rollup "raise an alert" | They are rows of the alerts table with rules `ingest_<kind>`, `p95_late`, `rollup_stalled` and no device, kept open until cleared (ingest alarms close 20 min after the last trip). | They are not metric rules and have no device; the metric-rule evaluator leaves these rule names alone. | M5 |
|
||||
| 30 | Proxmox cluster config could carry the token secret | Only `secret_name` exists; the secret is stored encrypted via `PUT /api/v1/secrets/{name}` and cannot be read back. | `config.yaml` is plain text. | M5 |
|
||||
| 31 | HTTPS by default; plain HTTP only with `--insecure-http` (Auth: OIDC only) | With `--insecure-http` **and** neither `auth.oidc` nor `auth.break_glass_file` configured, nmon runs in **dev-open mode**: every request is the admin principal `dev`, a loud warning is logged at start and the UI shows "Authentication disabled — development mode". Any start over TLS with no sign-in method configured refuses to start. If either sign-in method is configured, `--insecure-http` only means plain HTTP and cookies without `Secure`; authentication stays on. | The M1-M5 gates (and local development) run `nmon serve --insecure-http` with no IdP; M6 must not break them. The refusal for TLS-without-auth keeps the unsafe mode from ever being a production start. | M6 |
|
||||
| 32 | Settings (retention, alert thresholds, IdP, receivers, outbound allow-list) are edited in the UI and reloaded | `PUT /api/v1/settings` and SIGHUP reload in place: alert receivers, the outbound allow-list, trusted proxies, the nightly backup schedule, disk thresholds and the retired-device purge period. Alert rules and their interval, retention, timezone and sites, Proxmox, checks, `auth.oidc`, break-glass, session lifetimes, listen address, TLS and the data dir are saved but reported as `restart_required`. An invalid document is a 422 and changes nothing; the file is rewritten from the validated structure, so comments in a hand-edited `config.yaml` are lost by a UI save. | Those parts are built once at start (stores, evaluators, the OIDC client); rebuilding them live is a larger change than the risk of a restart warrants for v1. | M6 |
|
||||
| 33 | `nmon backup <file>` takes an online snapshot | `nmon backup` is a client of the running nmon: it calls `POST /api/v1/admin/backup` (admin token), nmon runs the backup in a scratch file under `<data-dir>/backups/.downloads/`, streams it and deletes it, and the client saves it atomically (temp file + rename). The key-included line is printed from the archive's own manifest. `nmon restore` runs offline and starts the restored stores for a moment to verify series. | Only the running process may touch the live stores and the database. | M6 |
|
||||
| 34 | (not specified) | Beyond the spec, additive to the API/CLI: `GET /api/v1/me`, `GET /api/v1/auth/options` (public), `GET /api/v1/audit` (admin), `POST /api/v1/admin/backup`, `POST /api/v1/admin/stores/{name}/restart`, `POST /api/v1/devices/{slug}/purge`, `POST /api/v1/devices/import`, `GET/PUT /api/v1/settings`; `nmon secret set NAME` (stores the OIDC client secret before the first start; it also creates the key on a brand-new install); API tokens (viewer, admin) share `/api/v1/tokens` with push tokens and have ids like `a7` because the two kinds live in different tables. | OIDC needs its client secret at start, while the API that stores secrets needs a way to sign in: the CLI breaks the cycle. The audit list makes "every use is logged" checkable. | M6 |
|
||||
| 35 | Port descriptions imported from CSV | Interfaces have a new operator-owned `description` column (migration 8). The CSV import and the API write it; discovery rewrites `ifalias` from the device on every run and never touches `description`. Interface search `q` matches ifname, ifalias and description. | A description typed into `ifalias` would be overwritten at the next discovery. | M6 |
|
||||
| 36 | OIDC login with a browser flow; break-glass as a local login | **No login UI** (owner decision). Every route except `/auth/*`, `/healthz` and push ingest needs a credential, the SPA's files included: an unauthenticated page load is a 302 to `/auth/login?return=<url>` (the OIDC flow; the return target must be a same-site path), an API call without credentials is a 401 problem+json. Break-glass is `GET /auth/breakglass` with HTTP Basic, so the browser's own prompt appears; success sets a session and redirects to `return` or `/` (`POST /auth/breakglass` with JSON stays for scripts). When no IdP is configured the redirect goes to the break-glass prompt. Logout is `POST /auth/logout`, then `/auth/signedout` (static HTML). `/docs` and `/openapi*` need the viewer role. A missing OIDC client secret at start leaves OIDC off with a warning. | The IdP is the login page; a second one would be a second thing to secure. | M6 |
|
||||
| 37 | Disk guard: "ingest throttled below the reserve" | Throttle answers push ingest with 429 + Retry-After below the 5% reserve; the poller keeps writing (its volume is bounded by the inventory). Above 90% used, new series are dropped on the nmon side (poller, push and checks) while known series continue; disk alarms are re-raised each minute while the condition holds and resolve themselves 3 minutes after it clears. | VictoriaMetrics series limits are start-up flags; the guard is nmon-side admission. | M6 |
|
||||
| 38 | `interface_down` is a default rule that fires for every down port | `interface_down` is **opt-in**: it alerts only for ports whose own `alert_down` flag is on, or that belong to a group with `alert_down` on (membership in force, `GroupMember.Covers`). Migration 9 adds the columns, default off. Opting a port out closes its open alert silently as `retired` (never a resolved notification). On an access switch most unused ports are admin-up and oper-down, so the default flooded the owner with alerts. `interface_errors` is unchanged. | Owner feedback, first hands-on session. | post-v1 (v1.0.1) |
|
||||
| 39 | Spec: a profile that was matched stays ("non-generic is sticky"); no way to choose one by hand | Discovery re-matches **every unpinned device on every run**, so a newer or better profile is picked up, and at startup nmon queues a discovery for each unpinned device whose stored profile differs from what `Set.Match` picks now. An admin can pin a profile (`PUT /api/v1/devices/{slug}` with `profile`, or the Edit dialog); a pinned device keeps it, and a pinned profile that no longer exists gives generic plus the `profile_missing` warning as before. `profile: ""` returns to automatic. Migration 10 adds `devices.profile_pinned`; `GET /api/v1/profiles` lists the loaded profiles; `DeviceView` gains `profile_pinned` and `profile_match`. Profile changes are audited (`device.profile` from discovery, `device.edit` from the API). | The sticky rule meant a device matched once as generic stayed generic after an upgrade added its profile. | post-v1 (v1.0.1) |
|
||||
| 40 | Profiles shipped: the M2 set | Three more shipped profiles for the owner's gear: `ubiquiti-edgeswitch` (sysObjectID `1.3.6.1.4.1.4413` or sysDescr `^EdgeSwitch`, priority 30), `ubiquiti-unifi-switch` (sysDescr `USW`/`US-`/`USL`/`UniFi`, priority 40, so it beats edgeswitch for UniFi switches that report enterprise 4413), `vyos` (sysObjectID `1.3.6.1.4.1.44641` or sysDescr `VyOS`, extends `linux-netsnmp`, priority 20), and `ubiquiti-unifi-ap` (sysDescr `UAP`/`U6`/`U7`/`UAL`/`UK`/`E7`/`U-LTE`, UBNT-UniFi-MIB per-VAP clients, channel and tx power, per-radio channel utilisation; the MIB has no noise floor, and no per-radio channel or tx power, so those are per VAP). EdgeSwitch CPU is a text string and its temperature table has no label column, so neither is collected. | The owner's switches, routers and access points matched only `generic`. | post-v1 (v1.0.1) |
|
||||
| 41 | Default rules apply everywhere (spec §Alerting) | Alerts fire only where an **alert policy** binds a rule to a scope (groups plus device and port attribute matchers), stored in the `alert_policies` table (migration 12), edited through `/api/v1/alert-policies` and the Alerts > Alert policies tab, and applied live. With no policies nothing alerts: not metric rules, not `device_down`/`device_warning`, not system alarms (`system` policy, scope `all` only). An `ignore` policy beats an `alert` one at equal priority. Upgrading seeds nothing, so open alerts without a binding close silently as `retired` with no resolved notice. Supersedes #38's mechanism (the bell now only feeds the `alert_down` matcher) and makes `Rule.OptIn` a no-op. | Owner decision 2026-10-23: preprod alerted on everything, and a WiFi port that always sees errors is judged by the same rule as a fiber uplink where one error matters. | v1.1 |
|
||||
| 50 | The spec's collection table lists BGP-4-MIB but no profile fills it, and it names no MAC/ARP lookup | v1.3 (owner request 2026-10-24) adds both: BGP peers in the `routing` category (CISCO-BGP4 for cisco-ios/cisco-nxos, Juniper BGP4-V2, Arista BGP4V2, BGP4-MIB for the IPv4-only profiles that answer it) with a Routing tab, a BGP page, `GET /api/v1/bgp/peers` and the built-in `bgp_peer_down` rule; and a MAC/ARP/FDB lookup with a Find host page. Additive API: `/l2/search`, `/devices/{slug}/{l2,fdb,arp,l2/ports}`, `/interfaces/{id}/macs`, `/bgp/peers`; `/poller/health` gains `l2` and `notes`. | The owner wants to find where a host is plugged in and to see BGP sessions. | v1.3 |
|
||||
| 51 | (not specified) table walks run in discovery or not at all; the poll path never walks | A separate **L2 table job** (`internal/l2`, SNMP path label `l2` in `nmon_pdus_total`) walks BRIDGE-MIB, Q-BRIDGE-MIB, CISCO-VTP-MIB (per-VLAN context, profile flag `l2.per_vlan_context`, set on cisco-ios only) and IP-MIB neighbour tables every `l2.interval` (default 15m, minimum 5m) with its own concurrency cap (`l2.concurrency`, 4), a per-device deadline (`l2.device_timeout`, 2m) and the rollup job's yield rule (it pauses while the hot write queue is over half full). A device that times out or fails is skipped for the cycle, recorded on `/poller/health`, and never delays the others. Rows live in the inventory database (migration 14: `fdb_entries`, `arp_entries`, `vlans`, and `l2_walks`, one row per device holding the time of its last walk, which is how a row is "current"), are in `nmon backup` (JSONL export too) and are pruned daily by `last_seen` against `l2.keep` (90d). `interface_id` is the empty string, not NULL, when a bridge port has no ifIndex, because the unique key must not contain NULL. | The hot poll path must not walk tables; the history behind "where was this MAC last week" is inventory, not a metric. | v1.3 |
|
||||
| 52 | The component label of a table metric is a column of the table | Profiles may label rows by decoding the **row index** (`index_label.layout`, tokens `skip`, `skipinet`, `inet`, `skipaddr`, `addr`, `ipv4`, `peerindex`, `afisafi`), because BGP peer tables are indexed by an address. The label is the peer address (IPv6 compressed); a per-address-family prefix count is labelled `<peer>\|<afi>-<safi>`, for example `2001:db8::1\|ipv6-unicast`. Juniper's prefix table is keyed by a peer index, so its rows resolve through the `peer_index` attribute of the peer table (`index_label.via`). Peer AS, local AS, router id and description are component attributes (migration 11), never labels, so the SNMP label contract (`device` + `component`) is unchanged. Other profile additions: `enable_metrics` (switch on optional parent metrics), `wire: counter32` (CISCO-BGP4 declares accepted prefixes as a Counter32 but they are a count), attribute `format: ipbytes`, and `l2.per_vlan_context`. | A BGP peer is identified by its address, which only the index carries; the alternatives (a label column that does not exist, or labelling by row number) would break "each peer appears once" and the alert text. | v1.3 |
|
||||
| 53 | `bgp_peer_down` summary names the peer, AS and description | The rule's own summary names the device and the peer. The AS, router id and description are appended to the **notification** (Summary, Description and labels) when it is delivered, from the component attributes; the stored alert row keeps the plain summary. | The metric label contract allows only `device` and `component`, and the rule templates see only those; extending the evaluator's template data belongs to the v1.2 rules plumbing. | v1.3 |
|
||||
| 54 | (not specified) vendor of a MAC | An optional IEEE OUI registry (MA-L, MA-M, MA-S) shows the vendor of each MAC and flags locally administered (`local`, shown "Private / randomised") and multicast addresses. It is fetched weekly through the outbound allow-list (`oui.fetch: false` turns it off; `nmon oui import DIR` loads the CSVs offline), kept as `<data-dir>/oui/registry.gz` (checksummed, written atomically, in `nmon backup`), and looked up at query time by the longest prefix, so a refresh relabels history. Without a registry the vendor is empty, nothing fails, and `/poller/health` carries an `oui_unavailable` note (never an alert). The CSVs are public IEEE data and are not in the repository. | Owner request 2026-10-24: nice to have, never required. | v1.3 |
|
||||
| 55 | Lookup ranks edge ports "the port with the fewest MACs, then the most recent last_seen" | Within a MAC's locations the order is: edge ports before uplinks, then rows the device's last walk still saw before history, then fewest MACs, then most recent. A port is an uplink above `l2.edge_max_macs` MACs (8), with an LLDP neighbour (the lldpRemTable local port number is taken to be the ifIndex), or as a LAG (ifType 161). A port's MAC count is the MACs on it at or after the row's `last_seen`, so for a current row it is the port's MACs in the last walk. | A host that moved should show its new port first even when the old, now empty, port had fewer MACs. | v1.3 |
|
||||
|
||||
91
docs/coverage.md
Normal file
@@ -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.
|
||||
@@ -44,7 +44,16 @@ by the store.
|
||||
|
||||
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/`.
|
||||
|
||||
@@ -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 |
|
||||
@@ -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).
|
||||
|
||||
@@ -17,7 +17,8 @@ its **Gate log** is filled in when the gate passes. A milestone is done only whe
|
||||
| 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) | in progress |
|
||||
| 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
|
||||
@@ -49,6 +50,14 @@ Departures from the spec are recorded in [`docs/DEVIATIONS.md`](../DEVIATIONS.md
|
||||
|
||||
- **Web push notifications (v2)**: a `webpush` receiver kind in `internal/notify` (RFC 8291 payload encryption, RFC 8292 VAPID, standard-library crypto), per-user subscriptions (needs M6 users), a service worker in the UI, and push endpoints in the outbound allow-list. Out of v1 per the spec's non-goals ("alerts go to webhooks and email only"). Owner deferred it to v2 on 2026-10-22. Until then, a webhook receiver pointed at ntfy or Gotify gives phone push.
|
||||
|
||||
- **BMC support**: SNMP profiles for Dell iDRAC, HPE iLO and Supermicro (health, temperatures, fans, PSUs, power, RAID) feeding the Health/Power/Environment tabs; a native Redfish collector later. Today, Redfish works only via an exporter pushing with a push token. Owner: "good enough for now" (2026-10-23).
|
||||
|
||||
## Post-v1 additions
|
||||
|
||||
- **Device management UI** (2026-10-23): the UI had no way to add a device. Added an admin-only "Add device" dialog on the device list (IPv6/IPv4 addresses, SNMP v1/v2c/v3, synchronous discovery with the reattach 409 flow) and an Actions menu on the device page (Edit with parents and SNMP replacement, Rediscover now, Retire; Purge now asks for the device name). New API route `POST /api/v1/devices/{slug}/retire` (admin, audited, additive). The SNMP fields are one shared component (`web/src/components/SnmpFields.tsx`). Screenshots: `docs/screenshots/devices-mgmt/`.
|
||||
|
||||
- **Profiles, profile pinning and row navigation** (2026-10-23): new shipped profiles `ubiquiti-edgeswitch`, `ubiquiti-unifi-switch`, `ubiquiti-unifi-ap` and `vyos`; discovery re-matches unpinned devices every run (and at startup) and an admin can pin a profile from the Edit dialog (DEVIATIONS 39, 40); the device header explains why a device is generic. On the device list, the interface search results and the poller health table, a click anywhere in a row (or Enter on a focused row) opens the device or graph; the name stays a real link. Screenshots: `docs/screenshots/v1.0.1/`.
|
||||
|
||||
## Review artifacts
|
||||
|
||||
- From M3 on, each UI milestone's gate captures Playwright screenshots of every page into `docs/screenshots/<milestone>/` for the user to review.
|
||||
|
||||
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.
|
||||
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 55).
|
||||
- **Per-VLAN fallback.** If the first VLAN context does not answer, the walker falls back to the plain tables instead of waiting out a timeout per VLAN.
|
||||
- **`bgp_peer_down` text.** AS and description are added to the notification, not the stored alert row (DEVIATIONS 53).
|
||||
- **BGP per profile.** See `docs/coverage.md`. Nothing for mikrotik-routeros (no BGP objects in MIKROTIK-MIB or the recordings) and the non-router profiles; cisco-nxos is unverified against a recording; VyOS is covered through BGP4-MIB on the strength of the FRR recording only.
|
||||
- **Open item for v1.2.** The prefix-drop example in `docs/operations.md` is to be added once custom rules land.
|
||||
|
||||
121
docs/operations.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# Operating nmon
|
||||
|
||||
How to run, secure, back up and maintain a production nmon. Design background: `docs/spec/spec.md` ("Auth", "Operations").
|
||||
|
||||
## Starting
|
||||
|
||||
nmon serve --data-dir /var/lib/nmon --secret-key-file /etc/nmon/secret.key \
|
||||
--tls-cert /etc/nmon/tls.crt --tls-key /etc/nmon/tls.key
|
||||
|
||||
- **HTTPS is the default.** Pass a certificate pair (`--tls-cert`, `--tls-key`; `SIGHUP` reloads them without dropping connections) or Let's Encrypt (`--acme-host nmon.example.net` repeatable, `--acme-email you@example.net`; nmon answers the http-01 challenge on `--acme-listen`, default `:80`, certificates are cached in `<data-dir>/acme`). Without either, `serve` refuses to start and says so. `--insecure-http` is for development: plain HTTP, cookies without `Secure`.
|
||||
- **A sign-in method is required** over TLS (`auth.oidc` and/or `auth.break_glass_file` in `config.yaml`). With `--insecure-http` and neither configured nmon runs in *dev-open mode* (everyone is admin `dev`; a banner says so); never expose that port.
|
||||
- Behind a reverse proxy, list it in `auth.trusted_proxies`; `X-Forwarded-*` is ignored from anyone else.
|
||||
|
||||
## Sign-in
|
||||
|
||||
`config.yaml`:
|
||||
|
||||
auth:
|
||||
oidc:
|
||||
issuer: https://idp.example.net/realms/net
|
||||
client_id: nmon
|
||||
client_secret_name: oidc-client # the secret itself is never in this file
|
||||
redirect_url: https://nmon.example.net/auth/callback
|
||||
groups_claim: groups
|
||||
admin_group: nmon-admins # members are admins; every other user is a viewer
|
||||
break_glass_file: /etc/nmon/breakglass # bcrypt hash, "hash" or "user:hash", created by you
|
||||
|
||||
Store the client secret before the first start: `printf '%s\n' "$SECRET" | nmon secret set oidc-client --data-dir D --secret-key-file K` (it can also be set later with `PUT /api/v1/secrets/oidc-client`; OIDC stays off until the secret exists). The break-glass file holds one bcrypt hash (`$2a$`/`$2b$`), made with any bcrypt tool (the source tree's `nmon-devtools bcrypt PASSWORD` works); keep it readable by the nmon user only.
|
||||
|
||||
- There is no login page. Opening nmon without a session redirects to the IdP and back; API clients use a token. If the IdP is down, open `/auth/breakglass`: the browser asks for the local admin user and password (HTTP Basic), and a successful sign-in lands on the page you asked for.
|
||||
- Break-glass: five wrong passwords lock the source address for 15 minutes; every use and every lock is audited and raises an alert. `nmon unlock [IP|all] --data-dir D` clears locks, also while nmon runs.
|
||||
- API tokens: an admin mints viewer, admin or push tokens (`POST /api/v1/tokens`, or Settings, API tokens). Use `Authorization: Bearer nma_...`. A push token works for ingest only. Revoking takes effect on the next request.
|
||||
- `GET /api/v1/audit` (admin) lists who did what.
|
||||
|
||||
## Backup and restore
|
||||
|
||||
A running nmon owns the stores, so copying the data folder is **not** a backup.
|
||||
|
||||
nmon backup /backups/nmon.tar --api https://nmon.example.net --token nma_... # key NOT included
|
||||
nmon backup /backups/nmon.tar --include-key --api ... --token ... # key included
|
||||
|
||||
Every run prints whether the key file is in the archive. **Lose the key file and a restore has an inventory with no SNMP credentials.** The nightly schedule is `backup: {at: "02:30", dir: /var/backups/nmon, keep: 7}` (time in the configured timezone, default dir `<data-dir>/backups`); a failed run raises the `backup_failed` alert. A backup is refused when the disk is below its 5% reserve.
|
||||
|
||||
Restore with nmon stopped, into a clean directory:
|
||||
|
||||
nmon restore /backups/nmon.tar --data-dir /var/lib/nmon-restored --secret-key-file /etc/nmon/secret.key
|
||||
|
||||
It verifies inventory row counts and a sample of series before it says done. Then start nmon on that directory. Restore needs the embedded stores (the normal release build).
|
||||
|
||||
## Keys
|
||||
|
||||
`nmon key rotate --data-dir D --secret-key-file K` (nmon stopped) adds a new key version, re-encrypts every SNMP credential and secret under it, and keeps the old key in the file so older backups still restore. Remove old keys only after a release.
|
||||
|
||||
## Configuration
|
||||
|
||||
Edit in Settings (admin) or `config.yaml` + `SIGHUP`. An invalid document is rejected and the running configuration stays. Alert receivers, the outbound allow-list, trusted proxies, the backup schedule, disk thresholds and the purge period apply live; everything else is listed as `restart_required` until the next restart. `disk: {alert_used: 0.8, admit_used: 0.9, reserve_free: 0.05}`: alert at 80% used, no new series at 90%, ingest throttled / rollup paused / backups refused below 5% free.
|
||||
|
||||
## Metric stores
|
||||
|
||||
The supervisor restarts a crashed store; after three restarts in ten minutes it raises `store_failed` and stops. `nmon store restart [hot|long] --api URL --token T` (or `SIGHUP`) resumes it.
|
||||
|
||||
## Interface-down alerts are opt-in
|
||||
|
||||
Since v1.1 the bell and the group switch only **mark** ports: `interface_down` alerts for them only where an alert policy for `interface_down` uses the `alert_down` matcher (template "Bell-marked ports: interface_down"). Mark ports in one at a time (the bell on the device page's interface table), all at once (the "Alert on down" control in the interfaces card header, or `POST /api/v1/devices/{slug}/interfaces/alert-down`), or through a group (the group's "Alert when member ports go down" switch; `PUT /api/v1/interfaces/{id}` with `{"alert_down": true}` for one port). A port in a group with the switch on alerts for as long as its membership is in force. Changes reach the evaluator at once; opting a port out closes its open alert silently as `retired`.
|
||||
|
||||
## Alert policies: nothing alerts until you add one
|
||||
|
||||
Alerts fire only where an **alert policy** binds a rule to a scope. A fresh install has no policies and records no alerts at all: no metric rule, no `device_down` or `device_warning`, no system alarm (ingest caps, late p95, rollup, disk guard, break-glass, backup). The startup log says so once ("no alert policies: nothing will alert"). Upgrading from v1.0 seeds nothing: the first cycle closes every open alert that no policy binds, silently, as `retired` (no "resolved" notices go out).
|
||||
|
||||
Manage policies under Alerts > Alert policies (admins edit, viewers read) or with `GET/POST /api/v1/alert-policies` and `GET/PUT/DELETE /api/v1/alert-policies/{id}`. `GET /api/v1/alert-rules` lists the rule names. `POST /api/v1/alert-policies/preview` counts the devices and ports a draft scope covers. Changes apply on the next cycle with no restart. Removing or disabling a policy retires its open alerts quietly.
|
||||
|
||||
A policy has a rule (a metric rule, `device_down`, `device_warning`, or `system`), an action (`alert` or `ignore`), a priority, optional `severity`, `threshold` and `for` overrides, and a scope. Scope fields are ANDed and any entry of a list may match: `all`, `groups` (a port is in force as a member now; a device matches if any port does), `devices` (`slugs` globs, `profiles`, `roles`, `sites`, `kinds`) and `interfaces` (`if_types`, `if_name` and `if_alias` as RE2, `min_speed_mbps`, `max_speed_mbps`, `alert_down`). For one entity and rule the matching policy with the highest priority decides; on a tie `ignore` beats `alert`, then the lowest name. A policy threshold beats a rule's group override, the profile threshold and the default. Interface matchers are refused on device-level rules, and a `system` policy takes only `all: true`.
|
||||
|
||||
Fiber and uplinks alert on any error, WiFi never alerts on errors:
|
||||
|
||||
```
|
||||
{"name": "Fiber + uplinks: errors", "rule": "interface_errors", "action": "alert", "threshold": 0,
|
||||
"scope": {"interfaces": {"if_types": [6], "if_alias": "(?i)fib|uplink|core"}}}
|
||||
{"name": "WiFi: no error alerts", "rule": "interface_errors", "action": "ignore", "priority": 10,
|
||||
"scope": {"interfaces": {"if_types": [71]}}}
|
||||
```
|
||||
|
||||
## Retired devices
|
||||
|
||||
`POST /api/v1/devices/{slug}/purge` (admin) deletes a retired device's series from both stores once it has been retired for `retired_purge_after` (default 1y).
|
||||
|
||||
## CSV import
|
||||
|
||||
Devices (`name,address,site,role,credential,parents,...`) and port descriptions (`device,ifname,alias`) from Settings or `POST /api/v1/devices/import`. Port descriptions are stored in the operator-owned `description` field, which discovery never overwrites. A dependency cycle is rejected with its path; nothing is written unless the whole import is valid.
|
||||
|
||||
## Find host: MAC, ARP and FDB lookup
|
||||
|
||||
The **L2 table job** walks the bridge forwarding tables, VLAN names and ARP / IPv6 neighbour tables of every SNMP device every `l2.interval` and keeps them with first and last seen in the inventory, so the **Find host** page (and the header search, for a MAC, IP, CIDR or `vendor:name`) answers "which switch port is this host plugged in at, in which VLAN, and when did we last see it", with the uplinks that also saw it collapsed underneath. A MAC is seen on every switch it passes through, so the answer is the **edge** port: one that carries at most `l2.edge_max_macs` MACs, has no LLDP neighbour and is not a LAG.
|
||||
|
||||
```yaml
|
||||
l2:
|
||||
interval: 15m # default 15m, minimum 5m
|
||||
keep: 90d # history kept by last_seen, pruned daily
|
||||
edge_max_macs: 8 # a port with more MACs is an uplink
|
||||
concurrency: 4 # devices walked at once
|
||||
device_timeout: 2m # a slower device is skipped for the cycle
|
||||
oui:
|
||||
fetch: true # IEEE vendor registry; false never touches the network
|
||||
refresh: 7d
|
||||
```
|
||||
|
||||
All of these apply from the next cycle after a reload (Settings save or SIGHUP) with no restart. The job never runs on the 60 s poll path and yields to the poller the way the rollup job does. A device that does not answer in time is skipped for that cycle and named on the Poller page (`l2` status and an `l2_devices_skipped` note); it never delays the other devices. A Cisco IOS switch keeps one bridge table per VLAN: the `cisco-ios` profile sets `l2.per_vlan_context: true`, which lists the VLANs from CISCO-VTP-MIB and walks each with community `<community>@<vlan>` (v3: context `vlan-<vlan>`). Other devices use Q-BRIDGE-MIB, then BRIDGE-MIB (VLAN unknown).
|
||||
|
||||
API: `GET /api/v1/l2/search?q=`, `GET /api/v1/devices/{slug}/{l2,fdb,arp,l2/ports}`, `GET /api/v1/interfaces/{id}/macs`. On a device, the **L2** tab (MAC table with a VLAN filter, ARP / ND, VLAN list) appears once the job has read it, and the Ports tab shows each port's MAC count linking to that port's MACs.
|
||||
|
||||
## MAC vendors (optional IEEE registry)
|
||||
|
||||
If a registry is loaded, each MAC shows its vendor, a locally administered address (typical of randomised phone and VM MACs) shows a "private" badge, and `vendor:ubiquiti` lists the current MACs of a vendor. nmon works fully without it: the vendor is simply empty, and the Poller page notes `oui_unavailable` (not an alert).
|
||||
|
||||
The registry is the IEEE's public data: MA-L (`https://standards-oui.ieee.org/oui/oui.csv`), MA-M (`.../oui28/mam.csv`) and MA-S (`.../oui36/oui36.csv`), downloaded weekly through the outbound allow-list (`standards-oui.ieee.org` must be allowed when an allow-list is configured) with timeouts, a size cap and a row-count sanity check, so a truncated or wrong download keeps the previous file. The compact result is `<data-dir>/oui/registry.gz` (checksummed, written atomically, included in `nmon backup`). Where nmon may not fetch, set `oui.fetch: false` and load the CSVs by hand: `nmon oui import <dir-or-file> --data-dir DIR` (a running nmon notices within an hour). The CSVs are not part of this repository.
|
||||
|
||||
## BGP
|
||||
|
||||
Router profiles collect BGP session state, uptime, accepted prefixes per address family and update rates every 5 minutes (see `docs/coverage.md` for which profiles and MIBs). The **BGP** page lists every session on every device, the ones that are down first, filterable by state, AS, family and text; a device's **Routing** tab shows its sessions with click-to-graph on prefixes and updates.
|
||||
|
||||
The built-in rule `bgp_peer_down` (critical, state not established for 2 minutes) fires only where a policy binds it, like every rule: use the template "Routers: BGP peer down" under Alerts > Alert policies. Its notification names the peer and, from discovery, the peer's AS and description. A prefix-drop alert is a custom rule (v1.2); an example will be added here once custom rules are in.
|
||||
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 603 KiB After Width: | Height: | Size: 607 KiB |
|
Before Width: | Height: | Size: 149 KiB After Width: | Height: | Size: 153 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 161 KiB After Width: | Height: | Size: 167 KiB |
|
Before Width: | Height: | Size: 161 KiB After Width: | Height: | Size: 167 KiB |
|
Before Width: | Height: | Size: 193 KiB After Width: | Height: | Size: 198 KiB |
|
Before Width: | Height: | Size: 194 KiB After Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 202 KiB After Width: | Height: | Size: 206 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 593 KiB After Width: | Height: | Size: 600 KiB |
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 158 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 149 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 195 KiB After Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 186 KiB After Width: | Height: | Size: 183 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 212 KiB After Width: | Height: | Size: 208 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 175 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 186 KiB After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 123 KiB |