Files
ayurishchevandClaude Sonnet 5.5 debf2afed2 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>
2026-10-01 11:35:24 +03:00

109 lines
2.8 KiB
Go

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
}