carlos / platform Public

console: relay operations under a writer lease, etag-first aggregate, converging reconcile (spec §5)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 841caccc55b581e1db6236260132d2b8578c1109 parent 1987052
4 files changed, +2050 −0
  • cmd/console/main.go +76 −0
  • internal/console/relayops.go +1364 −0
  • internal/console/relayops_test.go +558 −0
  • internal/console/server.go +52 −0
diff --git a/cmd/console/main.go b/cmd/console/main.go
index b8642dc85..e167bb71c 100644
--- a/cmd/console/main.go
+++ b/cmd/console/main.go
@@ -317,6 +317,12 @@ func serve() {
MailOperators: mailOperators(),
MailProvisioner: mailProvisioner,
DNSWriter: dnsWriter,
+ // The STUN/TURN relay (relay spec §5). The region list is the whole
+ // switch: unset is every deployment until an operator arms one, and
+ // it leaves the relay doors refusing and no background loop running.
+ RelayRegions: relayRegions(),
+ RelayOperators: relayOperators(),
+ PlatformDomain: platformDomain(),
}, st)
if err != nil {
log.Fatal(err)
@@ -340,6 +346,11 @@ func serve() {
// here, never in NewServer. It returns immediately when no engine is
// configured, which is every deployment that has not opted in.
go srv.StartScanPass(context.Background())
+ // The relay's writer lease and its converging reconcile
+ // (internal/console/relayops.go). Same placement rule as every pass
+ // above, and it returns immediately when CARLOS_RELAY_REGIONS is empty —
+ // which is every deployment that has not armed a relay.
+ go srv.StartRelay(context.Background())
addr := envOr("CONSOLE_ADDR", "127.0.0.1:8090")
ln, err := net.Listen("tcp", addr)
if err != nil {
@@ -556,6 +567,71 @@ func storeOperators() []string {
return out
}
+// relayRegions parses CARLOS_RELAY_REGIONS (comma-separated AWS region
+// names, e.g. "eu-west-1,us-east-1") — the regions this deployment runs a
+// STUN/TURN relay in, IN THE ORDER an enabled app's CARLOS_RELAY lists them.
+// The order is the operator's, verbatim: it is the order every browser tries
+// the relays in, so sorting it here would quietly re-home calls.
+//
+// Unset is the ordinary shape and means this deployment has no relay: the
+// enable door refuses with a sentence naming this variable, and no relay
+// background loop starts at all. Nothing here is fatal — an unknown region
+// name costs an app a relay it cannot reach, not an estate somewhere the
+// operator did not choose, which is the line storeBackend's fatals draw.
+func relayRegions() []string {
+ var out []string
+ for _, r := range strings.Split(os.Getenv("CARLOS_RELAY_REGIONS"), ",") {
+ if r = strings.ToLower(strings.TrimSpace(r)); r != "" {
+ out = append(out, r)
+ }
+ }
+ return out
+}
+
+// relayOperators parses CARLOS_RELAY_OPERATORS (comma-separated addresses) —
+// who may run the relay's rotate verb, the one that moves a region's issuer
+// pointer to a newly staged master and re-issues every enabled app's key.
+//
+// Same shape and same direction as storeOperators and mailOperators: unset is
+// fail-closed. The authority here is over the DEPLOYMENT's relay key
+// material, not over any one tenant's app, which is why it is its own list
+// rather than account ownership.
+func relayOperators() []string {
+ var out []string
+ for _, a := range strings.Split(os.Getenv("CARLOS_RELAY_OPERATORS"), ",") {
+ if a = strings.TrimSpace(a); a != "" {
+ out = append(out, a)
+ }
+ }
+ return out
+}
+
+// platformDomain reads CARLOS_PLATFORM_DOMAIN — the domain the relay's own
+// hostnames hang off (relay.<region>.<domain>), which an enabled app is
+// handed as its ICE server URLs.
+//
+// Its own variable rather than PLATFORM_APPS_DOMAIN, for the caution
+// DelegationZone and mailDeployment both already state (#167's blind spot):
+// the apps domain is the edge's routing zone, and a deployment may perfectly
+// well serve apps under one name and run its relay under another. Unset is
+// legal and falls back to the apps domain with a leading "apps." label
+// stripped (console.Server.relayDomain) — the one relationship between the
+// two this console can derive without guessing.
+//
+// Shape-checked at boot, like appsDomain and for the same reason: the value
+// rides verbatim into every enabled app's config, where a typo would be a
+// STUN URL that resolves to nothing and a call that silently never connects.
+func platformDomain() string {
+ d := strings.ToLower(strings.TrimSpace(os.Getenv("CARLOS_PLATFORM_DOMAIN")))
+ if d == "" {
+ return ""
+ }
+ if !provision.ValidHostShape(d) {
+ log.Fatalf("console: CARLOS_PLATFORM_DOMAIN %q is not a valid domain (dot-joined DNS labels, no wildcard, no leading dot)", d)
+ }
+ return d
+}
+
// mailOperators parses CARLOS_MAIL_OPERATORS (comma-separated addresses) —
// who may drive the email pause/cap door (apimail.go's handleAPIEmailPause
// and mailOperator's own doc comment explain why this is a SEPARATE list
diff --git a/internal/console/relayops.go b/internal/console/relayops.go
new file mode 100644
index 000000000..a93a6765f
--- /dev/null
+++ b/internal/console/relayops.go
@@ -0,0 +1,1364 @@
+// The relay's write side (relay spec §5): the writer lease that makes one
+// console the only process writing relay state, the operations that turn it
+// on and off for an app, the etag-first publish of the authority record every
+// relay box reads, and the five-minute reconcile that converges both derived
+// artefacts from the records whatever happened in between.
+//
+// THREE OBJECTS, ONE SOURCE OF TRUTH. `apps/<acct>/<app>/relay.json` (the
+// relay.Record) is the truth for an app. `control/relay.json` (the
+// relay.Authority) is DERIVED from every record and never patched:
+// publishing it is read-the-etag-first, list, build, CAS, and start over on
+// conflict, so a disable that lands during the listing makes the write fail
+// rather than quietly restore the app. `control/relay-issuer.json` is the
+// unsealed pointer saying WHICH staged master a region issues from; adding a
+// master to the console's secret issues nothing until that pointer moves.
+//
+// ONE WRITER, ENFORCED, AND THE WALK IS RESUMABLE. Every operation here
+// writes its intent to the record first (`op` bumped, `phase` set), then
+// writes config, then publishes, advancing `phase` after each step. That is
+// what makes an interrupted operation finishable: the reconcile does not look
+// for unfinished records, it re-derives what every record implies and fixes
+// whatever disagrees. The lease is the fence around all of it — a process
+// that loses a renewal stops before its next bucket write and abandons the
+// operation, and the next holder's reconcile finishes it.
+//
+// THE CONSOLE HOLDS MASTERS BUT OPENS NOTHING. relayMasters reads the
+// region's masters out of this console's OWN environment
+// (CARLOS_RELAY_MASTERS_<REGION>, delivered like every other console
+// variable) and derives an app key from them; the value it delivers is
+// SEALED with CARLOS_SECRETS_PUBKEY, which this console cannot open — the
+// same one-way street apiconfig.go's package comment describes, and the
+// reason a console with no sealing key refuses to enable rather than mint a
+// key it could never deliver.
+//
+// Spec: docs/superpowers/specs/2026-09-07-relay-stun-turn-design.md §4, §5.
+package console
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log"
+ "maps"
+ "os"
+ "regexp"
+ "slices"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/carlosframework/platform/internal/appconfig"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/relay"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+const (
+ // relayWriterKey is the writer lease: {holder, expires_at}, CAS-written.
+ relayWriterKey = "control/relay-writer.json"
+ // relayIssuerKey is the issuer pointer: {"<region>": "<kid>"}. Unsealed
+ // on purpose — it names a key, it is not one.
+ relayIssuerKey = "control/relay-issuer.json"
+ // relayAckPrefix is where each armed relay writes its heartbeat.
+ relayAckPrefix = "control/relay-ack/"
+
+ // The lease's two numbers, from spec §5: sixty seconds of validity,
+ // renewed every twenty. Three renewals inside one TTL means a single
+ // slow bucket round trip costs nothing, while a console that dies is
+ // replaced within a minute.
+ relayLeaseTTL = 60 * time.Second
+ relayLeaseRenew = 20 * time.Second
+ // relayLeaseWait bounds how long StartRelay waits for the lease before
+ // its first reconcile. A console that does not get it simply reconciles
+ // nothing — the holder is doing it.
+ relayLeaseWait = 5 * time.Second
+
+ // relayReconcileInterval is the convergence floor from spec §5: a stale
+ // config write is undone, an interrupted enable delivered, and a
+ // half-done deletion repaired, within this.
+ relayReconcileInterval = 5 * time.Minute
+
+ // relayHeartbeatFresh is how recent a relay's last SUCCESSFUL authority
+ // read must be for its heartbeat to count as a live answer — the same
+ // five minutes `carlos relay status` calls "stale" past.
+ relayHeartbeatFresh = 5 * time.Minute
+ // relayRetireAfter is the earliest a rotated-away master may be retired
+ // (spec §5, step 4): 28 hours, comfortably past the four-hour ceiling on
+ // any credential minted just before the pointer moved.
+ relayRetireAfter = 28 * time.Hour
+
+ // relayRouteMax caps the instance records one delivery check reads. An
+ // app with more routes than this has bigger questions than the relay.
+ relayRouteMax = 500
+
+ // relayVar is the one variable this whole file exists to deliver.
+ relayVar = "CARLOS_RELAY"
+)
+
+// The three phases of an operation's walk (spec §5). `done` is the only one
+// that means "nothing is owed".
+const (
+ relayPhaseConfig = "config"
+ relayPhaseAggregate = "aggregate"
+ relayPhaseDone = "done"
+)
+
+// The refusals callers (and, in Task 11, doors) match on. Each is a
+// configuration fact rather than a failure of the request, which is why they
+// are sentinels rather than sentences built at the call site: the door turns
+// them into a 503 naming the variable, and the CLI prints them verbatim.
+var (
+ errRelayNoBucket = errors.New("no deployment bucket on this host — this console cannot serve the relay")
+ errRelayNoRegions = errors.New(
+ "this deployment runs no relay (CARLOS_RELAY_REGIONS is empty) — ask the operator to arm a region before enabling it for an app")
+ errRelayNoSealKey = errors.New(
+ "this console has no secrets key (CARLOS_SECRETS_PUBKEY) — it cannot deliver a relay key, so it will not issue one")
+ errRelayNotHeld = errors.New(
+ "this console does not hold the relay writer lease — another console is driving the relay; the operation was not started")
+ errRelayNotEnabled = errors.New("the relay is not enabled for this app")
+ errRelayUndelivered = errors.New("a route has no config delivery")
+)
+
+// relayKidRe mirrors internal/relay's own kid alphabet (keys.go). Enforced
+// on the way OUT of the console's environment so a typo in a staged master
+// becomes a refusal here, not a username the relay silently declines.
+var relayKidRe = regexp.MustCompile(`^[a-z0-9]{1,8}$`)
+
+// relayLease is control/relay-writer.json.
+type relayLease struct {
+ Holder string `json:"holder"`
+ ExpiresAt time.Time `json:"expires_at"`
+}
+
+// relayHolderID draws this process's lease identity: 16 random hex
+// characters, like an incarnation and for the same reason — two consoles
+// must never collide, and a counter or a hostname can.
+func relayHolderID() string {
+ var b [8]byte
+ if _, err := rand.Read(b[:]); err != nil {
+ panic("console: crypto/rand: " + err.Error())
+ }
+ return hex.EncodeToString(b[:])
+}
+
+// relayTime is every relay path's clock, seamed for the lease tests.
+func (s *Server) relayTime() time.Time {
+ if s.relayNow != nil {
+ return s.relayNow()
+ }
+ return time.Now()
+}
+
+// relayHold reports whether this process currently holds the writer lease.
+func (s *Server) relayHold() bool { return s.relayHeld.Load() }
+
+// requireRelayLease is the check every relay bucket write makes FIRST. A
+// process that lost its renewal stops here rather than completing a walk
+// whose next step another console has already started — spec §5's "stops
+// every relay write in flight before its next bucket write".
+func (s *Server) requireRelayLease() error {
+ if !s.relayHold() {
+ return errRelayNotHeld
+ }
+ return nil
+}
+
+// relayAcquire is one acquire-or-renew attempt, by CAS on the lease object.
+// Absent, expired, or already ours means we take it; anyone else's live lease
+// means we do not, and — this is the load-bearing half — we drop `held` so
+// any operation in flight in this process fails its next check.
+func (s *Server) relayAcquire(ctx context.Context) error {
+ if s.cfg.Releases == nil {
+ return errRelayNoBucket
+ }
+ now := s.relayTime()
+ body, etag, err := s.cfg.Releases.Get(ctx, relayWriterKey)
+ switch {
+ case errors.Is(err, os.ErrNotExist):
+ body, etag = nil, ""
+ case err != nil:
+ s.relayHeld.Store(false)
+ return err
+ }
+ var cur relayLease
+ if len(body) > 0 {
+ if err := json.Unmarshal(body, &cur); err != nil {
+ // An unparseable lease is not a live one: it names nobody this
+ // process can defer to. Taking it over with the etag we read is
+ // still a CAS, so a concurrent repair wins the race, not us.
+ log.Printf("console: relay writer lease is unreadable, taking it over: %v", err)
+ cur = relayLease{}
+ }
+ }
+ if cur.Holder != "" && cur.Holder != s.relayHolder && now.Before(cur.ExpiresAt) {
+ s.relayHeld.Store(false)
+ return fmt.Errorf("relay writer lease is held by %s until %s: %w",
+ cur.Holder, cur.ExpiresAt.UTC().Format(time.RFC3339), errRelayNotHeld)
+ }
+ next, err := json.Marshal(relayLease{Holder: s.relayHolder, ExpiresAt: now.Add(relayLeaseTTL)})
+ if err != nil {
+ s.relayHeld.Store(false)
+ return err
+ }
+ if _, err := s.cfg.Releases.Put(ctx, relayWriterKey, next, etag); err != nil {
+ // A lost CAS is another console taking the same lapsed lease. Held
+ // goes false either way: a write we did not land is a lease we do
+ // not hold.
+ s.relayHeld.Store(false)
+ return err
+ }
+ s.relayHeld.Store(true)
+ return nil
+}
+
+// relayWriterLoop acquires the lease now and renews it every twenty seconds
+// until ctx ends. It is the ONLY writer of s.relayHeld outside a test.
+//
+// A failed attempt is logged at most as a line, never fatal: the ordinary
+// steady state of a two-console deployment is one process failing this call
+// forever while the other holds the lease, and a deployment overlap must not
+// fill a log with alarm.
+func (s *Server) relayWriterLoop(ctx context.Context) {
+ t := time.NewTicker(relayLeaseRenew)
+ defer t.Stop()
+ held := false
+ for {
+ err := s.relayAcquire(ctx)
+ switch {
+ case err == nil && !held:
+ log.Printf("console: relay writer lease acquired (holder %s)", s.relayHolder)
+ held = true
+ case err != nil && held:
+ log.Printf("console: relay writer lease lost: %v", err)
+ held = false
+ case err != nil && !errors.Is(err, errRelayNotHeld) && !errors.Is(err, release.ErrCASConflict):
+ log.Printf("console: relay writer lease: %v", err)
+ }
+ select {
+ case <-ctx.Done():
+ return
+ case <-t.C:
+ }
+ }
+}
+
+// StartRelay runs the relay's two background loops: the writer lease, and the
+// converging reconcile every five minutes and once at start. Started from
+// cmd/console beside the other passes — never from NewServer, which stays
+// goroutine-free so constructing a Server in a test starts nothing.
+//
+// Gated on a configured region list rather than on holding the lease: a
+// deployment with no relay has nothing to converge and should not be reading
+// a lease object every twenty seconds forever.
+func (s *Server) StartRelay(ctx context.Context) {
+ if s.cfg.Releases == nil || len(s.relayRegions()) == 0 {
+ return
+ }
+ go s.relayWriterLoop(ctx)
+ // The first reconcile is the one that finishes whatever the previous
+ // holder abandoned, so it is worth a short wait for the lease rather
+ // than five minutes of drift.
+ s.relayAwaitLease(ctx, relayLeaseWait)
+ t := time.NewTicker(relayReconcileInterval)
+ defer t.Stop()
+ for {
+ if s.relayHold() {
+ if err := s.relayReconcile(ctx); err != nil {
+ log.Printf("console: relay reconcile: %v", err)
+ }
+ }
+ select {
+ case <-ctx.Done():
+ return
+ case <-t.C:
+ }
+ }
+}
+
+// relayAwaitLease waits up to d for the writer loop's first acquire.
+func (s *Server) relayAwaitLease(ctx context.Context, d time.Duration) {
+ deadline := time.Now().Add(d)
+ for !s.relayHold() && time.Now().Before(deadline) {
+ select {
+ case <-ctx.Done():
+ return
+ case <-time.After(25 * time.Millisecond):
+ }
+ }
+}
+
+// ---- masters and the issuer pointer -----------------------------------------
+
+// relayMastersEnv is the console variable holding one region's masters:
+// CARLOS_RELAY_MASTERS_EU_WEST_1 for eu-west-1 (uppercased, hyphens to
+// underscores, since variable names refuse hyphens — spec §4).
+func relayMastersEnv(region string) string {
+ return "CARLOS_RELAY_MASTERS_" + strings.ToUpper(strings.ReplaceAll(region, "-", "_"))
+}
+
+// relayStagedMaster is one `kid:key` pair out of that variable, in the order
+// the operator wrote them.
+type relayStagedMaster struct {
+ Kid string
+ Key []byte
+}
+
+// relayStagedMasters parses CARLOS_RELAY_MASTERS_<REGION> — read from the
+// process environment AT USE TIME rather than cached at boot, so an operator
+// who stages a master and restarts nothing still has it available to the next
+// rotate.
+//
+// Every defect is a refusal naming the variable: this is key material, and a
+// half-parsed list would silently issue from the wrong master.
+func relayStagedMasters(region string) ([]relayStagedMaster, error) {
+ name := relayMastersEnv(region)
+ raw := strings.TrimSpace(os.Getenv(name))
+ if raw == "" {
+ return nil, fmt.Errorf("this console holds no master for region %s (%s is unset) — ask the operator to stage one", region, name)
+ }
+ var out []relayStagedMaster
+ for _, pair := range strings.Split(raw, ",") {
+ pair = strings.TrimSpace(pair)
+ if pair == "" {
+ continue
+ }
+ kid, keyB64, ok := strings.Cut(pair, ":")
+ if !ok {
+ return nil, fmt.Errorf("%s holds an entry that is not <kid>:<base64 key>", name)
+ }
+ kid = strings.TrimSpace(kid)
+ if !relayKidRe.MatchString(kid) {
+ return nil, fmt.Errorf("%s holds an invalid kid %q (1-8 lowercase letters or digits)", name, kid)
+ }
+ key, err := base64.StdEncoding.DecodeString(strings.TrimSpace(keyB64))
+ if err != nil || len(key) != relay.MasterKeyLen {
+ return nil, fmt.Errorf("%s: master %s must be base64 of %d bytes", name, kid, relay.MasterKeyLen)
+ }
+ if slices.ContainsFunc(out, func(m relayStagedMaster) bool { return m.Kid == kid }) {
+ return nil, fmt.Errorf("%s names kid %s twice", name, kid)
+ }
+ out = append(out, relayStagedMaster{Kid: kid, Key: key})
+ }
+ if len(out) == 0 {
+ return nil, fmt.Errorf("%s is set but holds no <kid>:<base64 key> entry", name)
+ }
+ return out, nil
+}
+
+// relayIssuers reads the issuer pointer with its etag. Absent is an empty
+// map, not an error: a deployment that has never rotated has never written
+// one, and the fallback below (the first staged master) is what it means.
+func (s *Server) relayIssuers(ctx context.Context) (map[string]string, string, error) {
+ body, etag, err := s.cfg.Releases.Get(ctx, relayIssuerKey)
+ if errors.Is(err, os.ErrNotExist) {
+ return map[string]string{}, "", nil
+ }
+ if err != nil {
+ return nil, "", err
+ }
+ out := map[string]string{}
+ if err := json.Unmarshal(body, &out); err != nil {
+ return nil, "", fmt.Errorf("console: %s: %w", relayIssuerKey, err)
+ }
+ return out, etag, nil
+}
+
+// relayMasters answers which master a region ISSUES from right now: the kid
+// the issuer pointer names, or — when the pointer says nothing about this
+// region — the FIRST entry in the console's variable, which is what a
+// deployment that has never rotated has.
+//
+// A pointer naming a kid this console does not hold is an error rather than a
+// fallback. It means the operator moved the pointer without staging the key
+// here (or removed the key too early), and issuing from a different master
+// than the pointer names would mint credentials every relay refuses.
+func (s *Server) relayMasters(ctx context.Context, region string) (string, []byte, error) {
+ staged, err := relayStagedMasters(region)
+ if err != nil {
+ return "", nil, err
+ }
+ issuers, _, err := s.relayIssuers(ctx)
+ if err != nil {
+ return "", nil, err
+ }
+ kid, ok := issuers[region]
+ if !ok || kid == "" {
+ return staged[0].Kid, staged[0].Key, nil
+ }
+ for _, m := range staged {
+ if m.Kid == kid {
+ return m.Kid, m.Key, nil
+ }
+ }
+ return "", nil, fmt.Errorf("%s names kid %s as region %s's issuer, but %s does not hold it",
+ relayIssuerKey, kid, region, relayMastersEnv(region))
+}
+
+// relayRegionMasters gathers the issuing master for each of an app's regions
+// — everything relay.BuildConfig needs, and nothing it does not.
+func (s *Server) relayRegionMasters(ctx context.Context, regions []string) ([]relay.RegionMaster, error) {
+ out := make([]relay.RegionMaster, 0, len(regions))
+ for _, r := range regions {
+ kid, master, err := s.relayMasters(ctx, r)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, relay.RegionMaster{Region: r, Kid: kid, Master: master})
+ }
+ return out, nil
+}
+
+// ---- regions, hostnames, and the config value -------------------------------
+
+// relayRegions is the deployment's region list, normalized and deduped, in
+// the operator's order.
+func (s *Server) relayRegions() []string {
+ var out []string
+ for _, r := range s.cfg.RelayRegions {
+ r = strings.ToLower(strings.TrimSpace(r))
+ if r != "" && !slices.Contains(out, r) {
+ out = append(out, r)
+ }
+ }
+ return out
+}
+
+// relayDomain is the domain relay hostnames hang off. CARLOS_PLATFORM_DOMAIN
+// when the operator set it; otherwise the apps domain with a leading "apps."
+// label stripped, which is the one shape this console can derive safely
+// (apps.oncarlos.com -> oncarlos.com; oncarlos.com -> oncarlos.com).
+func (s *Server) relayDomain() string {
+ if d := strings.ToLower(strings.TrimSpace(s.cfg.PlatformDomain)); d != "" {
+ return d
+ }
+ d := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain))
+ if label, rest, ok := strings.Cut(d, "."); ok && label == "apps" {
+ return rest
+ }
+ return d
+}
+
+// relayURLs is the ICE server list for one region (spec §2): the STUN
+// listener and its two TURN transports, all on one port.
+func (s *Server) relayURLs(region string) []string {
+ host := "relay." + region + "." + s.relayDomain() + ":3478"
+ return []string{
+ "stun:" + host,
+ "turn:" + host + "?transport=udp",
+ "turn:" + host + "?transport=tcp",
+ }
+}
+
+// relayReady is the three configuration facts every operation needs before it
+// touches anything: a bucket to write, a region to issue for, and a key to
+// seal with. Checked together, and BEFORE the first write, for storeSealKey's
+// reason: a key minted without delivery is worse than no key at all.
+func (s *Server) relayReady() error {
+ if s.cfg.Releases == nil {
+ return errRelayNoBucket
+ }
+ if len(s.relayRegions()) == 0 {
+ return errRelayNoRegions
+ }
+ if s.cfg.SecretsPub == "" {
+ return errRelayNoSealKey
+ }
+ return nil
+}
+
+// relayResolveRegions turns an app's request into the region list its record
+// carries: every armed region in the CONSOLE's order when the caller named
+// none, or the named subset — still in the console's order, since that order
+// is the one every browser tries them in.
+func (s *Server) relayResolveRegions(want []string) ([]string, error) {
+ armed := s.relayRegions()
+ if len(armed) == 0 {
+ return nil, errRelayNoRegions
+ }
+ var asked []string
+ for _, r := range want {
+ if r = strings.ToLower(strings.TrimSpace(r)); r != "" {
+ asked = append(asked, r)
+ }
+ }
+ if len(asked) == 0 {
+ return armed, nil
+ }
+ for _, r := range asked {
+ if !slices.Contains(armed, r) {
+ return nil, fmt.Errorf("this deployment runs no relay in %s — armed regions are %s",
+ r, strings.Join(armed, ", "))
+ }
+ }
+ out := make([]string, 0, len(asked))
+ for _, r := range armed {
+ if slices.Contains(asked, r) {
+ out = append(out, r)
+ }
+ }
+ return out, nil
+}
+
+// relayConfigValue builds the CARLOS_RELAY value for a record and the
+// username suffixes it issues, from the masters each region issues from now.
+func (s *Server) relayConfigValue(ctx context.Context, acct, app string, rec *relay.Record) (string, map[string]string, error) {
+ masters, err := s.relayRegionMasters(ctx, rec.Regions)
+ if err != nil {
+ return "", nil, err
+ }
+ return relay.BuildConfig(acct, app, rec, masters, s.relayURLs)
+}
+
+// relayExpectedSuffixes is what an enabled record SHOULD have issued, given
+// the masters in force right now — the reconcile's comparison value.
+//
+// Suffixes, not the sealed value: the console cannot open an envelope it
+// wrote, and two seals of identical plaintext differ anyway (each carries a
+// fresh ephemeral key). The suffix carries account, app, incarnation and kid
+// — every identity the value derives from — so a suffix set that matches is
+// a value that matches, and a rotation, a re-enable or a region change all
+// move it.
+func (s *Server) relayExpectedSuffixes(ctx context.Context, acct, app string, rec *relay.Record) (map[string]string, error) {
+ masters, err := s.relayRegionMasters(ctx, rec.Regions)
+ if err != nil {
+ return nil, err
+ }
+ out := make(map[string]string, len(masters))
+ for _, m := range masters {
+ out[m.Region] = relay.Suffix(acct, app, rec.Incarnation, m.Kid)
+ }
+ return out, nil
+}
+
+// relayConfigVar reads the app's delivered CARLOS_RELAY entry, if any.
+func (s *Server) relayConfigVar(ctx context.Context, acct, app string) (appconfig.Var, bool, error) {
+ m, err := appconfig.Get(ctx, s.cfg.Releases, acct, app)
+ if err != nil {
+ return appconfig.Var{}, false, err
+ }
+ v, ok := m.Vars[relayVar]
+ return v, ok, nil
+}
+
+// relayApplyConfig derives the app's key, seals it and writes it into the
+// DEFAULT config bundle, recording on the record which identities it just
+// issued. It mutates rec.IssuedSuffixes; the caller writes the record.
+func (s *Server) relayApplyConfig(ctx context.Context, acct, app string, rec *relay.Record) error {
+ if err := s.requireRelayLease(); err != nil {
+ return err
+ }
+ if s.cfg.SecretsPub == "" {
+ return errRelayNoSealKey
+ }
+ value, suffixes, err := s.relayConfigValue(ctx, acct, app, rec)
+ if err != nil {
+ return err
+ }
+ pub, err := appconfig.ParsePublicKey(s.cfg.SecretsPub, "CARLOS_SECRETS_PUBKEY")
+ if err != nil {
+ return err
+ }
+ env, err := appconfig.Seal(pub, []byte(value))
+ if err != nil {
+ return err
+ }
+ if err := s.requireRelayLease(); err != nil {
+ return err
+ }
+ if _, err := appconfig.Apply(ctx, s.cfg.Releases, acct, app,
+ map[string]appconfig.Var{relayVar: {Kind: "secret", Envelope: env}}, nil); err != nil {
+ return err
+ }
+ rec.IssuedSuffixes = suffixes
+ return nil
+}
+
+// relayUnsetConfig removes CARLOS_RELAY from the app's default bundle, and
+// reports whether there was anything to remove. It READS first because an
+// unset-only Apply against an app with no bundle at all is an error
+// (appconfig.applyAt), and "this app has no config" is not a failed disable.
+func (s *Server) relayUnsetConfig(ctx context.Context, acct, app string) (bool, error) {
+ if err := s.requireRelayLease(); err != nil {
+ return false, err
+ }
+ _, present, err := s.relayConfigVar(ctx, acct, app)
+ if err != nil || !present {
+ return false, err
+ }
+ if err := s.requireRelayLease(); err != nil {
+ return false, err
+ }
+ n, err := appconfig.Apply(ctx, s.cfg.Releases, acct, app, nil, []string{relayVar})
+ return n > 0, err
+}
+
+// ---- the record walk --------------------------------------------------------
+
+// relayAppLock is the per-app mutex every operation and the reconcile's
+// per-record work takes. The lease keeps two CONSOLES apart; this keeps two
+// handlers inside one console apart, which the lease says nothing about.
+func (s *Server) relayAppLock(acct, app string) *sync.Mutex {
+ v, _ := s.relayAppLocks.LoadOrStore(acct+"/"+app, &sync.Mutex{})
+ return v.(*sync.Mutex)
+}
+
+// relayAppRecord is one app's record with the coordinates that name it and
+// the etag it was read at — the unit both the publish listing and the
+// reconcile walk over.
+type relayAppRecord struct {
+ Account string
+ App string
+ Record *relay.Record
+ Etag string
+}
+
+// relayListRecords walks every app in the bucket and returns the ones that
+// have a relay record. Two delimiter-aware listings (accounts, then their
+// apps) rather than one flat List of the whole `apps/` prefix: a deployment's
+// object count is dominated by releases, and this must not read them.
+//
+// A record that will not parse is logged and skipped, not fatal. The
+// alternative is one corrupt object stopping the aggregate from being
+// published for every other app in the deployment.
+func (s *Server) relayListRecords(ctx context.Context) ([]relayAppRecord, error) {
+ accts, err := s.cfg.Releases.ListDirs(ctx, "apps/")
+ if err != nil {
+ return nil, err
+ }
+ var out []relayAppRecord
+ for _, acctPrefix := range accts {
+ acct := strings.Trim(strings.TrimPrefix(acctPrefix, "apps/"), "/")
+ if !release.ValidAccount(acct) {
+ continue
+ }
+ apps, err := s.cfg.Releases.ListDirs(ctx, acctPrefix)
+ if err != nil {
+ return nil, err
+ }
+ for _, appPrefix := range apps {
+ app := strings.Trim(strings.TrimPrefix(appPrefix, acctPrefix), "/")
+ if !release.ValidApp(app) {
+ continue
+ }
+ rec, etag, err := relay.GetRecord(ctx, s.cfg.Releases, acct, app)
+ if err != nil {
+ log.Printf("console: relay record %s/%s: %v", acct, app, err)
+ continue
+ }
+ if rec == nil {
+ continue
+ }
+ out = append(out, relayAppRecord{Account: acct, App: app, Record: rec, Etag: etag})
+ }
+ }
+ sort.Slice(out, func(i, j int) bool {
+ if out[i].Account != out[j].Account {
+ return out[i].Account < out[j].Account
+ }
+ return out[i].App < out[j].App
+ })
+ return out, nil
+}
+
+// relayPublishOnce is ONE attempt at spec §5's publish sequence, from an etag
+// the CALLER read before it did anything else: list every record, build the
+// authority from the enabled ones whatever their phase, and write with that
+// etag. A conflict comes straight back — this is the function the retry loop
+// wraps, and the function a test can hand a deliberately stale etag.
+//
+// The etag being an argument is the whole design. Read after the listing, it
+// would let a disable that landed mid-listing be overwritten by an aggregate
+// built before it; read before, that same disable makes this write fail.
+func (s *Server) relayPublishOnce(ctx context.Context, etag string) error {
+ if err := s.requireRelayLease(); err != nil {
+ return err
+ }
+ recs, err := s.relayListRecords(ctx)
+ if err != nil {
+ return err
+ }
+ a := relay.Authority{WrittenAt: s.relayTime().UTC()}
+ for _, r := range recs {
+ if !r.Record.Enabled {
+ continue
+ }
+ a.Apps = append(a.Apps, relay.AuthorityApp{
+ Account: r.Account,
+ App: r.App,
+ Incarnation: r.Record.Incarnation,
+ Regions: r.Record.Regions,
+ })
+ }
+ body, err := json.MarshalIndent(a, "", " ")
+ if err != nil {
+ return err
+ }
+ if err := s.requireRelayLease(); err != nil {
+ return err
+ }
+ _, err = s.cfg.Releases.Put(ctx, relay.AuthorityKey, append(body, '\n'), etag)
+ return err
+}
+
+// relayPublishAggregate publishes control/relay.json: read the etag FIRST,
+// then the whole sequence, restarting it on conflict, four attempts. Then a
+// serial bump so relays notice in seconds.
+//
+// The bump is a LATENCY HINT, not the lease (spec §5): a relay that never
+// sees it still re-reads on its own timer, so a failed bump is a log line and
+// a successful publish, never a failed operation.
+func (s *Server) relayPublishAggregate(ctx context.Context) error {
+ if err := s.requireRelayLease(); err != nil {
+ return err
+ }
+ var lastErr error
+ for attempt := 0; attempt < 4; attempt++ {
+ _, etag, err := s.cfg.Releases.Get(ctx, relay.AuthorityKey)
+ switch {
+ case errors.Is(err, os.ErrNotExist):
+ etag = ""
+ case err != nil:
+ return err
+ }
+ err = s.relayPublishOnce(ctx, etag)
+ if err == nil {
+ if err := release.BumpSerial(ctx, s.cfg.Releases, time.Now()); err != nil {
+ log.Printf("console: relay serial bump (relays converge on their own timer anyway): %v", err)
+ }
+ return nil
+ }
+ if !errors.Is(err, release.ErrCASConflict) {
+ return err
+ }
+ lastErr = err
+ }
+ return fmt.Errorf("publishing %s: %w", relay.AuthorityKey, lastErr)
+}
+
+// ---- route delivery ---------------------------------------------------------
+
+// relayRoute is one of an app's routes as the delivery check sees it: enough
+// to say which of spec §5's three shapes it is, and nothing else.
+type relayRoute struct {
+ Host string
+ Kind string // instance | service | static ("" reads as instance)
+ Addr string // a TCP upstream this platform does not own
+ Socket string
+ Unit string
+ Environment string
+}
+
+// relayAppRoutes enumerates an app's routes from both places the console can
+// see them: the instance records it writes itself, and — for routes an
+// operator added directly on a box — the platform registry.
+//
+// Records first, registry second, deduped on host: a route that exists in
+// both is one route, and the record is the authority on its shape.
+func (s *Server) relayAppRoutes(ctx context.Context, acct, app string) ([]relayRoute, error) {
+ recs, _, err := provision.ListRecords(ctx, s.cfg.Releases, acct, app, relayRouteMax)
+ if err != nil {
+ return nil, err
+ }
+ var out []relayRoute
+ seen := map[string]bool{}
+ for _, rec := range recs {
+ seen[rec.Host] = true
+ out = append(out, relayRoute{Host: rec.Host, Kind: rec.Kind, Addr: rec.Addr,
+ Unit: rec.Unit, Environment: rec.Environment})
+ }
+ if s.cfg.Registry == "" {
+ return out, nil
+ }
+ reg, err := openRegistry(s.cfg.Registry)
+ if err != nil {
+ // A registry this console cannot open is not a reason to refuse an
+ // enable: the records above are the platform's own writing, and the
+ // registry copy is the belt to their braces.
+ log.Printf("console: relay routes %s/%s: registry: %v", acct, app, err)
+ return out, nil
+ }
+ routes, err := reg.List()
+ if err != nil {
+ log.Printf("console: relay routes %s/%s: registry list: %v", acct, app, err)
+ return out, nil
+ }
+ for _, rt := range routes {
+ if rt.App != app || release.RouteAccount(rt) != acct || seen[rt.Host] {
+ continue
+ }
+ seen[rt.Host] = true
+ out = append(out, relayRoute{Host: rt.Host, Kind: rt.Kind, Addr: routeAddr(rt),
+ Socket: rt.Socket, Unit: rt.Unit, Environment: rt.Environment})
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].Host < out[j].Host })
+ return out, nil
+}
+
+// routeAddr folds a route's two upstream shapes into "does it have one".
+func routeAddr(rt registry.Route) string {
+ if rt.Addr != "" {
+ return rt.Addr
+ }
+ return rt.Addrs
+}
+
+// relayExecDelivery answers whether a route's BOUND bundle opts into file
+// delivery, applying the same precedence appconfig's own resolver does
+// (#195): the route's named bundle when it states something, the app default
+// otherwise. Absence is inheritance, never "off".
+func (s *Server) relayExecDelivery(ctx context.Context, acct, app, envName string) (bool, error) {
+ if envName != "" && appconfig.ValidEnvironmentName(envName) {
+ m, err := appconfig.LoadEnvironment(ctx, s.cfg.Releases, acct, app, envName)
+ if err != nil {
+ return false, err
+ }
+ if m.ExecDelivery != "" {
+ return m.ExecDelivery == appconfig.ExecDeliveryFile, nil
+ }
+ }
+ m, err := appconfig.Get(ctx, s.cfg.Releases, acct, app)
+ if err != nil {
+ return false, err
+ }
+ return m.ExecDelivery == appconfig.ExecDeliveryFile, nil
+}
+
+// relayDeliverable is enable's second refusal (spec §5, step 2): a route that
+// could never receive a rotated key must not be enabled, because it would
+// block every future retirement forever and nobody would find out until the
+// rotation stalled.
+//
+// The three shapes: an `instance` route is the platform's own — it has a
+// socket or a unit and the config sync restarts it, so it is deliverable. A
+// `static` route runs no process at all and is ignored. Everything else is
+// deliverable only if it has a socket or a unit to bounce, or its bundle opts
+// into file delivery — a bare TCP address whose process this platform never
+// spawned is exactly the route the spec refuses.
+func (s *Server) relayDeliverable(ctx context.Context, acct, app string) error {
+ routes, err := s.relayAppRoutes(ctx, acct, app)
+ if err != nil {
+ return err
+ }
+ for _, rt := range routes {
+ switch {
+ case rt.Kind == "static":
+ continue
+ case rt.Kind == "" || rt.Kind == "instance":
+ continue
+ case rt.Socket != "" || rt.Unit != "":
+ continue
+ case rt.Addr == "":
+ continue
+ }
+ on, err := s.relayExecDelivery(ctx, acct, app, rt.Environment)
+ if err != nil {
+ return err
+ }
+ if !on {
+ return fmt.Errorf("route %s is a TCP address this platform does not own and its config bundle does not opt into file delivery, "+
+ "so it could never pick up a rotated relay key — set it with `carlos env exec-delivery file` first: %w", rt.Host, errRelayUndelivered)
+ }
+ }
+ return nil
+}
+
+// ---- the operations ---------------------------------------------------------
+
+// relayEnable turns the relay on for one app and delivers its key (spec §5,
+// steps 3-7). The transition table decides what happens to the record; the
+// walk after it is always the same three steps, each recorded in `phase` so
+// the reconcile can finish it.
+//
+// A repeat enable of a FINISHED, identically-configured record is the one
+// path that writes nothing at all: no op bump, no config write, no new
+// envelope. Anything else — an unfinished operation, a region change, a
+// re-enable after a disable — walks.
+func (s *Server) relayEnable(ctx context.Context, acct *Account, app string, regions []string, by string) (*relay.Record, error) {
+ if err := s.relayReady(); err != nil {
+ return nil, err
+ }
+ want, err := s.relayResolveRegions(regions)
+ if err != nil {
+ return nil, err
+ }
+ mu := s.relayAppLock(acct.Sqid, app)
+ mu.Lock()
+ defer mu.Unlock()
+ if err := s.requireRelayLease(); err != nil {
+ return nil, err
+ }
+ if err := s.relayDeliverable(ctx, acct.Sqid, app); err != nil {
+ return nil, err
+ }
+ rec, etag, err := relay.GetRecord(ctx, s.cfg.Releases, acct.Sqid, app)
+ if err != nil {
+ return nil, err
+ }
+ now := s.relayTime().UTC()
+ switch {
+ case rec != nil && rec.Enabled && rec.Phase == relayPhaseDone && slices.Equal(rec.Regions, want):
+ return rec, nil
+ case rec != nil && rec.Enabled && slices.Equal(rec.Regions, want):
+ // An unfinished operation RESUMES rather than starting a new one:
+ // same incarnation, same op, and the walk below repeats the steps it
+ // may already have done, all of which are idempotent.
+ case rec != nil && rec.Enabled:
+ // A region change keeps the incarnation — the app's identity did not
+ // move, only where it may connect (spec §5's transition table).
+ rec.Regions = want
+ rec.Op++
+ rec.Phase = relayPhaseConfig
+ default:
+ op := int64(0)
+ if rec != nil {
+ op = rec.Op
+ }
+ // A NEW incarnation for a first enable and for every re-enable after
+ // a disable, so a key from before the disable can never be mistaken
+ // for a live one. Cutovers go with it: they acknowledged identities
+ // that no longer exist.
+ rec = &relay.Record{
+ Enabled: true, Incarnation: relay.NewIncarnation(), Regions: want,
+ Op: op + 1, Phase: relayPhaseConfig, EnabledAt: now,
+ }
+ }
+ if etag, err = relay.PutRecord(ctx, s.cfg.Releases, acct.Sqid, app, rec, etag); err != nil {
+ return nil, err
+ }
+ if err := s.relayApplyConfig(ctx, acct.Sqid, app, rec); err != nil {
+ return nil, err
+ }
+ rec.Phase = relayPhaseAggregate
+ if etag, err = relay.PutRecord(ctx, s.cfg.Releases, acct.Sqid, app, rec, etag); err != nil {
+ return nil, err
+ }
+ if err := s.relayPublishAggregate(ctx); err != nil {
+ return nil, err
+ }
+ rec.Phase = relayPhaseDone
+ if _, err = relay.PutRecord(ctx, s.cfg.Releases, acct.Sqid, app, rec, etag); err != nil {
+ return nil, err
+ }
+ log.Printf("console: relay enabled %s/%s in %s by %s (incarnation %s, op %d)",
+ acct.Sqid, app, strings.Join(rec.Regions, ","), by, rec.Incarnation, rec.Op)
+ return rec, nil
+}
+
+// relayDisable is the same walk in reverse (spec §5): record the intent,
+// remove the variable, publish an aggregate without the app, mark done. The
+// incarnation stays in the record — it is what `status` reports the pending
+// disable against — and a later enable draws a new one.
+func (s *Server) relayDisable(ctx context.Context, acct *Account, app string, by string) (*relay.Record, error) {
+ if s.cfg.Releases == nil {
+ return nil, errRelayNoBucket
+ }
+ mu := s.relayAppLock(acct.Sqid, app)
+ mu.Lock()
+ defer mu.Unlock()
+ if err := s.requireRelayLease(); err != nil {
+ return nil, err
+ }
+ rec, etag, err := relay.GetRecord(ctx, s.cfg.Releases, acct.Sqid, app)
+ if err != nil {
+ return nil, err
+ }
+ if rec == nil {
+ return nil, errRelayNotEnabled
+ }
+ if !rec.Enabled && rec.Phase == relayPhaseDone {
+ return rec, nil // already off and finished: nothing owed
+ }
+ if rec.Enabled {
+ rec.Enabled = false
+ rec.Op++
+ rec.Phase = relayPhaseConfig
+ rec.DisabledAt = s.relayTime().UTC()
+ // Nothing is issued any more. Cutovers stay: they are a historical
+ // record of what a member acknowledged, and a re-enable clears them.
+ rec.IssuedSuffixes = nil
+ if etag, err = relay.PutRecord(ctx, s.cfg.Releases, acct.Sqid, app, rec, etag); err != nil {
+ return nil, err
+ }
+ }
+ if _, err := s.relayUnsetConfig(ctx, acct.Sqid, app); err != nil {
+ return nil, err
+ }
+ rec.Phase = relayPhaseAggregate
+ if etag, err = relay.PutRecord(ctx, s.cfg.Releases, acct.Sqid, app, rec, etag); err != nil {
+ return nil, err
+ }
+ if err := s.relayPublishAggregate(ctx); err != nil {
+ return nil, err
+ }
+ rec.Phase = relayPhaseDone
+ if _, err = relay.PutRecord(ctx, s.cfg.Releases, acct.Sqid, app, rec, etag); err != nil {
+ return nil, err
+ }
+ log.Printf("console: relay disabled %s/%s by %s (op %d)", acct.Sqid, app, by, rec.Op)
+ return rec, nil
+}
+
+// relayAdopted records a member's acknowledgement that a TCP-address route
+// they restarted by hand now runs the identities the console last issued
+// (spec §5, step 5).
+//
+// The identities are stamped from the record AT THIS MOMENT, not named by the
+// caller, and they are what makes the ack meaningful: an acknowledgement
+// given before a later rotation names the older suffixes and so does not
+// count for the newer ones. One cutover per host — a second ack for the same
+// route replaces the first.
+func (s *Server) relayAdopted(ctx context.Context, acct *Account, app, host, by string) (*relay.Record, error) {
+ if s.cfg.Releases == nil {
+ return nil, errRelayNoBucket
+ }
+ mu := s.relayAppLock(acct.Sqid, app)
+ mu.Lock()
+ defer mu.Unlock()
+ if err := s.requireRelayLease(); err != nil {
+ return nil, err
+ }
+ rec, etag, err := relay.GetRecord(ctx, s.cfg.Releases, acct.Sqid, app)
+ if err != nil {
+ return nil, err
+ }
+ if rec == nil || !rec.Enabled {
+ return nil, errRelayNotEnabled
+ }
+ cut := relay.RouteCutover{
+ Host: host,
+ Suffixes: maps.Clone(rec.IssuedSuffixes),
+ At: s.relayTime().UTC(),
+ By: by,
+ }
+ replaced := false
+ for i := range rec.Cutovers {
+ if rec.Cutovers[i].Host == host {
+ rec.Cutovers[i] = cut
+ replaced = true
+ break
+ }
+ }
+ if !replaced {
+ rec.Cutovers = append(rec.Cutovers, cut)
+ }
+ if _, err := relay.PutRecord(ctx, s.cfg.Releases, acct.Sqid, app, rec, etag); err != nil {
+ return nil, err
+ }
+ return rec, nil
+}
+
+// ---- rotation ---------------------------------------------------------------
+
+// relayRotateResult is what `carlos relay rotate` prints: which kid the
+// region now issues from, how many apps were re-issued, and the earliest the
+// old master may be retired.
+type relayRotateResult struct {
+ Region string
+ Kid string
+ Apps int
+ RetireNoEarlierThan time.Time
+}
+
+// relayHeartbeats reads every armed relay's heartbeat.
+func (s *Server) relayHeartbeats(ctx context.Context) ([]relay.Heartbeat, error) {
+ keys, err := s.cfg.Releases.List(ctx, relayAckPrefix)
+ if err != nil {
+ return nil, err
+ }
+ var out []relay.Heartbeat
+ for _, key := range keys {
+ if !strings.HasSuffix(key, ".json") {
+ continue
+ }
+ body, _, err := s.cfg.Releases.Get(ctx, key)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ continue
+ }
+ return nil, err
+ }
+ var hb relay.Heartbeat
+ if err := json.Unmarshal(body, &hb); err != nil {
+ log.Printf("console: relay heartbeat %s: %v", key, err)
+ continue
+ }
+ out = append(out, hb)
+ }
+ return out, nil
+}
+
+// relayRotate moves a region's issuer pointer to the master the operator
+// staged, then re-issues every app enabled in that region (spec §5, step 4).
+//
+// IT REFUSES UNTIL EVERY LIVE RELAY IN THE REGION HAS THE NEW KID. That gate
+// is the whole safety of a rotation: issuing from a master a box has not
+// loaded mints credentials that box refuses, and every call in the region
+// fails until it catches up. A heartbeat older than five minutes is not an
+// answer — a dead relay must not be able to consent by silence — so a region
+// with no fresh heartbeat at all refuses too.
+//
+// The new kid is the LAST staged entry that is not the current issuer, which
+// is where an operator following the runbook appends it.
+func (s *Server) relayRotate(ctx context.Context, region string) (relayRotateResult, error) {
+ var res relayRotateResult
+ if err := s.relayReady(); err != nil {
+ return res, err
+ }
+ region = strings.ToLower(strings.TrimSpace(region))
+ if !slices.Contains(s.relayRegions(), region) {
+ return res, fmt.Errorf("this deployment runs no relay in %s — armed regions are %s",
+ region, strings.Join(s.relayRegions(), ", "))
+ }
+ if err := s.requireRelayLease(); err != nil {
+ return res, err
+ }
+ staged, err := relayStagedMasters(region)
+ if err != nil {
+ return res, err
+ }
+ current, issuerEtag, err := s.relayIssuers(ctx)
+ if err != nil {
+ return res, err
+ }
+ curKid := current[region]
+ if curKid == "" {
+ curKid = staged[0].Kid
+ }
+ next := ""
+ for _, m := range staged {
+ if m.Kid != curKid {
+ next = m.Kid
+ }
+ }
+ if next == "" {
+ return res, fmt.Errorf("no new master is staged for %s — add one to %s (and to /etc/carlos/relay-masters on the region's boxes) before rotating",
+ region, relayMastersEnv(region))
+ }
+ hbs, err := s.relayHeartbeats(ctx)
+ if err != nil {
+ return res, err
+ }
+ now := s.relayTime()
+ live := 0
+ for _, hb := range hbs {
+ if hb.Region != region || now.Sub(hb.ReadAt) > relayHeartbeatFresh {
+ continue
+ }
+ live++
+ if !slices.Contains(hb.Kids, next) {
+ return res, fmt.Errorf("relay %s in %s has not loaded kid %s yet (it has %s) — add the line to /etc/carlos/relay-masters there and try again",
+ hb.Box, region, next, strings.Join(hb.Kids, ","))
+ }
+ }
+ if live == 0 {
+ return res, fmt.Errorf("no relay in %s has heartbeated in the last %s — rotating now would issue keys nothing can verify",
+ region, relayHeartbeatFresh)
+ }
+ // The pointer moves FIRST, under CAS, and every key derived after this
+ // line uses the new master. An interrupted rotation therefore leaves a
+ // moved pointer and some apps still on the old kid, which is exactly what
+ // the reconcile converges — and why the old kid stays accepted for 28
+ // hours.
+ current[region] = next
+ body, err := json.MarshalIndent(current, "", " ")
+ if err != nil {
+ return res, err
+ }
+ if err := s.requireRelayLease(); err != nil {
+ return res, err
+ }
+ if _, err := s.cfg.Releases.Put(ctx, relayIssuerKey, append(body, '\n'), issuerEtag); err != nil {
+ return res, err
+ }
+ recs, err := s.relayListRecords(ctx)
+ if err != nil {
+ return res, err
+ }
+ reissued := 0
+ for _, r := range recs {
+ if !r.Record.Enabled || !slices.Contains(r.Record.Regions, region) {
+ continue
+ }
+ if err := s.relayReissue(ctx, r); err != nil {
+ // One app that cannot be re-issued must not stop the rest: the
+ // pointer has already moved, so every remaining app is one the
+ // reconcile would otherwise have to catch five minutes later.
+ log.Printf("console: relay rotate %s: %s/%s: %v", region, r.Account, r.App, err)
+ continue
+ }
+ reissued++
+ }
+ log.Printf("console: relay rotated %s to kid %s, re-issued %d app(s)", region, next, reissued)
+ return relayRotateResult{Region: region, Kid: next, Apps: reissued,
+ RetireNoEarlierThan: now.Add(relayRetireAfter).UTC()}, nil
+}
+
+// relayReissue is a rotation's per-app operation: op bumped, config
+// re-applied from the master in force now, aggregate published, done. The
+// aggregate does not CHANGE on a rotation (the incarnation is untouched), but
+// it is published anyway because the phase walk is what makes the operation
+// resumable, and a publish is cheap next to a half-recorded rotation.
+func (s *Server) relayReissue(ctx context.Context, r relayAppRecord) error {
+ mu := s.relayAppLock(r.Account, r.App)
+ mu.Lock()
+ defer mu.Unlock()
+ if err := s.requireRelayLease(); err != nil {
+ return err
+ }
+ rec, etag, err := relay.GetRecord(ctx, s.cfg.Releases, r.Account, r.App)
+ if err != nil {
+ return err
+ }
+ if rec == nil || !rec.Enabled {
+ return nil // disabled under us: the reconcile owns it now
+ }
+ rec.Op++
+ rec.Phase = relayPhaseConfig
+ if etag, err = relay.PutRecord(ctx, s.cfg.Releases, r.Account, r.App, rec, etag); err != nil {
+ return err
+ }
+ if err := s.relayApplyConfig(ctx, r.Account, r.App, rec); err != nil {
+ return err
+ }
+ rec.Phase = relayPhaseAggregate
+ if etag, err = relay.PutRecord(ctx, s.cfg.Releases, r.Account, r.App, rec, etag); err != nil {
+ return err
+ }
+ if err := s.relayPublishAggregate(ctx); err != nil {
+ return err
+ }
+ rec.Phase = relayPhaseDone
+ _, err = relay.PutRecord(ctx, s.cfg.Releases, r.Account, r.App, rec, etag)
+ return err
+}
+
+// ---- the reconcile ----------------------------------------------------------
+
+// relayReconcile converges BOTH derived artefacts from every record, every
+// five minutes and once at console start (spec §5).
+//
+// It deliberately does not look for unfinished records. For each enabled
+// record it derives what the config should hold from the record and the
+// masters in force, and re-applies when the config holds anything else; for
+// each disabled record it removes the variable if present; and it ALWAYS
+// rebuilds and publishes the aggregate, which is what repairs a deletion that
+// removed a record but died before publishing.
+//
+// One app's failure is logged and skipped, never fatal. The aggregate publish
+// is the last step and the only one whose failure is returned, because it is
+// the one that speaks for every app at once.
+func (s *Server) relayReconcile(ctx context.Context) error {
+ if s.cfg.Releases == nil {
+ return errRelayNoBucket
+ }
+ if err := s.requireRelayLease(); err != nil {
+ return err
+ }
+ recs, err := s.relayListRecords(ctx)
+ if err != nil {
+ return err
+ }
+ // Records whose walk reached `aggregate` in this pass, with the etag to
+ // mark them done once the publish below has landed.
+ type pending struct {
+ rec relayAppRecord
+ etag string
+ }
+ var advanced []pending
+ for _, r := range recs {
+ etag, changed, err := s.relayConverge(ctx, r)
+ if err != nil {
+ log.Printf("console: relay reconcile %s/%s: %v", r.Account, r.App, err)
+ continue
+ }
+ if changed {
+ advanced = append(advanced, pending{rec: r, etag: etag})
+ }
+ }
+ if err := s.relayPublishAggregate(ctx); err != nil {
+ return err
+ }
+ for _, p := range advanced {
+ if err := s.relayMarkDone(ctx, p.rec, p.etag); err != nil {
+ log.Printf("console: relay reconcile %s/%s: marking done: %v", p.rec.Account, p.rec.App, err)
+ }
+ }
+ return nil
+}
+
+// relayConverge brings ONE record's config into agreement with it, returning
+// the etag it left the record at and whether the record still needs marking
+// done after the publish.
+func (s *Server) relayConverge(ctx context.Context, r relayAppRecord) (string, bool, error) {
+ mu := s.relayAppLock(r.Account, r.App)
+ mu.Lock()
+ defer mu.Unlock()
+ if err := s.requireRelayLease(); err != nil {
+ return "", false, err
+ }
+ // Re-read under the lock: the listing is a snapshot, and an operation may
+ // have moved this record between it and here.
+ rec, etag, err := relay.GetRecord(ctx, s.cfg.Releases, r.Account, r.App)
+ if err != nil || rec == nil {
+ return "", false, err
+ }
+ if rec.Enabled {
+ want, err := s.relayExpectedSuffixes(ctx, r.Account, r.App, rec)
+ if err != nil {
+ return "", false, err
+ }
+ v, present, err := s.relayConfigVar(ctx, r.Account, r.App)
+ if err != nil {
+ return "", false, err
+ }
+ current := present && v.Kind == "secret" && v.Envelope != "" && maps.Equal(rec.IssuedSuffixes, want)
+ if current && rec.Phase == relayPhaseDone {
+ return etag, false, nil
+ }
+ if !current {
+ if err := s.relayApplyConfig(ctx, r.Account, r.App, rec); err != nil {
+ return "", false, err
+ }
+ }
+ } else {
+ if _, err := s.relayUnsetConfig(ctx, r.Account, r.App); err != nil {
+ return "", false, err
+ }
+ if rec.Phase == relayPhaseDone {
+ return etag, false, nil
+ }
+ }
+ rec.Phase = relayPhaseAggregate
+ etag, err = relay.PutRecord(ctx, s.cfg.Releases, r.Account, r.App, rec, etag)
+ if err != nil {
+ return "", false, err
+ }
+ return etag, true, nil
+}
+
+// relayMarkDone is the last step of a reconciled record's walk, after the
+// aggregate that carries it has been published.
+func (s *Server) relayMarkDone(ctx context.Context, r relayAppRecord, etag string) error {
+ mu := s.relayAppLock(r.Account, r.App)
+ mu.Lock()
+ defer mu.Unlock()
+ if err := s.requireRelayLease(); err != nil {
+ return err
+ }
+ rec, cur, err := relay.GetRecord(ctx, s.cfg.Releases, r.Account, r.App)
+ if err != nil || rec == nil {
+ return err
+ }
+ if cur != etag {
+ return nil // moved on under us; its own walk owns the phase now
+ }
+ rec.Phase = relayPhaseDone
+ _, err = relay.PutRecord(ctx, s.cfg.Releases, r.Account, r.App, rec, etag)
+ return err
+}
diff --git a/internal/console/relayops_test.go b/internal/console/relayops_test.go
new file mode 100644
index 000000000..95080b14f
--- /dev/null
+++ b/internal/console/relayops_test.go
@@ -0,0 +1,558 @@
+package console
+
+// The relay's write side (relay spec §5). What is pinned here, in the order
+// it would hurt if lost: an enable delivers a key AND publishes authority, or
+// it does neither; the aggregate is rebuilt from records with the etag read
+// FIRST, so a disable landing mid-listing fails the write instead of undoing
+// itself; the reconcile converges an interrupted enable, a stale config write
+// that outlived a disable, and an aggregate naming an app whose record is
+// gone; and one console at a time holds the writer lease, with the loser
+// stopping rather than writing.
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/appconfig"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/relay"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// relayFixture is storeFixture (an account, the app titogo, a member's token)
+// plus everything the relay needs: one armed region, a sealing key whose
+// private half only the test holds, and a staged master in this console's own
+// environment. It leaves the server HOLDING the writer lease, because every
+// operation refuses without it and no test here is about that refusal.
+//
+// The private key comes back so a test can open the envelope the console
+// delivered and prove it is the value the record implies — the console cannot
+// do that, by design, so the assertion belongs on this side.
+func relayFixture(t *testing.T, code string) (*consoleFixture, string) {
+ t.Helper()
+ fx, _ := storeFixture(t, code)
+ pub, priv, _ := sealedFor(t, "irrelevant")
+ fx.srv.cfg.SecretsPub = pub
+ fx.srv.cfg.RelayRegions = []string{"eu-west-1"}
+ fx.srv.cfg.PlatformDomain = "oncarlos.com"
+ t.Setenv(relayMastersEnv("eu-west-1"), "k1:"+relayTestMaster(1))
+ if err := fx.srv.relayAcquire(context.Background()); err != nil {
+ t.Fatalf("acquire the writer lease: %v", err)
+ }
+ return fx, priv
+}
+
+// relayTestMaster is a deterministic 32-byte master, base64 as the console
+// variable carries it.
+func relayTestMaster(seed byte) string {
+ b := make([]byte, relay.MasterKeyLen)
+ for i := range b {
+ b[i] = seed*31 + byte(i)
+ }
+ return base64.StdEncoding.EncodeToString(b)
+}
+
+// account is the fixture's account as the operations take it.
+func (fx *consoleFixture) account(t *testing.T) *Account {
+ t.Helper()
+ a, err := fx.st.AccountBySqid(fx.sqid)
+ if err != nil {
+ t.Fatalf("account %s: %v", fx.sqid, err)
+ }
+ return a
+}
+
+// relayRecord reads the app's record straight out of the bucket.
+func relayRecord(t *testing.T, fx *consoleFixture, app string) *relay.Record {
+ t.Helper()
+ rec, _, err := relay.GetRecord(context.Background(), fx.rel, fx.sqid, app)
+ if err != nil {
+ t.Fatalf("read relay record: %v", err)
+ }
+ return rec
+}
+
+// relayAuthority reads the published authority record, an empty one when
+// nothing has been published.
+func relayAuthority(t *testing.T, fx *consoleFixture) *relay.Authority {
+ t.Helper()
+ a, _, err := relay.ReadAuthority(context.Background(), fx.rel)
+ if err != nil {
+ t.Fatalf("read authority: %v", err)
+ }
+ if a == nil {
+ return &relay.Authority{}
+ }
+ return a
+}
+
+// TestRelayEnableWritesRecordConfigAndAggregate is the whole happy path: the
+// record lands finished, the app's config carries a SEALED CARLOS_RELAY whose
+// plaintext is the value the record implies, and the authority every relay
+// box reads names the app at that incarnation.
+func TestRelayEnableWritesRecordConfigAndAggregate(t *testing.T) {
+ fx, priv := relayFixture(t, "code-relay-enable")
+ ctx := context.Background()
+
+ rec, err := fx.srv.relayEnable(ctx, fx.account(t), "titogo", nil, "alice@keymail.dev")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !rec.Enabled || rec.Phase != relayPhaseDone || rec.Op != 1 || len(rec.Incarnation) != 16 {
+ t.Fatalf("record = %+v", rec)
+ }
+ if got := relayRecord(t, fx, "titogo"); got == nil || got.Phase != relayPhaseDone || got.Incarnation != rec.Incarnation {
+ t.Fatalf("stored record = %+v, want the returned one finished", got)
+ }
+
+ v, ok := appVars(t, fx, "titogo")[relayVar]
+ if !ok || v.Kind != "secret" || v.Envelope == "" {
+ t.Fatalf("%s = %+v, want a sealed secret", relayVar, v)
+ }
+ entries, err := relay.ParseConfig(openSecret(t, priv, v))
+ if err != nil {
+ t.Fatalf("delivered value: %v", err)
+ }
+ if len(entries) != 1 || entries[0].Region != "eu-west-1" {
+ t.Fatalf("entries = %+v, want one for the armed region", entries)
+ }
+ wantURLs := []string{
+ "stun:relay.eu-west-1.oncarlos.com:3478",
+ "turn:relay.eu-west-1.oncarlos.com:3478?transport=udp",
+ "turn:relay.eu-west-1.oncarlos.com:3478?transport=tcp",
+ }
+ if strings.Join(entries[0].URLs, ",") != strings.Join(wantURLs, ",") {
+ t.Fatalf("urls = %v, want %v", entries[0].URLs, wantURLs)
+ }
+ wantSuffix := relay.Suffix(fx.sqid, "titogo", rec.Incarnation, "k1")
+ if entries[0].UsernameSuffix != wantSuffix || rec.IssuedSuffixes["eu-west-1"] != wantSuffix {
+ t.Fatalf("suffix = %q / recorded %q, want %q", entries[0].UsernameSuffix, rec.IssuedSuffixes["eu-west-1"], wantSuffix)
+ }
+
+ a := relayAuthority(t, fx)
+ x, ok := a.Lookup(fx.sqid, "titogo")
+ if !ok || x.Incarnation != rec.Incarnation || strings.Join(x.Regions, ",") != "eu-west-1" {
+ t.Fatalf("aggregate = %+v", a)
+ }
+}
+
+// TestRelayRepeatEnableKeepsIncarnationAndWritesNoConfig: enabling an app
+// that is already enabled with the same regions and nothing owed writes
+// NOTHING — no op bump, no new envelope, no generation touch. A member who
+// re-runs the command must not re-key their own app.
+func TestRelayRepeatEnableKeepsIncarnationAndWritesNoConfig(t *testing.T) {
+ fx, _ := relayFixture(t, "code-relay-repeat")
+ ctx := context.Background()
+
+ first, err := fx.srv.relayEnable(ctx, fx.account(t), "titogo", nil, "alice@keymail.dev")
+ if err != nil {
+ t.Fatal(err)
+ }
+ before := appVars(t, fx, "titogo")[relayVar]
+ _, genBefore, err := fx.rel.Get(ctx, appconfig.GenKey(fx.sqid, "titogo"))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ second, err := fx.srv.relayEnable(ctx, fx.account(t), "titogo", []string{"eu-west-1"}, "alice@keymail.dev")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if second.Incarnation != first.Incarnation || second.Op != first.Op {
+ t.Fatalf("second enable = %+v, want the same incarnation and op as %+v", second, first)
+ }
+ if after := appVars(t, fx, "titogo")[relayVar]; after.Envelope != before.Envelope {
+ t.Fatal("a repeat enable re-sealed the value — the app would re-key for nothing")
+ }
+ _, genAfter, err := fx.rel.Get(ctx, appconfig.GenKey(fx.sqid, "titogo"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if genAfter != genBefore {
+ t.Fatalf("config generation moved (%q -> %q) with no config write", genBefore, genAfter)
+ }
+}
+
+// TestRelayDisableThenEnableIsANewIncarnation: a disable takes the app out of
+// authority and takes the variable out of its config; the enable after it
+// draws a NEW incarnation, so a credential minted before the disable can
+// never be mistaken for a live one.
+func TestRelayDisableThenEnableIsANewIncarnation(t *testing.T) {
+ fx, _ := relayFixture(t, "code-relay-cycle")
+ ctx := context.Background()
+ acct := fx.account(t)
+
+ first, err := fx.srv.relayEnable(ctx, acct, "titogo", nil, "alice@keymail.dev")
+ if err != nil {
+ t.Fatal(err)
+ }
+ off, err := fx.srv.relayDisable(ctx, acct, "titogo", "alice@keymail.dev")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if off.Enabled || off.Phase != relayPhaseDone || off.Op != first.Op+1 {
+ t.Fatalf("disabled record = %+v", off)
+ }
+ if _, ok := appVars(t, fx, "titogo")[relayVar]; ok {
+ t.Fatalf("%s survived the disable", relayVar)
+ }
+ if _, ok := relayAuthority(t, fx).Lookup(fx.sqid, "titogo"); ok {
+ t.Fatal("the aggregate still names a disabled app")
+ }
+
+ again, err := fx.srv.relayEnable(ctx, acct, "titogo", nil, "alice@keymail.dev")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if again.Incarnation == first.Incarnation {
+ t.Fatalf("re-enable kept incarnation %s — a pre-disable key would still verify", again.Incarnation)
+ }
+ if again.Op != off.Op+1 {
+ t.Fatalf("op = %d after a re-enable, want %d (the counter never rewinds)", again.Op, off.Op+1)
+ }
+ if x, ok := relayAuthority(t, fx).Lookup(fx.sqid, "titogo"); !ok || x.Incarnation != again.Incarnation {
+ t.Fatalf("aggregate = %+v, want the new incarnation", x)
+ }
+}
+
+// relayOpStore records the ORDER of the store calls one publish makes — the
+// only way to prove the etag is read before the listing rather than after,
+// which is the whole safety of spec §5's publish sequence.
+type relayOpStore struct {
+ release.Store
+ mu sync.Mutex
+ ops []string
+}
+
+func (r *relayOpStore) note(op string) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.ops = append(r.ops, op)
+}
+
+func (r *relayOpStore) seq() []string {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ return append([]string(nil), r.ops...)
+}
+
+func (r *relayOpStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ r.note("get " + key)
+ return r.Store.Get(ctx, key)
+}
+
+func (r *relayOpStore) ListDirs(ctx context.Context, prefix string) ([]string, error) {
+ r.note("listdirs " + prefix)
+ return r.Store.ListDirs(ctx, prefix)
+}
+
+func (r *relayOpStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
+ r.note("put " + key)
+ return r.Store.Put(ctx, key, body, ifMatch)
+}
+
+// TestRelayPublishAggregateIsEtagFirst pins the sequence itself: the
+// aggregate's etag is read BEFORE any record is listed, and a publish carrying
+// an etag that has since moved fails with ErrCASConflict rather than
+// overwriting whoever moved it. The retry then publishes the current truth.
+func TestRelayPublishAggregateIsEtagFirst(t *testing.T) {
+ fx, _ := relayFixture(t, "code-relay-etag")
+ ctx := context.Background()
+ acct := fx.account(t)
+
+ if _, err := fx.srv.relayEnable(ctx, acct, "titogo", nil, "alice@keymail.dev"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.srv.relayEnable(ctx, acct, "meet", nil, "alice@keymail.dev"); err != nil {
+ t.Fatal(err)
+ }
+
+ // The order, on a publish with nothing else going on.
+ rec := &relayOpStore{Store: fx.rel}
+ fx.srv.cfg.Releases = rec
+ if err := fx.srv.relayPublishAggregate(ctx); err != nil {
+ t.Fatal(err)
+ }
+ fx.srv.cfg.Releases = rec.Store
+ seq := rec.seq()
+ getAt, listAt := -1, -1
+ for i, op := range seq {
+ if getAt < 0 && op == "get "+relay.AuthorityKey {
+ getAt = i
+ }
+ if listAt < 0 && strings.HasPrefix(op, "listdirs apps/") {
+ listAt = i
+ }
+ }
+ if getAt < 0 || listAt < 0 || getAt > listAt {
+ t.Fatalf("publish sequence = %v, want the aggregate's etag read before the listing", seq)
+ }
+
+ // A stale etag must lose. Take one, then move the aggregate underneath it
+ // by disabling titogo's record directly and republishing.
+ _, stale, err := fx.rel.Get(ctx, relay.AuthorityKey)
+ if err != nil {
+ t.Fatal(err)
+ }
+ off, offEtag, err := relay.GetRecord(ctx, fx.rel, fx.sqid, "titogo")
+ if err != nil {
+ t.Fatal(err)
+ }
+ off.Enabled = false
+ if _, err := relay.PutRecord(ctx, fx.rel, fx.sqid, "titogo", off, offEtag); err != nil {
+ t.Fatal(err)
+ }
+ if err := fx.srv.relayPublishAggregate(ctx); err != nil {
+ t.Fatal(err)
+ }
+ if err := fx.srv.relayPublishOnce(ctx, stale); !errors.Is(err, release.ErrCASConflict) {
+ t.Fatalf("publish with a stale etag = %v, want ErrCASConflict", err)
+ }
+ if err := fx.srv.relayPublishAggregate(ctx); err != nil {
+ t.Fatal(err)
+ }
+ a := relayAuthority(t, fx)
+ if _, ok := a.Lookup(fx.sqid, "titogo"); ok {
+ t.Fatalf("aggregate = %+v, want the disabled app gone", a)
+ }
+ if _, ok := a.Lookup(fx.sqid, "meet"); !ok {
+ t.Fatalf("aggregate = %+v, want the still-enabled app kept", a)
+ }
+}
+
+// TestRelayReconcileFinishesInterruptedEnable: a console that wrote the
+// record and died before the config write leaves an enabled record at phase
+// `config`, no variable and no authority. The next holder's reconcile
+// delivers both and marks it done — without the member running anything.
+func TestRelayReconcileFinishesInterruptedEnable(t *testing.T) {
+ fx, _ := relayFixture(t, "code-relay-resume")
+ ctx := context.Background()
+ inc := relay.NewIncarnation()
+ if _, err := relay.PutRecord(ctx, fx.rel, fx.sqid, "titogo", &relay.Record{
+ Enabled: true, Incarnation: inc, Regions: []string{"eu-west-1"},
+ Op: 1, Phase: relayPhaseConfig, EnabledAt: time.Now().UTC(),
+ }, ""); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := fx.srv.relayReconcile(ctx); err != nil {
+ t.Fatal(err)
+ }
+
+ if v, ok := appVars(t, fx, "titogo")[relayVar]; !ok || v.Kind != "secret" {
+ t.Fatalf("%s = %+v after the reconcile, want a delivered secret", relayVar, v)
+ }
+ if x, ok := relayAuthority(t, fx).Lookup(fx.sqid, "titogo"); !ok || x.Incarnation != inc {
+ t.Fatal("the reconcile did not publish the interrupted enable")
+ }
+ if got := relayRecord(t, fx, "titogo"); got.Phase != relayPhaseDone {
+ t.Fatalf("phase = %q after the reconcile, want done", got.Phase)
+ }
+}
+
+// TestRelayReconcileUndoesStaleConfigAfterDisable: a config write that lands
+// AFTER a disable — a losing writer's last act — is undone within one
+// reconcile. This is the convergence spec §5 promises for the case the phase
+// walk alone cannot cover, because the record is already finished.
+func TestRelayReconcileUndoesStaleConfigAfterDisable(t *testing.T) {
+ fx, _ := relayFixture(t, "code-relay-stale")
+ ctx := context.Background()
+ acct := fx.account(t)
+
+ if _, err := fx.srv.relayEnable(ctx, acct, "titogo", nil, "alice@keymail.dev"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.srv.relayDisable(ctx, acct, "titogo", "alice@keymail.dev"); err != nil {
+ t.Fatal(err)
+ }
+ // The straggler write.
+ if _, err := appconfig.Apply(ctx, fx.rel, fx.sqid, "titogo",
+ map[string]appconfig.Var{relayVar: {Kind: "secret", Envelope: "stale-envelope"}}, nil); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := fx.srv.relayReconcile(ctx); err != nil {
+ t.Fatal(err)
+ }
+ if v, ok := appVars(t, fx, "titogo")[relayVar]; ok {
+ t.Fatalf("%s = %+v, want it removed again", relayVar, v)
+ }
+}
+
+// TestRelayReconcileRepairsDeletionWithoutRecord: an app whose record is gone
+// — deleted with the rest of its prefix by a walk that died before publishing
+// — disappears from authority on the next unconditional rebuild. That is why
+// the reconcile lists records rather than hunting for unfinished ones.
+func TestRelayReconcileRepairsDeletionWithoutRecord(t *testing.T) {
+ fx, _ := relayFixture(t, "code-relay-deleted")
+ ctx := context.Background()
+ body, err := json.Marshal(relay.Authority{
+ Apps: []relay.AuthorityApp{{Account: fx.sqid, App: "gone",
+ Incarnation: relay.NewIncarnation(), Regions: []string{"eu-west-1"}}},
+ WrittenAt: time.Now().UTC(),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.rel.Put(ctx, relay.AuthorityKey, body, ""); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := fx.srv.relayReconcile(ctx); err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := relayAuthority(t, fx).Lookup(fx.sqid, "gone"); ok {
+ t.Fatal("the aggregate still names an app with no record")
+ }
+}
+
+// TestRelayEnableRefusesRouteWithoutDelivery: a TCP-address route this
+// platform does not own, whose bundle has not opted into file delivery, could
+// never pick up a rotated key — so it would block every future retirement
+// forever. Enable refuses it BY NAME, and writes nothing on the way out.
+func TestRelayEnableRefusesRouteWithoutDelivery(t *testing.T) {
+ fx, _ := relayFixture(t, "code-relay-delivery")
+ ctx := context.Background()
+ fx.addRoute(t, registry.Route{Host: "x.titogo.oncarlos.com", App: "titogo",
+ Kind: "service", Addr: "10.0.0.5:8080", Status: "ready"})
+
+ _, err := fx.srv.relayEnable(ctx, fx.account(t), "titogo", nil, "alice@keymail.dev")
+ if !errors.Is(err, errRelayUndelivered) {
+ t.Fatalf("enable over an undeliverable route = %v, want a refusal", err)
+ }
+ if !strings.Contains(err.Error(), "x.titogo.oncarlos.com") {
+ t.Fatalf("error = %q, want the route named", err)
+ }
+ if rec := relayRecord(t, fx, "titogo"); rec != nil {
+ t.Fatalf("a refused enable left a record: %+v", rec)
+ }
+ if _, ok := appVars(t, fx, "titogo")[relayVar]; ok {
+ t.Fatal("a refused enable delivered a key")
+ }
+
+ // Opting the app in makes the same route deliverable.
+ if err := appconfig.SetExecDelivery(ctx, fx.rel, fx.sqid, "titogo", appconfig.ExecDeliveryFile); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.srv.relayEnable(ctx, fx.account(t), "titogo", nil, "alice@keymail.dev"); err != nil {
+ t.Fatalf("enable after opting into file delivery: %v", err)
+ }
+}
+
+// TestRelayRotateRefusesUntilHeartbeatsCarryKid: rotation's whole safety is
+// that the console will not issue from a master a live relay has not loaded.
+// Until the region's heartbeat lists the new kid, rotate refuses and the
+// issuer pointer does not move; once it does, every enabled app is re-issued
+// under the new kid.
+func TestRelayRotateRefusesUntilHeartbeatsCarryKid(t *testing.T) {
+ fx, _ := relayFixture(t, "code-relay-rotate")
+ ctx := context.Background()
+ t.Setenv(relayMastersEnv("eu-west-1"), "k1:"+relayTestMaster(1)+",k2:"+relayTestMaster(2))
+
+ before, err := fx.srv.relayEnable(ctx, fx.account(t), "titogo", nil, "alice@keymail.dev")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.HasSuffix(before.IssuedSuffixes["eu-west-1"], ":k1") {
+ t.Fatalf("issued %q, want the first staged master", before.IssuedSuffixes["eu-west-1"])
+ }
+ hb := relay.Heartbeat{Box: "box1", Region: "eu-west-1", ReadAt: time.Now(),
+ Listeners: []string{"udp:3478"}, Kids: []string{"k1"}}
+ if err := relay.WriteHeartbeat(ctx, fx.rel, hb); err != nil {
+ t.Fatal(err)
+ }
+
+ if _, err := fx.srv.relayRotate(ctx, "eu-west-1"); err == nil ||
+ !strings.Contains(err.Error(), "k2") || !strings.Contains(err.Error(), "box1") {
+ t.Fatalf("rotate before the relay loaded the kid = %v, want a refusal naming the box and kid", err)
+ }
+ if issuers, _, err := fx.srv.relayIssuers(ctx); err != nil || issuers["eu-west-1"] != "" {
+ t.Fatalf("issuer pointer = %v (%v) after a refused rotation, want it untouched", issuers, err)
+ }
+
+ hb.Kids = []string{"k1", "k2"}
+ if err := relay.WriteHeartbeat(ctx, fx.rel, hb); err != nil {
+ t.Fatal(err)
+ }
+ res, err := fx.srv.relayRotate(ctx, "eu-west-1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Kid != "k2" || res.Apps != 1 {
+ t.Fatalf("rotate result = %+v, want kid k2 and one app re-issued", res)
+ }
+ if d := time.Until(res.RetireNoEarlierThan); d < 27*time.Hour || d > 29*time.Hour {
+ t.Fatalf("retire-no-earlier-than is %s away, want about 28h", d)
+ }
+ issuers, _, err := fx.srv.relayIssuers(ctx)
+ if err != nil || issuers["eu-west-1"] != "k2" {
+ t.Fatalf("issuer pointer = %v (%v), want k2", issuers, err)
+ }
+ after := relayRecord(t, fx, "titogo")
+ if !strings.HasSuffix(after.IssuedSuffixes["eu-west-1"], ":k2") {
+ t.Fatalf("issued %q after the rotation, want the new kid", after.IssuedSuffixes["eu-west-1"])
+ }
+ if after.Incarnation != before.Incarnation {
+ t.Fatal("a rotation changed the incarnation — every live allocation would end")
+ }
+ if after.Op != before.Op+1 || after.Phase != relayPhaseDone {
+ t.Fatalf("record = %+v, want one finished operation past %d", after, before.Op)
+ }
+}
+
+// TestRelayWriterLeaseExcludesSecondHolder: two consoles over one bucket, one
+// lease. The second is excluded until the first's minute lapses — and the
+// first, once it has been taken over, fails its next renewal and drops the
+// flag every relay write checks, which is what stops a superseded writer
+// finishing a walk the new holder has already started.
+func TestRelayWriterLeaseExcludesSecondHolder(t *testing.T) {
+ fx, _ := relayFixture(t, "code-relay-lease")
+ ctx := context.Background()
+
+ other := newConsoleFixture(t)
+ other.srv.cfg.Releases = fx.rel
+ other.srv.cfg.RelayRegions = []string{"eu-west-1"}
+
+ base := time.Now()
+ first, second := base, base.Add(time.Second)
+ fx.srv.relayNow = func() time.Time { return first }
+ other.srv.relayNow = func() time.Time { return second }
+
+ if err := fx.srv.relayAcquire(ctx); err != nil || !fx.srv.relayHold() {
+ t.Fatalf("first acquire = %v, held = %v", err, fx.srv.relayHold())
+ }
+ err := other.srv.relayAcquire(ctx)
+ if !errors.Is(err, errRelayNotHeld) {
+ t.Fatalf("second acquire inside the TTL = %v, want a refusal", err)
+ }
+ if other.srv.relayHold() {
+ t.Fatal("the second console thinks it holds the lease")
+ }
+ if err := other.srv.relayReconcile(ctx); !errors.Is(err, errRelayNotHeld) {
+ t.Fatalf("reconcile without the lease = %v, want a refusal", err)
+ }
+
+ // Past the expiry, the lease is takeable.
+ second = base.Add(relayLeaseTTL + time.Second)
+ if err := other.srv.relayAcquire(ctx); err != nil || !other.srv.relayHold() {
+ t.Fatalf("acquire after expiry = %v, held = %v", err, other.srv.relayHold())
+ }
+
+ // And the first console's next renewal fails, dropping its own flag.
+ first = base.Add(relayLeaseTTL + 2*time.Second)
+ if err := fx.srv.relayAcquire(ctx); err == nil {
+ t.Fatal("the superseded console renewed a lease it does not hold")
+ }
+ if fx.srv.relayHold() {
+ t.Fatal("the superseded console still believes it holds the lease")
+ }
+ if _, err := fx.srv.relayEnable(ctx, fx.account(t), "titogo", nil, "alice@keymail.dev"); !errors.Is(err, errRelayNotHeld) {
+ t.Fatalf("enable without the lease = %v, want a refusal", err)
+ }
+}
diff --git a/internal/console/server.go b/internal/console/server.go
index c5ce9c38f..cf2e357f5 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -626,6 +626,38 @@ type Config struct {
// unaffected either way: it calls mail.SetPaused directly, never
// through this door.
MailOperators []string
+ // RelayRegions are the AWS regions this deployment runs a STUN/TURN
+ // relay in (CARLOS_RELAY_REGIONS, comma-separated), IN THE ORDER an
+ // enabled app's CARLOS_RELAY lists them — the order every browser will
+ // try them in, so it is the operator's list verbatim, not a set. Empty
+ // is a legal deployment and the one this platform has had until now:
+ // `carlos relay enable` refuses with a sentence naming the variable,
+ // and no relay background loop starts at all.
+ //
+ // These are AWS region names (eu-west-1), not placement codes (ie,
+ // fleet/x): they are the vocabulary of the relay hostnames
+ // (relay.<region>.<platform domain>) and of the per-region master
+ // secrets, and an app may sit in several placements at once (relay
+ // spec §5, step 3).
+ RelayRegions []string
+ // RelayOperators are the addresses allowed to drive `carlos relay
+ // rotate` (CARLOS_RELAY_OPERATORS) — the operator verb that moves a
+ // region's issuer pointer to a freshly staged master and re-issues
+ // every enabled app's key. Its own list for the same reason
+ // StoreOperators is: rotating a region's master is an authority over
+ // the DEPLOYMENT's key material, not over any one tenant's app, and a
+ // deployment may want to grant it to different people. Empty is
+ // fail-closed — nobody may rotate.
+ RelayOperators []string
+ // PlatformDomain is the domain relay hostnames are built under
+ // (CARLOS_PLATFORM_DOMAIN; flagship: "oncarlos.com"), so an enabled
+ // app is handed stun:relay.<region>.<PlatformDomain>:3478 and its two
+ // TURN siblings. Optional: unset falls back to AppsDomain, with a
+ // leading "apps." label stripped when the apps domain is a subdomain
+ // of the platform's own — see relayDomain(). Set it explicitly
+ // wherever the two differ by anything but that label; the relay's DNS
+ // names are operator-managed and this console cannot discover them.
+ PlatformDomain string
}
// Server is the console's http.Handler.
@@ -788,6 +820,21 @@ type Server struct {
systemJobs map[string]*systemJob
systemRunning map[string]string
systemUploads map[string]systemUpload
+
+ // The relay writer's three fields (relayops.go). relayHolder is this
+ // PROCESS's identity in control/relay-writer.json, drawn once at
+ // construction; relayHeld is whether the lease is currently ours, read
+ // before every relay bucket write by requireRelayLease; relayAppLocks
+ // serialises operations on one app ("<sqid>/<app>" -> *sync.Mutex)
+ // inside the holder, which is the second half of spec §5's one-writer
+ // rule — the lease keeps two CONSOLES apart, this keeps two handlers
+ // of the same console apart.
+ relayHolder string
+ relayHeld atomic.Bool
+ relayAppLocks sync.Map
+ // relayNow is the relay paths' clock seam: nil means time.Now. Tests
+ // drive lease expiry with it; nothing in production sets it.
+ relayNow func() time.Time
}
// edgeFoldTTL is how long one fold of the deployment's edges files stands
@@ -978,6 +1025,11 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
delegationZone: normalizeDelegationZone(cfg.DelegationZone),
steeringHost: normalizeSteeringHost(cfg.SteeringHost),
lookupCNAME: net.DefaultResolver.LookupCNAME,
+ // This process's identity in the relay writer lease. Random rather
+ // than derived from the host: two consoles on one box during a
+ // deploy overlap are two writers, and they must be able to tell
+ // each other apart (relayops.go).
+ relayHolder: relayHolderID(),
}
if s.km != nil {
s.sendApp = s.km.SendApp