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 relay (STUN and TURN) — Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** A platform-run STUN and TURN relay on every armed region box, enabled per app through `carlos relay enable`, with keys delivered as sealed config, so the Oficina Meet app connects calls across the internet with no third party in the path.
6
7**Architecture:** One new package, `internal/relay`, holds everything pure: key derivation, the credential contract, the authority record, the pion server wrapper with its own socket wrapping for termination and metering, and the limits. The console gains a relay record per app, a relay-writer lease, an aggregate publisher and a reconcile, plus four CLI doors. The host gains a `carlos-relay.service` unit, a `carlos relay serve` verb, and a per-route config acknowledgement written by the config sync. Infra gains an `extra_ingress` block and a DNS record per region.
8
9**Tech Stack:** Go 1.26, `github.com/pion/turn/v4` v4.1.4 (new dependency, pure Go, cross-compiles clean; checked 2026-09-07), `modernc.org/sqlite` (existing), OpenTofu (infra).
10
11**Spec:** `docs/superpowers/specs/2026-09-07-relay-stun-turn-design.md` (revision 6, codex accept). Parent: `docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md`.
12
13## Global Constraints
14
15- **The app-facing contract (spec §2) is frozen.** `CARLOS_RELAY` is standard padded base64 of a JSON array of `{region, urls, key, username_suffix}`; username is `<expiry>:<account>:<app>:<incarnation>:<kid>`; credential is `base64_std(HMAC-SHA256(key, username))`; max expiry four hours ahead. Test vector: master `SHA-256("carlos-relay-test-master")`, account `abc`, app `meet`, incarnation `0123456789abcdef`, kid `k1`, expiry `1900000000` → key `uMhwW4P/9O5dQUIs91t2CCHGhwJbeiM0xQMQ2G0CJLU=`, credential `jRU479Y3GO45nrRHqxBf3BMeBHE6Fip1cwAccNv28MQ=`. Any change is a spec change.
16- **Rulings (spec §9) are fixed:** four-hour credentials; member-level enable; console holds masters as sealed config; no TLS listener; two meters (`in`, `out`); limits as in spec §3.
17- **Wire shapes are additive.** Every new bucket object is a new key; `relay.json`, `control/relay.json`, `control/relay-issuer.json`, `control/relay-writer.json`, `control/relay-ack/<box>.json`, `control/config-ack/<box>/<host>.json`, `metrics/relay/<box>/<hour>.json`. Nothing existing is retyped.
18- **CAS is `Put(ctx, key, body, ifMatch)`** on `release.Store`; a mismatch returns `release.ErrCASConflict`. There is no other conditional primitive. (`internal/release/store.go:42`.)
19- **Config values must pass `appconfig.ValidVarValue`**: no quotes, backslashes, newlines or NUL. That is why `CARLOS_RELAY` is base64.
20- **`CGO_ENABLED=0` everywhere.** The CLI cross-compiles for darwin/arm64, darwin/amd64, linux/arm64, windows/amd64, windows/arm64 (`.amadan/ci.d/20-cross@5`). pion/turn is pure Go.
21- **Every `carlos` command needs a `### carlos <verb>` heading in `docs/site/cli.md`** or `internal/docsite` fails the gate.
22- **Build gate before any commit lands:** `go build ./...`, `go vet ./...`, `go test ./... -count=1`, `gofmt -l .` clean. Sandbox: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`; adding the dependency needs the sandbox lifted once for `go get`.
23- **The forge is `origin` (amadan.net).** Push there, `amadan branch describe`, wait for `amadan ci status -branch` to pass, `amadan branch merge … -expect <full sha>`. Never `gh`.
24- **Delegation (AGENTS.md):** Tasks 1 to 5 are Sonnet from this brief; Task 6 (pion wrapper) and Task 10 (console operations with the lease) are Opus; final whole-branch review is Opus.
25
26---
27
28## File structure
29
30| Path | Responsibility |
31|---|---|
32| `internal/relay/keys.go` | HKDF derivation, credential minting, username parse; the §2 contract |
33| `internal/relay/masters.go` | masters file parse, `retire_at`, watched reload |
34| `internal/relay/authority.go` | `control/relay.json` shape, read with lease clock, heartbeat write |
35| `internal/relay/limits.go` | admission counters, rolling-hour budgets, token buckets |
36| `internal/relay/conn.go` | wrapped `net.PacketConn` and listener conns: counting, buckets, EIP port gate, close |
37| `internal/relay/server.go` | pion `ServerConfig` assembly: generator, auth handler, permission handler, bindings, termination timers |
38| `internal/relay/meter.go` | per-app counters, minute samples, hourly object read-append-CAS |
39| `internal/relay/serve.go` | `Serve(ctx, Config)`: wires the above, env parsing, PSI shedding loop |
40| `internal/relay/record.go` | `apps/<acct>/<app>/relay.json` shape, Get/Put, transitions |
41| `internal/relay/config.go` | build the `CARLOS_RELAY` value from a record and issuer masters |
42| `internal/relay/vector_test.go` | the §2 test vector |
43| `internal/host/units/carlos-relay.service` | the unit |
44| `internal/host/bootstrap.go` | arm and start the unit |
45| `internal/host/systemapply.go` | restart the relay on platform update |
46| `internal/appconfig/sync.go`, `internal/appconfig/configack.go` | per-route config acknowledgement |
47| `internal/console/apirelay.go` | doors: enable, status, disable, adopted, rotate |
48| `internal/console/relayops.go` | writer lease, operations, aggregate publish, reconcile |
49| `relay.go` (root) | `carlos relay` CLI: enable, status, disable, adopted, rotate, keygen, serve |
50| `apimode.go` | `apiSession.relay*` client calls |
51| `infra/modules/region-host/main.tf`, `variables.tf`, `outputs.tf` | `relay` variable, ingress, VPC CIDR output |
52| `docs/site/cli.md` | `### carlos relay` |
53
54---
55
56### Task 1: Key derivation and the credential contract
57
58**Files:**
59- Create: `internal/relay/keys.go`
60- Test: `internal/relay/vector_test.go`, `internal/relay/keys_test.go`
61
62**Interfaces:**
63- Produces:
64 ```go
65 const MaxCredentialLife = 4 * time.Hour
66 func DeriveKey(master []byte, account, app, incarnation string) []byte // 32 bytes
67 func Suffix(account, app, incarnation, kid string) string // "acct:app:inc:kid"
68 func Credential(key []byte, username string) string // base64 std
69 type Username struct{ Expiry time.Time; Account, App, Incarnation, Kid string }
70 func ParseUsername(s string) (Username, error)
71 ```
72
73- [ ] **Step 1: Write the vector test**
74
75```go
76package relay
77
78import (
79 "crypto/sha256"
80 "encoding/base64"
81 "testing"
82)
83
84func TestVector(t *testing.T) {
85 m := sha256.Sum256([]byte("carlos-relay-test-master"))
86 key := DeriveKey(m[:], "abc", "meet", "0123456789abcdef")
87 if got := base64.StdEncoding.EncodeToString(key); got != "uMhwW4P/9O5dQUIs91t2CCHGhwJbeiM0xQMQ2G0CJLU=" {
88 t.Fatalf("key = %s", got)
89 }
90 u := "1900000000:" + Suffix("abc", "meet", "0123456789abcdef", "k1")
91 if u != "1900000000:abc:meet:0123456789abcdef:k1" {
92 t.Fatalf("username = %s", u)
93 }
94 if got := Credential(key, u); got != "jRU479Y3GO45nrRHqxBf3BMeBHE6Fip1cwAccNv28MQ=" {
95 t.Fatalf("credential = %s", got)
96 }
97}
98```
99
100- [ ] **Step 2: Write the parse tests**
101
102```go
103func TestParseUsername(t *testing.T) {
104 u, err := ParseUsername("1900000000:abc:meet:0123456789abcdef:k1")
105 if err != nil || u.Account != "abc" || u.App != "meet" || u.Incarnation != "0123456789abcdef" || u.Kid != "k1" || u.Expiry.Unix() != 1900000000 {
106 t.Fatalf("parse = %+v %v", u, err)
107 }
108 for _, bad := range []string{"", "1:2:3:4", "1:2:3:4:5:6", "x:abc:meet:0123456789abcdef:k1",
109 "1900000000:ABC:meet:0123456789abcdef:k1", "1900000000:abc:9meet:0123456789abcdef:k1",
110 "1900000000:abc:meet:short:k1", "1900000000:abc:meet:0123456789abcdef:K1"} {
111 if _, err := ParseUsername(bad); err == nil {
112 t.Errorf("ParseUsername(%q) accepted", bad)
113 }
114 }
115}
116```
117
118- [ ] **Step 3: Run tests to verify they fail**
119
120Run: `go test ./internal/relay/ -run 'TestVector|TestParseUsername' -v`
121Expected: FAIL, package does not exist.
122
123- [ ] **Step 4: Implement**
124
125```go
126// Package relay is the platform STUN/TURN relay: the app-facing credential
127// contract (spec §2), key derivation (§4), and the server (§3).
128package relay
129
130import (
131 "crypto/hmac"
132 "crypto/sha256"
133 "encoding/base64"
134 "fmt"
135 "regexp"
136 "strconv"
137 "strings"
138 "time"
139
140 "golang.org/x/crypto/hkdf"
141 "io"
142)
143
144const (
145 MaxCredentialLife = 4 * time.Hour
146 hkdfSalt = "carlos-relay-v1"
147)
148
149var (
150 accountRe = regexp.MustCompile(`^[a-z0-9]+$`)
151 appRe = regexp.MustCompile(`^[a-z][a-z0-9-]{0,31}$`)
152 incarnationRe = regexp.MustCompile(`^[0-9a-f]{16}$`)
153 kidRe = regexp.MustCompile(`^[a-z0-9]{1,8}$`)
154)
155
156// DeriveKey is HKDF-SHA256(master, salt "carlos-relay-v1",
157// info account||0x00||app||0x00||incarnation, 32).
158func DeriveKey(master []byte, account, app, incarnation string) []byte {
159 info := []byte(account + "\x00" + app + "\x00" + incarnation)
160 r := hkdf.New(sha256.New, master, []byte(hkdfSalt), info)
161 key := make([]byte, 32)
162 if _, err := io.ReadFull(r, key); err != nil {
163 panic("relay: hkdf: " + err.Error()) // cannot fail for 32 bytes
164 }
165 return key
166}
167
168func Suffix(account, app, incarnation, kid string) string {
169 return account + ":" + app + ":" + incarnation + ":" + kid
170}
171
172// Credential is base64(HMAC-SHA256(key, username)), the TURN password.
173func Credential(key []byte, username string) string {
174 h := hmac.New(sha256.New, key)
175 h.Write([]byte(username))
176 return base64.StdEncoding.EncodeToString(h.Sum(nil))
177}
178
179type Username struct {
180 Expiry time.Time
181 Account, App, Incarnation, Kid string
182}
183
184// ParseUsername splits "<expiry>:<account>:<app>:<incarnation>:<kid>" and
185// validates each field's alphabet. It does not check expiry against now;
186// the auth handler does that with its own clock.
187func ParseUsername(s string) (Username, error) {
188 f := strings.Split(s, ":")
189 if len(f) != 5 {
190 return Username{}, fmt.Errorf("relay: username has %d fields, want 5", len(f))
191 }
192 exp, err := strconv.ParseInt(f[0], 10, 64)
193 if err != nil || exp < 0 {
194 return Username{}, fmt.Errorf("relay: bad expiry %q", f[0])
195 }
196 switch {
197 case !accountRe.MatchString(f[1]):
198 return Username{}, fmt.Errorf("relay: bad account %q", f[1])
199 case !appRe.MatchString(f[2]):
200 return Username{}, fmt.Errorf("relay: bad app %q", f[2])
201 case !incarnationRe.MatchString(f[3]):
202 return Username{}, fmt.Errorf("relay: bad incarnation %q", f[3])
203 case !kidRe.MatchString(f[4]):
204 return Username{}, fmt.Errorf("relay: bad kid %q", f[4])
205 }
206 return Username{Expiry: time.Unix(exp, 0).UTC(), Account: f[1], App: f[2], Incarnation: f[3], Kid: f[4]}, nil
207}
208```
209
210`golang.org/x/crypto` is already in `go.mod`; `hkdf` is a subpackage of it.
211
212- [ ] **Step 5: Run tests to verify they pass**
213
214Run: `go test ./internal/relay/ -v`
215Expected: PASS.
216
217- [ ] **Step 6: Commit**
218
219```bash
220git add internal/relay/
221git commit -m "relay: key derivation and the credential contract (spec §2, §4)"
222```
223
224---
225
226### Task 2: The masters file
227
228**Files:**
229- Create: `internal/relay/masters.go`
230- Test: `internal/relay/masters_test.go`
231
232**Interfaces:**
233- Produces:
234 ```go
235 type Master struct{ Kid string; Key []byte; RetireAt time.Time } // zero RetireAt = never
236 type Masters struct{ /* unexported */ }
237 func ParseMasters(r io.Reader) (*Masters, error)
238 func (m *Masters) Lookup(kid string, now time.Time) (Master, bool) // false if absent or retired
239 func (m *Masters) Kids() []string // loaded kids, sorted
240 func WatchMasters(ctx context.Context, path string, onChange func(*Masters, error))
241 ```
242- File format, one master per line: `<kid> <base64 key> <retire_at RFC3339 or "-">`.
243
244- [ ] **Step 1: Write the tests**
245
246```go
247func TestParseMasters(t *testing.T) {
248 in := "k1 " + b64(bytes.Repeat([]byte{1}, 32)) + " -\n" +
249 "k0 " + b64(bytes.Repeat([]byte{2}, 32)) + " 2026-09-08T00:00:00Z\n" +
250 "# comment\n\n"
251 m, err := ParseMasters(strings.NewReader(in))
252 if err != nil {
253 t.Fatal(err)
254 }
255 if got := m.Kids(); !reflect.DeepEqual(got, []string{"k0", "k1"}) {
256 t.Fatalf("kids = %v", got)
257 }
258 before := time.Date(2026, 9, 7, 0, 0, 0, 0, time.UTC)
259 after := time.Date(2026, 9, 9, 0, 0, 0, 0, time.UTC)
260 if _, ok := m.Lookup("k0", before); !ok {
261 t.Fatal("k0 should be live before retire_at")
262 }
263 if _, ok := m.Lookup("k0", after); ok {
264 t.Fatal("k0 should be retired after retire_at")
265 }
266 if _, ok := m.Lookup("k1", after); !ok {
267 t.Fatal("k1 never retires")
268 }
269 if _, ok := m.Lookup("kx", before); ok {
270 t.Fatal("unknown kid")
271 }
272}
273
274func TestParseMastersRejectsBadLines(t *testing.T) {
275 for _, bad := range []string{"k1 notbase64 -", "k1 " + b64([]byte{1}) + " -", "K1 " + b64(bytes.Repeat([]byte{1}, 32)) + " -", "k1 " + b64(bytes.Repeat([]byte{1}, 32)) + " yesterday"} {
276 if _, err := ParseMasters(strings.NewReader(bad)); err == nil {
277 t.Errorf("accepted %q", bad)
278 }
279 }
280}
281
282func b64(b []byte) string { return base64.StdEncoding.EncodeToString(b) }
283```
284
285- [ ] **Step 2: Run to verify failure**
286
287Run: `go test ./internal/relay/ -run TestParseMasters -v`
288Expected: FAIL, undefined.
289
290- [ ] **Step 3: Implement**
291
292```go
293package relay
294
295import (
296 "bufio"
297 "context"
298 "encoding/base64"
299 "fmt"
300 "io"
301 "os"
302 "sort"
303 "strings"
304 "time"
305)
306
307type Master struct {
308 Kid string
309 Key []byte
310 RetireAt time.Time // zero means never
311}
312
313type Masters struct{ byKid map[string]Master }
314
315// ParseMasters reads "<kid> <base64 key> <retire_at|->" lines. Blank lines
316// and "#" comments are skipped. Keys must decode to exactly 32 bytes.
317func ParseMasters(r io.Reader) (*Masters, error) {
318 m := &Masters{byKid: map[string]Master{}}
319 sc := bufio.NewScanner(r)
320 line := 0
321 for sc.Scan() {
322 line++
323 s := strings.TrimSpace(sc.Text())
324 if s == "" || strings.HasPrefix(s, "#") {
325 continue
326 }
327 f := strings.Fields(s)
328 if len(f) != 3 {
329 return nil, fmt.Errorf("relay masters line %d: want 3 fields, got %d", line, len(f))
330 }
331 if !kidRe.MatchString(f[0]) {
332 return nil, fmt.Errorf("relay masters line %d: bad kid %q", line, f[0])
333 }
334 key, err := base64.StdEncoding.DecodeString(f[1])
335 if err != nil || len(key) != 32 {
336 return nil, fmt.Errorf("relay masters line %d: key must be base64 of 32 bytes", line)
337 }
338 var retire time.Time
339 if f[2] != "-" {
340 retire, err = time.Parse(time.RFC3339, f[2])
341 if err != nil {
342 return nil, fmt.Errorf("relay masters line %d: bad retire_at %q", line, f[2])
343 }
344 }
345 m.byKid[f[0]] = Master{Kid: f[0], Key: key, RetireAt: retire}
346 }
347 return m, sc.Err()
348}
349
350func (m *Masters) Lookup(kid string, now time.Time) (Master, bool) {
351 mk, ok := m.byKid[kid]
352 if !ok || (!mk.RetireAt.IsZero() && !now.Before(mk.RetireAt)) {
353 return Master{}, false
354 }
355 return mk, true
356}
357
358func (m *Masters) Kids() []string {
359 out := make([]string, 0, len(m.byKid))
360 for k := range m.byKid {
361 out = append(out, k)
362 }
363 sort.Strings(out)
364 return out
365}
366
367// WatchMasters re-reads path whenever its mtime changes, polling every
368// five seconds (fsnotify would be a dependency for one file). onChange is
369// called with the parse result or error each time the content changes,
370// and once at start.
371func WatchMasters(ctx context.Context, path string, onChange func(*Masters, error)) {
372 var last time.Time
373 tick := time.NewTicker(5 * time.Second)
374 defer tick.Stop()
375 load := func() {
376 st, err := os.Stat(path)
377 if err != nil {
378 onChange(nil, err)
379 return
380 }
381 if st.ModTime().Equal(last) {
382 return
383 }
384 last = st.ModTime()
385 f, err := os.Open(path)
386 if err != nil {
387 onChange(nil, err)
388 return
389 }
390 defer f.Close()
391 onChange(ParseMasters(f))
392 }
393 load()
394 for {
395 select {
396 case <-ctx.Done():
397 return
398 case <-tick.C:
399 load()
400 }
401 }
402}
403```
404
405- [ ] **Step 4: Run tests, then commit**
406
407Run: `go test ./internal/relay/ -v`
408Expected: PASS.
409
410```bash
411git add internal/relay/masters.go internal/relay/masters_test.go
412git commit -m "relay: masters file with retire_at and hot reload (spec §4)"
413```
414
415---
416
417### Task 3: Authority record, lease clock, heartbeat
418
419**Files:**
420- Create: `internal/relay/authority.go`
421- Test: `internal/relay/authority_test.go`
422
423**Interfaces:**
424- Produces:
425 ```go
426 const AuthorityKey = "control/relay.json"
427 const LeaseMaxAge = 15 * time.Minute
428 type AuthorityApp struct{ Account, App, Incarnation string; Regions []string }
429 type Authority struct{ Apps []AuthorityApp; WrittenAt time.Time }
430 func (a *Authority) Lookup(account, app string) (AuthorityApp, bool)
431 func ReadAuthority(ctx, s release.Store) (*Authority, string /*etag*/, error) // os.ErrNotExist → nil,"",nil
432 type Lease struct{ /* unexported */ }
433 func (l *Lease) Renew(now time.Time) // on a successful read
434 func (l *Lease) Live(now time.Time) bool // now - last <= LeaseMaxAge
435 func HeartbeatKey(box string) string // "control/relay-ack/<box>.json"
436 type Heartbeat struct{ Box, Region string; WrittenAt time.Time /* of the record held */; ReadAt time.Time; Listeners []string; Kids []string; MastersError string }
437 func WriteHeartbeat(ctx, s release.Store, hb Heartbeat) error // unconditional Put
438 ```
439
440- [ ] **Step 1: Tests**
441
442```go
443func TestAuthorityRoundTrip(t *testing.T) {
444 s := release.NewMemStore() // see internal/release for the in-memory Store used by console tests
445 ctx := context.Background()
446 a, _, err := ReadAuthority(ctx, s)
447 if err != nil || a != nil {
448 t.Fatalf("absent = %v %v, want nil,nil", a, err)
449 }
450 body, _ := json.Marshal(Authority{Apps: []AuthorityApp{{Account: "abc", App: "meet", Incarnation: "0123456789abcdef", Regions: []string{"eu-west-1"}}}, WrittenAt: time.Unix(1_800_000_000, 0).UTC()})
451 if _, err := s.Put(ctx, AuthorityKey, body, ""); err != nil {
452 t.Fatal(err)
453 }
454 a, etag, err := ReadAuthority(ctx, s)
455 if err != nil || etag == "" {
456 t.Fatal(err)
457 }
458 if got, ok := a.Lookup("abc", "meet"); !ok || got.Incarnation != "0123456789abcdef" {
459 t.Fatalf("lookup = %+v %v", got, ok)
460 }
461 if _, ok := a.Lookup("abc", "other"); ok {
462 t.Fatal("unknown app found")
463 }
464}
465
466func TestLease(t *testing.T) {
467 var l Lease
468 t0 := time.Unix(1_800_000_000, 0)
469 if l.Live(t0) {
470 t.Fatal("never renewed should not be live")
471 }
472 l.Renew(t0)
473 if !l.Live(t0.Add(14 * time.Minute)) {
474 t.Fatal("14m should be live")
475 }
476 if l.Live(t0.Add(16 * time.Minute)) {
477 t.Fatal("16m should not be live")
478 }
479}
480```
481
482If `release.NewMemStore` does not exist under that name, use whatever in-memory `release.Store` the console tests use (`grep -rn "func New.*Store" internal/release/*.go`); the console fixture's `rel` field is one.
483
484- [ ] **Step 2: Run to verify failure, then implement**
485
486```go
487package relay
488
489import (
490 "context"
491 "encoding/json"
492 "errors"
493 "os"
494 "time"
495
496 "github.com/carlosframework/platform/internal/release"
497)
498
499const (
500 AuthorityKey = "control/relay.json"
501 LeaseMaxAge = 15 * time.Minute
502)
503
504type AuthorityApp struct {
505 Account string `json:"account"`
506 App string `json:"app"`
507 Incarnation string `json:"incarnation"`
508 Regions []string `json:"regions"`
509}
510
511type Authority struct {
512 Apps []AuthorityApp `json:"apps"`
513 WrittenAt time.Time `json:"written_at"`
514}
515
516func (a *Authority) Lookup(account, app string) (AuthorityApp, bool) {
517 if a == nil {
518 return AuthorityApp{}, false
519 }
520 for _, x := range a.Apps {
521 if x.Account == account && x.App == app {
522 return x, true
523 }
524 }
525 return AuthorityApp{}, false
526}
527
528func ReadAuthority(ctx context.Context, s release.Store) (*Authority, string, error) {
529 body, etag, err := s.Get(ctx, AuthorityKey)
530 if errors.Is(err, os.ErrNotExist) {
531 return nil, "", nil
532 }
533 if err != nil {
534 return nil, "", err
535 }
536 var a Authority
537 if err := json.Unmarshal(body, &a); err != nil {
538 return nil, etag, err
539 }
540 return &a, etag, nil
541}
542
543// Lease is the relay's serving lease: live while the last SUCCESSFUL
544// authority read is younger than LeaseMaxAge. A failed read renews nothing.
545type Lease struct{ last time.Time }
546
547func (l *Lease) Renew(now time.Time) { l.last = now }
548func (l *Lease) Live(now time.Time) bool { return !l.last.IsZero() && now.Sub(l.last) <= LeaseMaxAge }
549
550func HeartbeatKey(box string) string { return "control/relay-ack/" + box + ".json" }
551
552type Heartbeat struct {
553 Box string `json:"box"`
554 Region string `json:"region"`
555 WrittenAt time.Time `json:"written_at"` // of the authority record held; zero if none
556 ReadAt time.Time `json:"read_at"`
557 Listeners []string `json:"listeners"`
558 Kids []string `json:"kids"`
559 MastersError string `json:"masters_error,omitempty"`
560}
561
562func WriteHeartbeat(ctx context.Context, s release.Store, hb Heartbeat) error {
563 body, err := json.Marshal(hb)
564 if err != nil {
565 return err
566 }
567 _, err = s.Put(ctx, HeartbeatKey(hb.Box), body, "")
568 return err
569}
570```
571
572- [ ] **Step 3: Run tests, commit**
573
574```bash
575git add internal/relay/authority.go internal/relay/authority_test.go
576git commit -m "relay: authority record, serving lease, heartbeat (spec §3, §5)"
577```
578
579---
580
581### Task 4: Limits
582
583**Files:**
584- Create: `internal/relay/limits.go`
585- Test: `internal/relay/limits_test.go`
586
587**Interfaces:**
588- Produces:
589 ```go
590 type Limits struct {
591 AppLive, AppPerMinute int; AppBytesPerHour int64
592 AccountLive, AccountPerMinute int; AccountBytesPerHour int64
593 BoxLive, BoxPerMinute int
594 AllocBitsPerSec, BoxBitsPerSec int64
595 }
596 func DefaultLimits() Limits // 64,30,20<<30; 256,60,60<<30; 2048,500; 8_000_000, 200_000_000
597 func LimitsFromEnv(getenv func(string) string) Limits // CARLOS_RELAY_LIMIT_* overrides, names below
598 type Identity struct{ Account, App string }
599 type Admission struct{ /* unexported; one mutex */ }
600 func NewAdmission(l Limits, now func() time.Time) *Admission
601 func (a *Admission) Admit(id Identity) (ok bool, reason string) // counts an attempt even on refusal
602 func (a *Admission) Release(id Identity)
603 func (a *Admission) AddBytes(id Identity, n int64) (over bool) // rolling hour; over=true when a budget is crossed
604 func (a *Admission) Seed(id Identity, at time.Time, n int64) // rebuild rolling window on start
605 func (a *Admission) OverBudget(id Identity, now time.Time) bool
606 type Bucket struct{ /* token bucket */ }
607 func NewBucket(bitsPerSec int64, now func() time.Time) *Bucket
608 func (b *Bucket) Allow(nbytes int) bool
609 ```
610- Env names: `CARLOS_RELAY_LIMIT_APP_LIVE`, `_APP_PER_MINUTE`, `_APP_BYTES_PER_HOUR`, `_ACCOUNT_LIVE`, `_ACCOUNT_PER_MINUTE`, `_ACCOUNT_BYTES_PER_HOUR`, `_BOX_LIVE`, `_BOX_PER_MINUTE`, `_ALLOC_BPS`, `_BOX_BPS`.
611
612- [ ] **Step 1: Tests**
613
614```go
615func TestAdmissionCaps(t *testing.T) {
616 now := time.Unix(1_800_000_000, 0)
617 clock := func() time.Time { return now }
618 l := DefaultLimits()
619 l.AppLive, l.AppPerMinute, l.AccountLive, l.BoxLive = 2, 100, 3, 100
620 a := NewAdmission(l, clock)
621 meet := Identity{"abc", "meet"}
622 docs := Identity{"abc", "docs"}
623 for i := 0; i < 2; i++ {
624 if ok, r := a.Admit(meet); !ok {
625 t.Fatalf("admit %d refused: %s", i, r)
626 }
627 }
628 if ok, r := a.Admit(meet); ok || r != "app live" {
629 t.Fatalf("third meet = %v %q", ok, r)
630 }
631 if ok, _ := a.Admit(docs); !ok {
632 t.Fatal("docs first should pass")
633 }
634 if ok, r := a.Admit(docs); ok || r != "account live" {
635 t.Fatalf("account cap = %v %q", ok, r)
636 }
637 a.Release(meet)
638 if ok, _ := a.Admit(docs); !ok {
639 t.Fatal("after release, account has room")
640 }
641}
642
643func TestAdmissionRate(t *testing.T) {
644 now := time.Unix(1_800_000_000, 0)
645 clock := func() time.Time { return now }
646 l := DefaultLimits()
647 l.AppPerMinute = 2
648 a := NewAdmission(l, clock)
649 id := Identity{"abc", "meet"}
650 a.Admit(id); a.Release(id)
651 a.Admit(id); a.Release(id)
652 if ok, r := a.Admit(id); ok || r != "app rate" {
653 t.Fatalf("rate = %v %q", ok, r)
654 }
655 now = now.Add(61 * time.Second)
656 if ok, _ := a.Admit(id); !ok {
657 t.Fatal("rate window should have rolled")
658 }
659}
660
661func TestAdmissionBudgetRollsAndSeeds(t *testing.T) {
662 now := time.Unix(1_800_000_000, 0)
663 clock := func() time.Time { return now }
664 l := DefaultLimits()
665 l.AppBytesPerHour = 100
666 a := NewAdmission(l, clock)
667 id := Identity{"abc", "meet"}
668 if a.AddBytes(id, 60) {
669 t.Fatal("60 under 100")
670 }
671 if !a.AddBytes(id, 50) {
672 t.Fatal("110 over 100")
673 }
674 now = now.Add(61 * time.Minute)
675 if a.OverBudget(id, now) {
676 t.Fatal("window rolled")
677 }
678 b := NewAdmission(l, clock)
679 b.Seed(id, now.Add(-30*time.Minute), 90)
680 if !b.AddBytes(id, 20) {
681 t.Fatal("seeded 90 + 20 over 100")
682 }
683}
684
685func TestBucket(t *testing.T) {
686 now := time.Unix(1_800_000_000, 0)
687 clock := func() time.Time { return now }
688 b := NewBucket(8000, clock) // 1000 bytes/s
689 if !b.Allow(1000) {
690 t.Fatal("first second's worth")
691 }
692 if b.Allow(1) {
693 t.Fatal("empty")
694 }
695 now = now.Add(500 * time.Millisecond)
696 if !b.Allow(500) || b.Allow(1) {
697 t.Fatal("refilled 500")
698 }
699}
700```
701
702- [ ] **Step 2: Implement**
703
704```go
705package relay
706
707import (
708 "strconv"
709 "sync"
710 "time"
711)
712
713type Limits struct {
714 AppLive, AppPerMinute int
715 AppBytesPerHour int64
716 AccountLive, AccountPerMinute int
717 AccountBytesPerHour int64
718 BoxLive, BoxPerMinute int
719 AllocBitsPerSec, BoxBitsPerSec int64
720}
721
722func DefaultLimits() Limits {
723 return Limits{
724 AppLive: 64, AppPerMinute: 30, AppBytesPerHour: 20 << 30,
725 AccountLive: 256, AccountPerMinute: 60, AccountBytesPerHour: 60 << 30,
726 BoxLive: 2048, BoxPerMinute: 500,
727 AllocBitsPerSec: 8_000_000, BoxBitsPerSec: 200_000_000,
728 }
729}
730
731func LimitsFromEnv(getenv func(string) string) Limits {
732 l := DefaultLimits()
733 i := func(name string, dst *int) {
734 if v, err := strconv.Atoi(getenv("CARLOS_RELAY_LIMIT_" + name)); err == nil && v > 0 {
735 *dst = v
736 }
737 }
738 i64 := func(name string, dst *int64) {
739 if v, err := strconv.ParseInt(getenv("CARLOS_RELAY_LIMIT_"+name), 10, 64); err == nil && v > 0 {
740 *dst = v
741 }
742 }
743 i("APP_LIVE", &l.AppLive); i("APP_PER_MINUTE", &l.AppPerMinute); i64("APP_BYTES_PER_HOUR", &l.AppBytesPerHour)
744 i("ACCOUNT_LIVE", &l.AccountLive); i("ACCOUNT_PER_MINUTE", &l.AccountPerMinute); i64("ACCOUNT_BYTES_PER_HOUR", &l.AccountBytesPerHour)
745 i("BOX_LIVE", &l.BoxLive); i("BOX_PER_MINUTE", &l.BoxPerMinute)
746 i64("ALLOC_BPS", &l.AllocBitsPerSec); i64("BOX_BPS", &l.BoxBitsPerSec)
747 return l
748}
749
750type Identity struct{ Account, App string }
751
752type window struct { // sliding counters bucketed per minute, 60 buckets for an hour
753 at [60]time.Time
754 n [60]int64
755}
756
757func (w *window) add(now time.Time, n int64) {
758 m := now.Truncate(time.Minute)
759 i := int(m.Unix()/60) % 60
760 if !w.at[i].Equal(m) {
761 w.at[i], w.n[i] = m, 0
762 }
763 w.n[i] += n
764}
765
766func (w *window) sum(now time.Time, span time.Duration) int64 {
767 var s int64
768 cut := now.Add(-span)
769 for i := range w.at {
770 if !w.at[i].IsZero() && w.at[i].After(cut) {
771 s += w.n[i]
772 }
773 }
774 return s
775}
776
777type counters struct {
778 live int
779 starts window // attempts per minute
780 bytes window // bytes per rolling hour
781}
782
783type Admission struct {
784 mu sync.Mutex
785 l Limits
786 now func() time.Time
787 app map[Identity]*counters
788 acct map[string]*counters
789 box counters
790}
791
792func NewAdmission(l Limits, now func() time.Time) *Admission {
793 return &Admission{l: l, now: now, app: map[Identity]*counters{}, acct: map[string]*counters{}}
794}
795
796func (a *Admission) get(id Identity) (*counters, *counters) {
797 c, ok := a.app[id]
798 if !ok {
799 c = &counters{}
800 a.app[id] = c
801 }
802 ac, ok := a.acct[id.Account]
803 if !ok {
804 ac = &counters{}
805 a.acct[id.Account] = ac
806 }
807 return c, ac
808}
809
810// Admit counts an attempt for the app, the account and the box whether or
811// not it is admitted, then checks live caps, rates and budgets in that order.
812func (a *Admission) Admit(id Identity) (bool, string) {
813 a.mu.Lock()
814 defer a.mu.Unlock()
815 now := a.now()
816 c, ac := a.get(id)
817 c.starts.add(now, 1); ac.starts.add(now, 1); a.box.starts.add(now, 1)
818 switch {
819 case c.live >= a.l.AppLive:
820 return false, "app live"
821 case ac.live >= a.l.AccountLive:
822 return false, "account live"
823 case a.box.live >= a.l.BoxLive:
824 return false, "box live"
825 case c.starts.sum(now, time.Minute) > int64(a.l.AppPerMinute):
826 return false, "app rate"
827 case ac.starts.sum(now, time.Minute) > int64(a.l.AccountPerMinute):
828 return false, "account rate"
829 case a.box.starts.sum(now, time.Minute) > int64(a.l.BoxPerMinute):
830 return false, "box rate"
831 case c.bytes.sum(now, time.Hour) > a.l.AppBytesPerHour:
832 return false, "app budget"
833 case ac.bytes.sum(now, time.Hour) > a.l.AccountBytesPerHour:
834 return false, "account budget"
835 }
836 c.live++; ac.live++; a.box.live++
837 return true, ""
838}
839
840func (a *Admission) Release(id Identity) {
841 a.mu.Lock()
842 defer a.mu.Unlock()
843 c, ac := a.get(id)
844 if c.live > 0 { c.live-- }
845 if ac.live > 0 { ac.live-- }
846 if a.box.live > 0 { a.box.live-- }
847}
848
849// AddBytes records forwarded bytes and reports whether the app or account
850// rolling-hour budget is now exceeded.
851func (a *Admission) AddBytes(id Identity, n int64) bool {
852 a.mu.Lock()
853 defer a.mu.Unlock()
854 now := a.now()
855 c, ac := a.get(id)
856 c.bytes.add(now, n); ac.bytes.add(now, n)
857 return c.bytes.sum(now, time.Hour) > a.l.AppBytesPerHour || ac.bytes.sum(now, time.Hour) > a.l.AccountBytesPerHour
858}
859
860func (a *Admission) Seed(id Identity, at time.Time, n int64) {
861 a.mu.Lock()
862 defer a.mu.Unlock()
863 c, ac := a.get(id)
864 c.bytes.add(at, n); ac.bytes.add(at, n)
865}
866
867func (a *Admission) OverBudget(id Identity, now time.Time) bool {
868 a.mu.Lock()
869 defer a.mu.Unlock()
870 c, ac := a.get(id)
871 return c.bytes.sum(now, time.Hour) > a.l.AppBytesPerHour || ac.bytes.sum(now, time.Hour) > a.l.AccountBytesPerHour
872}
873
874// Bucket is a token bucket in bytes with a one-second burst.
875type Bucket struct {
876 mu sync.Mutex
877 rate float64 // bytes per second
878 tokens float64
879 last time.Time
880 now func() time.Time
881}
882
883func NewBucket(bitsPerSec int64, now func() time.Time) *Bucket {
884 r := float64(bitsPerSec) / 8
885 return &Bucket{rate: r, tokens: r, last: now(), now: now}
886}
887
888func (b *Bucket) Allow(nbytes int) bool {
889 b.mu.Lock()
890 defer b.mu.Unlock()
891 t := b.now()
892 b.tokens += t.Sub(b.last).Seconds() * b.rate
893 if b.tokens > b.rate {
894 b.tokens = b.rate
895 }
896 b.last = t
897 if float64(nbytes) > b.tokens {
898 return false
899 }
900 b.tokens -= float64(nbytes)
901 return true
902}
903```
904
905- [ ] **Step 3: Run tests, commit**
906
907```bash
908git add internal/relay/limits.go internal/relay/limits_test.go
909git commit -m "relay: admission, rolling budgets, token buckets (spec §3 limits)"
910```
911
912---
913
914### Task 5: Wrapped sockets: counting, buckets, EIP port gate, close
915
916**Files:**
917- Create: `internal/relay/conn.go`
918- Test: `internal/relay/conn_test.go`
919
920**Interfaces:**
921- Produces:
922 ```go
923 // Binding is what a relay socket belongs to.
924 type Binding struct{ ID Identity; Expiry time.Time; Kid string; Src string /* typed source key */ }
925 // relayConn wraps an allocation's relay PacketConn.
926 type relayConn struct{ /* unexported */ }
927 func newRelayConn(inner net.PacketConn, b *Binding, eip net.IP, ports *portSet, appBucket, boxBucket *Bucket, onIn func(Identity, int), onRejected func(Identity, int), onClose func(*relayConn)) *relayConn
928 // WriteTo: client→peer forwarding. Drops (returns n,nil) when the bucket refuses or when dst is the EIP on a port not in ports.
929 // ReadFrom: peer→relay; counted by the server only after pion forwards (see Task 6), so this wrapper counts nothing on read.
930 // Close: idempotent; runs onClose once.
931 type portSet struct{ /* unexported; mutex + map[int]bool */ }
932 func (p *portSet) add(port int); func (p *portSet) remove(port int); func (p *portSet) has(port int) bool
933 func typedSrc(a net.Addr) string // "udp:" or "tcp:" + a.String(); the owner-binding key
934 ```
935
936- [ ] **Step 1: Tests**
937
938```go
939func TestRelayConnGatesEIPPorts(t *testing.T) {
940 inner := &fakePacketConn{}
941 ports := &portSet{m: map[int]bool{50000: true}}
942 eip := net.ParseIP("203.0.113.5")
943 now := time.Unix(1_800_000_000, 0)
944 clock := func() time.Time { return now }
945 var in int
946 c := newRelayConn(inner, &Binding{ID: Identity{"abc", "meet"}}, eip, ports, NewBucket(1<<40, clock), NewBucket(1<<40, clock),
947 func(_ Identity, n int) { in += n }, nil, func(*relayConn) {})
948 ok := &net.UDPAddr{IP: eip, Port: 50000}
949 bad := &net.UDPAddr{IP: eip, Port: 3478}
950 other := &net.UDPAddr{IP: net.ParseIP("198.51.100.9"), Port: 4444}
951 c.WriteTo([]byte("abc"), ok)
952 c.WriteTo([]byte("abc"), bad)
953 c.WriteTo([]byte("abcd"), other)
954 if len(inner.written) != 2 || in != 7 {
955 t.Fatalf("written=%d in=%d, want 2 packets and 7 bytes", len(inner.written), in)
956 }
957}
958
959func TestRelayConnBucketDrops(t *testing.T) {
960 inner := &fakePacketConn{}
961 now := time.Unix(1_800_000_000, 0)
962 clock := func() time.Time { return now }
963 c := newRelayConn(inner, &Binding{}, nil, &portSet{m: map[int]bool{}}, NewBucket(80, clock), NewBucket(1<<40, clock), func(Identity, int) {}, nil, func(*relayConn) {})
964 dst := &net.UDPAddr{IP: net.ParseIP("198.51.100.9"), Port: 4444}
965 c.WriteTo(make([]byte, 10), dst) // 10 bytes = the whole second
966 c.WriteTo(make([]byte, 1), dst)
967 if len(inner.written) != 1 {
968 t.Fatalf("written = %d, want 1 (second dropped by bucket)", len(inner.written))
969 }
970}
971
972func TestRelayConnCloseOnce(t *testing.T) {
973 n := 0
974 c := newRelayConn(&fakePacketConn{}, &Binding{}, nil, &portSet{m: map[int]bool{}}, nil, nil, nil, nil, func(*relayConn) { n++ })
975 c.Close(); c.Close()
976 if n != 1 {
977 t.Fatalf("onClose ran %d times", n)
978 }
979}
980
981func TestTypedSrc(t *testing.T) {
982 u := &net.UDPAddr{IP: net.ParseIP("192.0.2.1"), Port: 5000}
983 tc := &net.TCPAddr{IP: net.ParseIP("192.0.2.1"), Port: 5000}
984 if typedSrc(u) == typedSrc(tc) {
985 t.Fatal("udp and tcp with the same address must not collide")
986 }
987}
988
989// fakePacketConn records writes; ReadFrom blocks until Close.
990type fakePacketConn struct {
991 mu sync.Mutex
992 written [][]byte
993 closed chan struct{}
994 once sync.Once
995}
996func (f *fakePacketConn) init() { f.once.Do(func() { f.closed = make(chan struct{}) }) }
997func (f *fakePacketConn) ReadFrom(b []byte) (int, net.Addr, error) { f.init(); <-f.closed; return 0, nil, net.ErrClosed }
998func (f *fakePacketConn) WriteTo(b []byte, a net.Addr) (int, error) { f.mu.Lock(); defer f.mu.Unlock(); f.written = append(f.written, append([]byte(nil), b...)); return len(b), nil }
999func (f *fakePacketConn) Close() error { f.init(); select { case <-f.closed: default: close(f.closed) }; return nil }
1000func (f *fakePacketConn) LocalAddr() net.Addr { return &net.UDPAddr{IP: net.IPv4zero, Port: 50000} }
1001func (f *fakePacketConn) SetDeadline(time.Time) error { return nil }
1002func (f *fakePacketConn) SetReadDeadline(time.Time) error { return nil }
1003func (f *fakePacketConn) SetWriteDeadline(time.Time) error { return nil }
1004```
1005
1006- [ ] **Step 2: Implement**
1007
1008```go
1009package relay
1010
1011import (
1012 "net"
1013 "sync"
1014 "time"
1015)
1016
1017// Binding is the identity an allocation was created under. Src is the
1018// typed source key (typedSrc) pion will present on later requests.
1019type Binding struct {
1020 ID Identity
1021 Expiry time.Time
1022 Kid string
1023 Src string
1024}
1025
1026// typedSrc keys the owner binding on transport AND address, because pion
1027// tags every allocation's tuple as UDP even for TCP clients and a UDP and a
1028// TCP client can share an address string.
1029func typedSrc(a net.Addr) string {
1030 switch a.(type) {
1031 case *net.TCPAddr:
1032 return "tcp:" + a.String()
1033 default:
1034 return "udp:" + a.String()
1035 }
1036}
1037
1038type portSet struct {
1039 mu sync.Mutex
1040 m map[int]bool
1041}
1042
1043func (p *portSet) add(port int) { p.mu.Lock(); p.m[port] = true; p.mu.Unlock() }
1044func (p *portSet) remove(port int) { p.mu.Lock(); delete(p.m, port); p.mu.Unlock() }
1045func (p *portSet) has(port int) bool { p.mu.Lock(); defer p.mu.Unlock(); return p.m[port] }
1046
1047// relayConn is the PacketConn the relay hands pion for one allocation.
1048// Closing it is how the relay ends the allocation: pion's read loop deletes
1049// the allocation when ReadFrom errors (allocation.go:301 in pion/turn
1050// v4.1.4). WriteTo is the client→peer leg and is where forwarded bytes are
1051// counted, the per-allocation and box buckets applied, and the EIP port
1052// gate enforced.
1053type relayConn struct {
1054 net.PacketConn
1055 b *Binding
1056 eip net.IP
1057 ports *portSet
1058 appBucket *Bucket
1059 boxBucket *Bucket
1060 onIn func(Identity, int)
1061 onRejected func(Identity, int)
1062 onClose func(*relayConn)
1063 once sync.Once
1064}
1065
1066func newRelayConn(inner net.PacketConn, b *Binding, eip net.IP, ports *portSet, appBucket, boxBucket *Bucket,
1067 onIn func(Identity, int), onRejected func(Identity, int), onClose func(*relayConn)) *relayConn {
1068 return &relayConn{PacketConn: inner, b: b, eip: eip, ports: ports, appBucket: appBucket, boxBucket: boxBucket,
1069 onIn: onIn, onRejected: onRejected, onClose: onClose}
1070}
1071
1072func (c *relayConn) WriteTo(p []byte, addr net.Addr) (int, error) {
1073 if ua, ok := addr.(*net.UDPAddr); ok && c.eip != nil && ua.IP.Equal(c.eip) && !c.ports.has(ua.Port) {
1074 if c.onRejected != nil {
1075 c.onRejected(c.b.ID, len(p))
1076 }
1077 return len(p), nil // dropped: only live relay ports are reachable on our own EIP
1078 }
1079 if (c.appBucket != nil && !c.appBucket.Allow(len(p))) || (c.boxBucket != nil && !c.boxBucket.Allow(len(p))) {
1080 return len(p), nil // dropped by a token bucket
1081 }
1082 n, err := c.PacketConn.WriteTo(p, addr)
1083 if n > 0 && c.onIn != nil {
1084 c.onIn(c.b.ID, n)
1085 }
1086 return n, err
1087}
1088
1089func (c *relayConn) Close() error {
1090 var err error
1091 c.once.Do(func() {
1092 err = c.PacketConn.Close()
1093 if c.onClose != nil {
1094 c.onClose(c)
1095 }
1096 })
1097 return err
1098}
1099```
1100
1101- [ ] **Step 3: Run tests, commit**
1102
1103```bash
1104git add internal/relay/conn.go internal/relay/conn_test.go
1105git commit -m "relay: wrapped relay socket with counting, buckets and the EIP port gate (spec §3)"
1106```
1107
1108---
1109
1110### Task 6: The pion server wrapper (Opus)
1111
1112**Files:**
1113- Modify: `go.mod`, `go.sum` (add `github.com/pion/turn/v4 v4.1.4`)
1114- Create: `internal/relay/server.go`
1115- Test: `internal/relay/server_test.go` (end to end on loopback)
1116
1117**Interfaces:**
1118- Consumes: Tasks 1 to 5.
1119- Produces:
1120 ```go
1121 type ServerConfig struct {
1122 Region string
1123 RelayIP net.IP // the EIP advertised to clients
1124 UDP, TCP string // listen addresses, e.g. ":3478"; empty disables
1125 MinPort, MaxPort uint16 // 49152, 65535
1126 DenyCIDRs []*net.IPNet // from CARLOS_RELAY_DENY_CIDRS plus the built-in private ranges
1127 Masters func() *Masters
1128 Authority func() *Authority
1129 Lease func(now time.Time) bool
1130 Limits Limits
1131 Now func() time.Time
1132 Log func(string, ...any)
1133 Meter *Meter // Task 7; nil allowed in tests
1134 }
1135 type Server struct{ /* unexported */ }
1136 func NewServer(cfg ServerConfig) (*Server, error)
1137 func (s *Server) Close() error
1138 func (s *Server) EndAll(reason string) // lease lapse, shedding
1139 func (s *Server) EndWhere(func(*Binding) bool, reason string) // disable, incarnation change, region removed, budget, master retired
1140 func (s *Server) Live() int
1141 ```
1142
1143- [ ] **Step 1: Add the dependency** (sandbox lifted for this one command)
1144
1145Run: `GOFLAGS= GOPROXY=https://proxy.golang.org go get github.com/pion/turn/v4@v4.1.4 && go mod tidy`
1146Then: `GOFLAGS=-mod=mod GOPROXY=off go build ./...` must still pass; `git diff go.mod` shows only pion modules added.
1147
1148- [ ] **Step 2: Write the end-to-end test**
1149
1150```go
1151func TestServerEndToEnd(t *testing.T) {
1152 master := sha256.Sum256([]byte("carlos-relay-test-master"))
1153 masters, _ := ParseMasters(strings.NewReader("k1 " + b64(master[:]) + " -\n"))
1154 auth := &Authority{Apps: []AuthorityApp{{Account: "abc", App: "meet", Incarnation: "0123456789abcdef", Regions: []string{"test"}}}, WrittenAt: time.Now()}
1155 now := time.Now()
1156 var mu sync.Mutex
1157 clock := func() time.Time { mu.Lock(); defer mu.Unlock(); return now }
1158 s, err := NewServer(ServerConfig{
1159 Region: "test", RelayIP: net.ParseIP("127.0.0.1"), UDP: "127.0.0.1:0",
1160 MinPort: 40000, MaxPort: 40100,
1161 Masters: func() *Masters { return masters }, Authority: func() *Authority { return auth },
1162 Lease: func(time.Time) bool { return true }, Limits: DefaultLimits(), Now: clock, Log: t.Logf,
1163 })
1164 if err != nil {
1165 t.Fatal(err)
1166 }
1167 defer s.Close()
1168
1169 key := DeriveKey(master[:], "abc", "meet", "0123456789abcdef")
1170 exp := now.Add(time.Hour).Unix()
1171 username := fmt.Sprintf("%d:%s", exp, Suffix("abc", "meet", "0123456789abcdef", "k1"))
1172 client := turnClient(t, s.UDPAddr(), username, Credential(key, username))
1173 relayConn, err := client.Allocate()
1174 if err != nil {
1175 t.Fatalf("allocate: %v", err)
1176 }
1177 defer relayConn.Close()
1178 if s.Live() != 1 {
1179 t.Fatalf("live = %d", s.Live())
1180 }
1181
1182 // Wrong app's credential from the same source is refused (owner binding).
1183 other := fmt.Sprintf("%d:%s", exp, Suffix("abc", "docs", "0123456789abcdef", "k1"))
1184 if _, err := turnClient(t, s.UDPAddr(), other, Credential(DeriveKey(master[:], "abc", "docs", "0123456789abcdef"), other)).Allocate(); err == nil {
1185 t.Fatal("unknown app allocated")
1186 }
1187
1188 // Expiry ends the allocation with no client action.
1189 mu.Lock(); now = now.Add(2 * time.Hour); mu.Unlock()
1190 s.tick() // exported for tests: runs the expiry sweep once
1191 waitFor(t, func() bool { return s.Live() == 0 })
1192}
1193
1194func TestServerRefusesPrivatePeer(t *testing.T) {
1195 // allocate as above, then client.CreatePermission(&net.UDPAddr{IP: net.ParseIP("10.0.0.1"), Port: 1}) must error.
1196}
1197
1198func TestServerEndWhere(t *testing.T) {
1199 // allocate, then s.EndWhere(func(b *Binding) bool { return b.ID.App == "meet" }, "disable"); waitFor Live()==0.
1200}
1201```
1202
1203`turnClient` builds a `turn.Client` with `turn.ClientConfig{STUNServerAddr, TURNServerAddr: addr, Conn: udp, Username, Password, Realm: "carlos"}` and calls `Listen()`; see `github.com/pion/turn/v4` examples `turn-client/udp`. `waitFor` polls for two seconds.
1204
1205- [ ] **Step 3: Implement `server.go`**
1206
1207Design, from spec §3:
1208
1209- `NewServer` opens the UDP and TCP listeners, builds a `turn.ServerConfig{Realm: "carlos", AuthHandler: s.auth, QuotaHandler: s.quota, EventHandler: turn.EventHandler{OnAllocationCreated: s.created, OnAllocationDeleted: s.deleted}, PacketConnConfigs: [{PacketConn: udp, RelayAddressGenerator: s, PermissionHandler: s.permit}], ListenerConfigs: [{Listener: tcp, RelayAddressGenerator: s, PermissionHandler: s.permit}]}`.
1210- `Server` implements `turn.RelayAddressGenerator`: `Validate()`, `AllocatePacketConn(network string, requestedPort int) (net.PacketConn, net.Addr, error)` binds a UDP socket on `RelayIP`-side interface in `[MinPort, MaxPort]`, wraps it in `newRelayConn`, records it in `s.pending` keyed by relay port, adds the port to `s.ports`, and returns the wrapper and `&net.UDPAddr{IP: cfg.RelayIP, Port: port}`. `AllocateConn` returns `errUnsupported`.
1211- `auth(username, realm string, src net.Addr) ([]byte, bool)`: `ParseUsername`; expiry in `(now, now+MaxCredentialLife]`; `s.bound[typedSrc(src)]` if present must have the same Identity+Incarnation; `Authority().Lookup`; incarnation and region match; `Masters().Lookup(kid, now)`; `DeriveKey`; `Credential`; `turn.GenerateAuthKey(username, realm, cred)`; stash the parsed username in `s.lastAuth[typedSrc(src)]` so `created` can build the Binding. Count refusal reasons per app in `s.refusals`.
1212- `quota(username, realm string, src net.Addr) bool`: `Lease(now)` false → refuse; `Admit(Identity)` → refuse with reason logged.
1213- `created(src, dst net.Addr, proto, username, realm string, relayAddr net.Addr)`: take `s.pending[relayPort]`, set its Binding from `s.lastAuth[typedSrc(src)]`, move it to `s.allocs[typedSrc(src)]`, set `s.bound[typedSrc(src)] = binding`, start the expiry timer (`time.AfterFunc(expiry-now, conn.Close)`).
1214- `deleted(...)`: no-op; cleanup is in `relayConn.onClose`: remove the port from `s.ports`, `Admission.Release`, delete `s.bound[src]` only if it still points at this conn's binding, delete `s.allocs[src]` likewise.
1215- `permit(client net.Addr, peer net.IP) bool`: refuse if `peer` is in any `DenyCIDRs`, loopback, link-local, RFC 1918, ULA, or equals a relay listener's private address; allow `RelayIP` (the EIP; the port gate is in `relayConn.WriteTo`).
1216- `EndWhere`/`EndAll`: iterate `s.allocs`, `Close()` matching conns.
1217- `tick()`: close any conn whose `Binding.Expiry <= now` (belt for the timers), and any whose master is retired (`Masters().Lookup(kid, now)` false), and any whose app is `OverBudget`.
1218- Bytes: `onIn` → `Admission.AddBytes`; if over, `EndWhere(app)`. The `out` leg is counted by wrapping the listener conns' `WriteTo` (a `listenerConn` type in this file mirroring `relayConn` without the EIP gate, attributing by `s.bound[typedSrc(dst)]`). Both legs call `Meter.Add(id, in, out)` when `Meter != nil`.
1219- Built-in deny ranges: `127.0.0.0/8, ::1/128, 169.254.0.0/16, fe80::/10, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7, 100.64.0.0/10`.
1220
1221- [ ] **Step 4: Run the end-to-end tests, then the cross-compile check**
1222
1223Run: `go test ./internal/relay/ -run TestServer -v`
1224Run: `for t in darwin/arm64 windows/amd64 linux/arm64; do GOOS=${t%/*} GOARCH=${t#*/} CGO_ENABLED=0 go build ./... || echo FAIL $t; done`
1225Expected: PASS, no FAIL lines.
1226
1227- [ ] **Step 5: Commit**
1228
1229```bash
1230git add go.mod go.sum internal/relay/server.go internal/relay/server_test.go
1231git commit -m "relay: pion server with our own address generator, owner binding, termination and peer policy (spec §3, §4)"
1232```
1233
1234---
1235
1236### Task 7: Meter
1237
1238**Files:**
1239- Create: `internal/relay/meter.go`
1240- Test: `internal/relay/meter_test.go`
1241
1242**Interfaces:**
1243- Produces:
1244 ```go
1245 func MeterKey(box, hour string) string // "metrics/relay/<box>/<hour>.json"
1246 type MeterSample struct{ At time.Time; Apps map[string]MeterApp } // key "<account>/<app>"
1247 type MeterApp struct{ In, Out, Rejected int64; Allocs int }
1248 type MeterChunk struct{ V int; Box, Hour string; Samples []MeterSample }
1249 type Meter struct{ /* unexported */ }
1250 func NewMeter(box string, now func() time.Time) *Meter
1251 func (m *Meter) Add(id Identity, in, out, rejected int64)
1252 func (m *Meter) SetAllocs(id Identity, n int)
1253 func (m *Meter) Flush(ctx, s release.Store) error // one sample of deltas since last flush; read-append-CAS on the hour's object, retry 3x
1254 func LoadWindow(ctx, s release.Store, box string, now time.Time) ([]MeterSample, error) // previous and current hour's samples
1255 ```
1256
1257- [ ] **Step 1: Tests**
1258
1259```go
1260func TestMeterFlushAppendsDeltas(t *testing.T) {
1261 s := newMemStore()
1262 now := time.Date(2026, 9, 7, 12, 30, 0, 0, time.UTC)
1263 m := NewMeter("box1", func() time.Time { return now })
1264 id := Identity{"abc", "meet"}
1265 m.Add(id, 100, 200, 5)
1266 if err := m.Flush(context.Background(), s); err != nil { t.Fatal(err) }
1267 m.Add(id, 10, 20, 0)
1268 if err := m.Flush(context.Background(), s); err != nil { t.Fatal(err) }
1269 body, _, _ := s.Get(context.Background(), MeterKey("box1", "2026-09-07-12"))
1270 var c MeterChunk
1271 json.Unmarshal(body, &c)
1272 if len(c.Samples) != 2 || c.Samples[0].Apps["abc/meet"].In != 100 || c.Samples[1].Apps["abc/meet"].Out != 20 {
1273 t.Fatalf("chunk = %+v", c)
1274 }
1275}
1276
1277func TestLoadWindowSpansTwoHours(t *testing.T) {
1278 s := newMemStore()
1279 now := time.Date(2026, 9, 7, 12, 1, 0, 0, time.UTC)
1280 m := NewMeter("box1", func() time.Time { return now.Add(-30 * time.Minute) }) // 11:31
1281 m.Add(Identity{"abc", "meet"}, 7, 0, 0)
1282 m.Flush(context.Background(), s)
1283 samples, err := LoadWindow(context.Background(), s, "box1", now)
1284 if err != nil || len(samples) != 1 || samples[0].Apps["abc/meet"].In != 7 {
1285 t.Fatalf("window = %+v %v", samples, err)
1286 }
1287}
1288```
1289
1290- [ ] **Step 2: Implement**
1291
1292```go
1293package relay
1294
1295import (
1296 "context"
1297 "encoding/json"
1298 "errors"
1299 "os"
1300 "sync"
1301 "time"
1302
1303 "github.com/carlosframework/platform/internal/metrics"
1304 "github.com/carlosframework/platform/internal/release"
1305)
1306
1307func MeterKey(box, hour string) string { return "metrics/relay/" + box + "/" + hour + ".json" }
1308
1309type MeterApp struct {
1310 In int64 `json:"in"`
1311 Out int64 `json:"out"`
1312 Rejected int64 `json:"rejected"`
1313 Allocs int `json:"allocs"`
1314}
1315
1316type MeterSample struct {
1317 At time.Time `json:"at"`
1318 Apps map[string]MeterApp `json:"apps"`
1319}
1320
1321type MeterChunk struct {
1322 V int `json:"v"`
1323 Box string `json:"box"`
1324 Hour string `json:"hour"`
1325 Samples []MeterSample `json:"samples"`
1326}
1327
1328type Meter struct {
1329 mu sync.Mutex
1330 box string
1331 now func() time.Time
1332 cur map[Identity]*MeterApp
1333}
1334
1335func NewMeter(box string, now func() time.Time) *Meter {
1336 return &Meter{box: box, now: now, cur: map[Identity]*MeterApp{}}
1337}
1338
1339func (m *Meter) get(id Identity) *MeterApp {
1340 a, ok := m.cur[id]
1341 if !ok {
1342 a = &MeterApp{}
1343 m.cur[id] = a
1344 }
1345 return a
1346}
1347
1348func (m *Meter) Add(id Identity, in, out, rejected int64) {
1349 m.mu.Lock()
1350 a := m.get(id)
1351 a.In += in; a.Out += out; a.Rejected += rejected
1352 m.mu.Unlock()
1353}
1354
1355func (m *Meter) SetAllocs(id Identity, n int) {
1356 m.mu.Lock()
1357 m.get(id).Allocs = n
1358 m.mu.Unlock()
1359}
1360
1361// Flush writes one sample of deltas since the previous flush, appended to
1362// this box's hourly object by read-append-CAS. The relay is the only
1363// writer of these objects, so the CAS only ever races with itself.
1364func (m *Meter) Flush(ctx context.Context, s release.Store) error {
1365 m.mu.Lock()
1366 now := m.now()
1367 sample := MeterSample{At: now, Apps: map[string]MeterApp{}}
1368 for id, a := range m.cur {
1369 sample.Apps[id.Account+"/"+id.App] = *a
1370 m.cur[id] = &MeterApp{Allocs: a.Allocs} // deltas reset, gauge kept
1371 }
1372 m.mu.Unlock()
1373 hour := metrics.HourOf(now)
1374 key := MeterKey(m.box, hour)
1375 var lastErr error
1376 for attempt := 0; attempt < 3; attempt++ {
1377 body, etag, err := s.Get(ctx, key)
1378 var c MeterChunk
1379 switch {
1380 case errors.Is(err, os.ErrNotExist):
1381 c = MeterChunk{V: 1, Box: m.box, Hour: hour}
1382 etag = ""
1383 case err != nil:
1384 return err
1385 default:
1386 if json.Unmarshal(body, &c) != nil {
1387 c = MeterChunk{V: 1, Box: m.box, Hour: hour}
1388 }
1389 }
1390 c.Samples = append(c.Samples, sample)
1391 out, _ := json.Marshal(c)
1392 if _, err = s.Put(ctx, key, out, etag); err == nil {
1393 return nil
1394 } else if !errors.Is(err, release.ErrCASConflict) {
1395 return err
1396 } else {
1397 lastErr = err
1398 }
1399 }
1400 return lastErr
1401}
1402
1403// LoadWindow returns every sample from the previous and current hour's
1404// objects, for rebuilding rolling-hour budgets after a restart.
1405func LoadWindow(ctx context.Context, s release.Store, box string, now time.Time) ([]MeterSample, error) {
1406 var out []MeterSample
1407 for _, h := range []string{metrics.HourOf(now.Add(-time.Hour)), metrics.HourOf(now)} {
1408 body, _, err := s.Get(ctx, MeterKey(box, h))
1409 if errors.Is(err, os.ErrNotExist) {
1410 continue
1411 }
1412 if err != nil {
1413 return nil, err
1414 }
1415 var c MeterChunk
1416 if json.Unmarshal(body, &c) == nil {
1417 out = append(out, c.Samples...)
1418 }
1419 }
1420 return out, nil
1421}
1422```
1423
1424- [ ] **Step 3: Run tests, commit**
1425
1426```bash
1427git add internal/relay/meter.go internal/relay/meter_test.go
1428git commit -m "relay: per-app meter with its own hourly objects (spec §3 metering)"
1429```
1430
1431---
1432
1433### Task 8: `Serve`, the unit, bootstrap arming, and the adopt restart
1434
1435**Files:**
1436- Create: `internal/relay/serve.go`
1437- Create: `internal/host/units/carlos-relay.service`
1438- Modify: `internal/host/bootstrap.go:72` (`enabledUnits`), `:96-110` (`systemctlPlan`)
1439- Modify: `internal/host/systemapply.go:33` (unit const), `:382` (restart), `:568` (`restartEdge`)
1440- Modify: `internal/host/host_test.go:29` (`expectedUnits`)
1441- Modify: `relay.go` (root; created here with `serve` and `keygen`, other verbs in Task 11), `main.go:76` (dispatch)
1442- Test: `internal/host/host_test.go`, `internal/relay/serve_test.go`
1443
1444**Interfaces:**
1445- Produces:
1446 ```go
1447 // internal/relay
1448 type Config struct {
1449 Box, Region string; MastersPath string; PublicIP net.IP; DenyCIDRs []*net.IPNet
1450 Store release.Store; Limits Limits; PressurePath string /* /proc/pressure/memory */
1451 }
1452 func ConfigFromEnv(ctx context.Context, getenv func(string) string, imdsIP, imdsRegion func() string, store release.Store) (Config, error)
1453 func Serve(ctx context.Context, cfg Config, logf func(string, ...any)) error
1454 ```
1455- Env read by `ConfigFromEnv`: `CARLOS_RELAY_PUBLIC_IP` (override), `CARLOS_RELAY_DENY_CIDRS` (comma list), `CARLOS_AWS_REGION` (override), `CARLOS_RELAY_MASTERS_PATH` (default `/etc/carlos/relay-masters`), `CARLOS_RELAY_LIMIT_*`.
1456
1457- [ ] **Step 1: The unit file**
1458
1459```ini
1460# The platform STUN/TURN relay (media roadmap stage 4a). Armed only on a
1461# box that carries /etc/carlos/relay-masters; ConditionPathExists keeps a
1462# box without a master from ever listening. Shares the box with the edge and
1463# must lose first: bounded memory, PSI-driven shedding in the process, and
1464# the highest OOM adjustment there is.
1465[Unit]
1466Description=carlos relay (STUN and TURN)
1467After=network-online.target
1468Wants=network-online.target
1469ConditionPathExists=/etc/carlos/relay-masters
1470
1471[Service]
1472EnvironmentFile=-/etc/carlos/host.env
1473ExecStart=/opt/carlos/carlos relay serve
1474Restart=always
1475RestartSec=1
1476
1477User=carlos
1478Group=carlos
1479NoNewPrivileges=yes
1480ProtectSystem=strict
1481ProtectHome=yes
1482ReadWritePaths=/data/relay /run/carlos
1483PrivateTmp=yes
1484PrivateDevices=yes
1485ProtectClock=yes
1486ProtectHostname=yes
1487ProtectKernelTunables=yes
1488ProtectKernelModules=yes
1489ProtectKernelLogs=yes
1490ProtectControlGroups=yes
1491ProtectProc=invisible
1492RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
1493RestrictNamespaces=yes
1494RestrictRealtime=yes
1495RestrictSUIDSGID=yes
1496LockPersonality=yes
1497MemoryDenyWriteExecute=yes
1498SystemCallFilter=@system-service
1499SystemCallErrorNumber=EPERM
1500SystemCallArchitectures=native
1501MemoryHigh=160M
1502MemoryMax=256M
1503OOMScoreAdjust=1000
1504TasksMax=512
1505Nice=5
1506CPUWeight=50
1507
1508[Install]
1509WantedBy=multi-user.target
1510```
1511
1512- [ ] **Step 2: Arm it in bootstrap** (`internal/host/bootstrap.go`)
1513
1514Change line 72:
1515```go
1516var enabledUnits = []string{"carlos-edge.service", "carlos-relay.service", "carlos-adopt.timer", "carlos-adopt.path", "carlos-stage.path", "carlos-restart.path"}
1517```
1518Change `systemctlPlan` so the relay is started, not only enabled (timers and paths already are):
1519```go
1520for _, u := range units {
1521 if strings.HasSuffix(u, ".timer") || strings.HasSuffix(u, ".path") || u == "carlos-relay.service" {
1522 armed = append(armed, u)
1523 }
1524}
1525```
1526`ConditionPathExists` makes `start` a harmless no-op on a box with no masters file, so this is safe fleet-wide. Also create `/data/relay` in `bootstrap()` beside the other data dirs (find where `/data` subdirectories are created and add `relay` with the same owner and mode).
1527
1528- [ ] **Step 3: Restart on platform update** (`internal/host/systemapply.go`)
1529
1530Add beside line 33: `systemRelayUnit = "carlos-relay"`. Add after `restartEdge`:
1531```go
1532// restartRelay mirrors restartEdge for the relay; a unit that is inactive
1533// because its ConditionPathExists failed returns an error from restart,
1534// which is not a failure of the update, so it is logged and swallowed.
1535func (a *systemApplier) restartRelay() {
1536 a.ops.systemctl("reset-failed", systemRelayUnit)
1537 if _, err := a.ops.systemctl("try-restart", systemRelayUnit); err != nil {
1538 log.Printf("system: relay try-restart: %v", err)
1539 }
1540}
1541```
1542Call `a.restartRelay()` immediately after each `a.restartEdge()` succeeds (lines 382 and the rollback site near 692).
1543
1544- [ ] **Step 4: Tests** in `internal/host/host_test.go`
1545
1546Add `"carlos-relay.service"` to `expectedUnits` (keep the slice sorted). Add:
1547```go
1548func TestBootstrapStartsRelayUnit(t *testing.T) {
1549 plan := systemctlPlan(enabledUnits)
1550 var started []string
1551 for _, step := range plan {
1552 if step[0] == "start" {
1553 started = step[1:]
1554 }
1555 }
1556 if !slices.Contains(started, "carlos-relay.service") {
1557 t.Fatalf("relay not started by plan: %v", plan)
1558 }
1559 if slices.Contains(started, "carlos-edge.service") {
1560 t.Fatalf("edge must stay start-by-hand: %v", plan)
1561 }
1562}
1563```
1564
1565- [ ] **Step 5: `Serve` and `ConfigFromEnv`**
1566
1567```go
1568// Serve runs the relay until ctx is done: watches the masters file, reads
1569// authority every minute (and on control/serial change), heartbeats, flushes
1570// the meter, ticks expiry, and sheds under memory pressure.
1571func Serve(ctx context.Context, cfg Config, logf func(string, ...any)) error {
1572 var (
1573 mu sync.Mutex
1574 masters *Masters
1575 mastersE string
1576 auth *Authority
1577 lease Lease
1578 )
1579 now := time.Now
1580 meter := NewMeter(cfg.Box, now)
1581 if samples, err := LoadWindow(ctx, cfg.Store, cfg.Box, now()); err == nil {
1582 // seeded into Admission below after NewServer
1583 _ = samples
1584 }
1585 srv, err := NewServer(ServerConfig{
1586 Region: cfg.Region, RelayIP: cfg.PublicIP, UDP: ":3478", TCP: ":3478", MinPort: 49152, MaxPort: 65535,
1587 DenyCIDRs: cfg.DenyCIDRs, Limits: cfg.Limits, Now: now, Log: logf, Meter: meter,
1588 Masters: func() *Masters { mu.Lock(); defer mu.Unlock(); return masters },
1589 Authority: func() *Authority { mu.Lock(); defer mu.Unlock(); return auth },
1590 Lease: func(t time.Time) bool { mu.Lock(); defer mu.Unlock(); return lease.Live(t) },
1591 })
1592 if err != nil {
1593 return err
1594 }
1595 defer srv.Close()
1596 // (seed: for each sample in the window, srv.admission.Seed(id, sample.At, app.In+app.Out))
1597
1598 go WatchMasters(ctx, cfg.MastersPath, func(m *Masters, err error) {
1599 mu.Lock()
1600 if err != nil { mastersE = err.Error() } else { masters, mastersE = m, "" }
1601 mu.Unlock()
1602 if err == nil { srv.tick() } // retire_at may have passed
1603 })
1604
1605 readAuth := func() {
1606 a, _, err := ReadAuthority(ctx, cfg.Store)
1607 t := now()
1608 mu.Lock()
1609 if err == nil { auth = a; lease.Renew(t) } else { logf("relay: authority read: %v", err) }
1610 live := lease.Live(t)
1611 hb := Heartbeat{Box: cfg.Box, Region: cfg.Region, ReadAt: t, Listeners: srv.Listeners(), Kids: kidsOf(masters), MastersError: mastersE}
1612 if auth != nil { hb.WrittenAt = auth.WrittenAt }
1613 mu.Unlock()
1614 if !live { srv.EndAll("lease lapsed") }
1615 srv.EndWhere(func(b *Binding) bool { // authority changed under it
1616 x, ok := auth.Lookup(b.ID.Account, b.ID.App)
1617 return !ok || x.Incarnation != b.Incarnation || !slices.Contains(x.Regions, cfg.Region)
1618 }, "authority")
1619 if err := WriteHeartbeat(ctx, cfg.Store, hb); err != nil { logf("relay: heartbeat: %v", err) }
1620 }
1621 readAuth()
1622 authTick := time.NewTicker(time.Minute)
1623 serialTick := time.NewTicker(3 * time.Second)
1624 meterTick := time.NewTicker(time.Minute)
1625 expiryTick := time.NewTicker(10 * time.Second)
1626 psiTick := time.NewTicker(5 * time.Second)
1627 defer func() { for _, t := range []*time.Ticker{authTick, serialTick, meterTick, expiryTick, psiTick} { t.Stop() } }()
1628 var lastSerial int64 = -1
1629 for {
1630 select {
1631 case <-ctx.Done():
1632 return nil
1633 case <-authTick.C:
1634 readAuth()
1635 case <-serialTick.C:
1636 if doc, _, err := release.ReadSerial(ctx, cfg.Store); err == nil && doc.Serial != lastSerial {
1637 lastSerial = doc.Serial
1638 readAuth()
1639 }
1640 case <-meterTick.C:
1641 if err := meter.Flush(ctx, cfg.Store); err != nil { logf("relay: meter flush: %v", err) }
1642 case <-expiryTick.C:
1643 srv.tick()
1644 case <-psiTick.C:
1645 if some, err := readPSI(cfg.PressurePath); err == nil {
1646 if some > 20 { srv.ShedOldest(1) } // one per tick until under 10; ShedOldest is a Server method closing the oldest binding
1647 }
1648 }
1649 }
1650}
1651```
1652`readPSI` parses the `some avg10=` field of `/proc/pressure/memory`. `Binding` gains an `Incarnation string` field set at `created` from the parsed username (add it in Task 6 if not already present). Write `ConfigFromEnv` reading the env names above, using `imdsIP()`/`imdsRegion()` when the overrides are empty, and refusing to start with no public IP.
1653
1654- [ ] **Step 6: The verb**
1655
1656Create `relay.go` at the root with `runRelay(args)` dispatching `serve` and `keygen` now (other sub-verbs in Task 11):
1657```go
1658func runRelayServe(args []string) {
1659 ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
1660 defer stop()
1661 store, err := openDeployStoreForCLI(ctx) // see below
1662 if err != nil { log.Fatalf("relay serve: %v", err) }
1663 cfg, err := relay.ConfigFromEnv(ctx, os.Getenv, host.ReadPublicIP, host.ReadPlacementRegion, store)
1664 if err != nil { log.Fatalf("relay serve: %v", err) }
1665 if err := relay.Serve(ctx, cfg, log.Printf); err != nil { log.Fatalf("relay serve: %v", err) }
1666}
1667
1668func runRelayKeygen(_ []string) {
1669 key := make([]byte, 32)
1670 rand.Read(key)
1671 kid := make([]byte, 4)
1672 rand.Read(kid)
1673 fmt.Printf("%s %s -\n", hex.EncodeToString(kid), base64.StdEncoding.EncodeToString(key))
1674}
1675```
1676Export `host.ReadPublicIP` and `host.ReadPlacementRegion` as thin wrappers over the existing unexported `readPublicIP`/`readPlacementRegion` (`internal/host/agent.go:1808`, `:1831`), and `host.OpenDeployStore(ctx)` over `openDeployStore` (`agent.go:2692`); the box label comes from `host.BoxLabel()` wrapping `metricsBoxLabel(os.Getenv, readMachineID())`. Add `case "relay": runRelay(args)` to `main.go` after `store`, and a line to `usage()`.
1677
1678- [ ] **Step 7: Run all tests, cross-compile, commit**
1679
1680Run: `go test ./internal/relay/ ./internal/host/ -count=1 && go build ./... && for t in darwin/arm64 windows/amd64 linux/arm64; do GOOS=${t%/*} GOARCH=${t#*/} CGO_ENABLED=0 go build ./... || echo FAIL $t; done`
1681
1682```bash
1683git add internal/relay/serve.go internal/host/units/carlos-relay.service internal/host/bootstrap.go internal/host/systemapply.go internal/host/host_test.go internal/host/agent.go internal/host/metrics_loop.go relay.go main.go
1684git commit -m "relay: carlos relay serve, the unit, bootstrap arming and adopt restart (spec §3)"
1685```
1686
1687---
1688
1689### Task 9: The per-app record and the config value
1690
1691**Files:**
1692- Create: `internal/relay/record.go`, `internal/relay/config.go`
1693- Test: `internal/relay/record_test.go`, `internal/relay/config_test.go`
1694
1695**Interfaces:**
1696- Produces:
1697 ```go
1698 func RecordKey(acct, app string) string // "apps/<acct>/<app>/relay.json"
1699 type RouteCutover struct{ Host string; Suffixes map[string]string /* region→suffix */; At time.Time; By string }
1700 type Record struct {
1701 Enabled bool; Incarnation string; Regions []string
1702 Op int64; Phase string /* "config"|"aggregate"|"done" */
1703 IssuedSuffixes map[string]string /* region→username_suffix last issued */
1704 Cutovers []RouteCutover /* member acks for TCP exec routes */
1705 EnabledAt, DisabledAt time.Time
1706 }
1707 func GetRecord(ctx, s release.Store, acct, app string) (*Record, string, error)
1708 func PutRecord(ctx, s release.Store, acct, app string, r *Record, etag string) (string, error)
1709 func NewIncarnation() string // 16 lowercase hex from crypto/rand
1710 type RegionMaster struct{ Region, Kid string; Master []byte }
1711 type RegionURLs func(region string) []string // urls for a region, from the platform domain
1712 func BuildConfig(acct, app string, r *Record, masters []RegionMaster, urls RegionURLs) (value string, suffixes map[string]string, err error)
1713 // value is base64(std, padded) of the JSON array in spec §2, elements in r.Regions order; masters must cover every region in r.Regions.
1714 func ParseConfig(value string) ([]ConfigEntry, error) // for the config-ack (Task 12) and tests
1715 type ConfigEntry struct{ Region string; URLs []string; Key string; UsernameSuffix string }
1716 ```
1717
1718- [ ] **Step 1: Tests**
1719
1720```go
1721func TestBuildConfigMatchesContract(t *testing.T) {
1722 m := sha256.Sum256([]byte("carlos-relay-test-master"))
1723 r := &Record{Enabled: true, Incarnation: "0123456789abcdef", Regions: []string{"eu-west-1"}}
1724 urls := func(region string) []string {
1725 h := "relay." + region + ".oncarlos.com:3478"
1726 return []string{"stun:" + h, "turn:" + h + "?transport=udp", "turn:" + h + "?transport=tcp"}
1727 }
1728 val, suffixes, err := BuildConfig("abc", "meet", r, []RegionMaster{{Region: "eu-west-1", Kid: "k1", Master: m[:]}}, urls)
1729 if err != nil { t.Fatal(err) }
1730 if err := appconfig.ValidVarValue(val); err != nil { t.Fatalf("not env-safe: %v", err) }
1731 entries, err := ParseConfig(val)
1732 if err != nil || len(entries) != 1 { t.Fatalf("parse = %+v %v", entries, err) }
1733 e := entries[0]
1734 if e.Key != "uMhwW4P/9O5dQUIs91t2CCHGhwJbeiM0xQMQ2G0CJLU=" || e.UsernameSuffix != "abc:meet:0123456789abcdef:k1" || suffixes["eu-west-1"] != e.UsernameSuffix {
1735 t.Fatalf("entry = %+v", e)
1736 }
1737 if len(e.URLs) != 3 || e.URLs[0] != "stun:relay.eu-west-1.oncarlos.com:3478" { t.Fatalf("urls = %v", e.URLs) }
1738}
1739
1740func TestBuildConfigRefusesMissingMaster(t *testing.T) {
1741 r := &Record{Enabled: true, Incarnation: "0123456789abcdef", Regions: []string{"eu-west-1", "us-east-1"}}
1742 if _, _, err := BuildConfig("abc", "meet", r, []RegionMaster{{Region: "eu-west-1", Kid: "k1", Master: make([]byte, 32)}}, func(string) []string { return nil }); err == nil {
1743 t.Fatal("missing us-east-1 master accepted")
1744 }
1745}
1746
1747func TestNewIncarnation(t *testing.T) {
1748 a, b := NewIncarnation(), NewIncarnation()
1749 if !incarnationRe.MatchString(a) || a == b { t.Fatalf("%q %q", a, b) }
1750}
1751
1752func TestRecordRoundTrip(t *testing.T) {
1753 s := newMemStore()
1754 ctx := context.Background()
1755 r, etag, err := GetRecord(ctx, s, "abc", "meet")
1756 if err != nil || r != nil || etag != "" { t.Fatalf("absent = %v %q %v", r, etag, err) }
1757 etag, err = PutRecord(ctx, s, "abc", "meet", &Record{Enabled: true, Incarnation: NewIncarnation(), Op: 1, Phase: "config"}, "")
1758 if err != nil { t.Fatal(err) }
1759 if _, err := PutRecord(ctx, s, "abc", "meet", &Record{}, "stale-etag"); !errors.Is(err, release.ErrCASConflict) {
1760 t.Fatalf("stale put = %v, want CAS conflict", err)
1761 }
1762 r, _, _ = GetRecord(ctx, s, "abc", "meet")
1763 if !r.Enabled || r.Op != 1 { t.Fatalf("record = %+v", r) }
1764}
1765```
1766
1767- [ ] **Step 2: Implement** `record.go` (mirror `internal/objstore/objstore.go:132-170` Get/Put with `os.ErrNotExist` → nil) and `config.go`:
1768
1769```go
1770type ConfigEntry struct {
1771 Region string `json:"region"`
1772 URLs []string `json:"urls"`
1773 Key string `json:"key"`
1774 UsernameSuffix string `json:"username_suffix"`
1775}
1776
1777func BuildConfig(acct, app string, r *Record, masters []RegionMaster, urls RegionURLs) (string, map[string]string, error) {
1778 byRegion := map[string]RegionMaster{}
1779 for _, m := range masters { byRegion[m.Region] = m }
1780 entries := make([]ConfigEntry, 0, len(r.Regions))
1781 suffixes := map[string]string{}
1782 for _, region := range r.Regions {
1783 m, ok := byRegion[region]
1784 if !ok { return "", nil, fmt.Errorf("relay: no issuer master for region %s", region) }
1785 key := DeriveKey(m.Master, acct, app, r.Incarnation)
1786 suffix := Suffix(acct, app, r.Incarnation, m.Kid)
1787 suffixes[region] = suffix
1788 entries = append(entries, ConfigEntry{Region: region, URLs: urls(region), Key: base64.StdEncoding.EncodeToString(key), UsernameSuffix: suffix})
1789 }
1790 js, err := json.Marshal(entries)
1791 if err != nil { return "", nil, err }
1792 return base64.StdEncoding.EncodeToString(js), suffixes, nil
1793}
1794
1795func ParseConfig(value string) ([]ConfigEntry, error) {
1796 js, err := base64.StdEncoding.DecodeString(value)
1797 if err != nil { return nil, fmt.Errorf("relay: CARLOS_RELAY is not base64: %w", err) }
1798 var out []ConfigEntry
1799 if err := json.Unmarshal(js, &out); err != nil { return nil, fmt.Errorf("relay: CARLOS_RELAY is not a JSON array: %w", err) }
1800 return out, nil
1801}
1802```
1803
1804- [ ] **Step 3: Run tests, commit**
1805
1806```bash
1807git add internal/relay/record.go internal/relay/config.go internal/relay/*_test.go
1808git commit -m "relay: per-app record and the CARLOS_RELAY value (spec §2, §5)"
1809```
1810
1811---
1812
1813### Task 10: Console operations: writer lease, aggregate publish, reconcile (Opus)
1814
1815**Files:**
1816- Create: `internal/console/relayops.go`
1817- Test: `internal/console/relayops_test.go`
1818
1819**Interfaces:**
1820- Consumes: Task 9; `appconfig.Apply` (`internal/appconfig/generation.go:119`), `appconfig.Seal`, `release.BumpSerial`, `s.cfg.Releases`, `s.cfg.SecretsPub`.
1821- Produces (methods on `*Server`, all requiring the writer lease):
1822 ```go
1823 const relayWriterKey = "control/relay-writer.json"
1824 const relayIssuerKey = "control/relay-issuer.json" // {"<region>":"<kid>"}
1825 type relayWriter struct{ /* holder id, expiry; renew every 20s; 60s TTL */ }
1826 func (s *Server) relayWriterLoop(ctx) // acquires/renews; sets s.relayHeld atomically
1827 func (s *Server) relayHold() bool
1828 func (s *Server) relayMasters(region string) (kid string, master []byte, err error) // console secret CARLOS_RELAY_MASTERS_<REGION> + issuer pointer
1829 func (s *Server) relayEnable(ctx, acct *Account, app string, regions []string, by string) (*relay.Record, error)
1830 func (s *Server) relayDisable(ctx, acct *Account, app string, by string) (*relay.Record, error)
1831 func (s *Server) relayAdopted(ctx, acct *Account, app, host string, by string) (*relay.Record, error)
1832 func (s *Server) relayRotate(ctx, region string) error // gate on heartbeats, move issuer pointer, re-issue every enabled app in region
1833 func (s *Server) relayPublishAggregate(ctx) error // etag-first rebuild from a listing, CAS, retry
1834 func (s *Server) relayReconcile(ctx) error // every 5 min and at start; converges config and aggregate from every record
1835 ```
1836- Console config additions (`cmd/console/main.go` beside `storeOperators()`): `CARLOS_RELAY_REGIONS` (comma list, ordered) → `Config.RelayRegions []string`; `CARLOS_RELAY_MASTERS_<REGION>` read at use time through the console's own sealed-secret accessor (find how the console reads its own sealed secrets, `grep -rn "SecretsPriv\|consoleSecret" internal/console cmd/console`); `Config.PlatformDomain` for relay hostnames (`relay.<region>.<platform domain>`).
1837
1838- [ ] **Step 1: Tests** (each with the console fixture; `fx.rel` is the `release.Store`)
1839
1840```go
1841func TestRelayEnableWritesRecordConfigAndAggregate(t *testing.T) {
1842 fx, _ := relayFixture(t, "code-relay-enable") // like storeFixture plus RelayRegions=["eu-west-1"], a test master in the fixture's console secrets, SecretsPub set
1843 ctx := context.Background()
1844 rec, err := fx.srv.relayEnable(ctx, fx.acct(), "titogo", nil, "alice@keymail.dev")
1845 if err != nil { t.Fatal(err) }
1846 if !rec.Enabled || rec.Phase != "done" || rec.Op != 1 || len(rec.Incarnation) != 16 { t.Fatalf("record = %+v", rec) }
1847 m, _, _ := appconfig.Read(ctx, fx.rel, fx.sqid, "titogo") // whatever reads the default bundle manifest
1848 if v, ok := m.Vars["CARLOS_RELAY"]; !ok || v.Kind != "secret" { t.Fatalf("CARLOS_RELAY = %+v", v) }
1849 a, _, _ := relay.ReadAuthority(ctx, fx.rel)
1850 if x, ok := a.Lookup(fx.sqid, "titogo"); !ok || x.Incarnation != rec.Incarnation { t.Fatalf("aggregate = %+v", a) }
1851}
1852
1853func TestRelayRepeatEnableKeepsIncarnationAndWritesNoConfig(t *testing.T) { /* enable twice; second returns same Incarnation, Op unchanged, config generation unchanged */ }
1854
1855func TestRelayDisableThenEnableIsANewIncarnation(t *testing.T) { /* enable, disable (aggregate lacks app, CARLOS_RELAY unset), enable: Incarnation differs */ }
1856
1857func TestRelayPublishAggregateIsEtagFirst(t *testing.T) {
1858 // Seed two enabled records A and B. Read the aggregate etag. Disable A directly via PutRecord + relayPublishAggregate.
1859 // Then call the internal publish helper with the STALE etag and a listing that still has A: it must return ErrCASConflict and, on retry, publish without A.
1860}
1861
1862func TestRelayReconcileFinishesInterruptedEnable(t *testing.T) {
1863 // PutRecord{Enabled:true, Op:1, Phase:"config"} with no config and no aggregate; relayReconcile; expect CARLOS_RELAY set, aggregate has app, Phase "done".
1864}
1865
1866func TestRelayReconcileUndoesStaleConfigAfterDisable(t *testing.T) {
1867 // Disabled record but CARLOS_RELAY present in config; relayReconcile; expect it unset.
1868}
1869
1870func TestRelayReconcileRepairsDeletionWithoutRecord(t *testing.T) {
1871 // Aggregate lists an app with no record; relayReconcile; aggregate no longer lists it.
1872}
1873
1874func TestRelayEnableRefusesRouteWithoutDelivery(t *testing.T) {
1875 // fx.addRoute(registry.Route{Host: "x.titogo...", App: "titogo", Addr: "10.0.0.5:8080"}) with no exec_delivery; relayEnable errors naming the host.
1876}
1877
1878func TestRelayRotateRefusesUntilHeartbeatsCarryKid(t *testing.T) {
1879 // Two masters in the fixture secret for eu-west-1; issuer pointer k1; heartbeat for box1 lists only k1; relayRotate("eu-west-1") errors; write heartbeat listing k2; relayRotate succeeds; issuer pointer now k2; enabled app's IssuedSuffixes end in ":k2".
1880}
1881
1882func TestRelayWriterLeaseExcludesSecondHolder(t *testing.T) {
1883 // Two Server values over one store; first acquires; second's acquire fails until the first's expiry passes on a fake clock.
1884}
1885```
1886
1887- [ ] **Step 2: Implement** in this order, each behind the lease: `relayWriterLoop` (CAS on `relayWriterKey` with `{holder, expires_at}`; acquire if absent or expired; renew every 20s; on failed renew set held=false and let in-flight ops fail their next `requireRelayLease()` check); `relayMasters` (parse `CARLOS_RELAY_MASTERS_<REGION>` as `kid:key[,kid:key]`, read `relayIssuerKey`, return the named one); `relayPublishAggregate` exactly per spec §5 (read aggregate etag first, then `ListDirs("apps/")` and each account's apps to find every `relay.json`, build from records with `Enabled`, `Put` with the first etag, on `ErrCASConflict` restart, four attempts); `relayApplyConfig(ctx, acct, app string, rec *relay.Record)` (build via `relay.BuildConfig` with `urls(region) = []string{"stun:relay."+region+"."+domain+":3478", "turn:...?transport=udp", "turn:...?transport=tcp"}`, `appconfig.Seal` the value with `s.cfg.SecretsPub`, `appconfig.Apply(ctx, s.cfg.Releases, acct.Sqid, app, map[string]appconfig.Var{"CARLOS_RELAY": {Kind: "secret", Envelope: env}}, nil)`; for disable `Apply(..., nil, []string{"CARLOS_RELAY"})`); `relayEnable`/`relayDisable` as the spec's transition table with a per-app `sync.Mutex` from `s.relayAppLocks sync.Map`; the route-delivery check using `s.registryRoutesForApp` (whatever the instances API uses to list an app's routes and their `Backing`/`Addr`) and `appconfig`'s exec-delivery resolution; `relayReconcile` per spec §5 (for every record: if enabled, expected config value equals current → skip, else re-apply; if disabled and present → unset; then unconditional `relayPublishAggregate`); `relayRotate` per spec §5 step 4. Start `relayWriterLoop` and a five-minute `relayReconcile` ticker from the console's existing background-loop startup, gated on `len(s.cfg.RelayRegions) > 0`.
1888
1889- [ ] **Step 3: Run tests, commit**
1890
1891```bash
1892git add internal/console/relayops.go internal/console/relayops_test.go cmd/console/main.go internal/console/server.go
1893git commit -m "console: relay operations under a writer lease, etag-first aggregate, converging reconcile (spec §5)"
1894```
1895
1896---
1897
1898### Task 11: Console doors and the CLI
1899
1900**Files:**
1901- Create: `internal/console/apirelay.go`
1902- Modify: `internal/console/authcli.go:700` (routes), `apimode.go` (client calls), `relay.go` (root; add enable/status/disable/adopted/rotate), `docs/site/cli.md` (`### carlos relay`)
1903- Test: `internal/console/apirelay_test.go`
1904
1905**Interfaces:**
1906- Routes (`s.cliRoute`, `svcrole.Allow(svcrole.Admin)` for all but status which allows read roles too, matching store):
1907 ```
1908 POST /api/cli/apps/{account}/{app}/relay body {"regions":[...]} → relayEnable
1909 GET /api/cli/apps/{account}/{app}/relay → status
1910 POST /api/cli/apps/{account}/{app}/relay/disable → relayDisable
1911 POST /api/cli/apps/{account}/{app}/relay/adopted body {"host":"..."} → relayAdopted
1912 POST /api/cli/relay/rotate body {"region":"..."} → relayRotate (operator: CARLOS_RELAY_OPERATORS, checked after auth, byte-identical 404 on failure)
1913 ```
1914- Status reply shape:
1915 ```go
1916 type apiRelayReply struct {
1917 Enabled bool; Incarnation string; Regions []string; Op int64; Phase string
1918 Issued map[string]string // region → username_suffix
1919 Routes []apiRelayRoute // per route: Host, Shape, Delivery, AckedAt, Cutover
1920 Regions []apiRelayRegion // Region, HeartbeatAge string, Current bool, LeaseLive bool, Listeners, Kids []string, Stale bool
1921 DisablePending []string // regions not yet past the disable
1922 RetireNoEarlierThan map[string]string // region → RFC3339, when rotate is in progress
1923 }
1924 ```
1925
1926- [ ] **Step 1: Door tests** through `rawReq`/`getJSON`/`postJSON` with `relayURL(fx, app) = fx.ts.URL + "/api/cli/apps/" + acct + "/" + app + "/relay"`: 404 byte-identical for a non-member and an unknown app (compare bodies with the store door's 404); refusal message when `RelayRegions` empty; enable → 200 with `enabled:true` and 16-hex incarnation; status after enable shows the region row from a seeded heartbeat with `stale:true` when the heartbeat is six minutes old; disable → `enabled:false`; adopted for an unknown host → 404 with message; rotate without operator → 404.
1927
1928- [ ] **Step 2: Implement doors** on the `handleAPIStoreGet` pattern (`internal/console/apistore.go:299`): `apiAccountApp` gate, `storeNoBucket` equivalent when `s.cfg.Releases == nil`, `jsonError` on failures, `writeJSON` on success. Status assembles the reply from `relay.GetRecord`, the heartbeats under `control/relay-ack/`, the config-acks under `control/config-ack/` (Task 12's shape), and the route list.
1929
1930- [ ] **Step 3: CLI** in `relay.go`: `runRelay` dispatch adds `enable|status|disable|adopted|rotate`; each mirrors `runStoreStatus` (`store.go:157`): flags via `appFlag`, `accountFlags`, `--console`; `requireRelayAPISession` copied from `requireStoreAPISession` with its text adapted; `apiSession.relayEnable/relayGet/relayDisable/relayAdopted/relayRotate` in `apimode.go` on the `storeGet` shape (`apimode.go:1592`); `printRelayStatus` prints the reply as: desired state line, issued kids per region, one line per route with its delivery state, one line per region with heartbeat age and lease, and the retire-no-earlier-than line when present.
1931
1932- [ ] **Step 4: Docs**: add to `docs/site/cli.md` under "Your apps", after `### carlos store`:
1933
1934````markdown
1935### carlos relay
1936
1937A STUN and TURN relay the platform runs for your app's WebRTC calls, so a
1938browser can find its public address and, when two networks cannot reach
1939each other directly, relay through the deployment instead of a third party.
1940
1941```sh
1942carlos relay enable --app meet
1943carlos relay status --app meet
1944```
1945
1946`enable` turns it on for one app and delivers `CARLOS_RELAY` to the app's
1947config: a base64-wrapped JSON list of regions, each with its ICE server URLs,
1948a key and a username suffix. The app mints a TURN credential per call from
1949the key (see the relay design spec, §2). `status` shows what was issued,
1950which routes have picked it up, and the health of each region's relay,
1951none of which needs box access. `disable` removes it and ends every
1952allocation the app holds. `adopted --host <route>` is how a member tells the
1953platform that a TCP-address route it restarted by hand now runs the new
1954key. `rotate --region <r>` is the operator's verb after staging a new
1955master; `keygen` prints one. `serve` is the unit's entry point and not a
1956thing to run by hand.
1957````
1958
1959- [ ] **Step 5: Run the docsite gate and all tests, commit**
1960
1961Run: `go test ./internal/docsite/ ./internal/console/ -count=1 && go build ./...`
1962
1963```bash
1964git add internal/console/apirelay.go internal/console/apirelay_test.go internal/console/authcli.go apimode.go relay.go docs/site/cli.md
1965git commit -m "relay: console doors and carlos relay enable|status|disable|adopted|rotate (spec §5)"
1966```
1967
1968---
1969
1970### Task 12: The per-route config acknowledgement
1971
1972**Files:**
1973- Create: `internal/appconfig/configack.go`
1974- Modify: `internal/appconfig/sync.go:331-434` (`syncHost` gains an ack writer callback), `internal/host/agent.go:2937` (pass the store and box)
1975- Test: `internal/appconfig/configack_test.go`, `internal/appconfig/sync_test.go`
1976
1977**Interfaces:**
1978- Produces:
1979 ```go
1980 func ConfigAckKey(box, host string) string // "control/config-ack/<box>/<host>.json"
1981 type ConfigAck struct {
1982 Box, Host, Shape string // shape: "unit" | "socket-exec" | "tcp-exec-file"
1983 At time.Time
1984 Restarted bool; RestartError string
1985 RelaySuffixes map[string]string // region → username_suffix parsed from CARLOS_RELAY, nil if absent
1986 }
1987 func WriteConfigAck(ctx, s release.Store, ack ConfigAck) error
1988 // Sync gains a parameter: ack func(ConfigAck) (nil = no acks)
1989 func Sync(ctx, reg, s, priv, envDir string, restart func(host string) error, ack func(ConfigAck)) error
1990 ```
1991
1992- [ ] **Step 1: Tests**: `TestSyncWritesConfigAckNamingRelaySuffixes`: a route with a manifest carrying `CARLOS_RELAY` (built with `relay.BuildConfig` in the test) is synced with a recording `ack` func; the ack has `Shape: "unit"`, `Restarted: true`, `RelaySuffixes["eu-west-1"] == "abc:meet:...:k1"`. `TestSyncAckReportsRestartFailure`: restart returns an error; ack has `Restarted: false` and the error text. `TestSyncAckWithoutRelayHasNilSuffixes`.
1993
1994- [ ] **Step 2: Implement**: in `syncHost`, after the rename succeeds, build `ConfigAck{Host: rt.Host, Shape: shapeOf(rt), At: time.Now()}`; parse `CARLOS_RELAY` out of the rendered vars (the decrypted manifest is in scope; use `relay.ParseConfig` on its value if present and fill `RelaySuffixes`); call `restart`, set `Restarted`/`RestartError`; call `ack(a)` if non-nil. Import cycle check: `internal/appconfig` importing `internal/relay` is fine only if `internal/relay` does not import `appconfig`; it does not (Task 9's test imports `appconfig` but the package does not). `shapeOf`: `rt.Backing == "unit"` → `"unit"`; `rt.Socket != ""` → `"socket-exec"`; else `"tcp-exec-file"`. The exec-file path (`syncexec.go`) writes the same ack with `Restarted: false`. In `agent.go:2937`, pass `func(a appconfig.ConfigAck) { a.Box = box; if err := appconfig.WriteConfigAck(ctx, store, a); err != nil { log.Printf("adopt: config ack %s: %v", a.Host, err) } }`.
1995
1996- [ ] **Step 3: Run tests, commit**
1997
1998```bash
1999git add internal/appconfig/configack.go internal/appconfig/configack_test.go internal/appconfig/sync.go internal/appconfig/syncexec.go internal/appconfig/sync_test.go internal/host/agent.go
2000git commit -m "appconfig: per-route config acknowledgement naming rendered relay identities (spec §5)"
2001```
2002
2003---
2004
2005### Task 13: Infrastructure
2006
2007**Files:**
2008- Modify: `infra/modules/region-host/variables.tf`, `main.tf`, `outputs.tf`
2009- Modify (pinfra repo, separate PR there): `flagship/region-ie.tf` and siblings; `host-init.sh` if it stages `host.env`
2010
2011- [ ] **Step 1: Module**: add
2012```hcl
2013variable "relay" {
2014 description = "Open the platform STUN/TURN relay's ports (udp+tcp 3478, udp 49152-65535). Off by default; a box also needs /etc/carlos/relay-masters before the unit starts."
2015 type = bool
2016 default = false
2017}
2018```
2019and in `main.tf`, three more `dynamic "ingress"` entries driven by `var.relay ? [1] : []` with descriptions `"relay stun/turn udp"` (3478/3478/udp), `"relay stun/turn tcp"` (3478/3478/tcp), `"relay allocations"` (49152/65535/udp), all `0.0.0.0/0`. Add `output "vpc_cidr" { value = data.aws_vpc.selected.cidr_block }` (find the VPC data source the module already uses). Run `tofu validate` in the module directory.
2020
2021- [ ] **Step 2: Flagship** (pinfra repo): `relay = true` on the Ireland module first; an `aws_route53_record` `relay.eu-west-1.<platform domain>` A → `module.host_ie.eip_public_ip`; document in `flagship/README.md` that `/etc/carlos/relay-masters` and `CARLOS_RELAY_DENY_CIDRS=<vpc_cidr output>` are staged by hand like `host.env`, and that console config gets `CARLOS_RELAY_REGIONS=eu-west-1` and the sealed `CARLOS_RELAY_MASTERS_EU_WEST_1`.
2022
2023- [ ] **Step 3: Commit** (platform repo part)
2024
2025```bash
2026git add infra/modules/region-host/
2027git commit -m "infra: relay variable opens stun/turn ingress and outputs the VPC CIDR (spec §6)"
2028```
2029
2030---
2031
2032### Task 14: Whole-branch gate and landing
2033
2034- [ ] **Step 1: Full gate**: `go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l .` (empty output) and the three cross-compiles.
2035- [ ] **Step 2: Spec coverage walk**: for each of spec §3's bullets, §4's steps, §5's transitions and §7's unit-test list, name the test that covers it; add any missing test.
2036- [ ] **Step 3: Opus review** of the whole branch against the spec, with `superpowers:requesting-code-review`.
2037- [ ] **Step 4: Land**: push to `origin`, `amadan branch describe carlos/platform relay-stun-turn-impl -body -`, wait for `amadan ci status -branch relay-stun-turn-impl` to pass, `amadan branch merge carlos/platform relay-stun-turn-impl -expect <full sha>`.
2038- [ ] **Step 5: Flagship rollout** (operator, from the spec's §7 gate): stage masters and deny CIDRs on the Ireland box; apply pinfra; promote; confirm `carlos-relay.service` active; `carlos relay enable --app <meet app>`; run the §7 flagship checks in order; message the Meet session.
2039
2040---
2041
2042## Self-review
2043
2044**Spec coverage.** §2 contract → Tasks 1, 9 (vector test in 1, env-safety in 9). §3 process/unit → 8; hardening → 8; listeners and generator → 6; owner binding → 5, 6; termination → 6, 8; peer policy and EIP gate → 5, 6; limits → 4, 6; metering → 5, 6, 7; authority and lease → 3, 8. §4 masters → 2; console custody → 10; verification steps → 6. §5 records → 9; writer lease, transitions, publish, reconcile, rotate → 10; doors, status, adopted → 11; config-ack → 12. §6 → 13. §7 flagship gate → 14. §8 exclusions: none built. Gaps: none found.
2045
2046**Placeholders.** Task 6 step 3 and Task 10 step 2 are design-level for the Opus tasks by intent, with every function and behaviour named; Task 6's test file names the three tests. No "TBD".
2047
2048**Type consistency.** `Identity{Account, App}` used in Tasks 4 to 8; `Binding` gains `Incarnation` in Task 6 (noted in Task 8); `relay.Record` fields in Task 9 match Task 10 and 11 uses; `ConfigAck.RelaySuffixes` in Task 12 matches Task 11's status reading; `Sync` signature change in Task 12 must update the one caller at `agent.go:2937` and any test calling `Sync`.
2049