Merge branch 'worktree-password-gate' into main
28 files changed,
+5340
−27
apimode.go+35 −0docs/site/cli.md+23 −1docs/superpowers/plans/2026-09-07-password-gate.md+2130 −0docs/superpowers/specs/2026-09-07-password-gate-design.md+445 −0gate_cli.go+232 −0gate_cli_test.go+273 −0internal/console/apiinstances.go+69 −6internal/console/apiinstances_test.go+125 −5internal/console/appsettings.go+123 −0internal/console/appsettings_gate_test.go+132 −0internal/console/channelmove.go+2 −2internal/console/server.go+3 −0internal/console/templates/app_settings.html+38 −0internal/edge/edge.go+87 −1internal/edge/gate_test.go+508 −0internal/gate/cookie.go+61 −0internal/gate/gate.go+259 −0internal/gate/gate_test.go+399 −0internal/gate/limiter.go+64 −0internal/gate/login.html+55 −0internal/gate/page.go+41 −0internal/provision/provision.go+18 −0internal/provision/provision_test.go+11 −0internal/provision/reconcile.go+19 −0internal/provision/reconcile_test.go+65 −0internal/registry/registry.go+43 −12internal/registry/registry_test.go+77 −0main.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, ®ion, &fleetBox, &addrs, &health); err != nil { |
| + &channel, &backing, &tunnel, &dbPath, ®ion, &fleetBox, &addrs, &health, &gate); err != nil { |
| return "", err |
| } |
| - fmt.Fprintf(h, "%d\x1f%s\x1f%s\x1f%s\x1f%s\x1f%d\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1e", |
| + fmt.Fprintf(h, "%d\x1f%s\x1f%s\x1f%s\x1f%s\x1f%d\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1e", |
| rowid, status, socket, addr, version, hibernate, channel, backing, tunnel, |
| - dbPath, region, fleetBox, addrs, health) |
| + dbPath, region, fleetBox, addrs, health, gate) |
| n++ |
| } |
| if err := rows.Err(); err != nil { |
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 |