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

+108
View File
@@ -0,0 +1,108 @@
package httpapi
import (
"crypto/sha256"
"crypto/subtle"
"log/slog"
"net/http"
"strings"
)
// accessLevel says which credential a route requires.
type accessLevel int
const (
// accessOpen: no credential required (registration, heartbeat, fetching
// the assignment, /healthz).
accessOpen accessLevel = iota + 1
// accessAgent: requires the static agent token (validator-agents and
// probers writing results/events).
accessAgent
// accessAdmin: requires the administrator token (/api/v1/admin/*).
accessAdmin
)
func (a accessLevel) String() string {
switch a {
case accessOpen:
return "open"
case accessAgent:
return "agent"
case accessAdmin:
return "admin"
}
return "invalid"
}
// Authenticator holds the static bearer tokens. An empty token disables the
// check for that level (backward compatibility: the API starts open with a
// warning, see cmd/control-api).
type Authenticator struct {
AdminToken string
AgentToken string
Log *slog.Logger
}
// WithAuth sets the admin and agent tokens. Call it before Handler().
func (s *Server) WithAuth(admin, agent string) *Server {
s.Auth = Authenticator{AdminToken: admin, AgentToken: agent}
return s
}
func (a Authenticator) tokenFor(level accessLevel) string {
switch level {
case accessAdmin:
return a.AdminToken
case accessAgent:
return a.AgentToken
}
return ""
}
// require wraps h so that it is only reached with the credential demanded by
// level. Open routes, and levels with an empty configured token, pass through.
func (a Authenticator) require(level accessLevel, h http.HandlerFunc) http.HandlerFunc {
if level == accessOpen {
return h
}
if level != accessAdmin && level != accessAgent {
// An unclassified route must never be served.
panic("httpapi: route registered without a valid access level")
}
return func(w http.ResponseWriter, r *http.Request) {
want := a.tokenFor(level)
if want == "" {
h(w, r)
return
}
got, ok := bearerToken(r)
if !ok || !tokensEqual(got, want) {
if a.Log != nil {
a.Log.Warn("unauthorized request", "level", level.String(),
"method", r.Method, "path", r.URL.Path, "remote", r.RemoteAddr)
}
w.Header().Set("WWW-Authenticate", "Bearer")
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
h(w, r)
}
}
// bearerToken extracts the token from "Authorization: Bearer <token>".
func bearerToken(r *http.Request) (string, bool) {
h := r.Header.Get("Authorization")
const prefix = "bearer "
if len(h) <= len(prefix) || !strings.EqualFold(h[:len(prefix)], prefix) {
return "", false
}
tok := strings.TrimSpace(h[len(prefix):])
return tok, tok != ""
}
// tokensEqual compares in constant time; hashing first hides the length.
func tokensEqual(got, want string) bool {
g := sha256.Sum256([]byte(got))
w := sha256.Sum256([]byte(want))
return subtle.ConstantTimeCompare(g[:], w[:]) == 1
}
+181
View File
@@ -0,0 +1,181 @@
package httpapi
import (
"context"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"cloudipvalidator/internal/config"
"cloudipvalidator/internal/db"
"cloudipvalidator/internal/openstack"
"cloudipvalidator/internal/orchestrator"
)
const (
testAdminToken = "admin-token-for-tests"
testAgentToken = "agent-token-for-tests"
)
func newAuthTestServer(t *testing.T, admin, agent string) (*Server, *httptest.Server) {
t.Helper()
ctx := context.Background()
d, err := db.Open(ctx, filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { d.Close() })
cfg := &config.ControlAPI{
Orchestrator: config.OrchestratorConfig{
PollIntervalSeconds: 1, SelfCheckTimeoutSeconds: 10, MaxSelfCheckRetries: 3,
CheckingWindowSeconds: 120, MaxRetries: 3, LeaseTTLSeconds: 180, HeartbeatTimeoutSeconds: 30,
},
Inbound: config.InboundConfig{Ports: []int{22}, ICMP: true},
}
if err := d.BootstrapFromConfig(ctx, cfg); err != nil {
t.Fatalf("bootstrap: %v", err)
}
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv := New(d, orchestrator.New(d, openstack.NewMockClient(), cfg, log), log).WithAuth(admin, agent)
ts := httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
return srv, ts
}
// concretePath fills the {wildcards} of a ServeMux pattern with dummy values.
func concretePath(pattern string) (method, path string) {
method, path, _ = strings.Cut(pattern, " ")
var b strings.Builder
for {
i := strings.IndexByte(path, '{')
if i < 0 {
b.WriteString(path)
break
}
j := strings.IndexByte(path, '}')
b.WriteString(path[:i])
b.WriteString("1")
path = path[j+1:]
}
return method, b.String()
}
func callWithToken(t *testing.T, ts *httptest.Server, pattern, token string) *http.Response {
t.Helper()
method, path := concretePath(pattern)
req, err := http.NewRequest(method, ts.URL+path, strings.NewReader("{}"))
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := ts.Client().Do(req)
if err != nil {
t.Fatalf("%s: %v", pattern, err)
}
resp.Body.Close()
return resp
}
func TestRouteTableIsClassified(t *testing.T) {
srv, _ := newAuthTestServer(t, "", "")
counts := map[string]int{}
for _, rt := range srv.Routes() {
counts[rt.Access]++
if rt.Access == "invalid" {
t.Fatalf("route %q has no valid access level", rt.Pattern)
}
if strings.Contains(rt.Pattern, "/api/v1/admin/") && rt.Access != "admin" {
t.Fatalf("admin route %q is %s, want admin", rt.Pattern, rt.Access)
}
}
if counts["admin"] != 33 || counts["agent"] != 5 || counts["open"] != 7 {
t.Fatalf("access counts = %v, want admin=33 agent=5 open=7", counts)
}
}
func TestAuthMatrixOverRouteTable(t *testing.T) {
srv, ts := newAuthTestServer(t, testAdminToken, testAgentToken)
for _, rt := range srv.Routes() {
rt := rt
t.Run(rt.Pattern, func(t *testing.T) {
tokens := []struct {
name, token string
allowed bool
}{
{"none", "", rt.Access == "open"},
{"wrong", "definitely-wrong", rt.Access == "open"},
{"admin token", testAdminToken, rt.Access == "open" || rt.Access == "admin"},
{"agent token", testAgentToken, rt.Access == "open" || rt.Access == "agent"},
}
for _, tc := range tokens {
resp := callWithToken(t, ts, rt.Pattern, tc.token)
if tc.allowed && resp.StatusCode == http.StatusUnauthorized {
t.Fatalf("%s: got 401, want request to pass auth", tc.name)
}
if !tc.allowed {
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("%s: status=%d, want 401", tc.name, resp.StatusCode)
}
if resp.Header.Get("WWW-Authenticate") != "Bearer" {
t.Fatalf("%s: missing WWW-Authenticate: Bearer", tc.name)
}
}
}
})
}
}
func TestEmptyTokensLeaveEverythingOpen(t *testing.T) {
srv, ts := newAuthTestServer(t, "", "")
for _, rt := range srv.Routes() {
if resp := callWithToken(t, ts, rt.Pattern, ""); resp.StatusCode == http.StatusUnauthorized {
t.Fatalf("%s: got 401 with no tokens configured", rt.Pattern)
}
}
}
func TestOnlyConfiguredLevelIsEnforced(t *testing.T) {
_, ts := newAuthTestServer(t, testAdminToken, "")
if resp := callWithToken(t, ts, "GET /api/v1/admin/status", ""); resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("admin without token: status=%d, want 401", resp.StatusCode)
}
if resp := callWithToken(t, ts, "POST /api/v1/agents/{id}/results", ""); resp.StatusCode == http.StatusUnauthorized {
t.Fatalf("agent route must stay open while agent token is unset")
}
}
func TestHealthzAlwaysOpenAndBearerParsing(t *testing.T) {
_, ts := newAuthTestServer(t, testAdminToken, testAgentToken)
if resp := callWithToken(t, ts, "GET /healthz", ""); resp.StatusCode != http.StatusOK {
t.Fatalf("healthz: status=%d, want 200", resp.StatusCode)
}
// Raw token without the Bearer scheme must not authenticate.
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/api/v1/admin/status", nil)
req.Header.Set("Authorization", testAdminToken)
resp, err := ts.Client().Do(req)
if err != nil {
t.Fatalf("request: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("scheme-less token: status=%d, want 401", resp.StatusCode)
}
// Lowercase scheme is accepted (RFC 7235: case-insensitive).
req, _ = http.NewRequest(http.MethodGet, ts.URL+"/api/v1/admin/status", nil)
req.Header.Set("Authorization", "bearer "+testAdminToken)
resp, err = ts.Client().Do(req)
if err != nil {
t.Fatalf("request: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("lowercase bearer: status=%d, want 200", resp.StatusCode)
}
}
+75 -53
View File
@@ -2,61 +2,83 @@ package httpapi
import "net/http"
func (s *Server) routes(mux *http.ServeMux) {
mux.HandleFunc("GET /healthz", s.handleHealthz)
// route is one entry of the Control API route table. The access level is
// mandatory: every route must state who may call it, so a new endpoint cannot
// be exposed by accident. The same table is used by the tests.
type route struct {
Pattern string
Handler http.HandlerFunc
Access accessLevel
}
mux.HandleFunc("POST /api/v1/agents/register", s.handleAgentRegister)
mux.HandleFunc("POST /api/v1/agents/{id}/heartbeat", s.handleAgentHeartbeat)
mux.HandleFunc("GET /api/v1/agents/{id}/assignment", s.handleAgentAssignment)
mux.HandleFunc("POST /api/v1/agents/{id}/self-check", s.handleAgentSelfCheck)
mux.HandleFunc("POST /api/v1/agents/{id}/events", s.handleAgentEvent)
mux.HandleFunc("POST /api/v1/agents/{id}/results", s.handleAgentResults)
mux.HandleFunc("POST /api/v1/agents/{id}/complete", s.handleAgentComplete)
// RouteInfo is the exported, handler-free view of a route table entry.
type RouteInfo struct {
Pattern string
Access string // "open", "agent" or "admin"
}
mux.HandleFunc("POST /api/v1/probers/register", s.handleProberRegister)
mux.HandleFunc("POST /api/v1/probers/{site_id}/heartbeat", s.handleProberHeartbeat)
mux.HandleFunc("GET /api/v1/probers/{site_id}/assignments", s.handleProberAssignments)
mux.HandleFunc("POST /api/v1/probers/{site_id}/results", s.handleProberResults)
// Routes returns the access classification of every registered route.
func (s *Server) Routes() []RouteInfo {
table := s.routeTable()
out := make([]RouteInfo, 0, len(table))
for _, rt := range table {
out = append(out, RouteInfo{Pattern: rt.Pattern, Access: rt.Access.String()})
}
return out
}
mux.HandleFunc("GET /api/v1/admin/status", s.handleAdminStatus)
mux.HandleFunc("GET /api/v1/admin/ips", s.handleAdminIPs)
mux.HandleFunc("POST /api/v1/admin/ips", s.handleAdminSubmitIPs)
mux.HandleFunc("POST /api/v1/admin/ips/scan", s.handleAdminScanFloatingIPs)
mux.HandleFunc("GET /api/v1/admin/ips/{ip}", s.handleAdminIPDetail)
mux.HandleFunc("POST /api/v1/admin/ips/{ip}/cancel", s.handleAdminCancelIP)
mux.HandleFunc("DELETE /api/v1/admin/ips/{ip}", s.handleAdminDeleteIP)
mux.HandleFunc("POST /api/v1/admin/ips/delete", s.handleAdminDeleteIPs)
mux.HandleFunc("POST /api/v1/admin/ips/clear", s.handleAdminClearQueue)
mux.HandleFunc("GET /api/v1/admin/validators", s.handleAdminValidators)
mux.HandleFunc("GET /api/v1/admin/auto-cycle", s.handleAdminGetAutoCycle)
mux.HandleFunc("PUT /api/v1/admin/auto-cycle", s.handleAdminPutAutoCycle)
mux.HandleFunc("POST /api/v1/admin/auto-cycle/start", s.handleAdminStartAutoCycle)
mux.HandleFunc("POST /api/v1/admin/auto-cycle/stop", s.handleAdminStopAutoCycle)
mux.HandleFunc("GET /api/v1/admin/registry", s.handleAdminRegistry)
mux.HandleFunc("GET /api/v1/admin/registry/{ip}", s.handleAdminRegistryHistory)
mux.HandleFunc("GET /api/v1/admin/config/validators", s.handleConfigListValidators)
mux.HandleFunc("POST /api/v1/admin/config/validators", s.handleConfigCreateValidator)
mux.HandleFunc("PUT /api/v1/admin/config/validators/{id}", s.handleConfigUpdateValidator)
mux.HandleFunc("DELETE /api/v1/admin/config/validators/{id}", s.handleConfigDeleteValidator)
mux.HandleFunc("GET /api/v1/admin/config/sites", s.handleConfigListSites)
mux.HandleFunc("PUT /api/v1/admin/config/sites/{index}", s.handleConfigPutSite)
mux.HandleFunc("DELETE /api/v1/admin/config/sites/{index}", s.handleConfigDeleteSite)
mux.HandleFunc("GET /api/v1/admin/config/targets", s.handleConfigListTargets)
mux.HandleFunc("PUT /api/v1/admin/config/targets/{group}", s.handleConfigPutTargetGroup)
mux.HandleFunc("DELETE /api/v1/admin/config/targets/{group}", s.handleConfigDeleteTargetGroup)
mux.HandleFunc("GET /api/v1/admin/config/check-types", s.handleConfigListCheckTypes)
mux.HandleFunc("PUT /api/v1/admin/config/check-types/{name}", s.handleConfigPutCheckType)
mux.HandleFunc("DELETE /api/v1/admin/config/check-types/{name}", s.handleConfigDeleteCheckType)
mux.HandleFunc("GET /api/v1/admin/config/orchestrator", s.handleConfigGetOrchestratorSettings)
mux.HandleFunc("PUT /api/v1/admin/config/orchestrator", s.handleConfigPutOrchestratorSettings)
func (s *Server) routes(mux *http.ServeMux) {
for _, rt := range s.routeTable() {
mux.HandleFunc(rt.Pattern, s.Auth.require(rt.Access, rt.Handler))
}
}
mux.HandleFunc("GET /api/v1/admin/config/inbound-checks", s.handleConfigGetInboundChecks)
mux.HandleFunc("PUT /api/v1/admin/config/inbound-checks", s.handleConfigPutInboundChecks)
func (s *Server) routeTable() []route {
return []route{
{"GET /healthz", s.handleHealthz, accessOpen},
{"POST /api/v1/agents/register", s.handleAgentRegister, accessOpen},
{"POST /api/v1/agents/{id}/heartbeat", s.handleAgentHeartbeat, accessOpen},
{"GET /api/v1/agents/{id}/assignment", s.handleAgentAssignment, accessOpen},
{"POST /api/v1/agents/{id}/self-check", s.handleAgentSelfCheck, accessAgent},
{"POST /api/v1/agents/{id}/events", s.handleAgentEvent, accessAgent},
{"POST /api/v1/agents/{id}/results", s.handleAgentResults, accessAgent},
{"POST /api/v1/agents/{id}/complete", s.handleAgentComplete, accessAgent},
{"POST /api/v1/probers/register", s.handleProberRegister, accessOpen},
{"POST /api/v1/probers/{site_id}/heartbeat", s.handleProberHeartbeat, accessOpen},
{"GET /api/v1/probers/{site_id}/assignments", s.handleProberAssignments, accessOpen},
{"POST /api/v1/probers/{site_id}/results", s.handleProberResults, accessAgent},
{"GET /api/v1/admin/status", s.handleAdminStatus, accessAdmin},
{"GET /api/v1/admin/ips", s.handleAdminIPs, accessAdmin},
{"POST /api/v1/admin/ips", s.handleAdminSubmitIPs, accessAdmin},
{"POST /api/v1/admin/ips/scan", s.handleAdminScanFloatingIPs, accessAdmin},
{"GET /api/v1/admin/ips/{ip}", s.handleAdminIPDetail, accessAdmin},
{"POST /api/v1/admin/ips/{ip}/cancel", s.handleAdminCancelIP, accessAdmin},
{"DELETE /api/v1/admin/ips/{ip}", s.handleAdminDeleteIP, accessAdmin},
{"POST /api/v1/admin/ips/delete", s.handleAdminDeleteIPs, accessAdmin},
{"POST /api/v1/admin/ips/clear", s.handleAdminClearQueue, accessAdmin},
{"GET /api/v1/admin/validators", s.handleAdminValidators, accessAdmin},
{"GET /api/v1/admin/auto-cycle", s.handleAdminGetAutoCycle, accessAdmin},
{"PUT /api/v1/admin/auto-cycle", s.handleAdminPutAutoCycle, accessAdmin},
{"POST /api/v1/admin/auto-cycle/start", s.handleAdminStartAutoCycle, accessAdmin},
{"POST /api/v1/admin/auto-cycle/stop", s.handleAdminStopAutoCycle, accessAdmin},
{"GET /api/v1/admin/registry", s.handleAdminRegistry, accessAdmin},
{"GET /api/v1/admin/registry/{ip}", s.handleAdminRegistryHistory, accessAdmin},
{"GET /api/v1/admin/config/validators", s.handleConfigListValidators, accessAdmin},
{"POST /api/v1/admin/config/validators", s.handleConfigCreateValidator, accessAdmin},
{"PUT /api/v1/admin/config/validators/{id}", s.handleConfigUpdateValidator, accessAdmin},
{"DELETE /api/v1/admin/config/validators/{id}", s.handleConfigDeleteValidator, accessAdmin},
{"GET /api/v1/admin/config/sites", s.handleConfigListSites, accessAdmin},
{"PUT /api/v1/admin/config/sites/{index}", s.handleConfigPutSite, accessAdmin},
{"DELETE /api/v1/admin/config/sites/{index}", s.handleConfigDeleteSite, accessAdmin},
{"GET /api/v1/admin/config/targets", s.handleConfigListTargets, accessAdmin},
{"PUT /api/v1/admin/config/targets/{group}", s.handleConfigPutTargetGroup, accessAdmin},
{"DELETE /api/v1/admin/config/targets/{group}", s.handleConfigDeleteTargetGroup, accessAdmin},
{"GET /api/v1/admin/config/check-types", s.handleConfigListCheckTypes, accessAdmin},
{"PUT /api/v1/admin/config/check-types/{name}", s.handleConfigPutCheckType, accessAdmin},
{"DELETE /api/v1/admin/config/check-types/{name}", s.handleConfigDeleteCheckType, accessAdmin},
{"GET /api/v1/admin/config/orchestrator", s.handleConfigGetOrchestratorSettings, accessAdmin},
{"PUT /api/v1/admin/config/orchestrator", s.handleConfigPutOrchestratorSettings, accessAdmin},
{"GET /api/v1/admin/config/inbound-checks", s.handleConfigGetInboundChecks, accessAdmin},
{"PUT /api/v1/admin/config/inbound-checks", s.handleConfigPutInboundChecks, accessAdmin},
}
}
+2
View File
@@ -19,6 +19,7 @@ type Server struct {
DB *db.DB
Orch *orchestrator.Orchestrator
Log *slog.Logger
Auth Authenticator
}
func New(d *db.DB, o *orchestrator.Orchestrator, log *slog.Logger) *Server {
@@ -26,6 +27,7 @@ func New(d *db.DB, o *orchestrator.Orchestrator, log *slog.Logger) *Server {
}
func (s *Server) Handler() http.Handler {
s.Auth.Log = s.Log
mux := http.NewServeMux()
s.routes(mux)
return loggingMiddleware(s.Log, mux)