carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1# Password gate — a shared password in front of an instance
2
3**Date:** 2026-09-07 · **Status:** approved in conversation (Paul, 2026-09-07:
4"yes, full spec, plan, and overnight implementation, but also make sure I can
5set a password or disable password protection via the web console settings
6too"). · **Requested by:** Paul.
7
8**Builds on:** the instance-record converge (`internal/provision/reconcile.go`,
9the `Hibernate` precedent), the edge's cache-before-activator ordering
10(`internal/edge/edge.go` ServeHTTP), the mesh key's HMAC discipline
11(`internal/edge/mesh.go`), the fleet status page's stylesheet
12(`internal/status/assets/status.css`), and the console's form conventions
13(`internal/console/fleets.go` handleAppPlace).
14
15## 1. What this is for
16
17A member ships a static site or a prototype and wants it reachable by the
18people they give a password to, and nobody else. No accounts, no invitations,
19no identity provider: one shared password per host, a neutral page that asks
20for it, and a cookie that remembers the answer for a week.
21
22It is not access control for the app's own users. An app with real sign-in
23keeps doing that itself. This is a curtain the platform holds in front of the
24whole hostname, so the app behind it does not have to know the curtain exists.
25
26## 2. Where it lives, and why
27
28The edge is the only thing that fronts an instance, and it decides what to do
29with a request from the registry row the host resolves to. So the gate is a
30**route property**, and it travels the path hibernation already travels:
31
32 carlos gate / console Settings
33 → console PATCH on the instance record (bucket, CAS-guarded, audited)
34 → the host's provisioning reconcile converges it into a registry column
35 → the edge reads it at reload and enforces it per request
36
37Three consequences fall out of that placement, and each is a reason to place
38it there rather than anywhere else:
39
40- **Custom domains are covered for free.** An alias maps to the primary's
41 `upstream` entry verbatim (`edge.go`, `upstream.alias`), so a gate keyed on
42 `up.route` fronts `docs.example.com` the moment it fronts the platform
43 hostname.
44- **Static sites are covered.** A static site is an instance record of kind
45 `static`; its route is served by the edge's static handler, which sits
46 behind the same gate step as a proxied instance.
47- **A gated host never wakes for a stranger.** The gate step runs before the
48 activator, so unauthenticated traffic — crawlers, scanners, someone guessing
49 a hostname — is answered by the login page without spinning the instance up.
50
51What it is deliberately NOT: an app-level feature flag (`carlos features` is
52served to the app and never read by the edge), an env var (`carlos env` is
53rendered on the host and never read by the edge), or a manifest policy (the
54manifest is read at instance create; a gate is toggled on a running host).
55
56## 3. The record and the column
57
58### 3.1 `provision.Record.Gate`
59
60```go
61// Gate is the bcrypt hash of the host's shared password ("" = open, the
62// value every record written before this field existed decodes to). It is
63// the ONLY place the secret material lives in the bucket; the plaintext
64// crosses the console once, at the door that hashes it, and is never
65// stored, logged or echoed. Converged into routes.gate by reconcile
66// (§3.3) and read by the edge (§4). Additive: omitempty, and an old
67// reader that does not know the field drops it on the floor — see §9
68// for why that means boxes roll before the console.
69Gate string `json:"gate,omitempty"`
70```
71
72Validation in `ValidateShape`: if `Gate != ""` it must parse as a bcrypt
73hash (`bcrypt.Cost(hash)` succeeds). Any kind may carry one — `instance`,
74`static`, `service` all front something a person can browse to. No kind
75refuses it.
76
77### 3.2 The registry column
78
79`ALTER TABLE routes ADD COLUMN gate TEXT NOT NULL DEFAULT ''`, appended to
80the migration list; `Route.Gate string`; added to `routeCols`, `scanRoute`,
81the `Upsert` VALUES and its `ON CONFLICT` set. The conflict rule is the
82**preserve-when-empty** one `caps`, `tunnel` and `unit` use —
83`gate=CASE WHEN excluded.gate = '' THEN routes.gate ELSE excluded.gate END`
84— so a full-row `Upsert` from a writer that does not know the column
85(reconcile's mint path via `routeFor`, an old `carlos add`) cannot silently
86open a gated host. `TestUpsertRoundTripsAllColumns` counts the column.
87
88Narrow setter, the shape of `SetSteering`:
89
90```go
91// SetGate stamps or clears a route's password gate. "" opens the host.
92func (r *Registry) SetGate(host, hash string) error
93```
94
95**`Fingerprint()` must include `gate`.** The edge reloads when the registry
96fingerprint changes, and the fingerprint hashes a fixed column list. A gate
97set or cleared with the column left out of that list would take effect only
98on the next unrelated reload — an operator would "turn the password off"
99and watch the page keep asking for it. `Route.Gate`'s doc comment says it IS
100in `Fingerprint()`, the inverse of the `Caps`/`Unit`/`Steering` comments.
101
102### 3.3 Converge
103
104In reconcile's "existing, provisioned, same app/account" arm, beside the
105hibernate converge:
106
107```go
108if existing.Gate != rec.Gate {
109 // dry-run / SetGate / log "converged %s gate %s -> %s" with the two
110 // states rendered as "open"/"set", never the hash.
111}
112```
113
114Unlike hibernate this is not a pointer with an absent-means-silent rule:
115absent decodes to `""`, and `""` means open. A record that does not name a
116gate is an open host, which is also what every record in every production
117bucket says today. The converge log line prints `open`/`set`, never the
118hash — it is not a secret in the strong sense (§4.3), but it has no business
119in a log.
120
121## 4. The edge
122
123### 4.1 The step, and its place in the chain
124
125The hash rides `up.route.Gate` — the `upstream` entry already carries the
126whole row, and an alias copies the entry, so nothing new is folded.
127`ServeHTTP` gains one step, placed **after** `lookupUpstreamPath` resolves
128`up` and the usage meter wraps `w`, and **before** the upgrade branch:
129
130```go
131if up.route.Gate != "" && !e.gate.Admit(w, r, up.route.Host, up.route.Gate) {
132 return
133}
134```
135
136The placement is the whole design and each neighbour is load-bearing:
137
138- **Before the upgrade branch**, so a WebSocket handshake is gated. The
139 handshake is a GET that carries cookies; the check reads the cookie and
140 either lets the handshake through untouched (the writer stack is exactly
141 what it was) or answers 401 before any hijack.
142- **Before the cache check**, so a cache hit is only ever served to a
143 request that already passed. The shared LRU keys on host and path, not on
144 cookies; a gate behind the cache would let one visitor's authenticated
145 fetch of `/` warm the cache for the world.
146- **Before the activator**, for the reason in §2.
147- **After the meter**, so the 401 page's bytes are billed to the route like
148 any other response the route caused. It is the tenant's page.
149- **After the steering probe, the tunnel hostname and the hop acceptance**,
150 which do not resolve a route and have nothing to gate. A backhauled
151 request is gated on the box that owns the row, where `ServeHTTP` runs
152 after `acceptHop`; the sending edge has no row and makes no decision.
153
154`Admit` returns true when the request carries a valid cookie, after
155**removing the gate cookie from the request** so the app never sees it. It
156returns false when it has already written a response: the login page, the
157redirect after a successful POST, or the too-many-attempts page.
158
159### 4.2 The reserved path
160
161`POST /.carlos/gate` on a gated host is the login form's action. Fields:
162`password`, `next`. `next` is accepted only if it starts with a single `/`
163(not `//`, not a scheme, no control bytes), else it becomes `/`; that is
164the entire open-redirect surface and it is closed at the door.
165
166**The edge routes a login POST by `next`, not by its own path** (review
167finding, 2026-09-07). A hostname that routes by path prefix resolves each
168request's first segment to a target route; `/.carlos/gate` has no such
169segment and would fall through to the front host's own route — so a gated
170prefix target could never be logged into, and the password would be
171proxied to whatever app fronts the hostname. So, for exactly `POST
172/.carlos/gate`, the edge parses the form (bounded, 4 KiB) and resolves the
173upstream against the path in `next`, which is the path the visitor was
174refused on. On a host with no prefix map this resolves to the same route
175the request path would have; on a prefix host it resolves to the target
176the visitor was trying to reach, whose hash judges the password and whose
177host signs the cookie (§4.3).
178
179**A login POST to an ungated route is a 404, never proxied.** The page is
180only ever rendered by a gated route, so a POST that reaches an ungated one
181is a stray or a probe; forwarding a form field named `password` to an app
182that never asked for it is the one outcome to rule out. On every other
183method the path is ordinary and reaches the app.
184
185Outcomes:
186
187- Right password → `Set-Cookie`, `303 See Other` to `next`.
188- Wrong password → the login page with the error line, status 401.
189- Over the attempt limit → the too-many-attempts page, status 429, with
190 `Retry-After: 60`, and the password is **not** compared.
191
192The limit is a per-client, per-host token bucket held in memory on the edge:
19310 attempts per minute, keyed on the request's remote address and the route
194host. bcrypt at cost 10 already costs tens of milliseconds a comparison; the
195bucket exists so the CPU is spent on people and not on a script. A
196backhauled request's remote address is the sibling edge's, which folds every
197hop from that edge into one bucket; accepted for now, because the fold only
198ever makes the limit stricter, and named here so it is not rediscovered.
199
200### 4.3 The cookie
201
202Name `carlos_gate`; `Path=/`; `HttpOnly`; `Secure`; `SameSite=Lax`;
203`Max-Age` seven days. Value:
204
205 v1.<expiry unix seconds>.<base64url(HMAC-SHA256(key, host "\n" expiry))>
206
207with `key = HMAC-SHA256("carlos-gate-cookie-v1", hash)` — derived from the
208stored bcrypt hash and nothing else. Verification is one HMAC and a
209constant-time compare (`hmac.Equal`), no bcrypt, no lookup.
210
211Deriving the key from the hash is what makes the design stateless and gives
212it its one rotation rule: **change or clear the password and every cookie
213minted under the old one stops verifying at once**, on every box, with no
214key object to mint, mirror or rotate. The mesh key exists because the edges
215have to agree on a secret nobody else holds; here the secret every edge
216already holds — the hash, in its own registry — is enough.
217
218The trust it implies, stated so it is a decision and not a surprise: anyone
219who can read the hash can mint a cookie without knowing the password. The
220hash is readable by root on the box and by a bucket reader. Both already
221hold the tenant's sealed secrets and the edge's TLS keys; a gate cookie
222adds nothing to what they could do. It is why §5.4 keeps the hash out of
223every API answer, where the reader is a member, not the platform.
224
225`host` in the MAC is `up.route.Host`, the row's host, not the request's —
226so a path-routed prefix and its target agree on which key signs. Browsers
227scope the cookie to the hostname they were served from anyway, so an alias
228and its primary each log in once.
229
230### 4.4 The page
231
232A new package, `internal/gate`, owning `Admit`, the cookie, the limiter and
233the page. One embedded `html/template` (`login.html`) with the stylesheet
234inline — a single response, no asset path to reserve — using the palette
235and dark-mode flip of `internal/status/assets/status.css`, which is the
236platform's neutral look. It is not the console's template set: the console
237is a different binary, and the edge takes no dependency on it.
238
239Response headers on every page the gate writes: `Cache-Control: no-store`,
240`X-Robots-Tag: noindex, nofollow`. `HEAD` gets the status and headers with
241no body. The 401 is deliberate over a 200-with-a-form: an uptime monitor, a
242crawler or a `curl` sees at a glance that it was not let in.
243
244The page, entire — hostname and error rendered from the template, nothing
245else dynamic:
246
247> **Password required**
248> `<host>` is private. Enter the password to continue.
249> [Password] [Continue]
250>
251> error line, wrong password: *That password is not right.*
252> error line, over the limit: *Too many attempts. Try again in a minute.*
253
254No product name, no logo, no link out. The member is fronting their own
255thing; the page belongs to their hostname.
256
257## 5. The doors
258
259### 5.1 One set of rules, two doors
260
261The password rules live in `internal/gate` and both doors call them:
262
263```go
264// CheckPassword is the door's rule: 8..72 bytes (72 is bcrypt's input
265// limit; longer is refused rather than silently truncated).
266func CheckPassword(p string) error
267// Hash is bcrypt at cost 10.
268func Hash(p string) (string, error)
269```
270
271The API PATCH applies them inside its existing read-mutate-validate-write
272flow (a PATCH may carry other fields in the same body, so it keeps its one
273write). The Settings form handler has the same read, write and audit
274sequence of its own, against one field. Shared, and enforced at both:
275
276- `CheckPassword` before anything is hashed;
277- the record must exist (404, byte-identical to the family's other 404s);
278- CAS loss is a 409, as for every other record write;
279- audit detail names `gate` among the changed fields (PATCH) or reads
280 `set password gate on <host>` / `cleared password gate on <host>` with a
281 `(console)` suffix (form). The password is never in the detail, the log,
282 or an error message.
283
284### 5.2 The API PATCH
285
286`handleAPIInstanceUpdate`'s body gains `Gate *string \`json:"gate"\``. Absent
287leaves the gate alone; `"gate": ""` clears; `"gate": "<password>"` sets. The
288handler routes a present `Gate` through `setInstanceGate` and appends `gate`
289to the audited `changed` list. Everything else about the PATCH is unchanged,
290including the shape rail and the all-nil 400 (which now names `gate` too).
291
292The plaintext crosses the wire once, to the console, over TLS, the same as
293a sign-in. The alternative — hashing in the CLI and sending the hash — would
294make the CLI and the Settings form two hashers to keep in step, for no gain
295a TLS client would notice.
296
297### 5.3 The CLI
298
299`carlos gate`, a new top-level verb shaped like `carlos features`: API mode
300only, sub-verbs as positional words that never reach `fs.Parse`.
301
302 carlos gate set -app <a> [-host <h>] [-password <p>]
303 carlos gate clear -app <a> [-host <h>]
304 carlos gate list -app <a>
305
306`set` without `-password` reads one line from stdin, so a password can be
307piped or typed without landing in the shell's history or `ps` output; an
308empty line is refused. When stdin is a terminal the verb prints `Password:`
309to stderr first; what is typed echoes, deliberately, because a no-echo
310prompt needs `golang.org/x/term`, which the module does not depend on, and
311a platform-specific termios call is exactly what the cross-compile gate
312exists to keep out. The session is resolved before the read, so a
313logged-out user is told before typing a secret. Without `-host`, `set` and
314`clear` apply to **every** instance of the app, one PATCH each, and print
315each host as it goes; a failure part way is reported per host and the verb
316exits non-zero, with nothing rolled back — the record is per host and the
317operator re-runs. `list` prints host and one of three states:
318`open`, `password set`, or `unknown — console predates gates` when the
319reply carries no `gated` key at all (§9).
320
321`-account`, `-account-id`, `-console` as every other API verb.
322
323### 5.4 The hash never leaves the console
324
325Every response that carries a record goes out with the hash shadowed. The
326list and GET already answer with `instanceListRecord`, which embeds
327`provision.Record`; it gains two fields, and the PATCH, create and steering
328replies — which write a bare record today — answer with a small view built
329the same way:
330
331```go
332// On instanceListRecord and on apiRecordView (the write replies):
333Gate string `json:"gate,omitempty"` // shadows the embedded field; always ""
334Gated bool `json:"gated"` // what a member may know
335```
336
337An outer field shadows the embedded one for `encoding/json`, so the hash
338marshals as nothing and `Gated` says what the reader needs. A test pins
339every writer: a record with a hash serialises with `"gated":true` and no
340`"gate"` key, from each of the four handlers.
341
342### 5.5 The web console
343
344The app's **Settings** page (`app_settings.html`, `handleAppSettings`) gains a
345"Password gate" section under the existing app-level sections. One row per
346instance record: the host, its state, and a form —
347
348 <host> Open [password ........] [Set password]
349 <host> Password set [password ........] [Change] [Turn off]
350
351— `POST /a/{sqid}/apps/{app}/gate` with `csrf`, `host`, `action` (`set` or
352`clear`), `password`. The handler is `handleAppGate`, shaped exactly like
353`handleAppPlace`: `accountApp`, constant-time CSRF check, then
354`setInstanceGate`, then `setFlash` and a 303 back to Settings. Role: any
355**member**, matching the API PATCH's door (`apiAccountApp`), because the CLI
356already lets a member do this and a form that refuses what the CLI allows
357is a rule that lives in the wrong place. The password field is
358`autocomplete="new-password"` so browsers do not offer to save it against
359the console.
360
361Flash copy: *Password set on <host>.* / *Password turned off on <host>.* /
362the refusal's message.
363
364The section reads its rows from `provision.ListRecords` for the app; an app
365with no records shows one line — *No instances yet. A password gate is set
366per instance.*
367
368## 6. Docs
369
370- `docs/site/cli.md` gains `### carlos gate` under "Your apps", after
371 `### carlos features`: one paragraph and a fenced example, in the page's
372 voice. `internal/docsite`'s test fails the build without it.
373- The `### carlos instances` paragraph gets one sentence pointing at `gate`.
374- The console Settings section needs no doc; it is on the page.
375
376## 7. Tests
377
378Registry: the column round-trips (`TestUpsertRoundTripsAllColumns`),
379`SetGate` sets and clears, `Upsert` with an empty gate preserves a set one,
380`Fingerprint` changes when the gate does.
381
382Provision: `ValidateShape` accepts a bcrypt hash, refuses a non-hash;
383reconcile converges set→clear and clear→set and logs `open`/`set` only;
384an absent field leaves an open host open and closes a set one (it is a
385string, not a pointer — the test says so).
386
387Edge (`internal/edge`, alongside the cache and activator tests):
388- an ungated route is byte-for-byte unchanged (no cookie read, no header);
389- a gated route with no cookie gets 401, `no-store`, `noindex`, and **the
390 activator is never called**, **the cache is never consulted**;
391- a right POST sets the cookie and 303s to `next`; a bad `next` becomes `/`;
392- a valid cookie passes, and the upstream sees no `carlos_gate` cookie;
393- an expired cookie, a cookie for another host, a cookie minted under the
394 previous hash — all 401;
395- a WebSocket handshake without the cookie is 401, with it reaches the
396 proxy;
397- an alias of a gated primary is gated;
398- the eleventh attempt in a minute is 429 without a bcrypt compare (a
399 counting fake for the comparer);
400- a `HEAD` gets 401 and no body.
401
402Gate package: cookie mint/verify round trip, key changes with hash, MAC is
403host-bound, expiry honoured, limiter refills.
404
405Console: PATCH round-trip sets and clears, refuses short and over-long,
406refuses clear+password, audits with no password in the detail; every record
407response has `gated` and no `gate` key; the Settings form sets and clears
408behind CSRF and a session, and a non-member gets the family's 404.
409
410CLI: `gate set` fans out to every instance and stops with a non-zero exit
411naming the host that failed; `list` renders both states.
412
413## 8. Not built, on purpose
414
415- **Basic auth for `curl`.** A cookie flow serves browsers; anyone scripting
416 against a gated prototype can POST once and keep the jar.
417- **Per-path exemptions** (`/healthz` open, the rest gated). The platform's
418 own health probe goes straight to the upstream and never meets the gate;
419 an external monitor can carry the cookie.
420- **Per-user passwords, logout, "remember me" longer than a week.** Every
421 one of these is the start of an identity product; the app is where that
422 lives.
423- **A gate on the app manifest** (every instance at once, forever). The
424 CLI's fan-out gives the same day-one result without a second source of
425 truth to reconcile.
426- **`carlos route --gate` for operator routes with no record.** Every member
427 route is a record. An operator route is platform property, and the
428 first person to want one gated can add the flag beside `--hibernate`.
429- **Reporting "enforced" as distinct from "set".** `gate list` reports the
430 record; the box's converge line is the confirmation. Cross-box truth is
431 `internal/fleetapi`'s problem (#145).
432
433## 9. Rollout
434
435The column is additive and defaults to open, so nothing changes on a box
436that rolls with no records carrying a gate. The order that matters is
437**boxes before console**: a console that writes `gate` into a record before
438the boxes roll produces a record the old reconcile decodes without the
439field — and a member who just set a password on a host that is, for the
440length of the gap, still open. Roll the box binary (edge and host agent are
441one binary) across the fleet, read a converge line, then roll the console.
442
443The CLI is inert against an old console: a PATCH carrying `gate` to a
444console that does not know the field is a 400 (`nothing to update`), which
445the verb reports as the console being older than the verb.
446