carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1# Stage 4a: a platform STUN and TURN relay
2
3**Date:** 2026-09-07, revision 6
4**Status:** rulings taken. Paul ruled on the six decisions in §9 on
52026-09-07, each marked **(ruling)**; the roadmap's stage 4a text and
6gate are amended so the two documents agree. Reviewed five times by
7GPT-6 via Codex: revision 1's 20 findings are in
8`CODEX-FINDINGS-RELAY.md`, revision 2's 12 in
9`CODEX-FINDINGS-RELAY-R2.md`, revision 3's 8 in
10`CODEX-FINDINGS-RELAY-R3.md`, revision 4's 3 in
11`CODEX-FINDINGS-RELAY-R4.md`, revision 5's 1 in
12`CODEX-FINDINGS-RELAY-R5.md`, unedited; every one is taken, marked
13**(codex R1-N)** through **(codex R5-N)**. **Round 6 returned accept**
14with no findings (`CODEX-FINDINGS-RELAY-R6.md`). Stage 4a of the media roadmap
15(`2026-09-07-media-platform-roadmap-design.md`), built under rulings 8,
1610 and 11 there.
17
18**Why now.** Oficina's Meet is peer-to-peer WebRTC and cannot name a
19third-party STUN server, because suite ruling 6 forbids egress that is a
20property of the product. Without STUN, browsers gather only LAN
21candidates and calls connect on the same network only. A platform STUN
22listener is the smallest thing that makes Meet work across the
23internet; TURN on the same listener is what makes the one call in ten
24that cannot hole-punch connect anyway. Meet is blocked on nothing else
25(Meet session, 2026-09-07), and it has reviewed the app-facing contract
26in §2.
27
28## 1. What is built
29
30One new platform unit, `carlos-relay.service`, on every region box the
31operator arms, running `carlos relay serve` from the platform binary,
32built on `pion/turn` v4.1.4, pure Go, which fetches and cross-compiles
33clean for the CI shards (checked 2026-09-07). It answers STUN binding
34requests to anyone and TURN allocations to holders of a credential an
35enabled app minted. It learns which apps are enabled from a record the
36console writes, binds every allocation to the identity that made it,
37ends allocations when their credential, app, region, master or its own
38authority lapses, meters what it forwards per app, and refuses peers on
39private networks.
40
41Three console doors and a CLI verb, `carlos relay enable|status|disable`,
42that turn the relay on for an app and deliver its keys as sealed config,
43plus an operator verb `carlos relay rotate`. An operator-side master key
44per region, staged in a file on the region's boxes and, as a console
45secret, in the console. A config-adoption acknowledgement the host's
46config sync writes, so status can say "adopted" truthfully. A
47security-group change opening the relay's ports. A DNS name per region
48relay.
49
50Not built, by ruling: TURN over TLS, an inter-region byte meter. Not
51built, by scope: an SFU, recording, an issuance API, client-side relay
52selection, commercial quotas. §8 lists them with reasons.
53
54## 2. The app-facing contract
55
56This is what Meet codes against. It is fixed by this spec; the rest of
57the design may change without touching it.
58
59**Environment.** One variable reaches the instance through the sealed
60`config.json` path, the way store credentials do. Because the config
61renderer refuses quotes, backslashes and newlines in any value
62(`internal/appconfig/materialize.go:23`), the value is the **standard
63base64 encoding, with padding, of a JSON document**; the app decodes
64the base64, then parses the JSON. **(codex R1-1)**
65
66- `CARLOS_RELAY`: base64 of a JSON array, one element per region the
67 app may use, in the console's order (§5). Absent or empty means no
68 relay: the app shows its floor.
69
70 [{"region":"eu-west-1",
71 "urls":["stun:relay.eu-west-1.oncarlos.com:3478",
72 "turn:relay.eu-west-1.oncarlos.com:3478?transport=udp",
73 "turn:relay.eu-west-1.oncarlos.com:3478?transport=tcp"],
74 "key":"<standard base64, with padding, of 32 raw bytes>",
75 "username_suffix":"abc:meet:0123456789abcdef:k1"}]
76
77 `region` is the AWS region name, which is also the vocabulary of the
78 relay hostnames. `username_suffix` is `<account>:<app>:<incarnation>:
79 <kid>`, supplied by the platform so the app never has to know its own
80 canonical account sqid or app name. **(codex R1-11, R1-12)** There is
81 no separate incarnation variable; the suffix carries it. The app
82 builds its ICE server list from whatever `urls` are present and
83 assumes nothing about their number or schemes.
84
85**Minting, per call.** For the region it wants, the app computes:
86
87 key = base64_decode( element.key ) // 32 raw bytes
88 username = "<expiry>:" + element.username_suffix // ASCII
89 credential = base64_std( HMAC-SHA256( key, username ) )
90
91where `expiry` is a decimal Unix time in seconds, at most four hours
92ahead **(ruling 1)**. The username has exactly five colon-separated
93fields: `expiry` (`[0-9]+`), `account` (`[a-z0-9]+`), `app`
94(`^[a-z][a-z0-9-]{0,31}$`), `incarnation` (`[0-9a-f]{16}`) and `kid`
95(`[a-z0-9]{1,8}`). No field can contain a colon. The app hands the
96browser an `RTCIceServer` list: `stun:` entries bare, `turn:` entries
97carrying `username` and `credential`. STUN needs no credential.
98
99**Expiry.** The relay checks the expiry on every authenticated request
100the browser's ICE agent sends (allocate, refresh, permission, channel
101bind) **and** ends the allocation at the expiry regardless of what the
102client sends (§3, "termination"), so a credential's deadline is a hard
103deadline on relayed traffic. An app that needs a call to outlive its
104credential re-mints and performs an ICE restart before the deadline.
105Four hours is the maximum, not a promise about meeting lengths.
106**(codex R1-4, R1-20)**
107
108**What changes the incarnation.** Only `carlos relay disable` followed by
109`carlos relay enable`. A repeated enable, a region change, a deploy, a
110restart and a master rotation all keep it. **(codex R1-13)**
111
112**What changes the key.** A master rotation in a region changes that
113region's key and `kid`; a new incarnation changes every region's key.
114Either re-renders the app's config within about a minute, as any config
115change does today, and what happens next depends on the route's shape
116(`internal/appconfig/sync.go:30`, `syncexec.go:1`): a unit-backed route
117and a socket-backed exec route are restarted onto the new file; a
118TCP-address exec route whose bundle opts into file delivery receives
119the file and is **not** restarted, because its process belongs to an
120external supervisor CARLOS cannot bounce, so the app must re-read it
121and then tell the platform it has (`carlos relay adopted`, §5); a
122TCP-address route without file delivery receives nothing, and **an app
123with such a route cannot enable the relay** until it opts that route
124into file delivery or moves it to a unit. `carlos relay status` names
125which of these each route is. **(codex R1-10, R1-14, R2-6, R3-4, R5-1)**
126
127**A credential keeps working for its whole life** provided the master
128that signed its key is not force-retired before its expiry. The
129rotation procedure in §5 guarantees that when followed: the old master
130is retired no earlier than four hours after the last instance stopped
131minting from it, as observed by the console, and no earlier than 28
132hours after rotation regardless. **(codex R2-4)**
133
134**Test vector.** Master is `SHA-256("carlos-relay-test-master")`,
135account `abc`, app `meet`, incarnation `0123456789abcdef`, kid `k1`,
136expiry `1900000000`:
137
138 master 841d374df509340d6ab18a139a27ed0d9c5d61d0bdf306e4124ff913a7a92fb5 (hex)
139 key uMhwW4P/9O5dQUIs91t2CCHGhwJbeiM0xQMQ2G0CJLU=
140 username 1900000000:abc:meet:0123456789abcdef:k1
141 credential jRU479Y3GO45nrRHqxBf3BMeBHE6Fip1cwAccNv28MQ=
142
143The key derivation is in §4; an app never needs it, only the key it is
144given. The vector will be checked into `internal/relay/vector_test.go`
145by the plan, and Meet's minting test uses the same numbers.
146
147**Logging.** The app logs which region it used and nothing else about
148the credential. The relay logs the account, app and outcome of an
149authentication, never the credential or a key.
150
151## 3. The relay unit
152
153`carlos-relay.service` is a media service in the roadmap's §3 terms: a
154network-facing platform unit like the edge, not a codec helper.
155
156- **Process.** `carlos relay serve`, from the platform binary, so it
157 rides the same release and promote path as the edge. A new file in
158 `internal/host/units/`, embedded and written by bootstrap like every
159 unit, with `ConditionPathExists=/etc/carlos/relay-masters` so a box
160 without a master file never listens. Added to `enabledUnits` and to
161 the list of units the systemctl plan **starts**, not only enables,
162 and to the adopt restart path beside `restartEdge`
163 (`internal/host/systemapply.go:568`), so a platform update restarts
164 the relay onto the new binary and a fresh box runs it without a
165 reboot. The staging bootstrap plan prints the same steps without
166 running them, as it does for the edge. **(codex R1-16)**
167- **Hardening and memory.** From `carlos-edge.service` and then cut
168 down: no `CAP_NET_BIND_SERVICE` (3478 is unprivileged),
169 `User=carlos`, `ProtectSystem=strict`, `ReadWritePaths=/data/relay
170 /run/carlos`, `RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX`.
171 Memory: `MemoryMax=256M`, `MemoryHigh=160M`, `OOMScoreAdjust=1000`.
172 What that guarantees, stated exactly: the relay can never take more
173 than 256 MiB of the box; above 160 MiB it refuses new allocations and
174 ends its oldest until it is under; and it is the kernel's preferred
175 victim among processes of similar footprint, since 1000 is the
176 largest adjustment there is. What it does not guarantee: that the
177 relay dies before a tenant under every host-wide pressure. The
178 kernel's score counts swap and page tables as well as RSS, tenants
179 may swap up to their memory cap (`internal/cgroup/cgroup.go:313`),
180 and a kill inside the edge's own 80% cgroup considers only the edge's
181 tree. So the relay does not wait for the kernel: it watches the
182 box's memory pressure (`/proc/pressure/memory`, the `some` ten-second
183 average) and sheds allocations, oldest first, whenever it exceeds
184 20%, whatever caused the pressure, resuming admission when it falls
185 under 10%. The gate in §7 tests that shedding, not a kill order.
186 `Nice=5` and `CPUWeight=50` for CPU. **(codex R1-19, R2-8, R3-7)**
187- **Listeners.** UDP 3478 and TCP 3478 on the box's primary address.
188 No TLS listener **(ruling 4)**. The relay allocation range is UDP
189 49152 to 65535, bound through the relay's own address generator
190 (below) with the box's Elastic IP as the advertised relay address,
191 learned as the edge learns it, from IMDS (`readPublicIP`,
192 `internal/host/agent.go:1808`), with `CARLOS_RELAY_PUBLIC_IP` as the
193 override for a box without IMDS.
194- **STUN.** Binding requests are answered on every listener without
195 authentication, which is pion's default and the reason this stage
196 exists.
197- **The relay owns every socket pion uses.** pion has no public call to
198 end an allocation; its allocation manager is internal and `Server`
199 exposes only `AllocationCount` and `Close`. What it does expose is the
200 `RelayAddressGenerator` that creates each allocation's relay socket
201 (`server_config.go:20`, returning a `net.PacketConn`), the listener
202 sockets the relay passes in, and an `OnAllocationCreated` callback
203 that supplies the relay address before pion starts reading from it
204 (`internal/allocation/allocation_manager.go:140`). pion's read loop
205 deletes the allocation when the relay socket returns an error
206 (`internal/allocation/allocation.go:301`). So the relay implements
207 the generator and wraps both kinds of socket: every relay socket is
208 recorded against its allocation at `OnAllocationCreated` and can be
209 closed by the relay at any time, which ends that allocation inside
210 pion within one read; and both relay and listener sockets count what
211 they forward and apply the token buckets below. That one mechanism is
212 how every deadline is enforced, how metering is attributed, and how
213 bandwidth is bounded. **(codex R1-4, R1-17)**
214- **Owner binding.** pion locates a refresh, permission or channel bind
215 by the client's five-tuple and does not compare the authenticating
216 username to the one that created the allocation
217 (`internal/server/turn.go:34`); it also tags every allocation's tuple
218 as UDP even for TCP clients, so the event's protocol string cannot
219 distinguish transports. The relay keys its binding on the **typed
220 source address**: the Go type of the `net.Addr` it receives
221 (`*net.UDPAddr` or `*net.TCPAddr`) plus its string, which pion passes
222 unchanged to `AuthHandler` and to the events. On
223 `OnAllocationCreated` it records that key to the identity fields
224 (account, app, incarnation); its `AuthHandler` refuses a username
225 whose identity differs from the one bound to a live allocation from
226 that key. Cleanup belongs to the wrapped relay socket, not to
227 `OnAllocationDeleted`, which carries no relay address
228 (`internal/allocation/event_handler.go:20`): each wrapper knows the
229 binding it belongs to, and on `Close`, whether pion closed it or the
230 relay did, it removes that binding only if the map still points at
231 itself, so a replacement allocation bound while the old one was
232 closing (pion removes from its manager before closing,
233 `allocation_manager.go:150`) is never erased. **(codex R1-3, R2-3,
234 R3-3)**
235- **Termination.** An allocation is ended, by closing its relay socket,
236 at the earliest of: its credential's expiry (a timer set at creation
237 from the username's expiry field); the app being disabled, its
238 incarnation changing, or this region leaving its list (on the next
239 authority read); the master named by its `kid` reaching `retire_at`
240 (on the next masters-file read or timer); the relay's authority lease
241 expiring (below); the app or account crossing a byte budget (below);
242 the relay shedding under memory pressure. Ending one allocation ends
243 its permissions and channels with it. pion's own maximum requested
244 lifetime is one hour; the relay does not rely on it. **(codex R1-4,
245 R2-4)**
246- **Authentication.** A custom `AuthHandler` (§4), called by pion on
247 every authenticated request, which is where the username's expiry and
248 the authority record are checked; pion verifies the message integrity
249 against the key the handler returns.
250- **Peer policy.** A `PermissionHandler` refuses `CreatePermission` and
251 `ChannelBind` to loopback, link-local (including the IMDS address),
252 RFC 1918 and unique-local ranges, the box's own private address and
253 VPC CIDR (from `CARLOS_RELAY_DENY_CIDRS`, set by the operator from
254 tofu output), and any other relay's private address. The box's own
255 Elastic IP is allowed by the permission handler, because two clients
256 that both fall back to this relay hold relay candidates on that
257 address and must reach each other, **but only live relay allocation
258 ports are reachable there**: the permission handler sees only an IP,
259 so the wrapped relay socket enforces the port on `WriteTo`, dropping
260 any packet to the EIP whose destination port is not a currently
261 allocated relay port. Nothing else on the box is reachable through
262 the relay, whatever the security group says about source addresses.
263 Refusals and drops are counted per app. **(codex R1-8, R2-9)**
264- **Limits (ruling 6)**, all admitted under one mutex across every
265 listener so counts cannot race, with counters reset on process start
266 and the rolling-hour byte windows rebuilt on start from every hourly
267 metrics object that intersects the window, normally the previous
268 hour's and the current one **(codex R3-6)**:
269 - per app per box: 64 live allocations, 30 new a minute, 20 GB a
270 rolling hour;
271 - per account per box: 256 live, 60 new a minute, 60 GB a rolling
272 hour, whatever the app count;
273 - per box: 2048 live, 500 new a minute;
274 - instantaneous: a token bucket of 8 Mbps per allocation and 200 Mbps
275 per box applied in the wrapped sockets on both forwarding legs,
276 dropping packets beyond the bucket, so a burst is bounded before it
277 reaches the edge's share of the NIC;
278 - crossing a byte budget refuses new allocations with 486 for the
279 app or account and **ends its live allocations**, because a budget
280 that only stops new ones bounds nothing. Failed allocations count
281 against the per-minute rates.
282 These are operational limits with those defaults, tunable by env;
283 commercial quotas are out of scope. **(codex R1-9, R2-7)**
284- **Metering.** Two meters per app and one gauge, measured where pion
285 forwards and only for what it forwards: **(codex R1-17, R1-18, R2-5;
286 ruling 5)**
287 - `in`: payload bytes the relay accepted from the client and forwarded
288 to a peer, counted at the relay socket's `WriteTo`;
289 - `out`: payload bytes the relay forwarded from a peer to the client,
290 counted at the listener socket's `WriteTo` to the client's address,
291 attributed through the owner binding;
292 - `allocs`: live allocations at sample time.
293 Both meters are host egress and are what the bill is made of.
294 Peer bytes that arrive at a relay socket and are dropped for lack of
295 permission are counted separately as `rejected` and never against a
296 budget. Every sixty seconds the relay appends one sample
297 `{at, apps: {"<account>/<app>": {in, out, allocs, rejected}}}`, with
298 `in`, `out` and `rejected` as **deltas since the previous sample**,
299 to its own hourly object `metrics/relay/<box>/<hour>.json`, which it
300 alone writes: read, append, CAS-write, retry on conflict. On restart
301 it reads the current hour's object back, so earlier samples survive
302 and its rolling-hour budgets resume from them. The raw readers and
303 rollups learn the new key in the plan. An inter-region meter is not
304 built (§8).
305- **Authority record and lease.** The relay reads `control/relay.json`
306 (§5) from the deployment bucket **every sixty seconds**, and
307 additionally whenever `control/serial` changes as seen by its own
308 poll of that object (the host agent's `fastConvergeLoop`,
309 `internal/host/agent.go:1437`, is a separate process; the relay runs
310 the same shape of poll itself). The lease clock is the time of the
311 last **successful** read; a failed read renews nothing. If that time
312 is more than fifteen minutes ago the relay refuses new allocations
313 and ends the ones it carries, per the roadmap's serving lease, and
314 resumes on the next successful read. A relay with no record yet
315 serves STUN only. Each read also writes a heartbeat,
316 `control/relay-ack/<box>.json`, carrying the record's `written_at`,
317 the read time, which listeners are up, the `kid`s currently loaded
318 from the masters file, and the last masters-file reload error if
319 any. **(codex R1-5, R1-14, R2-4)**
320
321## 4. Keys and verification
322
323**Region master.** Thirty-two random bytes per region, generated by the
324operator with `carlos relay keygen`, which prints a `kid` and the key.
325On each of the region's boxes they live in `/etc/carlos/relay-masters`,
326root-owned, mode 0640, group `carlos`, one per line:
327
328 <kid> <base64 key> <retire_at RFC 3339 or "-">
329
330The relay reads the file at start and re-reads it when it changes, so
331a rotation never restarts the process. A master past its `retire_at` is
332ignored for new requests and its live allocations are ended (§3).
333**(codex R1-10, R2-4)**
334
335**Where the console's copy lives, and what that exposes (ruling 3).**
336The console must hold every region's masters to derive app keys at
337enable. It holds them as console secrets, `CARLOS_RELAY_MASTERS_<REGION>`
338(region name uppercased with hyphens as underscores, since variable
339names refuse hyphens, `internal/appconfig/appconfig.go:103`), one or
340more `kid:key` pairs per region, of which the one named by the issuer
341pointer `control/relay-issuer.json` is the one keys are derived from
342(§5, rotation); and console secrets are sealed config like any app's. Sealed config is
343decryptable on every host (roadmap §1). So the honest statement is: **a
344compromised host can read every region's master**, not only the derived
345keys the roadmap already conceded, and with a master it can mint
346credentials for any app in that region, present and future, until the
347operator rotates. Per-region masters still mean a relay box itself
348holds only its own region's, and rotation of one region recovers that
349region. The operator docs say this in those words. A design where only
350a region's boxes ever hold its master, the region's relay deriving app
351keys and writing them into config itself, is filed as the tightening
352and is not version one, because it makes the relay a writer of app
353config with all the CAS and ordering that implies. **(codex R1-7,
354R2-10)**
355
356**Derived key.** For an app, region and incarnation:
357
358 key = HKDF-SHA256( ikm = master,
359 salt = "carlos-relay-v1",
360 info = account || 0x00 || app || 0x00 || incarnation,
361 length = 32 )
362
363**Verification**, in the relay's `AuthHandler(username, realm, src)`,
364which pion calls once per authenticated request and which returns one
365key, so the master is chosen from the username, not by trial:
366**(codex R1-2)**
367
3681. Split the username on `:` into exactly five fields; anything else is
369 refused.
3702. Parse `expiry`; refuse if in the past or more than four hours ahead.
3713. If the typed source address has a live allocation bound to a
372 different identity (§3, owner binding), refuse.
3734. Look up `account/app` in the authority record; refuse if absent, if
374 the incarnation differs, or if this relay's region is not in the
375 app's list.
3765. Look up `kid` in the masters file; refuse if absent or retired.
3776. Derive the key, compute the expected credential, and return
378 `turn.GenerateAuthKey(username, realm, credential)`. pion verifies
379 `MESSAGE-INTEGRITY` against that key, so a wrong credential fails
380 inside pion; the handler never sees what the client sent.
3817. Count the refusal reason per app: bad shape, expired, bound
382 elsewhere, unknown app, wrong incarnation, wrong region, unknown
383 kid.
384
385The MD5 inside `GenerateAuthKey` is the long-term credential key of RFC
3868489's compatibility mechanism, which pion implements; RFC 8489 also
387defines a SHA-256 variant that pion does not. The HMAC-SHA256 above is
388ours. **(codex R1-20)**
389
390## 5. Enable, disable, rotate
391
392**Records.** Two, both additive. **(codex R1-6, R2-1, R2-2)**
393
394- `apps/<acct>/<app>/relay.json`, CAS-guarded like `store.json`, is the
395 **source of truth** for the app: `{enabled, incarnation, regions,
396 op, phase, issued_digest, issued_suffixes, cutovers, enabled_at,
397 disabled_at}`. `issued_digest` is the sha256 of the sealed envelope
398 that was delivered, which is what `Record.IssuedDigest` records and
399 what reconcile compares; an earlier draft of this spec called it
400 `config_generation` and no implementation ever carried that name.
401 `issued_suffixes` (region to the username suffix last issued) and
402 `cutovers` (the per-route member acknowledgements) are the other two
403 fields `relay.Record` has always carried and this listing has always
404 omitted. **(discussion #120)** `op` is a
405 counter bumped by every operation; `phase` is one of `config`,
406 `aggregate`, `done` and records how far the operation got. The
407 incarnation is sixteen hex characters drawn at random at first
408 enable and at each re-enable after a disable, never a counter, so
409 reuse across delete and name reuse is not a question.
410- `control/relay.json`, the authority record the relays read:
411 `{apps: [{account, app, incarnation, regions}], written_at}`. It is
412 **derived**, never patched. Publishing it is one fixed sequence: read
413 the aggregate's current etag; list every `relay.json`; build the
414 aggregate from records whose `enabled` is true, whatever their
415 `phase`; write with a CAS on the etag read **first**; on conflict
416 start the whole sequence again. Because the etag is read before the
417 listing, a disable that lands during the listing makes the write
418 fail rather than restore the app. Followed by a `control/serial` bump
419 so relays notice within seconds; the bump is a latency hint, not the
420 lease.
421
422**Every operation is a walk the reconcile can finish, and the
423reconcile converges everything.** An operation writes its intent to
424`relay.json` first (`op` bumped, `phase` set), then does the config
425write, then the aggregate publish, advancing `phase` after each step
426with a CAS that carries the `op`. **One writer, enforced.** Every relay
427operation, handler or reconcile, runs only in a console process that
428holds the relay-writer lease, `control/relay-writer.json`: a CAS-written
429record naming the holder with a sixty-second expiry, renewed every
430twenty seconds, taken over by another process only after it lapses. A
431process that fails a renewal stops every relay write in flight before
432its next bucket write and abandons the operation, which the next
433holder's reconcile finishes. Inside the holder, operations on one app
434are serialised behind a per-app lock. So two console processes,
435during a deploy overlap or otherwise, never both write relay config,
436and a superseded key selection cannot reach config after a migration:
437the writer that selected it either holds the lease, in which case it
438is the only writer and no migration happened under it, or lost it and
439stopped. **(codex R3-2, R4-1)** On top of that fence, the **reconcile**
440does not only finish unfinished records, it **converges both derived
441artefacts from every record, every five minutes and at console
442start**: for each enabled
443record it derives the expected `CARLOS_RELAY` value from the record and
444the active masters and re-applies it if the config bundle holds
445anything else; for each disabled record it removes the entry if
446present; and it always rebuilds and publishes the aggregate whether or
447not any record is unfinished. So a stale config write that lands after
448a disable is undone within five minutes, an interrupted enable
449delivers its config within five minutes, an interrupted disable
450removes it within five minutes, a repeated `enable` while a previous
451one is unfinished resumes it rather than skipping the config write, and
452a deletion that removed the record but died before publishing is
453repaired by the next unconditional rebuild. **(codex R2-2, R3-1, R3-2)**
454
455**When a disable is complete.** The app is out of every relay's
456authority when the aggregate has been published without it **and**
457every armed relay's heartbeat carries a `written_at` at or after that
458publish. `carlos relay status` shows "disable pending on <regions>"
459until then, and "revoked" after. Bounds: the publish is immediate on the
460happy path and within five minutes after a crash; each relay reads
461within sixty seconds of the publish; a relay that cannot read stops
462serving on its own fifteen-minute lease. **(codex R2-1)**
463
464**State transitions** for `relay.json`: **(codex R1-13)**
465
466| From | Operation | Incarnation | Keys re-derived | Allocations |
467|---|---|---|---|---|
468| none | enable | new | yes | none |
469| enabled, done | enable (same regions) | kept | no, no config write | untouched |
470| enabled, not done | enable | kept | resumes the unfinished operation | untouched |
471| enabled | enable `--regions` (change) | kept | only for added regions; removed regions' entries dropped | ended in removed regions on next authority read |
472| enabled | disable | kept in record, app removed from aggregate | entry removed | ended everywhere on next authority read |
473| disabled | enable | **new** | yes, all regions | none |
474| any | app deleted | record removed with the app prefix; the deletion walk publishes the aggregate | | ended on next authority read |
475| any | rotate `--region r` | kept | region `r` only, new kid | untouched; old kid accepted until retirement |
476
477**`carlos relay enable [--regions r1,r2]`** (member, API-mode only, on
478the store model in `store.go` and `internal/console/apistore.go`;
479ruling 2):
480
4811. `apiAccountApp` gate; unknown and foreign are one byte-identical 404.
4822. Refuse if the deployment has no relay regions (`CARLOS_RELAY_REGIONS`
483 in console config is empty) with a message naming the operator.
484 Refuse if any of the app's routes has no config delivery (a
485 TCP-address route whose bundle does not opt into file delivery),
486 naming the route, because such a route could never adopt a rotated
487 key and would block retirement forever. **(codex R5-1)**
4883. Regions are every region in `CARLOS_RELAY_REGIONS`, **in the
489 console's order**, or `--regions` narrows them keeping that order.
490 There is no placement-first promise: placement names (`ie`,
491 `fleet/x`) are not AWS regions and an app may have several, so the
492 order is one fixed list for the app and every browser tries every
493 listed relay. **(codex R1-12, R2-11)**
4944. Apply the transition above to `relay.json` (CAS, `op` bumped,
495 `phase: config`).
4965. For each region needing keys, derive from that region's current
497 master and its kid, and write `CARLOS_RELAY` as one `kind:"secret"`
498 entry through `appconfig.Apply` (the `writeEnv` shape in
499 `internal/objstore/walk.go:602`). `Apply` touches the app's config
500 generation, a nonce plus timestamp (`internal/appconfig/
501 generation.go:40`); record that generation string in `relay.json`
502 and advance to `phase: aggregate`. **(codex R2-6, R2-12)**
5036. Publish `control/relay.json`, bump `control/serial`, advance to
504 `phase: done`.
5057. Answer with the record. The CLI prints regions, incarnation, and
506 "config generation <g> written; delivery is reported by `carlos
507 relay status`", never "delivered".
508
509**Config adoption, observed per route.** The host's config sync, after
510it renders a route's env file and does whatever that route's shape
511calls for, writes an additive acknowledgement
512`control/config-ack/<box>/<route host>.json`: the route's shape (unit,
513socket exec, TCP exec with file delivery), the time, whether a restart
514happened and succeeded, and **the relay key identities it rendered**:
515the `username_suffix` per region parsed out of the `CARLOS_RELAY` value
516it just wrote (account, app, incarnation and kid together), or none. A
517kid alone would not do: a disable and re-enable between two host syncs
518changes the incarnation and the key while keeping the kid, and an ack
519naming only kids would report the old credentials as adopted.
520**(codex R4-2)** The ack is keyed on the route, not the app, because an
521app can have several routes on one box and one can adopt while another
522does not; and it names identities rather than a generation string,
523because the app-wide generation object is a change notification that
524can coalesce or fail after the manifest write
525(`internal/appconfig/generation.go:28`) and is not an identity of what
526was rendered. Nothing reads the ack today but `carlos relay status`;
527it is the first per-route statement of "this is what the process has"
528the platform has, and the plan adds it to `internal/appconfig/sync.go`
529as its own small step. **(codex R1-14, R2-6, R3-4)**
530
531**`carlos relay status`** reports, separately: desired state from
532`relay.json` with its `op` and `phase`; the identities (username
533suffixes) the console last issued per region; delivery per route,
534"adopted at <time>, restarted" when a config-ack names exactly the
535issued identities and a successful restart,
536"written, awaiting delivery" when no ack names them yet, "file
537delivered, awaiting `carlos relay adopted`" for a TCP exec route with
538file delivery until a member acknowledges it, "adopted by member at
539<time>" after, "restart failed" when the ack says so, and
540"hibernating, delivered on wake" when the route is asleep (a route
541added without delivery after enable shows "no config delivery: relay
542will not reach this route" and blocks retirement until fixed); disable
543progress as above; and per region, from
544`control/relay-ack/<box>.json`, the heartbeat age, whether the record
545it holds is current, whether its lease is live, which listeners are
546up, and which `kid`s it has loaded. A heartbeat older than five minutes
547is shown as "stale", so a dead relay cannot look current.
548
549**`carlos relay disable`** applies the transition (`phase: config`),
550removes the `CARLOS_RELAY` entry through `appconfig.Apply`, publishes
551the aggregate, marks `done`.
552
553**Rotation** (`carlos relay rotate --region r`, operator): **(codex
554R1-10, R2-4)**
555
5561. Operator runs `carlos relay keygen`, which prints a new `kid` and key.
5572. Operator adds the line to `/etc/carlos/relay-masters` on each of the
558 region's boxes with `retire_at` `-`. The relay re-reads the file;
559 no restart, no allocation touched. Its next heartbeat lists the new
560 `kid`.
5613. Operator **adds** the new `kid:key` to the console secret for the
562 region, which holds every master the console may need,
563 `CARLOS_RELAY_MASTERS_<REGION>="<kid>:<key>[,<kid>:<key>]"`. Adding
564 it issues nothing: **which master the console issues from is a
565 separate, unsealed pointer**, `control/relay-issuer.json`
566 `{"<region>": "<kid>"}`, and every path that derives a key for an
567 app (enable, region change, re-enable, reconcile) reads that
568 pointer and uses the master it names. A staged master is inert
569 until the pointer moves. **(codex R3-5)**
5704. `carlos relay rotate --region r` **refuses until every armed relay
571 in the region has heartbeated the new `kid`**, then moves the
572 issuer pointer to it, re-derives and re-delivers keys for every app
573 enabled in that region as an operation on each record, and prints
574 the earliest permitted retirement time for the old `kid`: 28 hours
575 from now.
5765. `carlos relay status --region r` shows, per route of every enabled
577 app, which identities its config-ack names and when. **Each route's
578 cutover** is established one of two ways, by route shape. A
579 unit-backed or socket-exec route: the config sync's ack naming the
580 issued identities, since the platform restarted it. A TCP-address
581 exec route with file delivery: its process belongs to an external
582 supervisor the platform cannot restart, so the cutover is a
583 **member acknowledgement**, `carlos relay adopted --host <route>`,
584 given after the member has restarted the process; the console
585 records it in `relay.json` against that route together with the
586 identities issued at that moment, so an acknowledgement given
587 before a later rotation does not count for it. A route in neither
588 category cannot exist under an enabled relay (enable refuses it),
589 and a route added later without delivery blocks retirement until
590 it is fixed. The old master's `retire_at` may be set no earlier
591 than `max(rotation + 28h, latest cutover + 4h)` across every route
592 of every enabled app in the region, and `status` prints that time
593 once every route has a cutover, or names the routes still without
594 one. Setting it earlier is refused by nothing but the docs, and ends
595 any allocation still on the old `kid`, which is the force-retirement
596 case §2 excludes from its guarantee. **(codex R3-4, R4-3, R5-1)**
5976. After `retire_at` the operator removes the line from the boxes and
598 the console secret at leisure.
599
600**App deletion** removes `relay.json` with the rest of the app prefix,
601and the deletion walk publishes the aggregate so the app is gone from
602authority at once. If the walk dies between the two, the reconcile's
603unconditional rebuild, which lists records rather than looking for
604unfinished ones, publishes without the app within five minutes.
605**(codex R3-1)**
606
607## 6. Infrastructure
608
609- `infra/modules/region-host/main.tf`: a `relay` variable, default off,
610 that adds ingress for UDP 3478, TCP 3478 and UDP 49152 to 65535 from
611 `0.0.0.0/0`, and outputs the VPC CIDR for `CARLOS_RELAY_DENY_CIDRS`.
612 Flagship turns it on per region.
613- DNS: an A record `relay.<aws region>.<platform domain>` to the region's
614 Elastic IP, next to the existing `aws_eip.host` resource.
615- Each armed box gains `/etc/carlos/relay-masters` (staged by the
616 operator, like `host.env`) and `CARLOS_RELAY_DENY_CIDRS` and
617 optionally `CARLOS_RELAY_PUBLIC_IP` in `host.env`.
618- Console config gains `CARLOS_RELAY_REGIONS` and one
619 `CARLOS_RELAY_MASTERS_<REGION>` secret per region.
620- Egress cost: every forwarded byte leaves the box once. The
621 unit-economics spec gets a line for relay egress per region, and the
622 two meters in §3 are what fill it.
623
624## 7. Testing
625
626- `internal/relay`: derivation and credential against the §2 vector;
627 the `AuthHandler` against a fixture authority record and masters file
628 covering every refusal reason in §4, including a valid credential for
629 app B presented from a typed source bound to app A, and a UDP and a
630 TCP client with the same address string holding separate bindings;
631 the permission handler against each denied range and the allowed
632 EIP; the relay socket dropping a packet to the EIP on a non-allocated
633 port; limits against a fake clock, including an account spreading
634 allocations across apps, an allocation ended by the byte budget, and
635 a token bucket dropping a burst; the meter against wrapped in-memory
636 sockets, checking that rejected peer ingress is not counted as `in`
637 and that both forwarding legs are counted; the hourly object surviving
638 a restart, including a restart across an hour boundary that rebuilds
639 the rolling window from both objects; a wrapper's close removing only
640 its own binding while a replacement binding for the same typed
641 source survives; shedding triggered by a fake pressure reading.
642- End to end, on loopback with a fixture master: a pion TURN client
643 allocates, refreshes, and keeps relaying until the credential's
644 expiry, at which point traffic stops with no further client request;
645 the same after a disable written to the fixture authority record;
646 two clients on one relay reaching each other's relay candidates; a
647 rotation, by rewriting the masters file, during which an allocation
648 on the old kid carries traffic uninterrupted, then a `retire_at`
649 ending it; a lease lapse ending allocations and a successful read
650 restoring service.
651- `internal/host`: the unit is embedded, written and started by the
652 bootstrap plan when the staging root carries a masters file, and not
653 started when it does not; the adopt restart path names the relay; the
654 config sync writes a config-ack.
655- `internal/console`: the doors through `rawReq` and a `relayURL`
656 fixture: the byte-identical 404, the no-regions refusal, each row of
657 the transition table, the config entry written and removed, the
658 aggregate after concurrent enable and disable of two apps with the
659 etag-first ordering, an enable interrupted after `phase: config`
660 finished by the reconcile, a superseded writer refused by its CAS, a
661 stale config write landing after a disable and undone by the next
662 reconcile, a deletion that removed the record without publishing
663 repaired by the next rebuild, an enable during a staged-but-unmoved
664 rotation issuing from the old kid, a process that lost the writer
665 lease making no further relay write, a delayed old-key publisher
666 refused after a migration, a disable and re-enable between two host
667 syncs not reported as adopted by the old ack, and `rotate` refusing
668 until the fixture heartbeats list the kid; the config sync writing a
669 per-route ack naming the identities it rendered; the retirement time
670 computed with a manual cutover after hour 28; enable refused for an
671 app with a route lacking config delivery; a `carlos relay adopted`
672 given before a later rotation not counting as that rotation's
673 cutover; an externally supervised TCP route with file delivery
674 blocking retirement until acknowledged.
675- `docs/site/cli.md` gains `### carlos relay` with a fenced example, or
676 the docsite gate fails.
677- Flagship gate, as amended in the roadmap: a Meet call between two
678 symmetric-NAT networks connecting through the Ireland relay; both
679 browsers forced to relay and connecting through it **on the real
680 EIP**, and a probe to a non-allocated port on the EIP dropped; a
681 credential minted for one app refused for another, including on an
682 existing allocation's tuple; a credential from before a disable
683 refused after re-enable and its allocation ended; traffic stopping at
684 a credential's expiry with the browser doing nothing; a relay with a
685 lease older than fifteen minutes refusing new allocations and ending
686 carried ones; a permission to a private address refused; the relay
687 meter object showing the call's bytes on both legs; a rotation
688 during a call with the call unaffected; the relay shedding
689 allocations under host memory pressure caused by a tenant, with the
690 edge still serving.
691
692## 8. Not built
693
694- **TURN over TLS (5349 or 443)** **(ruling 4)**. Certificate issuance
695 for a non-HTTP listener needs the relay hostname registered with the
696 deployment's ACME issuer, which is elected and may be another box
697 (`internal/edge/certsync.go`), and its warmer excludes such names
698 (`internal/edge/certwarm.go`); a listener that cannot start until a
699 certificate exists cannot trigger issuance by being visited. That is
700 its own small design, to be written when a network that needs it is
701 reported. 443 additionally needs a second address per box. The
702 roadmap's stage 4a text is amended to match. **(codex R1-15, R2-10)**
703- **An inter-region byte meter** **(ruling 5)**. Classifying a peer
704 address by AWS region needs the published IP ranges on the box and a
705 rule for non-AWS peers; version one meters what is forwarded in each
706 direction, which is what the bill is made of. Filed for stage 5 with
707 the usage view; the roadmap's meters and gate are amended.
708 **(codex R1-18, R2-10)**
709- An issuance API: apps mint offline from delivered keys.
710- Client-aware relay selection: the list is in console order; ICE picks.
711- Commercial quotas and an app-page usage view: the meters exist, the
712 page is stage 5.
713- An SFU: stage 4b, after this ships.
714- Any change to how apps signal: SSE plus POST stays Meet's.
715
716## 9. Rulings
717
718Paul ruled on each of these on 2026-09-07. **(codex R2-10)**
719
7201. **(ruling)** Maximum credential life is four hours. The roadmap's
721 one-hour figure is amended.
7222. **(ruling)** Enable is member-level; the operator's consent is the
723 region list and the limits.
7243. **(ruling)** The console holds every region's master as sealed
725 config; the exposure is stated in §4 and the operator docs; the
726 relay-derives-and-writes design is filed as the tightening.
7274. **(ruling)** No TURN over TLS in version one; the roadmap's stage 4a
728 ingress text is amended.
7295. **(ruling)** Two meters in version one, in and out; the inter-region
730 meter and the usage line move to stage 5; the roadmap's metering
731 text and gate are amended.
7326. **(ruling)** The limits in §3 with their stated defaults, including
733 the token buckets.
734