config: parse CARLOS_RELAY at boot; refuse a bad one outside development
internal/meet/config_test.go did not exist yet (the brief assumed it did); created it fresh with a minimalConfig(t) helper and a relayVector(t) helper built from relay's own platform vector, per the brief's fallback instructions. No existing test set RelayConfigured directly, so nothing needed adapting to the Relay/resolve contract.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2
6 files changed,
+168
−11
cmd/meet/main.go+1 −1internal/meet/app.go+31 −0internal/meet/config.go+30 −8internal/meet/config_test.go+104 −0internal/meet/handlers_join.go+1 −1internal/meet/handlers_room.go+1 −1
CI passed — run details
diff --git a/cmd/meet/main.go b/cmd/meet/main.go| index 45fab21..31a34c0 100644 |
| --- a/cmd/meet/main.go |
| +++ b/cmd/meet/main.go |
| @@ -57,7 +57,7 @@ func main() { |
| DEK: os.Getenv("MEET_DEK"), |
| AllowedAddresses: strings.Split(os.Getenv("MEET_ALLOWED_ADDRESSES"), ","), |
| Mailer: mailer, |
| - RelayConfigured: os.Getenv("CARLOS_RELAY") != "", |
| + Relay: os.Getenv("CARLOS_RELAY"), |
| Zone: os.Getenv("MEET_ZONE"), |
| Logger: logger, |
| } |
diff --git a/internal/meet/app.go b/internal/meet/app.go| index 7c9ae3e..ae01879 100644 |
| --- a/internal/meet/app.go |
| +++ b/internal/meet/app.go |
| @@ -19,6 +19,7 @@ import ( |
| "amadan.net/rastrillo/rastrillo/migrate" |
| "amadan.net/oficina/meet/internal/meet/hub" |
| + "amadan.net/oficina/meet/internal/meet/relay" |
| "amadan.net/oficina/meet/internal/meet/seal" |
| ) |
| @@ -62,8 +63,24 @@ type app struct { |
| // re-deriving standing. Nil means "as resolved at boot"; nothing in |
| // production ever stores into it. Read it with allowNow(). |
| allow atomic.Pointer[allowlist] |
| + |
| + // relay is the parsed CARLOS_RELAY contract, held as an atomic so a |
| + // test can hand the app a relay after boot (TestHandle.SetRelay) |
| + // while a request is reading it. build stores rc.relay once; |
| + // production never stores again. Read it with relayRegions(). |
| + relay atomic.Value |
| +} |
| + |
| +// relayRegions is the parsed contract, swapped atomically so a test can |
| +// hand the app a relay after boot (TestHandle.SetRelay). Production |
| +// sets it once in build. |
| +func (a *app) relayRegions() []relay.Region { |
| + v, _ := a.relay.Load().([]relay.Region) |
| + return v |
| } |
| +func (a *app) relayConfigured() bool { return len(a.relayRegions()) > 0 } |
| + |
| func (a *app) heartbeatEvery() time.Duration { return atLeast(a.heartbeat.Load()) } |
| func (a *app) streamLife() time.Duration { return atLeast(a.ttl.Load()) } |
| @@ -113,6 +130,16 @@ func (h *TestHandle) SetAllowlist(addrs ...string) { |
| h.a.allow.Store(&list) |
| } |
| +// SetRelay hands the app a CARLOS_RELAY value after boot. Tests only. |
| +func (h *TestHandle) SetRelay(raw string) error { |
| + regions, err := relay.Parse(raw) |
| + if err != nil { |
| + return err |
| + } |
| + h.a.relay.Store(regions) |
| + return nil |
| +} |
| + |
| // TickForTest runs one pass of the hub housekeeping App runs every |
| // fifteen seconds: unjoined and lapsed participations dropped, |
| // tombstones expired, every joined kind re-derived from standing. |
| @@ -331,6 +358,10 @@ func build(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *app, err |
| a.done = ctx.Done() |
| a.heartbeat.Store(int64(defaultHeartbeat)) |
| a.ttl.Store(int64(defaultStreamTTL)) |
| + a.relay.Store(rc.relay) |
| + if len(rc.relay) > 0 { |
| + rc.Logger.Info("meet: relay configured", "regions", len(rc.relay)) |
| + } |
| // The hub reads standing back through the app, and never under its |
| // own lock: hubStanding is a database read. |
| a.hub = hub.New(hub.Config{ |
diff --git a/internal/meet/config.go b/internal/meet/config.go| index c1e7c20..f092cb6 100644 |
| --- a/internal/meet/config.go |
| +++ b/internal/meet/config.go |
| @@ -8,6 +8,8 @@ import ( |
| "time" |
| "amadan.net/rastrillo/rastrillo/mail" |
| + |
| + "amadan.net/oficina/meet/internal/meet/relay" |
| ) |
| // EnvDevelopment is the one Env value that relaxes startup refusals. |
| @@ -37,9 +39,14 @@ type Config struct { |
| // Mailer delivers magic links. Nil outside development is refused; |
| // nil in development logs the link instead of sending it. |
| Mailer mail.Sender |
| - // RelayConfigured is whether CARLOS_RELAY is present. Slice 1 only |
| - // reads its presence for the holding page's floor sentence; slice 3 |
| - // parses it. |
| + // Relay is the raw $CARLOS_RELAY: the platform's base64-wrapped JSON |
| + // list of regions, keys and username suffixes (platform relay design |
| + // §2). Absent means the floor (§5.5). It is parsed once here and the |
| + // decoded value is held in memory and never logged (§5.2). |
| + Relay string |
| + // RelayConfigured is derived from Relay by resolve; callers read the |
| + // resolved value, not this field. Kept so existing tests that set it |
| + // still compile; resolve overwrites it. |
| RelayConfigured bool |
| // Zone is the IANA zone start times are entered and shown in. |
| // MEET_ZONE, default UTC. Per-person zones are deliberately not in |
| @@ -58,11 +65,13 @@ var devDEK = make([]byte, 32) |
| // default applied. |
| type resolved struct { |
| Config |
| - instanceKey string |
| - dek []byte |
| - mailer mail.Sender |
| - allow allowlist |
| - zone *time.Location |
| + instanceKey string |
| + dek []byte |
| + mailer mail.Sender |
| + allow allowlist |
| + relay []relay.Region |
| + relayConfigured bool |
| + zone *time.Location |
| } |
| func resolve(cfg Config) (resolved, error) { |
| @@ -126,6 +135,19 @@ func resolve(cfg Config) (resolved, error) { |
| } |
| out.allow = list |
| + regions, err := relay.Parse(cfg.Relay) |
| + switch { |
| + case err == nil: |
| + out.relay = regions |
| + out.relayConfigured = len(regions) > 0 |
| + case cfg.development(): |
| + // One line, the variable and not the value: the value carries a |
| + // key. Development runs without a relay rather than refusing. |
| + cfg.Logger.Warn("meet: CARLOS_RELAY is malformed and is ignored in development", "err", err.Error()) |
| + default: |
| + return out, fmt.Errorf("meet: CARLOS_RELAY is present but malformed (%v); a relay the app cannot read is a relay it must not pretend to have", err) |
| + } |
| + |
| zone := strings.TrimSpace(cfg.Zone) |
| if zone == "" { |
| zone = "UTC" |
diff --git a/internal/meet/config_test.go b/internal/meet/config_test.go| new file mode 100644 |
| index 0000000..1ca701d |
| --- /dev/null |
| +++ b/internal/meet/config_test.go |
| @@ -0,0 +1,104 @@ |
| +package meet |
| + |
| +import ( |
| + "bytes" |
| + "encoding/base64" |
| + "encoding/json" |
| + "log/slog" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// minimalConfig is a Config with every startup refusal already |
| +// satisfied outside development, so a test can flip exactly the one |
| +// field it cares about (here, Relay) and see only that field's effect. |
| +func minimalConfig(t *testing.T) Config { |
| + t.Helper() |
| + return Config{ |
| + Env: "production", |
| + Origin: "https://meet.example", |
| + InstanceKey: "meet-test-instance-key", |
| + DEK: base64.StdEncoding.EncodeToString(make([]byte, 32)), |
| + Mailer: failingSender{}, |
| + AllowedAddresses: []string{"paul@example.com"}, |
| + } |
| +} |
| + |
| +// relayVector is the base64 CARLOS_RELAY value for one region, using |
| +// relay's own platform vector key and username suffix (relay_test.go) |
| +// so a leak of either into a log line is unmistakable. |
| +func relayVector(t *testing.T) string { |
| + t.Helper() |
| + const vectorKey = "uMhwW4P/9O5dQUIs91t2CCHGhwJbeiM0xQMQ2G0CJLU=" |
| + regions := []map[string]any{{ |
| + "region": "eu-west-1", |
| + "urls": []string{"stun:relay.eu-west-1.oncarlos.com:3478"}, |
| + "key": vectorKey, |
| + "username_suffix": "abc:meet:0123456789abcdef:k1", |
| + }} |
| + b, err := json.Marshal(regions) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return base64.StdEncoding.EncodeToString(b) |
| +} |
| + |
| +func TestAMalformedRelayRefusesToStartOutsideDevelopment(t *testing.T) { |
| + cfg := minimalConfig(t) |
| + cfg.Env = "production" |
| + cfg.Relay = "%%%not-base64" |
| + _, err := resolve(cfg) |
| + if err == nil || !strings.Contains(err.Error(), "CARLOS_RELAY") { |
| + t.Fatalf("want a refusal naming the variable, got %v", err) |
| + } |
| + if strings.Contains(err.Error(), "%%%") { |
| + t.Fatal("the refusal echoes the value") |
| + } |
| +} |
| + |
| +func TestAMalformedRelayIsIgnoredWithAWarningInDevelopment(t *testing.T) { |
| + var buf bytes.Buffer |
| + cfg := minimalConfig(t) |
| + cfg.Env = EnvDevelopment |
| + cfg.Logger = slog.New(slog.NewTextHandler(&buf, nil)) |
| + cfg.Relay = "%%%not-base64" |
| + out, err := resolve(cfg) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if out.relay != nil || out.relayConfigured { |
| + t.Fatal("a malformed relay must mean no relay in development") |
| + } |
| + if !strings.Contains(buf.String(), "CARLOS_RELAY") || strings.Contains(buf.String(), "%%%") { |
| + t.Fatalf("want one warning naming the variable and not the value, got %q", buf.String()) |
| + } |
| +} |
| + |
| +func TestAGoodRelayIsParsedAndNeverLogged(t *testing.T) { |
| + var buf bytes.Buffer |
| + cfg := minimalConfig(t) |
| + cfg.Logger = slog.New(slog.NewTextHandler(&buf, nil)) |
| + cfg.Relay = relayVector(t) // base64 of the one-region contract with the vector key |
| + out, err := resolve(cfg) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(out.relay) != 1 || !out.relayConfigured { |
| + t.Fatalf("parsed %+v", out.relay) |
| + } |
| + if strings.Contains(buf.String(), "uMhwW4P") || strings.Contains(buf.String(), "0123456789abcdef") { |
| + t.Fatal("the key or the suffix reached a log line") |
| + } |
| +} |
| + |
| +func TestNoRelayIsTheFloor(t *testing.T) { |
| + cfg := minimalConfig(t) |
| + cfg.Relay = "" |
| + out, err := resolve(cfg) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if out.relay != nil || out.relayConfigured { |
| + t.Fatal("absent must mean the floor") |
| + } |
| +} |
diff --git a/internal/meet/handlers_join.go b/internal/meet/handlers_join.go| index 499c642..fd11c97 100644 |
| --- a/internal/meet/handlers_join.go |
| +++ b/internal/meet/handlers_join.go |
| @@ -70,6 +70,6 @@ func (a *app) holding(w http.ResponseWriter, r *http.Request) { |
| render(w, "join", page(r, map[string]any{ |
| "Sqid": m.Sqid, "Title": a.store.title(m), "Starts": a.fmtStart(m.StartsAt), |
| "Name": p.Name, "Standing": st.String(), "IsMember": st == StandingMember, |
| - "Floor": !a.cfg.RelayConfigured, "OpenElsewhere": openElsewhere, |
| + "Floor": !a.relayConfigured(), "OpenElsewhere": openElsewhere, |
| })) |
| } |
diff --git a/internal/meet/handlers_room.go b/internal/meet/handlers_room.go| index bfc3b0e..467d3fd 100644 |
| --- a/internal/meet/handlers_room.go |
| +++ b/internal/meet/handlers_room.go |
| @@ -67,7 +67,7 @@ func (a *app) room(w http.ResponseWriter, r *http.Request) { |
| render(w, "room", page(r, map[string]any{ |
| "Sqid": m.Sqid, "Title": a.store.title(m), "Starts": a.fmtStart(m.StartsAt), |
| "Name": p.Name, "Standing": st.String(), "IsMember": st == StandingMember, |
| - "Floor": !a.cfg.RelayConfigured, "Boot": a.hub.Boot(), |
| + "Floor": !a.relayConfigured(), "Boot": a.hub.Boot(), |
| })) |
| } |