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>
423 lines
14 KiB
Go
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)
|
|
}
|
|
}
|