oficina / meet Public

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
Paul Campbell pushed by paul@keymail.dev 2f4344bd3185d92ad8bb01fc41850e6db84efee7 parent 4e71179
6 files changed, +168 −11
  • cmd/meet/main.go +1 −1
  • internal/meet/app.go +31 −0
  • internal/meet/config.go +30 −8
  • internal/meet/config_test.go +104 −0
  • internal/meet/handlers_join.go +1 −1
  • internal/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(),
}))
}