carlos / platform Public

Merge branch 'worktree-password-gate' into main

claude agent, operated by paul@keymail.dev df7d1d25dd2d069ed77a6ed11996a31baae079ce parent 40c6ab7parent 9ad07bd
28 files changed, +5340 −27
  • apimode.go +35 −0
  • docs/site/cli.md +23 −1
  • docs/superpowers/plans/2026-09-07-password-gate.md +2130 −0
  • docs/superpowers/specs/2026-09-07-password-gate-design.md +445 −0
  • gate_cli.go +232 −0
  • gate_cli_test.go +273 −0
  • internal/console/apiinstances.go +69 −6
  • internal/console/apiinstances_test.go +125 −5
  • internal/console/appsettings.go +123 −0
  • internal/console/appsettings_gate_test.go +132 −0
  • internal/console/channelmove.go +2 −2
  • internal/console/server.go +3 −0
  • internal/console/templates/app_settings.html +38 −0
  • internal/edge/edge.go +87 −1
  • internal/edge/gate_test.go +508 −0
  • internal/gate/cookie.go +61 −0
  • internal/gate/gate.go +259 −0
  • internal/gate/gate_test.go +399 −0
  • internal/gate/limiter.go +64 −0
  • internal/gate/login.html +55 −0
  • internal/gate/page.go +41 −0
  • internal/provision/provision.go +18 −0
  • internal/provision/provision_test.go +11 −0
  • internal/provision/reconcile.go +19 −0
  • internal/provision/reconcile_test.go +65 −0
  • internal/registry/registry.go +43 −12
  • internal/registry/registry_test.go +77 −0
  • main.go +3 −0

CI passed — run details

