carlos / platform Public
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
upstreamentry verbatim (edge.go,upstream.alias), so a gate keyed onup.routefrontsdocs.example.comthe 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
// 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:
// SetGate stamps or clears a route's password gate. "" opens the host.
func (r *Registry) SetGate(host, hash string) errorFingerprint() 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:
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:
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
ServeHTTPruns afteracceptHop; 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 Othertonext. - 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:
// 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:
CheckPasswordbefore 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
gateamong the changed fields (PATCH) or readsset 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:
// 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 knowAn 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.mdgains### carlos gateunder "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 instancesparagraph gets one sentence pointing atgate. - 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 badnextbecomes/; - a valid cookie passes, and the upstream sees no
carlos_gatecookie; - 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
HEADgets 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 (
/healthzopen, 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 --gatefor 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 listreports the record; the box's converge line is the confirmation. Cross-box truth isinternal/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.