| index ceee6269d..a2fd9983f 100644 |
| --- a/docs/superpowers/specs/2026-09-07-relay-stun-turn-design.md |
| +++ b/docs/superpowers/specs/2026-09-07-relay-stun-turn-design.md |
| @@ -1,12 +1,15 @@ |
| # Stage 4a: a platform STUN and TURN relay |
| |
| -**Date:** 2026-09-07, revision 2 |
| -**Status:** draft. Revision 1 was reviewed by GPT-6 via Codex; its 20 |
| -findings are in `CODEX-FINDINGS-RELAY.md`, unedited, and every one is |
| -taken here, marked **(codex R1-N)**. Stage 4a of the media roadmap |
| +**Date:** 2026-09-07, revision 3 |
| +**Status:** rulings taken. Paul ruled on the six decisions in §9 on |
| +2026-09-07, each marked **(ruling)**; the roadmap's stage 4a text and |
| +gate are amended in the same commit so the two documents agree. |
| +Reviewed twice by GPT-6 via Codex: revision 1's 20 findings are in |
| +`CODEX-FINDINGS-RELAY.md`, revision 2's 12 in |
| +`CODEX-FINDINGS-RELAY-R2.md`, unedited; every one is taken, marked |
| +**(codex R1-N)** or **(codex R2-N)**. Stage 4a of the media roadmap |
| (`2026-09-07-media-platform-roadmap-design.md`), built under rulings 8, |
| -10 and 11 there. §9 lists what Paul is asked to decide, including three |
| -places this spec departs from the roadmap's text. |
| +10 and 11 there. |
| |
| **Why now.** Oficina's Meet is peer-to-peer WebRTC and cannot name a |
| third-party STUN server, because suite ruling 6 forbids egress that is a |
| @@ -27,19 +30,22 @@ clean for the CI shards (checked 2026-09-07). It answers STUN binding |
| requests to anyone and TURN allocations to holders of a credential an |
| enabled app minted. It learns which apps are enabled from a record the |
| console writes, binds every allocation to the identity that made it, |
| -ends allocations when their credential, app, region or its own |
| -authority lapses, meters what it relays per app, and refuses peers on |
| +ends allocations when their credential, app, region, master or its own |
| +authority lapses, meters what it forwards per app, and refuses peers on |
| private networks. |
| |
| Three console doors and a CLI verb, `carlos relay enable|status|disable`, |
| -that turn the relay on for an app and deliver its keys as sealed config. |
| -An operator-side master key per region, staged in a file on the region's |
| -boxes and, as a console secret, in the console. A security-group change |
| -opening the relay's ports. A DNS name per region relay. |
| - |
| -Not built: TURN over TLS on 5349 or 443, an SFU, recording, an issuance |
| -API, client-side relay selection, an inter-region byte meter, commercial |
| -quotas. §8 lists them with reasons. |
| +that turn the relay on for an app and deliver its keys as sealed config, |
| +plus an operator verb `carlos relay rotate`. An operator-side master key |
| +per region, staged in a file on the region's boxes and, as a console |
| +secret, in the console. A config-adoption acknowledgement the host's |
| +config sync writes, so status can say "adopted" truthfully. A |
| +security-group change opening the relay's ports. A DNS name per region |
| +relay. |
| + |
| +Not built, by ruling: TURN over TLS, an inter-region byte meter. Not |
| +built, by scope: an SFU, recording, an issuance API, client-side relay |
| +selection, commercial quotas. §8 lists them with reasons. |
| |
| ## 2. The app-facing contract |
| |
| @@ -68,7 +74,9 @@ the base64, then parses the JSON. **(codex R1-1)** |
| relay hostnames. `username_suffix` is `<account>:<app>:<incarnation>: |
| <kid>`, supplied by the platform so the app never has to know its own |
| canonical account sqid or app name. **(codex R1-11, R1-12)** There is |
| - no separate incarnation variable; the suffix carries it. |
| + no separate incarnation variable; the suffix carries it. The app |
| + builds its ICE server list from whatever `urls` are present and |
| + assumes nothing about their number or schemes. |
| |
| **Minting, per call.** For the region it wants, the app computes: |
| |
| @@ -77,8 +85,8 @@ the base64, then parses the JSON. **(codex R1-1)** |
| credential = base64_std( HMAC-SHA256( key, username ) ) |
| |
| where `expiry` is a decimal Unix time in seconds, at most four hours |
| -ahead. The username has exactly five colon-separated fields: |
| -`expiry` (`[0-9]+`), `account` (`[a-z0-9]+`), `app` |
| +ahead **(ruling 1)**. The username has exactly five colon-separated |
| +fields: `expiry` (`[0-9]+`), `account` (`[a-z0-9]+`), `app` |
| (`^[a-z][a-z0-9-]{0,31}$`), `incarnation` (`[0-9a-f]{16}`) and `kid` |
| (`[a-z0-9]{1,8}`). No field can contain a colon. The app hands the |
| browser an `RTCIceServer` list: `stun:` entries bare, `turn:` entries |
| @@ -91,7 +99,7 @@ client sends (§3, "termination"), so a credential's deadline is a hard |
| deadline on relayed traffic. An app that needs a call to outlive its |
| credential re-mints and performs an ICE restart before the deadline. |
| Four hours is the maximum, not a promise about meeting lengths. |
| -**(codex R1-4, R1-20; differs from the roadmap's one hour, §9)** |
| +**(codex R1-4, R1-20)** |
| |
| **What changes the incarnation.** Only `carlos relay disable` followed by |
| `carlos relay enable`. A repeated enable, a region change, a deploy, a |
| @@ -99,13 +107,19 @@ restart and a master rotation all keep it. **(codex R1-13)** |
| |
| **What changes the key.** A master rotation in a region changes that |
| region's key and `kid`; a new incarnation changes every region's key. |
| -Either re-renders the app's config, which for unit-backed and |
| -exec-backed routes restarts the instance within about a minute, as any |
| -config change does today; a TCP-address route receives the file and is |
| -not restarted (`internal/appconfig/syncexec.go:11`), and must re-read it |
| -itself. The relay accepts a rotated-out master until its retirement |
| -time, at least 28 hours after rotation (§5), so a credential minted from |
| -the old key keeps working for its whole life. **(codex R1-10, R1-14)** |
| +Either re-renders the app's config, which for unit-backed routes, and |
| +exec-backed routes whose bundle opts into file delivery, restarts the |
| +instance within about a minute, as any config change does today; a |
| +TCP-address route without file delivery receives nothing and the app |
| +must fetch its config another way (`internal/appconfig/syncexec.go:11`). |
| +**(codex R1-10, R1-14, R2-6)** |
| + |
| +**A credential keeps working for its whole life** provided the master |
| +that signed its key is not force-retired before its expiry. The |
| +rotation procedure in §5 guarantees that when followed: the old master |
| +is retired no earlier than four hours after the last instance stopped |
| +minting from it, as observed by the console, and no earlier than 28 |
| +hours after rotation regardless. **(codex R2-4)** |
| |
| **Test vector.** Master is `SHA-256("carlos-relay-test-master")`, |
| account `abc`, app `meet`, incarnation `0123456789abcdef`, kid `k1`, |
| @@ -119,7 +133,6 @@ expiry `1900000000`: |
| The key derivation is in §4; an app never needs it, only the key it is |
| given. The vector will be checked into `internal/relay/vector_test.go` |
| by the plan, and Meet's minting test uses the same numbers. |
| -**(codex R1-20)** |
| |
| **Logging.** The app logs which region it used and nothing else about |
| the credential. The relay logs the account, app and outcome of an |
| @@ -141,56 +154,73 @@ network-facing platform unit like the edge, not a codec helper. |
| the relay onto the new binary and a fresh box runs it without a |
| reboot. The staging bootstrap plan prints the same steps without |
| running them, as it does for the edge. **(codex R1-16)** |
| -- **Hardening.** From `carlos-edge.service` and then cut down: no |
| - `CAP_NET_BIND_SERVICE` (3478 is unprivileged), `User=carlos`, |
| - `ProtectSystem=strict`, `ReadWritePaths=/data/relay /run/carlos`, |
| - `RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX`. Memory is an |
| - absolute budget, not a share: `MemoryMax=256M`, `MemoryHigh=192M`, |
| - and `OOMScoreAdjust=800` so that under host memory pressure the relay |
| - is the first process the kernel kills, ahead of the edge and tenants. |
| - `Nice=5` and `CPUWeight=50` for CPU. The plan's flagship gate loads |
| - edge, a tenant and the relay together on a `t4g.small` and confirms |
| - the relay dies first. **(codex R1-19)** |
| +- **Hardening and memory.** From `carlos-edge.service` and then cut |
| + down: no `CAP_NET_BIND_SERVICE` (3478 is unprivileged), |
| + `User=carlos`, `ProtectSystem=strict`, `ReadWritePaths=/data/relay |
| + /run/carlos`, `RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX`. |
| + Memory: `MemoryMax=256M`, `MemoryHigh=160M`, `OOMScoreAdjust=1000`. |
| + The box's ceilings oversubscribe it (edge 80% of RAM, tenants' tier |
| + 1 GiB, relay 256 MiB on a 2 GiB `t4g.small`), and no setting makes |
| + the kernel's choice strict, so the relay is made the cheapest victim |
| + by arithmetic and sheds itself first: the kernel's score is RSS as a |
| + fraction of RAM in thousandths plus the adjustment, so the relay |
| + scores at least 1000 and at most 1125, a tenant at most 1000 (1 GiB |
| + cap, adjustment 500), the edge at most 800; and above `MemoryHigh` |
| + the relay refuses new allocations and ends its oldest until it is |
| + under, before the kernel is involved. `Nice=5` and `CPUWeight=50` for |
| + CPU. **(codex R1-19, R2-8)** |
| - **Listeners.** UDP 3478 and TCP 3478 on the box's primary address. |
| - No TLS listener in version one (§8). The relay allocation range is |
| - UDP 49152 to 65535, bound through the relay's own |
| - `RelayAddressGenerator` (below) with the box's Elastic IP as the |
| - advertised relay address, learned as the edge learns it, from IMDS |
| - (`readPublicIP`, `internal/host/agent.go:1808`), with |
| - `CARLOS_RELAY_PUBLIC_IP` as the override for a box without IMDS. |
| - **(codex R1-20)** |
| + No TLS listener **(ruling 4)**. The relay allocation range is UDP |
| + 49152 to 65535, bound through the relay's own address generator |
| + (below) with the box's Elastic IP as the advertised relay address, |
| + learned as the edge learns it, from IMDS (`readPublicIP`, |
| + `internal/host/agent.go:1808`), with `CARLOS_RELAY_PUBLIC_IP` as the |
| + override for a box without IMDS. |
| - **STUN.** Binding requests are answered on every listener without |
| - authentication, which is pion's default and the whole point of §0. |
| -- **Our own address generator, for termination and metering.** pion has |
| - no public call to end an allocation; its allocation manager is |
| - internal and `Server` exposes only `AllocationCount` and `Close`. What |
| - it does expose is the `RelayAddressGenerator` that creates each |
| - allocation's relay socket, and its read loop deletes the allocation |
| - when that socket returns an error |
| - (`internal/allocation/allocation.go:301`, v4.1.4). So the relay |
| - implements the generator itself: every relay socket it hands pion is |
| - wrapped, recorded against the client's five-tuple, and can be closed |
| - by the relay at any time, which ends that allocation inside pion |
| - within one read. The wrapper also counts bytes in and out. That one |
| - mechanism is how every deadline below is enforced and how metering is |
| - attributed. **(codex R1-4, R1-17)** |
| + authentication, which is pion's default and the reason this stage |
| + exists. |
| +- **The relay owns every socket pion uses.** pion has no public call to |
| + end an allocation; its allocation manager is internal and `Server` |
| + exposes only `AllocationCount` and `Close`. What it does expose is the |
| + `RelayAddressGenerator` that creates each allocation's relay socket |
| + (`server_config.go:20`, returning a `net.PacketConn`), the listener |
| + sockets the relay passes in, and an `OnAllocationCreated` callback |
| + that supplies the relay address before pion starts reading from it |
| + (`internal/allocation/allocation_manager.go:140`). pion's read loop |
| + deletes the allocation when the relay socket returns an error |
| + (`internal/allocation/allocation.go:301`). So the relay implements |
| + the generator and wraps both kinds of socket: every relay socket is |
| + recorded against its allocation at `OnAllocationCreated` and can be |
| + closed by the relay at any time, which ends that allocation inside |
| + pion within one read; and both relay and listener sockets count what |
| + they forward and apply the token buckets below. That one mechanism is |
| + how every deadline is enforced, how metering is attributed, and how |
| + bandwidth is bounded. **(codex R1-4, R1-17)** |
| - **Owner binding.** pion locates a refresh, permission or channel bind |
| by the client's five-tuple and does not compare the authenticating |
| username to the one that created the allocation |
| - (`internal/server/turn.go`, v4.1.4). The relay does: on |
| - `OnAllocationCreated` it records five-tuple to identity, and its |
| - `AuthHandler`, which receives the source address, refuses a username |
| + (`internal/server/turn.go:34`); it also tags every allocation's tuple |
| + as UDP even for TCP clients, so the event's protocol string cannot |
| + distinguish transports. The relay keys its binding on the **typed |
| + source address**: the Go type of the `net.Addr` it receives |
| + (`*net.UDPAddr` or `*net.TCPAddr`) plus its string, which pion passes |
| + unchanged to `AuthHandler` and to the events. On |
| + `OnAllocationCreated` it records that key to the identity fields |
| + (account, app, incarnation); its `AuthHandler` refuses a username |
| whose identity differs from the one bound to a live allocation from |
| - that source. The binding is cleared on `OnAllocationDeleted`. |
| - **(codex R1-3)** |
| + that key; `OnAllocationDeleted` removes only the binding whose relay |
| + address matches the event's. **(codex R1-3, R2-3)** |
| - **Termination.** An allocation is ended, by closing its relay socket, |
| at the earliest of: its credential's expiry (a timer set at creation |
| from the username's expiry field); the app being disabled, its |
| incarnation changing, or this region leaving its list (on the next |
| - authority read); the relay's authority lease expiring (below); the |
| - app crossing its byte budget (below). Ending one allocation ends its |
| - permissions and channels with it. pion's own maximum requested |
| - lifetime is one hour; the relay does not rely on it. **(codex R1-4)** |
| + authority read); the master named by its `kid` reaching `retire_at` |
| + (on the next masters-file read or timer); the relay's authority lease |
| + expiring (below); the app or account crossing a byte budget (below); |
| + the relay shedding under memory pressure. Ending one allocation ends |
| + its permissions and channels with it. pion's own maximum requested |
| + lifetime is one hour; the relay does not rely on it. **(codex R1-4, |
| + R2-4)** |
| - **Authentication.** A custom `AuthHandler` (§4), called by pion on |
| every authenticated request, which is where the username's expiry and |
| the authority record are checked; pion verifies the message integrity |
| @@ -199,35 +229,56 @@ network-facing platform unit like the edge, not a codec helper. |
| `ChannelBind` to loopback, link-local (including the IMDS address), |
| RFC 1918 and unique-local ranges, the box's own private address and |
| VPC CIDR (from `CARLOS_RELAY_DENY_CIDRS`, set by the operator from |
| - tofu output), and any other relay's private address. **The box's own |
| - Elastic IP is allowed**: two clients that both fall back to this relay |
| - hold relay candidates on that address at different ports and must |
| - reach each other, and a packet from the box to its own EIP hairpins |
| - through the internet gateway under the same security group as any |
| - outside sender, so it can reach nothing the public cannot. Refusals |
| - are counted per app. **(codex R1-8)** |
| -- **Limits**, all admitted under one mutex across every listener so |
| - counts cannot race, with counters reset on process start: |
| - - per app per box: 64 live allocations, 30 new allocations a minute; |
| - - per account per box: 256 live allocations, whatever the app count; |
| - - per box: 2048 live allocations; |
| - - per app per box: a byte budget of 20 GB per rolling hour; crossing |
| - it refuses new allocations with 486 and **ends the app's live |
| - allocations**, because a budget that only stops new ones does not |
| - bound anything. Failed allocations count against the per-minute |
| - rate. |
| + tofu output), and any other relay's private address. The box's own |
| + Elastic IP is allowed by the permission handler, because two clients |
| + that both fall back to this relay hold relay candidates on that |
| + address and must reach each other, **but only live relay allocation |
| + ports are reachable there**: the permission handler sees only an IP, |
| + so the wrapped relay socket enforces the port on `WriteTo`, dropping |
| + any packet to the EIP whose destination port is not a currently |
| + allocated relay port. Nothing else on the box is reachable through |
| + the relay, whatever the security group says about source addresses. |
| + Refusals and drops are counted per app. **(codex R1-8, R2-9)** |
| +- **Limits (ruling 6)**, all admitted under one mutex across every |
| + listener so counts cannot race, with counters reset on process start |
| + and the rolling-hour byte windows rebuilt from the relay's own |
| + metrics object on start: |
| + - per app per box: 64 live allocations, 30 new a minute, 20 GB a |
| + rolling hour; |
| + - per account per box: 256 live, 60 new a minute, 60 GB a rolling |
| + hour, whatever the app count; |
| + - per box: 2048 live, 500 new a minute; |
| + - instantaneous: a token bucket of 8 Mbps per allocation and 200 Mbps |
| + per box applied in the wrapped sockets on both forwarding legs, |
| + dropping packets beyond the bucket, so a burst is bounded before it |
| + reaches the edge's share of the NIC; |
| + - crossing a byte budget refuses new allocations with 486 for the |
| + app or account and **ends its live allocations**, because a budget |
| + that only stops new ones bounds nothing. Failed allocations count |
| + against the per-minute rates. |
| These are operational limits with those defaults, tunable by env; |
| - commercial quotas are out of scope. **(codex R1-9)** |
| -- **Metering.** From the wrapped sockets, per allocation, attributed to |
| - the identity bound at creation: bytes in from the client, bytes out |
| - to peers, and allocation count. Once a minute the relay writes its |
| - own chunk, `metrics/relay/<box>/<hour>.json`, a new additive key |
| - with one writer, rather than appending to the host sampler's objects, |
| - which that sampler rewrites from its own buffer |
| - (`internal/host/metrics_loop.go`, `writeHourChunk`). The schema is |
| - `{apps: {"<account>/<app>": {in, out, allocs}}, written_at}`, and the |
| - raw readers and rollups learn the new key in the plan. An |
| - inter-region meter is not built (§8). **(codex R1-17, R1-18)** |
| + commercial quotas are out of scope. **(codex R1-9, R2-7)** |
| +- **Metering.** Two meters per app and one gauge, measured where pion |
| + forwards and only for what it forwards: **(codex R1-17, R1-18, R2-5; |
| + ruling 5)** |
| + - `in`: payload bytes the relay accepted from the client and forwarded |
| + to a peer, counted at the relay socket's `WriteTo`; |
| + - `out`: payload bytes the relay forwarded from a peer to the client, |
| + counted at the listener socket's `WriteTo` to the client's address, |
| + attributed through the owner binding; |
| + - `allocs`: live allocations at sample time. |
| + Both meters are host egress and are what the bill is made of. |
| + Peer bytes that arrive at a relay socket and are dropped for lack of |
| + permission are counted separately as `rejected` and never against a |
| + budget. Every sixty seconds the relay appends one sample |
| + `{at, apps: {"<account>/<app>": {in, out, allocs, rejected}}}`, with |
| + `in`, `out` and `rejected` as **deltas since the previous sample**, |
| + to its own hourly object `metrics/relay/<box>/<hour>.json`, which it |
| + alone writes: read, append, CAS-write, retry on conflict. On restart |
| + it reads the current hour's object back, so earlier samples survive |
| + and its rolling-hour budgets resume from them. The raw readers and |
| + rollups learn the new key in the plan. An inter-region meter is not |
| + built (§8). |
| - **Authority record and lease.** The relay reads `control/relay.json` |
| (§5) from the deployment bucket **every sixty seconds**, and |
| additionally whenever `control/serial` changes as seen by its own |
| @@ -240,8 +291,9 @@ network-facing platform unit like the edge, not a codec helper. |
| resumes on the next successful read. A relay with no record yet |
| serves STUN only. Each read also writes a heartbeat, |
| `control/relay-ack/<box>.json`, carrying the record's `written_at`, |
| - the read time, and which listeners are up, so status (§5) can tell a |
| - live relay from a dead one. **(codex R1-5, R1-14, R1-20)** |
| + the read time, which listeners are up, the `kid`s currently loaded |
| + from the masters file, and the last masters-file reload error if |
| + any. **(codex R1-5, R1-14, R2-4)** |
| |
| ## 4. Keys and verification |
| |
| @@ -254,24 +306,27 @@ root-owned, mode 0640, group `carlos`, one per line: |
| |
| The relay reads the file at start and re-reads it when it changes, so |
| a rotation never restarts the process. A master past its `retire_at` is |
| -ignored. **(codex R1-10)** |
| - |
| -**Where the console's copy lives, and what that exposes.** The console |
| -must hold every region's masters to derive app keys at enable. It holds |
| -them as console secrets, `CARLOS_RELAY_MASTERS_<REGION>` (region name |
| -uppercased with hyphens as underscores, since variable names refuse |
| -hyphens, `internal/appconfig/appconfig.go:103`), and console secrets |
| -are sealed config like any app's. Sealed config is decryptable on every |
| -host (roadmap §1). So the honest statement is: **a compromised host can |
| -read every region's master**, not only the derived keys the roadmap |
| -already conceded, and with a master it can mint credentials for any |
| -app in that region, present and future, until the operator rotates. |
| -Per-region masters still mean a relay box itself holds only its own |
| -region's, and rotation of one region recovers that region. A design |
| -where only a region's boxes ever hold its master (the region's relay |
| -deriving app keys and writing them into config itself) is possible and |
| -is not version one, because it makes the relay a writer of app config |
| -with all the CAS and ordering that implies. **(codex R1-7; §9)** |
| +ignored for new requests and its live allocations are ended (§3). |
| +**(codex R1-10, R2-4)** |
| + |
| +**Where the console's copy lives, and what that exposes (ruling 3).** |
| +The console must hold every region's masters to derive app keys at |
| +enable. It holds them as console secrets, `CARLOS_RELAY_MASTERS_<REGION>` |
| +(region name uppercased with hyphens as underscores, since variable |
| +names refuse hyphens, `internal/appconfig/appconfig.go:103`), and |
| +console secrets are sealed config like any app's. Sealed config is |
| +decryptable on every host (roadmap §1). So the honest statement is: **a |
| +compromised host can read every region's master**, not only the derived |
| +keys the roadmap already conceded, and with a master it can mint |
| +credentials for any app in that region, present and future, until the |
| +operator rotates. Per-region masters still mean a relay box itself |
| +holds only its own region's, and rotation of one region recovers that |
| +region. The operator docs say this in those words. A design where only |
| +a region's boxes ever hold its master, the region's relay deriving app |
| +keys and writing them into config itself, is filed as the tightening |
| +and is not version one, because it makes the relay a writer of app |
| +config with all the CAS and ordering that implies. **(codex R1-7, |
| +R2-10)** |
| |
| **Derived key.** For an app, region and incarnation: |
| |
| @@ -288,8 +343,8 @@ key, so the master is chosen from the username, not by trial: |
| 1. Split the username on `:` into exactly five fields; anything else is |
| refused. |
| 2. Parse `expiry`; refuse if in the past or more than four hours ahead. |
| -3. If `src` has a live allocation bound to a different identity (§3, |
| - owner binding), refuse. |
| +3. If the typed source address has a live allocation bound to a |
| + different identity (§3, owner binding), refuse. |
| 4. Look up `account/app` in the authority record; refuse if absent, if |
| the incarnation differs, or if this relay's region is not in the |
| app's list. |
| @@ -309,96 +364,148 @@ ours. **(codex R1-20)** |
| |
| ## 5. Enable, disable, rotate |
| |
| -**Records.** Two, both additive: |
| - |
| -- `apps/<acct>/<app>/relay.json`, CAS-guarded like `store.json`: |
| - `{enabled, incarnation, regions, config_generation, enabled_at, |
| - disabled_at}`. The incarnation is sixteen hex characters drawn at |
| - random at first enable and at each re-enable after a disable, never a |
| - counter, so reuse across delete and name reuse is not a question. |
| -- `control/relay.json`, the authority record: `{apps: [{account, app, |
| - incarnation, regions}], written_at}`. It is **always rebuilt from a |
| - listing of every per-app record**, never patched from a snapshot, and |
| - written with a CAS on its own etag; a conflict re-lists and retries. |
| - A reconcile that does exactly that rebuild runs at console start and |
| - hourly, so an operation that died between the per-app write and the |
| - aggregate write is repaired within the hour and the aggregate can |
| - never carry an app whose record says disabled. Followed by a |
| - `control/serial` bump so relays notice within seconds; the bump is a |
| - latency hint, not the lease. **(codex R1-5, R1-6)** |
| +**Records.** Two, both additive. **(codex R1-6, R2-1, R2-2)** |
| + |
| +- `apps/<acct>/<app>/relay.json`, CAS-guarded like `store.json`, is the |
| + **source of truth** for the app: `{enabled, incarnation, regions, |
| + op, phase, config_generation, enabled_at, disabled_at}`. `op` is a |
| + counter bumped by every operation; `phase` is one of `config`, |
| + `aggregate`, `done` and records how far the operation got. The |
| + incarnation is sixteen hex characters drawn at random at first |
| + enable and at each re-enable after a disable, never a counter, so |
| + reuse across delete and name reuse is not a question. |
| +- `control/relay.json`, the authority record the relays read: |
| + `{apps: [{account, app, incarnation, regions}], written_at}`. It is |
| + **derived**, never patched. Publishing it is one fixed sequence: read |
| + the aggregate's current etag; list every `relay.json`; build the |
| + aggregate from records whose `enabled` is true, whatever their |
| + `phase`; write with a CAS on the etag read **first**; on conflict |
| + start the whole sequence again. Because the etag is read before the |
| + listing, a disable that lands during the listing makes the write |
| + fail rather than restore the app. Followed by a `control/serial` bump |
| + so relays notice within seconds; the bump is a latency hint, not the |
| + lease. |
| + |
| +**Every operation is a walk the reconcile can finish.** An operation |
| +writes its intent to `relay.json` first (`op` bumped, `phase` set), |
| +then does the config write, then the aggregate publish, advancing |
| +`phase` after each step with a CAS that carries the `op`, so a writer |
| +whose `op` has been superseded finds its CAS refused and stops. Before |
| +the config write it re-reads the record and refuses if the `op` is not |
| +its own. The **reconcile** lists every record every five minutes and |
| +at console start, and for any record not at `phase: done` performs the |
| +remaining steps of that record's operation idempotently (the config |
| +value is a pure function of the record and the masters, so re-applying |
| +it is harmless; the aggregate publish is always a rebuild). So an |
| +interrupted enable delivers its config within five minutes, an |
| +interrupted disable removes it within five minutes, and a repeated |
| +`enable` while a previous one is unfinished resumes it rather than |
| +skipping the config write. **(codex R2-2)** |
| + |
| +**When a disable is complete.** The app is out of every relay's |
| +authority when the aggregate has been published without it **and** |
| +every armed relay's heartbeat carries a `written_at` at or after that |
| +publish. `carlos relay status` shows "disable pending on <regions>" |
| +until then, and "revoked" after. Bounds: the publish is immediate on the |
| +happy path and within five minutes after a crash; each relay reads |
| +within sixty seconds of the publish; a relay that cannot read stops |
| +serving on its own fifteen-minute lease. **(codex R2-1)** |
| |
| **State transitions** for `relay.json`: **(codex R1-13)** |
| |
| | From | Operation | Incarnation | Keys re-derived | Allocations | |
| |---|---|---|---|---| |
| | none | enable | new | yes | none | |
| -| enabled | enable (repeat, same regions) | kept | no, no config write | untouched | |
| +| enabled, done | enable (same regions) | kept | no, no config write | untouched | |
| +| enabled, not done | enable | kept | resumes the unfinished operation | untouched | |
| | enabled | enable `--regions` (change) | kept | only for added regions; removed regions' entries dropped | ended in removed regions on next authority read | |
| -| enabled | disable | kept in record, app removed from aggregate | entries removed | ended everywhere on next authority read | |
| +| enabled | disable | kept in record, app removed from aggregate | entry removed | ended everywhere on next authority read | |
| | disabled | enable | **new** | yes, all regions | none | |
| -| any | app deleted | record removed with the app prefix; reconcile drops it from the aggregate | | ended on next authority read | |
| +| any | app deleted | record removed with the app prefix; the deletion walk publishes the aggregate | | ended on next authority read | |
| | any | rotate `--region r` | kept | region `r` only, new kid | untouched; old kid accepted until retirement | |
| |
| **`carlos relay enable [--regions r1,r2]`** (member, API-mode only, on |
| -the store model in `store.go` and `internal/console/apistore.go`): |
| +the store model in `store.go` and `internal/console/apistore.go`; |
| +ruling 2): |
| |
| 1. `apiAccountApp` gate; unknown and foreign are one byte-identical 404. |
| 2. Refuse if the deployment has no relay regions (`CARLOS_RELAY_REGIONS` |
| in console config is empty) with a message naming the operator. |
| -3. Regions default to every region in `CARLOS_RELAY_REGIONS`, ordered |
| - with the app's placement region first when the app has one, then |
| - the console's order; `--regions` narrows. The order is fixed at |
| - enable for the whole app; an app placed in two regions gets one |
| - order, and its browsers try every listed relay anyway. **(codex R1-12)** |
| -4. Apply the transition above to `relay.json` (CAS). |
| +3. Regions are every region in `CARLOS_RELAY_REGIONS`, **in the |
| + console's order**, or `--regions` narrows them keeping that order. |
| + There is no placement-first promise: placement names (`ie`, |
| + `fleet/x`) are not AWS regions and an app may have several, so the |
| + order is one fixed list for the app and every browser tries every |
| + listed relay. **(codex R1-12, R2-11)** |
| +4. Apply the transition above to `relay.json` (CAS, `op` bumped, |
| + `phase: config`). |
| 5. For each region needing keys, derive from that region's current |
| master and its kid, and write `CARLOS_RELAY` as one `kind:"secret"` |
| entry through `appconfig.Apply` (the `writeEnv` shape in |
| - `internal/objstore/walk.go:554`). Record the resulting config |
| - generation in `relay.json`. That bumps the app's config generation; |
| - the box re-renders within about a minute. |
| -6. Rebuild `control/relay.json`, bump `control/serial`. |
| -7. Answer with the record and the config generation written. The CLI |
| - prints regions, incarnation, and "config generation N written; the |
| - instance restarts on delivery", never "delivered". **(codex R1-14)** |
| + `internal/objstore/walk.go:602`). `Apply` touches the app's config |
| + generation, a nonce plus timestamp (`internal/appconfig/ |
| + generation.go:40`); record that generation string in `relay.json` |
| + and advance to `phase: aggregate`. **(codex R2-6, R2-12)** |
| +6. Publish `control/relay.json`, bump `control/serial`, advance to |
| + `phase: done`. |
| +7. Answer with the record. The CLI prints regions, incarnation, and |
| + "config generation <g> written; delivery is reported by `carlos |
| + relay status`", never "delivered". |
| + |
| +**Config adoption, observed.** The host's config sync, after it renders |
| +an app's env file and restarts the route (or writes the file for an |
| +exec route that opted into file delivery), writes an additive |
| +acknowledgement `control/config-ack/<box>/<acct>/<app>.json` carrying |
| +the generation string it rendered, the time, and whether a restart |
| +happened. Nothing reads it today but `carlos relay status`; it is the |
| +first per-instance statement of "this config is what the process runs" |
| +the platform has, and the plan adds it to `internal/appconfig/sync.go` |
| +as its own small step. **(codex R1-14, R2-6)** |
| |
| **`carlos relay status`** reports, separately: desired state from |
| -`relay.json`; the config generation written; delivery, as "adopted" when |
| -the instances API shows the instance restarted after that generation, |
| -"written, awaiting delivery" otherwise, and "written, not restarted by |
| -the platform" for a TCP-address route; and per region, from |
| -`control/relay-ack/<box>.json`, the heartbeat age, whether the record it |
| -holds is the current one, whether its lease is live (heartbeat under |
| -fifteen minutes) and which listeners are up. A heartbeat older than |
| +`relay.json` with its `op` and `phase`; the config generation written; |
| +delivery per box, "adopted at <time>" when a config-ack for that |
| +generation exists, "written, awaiting delivery" when it does not, |
| +"written, not restarted by the platform" for an exec route with file |
| +delivery, and "not delivered: route has no config delivery" for a |
| +TCP-address route without it; disable progress as above; and per |
| +region, from `control/relay-ack/<box>.json`, the heartbeat age, whether |
| +the record it holds is current, whether its lease is live, which |
| +listeners are up, and which `kid`s it has loaded. A heartbeat older than |
| five minutes is shown as "stale", so a dead relay cannot look current. |
| -**(codex R1-14)** |
| |
| -**`carlos relay disable`** applies the transition, removes the |
| -`CARLOS_RELAY` entry through `appconfig.Apply`, rebuilds the aggregate, |
| -bumps the serial. |
| +**`carlos relay disable`** applies the transition (`phase: config`), |
| +removes the `CARLOS_RELAY` entry through `appconfig.Apply`, publishes |
| +the aggregate, marks `done`. |
| |
| -**Rotation** (`carlos relay rotate --region r`, operator): **(codex R1-10)** |
| +**Rotation** (`carlos relay rotate --region r`, operator): **(codex |
| +R1-10, R2-4)** |
| |
| 1. Operator runs `carlos relay keygen`, which prints a new `kid` and key. |
| 2. Operator adds the line to `/etc/carlos/relay-masters` on each of the |
| - region's boxes with `retire_at` `-`, and sets `retire_at` on the old |
| - line to at least 28 hours ahead (24 hours for config to reach every |
| - instance plus a four-hour credential life). The relay re-reads the |
| - file; no restart, no allocation touched. |
| + region's boxes with `retire_at` `-`. The relay re-reads the file; |
| + no restart, no allocation touched. Its next heartbeat lists the new |
| + `kid`. |
| 3. Operator updates the console secret for the region to the new |
| `kid:key`. |
| -4. `carlos relay rotate --region r` re-derives and re-delivers keys for |
| - every app enabled in that region, recording each app's new config |
| - generation; `carlos relay status` for each shows when it is adopted. |
| -5. After every enabled app shows adopted, or after the retirement time |
| - passes, the old line's `retire_at` does its work; the operator |
| - removes the line at leisure. An app still minting from the old key |
| - past retirement gets refused with "unknown kid" and re-reads its |
| - config on restart. |
| +4. `carlos relay rotate --region r` **refuses until every armed relay |
| + in the region has heartbeated the new `kid`**, then re-derives and |
| + re-delivers keys for every app enabled in that region as an |
| + operation on each record, and prints the earliest permitted |
| + retirement time for the old `kid`: 28 hours from now. |
| +5. `carlos relay status --region r` shows, per app, whether the new |
| + generation is adopted. The old master's `retire_at` may be set to |
| + the later of: 28 hours after rotation, and four hours after the last |
| + app's adoption of the new generation. Setting it earlier is refused |
| + by nothing but the docs, and ends any allocation still on the old |
| + `kid`, which is the force-retirement case §2 excludes from its |
| + guarantee. |
| +6. After `retire_at` the operator removes the line at leisure. |
| |
| **App deletion** removes `relay.json` with the rest of the app prefix, |
| -and the reconcile drops the app from the aggregate within the hour, or |
| -at once if the deletion walk calls the rebuild, which the plan has it do. |
| +and the deletion walk publishes the aggregate so the app is gone from |
| +authority at once; the reconcile would catch it within five minutes |
| +otherwise. |
| |
| ## 6. Infrastructure |
| |
| @@ -413,92 +520,100 @@ at once if the deletion walk calls the rebuild, which the plan has it do. |
| optionally `CARLOS_RELAY_PUBLIC_IP` in `host.env`. |
| - Console config gains `CARLOS_RELAY_REGIONS` and one |
| `CARLOS_RELAY_MASTERS_<REGION>` secret per region. |
| -- Egress cost: every relayed byte leaves the box once. The |
| +- Egress cost: every forwarded byte leaves the box once. The |
| unit-economics spec gets a line for relay egress per region, and the |
| - `out` meter in §3 is what fills it. |
| + two meters in §3 are what fill it. |
| |
| ## 7. Testing |
| |
| - `internal/relay`: derivation and credential against the §2 vector; |
| the `AuthHandler` against a fixture authority record and masters file |
| covering every refusal reason in §4, including a valid credential for |
| - app B presented from a source bound to app A; the permission handler |
| - against each denied range and the allowed EIP; limits against a fake |
| - clock, including an account spreading allocations across apps and an |
| - allocation ended by the byte budget; the meter against wrapped |
| - in-memory sockets. |
| + app B presented from a typed source bound to app A, and a UDP and a |
| + TCP client with the same address string holding separate bindings; |
| + the permission handler against each denied range and the allowed |
| + EIP; the relay socket dropping a packet to the EIP on a non-allocated |
| + port; limits against a fake clock, including an account spreading |
| + allocations across apps, an allocation ended by the byte budget, and |
| + a token bucket dropping a burst; the meter against wrapped in-memory |
| + sockets, checking that rejected peer ingress is not counted as `in` |
| + and that both forwarding legs are counted; the hourly object surviving |
| + a restart. |
| - End to end, on loopback with a fixture master: a pion TURN client |
| allocates, refreshes, and keeps relaying until the credential's |
| expiry, at which point traffic stops with no further client request; |
| the same after a disable written to the fixture authority record; |
| two clients on one relay reaching each other's relay candidates; a |
| rotation, by rewriting the masters file, during which an allocation |
| - on the old kid carries traffic uninterrupted; a lease lapse ending |
| - allocations and a successful read restoring service. |
| + on the old kid carries traffic uninterrupted, then a `retire_at` |
| + ending it; a lease lapse ending allocations and a successful read |
| + restoring service. |
| - `internal/host`: the unit is embedded, written and started by the |
| bootstrap plan when the staging root carries a masters file, and not |
| - started when it does not; the adopt restart path names the relay. |
| -- `internal/console`: the three doors through `rawReq` and a `relayURL` |
| + started when it does not; the adopt restart path names the relay; the |
| + config sync writes a config-ack. |
| +- `internal/console`: the doors through `rawReq` and a `relayURL` |
| fixture: the byte-identical 404, the no-regions refusal, each row of |
| the transition table, the config entry written and removed, the |
| - aggregate after concurrent enable and disable of two apps, and the |
| - reconcile repairing an aggregate that disagrees with the records. |
| + aggregate after concurrent enable and disable of two apps with the |
| + etag-first ordering, an enable interrupted after `phase: config` |
| + finished by the reconcile, a superseded writer refused by its CAS, |
| + and `rotate` refusing until the fixture heartbeats list the kid. |
| - `docs/site/cli.md` gains `### carlos relay` with a fenced example, or |
| the docsite gate fails. |
| -- Flagship gate, from the roadmap with the corrections above: a Meet |
| - call between two symmetric-NAT networks connecting through the |
| - Ireland relay; both browsers forced to relay and connecting through |
| - it; a credential minted for one app refused for another, including on |
| - an existing allocation's tuple; a credential from before a disable |
| +- Flagship gate, as amended in the roadmap: a Meet call between two |
| + symmetric-NAT networks connecting through the Ireland relay; both |
| + browsers forced to relay and connecting through it **on the real |
| + EIP**, and a probe to a non-allocated port on the EIP dropped; a |
| + credential minted for one app refused for another, including on an |
| + existing allocation's tuple; a credential from before a disable |
| refused after re-enable and its allocation ended; traffic stopping at |
| a credential's expiry with the browser doing nothing; a relay with a |
| lease older than fifteen minutes refusing new allocations and ending |
| carried ones; a permission to a private address refused; the relay |
| - meter chunk showing the call's bytes; a rotation during a call with |
| - the call unaffected; the relay dying first under memory pressure. |
| + meter object showing the call's bytes on both legs; a rotation |
| + during a call with the call unaffected; the relay shedding and then |
| + being killed first under memory pressure with the edge still serving. |
| |
| ## 8. Not built |
| |
| -- **TURN over TLS (5349 or 443).** Certificate issuance for a non-HTTP |
| - listener needs the relay hostname registered with the deployment's |
| - ACME issuer, which is elected and may be another box |
| +- **TURN over TLS (5349 or 443)** **(ruling 4)**. Certificate issuance |
| + for a non-HTTP listener needs the relay hostname registered with the |
| + deployment's ACME issuer, which is elected and may be another box |
| (`internal/edge/certsync.go`), and its warmer excludes such names |
| (`internal/edge/certwarm.go`); a listener that cannot start until a |
| certificate exists cannot trigger issuance by being visited. That is |
| - its own small design. UDP and TCP on 3478 cover Meet's floor; the |
| - networks that block 3478 mostly block 5349 too, and 443 needs a second |
| - address per box. **(codex R1-15; §9)** |
| -- **An inter-region byte meter.** Classifying a peer address by AWS |
| - region needs the published IP ranges on the box and a rule for |
| - non-AWS peers; version one meters bytes in and out per app, which is |
| - what the bill is made of. **(codex R1-18; §9)** |
| + its own small design, to be written when a network that needs it is |
| + reported. 443 additionally needs a second address per box. The |
| + roadmap's stage 4a text is amended to match. **(codex R1-15, R2-10)** |
| +- **An inter-region byte meter** **(ruling 5)**. Classifying a peer |
| + address by AWS region needs the published IP ranges on the box and a |
| + rule for non-AWS peers; version one meters what is forwarded in each |
| + direction, which is what the bill is made of. Filed for stage 5 with |
| + the usage view; the roadmap's meters and gate are amended. |
| + **(codex R1-18, R2-10)** |
| - An issuance API: apps mint offline from delivered keys. |
| -- Client-aware relay selection: the list is ordered at enable; ICE picks. |
| +- Client-aware relay selection: the list is in console order; ICE picks. |
| - Commercial quotas and an app-page usage view: the meters exist, the |
| page is stage 5. |
| - An SFU: stage 4b, after this ships. |
| - Any change to how apps signal: SSE plus POST stays Meet's. |
| |
| -## 9. Decisions for Paul |
| - |
| -1. **Credential life four hours, not one.** The roadmap's text said one |
| - hour. Expiry is now a hard deadline the relay enforces by ending the |
| - allocation, so one hour would end any call longer than that unless |
| - the app re-mints and restarts ICE. Recommended: four. |
| -2. **Enable is member-level, no operator grant.** The operator's consent |
| - is the region list in console config and the limits in §3. |
| - Recommended. |
| -3. **The console holds every region's master as sealed config**, with |
| - the exposure stated in §4: a compromised host can read them all. The |
| - alternative, relays deriving and writing app keys themselves, is |
| - named there and deferred. Recommended for version one, with the |
| - exposure written into the operator docs. |
| -4. **No TURN over TLS in version one.** Recommended, for the reasons in |
| - §8; it returns as its own small spec when a network that needs it is |
| - reported. |
| -5. **No inter-region meter in version one.** The roadmap named three |
| - meters; this ships two. Recommended, with the third filed for stage |
| - 5. |
| -6. **Limits' defaults** (64 and 30 a minute per app, 256 per account, |
| - 2048 per box, 20 GB an hour per app). Numbers to tune; the |
| - mechanisms are the decision. |
| +## 9. Rulings |
| + |
| +Paul ruled on each of these on 2026-09-07. **(codex R2-10)** |
| + |
| +1. **(ruling)** Maximum credential life is four hours. The roadmap's |
| + one-hour figure is amended. |
| +2. **(ruling)** Enable is member-level; the operator's consent is the |
| + region list and the limits. |
| +3. **(ruling)** The console holds every region's master as sealed |
| + config; the exposure is stated in §4 and the operator docs; the |
| + relay-derives-and-writes design is filed as the tightening. |
| +4. **(ruling)** No TURN over TLS in version one; the roadmap's stage 4a |
| + ingress text is amended. |
| +5. **(ruling)** Two meters in version one, in and out; the inter-region |
| + meter and the usage line move to stage 5; the roadmap's metering |
| + text and gate are amended. |
| +6. **(ruling)** The limits in §3 with their stated defaults, including |
| + the token buckets. |