diff --git a/apimode.go b/apimode.go
index 4348c1561..523c97127 100644
--- a/apimode.go
+++ b/apimode.go
@@ -2535,6 +2535,16 @@ type apiInstanceRecord struct {
// the binding became something a member could set (2026-09-04), and a
// column the CLI cannot read is a column the CLI cannot show is wrong.
Environment string `json:"environment"`
+ // Gated says whether the host has a password gate (spec
+ // 2026-09-07-password-gate-design §5.4). The hash itself never leaves
+ // the console. A POINTER because absent and false are different facts
+ // here: nil means the console predates the field and has told us
+ // nothing, while false is its word that the host is open. Decoding the
+ // silence as "open" would have `carlos gate list` print a reassuring
+ // OPEN for hosts it knows nothing about — the one wrong answer this
+ // column can give, since the person reading it is checking whether
+ // something private is exposed.
+ Gated *bool `json:"gated"`
}
// apiUpstreamState mirrors status.UpstreamState's wire shape (internal/status)
@@ -2716,6 +2726,31 @@ func (s *apiSession) instanceSetSteering(ctx context.Context, a apiAcct, app, ho
return out, nil
}
+// instanceSetGate sets ("" clears) one instance's password gate through
+// the console, which hashes it; the plaintext crosses the wire once, over
+// TLS. A console older than the field answers 400 "nothing to update",
+// which is reported as exactly that.
+func (s *apiSession) instanceSetGate(ctx context.Context, a apiAcct, app, host, password string) (apiInstanceRecord, error) {
+ in := struct {
+ Gate string `json:"gate"`
+ }{password}
+ status, body, err := s.patchJSON(ctx, apiPath(a, app, "/instances/"+url.PathEscape(host)), in)
+ if err != nil {
+ return apiInstanceRecord{}, err
+ }
+ if status != http.StatusOK {
+ if status == http.StatusBadRequest && strings.Contains(string(body), "nothing to update") {
+ return apiInstanceRecord{}, fmt.Errorf("this console does not know about password gates yet — it needs a newer carlos")
+ }
+ return apiInstanceRecord{}, apiErrorFromBody(status, body)
+ }
+ var out apiInstanceRecord
+ if err := json.Unmarshal(body, &out); err != nil {
+ return apiInstanceRecord{}, fmt.Errorf("gate: unreadable response: %w", err)
+ }
+ return out, nil
+}
+
// instanceList reads the app's instance records through the console, honest
// about the server's 500-record cap. match and health are the server-side
// filters Task 9 added (q and health query params) — empty strings send
diff --git a/docs/site/cli.md b/docs/site/cli.md
index ba2b1cb8c..cf24fdcfb 100644
--- a/docs/site/cli.md
+++ b/docs/site/cli.md
@@ -443,7 +443,8 @@ An app's instances are the places it actually runs. `carlos instances enable`
is the app's opt-in to provisioning, and until it is set the API refuses to
create anything. After that, `create`, `list`, `delete`, `set-upstreams`,
`set-channel` and `set-environment` work on records through the console, and
-the box's reconcile pass turns a record into a live route.
+the box's reconcile pass turns a record into a live route. A password in
+front of an instance's hostname is `carlos gate`.
```sh
carlos instances enable --app hello
@@ -548,6 +549,27 @@ restart, no environment edit, no root-owned file on an edge box. Setting is
an admin-role write and every change is audited under the app's Activity;
the app's own instance credential can read the flags but never set them.
+### carlos gate
+
+Puts one shared password in front of an app's hostnames — for a static site
+or a prototype that should not be world-readable. Visitors see a plain page
+asking for the password and stay signed in for a week; the app behind it
+never sees the cookie.
+
+```sh
+carlos gate set --app site
+carlos gate list --app site
+carlos gate clear --app site
+```
+
+`set` reads the password from `--password` or, without it, one line from
+stdin, so it need not land in your shell history. Without `--host` the
+change applies to every instance of the app; name one to narrow it. Changing
+or clearing the password signs every visitor out at once. Custom domains
+attached to the app are gated along with the platform hostname. This is a
+curtain in front of the whole hostname, not accounts for the app's own
+users; an app with real sign-in keeps doing that itself.
+
### carlos logs
The app's own stdout and stderr, merged with the platform's events about it:
diff --git a/docs/superpowers/plans/2026-09-07-password-gate.md b/docs/superpowers/plans/2026-09-07-password-gate.md
new file mode 100644
index 000000000..5ba48c00d
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-07-password-gate.md
@@ -0,0 +1,2130 @@
+# Password Gate Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** A member can put one shared password in front of an instance's hostname (static site or prototype) from `carlos gate` or the console's app Settings page, and the edge shows a neutral login page to anyone without the cookie.
+
+**Architecture:** The bcrypt hash lives on the instance record (`provision.Record.Gate`), reconcile converges it into a new `routes.gate` column, and the edge enforces it in `ServeHTTP` ahead of the WebSocket bypass, the cache and the activator. A new package `internal/gate` owns the cookie (HMAC keyed off the hash, so a password change invalidates every cookie), the attempt limiter, the login page and the password rules; the console's API PATCH and Settings form both call those rules.
+
+**Tech Stack:** Go 1.26, `golang.org/x/crypto/bcrypt` (already a dependency), `html/template`, `embed`, SQLite via `internal/registry`.
+
+**Spec:** `docs/superpowers/specs/2026-09-07-password-gate-design.md` — read it first; every task below cites a section.
+
+## Global Constraints
+
+- `go build ./...`, `go vet ./...`, `go test ./... -count=1`, `gofmt -l .` clean before every commit (AGENTS.md "Build & test").
+- Sandbox: run go with `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`; if the toolchain reports "read-only file system", run that command with the sandbox disabled.
+- Cross-compile gate: no Linux-only syscalls anywhere in this change (`internal/gate` and the CLI are linked into the Mac binary). Nothing in this plan needs `//go:build`.
+- Wire shapes are additive: `gate` is `omitempty` on the record; the registry column defaults to `''`.
+- Never log, audit, echo or store a plaintext password. Log lines about the gate print `open` or `set`, never the hash.
+- Copy is fixed by the spec §4.4 and §5.5; do not reword it.
+- Commit messages: end with `Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>`. Commit on the branch `worktree-password-gate`; never touch `main`.
+- Model per AGENTS.md: Tasks 1, 2, 6, 7 → Sonnet; Tasks 3, 4, 5 → Opus. Opus reviews Tasks 3, 4, 5 (security- and production-touching).
+
+---
+
+### Task 1: Registry column `gate` (spec §3.2)
+
+**Files:**
+- Modify: `internal/registry/registry.go` — `Route` struct (near line 124, `Hibernate bool`), migration list (line ~417), `routeCols` (line ~683), `scanRoute` (line ~689), `Upsert` VALUES and `ON CONFLICT` (lines ~885-919), `Fingerprint` (line ~1918), new `SetGate` beside `SetSteering` (line ~1093).
+- Test: `internal/registry/registry_test.go`
+
+**Interfaces:**
+- Produces: `Route.Gate string`; `func (r *Registry) SetGate(host, hash string) error` (narrow UPDATE, error on unmatched host).
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `internal/registry/registry_test.go`:
+
+```go
+// 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
+}
+```
+
+Also add `Gate: "$2a$10$distinct"` to the `want` literal in `TestUpsertRoundTripsAllColumns` (every column there carries a distinct value).
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `go test ./internal/registry/ -run 'Gate|UpsertRoundTrips' -count=1`
+Expected: compile error, `Route` has no field `Gate`.
+
+- [ ] **Step 3: Implement**
+
+In `Route` (after `Hibernate bool`, line ~124):
+
+```go
+ // 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
+```
+
+Migration list: append `` `ALTER TABLE routes ADD COLUMN gate TEXT NOT NULL DEFAULT ''`, `` as the LAST entry.
+
+`routeCols`: change the tail to `... addrs, health, steering, gate, created_at`.
+
+`scanRoute`: scan `&t.Steering, &t.Gate, &t.CreatedAt`.
+
+`Upsert`: one more `?` in VALUES (37 total); in `ON CONFLICT` add after the `steering=excluded.steering` line: `, gate=CASE WHEN excluded.gate = '' THEN routes.gate ELSE excluded.gate END`; argument list `t.Steering, t.Gate, t.CreatedAt`.
+
+`Fingerprint`: add `gate` to the SELECT after `health`, scan into a new `gate string`, and append `\x1f%s` with `gate` to the `Fprintf` format and args.
+
+New setter beside `SetSteering`:
+
+```go
+// 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
+}
+```
+
+Search the package for other places that enumerate columns (`grep -n "steering" internal/registry/*.go`) — `SnapshotFingerprint` uses `routeCols` and needs nothing; any other hand-written SELECT that lists every column must gain `gate`.
+
+- [ ] **Step 4: Run the package tests**
+
+Run: `go test ./internal/registry/ -count=1`
+Expected: PASS, including `TestUpsertRoundTripsAllColumns`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/registry/registry.go internal/registry/registry_test.go
+git commit -m "registry: gate column, SetGate, and the fingerprint sees it"
+```
+
+---
+
+### Task 2: Record field, validation and converge (spec §3.1, §3.3)
+
+**Files:**
+- Modify: `internal/provision/provision.go` — `Record` (after `Steering`, line ~123), `ValidateShape` (line ~380).
+- Modify: `internal/provision/reconcile.go` — the owned-converge arm beside the hibernate block (line ~805-820).
+- Test: `internal/provision/provision_test.go`, `internal/provision/reconcile_test.go`
+
+**Interfaces:**
+- Consumes: `registry.SetGate` (Task 1).
+- Produces: `Record.Gate string \`json:"gate,omitempty"\``; `ValidateShape` refuses a non-bcrypt gate.
+
+- [ ] **Step 1: Write the failing tests**
+
+In `internal/provision/provision_test.go` add:
+
+```go
+func TestValidateShapeGate(t *testing.T) {
+ ok := Record{Kind: "static", Gate: "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"}
+ if err := ValidateShape(ok); err != nil {
+ t.Fatalf("bcrypt gate refused: %v", err)
+ }
+ bad := Record{Kind: "static", Gate: "hunter2"}
+ if err := ValidateShape(bad); err == nil || !strings.Contains(err.Error(), "gate") {
+ t.Fatalf("plaintext gate accepted: %v", err)
+ }
+}
+```
+
+In `internal/provision/reconcile_test.go` add (mirrors `TestReconcileConvergesSteering`):
+
+```go
+func TestReconcileConvergesGate(t *testing.T) {
+ s, sqid := setup(t)
+ rec := acmeRecord(sqid)
+ if err := CreateRecord(ctx, s, rec); err != nil {
+ t.Fatal(err)
+ }
+ reg := regWith(t)
+ cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"}
+ if err := Reconcile(ctx, s, reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ before := *mustGet(t, reg, "acme.go.tito.io")
+ if before.Gate != "" {
+ t.Fatalf("minted Gate = %q, want open", before.Gate)
+ }
+
+ const hash = "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"
+ rec.Gate = hash
+ putReplace(t, s, RecordKey(sqid, "tito", rec.Host), mustJSON(t, rec))
+ if err := Reconcile(ctx, s, reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ after := *mustGet(t, reg, "acme.go.tito.io")
+ if after.Gate != hash {
+ t.Errorf("gate = %q, want the hash", after.Gate)
+ }
+ after.Gate = before.Gate
+ if after != before {
+ t.Errorf("converging gate touched another column:\n before %+v\n after %+v", before, after)
+ }
+
+ // A record with no gate field is an OPEN host — it is a string, not a
+ // pointer: absent and "" are the same fact, and clearing must reach the
+ // row through SetGate.
+ rec.Gate = ""
+ putReplace(t, s, RecordKey(sqid, "tito", rec.Host), mustJSON(t, rec))
+ if err := Reconcile(ctx, s, reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ if got := mustGet(t, reg, "acme.go.tito.io").Gate; got != "" {
+ t.Errorf("gate = %q, want it cleared", got)
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `go test ./internal/provision/ -run 'Gate' -count=1`
+Expected: compile error, no field `Gate`.
+
+- [ ] **Step 3: Implement**
+
+`Record`, after `Steering`:
+
+```go
+ // Gate is the bcrypt hash of the host's shared password ("" = open,
+ // the value every record written before this field existed decodes
+ // to). The ONLY place the secret material lives in the bucket; the
+ // plaintext crosses the console once, at the door that hashes it
+ // (internal/gate.Hash), and is never stored, logged or echoed — the
+ // console's record views shadow this field (apiinstances.go). A
+ // string, not a pointer: absent and "" are the same fact, an open
+ // host. Converged into routes.gate by convergeGate and read by the
+ // edge on every request. Spec docs/superpowers/specs/2026-09-07-password-gate-design.md.
+ Gate string `json:"gate,omitempty"`
+```
+
+`ValidateShape`, after the steering check:
+
+```go
+ if rec.Gate != "" {
+ if _, err := bcrypt.Cost([]byte(rec.Gate)); err != nil {
+ return errors.New("gate: want a bcrypt hash — the console hashes the password; a record never carries it in clear")
+ }
+ }
+```
+
+Import `golang.org/x/crypto/bcrypt`. (Do not import `internal/gate` here: `internal/gate` will import nothing from provision, but keeping provision free of it keeps the record package's dependency list what it is.)
+
+`reconcile.go`, in the `existing.Provisioned && existing.App == rec.App && ...` arm, directly after the hibernate block:
+
+```go
+ if existing.Gate != rec.Gate {
+ if cfg.DryRun {
+ log.Printf("provision: dry-run: would converge %s gate %s -> %s", rec.Host, gateWord(existing.Gate), gateWord(rec.Gate))
+ } else if err := reg.SetGate(rec.Host, rec.Gate); err != nil {
+ log.Printf("provision: converge gate on %s: %v", rec.Host, err)
+ } else {
+ log.Printf("provision: converged %s gate %s -> %s", rec.Host, gateWord(existing.Gate), gateWord(rec.Gate))
+ }
+ }
+```
+
+and at file scope:
+
+```go
+// gateWord renders a gate column for a log line: "open" or "set", never the
+// hash — it has no business in a journal.
+func gateWord(hash string) string {
+ if hash == "" {
+ return "open"
+ }
+ return "set"
+}
+```
+
+Also in `routeFor` (reconcile.go:289) set `Gate: rec.Gate` on the minted route so a record created already gated (a future create-with-gate) mints closed; today the create door never sets it, so this is a one-line consistency edit.
+
+- [ ] **Step 4: Run the package tests**
+
+Run: `go test ./internal/provision/ -count=1`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/provision/
+git commit -m "provision: Record.Gate, validated as a bcrypt hash and converged into routes.gate"
+```
+
+---
+
+### Task 3: `internal/gate` — rules, cookie, limiter, page, Admit (spec §4.2–§4.4, §5.1)
+
+**Files:**
+- Create: `internal/gate/gate.go`, `internal/gate/cookie.go`, `internal/gate/limiter.go`, `internal/gate/page.go`, `internal/gate/login.html`
+- Test: `internal/gate/gate_test.go`
+
+**Interfaces:**
+- Produces:
+ - `const CookieName = "carlos_gate"`, `const LoginPath = "/.carlos/gate"`, `const Lifetime = 7 * 24 * time.Hour`
+ - `func CheckPassword(p string) error`, `func Hash(p string) (string, error)`
+ - `func Mint(host, hash string, expiry time.Time) string`, `func Verify(value, host, hash string, now time.Time) bool`
+ - `type Limiter`, `func NewLimiter(now func() time.Time) *Limiter`, `func (l *Limiter) Allow(key string) bool`
+ - `type Gate`, `func New() *Gate`, `func (g *Gate) Admit(w http.ResponseWriter, r *http.Request, host, hash string) bool`
+
+- [ ] **Step 1: Write the failing tests**
+
+`internal/gate/gate_test.go`:
+
+```go
+package gate
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestCheckPassword(t *testing.T) {
+ if err := CheckPassword("short7!"); err == nil {
+ t.Fatal("7 chars accepted")
+ }
+ if err := CheckPassword(strings.Repeat("x", 73)); err == nil {
+ t.Fatal("73 bytes accepted — bcrypt would silently truncate")
+ }
+ if err := CheckPassword("longenough"); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestHashThenVerifyPassword(t *testing.T) {
+ h, err := Hash("correct horse")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.HasPrefix(h, "$2a$10$") {
+ t.Fatalf("hash = %q, want bcrypt cost 10", h)
+ }
+ if !comparePassword(h, "correct horse") || comparePassword(h, "wrong horse") {
+ t.Fatal("comparePassword disagrees with Hash")
+ }
+}
+
+func TestCookieRoundTrip(t *testing.T) {
+ now := time.Unix(1_800_000_000, 0)
+ v := Mint("site.example", "$2a$10$hash", now.Add(Lifetime))
+ if !Verify(v, "site.example", "$2a$10$hash", now) {
+ t.Fatal("fresh cookie refused")
+ }
+ if Verify(v, "other.example", "$2a$10$hash", now) {
+ t.Fatal("cookie accepted for another host")
+ }
+ if Verify(v, "site.example", "$2a$10$rotated", now) {
+ t.Fatal("cookie accepted after the password changed")
+ }
+ if Verify(v, "site.example", "$2a$10$hash", now.Add(Lifetime+time.Second)) {
+ t.Fatal("expired cookie accepted")
+ }
+ if Verify("v1.garbage", "site.example", "$2a$10$hash", now) || Verify("", "site.example", "$2a$10$hash", now) {
+ t.Fatal("malformed cookie accepted")
+ }
+}
+
+func TestLimiterTenPerMinute(t *testing.T) {
+ now := time.Unix(0, 0)
+ l := NewLimiter(func() time.Time { return now })
+ for i := 0; i < 10; i++ {
+ if !l.Allow("k") {
+ t.Fatalf("attempt %d refused", i+1)
+ }
+ }
+ if l.Allow("k") {
+ t.Fatal("11th attempt allowed")
+ }
+ if !l.Allow("other") {
+ t.Fatal("another key shares the bucket")
+ }
+ now = now.Add(6 * time.Second)
+ if !l.Allow("k") {
+ t.Fatal("no refill after 6s")
+ }
+}
+
+func testGate(hash string) *Gate {
+ g := New()
+ g.now = func() time.Time { return time.Unix(1_800_000_000, 0) }
+ return g
+}
+
+func TestAdmitNoCookieIsTheLoginPage(t *testing.T) {
+ g := testGate("")
+ h, _ := Hash("correct horse")
+ for _, method := range []string{http.MethodGet, http.MethodPost, http.MethodHead} {
+ rec := httptest.NewRecorder()
+ r := httptest.NewRequest(method, "https://site.example/docs?x=1", nil)
+ if g.Admit(rec, r, "site.example", h) {
+ t.Fatalf("%s without a cookie admitted", method)
+ }
+ if rec.Code != http.StatusUnauthorized {
+ t.Fatalf("%s status = %d, want 401", method, rec.Code)
+ }
+ if cc := rec.Header().Get("Cache-Control"); cc != "no-store" {
+ t.Fatalf("Cache-Control = %q", cc)
+ }
+ if rt := rec.Header().Get("X-Robots-Tag"); rt != "noindex, nofollow" {
+ t.Fatalf("X-Robots-Tag = %q", rt)
+ }
+ body := rec.Body.String()
+ if method == http.MethodHead {
+ if body != "" {
+ t.Fatal("HEAD carried a body")
+ }
+ continue
+ }
+ if !strings.Contains(body, "Password required") || !strings.Contains(body, "site.example") {
+ t.Fatalf("page missing copy: %s", body)
+ }
+ if !strings.Contains(body, `name="next" value="/docs?x=1"`) {
+ t.Fatalf("page does not carry next: %s", body)
+ }
+ if !strings.Contains(body, `action="/.carlos/gate"`) {
+ t.Fatalf("form action wrong: %s", body)
+ }
+ }
+}
+
+func postLogin(g *Gate, host, hash, password, next string) *httptest.ResponseRecorder {
+ rec := httptest.NewRecorder()
+ form := url.Values{"password": {password}, "next": {next}}
+ r := httptest.NewRequest(http.MethodPost, "https://"+host+LoginPath, strings.NewReader(form.Encode()))
+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ r.RemoteAddr = "203.0.113.9:4242"
+ g.Admit(rec, r, host, hash)
+ return rec
+}
+
+func TestAdmitRightPasswordSetsCookieAndRedirects(t *testing.T) {
+ g := testGate("")
+ h, _ := Hash("correct horse")
+ rec := postLogin(g, "site.example", h, "correct horse", "/docs?x=1")
+ if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/docs?x=1" {
+ t.Fatalf("got %d -> %q, want 303 -> /docs?x=1", rec.Code, rec.Header().Get("Location"))
+ }
+ var c *http.Cookie
+ for _, k := range rec.Result().Cookies() {
+ if k.Name == CookieName {
+ c = k
+ }
+ }
+ if c == nil {
+ t.Fatal("no gate cookie set")
+ }
+ if !c.HttpOnly || !c.Secure || c.SameSite != http.SameSiteLaxMode || c.Path != "/" || c.MaxAge != int(Lifetime/time.Second) {
+ t.Fatalf("cookie attributes: %+v", c)
+ }
+ if !Verify(c.Value, "site.example", h, g.now()) {
+ t.Fatal("minted cookie does not verify")
+ }
+ // And the cookie is admitted, and stripped from what the app sees.
+ rec2 := httptest.NewRecorder()
+ r := httptest.NewRequest(http.MethodGet, "https://site.example/", nil)
+ r.Header.Set("Cookie", "theme=dark; "+CookieName+"="+c.Value+"; other=1")
+ if !g.Admit(rec2, r, "site.example", h) {
+ t.Fatal("valid cookie refused")
+ }
+ if got := r.Header.Get("Cookie"); got != "theme=dark; other=1" {
+ t.Fatalf("upstream Cookie = %q, want the gate cookie stripped", got)
+ }
+}
+
+func TestAdmitBadNextBecomesRoot(t *testing.T) {
+ g := testGate("")
+ h, _ := Hash("correct horse")
+ for _, next := range []string{"//evil.example/", "https://evil.example/", "docs", ""} {
+ rec := postLogin(g, "site.example", h, "correct horse", next)
+ if loc := rec.Header().Get("Location"); loc != "/" {
+ t.Fatalf("next %q -> Location %q, want /", next, loc)
+ }
+ }
+}
+
+func TestAdmitWrongPasswordThenLimit(t *testing.T) {
+ g := testGate("")
+ h, _ := Hash("correct horse")
+ compares := 0
+ g.compare = func(hash, password string) bool { compares++; return false }
+ for i := 0; i < 10; i++ {
+ rec := postLogin(g, "site.example", h, "nope nope", "/")
+ if rec.Code != http.StatusUnauthorized || !strings.Contains(rec.Body.String(), "That password is not right.") {
+ t.Fatalf("attempt %d: %d %s", i+1, rec.Code, rec.Body.String())
+ }
+ }
+ rec := postLogin(g, "site.example", h, "nope nope", "/")
+ if rec.Code != http.StatusTooManyRequests || rec.Header().Get("Retry-After") != "60" {
+ t.Fatalf("11th attempt: %d Retry-After=%q, want 429/60", rec.Code, rec.Header().Get("Retry-After"))
+ }
+ if !strings.Contains(rec.Body.String(), "Too many attempts. Try again in a minute.") {
+ t.Fatalf("429 page copy: %s", rec.Body.String())
+ }
+ if compares != 10 {
+ t.Fatalf("bcrypt compared %d times, want 10 — the limiter must refuse BEFORE comparing", compares)
+ }
+}
+
+func TestLoginPathOnGetIsJustAGatedRequest(t *testing.T) {
+ g := testGate("")
+ h, _ := Hash("correct horse")
+ rec := httptest.NewRecorder()
+ r := httptest.NewRequest(http.MethodGet, "https://site.example"+LoginPath, nil)
+ if g.Admit(rec, r, "site.example", h) || rec.Code != http.StatusUnauthorized {
+ t.Fatalf("GET %s: admitted=%v code=%d", LoginPath, false, rec.Code)
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `go test ./internal/gate/ -count=1`
+Expected: build fails, package does not exist.
+
+- [ ] **Step 3: Implement**
+
+`internal/gate/gate.go`:
+
+```go
+// Package gate is the password curtain the edge holds in front of a gated
+// host: one shared password per hostname, a neutral page that asks for it,
+// and a cookie that remembers the answer for a week. Spec:
+// docs/superpowers/specs/2026-09-07-password-gate-design.md.
+//
+// The edge calls Admit once per request to a route whose registry row
+// carries a gate hash. Everything else here — the rules the console
+// applies before hashing, the cookie's shape, the attempt limiter — is
+// owned by this package so the two console doors and the edge cannot
+// drift from one another.
+package gate
+
+import (
+ "fmt"
+ "net"
+ "net/http"
+ "strings"
+ "time"
+
+ "golang.org/x/crypto/bcrypt"
+)
+
+const (
+ // CookieName is the browser cookie a passed gate sets; stripped from
+ // the request before it reaches the app.
+ CookieName = "carlos_gate"
+ // LoginPath is the form's action on a gated host. Ordinary on every
+ // other host and for every other method.
+ LoginPath = "/.carlos/gate"
+ // Lifetime is how long a cookie stays valid; the expiry is inside the
+ // value, so a stolen cookie is bounded even if the browser keeps it.
+ Lifetime = 7 * 24 * time.Hour
+
+ bcryptCost = 10
+ minPassword = 8
+ maxPassword = 72 // bcrypt's input limit; longer is refused, never truncated
+ maxFormBody = 4 << 10
+)
+
+// CheckPassword is the door's rule, applied by the console before hashing.
+func CheckPassword(p string) error {
+ if len(p) < minPassword {
+ return fmt.Errorf("password must be at least %d characters", minPassword)
+ }
+ if len(p) > maxPassword {
+ return fmt.Errorf("password must be at most %d bytes", maxPassword)
+ }
+ return nil
+}
+
+// Hash is bcrypt at cost 10. The console calls it once per set; the edge
+// never does (it compares on the login POST and verifies an HMAC cookie
+// on every other request).
+func Hash(p string) (string, error) {
+ if err := CheckPassword(p); err != nil {
+ return "", err
+ }
+ b, err := bcrypt.GenerateFromPassword([]byte(p), bcryptCost)
+ if err != nil {
+ return "", err
+ }
+ return string(b), nil
+}
+
+func comparePassword(hash, password string) bool {
+ return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
+}
+
+// Gate is the per-edge instance: one limiter shared by every gated host on
+// the box (keys carry the host), a clock and a comparer for tests.
+type Gate struct {
+ limiter *Limiter
+ now func() time.Time
+ compare func(hash, password string) bool
+}
+
+// New is the edge's constructor. There is no configuration.
+func New() *Gate {
+ g := &Gate{now: time.Now, compare: comparePassword}
+ g.limiter = NewLimiter(func() time.Time { return g.now() })
+ return g
+}
+
+// Admit decides one request to a gated host. host is the route's own
+// hostname (the row's, not the request's — an alias and a path-routed
+// prefix must agree on which key signs), hash the row's gate.
+//
+// true: the request carries a valid cookie, now stripped from the Cookie
+// header; the caller proxies it exactly as it would have. false: Admit has
+// written the whole response — the login page (401), the redirect after a
+// successful POST (303), or the too-many-attempts page (429).
+func (g *Gate) Admit(w http.ResponseWriter, r *http.Request, host, hash string) bool {
+ if r.Method == http.MethodPost && r.URL.Path == LoginPath {
+ g.login(w, r, host, hash)
+ return false
+ }
+ if c, err := r.Cookie(CookieName); err == nil && Verify(c.Value, host, hash, g.now()) {
+ stripCookie(r)
+ return true
+ }
+ writePage(w, r, host, http.StatusUnauthorized, "", safeNext(r.URL.RequestURI()))
+ return false
+}
+
+func (g *Gate) login(w http.ResponseWriter, r *http.Request, host, hash string) {
+ r.Body = http.MaxBytesReader(w, r.Body, maxFormBody)
+ if err := r.ParseForm(); err != nil {
+ writePage(w, r, host, http.StatusUnauthorized, msgWrong, "/")
+ return
+ }
+ next := safeNext(r.PostForm.Get("next"))
+ if !g.limiter.Allow(clientKey(r) + "\x1f" + host) {
+ w.Header().Set("Retry-After", "60")
+ writePage(w, r, host, http.StatusTooManyRequests, msgTooMany, next)
+ return
+ }
+ if !g.compare(hash, r.PostForm.Get("password")) {
+ writePage(w, r, host, http.StatusUnauthorized, msgWrong, next)
+ return
+ }
+ http.SetCookie(w, &http.Cookie{
+ Name: CookieName,
+ Value: Mint(host, hash, g.now().Add(Lifetime)),
+ Path: "/",
+ MaxAge: int(Lifetime / time.Second),
+ HttpOnly: true,
+ Secure: true,
+ SameSite: http.SameSiteLaxMode,
+ })
+ w.Header().Set("Cache-Control", "no-store")
+ http.Redirect(w, r, next, http.StatusSeeOther)
+}
+
+// safeNext is the entire open-redirect surface: a path on this host, or "/".
+func safeNext(next string) string {
+ if !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") || strings.HasPrefix(next, "/\\") {
+ return "/"
+ }
+ return next
+}
+
+// clientKey is the remote address without its port. A backhauled request's
+// address is the sibling edge's, which folds every hop from that edge into
+// one bucket — stricter, never looser, and named in the spec (§4.2).
+func clientKey(r *http.Request) string {
+ if h, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
+ return h
+ }
+ return r.RemoteAddr
+}
+
+// stripCookie removes the gate cookie from the request so the app behind
+// the curtain never sees it. Other cookies pass through in their order.
+func stripCookie(r *http.Request) {
+ cookies := r.Cookies()
+ r.Header.Del("Cookie")
+ parts := make([]string, 0, len(cookies))
+ for _, c := range cookies {
+ if c.Name != CookieName {
+ parts = append(parts, c.Name+"="+c.Value)
+ }
+ }
+ if len(parts) > 0 {
+ r.Header.Set("Cookie", strings.Join(parts, "; "))
+ }
+}
+```
+
+`internal/gate/cookie.go`:
+
+```go
+package gate
+
+import (
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/base64"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// The cookie is v1.<expiry unix>.<base64url(HMAC-SHA256(key, host "\n" expiry))>
+// with key derived from the stored bcrypt hash and nothing else. That is
+// what makes the design stateless and gives it its one rotation rule:
+// change or clear the password and every cookie minted under the old one
+// stops verifying at once, on every box, with no key to mint or mirror.
+// Anyone who can read the hash can mint a cookie — root on the box and a
+// bucket reader, who already hold the tenant's sealed secrets (spec §4.3).
+
+const cookieVersion = "v1"
+
+func cookieKey(hash string) []byte {
+ m := hmac.New(sha256.New, []byte("carlos-gate-cookie-v1"))
+ m.Write([]byte(hash))
+ return m.Sum(nil)
+}
+
+func cookieMAC(key []byte, host string, expiry int64) []byte {
+ m := hmac.New(sha256.New, key)
+ m.Write([]byte(host))
+ m.Write([]byte{'\n'})
+ m.Write([]byte(strconv.FormatInt(expiry, 10)))
+ return m.Sum(nil)
+}
+
+// Mint produces a cookie value for host, valid until expiry.
+func Mint(host, hash string, expiry time.Time) string {
+ exp := expiry.Unix()
+ mac := cookieMAC(cookieKey(hash), host, exp)
+ return cookieVersion + "." + strconv.FormatInt(exp, 10) + "." + base64.RawURLEncoding.EncodeToString(mac)
+}
+
+// Verify reports whether value was minted for host under hash and has not
+// expired at now. One HMAC and a constant-time compare; no bcrypt.
+func Verify(value, host, hash string, now time.Time) bool {
+ parts := strings.Split(value, ".")
+ if len(parts) != 3 || parts[0] != cookieVersion {
+ return false
+ }
+ exp, err := strconv.ParseInt(parts[1], 10, 64)
+ if err != nil || exp <= now.Unix() {
+ return false
+ }
+ got, err := base64.RawURLEncoding.DecodeString(parts[2])
+ if err != nil {
+ return false
+ }
+ return hmac.Equal(got, cookieMAC(cookieKey(hash), host, exp))
+}
+```
+
+`internal/gate/limiter.go`:
+
+```go
+package gate
+
+import (
+ "sync"
+ "time"
+)
+
+const (
+ limitBurst = 10
+ limitPeriod = time.Minute
+ limitPrune = 10_000
+)
+
+// Limiter is a per-key token bucket: limitBurst attempts, refilling at
+// limitBurst per limitPeriod. bcrypt at cost 10 already costs tens of
+// milliseconds a comparison; this exists so the CPU is spent on people and
+// not on a script.
+type Limiter struct {
+ mu sync.Mutex
+ now func() time.Time
+ buckets map[string]*bucket
+}
+
+type bucket struct {
+ tokens float64
+ last time.Time
+}
+
+func NewLimiter(now func() time.Time) *Limiter {
+ return &Limiter{now: now, buckets: map[string]*bucket{}}
+}
+
+// Allow spends one token for key, reporting whether one was available.
+func (l *Limiter) Allow(key string) bool {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ now := l.now()
+ b, ok := l.buckets[key]
+ if !ok {
+ if len(l.buckets) >= limitPrune {
+ l.prune(now)
+ }
+ b = &bucket{tokens: limitBurst, last: now}
+ l.buckets[key] = b
+ }
+ refill := now.Sub(b.last).Seconds() * (limitBurst / limitPeriod.Seconds())
+ b.tokens = min(limitBurst, b.tokens+refill)
+ b.last = now
+ if b.tokens < 1 {
+ return false
+ }
+ b.tokens--
+ return true
+}
+
+// prune drops every bucket that has been idle long enough to be full again;
+// keeping it would change no answer.
+func (l *Limiter) prune(now time.Time) {
+ for k, b := range l.buckets {
+ if now.Sub(b.last) >= limitPeriod {
+ delete(l.buckets, k)
+ }
+ }
+}
+```
+
+`internal/gate/page.go`:
+
+```go
+package gate
+
+import (
+ _ "embed"
+ "html/template"
+ "log"
+ "net/http"
+)
+
+//go:embed login.html
+var loginHTML string
+
+var loginTmpl = template.Must(template.New("login").Parse(loginHTML))
+
+const (
+ msgWrong = "That password is not right."
+ msgTooMany = "Too many attempts. Try again in a minute."
+)
+
+type pageData struct {
+ Host string
+ Error string
+ Next string
+ Action string
+}
+
+// writePage renders the login page at status. Every page this package
+// writes is no-store and noindex: it is a curtain, not content.
+func writePage(w http.ResponseWriter, r *http.Request, host string, status int, msg, next string) {
+ h := w.Header()
+ h.Set("Cache-Control", "no-store")
+ h.Set("X-Robots-Tag", "noindex, nofollow")
+ h.Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(status)
+ if r.Method == http.MethodHead {
+ return
+ }
+ if err := loginTmpl.Execute(w, pageData{Host: host, Error: msg, Next: next, Action: LoginPath}); err != nil {
+ log.Printf("gate: render login page for %s: %v", host, err)
+ }
+}
+```
+
+`internal/gate/login.html` — the palette is `internal/status/assets/status.css`'s, inline, so the page is one response:
+
+```html
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<meta name="robots" content="noindex, nofollow">
+<title>Password required</title>
+<style>
+:root {
+ --bg: #ffffff; --ink: #1d2029; --muted: #64707f; --accent: #0f5e85;
+ --accent-soft: rgba(15, 94, 133, 0.09); --rule: #e7e9f0; --bad: #b3403a;
+}
+@media (prefers-color-scheme: dark) {
+ :root {
+ --bg: #14151c; --ink: #eef0f6; --muted: #93a4ba; --accent: #5db8dd;
+ --accent-soft: rgba(93, 184, 221, 0.12); --rule: #2a2e38; --bad: #e07b76;
+ }
+}
+* { margin: 0; padding: 0; box-sizing: border-box; }
+body {
+ background: var(--bg); color: var(--ink);
+ font: 400 1rem/1.6 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
+ min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 1.25rem;
+}
+main { width: 100%; max-width: 22rem; }
+h1 { font-size: 1.25rem; margin-bottom: 0.5rem; }
+p { color: var(--muted); margin-bottom: 1.25rem; }
+p code { color: var(--ink); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.95em; }
+label { display: block; font-size: 0.85rem; color: var(--muted); margin-bottom: 0.35rem; }
+input[type=password] {
+ width: 100%; font: inherit; color: var(--ink); background: var(--bg);
+ border: 1px solid var(--rule); border-radius: 0.4rem; padding: 0.55rem 0.7rem; margin-bottom: 0.9rem;
+}
+input[type=password]:focus { outline: 2px solid var(--accent); outline-offset: 1px; border-color: var(--accent); }
+button {
+ width: 100%; font: inherit; font-weight: 600; color: var(--bg); background: var(--accent);
+ border: 0; border-radius: 0.4rem; padding: 0.6rem 0.9rem; cursor: pointer;
+}
+.error { color: var(--bad); margin: 0 0 0.9rem; }
+</style>
+</head>
+<body>
+<main>
+ <h1>Password required</h1>
+ <p><code>{{.Host}}</code> is private. Enter the password to continue.</p>
+ <form method="post" action="{{.Action}}">
+ <input type="hidden" name="next" value="{{.Next}}">
+ {{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
+ <label for="password">Password</label>
+ <input type="password" id="password" name="password" autocomplete="current-password" autofocus required>
+ <button type="submit">Continue</button>
+ </form>
+</main>
+</body>
+</html>
+```
+
+- [ ] **Step 4: Run the package tests**
+
+Run: `go test ./internal/gate/ -count=1 && go vet ./internal/gate/`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/gate/
+git commit -m "gate: password rules, HMAC cookie keyed off the hash, limiter and login page"
+```
+
+---
+
+### Task 4: The edge enforces it (spec §4.1)
+
+**Files:**
+- Modify: `internal/edge/edge.go` — `Edge` struct (line ~86), `New` (line ~274), `ServeHTTP` between the usage-meter wrap and the `isUpgradeRequest` branch (line ~1232-1250).
+- Test: `internal/edge/gate_test.go` (new)
+
+**Interfaces:**
+- Consumes: `gate.New`, `(*gate.Gate).Admit`, `gate.CookieName`, `gate.Mint`, `gate.Hash`, `gate.LoginPath` (Task 3); `registry.Route.Gate` (Task 1).
+
+- [ ] **Step 1: Write the failing tests**
+
+`internal/edge/gate_test.go`:
+
+```go
+package edge
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/gate"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+func gatedEdge(t *testing.T, act Activator, hibernate bool) (*Edge, *httptest.Server, string, *string) {
+ t.Helper()
+ backend, seenCookie := cookieEchoBackend(t, "secret page")
+ h, err := gate.Hash("correct horse")
+ if err != nil {
+ t.Fatal(err)
+ }
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{Host: "gated.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(),
+ Status: "ready", Hibernate: hibernate, Gate: h}); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.UpsertAlias(registry.RouteAlias{Alias: "www.custom.example", Host: "gated.example.com"}); err != nil {
+ t.Fatal(err)
+ }
+ e, err := New(reg, act)
+ if err != nil {
+ t.Fatal(err)
+ }
+ srv := httptest.NewServer(e)
+ t.Cleanup(srv.Close)
+ return e, srv, h, seenCookie
+}
+
+// cookieEchoBackend records the Cookie header it saw.
+func cookieEchoBackend(t *testing.T, body string) (*httptest.Server, *string) {
+ t.Helper()
+ seen := new(string)
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ *seen = r.Header.Get("Cookie")
+ io.WriteString(w, body)
+ }))
+ t.Cleanup(ts.Close)
+ return ts, seen
+}
+
+func doHost(t *testing.T, srv *httptest.Server, method, host, path, cookie string) *http.Response {
+ t.Helper()
+ req, _ := http.NewRequest(method, srv.URL+path, nil)
+ req.Host = host
+ if cookie != "" {
+ req.Header.Set("Cookie", cookie)
+ }
+ c := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
+ resp, err := c.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { resp.Body.Close() })
+ return resp
+}
+
+func TestGatedRouteWithoutCookieNeverWakesOrProxies(t *testing.T) {
+ _, srv, _, seen := gatedEdge(t, refusingActivator{t: t}, true)
+ resp := doHost(t, srv, http.MethodGet, "gated.example.com", "/docs", "")
+ if resp.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("status = %d, want 401", resp.StatusCode)
+ }
+ b, _ := io.ReadAll(resp.Body)
+ if !strings.Contains(string(b), "Password required") {
+ t.Fatalf("body: %s", b)
+ }
+ if resp.Header.Get("Cache-Control") != "no-store" {
+ t.Fatalf("Cache-Control = %q", resp.Header.Get("Cache-Control"))
+ }
+ if *seen != "" || strings.Contains(string(b), "secret page") {
+ t.Fatal("upstream was reached without a cookie")
+ }
+}
+
+func TestGatedRouteCookieAdmitsAndIsStripped(t *testing.T) {
+ _, srv, h, seen := gatedEdge(t, &fakeActivator{}, false)
+ cookie := gate.CookieName + "=" + gate.Mint("gated.example.com", h, time.Now().Add(time.Hour))
+ resp := doHost(t, srv, http.MethodGet, "gated.example.com", "/", "a=1; "+cookie)
+ b, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode != 200 || string(b) != "secret page" {
+ t.Fatalf("got %d %q", resp.StatusCode, b)
+ }
+ if *seen != "a=1" {
+ t.Fatalf("upstream Cookie = %q, want the gate cookie stripped", *seen)
+ }
+}
+
+func TestGatedAliasIsGatedToo(t *testing.T) {
+ _, srv, h, _ := gatedEdge(t, &fakeActivator{}, false)
+ if resp := doHost(t, srv, http.MethodGet, "www.custom.example", "/", ""); resp.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("alias without cookie = %d, want 401", resp.StatusCode)
+ }
+ // The MAC is bound to the ROUTE host, so a cookie minted for the primary
+ // verifies when presented on the alias.
+ cookie := gate.CookieName + "=" + gate.Mint("gated.example.com", h, time.Now().Add(time.Hour))
+ if resp := doHost(t, srv, http.MethodGet, "www.custom.example", "/", cookie); resp.StatusCode != 200 {
+ t.Fatalf("alias with cookie = %d, want 200", resp.StatusCode)
+ }
+}
+
+func TestGatedLoginPostSetsCookie(t *testing.T) {
+ _, srv, h, _ := gatedEdge(t, &fakeActivator{}, false)
+ form := url.Values{"password": {"correct horse"}, "next": {"/docs"}}
+ req, _ := http.NewRequest(http.MethodPost, srv.URL+gate.LoginPath, strings.NewReader(form.Encode()))
+ req.Host = "gated.example.com"
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ c := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
+ resp, err := c.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "/docs" {
+ t.Fatalf("got %d -> %q", resp.StatusCode, resp.Header.Get("Location"))
+ }
+ var got string
+ for _, k := range resp.Cookies() {
+ if k.Name == gate.CookieName {
+ got = k.Value
+ }
+ }
+ if got == "" || !gate.Verify(got, "gated.example.com", h, time.Now()) {
+ t.Fatalf("cookie %q does not verify", got)
+ }
+}
+
+func TestGatedRouteCacheIsNeverConsultedBeforeTheGate(t *testing.T) {
+ e, srv, _, _ := gatedEdge(t, &fakeActivator{}, false)
+ e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
+ e.cache.Put(CacheKey{Host: "gated.example.com", Method: "GET", Path: "/"}, "", &CachedResponse{
+ StatusCode: 200, Header: http.Header{"Content-Type": {"text/plain"}}, Body: []byte("cached secret"),
+ StoredAt: time.Now(), TTL: time.Minute,
+ })
+ resp := doHost(t, srv, http.MethodGet, "gated.example.com", "/", "")
+ b, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode != http.StatusUnauthorized || strings.Contains(string(b), "cached secret") {
+ t.Fatalf("cache served past the gate: %d %s", resp.StatusCode, b)
+ }
+}
+
+func TestGatedWebSocketHandshakeIsGated(t *testing.T) {
+ _, srv, h, _ := gatedEdge(t, &fakeActivator{}, false)
+ req, _ := http.NewRequest(http.MethodGet, srv.URL+"/ws", nil)
+ req.Host = "gated.example.com"
+ req.Header.Set("Connection", "Upgrade")
+ req.Header.Set("Upgrade", "websocket")
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("upgrade without cookie = %d, want 401", resp.StatusCode)
+ }
+ req.Header.Set("Cookie", gate.CookieName+"="+gate.Mint("gated.example.com", h, time.Now().Add(time.Hour)))
+ resp, err = http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode == http.StatusUnauthorized {
+ t.Fatal("upgrade with cookie was refused")
+ }
+}
+
+func TestUngatedRouteUnchanged(t *testing.T) {
+ backend, seen := cookieEchoBackend(t, "open page")
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{Host: "open.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil {
+ t.Fatal(err)
+ }
+ e, err := New(reg, &fakeActivator{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ srv := httptest.NewServer(e)
+ defer srv.Close()
+ resp := doHost(t, srv, http.MethodGet, "open.example.com", "/", gate.CookieName+"=whatever; a=1")
+ if resp.StatusCode != 200 || *seen != gate.CookieName+"=whatever; a=1" {
+ t.Fatalf("ungated route touched: %d cookie=%q", resp.StatusCode, *seen)
+ }
+}
+```
+
+`registry.RouteAlias` has more fields than `Alias` and `Host`; check its definition (`grep -n "type RouteAlias" internal/registry/registry.go`) and set any that the edge's alias fold requires (a status, for instance) the way `internal/edge`'s existing alias tests do (`grep -n "UpsertAlias" internal/edge/*_test.go`).
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `go test ./internal/edge/ -run 'Gated|Ungated' -count=1`
+Expected: the no-cookie tests fail with 200 (the page is served open).
+
+- [ ] **Step 3: Implement**
+
+`Edge` struct, near `act`:
+
+```go
+ // gate is the password curtain (internal/gate) for routes whose row
+ // carries a gate hash. Always constructed: the check is one string
+ // compare on up.route.Gate for every ungated host, and the limiter it
+ // carries is shared across the box's gated hosts.
+ gate *gate.Gate
+```
+
+`New`: `e := &Edge{reg: reg, act: act, gate: gate.New(), probers: ...}`.
+
+`ServeHTTP`, immediately after the usage-meter block (the `if m := e.usageMeter.Load(); ...` statement) and before the `isUpgradeRequest` branch:
+
+```go
+ // The password gate (internal/gate; spec 2026-09-07-password-gate-design
+ // §4.1). Placed HERE and the neighbours are load-bearing: after the
+ // meter, so the login page's bytes bill to the route that caused them;
+ // before the upgrade branch, so a WebSocket handshake is gated; before
+ // the cache check, so a cache hit is only ever served to a request that
+ // already passed (the LRU keys on host and path, not on cookies); and
+ // before the activator, so a stranger — a crawler, a scanner, a guessed
+ // hostname — never wakes a sleeping instance. Admit strips the cookie
+ // on the way through, so the app never sees it.
+ if up.route.Gate != "" && !e.gate.Admit(w, r, up.route.Host, up.route.Gate) {
+ return
+ }
+```
+
+Import `github.com/carlosframework/platform/internal/gate`. Note the receiver field `e.gate` and the package `gate` share a name; Go resolves `e.gate` as the field and `gate.New()` as the package — this is fine, but if a reviewer prefers, name the field `pw`.
+
+- [ ] **Step 4: Run the package tests**
+
+Run: `go test ./internal/edge/ -count=1`
+Expected: PASS, every pre-existing test included.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/edge/edge.go internal/edge/gate_test.go
+git commit -m "edge: enforce the password gate ahead of the upgrade branch, the cache and the activator"
+```
+
+---
+
+### Task 5: Console API — PATCH `gate`, and the hash never leaves (spec §5.2, §5.4)
+
+**Files:**
+- Modify: `internal/console/apiinstances.go` — `instanceListRecord` (line ~133), `instanceGetRecord` (line ~480), `handleAPIInstanceCreate`'s `writeJSON` (line ~757), `handleAPIInstanceUpdate` (req struct ~line 920, mutation ~line 960, `changed` list ~line 1105, `writeJSON` ~line 1119).
+- Test: `internal/console/apiinstances_test.go`
+
+**Interfaces:**
+- Consumes: `gate.CheckPassword`, `gate.Hash` (Task 3).
+- Produces: PATCH body field `gate` (string: password to set, `""` to clear, absent to leave); every record response carries `"gated": bool` and never a `"gate"` key.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `internal/console/apiinstances_test.go`:
+
+```go
+func TestAPIInstanceUpdateGateRoundTrip(t *testing.T) {
+ fx, token := instanceFixture(t, "code-inst-gate-round")
+ enableInstances(t, fx, "titogo", sharedPoolManifest())
+ u := instancesURL(fx, "bab", "titogo")
+ if status, _, out := rawReq(t, http.MethodPost, u, token,
+ `{"host":"gate.go.tito.io","region":"ie","addrs":["127.0.0.1:7001"]}`); status != http.StatusCreated {
+ t.Fatalf("create = %d %s", status, out)
+ }
+ pu := updateURL(fx, "bab", "titogo", "gate.go.tito.io")
+
+ status, _, out := rawReq(t, http.MethodPatch, pu, token, `{"gate":"correct horse"}`)
+ if status != http.StatusOK {
+ t.Fatalf("patch gate = %d %s, want 200", status, out)
+ }
+ var echoed map[string]any
+ if err := json.Unmarshal([]byte(out), &echoed); err != nil {
+ t.Fatal(err)
+ }
+ if _, leaked := echoed["gate"]; leaked {
+ t.Fatalf("patch reply carries the gate hash: %s", out)
+ }
+ if echoed["gated"] != true {
+ t.Fatalf("patch reply gated = %v, want true", echoed["gated"])
+ }
+ stored, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "gate.go.tito.io")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.HasPrefix(stored.Gate, "$2a$10$") {
+ t.Fatalf("stored gate = %q, want a bcrypt hash", stored.Gate)
+ }
+ if strings.Contains(stored.Gate, "correct horse") {
+ t.Fatal("stored gate carries the plaintext")
+ }
+ if detail := lastEventDetail(t, fx, "titogo"); detail != "updated instance gate.go.tito.io (gate) (cli)" {
+ t.Fatalf("audit detail = %q", detail)
+ }
+
+ // The list and the GET shadow the hash the same way.
+ for _, path := range []string{u, pu} {
+ _, _, body := rawReq(t, http.MethodGet, path, token, "")
+ if strings.Contains(body, "$2a$") {
+ t.Fatalf("GET %s leaks the hash: %s", path, body)
+ }
+ if !strings.Contains(body, `"gated":true`) {
+ t.Fatalf("GET %s lacks gated:true: %s", path, body)
+ }
+ }
+
+ status, _, out = rawReq(t, http.MethodPatch, pu, token, `{"gate":""}`)
+ if status != http.StatusOK {
+ t.Fatalf("patch clear = %d %s", status, out)
+ }
+ stored, _ = provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "gate.go.tito.io")
+ if stored.Gate != "" {
+ t.Fatalf("stored gate after clear = %q", stored.Gate)
+ }
+ _, _, body := rawReq(t, http.MethodGet, pu, token, "")
+ if !strings.Contains(body, `"gated":false`) {
+ t.Fatalf("GET after clear: %s", body)
+ }
+}
+
+func TestAPIInstanceUpdateGateRefusals(t *testing.T) {
+ fx, token := instanceFixture(t, "code-inst-gate-refuse")
+ enableInstances(t, fx, "titogo", sharedPoolManifest())
+ u := instancesURL(fx, "bab", "titogo")
+ if status, _, out := rawReq(t, http.MethodPost, u, token,
+ `{"host":"gr.go.tito.io","region":"ie","addrs":["127.0.0.1:7001"]}`); status != http.StatusCreated {
+ t.Fatalf("create = %d %s", status, out)
+ }
+ pu := updateURL(fx, "bab", "titogo", "gr.go.tito.io")
+ for _, body := range []string{`{"gate":"short7!"}`, `{"gate":"` + strings.Repeat("x", 73) + `"}`} {
+ status, _, out := rawReq(t, http.MethodPatch, pu, token, body)
+ if status != http.StatusBadRequest || !strings.Contains(out, "password") {
+ t.Fatalf("%s -> %d %s, want 400 naming the password rule", body, status, out)
+ }
+ if strings.Contains(out, "short7!") || strings.Contains(out, "xxxxxxxx") {
+ t.Fatalf("refusal echoes the password: %s", out)
+ }
+ }
+ // The empty-body rail names gate among the patchable fields.
+ if _, _, out := rawReq(t, http.MethodPatch, pu, token, `{}`); !strings.Contains(out, "gate") {
+ t.Fatalf("empty-body refusal should name gate: %s", out)
+ }
+}
+```
+
+Also extend `TestAPIInstanceUpdateAuditNamesChangedFields`'s expectation comment if it enumerates the field order (`addrs, probe, steering, environment, gate`).
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `go test ./internal/console/ -run 'InstanceUpdateGate' -count=1`
+Expected: FAIL — PATCH answers 400 "nothing to update".
+
+- [ ] **Step 3: Implement**
+
+Record views. Add to `instanceListRecord` and `instanceGetRecord`, after the embedded `provision.Record`:
+
+```go
+ // HiddenGate shadows Record.Gate for encoding/json (a shallower field of
+ // the same name wins), so the bcrypt hash never leaves the console; it
+ // is always "" and omitted. Gated is what a member may know.
+ HiddenGate string `json:"gate,omitempty"`
+ Gated bool `json:"gated"`
+```
+
+Set `Gated: rec.Gate != ""` wherever each is constructed (list loop, GET). Add a view for the write replies:
+
+```go
+// apiRecordView is a record as the write doors answer it: the whole record
+// with the gate hash shadowed and a gated flag in its place. Every handler
+// that used to writeJSON a bare record answers through this.
+type apiRecordView struct {
+ provision.Record
+ HiddenGate string `json:"gate,omitempty"`
+ Gated bool `json:"gated"`
+}
+
+func recordView(rec provision.Record) apiRecordView {
+ return apiRecordView{Record: rec, Gated: rec.Gate != ""}
+}
+```
+
+Change `writeJSON(w, http.StatusCreated, rec)` in create to `writeJSON(w, http.StatusCreated, recordView(rec))` (adjust for pointer/value as the variable is typed) and `writeJSON(w, http.StatusOK, rec)` at the end of update to `writeJSON(w, http.StatusOK, recordView(*rec))`. Grep the package for any other `writeJSON(..., rec)` of a provision record (steering, channel, environment, move handlers) and route them through `recordView` too.
+
+PATCH. In the `req` struct add `Gate *string \`json:"gate"\`` with a comment:
+
+```go
+ // Gate is a *string like Steering: absent leaves the gate alone,
+ // "" opens the host, anything else is the new password — hashed
+ // here, never stored or echoed (spec §5.2). The plaintext crosses
+ // the wire once, over TLS, the same as a sign-in.
+ Gate *string `json:"gate"`
+```
+
+Extend the all-nil check with `&& req.Gate == nil` and the message to `"nothing to update — send addrs, probe, steering, environment, gate, or a combination"`.
+
+In the mutation block, after the Environment case:
+
+```go
+ if req.Gate != nil {
+ if *req.Gate == "" {
+ rec.Gate = ""
+ } else {
+ if err := gate.CheckPassword(*req.Gate); err != nil {
+ jsonError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ h, err := gate.Hash(*req.Gate)
+ if err != nil {
+ log.Printf("console: api instances update %s/%s/%s: hash gate: %v", acct.Sqid, app, host, err)
+ jsonError(w, http.StatusInternalServerError, "could not set the password — try again")
+ return
+ }
+ rec.Gate = h
+ }
+ }
+```
+
+In the `changed` list, after steering (and environment if it is listed; add it if the existing code omits it): `if req.Gate != nil { changed = append(changed, "gate") }`.
+
+Import `github.com/carlosframework/platform/internal/gate`.
+
+- [ ] **Step 4: Run the package tests**
+
+Run: `go test ./internal/console/ -count=1`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/console/apiinstances.go internal/console/apiinstances_test.go
+git commit -m "console: PATCH gate hashes the password; record replies carry gated, never the hash"
+```
+
+---
+
+### Task 6: Console Settings page — set or turn off the password (spec §5.5)
+
+**Files:**
+- Modify: `internal/console/appsettings.go` — `SettingsView` (line ~47), `assembleSettings` (line ~202); new `handleAppGate` in the same file.
+- Modify: `internal/console/server.go` — route table after `/place` (line ~1070).
+- Modify: `internal/console/templates/app_settings.html` — new section after "Domains & TLS"'s attach form.
+- Test: `internal/console/appsettings_gate_test.go` (new)
+
+**Interfaces:**
+- Consumes: `gate.CheckPassword`, `gate.Hash`; `provision.ListRecords`, `provision.GetRecordETag`, `provision.UpdateRecord`; `s.audit`, `s.setFlash`, `s.bumpSerial`, `s.accountApp`.
+- Produces: `POST /a/{sqid}/apps/{app}/gate` with form fields `csrf`, `host`, `action` (`set`|`clear`), `password`.
+
+- [ ] **Step 1: Write the failing test**
+
+`internal/console/appsettings_gate_test.go` (fixture pattern from `TestAppPlacePageMovesAndClears`, fleets_test.go:1244 — `placeFixture` gives a console with a deployment bucket; use `enableInstances` + the API create to seed a record, then the browser session for the form):
+
+```go
+package console
+
+import (
+ "context"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/provision"
+)
+
+func TestAppSettingsGateSetAndClear(t *testing.T) {
+ fx, token := instanceFixture(t, "code-settings-gate")
+ enableInstances(t, fx, "titogo", sharedPoolManifest())
+ if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), token,
+ `{"host":"pw.go.tito.io","region":"ie","addrs":["127.0.0.1:7001"]}`); status != http.StatusCreated {
+ t.Fatalf("create = %d %s", status, out)
+ }
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-settings-gate-browser")
+ csrf := sessionToken(t, fx, c)
+
+ // The page shows the host as open, with a form.
+ _, _, body := get(t, c, fx.u("/apps/titogo/settings"))
+ if !strings.Contains(body, "Password gate") || !strings.Contains(body, "pw.go.tito.io") || !strings.Contains(body, "Open") {
+ t.Fatalf("settings page lacks the gate section: %s", body)
+ }
+
+ status, loc, _ := postForm(t, c, fx.u("/apps/titogo/gate"),
+ url.Values{"csrf": {csrf}, "host": {"pw.go.tito.io"}, "action": {"set"}, "password": {"correct horse"}})
+ if status != http.StatusSeeOther || loc != fx.path("/apps/titogo/settings") {
+ t.Fatalf("set = %d -> %q", status, loc)
+ }
+ rec, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "pw.go.tito.io")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.HasPrefix(rec.Gate, "$2a$10$") {
+ t.Fatalf("stored gate = %q", rec.Gate)
+ }
+ _, _, body = get(t, c, fx.u("/apps/titogo/settings"))
+ if !strings.Contains(body, "Password set on pw.go.tito.io.") || !strings.Contains(body, "Turn off") {
+ t.Fatalf("after set: %s", body)
+ }
+ if strings.Contains(body, "$2a$") || strings.Contains(body, "correct horse") {
+ t.Fatal("page leaks the hash or the password")
+ }
+
+ // A short password is refused with the rule, and nothing changes.
+ postForm(t, c, fx.u("/apps/titogo/gate"),
+ url.Values{"csrf": {csrf}, "host": {"pw.go.tito.io"}, "action": {"set"}, "password": {"short"}})
+ _, _, body = get(t, c, fx.u("/apps/titogo/settings"))
+ if !strings.Contains(body, "at least 8 characters") {
+ t.Fatalf("short password not refused on the page: %s", body)
+ }
+
+ status, _, _ = postForm(t, c, fx.u("/apps/titogo/gate"),
+ url.Values{"csrf": {csrf}, "host": {"pw.go.tito.io"}, "action": {"clear"}})
+ if status != http.StatusSeeOther {
+ t.Fatalf("clear = %d", status)
+ }
+ rec, _ = provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "pw.go.tito.io")
+ if rec.Gate != "" {
+ t.Fatalf("gate after clear = %q", rec.Gate)
+ }
+ events, _ := fx.st.Events(OpsAccountID, "titogo", 20)
+ var saw int
+ for _, e := range events {
+ if e.Action == "gate" {
+ saw++
+ if strings.Contains(e.Detail, "correct horse") {
+ t.Fatal("audit carries the password")
+ }
+ }
+ }
+ if saw != 2 {
+ t.Fatalf("gate audit rows = %d, want 2", saw)
+ }
+}
+
+func TestAppSettingsGateNeedsCSRF(t *testing.T) {
+ fx, _ := instanceFixture(t, "code-settings-gate-csrf")
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-settings-gate-csrf-browser")
+ status, _, body := postForm(t, c, fx.u("/apps/titogo/gate"),
+ url.Values{"csrf": {"not-the-token"}, "host": {"pw.go.tito.io"}, "action": {"clear"}})
+ if status != http.StatusForbidden || !strings.Contains(body, "Cross-site request blocked.") {
+ t.Fatalf("got %d %s", status, body)
+ }
+}
+```
+
+`get` is server_test.go's existing GET helper (status, location, body). If `instanceFixture` does not create the `titogo` app for the browser account, look at how `TestAppPlacePageMovesAndClears`'s `placeFixture` seeds it and use that instead; the point is one fixture with a bucket, a record, and a signed-in browser.
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `go test ./internal/console/ -run 'AppSettingsGate' -count=1`
+Expected: FAIL — 404 on the POST, no section on the page.
+
+- [ ] **Step 3: Implement**
+
+`SettingsView` gains:
+
+```go
+ // Gates is the password-gate section (spec §5.5): one row per instance
+ // record, with whether a password is set. GatesUnavailable carries the
+ // reason when the records could not be read (no bucket, a store error)
+ // and the section says so instead of rendering an empty list as "no
+ // instances".
+ Gates []GateRow
+ GatesUnavailable string
+```
+
+```go
+// GateRow is one instance on the Settings page's password-gate section.
+type GateRow struct {
+ Host string
+ Gated bool
+}
+```
+
+In `assembleSettings`, before the `if s.cfg.Releases == nil` branch (so it runs regardless of what that branch does with RenameBlocked), add:
+
+```go
+ sv.Gates, sv.GatesUnavailable = s.settingsGates(ctx, acct, p.AppName)
+```
+
+and:
+
+```go
+// settingsGates lists the app's instance records for the password-gate
+// section. The hash never reaches the template: GateRow carries a bool.
+func (s *Server) settingsGates(ctx context.Context, acct *Account, app string) ([]GateRow, string) {
+ if s.cfg.Releases == nil {
+ return nil, "No deployment bucket on this host, so instance records cannot be read."
+ }
+ recs, _, err := provision.ListRecords(ctx, s.cfg.Releases, acct.Sqid, app, instanceListMax)
+ if err != nil {
+ log.Printf("console: settings gates %s/%s: %v", acct.Sqid, app, err)
+ return nil, "Instance records could not be read just now."
+ }
+ rows := make([]GateRow, 0, len(recs))
+ for _, rec := range recs {
+ rows = append(rows, GateRow{Host: rec.Host, Gated: rec.Gate != ""})
+ }
+ sort.Slice(rows, func(i, j int) bool { return rows[i].Host < rows[j].Host })
+ return rows, ""
+}
+```
+
+Handler, in `appsettings.go`:
+
+```go
+// handleAppGate is the Settings page's password-gate form: set a password
+// on one instance host, or turn it off. Member-gated, matching the API
+// PATCH's door — the CLI already lets a member do this, and a form that
+// refuses what the CLI allows is a rule in the wrong place. CSRF first,
+// then the same read-mutate-validate-write-audit sequence as the PATCH,
+// against one field. The password is never logged, audited or echoed.
+func (s *Server) handleAppGate(w http.ResponseWriter, r *http.Request) {
+ addr, raw, acct, _, app, ok := s.accountApp(w, r)
+ if !ok {
+ return
+ }
+ if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
+ s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
+ return
+ }
+ back := func(kind, msg string) {
+ s.setFlash(w, kind, msg)
+ http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/settings",
+ http.StatusSeeOther)
+ }
+ if s.cfg.Releases == nil {
+ back("err", "No deployment bucket on this host, so the gate cannot be changed here.")
+ return
+ }
+ host := strings.TrimSpace(r.FormValue("host"))
+ if !provision.ValidHostShape(host) {
+ http.NotFound(w, r)
+ return
+ }
+ var hash string
+ switch r.FormValue("action") {
+ case "clear":
+ case "set":
+ p := r.FormValue("password")
+ if err := gate.CheckPassword(p); err != nil {
+ back("err", err.Error()+".")
+ return
+ }
+ h, err := gate.Hash(p)
+ if err != nil {
+ log.Printf("console: gate hash %s/%s/%s: %v", acct.Sqid, app, host, err)
+ back("err", "Could not set the password — try again.")
+ return
+ }
+ hash = h
+ default:
+ back("err", "Choose set or clear.")
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
+ defer cancel()
+ rec, etag, err := provision.GetRecordETag(ctx, s.cfg.Releases, acct.Sqid, app, host)
+ if errors.Is(err, os.ErrNotExist) {
+ http.NotFound(w, r)
+ return
+ }
+ if err != nil {
+ log.Printf("console: gate read %s/%s/%s: %v", acct.Sqid, app, host, err)
+ back("err", "Could not read the instance — try again.")
+ return
+ }
+ rec.Gate = hash
+ if err := provision.ValidateShape(*rec); err != nil {
+ back("err", err.Error()+".")
+ return
+ }
+ if err := provision.UpdateRecord(ctx, s.cfg.Releases, *rec, etag); err != nil {
+ if errors.Is(err, release.ErrCASConflict) {
+ back("err", "The instance changed while this request was in flight — try again.")
+ return
+ }
+ log.Printf("console: gate write %s/%s/%s: %v", acct.Sqid, app, host, err)
+ back("err", "Could not update the instance — try again.")
+ return
+ }
+ if hash == "" {
+ s.audit(acct, app, addr, "gate", "cleared password gate on "+host+" (console)")
+ s.bumpSerial(ctx)
+ back("ok", "Password turned off on "+host+".")
+ return
+ }
+ s.audit(acct, app, addr, "gate", "set password gate on "+host+" (console)")
+ s.bumpSerial(ctx)
+ back("ok", "Password set on "+host+".")
+}
+```
+
+Route table, after the `/place` line: `s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/gate", s.handleAppGate)`.
+
+Template, inserted after the "Attach a custom domain" block's closing `{{end}}` and before `{{if and .Settings .Settings.MismatchTarget}}`:
+
+```html
+<h2>Password gate</h2>
+<p class="small">One shared password in front of a hostname — for a static
+site or a prototype that should not be world-readable. Visitors see a plain
+page asking for it and stay signed in for a week. It is set per instance.</p>
+{{if .Settings.GatesUnavailable}}<p class="small">{{.Settings.GatesUnavailable}}</p>
+{{else if .Settings.Gates}}
+<div class="tablewrap">
+<table>
+ <tr><th>Host</th><th>State</th><th></th></tr>
+ {{range $i, $g := .Settings.Gates}}
+ <tr>
+ <td><code>{{$g.Host}}</code></td>
+ <td>{{if $g.Gated}}Password set{{else}}Open{{end}}</td>
+ <td>
+ <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/gate" class="settings-row">
+ <input type="hidden" name="csrf" value="{{$.CSRF}}">
+ <input type="hidden" name="host" value="{{$g.Host}}">
+ <input type="hidden" name="action" value="set">
+ <label class="visually-hidden" for="gate-pw-{{$i}}">Password for {{$g.Host}}</label>
+ <input type="password" id="gate-pw-{{$i}}" name="password" minlength="8" maxlength="72"
+ autocomplete="new-password" placeholder="password" required>
+ <button type="submit" class="btn-sm">{{if $g.Gated}}Change{{else}}Set password{{end}}</button>
+ </form>
+ {{if $g.Gated}}
+ <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/gate" class="settings-row">
+ <input type="hidden" name="csrf" value="{{$.CSRF}}">
+ <input type="hidden" name="host" value="{{$g.Host}}">
+ <input type="hidden" name="action" value="clear">
+ <button type="submit" class="btn-sm btn-danger">Turn off</button>
+ </form>
+ {{end}}
+ </td>
+ </tr>
+ {{end}}
+</table>
+</div>
+{{else}}<p class="small">No instances yet. A password gate is set per instance.</p>{{end}}
+```
+
+The section is inside no `{{if .IsOwner}}` — members see it, matching the handler.
+
+- [ ] **Step 4: Run the package tests**
+
+Run: `go test ./internal/console/ -count=1`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/console/appsettings.go internal/console/server.go internal/console/templates/app_settings.html internal/console/appsettings_gate_test.go
+git commit -m "console: password gate section on app Settings — set, change, turn off"
+```
+
+---
+
+### Task 7: `carlos gate` and the docs (spec §5.3, §6)
+
+**Files:**
+- Create: `gate_cli.go`, `gate_cli_test.go`
+- Modify: `apimode.go` — `apiInstanceRecord` (line ~2485, add `Gated`), new `instanceSetGate` beside `instanceSetSteering` (line ~2699).
+- Modify: `main.go` — `case "gate":` after `features` (line ~60); the `usage` text (line ~195, one line beside `features`).
+- Modify: `docs/site/cli.md` — new `### carlos gate` after `### carlos features`; one sentence in `### carlos instances`.
+
+**Interfaces:**
+- Consumes: PATCH `gate` (Task 5); `instanceList`, `patchJSON`, `apiPath`, `resolvedAccount`, `resolvedApp`, `printUsing`, `mutedf`, `appFlag`, `newTable`, `colorFor` (existing).
+- Produces: `carlos gate set|clear|list`.
+
+- [ ] **Step 1: Write the failing tests**
+
+`gate_cli_test.go`:
+
+```go
+package main
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestGateNeedsASubVerb(t *testing.T) {
+ out, err := runCarlosCLI(t, "gate")
+ if err == nil {
+ t.Fatalf("bare `gate` exited 0: %s", out)
+ }
+ for _, w := range []string{"set", "clear", "list"} {
+ if !strings.Contains(out, w) {
+ t.Fatalf("usage should name %s: %s", w, out)
+ }
+ }
+}
+
+func TestGateSetRefusesAnEmptyPasswordBeforeTheNetwork(t *testing.T) {
+ out, err := runCarlosCLI(t, "gate", "set", "-app", "site", "-password", "")
+ if err == nil || !strings.Contains(out, "password") {
+ t.Fatalf("empty -password: err=%v out=%s", err, out)
+ }
+}
+
+func TestReadGatePassword(t *testing.T) {
+ if got, err := readGatePassword("flagged", strings.NewReader("")); err != nil || got != "flagged" {
+ t.Fatalf("flag: %q %v", got, err)
+ }
+ if got, err := readGatePassword("", strings.NewReader("from stdin\n")); err != nil || got != "from stdin" {
+ t.Fatalf("stdin: %q %v", got, err)
+ }
+ if _, err := readGatePassword("", strings.NewReader("\n")); err == nil {
+ t.Fatal("empty stdin line accepted")
+ }
+}
+```
+
+`runCarlosCLI` — reuse `runFeaturesCLI`'s body (features_cli_test.go:44) if no generic one exists: build with `buildCarlos`, env from `carlosTestEnv`, `CombinedOutput`. Name it `runCarlosCLI` in `gate_cli_test.go` only if nothing else defines it.
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `go test . -run 'Gate' -count=1`
+Expected: compile error, `readGatePassword` undefined.
+
+- [ ] **Step 3: Implement**
+
+`apimode.go`: add to `apiInstanceRecord`:
+
+```go
+ // Gated says whether the host has a password gate (spec
+ // 2026-09-07-password-gate-design §5.4). The hash itself never leaves
+ // the console. False from a console that predates the field.
+ Gated bool `json:"gated"`
+```
+
+and beside `instanceSetSteering`:
+
+```go
+// instanceSetGate sets ("" clears) one instance's password gate through
+// the console, which hashes it; the plaintext crosses the wire once, over
+// TLS. A console older than the field answers 400 "nothing to update",
+// which is reported as exactly that.
+func (s *apiSession) instanceSetGate(ctx context.Context, a apiAcct, app, host, password string) (apiInstanceRecord, error) {
+ in := struct {
+ Gate string `json:"gate"`
+ }{password}
+ status, body, err := s.patchJSON(ctx, apiPath(a, app, "/instances/"+url.PathEscape(host)), in)
+ if err != nil {
+ return apiInstanceRecord{}, err
+ }
+ if status != http.StatusOK {
+ if status == http.StatusBadRequest && strings.Contains(string(body), "nothing to update") {
+ return apiInstanceRecord{}, fmt.Errorf("this console does not know about password gates yet — it needs a newer carlos")
+ }
+ return apiInstanceRecord{}, apiErrorFromBody(status, body)
+ }
+ var out apiInstanceRecord
+ if err := json.Unmarshal(body, &out); err != nil {
+ return apiInstanceRecord{}, fmt.Errorf("gate: unreadable response: %w", err)
+ }
+ return out, nil
+}
+```
+
+`gate_cli.go`:
+
+```go
+// The `carlos gate` verb family: one shared password in front of an
+// instance's hostname, for a static site or a prototype that should not be
+// world-readable (spec docs/superpowers/specs/2026-09-07-password-gate-design.md).
+// API mode only, like `carlos features` and for the same reason: setting a
+// gate is a console-authorized write with an audit row.
+package main
+
+import (
+ "bufio"
+ "context"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "log"
+ "os"
+ "strings"
+)
+
+const gateUsage = `usage: carlos gate <set|clear|list> [flags]
+
+ set -app <a> [-host <h>] [-password <p>]
+ put a password in front of the app's instances (or one host);
+ without -password, one line is read from stdin
+ clear -app <a> [-host <h>]
+ turn the password off
+ list -app <a> which hosts have a password set
+
+ -account <a> account (sqid or name; default "ops")
+ -account-id <id> account sqid, matched as a sqid ONLY
+ -console <origin> console to act through (default: the only logged-in one)
+
+Signed-in only (carlos auth login). Visitors to a gated host see a plain
+page asking for the password and stay signed in for a week; changing or
+clearing the password signs everyone out at once. The box picks the change
+up within seconds of the next converge tick.
+`
+
+func runGate(args []string) {
+ if len(args) > 0 {
+ switch args[0] {
+ case "set":
+ runGateSet(args[1:])
+ return
+ case "clear":
+ runGateClear(args[1:])
+ return
+ case "list":
+ runGateList(args[1:])
+ return
+ }
+ }
+ log.Fatal(gateUsage)
+}
+
+func requireGateAPISession(consoleFlag string) *apiSession {
+ sess, err := resolveMode(consoleFlag)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if sess == nil {
+ log.Fatal("carlos gate works through a console, and this shell is in direct bucket mode\n" +
+ "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). A password gate is a console write with an\n" +
+ "audit row. Unset the store env and run: carlos auth login -console <url>.")
+ }
+ return sess
+}
+
+// readGatePassword is the flag, else one line from stdin — so a password
+// can be piped or typed without landing in shell history or ps output. An
+// empty line is refused; the console's own length rule is applied there.
+func readGatePassword(flagged string, stdin io.Reader) (string, error) {
+ if flagged != "" {
+ return flagged, nil
+ }
+ line, err := bufio.NewReader(stdin).ReadString('\n')
+ if err != nil && !errors.Is(err, io.EOF) {
+ return "", err
+ }
+ line = strings.TrimRight(line, "\r\n")
+ if line == "" {
+ return "", errors.New("no password given — pass -password or pipe one line on stdin")
+ }
+ return line, nil
+}
+
+// gateHosts resolves the hosts a set/clear applies to: -host alone, else
+// every instance record of the app.
+func gateHosts(sess *apiSession, acct apiAcct, app, host string) []string {
+ if host != "" {
+ return []string{host}
+ }
+ recs, _, _, err := sess.instanceList(context.Background(), acct, app, "", "")
+ if err != nil {
+ log.Fatalf("gate: list instances: %v", err)
+ }
+ if len(recs) == 0 {
+ log.Fatalf("gate: %s has no instances — a gate is set per instance; create one first (carlos instances create)", app)
+ }
+ hosts := make([]string, 0, len(recs))
+ for _, r := range recs {
+ hosts = append(hosts, r.Host)
+ }
+ return hosts
+}
+
+func gateFlags(name string) (*flag.FlagSet, *string, *string, *string, *string, *string) {
+ fs := flag.NewFlagSet("gate "+name, flag.ExitOnError)
+ app := appFlag(fs, "app whose instances to gate")
+ host := fs.String("host", "", "one instance host (default: every instance of the app)")
+ consoleFlag := fs.String("console", "", "console to act through (default: the only logged-in one)")
+ acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
+ acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
+ return fs, app, host, consoleFlag, acctRaw, acctIDRaw
+}
+
+func runGateSet(args []string) {
+ fs, app, host, consoleFlag, acctRaw, acctIDRaw := gateFlags("set")
+ pw := fs.String("password", "", "the password (default: one line from stdin)")
+ passwordGiven := false
+ fs.Parse(args)
+ fs.Visit(func(f *flag.Flag) {
+ if f.Name == "password" {
+ passwordGiven = true
+ }
+ })
+ if passwordGiven && *pw == "" {
+ log.Fatal("gate set: -password is empty — give a password of 8 to 72 characters")
+ }
+ password, err := readGatePassword(*pw, os.Stdin)
+ if err != nil {
+ log.Fatalf("gate set: %v", err)
+ }
+ sess := requireGateAPISession(*consoleFlag)
+ printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ app2 := resolvedApp(sess, acct, *app, gateUsage)
+ failed := 0
+ for _, h := range gateHosts(sess, acct, app2, strings.TrimSpace(*host)) {
+ if _, err := sess.instanceSetGate(context.Background(), acct, app2, h, password); err != nil {
+ failed++
+ fmt.Fprintf(stdout, "%s %s: %v\n", colorFor(stdout).bad("gate not set on"), h, err)
+ continue
+ }
+ fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("password set on"), h)
+ }
+ mutedf(stdout, " the box picks it up within seconds of the next converge tick; visitors already signed in are signed out")
+ if failed > 0 {
+ os.Exit(1)
+ }
+}
+
+func runGateClear(args []string) {
+ fs, app, host, consoleFlag, acctRaw, acctIDRaw := gateFlags("clear")
+ fs.Parse(args)
+ sess := requireGateAPISession(*consoleFlag)
+ printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ app2 := resolvedApp(sess, acct, *app, gateUsage)
+ failed := 0
+ for _, h := range gateHosts(sess, acct, app2, strings.TrimSpace(*host)) {
+ if _, err := sess.instanceSetGate(context.Background(), acct, app2, h, ""); err != nil {
+ failed++
+ fmt.Fprintf(stdout, "%s %s: %v\n", colorFor(stdout).bad("gate not cleared on"), h, err)
+ continue
+ }
+ fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("password turned off on"), h)
+ }
+ if failed > 0 {
+ os.Exit(1)
+ }
+}
+
+func runGateList(args []string) {
+ fs, app, _, consoleFlag, acctRaw, acctIDRaw := gateFlags("list")
+ fs.Parse(args)
+ sess := requireGateAPISession(*consoleFlag)
+ printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ app2 := resolvedApp(sess, acct, *app, gateUsage)
+ recs, _, _, err := sess.instanceList(context.Background(), acct, app2, "", "")
+ if err != nil {
+ log.Fatalf("gate list: %v", err)
+ }
+ if len(recs) == 0 {
+ fmt.Fprintln(stdout, "no instances")
+ return
+ }
+ w := newTable(stdout)
+ fmt.Fprintln(w, "HOST\tGATE")
+ for _, r := range recs {
+ state := "open"
+ if r.Gated {
+ state = "password set"
+ }
+ fmt.Fprintf(w, "%s\t%s\n", r.Host, state)
+ }
+ w.Flush()
+}
+```
+
+`colorFor(stdout).bad` and `.ok` exist (color.go:117-119). `newTable` (color.go:206) returns a `*table`; finish it the way `printFeatures` does (check the method it calls after the rows — `grep -n "func (t \*table)" color.go`).
+
+`main.go`: after `case "features": runFeatures(args)` add `case "gate": runGate(args)`; in the `usage` text, after the `features` line, add ` gate a shared password in front of an app's hostnames` (match the column alignment of the neighbours).
+
+`docs/site/cli.md`: after the `### carlos features` section (before `### carlos releases`):
+
+```markdown
+### carlos gate
+
+Puts one shared password in front of an app's hostnames — for a static site
+or a prototype that should not be world-readable. Visitors see a plain page
+asking for the password and stay signed in for a week; the app behind it
+never sees the cookie.
+
+```sh
+carlos gate set --app site
+carlos gate list --app site
+carlos gate clear --app site
+```
+
+`set` reads the password from `--password` or, without it, one line from
+stdin, so it need not land in your shell history. Without `--host` the
+change applies to every instance of the app; name one to narrow it. Changing
+or clearing the password signs every visitor out at once. Custom domains
+attached to the app are gated along with the platform hostname. This is a
+curtain in front of the whole hostname, not accounts for the app's own
+users; an app with real sign-in keeps doing that itself.
+```
+
+In `### carlos instances`, append one sentence to its first paragraph: `A password in front of an instance's hostname is `carlos gate`.`
+
+- [ ] **Step 4: Run the tests and the docs gate**
+
+Run: `go test . -run 'Gate' -count=1 && go test ./internal/docsite/ -count=1 && go build ./... && go vet ./... && gofmt -l .`
+Expected: PASS, no unformatted files.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add gate_cli.go gate_cli_test.go apimode.go main.go docs/site/cli.md
+git commit -m "carlos gate: set, clear and list an app's password gates; documented"
+```
+
+---
+
+### Task 8: Whole-tree verification
+
+- [ ] **Step 1:** `go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l .` — all clean.
+- [ ] **Step 2:** `GOOS=darwin go build ./... && GOOS=windows go build ./... && GOOS=linux GOARCH=arm64 go build ./...` — the cross-compile gate.
+- [ ] **Step 3:** Push the branch to `origin` and to `amadan`; open a draft PR titled "Password gate: a shared password in front of an instance" whose body links the spec and this plan and lists the roll order from spec §9 (boxes before console). Read the amadan run.
diff --git a/docs/superpowers/specs/2026-09-07-password-gate-design.md b/docs/superpowers/specs/2026-09-07-password-gate-design.md
new file mode 100644
index 000000000..515d391ba
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-07-password-gate-design.md
@@ -0,0 +1,445 @@
+# Password gate — a shared password in front of an instance
+
+**Date:** 2026-09-07 · **Status:** approved in conversation (Paul, 2026-09-07:
+"yes, full spec, plan, and overnight implementation, but also make sure I can
+set a password or disable password protection via the web console settings
+too"). · **Requested by:** Paul.
+
+**Builds on:** the instance-record converge (`internal/provision/reconcile.go`,
+the `Hibernate` precedent), the edge's cache-before-activator ordering
+(`internal/edge/edge.go` ServeHTTP), the mesh key's HMAC discipline
+(`internal/edge/mesh.go`), the fleet status page's stylesheet
+(`internal/status/assets/status.css`), and the console's form conventions
+(`internal/console/fleets.go` handleAppPlace).
+
+## 1. What this is for
+
+A member ships a static site or a prototype and wants it reachable by the
+people they give a password to, and nobody else. No accounts, no invitations,
+no identity provider: one shared password per host, a neutral page that asks
+for it, and a cookie that remembers the answer for a week.
+
+It is not access control for the app's own users. An app with real sign-in
+keeps doing that itself. This is a curtain the platform holds in front of the
+whole hostname, so the app behind it does not have to know the curtain exists.
+
+## 2. Where it lives, and why
+
+The edge is the only thing that fronts an instance, and it decides what to do
+with a request from the registry row the host resolves to. So the gate is a
+**route property**, and it travels the path hibernation already travels:
+
+ carlos gate / console Settings
+ → console PATCH on the instance record (bucket, CAS-guarded, audited)
+ → the host's provisioning reconcile converges it into a registry column
+ → the edge reads it at reload and enforces it per request
+
+Three consequences fall out of that placement, and each is a reason to place
+it there rather than anywhere else:
+
+- **Custom domains are covered for free.** An alias maps to the primary's
+ `upstream` entry verbatim (`edge.go`, `upstream.alias`), so a gate keyed on
+ `up.route` fronts `docs.example.com` the moment it fronts the platform
+ hostname.
+- **Static sites are covered.** A static site is an instance record of kind
+ `static`; its route is served by the edge's static handler, which sits
+ behind the same gate step as a proxied instance.
+- **A gated host never wakes for a stranger.** The gate step runs before the
+ activator, so unauthenticated traffic — crawlers, scanners, someone guessing
+ a hostname — is answered by the login page without spinning the instance up.
+
+What it is deliberately NOT: an app-level feature flag (`carlos features` is
+served to the app and never read by the edge), an env var (`carlos env` is
+rendered on the host and never read by the edge), or a manifest policy (the
+manifest is read at instance create; a gate is toggled on a running host).
+
+## 3. The record and the column
+
+### 3.1 `provision.Record.Gate`
+
+```go
+// Gate is the bcrypt hash of the host's shared password ("" = open, the
+// value every record written before this field existed decodes to). It is
+// the ONLY place the secret material lives in the bucket; the plaintext
+// crosses the console once, at the door that hashes it, and is never
+// stored, logged or echoed. Converged into routes.gate by reconcile
+// (§3.3) and read by the edge (§4). Additive: omitempty, and an old
+// reader that does not know the field drops it on the floor — see §9
+// for why that means boxes roll before the console.
+Gate string `json:"gate,omitempty"`
+```
+
+Validation in `ValidateShape`: if `Gate != ""` it must parse as a bcrypt
+hash (`bcrypt.Cost(hash)` succeeds). Any kind may carry one — `instance`,
+`static`, `service` all front something a person can browse to. No kind
+refuses it.
+
+### 3.2 The registry column
+
+`ALTER TABLE routes ADD COLUMN gate TEXT NOT NULL DEFAULT ''`, appended to
+the migration list; `Route.Gate string`; added to `routeCols`, `scanRoute`,
+the `Upsert` VALUES and its `ON CONFLICT` set. The conflict rule is the
+**preserve-when-empty** one `caps`, `tunnel` and `unit` use —
+`gate=CASE WHEN excluded.gate = '' THEN routes.gate ELSE excluded.gate END`
+— so a full-row `Upsert` from a writer that does not know the column
+(reconcile's mint path via `routeFor`, an old `carlos add`) cannot silently
+open a gated host. `TestUpsertRoundTripsAllColumns` counts the column.
+
+Narrow setter, the shape of `SetSteering`:
+
+```go
+// SetGate stamps or clears a route's password gate. "" opens the host.
+func (r *Registry) SetGate(host, hash string) error
+```
+
+**`Fingerprint()` must include `gate`.** The edge reloads when the registry
+fingerprint changes, and the fingerprint hashes a fixed column list. A gate
+set or cleared with the column left out of that list would take effect only
+on the next unrelated reload — an operator would "turn the password off"
+and watch the page keep asking for it. `Route.Gate`'s doc comment says it IS
+in `Fingerprint()`, the inverse of the `Caps`/`Unit`/`Steering` comments.
+
+### 3.3 Converge
+
+In reconcile's "existing, provisioned, same app/account" arm, beside the
+hibernate converge:
+
+```go
+if existing.Gate != rec.Gate {
+ // dry-run / SetGate / log "converged %s gate %s -> %s" with the two
+ // states rendered as "open"/"set", never the hash.
+}
+```
+
+Unlike hibernate this is not a pointer with an absent-means-silent rule:
+absent decodes to `""`, and `""` means open. A record that does not name a
+gate is an open host, which is also what every record in every production
+bucket says today. The converge log line prints `open`/`set`, never the
+hash — it is not a secret in the strong sense (§4.3), but it has no business
+in a log.
+
+## 4. The edge
+
+### 4.1 The step, and its place in the chain
+
+The hash rides `up.route.Gate` — the `upstream` entry already carries the
+whole row, and an alias copies the entry, so nothing new is folded.
+`ServeHTTP` gains one step, placed **after** `lookupUpstreamPath` resolves
+`up` and the usage meter wraps `w`, and **before** the upgrade branch:
+
+```go
+if up.route.Gate != "" && !e.gate.Admit(w, r, up.route.Host, up.route.Gate) {
+ return
+}
+```
+
+The placement is the whole design and each neighbour is load-bearing:
+
+- **Before the upgrade branch**, so a WebSocket handshake is gated. The
+ handshake is a GET that carries cookies; the check reads the cookie and
+ either lets the handshake through untouched (the writer stack is exactly
+ what it was) or answers 401 before any hijack.
+- **Before the cache check**, so a cache hit is only ever served to a
+ request that already passed. The shared LRU keys on host and path, not on
+ cookies; a gate behind the cache would let one visitor's authenticated
+ fetch of `/` warm the cache for the world.
+- **Before the activator**, for the reason in §2.
+- **After the meter**, so the 401 page's bytes are billed to the route like
+ any other response the route caused. It is the tenant's page.
+- **After the steering probe, the tunnel hostname and the hop acceptance**,
+ which do not resolve a route and have nothing to gate. A backhauled
+ request is gated on the box that owns the row, where `ServeHTTP` runs
+ after `acceptHop`; the sending edge has no row and makes no decision.
+
+`Admit` returns true when the request carries a valid cookie, after
+**removing the gate cookie from the request** so the app never sees it. It
+returns false when it has already written a response: the login page, the
+redirect after a successful POST, or the too-many-attempts page.
+
+### 4.2 The reserved path
+
+`POST /.carlos/gate` on a gated host is the login form's action. Fields:
+`password`, `next`. `next` is accepted only if it starts with a single `/`
+(not `//`, not a scheme, no control bytes), else it becomes `/`; that is
+the entire open-redirect surface and it is closed at the door.
+
+**The edge routes a login POST by `next`, not by its own path** (review
+finding, 2026-09-07). A hostname that routes by path prefix resolves each
+request's first segment to a target route; `/.carlos/gate` has no such
+segment and would fall through to the front host's own route — so a gated
+prefix target could never be logged into, and the password would be
+proxied to whatever app fronts the hostname. So, for exactly `POST
+/.carlos/gate`, the edge parses the form (bounded, 4 KiB) and resolves the
+upstream against the path in `next`, which is the path the visitor was
+refused on. On a host with no prefix map this resolves to the same route
+the request path would have; on a prefix host it resolves to the target
+the visitor was trying to reach, whose hash judges the password and whose
+host signs the cookie (§4.3).
+
+**A login POST to an ungated route is a 404, never proxied.** The page is
+only ever rendered by a gated route, so a POST that reaches an ungated one
+is a stray or a probe; forwarding a form field named `password` to an app
+that never asked for it is the one outcome to rule out. On every other
+method the path is ordinary and reaches the app.
+
+Outcomes:
+
+- Right password → `Set-Cookie`, `303 See Other` to `next`.
+- Wrong password → the login page with the error line, status 401.
+- Over the attempt limit → the too-many-attempts page, status 429, with
+ `Retry-After: 60`, and the password is **not** compared.
+
+The limit is a per-client, per-host token bucket held in memory on the edge:
+10 attempts per minute, keyed on the request's remote address and the route
+host. bcrypt at cost 10 already costs tens of milliseconds a comparison; the
+bucket exists so the CPU is spent on people and not on a script. A
+backhauled request's remote address is the sibling edge's, which folds every
+hop from that edge into one bucket; accepted for now, because the fold only
+ever makes the limit stricter, and named here so it is not rediscovered.
+
+### 4.3 The cookie
+
+Name `carlos_gate`; `Path=/`; `HttpOnly`; `Secure`; `SameSite=Lax`;
+`Max-Age` seven days. Value:
+
+ v1.<expiry unix seconds>.<base64url(HMAC-SHA256(key, host "\n" expiry))>
+
+with `key = HMAC-SHA256("carlos-gate-cookie-v1", hash)` — derived from the
+stored bcrypt hash and nothing else. Verification is one HMAC and a
+constant-time compare (`hmac.Equal`), no bcrypt, no lookup.
+
+Deriving the key from the hash is what makes the design stateless and gives
+it its one rotation rule: **change or clear the password and every cookie
+minted under the old one stops verifying at once**, on every box, with no
+key object to mint, mirror or rotate. The mesh key exists because the edges
+have to agree on a secret nobody else holds; here the secret every edge
+already holds — the hash, in its own registry — is enough.
+
+The trust it implies, stated so it is a decision and not a surprise: anyone
+who can read the hash can mint a cookie without knowing the password. The
+hash is readable by root on the box and by a bucket reader. Both already
+hold the tenant's sealed secrets and the edge's TLS keys; a gate cookie
+adds nothing to what they could do. It is why §5.4 keeps the hash out of
+every API answer, where the reader is a member, not the platform.
+
+`host` in the MAC is `up.route.Host`, the row's host, not the request's —
+so a path-routed prefix and its target agree on which key signs. Browsers
+scope the cookie to the hostname they were served from anyway, so an alias
+and its primary each log in once.
+
+### 4.4 The page
+
+A new package, `internal/gate`, owning `Admit`, the cookie, the limiter and
+the page. One embedded `html/template` (`login.html`) with the stylesheet
+inline — a single response, no asset path to reserve — using the palette
+and dark-mode flip of `internal/status/assets/status.css`, which is the
+platform's neutral look. It is not the console's template set: the console
+is a different binary, and the edge takes no dependency on it.
+
+Response headers on every page the gate writes: `Cache-Control: no-store`,
+`X-Robots-Tag: noindex, nofollow`. `HEAD` gets the status and headers with
+no body. The 401 is deliberate over a 200-with-a-form: an uptime monitor, a
+crawler or a `curl` sees at a glance that it was not let in.
+
+The page, entire — hostname and error rendered from the template, nothing
+else dynamic:
+
+> **Password required**
+> `<host>` is private. Enter the password to continue.
+> [Password] [Continue]
+>
+> error line, wrong password: *That password is not right.*
+> error line, over the limit: *Too many attempts. Try again in a minute.*
+
+No product name, no logo, no link out. The member is fronting their own
+thing; the page belongs to their hostname.
+
+## 5. The doors
+
+### 5.1 One set of rules, two doors
+
+The password rules live in `internal/gate` and both doors call them:
+
+```go
+// CheckPassword is the door's rule: 8..72 bytes (72 is bcrypt's input
+// limit; longer is refused rather than silently truncated).
+func CheckPassword(p string) error
+// Hash is bcrypt at cost 10.
+func Hash(p string) (string, error)
+```
+
+The API PATCH applies them inside its existing read-mutate-validate-write
+flow (a PATCH may carry other fields in the same body, so it keeps its one
+write). The Settings form handler has the same read, write and audit
+sequence of its own, against one field. Shared, and enforced at both:
+
+- `CheckPassword` before anything is hashed;
+- the record must exist (404, byte-identical to the family's other 404s);
+- CAS loss is a 409, as for every other record write;
+- audit detail names `gate` among the changed fields (PATCH) or reads
+ `set password gate on <host>` / `cleared password gate on <host>` with a
+ `(console)` suffix (form). The password is never in the detail, the log,
+ or an error message.
+
+### 5.2 The API PATCH
+
+`handleAPIInstanceUpdate`'s body gains `Gate *string \`json:"gate"\``. Absent
+leaves the gate alone; `"gate": ""` clears; `"gate": "<password>"` sets. The
+handler routes a present `Gate` through `setInstanceGate` and appends `gate`
+to the audited `changed` list. Everything else about the PATCH is unchanged,
+including the shape rail and the all-nil 400 (which now names `gate` too).
+
+The plaintext crosses the wire once, to the console, over TLS, the same as
+a sign-in. The alternative — hashing in the CLI and sending the hash — would
+make the CLI and the Settings form two hashers to keep in step, for no gain
+a TLS client would notice.
+
+### 5.3 The CLI
+
+`carlos gate`, a new top-level verb shaped like `carlos features`: API mode
+only, sub-verbs as positional words that never reach `fs.Parse`.
+
+ carlos gate set -app <a> [-host <h>] [-password <p>]
+ carlos gate clear -app <a> [-host <h>]
+ carlos gate list -app <a>
+
+`set` without `-password` reads one line from stdin, so a password can be
+piped or typed without landing in the shell's history or `ps` output; an
+empty line is refused. When stdin is a terminal the verb prints `Password:`
+to stderr first; what is typed echoes, deliberately, because a no-echo
+prompt needs `golang.org/x/term`, which the module does not depend on, and
+a platform-specific termios call is exactly what the cross-compile gate
+exists to keep out. The session is resolved before the read, so a
+logged-out user is told before typing a secret. Without `-host`, `set` and
+`clear` apply to **every** instance of the app, one PATCH each, and print
+each host as it goes; a failure part way is reported per host and the verb
+exits non-zero, with nothing rolled back — the record is per host and the
+operator re-runs. `list` prints host and one of three states:
+`open`, `password set`, or `unknown — console predates gates` when the
+reply carries no `gated` key at all (§9).
+
+`-account`, `-account-id`, `-console` as every other API verb.
+
+### 5.4 The hash never leaves the console
+
+Every response that carries a record goes out with the hash shadowed. The
+list and GET already answer with `instanceListRecord`, which embeds
+`provision.Record`; it gains two fields, and the PATCH, create and steering
+replies — which write a bare record today — answer with a small view built
+the same way:
+
+```go
+// On instanceListRecord and on apiRecordView (the write replies):
+Gate string `json:"gate,omitempty"` // shadows the embedded field; always ""
+Gated bool `json:"gated"` // what a member may know
+```
+
+An outer field shadows the embedded one for `encoding/json`, so the hash
+marshals as nothing and `Gated` says what the reader needs. A test pins
+every writer: a record with a hash serialises with `"gated":true` and no
+`"gate"` key, from each of the four handlers.
+
+### 5.5 The web console
+
+The app's **Settings** page (`app_settings.html`, `handleAppSettings`) gains a
+"Password gate" section under the existing app-level sections. One row per
+instance record: the host, its state, and a form —
+
+ <host> Open [password ........] [Set password]
+ <host> Password set [password ........] [Change] [Turn off]
+
+— `POST /a/{sqid}/apps/{app}/gate` with `csrf`, `host`, `action` (`set` or
+`clear`), `password`. The handler is `handleAppGate`, shaped exactly like
+`handleAppPlace`: `accountApp`, constant-time CSRF check, then
+`setInstanceGate`, then `setFlash` and a 303 back to Settings. Role: any
+**member**, matching the API PATCH's door (`apiAccountApp`), because the CLI
+already lets a member do this and a form that refuses what the CLI allows
+is a rule that lives in the wrong place. The password field is
+`autocomplete="new-password"` so browsers do not offer to save it against
+the console.
+
+Flash copy: *Password set on <host>.* / *Password turned off on <host>.* /
+the refusal's message.
+
+The section reads its rows from `provision.ListRecords` for the app; an app
+with no records shows one line — *No instances yet. A password gate is set
+per instance.*
+
+## 6. Docs
+
+- `docs/site/cli.md` gains `### carlos gate` under "Your apps", after
+ `### carlos features`: one paragraph and a fenced example, in the page's
+ voice. `internal/docsite`'s test fails the build without it.
+- The `### carlos instances` paragraph gets one sentence pointing at `gate`.
+- The console Settings section needs no doc; it is on the page.
+
+## 7. Tests
+
+Registry: the column round-trips (`TestUpsertRoundTripsAllColumns`),
+`SetGate` sets and clears, `Upsert` with an empty gate preserves a set one,
+`Fingerprint` changes when the gate does.
+
+Provision: `ValidateShape` accepts a bcrypt hash, refuses a non-hash;
+reconcile converges set→clear and clear→set and logs `open`/`set` only;
+an absent field leaves an open host open and closes a set one (it is a
+string, not a pointer — the test says so).
+
+Edge (`internal/edge`, alongside the cache and activator tests):
+- an ungated route is byte-for-byte unchanged (no cookie read, no header);
+- a gated route with no cookie gets 401, `no-store`, `noindex`, and **the
+ activator is never called**, **the cache is never consulted**;
+- a right POST sets the cookie and 303s to `next`; a bad `next` becomes `/`;
+- a valid cookie passes, and the upstream sees no `carlos_gate` cookie;
+- an expired cookie, a cookie for another host, a cookie minted under the
+ previous hash — all 401;
+- a WebSocket handshake without the cookie is 401, with it reaches the
+ proxy;
+- an alias of a gated primary is gated;
+- the eleventh attempt in a minute is 429 without a bcrypt compare (a
+ counting fake for the comparer);
+- a `HEAD` gets 401 and no body.
+
+Gate package: cookie mint/verify round trip, key changes with hash, MAC is
+host-bound, expiry honoured, limiter refills.
+
+Console: PATCH round-trip sets and clears, refuses short and over-long,
+refuses clear+password, audits with no password in the detail; every record
+response has `gated` and no `gate` key; the Settings form sets and clears
+behind CSRF and a session, and a non-member gets the family's 404.
+
+CLI: `gate set` fans out to every instance and stops with a non-zero exit
+naming the host that failed; `list` renders both states.
+
+## 8. Not built, on purpose
+
+- **Basic auth for `curl`.** A cookie flow serves browsers; anyone scripting
+ against a gated prototype can POST once and keep the jar.
+- **Per-path exemptions** (`/healthz` open, the rest gated). The platform's
+ own health probe goes straight to the upstream and never meets the gate;
+ an external monitor can carry the cookie.
+- **Per-user passwords, logout, "remember me" longer than a week.** Every
+ one of these is the start of an identity product; the app is where that
+ lives.
+- **A gate on the app manifest** (every instance at once, forever). The
+ CLI's fan-out gives the same day-one result without a second source of
+ truth to reconcile.
+- **`carlos route --gate` for operator routes with no record.** Every member
+ route is a record. An operator route is platform property, and the
+ first person to want one gated can add the flag beside `--hibernate`.
+- **Reporting "enforced" as distinct from "set".** `gate list` reports the
+ record; the box's converge line is the confirmation. Cross-box truth is
+ `internal/fleetapi`'s problem (#145).
+
+## 9. Rollout
+
+The column is additive and defaults to open, so nothing changes on a box
+that rolls with no records carrying a gate. The order that matters is
+**boxes before console**: a console that writes `gate` into a record before
+the boxes roll produces a record the old reconcile decodes without the
+field — and a member who just set a password on a host that is, for the
+length of the gap, still open. Roll the box binary (edge and host agent are
+one binary) across the fleet, read a converge line, then roll the console.
+
+The CLI is inert against an old console: a PATCH carrying `gate` to a
+console that does not know the field is a 400 (`nothing to update`), which
+the verb reports as the console being older than the verb.
diff --git a/gate_cli.go b/gate_cli.go
new file mode 100644
index 000000000..612baed23
--- /dev/null
+++ b/gate_cli.go
@@ -0,0 +1,232 @@
+// The `carlos gate` verb family: one shared password in front of an
+// instance's hostname, for a static site or a prototype that should not be
+// world-readable (spec docs/superpowers/specs/2026-09-07-password-gate-design.md).
+// API mode only, like `carlos features` and for the same reason: setting a
+// gate is a console-authorized write with an audit row.
+package main
+
+import (
+ "bufio"
+ "context"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "log"
+ "os"
+ "strings"
+)
+
+const gateUsage = `usage: carlos gate <set|clear|list> [flags]
+
+ set -app <a> [-host <h>] [-password <p>]
+ put a password in front of the app's instances (or one host);
+ without -password, one line is read from stdin
+ clear -app <a> [-host <h>]
+ turn the password off
+ list -app <a> which hosts have a password set
+
+ -account <a> account (sqid or name; default "ops")
+ -account-id <id> account sqid, matched as a sqid ONLY
+ -console <origin> console to act through (default: the only logged-in one)
+
+Signed-in only (carlos auth login). Visitors to a gated host see a plain
+page asking for the password and stay signed in for a week; changing or
+clearing the password signs everyone out at once. The box picks the change
+up within seconds of the next converge tick.
+`
+
+func runGate(args []string) {
+ if len(args) > 0 {
+ switch args[0] {
+ case "set":
+ runGateSet(args[1:])
+ return
+ case "clear":
+ runGateClear(args[1:])
+ return
+ case "list":
+ runGateList(args[1:])
+ return
+ }
+ }
+ log.Fatal(gateUsage)
+}
+
+func requireGateAPISession(consoleFlag string) *apiSession {
+ sess, err := resolveMode(consoleFlag)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if sess == nil {
+ log.Fatal("carlos gate works through a console, and this shell is in direct bucket mode\n" +
+ "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). A password gate is a console write with an\n" +
+ "audit row. Unset the store env and run: carlos auth login -console <url>.")
+ }
+ return sess
+}
+
+// readGatePassword is the flag, else one line from stdin — so a password
+// can be piped or typed without landing in shell history or ps output. An
+// empty line is refused; the console's own length rule is applied there.
+func readGatePassword(flagged string, stdin io.Reader) (string, error) {
+ if flagged != "" {
+ return flagged, nil
+ }
+ line, err := bufio.NewReader(stdin).ReadString('\n')
+ if err != nil && !errors.Is(err, io.EOF) {
+ return "", err
+ }
+ line = strings.TrimRight(line, "\r\n")
+ if line == "" {
+ return "", errors.New("no password given — pass -password or pipe one line on stdin")
+ }
+ return line, nil
+}
+
+// gateHosts resolves the hosts a set/clear applies to: -host alone, else
+// every instance record of the app.
+func gateHosts(sess *apiSession, acct apiAcct, app, host string) []string {
+ if host != "" {
+ return []string{host}
+ }
+ recs, _, _, err := sess.instanceList(context.Background(), acct, app, "", "")
+ if err != nil {
+ log.Fatalf("gate: list instances: %v", err)
+ }
+ if len(recs) == 0 {
+ log.Fatalf("gate: %s has no instances — a gate is set per instance; create one first (carlos instances create)", app)
+ }
+ hosts := make([]string, 0, len(recs))
+ for _, r := range recs {
+ hosts = append(hosts, r.Host)
+ }
+ return hosts
+}
+
+func gateFlags(name string) (*flag.FlagSet, *string, *string, *string, *string, *string) {
+ fs := flag.NewFlagSet("gate "+name, flag.ExitOnError)
+ app := appFlag(fs, "app whose instances to gate")
+ host := fs.String("host", "", "one instance host (default: every instance of the app)")
+ consoleFlag := fs.String("console", "", "console to act through (default: the only logged-in one)")
+ acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
+ acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
+ return fs, app, host, consoleFlag, acctRaw, acctIDRaw
+}
+
+func runGateSet(args []string) {
+ fs, app, host, consoleFlag, acctRaw, acctIDRaw := gateFlags("set")
+ pw := fs.String("password", "", "the password (default: one line from stdin)")
+ passwordGiven := false
+ fs.Parse(args)
+ fs.Visit(func(f *flag.Flag) {
+ if f.Name == "password" {
+ passwordGiven = true
+ }
+ })
+ if passwordGiven && *pw == "" {
+ log.Fatal("gate set: -password is empty — give a password of 8 to 72 characters")
+ }
+ // The session is resolved BEFORE the password is read. Reading first
+ // meant a shell with no console sat there swallowing a typed password
+ // and only then said it could not use it — and the person has now typed
+ // a secret at a prompt that was never going to work, with no way to
+ // know where it went.
+ sess := requireGateAPISession(*consoleFlag)
+ // A pipe gets no prompt (nothing is there to read it, and it would land
+ // in whatever the caller is capturing); a person typing gets told what
+ // the waiting cursor is for, on stderr so a redirected stdout stays
+ // clean. isTerminal is color.go's dependency-free isatty — no x/term,
+ // and no echo suppression: the gate's plaintext is a shared site
+ // password that -password already puts in full view.
+ if !passwordGiven && isTerminal(os.Stdin) {
+ fmt.Fprint(os.Stderr, "Password: ")
+ }
+ password, err := readGatePassword(*pw, os.Stdin)
+ if err != nil {
+ log.Fatalf("gate set: %v", err)
+ }
+ printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ app2 := resolvedApp(sess, acct, *app, gateUsage)
+ failed := gateApply(sess, acct, app2, gateHosts(sess, acct, app2, strings.TrimSpace(*host)), password, stdout)
+ mutedf(stdout, " the box picks it up within seconds of the next converge tick; visitors already signed in are signed out")
+ if failed > 0 {
+ os.Exit(1)
+ }
+}
+
+// gateApply is the set/clear loop both verbs share: one PATCH per host, one
+// line each, and the number that failed — which the callers turn into the
+// exit code. A failure on one host does NOT stop the others: with no -host
+// the fan-out is every instance of the app, and stopping at the first
+// refusal would leave the operator guessing which of the rest took the
+// password. password == "" is the clear.
+func gateApply(sess *apiSession, acct apiAcct, app string, hosts []string, password string, w io.Writer) (failed int) {
+ bad, ok := "gate not set on", "password set on"
+ if password == "" {
+ bad, ok = "gate not cleared on", "password turned off on"
+ }
+ for _, h := range hosts {
+ if _, err := sess.instanceSetGate(context.Background(), acct, app, h, password); err != nil {
+ failed++
+ fmt.Fprintf(w, "%s %s: %v\n", colorFor(w).bad(bad), h, err)
+ continue
+ }
+ fmt.Fprintf(w, "%s %s\n", colorFor(w).ok(ok), h)
+ }
+ return failed
+}
+
+func runGateClear(args []string) {
+ fs, app, host, consoleFlag, acctRaw, acctIDRaw := gateFlags("clear")
+ fs.Parse(args)
+ sess := requireGateAPISession(*consoleFlag)
+ printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ app2 := resolvedApp(sess, acct, *app, gateUsage)
+ failed := gateApply(sess, acct, app2, gateHosts(sess, acct, app2, strings.TrimSpace(*host)), "", stdout)
+ if failed > 0 {
+ os.Exit(1)
+ }
+}
+
+func runGateList(args []string) {
+ fs, app, _, consoleFlag, acctRaw, acctIDRaw := gateFlags("list")
+ fs.Parse(args)
+ sess := requireGateAPISession(*consoleFlag)
+ printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ app2 := resolvedApp(sess, acct, *app, gateUsage)
+ recs, _, _, err := sess.instanceList(context.Background(), acct, app2, "", "")
+ if err != nil {
+ log.Fatalf("gate list: %v", err)
+ }
+ printGateList(stdout, recs)
+}
+
+// printGateList renders the GATE column's three states. The third one is
+// the honest half: a console that predates the field sends no "gated" key,
+// which decodes to nil, and printing "open" for that would tell someone
+// checking whether a host is exposed exactly what they hoped to hear on no
+// evidence at all.
+func printGateList(out io.Writer, recs []apiInstanceRecord) {
+ if len(recs) == 0 {
+ fmt.Fprintln(out, "no instances")
+ return
+ }
+ w := newTable(out)
+ fmt.Fprintln(w, "HOST\tGATE")
+ for _, r := range recs {
+ state := "unknown — console predates gates"
+ switch {
+ case r.Gated == nil:
+ case *r.Gated:
+ state = "password set"
+ default:
+ state = "open"
+ }
+ fmt.Fprintf(w, "%s\t%s\n", r.Host, state)
+ }
+ w.Flush()
+}
diff --git a/gate_cli_test.go b/gate_cli_test.go
new file mode 100644
index 000000000..ca0debaf0
--- /dev/null
+++ b/gate_cli_test.go
@@ -0,0 +1,273 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os/exec"
+ "strings"
+ "testing"
+)
+
+func runCarlosCLI(t *testing.T, args ...string) (string, error) {
+ t.Helper()
+ bin := buildCarlos(t)
+ cmd := exec.Command(bin, args...)
+ cmd.Env = carlosTestEnv(t)
+ cmd.Dir = t.TempDir()
+ out, err := cmd.CombinedOutput()
+ return string(out), err
+}
+
+func TestGateNeedsASubVerb(t *testing.T) {
+ out, err := runCarlosCLI(t, "gate")
+ if err == nil {
+ t.Fatalf("bare `gate` exited 0: %s", out)
+ }
+ for _, w := range []string{"set", "clear", "list"} {
+ if !strings.Contains(out, w) {
+ t.Fatalf("usage should name %s: %s", w, out)
+ }
+ }
+}
+
+func TestGateSetRefusesAnEmptyPasswordBeforeTheNetwork(t *testing.T) {
+ out, err := runCarlosCLI(t, "gate", "set", "-app", "site", "-password", "")
+ if err == nil || !strings.Contains(out, "password") {
+ t.Fatalf("empty -password: err=%v out=%s", err, out)
+ }
+}
+
+func TestReadGatePassword(t *testing.T) {
+ if got, err := readGatePassword("flagged", strings.NewReader("")); err != nil || got != "flagged" {
+ t.Fatalf("flag: %q %v", got, err)
+ }
+ if got, err := readGatePassword("", strings.NewReader("from stdin\n")); err != nil || got != "from stdin" {
+ t.Fatalf("stdin: %q %v", got, err)
+ }
+ if _, err := readGatePassword("", strings.NewReader("\n")); err == nil {
+ t.Fatal("empty stdin line accepted")
+ }
+}
+
+// A `gate set` in a shell with no console must refuse BEFORE it reads
+// stdin. Reading first meant the CLI sat there swallowing a typed password
+// and only then said it could not use it — the person has now typed a
+// secret at a prompt that was never going to work, with no idea where it
+// went. Stdin is empty here, which is what separates the two orders: read
+// first and the CLI complains about the missing password, when the real
+// problem is that it had nowhere to send one.
+func TestGateSetResolvesTheSessionBeforeReadingThePassword(t *testing.T) {
+ bin := buildCarlos(t)
+ cmd := exec.Command(bin, "gate", "set", "-app", "site")
+ cmd.Env = carlosTestEnv(t)
+ cmd.Dir = t.TempDir()
+ cmd.Stdin = strings.NewReader("")
+ out, err := cmd.CombinedOutput()
+ if err == nil {
+ t.Fatalf("gate set with no console exited 0: %s", out)
+ }
+ if strings.Contains(string(out), "no password given") {
+ t.Fatalf("the password was read before the session was resolved: %s", out)
+ }
+ if !strings.Contains(string(out), "auth login") {
+ t.Fatalf("want the sign-in refusal, got: %s", out)
+ }
+}
+
+// gateConsole is a fake console for the gate client: it answers the two
+// doors `carlos gate` uses (GET the instance list, PATCH one instance) with
+// whatever the test hands it, and records every request so a test can prove
+// a door was NOT knocked on.
+type gateConsole struct {
+ ts *httptest.Server
+ seen []string
+ list string // body for GET .../instances
+ patch func(host string) (int, string) // status and body for PATCH .../instances/<host>
+}
+
+func newGateConsole(t *testing.T) *gateConsole {
+ t.Helper()
+ fc := &gateConsole{}
+ fc.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ fc.seen = append(fc.seen, r.Method+" "+r.URL.Path)
+ if r.Method == http.MethodPatch {
+ host := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]
+ status, body := 200, `{"host":"`+host+`","gated":true}`
+ if fc.patch != nil {
+ status, body = fc.patch(host)
+ }
+ w.WriteHeader(status)
+ w.Write([]byte(body))
+ return
+ }
+ w.Write([]byte(fc.list))
+ }))
+ t.Cleanup(fc.ts.Close)
+ return fc
+}
+
+// instanceSetGate sends the plaintext for the console to hash, and decodes
+// the console's word about the host.
+func TestInstanceSetGate(t *testing.T) {
+ var gotBody, gotMethod string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotMethod = r.Method
+ b, _ := io.ReadAll(r.Body)
+ gotBody = string(b)
+ w.Write([]byte(`{"host":"h","gated":true}`))
+ }))
+ defer ts.Close()
+
+ rec, err := newTestSession(ts.URL).instanceSetGate(context.Background(), apiAcct{Seg: "bah"}, "app", "h", "correct horse")
+ if err != nil {
+ t.Fatalf("instanceSetGate: %v", err)
+ }
+ if gotMethod != http.MethodPatch {
+ t.Errorf("method = %s, want PATCH", gotMethod)
+ }
+ if !strings.Contains(gotBody, `"gate":"correct horse"`) {
+ t.Errorf("request body = %s, want the plaintext for the console to hash", gotBody)
+ }
+ if rec.Gated == nil || !*rec.Gated {
+ t.Errorf("decoded gated = %v, want the console's true", rec.Gated)
+ }
+}
+
+// The one refusal an operator cannot otherwise read: a PATCH carrying only
+// `gate` looks like an empty update to a door that has never heard of the
+// field, and the door's own "nothing to update" reads like a bug in the CLI.
+func TestInstanceSetGateAgainstAnOldConsole(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusBadRequest)
+ w.Write([]byte(`{"error":"nothing to update — send addrs, probe, steering"}`))
+ }))
+ defer ts.Close()
+
+ _, err := newTestSession(ts.URL).instanceSetGate(context.Background(), apiAcct{Seg: "bah"}, "app", "h", "correct horse")
+ if err == nil || !strings.Contains(err.Error(), "does not know about password gates") {
+ t.Fatalf("err = %v, want the older-console sentence", err)
+ }
+}
+
+// Every other refusal is the console's own, passed through verbatim:
+// guessing at what a 403 or a 409 meant would put words in its mouth.
+func TestInstanceSetGatePassesOtherRefusalsThrough(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusConflict)
+ w.Write([]byte(`{"error":"the record moved under you — try again"}`))
+ }))
+ defer ts.Close()
+
+ _, err := newTestSession(ts.URL).instanceSetGate(context.Background(), apiAcct{Seg: "bah"}, "app", "h", "")
+ if err == nil || err.Error() != "the record moved under you — try again" {
+ t.Fatalf("err = %v, want the console's own message", err)
+ }
+}
+
+// The fan-out: no -host means every instance of the app, and -host means
+// exactly that one — without asking the console for a list it does not need.
+func TestGateHostsFanOut(t *testing.T) {
+ fc := newGateConsole(t)
+ fc.list = `{"instances":[{"host":"a.example.com"},{"host":"b.example.com"}],"total":2}`
+ sess := newTestSession(fc.ts.URL)
+
+ got := gateHosts(sess, apiAcct{Seg: "bah"}, "app", "")
+ if len(got) != 2 || got[0] != "a.example.com" || got[1] != "b.example.com" {
+ t.Fatalf("hosts = %v, want both instances", got)
+ }
+
+ fc.seen = nil
+ if got := gateHosts(sess, apiAcct{Seg: "bah"}, "app", "one.example.com"); len(got) != 1 || got[0] != "one.example.com" {
+ t.Fatalf("hosts = %v, want just the named one", got)
+ }
+ if len(fc.seen) != 0 {
+ t.Fatalf("-host still called the console: %v", fc.seen)
+ }
+}
+
+// gateApply keeps going after a refusal and counts it. With no -host the
+// fan-out is every instance of the app, so stopping at the first failure
+// would leave the operator not knowing which of the rest took the password
+// — and the exit code must still say something went wrong.
+func TestGateApplyReportsEveryHostAndCountsFailures(t *testing.T) {
+ fc := newGateConsole(t)
+ fc.patch = func(host string) (int, string) {
+ if host == "b.example.com" {
+ return http.StatusConflict, `{"error":"the record moved under you"}`
+ }
+ return 200, `{"host":"` + host + `","gated":true}`
+ }
+ var out bytes.Buffer
+ failed := gateApply(newTestSession(fc.ts.URL), apiAcct{Seg: "bah"}, "app",
+ []string{"a.example.com", "b.example.com", "c.example.com"}, "correct horse", &out)
+
+ if failed != 1 {
+ t.Errorf("failed = %d, want 1 — the caller turns this into the exit code", failed)
+ }
+ s := out.String()
+ for _, want := range []string{
+ "password set on a.example.com",
+ "gate not set on b.example.com: the record moved under you",
+ "password set on c.example.com",
+ } {
+ if !strings.Contains(s, want) {
+ t.Errorf("output missing %q:\n%s", want, s)
+ }
+ }
+ if n := len(fc.seen); n != 3 {
+ t.Errorf("%d PATCHes, want 3 — the loop stopped at the refusal: %v", n, fc.seen)
+ }
+}
+
+// Clearing says so. Same loop, and the words an operator reads are the only
+// thing that tells them which way it went.
+func TestGateApplyWordsTheClear(t *testing.T) {
+ fc := newGateConsole(t)
+ fc.patch = func(host string) (int, string) { return 200, `{"host":"` + host + `","gated":false}` }
+ var out bytes.Buffer
+ if failed := gateApply(newTestSession(fc.ts.URL), apiAcct{Seg: "bah"}, "app",
+ []string{"a.example.com"}, "", &out); failed != 0 {
+ t.Fatalf("failed = %d, want 0", failed)
+ }
+ if !strings.Contains(out.String(), "password turned off on a.example.com") {
+ t.Errorf("output = %q, want the clear's own wording", out.String())
+ }
+}
+
+// All three states of the GATE column. The third is the one that matters:
+// a console that predates the field sends no "gated" key, and calling that
+// "open" would tell someone checking whether a host is exposed exactly what
+// they hoped to hear on no evidence at all.
+func TestPrintGateList(t *testing.T) {
+ yes, no := true, false
+ var out bytes.Buffer
+ printGateList(&out, []apiInstanceRecord{
+ {Host: "set.example.com", Gated: &yes},
+ {Host: "open.example.com", Gated: &no},
+ {Host: "old.example.com"},
+ })
+ s := out.String()
+ for _, want := range []string{
+ "set.example.com", "password set",
+ "open.example.com", "open",
+ "old.example.com", "unknown — console predates gates",
+ } {
+ if !strings.Contains(s, want) {
+ t.Errorf("table missing %q:\n%s", want, s)
+ }
+ }
+ for _, line := range strings.Split(s, "\n") {
+ if strings.HasPrefix(line, "old.example.com") && !strings.Contains(line, "unknown") {
+ t.Errorf("a console that said nothing was reported as %q", line)
+ }
+ }
+
+ out.Reset()
+ printGateList(&out, nil)
+ if strings.TrimSpace(out.String()) != "no instances" {
+ t.Errorf("empty list = %q, want \"no instances\"", out.String())
+ }
+}
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go
index 6ce77a0c8..de186a440 100644
--- a/internal/console/apiinstances.go
+++ b/internal/console/apiinstances.go
@@ -38,6 +38,7 @@ import (
"strings"
"time"
+ "github.com/carlosframework/platform/internal/gate"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
@@ -168,6 +169,11 @@ type instanceListRecord struct {
// Record.Region, which is the REQUESTED placement code ("" =
// unconstrained, "fleet/<name>"), and absent for a host no file names.
ServedBy []servedBy `json:"served_by,omitempty"`
+ // HiddenGate shadows Record.Gate for encoding/json (a shallower field of
+ // the same name wins), so the bcrypt hash never leaves the console; it
+ // is always "" and omitted. Gated is what a member may know.
+ HiddenGate string `json:"gate,omitempty"`
+ Gated bool `json:"gated"`
}
// instanceHealth computes each record host's health word the same way the
@@ -409,7 +415,7 @@ func (s *Server) handleAPIInstanceList(w http.ResponseWriter, r *http.Request) {
}
matched = append(matched, instanceListRecord{Record: rec,
StagedVersion: join.versions[rec.Host], Health: h, Upstreams: join.upstreams[rec.Host],
- ServedBy: served[rec.Host]})
+ ServedBy: served[rec.Host], Gated: rec.Gate != ""})
}
total := len(matched)
// provision.ListRecords returns records host-sorted and nothing above
@@ -469,7 +475,7 @@ func (s *Server) handleAPIInstanceGet(w http.ResponseWriter, r *http.Request) {
return
}
writeJSON(w, http.StatusOK, instanceGetRecord{Record: *rec,
- ServedBy: loadEdgesJoin(ctx, s.cfg.Releases)[host]})
+ ServedBy: loadEdgesJoin(ctx, s.cfg.Releases)[host], Gated: rec.Gate != ""})
}
// instanceGetRecord is the single-record answer: the stored record plus the
@@ -480,6 +486,25 @@ func (s *Server) handleAPIInstanceGet(w http.ResponseWriter, r *http.Request) {
type instanceGetRecord struct {
provision.Record
ServedBy []servedBy `json:"served_by,omitempty"`
+ // HiddenGate shadows Record.Gate for encoding/json (a shallower field of
+ // the same name wins), so the bcrypt hash never leaves the console; it
+ // is always "" and omitted. Gated is what a member may know.
+ HiddenGate string `json:"gate,omitempty"`
+ Gated bool `json:"gated"`
+}
+
+// apiRecordView is a record as the write doors answer it: the whole record
+// with the gate hash shadowed and a gated flag in its place. Every handler
+// that used to writeJSON a bare record answers through this, so there is one
+// place — not five — where "the hash never leaves" has to hold.
+type apiRecordView struct {
+ provision.Record
+ HiddenGate string `json:"gate,omitempty"`
+ Gated bool `json:"gated"`
+}
+
+func recordView(rec provision.Record) apiRecordView {
+ return apiRecordView{Record: rec, Gated: rec.Gate != ""}
}
// handleAPIInstanceCreate is the point of the feature: one create-only
@@ -754,7 +779,7 @@ func (s *Server) handleAPIInstanceCreate(w http.ResponseWriter, r *http.Request)
// a box-consumed write (carlos-deploy §3): announce it, and the instance
// is serving seconds after signup instead of on the hour.
s.bumpSerial(ctx)
- writeJSON(w, http.StatusCreated, rec)
+ writeJSON(w, http.StatusCreated, recordView(rec))
}
// instanceChannel answers "which channel is this record born on" for a
@@ -973,13 +998,18 @@ func (s *Server) handleAPIInstanceUpdate(w http.ResponseWriter, r *http.Request)
Probe json.RawMessage `json:"probe"`
Steering *string `json:"steering"`
Environment *string `json:"environment"`
+ // Gate is a *string like Steering: absent leaves the gate alone,
+ // "" opens the host, anything else is the new password — hashed
+ // here, never stored or echoed (spec §5.2). The plaintext crosses
+ // the wire once, over TLS, the same as a sign-in.
+ Gate *string `json:"gate"`
}
if !readJSON(w, r, &req) {
return
}
- if req.Addrs == nil && req.Probe == nil && req.Steering == nil && req.Environment == nil {
+ if req.Addrs == nil && req.Probe == nil && req.Steering == nil && req.Environment == nil && req.Gate == nil {
jsonError(w, http.StatusBadRequest,
- "nothing to update — send addrs, probe, steering, environment, or a combination")
+ "nothing to update — send addrs, probe, steering, environment, gate, or a combination")
return
}
var probe *provision.Health
@@ -1026,6 +1056,30 @@ func (s *Server) handleAPIInstanceUpdate(w http.ResponseWriter, r *http.Request)
if req.Environment != nil {
rec.Environment = strings.TrimSpace(*req.Environment)
}
+ // The gate is the one field this door does not store as it was sent: the
+ // record carries a bcrypt hash and nothing else, so the plaintext lives
+ // only in this stack frame. Not trimmed either — a password's leading and
+ // trailing spaces are part of it, and the login POST compares the bytes
+ // the member typed.
+ if req.Gate != nil {
+ if *req.Gate == "" {
+ rec.Gate = ""
+ } else {
+ // CheckPassword's sentences name the rule and never the value:
+ // nothing below (and nothing logged) can echo the password back.
+ if err := gate.CheckPassword(*req.Gate); err != nil {
+ jsonError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ h, err := gate.Hash(*req.Gate)
+ if err != nil {
+ log.Printf("console: api instances update %s/%s/%s: hash gate: %v", acct.Sqid, app, host, err)
+ jsonError(w, http.StatusInternalServerError, "could not set the password — try again")
+ return
+ }
+ rec.Gate = h
+ }
+ }
// The same door the create path and the box's reconcile pass check
// against, so a shape that could not have been created cannot be patched
// into existence either — and the caller gets ValidateShape's own
@@ -1109,6 +1163,15 @@ func (s *Server) handleAPIInstanceUpdate(w http.ResponseWriter, r *http.Request)
if req.Steering != nil {
changed = append(changed, "steering")
}
+ if req.Environment != nil {
+ changed = append(changed, "environment")
+ }
+ // "gate" names that the host's password was set or cleared, never which
+ // it was: the audit log answers "did anyone change the gate on this host
+ // last week?" without becoming a place a password can be read.
+ if req.Gate != nil {
+ changed = append(changed, "gate")
+ }
s.audit(acct, app, principal, "instance-update",
"updated instance "+host+" ("+strings.Join(changed, ", ")+") (cli)")
// Strictly after the write landed, and exactly once: the record is what a
@@ -1116,7 +1179,7 @@ func (s *Server) handleAPIInstanceUpdate(w http.ResponseWriter, r *http.Request)
// so this announcement is what makes the new set live in seconds instead
// of on the hour.
s.bumpSerial(ctx)
- writeJSON(w, http.StatusOK, rec)
+ writeJSON(w, http.StatusOK, recordView(*rec))
}
// handleAPIInstanceDelete retires an instance's route authorization. The
diff --git a/internal/console/apiinstances_test.go b/internal/console/apiinstances_test.go
index c6c623267..0f9a38a78 100644
--- a/internal/console/apiinstances_test.go
+++ b/internal/console/apiinstances_test.go
@@ -854,8 +854,8 @@ func TestAPIInstanceListHealthAndFilters(t *testing.T) {
}
// 5) ADDITIVE: the OLD client's shape still parses, and every field a
- // record already had is byte-identical. The only key the wire gained
- // is "health".
+ // record already had is byte-identical. The keys the wire gained are
+ // "health", "staged_version" and "gated".
_, raw := get(t, "")
var old struct {
Instances []provision.Record `json:"instances"`
@@ -896,8 +896,11 @@ func TestAPIInstanceListHealthAndFilters(t *testing.T) {
}
}
sort.Strings(extra)
- if !slices.Equal(extra, []string{"health", "staged_version"}) {
- t.Errorf("%s carries extra keys %v, want exactly health and staged_version", rec.Host, extra)
+ // "gated" joined health and staged_version when the gate landed: the
+ // hash itself is shadowed out of the wire (recordView), so what a
+ // list gains is the one bit a member may know.
+ if !slices.Equal(extra, []string{"gated", "health", "staged_version"}) {
+ t.Errorf("%s carries extra keys %v, want exactly gated, health and staged_version", rec.Host, extra)
}
}
}
@@ -1457,7 +1460,8 @@ func TestAPIInstanceUpdateAuditNamesChangedFields(t *testing.T) {
// Multi-field case: a patch touching addrs AND steering together names
// both, in the order the request struct declares them (addrs, probe,
- // steering) — not just one of the two, and not a generic "updated".
+ // steering, environment, gate) — not just one of the two, and not a
+ // generic "updated".
if status, _, out := rawReq(t, http.MethodPatch, pu, token,
`{"addrs":["127.0.0.1:7002"],"steering":""}`); status != http.StatusOK {
t.Fatalf("patch addrs+steering = %d %s, want 200", status, out)
@@ -1465,6 +1469,25 @@ func TestAPIInstanceUpdateAuditNamesChangedFields(t *testing.T) {
if detail := lastEventDetail(t, fx, "titogo"); detail != "updated instance audit.go.tito.io (addrs, steering) (cli)" {
t.Fatalf("audit detail = %q, want it to name both addrs and steering", detail)
}
+
+ // environment-only case: an environment patch names ONLY environment —
+ // the mutation applies req.Environment, so the audit line must too.
+ if status, _, out := rawReq(t, http.MethodPatch, pu, token, `{"environment":"demo"}`); status != http.StatusOK {
+ t.Fatalf("patch environment = %d %s, want 200", status, out)
+ }
+ if detail := lastEventDetail(t, fx, "titogo"); detail != "updated instance audit.go.tito.io (environment) (cli)" {
+ t.Fatalf("audit detail = %q, want it to name exactly environment", detail)
+ }
+
+ // Combined case: steering, environment and gate together name all
+ // three, in request-struct order.
+ if status, _, out := rawReq(t, http.MethodPatch, pu, token,
+ `{"steering":"","environment":"","gate":""}`); status != http.StatusOK {
+ t.Fatalf("patch steering+environment+gate = %d %s, want 200", status, out)
+ }
+ if detail := lastEventDetail(t, fx, "titogo"); detail != "updated instance audit.go.tito.io (steering, environment, gate) (cli)" {
+ t.Fatalf("audit detail = %q, want it to name steering, environment and gate", detail)
+ }
}
// TestAPIInstanceUpdateSteeringInvalidValue: the door's own vocabulary rail
@@ -2409,3 +2432,100 @@ func TestAPIInstanceCreateStaticInAPoolDoesNotSteer(t *testing.T) {
t.Fatalf("stored Steering = %q, want empty — steering requires the shared pool", stored.Steering)
}
}
+
+// TestAPIInstanceUpdateGateRoundTrip: the PATCH takes a PLAINTEXT password,
+// hashes it here (spec §5.2) and answers with a record carrying "gated":true
+// and no "gate" key at all — the bcrypt hash is written to the bucket and
+// never leaves the console again, not on the write reply, not on the list,
+// not on the GET. Clearing is `{"gate":""}`, which opens the host and flips
+// the same flag back.
+func TestAPIInstanceUpdateGateRoundTrip(t *testing.T) {
+ fx, token := instanceFixture(t, "code-inst-gate-round")
+ enableInstances(t, fx, "titogo", sharedPoolManifest())
+ u := instancesURL(fx, "bab", "titogo")
+ if status, _, out := rawReq(t, http.MethodPost, u, token,
+ `{"host":"gate.go.tito.io","region":"ie","addrs":["127.0.0.1:7001"]}`); status != http.StatusCreated {
+ t.Fatalf("create = %d %s", status, out)
+ }
+ pu := updateURL(fx, "bab", "titogo", "gate.go.tito.io")
+
+ status, _, out := rawReq(t, http.MethodPatch, pu, token, `{"gate":"correct horse"}`)
+ if status != http.StatusOK {
+ t.Fatalf("patch gate = %d %s, want 200", status, out)
+ }
+ var echoed map[string]any
+ if err := json.Unmarshal([]byte(out), &echoed); err != nil {
+ t.Fatal(err)
+ }
+ if _, leaked := echoed["gate"]; leaked {
+ t.Fatalf("patch reply carries the gate hash: %s", out)
+ }
+ if echoed["gated"] != true {
+ t.Fatalf("patch reply gated = %v, want true", echoed["gated"])
+ }
+ stored, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "gate.go.tito.io")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.HasPrefix(stored.Gate, "$2a$10$") {
+ t.Fatalf("stored gate = %q, want a bcrypt hash", stored.Gate)
+ }
+ if strings.Contains(stored.Gate, "correct horse") {
+ t.Fatal("stored gate carries the plaintext")
+ }
+ if detail := lastEventDetail(t, fx, "titogo"); detail != "updated instance gate.go.tito.io (gate) (cli)" {
+ t.Fatalf("audit detail = %q", detail)
+ }
+
+ // The list and the GET shadow the hash the same way.
+ for _, path := range []string{u, pu} {
+ _, _, body := rawReq(t, http.MethodGet, path, token, "")
+ if strings.Contains(body, "$2a$") {
+ t.Fatalf("GET %s leaks the hash: %s", path, body)
+ }
+ if !strings.Contains(body, `"gated":true`) {
+ t.Fatalf("GET %s lacks gated:true: %s", path, body)
+ }
+ }
+
+ status, _, out = rawReq(t, http.MethodPatch, pu, token, `{"gate":""}`)
+ if status != http.StatusOK {
+ t.Fatalf("patch clear = %d %s", status, out)
+ }
+ stored, _ = provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "gate.go.tito.io")
+ if stored.Gate != "" {
+ t.Fatalf("stored gate after clear = %q", stored.Gate)
+ }
+ _, _, body := rawReq(t, http.MethodGet, pu, token, "")
+ if !strings.Contains(body, `"gated":false`) {
+ t.Fatalf("GET after clear: %s", body)
+ }
+}
+
+// TestAPIInstanceUpdateGateRefusals: gate.CheckPassword's two bounds are this
+// door's own 400, in the caller's terms — and the refusal never echoes the
+// password back, because a 400 body is exactly where a rejected password
+// would otherwise land in a log or a terminal's scrollback.
+func TestAPIInstanceUpdateGateRefusals(t *testing.T) {
+ fx, token := instanceFixture(t, "code-inst-gate-refuse")
+ enableInstances(t, fx, "titogo", sharedPoolManifest())
+ u := instancesURL(fx, "bab", "titogo")
+ if status, _, out := rawReq(t, http.MethodPost, u, token,
+ `{"host":"gr.go.tito.io","region":"ie","addrs":["127.0.0.1:7001"]}`); status != http.StatusCreated {
+ t.Fatalf("create = %d %s", status, out)
+ }
+ pu := updateURL(fx, "bab", "titogo", "gr.go.tito.io")
+ for _, body := range []string{`{"gate":"short7!"}`, `{"gate":"` + strings.Repeat("x", 73) + `"}`} {
+ status, _, out := rawReq(t, http.MethodPatch, pu, token, body)
+ if status != http.StatusBadRequest || !strings.Contains(out, "password") {
+ t.Fatalf("%s -> %d %s, want 400 naming the password rule", body, status, out)
+ }
+ if strings.Contains(out, "short7!") || strings.Contains(out, "xxxxxxxx") {
+ t.Fatalf("refusal echoes the password: %s", out)
+ }
+ }
+ // The empty-body rail names gate among the patchable fields.
+ if _, _, out := rawReq(t, http.MethodPatch, pu, token, `{}`); !strings.Contains(out, "gate") {
+ t.Fatalf("empty-body refusal should name gate: %s", out)
+ }
+}
diff --git a/internal/console/appsettings.go b/internal/console/appsettings.go
index ba6703eab..c6af24e27 100644
--- a/internal/console/appsettings.go
+++ b/internal/console/appsettings.go
@@ -34,12 +34,15 @@ import (
"log"
"net/http"
"net/url"
+ "os"
"sort"
"strings"
"time"
+ "github.com/carlosframework/platform/internal/gate"
"github.com/carlosframework/platform/internal/mail"
"github.com/carlosframework/platform/internal/objstore"
+ "github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/release"
)
@@ -110,6 +113,20 @@ type SettingsView struct {
// readyRouteHost skips Platform rows, so it still takes the
// first-route branch and still needs somewhere to type a channel.
NeedsChannel bool
+
+ // Gates is the password-gate section (spec §5.5): one row per instance
+ // record, with whether a password is set. GatesUnavailable carries the
+ // reason when the records could not be read (no bucket, a store error)
+ // and the section says so instead of rendering an empty list as "no
+ // instances".
+ Gates []GateRow
+ GatesUnavailable string
+}
+
+// GateRow is one instance on the Settings page's password-gate section.
+type GateRow struct {
+ Host string
+ Gated bool
}
// StoreCard is the Settings tab's view of apps/<acct>/<app>/store.json (issue
@@ -238,6 +255,7 @@ func (s *Server) assembleSettings(ctx context.Context, p *page, acct *Account) *
} else {
sv.NeedsChannel = route == ""
}
+ sv.Gates, sv.GatesUnavailable = s.settingsGates(ctx, acct, p.AppName)
if s.cfg.Releases == nil {
sv.RenameBlocked = "No deployment bucket on this host, so the never-shipped check cannot run."
return sv
@@ -267,6 +285,25 @@ func (s *Server) assembleSettings(ctx context.Context, p *page, acct *Account) *
return sv
}
+// settingsGates lists the app's instance records for the password-gate
+// section. The hash never reaches the template: GateRow carries a bool.
+func (s *Server) settingsGates(ctx context.Context, acct *Account, app string) ([]GateRow, string) {
+ if s.cfg.Releases == nil {
+ return nil, "No deployment bucket on this host, so instance records cannot be read."
+ }
+ recs, _, err := provision.ListRecords(ctx, s.cfg.Releases, acct.Sqid, app, instanceListMax)
+ if err != nil {
+ log.Printf("console: settings gates %s/%s: %v", acct.Sqid, app, err)
+ return nil, "Instance records could not be read just now."
+ }
+ rows := make([]GateRow, 0, len(recs))
+ for _, rec := range recs {
+ rows = append(rows, GateRow{Host: rec.Host, Gated: rec.Gate != ""})
+ }
+ sort.Slice(rows, func(i, j int) bool { return rows[i].Host < rows[j].Host })
+ return rows, ""
+}
+
// FeaturesCard is the Settings page's read of the app's feature flags.
type FeaturesCard struct {
Flags []FeatureFlag // key-sorted
@@ -634,6 +671,92 @@ func renameAdvice(err error, to string) string {
}
}
+// handleAppGate is the Settings page's password-gate form: set a password
+// on one instance host, or turn it off. Member-gated, matching the API
+// PATCH's door — the CLI already lets a member do this, and a form that
+// refuses what the CLI allows is a rule in the wrong place. CSRF first,
+// then the same read-mutate-validate-write-audit sequence as the PATCH,
+// against one field. The password is never logged, audited or echoed.
+func (s *Server) handleAppGate(w http.ResponseWriter, r *http.Request) {
+ addr, raw, acct, _, app, ok := s.accountApp(w, r)
+ if !ok {
+ return
+ }
+ if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
+ s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
+ return
+ }
+ back := func(kind, msg string) {
+ s.setFlash(w, kind, msg)
+ http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/settings",
+ http.StatusSeeOther)
+ }
+ if s.cfg.Releases == nil {
+ back("err", "No deployment bucket on this host, so the gate cannot be changed here.")
+ return
+ }
+ host := strings.TrimSpace(r.FormValue("host"))
+ if !provision.ValidHostShape(host) {
+ http.NotFound(w, r)
+ return
+ }
+ var hash string
+ switch r.FormValue("action") {
+ case "clear":
+ case "set":
+ p := r.FormValue("password")
+ if err := gate.CheckPassword(p); err != nil {
+ back("err", err.Error()+".")
+ return
+ }
+ h, err := gate.Hash(p)
+ if err != nil {
+ log.Printf("console: gate hash %s/%s/%s: %v", acct.Sqid, app, host, err)
+ back("err", "Could not set the password — try again.")
+ return
+ }
+ hash = h
+ default:
+ back("err", "Choose set or clear.")
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
+ defer cancel()
+ rec, etag, err := provision.GetRecordETag(ctx, s.cfg.Releases, acct.Sqid, app, host)
+ if errors.Is(err, os.ErrNotExist) {
+ http.NotFound(w, r)
+ return
+ }
+ if err != nil {
+ log.Printf("console: gate read %s/%s/%s: %v", acct.Sqid, app, host, err)
+ back("err", "Could not read the instance — try again.")
+ return
+ }
+ rec.Gate = hash
+ if err := provision.ValidateShape(*rec); err != nil {
+ back("err", err.Error()+".")
+ return
+ }
+ if err := provision.UpdateRecord(ctx, s.cfg.Releases, *rec, etag); err != nil {
+ if errors.Is(err, release.ErrCASConflict) {
+ back("err", "The instance changed while this request was in flight — try again.")
+ return
+ }
+ log.Printf("console: gate write %s/%s/%s: %v", acct.Sqid, app, host, err)
+ back("err", "Could not update the instance — try again.")
+ return
+ }
+ if hash == "" {
+ s.audit(acct, app, addr, "gate", "cleared password gate on "+host+" (console)")
+ s.bumpSerial(ctx)
+ back("ok", "Password turned off on "+host+".")
+ return
+ }
+ s.audit(acct, app, addr, "gate", "set password gate on "+host+" (console)")
+ s.bumpSerial(ctx)
+ back("ok", "Password set on "+host+".")
+}
+
// handleAppDelete puts the app in the trash: the deletion-policy marker in
// the bucket, then this console's row — the same order and the same clock
// discipline as the API door (handleAPITrashApp), behind the account-owner
diff --git a/internal/console/appsettings_gate_test.go b/internal/console/appsettings_gate_test.go
new file mode 100644
index 000000000..b03ddcd34
--- /dev/null
+++ b/internal/console/appsettings_gate_test.go
@@ -0,0 +1,132 @@
+package console
+
+import (
+ "context"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/provision"
+)
+
+func TestAppSettingsGateSetAndClear(t *testing.T) {
+ fx, token := instanceFixture(t, "code-settings-gate")
+ enableInstances(t, fx, "titogo", sharedPoolManifest())
+ if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), token,
+ `{"host":"pw.go.tito.io","region":"ie","addrs":["127.0.0.1:7001"]}`); status != http.StatusCreated {
+ t.Fatalf("create = %d %s", status, out)
+ }
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-settings-gate-browser")
+ csrf := sessionToken(t, fx, c)
+
+ // The page shows the host as open, with a form.
+ _, _, body := get(t, c, fx.u("/apps/titogo/settings"))
+ if !strings.Contains(body, "Password gate") || !strings.Contains(body, "pw.go.tito.io") || !strings.Contains(body, "Open") {
+ t.Fatalf("settings page lacks the gate section: %s", body)
+ }
+
+ status, loc, _ := postForm(t, c, fx.u("/apps/titogo/gate"),
+ url.Values{"csrf": {csrf}, "host": {"pw.go.tito.io"}, "action": {"set"}, "password": {"correct horse"}})
+ if status != http.StatusSeeOther || loc != fx.path("/apps/titogo/settings") {
+ t.Fatalf("set = %d -> %q", status, loc)
+ }
+ rec, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "pw.go.tito.io")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.HasPrefix(rec.Gate, "$2a$10$") {
+ t.Fatalf("stored gate = %q", rec.Gate)
+ }
+ _, _, body = get(t, c, fx.u("/apps/titogo/settings"))
+ if !strings.Contains(body, "Password set on pw.go.tito.io.") || !strings.Contains(body, "Turn off") {
+ t.Fatalf("after set: %s", body)
+ }
+ if strings.Contains(body, "$2a$") || strings.Contains(body, "correct horse") {
+ t.Fatal("page leaks the hash or the password")
+ }
+
+ // A short password is refused with the rule, and nothing changes.
+ postForm(t, c, fx.u("/apps/titogo/gate"),
+ url.Values{"csrf": {csrf}, "host": {"pw.go.tito.io"}, "action": {"set"}, "password": {"short"}})
+ _, _, body = get(t, c, fx.u("/apps/titogo/settings"))
+ if !strings.Contains(body, "at least 8 characters") {
+ t.Fatalf("short password not refused on the page: %s", body)
+ }
+
+ status, _, _ = postForm(t, c, fx.u("/apps/titogo/gate"),
+ url.Values{"csrf": {csrf}, "host": {"pw.go.tito.io"}, "action": {"clear"}})
+ if status != http.StatusSeeOther {
+ t.Fatalf("clear = %d", status)
+ }
+ rec, _ = provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "pw.go.tito.io")
+ if rec.Gate != "" {
+ t.Fatalf("gate after clear = %q", rec.Gate)
+ }
+ events, _ := fx.st.Events(OpsAccountID, "titogo", 20)
+ var saw int
+ for _, e := range events {
+ if e.Action == "gate" {
+ saw++
+ if strings.Contains(e.Detail, "correct horse") {
+ t.Fatal("audit carries the password")
+ }
+ }
+ }
+ if saw != 2 {
+ t.Fatalf("gate audit rows = %d, want 2", saw)
+ }
+}
+
+// The gate door is not an oracle. A signed-in browser that belongs to no
+// account here gets the account family's 404 — byte for byte what an
+// account that does not exist answers, so a stranger cannot use the gate
+// form to learn that this account, this app or this host is real — and the
+// record is untouched. Worth pinning on THIS door in particular: it is the
+// one that takes a password, and a stranger who could set one would lock an
+// account out of its own site.
+func TestAppSettingsGateIsNotAnOracle(t *testing.T) {
+ fx, token := instanceFixture(t, "code-settings-gate-stranger")
+ enableInstances(t, fx, "titogo", sharedPoolManifest())
+ if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), token,
+ `{"host":"pw.go.tito.io","region":"ie","addrs":["127.0.0.1:7001"]}`); status != http.StatusCreated {
+ t.Fatalf("create = %d %s", status, out)
+ }
+
+ stranger := newBrowser(t)
+ fx.setUser("eve@keymail.dev") // signs up into an account of her own
+ signUp(t, fx, stranger, "code-settings-gate-stranger-browser")
+ csrf := sessionToken(t, fx, stranger)
+
+ form := url.Values{"csrf": {csrf}, "host": {"pw.go.tito.io"}, "action": {"set"}, "password": {"eve was here"}}
+ ghost, _, missing := postForm(t, stranger, fx.ts.URL+"/a/zzzzz/apps/titogo/gate", form)
+ if ghost != http.StatusNotFound {
+ t.Fatalf("the yardstick is not a 404: nonexistent account = %d", ghost)
+ }
+ status, loc, body := postForm(t, stranger, fx.u("/apps/titogo/gate"), form)
+ if status != http.StatusNotFound {
+ t.Fatalf("gate POST as a non-member = %d -> %q, want 404", status, loc)
+ }
+ if body != missing {
+ t.Fatalf("a non-member's 404 differs from a nonexistent account's:\n%q\nvs\n%q", body, missing)
+ }
+ rec, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "pw.go.tito.io")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if rec.Gate != "" {
+ t.Fatalf("a stranger set a gate: %q", rec.Gate)
+ }
+}
+
+func TestAppSettingsGateNeedsCSRF(t *testing.T) {
+ fx, _ := instanceFixture(t, "code-settings-gate-csrf")
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-settings-gate-csrf-browser")
+ status, _, body := postForm(t, c, fx.u("/apps/titogo/gate"),
+ url.Values{"csrf": {"not-the-token"}, "host": {"pw.go.tito.io"}, "action": {"clear"}})
+ if status != http.StatusForbidden || !strings.Contains(body, "Cross-site request blocked.") {
+ t.Fatalf("got %d %s", status, body)
+ }
+}
diff --git a/internal/console/channelmove.go b/internal/console/channelmove.go
index d5cbffd15..9dfd53c52 100644
--- a/internal/console/channelmove.go
+++ b/internal/console/channelmove.go
@@ -140,7 +140,7 @@ func (s *Server) handleAPIInstanceChannel(w http.ResponseWriter, r *http.Request
// Nothing changed: no write, no serial bump, no audit line. A serial
// that names no change sends every box in the fleet converging for
// nothing, and buries the tenant's audit log under no-ops.
- writeJSON(w, http.StatusOK, rec)
+ writeJSON(w, http.StatusOK, recordView(*rec))
return
}
pipe, err := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app)
@@ -189,7 +189,7 @@ func (s *Server) handleAPIInstanceChannel(w http.ResponseWriter, r *http.Request
// column from this record, so announcing it is what makes the move take
// seconds instead of an hour.
s.bumpSerial(ctx)
- writeJSON(w, http.StatusOK, rec)
+ writeJSON(w, http.StatusOK, recordView(*rec))
}
// isCanaryChannel reports whether a name is in the reserved canary
diff --git a/internal/console/server.go b/internal/console/server.go
index f9a80409a..b3935dcf5 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -1089,6 +1089,9 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
// The app Settings writes (appsettings.go): owner-only, and delete/move
// behind passkey step-up besides.
s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/place", s.handleAppPlace)
+ // The password-gate form (appsettings.go's handleAppGate): member-gated,
+ // unlike the owner-only writes above it.
+ s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/gate", s.handleAppGate)
s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/rename", s.handleAppRename)
s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/delete", s.handleAppDelete)
s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/move", s.handleAppMoveAccount)
diff --git a/internal/console/templates/app_settings.html b/internal/console/templates/app_settings.html
index f14df31e8..f6cbb790b 100644
--- a/internal/console/templates/app_settings.html
+++ b/internal/console/templates/app_settings.html
@@ -84,6 +84,44 @@ certificate itself and renews it, so nothing has to visit the domain
first.</p>
{{end}}
+<h2>Password gate</h2>
+<p class="small">One shared password in front of a hostname — for a static
+site or a prototype that should not be world-readable. Visitors see a plain
+page asking for it and stay signed in for a week. It is set per instance.</p>
+{{if .Settings.GatesUnavailable}}<p class="small">{{.Settings.GatesUnavailable}}</p>
+{{else if .Settings.Gates}}
+<div class="tablewrap">
+<table>
+ <tr><th>Host</th><th>State</th><th></th></tr>
+ {{range $i, $g := .Settings.Gates}}
+ <tr>
+ <td><code>{{$g.Host}}</code></td>
+ <td>{{if $g.Gated}}Password set{{else}}Open{{end}}</td>
+ <td>
+ <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/gate" class="settings-row">
+ <input type="hidden" name="csrf" value="{{$.CSRF}}">
+ <input type="hidden" name="host" value="{{$g.Host}}">
+ <input type="hidden" name="action" value="set">
+ <label class="visually-hidden" for="gate-pw-{{$i}}">Password for {{$g.Host}}</label>
+ <input type="password" id="gate-pw-{{$i}}" name="password" minlength="8" maxlength="72"
+ autocomplete="new-password" placeholder="password" required>
+ <button type="submit" class="btn-sm">{{if $g.Gated}}Change{{else}}Set password{{end}}</button>
+ </form>
+ {{if $g.Gated}}
+ <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/gate" class="settings-row">
+ <input type="hidden" name="csrf" value="{{$.CSRF}}">
+ <input type="hidden" name="host" value="{{$g.Host}}">
+ <input type="hidden" name="action" value="clear">
+ <button type="submit" class="btn-sm btn-danger">Turn off</button>
+ </form>
+ {{end}}
+ </td>
+ </tr>
+ {{end}}
+</table>
+</div>
+{{else}}<p class="small">No instances yet. A password gate is set per instance.</p>{{end}}
+
{{if and .Settings .Settings.MismatchTarget}}
<h2>Account</h2>
<p>This app's hosts read releases from account
diff --git a/internal/edge/edge.go b/internal/edge/edge.go
index cbd40ad2f..e11c2a27e 100644
--- a/internal/edge/edge.go
+++ b/internal/edge/edge.go
@@ -34,6 +34,7 @@ import (
"net/http"
"net/http/httputil"
"net/textproto"
+ "net/url"
"os"
"os/signal"
"path"
@@ -50,6 +51,7 @@ import (
"github.com/carlosframework/platform/internal/apperr"
"github.com/carlosframework/platform/internal/edgesfile"
+ "github.com/carlosframework/platform/internal/gate"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
@@ -93,6 +95,11 @@ type Edge struct {
// processes woken per request. Nil keeps the proxy-only behavior; with it
// set, unflagged routes are still proxied straight (the flag is per host).
act Activator
+ // gate is the password curtain (internal/gate) for routes whose row
+ // carries a gate hash. Always constructed: the check is one string
+ // compare on up.route.Gate for every ungated host, and the limiter it
+ // carries is shared across the box's gated hosts.
+ gate *gate.Gate
// fallbackURL, when set, is where requests for unknown hosts are redirected
// (e.g. the apex marketing site) instead of a bare 404 — a wildcard DNS
// record then makes typo'd or retired subdomains land somewhere useful. Note
@@ -286,7 +293,7 @@ type upstream struct {
// New builds an Edge over reg and performs the startup reload. act may be nil
// (no hibernation).
func New(reg *registry.Registry, act Activator) (*Edge, error) {
- e := &Edge{reg: reg, act: act, probers: newProberSet(), gzipOn: gzipEnabled(), steeringHost: steeringProbeHostFromEnv()}
+ e := &Edge{reg: reg, act: act, gate: gate.New(), probers: newProberSet(), gzipOn: gzipEnabled(), steeringHost: steeringProbeHostFromEnv()}
if err := e.Reload(); err != nil {
return nil, err
}
@@ -1270,6 +1277,62 @@ func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
+ // A login POST re-resolves its upstream by the path in `next`, not by
+ // its own (spec §4.2). On a hostname that routes by path prefix,
+ // lookupUpstreamPath keys on the first segment; "/.carlos/gate" has no
+ // segment that can ever be a prefix, so a login POST falls through to
+ // the FRONT host's route — the password would be judged by the wrong
+ // row (or proxied to an app that never asked for it, which the
+ // ungated-route branch below then refuses), and the cookie would be
+ // signed for the wrong host. `next` is the path the visitor was refused
+ // on, so resolving by it lands on the route that put up the page. On a
+ // host with no prefix map it resolves to the same route either way.
+ //
+ // Placed AFTER the lookup above and its !ok branch, which stay
+ // byte-for-byte what they were, because this reads the request BODY: a
+ // host this box does not serve may be backhauled to the box that homes
+ // it (e.backhaul, above), and that reverse-proxies this very request —
+ // draining its body here would send the home box a ContentLength it
+ // cannot fill, and the hop would 502. Nothing is read until the host is
+ // known to be ours. Placed before the meter so every step below,
+ // billing included, sees one settled up.
+ //
+ // A body that will not parse — over gate.MaxFormBody, or malformed — is
+ // answered 413 here and never reaches Admit, so it spends no limiter
+ // token and no bcrypt comparison. Refusing junk must not cost a real
+ // visitor their attempts.
+ loginPost := r.Method == http.MethodPost && r.URL.Path == gate.LoginPath
+ if loginPost {
+ r.Body = http.MaxBytesReader(w, r.Body, gate.MaxFormBody)
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, http.StatusText(http.StatusRequestEntityTooLarge), http.StatusRequestEntityTooLarge)
+ return
+ }
+ // Resolve on the PATH of `next`, never on the raw string. The 401
+ // page's next is SafeNext(r.URL.RequestURI()), so it carries the
+ // query the visitor was refused on and it is percent-ENCODED —
+ // while the prefix map is keyed on decoded segments, the way
+ // r.URL.Path arrives for an ordinary request. Splitting the raw
+ // string makes firstSegment("/paulca?tab=1") "paulca?tab=1", which
+ // matches nothing and drops the login onto the FRONT route: the
+ // wrong hash judges the password and the cookie is signed for the
+ // wrong host. Parsing first drops the query and the fragment and
+ // decodes the segments, so both shapes land where the refusal did.
+ //
+ // A miss cannot happen — SafeNext yields a path on this host, and
+ // the worst case "/" resolves to the host's own route, the one
+ // already in up — but keeping the first up is the honest answer if
+ // the table changed underneath.
+ next := gate.SafeNext(r.PostForm.Get("next"))
+ lookupPath := "/"
+ if u, err := url.ParseRequestURI(next); err == nil && u.Path != "" {
+ lookupPath = u.Path
+ }
+ if target, hit := e.lookupUpstreamPath(host, lookupPath); hit {
+ up = target
+ }
+ }
+
// Observe every route this box serves except the platform's own status
// host (not billable, and not a member's app) — wrapping w here, once,
// before the upgrade/cache branching below, is what makes all three
@@ -1319,6 +1382,29 @@ func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}()
}
+ // The password gate (internal/gate; spec 2026-09-07-password-gate-design
+ // §4.1). Placed HERE and the neighbours are load-bearing: after the
+ // meter, so the login page's bytes bill to the route that caused them;
+ // before the upgrade branch, so a WebSocket handshake is gated; before
+ // the cache check, so a cache hit is only ever served to a request that
+ // already passed (the LRU keys on host and path, not on cookies); and
+ // before the activator, so a stranger — a crawler, a scanner, a guessed
+ // hostname — never wakes a sleeping instance. Admit strips the cookie
+ // on the way through, so the app never sees it.
+ //
+ // A login POST that lands on an UNGATED route is a 404 and is never
+ // proxied (spec §4.2): the form is only ever rendered by a gated route,
+ // so this is a stray or a probe, and forwarding a field named
+ // "password" to an app that never asked for it is the one outcome to
+ // rule out. Every other method leaves the path ordinary.
+ if loginPost && up.route.Gate == "" {
+ http.NotFound(w, r)
+ return
+ }
+ if up.route.Gate != "" && !e.gate.Admit(w, r, up.route.Host, up.route.Gate) {
+ return
+ }
+
// A Connection: Upgrade request (a WebSocket handshake, typically GET)
// must reach the activator/proxy with the real ResponseWriter, completely
// untouched by caching: the stdlib reverse proxy's upgrade path type-
diff --git a/internal/edge/gate_test.go b/internal/edge/gate_test.go
new file mode 100644
index 000000000..dbeb39b26
--- /dev/null
+++ b/internal/edge/gate_test.go
@@ -0,0 +1,508 @@
+package edge
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/gate"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+func gatedEdge(t *testing.T, act Activator, hibernate bool) (*Edge, *httptest.Server, string, *string) {
+ t.Helper()
+ backend, seenCookie := cookieEchoBackend(t, "secret page")
+ h, err := gate.Hash("correct horse")
+ if err != nil {
+ t.Fatal(err)
+ }
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{Host: "gated.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(),
+ Status: "ready", Hibernate: hibernate, Gate: h}); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.UpsertAlias(registry.RouteAlias{Alias: "www.custom.example", Host: "gated.example.com",
+ Account: "acct", App: "app", CreatedAt: 1}); err != nil {
+ t.Fatal(err)
+ }
+ e, err := New(reg, act)
+ if err != nil {
+ t.Fatal(err)
+ }
+ srv := httptest.NewServer(e)
+ t.Cleanup(srv.Close)
+ return e, srv, h, seenCookie
+}
+
+// cookieEchoBackend records the Cookie header it saw.
+func cookieEchoBackend(t *testing.T, body string) (*httptest.Server, *string) {
+ t.Helper()
+ seen := new(string)
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ *seen = r.Header.Get("Cookie")
+ io.WriteString(w, body)
+ }))
+ t.Cleanup(ts.Close)
+ return ts, seen
+}
+
+func doHost(t *testing.T, srv *httptest.Server, method, host, path, cookie string) *http.Response {
+ t.Helper()
+ req, _ := http.NewRequest(method, srv.URL+path, nil)
+ req.Host = host
+ if cookie != "" {
+ req.Header.Set("Cookie", cookie)
+ }
+ c := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
+ resp, err := c.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { resp.Body.Close() })
+ return resp
+}
+
+func TestGatedRouteWithoutCookieNeverWakesOrProxies(t *testing.T) {
+ // refusingActivator's Fatal message talks about caching — that is where
+ // it came from — but here it proves the gate: a route flagged
+ // Hibernate:true whose activator is never invoked is one a stranger
+ // could not have woken.
+ _, srv, _, seen := gatedEdge(t, refusingActivator{t: t}, true)
+ resp := doHost(t, srv, http.MethodGet, "gated.example.com", "/docs", "")
+ if resp.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("status = %d, want 401", resp.StatusCode)
+ }
+ b, _ := io.ReadAll(resp.Body)
+ if !strings.Contains(string(b), "Password required") {
+ t.Fatalf("body: %s", b)
+ }
+ if resp.Header.Get("Cache-Control") != "no-store" {
+ t.Fatalf("Cache-Control = %q", resp.Header.Get("Cache-Control"))
+ }
+ if resp.Header.Get("X-Robots-Tag") != "noindex, nofollow" {
+ t.Fatalf("X-Robots-Tag = %q", resp.Header.Get("X-Robots-Tag"))
+ }
+ if *seen != "" || strings.Contains(string(b), "secret page") {
+ t.Fatal("upstream was reached without a cookie")
+ }
+}
+
+func TestGatedRouteCookieAdmitsAndIsStripped(t *testing.T) {
+ _, srv, h, seen := gatedEdge(t, &fakeActivator{}, false)
+ cookie := gate.CookieName + "=" + gate.Mint("gated.example.com", h, time.Now().Add(time.Hour))
+ resp := doHost(t, srv, http.MethodGet, "gated.example.com", "/", "a=1; "+cookie)
+ b, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode != 200 || string(b) != "secret page" {
+ t.Fatalf("got %d %q", resp.StatusCode, b)
+ }
+ if *seen != "a=1" {
+ t.Fatalf("upstream Cookie = %q, want the gate cookie stripped", *seen)
+ }
+}
+
+func TestGatedAliasIsGatedToo(t *testing.T) {
+ _, srv, h, _ := gatedEdge(t, &fakeActivator{}, false)
+ if resp := doHost(t, srv, http.MethodGet, "www.custom.example", "/", ""); resp.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("alias without cookie = %d, want 401", resp.StatusCode)
+ }
+ // The MAC is bound to the ROUTE host, so a cookie minted for the primary
+ // verifies when presented on the alias.
+ cookie := gate.CookieName + "=" + gate.Mint("gated.example.com", h, time.Now().Add(time.Hour))
+ if resp := doHost(t, srv, http.MethodGet, "www.custom.example", "/", cookie); resp.StatusCode != 200 {
+ t.Fatalf("alias with cookie = %d, want 200", resp.StatusCode)
+ }
+}
+
+func TestGatedLoginPostSetsCookie(t *testing.T) {
+ _, srv, h, _ := gatedEdge(t, &fakeActivator{}, false)
+ form := url.Values{"password": {"correct horse"}, "next": {"/docs"}}
+ req, _ := http.NewRequest(http.MethodPost, srv.URL+gate.LoginPath, strings.NewReader(form.Encode()))
+ req.Host = "gated.example.com"
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ c := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
+ resp, err := c.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "/docs" {
+ t.Fatalf("got %d -> %q", resp.StatusCode, resp.Header.Get("Location"))
+ }
+ var got string
+ for _, k := range resp.Cookies() {
+ if k.Name == gate.CookieName {
+ got = k.Value
+ }
+ }
+ if got == "" || !gate.Verify(got, "gated.example.com", h, time.Now()) {
+ t.Fatalf("cookie %q does not verify", got)
+ }
+}
+
+// TestGatedLoginPostOnAliasKeepsTheInboundHost is the pin on what the edge
+// hands Admit: the same-origin check compares Origin against the REQUEST's
+// Host, so a login posted to the attached hostname must arrive with that
+// hostname intact — while the cookie it mints is signed for the ROUTE's
+// host, the one every alias of the route shares. Rewriting r.Host to the
+// route before the gate step would 403 every alias login.
+func TestGatedLoginPostOnAliasKeepsTheInboundHost(t *testing.T) {
+ _, srv, h, _ := gatedEdge(t, &fakeActivator{}, false)
+ form := url.Values{"password": {"correct horse"}, "next": {"/docs"}}
+ req, _ := http.NewRequest(http.MethodPost, srv.URL+gate.LoginPath, strings.NewReader(form.Encode()))
+ req.Host = "www.custom.example"
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Origin", "http://www.custom.example")
+ c := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
+ resp, err := c.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "/docs" {
+ t.Fatalf("got %d -> %q", resp.StatusCode, resp.Header.Get("Location"))
+ }
+ var got string
+ for _, k := range resp.Cookies() {
+ if k.Name == gate.CookieName {
+ got = k.Value
+ }
+ }
+ if got == "" || !gate.Verify(got, "gated.example.com", h, time.Now()) {
+ t.Fatalf("cookie %q does not verify for the route host", got)
+ }
+}
+
+// gatedPrefixEdge stands up the shape §4.2 is about: a hostname whose own
+// route is UNGATED and whose "paulca" prefix reaches a GATED route on
+// another host. A login POST to /.carlos/gate has no first segment that can
+// ever be a prefix, so the edge must route it by `next` to land on the
+// gated target rather than on the front.
+func gatedPrefixEdge(t *testing.T) (*httptest.Server, string, *string, *string) {
+ t.Helper()
+ front, frontSeen := hostEchoBackend(t, "the front app")
+ target, targetCookie := cookieEchoBackend(t, "secret page")
+ h, err := gate.Hash("correct horse")
+ if err != nil {
+ t.Fatal(err)
+ }
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{Host: "front.example.com", Kind: "service",
+ Addr: front.Listener.Addr().String(), Status: "ready"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.Upsert(registry.Route{Host: "gated.example.com", Kind: "service",
+ Addr: target.Listener.Addr().String(), Status: "ready", Gate: h}); err != nil {
+ t.Fatal(err)
+ }
+ upsertPath(t, reg, "front.example.com", "paulca", "gated.example.com")
+ e, err := New(reg, &fakeActivator{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ srv := httptest.NewServer(e)
+ t.Cleanup(srv.Close)
+ return srv, h, frontSeen, targetCookie
+}
+
+// TestGatedPrefixTargetCanBeLoggedInto is the §4.2 finding: routing the
+// login POST by its own path would resolve it to the FRONT host's ungated
+// route, which either proxies the password to an app that never asked for
+// it or mints a cookie signed for the wrong host. Routed by `next`, the
+// POST reaches the target whose hash judges it and whose host signs the
+// cookie — and that cookie then admits the visitor on the front hostname,
+// because the prefix resolves to the same route either way.
+func TestGatedPrefixTargetCanBeLoggedInto(t *testing.T) {
+ srv, h, frontSeen, targetCookie := gatedPrefixEdge(t)
+
+ // The refusal itself: the prefix reaches the gated route, so the front
+ // app never sees the request.
+ resp := doHost(t, srv, http.MethodGet, "front.example.com", "/paulca/x", "")
+ b, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode != http.StatusUnauthorized || !strings.Contains(string(b), "Password required") {
+ t.Fatalf("prefix GET without a cookie = %d %s, want the 401 login page", resp.StatusCode, b)
+ }
+ if *frontSeen != "" {
+ t.Fatalf("the front app was reached: Host %q", *frontSeen)
+ }
+
+ post := postLogin(t, srv, "front.example.com", "correct horse", "/paulca/x")
+ if post.StatusCode != http.StatusSeeOther || post.Header.Get("Location") != "/paulca/x" {
+ t.Fatalf("login POST = %d -> %q, want 303 -> /paulca/x", post.StatusCode, post.Header.Get("Location"))
+ }
+ if *frontSeen != "" {
+ t.Fatalf("the login POST reached the front app: Host %q — the password must never be proxied", *frontSeen)
+ }
+ got := gateCookie(post)
+ if got == "" || !gate.Verify(got, "gated.example.com", h, time.Now()) {
+ t.Fatalf("cookie %q does not verify for the target host", got)
+ }
+
+ resp = doHost(t, srv, http.MethodGet, "front.example.com", "/paulca/x", gate.CookieName+"="+got)
+ b, _ = io.ReadAll(resp.Body)
+ if resp.StatusCode != 200 || string(b) != "secret page" {
+ t.Fatalf("prefix GET with the cookie = %d %q", resp.StatusCode, b)
+ }
+ if *targetCookie != "" {
+ t.Fatalf("target Cookie = %q, want the gate cookie stripped", *targetCookie)
+ }
+
+ // A refusal on a prefix path that carries a QUERY. The 401's next is
+ // SafeNext(r.URL.RequestURI()), which keeps the query, so the browser
+ // posts next=/paulca?tab=1 — and resolving the POST on that raw string
+ // makes the first segment "paulca?tab=1", which matches no prefix and
+ // drops the login onto the FRONT route: the wrong hash judges the
+ // password and the cookie is signed for the wrong host. The lookup must
+ // see the PATH only.
+ resp = doHost(t, srv, http.MethodGet, "front.example.com", "/paulca?tab=1", "")
+ b, _ = io.ReadAll(resp.Body)
+ if resp.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("prefix GET with a query = %d, want the 401 login page", resp.StatusCode)
+ }
+ if !strings.Contains(string(b), `name="next" value="/paulca?tab=1"`) {
+ t.Fatalf("the 401 page does not carry next=/paulca?tab=1:\n%s", b)
+ }
+
+ post = postLogin(t, srv, "front.example.com", "correct horse", "/paulca?tab=1")
+ if post.StatusCode != http.StatusSeeOther || post.Header.Get("Location") != "/paulca?tab=1" {
+ t.Fatalf("login POST with a query in next = %d -> %q, want 303 -> /paulca?tab=1",
+ post.StatusCode, post.Header.Get("Location"))
+ }
+ if *frontSeen != "" {
+ t.Fatalf("the login POST reached the front app: Host %q — it resolved on the raw next, query and all", *frontSeen)
+ }
+ got = gateCookie(post)
+ if got == "" || !gate.Verify(got, "gated.example.com", h, time.Now()) {
+ t.Fatalf("cookie %q does not verify for the target host — the POST was judged by the front route", got)
+ }
+
+ // And a percent-encoded first segment. RequestURI is raw, r.URL.Path is
+ // decoded: "/pau%6Cca/x" is "/paulca/x" once parsed, so the POST belongs
+ // to the target too.
+ post = postLogin(t, srv, "front.example.com", "correct horse", "/pau%6Cca/x")
+ if post.StatusCode != http.StatusSeeOther {
+ t.Fatalf("login POST with a percent-encoded segment = %d, want 303", post.StatusCode)
+ }
+ if *frontSeen != "" {
+ t.Fatalf("the login POST reached the front app: Host %q — the encoded segment was not decoded", *frontSeen)
+ }
+ got = gateCookie(post)
+ if got == "" || !gate.Verify(got, "gated.example.com", h, time.Now()) {
+ t.Fatalf("cookie %q does not verify for the target host", got)
+ }
+}
+
+// postLogin submits the gate's login form to srv as host, exactly as the
+// 401 page's form would, and returns the response with redirects left
+// unfollowed.
+func postLogin(t *testing.T, srv *httptest.Server, host, password, next string) *http.Response {
+ t.Helper()
+ form := url.Values{"password": {password}, "next": {next}}
+ req, _ := http.NewRequest(http.MethodPost, srv.URL+gate.LoginPath, strings.NewReader(form.Encode()))
+ req.Host = host
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ c := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
+ resp, err := c.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { resp.Body.Close() })
+ return resp
+}
+
+func gateCookie(resp *http.Response) string {
+ for _, k := range resp.Cookies() {
+ if k.Name == gate.CookieName {
+ return k.Value
+ }
+ }
+ return ""
+}
+
+// TestLoginPostToUngatedRouteIs404 is the other half of §4.2: the form is
+// only ever rendered by a gated route, so a login POST that lands on an
+// ungated one is a stray or a probe. It is answered 404 and the body — a
+// field literally named "password" — is never forwarded to an app that did
+// not ask for it.
+func TestLoginPostToUngatedRouteIs404(t *testing.T) {
+ backend, seenHost := hostEchoBackend(t, "open page")
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{Host: "open.example.com", Kind: "service",
+ Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil {
+ t.Fatal(err)
+ }
+ e, err := New(reg, &fakeActivator{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ srv := httptest.NewServer(e)
+ defer srv.Close()
+
+ form := url.Values{"password": {"correct horse"}, "next": {"/"}}
+ req, _ := http.NewRequest(http.MethodPost, srv.URL+gate.LoginPath, strings.NewReader(form.Encode()))
+ req.Host = "open.example.com"
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusNotFound {
+ t.Fatalf("login POST to an ungated route = %d, want 404", resp.StatusCode)
+ }
+ if *seenHost != "" {
+ t.Fatalf("the app was reached with the password form: Host %q", *seenHost)
+ }
+ // Every other method on that path stays ordinary and reaches the app.
+ if resp := doHost(t, srv, http.MethodGet, "open.example.com", gate.LoginPath, ""); resp.StatusCode != 200 {
+ t.Fatalf("GET %s on an ungated route = %d, want the app's 200", gate.LoginPath, resp.StatusCode)
+ }
+}
+
+// TestGatedRefusalIsMeteredToTheRoute pins the meter neighbour of the gate
+// step: the 401 is the tenant's page, served by the route, and its bytes
+// bill to the route like any other response it caused. Placing the gate
+// above the meter's wrap would make refusals free.
+func TestGatedRefusalIsMeteredToTheRoute(t *testing.T) {
+ e, srv, _, _ := gatedEdge(t, &fakeActivator{}, false)
+ m := NewUsageMeter()
+ e.EnableUsage(m)
+ resp := doHost(t, srv, http.MethodGet, "gated.example.com", "/docs", "")
+ if resp.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("status = %d, want 401", resp.StatusCode)
+ }
+ // Drain only once the whole body is in hand: the login page is over
+ // 2 KiB, so the headers reach the client before the handler returns and
+ // the meter's deferred Record has run.
+ io.ReadAll(resp.Body)
+ row, ok := m.Drain()["gated.example.com"]
+ if !ok {
+ t.Fatal("no usage row for the gated route — the refusal was not metered")
+ }
+ if row.Requests != 1 {
+ t.Fatalf("Requests = %d, want 1", row.Requests)
+ }
+ if row.BytesOut <= 0 {
+ t.Fatalf("BytesOut = %d, want the login page's bytes", row.BytesOut)
+ }
+}
+
+// TestLoginPostForAnUnknownHostLeavesTheBodyUnread is the backhaul pin. A
+// host this box does not serve may be homed on a sibling edge, and
+// e.backhaul reverse-proxies THIS request there — so nothing on the way to
+// that branch may read the body. Parsing the login form before the route
+// lookup drained it while leaving ContentLength alone, and the hop 502'd
+// with "ContentLength=41 with Body length 0". The form is parsed only once
+// the host is known to be ours.
+func TestLoginPostForAnUnknownHostLeavesTheBodyUnread(t *testing.T) {
+ e, _, _, _ := gatedEdge(t, &fakeActivator{}, false)
+ form := url.Values{"password": {"correct horse"}, "next": {"/docs"}}.Encode()
+ body := strings.NewReader(form)
+ req := httptest.NewRequest(http.MethodPost, gate.LoginPath, body)
+ req.Host = "not.served.here.example"
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, req)
+ if body.Len() != len(form) {
+ t.Fatalf("%d of %d body bytes were consumed before the route lookup — a backhauled hop would 502",
+ len(form)-body.Len(), len(form))
+ }
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("unknown host = %d, want the unchanged unknown-host answer (404)", rec.Code)
+ }
+}
+
+// TestOversizedLoginPostIs413AndSpendsNoAttempt: a body over
+// gate.MaxFormBody is refused at the edge, before Admit is ever called, so
+// it costs neither a bcrypt comparison nor a limiter token. The proof that
+// no token was spent is the ten wrong-password attempts that follow from
+// the same address: the bucket holds exactly ten, so if the junk body had
+// taken one, the last would be 429 instead of 401.
+func TestOversizedLoginPostIs413AndSpendsNoAttempt(t *testing.T) {
+ _, srv, _, seen := gatedEdge(t, &fakeActivator{}, false)
+ c := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
+ post := func(t *testing.T, form string) int {
+ t.Helper()
+ req, _ := http.NewRequest(http.MethodPost, srv.URL+gate.LoginPath, strings.NewReader(form))
+ req.Host = "gated.example.com"
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ resp, err := c.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ io.Copy(io.Discard, resp.Body)
+ return resp.StatusCode
+ }
+ if code := post(t, "password="+strings.Repeat("a", 64<<10)); code != http.StatusRequestEntityTooLarge {
+ t.Fatalf("oversized login POST = %d, want 413", code)
+ }
+ if *seen != "" {
+ t.Fatal("the oversized body reached the upstream")
+ }
+ for i := range 10 {
+ if code := post(t, url.Values{"password": {"wrong horse"}}.Encode()); code != http.StatusUnauthorized {
+ t.Fatalf("wrong-password attempt %d = %d, want 401 — the 413 spent a token", i+1, code)
+ }
+ }
+}
+
+func TestGatedRouteCacheIsNeverConsultedBeforeTheGate(t *testing.T) {
+ e, srv, _, _ := gatedEdge(t, &fakeActivator{}, false)
+ e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
+ e.cache.Put(CacheKey{Host: "gated.example.com", Method: "GET", Path: "/"}, "", &CachedResponse{
+ StatusCode: 200, Header: http.Header{"Content-Type": {"text/plain"}}, Body: []byte("cached secret"),
+ StoredAt: time.Now(), TTL: time.Minute,
+ })
+ resp := doHost(t, srv, http.MethodGet, "gated.example.com", "/", "")
+ b, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode != http.StatusUnauthorized || strings.Contains(string(b), "cached secret") {
+ t.Fatalf("cache served past the gate: %d %s", resp.StatusCode, b)
+ }
+}
+
+func TestGatedWebSocketHandshakeIsGated(t *testing.T) {
+ _, srv, h, _ := gatedEdge(t, &fakeActivator{}, false)
+ req, _ := http.NewRequest(http.MethodGet, srv.URL+"/ws", nil)
+ req.Host = "gated.example.com"
+ req.Header.Set("Connection", "Upgrade")
+ req.Header.Set("Upgrade", "websocket")
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("upgrade without cookie = %d, want 401", resp.StatusCode)
+ }
+ req.Header.Set("Cookie", gate.CookieName+"="+gate.Mint("gated.example.com", h, time.Now().Add(time.Hour)))
+ resp, err = http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode == http.StatusUnauthorized {
+ t.Fatal("upgrade with cookie was refused")
+ }
+}
+
+func TestUngatedRouteUnchanged(t *testing.T) {
+ backend, seen := cookieEchoBackend(t, "open page")
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{Host: "open.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil {
+ t.Fatal(err)
+ }
+ e, err := New(reg, &fakeActivator{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ srv := httptest.NewServer(e)
+ defer srv.Close()
+ resp := doHost(t, srv, http.MethodGet, "open.example.com", "/", gate.CookieName+"=whatever; a=1")
+ if resp.StatusCode != 200 || *seen != gate.CookieName+"=whatever; a=1" {
+ t.Fatalf("ungated route touched: %d cookie=%q", resp.StatusCode, *seen)
+ }
+}
diff --git a/internal/gate/cookie.go b/internal/gate/cookie.go
new file mode 100644
index 000000000..764adc720
--- /dev/null
+++ b/internal/gate/cookie.go
@@ -0,0 +1,61 @@
+package gate
+
+import (
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/base64"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// The cookie is v1.<expiry unix>.<base64url(HMAC-SHA256(key, host "\n" expiry))>
+// with key derived from the stored bcrypt hash and nothing else. That is
+// what makes the design stateless and gives it its one rotation rule:
+// change or clear the password and every cookie minted under the old one
+// stops verifying at once, on every box, with no key to mint or mirror.
+// Anyone who can read the hash can mint a cookie — root on the box and a
+// bucket reader, who already hold the tenant's sealed secrets (spec §4.3).
+
+const cookieVersion = "v1"
+
+func cookieKey(hash string) []byte {
+ m := hmac.New(sha256.New, []byte("carlos-gate-cookie-v1"))
+ m.Write([]byte(hash))
+ return m.Sum(nil)
+}
+
+func cookieMAC(key []byte, host string, expiry int64) []byte {
+ m := hmac.New(sha256.New, key)
+ m.Write([]byte(host))
+ m.Write([]byte{'\n'})
+ m.Write([]byte(strconv.FormatInt(expiry, 10)))
+ return m.Sum(nil)
+}
+
+// Mint produces a cookie value for host, valid until expiry.
+func Mint(host, hash string, expiry time.Time) string {
+ exp := expiry.Unix()
+ mac := cookieMAC(cookieKey(hash), host, exp)
+ return cookieVersion + "." + strconv.FormatInt(exp, 10) + "." + base64.RawURLEncoding.EncodeToString(mac)
+}
+
+// Verify reports whether value was minted for host under hash and has not
+// expired at now. One HMAC and a constant-time compare; no bcrypt.
+func Verify(value, host, hash string, now time.Time) bool {
+ parts := strings.Split(value, ".")
+ if len(parts) != 3 || parts[0] != cookieVersion {
+ return false
+ }
+ exp, err := strconv.ParseInt(parts[1], 10, 64)
+ // One spelling per expiry: ParseInt accepts "+123" and "-0", which
+ // would MAC differently from what Mint wrote for the same instant.
+ if err != nil || parts[1] != strconv.FormatInt(exp, 10) || exp <= now.Unix() {
+ return false
+ }
+ got, err := base64.RawURLEncoding.DecodeString(parts[2])
+ if err != nil {
+ return false
+ }
+ return hmac.Equal(got, cookieMAC(cookieKey(hash), host, exp))
+}
diff --git a/internal/gate/gate.go b/internal/gate/gate.go
new file mode 100644
index 000000000..58cb90c40
--- /dev/null
+++ b/internal/gate/gate.go
@@ -0,0 +1,259 @@
+// Package gate is the password curtain the edge holds in front of a gated
+// host: one shared password per hostname, a neutral page that asks for it,
+// and a cookie that remembers the answer for a week. Spec:
+// docs/superpowers/specs/2026-09-07-password-gate-design.md.
+//
+// The edge calls Admit once per request to a route whose registry row
+// carries a gate hash. Everything else here — the rules the console
+// applies before hashing, the cookie's shape, the attempt limiter — is
+// owned by this package so the two console doors and the edge cannot
+// drift from one another.
+//
+// The cookie is Secure, always. A gate that a network can read the answer
+// to is not a curtain, and the platform terminates TLS at the edge for
+// every real host — so the one place this bites is a plain-http dev edge
+// (`carlos edge -dev`), which cannot pass its own gate except on
+// localhost, where browsers make an exception for Secure. Testing a gate
+// off-localhost means testing it over https.
+package gate
+
+import (
+ "fmt"
+ "net"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "golang.org/x/crypto/bcrypt"
+)
+
+const (
+ // CookieName is the browser cookie a passed gate sets; stripped from
+ // the request before it reaches the app.
+ CookieName = "carlos_gate"
+ // LoginPath is the form's action on a gated host. Ordinary on every
+ // other host and for every other method.
+ LoginPath = "/.carlos/gate"
+ // Lifetime is how long a cookie stays valid; the expiry is inside the
+ // value, so a stolen cookie is bounded even if the browser keeps it.
+ Lifetime = 7 * 24 * time.Hour
+
+ // MaxFormBody bounds the login POST's body. Exported because the edge
+ // parses that form too — it routes a login POST by the `next` field
+ // (spec §4.2) — and the two must agree on one number.
+ MaxFormBody = 4 << 10
+
+ bcryptCost = 10
+ minPassword = 8
+ maxPassword = 72 // bcrypt's input limit; longer is refused, never truncated
+)
+
+// CheckPassword is the door's rule, applied by the console before hashing.
+func CheckPassword(p string) error {
+ if len(p) < minPassword {
+ return fmt.Errorf("password must be at least %d characters", minPassword)
+ }
+ if len(p) > maxPassword {
+ return fmt.Errorf("password must be at most %d bytes", maxPassword)
+ }
+ return nil
+}
+
+// Hash is bcrypt at cost 10. The console calls it once per set; the edge
+// never does (it compares on the login POST and verifies an HMAC cookie
+// on every other request).
+func Hash(p string) (string, error) {
+ if err := CheckPassword(p); err != nil {
+ return "", err
+ }
+ b, err := bcrypt.GenerateFromPassword([]byte(p), bcryptCost)
+ if err != nil {
+ return "", err
+ }
+ return string(b), nil
+}
+
+func comparePassword(hash, password string) bool {
+ return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
+}
+
+// Gate is the per-edge instance: one limiter shared by every gated host on
+// the box (keys carry the host), a clock and a comparer for tests.
+type Gate struct {
+ limiter *Limiter
+ now func() time.Time
+ compare func(hash, password string) bool
+}
+
+// New is the edge's constructor. There is no configuration.
+func New() *Gate {
+ g := &Gate{now: time.Now, compare: comparePassword}
+ g.limiter = NewLimiter(func() time.Time { return g.now() })
+ return g
+}
+
+// Admit decides one request to a gated host. host is the route's own
+// hostname (the row's, not the request's — an alias and a path-routed
+// prefix must agree on which key signs), hash the row's gate.
+//
+// true: the request carries a valid cookie, now stripped from the Cookie
+// header; the caller proxies it exactly as it would have. false: Admit has
+// written the whole response — the login page (401), the redirect after a
+// successful POST (303), the cross-origin refusal (403), or the
+// too-many-attempts page (429).
+//
+// Do not call Admit for a row with an empty hash. An empty hash fails
+// closed, not open — no password can match it and no cookie verifies
+// against it, so the host would become unreachable rather than public.
+// The edge gates on up.route.Gate != "" before it gets here.
+func (g *Gate) Admit(w http.ResponseWriter, r *http.Request, host, hash string) bool {
+ if r.Method == http.MethodPost && r.URL.Path == LoginPath {
+ g.login(w, r, host, hash)
+ return false
+ }
+ // Any cookie by this name will do. Tenant hosts share a wildcard apex,
+ // so a sibling host — or an XSS on one — can plant a second
+ // carlos_gate for .apex that sorts ahead of the real one; taking only
+ // the first would let that lock the visitor out for good.
+ for _, c := range r.Cookies() {
+ if c.Name == CookieName && Verify(c.Value, host, hash, g.now()) {
+ stripCookie(r)
+ return true
+ }
+ }
+ writePage(w, r, host, http.StatusUnauthorized, "", SafeNext(r.URL.RequestURI()))
+ return false
+}
+
+func (g *Gate) login(w http.ResponseWriter, r *http.Request, host, hash string) {
+ // The edge may already have parsed this form — it routes a login POST
+ // by the `next` field (spec §4.2) — in which case the body is spent
+ // and was bounded there. ParseForm itself is idempotent: with PostForm
+ // already set it returns the cached values.
+ if r.PostForm == nil {
+ r.Body = http.MaxBytesReader(w, r.Body, MaxFormBody)
+ }
+ if err := r.ParseForm(); err != nil {
+ writePage(w, r, host, http.StatusUnauthorized, msgWrong, "/")
+ return
+ }
+ next := SafeNext(r.PostForm.Get("next"))
+ if !sameOrigin(r) {
+ // The POST needs no cookie, so SameSite does not cover it: a hidden
+ // form on an attacker's page could auto-submit eleven guesses and
+ // park a visitor at 429 on a site they can otherwise reach. Refuse
+ // before the bucket is touched and before bcrypt runs, and say
+ // nothing about the password — none was judged.
+ writePage(w, r, host, http.StatusForbidden, "", next)
+ return
+ }
+ if !g.limiter.Allow(clientKey(r) + "\x1f" + host) {
+ w.Header().Set("Retry-After", "60")
+ writePage(w, r, host, http.StatusTooManyRequests, msgTooMany, next)
+ return
+ }
+ if !g.compare(hash, r.PostForm.Get("password")) {
+ writePage(w, r, host, http.StatusUnauthorized, msgWrong, next)
+ return
+ }
+ http.SetCookie(w, &http.Cookie{
+ Name: CookieName,
+ Value: Mint(host, hash, g.now().Add(Lifetime)),
+ Path: "/",
+ MaxAge: int(Lifetime / time.Second),
+ HttpOnly: true,
+ Secure: true,
+ SameSite: http.SameSiteLaxMode,
+ })
+ w.Header().Set("Cache-Control", "no-store")
+ w.Header().Set("X-Robots-Tag", "noindex, nofollow")
+ http.Redirect(w, r, next, http.StatusSeeOther)
+}
+
+// sameOrigin reports whether the POST came from the host it is posting to.
+// An absent Origin is allowed — curl and older clients send none — but a
+// present one must match, and "null" (a sandboxed frame, a file:// page)
+// never does. The comparison is against r.Host, not the route's host: an
+// alias posts from its own name, and the route's primary is a different
+// string.
+func sameOrigin(r *http.Request) bool {
+ o := r.Header.Get("Origin")
+ if o == "" {
+ return true
+ }
+ u, err := url.Parse(o)
+ if err != nil || u.Host == "" {
+ return false
+ }
+ return strings.EqualFold(hostOnly(u.Host), hostOnly(r.Host))
+}
+
+// hostOnly drops the port, if there is one, so :443 and an implicit 443
+// are the same origin here.
+func hostOnly(h string) string {
+ if x, _, err := net.SplitHostPort(h); err == nil {
+ return x
+ }
+ return h
+}
+
+// SafeNext is the entire open-redirect surface: a path on this host, or
+// "/". Exported because the edge sanitises the login POST's `next` with it
+// before resolving the upstream (spec §4.2) — one sanitiser, so the path
+// the edge routes a login POST by and the path it redirects to cannot
+// differ.
+func SafeNext(next string) string {
+ // A control byte — a tab above all — survives net/http's header writer,
+ // which rewrites only CR and LF, and browsers strip it before they
+ // resolve the URL: "/\t/evil.example/" would leave here intact and land
+ // as "//evil.example/". Refuse the lot before the shape is judged.
+ if strings.IndexFunc(next, func(r rune) bool { return r < 0x20 || r == 0x7f }) >= 0 {
+ return "/"
+ }
+ if !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") || strings.HasPrefix(next, "/\\") {
+ return "/"
+ }
+ // Bouncing back to the login path lands on a page the app behind the
+ // curtain does not serve; root is the honest destination.
+ p, _, _ := strings.Cut(next, "?")
+ p, _, _ = strings.Cut(p, "#")
+ if p == LoginPath {
+ return "/"
+ }
+ return next
+}
+
+// clientKey is the remote address without its port. A backhauled request's
+// address is the sibling edge's, which folds every hop from that edge into
+// one bucket — stricter, never looser, and named in the spec (§4.2).
+func clientKey(r *http.Request) string {
+ if h, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
+ return h
+ }
+ return r.RemoteAddr
+}
+
+// stripCookie removes the gate cookie from the request so the app behind
+// the curtain never sees it. Other cookies pass through in their order.
+//
+// It re-serialises from r.Cookies() rather than editing the header text,
+// which has two consequences worth knowing: a cookie Go's parser drops as
+// malformed does not reach the app either, and a quoted value arrives
+// unquoted, because that is what Cookie.Value holds. Both are narrower
+// than what the app would have received unproxied, and neither has ever
+// mattered for a real client — but an app that hand-parses exotic cookies
+// is looking at this function, not at a browser bug.
+func stripCookie(r *http.Request) {
+ cookies := r.Cookies()
+ r.Header.Del("Cookie")
+ parts := make([]string, 0, len(cookies))
+ for _, c := range cookies {
+ if c.Name != CookieName {
+ parts = append(parts, c.Name+"="+c.Value)
+ }
+ }
+ if len(parts) > 0 {
+ r.Header.Set("Cookie", strings.Join(parts, "; "))
+ }
+}
diff --git a/internal/gate/gate_test.go b/internal/gate/gate_test.go
new file mode 100644
index 000000000..8c78c3551
--- /dev/null
+++ b/internal/gate/gate_test.go
@@ -0,0 +1,399 @@
+package gate
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestCheckPassword(t *testing.T) {
+ if err := CheckPassword("short7!"); err == nil {
+ t.Fatal("7 chars accepted")
+ }
+ if err := CheckPassword(strings.Repeat("x", 73)); err == nil {
+ t.Fatal("73 bytes accepted — bcrypt would silently truncate")
+ }
+ if err := CheckPassword("longenough"); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestHashThenVerifyPassword(t *testing.T) {
+ h, err := Hash("correct horse")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.HasPrefix(h, "$2a$10$") {
+ t.Fatalf("hash = %q, want bcrypt cost 10", h)
+ }
+ if !comparePassword(h, "correct horse") || comparePassword(h, "wrong horse") {
+ t.Fatal("comparePassword disagrees with Hash")
+ }
+}
+
+func TestCookieRoundTrip(t *testing.T) {
+ now := time.Unix(1_800_000_000, 0)
+ v := Mint("site.example", "$2a$10$hash", now.Add(Lifetime))
+ if !Verify(v, "site.example", "$2a$10$hash", now) {
+ t.Fatal("fresh cookie refused")
+ }
+ if Verify(v, "other.example", "$2a$10$hash", now) {
+ t.Fatal("cookie accepted for another host")
+ }
+ if Verify(v, "site.example", "$2a$10$rotated", now) {
+ t.Fatal("cookie accepted after the password changed")
+ }
+ if Verify(v, "site.example", "$2a$10$hash", now.Add(Lifetime+time.Second)) {
+ t.Fatal("expired cookie accepted")
+ }
+ if Verify("v1.garbage", "site.example", "$2a$10$hash", now) || Verify("", "site.example", "$2a$10$hash", now) {
+ t.Fatal("malformed cookie accepted")
+ }
+}
+
+func TestLimiterTenPerMinute(t *testing.T) {
+ now := time.Unix(0, 0)
+ l := NewLimiter(func() time.Time { return now })
+ for i := 0; i < 10; i++ {
+ if !l.Allow("k") {
+ t.Fatalf("attempt %d refused", i+1)
+ }
+ }
+ if l.Allow("k") {
+ t.Fatal("11th attempt allowed")
+ }
+ if !l.Allow("other") {
+ t.Fatal("another key shares the bucket")
+ }
+ now = now.Add(6 * time.Second)
+ if !l.Allow("k") {
+ t.Fatal("no refill after 6s")
+ }
+}
+
+func testGate() *Gate {
+ g := New()
+ g.now = func() time.Time { return time.Unix(1_800_000_000, 0) }
+ return g
+}
+
+func TestAdmitNoCookieIsTheLoginPage(t *testing.T) {
+ g := testGate()
+ h, _ := Hash("correct horse")
+ for _, method := range []string{http.MethodGet, http.MethodPost, http.MethodHead} {
+ rec := httptest.NewRecorder()
+ r := httptest.NewRequest(method, "https://site.example/docs?x=1", nil)
+ if g.Admit(rec, r, "site.example", h) {
+ t.Fatalf("%s without a cookie admitted", method)
+ }
+ if rec.Code != http.StatusUnauthorized {
+ t.Fatalf("%s status = %d, want 401", method, rec.Code)
+ }
+ if cc := rec.Header().Get("Cache-Control"); cc != "no-store" {
+ t.Fatalf("Cache-Control = %q", cc)
+ }
+ if rt := rec.Header().Get("X-Robots-Tag"); rt != "noindex, nofollow" {
+ t.Fatalf("X-Robots-Tag = %q", rt)
+ }
+ body := rec.Body.String()
+ if method == http.MethodHead {
+ if body != "" {
+ t.Fatal("HEAD carried a body")
+ }
+ continue
+ }
+ if !strings.Contains(body, "Password required") || !strings.Contains(body, "site.example") {
+ t.Fatalf("page missing copy: %s", body)
+ }
+ if !strings.Contains(body, `name="next" value="/docs?x=1"`) {
+ t.Fatalf("page does not carry next: %s", body)
+ }
+ if !strings.Contains(body, `action="/.carlos/gate"`) {
+ t.Fatalf("form action wrong: %s", body)
+ }
+ }
+}
+
+func postLogin(g *Gate, host, hash, password, next string) *httptest.ResponseRecorder {
+ rec := httptest.NewRecorder()
+ form := url.Values{"password": {password}, "next": {next}}
+ r := httptest.NewRequest(http.MethodPost, "https://"+host+LoginPath, strings.NewReader(form.Encode()))
+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ r.RemoteAddr = "203.0.113.9:4242"
+ g.Admit(rec, r, host, hash)
+ return rec
+}
+
+func TestAdmitRightPasswordSetsCookieAndRedirects(t *testing.T) {
+ g := testGate()
+ h, _ := Hash("correct horse")
+ rec := postLogin(g, "site.example", h, "correct horse", "/docs?x=1")
+ if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/docs?x=1" {
+ t.Fatalf("got %d -> %q, want 303 -> /docs?x=1", rec.Code, rec.Header().Get("Location"))
+ }
+ var c *http.Cookie
+ for _, k := range rec.Result().Cookies() {
+ if k.Name == CookieName {
+ c = k
+ }
+ }
+ if c == nil {
+ t.Fatal("no gate cookie set")
+ }
+ if !c.HttpOnly || !c.Secure || c.SameSite != http.SameSiteLaxMode || c.Path != "/" || c.MaxAge != int(Lifetime/time.Second) {
+ t.Fatalf("cookie attributes: %+v", c)
+ }
+ if !Verify(c.Value, "site.example", h, g.now()) {
+ t.Fatal("minted cookie does not verify")
+ }
+ // And the cookie is admitted, and stripped from what the app sees.
+ rec2 := httptest.NewRecorder()
+ r := httptest.NewRequest(http.MethodGet, "https://site.example/", nil)
+ r.Header.Set("Cookie", "theme=dark; "+CookieName+"="+c.Value+"; other=1")
+ if !g.Admit(rec2, r, "site.example", h) {
+ t.Fatal("valid cookie refused")
+ }
+ if got := r.Header.Get("Cookie"); got != "theme=dark; other=1" {
+ t.Fatalf("upstream Cookie = %q, want the gate cookie stripped", got)
+ }
+}
+
+func TestAdmitBadNextBecomesRoot(t *testing.T) {
+ g := testGate()
+ h, _ := Hash("correct horse")
+ for _, next := range []string{"//evil.example/", "https://evil.example/", "docs", ""} {
+ rec := postLogin(g, "site.example", h, "correct horse", next)
+ if loc := rec.Header().Get("Location"); loc != "/" {
+ t.Fatalf("next %q -> Location %q, want /", next, loc)
+ }
+ }
+}
+
+func TestAdmitWrongPasswordThenLimit(t *testing.T) {
+ g := testGate()
+ h, _ := Hash("correct horse")
+ compares := 0
+ g.compare = func(hash, password string) bool { compares++; return false }
+ for i := 0; i < 10; i++ {
+ rec := postLogin(g, "site.example", h, "nope nope", "/")
+ if rec.Code != http.StatusUnauthorized || !strings.Contains(rec.Body.String(), "That password is not right.") {
+ t.Fatalf("attempt %d: %d %s", i+1, rec.Code, rec.Body.String())
+ }
+ }
+ rec := postLogin(g, "site.example", h, "nope nope", "/")
+ if rec.Code != http.StatusTooManyRequests || rec.Header().Get("Retry-After") != "60" {
+ t.Fatalf("11th attempt: %d Retry-After=%q, want 429/60", rec.Code, rec.Header().Get("Retry-After"))
+ }
+ if !strings.Contains(rec.Body.String(), "Too many attempts. Try again in a minute.") {
+ t.Fatalf("429 page copy: %s", rec.Body.String())
+ }
+ if compares != 10 {
+ t.Fatalf("bcrypt compared %d times, want 10 — the limiter must refuse BEFORE comparing", compares)
+ }
+}
+
+func TestLoginPathOnGetIsJustAGatedRequest(t *testing.T) {
+ g := testGate()
+ h, _ := Hash("correct horse")
+ rec := httptest.NewRecorder()
+ r := httptest.NewRequest(http.MethodGet, "https://site.example"+LoginPath, nil)
+ if g.Admit(rec, r, "site.example", h) || rec.Code != http.StatusUnauthorized {
+ t.Fatalf("GET %s: admitted=%v code=%d", LoginPath, false, rec.Code)
+ }
+}
+
+// SafeNext is the whole open-redirect surface, so pin it directly rather
+// than only through the three shapes the POST test happens to send.
+func TestSafeNextIsAPathOnThisHostOrRoot(t *testing.T) {
+ bad := []string{
+ "//evil.example/", `/\evil.example/`, `/\/evil.example/`,
+ "https://evil.example/", "javascript:alert(1)", `\\evil.example\`,
+ "", "docs", "?x=1", "#frag",
+ // Control bytes: net/http rewrites only CR and LF, and a browser
+ // strips the rest before resolving, so a tab here would land as
+ // "//evil.example/".
+ "/\t/evil.example/", "/\r\n/evil.example/", "/\x00/evil.example/",
+ "/\x7f/evil.example/", "/\x1f/evil.example/",
+ // The login path itself: the app behind the curtain does not serve it.
+ LoginPath, LoginPath + "?x=1", LoginPath + "#f",
+ }
+ for _, bad := range bad {
+ if got := SafeNext(bad); got != "/" {
+ t.Errorf("SafeNext(%q) = %q, want /", bad, got)
+ }
+ }
+ for _, ok := range []string{"/", "/docs?x=1", "/a/b#c"} {
+ if got := SafeNext(ok); got != ok {
+ t.Errorf("SafeNext(%q) = %q, want it unchanged", ok, got)
+ }
+ }
+}
+
+// The MAC covers the expiry, so pushing the expiry out on a stolen cookie
+// must not buy another week.
+func TestCookieForgedExpiryAndTamperedMAC(t *testing.T) {
+ now := time.Unix(1_800_000_000, 0)
+ v := Mint("site.example", "$2a$10$hash", now.Add(Lifetime))
+ parts := strings.Split(v, ".")
+ if Verify(parts[0]+".9999999999."+parts[2], "site.example", "$2a$10$hash", now) {
+ t.Fatal("expiry moved forward without re-signing was accepted")
+ }
+ if Verify(v+".x", "site.example", "$2a$10$hash", now) {
+ t.Fatal("a fourth field was accepted")
+ }
+ if Verify("v2."+parts[1]+"."+parts[2], "site.example", "$2a$10$hash", now) {
+ t.Fatal("another version was accepted")
+ }
+ if Verify(parts[0]+"."+parts[1]+".!!!", "site.example", "$2a$10$hash", now) {
+ t.Fatal("a non-base64 MAC was accepted")
+ }
+}
+
+func TestStripCookieDropsTheHeaderWhenTheGateCookieIsTheOnlyOne(t *testing.T) {
+ r := httptest.NewRequest(http.MethodGet, "https://site.example/", nil)
+ r.Header.Set("Cookie", CookieName+"=x")
+ stripCookie(r)
+ if _, ok := r.Header["Cookie"]; ok {
+ t.Fatalf("Cookie header survived as %q", r.Header.Get("Cookie"))
+ }
+}
+
+// A body over MaxFormBody fails to parse; that must cost no bcrypt and no
+// limiter token. The bucket is keyed on the sender's own address, so a junk
+// flood only ever starves the flooder — except behind the backhaul fold,
+// where a sibling edge's address stands in for every visitor through it,
+// and there this is what keeps a flood from reaching them.
+func TestOversizedFormBodyIsRefusedWithoutSpendingAnAttempt(t *testing.T) {
+ g := testGate()
+ h, _ := Hash("correct horse")
+ compares := 0
+ g.compare = func(hash, password string) bool { compares++; return true }
+ rec := httptest.NewRecorder()
+ r := httptest.NewRequest(http.MethodPost, "https://site.example"+LoginPath, strings.NewReader("password="+strings.Repeat("a", 64<<10)))
+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ r.RemoteAddr = "203.0.113.9:4242"
+ g.Admit(rec, r, "site.example", h)
+ if rec.Code != http.StatusUnauthorized {
+ t.Fatalf("oversized body -> %d, want 401", rec.Code)
+ }
+ if compares != 0 {
+ t.Fatal("an unparseable body reached bcrypt")
+ }
+ if !g.limiter.Allow(clientKey(r) + "\x1f" + "site.example") {
+ t.Fatal("an unparseable body spent an attempt")
+ }
+}
+
+// Tenant hosts share a wildcard apex, so a sibling host — or an XSS on one
+// — can plant a second carlos_gate scoped to .apex, and the browser sends
+// it first. Taking only the first cookie would lock the visitor out of a
+// site they hold a good cookie for, with no way to clear it.
+func TestAdmitAcceptsAnyValidGateCookieNotJustTheFirst(t *testing.T) {
+ g := testGate()
+ h, _ := Hash("correct horse")
+ good := Mint("site.example", h, g.now().Add(Lifetime))
+ rec := httptest.NewRecorder()
+ r := httptest.NewRequest(http.MethodGet, "https://site.example/", nil)
+ r.Header.Set("Cookie", CookieName+"=v1.9999999999.junk; theme=dark; "+CookieName+"="+good)
+ if !g.Admit(rec, r, "site.example", h) {
+ t.Fatal("a planted decoy cookie locked the visitor out")
+ }
+ got := r.Header.Get("Cookie")
+ if strings.Contains(got, CookieName) {
+ t.Fatalf("upstream Cookie = %q, want every gate cookie stripped", got)
+ }
+ if got != "theme=dark" {
+ t.Fatalf("upstream Cookie = %q, want the other cookies untouched", got)
+ }
+}
+
+func postLoginFrom(g *Gate, reqHost, host, hash, password, next, origin string) *httptest.ResponseRecorder {
+ rec := httptest.NewRecorder()
+ form := url.Values{"password": {password}, "next": {next}}
+ r := httptest.NewRequest(http.MethodPost, "https://"+reqHost+LoginPath, strings.NewReader(form.Encode()))
+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ if origin != "" {
+ r.Header.Set("Origin", origin)
+ }
+ r.RemoteAddr = "203.0.113.9:4242"
+ g.Admit(rec, r, host, hash)
+ return rec
+}
+
+// The login POST needs no cookie, so SameSite does not cover it: without an
+// Origin check a hidden form on an attacker's page could auto-submit eleven
+// guesses and park a visitor at 429.
+func TestLoginRefusesACrossOriginPostWithoutSpendingAnAttempt(t *testing.T) {
+ g := testGate()
+ h, _ := Hash("correct horse")
+ compares := 0
+ g.compare = func(hash, password string) bool { compares++; return true }
+
+ for _, origin := range []string{"https://evil.example", "null", "https://evil.example:443", "http://site.example.evil.example"} {
+ rec := postLoginFrom(g, "site.example", "site.example", h, "correct horse", "/docs", origin)
+ if rec.Code != http.StatusForbidden {
+ t.Fatalf("Origin %q -> %d, want 403", origin, rec.Code)
+ }
+ if strings.Contains(rec.Body.String(), msgWrong) || strings.Contains(rec.Body.String(), msgTooMany) {
+ t.Fatalf("Origin %q: the 403 page judged the password: %s", origin, rec.Body.String())
+ }
+ if !strings.Contains(rec.Body.String(), "Password required") {
+ t.Fatalf("Origin %q: the 403 is not the login page: %s", origin, rec.Body.String())
+ }
+ }
+ if compares != 0 {
+ t.Fatalf("bcrypt compared %d times on cross-origin posts, want 0", compares)
+ }
+ // The bucket is untouched: a visitor who was targeted can still make
+ // all ten of their own attempts.
+ for i := 0; i < 10; i++ {
+ if rec := postLogin(g, "site.example", h, "correct horse", "/"); rec.Code == http.StatusTooManyRequests {
+ t.Fatalf("attempt %d was rate-limited — the cross-origin posts drained the bucket", i+1)
+ }
+ }
+}
+
+func TestLoginAllowsSameOriginAndAbsentOrigin(t *testing.T) {
+ g := testGate()
+ h, _ := Hash("correct horse")
+ cases := []struct{ name, reqHost, origin string }{
+ {"same origin", "site.example", "https://site.example"},
+ {"absent origin (curl)", "site.example", ""},
+ {"origin and host both carry the port", "site.example:8443", "https://site.example:8443"},
+ {"port on one side only", "site.example:8443", "https://site.example"},
+ {"case-insensitive host", "site.example", "https://SITE.example"},
+ // An alias posts from its own name, so the comparison is against
+ // r.Host and not the route's primary hostname.
+ {"alias posts from the alias", "alias.example", "https://alias.example"},
+ }
+ for _, c := range cases {
+ rec := postLoginFrom(g, c.reqHost, "site.example", h, "correct horse", "/docs", c.origin)
+ if rec.Code != http.StatusSeeOther {
+ t.Errorf("%s: %d, want 303", c.name, rec.Code)
+ }
+ }
+}
+
+func TestSuccessfulLoginIsNoStoreAndNoindex(t *testing.T) {
+ g := testGate()
+ h, _ := Hash("correct horse")
+ rec := postLogin(g, "site.example", h, "correct horse", "/docs")
+ if cc := rec.Header().Get("Cache-Control"); cc != "no-store" {
+ t.Errorf("Cache-Control = %q", cc)
+ }
+ if rt := rec.Header().Get("X-Robots-Tag"); rt != "noindex, nofollow" {
+ t.Errorf("X-Robots-Tag = %q", rt)
+ }
+}
+
+func TestCookieExpiryEncodingIsCanonical(t *testing.T) {
+ now := time.Unix(1_800_000_000, 0)
+ v := Mint("site.example", "$2a$10$hash", now.Add(Lifetime))
+ parts := strings.Split(v, ".")
+ if Verify(parts[0]+".+"+parts[1]+"."+parts[2], "site.example", "$2a$10$hash", now) {
+ t.Fatal(`a "+"-signed expiry was accepted`)
+ }
+}
diff --git a/internal/gate/limiter.go b/internal/gate/limiter.go
new file mode 100644
index 000000000..a96294b08
--- /dev/null
+++ b/internal/gate/limiter.go
@@ -0,0 +1,64 @@
+package gate
+
+import (
+ "sync"
+ "time"
+)
+
+const (
+ limitBurst = 10
+ limitPeriod = time.Minute
+ limitPrune = 10_000
+)
+
+// Limiter is a per-key token bucket: limitBurst attempts, refilling at
+// limitBurst per limitPeriod. bcrypt at cost 10 already costs tens of
+// milliseconds a comparison; this exists so the CPU is spent on people and
+// not on a script.
+type Limiter struct {
+ mu sync.Mutex
+ now func() time.Time
+ buckets map[string]*bucket
+}
+
+type bucket struct {
+ tokens float64
+ last time.Time
+}
+
+func NewLimiter(now func() time.Time) *Limiter {
+ return &Limiter{now: now, buckets: map[string]*bucket{}}
+}
+
+// Allow spends one token for key, reporting whether one was available.
+func (l *Limiter) Allow(key string) bool {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ now := l.now()
+ b, ok := l.buckets[key]
+ if !ok {
+ if len(l.buckets) >= limitPrune {
+ l.prune(now)
+ }
+ b = &bucket{tokens: limitBurst, last: now}
+ l.buckets[key] = b
+ }
+ refill := now.Sub(b.last).Seconds() * (limitBurst / limitPeriod.Seconds())
+ b.tokens = min(limitBurst, b.tokens+refill)
+ b.last = now
+ if b.tokens < 1 {
+ return false
+ }
+ b.tokens--
+ return true
+}
+
+// prune drops every bucket that has been idle long enough to be full again;
+// keeping it would change no answer.
+func (l *Limiter) prune(now time.Time) {
+ for k, b := range l.buckets {
+ if now.Sub(b.last) >= limitPeriod {
+ delete(l.buckets, k)
+ }
+ }
+}
diff --git a/internal/gate/login.html b/internal/gate/login.html
new file mode 100644
index 000000000..2fa757543
--- /dev/null
+++ b/internal/gate/login.html
@@ -0,0 +1,55 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<meta name="robots" content="noindex, nofollow">
+<title>Password required</title>
+<style>
+:root {
+ --bg: #ffffff; --ink: #1d2029; --muted: #64707f; --accent: #0f5e85;
+ --accent-soft: rgba(15, 94, 133, 0.09); --rule: #e7e9f0; --bad: #b3403a;
+}
+@media (prefers-color-scheme: dark) {
+ :root {
+ --bg: #14151c; --ink: #eef0f6; --muted: #93a4ba; --accent: #5db8dd;
+ --accent-soft: rgba(93, 184, 221, 0.12); --rule: #2a2e38; --bad: #e07b76;
+ }
+}
+* { margin: 0; padding: 0; box-sizing: border-box; }
+body {
+ background: var(--bg); color: var(--ink);
+ font: 400 1rem/1.6 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
+ min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 1.25rem;
+}
+main { width: 100%; max-width: 22rem; }
+h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
+p { color: var(--muted); margin-bottom: 1.25rem; }
+p code { color: var(--ink); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
+label { display: block; font-size: 0.75rem; color: var(--muted); margin-bottom: 0.35rem; }
+input[type=password] {
+ width: 100%; font: inherit; color: var(--ink); background: var(--bg);
+ border: 1px solid var(--rule); border-radius: 0.4rem; padding: 0.55rem 0.7rem; margin-bottom: 0.9rem;
+}
+input[type=password]:focus { outline: 2px solid var(--accent); outline-offset: 1px; border-color: var(--accent); }
+button {
+ width: 100%; font: inherit; font-weight: 600; color: var(--bg); background: var(--accent);
+ border: 0; border-radius: 0.4rem; padding: 0.6rem 0.9rem; cursor: pointer;
+}
+.error { color: var(--bad); margin: 0 0 0.9rem; }
+</style>
+</head>
+<body>
+<main>
+ <h1>Password required</h1>
+ <p><code>{{.Host}}</code> is private. Enter the password to continue.</p>
+ <form method="post" action="{{.Action}}">
+ <input type="hidden" name="next" value="{{.Next}}">
+ {{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
+ <label for="password">Password</label>
+ <input type="password" id="password" name="password" autocomplete="current-password" autofocus required>
+ <button type="submit">Continue</button>
+ </form>
+</main>
+</body>
+</html>
diff --git a/internal/gate/page.go b/internal/gate/page.go
new file mode 100644
index 000000000..973c28c36
--- /dev/null
+++ b/internal/gate/page.go
@@ -0,0 +1,41 @@
+package gate
+
+import (
+ _ "embed"
+ "html/template"
+ "log"
+ "net/http"
+)
+
+//go:embed login.html
+var loginHTML string
+
+var loginTmpl = template.Must(template.New("login").Parse(loginHTML))
+
+const (
+ msgWrong = "That password is not right."
+ msgTooMany = "Too many attempts. Try again in a minute."
+)
+
+type pageData struct {
+ Host string
+ Error string
+ Next string
+ Action string
+}
+
+// writePage renders the login page at status. Every page this package
+// writes is no-store and noindex: it is a curtain, not content.
+func writePage(w http.ResponseWriter, r *http.Request, host string, status int, msg, next string) {
+ h := w.Header()
+ h.Set("Cache-Control", "no-store")
+ h.Set("X-Robots-Tag", "noindex, nofollow")
+ h.Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(status)
+ if r.Method == http.MethodHead {
+ return
+ }
+ if err := loginTmpl.Execute(w, pageData{Host: host, Error: msg, Next: next, Action: LoginPath}); err != nil {
+ log.Printf("gate: render login page for %s: %v", host, err)
+ }
+}
diff --git a/internal/provision/provision.go b/internal/provision/provision.go
index cd5f59187..83b848e31 100644
--- a/internal/provision/provision.go
+++ b/internal/provision/provision.go
@@ -20,6 +20,8 @@ import (
"strings"
"time"
+ "golang.org/x/crypto/bcrypt"
+
"github.com/carlosframework/platform/internal/appconfig"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
@@ -121,6 +123,17 @@ type Record struct {
// converger (Tasks 4-6) are what give a non-empty value any effect, and
// even then only once a deployment arms CARLOS_STEERING_ZONE_ID.
Steering string `json:"steering,omitempty"`
+
+ // Gate is the bcrypt hash of the host's shared password ("" = open,
+ // the value every record written before this field existed decodes
+ // to). The ONLY place the secret material lives in the bucket; the
+ // plaintext crosses the console once, at the door that hashes it
+ // (internal/gate.Hash), and is never stored, logged or echoed — the
+ // console's record views shadow this field (apiinstances.go). A
+ // string, not a pointer: absent and "" are the same fact, an open
+ // host. Converged into routes.gate by Reconcile's owned-converge arm
+ // (reconcile.go) and read by the edge on every request. Spec docs/superpowers/specs/2026-09-07-password-gate-design.md.
+ Gate string `json:"gate,omitempty"`
}
// Health is the per-route probe config (HA spec Part II). Zero value =
@@ -384,6 +397,11 @@ func ValidateShape(rec Record) error {
if !ValidSteering(rec.Steering) {
return fmt.Errorf("steering %q: want \"\" or \"latency\"", rec.Steering)
}
+ if rec.Gate != "" {
+ if _, err := bcrypt.Cost([]byte(rec.Gate)); err != nil {
+ return errors.New("gate: want a bcrypt hash — the console hashes the password; a record never carries it in clear")
+ }
+ }
if rec.Addr != "" {
if err := validAddr(rec.Addr); err != nil {
return err
diff --git a/internal/provision/provision_test.go b/internal/provision/provision_test.go
index 37bf85390..407dbb631 100644
--- a/internal/provision/provision_test.go
+++ b/internal/provision/provision_test.go
@@ -995,3 +995,14 @@ func TestMoveFieldAbsentOnPreMoveRecords(t *testing.T) {
t.Fatalf("re-marshal grew a move key: %s", out)
}
}
+
+func TestValidateShapeGate(t *testing.T) {
+ ok := Record{Kind: "static", Gate: "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"}
+ if err := ValidateShape(ok); err != nil {
+ t.Fatalf("bcrypt gate refused: %v", err)
+ }
+ bad := Record{Kind: "static", Gate: "hunter2"}
+ if err := ValidateShape(bad); err == nil || !strings.Contains(err.Error(), "gate") {
+ t.Fatalf("plaintext gate accepted: %v", err)
+ }
+}
diff --git a/internal/provision/reconcile.go b/internal/provision/reconcile.go
index 7f620aeae..610088ef8 100644
--- a/internal/provision/reconcile.go
+++ b/internal/provision/reconcile.go
@@ -293,6 +293,7 @@ func routeFor(rec Record, dataDir, seed string) registry.Route {
Channel: rec.Channel, Environment: rec.Environment,
Hibernate: rec.HibernateOrDefault(), StatusNamed: rec.StatusNamedOrDefault(),
Steering: rec.Steering,
+ Gate: rec.Gate,
Provisioned: true, Seed: seed, Status: "ready",
}
if rec.DerivesPaths() {
@@ -837,6 +838,15 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg
log.Printf("provision: converged %s hibernate %v -> %v%s", rec.Host, existing.Hibernate, *rec.Hibernate, dropped)
}
}
+ if existing.Gate != rec.Gate {
+ if cfg.DryRun {
+ log.Printf("provision: dry-run: would converge %s gate %s -> %s", rec.Host, gateWord(existing.Gate), gateWord(rec.Gate))
+ } else if err := reg.SetGate(rec.Host, rec.Gate); err != nil {
+ log.Printf("provision: converge gate on %s: %v", rec.Host, err)
+ } else {
+ log.Printf("provision: converged %s gate %s -> %s", rec.Host, gateWord(existing.Gate), gateWord(rec.Gate))
+ }
+ }
// seedIsKnown guards against stamping a transient manifest-read
// failure's "" fallback over a working seed — see seedFor.
if seedIsKnown && existing.Seed != seed {
@@ -1002,6 +1012,15 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg
return nil
}
+// gateWord renders a gate column for a log line: "open" or "set", never the
+// hash — it has no business in a journal.
+func gateWord(hash string) string {
+ if hash == "" {
+ return "open"
+ }
+ return "set"
+}
+
// knownAccounts lists accounts/ and materializes the sqid set this pass
// trusts for ParseRecordKey's "known account" guard — the same three-way
// sqid agreement domains.Reconcile enforces (reconcile.go:106): the key
diff --git a/internal/provision/reconcile_test.go b/internal/provision/reconcile_test.go
index d71e9b35d..d4c389278 100644
--- a/internal/provision/reconcile_test.go
+++ b/internal/provision/reconcile_test.go
@@ -566,6 +566,71 @@ func TestReconcileConvergesSteering(t *testing.T) {
}
}
+func TestReconcileConvergesGate(t *testing.T) {
+ s, sqid := setup(t)
+ rec := acmeRecord(sqid)
+ if err := CreateRecord(ctx, s, rec); err != nil {
+ t.Fatal(err)
+ }
+ reg := regWith(t)
+ cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"}
+ if err := Reconcile(ctx, s, reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ before := *mustGet(t, reg, "acme.go.tito.io")
+ if before.Gate != "" {
+ t.Fatalf("minted Gate = %q, want open", before.Gate)
+ }
+
+ // From here on the log is captured: the converge line names the STATE,
+ // never the secret. A bcrypt hash is not a password, but it is the
+ // material an offline cracker works on, and an operations log is copied,
+ // shipped and pasted far more freely than the registry row is.
+ var logged bytes.Buffer
+ log.SetOutput(&logged)
+ t.Cleanup(func() { log.SetOutput(os.Stderr) })
+
+ const hash = "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"
+ rec.Gate = hash
+ putReplace(t, s, RecordKey(sqid, "tito", rec.Host), mustJSON(t, rec))
+ if err := Reconcile(ctx, s, reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(logged.String(), "converged acme.go.tito.io gate open -> set") {
+ t.Errorf("no open -> set line logged; log was:\n%s", logged.String())
+ }
+ if strings.Contains(logged.String(), hash) {
+ t.Errorf("the hash reached the log:\n%s", logged.String())
+ }
+ after := *mustGet(t, reg, "acme.go.tito.io")
+ if after.Gate != hash {
+ t.Errorf("gate = %q, want the hash", after.Gate)
+ }
+ after.Gate = before.Gate
+ if after != before {
+ t.Errorf("converging gate touched another column:\n before %+v\n after %+v", before, after)
+ }
+
+ // A record with no gate field is an OPEN host — it is a string, not a
+ // pointer: absent and "" are the same fact, and clearing must reach the
+ // row through SetGate.
+ rec.Gate = ""
+ putReplace(t, s, RecordKey(sqid, "tito", rec.Host), mustJSON(t, rec))
+ logged.Reset()
+ if err := Reconcile(ctx, s, reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ if got := mustGet(t, reg, "acme.go.tito.io").Gate; got != "" {
+ t.Errorf("gate = %q, want it cleared", got)
+ }
+ if !strings.Contains(logged.String(), "converged acme.go.tito.io gate set -> open") {
+ t.Errorf("no set -> open line logged; log was:\n%s", logged.String())
+ }
+ if strings.Contains(logged.String(), hash) {
+ t.Errorf("the hash reached the log:\n%s", logged.String())
+ }
+}
+
// An explicit Hibernate converges; an absent one never does. Absent means
// "the record does not speak to this", and a pass that stamped its
// derivation over an operator's deliberate SetHibernate would be the silent
diff --git a/internal/registry/registry.go b/internal/registry/registry.go
index 05ede7c7f..d75296270 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
@@ -675,7 +686,7 @@ func (r *Registry) Accounts() ([]Account, error) {
}
// routeCols is the column list every route read and the one route write
-// share — 36 names, in this exact order. scanRoute's destinations, Upsert's
+// share — 37 names, in this exact order. scanRoute's destinations, Upsert's
// placeholder row and Upsert's argument list all count from here: adding a
// column means adding it in FOUR places, and a miscount is a silent column
// shift rather than an error (TestUpsertRoundTripsAllColumns is what catches
@@ -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
+}
diff --git a/main.go b/main.go
index 4f8de37b9..6f1fe254a 100644
--- a/main.go
+++ b/main.go
@@ -59,6 +59,8 @@ func main() {
runDomains(args)
case "features":
runFeatures(args)
+ case "gate":
+ runGate(args)
case "ship":
runShip(args)
case "promote":
@@ -198,6 +200,7 @@ Your apps — ship and run software on a CARLOS deployment
attach | detach | list
features an app's own feature flags, stored and served by the
platform, never interpreted: set | list
+ gate a shared password in front of an app's hostnames
logs an app's merged log timeline: its own output plus platform
events (wakes, restarts, failures); -f follows live
errors error groups for an app: log lines at the set level plus