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

423 lines
14 KiB
Go

package dashboard
import (
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"time"
)
const (
testUser = "admin"
testPass = "correct horse battery staple"
testSecret = "test-session-secret"
)
func newAuthTestServer(t *testing.T, caURL string, mutate func(*Config)) (*Server, *httptest.Server) {
t.Helper()
cfg := Config{
ControlAPIBaseURL: caURL,
ControlAPITimeout: 5 * time.Second,
LastCompletedCount: 20,
OverviewPollIntervalS: 5,
ControlAPIToken: "ca-admin-token",
Username: testUser,
Password: testPass,
SessionSecret: testSecret,
SessionTTL: time.Hour,
}
if mutate != nil {
mutate(&cfg)
}
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, err := New(cfg, log)
if err != nil {
t.Fatalf("new dashboard server: %v", err)
}
ts := httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
return srv, ts
}
// noFollow is a client that returns redirects as-is.
func noFollow(ts *httptest.Server) *http.Client {
c := *ts.Client()
c.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
return &c
}
type reqOpts struct {
method string
path string
form url.Values
cookie string
headers map[string]string
}
func doReq(t *testing.T, ts *httptest.Server, o reqOpts) (*http.Response, string) {
t.Helper()
if o.method == "" {
o.method = http.MethodGet
}
var body io.Reader
if o.form != nil {
body = strings.NewReader(o.form.Encode())
}
req, err := http.NewRequest(o.method, ts.URL+o.path, body)
if err != nil {
t.Fatalf("new request: %v", err)
}
if o.form != nil {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
if o.cookie != "" {
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: o.cookie})
}
for k, v := range o.headers {
req.Header.Set(k, v)
}
resp, err := noFollow(ts).Do(req)
if err != nil {
t.Fatalf("%s %s: %v", o.method, o.path, err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp, string(b)
}
func sessionCookie(resp *http.Response) *http.Cookie {
for _, c := range resp.Cookies() {
if c.Name == sessionCookieName {
return c
}
}
return nil
}
func loginForm(user, pass string) url.Values {
return url.Values{"username": {user}, "password": {pass}}
}
// login performs a successful login and returns the session cookie value.
func login(t *testing.T, ts *httptest.Server) string {
t.Helper()
resp, _ := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/login", form: loginForm(testUser, testPass),
headers: map[string]string{"Origin": ts.URL}})
c := sessionCookie(resp)
if resp.StatusCode != http.StatusSeeOther || c == nil {
t.Fatalf("login: status=%d cookie=%v, want 303 + session cookie", resp.StatusCode, c)
}
return c.Value
}
func TestUnauthenticatedRedirectsToLogin(t *testing.T) {
_, caURL := newFakeControlAPI(t)
_, ts := newAuthTestServer(t, caURL, nil)
resp, _ := doReq(t, ts, reqOpts{path: "/ips"})
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("status=%d, want 303", resp.StatusCode)
}
if loc := resp.Header.Get("Location"); loc != "/login?next=%2Fips" {
t.Fatalf("Location=%q, want /login?next=%%2Fips", loc)
}
}
func TestUnauthenticatedHTMXGets401WithHXRedirect(t *testing.T) {
_, caURL := newFakeControlAPI(t)
_, ts := newAuthTestServer(t, caURL, nil)
resp, _ := doReq(t, ts, reqOpts{path: "/overview/fragment", headers: map[string]string{"HX-Request": "true"}})
if resp.StatusCode != http.StatusUnauthorized || resp.Header.Get("HX-Redirect") != "/login" {
t.Fatalf("status=%d HX-Redirect=%q, want 401 + /login", resp.StatusCode, resp.Header.Get("HX-Redirect"))
}
}
func TestLoginSuccessSetsCookieAndGrantsAccess(t *testing.T) {
_, caURL := newFakeControlAPI(t)
_, ts := newAuthTestServer(t, caURL, nil)
resp, _ := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/login",
form: url.Values{"username": {testUser}, "password": {testPass}, "next": {"/ips"}},
headers: map[string]string{"Origin": ts.URL}})
if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "/ips" {
t.Fatalf("status=%d Location=%q, want 303 /ips", resp.StatusCode, resp.Header.Get("Location"))
}
c := sessionCookie(resp)
if c == nil {
t.Fatal("no session cookie")
}
if !c.HttpOnly || c.SameSite != http.SameSiteStrictMode || c.Path != "/" || c.MaxAge <= 0 {
t.Fatalf("cookie flags: httponly=%v samesite=%v path=%q maxage=%d", c.HttpOnly, c.SameSite, c.Path, c.MaxAge)
}
if c.Secure {
t.Fatal("cookie must not be Secure over plain HTTP")
}
resp, body := doReq(t, ts, reqOpts{path: "/overview", cookie: c.Value})
if resp.StatusCode != http.StatusOK {
t.Fatalf("authenticated GET /overview: status=%d", resp.StatusCode)
}
if !strings.Contains(body, "Выйти") || !strings.Contains(body, testUser) {
t.Fatal("sidebar must show the user and the logout button when auth is enabled")
}
}
func TestCookieSecureBehindHTTPSProxy(t *testing.T) {
_, caURL := newFakeControlAPI(t)
_, ts := newAuthTestServer(t, caURL, nil)
resp, _ := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/login", form: loginForm(testUser, testPass),
headers: map[string]string{"Origin": ts.URL, "X-Forwarded-Proto": "https"}})
if c := sessionCookie(resp); c == nil || !c.Secure {
t.Fatalf("cookie=%v, want Secure with X-Forwarded-Proto=https", c)
}
}
func TestLoginWrongPasswordShowsErrorWithoutCookie(t *testing.T) {
_, caURL := newFakeControlAPI(t)
_, ts := newAuthTestServer(t, caURL, nil)
for _, f := range []url.Values{loginForm(testUser, "nope"), loginForm("nobody", testPass)} {
resp, body := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/login", form: f,
headers: map[string]string{"Origin": ts.URL}})
if resp.StatusCode != http.StatusOK || sessionCookie(resp) != nil {
t.Fatalf("status=%d cookie=%v, want 200 and no cookie", resp.StatusCode, sessionCookie(resp))
}
if !strings.Contains(body, "Неверный логин или пароль") {
t.Fatal("login page must show the error")
}
}
}
func TestTamperedAndExpiredCookiesRejected(t *testing.T) {
_, caURL := newFakeControlAPI(t)
srv, ts := newAuthTestServer(t, caURL, nil)
good := login(t, ts)
payload, sig, _ := strings.Cut(good, ".")
for name, v := range map[string]string{
"tampered payload": "AAAA" + payload[4:] + "." + sig,
"tampered signature": payload + "." + sig[:len(sig)-2] + "AA",
"no signature": payload,
"garbage": "x.y",
"signed with new key": otherKeyCookie(t),
} {
resp, _ := doReq(t, ts, reqOpts{path: "/overview", cookie: v})
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("%s: status=%d, want 303 to /login", name, resp.StatusCode)
}
}
// Expiry: advance the server's clock beyond the TTL.
srv.auth.now = func() time.Time { return time.Now().Add(2 * time.Hour) }
resp, _ := doReq(t, ts, reqOpts{path: "/overview", cookie: good})
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("expired cookie: status=%d, want 303", resp.StatusCode)
}
}
func otherKeyCookie(t *testing.T) string {
t.Helper()
a := newAuthState(Config{Username: "u", Password: "p", SessionSecret: "another-secret"},
slog.New(slog.NewTextHandler(io.Discard, nil)))
v, _ := a.issue(testUser)
return v
}
func TestLogoutClearsCookie(t *testing.T) {
_, caURL := newFakeControlAPI(t)
_, ts := newAuthTestServer(t, caURL, nil)
good := login(t, ts)
resp, _ := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/logout", cookie: good,
headers: map[string]string{"Origin": ts.URL}})
c := sessionCookie(resp)
if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "/login" || c == nil || c.MaxAge >= 0 || c.Value != "" {
t.Fatalf("logout: status=%d loc=%q cookie=%+v", resp.StatusCode, resp.Header.Get("Location"), c)
}
}
func TestCSRFOriginCheck(t *testing.T) {
_, caURL := newFakeControlAPI(t)
_, ts := newAuthTestServer(t, caURL, nil)
good := login(t, ts)
cases := map[string]map[string]string{
"foreign origin": {"Origin": "http://evil.example"},
"null origin": {"Origin": "null"},
"no origin/referer": {},
"foreign referer": {"Referer": "http://evil.example/page"},
}
for name, h := range cases {
resp, _ := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/ips/clear", cookie: good, headers: h})
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("%s: status=%d, want 403", name, resp.StatusCode)
}
}
// Login itself is covered too.
resp, _ := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/login", form: loginForm(testUser, testPass),
headers: map[string]string{"Origin": "http://evil.example"}})
if resp.StatusCode != http.StatusForbidden || sessionCookie(resp) != nil {
t.Fatalf("cross-origin login: status=%d, want 403 and no cookie", resp.StatusCode)
}
// Same-origin Origin, and Referer fallback, pass.
for name, h := range map[string]map[string]string{
"origin": {"Origin": ts.URL},
"referer": {"Referer": ts.URL + "/ips"},
} {
resp, _ := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/ips/clear", cookie: good, headers: h})
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusSeeOther {
t.Fatalf("same-origin %s: status=%d, want request to be served", name, resp.StatusCode)
}
}
}
func TestLoginThrottle(t *testing.T) {
_, caURL := newFakeControlAPI(t)
_, ts := newAuthTestServer(t, caURL, nil)
for i := 0; i < maxLoginFailures; i++ {
resp, _ := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/login", form: loginForm(testUser, "bad"),
headers: map[string]string{"Origin": ts.URL}})
if resp.StatusCode != http.StatusOK {
t.Fatalf("attempt %d: status=%d, want 200", i+1, resp.StatusCode)
}
}
// Now locked out, even with the right password.
resp, _ := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/login", form: loginForm(testUser, testPass),
headers: map[string]string{"Origin": ts.URL}})
if resp.StatusCode != http.StatusTooManyRequests || resp.Header.Get("Retry-After") == "" || sessionCookie(resp) != nil {
t.Fatalf("status=%d Retry-After=%q, want 429 with Retry-After and no cookie", resp.StatusCode, resp.Header.Get("Retry-After"))
}
}
func TestLoginThrottleClearedBySuccessAndExpires(t *testing.T) {
th := newLoginThrottle()
now := time.Now()
for i := 0; i < maxLoginFailures-1; i++ {
th.fail("1.2.3.4", now)
}
if b, _ := th.blocked("1.2.3.4", now); b {
t.Fatal("blocked before reaching the limit")
}
th.fail("1.2.3.4", now)
if b, _ := th.blocked("1.2.3.4", now); !b {
t.Fatal("not blocked at the limit")
}
if b, _ := th.blocked("1.2.3.4", now.Add(loginFailureWindow+time.Second)); b {
t.Fatal("still blocked after the window")
}
if len(th.failures) != 0 {
t.Fatal("expired entries must be pruned")
}
th.fail("5.6.7.8", now)
th.clear("5.6.7.8")
if len(th.failures) != 0 {
t.Fatal("clear must drop the entry")
}
}
func TestStaticAndLoginPageOpen(t *testing.T) {
_, caURL := newFakeControlAPI(t)
_, ts := newAuthTestServer(t, caURL, nil)
if resp, _ := doReq(t, ts, reqOpts{path: "/static/dashboard.css"}); resp.StatusCode != http.StatusOK {
t.Fatalf("/static/dashboard.css: status=%d, want 200", resp.StatusCode)
}
resp, body := doReq(t, ts, reqOpts{path: "/login"})
if resp.StatusCode != http.StatusOK || !strings.Contains(body, `name="password"`) {
t.Fatalf("/login: status=%d", resp.StatusCode)
}
}
func TestLoginNextOpenRedirectRejected(t *testing.T) {
for next, want := range map[string]string{
"/ips": "/ips",
"/ips?q=1": "/ips?q=1",
"//evil.example": "/",
"/\\evil.example": "/",
"http://evil.example": "/",
"https://evil.example/x": "/",
"javascript:alert(1)": "/",
"": "/",
"/a\r\nSet-Cookie: x": "/",
} {
if got := safeNext(next); got != want {
t.Fatalf("safeNext(%q)=%q, want %q", next, got, want)
}
}
_, caURL := newFakeControlAPI(t)
_, ts := newAuthTestServer(t, caURL, nil)
resp, _ := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/login",
form: url.Values{"username": {testUser}, "password": {testPass}, "next": {"//evil.example"}},
headers: map[string]string{"Origin": ts.URL}})
if resp.Header.Get("Location") != "/" {
t.Fatalf("Location=%q, want /", resp.Header.Get("Location"))
}
}
func TestControlAPITokenForwarded(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
_, ts := newAuthTestServer(t, caURL, nil)
good := login(t, ts)
if resp, _ := doReq(t, ts, reqOpts{path: "/overview", cookie: good}); resp.StatusCode != http.StatusOK {
t.Fatalf("GET /overview: status=%d", resp.StatusCode)
}
fake.authMu.Lock()
defer fake.authMu.Unlock()
if len(fake.authHeaders) == 0 {
t.Fatal("control-api was never called")
}
for _, h := range fake.authHeaders {
if h != "Bearer ca-admin-token" {
t.Fatalf("control-api Authorization=%q, want Bearer ca-admin-token", h)
}
}
}
func TestAuthDisabledWithZeroConfig(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
ts := newTestServer(t, caURL) // zero auth fields
resp, body := doReq(t, ts, reqOpts{path: "/overview"})
if resp.StatusCode != http.StatusOK {
t.Fatalf("status=%d, want 200 without login", resp.StatusCode)
}
if strings.Contains(body, "/logout") {
t.Fatal("logout control must be hidden when auth is disabled")
}
if resp, _ := doReq(t, ts, reqOpts{path: "/login"}); resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "/" {
t.Fatalf("/login with auth disabled: status=%d loc=%q, want redirect to /", resp.StatusCode, resp.Header.Get("Location"))
}
fake.authMu.Lock()
defer fake.authMu.Unlock()
for _, h := range fake.authHeaders {
if h != "" {
t.Fatalf("no token configured but Authorization=%q was sent", h)
}
}
}
func TestAuthDisabledWhenOnlyUsernameSet(t *testing.T) {
_, caURL := newFakeControlAPI(t)
_, ts := newAuthTestServer(t, caURL, func(c *Config) { c.Password = "" })
if resp, _ := doReq(t, ts, reqOpts{path: "/overview"}); resp.StatusCode != http.StatusOK {
t.Fatalf("status=%d, want 200 (auth disabled without password)", resp.StatusCode)
}
}
func TestEmptySessionSecretUsesRandomKey(t *testing.T) {
_, caURL := newFakeControlAPI(t)
_, ts := newAuthTestServer(t, caURL, func(c *Config) { c.SessionSecret = "" })
good := login(t, ts)
if resp, _ := doReq(t, ts, reqOpts{path: "/overview", cookie: good}); resp.StatusCode != http.StatusOK {
t.Fatalf("status=%d, want 200 with random-key session", resp.StatusCode)
}
}