Add authentication: admin/agent bearer tokens for the API, login for the dashboard

control-api: every route now carries a mandatory access level (admin / agent /
open) in a route table. All /api/v1/admin/* require the admin token; the
write calls of validator-agent and prober (self-check, events, results,
complete) require a separate static agent token; register, heartbeat and
fetching the assignment stay open. Tokens come from env vars, are compared in
constant time and never logged. An empty token leaves that level open with a
startup warning (backward compatible).

validator-agent / prober: apiclient sends the agent token only to control-api.

admin-dashboard: login/password (from env) with a stateless HMAC session
cookie, Origin-based CSRF check, per-IP brute-force throttle, HX-Redirect for
htmx polls, logout in the sidebar; the dashboard calls control-api with the
admin token. Login page layout fixed after review.

Also: env plumbing in docker-compose/rxprod-compose/systemd/config examples,
e2e script with token assertions, tests, docs (API, SETUP, USAGE, DASHBOARD,
README), plan and review under docs/changes/, bin/ rebuilt with new
SHA256SUMS.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ayurishchevandClaude Sonnet 5.5 committed 2026-10-01 11:35:24 +03:00
1 parent 972ad47d0c
commit debf2afed2
67 files changed
+2050 -105

No files matched your search

+42 -9
View File
@@ -21,6 +21,14 @@ BIN_DIR="$WORK_DIR/bin"
LOG_DIR="$WORK_DIR/logs"
mkdir -p "$BIN_DIR" "$LOG_DIR"
# Static bearer tokens (fixed, test-only values). control-api enforces both;
# validator-agent and prober pick the agent token up from the same variable
# (the default name of their control_api_token_env); admin curls below send the
# admin token explicitly.
export CONTROL_API_ADMIN_TOKEN="e2e-admin-token-0123456789abcdef"
export CONTROL_API_AGENT_TOKEN="e2e-agent-token-fedcba9876543210"
ADMIN_AUTH=(-H "Authorization: Bearer $CONTROL_API_ADMIN_TOKEN")
PIDS=()
cleanup() {
echo "--- cleaning up (workdir: $WORK_DIR) ---"
@@ -148,6 +156,31 @@ for i in $(seq 1 30); do
fi
done
echo "--- authentication checks ---"
BASE="http://127.0.0.1:28080"
http_code() { curl -s -o /dev/null -w '%{http_code}' "$@"; }
expect_code() { # expect_code <want> <description> <curl args...>
local want="$1" desc="$2" got
shift 2
got="$(http_code "$@")"
if [ "$got" != "$want" ]; then
echo "FAIL: $desc: expected HTTP $want, got $got"
exit 1
fi
echo "ok: $desc -> $got"
}
expect_code 401 "admin endpoint without token" "$BASE/api/v1/admin/status"
expect_code 200 "admin endpoint with admin token" "${ADMIN_AUTH[@]}" "$BASE/api/v1/admin/status"
expect_code 401 "agent results without token" -X POST -H 'Content-Type: application/json' -d '{}' "$BASE/api/v1/agents/validator_01/results"
expect_code 401 "agent results with the ADMIN token" -X POST "${ADMIN_AUTH[@]}" -H 'Content-Type: application/json' -d '{}' "$BASE/api/v1/agents/validator_01/results"
# Open route: whatever the status (200/204/404 depending on state), it must not be 401.
assignment_code="$(http_code "$BASE/api/v1/agents/validator_01/assignment")"
if [ "$assignment_code" = "401" ]; then
echo "FAIL: GET /agents/validator_01/assignment must be open (no token), got 401"
exit 1
fi
echo "ok: assignment without token is not rejected -> $assignment_code"
start_validator_agent() {
"$BIN_DIR/validator-agent" -config "$WORK_DIR/validator-agent.yaml" -stub-ports "22022,28081,28443,28888" \
>>"$LOG_DIR/validator-agent.log" 2>&1 & VALIDATOR_PID=$!
@@ -155,8 +188,8 @@ start_validator_agent() {
echo "validator-agent started (pid $VALIDATOR_PID)"
}
status() { curl -fs "http://127.0.0.1:28080/api/v1/admin/status"; }
ips() { curl -fs "http://127.0.0.1:28080/api/v1/admin/ips"; }
status() { curl -fs "${ADMIN_AUTH[@]}" "http://127.0.0.1:28080/api/v1/admin/status"; }
ips() { curl -fs "${ADMIN_AUTH[@]}" "http://127.0.0.1:28080/api/v1/admin/ips"; }
ip_state() { ips | python3 -c "import json,sys; d=json.load(sys.stdin); print(d[0]['State'] if d else 'none')" 2>/dev/null || echo "?"; }
echo "--- starting validator-agent ---"
@@ -211,7 +244,7 @@ echo "--- final ip results ---"
ips | python3 -m json.tool
echo "--- demonstrating admin API: force a re-check of the already-done address ---"
curl -fs -X POST "http://127.0.0.1:28080/api/v1/admin/ips" \
curl -fs "${ADMIN_AUTH[@]}" -X POST "http://127.0.0.1:28080/api/v1/admin/ips" \
-H 'Content-Type: application/json' -d '{"addresses":["127.0.0.1"]}' | python3 -m json.tool
echo "--- waiting for the forced re-check to drain (up to 30s) ---"
@@ -229,7 +262,7 @@ ips | python3 -m json.tool
echo "--- automatic cycle: enable it and wait for the first cycle (clear queue -> scan FIPs -> checks -> registry) ---"
AC_URL="http://127.0.0.1:28080/api/v1/admin/auto-cycle"
registry_cycles() {
curl -fs "http://127.0.0.1:28080/api/v1/admin/registry" | python3 -c "
curl -fs "${ADMIN_AUTH[@]}" "http://127.0.0.1:28080/api/v1/admin/registry" | python3 -c "
import json,sys
for r in json.load(sys.stdin):
if r['ip_address'] == '127.0.0.1':
@@ -238,14 +271,14 @@ for r in json.load(sys.stdin):
else:
print(0)"
}
ac_field() { curl -fs "$AC_URL" | python3 -c "import json,sys; print(json.load(sys.stdin)['$1'])"; }
ac_field() { curl -fs "${ADMIN_AUTH[@]}" "$AC_URL" | python3 -c "import json,sys; print(json.load(sys.stdin)['$1'])"; }
CYCLES_BEFORE="$(registry_cycles)"
# 60s is the smallest interval control-api accepts; the second cycle is
# covered by unit tests, so the script stops the auto-cycle after the first.
curl -fs -X PUT "$AC_URL" -H 'Content-Type: application/json' \
curl -fs "${ADMIN_AUTH[@]}" -X PUT "$AC_URL" -H 'Content-Type: application/json' \
-d '{"interval_seconds":60,"max_run_seconds":120}' >/dev/null
curl -fs -X POST "$AC_URL/start" | python3 -m json.tool
curl -fs "${ADMIN_AUTH[@]}" -X POST "$AC_URL/start" | python3 -m json.tool
echo "--- waiting for the first auto cycle to complete (up to 90s) ---"
AC_OUTCOME=""
@@ -259,7 +292,7 @@ for i in $(seq 1 180); do
done
echo "--- auto-cycle status ---"
curl -fs "$AC_URL" | python3 -m json.tool
curl -fs "${ADMIN_AUTH[@]}" "$AC_URL" | python3 -m json.tool
CYCLES_AFTER="$(registry_cycles)"
echo "registry total_cycles for 127.0.0.1: $CYCLES_BEFORE -> $CYCLES_AFTER"
@@ -277,7 +310,7 @@ if [ "$CYCLES_AFTER" -le "$CYCLES_BEFORE" ]; then
fi
echo "--- automatic cycle: disable it; no further cycles must start ---"
curl -fs -X POST "$AC_URL/stop" | python3 -m json.tool
curl -fs "${ADMIN_AUTH[@]}" -X POST "$AC_URL/stop" | python3 -m json.tool
if [ "$(ac_field enabled)" != "False" ] || [ "$(ac_field phase)" != "idle" ]; then
echo "FAIL: expected enabled=false and phase=idle after stop"
exit 1