Files
nmon/scripts/gate-m6.sh
jleung 0754268e9e
Some checks failed
ci / lint (push) Has been cancelled
ci / test (ubuntu-24.04-arm) (push) Has been cancelled
ci / test (ubuntu-latest) (push) Has been cancelled
ci / postgres (push) Has been cancelled
ci / openapi (push) Has been cancelled
M6: gate-m6, operations guide, deviations, screenshots
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-23 02:15:49 +00:00

471 lines
37 KiB
Bash
Executable File

#!/usr/bin/env bash
# Gate M6: a viewer gets 403 on PUT (the 2,000-device loadtest is skipped: docs/DEVIATIONS.md row 17).
# Procedure: docs/milestones/M6-auth-and-ops.md.
# 0. make lint; unit tests (race) and the integration tests of the packages this milestone adds.
# 1. make build + make devtools. nmon serve refuses to start with no TLS and no --insecure-http.
# 2. A mock OIDC issuer (nmon-devtools oidc-mock), a break-glass password file, two simulated switches and
# nmon serve --insecure-http with auth.oidc + auth.break_glass_file configured (so authentication is ON).
# 3. (1) No login page: an unauthenticated page load is a 302 into the OIDC flow, API calls stay 401, break-glass is
# HTTP Basic on GET /auth/breakglass. (1) viewer 403: an OIDC viewer gets 403 on PUT /api/v1/devices/{slug} (with a valid CSRF token); an
# admin-group user gets 200; a viewer API token gets 403; a push token gets 403 on GET /api/v1/devices; a revoked
# token gets 401; a cookie mutation without the CSRF token gets 403; X-Forwarded-For from a non-proxy is ignored.
# (2) the viewer-fuzz Go test over every state-changing route. (3) break-glass: 5 wrong passwords lock the
# address, `nmon unlock all` lets it in again, audit rows and alerts exist. (4) `nmon backup` during polling
# (prints whether the key is included), stop, `nmon restore` into a clean dir (verified), start on it: devices,
# tokens and a sample series are there. (5) `nmon key rotate` on a copy: nmon starts over HTTPS with its own
# certificate and still polls and signs in (credentials and secrets decrypt). (6) settings PUT invalid -> 422 and
# unchanged, valid -> applied, SIGHUP reloads the file and survives a broken one. (7) CSV import: a cycle -> 422 with
# the path and nothing written; a valid import creates devices and port descriptions that survive rediscovery.
# (8) openapi-check against m3, the web client built from the live spec, mock UI specs, live screenshots.
# Needs: go, make, node 22+, npm, Playwright's chromium (cd web && npx playwright install chromium), curl, ss, awk,
# python3, openssl.
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
# Maintainability is a hard rule (docs/MAINTAINABILITY.md): no gate passes with lint findings.
make lint || { echo "FAIL: make lint"; exit 1; }
TMP="$(mktemp -d)"
FAILS=0
NMON_PID="" AGENTS_PID="" OIDC_PID=""
SHOTS="$ROOT/${NMON_SHOT_DIR:-docs/screenshots/M6}"
TOPO="$ROOT/testdata/topologies/m5.yaml"
DEVTOOLS="$ROOT/nmon-devtools"
MAP="$TMP/slugs.map"
CTL="$TMP/ctl"
BG_PASSWORD="gate-break-glass-pw"
OIDC_SECRET="gate-oidc-client-secret"
pass() { echo "PASS: $*"; }
fail() { echo "FAIL: $*"; FAILS=$((FAILS + 1)); }
die() { fail "$*"; exit 1; }
cleanup() {
for p in "$NMON_PID" "$AGENTS_PID" "$OIDC_PID"; do
[ -n "$p" ] && kill -TERM "$p" 2>/dev/null && wait "$p" 2>/dev/null
done
rm -rf "$TMP"
}
trap cleanup EXIT
trap 'exit 130' INT TERM
pick_port() { # a port free for both TCP and UDP
local p
while :; do
p=$((20000 + RANDOM % 30000))
ss -H -lntu 2>/dev/null | awk '{print $5}' | grep -q "[:.]$p\$" || { echo "$p"; return; }
done
}
pick_udp_range() { # $1 consecutive UDP ports that are all free
local base n="$1" i busy
while :; do
base=$((20000 + RANDOM % 25000)); busy=0
for i in $(seq 0 $((n - 1))); do
ss -H -lnu 2>/dev/null | awk '{print $5}' | grep -q "[:.]$((base + i))\$" && { busy=1; break; }
done
[ "$busy" -eq 0 ] && { echo "$base"; return; }
done
}
wait_for() { # wait_for <seconds> <description> <command...>
local n="$1" what="$2"; shift 2
for _ in $(seq 1 "$((n * 2))"); do "$@" && return 0; sleep 0.5; done
echo "timed out after ${n}s waiting for $what"; return 1
}
# jq-less JSON access: py <python expression over d> reads JSON on stdin.
py() { python3 -I -c 'import json,sys; d=json.load(sys.stdin); print(eval(sys.argv[1]))' "$1"; }
code() { curl -s -o "$TMP/last.body" -w '%{http_code}' "$@"; } # prints the status; the body is in $TMP/last.body
vm_value() { # vm_value <base url> <promql>: first sample value or empty
curl -fsS -G "$1/api/v1/query" --data-urlencode "query=$2" | py "d['data']['result'][0]['value'][1] if d['data']['result'] else ''"
}
# Sign-in helpers. A "jar" is a curl cookie file.
ctl_user() { curl -fsS -G "$CTL_URL/user" --data-urlencode "sub=$1" --data-urlencode "name=$2" --data-urlencode "groups=${3:-}" -o /dev/null; }
sso_login() { # sso_login <jar> <sub> <name> [groups]: the whole OIDC code flow, redirects followed
rm -f "$1"; ctl_user "$2" "$3" "${4:-}"
curl -fsS -L -c "$1" -b "$1" -o /dev/null "$URL/auth/login?return=/"
}
bg_login() { # bg_login <jar> [password] [extra curl args...]: HTTP Basic against /auth/breakglass; success is a 302 to /
local jar="$1" pw="${2:-$BG_PASSWORD}"
shift; [ $# -gt 0 ] && shift
code -c "$jar" -b "$jar" -u "breakglass:$pw" "$URL/auth/breakglass" "$@"
}
csrf_of() { curl -fsS -b "$1" "$URL/api/v1/me" | py "d['csrf']"; }
as() { # as <jar> <method> <path> [json body]: a cookie request WITH its CSRF token; prints the status
local jar="$1" m="$2" p="$3" body="${4:-}"
code -b "$jar" -H "X-CSRF-Token: $(csrf_of "$jar")" -X "$m" "$URL$p" ${body:+-H 'Content-Type: application/json' -d "$body"}
}
tok() { # tok <token> <method> <path> [json body]
local t="$1" m="$2" p="$3" body="${4:-}"
code -H "Authorization: Bearer $t" -X "$m" "$URL$p" ${body:+-H 'Content-Type: application/json' -d "$body"}
}
api() { curl -fsS -H "Authorization: Bearer $ADMIN_TOKEN" "$URL$1"; }
slug_by_name() { api "/api/v1/devices?per_page=1000" | py "next((i['slug'] for i in d['items'] if i['name']=='$1'), '')" 2>/dev/null; }
start_nmon() { # start_nmon <data dir> <key file> <port> <log> [extra serve flags...]
local data="$1" key="$2" port="$3" log="$4"; shift 4
./nmon serve --data-dir "$data" --secret-key-file "$key" --listen "127.0.0.1:$port" \
--poll-interval 5s --icmp off --trap-listen '' --graphite-listen '' "$@" >"$log" 2>&1 &
NMON_PID=$!
}
stop_nmon() {
[ -n "$NMON_PID" ] && kill -TERM "$NMON_PID" 2>/dev/null && wait "$NMON_PID" 2>/dev/null
NMON_PID=""
}
healthy() { curl -fsS "$URL/healthz" >/dev/null 2>&1; }
# 0. Unit and integration tests of what this milestone adds.
echo "== unit tests"
if go test -race -count=1 ./internal/auth/... ./internal/api/... ./internal/app/... ./internal/backup/... ./internal/keyrotate/... \
./internal/diskguard/... ./internal/importcsv/... ./internal/config/... ./internal/store/... ./internal/rollup/... \
./internal/ingest/... ./internal/netpolicy/... ./internal/vmsup/... ./internal/alert/... ./cmd/... >"$TMP/unit.log" 2>&1; then
pass "go test -race: auth, api, app, backup, keyrotate, diskguard, importcsv, config, store, rollup, ingest, netpolicy, vmsup, alert, cmd"
else
tail -n 30 "$TMP/unit.log"; fail "go test -race"
fi
if go test -count=1 -tags 'integration vmembed' ./internal/backup/... >"$TMP/integ.log" 2>&1; then
pass "backup/restore integration tests against a real VictoriaMetrics"
else
tail -n 30 "$TMP/integ.log"; fail "backup integration tests"
fi
# 1. Binaries and the start-up refusals.
echo "== make build"
make build >"$TMP/build.log" 2>&1 || { tail -n 30 "$TMP/build.log"; die "make build"; }
make devtools >"$TMP/devtools.log" 2>&1 || { tail -n 30 "$TMP/devtools.log"; die "make devtools"; }
pass "nmon and nmon-devtools built"
echo "== start-up refusals"
if ./nmon serve --data-dir "$TMP/refuse1" --secret-key-file "$TMP/refuse1.key" >"$TMP/refuse1.log" 2>&1; then
fail "serve with no TLS and no --insecure-http started"
else
grep -q -- '--tls-cert' "$TMP/refuse1.log" && grep -q -- '--insecure-http' "$TMP/refuse1.log" &&
pass "serve without TLS or --insecure-http refuses, and says how to fix it" || { cat "$TMP/refuse1.log"; fail "refusal message"; }
fi
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -days 2 -subj "/CN=nmon-gate" \
-addext "subjectAltName=IP:127.0.0.1" -keyout "$TMP/tls.key" -out "$TMP/tls.crt" >/dev/null 2>&1 || die "openssl"
if ./nmon serve --data-dir "$TMP/refuse2" --secret-key-file "$TMP/refuse2.key" --tls-cert "$TMP/tls.crt" --tls-key "$TMP/tls.key" \
>"$TMP/refuse2.log" 2>&1; then
fail "serve over TLS with no sign-in method started"
else
grep -q 'no way to sign in' "$TMP/refuse2.log" && pass "serve over TLS with no auth configured refuses to start" || { cat "$TMP/refuse2.log"; fail "auth refusal message"; }
fi
[ ! -e "$TMP/refuse1.key" ] && [ ! -e "$TMP/refuse2.key" ] && pass "a refused start creates no key file" || fail "a refused start created a key file"
# 2. Mock IDP, break-glass file, simulated switches, nmon with authentication on.
echo "== mock oidc + agents + nmon serve"
mkdir -p "$CTL"
"$DEVTOOLS" oidc-mock --client-id nmon --client-secret "$OIDC_SECRET" --issuer-file "$TMP/issuer" --port-file "$TMP/oidc.port" >"$TMP/oidc.log" 2>&1 &
OIDC_PID=$!
wait_for 20 "the mock issuer" test -s "$TMP/issuer" -a -s "$TMP/oidc.port" || { cat "$TMP/oidc.log"; die "oidc-mock did not start"; }
ISSUER="$(cat "$TMP/issuer")"; CTL_URL="http://[::1]:$(cat "$TMP/oidc.port")"
"$DEVTOOLS" bcrypt "$BG_PASSWORD" >"$TMP/breakglass.hash" || die "bcrypt"
NDEV="$(grep -c '^ - {slug:' "$TOPO")"
BASE="$(pick_udp_range "$NDEV")"
"$DEVTOOLS" topology-agents --topology "$TOPO" --port-base "$BASE" --control-dir "$CTL" >"$TMP/agents.log" 2>&1 &
AGENTS_PID=$!
wait_for 30 "the simulated agents" grep -q 'agents ready' "$TMP/agents.log" || { cat "$TMP/agents.log"; die "agents did not start"; }
PORT="$(pick_port)"
URL="http://127.0.0.1:$PORT"
DATA="$TMP/data"; KEY="$TMP/secret.key"
mkdir -p "$DATA"
cat >"$DATA/config.yaml" <<YAML
alerting:
outbound_allow: ["::1"]
auth:
oidc:
issuer: $ISSUER
client_id: nmon
client_secret_name: oidc-client
redirect_url: $URL/auth/callback
groups_claim: groups
admin_group: nmon-admins
break_glass_file: $TMP/breakglass.hash
YAML
printf '%s\n' "$OIDC_SECRET" | ./nmon secret set oidc-client --data-dir "$DATA" --secret-key-file "$KEY" >"$TMP/secretset.log" 2>&1 &&
pass "nmon secret set stores the OIDC client secret before the first start ($(tail -n 1 "$TMP/secretset.log"))" || { cat "$TMP/secretset.log"; die "nmon secret set"; }
start_nmon "$DATA" "$KEY" "$PORT" "$TMP/nmon.log" --insecure-http
wait_for 90 "nmon to serve" healthy || { tail -n 20 "$TMP/nmon.log"; die "nmon serve did not start"; }
HOT="$(grep -o 'hot=http://[^ ]*' "$TMP/nmon.log" | head -n 1 | cut -d= -f2)"
[ -n "$HOT" ] || die "cannot read the hot store URL from the log"
grep -q 'authentication on.*oidc=true.*break_glass=true' "$TMP/nmon.log" && pass "nmon is up with OIDC and break-glass configured (authentication is on)" ||
{ grep -i 'auth' "$TMP/nmon.log"; fail "auth did not start with OIDC"; }
! grep -q 'AUTHENTICATION IS DISABLED' "$TMP/nmon.log" && pass "no dev-open warning when sign-in is configured" || fail "dev-open warning printed"
# Bootstrap: break-glass admin session, an admin API token for the tooling.
ADMIN_JAR="$TMP/admin.jar"
[ "$(bg_login "$ADMIN_JAR" "$BG_PASSWORD" -D "$TMP/bg.headers")" = 302 ] && pass "break-glass login (HTTP Basic on GET /auth/breakglass) creates an admin session and redirects" || { cat "$TMP/last.body"; die "break-glass login"; }
grep -qi '^set-cookie: nmon_session=.*HttpOnly' "$TMP/bg.headers" && grep -qi 'SameSite=Lax' "$TMP/bg.headers" &&
pass "the session cookie is HttpOnly and SameSite=Lax (not Secure only because of --insecure-http)" || { cat "$TMP/bg.headers"; fail "session cookie attributes"; }
as "$ADMIN_JAR" POST /api/v1/tokens '{"name":"gate-admin","scope":"admin"}' >/dev/null
ADMIN_TOKEN="$(py "d['secret']" <"$TMP/last.body")"; ADMIN_TOKEN_ID="$(py "d['id']" <"$TMP/last.body")"
case "$ADMIN_TOKEN" in nma_*) pass "minted an admin API token ($ADMIN_TOKEN_ID)";; *) fail "admin token: $ADMIN_TOKEN"; die "cannot continue without a token";; esac
export NMON_TOKEN="$ADMIN_TOKEN"
"$DEVTOOLS" topology-add --api "$URL" --topology "$TOPO" --port-base "$BASE" --map-out "$MAP" >"$TMP/add.log" 2>&1 || { cat "$TMP/add.log"; die "topology-add"; }
pass "devices added through the API with the admin token: $(cat "$TMP/add.log")"
SW1="$(awk '$2=="sw-1" {print $1}' "$MAP")"; SW2="$(awk '$2=="sw-2" {print $1}' "$MAP")"
have_ifaces() { [ "$(api "/api/v1/devices/$SW1" | py "len(d['interfaces'])")" -ge 1 ] && [ "$(api "/api/v1/devices/$SW2" | py "len(d['interfaces'])")" -ge 1 ]; }
wait_for 90 "interfaces on both switches" have_ifaces || die "switches have no interfaces"
IFNAME="$(api "/api/v1/devices/$SW1" | py "d['interfaces'][0]['ifname']")"
IFALIAS0="$(api "/api/v1/devices/$SW1" | py "d['interfaces'][0]['ifalias']")"
# 3(1). Roles and tokens.
echo "== (1) a viewer gets 403 on PUT"
[ "$(code "$URL/api/v1/devices")" = 401 ] && pass "no credentials: GET /api/v1/devices is 401" || fail "anonymous GET: $(cat "$TMP/last.body")"
[ "$(code "$URL/healthz")" = 200 ] && [ "$(code "$URL/auth/signedout")" = 200 ] && pass "/healthz and /auth/signedout need no credentials" || fail "public routes"
for pth in / /devices/x /assets/index.js /docs; do
c="$(code -D "$TMP/redir.headers" "$URL$pth")"
loc="$(tr -d '\r' <"$TMP/redir.headers" | awk 'tolower($1)=="location:" {print $2}')"
[ "$c" = 302 ] && case "$loc" in /auth/login?return=*) true;; *) false;; esac &&
pass "an unauthenticated page load of $pth is a 302 to $loc (no login page exists)" || fail "GET $pth without credentials: $c $loc"
done
[ "$(code -D "$TMP/redir.headers" "$URL/auth/breakglass")" = 401 ] && grep -qi '^www-authenticate: Basic realm="nmon break-glass"' "$TMP/redir.headers" &&
pass "GET /auth/breakglass without credentials is a Basic challenge (the browser's own prompt)" || fail "break-glass challenge"
for pth in /api/v1/me /metrics /openapi.json; do [ "$(code "$URL$pth")" = 401 ] || fail "GET $pth without credentials was not 401"; done
pass "API paths without credentials stay 401 problem+json (no redirect)"
EVIL="$(curl -s -L -c "$TMP/evil.jar" -b "$TMP/evil.jar" -o /dev/null -w '%{url_effective}' "$URL/auth/login?return=//evil.example/x")"
[ "$EVIL" = "$URL/" ] && pass "an off-site return target is rejected: the flow comes back to $EVIL" || fail "open redirect: ended at $EVIL"
VIEWER_JAR="$TMP/viewer.jar"; ADMINU_JAR="$TMP/adminuser.jar"
sso_login "$VIEWER_JAR" viewer-1 "Vera Viewer" "staff" && sso_login "$ADMINU_JAR" admin-1 "Adam Admin" "staff,nmon-admins" || die "OIDC sign-in"
[ "$(curl -fsS -b "$VIEWER_JAR" "$URL/api/v1/me" | py "d['role']")" = viewer ] && pass "an OIDC user outside the admin group is a viewer" || fail "viewer role"
[ "$(curl -fsS -b "$ADMINU_JAR" "$URL/api/v1/me" | py "d['role']")" = admin ] && pass "an OIDC user in the admin group is an admin" || fail "admin role"
[ "$(curl -s -o /dev/null -w '%{http_code}' -b "$VIEWER_JAR" "$URL/api/v1/devices")" = 200 ] && pass "the viewer can read" || fail "viewer read"
PUTBODY='{"site":"gate-site"}'
[ "$(as "$VIEWER_JAR" PUT "/api/v1/devices/$SW1" "$PUTBODY")" = 403 ] && pass "viewer session: PUT /api/v1/devices/$SW1 with a valid CSRF token is 403" || { cat "$TMP/last.body"; fail "viewer PUT was not 403"; }
[ "$(as "$ADMINU_JAR" PUT "/api/v1/devices/$SW1" "$PUTBODY")" = 200 ] && pass "admin-group user: the same PUT is 200" || { cat "$TMP/last.body"; fail "admin PUT"; }
[ "$(api "/api/v1/devices/$SW1" | py "d['site']")" = gate-site ] && pass "the admin's change is stored" || fail "site not changed"
[ "$(code -b "$ADMINU_JAR" -X PUT "$URL/api/v1/devices/$SW1" -H 'Content-Type: application/json' -d "$PUTBODY")" = 403 ] &&
pass "a cookie mutation without the CSRF token is 403 (even for an admin)" || { cat "$TMP/last.body"; fail "missing CSRF accepted"; }
[ "$(code -b "$ADMINU_JAR" -H "X-CSRF-Token: $(csrf_of "$VIEWER_JAR")" -X PUT "$URL/api/v1/devices/$SW1" -H 'Content-Type: application/json' -d "$PUTBODY")" = 403 ] &&
pass "another session's CSRF token is refused" || fail "foreign CSRF token accepted"
as "$ADMIN_JAR" POST /api/v1/tokens '{"name":"gate-viewer","scope":"viewer"}' >/dev/null; VTOK="$(py "d['secret']" <"$TMP/last.body")"; VTOK_ID="$(py "d['id']" <"$TMP/last.body")"
as "$ADMIN_JAR" POST /api/v1/tokens "{\"name\":\"gate-push\",\"scope\":\"push\",\"devices\":[\"$SW2\"]}" >/dev/null; PTOK="$(py "d['secret']" <"$TMP/last.body")"
[ "$(tok "$VTOK" GET /api/v1/devices)" = 200 ] && pass "viewer API token: GET /api/v1/devices is 200" || fail "viewer token read"
[ "$(tok "$VTOK" PUT "/api/v1/devices/$SW1" "$PUTBODY")" = 403 ] && pass "viewer API token: PUT is 403" || { cat "$TMP/last.body"; fail "viewer token PUT"; }
[ "$(tok "$VTOK" GET /api/v1/tokens)" = 403 ] && [ "$(tok "$VTOK" GET /api/v1/settings)" = 403 ] && pass "a viewer cannot list tokens or read settings" || fail "viewer read of admin-only GETs"
[ "$(tok "$PTOK" GET /api/v1/devices)" = 403 ] && pass "push token: GET /api/v1/devices is 403" || { cat "$TMP/last.body"; fail "push token on the API"; }
[ "$(code -X POST "$URL/write" -H "Authorization: Bearer $PTOK" --data-binary "gate,host=$SW2 v=1")" = 204 ] && pass "the push token still writes through ingest" || { cat "$TMP/last.body"; fail "push write"; }
[ "$(code -X POST "$URL/write" -H "Authorization: Bearer $VTOK" --data-binary "gate,host=$SW2 v=1")" != 204 ] && pass "a viewer API token cannot push" || fail "viewer token pushed"
[ "$(tok "$ADMIN_TOKEN" PUT "/api/v1/devices/$SW1" "$PUTBODY")" = 200 ] && pass "admin API token: PUT is 200" || fail "admin token PUT"
[ "$(tok "$ADMIN_TOKEN" DELETE "/api/v1/tokens/$VTOK_ID")" = 204 ] && pass "revoked the viewer token" || { cat "$TMP/last.body"; fail "revoke"; }
[ "$(tok "$VTOK" GET /api/v1/devices)" = 401 ] && pass "a revoked token is 401 on its very next request" || fail "revoked token still works"
api /api/v1/tokens >"$TMP/toks.json"
! grep -q "$ADMIN_TOKEN\|$PTOK" "$TMP/toks.json" && ! grep -q '\$' "$TMP/toks.json" && grep -q '"gate-push"' "$TMP/toks.json" && grep -q '"gate-admin"' "$TMP/toks.json" &&
pass "the token list shows push and API tokens without secrets or hashes" || { cat "$TMP/toks.json"; fail "token listing"; }
[ "$(code -H "Authorization: Bearer nma_9999_notatoken" "$URL/api/v1/devices")" = 401 ] && pass "a made-up API token is 401" || fail "bad token"
# 3(2). The fuzz test.
echo "== (2) viewer fuzz"
if go test -count=1 -run 'TestViewerCannotMutate|TestEveryRouteIsEitherPublic|TestViewerAndPush' ./internal/api/... >"$TMP/fuzz.log" 2>&1; then
pass "TestViewerCannotMutate: every state-changing route refuses a viewer session and a viewer token with 403"
else
tail -n 30 "$TMP/fuzz.log"; fail "viewer fuzz"
fi
# 3(5 part 1). Break-glass lock-out, unlock, audit and alerts.
echo "== (3) break-glass"
before="$(api "/api/v1/audit?per_page=1000" | py "d['total']")"
for i in 1 2 3 4; do # a different X-Forwarded-For each time: from a non-proxy it must change nothing
c="$(bg_login "$TMP/bad.jar" "wrong-$i" -H "X-Forwarded-For: 203.0.113.$i")"
[ "$c" = 401 ] || { fail "wrong password $i answered $c"; break; }
done
[ "$(bg_login "$TMP/bad.jar" "wrong-5" -H "X-Forwarded-For: 203.0.113.5")" = 429 ] && pass "the 5th wrong password locks the source address (429)" || fail "the 5th failure did not lock"
c="$(bg_login "$TMP/bad.jar" "$BG_PASSWORD" -H "X-Forwarded-For: 203.0.113.99")"
[ "$c" = 429 ] && pass "while locked even the RIGHT password is 429 (5 failures with 5 different X-Forwarded-For values counted against the real peer: the header from a non-proxy was ignored)" ||
{ cat "$TMP/last.body"; fail "no lock-out after 5 failures (HTTP $c)"; }
grep -q 'locked' "$TMP/last.body" && pass "the lock-out is a problem response naming the cause" || fail "lock-out body: $(cat "$TMP/last.body")"
[ "$(code -b "$ADMIN_JAR" "$URL/api/v1/me")" = 200 ] && pass "an existing session keeps working during the lock-out" || fail "existing session broken"
./nmon unlock all --data-dir "$DATA" >"$TMP/unlock.log" 2>&1 && pass "nmon unlock all (nmon running): $(cat "$TMP/unlock.log")" || { cat "$TMP/unlock.log"; fail "nmon unlock"; }
[ "$(bg_login "$TMP/again.jar" "$BG_PASSWORD")" = 302 ] && pass "after nmon unlock the break-glass login works again" || fail "still locked after unlock"
api "/api/v1/audit?per_page=1000" >"$TMP/audit.json"
for a in breakglass.login breakglass.unlock; do
py "[x['action'] for x in d['items']].count('$a')" <"$TMP/audit.json" | grep -qv '^0$' && pass "audit has $a rows" || fail "no $a audit row"
done
py "sum(1 for x in d['items'] if x['action'].startswith('breakglass.') and x['action'] not in ('breakglass.login','breakglass.unlock'))" <"$TMP/audit.json" |
grep -qv '^0$' && pass "failed attempts and the lock are audited" || fail "no failure audit rows"
py "d['total'] > $before" <"$TMP/audit.json" | grep -q True || fail "audit total did not grow"
have_alert() { api "/api/v1/alerts?per_page=1000" | py "'$1' in [x['rule'] for x in d['items']]" 2>/dev/null | grep -q True; }
wait_for 30 "the break-glass alert" have_alert breakglass_login && pass "every break-glass use is alerted (rule breakglass_login)" || { api "/api/v1/alerts?per_page=50"; fail "no breakglass_login alert"; }
wait_for 30 "the lock-out alert" have_alert breakglass_locked && pass "the lock-out is alerted (rule breakglass_locked)" || fail "no breakglass_locked alert"
if grep -q "$BG_PASSWORD" "$TMP/nmon.log"; then fail "the break-glass password is in the log"; else pass "the break-glass password is not in the log"; fi
# 3(7). CSV import.
echo "== (7) CSV import"
ndev() { api "/api/v1/devices?per_page=1000" | py "d['total']"; }
N0="$(ndev)"
cat >"$TMP/cycle.csv" <<CSV
name,address,site,role,parents
gate-imp-a,192.0.2.11,hq,core,gate-imp-c
gate-imp-b,192.0.2.12,hq,edge,gate-imp-a
gate-imp-c,192.0.2.13,hq,edge,gate-imp-b
CSV
C="$(code -b "$ADMIN_JAR" -H "X-CSRF-Token: $(csrf_of "$ADMIN_JAR")" -F "devices=@$TMP/cycle.csv;type=text/csv" "$URL/api/v1/devices/import")"
[ "$C" = 422 ] && grep -q 'cycle' "$TMP/last.body" && grep -q -- '->' "$TMP/last.body" && pass "an import with a cycle is 422 and names the path: $(py "d['detail']" <"$TMP/last.body")" ||
{ cat "$TMP/last.body"; fail "cycle import (HTTP $C)"; }
[ "$(ndev)" = "$N0" ] && pass "nothing was written by the rejected import" || fail "devices appeared after a rejected import"
printf 'name,address\n,192.0.2.50\n' >"$TMP/badrow.csv"
C="$(code -b "$ADMIN_JAR" -H "X-CSRF-Token: $(csrf_of "$ADMIN_JAR")" -F "devices=@$TMP/badrow.csv;type=text/csv" "$URL/api/v1/devices/import")"
[ "$C" = 422 ] && grep -q 'devices.csv row 2' "$TMP/last.body" && pass "a bad row is reported with its row number" || { cat "$TMP/last.body"; fail "bad row import"; }
cat >"$TMP/devices.csv" <<CSV
name,address,site,role,parents
gate-imp-a,192.0.2.11,hq,core,
gate-imp-b,192.0.2.12,hq,edge,gate-imp-a
CSV
printf 'device,ifname,alias\n%s,%s,Customer ACME Corp\n' "$SW1" "$IFNAME" >"$TMP/ports.csv"
C="$(code -b "$ADMIN_JAR" -H "X-CSRF-Token: $(csrf_of "$ADMIN_JAR")" -F "devices=@$TMP/devices.csv;type=text/csv" -F "ports=@$TMP/ports.csv;type=text/csv" "$URL/api/v1/devices/import")"
[ "$C" = 200 ] && [ "$(py "d['devices_created']" <"$TMP/last.body")" = 2 ] && [ "$(py "d['ports_updated']" <"$TMP/last.body")" = 1 ] &&
pass "a valid import creates 2 devices and sets 1 port description" || { cat "$TMP/last.body"; fail "valid import (HTTP $C)"; }
IMPA="$(slug_by_name gate-imp-a)"; IMPB="$(slug_by_name gate-imp-b)"
[ -n "$IMPA" ] && [ -n "$IMPB" ] && [ "$(api "/api/v1/devices/$IMPB" | py "[p['slug'] for p in d['parents']][0]")" = "$IMPA" ] &&
pass "imported devices exist and gate-imp-b's parent is gate-imp-a" || fail "imported devices"
desc() { api "/api/v1/devices/$SW1" | py "next(i['description'] for i in d['interfaces'] if i['ifname']=='$IFNAME')"; }
[ "$(desc)" = "Customer ACME Corp" ] && pass "the port description is on the device page" || fail "description: $(desc)"
[ "$(api "/api/v1/interfaces?q=acme" | py "len([i for i in d['items'] if i['description']=='Customer ACME Corp'])")" = 1 ] && pass "interface search (q=acme) finds it by description" || fail "search by description"
[ "$(api "/api/v1/devices/$SW1" | py "next(i['ifalias'] for i in d['interfaces'] if i['ifname']=='$IFNAME')")" = "$IFALIAS0" ] && pass "the discovered ifalias is untouched" || fail "ifalias changed"
[ "$(as "$ADMIN_JAR" POST "/api/v1/devices/$SW1/discover")" = 200 ] && pass "rediscovery of $SW1 ran" || { cat "$TMP/last.body"; fail "discover"; }
[ "$(desc)" = "Customer ACME Corp" ] && pass "the port description SURVIVES rediscovery (discovery rewrites ifalias, never description)" || fail "description lost after rediscovery: $(desc)"
[ "$(as "$VIEWER_JAR" POST /api/v1/devices/import)" = 403 ] && pass "a viewer cannot import" || fail "viewer import"
[ "$(as "$ADMIN_JAR" POST "/api/v1/devices/$SW1/purge")" = 409 ] && pass "purge of an active device is refused (409): $(py "d['detail']" <"$TMP/last.body")" || fail "purge of an active device"
# 3(6). Settings.
echo "== (6) settings"
api /api/v1/settings >"$TMP/settings0.json"; Y0="$(py "d['yaml']" <"$TMP/settings0.json")"
put_settings() { # put_settings <yaml text>: prints the status; the body is in $TMP/last.body
python3 -I -c 'import json,sys; print(json.dumps({"yaml": sys.argv[1]}))' "$1" >"$TMP/put.json"
code -b "$ADMIN_JAR" -H "X-CSRF-Token: $(csrf_of "$ADMIN_JAR")" -X PUT "$URL/api/v1/settings" -H 'Content-Type: application/json' -d @"$TMP/put.json"
}
C="$(put_settings "retention: {hot: 1h}")"
[ "$C" = 422 ] && grep -q 'retention.hot' "$TMP/last.body" && pass "an invalid settings document is 422: $(py "d['detail']" <"$TMP/last.body")" || { cat "$TMP/last.body"; fail "invalid settings (HTTP $C)"; }
[ "$(put_settings "nonsense_key: 1")" = 422 ] && pass "an unknown key is 422 too" || fail "unknown key accepted"
[ "$(api /api/v1/settings | py "d['yaml']")" = "$Y0" ] && pass "the running configuration is unchanged after invalid PUTs" || fail "configuration changed by an invalid PUT"
grep -q 'nonsense_key\|hot: 1h' "$DATA/config.yaml" && fail "an invalid document reached config.yaml" || pass "config.yaml on disk is unchanged"
Y1="$(printf '%s\n' "$Y0" | sed 's/hot: 30d/hot: 31d/')"
C="$(put_settings "$Y1
backup: {at: \"03:30\", keep: 3}
disk: {alert_used: 0.75}
")"
[ "$C" = 200 ] && py "'backup' in d['applied'] and 'disk' in d['applied']" <"$TMP/last.body" | grep -q True &&
pass "a valid PUT is applied in place: applied=$(py "d['applied']" <"$TMP/last.body")" || { cat "$TMP/last.body"; fail "valid settings (HTTP $C)"; }
py "'retention' in d['restart_required']" <"$TMP/last.body" | grep -q True && pass "a change that needs a restart (retention) is reported as restart_required" || fail "restart_required"
grep -q '03:30' "$DATA/config.yaml" && pass "config.yaml was rewritten" || fail "config.yaml not updated"
grep -q 'nightly backup scheduled.*03:30' "$TMP/nmon.log" && pass "the nightly backup schedule was reloaded without a restart" || fail "backup schedule not reloaded"
[ "$(as "$VIEWER_JAR" PUT /api/v1/settings '{"yaml":"timezone: UTC"}')" = 403 ] && pass "a viewer cannot PUT settings" || fail "viewer PUT settings"
cp "$DATA/config.yaml" "$TMP/config.good"
sed -i 's/keep: 3/keep: 4/' "$DATA/config.yaml"
kill -HUP "$NMON_PID"; wait_for 10 "SIGHUP reload" grep -q 'SIGHUP: config reloaded' "$TMP/nmon.log" && pass "SIGHUP reloads config.yaml from the file" || fail "no SIGHUP reload"
printf 'retention: {hot: 1h}\n' >"$DATA/config.yaml"
kill -HUP "$NMON_PID"; wait_for 10 "SIGHUP refusal" grep -q 'SIGHUP: config.yaml is invalid' "$TMP/nmon.log" && pass "SIGHUP with a broken file keeps the running configuration and logs why" || fail "SIGHUP with a broken file"
cp "$TMP/config.good" "$DATA/config.yaml"
[ "$(code "$URL/healthz")" = 200 ] && pass "nmon is still serving after the reload attempts" || fail "nmon down after reload"
[ "$(code -H "Authorization: Bearer $ADMIN_TOKEN" -X POST "$URL/api/v1/admin/stores/hot/restart")" = 409 ] &&
pass "POST /api/v1/admin/stores/hot/restart on a healthy store is 409: $(py "d['detail']" <"$TMP/last.body")" || { cat "$TMP/last.body"; fail "store restart route"; }
./nmon store restart hot --api "$URL" --token "$ADMIN_TOKEN" >"$TMP/storerestart.log" 2>&1
grep -q 'has not failed' "$TMP/storerestart.log" && pass "nmon store restart reaches the supervisor and says the store has not failed" || { cat "$TMP/storerestart.log"; fail "nmon store restart"; }
# 3(4). Backup during polling, restore, start on the restored directory.
echo "== (4) backup and restore"
have_samples() { [ "$(vm_value "$HOT" 'count(ifHCInOctets)')" != "" ]; }
wait_for 60 "polled samples in the hot store" have_samples || fail "no ifHCInOctets samples before the backup"
sleep 95 # the backup's consistent boundary sits 90 s in the past: let polling write samples older than that
./nmon backup "$TMP/nokey.tar" --api "$URL" --token "$ADMIN_TOKEN" >"$TMP/backup1.log" 2>&1 && grep -q 'NOT included' "$TMP/backup1.log" &&
pass "nmon backup (default) prints that the key was NOT included" || { cat "$TMP/backup1.log"; fail "backup without key"; }
./nmon backup "$TMP/withkey.tar" --include-key --api "$URL" --token "$ADMIN_TOKEN" >"$TMP/backup2.log" 2>&1 && grep -q 'INCLUDED' "$TMP/backup2.log" &&
pass "nmon backup --include-key prints that the key was INCLUDED" || { cat "$TMP/backup2.log"; fail "backup with key"; }
tar tf "$TMP/nokey.tar" | grep -q '^secret.key$' && fail "the no-key archive contains the key" || pass "the default archive holds no key file"
tar tf "$TMP/withkey.tar" | grep -q '^secret.key$' && pass "the --include-key archive holds the key file" || fail "no key in the --include-key archive"
[ "$(as "$VIEWER_JAR" POST /api/v1/admin/backup)" = 403 ] && pass "a viewer cannot take a backup" || fail "backup route authorisation"
NDEV_BEFORE="$(ndev)"; DEVICES_BEFORE="$(api "/api/v1/devices?per_page=1000" | py "sorted(i['slug'] for i in d['items'])")"
stop_nmon
./nmon restore "$TMP/withkey.tar" --data-dir "$TMP/restored" --secret-key-file "$TMP/restored.key" >"$TMP/restore.log" 2>&1 &&
grep -q 'done and verified' "$TMP/restore.log" && pass "nmon restore into a clean dir: $(grep 'done and verified' "$TMP/restore.log")" || { cat "$TMP/restore.log"; die "restore"; }
[ -s "$TMP/restored.key" ] && pass "the restored key was written outside the data dir" || fail "restored key missing"
if ./nmon restore "$TMP/withkey.tar" --data-dir "$TMP/restored" --secret-key-file "$TMP/restored2.key" >"$TMP/restore2.log" 2>&1; then fail "restore into a non-empty dir succeeded"; else
pass "restore refuses a data dir that is not clean"; fi
PORT2="$(pick_port)"; URL="http://127.0.0.1:$PORT2"
sed -i "s#redirect_url: .*#redirect_url: $URL/auth/callback#" "$TMP/restored/config.yaml"
start_nmon "$TMP/restored" "$TMP/restored.key" "$PORT2" "$TMP/nmon-restored.log" --insecure-http
wait_for 90 "nmon on the restored dir" healthy || { tail -n 20 "$TMP/nmon-restored.log"; die "nmon did not start on the restored dir"; }
HOT="$(grep -o 'hot=http://[^ ]*' "$TMP/nmon-restored.log" | head -n 1 | cut -d= -f2)"
[ "$(ndev)" = "$NDEV_BEFORE" ] && [ "$(api "/api/v1/devices?per_page=1000" | py "sorted(i['slug'] for i in d['items'])")" = "$DEVICES_BEFORE" ] &&
pass "the restored nmon has the same $NDEV_BEFORE devices (and the admin API token still works)" || fail "devices differ after restore"
[ "$(desc)" = "Customer ACME Corp" ] && pass "the port description came back with the inventory" || fail "description lost in restore"
SAMPLES="$(vm_value "$HOT" 'count(ifHCInOctets{device="'"$SW1"'"})')"
[ -n "$SAMPLES" ] && [ "${SAMPLES%.*}" -ge 1 ] && pass "a sample series is present in the restored hot store (ifHCInOctets for $SW1: $SAMPLES series)" || fail "no ifHCInOctets series after restore"
OLDEST="$(curl -fsS -G "$HOT/api/v1/query" --data-urlencode "query=count_over_time(ifHCInOctets{device=\"$SW1\"}[2h])" | py "max([float(r['value'][1]) for r in d['data']['result']] or [0])")"
python3 -I -c "import sys; sys.exit(0 if float('$OLDEST') >= 5 else 1)" && pass "history was restored, not just new samples ($OLDEST samples in the last 2 h)" || fail "restored store has too few samples ($OLDEST)"
sso_login "$TMP/restored.jar" admin-1 "Adam Admin" "nmon-admins" && [ "$(curl -fsS -b "$TMP/restored.jar" "$URL/api/v1/me" | py "d['role']")" = admin ] &&
pass "OIDC sign-in works on the restored instance (the client secret was restored and decrypts)" || fail "OIDC on the restored instance"
stop_nmon
# 3(5). Key rotation on a copy, started over HTTPS.
echo "== (5) key rotate"
cp -r "$TMP/restored" "$TMP/rotated"; cp "$TMP/restored.key" "$TMP/rotated.key"
./nmon key rotate --data-dir "$TMP/rotated" --secret-key-file "$TMP/rotated.key" >"$TMP/rotate.log" 2>&1 && grep -q 're-encrypted' "$TMP/rotate.log" &&
pass "nmon key rotate: $(cat "$TMP/rotate.log")" || { cat "$TMP/rotate.log"; die "key rotate"; }
[ "$(wc -l <"$TMP/rotated.key")" -gt "$(wc -l <"$TMP/restored.key")" ] && ! cmp -s "$TMP/rotated.key" "$TMP/restored.key" &&
pass "the key file now holds the new key as well as the old one" || fail "key file unchanged"
PORT3="$(pick_port)"; URL="https://127.0.0.1:$PORT3"
sed -i "s#redirect_url: .*#redirect_url: $URL/auth/callback#" "$TMP/rotated/config.yaml"
start_nmon "$TMP/rotated" "$TMP/rotated.key" "$PORT3" "$TMP/nmon-rotated.log" --tls-cert "$TMP/tls.crt" --tls-key "$TMP/tls.key"
healthy_tls() { curl -fsS --cacert "$TMP/tls.crt" "$URL/healthz" >/dev/null 2>&1; }
wait_for 90 "nmon over HTTPS" healthy_tls || { tail -n 20 "$TMP/nmon-rotated.log"; die "nmon did not start over HTTPS on the rotated dir"; }
pass "nmon serves HTTPS with the provided certificate on the rotated data dir"
curl() { command curl --cacert "$TMP/tls.crt" "$@"; } # from here on talk TLS and verify the certificate
[ "$(command curl -s -o /dev/null -w '%{http_code}' "$URL/healthz")" = 000 ] && pass "an HTTPS endpoint without the CA is refused by a verifying client" || fail "certificate not verified"
bg_login "$TMP/rot.jar" "$BG_PASSWORD" -D "$TMP/rot.headers" | grep -q 302 && grep -qi '^set-cookie: nmon_session=.*; Secure' "$TMP/rot.headers" &&
pass "over HTTPS the session cookie is Secure" || { cat "$TMP/rot.headers"; fail "cookie not Secure over HTTPS"; }
ADMIN_TOKEN_OK="$(api "/api/v1/devices?per_page=1000" | py "d['total']")"
[ "$ADMIN_TOKEN_OK" = "$NDEV_BEFORE" ] && pass "the rotated instance serves the same devices" || fail "devices after rotation: $ADMIN_TOKEN_OK"
polled() { api /api/v1/poller/health | py "sum(1 for x in d['devices'] if x['slug'] in ('$SW1','$SW2') and x['state']=='ok')" 2>/dev/null | grep -qx 2; }
wait_for 90 "both switches to poll ok with the rotated key" polled && pass "nmon polls both switches with the rotated key (SNMP credentials still decrypt)" ||
{ api /api/v1/poller/health | head -c 600; fail "polling after rotation"; }
sso_login "$TMP/rot-sso.jar" admin-1 "Adam Admin" "nmon-admins" 2>/dev/null
[ "$(curl -fsS -b "$TMP/rot-sso.jar" "$URL/api/v1/me" 2>/dev/null | py "d['role']" 2>/dev/null)" = admin ] && pass "OIDC still works after the rotation (the client secret was re-sealed)" || fail "OIDC after rotation"
unset -f curl
stop_nmon
# Back to the first data dir (plain HTTP) for the remaining checks and the UI.
URL="http://127.0.0.1:$PORT"
start_nmon "$DATA" "$KEY" "$PORT" "$TMP/nmon2.log" --insecure-http
wait_for 90 "nmon to serve again" healthy || { tail -n 20 "$TMP/nmon2.log"; die "nmon did not restart"; }
HOT="$(grep -o 'hot=http://[^ ]*' "$TMP/nmon2.log" | head -n 1 | cut -d= -f2)"
# 3(8). OpenAPI, the web client, UI specs, screenshots.
echo "== openapi"
curl -fsS -H "Authorization: Bearer $ADMIN_TOKEN" "$URL/openapi.json" >"$TMP/openapi.live.json" || die "GET /openapi.json"
OPENAPI_BASE_TAG=m3 OPENAPI_NEW="$TMP/openapi.live.json" ./scripts/openapi-check.sh && pass "openapi-check against m3: additive only" || fail "openapi-check"
if ./nmon openapi >"$TMP/openapi.cli.json" 2>/dev/null; then
cmp -s "$TMP/openapi.cli.json" openapi.json && pass "openapi.json matches nmon openapi" || fail "openapi.json is stale: run ./nmon openapi > openapi.json"
fi
echo "== contract drift"
cp web/src/api/schema.d.ts "$TMP/schema.before.d.ts"
if (cd web && npm run gen:api -- "$TMP/openapi.live.json" >"$TMP/genapi.log" 2>&1 && npm run build >"$TMP/webbuild.log" 2>&1); then
pass "web client generated from the live spec type-checks and builds"
else
tail -n 30 "$TMP/genapi.log" "$TMP/webbuild.log" 2>/dev/null; fail "web build against the live spec"
fi
cmp -s web/src/api/schema.d.ts "$TMP/schema.before.d.ts" && pass "web/src/api/schema.d.ts is current" || fail "web/src/api/schema.d.ts changed after regeneration from the live spec; commit it"
(cd web && npm run lint >"$TMP/weblint.log" 2>&1) && pass "web lint after regeneration" || { tail -n 20 "$TMP/weblint.log"; fail "web lint"; }
echo "== playwright (mock)"
if (cd web && npm run e2e) >"$TMP/e2e-mock.log" 2>&1; then pass "mock UI specs (incl. docs/screenshots/M6-mock)"; else tail -n 40 "$TMP/e2e-mock.log"; fail "mock UI specs"; fi
echo "== screenshots (live)"
rm -rf "$SHOTS"; mkdir -p "$SHOTS"
printf 'name,address,parents\nshot-a,192.0.2.71,shot-b\nshot-b,192.0.2.72,shot-a\nshot-c,,\n' >"$TMP/shot-devices.csv"
export NMON_URL="$URL" NMON_DEVICE="$SW1" NMON_BG_USER=breakglass NMON_BG_PASS="$BG_PASSWORD" NMON_OIDC_CONTROL="$CTL_URL" NMON_IMPORT_BAD_CSV="$TMP/shot-devices.csv"
if (cd web && NMON_SHOT_DIR="$SHOTS" npm run e2e:live -- screenshots-m6.spec.ts) >"$TMP/shots.log" 2>&1; then
N="$(ls "$SHOTS"/*.png 2>/dev/null | wc -l)"
[ "$N" -ge 4 ] && pass "$N screenshots in $SHOTS" || fail "only $N screenshots in $SHOTS"
ls "$SHOTS"
else
tail -n 40 "$TMP/shots.log"; fail "screenshots"
fi
[ "$(ndev)" = "$((NDEV_BEFORE))" ] && pass "the screenshot run wrote no data (the bad CSV was rejected)" || fail "device count changed during the screenshots"
if [ "$FAILS" -eq 0 ]; then echo "GATE M6: PASS"; else echo "GATE M6: FAIL ($FAILS)"; exit 1; fi