| 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. |
| 49 | package console |
| 50 | |
| 51 | import ( |
| 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). |
| 67 | const 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. |
| 72 | const 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. |
| 76 | const 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. |
| 82 | func 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. |
| 99 | func 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. |
| 118 | func 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. |
| 138 | type 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. |
| 166 | type 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. |
| 198 | type 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. |
| 235 | type 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. |
| 246 | const ( |
| 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. |
| 277 | func 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. |
| 294 | func 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). |
| 322 | func 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. |
| 342 | func (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. |
| 529 | func (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. |
| 576 | func (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`. |
| 610 | func (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). |
| 645 | func (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. |
| 704 | func (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. |
| 721 | func (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. |
| 736 | func (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 | |