registry: fleets fixes — rev-counter fingerprint, atomic add, validation
3 files changed,
+235
−44
internal/registry/fleets.go+97 −24internal/registry/fleets_test.go+121 −14internal/registry/registry.go+17 −6
diff --git a/internal/registry/fleets.go b/internal/registry/fleets.go| index e4a865baa..56d201f08 100644 |
| --- a/internal/registry/fleets.go |
| +++ b/internal/registry/fleets.go |
| @@ -10,6 +10,7 @@ package registry |
| import ( |
| "database/sql" |
| "fmt" |
| + "regexp" |
| "time" |
| ) |
| @@ -33,16 +34,40 @@ type FleetBox struct { |
| AddedAt int64 |
| } |
| +// tokenHashRe: exactly 64 lowercase hex characters — a sha256 digest, |
| +// structurally enforcing the "token hashes only, never plaintext" rule at |
| +// every writer that accepts one (AddFleetBox, SetFleetBoxToken). A bearer |
| +// token or an empty string both fail this shape. |
| +var tokenHashRe = regexp.MustCompile(`^[0-9a-f]{64}$`) |
| + |
| +func validTokenHash(hash string) bool { |
| + return tokenHashRe.MatchString(hash) |
| +} |
| + |
| +// validFleetBoxStatus reports whether status is one of the two values the |
| +// FleetBox.Status doc comment promises. Rejecting anything else at the |
| +// writer matters because AddFleetBox's uniqueness guard keys off status = |
| +// 'active': a typo'd status would make a live box invisible to it. |
| +func validFleetBoxStatus(status string) bool { |
| + return status == "active" || status == "detached" |
| +} |
| + |
| // migrateFleets creates the fleets and fleet_boxes tables — additive |
| // (CREATE TABLE IF NOT EXISTS), called from migrate() beside the routes DDL, |
| // so deploying this code over an existing registry is always safe. |
| +// |
| +// Both tables carry `rev`, bumped by every writer — see Fingerprint's doc |
| +// comment (registry.go) for why a plain length/count/rowid aggregate is |
| +// blind to a fixed-length rewrite (a sha256 token_hash never changes |
| +// length) and rev is what fixes that. |
| func (r *Registry) migrateFleets() error { |
| if _, err := r.sql.Exec(` |
| CREATE TABLE IF NOT EXISTS fleets ( |
| name TEXT PRIMARY KEY, |
| account TEXT NOT NULL, |
| bucket TEXT NOT NULL, |
| - created_at INTEGER NOT NULL |
| + created_at INTEGER NOT NULL, |
| + rev INTEGER NOT NULL DEFAULT 0 |
| );`); err != nil { |
| return err |
| } |
| @@ -53,6 +78,7 @@ CREATE TABLE IF NOT EXISTS fleet_boxes ( |
| token_hash TEXT NOT NULL, |
| status TEXT NOT NULL, |
| added_at INTEGER NOT NULL, |
| + rev INTEGER NOT NULL DEFAULT 0, |
| PRIMARY KEY (fleet, label) |
| );`) |
| return err |
| @@ -86,10 +112,10 @@ func (r *Registry) UpsertFleet(f Fleet) error { |
| f.CreatedAt = time.Now().Unix() |
| } |
| _, err := r.sql.Exec(` |
| -INSERT INTO fleets (name, account, bucket, created_at) |
| -VALUES (?, ?, ?, ?) |
| +INSERT INTO fleets (name, account, bucket, created_at, rev) |
| +VALUES (?, ?, ?, ?, 1) |
| ON CONFLICT(name) DO UPDATE SET |
| - account = excluded.account, bucket = excluded.bucket`, |
| + account = excluded.account, bucket = excluded.bucket, rev = fleets.rev + 1`, |
| f.Name, f.Account, f.Bucket, f.CreatedAt) |
| return err |
| } |
| @@ -131,11 +157,29 @@ func (r *Registry) ListFleets() ([]Fleet, error) { |
| return out, rows.Err() |
| } |
| -// AddFleetBox registers a box to a fleet. It errors if (fleet, label) |
| -// already names an ACTIVE box — the channel identity must stay unique while |
| -// live. A label that exists but is DETACHED is reactivated in place with the |
| -// incoming token/status/added_at, which is the path a re-provisioned box |
| -// takes back onto its old name. |
| +// AddFleetBox registers a box to a fleet. It errors if the fleet does not |
| +// exist, if TokenHash or Status fail their shape checks, and if |
| +// (fleet, label) already names an ACTIVE box. |
| +// |
| +// The fleet-existence check is a plain SELECT rather than a SQLite FOREIGN |
| +// KEY, because FK enforcement is a per-connection PRAGMA |
| +// (`foreign_keys=ON`) that Open() never sets and that a fresh short-lived |
| +// connection (freshFleetsConn, another process entirely) could disagree |
| +// with — an FK that only some connections enforce is worse than none, since |
| +// it would look enforced in tests that happen to run through the same |
| +// handle. Both statements run inside one transaction so the check and the |
| +// insert see the same snapshot. |
| +// |
| +// The active-box check is NOT a read-then-write in Go: it is the INSERT's |
| +// own ON CONFLICT ... WHERE clause, so a second process racing this call on |
| +// the same (fleet, label) can never observe a stale "not active yet" read |
| +// and steal a live channel's token out from under it — SQLite evaluates the |
| +// WHERE clause and applies (or skips) the DO UPDATE atomically. A label that |
| +// exists but is DETACHED clears the WHERE guard and is reactivated in place |
| +// with the incoming token/status/added_at (rev bumped) — the path a |
| +// re-provisioned box takes back onto its old name. RowsAffected()==0 means |
| +// the guard fired (an active row was left untouched), which is the signal |
| +// this returns as the duplicate-active error. |
| func (r *Registry) AddFleetBox(b FleetBox) error { |
| if !ValidTunnelName(b.Fleet) { |
| return fmt.Errorf("invalid fleet name %q", b.Fleet) |
| @@ -146,6 +190,12 @@ func (r *Registry) AddFleetBox(b FleetBox) error { |
| if b.Status == "" { |
| b.Status = "active" |
| } |
| + if !validFleetBoxStatus(b.Status) { |
| + return fmt.Errorf("invalid box status %q", b.Status) |
| + } |
| + if !validTokenHash(b.TokenHash) { |
| + return fmt.Errorf("invalid token hash %q (want 64 lowercase hex characters)", b.TokenHash) |
| + } |
| if b.AddedAt == 0 { |
| b.AddedAt = time.Now().Unix() |
| } |
| @@ -153,31 +203,45 @@ func (r *Registry) AddFleetBox(b FleetBox) error { |
| if err != nil { |
| return err |
| } |
| - var status string |
| - err = tx.QueryRow(`SELECT status FROM fleet_boxes WHERE fleet = ? AND label = ?`, b.Fleet, b.Label).Scan(&status) |
| - if err != nil && err != sql.ErrNoRows { |
| + var exists int |
| + err = tx.QueryRow(`SELECT 1 FROM fleets WHERE name = ?`, b.Fleet).Scan(&exists) |
| + if err == sql.ErrNoRows { |
| tx.Rollback() |
| - return err |
| + return fmt.Errorf("no fleet %q", b.Fleet) |
| } |
| - if err == nil && status == "active" { |
| + if err != nil { |
| tx.Rollback() |
| - return fmt.Errorf("fleet %q already has an active box %q", b.Fleet, b.Label) |
| + return err |
| } |
| - if _, err := tx.Exec(` |
| -INSERT INTO fleet_boxes (fleet, label, token_hash, status, added_at) |
| -VALUES (?, ?, ?, ?, ?) |
| + res, err := tx.Exec(` |
| +INSERT INTO fleet_boxes (fleet, label, token_hash, status, added_at, rev) |
| +VALUES (?, ?, ?, ?, ?, 1) |
| ON CONFLICT(fleet, label) DO UPDATE SET |
| - token_hash = excluded.token_hash, status = excluded.status, added_at = excluded.added_at`, |
| - b.Fleet, b.Label, b.TokenHash, b.Status, b.AddedAt); err != nil { |
| + token_hash = excluded.token_hash, status = excluded.status, added_at = excluded.added_at, |
| + rev = fleet_boxes.rev + 1 |
| +WHERE fleet_boxes.status <> 'active'`, |
| + b.Fleet, b.Label, b.TokenHash, b.Status, b.AddedAt) |
| + if err != nil { |
| + tx.Rollback() |
| + return err |
| + } |
| + n, err := res.RowsAffected() |
| + if err != nil { |
| tx.Rollback() |
| return err |
| } |
| + if n == 0 { |
| + tx.Rollback() |
| + return fmt.Errorf("fleet %q already has an active box %q", b.Fleet, b.Label) |
| + } |
| return tx.Commit() |
| } |
| // SetFleetBoxToken rotates a box's stored token hash. Errors when no |
| // (fleet, label) row exists — a caller minting a fresh token against a box |
| -// that no longer exists is a race worth reporting, not swallowing. |
| +// that no longer exists is a race worth reporting, not swallowing. Bumps |
| +// rev: a sha256 token_hash is always 64 characters, so the rotation alone |
| +// changes no length Fingerprint could otherwise key off. |
| func (r *Registry) SetFleetBoxToken(fleet, label, tokenHash string) error { |
| if !ValidTunnelName(fleet) { |
| return fmt.Errorf("invalid fleet name %q", fleet) |
| @@ -185,7 +249,10 @@ func (r *Registry) SetFleetBoxToken(fleet, label, tokenHash string) error { |
| if !ValidTunnelName(label) { |
| return fmt.Errorf("invalid box label %q", label) |
| } |
| - res, err := r.sql.Exec(`UPDATE fleet_boxes SET token_hash = ? WHERE fleet = ? AND label = ?`, tokenHash, fleet, label) |
| + if !validTokenHash(tokenHash) { |
| + return fmt.Errorf("invalid token hash %q (want 64 lowercase hex characters)", tokenHash) |
| + } |
| + res, err := r.sql.Exec(`UPDATE fleet_boxes SET token_hash = ?, rev = rev + 1 WHERE fleet = ? AND label = ?`, tokenHash, fleet, label) |
| if err != nil { |
| return err |
| } |
| @@ -200,7 +267,10 @@ func (r *Registry) SetFleetBoxToken(fleet, label, tokenHash string) error { |
| } |
| // SetFleetBoxStatus flips a box between "active" and "detached". Errors when |
| -// no (fleet, label) row exists, same contract as SetFleetBoxToken. |
| +// no (fleet, label) row exists, same contract as SetFleetBoxToken. Rejects |
| +// any status outside those two values — AddFleetBox's uniqueness guard keys |
| +// off status = 'active', so a typo'd status here would make a live box |
| +// invisible to it. |
| func (r *Registry) SetFleetBoxStatus(fleet, label, status string) error { |
| if !ValidTunnelName(fleet) { |
| return fmt.Errorf("invalid fleet name %q", fleet) |
| @@ -208,7 +278,10 @@ func (r *Registry) SetFleetBoxStatus(fleet, label, status string) error { |
| if !ValidTunnelName(label) { |
| return fmt.Errorf("invalid box label %q", label) |
| } |
| - res, err := r.sql.Exec(`UPDATE fleet_boxes SET status = ? WHERE fleet = ? AND label = ?`, status, fleet, label) |
| + if !validFleetBoxStatus(status) { |
| + return fmt.Errorf("invalid box status %q", status) |
| + } |
| + res, err := r.sql.Exec(`UPDATE fleet_boxes SET status = ?, rev = rev + 1 WHERE fleet = ? AND label = ?`, status, fleet, label) |
| if err != nil { |
| return err |
| } |
diff --git a/internal/registry/fleets_test.go b/internal/registry/fleets_test.go| index 777369756..9009b2cab 100644 |
| --- a/internal/registry/fleets_test.go |
| +++ b/internal/registry/fleets_test.go |
| @@ -1,6 +1,17 @@ |
| package registry |
| -import "testing" |
| +import ( |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// Distinct, realistic-shape (64 lowercase hex) token hashes for tests — |
| +// AddFleetBox/SetFleetBoxToken now reject anything else. |
| +var ( |
| + tokenHashA = strings.Repeat("a", 64) |
| + tokenHashB = strings.Repeat("b", 64) |
| + tokenHashC = strings.Repeat("c", 64) |
| +) |
| // TestFleetRoundTrips pins UpsertFleet/GetFleet/ListFleets against the exact |
| // shape later tasks depend on (task-1-brief.md's Fleet struct). |
| @@ -44,7 +55,7 @@ func TestFleetBoxRoundTrips(t *testing.T) { |
| if err := reg.UpsertFleet(Fleet{Name: "acme", Account: "amadan", Bucket: "acme-data"}); err != nil { |
| t.Fatal(err) |
| } |
| - want := FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: "deadbeef", Status: "active", AddedAt: 1700000001} |
| + want := FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: tokenHashA, Status: "active", AddedAt: 1700000001} |
| if err := reg.AddFleetBox(want); err != nil { |
| t.Fatal(err) |
| } |
| @@ -57,12 +68,25 @@ func TestFleetBoxRoundTrips(t *testing.T) { |
| } |
| } |
| +// TestAddFleetBoxRejectsUnknownFleet: no FK, so this is the only rail |
| +// against an orphan box — one authenticating a channel for a fleet with no |
| +// account/bucket record at all. |
| +func TestAddFleetBoxRejectsUnknownFleet(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + if err := reg.AddFleetBox(FleetBox{Fleet: "ghost", Label: "pi-1", TokenHash: tokenHashA, Status: "active"}); err == nil { |
| + t.Fatal("AddFleetBox against a never-upserted fleet: got nil error, want one") |
| + } |
| +} |
| + |
| // TestAddFleetBoxRejectsDuplicateActive is the brief's core invariant: two |
| // boxes cannot hold the same (fleet, label) channel identity while both are |
| // active. |
| func TestAddFleetBoxRejectsDuplicateActive(t *testing.T) { |
| reg := openTestRegistry(t) |
| - box := FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: "hash1", Status: "active"} |
| + if err := reg.UpsertFleet(Fleet{Name: "acme", Account: "amadan", Bucket: "acme-data"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + box := FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: tokenHashA, Status: "active"} |
| if err := reg.AddFleetBox(box); err != nil { |
| t.Fatal(err) |
| } |
| @@ -76,14 +100,17 @@ func TestAddFleetBoxRejectsDuplicateActive(t *testing.T) { |
| // a re-provisioned box takes. |
| func TestAddFleetBoxReactivatesADetachedLabel(t *testing.T) { |
| reg := openTestRegistry(t) |
| - box := FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: "hash1", Status: "active"} |
| + if err := reg.UpsertFleet(Fleet{Name: "acme", Account: "amadan", Bucket: "acme-data"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + box := FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: tokenHashA, Status: "active"} |
| if err := reg.AddFleetBox(box); err != nil { |
| t.Fatal(err) |
| } |
| if err := reg.SetFleetBoxStatus("acme", "pi-1", "detached"); err != nil { |
| t.Fatal(err) |
| } |
| - reAdd := FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: "hash2", Status: "active"} |
| + reAdd := FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: tokenHashB, Status: "active"} |
| if err := reg.AddFleetBox(reAdd); err != nil { |
| t.Fatalf("AddFleetBox over a detached label: %v", err) |
| } |
| @@ -91,7 +118,7 @@ func TestAddFleetBoxReactivatesADetachedLabel(t *testing.T) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - if len(boxes) != 1 || boxes[0].TokenHash != "hash2" || boxes[0].Status != "active" { |
| + if len(boxes) != 1 || boxes[0].TokenHash != tokenHashB || boxes[0].Status != "active" { |
| t.Fatalf("re-add over a detached label = %+v, want reactivated with the new token", boxes) |
| } |
| } |
| @@ -100,10 +127,13 @@ func TestAddFleetBoxReactivatesADetachedLabel(t *testing.T) { |
| // rely on: channel name "<fleet>/<label>" -> TokenHash, ACTIVE boxes only. |
| func TestFleetChannelAuthOmitsDetachedBoxes(t *testing.T) { |
| reg := openTestRegistry(t) |
| - if err := reg.AddFleetBox(FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: "hash1", Status: "active"}); err != nil { |
| + if err := reg.UpsertFleet(Fleet{Name: "acme", Account: "amadan", Bucket: "acme-data"}); err != nil { |
| t.Fatal(err) |
| } |
| - if err := reg.AddFleetBox(FleetBox{Fleet: "acme", Label: "pi-2", TokenHash: "hash2", Status: "active"}); err != nil { |
| + if err := reg.AddFleetBox(FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: tokenHashA, Status: "active"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.AddFleetBox(FleetBox{Fleet: "acme", Label: "pi-2", TokenHash: tokenHashB, Status: "active"}); err != nil { |
| t.Fatal(err) |
| } |
| if err := reg.SetFleetBoxStatus("acme", "pi-2", "detached"); err != nil { |
| @@ -116,14 +146,14 @@ func TestFleetChannelAuthOmitsDetachedBoxes(t *testing.T) { |
| if _, ok := auth["acme/pi-2"]; ok { |
| t.Fatalf("FleetChannelAuth included a detached box: %+v", auth) |
| } |
| - if got, ok := auth["acme/pi-1"]; !ok || got != "hash1" { |
| + if got, ok := auth["acme/pi-1"]; !ok || got != tokenHashA { |
| t.Fatalf("FleetChannelAuth missing/wrong entry for the active box: %+v", auth) |
| } |
| } |
| func TestSetFleetBoxTokenMissingRowErrors(t *testing.T) { |
| reg := openTestRegistry(t) |
| - if err := reg.SetFleetBoxToken("acme", "nope", "hash"); err == nil { |
| + if err := reg.SetFleetBoxToken("acme", "nope", tokenHashA); err == nil { |
| t.Fatal("SetFleetBoxToken on a missing row: got nil error, want one") |
| } |
| } |
| @@ -142,13 +172,13 @@ func TestFleetWritersRejectInvalidNames(t *testing.T) { |
| if err := reg.UpsertFleet(Fleet{Name: "UPPER"}); err == nil { |
| t.Fatal("UpsertFleet({Name:UPPER}): got nil error, want one") |
| } |
| - if err := reg.AddFleetBox(FleetBox{Fleet: "UPPER", Label: "pi-1"}); err == nil { |
| + if err := reg.AddFleetBox(FleetBox{Fleet: "UPPER", Label: "pi-1", TokenHash: tokenHashA}); err == nil { |
| t.Fatal("AddFleetBox with an invalid fleet name: got nil error, want one") |
| } |
| - if err := reg.AddFleetBox(FleetBox{Fleet: "acme", Label: "UPPER"}); err == nil { |
| + if err := reg.AddFleetBox(FleetBox{Fleet: "acme", Label: "UPPER", TokenHash: tokenHashA}); err == nil { |
| t.Fatal("AddFleetBox with an invalid label: got nil error, want one") |
| } |
| - if err := reg.SetFleetBoxToken("UPPER", "pi-1", "hash"); err == nil { |
| + if err := reg.SetFleetBoxToken("UPPER", "pi-1", tokenHashA); err == nil { |
| t.Fatal("SetFleetBoxToken with an invalid fleet name: got nil error, want one") |
| } |
| if err := reg.SetFleetBoxStatus("acme", "UPPER", "detached"); err == nil { |
| @@ -156,6 +186,47 @@ func TestFleetWritersRejectInvalidNames(t *testing.T) { |
| } |
| } |
| +// TestFleetBoxWritersRejectInvalidStatus: AddFleetBox and SetFleetBoxStatus |
| +// both reject a status outside {"active","detached"} — a typo'd status |
| +// would otherwise make a live box invisible to AddFleetBox's uniqueness |
| +// guard, which keys off status = 'active'. |
| +func TestFleetBoxWritersRejectInvalidStatus(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + if err := reg.UpsertFleet(Fleet{Name: "acme", Account: "amadan", Bucket: "acme-data"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.AddFleetBox(FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: tokenHashA, Status: "enabled"}); err == nil { |
| + t.Fatal("AddFleetBox with status \"enabled\": got nil error, want one") |
| + } |
| + if err := reg.AddFleetBox(FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: tokenHashA, Status: "active"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.SetFleetBoxStatus("acme", "pi-1", "enabled"); err == nil { |
| + t.Fatal("SetFleetBoxStatus(\"enabled\"): got nil error, want one") |
| + } |
| +} |
| + |
| +// TestFleetBoxWritersRejectInvalidTokenHash: AddFleetBox and |
| +// SetFleetBoxToken both require exactly 64 lowercase hex characters — |
| +// structurally enforcing "hashes only, never plaintext". |
| +func TestFleetBoxWritersRejectInvalidTokenHash(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + if err := reg.UpsertFleet(Fleet{Name: "acme", Account: "amadan", Bucket: "acme-data"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, bad := range []string{"", "not-a-hash", strings.Repeat("a", 63), strings.Repeat("A", 64)} { |
| + if err := reg.AddFleetBox(FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: bad, Status: "active"}); err == nil { |
| + t.Fatalf("AddFleetBox with token hash %q: got nil error, want one", bad) |
| + } |
| + } |
| + if err := reg.AddFleetBox(FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: tokenHashA, Status: "active"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.SetFleetBoxToken("acme", "pi-1", "plaintext-token"); err == nil { |
| + t.Fatal("SetFleetBoxToken with a plaintext-shaped value: got nil error, want one") |
| + } |
| +} |
| + |
| // TestFingerprintChangesOnFleetMutation: Fingerprint must fold the fleets |
| // table into its aggregate — mutating a FLEET row (not a route) must still |
| // change it, which is what lets the edge notice a fleet change later. |
| @@ -185,7 +256,10 @@ func TestFingerprintChangesOnFleetMutation(t *testing.T) { |
| // exactly how a detach reaches the edge in later tasks). |
| func TestFingerprintChangesOnFleetBoxMutation(t *testing.T) { |
| reg := openTestRegistry(t) |
| - if err := reg.AddFleetBox(FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: "hash1", Status: "active"}); err != nil { |
| + if err := reg.UpsertFleet(Fleet{Name: "acme", Account: "amadan", Bucket: "acme-data"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.AddFleetBox(FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: tokenHashA, Status: "active"}); err != nil { |
| t.Fatal(err) |
| } |
| before, err := reg.Fingerprint() |
| @@ -203,3 +277,36 @@ func TestFingerprintChangesOnFleetBoxMutation(t *testing.T) { |
| t.Fatalf("fingerprint unchanged across a fleet_boxes status flip: %q", after) |
| } |
| } |
| + |
| +// TestFingerprintChangesOnTokenRotation is the Critical-1 regression pin: a |
| +// sha256 hex token_hash is ALWAYS 64 characters, so a rotation between two |
| +// same-length, different-content hashes changes no LENGTH, no COUNT, no |
| +// MAX(rowid) — only the rev column moves. Without rev folded into |
| +// Fingerprint, this rotation would be invisible to the edge's poll and a |
| +// revoked token would keep authenticating. |
| +func TestFingerprintChangesOnTokenRotation(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + if err := reg.UpsertFleet(Fleet{Name: "acme", Account: "amadan", Bucket: "acme-data"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.AddFleetBox(FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: tokenHashA, Status: "active"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(tokenHashA) != len(tokenHashC) { |
| + t.Fatalf("test fixture bug: rotation hashes must be equal length, got %d and %d", len(tokenHashA), len(tokenHashC)) |
| + } |
| + before, err := reg.Fingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.SetFleetBoxToken("acme", "pi-1", tokenHashC); err != nil { |
| + t.Fatal(err) |
| + } |
| + after, err := reg.Fingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if before == after { |
| + t.Fatalf("fingerprint unchanged across a same-length token rotation: %q", after) |
| + } |
| +} |
diff --git a/internal/registry/registry.go b/internal/registry/registry.go| index 07951f3c9..6e8f3a4f0 100644 |
| --- a/internal/registry/registry.go |
| +++ b/internal/registry/registry.go |
| @@ -1002,12 +1002,23 @@ WHERE kind = 'instance' AND next_due > 0 AND next_due <= ? ORDER BY host`, now) |
| // that would actually hide a change forever. |
| // |
| // fleets and fleet_boxes each get their own weighted scalar subquery (own |
| -// weight sets — 19/23/29 and 31/37/41/43/47 — so a routes change and a |
| +// weight sets — 19/23/29/53 and 31/37/41/43/47/59 — so a routes change and a |
| // fleets change can never cancel each other out either), concatenated onto |
| // the routes aggregate: still ONE query, so a fleet or fleet_boxes mutation |
| -// (an account move, a box's status flip, a token rotation) changes the |
| -// fingerprint exactly like a route write does, which is what lets the edge's |
| -// Watch poll (edge.go:830) notice a fleet change and reload. |
| +// changes the fingerprint exactly like a route write does, which is what |
| +// lets the edge's Watch poll (edge.go:830) notice a fleet change and reload. |
| +// |
| +// Both new tables carry a `rev` column, bumped by every writer |
| +// (UpsertFleet, AddFleetBox, SetFleetBoxToken, SetFleetBoxStatus), folded in |
| +// here as SUM(rev*53)/SUM(rev*59). This is NOT the same length-based trick |
| +// the routes aggregate uses above — a sha256 hex token_hash is ALWAYS 64 |
| +// characters, so SetFleetBoxToken's rotation changes no length, no COUNT, |
| +// no MAX(rowid): without rev the aggregate would be byte-identical across a |
| +// token rotation, the edge's poll would never fire, and a revoked token |
| +// would keep authenticating until an unrelated mutation happened to land. |
| +// rev is what makes a fixed-length-value rewrite visible, the same job |
| +// COUNT/MAX(rowid) do for inserts/deletes and LENGTH does for |
| +// variable-length rewrites. |
| func (r *Registry) Fingerprint() (string, error) { |
| db, err := sql.Open("sqlite", r.path) |
| if err != nil { |
| @@ -1027,12 +1038,12 @@ SELECT |
| FROM routes) |
| || ':' || |
| (SELECT COUNT(*) || ':' || COALESCE(MAX(rowid),0) || ':' || |
| - COALESCE(SUM(rowid*19 + LENGTH(account)*23 + LENGTH(bucket)*29),0) |
| + COALESCE(SUM(rowid*19 + LENGTH(account)*23 + LENGTH(bucket)*29 + rev*53),0) |
| FROM fleets) |
| || ':' || |
| (SELECT COUNT(*) || ':' || COALESCE(MAX(rowid),0) || ':' || |
| COALESCE(SUM(rowid*31 + LENGTH(fleet)*37 + LENGTH(label)*41 |
| - + LENGTH(token_hash)*43 + LENGTH(status)*47),0) |
| + + LENGTH(token_hash)*43 + LENGTH(status)*47 + rev*59),0) |
| FROM fleet_boxes) |
| `).Scan(&fp) |
| return fp, err |