diff --git a/mothership/internal/ingestion/server.go b/mothership/internal/ingestion/server.go index 2ca881c..0a993dc 100644 --- a/mothership/internal/ingestion/server.go +++ b/mothership/internal/ingestion/server.go @@ -182,6 +182,15 @@ const ( // Frame gauge buffer size for load shedding fullness detection. frameGaugeSize = 256 + + // spaxelTokenHeader is the HTTP header carrying the node's HMAC token on the + // /ws/node upgrade request. This is the documented auth channel (plan.md > + // Authentication): the node includes its token here, and the mothership + // verifies it before completing the upgrade. The hello body may also carry a + // token (older/alternate clients); the body token takes precedence when both + // are present, and the header fills in for a node that omits it from hello + // (e.g. spaxel-sim, which only sends the header). + spaxelTokenHeader = "X-Spaxel-Token" ) // NewServer creates a new ingestion server @@ -503,6 +512,21 @@ func (s *Server) HandleNodeWS(w http.ResponseWriter, r *http.Request) { nc.MAC = hello.MAC nc.Hello = hello + // Bridge the X-Spaxel-Token WS-handshake header into the hello body token. The + // validator below only reads hello.Token; many clients (spaxel-sim, and any + // firmware following the documented plan.md auth contract) present their token + // solely as the upgrade-request header and omit it from the hello JSON. Without + // this bridge, hello.Token is always "" for such nodes and every connection + // falls through to the migration-window branch. The body token wins when both + // are present, so existing body-token clients are unaffected. This does not + // weaken the validator: an empty/invalid header is still rejected once the + // migration window closes (bf-4iewr / bf-1o7qi). + if hello.Token == "" { + if hdr := r.Header.Get(spaxelTokenHeader); hdr != "" { + hello.Token = hdr + } + } + // Token validation: if a validator is configured, reject unauthenticated nodes // unless the migration window is still open, in which case allow but mark as Unpaired. s.mu.RLock() diff --git a/mothership/internal/ingestion/server_test.go b/mothership/internal/ingestion/server_test.go index 4281f62..bcaca12 100644 --- a/mothership/internal/ingestion/server_test.go +++ b/mothership/internal/ingestion/server_test.go @@ -2,6 +2,7 @@ package ingestion import ( "encoding/json" + "fmt" "net/http" "net/http/httptest" "strings" @@ -9,6 +10,7 @@ import ( "time" "github.com/gorilla/websocket" + "github.com/spaxel/mothership/internal/provisioning" ) // TestMalformedCounter_WarnThreshold verifies that WARN is logged when count exceeds 100 @@ -594,6 +596,170 @@ func TestMigrationWindow(t *testing.T) { } } +// TestTokenValidation_HeaderTokenBridged exercises the bf-4iewr/bf-1o7qi fix: a +// node that presents its token ONLY via the X-Spaxel-Token upgrade-request header +// (the documented auth channel, used by spaxel-sim) must be accepted and marked +// paired even when the migration window is closed. It wires the REAL +// provisioning.Server.ValidateToken (HMAC-SHA256(installSecret, mac)) as the +// validator and mints the token through the real provisioning handler, so the +// bridge is proven end-to-end through the same HMAC path production uses. +// Regression cases assert invalid/empty tokens are still rejected once the +// window closes. +func TestTokenValidation_HeaderTokenBridged(t *testing.T) { + // Real provisioning server with an auto-generated install secret persisted + // to a temp dir. HandleProvision and ValidateToken share that secret. + provSrv := provisioning.NewServer(t.TempDir(), "spaxel", 8080, "pool.ntp.org", "") + + const mac = "AA:BB:CC:DD:EE:FF" + + // Mint a REAL provisioned token for this MAC via the provisioning handler — + // exactly the token a node receives during Web Serial onboarding. + provisionToken := func() string { + req := httptest.NewRequest(http.MethodPost, "/api/provision", strings.NewReader(fmt.Sprintf(`{"mac":%q}`, mac))) + rec := httptest.NewRecorder() + provSrv.HandleProvision(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("provision failed: status %d body %s", rec.Code, rec.Body.String()) + } + var payload struct { + NodeToken string `json:"node_token"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode provision payload: %v", err) + } + if payload.NodeToken == "" { + t.Fatal("empty node_token from provisioning") + } + return payload.NodeToken + } + validToken := provisionToken() + + // Setup invariant: provisioning and ValidateToken must agree on the token. + if !provSrv.ValidateToken(mac, validToken) { + t.Fatal("setup invariant failed: provisioning token not accepted by ValidateToken") + } + + tests := []struct { + name string + headerToken string // X-Spaxel-Token value; "" omits the header + helloToken string // token in hello body; "" omits it (the sim case) + wantAccept bool + wantUnpair bool + }{ + { + name: "valid header token accepted + paired with window closed", + headerToken: validToken, + wantAccept: true, + wantUnpair: false, + }, + { + name: "no header and no body token rejected with window closed", + headerToken: "", + wantAccept: false, + }, + { + name: "invalid header token rejected with window closed", + headerToken: strings.Repeat("0", 64), // well-formed 64-hex, but wrong + wantAccept: false, + }, + { + name: "body token wins when both header and body present", + headerToken: strings.Repeat("0", 64), // wrong header is ignored + helloToken: validToken, // valid body wins + wantAccept: true, + wantUnpair: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ingestServer := NewServer() + ingestServer.SetTokenValidator(provSrv.ValidateToken) + // Migration window CLOSED: leave migrationDeadline at its zero value, + // which the validator treats as strict (no grace) — see TestMigrationWindow. + + httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ingestServer.HandleNodeWS(w, r) + })) + defer httpServer.Close() //nolint:errcheck + + wsURL := "ws" + strings.TrimPrefix(httpServer.URL, "http") + "/ws/node" + dialer := websocket.Dialer{} + hdr := http.Header{} + if tt.headerToken != "" { + hdr.Set(spaxelTokenHeader, tt.headerToken) + } + conn, _, err := dialer.Dial(wsURL, hdr) + if err != nil { + t.Fatalf("Failed to connect: %v", err) + } + defer conn.Close() //nolint:errcheck + + // Hello WITHOUT a body token (the sim's case) unless this case sets one. + hello := fmt.Sprintf(`{"type":"hello","mac":%q,"firmware_version":"1.0.0","chip":"ESP32-S3"}`, mac) + if tt.helloToken != "" { + hello = fmt.Sprintf(`{"type":"hello","mac":%q,"firmware_version":"1.0.0","chip":"ESP32-S3","token":%q}`, + mac, tt.helloToken) + } + if err := conn.WriteMessage(websocket.TextMessage, []byte(hello)); err != nil { + t.Fatalf("send hello: %v", err) + } + + conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, data, rerr := conn.ReadMessage() + + if tt.wantAccept { + if rerr != nil { + t.Fatalf("expected acceptance, got read error: %v", rerr) + } + var msg struct { + Type string `json:"type"` + } + if err := json.Unmarshal(data, &msg); err != nil { + t.Fatalf("parse response: %v (data=%s)", err, string(data)) + } + if msg.Type == "reject" { + t.Fatalf("valid token was rejected: %s", string(data)) + } + if !ingestServer.IsNodeConnected(mac) { + t.Fatal("node should be registered as connected") + } + // Paired (not Unpaired) is the whole point of the fix. + for _, info := range ingestServer.GetConnectedNodesInfo() { + if info.MAC == mac { + if info.Unpaired != tt.wantUnpair { + t.Fatalf("Unpaired=%v, want %v", info.Unpaired, tt.wantUnpair) + } + return + } + } + t.Fatal("node not found in GetConnectedNodesInfo") + } else { + // Rejected: either a reject frame then close, or an immediate read error. + rejected := false + if rerr == nil { + var msg struct { + Type string `json:"type"` + Reason string `json:"reason"` + } + if jerr := json.Unmarshal(data, &msg); jerr == nil && msg.Type == "reject" { + rejected = true + if msg.Reason != "invalid_token" { + t.Fatalf("expected reason 'invalid_token', got %q", msg.Reason) + } + } + } + if !rejected { + t.Fatalf("expected rejection, got: err=%v data=%s", rerr, string(data)) + } + if ingestServer.IsNodeConnected(mac) { + t.Fatal("rejected node must not be in connected list") + } + } + }) + } +} + func TestGetUnpairedMACs_Empty(t *testing.T) { server := NewServer() macs := server.GetUnpairedMACs()