carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1# Instance moves P7: the edge-local door — Implementation Plan (rev 6)
2
3> **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.
4
5**Goal:** An app's own processes on a box (Tito's router, relay and import-sync loop) reach any of the app's instances through one box-local unix socket, whatever box the instance lives on and whatever its socket path, authenticated by an app-scoped door key that a box verifies offline.
6
7**Architecture:** A signed-in person mints a door key at the console. The console's own database is the authority for keys. The bucket holds a signed projection of them, under `control/door-keys/<account>/<app>.json`, signed with the deployment's root release key, carrying a revision and a 72-hour expiry, re-signed every 12 hours, and written by one serialized, compare-and-swap publisher that always rebuilds from the database. Every edge mirrors the projections on their own 30-second loop and verifies them against `CARLOS_RELEASE_PUBKEY`. A second listener on `/run/carlos/edge-local.sock` (0660, group `carlos-sockets` through the directory's setgid) authenticates `X-Carlos-Door-Key`, strips it, removes every forwarded-address header, marks the request as a door caller and hands it to `Edge.ServeHTTP`. For a host this box serves, `ServeHTTP` refuses reserved handlers to door callers and checks ownership against the final upstream it is about to serve. For a host served elsewhere, the door checks the owner the home box published, which a box publishes only for a host whose every route has one owner, and then backhauls it as an ordinary hop.
8
9## Security model (rev 6; Paul's ruling, 2026-09-28)
10
11**A door request is a request from this box.** Paul ruled (2026-09-28) that the door believes no caller-supplied visitor address. Rounds 4 and 5 showed that any *invented* representation (`unknown`, then RFC 7600's `192.0.0.8`) has some framework that reads it as local, private or trusted (Rails' `local?` fallback; python-ipware, where Python classes `192.0.0.8` as private). So the door invents nothing. It presents the box's own public address, the one its edges file publishes (`readPublicIP`, `internal/host/edgesfile.go:534`). That is exactly what the app would see if the calling process, on this box, requested the instance's public URL, since the request would leave the box and come back through an edge from that address. So the door grants nothing a public request from the same box lacks, whatever framework the app runs.
12
13- `serveDoor` deletes every header that claims a client address, then sets `RemoteAddr` to `<box public address>:0`. The headers deleted are:
14 - `stripForwardedHeaders` (`homemap.go:806`): `Forwarded`, `X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Proto`;
15 - `doorAddressHeaders`: `X-Forwarded`, `Forwarded-For`, `Client-IP`, `X-Real-IP`, `True-Client-IP`, `CF-Connecting-IP`, `Fastly-Client-IP`, `X-Client-IP`, `X-Cluster-Client-IP`, `X-Originating-IP`.
16- The ordinary `SetXForwarded` in `setForwarded` then writes that address into `X-Forwarded-For`, as it does for any client. The deletion is belt and braces. With a global address in `X-Forwarded-For`, the resolvers Astra traced (Rails `RemoteIp`, Express `proxy-addr`, Laravel/Symfony, Go `realip`, chi, python-ipware) pick it, exactly as they do for a public request.
17- `setForwarded`'s only door branch sets `X-Forwarded-Proto: https`, the scheme the same request would have had through the public URL. The far edge of a backhaul gives `https` by its own TLS.
18- Everything downstream then treats the request as a request from the box: the gate's limiter, analytics' visitor hash, the response cache, and a backhaul. Backhauled, the far edge believes the near edge's chain (the box address). If it cannot verify the hop, it uses the near edge's peer address, which is the same box. There is no separate degraded mode. **Door requests are not excluded from the cache.** Rev 5's bypass is dropped, because a request from the box is cached like any public request. The cache keys `Vary` on inbound headers (`edge.go:1583`, `:1702`) while forwarding rewrites them, and that mismatch is pre-existing for all traffic. It is filed as its own discussion.
19- **No public address, no door.** If the agent cannot read the box's public address, the door stays closed and says so in the journal. It never falls back to loopback, a private address or a placeholder.
20- An app that needs the buyer's address has its own router carry it in the app's own signed header, as Tito already does for identity (`X-Tito-Auth-*`). The platform reads no such header.
21
22**What the door key is for, then.** It says which app's processes are calling, and confines them to that app's hosts (ruling Q3). With no privilege behind the door, a gap in that confinement lets a caller reach a host through the door that it could already reach from the internet, with less than an internet request carries. That is why the residuals below are accepted. They are hygiene, not security boundaries:
23
24| residual | bound |
25|---|---|
26| a bucket writer (#171) forges or replays a door-key record | a replayed record is admitted until its own `expires_at`, at most 72 h after signing |
27| a bucket writer forges an edges-file owner, or ownership is stale | until an honest publish replaces it; unbounded if the owner's box never rewrites the file |
28| ownership changes (a host moves between apps) | until every edge's home map has refreshed |
29| the console's database is restored from backup | keys minted after the backup vanish; keys revoked after it come back until revoked again |
30
31The credential machinery (signed, revisioned, expiring records; one serialized publisher) stays as reviewed in rounds 1–3. It costs little, keeps keys unforgeable by a bucket writer, and would be the base if the door ever needed a privilege. Any such privilege needs its own design and review.
32
33**Tech Stack:** Go standard library (`net/http`, `crypto/ed25519`, `crypto/hmac`, `crypto/sha256`, `crypto/subtle`), the repo's `release.Store`, the console's SQLite store, `cliRoute`/`svcrole`, the CLI's `apiSession`.
34
35**Spec:** `docs/superpowers/specs/2026-09-24-instance-moves-for-every-app-design.md` on branch `docs-instance-moves-design` (§0 rulings R5, Q3, Q5; §8; §10), and `CODEX-RULINGS-INSTANCE-MOVES.md` beside it. Review round 1: `CODEX-FINDINGS-P7-EDGE-DOOR.md` beside this plan.
36
37## Revision 2: Astra round 1 dispositions
38
39| # | finding | disposition |
40|---|---|---|
41| 1 | console re-signs a copied record for another app | **Accepted, fixed structurally:** keys live in the console database; the console never reads a bucket record back (Task 2). Edge also requires payload account/app to match the path (Task 4). |
42| 2 | remote authorization loses the app boundary | **Accepted:** door scope bound into the hop MAC; the receiving edge enforces it on its final route; door traffic backhauls only to boxes advertising `door: true` (Tasks 5, 6). |
43| 3 | login re-resolution and lookup race | **Accepted:** the check moves into `ServeHTTP`, against the final `up` after the login re-resolution, before the gate, cache or dispatch (Task 5). |
44| 4 | reserved handlers ahead of the lookup | **Accepted:** door callers never reach the steering probe, tunnel hub or store door; a door request for either reserved name answers unknown host (Task 5). |
45| 5 | replayed pre-revocation record | **Accepted:** monotonic revision from the console database, persisted high-water mark per box, 72 h expiry, 12 h re-sign (Tasks 1, 2, 4). |
46| 6 | malformed XFF stays authoritative | **Accepted:** the door replaces X-Forwarded-For with exactly one validated address before `ServeHTTP` (Task 6). |
47| 7 | "30 s" is not the failure contract | **Accepted:** own loop and 10 s budget; list failure retains at most 10 minutes; absence, bad signature, expiry drop at once (Task 4). |
48| 8 | ready hook runs before the mirror | **Accepted:** `WithEdgeDoor` RunOption; the edge opens the door after the home map and the key mirror are armed, and closes it on shutdown; off in dev mode (Task 7). |
49| 9 | wrong file and fixture references | **Accepted:** `runInstances` is in `cli.go:284`; fixture is `fx.ts`/`fx.pub`, helpers in `apiinstances_test.go`; catchall publisher `edgesfile.go:363` and the alias-versus-route fold covered; docs go to `docs/site/cli.md` (the only reference page). |
50| 10 | root signing acceptable; add a domain prefix | **Accepted:** signatures cover `"carlos door-keys v1\n" + payload`; cross-kind rejection tests (Task 1). |
51
52## Revision 6: Astra round 5 dispositions
53
54| # | finding | disposition |
55|---|---|---|
56| R5-1 | python-ipware: `X-Forwarded`/`Forwarded-For` survive, and Python classes `192.0.0.8` as private | **Accepted, fixed by removing the invented address:** the door presents the box's own public address (Security model), which is global in every classifier and is what a public request from the box shows. Both headers join the strip list anyway. |
57| R5-2 | the cache bypass does not cross a backhaul | **Superseded:** a door request is a request from the box and is cached like one; no bypass. The pre-existing inbound-`Vary` mismatch is filed separately. |
58
59## Revision 5: Astra round 4 dispositions
60
61| # | finding | disposition |
62|---|---|---|
63| R4-1 | `unknown` lets frameworks fall back to `Client-IP` or loopback | **Accepted:** the address is `192.0.0.8` (RFC 7600), and the door strips every alternate client-address header (Security model; Task 6 tests with a Rails-rule resolver). |
64| R4-2 | the cache varies on inbound headers the door rewrote | **Accepted:** door requests bypass the cache (Task 5); the public-traffic mismatch is filed separately. |
65| R4-3 | failed hop verification substitutes an infrastructure address | **Accepted as a stated degraded mode** (Security model): it is the same outcome public backhaul has in that failure, and a box's public EIP confers nothing. |
66| R4-4 | gate and visitor partitions misstated | **Accepted:** the Security model states the actual keys. |
67
68## Revision 4: Astra round 3 dispositions
69
70| # | finding | disposition |
71|---|---|---|
72| R3-1 | address trust confers gate and app authority | **Accepted, resolved by Paul's ruling:** the door believes no caller address (Security model). The remote-confinement question goes away with the privilege. |
73| R3-2 | ownership residuals have no maximum lifetime | **Accepted:** the bounds are restated honestly (Security model), and the residuals are accepted as hygiene because the door carries no privilege. |
74| R3-3 | forwarded scheme differs by placement | **Accepted:** `setForwarded` gives door requests `X-Forwarded-Proto: https` locally; the far edge's TLS gives the same (Task 5). |
75| R3-4 | renewal bumps the revision; stale `WithEdgeDoor` example | **Accepted:** the advance fires only when the database is *below* the bucket (Task 2). The agent example takes one argument (Task 7). |
76
77## Revision 3: Astra round 2 dispositions
78
79| # | finding | disposition |
80|---|---|---|
81| R2-1 | failed door-hop verification drops the scope | **Superseded:** rev 3 carries no door scope across a hop (Security model). |
82| R2-2 | `door: true` is unauthenticated; selection race | **Superseded:** no capability flag. The remote check reads owner and dial target from one `Owner` call on one snapshot (Task 5). |
83| R2-3 | receiving edge reaches reserved handlers first | **Superseded:** a backhauled door request is an ordinary hop; the reserved handlers it could reach are the ones any internet client reaches. Local door callers are still excluded from them (Task 5). |
84| R2-4 | `Put(..., "")` is create-only | **Accepted:** publication reads the current ETag and writes with it, retrying on conflict with a fresh rebuild from the database (Task 2). |
85| R2-5 | concurrent publishers overwrite a revocation | **Accepted:** one per-app publication mutex in the console process; keys and revision read in one transaction inside it; a CAS retry rebuilds from the database; the publisher never writes a revision lower than the verified one in the bucket (Task 2). The console is one process per deployment. |
86| R2-6 | a restored database reuses revisions | **Accepted in part:** if the bucket's verified revision is at or above the database's, the publisher first advances the database's counter past it, so revisions stay monotonic across a restore. That a restore brings back keys revoked after the backup is stated as a residual (Security model). |
87| R2-7 | high-water persistence contract | **Superseded:** no box-side persistence. |
88
89## Global Constraints
90
91- **Q3 (ruled):** the door authenticates an app-scoped credential restricted to this door; the edge checks the requested host's account and app against the credential's, and strips the credential before forwarding. Unix permissions on the socket are a second check, not the identity.
92- **R5 (ruled):** one box-local door; every enrolled consumer (router, relay, import-sync loop: G18) moves to it before P9. P7 builds the door; moving Tito's processes is Tito's slice.
93- **§8:** the door is not a bypass: the password gate and (from P1) the hook-path refusal apply as on the public door. Headers the app sets pass through untouched.
94- **R2 (ruled, lands in P1):** hook paths are refused on the local door too. P1 adds that refusal beside Task 5's ownership check in `ServeHTTP`, so it covers local and backhauled door traffic alike.
95- **Q5 (ruled):** the door moves nothing, so it is not gated by Q5; the key record must not be forgeable or replayable by a bucket writer (#171).
96- **Dormant by default:** no socket unless `CARLOS_EDGE_DOOR` names a path.
97- **Every `carlos` command completes its job without box access** (AGENTS.md): mint, list and revoke are console API plus CLI.
98- **Copy:** every new user-facing string passes copy review before merge. No em dashes; in Go error strings, a full stop and a new sentence in lower case.
99- **Cross-compile:** `GOOS=darwin go build ./... && GOOS=windows go build ./...` must pass.
100- **Tests with unix sockets** run outside the Claude sandbox with `TMPDIR=/tmp/g GOCACHE=/tmp/g/gocache GOFLAGS=-buildvcs=false`.
101
102## Freshness contract (the numbers)
103
104| event | effect at a box |
105|---|---|
106| key minted | admitted within one mirror interval (30 s) after the serial bump |
107| key revoked, bucket healthy | refused within 30 s |
108| key revoked, bucket list failing | refused at the latest 10 minutes after the last successful list, when the whole map is cleared |
109| a lower revision appears than the one this process last admitted | kept out for the life of the process (in-memory only); after a restart, admitted until its own `expires_at` (Security model residual) |
110| old record replayed into the bucket | admitted until its own `expires_at`, at most 72 h after it was signed (Security model residual) |
111| console down | records expire 72 h after their last re-sign; the door refuses every key of an expired record |
112
113## Review Focus
114
1151. **A door caller sending mesh vocabulary** (`X-Carlos-Hop`, `X-Carlos-Hop-Auth`) never gains hop trust; the door deletes both (Task 6 test).
1162. **A path-prefix hostname whose prefixes belong to different apps**: locally, app A's key never reaches app B's prefix; remotely, the host publishes no owner and the door refuses it (Tasks 4 and 5 tests).
1173. **A gate login POST whose `next` names another app's prefix** is refused before the gate judges the password (Task 5 test).
1184. **A revoke racing a renewal** never leaves the older record in the bucket (Task 2 test).
1195. **Any header claiming a client address** (`Forwarded`, `X-Forwarded-*`, and every name in `doorAddressHeaders`) never reaches the app, the gate or analytics. The app sees the box's public address and `https`, served on this box or across a hop (Tasks 5 and 6 tests).
120
121---
122
123## File structure
124
125| File | Responsibility |
126|---|---|
127| `internal/doorkey/doorkey.go` (new) | Token format, record schema, key path, domain-separated sign/verify, admit. No I/O. |
128| `internal/console/doorkeys_store.go` (new) | `door_keys` table: insert, list, revoke, per-app revision counter. |
129| `internal/console/apidoorkeys.go` (new) | Mint, list, revoke doors; `publishDoorKeys`; the 12 h re-sign loop. |
130| `internal/console/authcli.go` (modify, beside `instance-token`, ~line 637) | Routes, person-only. |
131| `doorkeys_cli.go` (new, package main) | `carlos instances door-key create|list|revoke`. |
132| `cli.go` (modify, `runInstances` at ~line 284) | Dispatch `door-key`. |
133| `internal/host/edgesfile.go` (modify: `edgeHost`, the route fill, the catchall publisher ~line 363) | Publish per-host `account`/`app` for single-owner hostnames. |
134| `internal/edge/homemap.go` (modify) | Fold the owner with the winning entry; `Owner`; `setForwarded`'s door branch. |
135| `internal/edge/doorkeys.go` (new) | The key mirror: its own loop and freshness contract. |
136| `internal/edge/door.go` (new) | Door mark, `serveDoor`, `doorOwnsRoute`, reserved-name refusal, the listener. |
137| `internal/edge/edge.go` (modify: `ServeHTTP`, `backhaul`/`backhaulProxy`, `RunWithActivatorReady`, shutdown) | Enforcement points and lifecycle. |
138| `internal/host/agent.go` (modify: options passed to `RunWithActivatorReady`) | `edge.WithEdgeDoor` from `CARLOS_EDGE_DOOR`. |
139| `docs/site/cli.md` (modify) | The door and the key verbs. |
140
141---
142
143### Task 1: The door-key package
144
145**Files:** Create `internal/doorkey/doorkey.go`, `internal/doorkey/doorkey_test.go`.
146
147**Interfaces (produced):**
148- `const Header = "X-Carlos-Door-Key"`, `const Prefix = "control/door-keys/"`, `const MaxKeys = 8`, `const Lifetime = 72 * time.Hour`
149- `func RecordKey(account, app string) string` → `Prefix + account + "/" + app + ".json"`
150- `type Token struct{ Account, App, ID, Secret string }`
151- `func NewToken(account, app string) (raw string, entry Entry, err error)`
152- `func ParseToken(raw string) (Token, error)`
153- `type Entry struct{ ID, SHA256, Label string; CreatedAt time.Time }` (JSON `id`, `sha256`, `label`, `created_at`)
154- `type Record struct{ V int; Account, App string; Revision uint64; Keys []Entry; SignedAt, ExpiresAt time.Time }` (JSON `v`, `account`, `app`, `revision`, `keys`, `signed_at`, `expires_at`)
155- `func Sign(priv ed25519.PrivateKey, rec Record) ([]byte, error)`
156- `func Verify(pub ed25519.PublicKey, wire []byte, now time.Time) (Record, error)` — signature, shape, and `now` before `ExpiresAt`
157- `func (r Record) Admits(t Token) bool`
158
159Token wire form `cdk1.<account>.<app>.<id>.<secret>`: account per `release.ValidAccount`, app `^[a-z][a-z0-9-]{0,31}$`, id 16 lowercase hex, secret 43 characters unpadded base64url (32 random bytes). No field can contain a dot.
160
161The signature covers `"carlos door-keys v1\n" + payloadB64`, never `payloadB64` alone, so no other root-signed wrapper (release pointers, pipelines, system pointers, grants: all sign the bare base64) verifies as a door-key record, and a door-key record verifies as none of them.
162
163- [ ] **Step 1: Write the failing tests**
164
165```go
166package doorkey
167
168import (
169 "crypto/ed25519"
170 "crypto/rand"
171 "encoding/base64"
172 "encoding/json"
173 "strings"
174 "testing"
175 "time"
176)
177
178func rec1(entry Entry) Record {
179 now := time.Now().UTC()
180 return Record{V: 1, Account: "bae", App: "titogo", Revision: 1, Keys: []Entry{entry},
181 SignedAt: now, ExpiresAt: now.Add(Lifetime)}
182}
183
184func TestTokenRoundTripAndAdmit(t *testing.T) {
185 raw, entry, err := NewToken("bae", "titogo")
186 if err != nil {
187 t.Fatal(err)
188 }
189 if !strings.HasPrefix(raw, "cdk1.bae.titogo.") {
190 t.Fatalf("token %q lacks its prefix", raw)
191 }
192 tok, err := ParseToken(raw)
193 if err != nil || tok.ID != entry.ID {
194 t.Fatalf("parse %q: %+v %v", raw, tok, err)
195 }
196 if !rec1(entry).Admits(tok) {
197 t.Fatal("the record does not admit its own key")
198 }
199 tok.Secret = strings.Repeat("A", 43)
200 if rec1(entry).Admits(tok) {
201 t.Fatal("a wrong secret was admitted")
202 }
203}
204
205func TestRecordAdmitsOnlyItsOwnAccountAndApp(t *testing.T) {
206 raw, entry, _ := NewToken("bae", "titogo")
207 tok, _ := ParseToken(raw)
208 other := rec1(entry)
209 other.App = "other"
210 if other.Admits(tok) {
211 t.Fatal("a record for another app admitted the key")
212 }
213}
214
215func TestParseTokenRefusesMalformed(t *testing.T) {
216 good := "0123456789abcdef." + strings.Repeat("A", 43)
217 for _, raw := range []string{
218 "", "cdk1", "cdk2.bae.titogo." + good, "cdk1.BAE.titogo." + good,
219 "cdk1.bae.titogo.0123456789ABCDEF." + strings.Repeat("A", 43),
220 "cdk1.bae.titogo.0123456789abcdef.short", "cdk1.bae.titogo." + good + ".extra",
221 } {
222 if _, err := ParseToken(raw); err == nil {
223 t.Errorf("ParseToken(%q) accepted a malformed token", raw)
224 }
225 }
226}
227
228func TestSignVerifyExpiryAndKeys(t *testing.T) {
229 pub, priv, _ := ed25519.GenerateKey(rand.Reader)
230 _, entry, _ := NewToken("bae", "titogo")
231 wire, err := Sign(priv, rec1(entry))
232 if err != nil {
233 t.Fatal(err)
234 }
235 got, err := Verify(pub, wire, time.Now())
236 if err != nil || got.Revision != 1 || got.Keys[0].ID != entry.ID {
237 t.Fatalf("Verify = %+v, %v", got, err)
238 }
239 if _, err := Verify(pub, wire, time.Now().Add(Lifetime+time.Minute)); err == nil {
240 t.Fatal("an expired record verified")
241 }
242 otherPub, _, _ := ed25519.GenerateKey(rand.Reader)
243 if _, err := Verify(otherPub, wire, time.Now()); err == nil {
244 t.Fatal("a record signed by another key verified")
245 }
246}
247
248// The domain prefix: a wrapper signed over the bare payload (every other
249// root-signed record's shape) must not verify as a door-key record.
250func TestVerifyRefusesABareSignedWrapper(t *testing.T) {
251 pub, priv, _ := ed25519.GenerateKey(rand.Reader)
252 _, entry, _ := NewToken("bae", "titogo")
253 payload, _ := json.Marshal(rec1(entry))
254 b64 := base64.StdEncoding.EncodeToString(payload)
255 wire, _ := json.Marshal(map[string]string{"payload": b64,
256 "sig": base64.StdEncoding.EncodeToString(ed25519.Sign(priv, []byte(b64)))})
257 if _, err := Verify(pub, wire, time.Now()); err == nil {
258 t.Fatal("a bare-signed wrapper verified as a door-key record")
259 }
260 // And the other direction: a door-key record's signature does not
261 // verify over its bare payload.
262 door, _ := Sign(priv, rec1(entry))
263 var w struct{ Payload, Sig string }
264 json.Unmarshal(door, &w)
265 sig, _ := base64.StdEncoding.DecodeString(w.Sig)
266 if ed25519.Verify(pub, []byte(w.Payload), sig) {
267 t.Fatal("a door-key signature verifies over the bare payload")
268 }
269}
270
271func TestSignRefusesTooManyKeys(t *testing.T) {
272 _, priv, _ := ed25519.GenerateKey(rand.Reader)
273 _, e, _ := NewToken("bae", "titogo")
274 r := rec1(e)
275 for i := 0; i < MaxKeys; i++ {
276 _, e, _ := NewToken("bae", "titogo")
277 r.Keys = append(r.Keys, e)
278 }
279 if _, err := Sign(priv, r); err == nil {
280 t.Fatal("signed a record over MaxKeys")
281 }
282}
283```
284
285- [ ] **Step 2: Run** `go test ./internal/doorkey/` → FAIL (no package).
286
287- [ ] **Step 3: Implement**
288
289```go
290// Package doorkey is the credential for the edge-local door (instance-moves
291// design §8, ruling Q3): an app-scoped key a box verifies offline. The
292// console's database is the authority; the bucket holds a signed, expiring,
293// revisioned projection of it, so a bucket writer (discussion #171) can
294// neither add a key nor bring a revoked one back for longer than the
295// projection's own lifetime on a box that never saw the revocation.
296package doorkey
297
298import (
299 "crypto/ed25519"
300 "crypto/rand"
301 "crypto/sha256"
302 "crypto/subtle"
303 "encoding/base64"
304 "encoding/hex"
305 "encoding/json"
306 "errors"
307 "fmt"
308 "regexp"
309 "strings"
310 "time"
311
312 "github.com/carlosframework/platform/internal/release"
313)
314
315const (
316 Header = "X-Carlos-Door-Key"
317 Prefix = "control/door-keys/"
318 MaxKeys = 8
319 Lifetime = 72 * time.Hour
320 scheme = "cdk1"
321 domain = "carlos door-keys v1\n"
322)
323
324var (
325 appRe = regexp.MustCompile(`^[a-z][a-z0-9-]{0,31}$`)
326 idRe = regexp.MustCompile(`^[0-9a-f]{16}$`)
327 secretRe = regexp.MustCompile(`^[A-Za-z0-9_-]{43}$`)
328)
329
330func RecordKey(account, app string) string { return Prefix + account + "/" + app + ".json" }
331
332type Token struct{ Account, App, ID, Secret string }
333
334type Entry struct {
335 ID string `json:"id"`
336 SHA256 string `json:"sha256"`
337 Label string `json:"label,omitempty"`
338 CreatedAt time.Time `json:"created_at"`
339}
340
341type Record struct {
342 V int `json:"v"`
343 Account string `json:"account"`
344 App string `json:"app"`
345 Revision uint64 `json:"revision"`
346 Keys []Entry `json:"keys"`
347 SignedAt time.Time `json:"signed_at"`
348 ExpiresAt time.Time `json:"expires_at"`
349}
350
351type signedWrapper struct {
352 Payload string `json:"payload"`
353 Sig string `json:"sig"`
354}
355
356// Digest is the stored form of a key's secret.
357func Digest(secret string) string {
358 sum := sha256.Sum256([]byte(secret))
359 return hex.EncodeToString(sum[:])
360}
361
362func NewToken(account, app string) (string, Entry, error) {
363 if !release.ValidAccount(account) || !appRe.MatchString(app) {
364 return "", Entry{}, fmt.Errorf("doorkey: invalid account or app %q/%q", account, app)
365 }
366 var id [8]byte
367 var secret [32]byte
368 if _, err := rand.Read(id[:]); err != nil {
369 return "", Entry{}, err
370 }
371 if _, err := rand.Read(secret[:]); err != nil {
372 return "", Entry{}, err
373 }
374 t := Token{Account: account, App: app, ID: hex.EncodeToString(id[:]),
375 Secret: base64.RawURLEncoding.EncodeToString(secret[:])}
376 raw := strings.Join([]string{scheme, t.Account, t.App, t.ID, t.Secret}, ".")
377 return raw, Entry{ID: t.ID, SHA256: Digest(t.Secret), CreatedAt: time.Now().UTC()}, nil
378}
379
380func ParseToken(raw string) (Token, error) {
381 f := strings.Split(raw, ".")
382 if len(f) != 5 || f[0] != scheme || !release.ValidAccount(f[1]) || !appRe.MatchString(f[2]) ||
383 !idRe.MatchString(f[3]) || !secretRe.MatchString(f[4]) {
384 return Token{}, errors.New("doorkey: malformed door key")
385 }
386 return Token{Account: f[1], App: f[2], ID: f[3], Secret: f[4]}, nil
387}
388
389// Admits answers whether t is one of this record's keys. The record's own
390// account and app must be the token's, so a key can never pass against a
391// record filed for another app.
392func (r Record) Admits(t Token) bool {
393 if r.Account != t.Account || r.App != t.App {
394 return false
395 }
396 want := Digest(t.Secret)
397 for _, e := range r.Keys {
398 if e.ID == t.ID && subtle.ConstantTimeCompare([]byte(e.SHA256), []byte(want)) == 1 {
399 return true
400 }
401 }
402 return false
403}
404
405func (r Record) valid() error {
406 if r.V != 1 || !release.ValidAccount(r.Account) || !appRe.MatchString(r.App) || r.Revision == 0 ||
407 r.ExpiresAt.IsZero() || !r.ExpiresAt.After(r.SignedAt) || r.ExpiresAt.Sub(r.SignedAt) > Lifetime {
408 return errors.New("doorkey: record shape")
409 }
410 if len(r.Keys) > MaxKeys {
411 return fmt.Errorf("doorkey: %d keys, at most %d", len(r.Keys), MaxKeys)
412 }
413 for _, e := range r.Keys {
414 if !idRe.MatchString(e.ID) || len(e.SHA256) != 64 {
415 return errors.New("doorkey: record entry shape")
416 }
417 }
418 return nil
419}
420
421func Sign(priv ed25519.PrivateKey, rec Record) ([]byte, error) {
422 if len(priv) != ed25519.PrivateKeySize {
423 return nil, errors.New("doorkey: no signing key")
424 }
425 if err := rec.valid(); err != nil {
426 return nil, err
427 }
428 payload, err := json.Marshal(rec)
429 if err != nil {
430 return nil, err
431 }
432 b64 := base64.StdEncoding.EncodeToString(payload)
433 return json.MarshalIndent(signedWrapper{Payload: b64,
434 Sig: base64.StdEncoding.EncodeToString(ed25519.Sign(priv, []byte(domain+b64)))}, "", " ")
435}
436
437func Verify(pub ed25519.PublicKey, wire []byte, now time.Time) (Record, error) {
438 if len(pub) != ed25519.PublicKeySize {
439 return Record{}, errors.New("doorkey: no public key")
440 }
441 var w signedWrapper
442 if err := json.Unmarshal(wire, &w); err != nil {
443 return Record{}, err
444 }
445 sig, err := base64.StdEncoding.DecodeString(w.Sig)
446 if err != nil || len(sig) != ed25519.SignatureSize || !ed25519.Verify(pub, []byte(domain+w.Payload), sig) {
447 return Record{}, errors.New("doorkey: bad signature")
448 }
449 payload, err := base64.StdEncoding.DecodeString(w.Payload)
450 if err != nil {
451 return Record{}, err
452 }
453 var rec Record
454 if err := json.Unmarshal(payload, &rec); err != nil {
455 return Record{}, err
456 }
457 if err := rec.valid(); err != nil {
458 return Record{}, err
459 }
460 if !now.Before(rec.ExpiresAt) {
461 return Record{}, errors.New("doorkey: record expired")
462 }
463 return rec, nil
464}
465```
466
467- [ ] **Step 4: Run** `go test ./internal/doorkey/ -count=1` → PASS.
468- [ ] **Step 5: Commit** `doorkey: the edge-local door's app-scoped, root-signed, expiring credential (#175 P7)`.
469
470---
471
472### Task 2: Console authority, doors and re-sign loop
473
474**Files:** Create `internal/console/doorkeys_store.go`, `internal/console/apidoorkeys.go`, `internal/console/apidoorkeys_test.go`. Modify `internal/console/authcli.go` (beside the `instance-token` route, ~line 637), and the console's schema migration list (find it with `grep -n "CREATE TABLE IF NOT EXISTS cli_tokens" internal/console/*.go` and add beside it).
475
476**Interfaces:**
477- Consumes: `doorkey.*`; `s.cfg.Key` (root `ed25519.PrivateKey`, empty on a read-only console); `s.cfg.Releases`; `s.apiPerson`, `s.apiAccountApp`, `s.allowWrite`, `cleanLabel`, `s.audit`, `s.bumpSerial`, `writeJSON`, `jsonError`, `readJSON` (as `handleAPIInstanceToken`, `apiinstances.go:1554`, uses them).
478- Produces:
479 - table `door_keys(account_id INTEGER, app TEXT, id TEXT, sha256 TEXT, label TEXT, created_at INTEGER, revoked_at INTEGER, PRIMARY KEY(account_id, app, id))` and `door_key_revisions(account_id INTEGER, app TEXT, revision INTEGER, PRIMARY KEY(account_id, app))`
480 - `func (st *Store) DoorKeyInsert(accountID int64, app string, e doorkey.Entry) (revision uint64, err error)` — refuses when the app already has `doorkey.MaxKeys` live keys (`errDoorKeysFull`); bumps the revision in the same transaction
481 - `func (st *Store) DoorKeyRevoke(accountID int64, app, id string) (revision uint64, err error)` — `os.ErrNotExist` for an unknown or already revoked id; bumps the revision
482 - `func (st *Store) DoorKeysLive(accountID int64, app string) (keys []doorkey.Entry, revision uint64, err error)`
483 - `func (st *Store) DoorKeyApps() ([]struct{ AccountID int64; Sqid, App string }, error)` — every app with a revision row
484 - `func (st *Store) DoorKeyAdvanceRevision(accountID int64, app string, past uint64) (uint64, error)` — sets the revision to `past+1` if it is below `past`; returns the resulting revision
485 - `func (s *Server) publishDoorKeys(ctx context.Context, acct *Account, app string) error` — the one publisher, protocol below
486 - `s.doorPublish` — `map[string]*sync.Mutex` keyed `"<sqid>/<app>"`, guarded by its own mutex, one lock per app
487
488**Publication protocol** (round 2, findings 4–6). `release.Store.Put(ctx, key, body, etag)` is create-only with `etag == ""` and compare-and-swap otherwise (`internal/release/store.go:37`), so:
489
4901. Take the app's publication mutex; hold it to the end.
4912. `wire, etag, err := s.cfg.Releases.Get(ctx, key)`. Not found: `etag = ""`, `bucketRev = 0`. Found: `rec, verr := doorkey.Verify(pub, wire, time.Time{})`. Pass the zero time so an expired record still yields its revision. If `Verify` refuses the zero time, add `doorkey.VerifyRevision(pub, wire) (uint64, error)`, which checks the signature and shape only. `bucketRev` is the verified revision, or `0` for anything unverifiable (a tampered object is overwritten, never trusted).
4923. `keys, rev := DoorKeysLive(...)`, one read transaction. If `rev < bucketRev`, then `rev = DoorKeyAdvanceRevision(..., bucketRev)` and re-read `keys`. An equal revision is the ordinary renewal case and is re-published as it is. That covers a restored database and a second writer.
4934. Build `doorkey.Record{Revision: rev, Keys: keys, SignedAt: now, ExpiresAt: now + doorkey.Lifetime}`, sign it, and `Put(ctx, key, body, etag)`.
4945. On `release.ErrCASConflict`, go back to step 2, at most three times. Every retry rebuilds from the database and never reuses signed bytes. If all three fail, return the error.
495
496Mint and revoke do the database write, then `publishDoorKeys`, then `bumpSerial`. The renewal loop calls `publishDoorKeys` with no database write, so a renewal re-publishes the current revision with a fresh expiry, or a newer one if step 3 advanced it. Because the mutex serializes a renewal against a revoke, and each builds from the database inside the lock, a renewal can never write a key set older than one already published. The console runs as one process per deployment. If that ever changes, step 2's revision check keeps a slower writer from putting back an older revision, because it re-reads under CAS.
497 - `func (s *Server) doorKeyResignLoop(stop <-chan struct{})` — every 12 h, `publishDoorKeys` for every `DoorKeyApps()` row; logs failures; started beside the console's other loops (find them with `grep -n "go s\..*Loop(" internal/console/server.go`), only when `len(s.cfg.Key) > 0`
498 - Wire: `POST /api/cli/apps/{account}/{app}/door-keys` `{"label"}` → 201 `{"key","id","account","app"}`; `GET .../door-keys` → 200 `{"keys":[{"id","label","created_at"}]}`; `POST .../door-keys/{id}/revoke` → 200 `{"revoked":id}`, 404 unknown id; 409 on a full set; 503 with the read-only sentence when `len(s.cfg.Key) == 0`. All person-only.
499
500A mutation is: database write (which yields the new revision), then `publishDoorKeys`, then `bumpSerial`. If publishing fails after the database write, answer 502 "saved but not yet published" family and rely on the re-sign loop; the database is still the truth.
501
502- [ ] **Step 1: Failing tests** (fixture from `apiinstances_test.go`: `instanceFixture(t, code)` returns the fixture and a person token; the HTTP server is `fx.ts`, the signing public key `fx.pub`, the bucket `fx.rel`):
503
504```go
505func doorKeysURL(fx *consoleFixture) string { return fx.ts.URL + "/api/cli/apps/bab/titogo/door-keys" }
506
507func readDoorRecord(t *testing.T, fx *consoleFixture) doorkey.Record {
508 t.Helper()
509 wire, _, err := fx.rel.Get(context.Background(), doorkey.RecordKey("bab", "titogo"))
510 if err != nil {
511 t.Fatal(err)
512 }
513 rec, err := doorkey.Verify(fx.pub, wire, time.Now())
514 if err != nil {
515 t.Fatal(err)
516 }
517 return rec
518}
519
520func TestDoorKeysMintListRevoke(t *testing.T) {
521 fx, token := instanceFixture(t, "code-door-keys")
522 code, _, out := rawReq(t, http.MethodPost, doorKeysURL(fx), token, `{"label":"router"}`)
523 if code != http.StatusCreated {
524 t.Fatalf("mint = %d %s", code, out)
525 }
526 var minted struct{ Key, ID string }
527 json.Unmarshal([]byte(out), &minted)
528 tok, err := doorkey.ParseToken(minted.Key)
529 if err != nil || tok.ID != minted.ID {
530 t.Fatalf("minted key %q: %v", minted.Key, err)
531 }
532 rec := readDoorRecord(t, fx)
533 if !rec.Admits(tok) || rec.Revision != 1 {
534 t.Fatalf("record %+v does not admit the minted key at revision 1", rec)
535 }
536 code, _, out = rawReq(t, http.MethodGet, doorKeysURL(fx), token, "")
537 if code != http.StatusOK || !strings.Contains(out, minted.ID) || strings.Contains(out, "sha256") {
538 t.Fatalf("list = %d %s", code, out)
539 }
540 if code, _, out = rawReq(t, http.MethodPost, doorKeysURL(fx)+"/"+minted.ID+"/revoke", token, ""); code != http.StatusOK {
541 t.Fatalf("revoke = %d %s", code, out)
542 }
543 if rec := readDoorRecord(t, fx); rec.Admits(tok) || rec.Revision != 2 {
544 t.Fatalf("after revoke: %+v", rec)
545 }
546}
547
548// Finding 1: whatever sits in the bucket, the console publishes from its
549// database. A record copied in from another app's path is overwritten, not
550// extended.
551func TestDoorKeysPublishFromTheDatabaseNotTheBucket(t *testing.T) {
552 fx, token := instanceFixture(t, "code-door-keys-copy")
553 _, planted, _ := doorkey.NewToken("bab", "titogo")
554 now := time.Now().UTC()
555 forged, _ := doorkey.Sign(fx.key, doorkey.Record{V: 1, Account: "bab", App: "titogo", Revision: 99,
556 Keys: []doorkey.Entry{planted}, SignedAt: now, ExpiresAt: now.Add(time.Hour)})
557 fx.rel.Put(context.Background(), doorkey.RecordKey("bab", "titogo"), forged, "")
558 if code, _, out := rawReq(t, http.MethodPost, doorKeysURL(fx), token, `{}`); code != http.StatusCreated {
559 t.Fatalf("mint = %d %s", code, out)
560 }
561 rec := readDoorRecord(t, fx)
562 for _, e := range rec.Keys {
563 if e.ID == planted.ID {
564 t.Fatal("the console carried a key it never minted into its signed record")
565 }
566 }
567}
568
569func TestDoorKeysRefuseServiceCredentialsAndReadOnlyConsoles(t *testing.T) { /* an Admin-role service principal gets 403 (use the existing service-principal fixture helper: grep -n "NewServicePrincipal" internal/console/*_test.go); a fixture with cfg.Key = nil gets 503 and no object is written */ }
570
571func TestDoorKeysRefuseANinthKey(t *testing.T) { /* eight mints 201, the ninth 409, the record still has eight */ }
572
573func TestDoorKeyResignRefreshesExpiry(t *testing.T) { /* mint; call publishDoorKeys again after advancing the store's clock seam (or compare SignedAt before/after a second call); revision unchanged, ExpiresAt later */ }
574
575// Round 2 finding 5: a renewal that read revision N must not land after a
576// revoke published N+1. Drive it deterministically: wrap fx.rel in a store
577// whose Put blocks on a channel for the FIRST call only; start a renewal
578// (publishDoorKeys) in a goroutine so it blocks inside the lock; start a
579// revoke in another goroutine; release the renewal; wait for both. The
580// bucket must hold the revoke's revision and not admit the revoked key.
581func TestDoorKeyRevokeIsNotOverwrittenByARacingRenewal(t *testing.T) { /* as described */ }
582
583// Round 2 finding 6: a database whose counter is behind the bucket (a
584// restore) publishes above the bucket's revision, never at or below it.
585func TestDoorKeyPublishAdvancesPastTheBucketRevision(t *testing.T) { /* publish revision 1; plant a validly signed revision 7 in the bucket; mint; the new record's revision is 8 and the database's counter is 8 */ }
586
587// Round 2 finding 4: replacing an existing object works, and a tampered
588// object is overwritten rather than trusted.
589func TestDoorKeyPublishReplacesExistingAndTamperedObjects(t *testing.T) { /* mint twice (second Put replaces); then write garbage bytes at the key and revoke: the revoke publishes a verifiable record */ }
590```
591
592(`fx.key`: if the fixture exposes the private key under another name, use that; `grep -n "ed25519.GenerateKey" internal/console/server_test.go`.) Write out the three stubbed tests in full in the same style before running.
593
594- [ ] **Step 2: Run** `go test ./internal/console -run DoorKey -count=1` → FAIL.
595- [ ] **Step 3: Implement** the store, the doors, `publishDoorKeys`, the loop, and the routes:
596
597```go
598 // Door keys (#175 P7): person-only, the instance-token rule. A door key
599 // reaches every instance of the app through every box's edge-local door.
600 s.cliRoute("GET /api/cli/apps/{account}/{app}/door-keys", svcrole.Set{}, s.handleAPIDoorKeyList)
601 s.cliRoute("POST /api/cli/apps/{account}/{app}/door-keys", svcrole.Set{}, s.handleAPIDoorKeyMint)
602 s.cliRoute("POST /api/cli/apps/{account}/{app}/door-keys/{id}/revoke", svcrole.Set{}, s.handleAPIDoorKeyRevoke)
603```
604
605- [ ] **Step 4: Run** the new tests, then `go test ./internal/console -count=1`.
606- [ ] **Step 5: Commit** `console: door keys, with the database as authority and a re-signed projection (#175 P7)`.
607
608---
609
610### Task 3: `carlos instances door-key`
611
612**Files:** Create `doorkeys_cli.go`, `doorkeys_cli_test.go`. Modify `cli.go` (`runInstances`, ~line 284: add `case "door-key":` beside the existing subcommand words).
613
614**Interfaces:** Consumes the three doors; `requireAPISession`, `printUsing`, `resolvedAccount`, `resolvedApp`, `apiPath`, `s.getJSON`, `s.postJSON` (as `runInstancesDelete` and `mail_cli.go`'s wire calls use them). Produces `carlos instances door-key create --app <app> [--label <text>]`, `list --app <app>`, `revoke --app <app> <id>`.
615
616- [ ] **Step 1: Failing test** against an `httptest` console, in the pattern of the existing `apiSession` tests (`grep -ln "httptest.NewServer" *_test.go`, pick the one that stubs `/api/cli/apps/`): assert request method and path for each verb and that `create` prints the key.
617- [ ] **Step 2: Run** `go test . -run DoorKey -count=1` → FAIL.
618- [ ] **Step 3: Implement** `doorKeyMint`, `doorKeyList`, `doorKeyRevoke` wire calls and the three verbs.
619- [ ] **Step 4: Run** → PASS.
620- [ ] **Step 5: Commit** `cli: carlos instances door-key create, list, revoke (#175 P7)`.
621
622---
623
624### Task 4: Edges files publish single owners; the edge mirrors door keys
625
626**Files:** Modify `internal/host/edgesfile.go`, `internal/edge/homemap.go`. Create `internal/edge/doorkeys.go`. Tests in `internal/host/edgesfile_test.go`, `internal/edge/homemap_test.go`, `internal/edge/doorkeys_test.go`.
627
628**Interfaces:**
629- Edges file: `edgeHost` gains `Account string \`json:"account,omitempty"\`` and `App string \`json:"app,omitempty"\``. **They are published only when every route this box serves under that hostname (the row, every path-prefix row that shares it, and for an alias, its primary's rows) has one account and app.** A mixed hostname publishes neither, and the door refuses it remotely. Set for role-`home` routes from `release.RouteAccount(rt)`/`rt.App`, for aliases from their primary, and for the served catchall at `edgesfile.go:363` from the wildcard claim's account/app. No capability flag.
630- Fold: `edgesHostView` and `homeEntry` gain `account`, `app`. When `foldHomes` lets a route replace an alias (~`homemap.go:581`), the owner comes from the winner, never merged.
631- `func (m *homeMap) Owner(host string) (account, app, eip, box string, ok bool)`: one read of one snapshot answers both whom to check and where to dial, so the check and the dial cannot disagree. It uses the same lookup as `Home`, including the one-label wildcard. `ok` is false when the winning entry has no owner or is not a dial target. A nil receiver returns zeros.
632- `type doorKeyMirror struct{ ... }` in `doorkeys.go`:
633 - `func newDoorKeyMirror(s release.Store, pub ed25519.PublicKey) *doorKeyMirror`
634 - `func (d *doorKeyMirror) Refresh(ctx context.Context) error`
635 - `func (d *doorKeyMirror) run(stop <-chan struct{})`, with a 30 s ticker and a 10 s budget per refresh
636 - `func (d *doorKeyMirror) Record(account, app string) (doorkey.Record, bool)`, which is nil-safe and also refuses a record whose `ExpiresAt` has passed at read time.
637
638Mirror rules, each with its own test:
639- list succeeds: records absent from the list are dropped immediately;
640- list fails: the previous map is kept, but once 10 minutes have passed since the last successful list the whole map is cleared;
641- a GET fails for one key: its previous record is kept under the same 10-minute bound, measured from that record's last successful read;
642- verification fails (signature, shape, expiry), or the payload's account/app differ from the path's: that record is dropped immediately;
643- a lower revision than the one this process last admitted for that account/app: the newer record is kept while unexpired and the lower one is logged once. This is in memory only; the Security model states what a restart allows.
644
645- [ ] **Step 1: Failing tests** — publisher: owner on a home row, on an alias (the primary's), and on the served catchall; **no owner on a hostname whose path-prefix rows belong to two apps**. Fold: the owner through exact and wildcard lookup; a missing owner gives `ok == false`; a route replacing an alias carries the route's owner; `Owner`'s `eip`/`box` equal `Home`'s for the same host. Mirror: each rule above.
646- [ ] **Step 2: Run** `go test ./internal/host ./internal/edge -run 'Owner|DoorKey|EdgesFile|Fold' -count=1` → FAIL.
647- [ ] **Step 3: Implement.**
648- [ ] **Step 4: Run** both packages whole.
649- [ ] **Step 5: Commit** `edges files publish single owners; the edge mirrors signed door keys (#175 P7)`.
650
651---
652
653### Task 5: Enforcement inside `ServeHTTP`
654
655**Files:** Create `internal/edge/door.go`. Modify `internal/edge/edge.go` (`ServeHTTP`, `backhaul`) and `internal/edge/homemap.go` (`setForwarded`). Tests in `internal/edge/door_test.go`.
656
657**Interfaces:**
658- `type doorCallerKey struct{}`; `type doorCaller struct{ Account, App string }`; `func doorCallerFrom(ctx context.Context) (doorCaller, bool)`
659- `func (e *Edge) backhaulTo(w http.ResponseWriter, r *http.Request, host, eip, box string) bool`: the dial half of `backhaul`, split out so the door dials exactly the target `Owner` returned. `backhaul` becomes `Home` plus `backhaulTo`, and its behaviour is unchanged.
660- `func doorOwnsRoute(c doorCaller, rt registry.Route) bool` → `release.RouteAccount(rt) == c.Account && rt.App == c.App`
661- `func (e *Edge) doorReservedHost(host string) bool` — true for the tunnel hostname and the steering probe hostname, compared after lowercasing and stripping one trailing dot (the normalization `steeringProbe` and `canonicalizeHopHost` use)
662- `var doorAddressHeaders = []string{"X-Forwarded", "Forwarded-For", "Client-IP", "X-Real-IP", "True-Client-IP", "CF-Connecting-IP", "Fastly-Client-IP", "X-Client-IP", "X-Cluster-Client-IP", "X-Originating-IP"}`
663- `setForwarded` gains a first branch:
664
665```go
666func setForwarded(pr *httputil.ProxyRequest) {
667 pr.SetXForwarded()
668 // A door request is a request from this box (Security model): its
669 // RemoteAddr is already the box's public address, so SetXForwarded wrote
670 // the right chain. Only the scheme differs from the public route to the
671 // same URL, which would have arrived over TLS.
672 if _, ok := doorCallerFrom(pr.In.Context()); ok {
673 pr.Out.Header.Set("X-Forwarded-Proto", "https")
674 return
675 }
676 if !hopVerified(pr.In.Context()) {
677 return
678 }
679 if chain := pr.In.Header.Values("X-Forwarded-For"); len(chain) > 0 {
680 pr.Out.Header.Set("X-Forwarded-For", strings.Join(chain, ", "))
681 }
682}
683```
684
685Enforcement, in `ServeHTTP` order:
6861. **First statement after the port strip:** `door, isDoor := doorCallerFrom(r.Context())`. If `isDoor && e.doorReservedHost(host)`: `e.unknownHost(w, r); return`. If `isDoor`, skip the steering probe and tunnel branches entirely (wrap them in `if !isDoor { ... }`). The mark is only ever set by `serveDoor` on this box. Nothing carries it across a hop, and `acceptHop` is unchanged.
6872. **Miss branch:** if `isDoor`, call `acct, app, eip, box, ok := e.homes.Load().Owner(host)`. Unless `ok && acct == door.Account && app == door.App`, answer `unknownHost`. Otherwise `backhaulTo(w, r, host, eip, box)`. The receiving edge sees an ordinary verified hop, as it does for public backhaul today. It believes the forwarded chain, which is the box's public address, so the app there sees exactly what a local serve shows it (Security model).
6883. **After the final `up` is chosen**, that is, after the login POST re-resolution block (~`edge.go:1416-1446`) and before the gate (`:1516`), the cache and dispatch: `if isDoor && !doorOwnsRoute(door, up.route) { e.unknownHost(w, r); return }`. The same `up` value is then used for everything after it, so authorization and dispatch cannot diverge.
689
690- [ ] **Step 1: Failing tests** (an in-process `Edge` built the way existing `edge_test.go` tests build one with local routes and a stubbed home map; `grep -n "func newTestEdge\|func testEdge" internal/edge/*_test.go`):
691 1. door caller, local route of its app → served;
692 2. door caller, local route of another app → unknown host, upstream never called;
693 3. path-prefix host: `/a/` owned by the caller's app, `/b/` by another → `/a/x` served, `/b/x` unknown host;
694 4. gate login POST to `/.carlos/gate` with `next=/b/x` (plain and percent-encoded) on that host, caller owns `/a/` only → unknown host, and the gate's password check never runs (assert via a gate stub or by a correct password not yielding a cookie);
695 5. the tunnel hostname and the steering probe hostname (also upper-cased and with a trailing dot), with a wildcard route owned by the caller that would otherwise match → unknown host, and neither the hub nor the probe handler runs;
696 6. remote host whose published owner is the caller → `backhaulTo` is called with `Owner`'s own `eip` and `box`; published owner another app → unknown host; no published owner (a mixed hostname, or an older publisher) → unknown host;
697 7. a request carrying a `doorCallerKey` mark can only come from `serveDoor`: assert that a request arriving on the public handler with any `X-Carlos-*` header never has the mark.
698- [ ] **Step 2–4:** run (outside the sandbox), implement, run `go test ./internal/edge -count=1`.
699- [ ] **Step 5: Commit** `edge: enforce the door's scope on the final route and away from reserved handlers (#175 P7)`.
700
701---
702
703### Task 6: The door handler and listener
704
705**Files:** Modify `internal/edge/door.go`; tests in `internal/edge/door_test.go`.
706
707**Interfaces:**
708- `func (e *Edge) serveDoor(w http.ResponseWriter, r *http.Request)`
709- `func (e *Edge) openDoor(path string) (closeDoor func(), err error)`
710
711```go
712func (e *Edge) serveDoor(w http.ResponseWriter, r *http.Request) {
713 raw := r.Header.Get(doorkey.Header)
714 r.Header.Del(doorkey.Header)
715 // The mesh's vocabulary is never a door caller's to speak.
716 r.Header.Del(hopHeader)
717 r.Header.Del(hopAuthHeader)
718 tok, err := doorkey.ParseToken(raw)
719 if err != nil {
720 doorRefused(w)
721 return
722 }
723 rec, ok := e.doorKeys.Record(tok.Account, tok.App)
724 if !ok || !rec.Admits(tok) {
725 doorRefused(w)
726 return
727 }
728 // No visitor address is believed (Security model): every header that
729 // claims one goes, and the request comes from this box's own public
730 // address, exactly as it would through the public URL.
731 stripForwardedHeaders(r)
732 for _, h := range doorAddressHeaders {
733 r.Header.Del(h)
734 }
735 r.RemoteAddr = net.JoinHostPort(e.doorAddr, "0")
736 e.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), doorCallerKey{}, doorCaller{Account: tok.Account, App: tok.App})))
737}
738
739func doorRefused(w http.ResponseWriter) {
740 w.Header().Set("Cache-Control", "no-store")
741 http.Error(w, "door key refused", http.StatusUnauthorized)
742}
743```
744
745`openDoor(path)`: refuse if `e.doorKeys == nil` or `e.doorAddr` does not parse as a global unicast IP (`net.IP.IsGlobalUnicast` and not `IsPrivate`); `os.Remove(path)` a stale socket; `net.Listen("unix", path)`; `os.Chmod(path, 0o660)`; serve `&http.Server{Handler: http.HandlerFunc(e.serveDoor), ReadHeaderTimeout: 10 * time.Second}` in a goroutine; `closeDoor` shuts the server down with a 5 s context and removes the socket. Group comes from `/run/carlos`'s setgid `carlos-sockets` (bootstrap.go:61, 86-87); do not chgrp.
746
747- [ ] **Step 1: Failing tests:**
748 1. no key, malformed key, revoked key → 401 with `Cache-Control: no-store`, upstream never called;
749 2. valid key → served; the upstream sees no `X-Carlos-Door-Key` and sees `X-Tito-Auth-User` untouched;
750 3. inbound `X-Carlos-Hop`, `X-Carlos-Hop-Auth` → neither reaches the upstream, and a miss goes to the door's owner check, never `staleHopRefusal`;
751 4. forwarded headers: with the door's address set to `203.0.113.10`, a request carrying `X-Forwarded-For: 198.51.100.7`, `Forwarded: for=198.51.100.9` and `X-Forwarded-Proto: http` reaches the upstream with exactly `X-Forwarded-For: 203.0.113.10` and `X-Forwarded-Proto: https`, and no `Forwarded`. The same holds when none of them is sent. `clientIP` returns `203.0.113.10`, and so does the gate stub's `clientKey`;
752 4b. alternate address headers: a request carrying every name in `doorAddressHeaders` reaches the upstream with none of them;
753 4c. **resolver-rule tests in the upstream**, written in the test to two published rule sets, fed the upstream's headers and a loopback peer:
754 - Rails 8.0.2 `RemoteIp#calculate_ip`: drop non-IP XFF entries; strip trusted proxies (loopback, `fc00::/7`, 10/8, 172.16/12, 192.168/16); if nothing is left, prefer `Client-IP`, else the peer.
755 - python-ipware 3.0: consult its header list; prefer a global address over a non-global one; Python's `is_private` ranges.
756
757 A door request carrying every name in `doorAddressHeaders` set to `8.8.8.8` resolves to `203.0.113.10` under both, served locally and backhauled. It never resolves loopback, a private address or `8.8.8.8`;
758 5. a gated route still answers with the gate's page;
759 5b. a backhauled door request: using two in-process edges joined as the existing backhaul tests join them (`grep -n "func.*TwoEdges\|backhaul.*httptest" internal/edge/*_test.go`), the far upstream sees `X-Forwarded-For: 203.0.113.10` and `X-Forwarded-Proto: https`, the same as case 4, and the same again when the far edge cannot verify the hop;
760 6. `openDoor` on a temp path makes a 0660 socket; a real unix-socket client round-trips case 2; `closeDoor` removes the socket; `openDoor` with a nil mirror returns an error and creates nothing.
761- [ ] **Step 2–4:** run, implement, run.
762- [ ] **Step 5: Commit** `edge: the edge-local door (#175 P7)`.
763
764---
765
766### Task 7: Lifecycle: arm the door after the mirrors, close it on shutdown
767
768**Files:** Modify `internal/edge/edge.go` (a new `RunOption`; the home-map arming block ~line 2985; the shutdown block ~line 3110; the dev-mode branch that returns early), `internal/host/agent.go` (the options passed to `edge.RunWithActivatorReady`, ~line 510). Tests in `internal/edge/edge_test.go` (or `door_test.go`) and `internal/host/agent_test.go`.
769
770**Interfaces:**
771- `func WithEdgeDoor(path, publicAddr string) RunOption` — records the path, and the box's public address as `e.doorAddr`.
772- In the run path, immediately after `e.homes.Store(hm)` and its `go hm.run(...)`: when a door path is set and `e.staticStore` and `e.staticPub` are non-nil, build `e.doorKeys = newDoorKeyMirror(e.staticStore, e.staticPub)`, run one synchronous `Refresh` with a 10 s budget (log a failure, carry on: every key is refused until a refresh lands), start `go e.doorKeys.run(...)`, then `openDoor(path)` and keep `closeDoor` on the `Edge`. With no store, no public key, or no usable public address: log that the door is off and why. In dev mode: log that the door is off in dev mode.
773- Shutdown calls `closeDoor` before the HTTP servers' `Shutdown`.
774- agent: `if p := strings.TrimSpace(os.Getenv("CARLOS_EDGE_DOOR")); p != "" { opts = append(opts, edge.WithEdgeDoor(p, readPublicIP())) }`.
775
776- [ ] **Step 1: Failing tests:** extract the arming block into `func (e *Edge) armDoor(ctx context.Context) error` so it is testable: with a `DirStore` holding one signed record and a temp socket path, `armDoor` then a real unix-socket request with the key → served; before any record exists → 401 (not a panic); after `closeDoor` the socket is gone. Agent: with `CARLOS_EDGE_DOOR` unset no `WithEdgeDoor` option is passed (extract `edgeDoorOptions(getenv func(string) string, dataDir string) []edge.RunOption`).
777- [ ] **Step 2–4:** run, implement, run; then `GOOS=darwin go build ./... && GOOS=windows go build ./...`.
778- [ ] **Step 5: Commit** `edge: arm the door after its mirrors, close it on shutdown; agent: CARLOS_EDGE_DOOR (#175 P7)`.
779
780---
781
782### Task 8: Docs and copy review
783
784**Files:** Modify `docs/site/cli.md` (the `carlos instances` section: a `carlos instances door-key` entry, and a short passage on the edge-local door: what it is, `CARLOS_EDGE_DOOR`, the socket path, the `Host:` and `X-Carlos-Door-Key:` headers, enrolling the calling user with `carlos bootstrap --socket-consumer <user>`, the freshness contract in plain words).
785
786- [ ] **Step 1:** Draft into `copy-review/strings.json`: every CLI line Task 3 prints, every console error Task 2 returns, `door key refused`, and the docs prose.
787- [ ] **Step 2:** Run the copy-review server; apply the approved text verbatim.
788- [ ] **Step 3:** `go test ./...` outside the sandbox; cross-compile.
789- [ ] **Step 4: Commit** `docs: the edge-local door and door keys (#175 P7)`.
790
791---
792
793## As built: notes from Fable's final review (2026-09-29)
794
795No blockers. Three notes were fixed on the branch: the racing-renewal test counts Puts, so it pins the lock; `Verify` refuses a record signed more than `MaxClockSkew` (5 minutes) ahead; the socket is bound in a private directory and renamed into place. The rest are recorded here:
796
797- **Mint when publishing fails** revokes the fresh key and answers 502, where Task 2 said "saved but not yet published". The key has been shown to nobody, so keeping it would strand a slot. It costs two revision bumps. Revoke keeps Task 2's 502.
798- **The door's address is read once, at agent start.** The edges-file loop re-probes it every tick. That matches the agent's own assumption that an EIP cannot change under a running box (`edgesfile.go:531`). If a box's EIP is ever re-associated, restart its agent, or the door keeps presenting the old address.
799- **Runbook line:** if the console database *and* the bucket are both restored below a revision an edge has already admitted, that edge refuses the app's keys once its kept record expires (72 h at most), until the console climbs past the mark or the agent restarts.
800- **Reachability, not authority:** the door needs no certificate or DNS for the host, so a route whose certificate never issued is reachable through the door only. It refuses every path on the steering hostname, where the public branch intercepts only `GET /`. Both are less than or equal to the public door.
801
802## Rollout (for the handoff, not a task)
803
8041. Consoles first (they publish records nothing reads yet), then boxes (they publish owners, but open no socket).
8052. On carlos-tito-ie, with scoped approval: set `CARLOS_EDGE_DOOR=/run/carlos/edge-local.sock`, restart the agent, confirm the socket is `srw-rw---- carlos carlos-sockets`. Boxes that home Tito instances elsewhere need only the new binary to receive door traffic; they need no door of their own.
8063. Tito: mint a key (`carlos instances door-key create --app titogo`), deliver it to the router, relay and import-sync processes, enrol any of their users not already in `carlos-sockets`, and switch each from computed socket paths to the door. Only after that may P9 run on that box.
807