carlos / platform Public

registry: gate column, SetGate, and the fingerprint sees it

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev a1d097b03c389fd8aa6416386bd516f058d72cd0 parent 297fb8a
2 files changed, +119 −11
  • internal/registry/registry.go +42 −11
  • internal/registry/registry_test.go +77 −0
diff --git a/internal/registry/registry.go b/internal/registry/registry.go
index 05ede7c7f..48610ddd9 100644
--- a/internal/registry/registry.go
+++ b/internal/registry/registry.go
@@ -122,7 +122,17 @@ type Route struct {
Caps string
Hibernate bool
- Canary bool
+ // Gate is the bcrypt hash of the host's shared password ("" = open),
+ // converged from provision.Record.Gate by SetGate and read by the edge
+ // on every request to the host (internal/gate). Validated at the
+ // console door, never here — SetCaps's rule. IN Fingerprint(): a gate
+ // set or cleared changes what the edge serves, and must reload it.
+ // Upsert preserves a non-empty value against an empty one (the
+ // caps/tunnel/unit idiom), so a full-row writer that predates the
+ // column can never silently open a gated host.
+ Gate string
+
+ Canary bool
// Replication is the route's litestream sync class — the rung of the
// replication ladder its database is replicated on. The vocabulary is
// the four Replication* constants below, and internal/activator maps it
@@ -435,6 +445,7 @@ CREATE TABLE IF NOT EXISTS routes (
`ALTER TABLE routes ADD COLUMN addrs TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE routes ADD COLUMN health TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE routes ADD COLUMN steering TEXT NOT NULL DEFAULT ''`,
+ `ALTER TABLE routes ADD COLUMN gate TEXT NOT NULL DEFAULT ''`,
} {
if _, err := r.sql.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") {
return err
@@ -684,7 +695,7 @@ const routeCols = `host, slug, kind, socket, addr, status, last_error, db_path,
version, prev_version, app, pool, region, channel, hibernate, canary,
replication, next_due, backing, status_named, platform, dns_status,
dns_checked_at, cert_expires_at, account, environment, caps, tunnel,
-provisioned, seed, unit, fleet_box, addrs, health, steering, created_at`
+provisioned, seed, unit, fleet_box, addrs, health, steering, gate, created_at`
func scanRoute(scan func(...any) error) (Route, error) {
var t Route
@@ -694,7 +705,7 @@ func scanRoute(scan func(...any) error) (Route, error) {
&t.Region, &t.Channel, &hib, &can, &t.Replication, &t.NextDue, &t.Backing,
&sn, &plat, &t.DNSStatus, &t.DNSCheckedAt, &t.CertExpiresAt, &t.Account,
&t.Environment, &t.Caps, &t.Tunnel, &prov, &t.Seed, &t.Unit, &t.FleetBox,
- &t.Addrs, &t.Health, &t.Steering, &t.CreatedAt)
+ &t.Addrs, &t.Health, &t.Steering, &t.Gate, &t.CreatedAt)
t.Hibernate = hib != 0
t.Canary = can != 0
t.StatusNamed = sn != 0
@@ -887,7 +898,7 @@ func (r *Registry) Upsert(t Route) error {
}
_, err := r.sql.Exec(`
INSERT INTO routes (`+routeCols+`)
-VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
+VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(host) DO UPDATE SET
slug=excluded.slug, kind=excluded.kind, socket=excluded.socket,
addr=excluded.addr, status=excluded.status, last_error=excluded.last_error,
@@ -908,14 +919,15 @@ ON CONFLICT(host) DO UPDATE SET
seed=CASE WHEN excluded.seed = '' THEN routes.seed ELSE excluded.seed END,
unit=CASE WHEN excluded.unit = '' THEN routes.unit ELSE excluded.unit END,
fleet_box=CASE WHEN excluded.fleet_box = '' THEN routes.fleet_box ELSE excluded.fleet_box END,
- addrs=excluded.addrs, health=excluded.health, steering=excluded.steering`,
+ addrs=excluded.addrs, health=excluded.health, steering=excluded.steering
+ , gate=CASE WHEN excluded.gate = '' THEN routes.gate ELSE excluded.gate END`,
t.Host, t.Slug, t.Kind, t.Socket, t.Addr, t.Status, t.LastError,
t.DBPath, t.Version, t.PrevVersion, t.App, t.Pool, t.Region, t.Channel,
b2i(t.Hibernate), b2i(t.Canary), t.Replication, t.NextDue, t.Backing,
b2i(t.StatusNamed), b2i(t.Platform), t.DNSStatus, t.DNSCheckedAt,
t.CertExpiresAt, t.Account, t.Environment, t.Caps, t.Tunnel,
b2i(t.Provisioned), t.Seed, t.Unit, t.FleetBox, t.Addrs, t.Health,
- t.Steering, t.CreatedAt)
+ t.Steering, t.Gate, t.CreatedAt)
return err
}
@@ -1255,6 +1267,25 @@ func (r *Registry) SetSteering(host, steering string) error {
return nil
}
+// SetGate stamps or clears a route's password gate — the narrow UPDATE the
+// provision converge uses (never a full-row Upsert, which preserves a set
+// gate by design and so could not clear one). Errors on an unmatched host,
+// SetBacking's contract.
+func (r *Registry) SetGate(host, hash string) error {
+ res, err := r.sql.Exec(`UPDATE routes SET gate = ? WHERE host = ?`, hash, host)
+ if err != nil {
+ return err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return fmt.Errorf("no route for host %q", host)
+ }
+ return nil
+}
+
// SetChannels repoints every host in hosts to channel as ONE transaction —
// every row moves or none do. `carlos route -app` uses this for its bulk
// form: the caller runs every route's safety-rail check to completion FIRST
@@ -1926,7 +1957,7 @@ func (r *Registry) Fingerprint() (string, error) {
}
rows, err := db.Query(`
SELECT rowid, status, socket, addr, version, hibernate, channel, backing, tunnel,
- db_path, region, fleet_box, addrs, health
+ db_path, region, fleet_box, addrs, health, gate
FROM routes ORDER BY rowid`)
if err != nil {
return "", err
@@ -1936,14 +1967,14 @@ FROM routes ORDER BY rowid`)
n := 0
for rows.Next() {
var rowid, hibernate int64
- var status, socket, addr, version, channel, backing, tunnel, dbPath, region, fleetBox, addrs, health string
+ var status, socket, addr, version, channel, backing, tunnel, dbPath, region, fleetBox, addrs, health, gate string
if err := rows.Scan(&rowid, &status, &socket, &addr, &version, &hibernate,
- &channel, &backing, &tunnel, &dbPath, &region, &fleetBox, &addrs, &health); err != nil {
+ &channel, &backing, &tunnel, &dbPath, &region, &fleetBox, &addrs, &health, &gate); err != nil {
return "", err
}
- fmt.Fprintf(h, "%d\x1f%s\x1f%s\x1f%s\x1f%s\x1f%d\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1e",
+ fmt.Fprintf(h, "%d\x1f%s\x1f%s\x1f%s\x1f%s\x1f%d\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1e",
rowid, status, socket, addr, version, hibernate, channel, backing, tunnel,
- dbPath, region, fleetBox, addrs, health)
+ dbPath, region, fleetBox, addrs, health, gate)
n++
}
if err := rows.Err(); err != nil {
diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go
index 196a1ad45..2dd991b7b 100644
--- a/internal/registry/registry_test.go
+++ b/internal/registry/registry_test.go
@@ -77,6 +77,7 @@ func TestUpsertRoundTripsAllColumns(t *testing.T) {
Hibernate: true, Canary: true, Replication: "batched",
NextDue: 4200, Backing: BackingUnit, Unit: "console.service",
Steering: "latency",
+ Gate: "$2a$10$distinct",
CreatedAt: 1700000000,
}
if err := reg.Upsert(want); err != nil {
@@ -2524,3 +2525,79 @@ func TestSnapshotChurnColsExist(t *testing.T) {
}
}
}
+
+// Gate round-trips through Upsert (TestUpsertRoundTripsAllColumns is the
+// column-shift canary; this pins the column's own semantics), a full-row
+// Upsert with an empty gate PRESERVES a set one, and SetGate is the one
+// door that clears it.
+func TestGateColumnPreservedByUpsertClearedBySetGate(t *testing.T) {
+ reg := openTestRegistry(t)
+ if err := reg.Upsert(Route{Host: "g.example.com", Kind: "static", Status: "ready", Gate: "$2a$10$hash"}); err != nil {
+ t.Fatal(err)
+ }
+ if got := mustRoute(t, reg, "g.example.com").Gate; got != "$2a$10$hash" {
+ t.Fatalf("Gate after upsert = %q", got)
+ }
+ // A writer that knows nothing about the column must not open the host.
+ if err := reg.Upsert(Route{Host: "g.example.com", Kind: "static", Status: "ready"}); err != nil {
+ t.Fatal(err)
+ }
+ if got := mustRoute(t, reg, "g.example.com").Gate; got != "$2a$10$hash" {
+ t.Fatalf("Gate after a gate-less upsert = %q, want it preserved", got)
+ }
+ if err := reg.SetGate("g.example.com", ""); err != nil {
+ t.Fatal(err)
+ }
+ if got := mustRoute(t, reg, "g.example.com").Gate; got != "" {
+ t.Fatalf("Gate after SetGate(\"\") = %q, want cleared", got)
+ }
+ if err := reg.SetGate("g.example.com", "$2a$10$other"); err != nil {
+ t.Fatal(err)
+ }
+ if got := mustRoute(t, reg, "g.example.com").Gate; got != "$2a$10$other" {
+ t.Fatalf("Gate after SetGate = %q", got)
+ }
+}
+
+func TestSetGateErrorsOnUnmatchedHost(t *testing.T) {
+ reg := openTestRegistry(t)
+ if err := reg.SetGate("nope.example.com", "$2a$10$hash"); err == nil {
+ t.Fatal("SetGate on an unmatched host: want error, got nil")
+ }
+}
+
+// The edge reloads only when Fingerprint moves; a gate set or cleared with
+// the column left out would take effect on the next unrelated reload — an
+// operator turning the password off and watching the page keep asking.
+func TestFingerprintSeesGateChanges(t *testing.T) {
+ reg := openTestRegistry(t)
+ if err := reg.Upsert(Route{Host: "g.example.com", Kind: "static", Status: "ready"}); err != nil {
+ t.Fatal(err)
+ }
+ fp1, err := reg.Fingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetGate("g.example.com", "$2a$10$hash"); err != nil {
+ t.Fatal(err)
+ }
+ fp2, err := reg.Fingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if fp1 == fp2 {
+ t.Fatal("gate change invisible to Fingerprint — the edge would keep serving the old answer")
+ }
+}
+
+func mustRoute(t *testing.T, reg *Registry, host string) Route {
+ t.Helper()
+ got, err := reg.Get(host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got == nil {
+ t.Fatalf("no route %s", host)
+ }
+ return *got
+}