| 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, ®ion, &fleetBox, &addrs, &health); err != nil { |
| + &channel, &backing, &tunnel, &dbPath, ®ion, &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 { |