carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The CLI's doors onto the platform STUN/TURN relay (relay spec §5):
2// `carlos relay enable|status|disable|adopted` for a member, and
3// `carlos relay rotate` for a deployment operator. Every write here is a
4// thin shell over internal/console/relayops.go's operations — the record,
5// the lease, the walk and the reconcile all live there; this file's whole
6// job is the door: gate the caller, decode the request, translate the
7// operation's sentinel errors into HTTP, and answer with the ONE reply
8// shape every member-facing door shares (storeReply's pattern, apistore.go).
9//
10// FOUR DOORS ARE APP-SCOPED, ONE IS NOT. enable, status, disable and adopted
11// sit under .../apps/{account}/{app}/relay, behind apiAccountApp exactly
12// like the store doors: an unknown account, someone else's account and an
13// app that was never claimed are one byte-identical 404 (apiAccountApp's own
14// doc comment). rotate has no account or app in its path at all — it moves a
15// REGION's issuer pointer and re-issues every app enabled there, so its gate
16// is apiRelayOperator, not apiAccountApp, and its refusal is a byte-identical
17// 404 rather than the 403 the store's grant door gives a non-operator MEMBER
18// (apistore.go's handleAPIStoreGrant): that 403 is safe to speak only because
19// apiAccountApp has already proven the caller belongs to the account it
20// named, so it teaches them only what THEY lack. rotate proves no membership
21// at all before answering, so a 403 here would confirm to any valid token
22// that this deployment runs a relay a caller might rotate — the same
23// reasoning apiSystemRoller's family already applies to `carlos system`.
24//
25// DELIVERY READS THE PER-ROUTE CONFIG-ACK. Spec §5 describes five delivery
26// states a route can be in; most of them come from a per-route config-ack
27// (`control/config-ack/<box>/<route host>.json`, internal/appconfig/
28// configack.go) config sync writes after it converges a route. There is no
29// per-host box list to narrow that read by (relayHeartbeats' own doc
30// comment says the same for control/relay-ack/<box>.json), so relayReply
31// reads every ack that exists (relayConfigAcks, relayops.go) and matches by
32// Host. An ack counts only when its RelaySuffixes equal exactly what the
33// record currently has issued — the same freshness rule relayCutoverFor's
34// maps.Equal already applies to a member's `carlos relay adopted`: an ack
35// that named an OLDER incarnation's identities must not read as "this route
36// runs what was just issued", so a stale ack falls back to "written,
37// awaiting delivery" rather than reporting an adoption that never happened
38// under the current keys.
39//
40// syncExecHost's ack (the socket-exec and tcp-exec-file shapes) always
41// leaves Restarted false and RestartError empty — its bounce is
42// best-effort and fire-and-forget, and a tcp-exec-file route's process is
43// never restarted by this platform at all — so "adopted at <time>,
44// restarted" is reachable only for a unit-backed route today; a
45// socket-exec route with a matching ack stays "written, awaiting delivery"
46// until its own restart signal exists to report.
47//
48// Spec: docs/superpowers/specs/2026-09-07-relay-stun-turn-design.md §2, §5.
49package console
50
51import (
52 "context"
53 "errors"
54 "fmt"
55 "log"
56 "maps"
57 "net/http"
58 "strings"
59 "time"
60
61 "github.com/carlosframework/platform/internal/appconfig"
62 "github.com/carlosframework/platform/internal/relay"
63)
64
65// relayTimeout bounds a single relay round trip: one GET status, one adopted
66// ack. The same 15s storeTimeout uses, for the same reason (apistore.go).
67const relayTimeout = 15 * time.Second
68
69// relayOpTimeout bounds enable and disable: each is relayops.go's three-step
70// walk (record, config, aggregate) rather than one object read, so it gets
71// storeGrantTimeout's ceiling rather than storeTimeout's.
72const relayOpTimeout = 60 * time.Second
73
74// relayRotateTimeout bounds rotate: the issuer pointer move plus a
75// re-issue of every app enabled in the region, one at a time.
76const relayRotateTimeout = 60 * time.Second
77
78// relayNoBucket is the 503 every relay door gives when this console has no
79// deployment bucket — storeNoBucket's exact reasoning (apistore.go): the
80// record IS a bucket object, and answering as if the relay were simply off
81// would be a lie a member could act on.
82func relayNoBucket(w http.ResponseWriter) {
83 jsonError(w, http.StatusServiceUnavailable, errRelayNoBucket.Error())
84}
85
86// relayDoorError turns one of relayops.go's errors into the door's answer.
87// The sentinels each name a configuration fact or a genuine 404; anything
88// else is logged (a relay error can carry bucket paths and request ids, the
89// same reasoning storeGrant's own %v log line gives) and answered with a
90// generic retry.
91//
92// A few of relayops.go's refusals are not sentinels — relayResolveRegions
93// and relayRotate build fmt.Errorf sentences for "no such region" and "the
94// staged master is not loaded yet" because they always name the specific
95// region or kid at fault, which a sentinel compared by value cannot do. They
96// are still the CALLER's mistake or something for the operator to fix by
97// re-running the runbook, not this console failing, so they are matched by
98// their own stable wording rather than left to read as a 502.
99func relayDoorError(w http.ResponseWriter, verb string, err error) {
100 switch {
101 case errors.Is(err, errRelayNoRegions), errors.Is(err, errRelayNoSealKey),
102 errors.Is(err, errRelayNotHeld), errors.Is(err, errRelayNoBucket):
103 jsonError(w, http.StatusServiceUnavailable, err.Error())
104 case errors.Is(err, errRelayUnknownHost), errors.Is(err, errRelayNotEnabled):
105 jsonError(w, http.StatusNotFound, err.Error())
106 case errors.Is(err, errRelayUndelivered), errors.Is(err, errRelayNotMemberRoute),
107 relayLooksLikeClientError(err):
108 jsonError(w, http.StatusBadRequest, err.Error())
109 default:
110 log.Printf("console: api relay %s: %v", verb, err)
111 jsonError(w, http.StatusBadGateway, "the relay could not complete that — try again")
112 }
113}
114
115// relayLooksLikeClientError matches the handful of relayResolveRegions' and
116// relayRotate's own sentences that name something the CALLER typed wrong or
117// the OPERATOR has not finished staging — never this console's own failure.
118func relayLooksLikeClientError(err error) bool {
119 msg := err.Error()
120 for _, s := range []string{
121 "runs no relay in", // relayResolveRegions, relayRotate: unarmed region named
122 "no new master is staged", // relayRotate: nothing new in CARLOS_RELAY_MASTERS_<REGION>
123 "has not loaded kid", // relayRotate: a box has not picked up the staged master yet
124 "has heartbeated in the last", // relayRotate: no live relay in the region at all
125 } {
126 if strings.Contains(msg, s) {
127 return true
128 }
129 }
130 return false
131}
132
133// ---- the reply shape ---------------------------------------------------------
134
135// apiRelayRoute is one of an app's routes as `carlos relay status` reports
136// it: which of spec §5's delivery shapes it is, and what the console can say
137// about whether its key has arrived.
138type apiRelayRoute struct {
139 Host string `json:"host"`
140 // One of the relayShape* constants below: the first three are spec §5's
141 // delivery shapes, "no-delivery" is a route a key can never reach, and
142 // "unknown" is the fail-closed answer for a route this console cannot
143 // describe.
144 Shape string `json:"shape"`
145 Delivery string `json:"delivery"`
146 // AckedAt and Cutover are both empty/false until a member has run
147 // `carlos relay adopted --host` for this route (relay.RouteCutover).
148 AckedAt string `json:"acked_at,omitempty"`
149 Cutover bool `json:"cutover"`
150 // ConfigAckAt and ConfigAckShape echo config sync's own per-route
151 // acknowledgement (control/config-ack/<box>/<route host>.json) when one
152 // exists naming exactly the identities this record currently has
153 // issued — empty until then, and left empty for a stale ack (one
154 // naming an older incarnation's identities) exactly like AckedAt above.
155 // Distinct from AckedAt/Cutover: those describe a MEMBER's `carlos
156 // relay adopted`, this describes what the PLATFORM'S OWN config sync
157 // last rendered and, where it could, restarted.
158 ConfigAckAt string `json:"config_acked_at,omitempty"`
159 ConfigAckShape string `json:"config_ack_shape,omitempty"`
160}
161
162// apiRelayRegion is one region's health as this app's status sees it, built
163// from a single armed relay's own heartbeat (control/relay-ack/<box>.json).
164// A region with several relays gets one row per box — Box is the reader's
165// only way to tell them apart, since Region alone is not unique across them.
166type apiRelayRegion struct {
167 Region string `json:"region"`
168 Box string `json:"box"`
169 HeartbeatAge string `json:"heartbeat_age"` // "never", or a Go duration string
170 Current bool `json:"current"` // this box's authority copy is the latest published
171 LeaseLive bool `json:"lease_live"` // relay.Lease still armed to serve (15-minute ceiling)
172 Listeners []string `json:"listeners"`
173 Kids []string `json:"kids"`
174 // Retiring is kid -> RFC3339 retire_at for the masters this box loaded
175 // that carry one. It answers the operator's mid-rotation question —
176 // when may the old key leave /etc/carlos/relay-masters — from the CLI,
177 // per box, which is the only place the answer is true: a region is only
178 // done with a key once EVERY relay there has stopped loading it
179 // (discussion #120).
180 Retiring map[string]string `json:"retiring,omitempty"`
181 // Stale mirrors relayRotate's own gate: a heartbeat whose last successful
182 // authority read is older than relayHeartbeatFresh (five minutes) cannot
183 // be read as current, so a dead relay cannot look alive by silence.
184 Stale bool `json:"stale"`
185 // The relay's own last two errors, verbatim (relay.Heartbeat): the
186 // masters file it could not reload, and the authority record it could not
187 // read. Either one is the REASON a row is stale, and printing the state
188 // without the reason leaves a member with nothing to act on.
189 MastersError string `json:"masters_error,omitempty"`
190 AuthorityError string `json:"authority_error,omitempty"`
191}
192
193// apiRelayReply is the one answer shape enable, status, disable and adopted
194// all give — storeReply's pattern (apistore.go): describing the SAME record
195// from four different doors in one function is what keeps them from
196// describing it differently. rotate is not app-scoped and does not produce
197// one of these at all; see apiRelayRotateReply below.
198type apiRelayReply struct {
199 App string `json:"app"`
200 Enabled bool `json:"enabled"`
201 Incarnation string `json:"incarnation,omitempty"`
202 Regions []string `json:"regions,omitempty"`
203 Op int64 `json:"op"`
204 Phase string `json:"phase,omitempty"`
205 // Issued is the username suffix the console last issued per region —
206 // relay.Record.IssuedSuffixes, echoed verbatim. Not a secret: it is the
207 // same account/app/incarnation/kid identity §2's CARLOS_RELAY carries,
208 // never the key.
209 Issued map[string]string `json:"issued,omitempty"`
210 Routes []apiRelayRoute `json:"routes,omitempty"`
211 RegionStatus []apiRelayRegion `json:"region_status,omitempty"`
212 // DisablePending names the regions where the aggregate publish that
213 // removed this app has not yet been confirmed by every armed relay's own
214 // heartbeat (spec §5, "when a disable is complete") — present only while
215 // the record is disabled and at least one region has not caught up.
216 DisablePending []string `json:"disable_pending,omitempty"`
217 // There is deliberately NO retire-no-earlier-than here. That time is a
218 // property of a ROTATION, not of an app: relayRotate computes it from the
219 // moment the issuer pointer moved and hands it to the operator who moved
220 // it (apiRelayRotateReply), and nothing persists it. Reporting a per-region
221 // retirement date on an app's status would mean either inventing state the
222 // console does not keep or recomputing it from a clock, which would tell a
223 // member a retirement deadline that drifts every time they ask.
224 //
225 // apiRelayRegion.Retiring is NOT that value and is not a softening of this
226 // rule (discussion #120). It is the retire_at a relay actually loaded out
227 // of its own masters file, reported per box and echoed verbatim: state the
228 // operator wrote, not state this console computed, so it cannot drift
229 // between two reads.
230}
231
232// apiRelayRotateReply is what `carlos relay rotate` answers with — a region
233// and a kid, not an app's record, so it does not try to fit apiRelayReply's
234// shape.
235type apiRelayRotateReply struct {
236 Region string `json:"region"`
237 Kid string `json:"kid"`
238 Apps int `json:"apps"`
239 RetireNoEarlierThan string `json:"retire_no_earlier_than"` // RFC3339
240}
241
242// The five shapes `carlos relay status` reports a route as. The first three
243// are spec §5's delivery shapes; relayShapeNone is a route the config sync
244// can never reach at all, and relayShapeUnknown is the fail-closed answer for
245// a route that matches none of them.
246const (
247 relayShapeUnit = "unit"
248 relayShapeSocket = "socket-exec"
249 relayShapeFile = "tcp-exec-file"
250 relayShapeNone = "no-delivery"
251 relayShapeUnknown = "unknown"
252)
253
254// relayRouteShape names which shape a route is, in relayDeliverable's OWN
255// branch order (relayops.go), so that "what status calls this route" and
256// "what enable refused, or did not refuse, about it" can never disagree:
257//
258// 1. a named UNIT is a unit whatever else the row carries — the config sync
259// restarts it, so the key arrives without anyone being asked;
260// 2. a unix SOCKET is a socket-exec, restarted the same way;
261// 3. an `instance` route (Kind "" reads as instance) is the platform's OWN,
262// restarted by the config sync even though its record also carries the
263// TCP address the router dials. This branch sits ABOVE the address one
264// deliberately: reading that address as a foreign process would tell a
265// member to run `carlos relay adopted` for a route they must not adopt;
266// 4. only what is left — a TCP address belonging to a process this platform
267// never spawned — is the shape a member has to restart and acknowledge.
268//
269// Whether that last shape can receive the file at all is a SEPARATE question
270// this function cannot answer (it needs the route's config bundle):
271// relayReply asks relayNoDelivery and downgrades the row to relayShapeNone.
272//
273// The default is relayShapeUnknown, not "unit". A route with no unit, no
274// socket, no address and a kind this code does not recognise is something
275// this console cannot describe, and calling it a unit would promise a
276// restart-on-write that nothing is going to perform.
277func relayRouteShape(rt relayRoute) string {
278 switch {
279 case rt.Unit != "":
280 return relayShapeUnit
281 case rt.Socket != "":
282 return relayShapeSocket
283 case rt.Kind == "" || rt.Kind == "instance":
284 return relayShapeUnit
285 case rt.Addr != "":
286 return relayShapeFile
287 default:
288 return relayShapeUnknown
289 }
290}
291
292// relayCutoverFor finds host's cutover in rec, nil if a member has never
293// acknowledged it.
294func relayCutoverFor(rec *relay.Record, host string) *relay.RouteCutover {
295 for i := range rec.Cutovers {
296 if rec.Cutovers[i].Host == host {
297 return &rec.Cutovers[i]
298 }
299 }
300 return nil
301}
302
303// relayMatchingAck finds the freshest config-ack among acks naming host
304// whose RelaySuffixes equal issued exactly — nil if none does. The equality
305// check is the same freshness rule relayCutoverFor's caller applies to a
306// member's Cutover (maps.Equal against rec.IssuedSuffixes): an ack written
307// before a rotation names the identities that rotation retired, and
308// reporting it as current would tell a member their route runs a key it
309// has never actually seen. "Freshest" (latest At) only matters if more than
310// one box's ack somehow matches, which config sync's own per-(box,host) key
311// makes rare in practice but not impossible across a route migration.
312//
313// phase and issued are checked FIRST, and deliberately: maps.Equal(nil,
314// nil) is true, so without this guard an enabled record still at phase
315// "config" — IssuedSuffixes not written yet, a state an interrupted enable
316// can persist in indefinitely — would match ANY route's ordinary pre-relay
317// ack (RelaySuffixes nil, no CARLOS_RELAY entry at all) and print an
318// adoption for identities the console has never actually issued. Only a
319// record that finished an operation (phase done) with something to show
320// for it (len(issued) > 0) can have an ack legitimately match it at all
321// (review finding 2026-09-07).
322func relayMatchingAck(acks []appconfig.ConfigAck, host string, issued map[string]string, phase string) *appconfig.ConfigAck {
323 if phase != relayPhaseDone || len(issued) == 0 {
324 return nil
325 }
326 var best *appconfig.ConfigAck
327 for i := range acks {
328 a := &acks[i]
329 if a.Host != host || !maps.Equal(a.RelaySuffixes, issued) {
330 continue
331 }
332 if best == nil || a.At.After(best.At) {
333 best = a
334 }
335 }
336 return best
337}
338
339// relayReply assembles apiRelayReply from a record that has already been
340// read or just been written. rec is never nil here — the callers that could
341// see one (handleAPIRelayGet) answer their own 404 first.
342func (s *Server) relayReply(ctx context.Context, acct *Account, app string, rec *relay.Record) (apiRelayReply, error) {
343 out := apiRelayReply{
344 App: app, Enabled: rec.Enabled, Incarnation: rec.Incarnation,
345 Regions: rec.Regions, Op: rec.Op, Phase: rec.Phase, Issued: rec.IssuedSuffixes,
346 }
347
348 routes, err := s.relayAppRoutes(ctx, acct.Sqid, app)
349 if err != nil {
350 return apiRelayReply{}, err
351 }
352 // Read once for the whole reply, not once per route — and skipped
353 // entirely when there is nothing for an ack to say anything about: a
354 // disabled record has no key delivered (relayReply's own switch below
355 // never reads an ack for it), and no routes means no hosts to match.
356 // relayConfigAcks itself narrows the underlying List to just THIS
357 // app's route hosts before issuing any Get (review finding
358 // 2026-09-07: this used to cost a Get per ack in the whole fleet on
359 // every status call).
360 var acks []appconfig.ConfigAck
361 if rec.Enabled && len(routes) > 0 {
362 hosts := make(map[string]bool, len(routes))
363 for _, rt := range routes {
364 hosts[rt.Host] = true
365 }
366 acks, err = s.relayConfigAcks(ctx, hosts)
367 if err != nil {
368 return apiRelayReply{}, err
369 }
370 }
371 for _, rt := range routes {
372 if rt.Kind == "static" {
373 // A static route runs no process at all — relayDeliverable
374 // (relayops.go) ignores it for the same reason: there is nothing
375 // here for a relay key to ever reach.
376 continue
377 }
378 shape := relayRouteShape(rt)
379 // A route can be ADDED after an enable, and nothing re-runs enable's
380 // refusal when it is. Asked here, every status answer names such a
381 // route instead of reporting it as fine — which is the whole point of
382 // spec §5's "a route added without delivery after enable shows 'no
383 // config delivery'": it blocks the next retirement, and the member is
384 // the only one who can fix it.
385 if shape == relayShapeFile {
386 none, err := s.relayNoDelivery(ctx, acct.Sqid, app, rt)
387 if err != nil {
388 return apiRelayReply{}, err
389 }
390 if none {
391 shape = relayShapeNone
392 }
393 }
394 row := apiRelayRoute{Host: rt.Host, Shape: shape}
395 switch {
396 case !rec.Enabled:
397 // A disabled record has no key to deliver: relayDisable removed
398 // the variable from the bundle. "Written, awaiting delivery"
399 // would name a write that has been UNDONE, and a member reading
400 // it would go looking for a key that is on its way out.
401 row.Delivery = "the relay key was removed from this app's config"
402 case shape == relayShapeNone:
403 row.Delivery = "no config delivery: relay will not reach this route; " +
404 "opt it into exec_delivery file or move it to a unit"
405 default:
406 row.Delivery = "written, awaiting delivery"
407 // A config-ack counts only when it names EXACTLY the identities
408 // this record has issued now (relayMatchingAck) — the same
409 // freshness rule the member-Cutover comparison just below makes
410 // for `carlos relay adopted`. No match at all (never synced yet,
411 // or a stale ack left over from before a rotation) leaves the
412 // route at the base "written, awaiting delivery" rather than
413 // reporting an adoption under keys the route may never have run.
414 if ca := relayMatchingAck(acks, rt.Host, rec.IssuedSuffixes, rec.Phase); ca != nil {
415 row.ConfigAckAt = ca.At.UTC().Format(time.RFC3339)
416 row.ConfigAckShape = ca.Shape
417 switch {
418 case shape == relayShapeFile:
419 // The ONE shape whose process this platform cannot
420 // bounce: the ack proves the FILE landed, but adoption
421 // itself is still the member's move (below).
422 row.Delivery = "file delivered, awaiting `carlos relay adopted --host " + rt.Host + "` after you restart it"
423 case ca.RestartError != "":
424 row.Delivery = "restart failed: " + ca.RestartError
425 case ca.Hibernating:
426 row.Delivery = "hibernating, delivered on wake"
427 case ca.Restarted:
428 row.Delivery = "adopted at " + row.ConfigAckAt + ", restarted"
429 }
430 }
431 if cut := relayCutoverFor(rec, rt.Host); cut != nil {
432 row.AckedAt = cut.At.UTC().Format(time.RFC3339)
433 // An ack counts only for the identities it NAMED. relayAdopted
434 // stamps the record's issued suffixes into the cutover for
435 // exactly this comparison: a rotation since then re-issued the
436 // app under a new kid, and reporting the older acknowledgement
437 // as current would say a route runs a key it has never seen.
438 if maps.Equal(cut.Suffixes, rec.IssuedSuffixes) {
439 row.Cutover = true
440 row.Delivery = "adopted by member at " + row.AckedAt
441 } else {
442 row.Delivery = "acknowledged at " + row.AckedAt +
443 ", but the identities have changed since — re-run `carlos relay adopted --host " + rt.Host + "`"
444 }
445 }
446 }
447 out.Routes = append(out.Routes, row)
448 }
449
450 hbs, err := s.relayHeartbeats(ctx)
451 if err != nil {
452 return apiRelayReply{}, err
453 }
454 auth, _, err := relay.ReadAuthority(ctx, s.cfg.Releases)
455 if err != nil {
456 return apiRelayReply{}, err
457 }
458 now := s.relayTime()
459 for _, region := range rec.Regions {
460 for _, hb := range hbs {
461 if hb.Region != region {
462 continue
463 }
464 row := apiRelayRegion{
465 Region: region, Box: hb.Box, Listeners: hb.Listeners, Kids: hb.Kids,
466 LeaseLive: !hb.ReadAt.IsZero() && now.Sub(hb.ReadAt) <= relay.LeaseMaxAge,
467 Stale: hb.ReadAt.IsZero() || now.Sub(hb.ReadAt) > relayHeartbeatFresh,
468 Current: auth != nil && !hb.WrittenAt.IsZero() && !hb.WrittenAt.Before(auth.WrittenAt),
469 // The two "why has this relay stopped moving" fields, carried
470 // straight through. A stale row without them tells a member
471 // their calls will fail and nothing about who fixes it: a
472 // masters error is the operator's staging, an authority error
473 // is this console's bucket.
474 MastersError: hb.MastersError,
475 AuthorityError: hb.AuthorityError,
476 }
477 for kid, at := range hb.Retiring {
478 if row.Retiring == nil {
479 row.Retiring = map[string]string{}
480 }
481 row.Retiring[kid] = at.UTC().Format(time.RFC3339)
482 }
483 if hb.ReadAt.IsZero() {
484 row.HeartbeatAge = "never"
485 } else {
486 row.HeartbeatAge = now.Sub(hb.ReadAt).Round(time.Second).String()
487 }
488 out.RegionStatus = append(out.RegionStatus, row)
489 }
490 }
491
492 // "When a disable is complete" (spec §5): the app is out of a region's
493 // authority once the aggregate has been published without it AND that
494 // region's own armed relay has heartbeated an authority record written at
495 // or after that publish. The published record's OWN written_at is the
496 // timestamp to compare against — the same one the Current field above
497 // uses — because it is the object the relays actually read. rec.DisabledAt
498 // is only this console's note of when it started the walk, and it stands
499 // in when the aggregate has never been published at all (in which case no
500 // relay can possibly have caught up, so every region is pending).
501 if !rec.Enabled && !rec.DisabledAt.IsZero() {
502 publishedAt := rec.DisabledAt
503 if auth != nil && !auth.WrittenAt.IsZero() {
504 publishedAt = auth.WrittenAt
505 }
506 for _, region := range rec.Regions {
507 revoked := false
508 for _, hb := range hbs {
509 if hb.Region == region && !hb.WrittenAt.IsZero() && !hb.WrittenAt.Before(publishedAt) {
510 revoked = true
511 break
512 }
513 }
514 if !revoked {
515 out.DisablePending = append(out.DisablePending, region)
516 }
517 }
518 }
519 return out, nil
520}
521
522// ---- the app-scoped doors: enable, status, disable, adopted -----------------
523
524// handleAPIRelayEnable answers `carlos relay enable`. The doors, in order,
525// mirror handleAPIStoreCreate's (apistore.go): token 401 (cliRoute), then
526// apiAccountApp's byte-identical 404, then Releases nil (503), the write
527// budget, and only then the body — a malformed request from a stranger must
528// not distinguish itself from one that was never a member to begin with.
529func (s *Server) handleAPIRelayEnable(w http.ResponseWriter, r *http.Request) {
530 addr, acct, app, ok := s.apiAccountApp(w, r)
531 if !ok {
532 return
533 }
534 if s.refuseTemporary(w, acct, app, "serve anything but a static site at "+app+"'s own platform address") {
535 return
536 }
537 if s.cfg.Releases == nil {
538 relayNoBucket(w)
539 return
540 }
541 p, _ := principalFrom(r.Context())
542 if !s.allowWrite(p, time.Now()) {
543 jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
544 return
545 }
546 var req struct {
547 Regions []string `json:"regions,omitempty"`
548 }
549 if !readJSON(w, r, &req) {
550 return
551 }
552 ctx, cancel := context.WithTimeout(r.Context(), relayOpTimeout)
553 defer cancel()
554 rec, err := s.relayEnable(ctx, acct, app, req.Regions, addr)
555 if err != nil {
556 relayDoorError(w, "enable", err)
557 return
558 }
559 s.audit(acct, app, addr, "relay-enable",
560 "enabled the relay in "+strings.Join(rec.Regions, ",")+" (cli)")
561 out, err := s.relayReply(ctx, acct, app, rec)
562 if err != nil {
563 log.Printf("console: api relay reply %s/%s: %v", acct.Sqid, app, err)
564 jsonError(w, http.StatusBadGateway,
565 "the relay was enabled, but its status could not be read — try `carlos relay status`")
566 return
567 }
568 writeJSON(w, http.StatusOK, out)
569}
570
571// handleAPIRelayGet answers `carlos relay status` — the one sight a member
572// with no box access has of delivery per route and health per region,
573// handleAPIStoreGet's exact reasoning (apistore.go): the 404 here speaks only
574// after apiAccountApp has already proven membership, so it teaches a
575// stranger nothing. Not rate limited, matching every other read door.
576func (s *Server) handleAPIRelayGet(w http.ResponseWriter, r *http.Request) {
577 _, acct, app, ok := s.apiAccountApp(w, r)
578 if !ok {
579 return
580 }
581 if s.cfg.Releases == nil {
582 relayNoBucket(w)
583 return
584 }
585 ctx, cancel := context.WithTimeout(r.Context(), relayTimeout)
586 defer cancel()
587 rec, _, err := relay.GetRecord(ctx, s.cfg.Releases, acct.Sqid, app)
588 if err != nil {
589 log.Printf("console: api relay status %s/%s: %v", acct.Sqid, app, err)
590 jsonError(w, http.StatusBadGateway, "could not read the relay record — try again")
591 return
592 }
593 if rec == nil {
594 // A state, not a failure — objstore's same choice (handleAPIStoreGet):
595 // "never enabled" and "enabled with nothing filled in" would
596 // otherwise be indistinguishable to the CLI.
597 jsonError(w, http.StatusNotFound, "the relay has never been enabled for this app — run `carlos relay enable`")
598 return
599 }
600 out, err := s.relayReply(ctx, acct, app, rec)
601 if err != nil {
602 log.Printf("console: api relay delivery %s/%s: %v", acct.Sqid, app, err)
603 jsonError(w, http.StatusBadGateway, "could not read the relay's delivery status — try again")
604 return
605 }
606 writeJSON(w, http.StatusOK, out)
607}
608
609// handleAPIRelayDisable answers `carlos relay disable`.
610func (s *Server) handleAPIRelayDisable(w http.ResponseWriter, r *http.Request) {
611 addr, acct, app, ok := s.apiAccountApp(w, r)
612 if !ok {
613 return
614 }
615 if s.cfg.Releases == nil {
616 relayNoBucket(w)
617 return
618 }
619 p, _ := principalFrom(r.Context())
620 if !s.allowWrite(p, time.Now()) {
621 jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
622 return
623 }
624 ctx, cancel := context.WithTimeout(r.Context(), relayOpTimeout)
625 defer cancel()
626 rec, err := s.relayDisable(ctx, acct, app, addr)
627 if err != nil {
628 relayDoorError(w, "disable", err)
629 return
630 }
631 s.audit(acct, app, addr, "relay-disable", "disabled the relay (cli)")
632 out, err := s.relayReply(ctx, acct, app, rec)
633 if err != nil {
634 log.Printf("console: api relay reply %s/%s: %v", acct.Sqid, app, err)
635 jsonError(w, http.StatusBadGateway,
636 "the relay was disabled, but its status could not be read — try `carlos relay status`")
637 return
638 }
639 writeJSON(w, http.StatusOK, out)
640}
641
642// handleAPIRelayAdopted answers `carlos relay adopted --host <route>`: a
643// member telling the platform that a TCP-address route they restarted by
644// hand now runs the identities the console last issued (spec §5, step 5).
645func (s *Server) handleAPIRelayAdopted(w http.ResponseWriter, r *http.Request) {
646 addr, acct, app, ok := s.apiAccountApp(w, r)
647 if !ok {
648 return
649 }
650 if s.cfg.Releases == nil {
651 relayNoBucket(w)
652 return
653 }
654 p, _ := principalFrom(r.Context())
655 if !s.allowWrite(p, time.Now()) {
656 jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
657 return
658 }
659 var req struct {
660 Host string `json:"host"`
661 }
662 if !readJSON(w, r, &req) {
663 return
664 }
665 host := strings.TrimSpace(req.Host)
666 if host == "" {
667 jsonError(w, http.StatusBadRequest, "host is required")
668 return
669 }
670 ctx, cancel := context.WithTimeout(r.Context(), relayTimeout)
671 defer cancel()
672 rec, err := s.relayAdopted(ctx, acct, app, host, addr)
673 if err != nil {
674 relayDoorError(w, "adopted", err)
675 return
676 }
677 s.audit(acct, app, addr, "relay-adopted", "acknowledged the relay key on "+host+" (cli)")
678 out, err := s.relayReply(ctx, acct, app, rec)
679 if err != nil {
680 log.Printf("console: api relay reply %s/%s: %v", acct.Sqid, app, err)
681 jsonError(w, http.StatusBadGateway,
682 "the acknowledgement was recorded, but status could not be read — try `carlos relay status`")
683 return
684 }
685 writeJSON(w, http.StatusOK, out)
686}
687
688// ---- the operator door: rotate -----------------------------------------------
689
690// apiRelayOperator is rotate's whole gate. RelayRegions/RelayOperators name a
691// deployment-wide authority (RelayOperators' own doc comment, server.go) with
692// no account or app to check membership against — POST /api/cli/relay/rotate
693// carries neither segment, the same shape `carlos system`'s doors answer for
694// (apiSystemRoller, apisystemupdate.go; apiSystemOperator, apisystem.go).
695//
696// Unlike those two — and unlike the store's grant door, which gives a
697// member-but-not-operator caller a 403 (apistore.go's storeOperator) — a
698// failed check here is a byte-identical 404, because there is no membership
699// proof for a 403 to be safe AFTER: this door proves nothing about the
700// caller before answering, so a 403 naming CARLOS_RELAY_OPERATORS would tell
701// any valid token holder that this deployment runs a relay at all. A
702// non-person principal (a service credential, or a request that never
703// reached cliRoute) gets the identical answer, never a distinguishing one.
704func (s *Server) apiRelayOperator(w http.ResponseWriter, r *http.Request) (string, bool) {
705 p, ok := principalFrom(r.Context())
706 if !ok || p.Kind != PrincipalUser || !s.relayOperator(p.Address) {
707 http.NotFound(w, r)
708 return "", false
709 }
710 return p.Address, true
711}
712
713// relayOperator reports whether addr may drive `carlos relay rotate` —
714// storeOperator's exact-match rule, verbatim (apistore.go's doc comment
715// explains why EqualFold is wrong for a gate like this one: the ſ homoglyph
716// folds to a lowercase "s" that already survived every ToLower on the way
717// in). Its own list rather than StoreOperators', for RelayOperators' own
718// reason (server.go): rotating a region's master is authority over the
719// deployment's key material, not over spending its cloud bill, and a
720// deployment may grant the two to different people.
721func (s *Server) relayOperator(addr string) bool {
722 for _, op := range s.cfg.RelayOperators {
723 if strings.ToLower(strings.TrimSpace(op)) == addr {
724 return true
725 }
726 }
727 return false
728}
729
730// handleAPIRelayRotate answers `carlos relay rotate --region <r>` (spec §5,
731// step 4): move the region's issuer pointer to the master the operator
732// staged, and re-issue every app enabled there. Not rate limited on the
733// address the way the member doors are — apiRelayOperator's own 404 already
734// bounds who reaches this far, and a rotation the caller is entitled to
735// start must not be delayed by a shared write budget spent by other doors.
736func (s *Server) handleAPIRelayRotate(w http.ResponseWriter, r *http.Request) {
737 addr, ok := s.apiRelayOperator(w, r)
738 if !ok {
739 return
740 }
741 if s.cfg.Releases == nil {
742 relayNoBucket(w)
743 return
744 }
745 var req struct {
746 Region string `json:"region"`
747 }
748 if !readJSON(w, r, &req) {
749 return
750 }
751 region := strings.TrimSpace(req.Region)
752 if region == "" {
753 jsonError(w, http.StatusBadRequest, "region is required")
754 return
755 }
756 ctx, cancel := context.WithTimeout(r.Context(), relayRotateTimeout)
757 defer cancel()
758 res, err := s.relayRotate(ctx, region)
759 if err != nil {
760 relayDoorError(w, "rotate", err)
761 return
762 }
763 s.auditSystem(addr, "relay-rotate",
764 fmt.Sprintf("rotated %s to kid %s (%d app(s) re-issued, cli)", res.Region, res.Kid, res.Apps))
765 writeJSON(w, http.StatusOK, apiRelayRotateReply{
766 Region: res.Region, Kid: res.Kid, Apps: res.Apps,
767 RetireNoEarlierThan: res.RetireNoEarlierThan.UTC().Format(time.RFC3339),
768 })
769}
770