Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 25 additions & 11 deletions dashboard/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -783,6 +783,16 @@ func localhostOnly(next http.HandlerFunc) http.HandlerFunc {

// Serve starts an HTTP server serving the dashboard UI and stats API.
func (h *Handler) Serve(addr string) error {
mux := h.buildMux()
slog.Info("dashboard listening", "addr", addr)
return http.ListenAndServe(addr, mux)
}

// buildMux constructs the dashboard's route table. Factored out of
// Serve so tests can exercise the real routes (e.g. the /api/stats
// auth path) against an httptest server without re-implementing
// registration and drifting from production wiring.
func (h *Handler) buildMux() *http.ServeMux {
mux := http.NewServeMux()

mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -882,14 +892,15 @@ func (h *Handler) Serve(addr string) error {
mux.HandleFunc("/api/stats", h.requireAdminToken(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
authenticated := false
if token := r.URL.Query().Get("token"); token != "" {
dt := h.cb.GetDashboardToken()
if dt != "" && subtle.ConstantTimeCompare([]byte(token), []byte(dt)) == 1 {
authenticated = true
}
}
_ = json.NewEncoder(w).Encode(h.buildStatsResponse(authenticated))
// requireAdminToken (PR #50) already verified a valid admin
// token reached this handler — so the caller is an authenticated
// operator and gets the elevated per-network payload. The old
// inner ?token= dashboard-token check became dead once the
// endpoint moved behind the admin gate (the gate 401s anything
// without the admin token first), and the dashboard frontend
// sent ?token= which the gate ignored, so operator stats silently
// 401'd. The frontend now sends admin_token=; this matches it.
_ = json.NewEncoder(w).Encode(h.buildStatsResponse(true))
}))

mux.HandleFunc("/api/nodes", func(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -1551,8 +1562,7 @@ func (h *Handler) Serve(addr string) error {
// because adding a router dependency for three routes isn't worth it.
mux.HandleFunc("/api/admin/networks/", h.requireAdminToken(h.serveMembershipAdmin))

slog.Info("dashboard listening", "addr", addr)
return http.ListenAndServe(addr, mux)
return mux
}

// serveMembershipAdmin dispatches the three /api/admin/networks/{id}/members*
Expand Down Expand Up @@ -2163,7 +2173,11 @@ function update(){
// directly. Token-aware dashboard-token path retained for operators
// who set one in localStorage.
var t=getToken();
var url=t?('/api/stats?token='+encodeURIComponent(t)):'/api/public-stats';
// /api/stats is admin-gated (PR #50). The operator's stored token is
// the admin token (same one /api/pulse uses below), so send it as
// admin_token to clear the gate. Without this the request was sent as
// ?token= — which the admin gate ignores — and silently 401'd.
var url=t?('/api/stats?admin_token='+encodeURIComponent(t)):'/api/public-stats';
fetch(url).then(function(r){
if(!r.ok)return null;
return r.json();
Expand Down
101 changes: 101 additions & 0 deletions dashboard/zz_stats_auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// SPDX-License-Identifier: AGPL-3.0-or-later

package dashboard

import (
"net/http"
"net/http/httptest"
"testing"
)

// TestStatsAuth_AdminTokenQueryParam pins the reconciled auth path:
// /api/stats is admin-gated, and the dashboard frontend authenticates
// with admin_token=<token> (the same token it sends to /api/pulse).
// Previously the frontend sent ?token=, which the admin gate ignores,
// so operator stats silently 401'd.
func TestStatsAuth_AdminTokenQueryParam(t *testing.T) {
t.Parallel()
cb := minimalCallbacks()
cb.GetAdminToken = func() string { return "op-secret" }
// Mark the payload so we can confirm the elevated (authenticated)
// branch ran.
cb.BuildStatsPayload = func(authenticated bool) map[string]interface{} {
return map[string]interface{}{"authenticated": authenticated}
}
h := NewHandler(cb)
srv := httptest.NewServer(h.buildMux())
defer srv.Close()

// admin_token query param — what the frontend now sends — is accepted.
resp, err := http.Get(srv.URL + "/api/stats?admin_token=op-secret")
if err != nil {
t.Fatalf("GET /api/stats: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("admin_token query: status %d, want 200", resp.StatusCode)
}

// X-Admin-Token header is accepted too.
req, _ := http.NewRequest("GET", srv.URL+"/api/stats", nil)
req.Header.Set("X-Admin-Token", "op-secret")
resp2, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("GET /api/stats (header): %v", err)
}
defer resp2.Body.Close()
if resp2.StatusCode != http.StatusOK {
t.Fatalf("X-Admin-Token header: status %d, want 200", resp2.StatusCode)
}
}

// TestStatsAuth_RejectsBadAndMissingToken confirms the gate is not
// weakened: a missing token, a wrong token, and the legacy ?token=
// (which the admin gate does not accept) are all rejected.
func TestStatsAuth_RejectsBadAndMissingToken(t *testing.T) {
t.Parallel()
cb := minimalCallbacks()
cb.GetAdminToken = func() string { return "op-secret" }
h := NewHandler(cb)
srv := httptest.NewServer(h.buildMux())
defer srv.Close()

cases := []struct {
name string
url string
}{
{"missing", srv.URL + "/api/stats"},
{"wrong admin_token", srv.URL + "/api/stats?admin_token=nope"},
{"legacy token param not honored by gate", srv.URL + "/api/stats?token=op-secret"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
resp, err := http.Get(c.url)
if err != nil {
t.Fatalf("GET: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("%s: status %d, want 401", c.name, resp.StatusCode)
}
})
}
}

// TestStatsAuth_UnconfiguredLocksShut confirms that with no admin token
// configured the endpoint is locked shut (matches the PR #50 lockdown).
func TestStatsAuth_UnconfiguredLocksShut(t *testing.T) {
t.Parallel()
h := NewHandler(minimalCallbacks()) // GetAdminToken returns ""
srv := httptest.NewServer(h.buildMux())
defer srv.Close()

resp, err := http.Get(srv.URL + "/api/stats?admin_token=anything")
if err != nil {
t.Fatalf("GET: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("unconfigured: status %d, want 401", resp.StatusCode)
}
}
Loading