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:
1 parent
972ad47d0c
commit
debf2afed2
67 files changed
+2050
-105
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user