| 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 | |
| 31 | The 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 | |
| 115 | 1. **A door caller sending mesh vocabulary** (`X-Carlos-Hop`, `X-Carlos-Hop-Auth`) never gains hop trust; the door deletes both (Task 6 test). |
| 116 | 2. **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). |
| 117 | 3. **A gate login POST whose `next` names another app's prefix** is refused before the gate judges the password (Task 5 test). |
| 118 | 4. **A revoke racing a renewal** never leaves the older record in the bucket (Task 2 test). |
| 119 | 5. **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 | |
| 159 | Token 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 | |
| 161 | The 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 |
| 166 | package doorkey |
| 167 | |
| 168 | import ( |
| 169 | "crypto/ed25519" |
| 170 | "crypto/rand" |
| 171 | "encoding/base64" |
| 172 | "encoding/json" |
| 173 | "strings" |
| 174 | "testing" |
| 175 | "time" |
| 176 | ) |
| 177 | |
| 178 | func 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 | |
| 184 | func 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 | |
| 205 | func 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 | |
| 215 | func 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 | |
| 228 | func 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. |
| 250 | func 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 | |
| 271 | func 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. |
| 296 | package doorkey |
| 297 | |
| 298 | import ( |
| 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 | |
| 315 | const ( |
| 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 | |
| 324 | var ( |
| 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 | |
| 330 | func RecordKey(account, app string) string { return Prefix + account + "/" + app + ".json" } |
| 331 | |
| 332 | type Token struct{ Account, App, ID, Secret string } |
| 333 | |
| 334 | type 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 | |
| 341 | type 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 | |
| 351 | type 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. |
| 357 | func Digest(secret string) string { |
| 358 | sum := sha256.Sum256([]byte(secret)) |
| 359 | return hex.EncodeToString(sum[:]) |
| 360 | } |
| 361 | |
| 362 | func 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 | |
| 380 | func 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. |
| 392 | func (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 | |
| 405 | func (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 | |
| 421 | func 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 | |
| 437 | func 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 | |
| 490 | 1. Take the app's publication mutex; hold it to the end. |
| 491 | 2. `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). |
| 492 | 3. `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. |
| 493 | 4. Build `doorkey.Record{Revision: rev, Keys: keys, SignedAt: now, ExpiresAt: now + doorkey.Lifetime}`, sign it, and `Put(ctx, key, body, etag)`. |
| 494 | 5. 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 | |
| 496 | Mint 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 | |
| 500 | A 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 |
| 505 | func doorKeysURL(fx *consoleFixture) string { return fx.ts.URL + "/api/cli/apps/bab/titogo/door-keys" } |
| 506 | |
| 507 | func 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 | |
| 520 | func 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. |
| 551 | func 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 | |
| 569 | func 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 | |
| 571 | func TestDoorKeysRefuseANinthKey(t *testing.T) { /* eight mints 201, the ninth 409, the record still has eight */ } |
| 572 | |
| 573 | func 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. |
| 581 | func 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. |
| 585 | func 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. |
| 589 | func 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 | |
| 638 | Mirror 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 |
| 666 | func 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 | |
| 685 | Enforcement, in `ServeHTTP` order: |
| 686 | 1. **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. |
| 687 | 2. **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). |
| 688 | 3. **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 |
| 712 | func (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 | |
| 739 | func 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 | |
| 795 | No 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 | |
| 804 | 1. Consoles first (they publish records nothing reads yet), then boxes (they publish owners, but open no socket). |
| 805 | 2. 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. |
| 806 | 3. 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 | |