| index f3cd0aba4..113ea1941 100644 |
| --- a/docs/superpowers/plans/2026-09-28-instance-moves-p7-edge-door.md |
| +++ b/docs/superpowers/plans/2026-09-28-instance-moves-p7-edge-door.md |
| @@ -1,35 +1,60 @@ |
| -# Instance moves P7: the edge-local door — Implementation Plan |
| +# Instance moves P7: the edge-local door — Implementation Plan (rev 2) |
| |
| > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| |
| **Goal:** 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. |
| |
| -**Architecture:** A signed-in person mints a door key for an app at the console. The console stores only the key's SHA-256, in a record under `control/door-keys/<account>/<app>.json` that it signs with the deployment's root release key. Every edge mirrors those records on its existing 30 s home-map poll and verifies them against `CARLOS_RELEASE_PUBKEY`, so a bucket writer (discussion #171) cannot mint a key. A second listener on `/run/carlos/edge-local.sock` (mode 0660, group `carlos-sockets` by the directory's setgid) authenticates the `X-Carlos-Door-Key` header, strips it, checks that the requested host belongs to the key's account and app, marks the request as a door caller, and hands it to the ordinary `Edge.ServeHTTP`. Local hosts are served (and woken) as for public traffic. Remote hosts backhaul exactly as public traffic does. Remote ownership comes from new `account`/`app` fields each box publishes in its edges file. |
| +**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 monotonic revision and a 72-hour expiry, and re-signed every 12 hours. Every edge mirrors the projections on their own 30-second loop, verifies them against `CARLOS_RELEASE_PUBKEY`, and keeps a persisted revision high-water mark, so neither a forged nor a replayed record admits a key. 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, normalizes X-Forwarded-For, marks the request as a door caller and hands it to `Edge.ServeHTTP`. `ServeHTTP` refuses reserved handlers to door callers and checks ownership against the final upstream it is about to serve, after the login re-resolution. A host served elsewhere is backhauled only to a box that advertises door support, with the door scope bound into the hop MAC, and the receiving edge enforces the same rule on its own final route. |
| |
| -**Tech Stack:** Go 1.x standard library (`net/http`, `crypto/ed25519`, `crypto/sha256`, `crypto/subtle`), the repo's `release.Store`, the console's `cliRoute`/`svcrole`, the CLI's `apiSession`. |
| +**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`. |
| |
| -**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 Astra's rulings file `CODEX-RULINGS-INSTANCE-MOVES.md` beside it. |
| +**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. |
| + |
| +## Revision 2: Astra round 1 dispositions |
| + |
| +| # | finding | disposition | |
| +|---|---|---| |
| +| 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). | |
| +| 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). | |
| +| 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). | |
| +| 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). | |
| +| 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). | |
| +| 6 | malformed XFF stays authoritative | **Accepted:** the door replaces X-Forwarded-For with exactly one validated address before `ServeHTTP` (Task 6). | |
| +| 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). | |
| +| 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). | |
| +| 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). | |
| +| 10 | root signing acceptable; add a domain prefix | **Accepted:** signatures cover `"carlos door-keys v1\n" + payload`; cross-kind rejection tests (Task 1). | |
| |
| ## Global Constraints |
| |
| -- **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 stay as a second check, not the identity. |
| +- **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. |
| - **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. |
| -- **§8:** the door is not a bypass: the password gate, the edge's XFF rules and (from P1) the hook-path refusal apply as on the public door. Headers the app sets pass through untouched. |
| -- **R2 (ruled, lands in P1):** hook paths are refused on the local door too. P7 adds no hook paths; P1 must add its refusal where this plan's `serveDoor` calls `ServeHTTP`. |
| -- **Q5 (ruled):** move mutations for other accounts are operator-only until #171 is fixed. The door moves nothing, so it is not gated by Q5, but the key record must not be forgeable by a bucket writer (signed, below). |
| -- **Dormant by default:** no socket is created unless `CARLOS_EDGE_DOOR` names a path. Rolling the binary changes nothing on a box until that is set. |
| -- **Every `carlos` command completes its job without box access** (AGENTS.md, 2026-08-08): minting, listing and revoking keys are console API plus CLI. |
| -- **Copy:** every new user-facing string (CLI output, API errors, docs) passes copy review before merge. No em dashes; a full stop and a new sentence, lower case, in Go error strings. |
| -- **Cross-compile:** CI builds darwin and windows (`GOOS=darwin go build ./... && GOOS=windows go build ./...`); anything Linux-only needs `//go:build linux` and a `!linux` stub. |
| +- **§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. |
| +- **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. |
| +- **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). |
| +- **Dormant by default:** no socket unless `CARLOS_EDGE_DOOR` names a path. |
| +- **Every `carlos` command completes its job without box access** (AGENTS.md): mint, list and revoke are console API plus CLI. |
| +- **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. |
| +- **Cross-compile:** `GOOS=darwin go build ./... && GOOS=windows go build ./...` must pass. |
| - **Tests with unix sockets** run outside the Claude sandbox with `TMPDIR=/tmp/g GOCACHE=/tmp/g/gocache GOFLAGS=-buildvcs=false`. |
| |
| +## Freshness contract (the numbers) |
| + |
| +| event | effect at a box | |
| +|---|---| |
| +| key minted | admitted within one mirror interval (30 s) after the serial bump | |
| +| key revoked, bucket healthy | refused within 30 s | |
| +| key revoked, bucket list failing | refused at the latest 10 minutes after the last successful list, when the whole map is cleared | |
| +| old record replayed into the bucket | refused on any box that has seen a newer revision (persisted high-water mark); a box that never saw the newer revision accepts it until the replayed record's own `expires_at`, at most 72 h after it was signed | |
| +| console down | records expire 72 h after their last re-sign; the door refuses every key of an expired record | |
| + |
| ## Review Focus |
| |
| -1. **A door caller sending the mesh's hop headers** (`X-Carlos-Hop`, `X-Carlos-Hop-Auth`) must never gain hop trust; the door deletes both before `ServeHTTP`. Test in Task 6. |
| -2. **A host on a path-prefix hostname** whose prefixes belong to different apps: ownership is checked on the route `lookupUpstreamPath(host, path)` resolves, the same resolution `ServeHTTP` uses, so app A's key cannot reach app B's prefix. Test in Task 6. |
| -3. **A remote host published by a box that predates the owner fields** has no owner in the fold; the door answers unknown host for it (fail closed) rather than backhauling. Test in Task 5. |
| -4. **A revoked key** stops working within one home-map poll (30 s); a record that fails signature verification is dropped, never kept as last-good. Test in Task 4. |
| -5. **An X-Forwarded-For that is absent, empty or not an IP**: the door's client address falls back to `127.0.0.1`, and a garbage chain never reaches the gate's rate-limit key or analytics. Test in Task 6. |
| +1. **A door caller sending mesh vocabulary** (`X-Carlos-Hop`, `X-Carlos-Hop-Auth`, `X-Carlos-Hop-Door`) never gains hop trust or a different scope; the door deletes all three (Task 6 test). |
| +2. **A path-prefix hostname whose prefixes belong to different apps**, locally and behind a backhaul: app A's key never reaches app B's prefix (Tasks 5 and 6 tests). |
| +3. **A gate login POST whose `next` names another app's prefix** is refused before the gate judges the password (Task 5 test). |
| +4. **A replayed older record** after a revocation, before and after a verifier restart (Task 4 test). |
| +5. **An X-Forwarded-For that is absent, empty, multi-line or not an IP** leaves exactly one valid address in the header the app and analytics see (Task 6 test). |
| |
| --- |
| |
| @@ -37,45 +62,43 @@ |
| |
| | File | Responsibility | |
| |---|---| |
| -| `internal/doorkey/doorkey.go` (new) | Token format, record schema, record key, sign/verify, constant-time admit. No I/O. | |
| -| `internal/doorkey/doorkey_test.go` (new) | Unit tests for the above. | |
| -| `internal/console/apidoorkeys.go` (new) | Console doors: create, list, revoke. | |
| -| `internal/console/apidoorkeys_test.go` (new) | Door tests. | |
| -| `internal/console/authcli.go` (modify, near line 637) | Route registration, person-only. | |
| +| `internal/doorkey/doorkey.go` (new) | Token format, record schema, key path, domain-separated sign/verify, admit. No I/O. | |
| +| `internal/console/doorkeys_store.go` (new) | `door_keys` table: insert, list, revoke, per-app revision counter. | |
| +| `internal/console/apidoorkeys.go` (new) | Mint, list, revoke doors; `publishDoorKeys`; the 12 h re-sign loop. | |
| +| `internal/console/authcli.go` (modify, beside `instance-token`, ~line 637) | Routes, person-only. | |
| | `doorkeys_cli.go` (new, package main) | `carlos instances door-key create|list|revoke`. | |
| -| `instances_cli.go` (modify) | Dispatch `door-key` from `runInstances`. | |
| -| `internal/host/edgesfile.go` (modify, `edgeHost`) | Publish `account` and `app` per host. | |
| -| `internal/edge/homemap.go` (modify) | Fold the owner; mirror and verify door-key records; `Owner(host)`; `doorKey(acct, app)`. | |
| -| `internal/edge/door.go` (new) | Door context mark, `serveDoor`, `ServeDoor(path)` listener. | |
| -| `internal/edge/door_test.go` (new) | Door behaviour tests. | |
| -| `internal/edge/homemap.go` `setForwarded`, `internal/edge/analytics.go` `clientIP` (modify) | Trust the door's chain the way a verified hop's is trusted. | |
| -| `internal/host/agent.go` (modify, the `RunWithActivatorReady` ready hook ~line 510) | Start the door when `CARLOS_EDGE_DOOR` is set. | |
| -| `docs/site/instances.md` or the CLI reference (modify) | Document the door and the key verbs (copy review). | |
| +| `cli.go` (modify, `runInstances` at ~line 284) | Dispatch `door-key`. | |
| +| `internal/host/edgesfile.go` (modify: `edgeHost`, the route fill, the catchall publisher ~line 363, top-level `Door`) | Publish per-host `account`/`app` and the box's `door: true`. | |
| +| `internal/edge/homemap.go` (modify) | Fold owner and door support with the winning entry; `Owner`; `acceptHop` door scope; `setForwarded` trust. | |
| +| `internal/edge/mesh.go` (modify) | `signHopDoor`/`verifyHopDoorEitherKey`: the MAC with the scope bound in. | |
| +| `internal/edge/doorkeys.go` (new) | The key mirror: its own loop, freshness contract, high-water state file. | |
| +| `internal/edge/door.go` (new) | Door mark, `serveDoor`, `doorOwnsRoute`, reserved-name refusal, the listener. | |
| +| `internal/edge/edge.go` (modify: `ServeHTTP`, `backhaul`/`backhaulProxy`, `RunWithActivatorReady`, shutdown) | Enforcement points and lifecycle. | |
| +| `internal/edge/analytics.go` (modify `clientIP`) | Trust the door's normalized address. | |
| +| `internal/host/agent.go` (modify: options passed to `RunWithActivatorReady`) | `edge.WithEdgeDoor` from `CARLOS_EDGE_DOOR`. | |
| +| `docs/site/cli.md` (modify) | The door and the key verbs. | |
| |
| --- |
| |
| ### Task 1: The door-key package |
| |
| -**Files:** |
| -- Create: `internal/doorkey/doorkey.go` |
| -- Test: `internal/doorkey/doorkey_test.go` |
| +**Files:** Create `internal/doorkey/doorkey.go`, `internal/doorkey/doorkey_test.go`. |
| |
| -**Interfaces:** |
| -- Produces: |
| - - `const Header = "X-Carlos-Door-Key"` |
| - - `func RecordKey(account, app string) string` → `"control/door-keys/" + account + "/" + app + ".json"` |
| - - `const Prefix = "control/door-keys/"` |
| - - `type Token struct{ Account, App, ID, Secret string }` |
| - - `func NewToken(account, app string) (raw string, entry Entry, err error)` |
| - - `func ParseToken(raw string) (Token, error)` |
| - - `type Entry struct{ ID string; SHA256 string; Label string; CreatedAt time.Time }` (JSON: `id`, `sha256`, `label`, `created_at`) |
| - - `type Record struct{ V int; Account, App string; Keys []Entry; UpdatedAt time.Time }` (JSON: `v`, `account`, `app`, `keys`, `updated_at`) |
| - - `const MaxKeys = 8` |
| - - `func Sign(priv ed25519.PrivateKey, rec Record) ([]byte, error)` |
| - - `func Verify(pub ed25519.PublicKey, wire []byte) (Record, error)` |
| - - `func (r Record) Admits(t Token) bool` |
| - |
| -Token wire form: `cdk1.<account>.<app>.<id>.<secret>`. `account` matches `release.ValidAccount`; `app` matches `^[a-z][a-z0-9-]{0,31}$`; `id` is 16 lowercase hex; `secret` is 43 characters of unpadded base64url (32 random bytes). The dots cannot appear in any field, so `strings.Split` into exactly five parts is unambiguous. |
| +**Interfaces (produced):** |
| +- `const Header = "X-Carlos-Door-Key"`, `const Prefix = "control/door-keys/"`, `const MaxKeys = 8`, `const Lifetime = 72 * time.Hour` |
| +- `func RecordKey(account, app string) string` → `Prefix + account + "/" + app + ".json"` |
| +- `type Token struct{ Account, App, ID, Secret string }` |
| +- `func NewToken(account, app string) (raw string, entry Entry, err error)` |
| +- `func ParseToken(raw string) (Token, error)` |
| +- `type Entry struct{ ID, SHA256, Label string; CreatedAt time.Time }` (JSON `id`, `sha256`, `label`, `created_at`) |
| +- `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`) |
| +- `func Sign(priv ed25519.PrivateKey, rec Record) ([]byte, error)` |
| +- `func Verify(pub ed25519.PublicKey, wire []byte, now time.Time) (Record, error)` — signature, shape, and `now` before `ExpiresAt` |
| +- `func (r Record) Admits(t Token) bool` |
| + |
| +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. |
| + |
| +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. |
| |
| - [ ] **Step 1: Write the failing tests** |
| |
| @@ -85,11 +108,19 @@ package doorkey |
| import ( |
| "crypto/ed25519" |
| "crypto/rand" |
| + "encoding/base64" |
| + "encoding/json" |
| "strings" |
| "testing" |
| "time" |
| ) |
| |
| +func rec1(entry Entry) Record { |
| + now := time.Now().UTC() |
| + return Record{V: 1, Account: "bae", App: "titogo", Revision: 1, Keys: []Entry{entry}, |
| + SignedAt: now, ExpiresAt: now.Add(Lifetime)} |
| +} |
| + |
| func TestTokenRoundTripAndAdmit(t *testing.T) { |
| raw, entry, err := NewToken("bae", "titogo") |
| if err != nil { |
| @@ -99,18 +130,14 @@ func TestTokenRoundTripAndAdmit(t *testing.T) { |
| t.Fatalf("token %q lacks its prefix", raw) |
| } |
| tok, err := ParseToken(raw) |
| - if err != nil { |
| - t.Fatal(err) |
| - } |
| - if tok.Account != "bae" || tok.App != "titogo" || tok.ID != entry.ID { |
| - t.Fatalf("parsed %+v, entry %+v", tok, entry) |
| + if err != nil || tok.ID != entry.ID { |
| + t.Fatalf("parse %q: %+v %v", raw, tok, err) |
| } |
| - rec := Record{V: 1, Account: "bae", App: "titogo", Keys: []Entry{entry}} |
| - if !rec.Admits(tok) { |
| + if !rec1(entry).Admits(tok) { |
| t.Fatal("the record does not admit its own key") |
| } |
| tok.Secret = strings.Repeat("A", 43) |
| - if rec.Admits(tok) { |
| + if rec1(entry).Admits(tok) { |
| t.Fatal("a wrong secret was admitted") |
| } |
| } |
| @@ -118,19 +145,19 @@ func TestTokenRoundTripAndAdmit(t *testing.T) { |
| func TestRecordAdmitsOnlyItsOwnAccountAndApp(t *testing.T) { |
| raw, entry, _ := NewToken("bae", "titogo") |
| tok, _ := ParseToken(raw) |
| - other := Record{V: 1, Account: "bae", App: "other", Keys: []Entry{entry}} |
| + other := rec1(entry) |
| + other.App = "other" |
| if other.Admits(tok) { |
| t.Fatal("a record for another app admitted the key") |
| } |
| } |
| |
| func TestParseTokenRefusesMalformed(t *testing.T) { |
| + good := "0123456789abcdef." + strings.Repeat("A", 43) |
| for _, raw := range []string{ |
| - "", "cdk1", "cdk2.bae.titogo.0123456789abcdef." + strings.Repeat("A", 43), |
| - "cdk1.BAE.titogo.0123456789abcdef." + strings.Repeat("A", 43), |
| + "", "cdk1", "cdk2.bae.titogo." + good, "cdk1.BAE.titogo." + good, |
| "cdk1.bae.titogo.0123456789ABCDEF." + strings.Repeat("A", 43), |
| - "cdk1.bae.titogo.0123456789abcdef.short", |
| - "cdk1.bae.titogo.0123456789abcdef." + strings.Repeat("A", 43) + ".extra", |
| + "cdk1.bae.titogo.0123456789abcdef.short", "cdk1.bae.titogo." + good + ".extra", |
| } { |
| if _, err := ParseToken(raw); err == nil { |
| t.Errorf("ParseToken(%q) accepted a malformed token", raw) |
| @@ -138,54 +165,74 @@ func TestParseTokenRefusesMalformed(t *testing.T) { |
| } |
| } |
| |
| -func TestSignVerify(t *testing.T) { |
| +func TestSignVerifyExpiryAndKeys(t *testing.T) { |
| pub, priv, _ := ed25519.GenerateKey(rand.Reader) |
| _, entry, _ := NewToken("bae", "titogo") |
| - rec := Record{V: 1, Account: "bae", App: "titogo", Keys: []Entry{entry}, UpdatedAt: time.Now().UTC()} |
| - wire, err := Sign(priv, rec) |
| + wire, err := Sign(priv, rec1(entry)) |
| if err != nil { |
| t.Fatal(err) |
| } |
| - got, err := Verify(pub, wire) |
| - if err != nil || len(got.Keys) != 1 || got.Keys[0].ID != entry.ID { |
| + got, err := Verify(pub, wire, time.Now()) |
| + if err != nil || got.Revision != 1 || got.Keys[0].ID != entry.ID { |
| t.Fatalf("Verify = %+v, %v", got, err) |
| } |
| + if _, err := Verify(pub, wire, time.Now().Add(Lifetime+time.Minute)); err == nil { |
| + t.Fatal("an expired record verified") |
| + } |
| otherPub, _, _ := ed25519.GenerateKey(rand.Reader) |
| - if _, err := Verify(otherPub, wire); err == nil { |
| + if _, err := Verify(otherPub, wire, time.Now()); err == nil { |
| t.Fatal("a record signed by another key verified") |
| } |
| - tampered := []byte(strings.Replace(string(wire), `"sig": "`, `"sig": "A`, 1)) |
| - if _, err := Verify(pub, tampered); err == nil { |
| - t.Fatal("a tampered signature verified") |
| +} |
| + |
| +// The domain prefix: a wrapper signed over the bare payload (every other |
| +// root-signed record's shape) must not verify as a door-key record. |
| +func TestVerifyRefusesABareSignedWrapper(t *testing.T) { |
| + pub, priv, _ := ed25519.GenerateKey(rand.Reader) |
| + _, entry, _ := NewToken("bae", "titogo") |
| + payload, _ := json.Marshal(rec1(entry)) |
| + b64 := base64.StdEncoding.EncodeToString(payload) |
| + wire, _ := json.Marshal(map[string]string{"payload": b64, |
| + "sig": base64.StdEncoding.EncodeToString(ed25519.Sign(priv, []byte(b64)))}) |
| + if _, err := Verify(pub, wire, time.Now()); err == nil { |
| + t.Fatal("a bare-signed wrapper verified as a door-key record") |
| + } |
| + // And the other direction: a door-key record's signature does not |
| + // verify over its bare payload. |
| + door, _ := Sign(priv, rec1(entry)) |
| + var w struct{ Payload, Sig string } |
| + json.Unmarshal(door, &w) |
| + sig, _ := base64.StdEncoding.DecodeString(w.Sig) |
| + if ed25519.Verify(pub, []byte(w.Payload), sig) { |
| + t.Fatal("a door-key signature verifies over the bare payload") |
| } |
| } |
| |
| func TestSignRefusesTooManyKeys(t *testing.T) { |
| _, priv, _ := ed25519.GenerateKey(rand.Reader) |
| - rec := Record{V: 1, Account: "bae", App: "titogo"} |
| - for i := 0; i <= MaxKeys; i++ { |
| + _, e, _ := NewToken("bae", "titogo") |
| + r := rec1(e) |
| + for i := 0; i < MaxKeys; i++ { |
| _, e, _ := NewToken("bae", "titogo") |
| - rec.Keys = append(rec.Keys, e) |
| + r.Keys = append(r.Keys, e) |
| } |
| - if _, err := Sign(priv, rec); err == nil { |
| + if _, err := Sign(priv, r); err == nil { |
| t.Fatal("signed a record over MaxKeys") |
| } |
| } |
| ``` |
| |
| -- [ ] **Step 2: Run to verify they fail** |
| - |
| -Run: `go test ./internal/doorkey/` |
| -Expected: FAIL (package has no non-test files). |
| +- [ ] **Step 2: Run** `go test ./internal/doorkey/` → FAIL (no package). |
| |
| - [ ] **Step 3: Implement** |
| |
| ```go |
| // Package doorkey is the credential for the edge-local door (instance-moves |
| -// design §8, ruling Q3): an app-scoped key that a box verifies offline. The |
| -// console mints it and keeps only its SHA-256, in a record it signs with the |
| -// deployment's root release key, so a bucket writer (discussion #171) can |
| -// read the record but cannot add a key to it. |
| +// design §8, ruling Q3): an app-scoped key a box verifies offline. The |
| +// console's database is the authority; the bucket holds a signed, expiring, |
| +// revisioned projection of it, so a bucket writer (discussion #171) can |
| +// neither add a key nor bring a revoked one back for longer than the |
| +// projection's own lifetime on a box that never saw the revocation. |
| package doorkey |
| |
| import ( |
| @@ -206,10 +253,12 @@ import ( |
| ) |
| |
| const ( |
| - Header = "X-Carlos-Door-Key" |
| - Prefix = "control/door-keys/" |
| - MaxKeys = 8 |
| - scheme = "cdk1" |
| + Header = "X-Carlos-Door-Key" |
| + Prefix = "control/door-keys/" |
| + MaxKeys = 8 |
| + Lifetime = 72 * time.Hour |
| + scheme = "cdk1" |
| + domain = "carlos door-keys v1\n" |
| ) |
| |
| var ( |
| @@ -233,8 +282,10 @@ type Record struct { |
| V int `json:"v"` |
| Account string `json:"account"` |
| App string `json:"app"` |
| + Revision uint64 `json:"revision"` |
| Keys []Entry `json:"keys"` |
| - UpdatedAt time.Time `json:"updated_at"` |
| + SignedAt time.Time `json:"signed_at"` |
| + ExpiresAt time.Time `json:"expires_at"` |
| } |
| |
| type signedWrapper struct { |
| @@ -242,7 +293,8 @@ type signedWrapper struct { |
| Sig string `json:"sig"` |
| } |
| |
| -func digest(secret string) string { |
| +// Digest is the stored form of a key's secret. |
| +func Digest(secret string) string { |
| sum := sha256.Sum256([]byte(secret)) |
| return hex.EncodeToString(sum[:]) |
| } |
| @@ -262,7 +314,7 @@ func NewToken(account, app string) (string, Entry, error) { |
| t := Token{Account: account, App: app, ID: hex.EncodeToString(id[:]), |
| Secret: base64.RawURLEncoding.EncodeToString(secret[:])} |
| raw := strings.Join([]string{scheme, t.Account, t.App, t.ID, t.Secret}, ".") |
| - return raw, Entry{ID: t.ID, SHA256: digest(t.Secret), CreatedAt: time.Now().UTC()}, nil |
| + return raw, Entry{ID: t.ID, SHA256: Digest(t.Secret), CreatedAt: time.Now().UTC()}, nil |
| } |
| |
| func ParseToken(raw string) (Token, error) { |
| @@ -274,14 +326,14 @@ func ParseToken(raw string) (Token, error) { |
| return Token{Account: f[1], App: f[2], ID: f[3], Secret: f[4]}, nil |
| } |
| |
| -// Admits answers whether t is one of this record's keys. The account and app |
| -// must match the record's own, so a key can never be looked up under |
| -// another app's record and pass. |
| +// Admits answers whether t is one of this record's keys. The record's own |
| +// account and app must be the token's, so a key can never pass against a |
| +// record filed for another app. |
| func (r Record) Admits(t Token) bool { |
| if r.Account != t.Account || r.App != t.App { |
| return false |
| } |
| - want := digest(t.Secret) |
| + want := Digest(t.Secret) |
| for _, e := range r.Keys { |
| if e.ID == t.ID && subtle.ConstantTimeCompare([]byte(e.SHA256), []byte(want)) == 1 { |
| return true |
| @@ -291,7 +343,8 @@ func (r Record) Admits(t Token) bool { |
| } |
| |
| func (r Record) valid() error { |
| - if r.V != 1 || !release.ValidAccount(r.Account) || !appRe.MatchString(r.App) { |
| + if r.V != 1 || !release.ValidAccount(r.Account) || !appRe.MatchString(r.App) || r.Revision == 0 || |
| + r.ExpiresAt.IsZero() || !r.ExpiresAt.After(r.SignedAt) || r.ExpiresAt.Sub(r.SignedAt) > Lifetime { |
| return errors.New("doorkey: record shape") |
| } |
| if len(r.Keys) > MaxKeys { |
| @@ -318,10 +371,10 @@ func Sign(priv ed25519.PrivateKey, rec Record) ([]byte, error) { |
| } |
| b64 := base64.StdEncoding.EncodeToString(payload) |
| return json.MarshalIndent(signedWrapper{Payload: b64, |
| - Sig: base64.StdEncoding.EncodeToString(ed25519.Sign(priv, []byte(b64)))}, "", " ") |
| + Sig: base64.StdEncoding.EncodeToString(ed25519.Sign(priv, []byte(domain+b64)))}, "", " ") |
| } |
| |
| -func Verify(pub ed25519.PublicKey, wire []byte) (Record, error) { |
| +func Verify(pub ed25519.PublicKey, wire []byte, now time.Time) (Record, error) { |
| if len(pub) != ed25519.PublicKeySize { |
| return Record{}, errors.New("doorkey: no public key") |
| } |
| @@ -330,7 +383,7 @@ func Verify(pub ed25519.PublicKey, wire []byte) (Record, error) { |
| return Record{}, err |
| } |
| sig, err := base64.StdEncoding.DecodeString(w.Sig) |
| - if err != nil || len(sig) != ed25519.SignatureSize || !ed25519.Verify(pub, []byte(w.Payload), sig) { |
| + if err != nil || len(sig) != ed25519.SignatureSize || !ed25519.Verify(pub, []byte(domain+w.Payload), sig) { |
| return Record{}, errors.New("doorkey: bad signature") |
| } |
| payload, err := base64.StdEncoding.DecodeString(w.Payload) |
| @@ -341,273 +394,243 @@ func Verify(pub ed25519.PublicKey, wire []byte) (Record, error) { |
| if err := json.Unmarshal(payload, &rec); err != nil { |
| return Record{}, err |
| } |
| - return rec, rec.valid() |
| + if err := rec.valid(); err != nil { |
| + return Record{}, err |
| + } |
| + if !now.Before(rec.ExpiresAt) { |
| + return Record{}, errors.New("doorkey: record expired") |
| + } |
| + return rec, nil |
| } |
| ``` |
| |
| -- [ ] **Step 4: Run to verify they pass** |
| - |
| -Run: `go test ./internal/doorkey/ -count=1` |
| -Expected: PASS. |
| - |
| -- [ ] **Step 5: Commit** |
| - |
| -```bash |
| -git add internal/doorkey |
| -git commit -m "doorkey: the edge-local door's app-scoped, root-signed credential (#175 P7)" |
| -``` |
| +- [ ] **Step 4: Run** `go test ./internal/doorkey/ -count=1` → PASS. |
| +- [ ] **Step 5: Commit** `doorkey: the edge-local door's app-scoped, root-signed, expiring credential (#175 P7)`. |
| |
| --- |
| |
| -### Task 2: Console doors to mint, list and revoke |
| +### Task 2: Console authority, doors and re-sign loop |
| |
| -**Files:** |
| -- Create: `internal/console/apidoorkeys.go`, `internal/console/apidoorkeys_test.go` |
| -- Modify: `internal/console/authcli.go` (register beside the `instance-token` route at ~line 637) |
| +**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). |
| |
| **Interfaces:** |
| -- Consumes: `doorkey.NewToken`, `doorkey.Sign`, `doorkey.Verify`, `doorkey.RecordKey`, `doorkey.MaxKeys`; `s.cfg.Key` (the root `ed25519.PrivateKey`; nil on a read-only console); `s.cfg.Releases` (`release.Store`); `s.apiPerson`, `s.apiAccountApp`, `s.allowWrite`, `cleanLabel`, `s.audit`, `writeJSON`, `jsonError`, `readJSON` (all existing, as used by `handleAPIInstanceToken` in `apiinstances.go:1554`). |
| -- Produces (wire): |
| - - `POST /api/cli/apps/{account}/{app}/door-keys` body `{"label": "..."}` → 201 `{"key": "<raw>", "id": "...", "account": "...", "app": "..."}` |
| - - `GET /api/cli/apps/{account}/{app}/door-keys` → 200 `{"keys": [{"id","label","created_at"}]}` (never the hash) |
| - - `POST /api/cli/apps/{account}/{app}/door-keys/{id}/revoke` → 200 `{"revoked": "<id>"}`; 404 for an unknown id. |
| +- 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). |
| +- Produces: |
| + - 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))` |
| + - `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 |
| + - `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 |
| + - `func (st *Store) DoorKeysLive(accountID int64, app string) (keys []doorkey.Entry, revision uint64, err error)` |
| + - `func (st *Store) DoorKeyApps() ([]struct{ AccountID int64; Sqid, App string }, error)` — every app with a revision row |
| + - `func (s *Server) publishDoorKeys(ctx context.Context, acct *Account, app string) error` — builds the record from `DoorKeysLive` (never from the bucket), `SignedAt = now`, `ExpiresAt = now + doorkey.Lifetime`, signs, and `Put`s unconditionally (no etag: the database is the authority, and overwriting a tampered object is the point) |
| + - `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` |
| + - 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. |
| |
| -All three are person-only (`svcrole.Set{}` plus `s.apiPerson`), the `instance-token` rule: a door key reaches every instance of the app. A console with no signing key answers 503 with the existing read-only sentence family. Writes are a read-modify-CAS on the record's etag with one retry, like `SetRecordMove`'s `updateRecord`. |
| +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. |
| |
| -- [ ] **Step 1: Write the failing tests** — mint returns a parseable key whose record verifies against the fixture's public key and admits it; list shows the id and label but no `sha256`; revoke removes it and the re-read record no longer admits it; a ninth key is refused 409; an API key (service principal of any role, including Admin) is refused; a console fixture with no signing key answers 503 and writes nothing. Use the fixture helpers `instanceFixture`/`rawReq` from `apiinstancemove_test.go`, and read the fixture's public key the way the pipeline tests do (`grep -n "cfg.Key" internal/console/*_test.go` for the existing pattern). |
| +- [ ] **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`): |
| |
| ```go |
| +func doorKeysURL(fx *consoleFixture) string { return fx.ts.URL + "/api/cli/apps/bab/titogo/door-keys" } |
| + |
| +func readDoorRecord(t *testing.T, fx *consoleFixture) doorkey.Record { |
| + t.Helper() |
| + wire, _, err := fx.rel.Get(context.Background(), doorkey.RecordKey("bab", "titogo")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + rec, err := doorkey.Verify(fx.pub, wire, time.Now()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return rec |
| +} |
| + |
| func TestDoorKeysMintListRevoke(t *testing.T) { |
| fx, token := instanceFixture(t, "code-door-keys") |
| - u := fx.srv.URL + "/api/cli/apps/bab/titogo/door-keys" |
| - code, _, out := rawReq(t, http.MethodPost, u, token, `{"label":"router"}`) |
| + code, _, out := rawReq(t, http.MethodPost, doorKeysURL(fx), token, `{"label":"router"}`) |
| if code != http.StatusCreated { |
| t.Fatalf("mint = %d %s", code, out) |
| } |
| var minted struct{ Key, ID string } |
| - if err := json.Unmarshal([]byte(out), &minted); err != nil { |
| - t.Fatal(err) |
| - } |
| + json.Unmarshal([]byte(out), &minted) |
| tok, err := doorkey.ParseToken(minted.Key) |
| if err != nil || tok.ID != minted.ID { |
| t.Fatalf("minted key %q: %v", minted.Key, err) |
| } |
| - wire, _, err := fx.rel.Get(context.Background(), doorkey.RecordKey("bab", "titogo")) |
| - if err != nil { |
| - t.Fatal(err) |
| - } |
| - rec, err := doorkey.Verify(fx.pub, wire) |
| - if err != nil || !rec.Admits(tok) { |
| - t.Fatalf("record %+v, %v: does not admit the minted key", rec, err) |
| + rec := readDoorRecord(t, fx) |
| + if !rec.Admits(tok) || rec.Revision != 1 { |
| + t.Fatalf("record %+v does not admit the minted key at revision 1", rec) |
| } |
| - code, _, out = rawReq(t, http.MethodGet, u, token, "") |
| + code, _, out = rawReq(t, http.MethodGet, doorKeysURL(fx), token, "") |
| if code != http.StatusOK || !strings.Contains(out, minted.ID) || strings.Contains(out, "sha256") { |
| t.Fatalf("list = %d %s", code, out) |
| } |
| - code, _, out = rawReq(t, http.MethodPost, u+"/"+minted.ID+"/revoke", token, "") |
| - if code != http.StatusOK { |
| + if code, _, out = rawReq(t, http.MethodPost, doorKeysURL(fx)+"/"+minted.ID+"/revoke", token, ""); code != http.StatusOK { |
| t.Fatalf("revoke = %d %s", code, out) |
| } |
| - wire, _, _ = fx.rel.Get(context.Background(), doorkey.RecordKey("bab", "titogo")) |
| - if rec, _ := doorkey.Verify(fx.pub, wire); rec.Admits(tok) { |
| - t.Fatal("a revoked key is still admitted") |
| + if rec := readDoorRecord(t, fx); rec.Admits(tok) || rec.Revision != 2 { |
| + t.Fatalf("after revoke: %+v", rec) |
| } |
| } |
| -``` |
| - |
| -(`fx.pub` and `fx.srv`: if the fixture does not already expose the signing public key and server URL under those names, add the two fields to `consoleFixture` in this task; `instancesURL(fx, ...)` in `apiinstancemove_test.go` shows how the URL is built today.) |
| - |
| -- [ ] **Step 2: Run to verify it fails** — `go test ./internal/console -run DoorKeys -count=1` → FAIL (404 from the unregistered route). |
| |
| -- [ ] **Step 3: Implement** `apidoorkeys.go`: |
| - |
| -```go |
| -package console |
| - |
| -// Door keys (instance-moves design §8, ruling Q3): the credential an app's |
| -// own processes present at a box's edge-local door. Person-only, like the |
| -// instance token: a door key reaches every instance of the app on every box. |
| -// The console keeps only each key's SHA-256, in a record signed with the |
| -// root release key, so a bucket writer cannot add one (discussion #171). |
| - |
| -func (s *Server) doorKeyUpdate(ctx context.Context, acct, app string, mut func(*doorkey.Record) error) (*doorkey.Record, error) { |
| - key := doorkey.RecordKey(acct, app) |
| - update := func() (*doorkey.Record, error) { |
| - wire, etag, err := s.cfg.Releases.Get(ctx, key) |
| - rec := doorkey.Record{V: 1, Account: acct, App: app} |
| - switch { |
| - case errors.Is(err, os.ErrNotExist): |
| - etag = "" |
| - case err != nil: |
| - return nil, err |
| - default: |
| - if rec, err = doorkey.Verify(s.cfg.Key.Public().(ed25519.PublicKey), wire); err != nil { |
| - return nil, fmt.Errorf("door-key record for %s/%s does not verify: %w", acct, app, err) |
| - } |
| - } |
| - if err := mut(&rec); err != nil { |
| - return nil, err |
| - } |
| - rec.UpdatedAt = time.Now().UTC() |
| - body, err := doorkey.Sign(s.cfg.Key, rec) |
| - if err != nil { |
| - return nil, err |
| - } |
| - if _, err := s.cfg.Releases.Put(ctx, key, body, etag); err != nil { |
| - return nil, err |
| - } |
| - return &rec, nil |
| +// Finding 1: whatever sits in the bucket, the console publishes from its |
| +// database. A record copied in from another app's path is overwritten, not |
| +// extended. |
| +func TestDoorKeysPublishFromTheDatabaseNotTheBucket(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-door-keys-copy") |
| + _, planted, _ := doorkey.NewToken("bab", "titogo") |
| + now := time.Now().UTC() |
| + forged, _ := doorkey.Sign(fx.key, doorkey.Record{V: 1, Account: "bab", App: "titogo", Revision: 99, |
| + Keys: []doorkey.Entry{planted}, SignedAt: now, ExpiresAt: now.Add(time.Hour)}) |
| + fx.rel.Put(context.Background(), doorkey.RecordKey("bab", "titogo"), forged, "") |
| + if code, _, out := rawReq(t, http.MethodPost, doorKeysURL(fx), token, `{}`); code != http.StatusCreated { |
| + t.Fatalf("mint = %d %s", code, out) |
| } |
| - rec, err := update() |
| - if errors.Is(err, release.ErrCASConflict) { |
| - return update() |
| + rec := readDoorRecord(t, fx) |
| + for _, e := range rec.Keys { |
| + if e.ID == planted.ID { |
| + t.Fatal("the console carried a key it never minted into its signed record") |
| + } |
| } |
| - return rec, err |
| } |
| + |
| +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 */ } |
| + |
| +func TestDoorKeysRefuseANinthKey(t *testing.T) { /* eight mints 201, the ninth 409, the record still has eight */ } |
| + |
| +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 */ } |
| ``` |
| |
| -Then the three handlers, each opening with `if !s.apiPerson(w, r) { return }`, `addr, acct, app, ok := s.apiAccountApp(w, r)`, and a 503 when `len(s.cfg.Key) == 0`. Mint: `raw, entry, err := doorkey.NewToken(acct.Sqid, app)`; `entry.Label = cleanLabel(req.Label)` defaulting to `app + " door key"`; in the mutator refuse `len(rec.Keys) >= doorkey.MaxKeys` with a sentinel mapped to 409; append; `s.audit(acct, app, addr, "door-key", "minted door key "+entry.ID+" for "+app)`; `s.bumpSerial(ctx)`; 201 with the raw key shown once. List: read and verify (404-free: an absent record lists `[]`), return `id`, `label`, `created_at`. Revoke: the mutator removes the entry by id, or returns a sentinel mapped to 404; audit; bump serial. |
| +(`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. |
| |
| -Register in `authcli.go` beside `instance-token`: |
| +- [ ] **Step 2: Run** `go test ./internal/console -run DoorKey -count=1` → FAIL. |
| +- [ ] **Step 3: Implement** the store, the doors, `publishDoorKeys`, the loop, and the routes: |
| |
| ```go |
| // Door keys (#175 P7): person-only, the instance-token rule. A door key |
| - // reaches every instance of the app on every box's edge-local door. |
| + // reaches every instance of the app through every box's edge-local door. |
| s.cliRoute("GET /api/cli/apps/{account}/{app}/door-keys", svcrole.Set{}, s.handleAPIDoorKeyList) |
| s.cliRoute("POST /api/cli/apps/{account}/{app}/door-keys", svcrole.Set{}, s.handleAPIDoorKeyMint) |
| s.cliRoute("POST /api/cli/apps/{account}/{app}/door-keys/{id}/revoke", svcrole.Set{}, s.handleAPIDoorKeyRevoke) |
| ``` |
| |
| -- [ ] **Step 4: Run** `go test ./internal/console -run DoorKeys -count=1` → PASS; then the whole package. |
| -- [ ] **Step 5: Commit** `console: mint, list and revoke door keys (#175 P7)`. |
| +- [ ] **Step 4: Run** the new tests, then `go test ./internal/console -count=1`. |
| +- [ ] **Step 5: Commit** `console: door keys, with the database as authority and a re-signed projection (#175 P7)`. |
| |
| --- |
| |
| ### Task 3: `carlos instances door-key` |
| |
| -**Files:** |
| -- Create: `doorkeys_cli.go`, `doorkeys_cli_test.go` |
| -- Modify: `instances_cli.go` (`runInstances` dispatch) |
| +**Files:** Create `doorkeys_cli.go`, `doorkeys_cli_test.go`. Modify `cli.go` (`runInstances`, ~line 284: add `case "door-key":` beside the existing subcommand words). |
| |
| -**Interfaces:** |
| -- Consumes: the three console doors; `requireAPISession`, `resolvedAccount`, `resolvedApp`, `apiPath`, `s.getJSON`, `s.postJSON`, `printUsing` (existing, as `runInstancesDelete` uses them). |
| -- Produces: `carlos instances door-key create --app <app> [--label <text>]` (prints the key once, then one line saying it is shown once); `... list --app <app>` (id, label, created); `... revoke --app <app> <id>`. |
| +**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>`. |
| |
| -- [ ] **Step 1: Failing test** — drive the three verbs against an `httptest` console the way existing `apiSession` tests do (`grep -n "httptest.NewServer" instances_cli_test.go apimode_test.go` for the harness), asserting the request paths and that `create` prints the key. |
| +- [ ] **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. |
| - [ ] **Step 2: Run** `go test . -run DoorKey -count=1` → FAIL. |
| -- [ ] **Step 3: Implement** the three `apiSession` wire calls (`doorKeyMint`, `doorKeyList`, `doorKeyRevoke`) in the `mail_cli.go` style and the flag parsing in the `runInstancesDelete` style; add `case "door-key":` to `runInstances`. |
| +- [ ] **Step 3: Implement** `doorKeyMint`, `doorKeyList`, `doorKeyRevoke` wire calls and the three verbs. |
| - [ ] **Step 4: Run** → PASS. |
| - [ ] **Step 5: Commit** `cli: carlos instances door-key create, list, revoke (#175 P7)`. |
| |
| --- |
| |
| -### Task 4: Edges files publish the owner; the edge mirrors door keys |
| +### Task 4: Edges files publish owners and door support; the edge mirrors door keys |
| |
| -**Files:** |
| -- Modify: `internal/host/edgesfile.go` (`edgeHost`, and wherever hosts are filled, ~lines 190-280) |
| -- Modify: `internal/edge/homemap.go` (`edgesHostView`, `homeEntry`, `foldHomes`, `Refresh`, new `refreshDoorKeys`, new `Owner`, new `doorRecord`) |
| -- Test: `internal/host/edgesfile_test.go`, `internal/edge/homemap_test.go` |
| +**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`. |
| |
| **Interfaces:** |
| -- Produces: |
| - - edges-file `hosts[h].account`, `hosts[h].app` (omitempty; set for role `home` routes and aliases from the row's `release.RouteAccount(rt)` and `rt.App`; an alias carries its primary's). |
| - - `func (m *homeMap) Owner(host string) (account, app string, ok bool)` — from the same fold `Home` reads, including its one-label wildcard match; `ok` false when the entry has no owner (a pre-field publisher). |
| - - `func (m *homeMap) doorRecord(account, app string) (doorkey.Record, bool)` |
| - - `homeMap` gains `pub ed25519.PublicKey` (from `e.staticPub`, set in `newHomeMap`). |
| - |
| -`refreshDoorKeys` lists `doorkey.Prefix`, gets each object, verifies it with `m.pub`, and requires the record's own `account`/`app` to equal the key's path segments (a signed record copied to another app's path is refused). It swaps in the whole verified map on success. **Unlike the host fold, a record that fails verification is dropped, not kept as last good**, and a list failure keeps the previous map (a blip must not lock every router out). It runs inside `Refresh` beside `refreshHosts` and `refreshKeys`, joined into the same error. |
| - |
| -- [ ] **Step 1: Failing tests** — (a) the publisher emits `account`/`app` for a home row and for an alias (the primary's owner); (b) `Owner` returns them through the fold and through a one-label wildcard; (c) a file without the fields gives `ok == false`; (d) `refreshDoorKeys` admits a validly signed record, drops one signed by another key, drops one whose payload names a different app than its path, and after a revoke (record rewritten without the key) the next refresh no longer admits it. |
| -- [ ] **Step 2: Run** `go test ./internal/host ./internal/edge -run 'Owner|DoorKey|EdgesFile' -count=1` → FAIL. |
| -- [ ] **Step 3: Implement** as specified. |
| -- [ ] **Step 4: Run** → PASS; then both packages whole. |
| -- [ ] **Step 5: Commit** `edges files publish each host's owner; the edge mirrors signed door keys (#175 P7)`. |
| +- Edges file: `edgeHost` gains `Account string \`json:"account,omitempty"\``, `App string \`json:"app,omitempty"\``, set for every role-`home` route from `release.RouteAccount(rt)`/`rt.App`, for aliases from their primary's row, and for the served catchall at `edgesfile.go:363` from the wildcard claim's account/app. `edgesFile` gains `Door bool \`json:"door,omitempty"\``, always true from this build on. It is a capability ("this edge verifies door hops and enforces their scope"), not a statement that the box runs a door of its own: a box receiving backhauled door traffic needs only the binary. An older publisher omits it, and door traffic is never backhauled to it. |
| +- Fold: `edgesHostView` and `homeEntry` gain `account`, `app`; `edgesFileView` gains `Door`; `homeEntry` gains `door bool` from its file. When `foldHomes` lets a route replace an alias (~`homemap.go:581`), owner and door come from the winner, never merged. |
| +- `func (m *homeMap) Owner(host string) (account, app string, door, ok bool)` — same lookup as `Home`, including the one-label wildcard; `ok` false when the winning entry carries no owner. Nil receiver returns zeros. |
| +- `type doorKeyMirror struct{ ... }` in `doorkeys.go` with `func newDoorKeyMirror(s release.Store, pub ed25519.PublicKey, stateFile string) *doorKeyMirror`, `func (d *doorKeyMirror) Refresh(ctx context.Context) error`, `func (d *doorKeyMirror) run(stop <-chan struct{})` (30 s ticker, 10 s per-refresh budget), `func (d *doorKeyMirror) Record(account, app string) (doorkey.Record, bool)` (nil-safe; also refuses a record whose `ExpiresAt` has passed at read time). |
| + |
| +Mirror rules, each with its own test: |
| +- list succeeds: records absent from the list are dropped immediately; |
| +- list fails: the previous map is kept, but once 10 minutes have passed since the last successful list the whole map is cleared; |
| +- 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; |
| +- verification fails (signature, shape, expiry), or the payload's account/app differ from the path's: that record is dropped immediately; |
| +- revision below the high-water mark for that account/app: the stored newer record is kept (if still unexpired) and the replay is logged once per revision; |
| +- the high-water marks are persisted to `stateFile` (JSON map `"<account>/<app>": revision`, written atomically via temp file and rename, 0600) after any refresh that raised one, and loaded in `newDoorKeyMirror`. |
| + |
| +- [ ] **Step 1: Failing tests** — publisher: owner on a home row, on an alias (primary's), on the served catchall; `door: true` on every file this build writes. Fold: owner through exact and wildcard lookup; missing owner gives `ok == false`; a route replacing an alias carries the route's owner and door. Mirror: each rule above, plus **replay across restart**: publish revision 2 (key revoked), refresh, then put revision 1 (key present) back, refresh → not admitted; construct a new mirror over the same `stateFile`, put revision 1, refresh → still not admitted. |
| +- [ ] **Step 2: Run** `go test ./internal/host ./internal/edge -run 'Owner|DoorKey|EdgesFile|Fold' -count=1` → FAIL. |
| +- [ ] **Step 3: Implement.** |
| +- [ ] **Step 4: Run** both packages whole. |
| +- [ ] **Step 5: Commit** `edges files publish owners and door support; the edge mirrors signed door keys (#175 P7)`. |
| |
| --- |
| |
| -### Task 5: The door's ownership rule |
| +### Task 5: Enforcement inside `ServeHTTP` and across the hop |
| |
| -**Files:** |
| -- Create: `internal/edge/door.go` |
| -- Test: `internal/edge/door_test.go` |
| +**Files:** Create `internal/edge/door.go`. Modify `internal/edge/edge.go` (`ServeHTTP`, `backhaul`, `backhaulProxy`), `internal/edge/homemap.go` (`acceptHop`, `setForwarded`), `internal/edge/mesh.go`, `internal/edge/analytics.go` (`clientIP`). Tests in `internal/edge/door_test.go`, `internal/edge/mesh_test.go`. |
| |
| **Interfaces:** |
| -- Consumes: `doorkey.ParseToken`, `doorkey.Header`, `m.doorRecord`, `m.Owner`, `e.lookupUpstreamPath`, `release.RouteAccount`. |
| -- Produces: |
| - - `type doorCallerKey struct{}`; `type doorCaller struct{ Account, App string }` |
| - - `func doorCallerFrom(ctx context.Context) (doorCaller, bool)` |
| - - `func (e *Edge) doorOwns(c doorCaller, host, path string) bool` |
| - |
| -```go |
| -// doorOwns is the door's whole authorization rule: the host (and, on a |
| -// path-prefix hostname, the prefix) resolves to a route of the caller's |
| -// account and app. A local route answers from the row itself, resolved the |
| -// way ServeHTTP will resolve it. A host this box does not serve answers from |
| -// the owner its home box published. No owner published is no. |
| -func (e *Edge) doorOwns(c doorCaller, host, path string) bool { |
| - if up, ok := e.lookupUpstreamPath(host, path); ok { |
| - return release.RouteAccount(up.route) == c.Account && up.route.App == c.App |
| - } |
| - acct, app, ok := e.homes.Load().Owner(host) |
| - return ok && acct == c.Account && app == c.App |
| -} |
| -``` |
| - |
| -- [ ] **Step 1: Failing tests** — local row of the caller's app: true; local row of another app: false; path-prefix hostname with prefixes owned by two apps: true only for the caller's prefix; remote host whose published owner matches: true; remote host with no owner fields: false; unknown host: false. |
| -- [ ] **Step 2–4:** run, implement, run. |
| -- [ ] **Step 5: Commit** `edge: the door's ownership rule (#175 P7)`. |
| +- `type doorCallerKey struct{}`; `type doorCaller struct{ Account, App string }`; `func doorCallerFrom(ctx context.Context) (doorCaller, bool)` |
| +- `const hopDoorHeader = "X-Carlos-Hop-Door"` (value `<account>/<app>`) |
| +- `func signHopDoor(key []byte, host, scope string, now time.Time) string` and `func verifyHopDoorEitherKey(current, next []byte, host, scope, header string, now time.Time) bool` — same timestamp format and skew window as `signHop`/`verifyHop`, MAC over `canonicalizeHopHost(host) + "\n" + ts + "\n" + "door " + scope`. A door hop's signature therefore never verifies as a plain hop and the reverse. |
| +- `func doorOwnsRoute(c doorCaller, rt registry.Route) bool` → `release.RouteAccount(rt) == c.Account && rt.App == c.App` |
| +- `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) |
| +- `func forwardedTrusted(ctx context.Context) bool` → `hopVerified(ctx) || door caller present`; used by `setForwarded` and `clientIP` only. `backhaul`'s one-hop refusal keeps reading `hopVerified`. |
| + |
| +Enforcement, in `ServeHTTP` order: |
| +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 { ... }`). |
| +2. `acceptHop` now also handles `X-Carlos-Hop-Door`: it deletes the header in every case; when present on a hop, it verifies with `verifyHopDoorEitherKey` instead of the plain verifier, and on success returns the request with both `hopVerifiedKey` and `doorCallerKey{scope}`. On any request without a hop header it deletes a stray `X-Carlos-Hop-Door`. So a hop-delivered door request becomes `isDoor` at the receiving edge; re-read `door, isDoor` after `acceptHop`. |
| +3. **Miss branch:** if `isDoor` and not hopped: `acct, app, doorOK, ok := e.homes.Load().Owner(host)`; unless `ok && doorOK && acct == door.Account && app == door.App`, answer `unknownHost`. Otherwise `backhaul`, which, for a door request, signs with `signHopDoor(key, host, door.Account+"/"+door.App, now)` and sets `X-Carlos-Hop-Door` (in `backhaulProxy`'s Rewrite, after deleting any inbound copy). |
| +4. **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. |
| + |
| +- [ ] **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`): |
| + 1. door caller, local route of its app → served; |
| + 2. door caller, local route of another app → unknown host, upstream never called; |
| + 3. path-prefix host: `/a/` owned by the caller's app, `/b/` by another → `/a/x` served, `/b/x` unknown host; |
| + 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); |
| + 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; |
| + 6. remote host with published owner = caller and `door: true` → `backhaul` called with the door scope signed; published owner = caller but `door` false → unknown host; no published owner → unknown host; |
| + 7. **receiving side:** a request with a valid door-hop signature for scope `bab/a` to a path-prefix host whose `/b/` belongs to app `b` → unknown host; to `/a/` → served; a plain-hop signature presented with `X-Carlos-Hop-Door` → not verified (forwarded headers stripped, no door mark); |
| + 8. mesh: `verifyHopDoorEitherKey` refuses a plain `signHop` value and `verifyHopEitherKey` refuses a `signHopDoor` value. |
| +- [ ] **Step 2–4:** run (outside the sandbox), implement, run `go test ./internal/edge -count=1`. |
| +- [ ] **Step 5: Commit** `edge: enforce the door's scope on the final route, across the hop, and away from reserved handlers (#175 P7)`. |
| |
| --- |
| |
| ### Task 6: The door handler and listener |
| |
| -**Files:** |
| -- Modify: `internal/edge/door.go`, `internal/edge/homemap.go` (`setForwarded`), `internal/edge/analytics.go` (`clientIP`) |
| -- Test: `internal/edge/door_test.go` |
| +**Files:** Modify `internal/edge/door.go`; tests in `internal/edge/door_test.go`. |
| |
| **Interfaces:** |
| -- Produces: |
| - - `func (e *Edge) serveDoor(w http.ResponseWriter, r *http.Request)` |
| - - `func (e *Edge) ServeDoor(path string) (stop func(), err error)` |
| - - `func forwardedTrusted(ctx context.Context) bool` → `hopVerified(ctx) || doorCaller present`, replacing `hopVerified` in `setForwarded` and `clientIP` only. **`backhaul`'s one-hop refusal keeps reading `hopVerified`**: a door request is not a hop and must be allowed to backhaul. |
| +- `func (e *Edge) serveDoor(w http.ResponseWriter, r *http.Request)` |
| +- `func (e *Edge) openDoor(path string) (closeDoor func(), err error)` |
| |
| ```go |
| func (e *Edge) serveDoor(w http.ResponseWriter, r *http.Request) { |
| raw := r.Header.Get(doorkey.Header) |
| r.Header.Del(doorkey.Header) |
| - // The mesh's vocabulary is never a door caller's to speak: a hop mark |
| - // would buy the stale-map refusal and chain trust without a mesh key. |
| + // The mesh's vocabulary is never a door caller's to speak. |
| r.Header.Del(hopHeader) |
| r.Header.Del(hopAuthHeader) |
| + r.Header.Del(hopDoorHeader) |
| tok, err := doorkey.ParseToken(raw) |
| if err != nil { |
| doorRefused(w) |
| return |
| } |
| - rec, ok := e.homes.Load().doorRecord(tok.Account, tok.App) |
| + rec, ok := e.doorKeys.Record(tok.Account, tok.App) |
| if !ok || !rec.Admits(tok) { |
| doorRefused(w) |
| return |
| } |
| - host := r.Host |
| - if h, _, err := net.SplitHostPort(host); err == nil { |
| - host = h |
| - } |
| - c := doorCaller{Account: tok.Account, App: tok.App} |
| - if !e.doorOwns(c, host, r.URL.Path) { |
| - e.unknownHost(w, r) |
| - return |
| - } |
| - r.RemoteAddr = net.JoinHostPort(doorClientIP(r), "0") |
| - e.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), doorCallerKey{}, c))) |
| + ip := doorClientIP(r) |
| + r.Header.Del("Forwarded") |
| + r.Header.Set("X-Forwarded-For", ip) |
| + r.RemoteAddr = net.JoinHostPort(ip, "0") |
| + e.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), doorCallerKey{}, doorCaller{Account: tok.Account, App: tok.App}))) |
| } |
| |
| // doorClientIP is the caller's claim of who the visitor is: the last |
| -// X-Forwarded-For element, if it parses as an IP. Anything else is the |
| -// loopback address, so a malformed chain never reaches the gate's rate-limit |
| -// key or the visitor hash. |
| +// X-Forwarded-For element across every header line, if it parses as an IP. |
| +// Anything else is the loopback address. The door then REPLACES the header |
| +// with that one address, so nothing unparsed travels on to the app, the |
| +// gate, analytics or a backhaul. |
| func doorClientIP(r *http.Request) string { |
| - chain := strings.Join(r.Header.Values("X-Forwarded-For"), ", ") |
| + chain := strings.Join(r.Header.Values("X-Forwarded-For"), ",") |
| if i := strings.LastIndexByte(chain, ','); i >= 0 { |
| chain = chain[i+1:] |
| } |
| @@ -623,61 +646,49 @@ func doorRefused(w http.ResponseWriter) { |
| } |
| ``` |
| |
| -`ServeDoor(path)`: `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; `stop` closes the server and removes the socket. The socket's group comes from `/run/carlos`'s setgid `carlos-sockets` (bootstrap.go:61, 86-87); do not chgrp. |
| - |
| -- [ ] **Step 1: Failing tests** (httptest with an in-process `Edge`, a local upstream, and a homeMap stub holding one signed record): |
| - 1. no key → 401, upstream never called; |
| - 2. a valid key for a local host of its app → upstream called; the upstream sees no `X-Carlos-Door-Key` and sees the caller's other headers (e.g. `X-Tito-Auth-User`) untouched; |
| - 3. a valid key for another app's host → the unknown-host answer, upstream never called; |
| - 4. a request carrying `X-Carlos-Hop` and `X-Carlos-Hop-Auth` → neither reaches the upstream, and a miss goes to `backhaul`, not `staleHopRefusal` (Review Focus 1); |
| - 5. `X-Forwarded-For: 203.0.113.9` → the upstream's `X-Forwarded-For` ends in `203.0.113.9`; `X-Forwarded-For: garbage` and none → it ends in `127.0.0.1` (Review Focus 5); |
| - 6. a gated route still demands the password (the gate's refusal page, not the upstream); |
| - 7. `ServeDoor` on a temp path creates a 0660 socket and a real unix-socket client round-trips case 2. |
| -- [ ] **Step 2–4:** run, implement, run (`go test ./internal/edge -count=1`, outside the sandbox). |
| +`openDoor(path)`: refuse if `e.doorKeys == nil`; `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. |
| + |
| +- [ ] **Step 1: Failing tests:** |
| + 1. no key, malformed key, revoked key → 401 with `Cache-Control: no-store`, upstream never called; |
| + 2. valid key → served; the upstream sees no `X-Carlos-Door-Key` and sees `X-Tito-Auth-User` untouched; |
| + 3. inbound `X-Carlos-Hop`, `X-Carlos-Hop-Auth`, `X-Carlos-Hop-Door` → none reaches the upstream, and a miss goes to the door's owner check, never `staleHopRefusal`; |
| + 4. X-Forwarded-For cases: `203.0.113.9` → upstream sees exactly `203.0.113.9`; `garbage`, empty, absent, a trailing empty element (`203.0.113.9, `), and two header lines whose last element is `198.51.100.7` → upstream sees exactly `127.0.0.1`, `127.0.0.1`, `127.0.0.1`, `127.0.0.1`, `198.51.100.7`; `clientIP` returns the same value; |
| + 5. a gated route still answers with the gate's page; |
| + 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. |
| +- [ ] **Step 2–4:** run, implement, run. |
| - [ ] **Step 5: Commit** `edge: the edge-local door (#175 P7)`. |
| |
| --- |
| |
| -### Task 7: Arm the door on the box, dormant by default |
| +### Task 7: Lifecycle: arm the door after the mirrors, close it on shutdown |
| |
| -**Files:** |
| -- Modify: `internal/host/agent.go` (the ready hook passed to `edge.RunWithActivatorReady`, ~line 510) |
| -- Test: `internal/host/agent_test.go` (or the nearest existing ready-hook test) |
| +**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`. |
| |
| -```go |
| - // The edge-local door (#175 P7), dormant unless the box names a path. |
| - // /run/carlos/edge-local.sock is the convention; the directory's |
| - // setgid gives the socket group carlos-sockets, so only enrolled |
| - // consumers can dial it, and the door key is the identity on top. |
| - if p := strings.TrimSpace(os.Getenv("CARLOS_EDGE_DOOR")); p != "" { |
| - if _, err := e.ServeDoor(p); err != nil { |
| - log.Printf("agent: edge-local door at %s: %v (the door is off)", p, err) |
| - } else { |
| - log.Printf("agent: edge-local door listening at %s", p) |
| - } |
| - } |
| -``` |
| +**Interfaces:** |
| +- `func WithEdgeDoor(path, stateFile string) RunOption` — records both on the `Edge`. |
| +- 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, stateFile)`, 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 or no public key: log that the door is off and why. In dev mode: log that the door is off in dev mode. |
| +- Shutdown calls `closeDoor` before the HTTP servers' `Shutdown`. |
| +- agent: `if p := strings.TrimSpace(os.Getenv("CARLOS_EDGE_DOOR")); p != "" { opts = append(opts, edge.WithEdgeDoor(p, filepath.Join(dataDir, "door-keys.state"))) }`. |
| |
| -- [ ] **Step 1: Failing test** — with `CARLOS_EDGE_DOOR` unset no socket exists; set to a temp path, the socket appears. If the ready hook is not reachable from a unit test, extract `armEdgeDoor(e *edge.Edge, getenv func(string) string)` and test that. |
| -- [ ] **Step 2–4:** run, implement, run; `GOOS=darwin go build ./... && GOOS=windows go build ./...`. |
| -- [ ] **Step 5: Commit** `agent: arm the edge-local door when CARLOS_EDGE_DOOR names a path (#175 P7)`. |
| +- [ ] **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`). |
| +- [ ] **Step 2–4:** run, implement, run; then `GOOS=darwin go build ./... && GOOS=windows go build ./...`. |
| +- [ ] **Step 5: Commit** `edge: arm the door after its mirrors, close it on shutdown; agent: CARLOS_EDGE_DOOR (#175 P7)`. |
| |
| --- |
| |
| ### Task 8: Docs and copy review |
| |
| -**Files:** |
| -- Modify: `docs/site/cli.md` (the `carlos instances` section) and the instances guide page that covers app-owned routers (find with `grep -ln "socket-consumer\|carlos-sockets" docs/site`). |
| +**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). |
| |
| -- [ ] **Step 1:** Draft the strings into `copy-review/strings.json`: every CLI line Task 3 prints, the console errors Task 2 returns, `door key refused`, and the docs prose (what the door is, how to mint a key, how to point a router at `/run/carlos/edge-local.sock` with `Host:` and `X-Carlos-Door-Key:`, that the box must set `CARLOS_EDGE_DOOR`, that the calling process must be enrolled with `carlos bootstrap --socket-consumer <user>`). |
| -- [ ] **Step 2:** Run the copy-review server; apply the approved text verbatim to code and docs. |
| -- [ ] **Step 3:** `go test ./... ` outside the sandbox; cross-compile. |
| +- [ ] **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. |
| +- [ ] **Step 2:** Run the copy-review server; apply the approved text verbatim. |
| +- [ ] **Step 3:** `go test ./...` outside the sandbox; cross-compile. |
| - [ ] **Step 4: Commit** `docs: the edge-local door and door keys (#175 P7)`. |
| |
| --- |
| |
| -## Rollout (not a task; for the handoff) |
| +## Rollout (for the handoff, not a task) |
| |
| -1. Consoles first (they mint the records nobody reads yet), then boxes (they publish owners and mirror keys, still dormant). |
| -2. On carlos-tito-ie, with scoped approval: set `CARLOS_EDGE_DOOR=/run/carlos/edge-local.sock` in the agent's environment, restart the agent, confirm the socket is `srw-rw---- carlos carlos-sockets`. |
| +1. Consoles first (they publish records nothing reads yet), then boxes (they publish owners and `door: true`, and verify door hops, but open no socket). |
| +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. |
| 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. |