carlos / platform Public

spec: stage 4a relay draft and codex brief

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 3d453c6b1f50b9c1fe8dc0a835e688e913e8c548 parent 8291cb2
2 files changed, +417 −0
  • docs/superpowers/specs/2026-09-07-relay-stun-turn-design.md +348 −0
  • docs/superpowers/specs/CODEX-REVIEW-BRIEF-RELAY.md +69 −0
diff --git a/docs/superpowers/specs/2026-09-07-relay-stun-turn-design.md b/docs/superpowers/specs/2026-09-07-relay-stun-turn-design.md
new file mode 100644
index 000000000..595edafad
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-07-relay-stun-turn-design.md
@@ -0,0 +1,348 @@
+# Stage 4a: a platform STUN and TURN relay
+
+**Date:** 2026-09-07
+**Status:** draft for codex review, then Paul. Stage 4a of the media
+roadmap (`2026-09-07-media-platform-roadmap-design.md`), built under
+rulings 8, 10 and 11 there. One parameter differs from the roadmap's
+text and is flagged in §9 for Paul: credential life is four hours, not
+one.
+
+**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
+property of the product. Without STUN, browsers gather only LAN
+candidates and calls connect on the same network only. A platform STUN
+listener is the smallest thing that makes Meet work across the
+internet; TURN on the same listener is what makes the one call in ten
+that cannot hole-punch connect anyway. Meet is blocked on nothing else
+(Meet session, 2026-09-07), and it has reviewed the app-facing contract
+in §2.
+
+## 1. What is built
+
+One new platform unit, `carlos-relay.service`, on every region box that
+the operator enables, running `carlos relay serve` from the platform
+binary, built on `pion/turn` v4, pure Go. 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, meters what it relays 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 key per region, staged in `host.env` like the other
+platform secrets. A security-group change opening the relay's ports. A
+DNS name per region relay.
+
+Not built: an SFU, recording, an issuance API, client-side relay
+selection, TURN over TLS on 443, commercial quotas. §8 lists them.
+
+## 2. The app-facing contract
+
+This is what Meet codes against. It is fixed by this spec; the rest of
+the design may change without touching it.
+
+**Environment.** Two variables reach the instance through the sealed
+`config.json` path, the way store credentials do:
+
+- `CARLOS_RELAY`: a JSON array, one element per region the app may use,
+ nearest first (the instance's own region first, then the rest of the
+ app's allowed list in the console's order). Absent or empty means no
+ relay: the app shows its floor.
+
+ [{"region":"eu-west-1",
+ "urls":["stun:relay.eu-west-1.oncarlos.com:3478",
+ "turn:relay.eu-west-1.oncarlos.com:3478?transport=udp",
+ "turn:relay.eu-west-1.oncarlos.com:3478?transport=tcp",
+ "turns:relay.eu-west-1.oncarlos.com:5349"],
+ "key":"<base64, 32 bytes>"}]
+
+- `CARLOS_RELAY_INCARNATION`: sixteen lowercase hex characters. It is
+ not per region.
+
+**Minting, per call.** The app computes, for the region it wants:
+
+ username = "<expiry>:<account>:<app>:<incarnation>"
+ credential = base64std( HMAC-SHA256( key, username ) )
+
+where `expiry` is a Unix time in seconds no more than four hours ahead,
+`account` is the account's sqid (alphabet `[a-z0-9]`), `app` is the app
+name (`^[a-z][a-z0-9-]{0,31}$`), and `incarnation` is the value above.
+None of the fields can contain a colon, so the relay parses by splitting
+on `:` into exactly four fields. The app hands the browser an
+`RTCIceServer` list: `stun:` entries bare, `turn:` and `turns:` entries
+carrying `username` and `credential`. STUN needs no credential.
+
+**Expiry** is checked by the relay on every authenticated request:
+allocate and every refresh, permission and channel bind. A credential
+that expires mid-call ends that call's relay at the next refresh, which
+pion sends every few minutes. Four hours covers any meeting; an app
+that wants longer re-mints and performs an ICE restart before the hour
+is up, and Meet does not need to. **(differs from the roadmap's one
+hour; §9)**
+
+**What changes the incarnation.** Only `carlos relay disable` followed by
+`carlos relay enable`. Deploys, restarts, key rotation and region changes
+do not. An in-flight credential survives everything except disable.
+
+**What changes the key.** Region master rotation (§5) and the
+incarnation. Either re-renders the app's config, which restarts the
+instance within about a minute, as any config change does today. The
+relay accepts the previous master for twenty-four hours after rotation,
+so a credential minted before the restart keeps working for its life.
+
+**Test vector.** Master is `SHA-256("carlos-relay-test-master")`,
+account `abc`, app `meet`, incarnation `0123456789abcdef`, expiry
+`1900000000`:
+
+ master 841d374df509340d6ab18a139a27ed0d9c5d61d0bdf306e4124ff913a7a92fb5 (hex)
+ key uMhwW4P/9O5dQUIs91t2CCHGhwJbeiM0xQMQ2G0CJLU=
+ username 1900000000:abc:meet:0123456789abcdef
+ credential khBx1BoommS1C69pSAgv0r0TM2H9IKDDE0M9OVr7VXQ=
+
+The key derivation is in §4; an app never needs it, only the key it is
+given. The vector is checked into `internal/relay/vector_test.go` and
+Meet's minting test uses the same numbers.
+
+**Logging.** The app logs which region it used and nothing else about
+the credential. The relay logs the account, app and outcome of an
+authentication, never the credential or the derived key.
+
+## 3. The relay unit
+
+`carlos-relay.service` is a §3 media service in the roadmap's terms: a
+network-facing platform unit like the edge, not a codec helper.
+
+- **Process.** `carlos relay serve`, from the platform binary, so it
+ rides the same release, promote and adopt path as the edge. A new
+ file in `internal/host/units/`, added to `enabledUnits` in
+ `bootstrap.go` under a guard: the unit is written on every box but
+ armed only where `host.env` carries `CARLOS_RELAY_MASTERS`, so a box
+ without a master never listens. Bootstrap is idempotent and converges
+ an existing box the way it does for every unit.
+- **Hardening.** Copied from `carlos-edge.service` and then cut down: no
+ `CAP_NET_BIND_SERVICE` (3478 and 5349 are unprivileged), `User=carlos`,
+ `ProtectSystem=strict`, `ReadWritePaths=/data/relay /run/carlos`,
+ `RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX`,
+ `MemoryHigh=20%`, `MemoryMax=25%`, `TasksMax=512`, `Restart=always`.
+ It shares the box with the edge and must lose first: `Nice=5` and
+ `CPUWeight=50` against the edge's default.
+- **Listeners.** UDP 3478 and TCP 3478 on the box's primary address; TLS
+ 5349 when a certificate for the relay hostname is present. The relay
+ allocation range is UDP 49152 to 65535, configured as
+ `RelayAddressGeneratorPortRange` with `RelayAddress` set to the box's
+ Elastic IP, which the process learns the way the edge does, from
+ IMDS (`internal/host/agent.go:1761`), with `CARLOS_RELAY_PUBLIC_IP` as
+ the override for a box without IMDS.
+- **Certificate for 5349.** The relay hostname is a platform host the
+ edge already knows how to mint a certificate for; the edge adds
+ `relay.<aws region>.<platform domain>` to its allowed set on boxes
+ with a master, and the relay reads the certificate from the edge's
+ autocert cache read-only, reloading when the file changes. If no
+ certificate is present yet, the TLS listener is not started and the
+ relay logs once; `turns:` is still advertised, because the browser
+ falls through to the other URLs.
+- **STUN.** Binding requests are answered on every listener without
+ authentication, which is pion's default and the whole point of §0.
+- **Authentication.** A custom `AuthHandler` (§4). pion calls it on every
+ authenticated request, which is where expiry is enforced.
+- **Peer policy.** A `PermissionHandler` that refuses `CreatePermission`
+ and `ChannelBind` to loopback, link-local, RFC 1918 and unique-local
+ ranges, the box's own VPC CIDR (from `CARLOS_RELAY_DENY_CIDRS`, set by
+ the operator from tofu output), and the relay's own addresses.
+ Refusals are counted per app.
+- **Limits.** Per app, per box, in the relay process: at most 64 live
+ allocations, at most 30 new allocations a minute, allocation lifetime
+ capped at ten minutes so a refresh is required (pion's default), and
+ a byte budget of 20 GB per hour after which new allocations for that
+ app are refused with 486 (allocation quota reached) until the hour
+ rolls. Per box: at most 2048 allocations. These are operational
+ limits, tuned by env with those defaults; commercial quotas are out
+ of scope.
+- **Metering.** The relay wraps its UDP and TCP connections to count
+ bytes in and bytes out per allocation, attributes them to the
+ allocation's account and app, and once a minute appends a sample to
+ the box's metrics chunks alongside the per-unit samples the metrics
+ loop already flushes (`internal/host/metrics_loop.go`). The sample
+ carries `relay_in`, `relay_out` and `relay_allocs` per app. Reading
+ those samples into an app page is stage 5 work and not in this spec;
+ the numbers exist from the first byte relayed.
+- **Authority record.** The relay reads `control/relay.json` (§5) from
+ the deployment bucket on start and whenever `control/serial` changes,
+ through the same fast-converge poll the host agent already runs
+ (`internal/host/agent.go:1304`). If the record it holds is older than
+ fifteen minutes it refuses new allocations and ends the ones it is
+ carrying, per the roadmap's serving lease. A relay with no record at
+ all serves STUN only.
+
+## 4. Keys and verification
+
+**Region master.** Thirty-two random bytes per region, generated by the
+operator with `carlos relay keygen` and staged as
+`CARLOS_RELAY_MASTERS="<kid>:<base64>[,<kid>:<base64>]"` in that
+region's `host.env`, the way `CARLOS_SECRETS_KEY` is staged today. The
+console holds every region's masters as sealed console secrets,
+`CARLOS_RELAY_MASTERS_<REGION>`, so it can derive app keys at enable.
+Two copies, both staged by hand, both rotated by hand; §5 gives the
+procedure. There is no bucket copy of a master, sealed or otherwise,
+because sealed config is decryptable on every host (roadmap §1).
+
+**Derived key.** For an app, region and incarnation:
+
+ key = HKDF-SHA256( ikm = master,
+ salt = "carlos-relay-v1",
+ info = account || 0x00 || app || 0x00 || incarnation,
+ length = 32 )
+
+**Verification**, in the relay's `AuthHandler(username, realm, src)`:
+
+1. Split the username on `:` into exactly four fields; anything else is
+ refused.
+2. Parse `expiry` as a decimal Unix time; refuse if it is in the past or
+ more than four hours ahead.
+3. Look up `account/app` in the authority record; refuse if absent, if
+ the app's incarnation differs, or if this relay's region is not in
+ the app's allowed list.
+4. For the current master, then the previous one if within its
+ twenty-four-hour grace: derive the key, compute the expected
+ credential, and return `turn.GenerateAuthKey(username, realm,
+ credential)`. pion verifies `MESSAGE-INTEGRITY` against that key, so
+ a wrong credential fails inside pion; the handler itself never sees
+ the credential the client sent.
+5. Refuse with the reason counted per app: bad shape, expired, unknown
+ app, wrong incarnation, wrong region.
+
+The MD5 inside `GenerateAuthKey` is the long-term credential mechanism
+RFC 8489 mandates and is not ours to choose; the HMAC-SHA256 above is.
+
+## 5. Enable, disable, rotate
+
+**Records.** Two, both additive:
+
+- `apps/<acct>/<app>/relay.json`, CAS-guarded like `store.json`, holding
+ `{enabled, incarnation, regions, enabled_at, disabled_at}`. The
+ incarnation is sixteen hex characters drawn at random at each enable,
+ 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}`, rewritten by
+ the console from the per-app records on every enable and disable,
+ followed by a `control/serial` bump so every box notices within
+ seconds.
+
+**`carlos relay enable [--regions r1,r2]`** (member, API-mode only, on
+the store model in `store.go` and `internal/console/apistore.go`):
+
+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; `--regions` narrows.
+4. Write `relay.json` with a fresh incarnation and the region list.
+5. Derive one key per region from that region's current master and
+ write `CARLOS_RELAY` (as `kind:"secret"`, since it carries keys) and
+ `CARLOS_RELAY_INCARNATION` (as `kind:"env"`) through
+ `appconfig.Apply`, the `writeEnv` shape in `internal/objstore/walk.go`.
+ That bumps the app's config generation; the box re-renders and
+ restarts the instance within about a minute.
+6. Rewrite `control/relay.json`, bump `control/serial`.
+7. Answer with the record; the CLI prints regions, incarnation and
+ "config delivered, instance restarting".
+
+**`carlos relay status`** prints the record and, per region, whether
+that region's relay has acknowledged the current `control/relay.json`
+(each relay writes `control/relay-ack/<box>.json` with the
+`written_at` it holds, every minute), so a member can see "eu-west-1:
+current, ap-southeast-2: 3 minutes behind" without a box command.
+
+**`carlos relay disable`** clears the two env entries, marks the record
+disabled, rewrites `control/relay.json` without the app, bumps the
+serial. Every relay ends the app's allocations on its next record read.
+
+**Rotation** (`carlos relay rotate --region r`, operator):
+
+1. Operator runs `carlos relay keygen`, adds the new `kid:key` in front
+ of the old one in that region's `host.env`, restarts
+ `carlos-relay.service`; the relay now accepts both, new first.
+2. Operator updates the console secret for that region the same way.
+3. `carlos relay rotate` re-derives and re-delivers keys for every app
+ enabled in that region; each instance restarts within a minute.
+4. After twenty-four hours the operator removes the old entry from
+ `host.env` and the console secret. Any credential minted from the old
+ key has expired by then.
+
+**App deletion** removes `relay.json` with the rest of the app prefix
+and the console's deletion walk drops the app from `control/relay.json`.
+
+## 6. Infrastructure
+
+- `infra/modules/region-host/main.tf`: a `relay` variable, default off,
+ that adds ingress for UDP 3478, TCP 3478, TCP 5349 and UDP 49152 to
+ 65535 from `0.0.0.0/0`, and outputs the VPC CIDR for
+ `CARLOS_RELAY_DENY_CIDRS`. Flagship turns it on per region.
+- DNS: an A record `relay.<aws region>.<platform domain>` to the region's
+ Elastic IP, next to the existing EIP resource. One line per region.
+- `host.env` on each enabled region box gains `CARLOS_RELAY_MASTERS`
+ and `CARLOS_RELAY_DENY_CIDRS`.
+- 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
+ unit-economics spec gets a line for relay egress per region, and the
+ meters in §3 are what fill it.
+
+## 7. Testing
+
+- `internal/relay`: the key derivation and credential functions against
+ the §2 test vector; the `AuthHandler` against a fixture authority
+ record covering every refusal reason; the permission handler against
+ each denied range; the limits against a fake clock; the meter against
+ a wrapped in-memory conn.
+- An end-to-end test that starts the relay on loopback with a fixture
+ master, mints a credential with the app-side code, and drives a pion
+ TURN client through allocate, refresh past expiry, and a permission
+ to a denied peer.
+- `internal/host`: the unit is embedded and written by bootstrap
+ (`TestUnitsEmbedComplete`, `TestBootstrapWritesUnitsToStagingDir`),
+ and armed only when the staging `host.env` carries a master.
+- `internal/console`: the three doors through `rawReq` and a
+ `relayURL` fixture, asserting the byte-identical 404, the no-regions
+ refusal, the config entries written, and `control/relay.json` after
+ enable and disable.
+- `docs/site/cli.md` gains `### carlos relay` with a fenced example, or
+ the docsite gate fails.
+- Flagship gate, from the roadmap: a Meet call between two symmetric-NAT
+ networks connecting through the Ireland relay; a credential minted for
+ one app refused for another; a credential from before a disable
+ refused after re-enable and its allocation ended; a relay with a
+ record older than fifteen minutes refusing new allocations and ending
+ carried ones; a permission to a private address refused; the meters
+ moving; a 443-only network failing as documented.
+
+## 8. Not built
+
+- TURN over TLS on 443: needs a second address per box.
+- An issuance API: apps mint offline from delivered keys; the alternative
+ is named in the roadmap if the trade ever changes.
+- Client-aware relay selection: the list is ordered by the instance's
+ region; 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 recommendation
+ said one hour. pion enforces expiry on every refresh, so a one-hour
+ credential drops the relay from any call longer than an hour unless
+ the app re-mints and restarts ICE. Four hours makes that Meet's
+ problem never. Recommended.
+2. **Enable is member-level, no operator grant.** The store model gates
+ `grant` on operators because it spends the deployment's cloud
+ account. Enabling a relay spends bandwidth, bounded by the §3 limits,
+ and the operator's consent is the region list in console config.
+ Recommended over a per-app operator step that Meet would wait on.
+3. **Limits' defaults** (64 allocations, 30 a minute, 20 GB an hour per
+ app per box). Numbers to tune; the mechanism is the decision.
+4. **`turns:` advertised before its certificate exists.** Harmless, since
+ browsers fall through, and it means no config re-render when the
+ certificate lands. Recommended.
diff --git a/docs/superpowers/specs/CODEX-REVIEW-BRIEF-RELAY.md b/docs/superpowers/specs/CODEX-REVIEW-BRIEF-RELAY.md
new file mode 100644
index 000000000..2a17ca9ba
--- /dev/null
+++ b/docs/superpowers/specs/CODEX-REVIEW-BRIEF-RELAY.md
@@ -0,0 +1,69 @@
+# Review brief: stage 4a relay spec
+
+You are GPT-6 reviewing a stage design spec independently of its author.
+Your output is a findings report, not an edit. Do not modify any file.
+
+## What to read
+
+1. The spec under review:
+ `docs/superpowers/specs/2026-09-07-relay-stun-turn-design.md`.
+2. Its parent, which it must honour: the media roadmap
+ `docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md`,
+ especially §1, §3, stage 4a, §7 rulings 8, 10 and 11, and §9's stage
+ 4a gate. Rulings are fixed; report a mechanism that fails one, do not
+ re-argue one.
+3. The code it fits into, in this worktree: `internal/host/bootstrap.go`
+ and `internal/host/units/carlos-edge.service` (units, hardening,
+ `enabledUnits`); `internal/host/agent.go` (IMDS reads near line 1761,
+ fast-converge loop near 1304, config generation loop near 985);
+ `internal/host/edgesfile.go`; `internal/host/metrics_loop.go`;
+ `internal/objstore/walk.go` (`writeEnv` near 554) and
+ `internal/appconfig/` (Apply, generation, sync); `store.go`,
+ `internal/console/apistore.go`, `internal/console/apicli.go`
+ (`apiAccountApp`); `internal/release/serial.go`;
+ `infra/modules/region-host/main.tf`; `go.mod`; `AGENTS.md`.
+4. pion/turn v4's API as documented at pkg.go.dev
+ (`ServerConfig`, `AuthHandler`, `PermissionHandler`, `QuotaHandler`,
+ `EventHandler`, `RelayAddressGeneratorPortRange`, `GenerateAuthKey`).
+ Check the spec's claims about when pion calls the auth handler and
+ what it does with the returned key.
+5. The suite doctrine ruling 6 and §8 at
+ `/home/paulca/amadan.net/oficina/suite/docs/superpowers/specs/2026-09-05-suite-sharing-doctrine.md`.
+
+## What matters most
+
+- **The app-facing contract in §2** is what the Meet app will code
+ against today. Is it complete, unambiguous and implementable from the
+ text alone? Does the test vector follow from §4 exactly? Is anything
+ the app needs missing?
+- **Verification in §4.** Look for a way to obtain a TURN allocation
+ without a key an enabled app was given, to use one app's credential
+ for another, to keep using a credential after disable, or to make the
+ relay accept something the authority record does not cover. Check the
+ interaction with pion's long-term credential mechanism.
+- **Key custody in §4 and §5.** Masters in `host.env` and as console
+ secrets, two hand-staged copies. Is the exposure stated honestly and
+ consistently with roadmap §1's statement that sealed config is
+ decryptable on every host? Does rotation as written ever leave a
+ window where a credential stops working or an old master is accepted
+ too long?
+- **The unit in §3.** Hardening, ports, the allocation range, the EIP
+ as relay address, the certificate for 5349, the authority record and
+ its fifteen-minute lease. Is anything unbuildable as written on a
+ `t4g.small` sharing the box with the edge?
+- **Limits and metering.** Can one app starve another, exhaust the port
+ range, or relay to somewhere it should not? Can the meters be
+ attributed wrongly?
+- **Claims about the code.** Every `file:line` and every "already
+ exists" claim: check it. Report each one that is wrong.
+- **Operability.** Can a member, with no box access, enable, see status
+ and disable? Can an operator rotate a master without a call dropping?
+
+## How to report
+
+Number findings R1-1 onward. For each: severity (P1 breaks a trust
+guarantee or makes the stage unbuildable; P2 a contract or claim defect
+to fix before the plan; P3 advisory), the section, the problem, the
+evidence with citations, a proposed correction. End with a verdict:
+accept, accept with corrections, or request changes. Do not soften, do
+not pad, do not restate the spec.