// Customer fleets (2026-08-05-customer-fleets-design.md): the console-API // door onto internal/registry's account-owned Fleet/FleetBox rows and onto // an app's placement — where its routes (and its already-provisioned // instances) should run. Every route below is bearer-authenticated and // account-scoped, the same apiAccount/apiAccountApp resolution the rest of // the CLI API uses, for the same reason: an unknown account, someone else's // account, and — new here — someone else's FLEET must be one indistinguishable // 404, because fleet names live in one flat, cross-account namespace // (fleets.name is the registry's primary key, not scoped per account), so a // caller's own valid {account} segment could otherwise be used to probe // which fleet names exist elsewhere. // // ONE HONEST EXCEPTION to that closed-probing posture: POST .../new's 409 // ("that name is taken") is unavoidably a cross-account existence oracle. // Fleet names are that same flat, global namespace, and CreateFleet's // insert-only uniqueness check (registry/fleets.go) has to answer "already // exists" the same way whether the existing owner is you or a stranger — // there is no way to make "is this name free" account-scoped when the // thing being asked about is a single shared channel-identity namespace. No // other door in this file leaks anything through it: the 409 says a name is // taken, never by whom. package console import ( "context" "crypto/rand" "crypto/subtle" "encoding/hex" "errors" "log" "net/http" "net/url" "os" "regexp" "sort" "strings" "time" "github.com/carlosframework/platform/internal/hostops" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/status" ) // fleetBoxView and fleetView are the wire shapes for GET .../fleets and the // "fleet" field POST .../new returns — deliberately NOT registry.Fleet / // registry.FleetBox: those carry TokenHash, which this door must never echo // back (the plaintext is shown once, at mint/rotate, and nowhere else). type fleetBoxView struct { Label string `json:"label"` Status string `json:"status"` // Tags is additive (2026-08-10, Task 2): omitted entirely for an // untagged box, never a bare "" — existing callers parsing this shape // before tags existed see nothing new unless a box actually carries some. Tags string `json:"tags,omitempty"` } type fleetView struct { Name string `json:"name"` Bucket string `json:"bucket"` Boxes []fleetBoxView `json:"boxes"` } // apiFleet resolves and authorizes one {account}/{fleet} CLI API request: // the account half is apiAccount's own resolution (bearer -> address -> // caller's own memberships), and the fleet half is GetFleet — with a fleet // that does not exist and a fleet owned by a DIFFERENT account answering the // exact same 404, mirroring apiAccountAppBy's "unknown and not-yours are one // answer" rule for apps. It also hands back the registry handle it opened, // since every caller needs one for whatever it does next. func (s *Server) apiFleet(w http.ResponseWriter, r *http.Request) (addr string, acct *Account, fleet *registry.Fleet, reg consoleRegistry, ok bool) { addr, acct, ok = s.apiAccount(w, r) if !ok { return "", nil, nil, nil, false } reg, err := openRegistry(s.cfg.Registry) if err != nil { log.Printf("console: api fleet: open registry: %v", err) jsonError(w, http.StatusInternalServerError, "could not reach the fleet registry") return "", nil, nil, nil, false } name := r.PathValue("fleet") f, err := reg.GetFleet(name) if err != nil { log.Printf("console: api fleet %s/%s: %v", acct.Sqid, name, err) jsonError(w, http.StatusInternalServerError, "could not read the fleet") return "", nil, nil, nil, false } if f == nil || f.Account != acct.Sqid { http.NotFound(w, r) return "", nil, nil, nil, false } return addr, acct, f, reg, true } // findFleetBox looks up one box by label within a fleet already proven to // exist and be owned by the caller — a plain scan of ListFleetBoxes rather // than a new registry method, since this door is the only caller and a // fleet's box count is small. (nil, nil) means no such label; that answers // its own refusal (404, "no such box") rather than the fleet-ownership 404 // above, because within an ALREADY-OWNED fleet a bad label is an ordinary // not-found, not a cross-tenant probe. func findFleetBox(reg consoleRegistry, fleet, label string) (*registry.FleetBox, error) { boxes, err := reg.ListFleetBoxes(fleet) if err != nil { return nil, err } for i := range boxes { if boxes[i].Label == label { return &boxes[i], nil } } return nil, nil } // mintFleetToken mints one bearer credential the way every token-bearing // door in this console does: hex(32 bytes of crypto/rand) as the plaintext, // sha256 hex of THAT STRING (not the raw bytes) as what gets stored — the // exact convention hashToken already applies to CLI/app tokens, and the one // internal/edge's tunnel listener assumes when it hashes a presented bearer // token to check against FleetChannelAuth's table (tunnel.go). Reusing // hashToken here, rather than hashing the raw bytes directly, is what keeps // mint and verify agreeing on what "the token" means. func mintFleetToken() (plaintext, hash string, err error) { var raw [32]byte if _, err := rand.Read(raw[:]); err != nil { return "", "", err } plaintext = hex.EncodeToString(raw[:]) return plaintext, hashToken(plaintext), nil } // bucketNameRe is the S3 bucket-name shape: 3-63 characters, lowercase // letters/digits/dot/hyphen, starting and ending with a letter or digit. // (The real S3 rules also forbid a few extra things — adjacent dots, // IP-address-shaped names — that this simplified check does not chase; // those still fail cleanly when the customer's tofu tries to use the // bucket, which is a world away from THIS value's actual exposure: it ends // up verbatim in a fleet box's env file, on hardware this console does not // control, so a value that is not even bucket-name-shaped must never reach // that far only to fail somewhere with no error message at all.) var bucketNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$`) // handleAPIFleetNew answers `carlos fleets create`: mints the account-owned // fleet record. The data bucket is taken on trust that it already exists // (customer tofu, spec §"Surfaces") — this door only checks its NAME is // bucket-shaped and records it. // // CreateFleet (registry/fleets.go) is insert-only — ON CONFLICT DO NOTHING, // RowsAffected is the atomic "did I win" answer — so there is no // read-then-write window here for a second account to silently take over a // name a first caller just created (an earlier revision of this handler did // a GetFleet pre-check before UpsertFleet, which had exactly that race; // CreateFleet replaces both calls with the one atomic one). func (s *Server) handleAPIFleetNew(w http.ResponseWriter, r *http.Request) { addr, acct, ok := s.apiAccount(w, r) if !ok { return } p, _ := principalFrom(r.Context()) // The fleets namespace is flat and global with no delete door (see this // file's package doc comment) — the same "a claimed name is a permanent // row, not a pointer the next call overwrites" shape apps' create budget // exists for, so this spends the SAME scarce per-day budget // (handleAPICreateApp), not the ordinary per-minute write budget every // other fleet door here uses. Every fleets door is registered with the // zero role Set, so only a person ever reaches here — allowCreate is // used anyway to keep one idiom. if !s.allowCreate(p, time.Now()) { jsonError(w, http.StatusTooManyRequests, "fleet creation limit reached for today — try again tomorrow") return } // Plans 3a: fleets need a plan that includes them. if err := s.checkFleets(acct); err != nil { if isPlanCap(err) { writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error(), "code": "cap"}) return } log.Printf("console: fleet create %s: plan: %v", acct.Sqid, err) jsonError(w, http.StatusBadGateway, planCheckFailedCopy) return } var req struct { Name string `json:"name"` Bucket string `json:"bucket"` } if !readJSON(w, r, &req) { return } name := strings.TrimSpace(req.Name) if !registry.ValidTunnelName(name) { jsonError(w, http.StatusBadRequest, "invalid fleet name — 1-32 characters of a-z, 0-9 and hyphen") return } bucket := strings.TrimSpace(req.Bucket) if !bucketNameRe.MatchString(bucket) { jsonError(w, http.StatusBadRequest, "invalid bucket name — 3-63 characters of lowercase letters, digits, dot and hyphen, "+ "starting and ending with a letter or digit (S3 bucket-name shape)") return } reg, err := openRegistry(s.cfg.Registry) if err != nil { log.Printf("console: fleet new %s/%s: open registry: %v", acct.Sqid, name, err) jsonError(w, http.StatusInternalServerError, "could not reach the fleet registry") return } created, err := reg.CreateFleet(registry.Fleet{Name: name, Account: acct.Sqid, Bucket: bucket}) if err != nil { log.Printf("console: fleet new %s/%s: %v", acct.Sqid, name, err) jsonError(w, http.StatusInternalServerError, "could not create the fleet — try again") return } if !created { jsonError(w, http.StatusConflict, "that name is taken") return } s.audit(acct, name, addr, "fleet-create", "created fleet "+name+" (cli)") writeJSON(w, http.StatusOK, map[string]any{ "fleet": fleetView{Name: name, Bucket: bucket, Boxes: []fleetBoxView{}}, }) } // handleAPIFleetList answers `carlos fleets ls`: every fleet the caller's // account owns, each with its boxes' label and status — never a token hash. func (s *Server) handleAPIFleetList(w http.ResponseWriter, r *http.Request) { _, acct, ok := s.apiAccount(w, r) if !ok { return } reg, err := openRegistry(s.cfg.Registry) if err != nil { log.Printf("console: fleet list %s: open registry: %v", acct.Sqid, err) jsonError(w, http.StatusInternalServerError, "could not reach the fleet registry") return } all, err := reg.ListFleets() if err != nil { log.Printf("console: fleet list %s: %v", acct.Sqid, err) jsonError(w, http.StatusInternalServerError, "could not list fleets") return } out := make([]fleetView, 0) for _, f := range all { if f.Account != acct.Sqid { continue } boxes, err := reg.ListFleetBoxes(f.Name) if err != nil { log.Printf("console: fleet list %s: boxes of %s: %v", acct.Sqid, f.Name, err) jsonError(w, http.StatusInternalServerError, "could not list fleets") return } bv := make([]fleetBoxView, 0, len(boxes)) for _, b := range boxes { bv = append(bv, fleetBoxView{Label: b.Label, Status: b.Status, Tags: b.Tags}) } out = append(out, fleetView{Name: f.Name, Bucket: f.Bucket, Boxes: bv}) } writeJSON(w, http.StatusOK, map[string]any{"fleets": out}) } // handleAPIFleetAddBox answers `carlos fleets add-box`: mints a box row and its // bearer token. The plaintext appears exactly once, in this response body — // only its sha256 hex reaches the registry (AddFleetBox), and it is never // logged. func (s *Server) handleAPIFleetAddBox(w http.ResponseWriter, r *http.Request) { addr, acct, fleet, reg, ok := s.apiFleet(w, r) if !ok { return } p, _ := principalFrom(r.Context()) if !s.allowWrite(p, time.Now()) { jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") return } var req struct { Label string `json:"label"` } if !readJSON(w, r, &req) { return } label := strings.TrimSpace(req.Label) if !registry.ValidTunnelName(label) { jsonError(w, http.StatusBadRequest, "invalid box label — 1-32 characters of a-z, 0-9 and hyphen") return } plaintext, hash, err := mintFleetToken() if err != nil { log.Printf("console: fleet add-box %s/%s: mint token: %v", acct.Sqid, fleet.Name, err) jsonError(w, http.StatusInternalServerError, "could not mint a token — try again") return } // The fleet is already proven to exist (apiFleet), so AddFleetBox's own // "no fleet" case is unreachable here — but a tx/busy/disk failure is // not, and must not be rendered as the ordinary duplicate-box 409: // errors.Is against the sentinel is what tells the two apart, rather // than assuming every failure here is the uniqueness guard. if err := reg.AddFleetBox(registry.FleetBox{Fleet: fleet.Name, Label: label, TokenHash: hash, Status: "active"}); err != nil { if errors.Is(err, registry.ErrFleetBoxActive) { jsonError(w, http.StatusConflict, "fleet "+fleet.Name+" already has an active box "+label) return } log.Printf("console: fleet add-box %s/%s/%s: %v", acct.Sqid, fleet.Name, label, err) jsonError(w, http.StatusInternalServerError, "could not add the box — try again") return } s.audit(acct, fleet.Name, addr, "fleet-add-box", "added box "+label+" to fleet "+fleet.Name+" (cli)") writeJSON(w, http.StatusOK, map[string]any{"label": label, "token": plaintext}) } // handleAPIFleetRotateBox answers `carlos fleets rotate-token`: a fresh token // for a box that already exists, its hash replacing the stored one. // // ROTATION IS NOT REVOCATION. It refuses the box's NEXT dial-in attempt with // the OLD token — the edge's tunnel listener checks a presented token's hash // against the registry on every new connection, and the old hash simply // stops matching once this write lands (spec's "Edge: dynamic fleet // channels", within the console's usual convergence window; carried from // Task 3's own rotation contract for the platform's own tunnel clients). It // does NOT evict a channel that is already connected: a box mid-session // keeps talking on the connection it already holds, because rotation only // changes what is checked when a NEW connection is authenticated. A box // whose credential may be compromised needs DETACH (handleAPIFleetDetachBox), // which the edge honours by closing the live channel outright — rotation // alone is the wrong tool for "get this box off the network right now". func (s *Server) handleAPIFleetRotateBox(w http.ResponseWriter, r *http.Request) { addr, acct, fleet, reg, ok := s.apiFleet(w, r) if !ok { return } p, _ := principalFrom(r.Context()) if !s.allowWrite(p, time.Now()) { jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") return } var req struct{} if !readJSON(w, r, &req) { return } label := r.PathValue("label") box, err := findFleetBox(reg, fleet.Name, label) if err != nil { log.Printf("console: fleet rotate %s/%s/%s: %v", acct.Sqid, fleet.Name, label, err) jsonError(w, http.StatusInternalServerError, "could not read the box") return } if box == nil { jsonError(w, http.StatusNotFound, "no such box") return } plaintext, hash, err := mintFleetToken() if err != nil { log.Printf("console: fleet rotate %s/%s/%s: mint token: %v", acct.Sqid, fleet.Name, label, err) jsonError(w, http.StatusInternalServerError, "could not mint a token — try again") return } if err := reg.SetFleetBoxToken(fleet.Name, label, hash); err != nil { log.Printf("console: fleet rotate %s/%s/%s: %v", acct.Sqid, fleet.Name, label, err) jsonError(w, http.StatusInternalServerError, "could not rotate the token — try again") return } s.audit(acct, fleet.Name, addr, "fleet-rotate-box", "rotated the token for "+fleet.Name+"/"+label+" (cli)") writeJSON(w, http.StatusOK, map[string]any{"token": plaintext}) } // handleAPIFleetTagBox answers `carlos fleets tag-box`: a FULL REPLACE of a // box's tag list, never a merge — an empty "tags" clears every tag rather // than being refused as a no-op, matching SetFleetBoxTags's own plain // overwrite (`UPDATE ... SET tags = ?`, registry/fleets.go): whatever this // door validates and passes through is exactly what lands. // // Grammar (registry.ValidBoxTags) is checked here, before the registry // write ever runs — the same "a shape the writer is certain to refuse // should never reach it" principle handleAPIFleetNew's name/bucket checks // and handleAPIFleetAddBox's label check already follow. The gate order and // refusal shapes below deliberately match rotate's: apiFleet's byte- // identical unknown/foreign-fleet 404 first, then an unknown LABEL within // an already-owned fleet is its own ordinary 404 ("no such box") — see // findFleetBox's doc comment for why that one is not the cross-tenant-probe // 404. func (s *Server) handleAPIFleetTagBox(w http.ResponseWriter, r *http.Request) { addr, acct, fleet, reg, ok := s.apiFleet(w, r) if !ok { return } p, _ := principalFrom(r.Context()) if !s.allowWrite(p, time.Now()) { jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") return } var req struct { Tags string `json:"tags"` } if !readJSON(w, r, &req) { return } tags := strings.TrimSpace(req.Tags) if !registry.ValidBoxTags(tags) { jsonError(w, http.StatusBadRequest, "invalid tags — comma-separated [a-z0-9-] (hyphens interior), max 8") return } label := r.PathValue("label") box, err := findFleetBox(reg, fleet.Name, label) if err != nil { log.Printf("console: fleet tag-box %s/%s/%s: %v", acct.Sqid, fleet.Name, label, err) jsonError(w, http.StatusInternalServerError, "could not read the box") return } if box == nil { jsonError(w, http.StatusNotFound, "no such box") return } if err := reg.SetFleetBoxTags(fleet.Name, label, tags); err != nil { log.Printf("console: fleet tag-box %s/%s/%s: %v", acct.Sqid, fleet.Name, label, err) jsonError(w, http.StatusInternalServerError, "could not tag the box — try again") return } s.audit(acct, fleet.Name, addr, "fleet-tag-box", "tagged "+fleet.Name+"/"+label+" ["+tags+"] (cli)") writeJSON(w, http.StatusOK, map[string]any{}) } // handleAPIFleetDetachBox answers `carlos fleets detach-box`: flips a box to // "detached", which the edge treats as an outright refusal (a dial-in is // refused, and — unlike rotation — an already-connected channel is closed). // This is the hard revocation rotation is not. func (s *Server) handleAPIFleetDetachBox(w http.ResponseWriter, r *http.Request) { addr, acct, fleet, reg, ok := s.apiFleet(w, r) if !ok { return } p, _ := principalFrom(r.Context()) if !s.allowWrite(p, time.Now()) { jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") return } var req struct{} if !readJSON(w, r, &req) { return } label := r.PathValue("label") box, err := findFleetBox(reg, fleet.Name, label) if err != nil { log.Printf("console: fleet detach %s/%s/%s: %v", acct.Sqid, fleet.Name, label, err) jsonError(w, http.StatusInternalServerError, "could not read the box") return } if box == nil { jsonError(w, http.StatusNotFound, "no such box") return } if err := reg.SetFleetBoxStatus(fleet.Name, label, "detached"); err != nil { log.Printf("console: fleet detach %s/%s/%s: %v", acct.Sqid, fleet.Name, label, err) jsonError(w, http.StatusInternalServerError, "could not detach the box — try again") return } s.audit(acct, fleet.Name, addr, "fleet-detach-box", "detached "+fleet.Name+"/"+label+" (cli)") writeJSON(w, http.StatusOK, map[string]any{}) } // appPlacement reports an app's fleet placement target ("fleet/"), or // "" for none. // // 2026-08-06 ruling: the STORE's placement column (Store.AppPlacement) is // consulted FIRST — the fact `carlos apps place` and `carlos apps create // -place` both write (placeAppTarget, handleAPICreateApp) — because it is // the only source that exists the instant an app is placed with zero // routes yet, which a route-derived reading structurally cannot answer (see // the KNOWN LIMITATION this comment used to carry, below). Only when the // store has never recorded a placement for this app (an app placed by an // OLDER build, before this column existed, still carries its placement // only in its routes) does this fall back to the original route-derived // reading: placeAppTarget stamps every one of an app's routes with the SAME // target in one call, so by construction they are uniform once placed, and // the first fleet-shaped Region found among them is authoritative. // // The one caller outside this file is handleAPIInstanceCreate // (apiinstances.go): a fleet-placed app's NEW instances must inherit its // placement uniformly (spec's "one flag on the app, all its instances // follow") rather than trusting whatever region a create request's body // happens to send — see placeAppTarget's doc comment for why that // inheritance has to be a separate mechanism from the route/record stamping // below, not the same one. func (s *Server) appPlacement(reg consoleRegistry, acct *Account, app string) (string, error) { stored, err := s.store.AppPlacement(acct.ID, app) if err != nil { return "", err } if stored != "" { return stored, nil } // Fallback: route-derived placement, for an app placed before the store // column existed (see the doc comment above). routes, err := reg.List() if err != nil { return "", err } for _, rt := range routes { if rt.Account != acct.Sqid || rt.App != app { continue } if _, ok := registry.FleetPlacement(rt.Region); ok { return rt.Region, nil } } return "", nil } // placeInstancesFailed is placeAppTarget's answer when it cannot read the // app's instance records. const placeInstancesFailed = "could not update this app's instances — try again" // placeAppTarget is placement's whole mechanism, and the ONLY place it // lives: repoints where an app's routes (and its already-provisioned // instances) should run. target "" clears placement back to // platform-served/unconstrained; any other value must parse as // "fleet/" (registry.FleetPlacement) naming a fleet acct ITSELF owns — // placing onto another account's fleet is refused with the same // byte-identical 404 apiFleet gives an unowned one, since fleet names are a // flat, cross-account namespace (see this file's package doc comment). // // Every door onto placement calls this — the CLI API's handleAPIPlace, the // app Instances tab's handleAppPlace, the fleet page's // handleFleetPlacePage — because the ordering discipline below is the whole // correctness of the operation, and a second copy of it is a second place // for it to be got wrong. Callers own authentication, the app-ownership // gate, rate limiting, the audit line and how the outcome is rendered; // nothing else. // // The return is a translation contract, not an HTTP response: (0, "") means // it landed. A non-zero status with a MESSAGE is an ordinary refusal the // caller renders in its own idiom (jsonError, or a flash). A non-zero status // with an EMPTY message is exactly one case — http.StatusNotFound, the // cross-account target refusal — and it means the caller must answer with a // byte-identical http.NotFound, never a message of its own: a distinguishable // error there would turn this door into an oracle for which fleet names exist // in someone else's account. // // THE TRIPWIRE this function exists to close: placement is stored in // Route.Region (SetPlacement) AND, separately, in every one of the app's // already-provisioned instance records' Region field // (provision.SetRecordRegion) — both are read by provision.Reconcile's // filter (rec.Region != cfg.Region) on a fleet box running // CARLOS_REGION=fleet/. Stamping routes here does NOT, by itself, // repoint where a NEW instance provisions — instance-create validates its // Region from the REQUEST BODY (handleAPIInstanceCreate, apiinstances.go), // and provision.ValidRegion's shape (no "/") would refuse a "fleet/" // value outright if it somehow arrived that way. A new instance inheriting // placement is a SEPARATE mechanism (appPlacement, above, consulted by // handleAPIInstanceCreate directly) — spec's "one flag on the app, all its // instances follow". What THIS function's record write closes is the gap // that inheritance cannot: every instance that ALREADY EXISTS at the moment // placement changes. Inheritance only ever applies going forward, at // create time, so without the write below an already-provisioned record // would stay filed under its old region forever — silently exempt from a // placement change the caller was just told succeeded. // // ORDER MATTERS: routes are stamped BEFORE records, never the reverse. // provision.Reconcile's adoption pass (reconcile.go:265) retires a route // whose Region no longer matches what it minted the route from; a // records-first ordering would open a window in which a reconcile pass — // running concurrently, on either the OLD or the NEW fleet's box — could // see a record already repointed to the new target while the route still // names the old one, and read that route as no longer wanted, deleting a // route that is still live. Stamping routes first means the route is // always ahead of (or equal to) any record a concurrent reconcile pass // would compare it against, never behind. // // A console with no deployment bucket configured (cfg.Releases == nil) // refuses the whole request rather than silently doing the routes-only // half. func (s *Server) placeAppTarget(ctx context.Context, acct *Account, app, target string) (status int, errMsg string) { // A temporary app stays on the shared fleet at its own platform address // (spec §4, ruling 5, Task 6 review fix): placement would move its // already-provisioned estate onto a fleet an automatic purge never // looks at. Checked first, before the registry or bucket are even // opened — every caller has already proved membership/ownership. if msg, st := s.temporaryRefusal(acct, app, "serve anything but a static site at "+app+"'s own platform address"); msg != "" { return st, msg } if s.cfg.Releases == nil { return http.StatusServiceUnavailable, "no deployment bucket on this host — placement needs it to stamp this app's instances" } reg, err := openRegistry(s.cfg.Registry) if err != nil { log.Printf("console: place %s/%s: open registry: %v", acct.Sqid, app, err) return http.StatusInternalServerError, "could not reach the registry" } if target != "" { fleetName, validShape := registry.FleetPlacement(target) if !validShape { return http.StatusBadRequest, `invalid target — want "" or "fleet/"` } f, err := reg.GetFleet(fleetName) if err != nil { log.Printf("console: place %s/%s: get fleet %s: %v", acct.Sqid, app, fleetName, err) return http.StatusInternalServerError, "could not check the fleet" } if f == nil || f.Account != acct.Sqid { // Unknown and not-yours are the same refusal everywhere else in // this file (apiFleet); place's target check is no exception — // this door must not become a way to learn which fleet names // exist in someone else's account. The empty message is the // signal to the caller: answer http.NotFound, say nothing. return http.StatusNotFound, "" } } // A place re-homes every record of the app, so a seed that may not move // yet (spec §5.8) refuses it, before any write. A record that cannot be // read refuses it too. SetRecordRegion refuses such a seed again under // its CAS, for a record created after this read. switch block, err := placeSeedBlock(ctx, s.cfg.Releases, acct.Sqid, app); { case err != nil: log.Printf("console: place %s/%s: read instances: %v", acct.Sqid, app, err) return http.StatusBadGateway, placeSeedReadFailed case block != "": return http.StatusConflict, provision.SeedMoveSentence(block) } // The store-of-record write (2026-08-06 ruling): appPlacement consults // THIS before it ever walks a route. It has to land before routes/records // are stamped below, not after — the same "never observe an app in a // getting-placed state that some OTHER path already trusts" reasoning // the ORDER MATTERS paragraph above applies to routes-before-records, one // step earlier: a concurrent instance-create must never inherit a target // this request has not actually committed to. if s.cfg.HostClaim { if s.cfg.ClaimPlacement == nil { return http.StatusServiceUnavailable, claimResponseUnavailable } id, err := s.cfg.ClaimPlacement.Start(ctx, acct.Sqid, app, target, "console") if err != nil && !errors.Is(err, hostops.ErrPending) { ref := claimResponseRefusal(err) return ref.Status, ref.Msg } if id == "" { return http.StatusConflict, claimRetireBusyMsg } appID, err := s.store.ClaimAppID(acct.ID, app) if err != nil { return http.StatusServiceUnavailable, claimResponseUnavailable } admission, err := hostops.ReadAdmission(ctx, s.cfg.Releases, acct.Sqid, app, appID) if err != nil { return http.StatusServiceUnavailable, claimResponseUnavailable } if admission.Value.Op == nil { return 0, "" } if admission.Value.Op.ID != id { return http.StatusConflict, claimRetireBusyMsg } return http.StatusAccepted, id } if err := s.store.SetAppPlacementContext(ctx, acct.ID, app, target); err != nil { log.Printf("console: place %s/%s: set placement: %v", acct.Sqid, app, err) return http.StatusInternalServerError, "could not record placement — try again" } routes, err := reg.List() if err != nil { log.Printf("console: place %s/%s: list routes: %v", acct.Sqid, app, err) return http.StatusBadGateway, "could not read the app's routes — try again" } for _, rt := range routes { if rt.Account != acct.Sqid || rt.App != app { continue } if err := reg.SetPlacement(rt.Host, target); err != nil { log.Printf("console: place %s/%s: set placement on %s: %v", acct.Sqid, app, rt.Host, err) return http.StatusBadGateway, "could not update every route — try again" } } ctx, cancel := context.WithTimeout(ctx, instanceStoreTimeout) defer cancel() prefix := provision.RecordsPrefix(acct.Sqid, app) keys, err := s.cfg.Releases.List(ctx, prefix) if err != nil { log.Printf("console: place %s/%s: list instances: %v", acct.Sqid, app, err) return http.StatusBadGateway, placeInstancesFailed } for _, key := range keys { rest, ok := strings.CutPrefix(key, prefix) if !ok || rest == "" || strings.Contains(rest, "/") { continue // not a record at this level } host, ok := strings.CutSuffix(rest, ".json") if !ok || host == "" { continue } if err := provision.SetRecordRegion(ctx, s.cfg.Releases, acct.Sqid, app, host, target); err != nil { if errors.Is(err, os.ErrNotExist) { // The record was retired (DeleteRecord) between the List // above and this write reaching it — not a failure of this // call, just a target that's gone. Every OTHER error still // fails the whole place: this is the one case where "the // thing we were about to touch already doesn't exist" is // indistinguishable from success. log.Printf("console: place %s/%s: instance %s vanished mid-walk, skipping", acct.Sqid, app, host) continue } if msg := seedMoveConflict(err); msg != "" { return http.StatusConflict, msg } log.Printf("console: place %s/%s: stamp instance %s: %v", acct.Sqid, app, host, err) return http.StatusBadGateway, "could not update every instance — try again" } } return 0, "" } // handleAPIPlace answers `carlos apps place`: the bearer-authenticated door // onto placeAppTarget. Everything that makes placement CORRECT lives in that // function (see its doc comment); what belongs here is the CLI API's own // share — bearer resolution, the write budget, the JSON body, the "(cli)" // audit line, and translating the core's (status, message) onto this API's // refusal shapes. func (s *Server) handleAPIPlace(w http.ResponseWriter, r *http.Request) { addr, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } p, _ := principalFrom(r.Context()) if !s.allowWrite(p, time.Now()) { jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") return } var req struct { Target string `json:"target"` } if !readJSON(w, r, &req) { return } target := strings.TrimSpace(req.Target) switch status, msg := s.placeAppTarget(r.Context(), acct, app, target); { case status == http.StatusNotFound: // The cross-account target refusal — byte-identical to any other // not-found, never a JSON message of its own. http.NotFound(w, r) return case status == http.StatusAccepted: writeJSON(w, http.StatusAccepted, map[string]any{"operation_id": msg, "phase": "pending"}) return case status != 0: jsonError(w, status, msg) return } detail := "cleared placement for " + app + " (cli)" if target != "" { detail = "placed " + app + " on " + target + " (cli)" } s.audit(acct, app, addr, "place", detail) writeJSON(w, http.StatusOK, map[string]any{}) } // placeFlash is the one sentence both browser doors say about an outcome, // and the one audit detail both record — kept together here so the two forms // can never describe the same act differently. suffix is the door's own // provenance mark ("(console)"), the same convention the fleet-box page // doors use against the CLI API's "(cli)". func placeFlash(app, target, suffix string) (flash, detail string) { if target == "" { return app + " is back on the shared fleet.", "cleared placement for " + app + " " + suffix } return app + " is now placed on " + target + ".", "placed " + app + " on " + target + " " + suffix } // handleAppPlace answers the Placement form on an app's Instances tab: move // THIS app onto one of the account's fleets, or back to the shared one. // // The gate skeleton is handleAppRename's, deliberately — accountApp, then // CSRF, then owner (a member gets the byte-identical 404, never a 403 that // would confirm both the app's existence and the caller's under-privilege). // The move itself is placeAppTarget's; this door only translates its outcome // into a flash, except for the cross-account target refusal, which it must // answer as an ordinary http.NotFound and nothing else (see the core's doc // comment on why an empty message is that signal). func (s *Server) handleAppPlace(w http.ResponseWriter, r *http.Request) { addr, raw, acct, role, app, ok := s.accountApp(w, r) if !ok { return } if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") return } if role != RoleOwner { http.NotFound(w, r) return } back := func(kind, msg string) { s.setFlash(w, kind, msg) http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/instances", http.StatusSeeOther) } target := strings.TrimSpace(r.FormValue("target")) switch status, msg := s.placeAppTarget(r.Context(), acct, app, target); { case status == http.StatusNotFound: http.NotFound(w, r) return case status == http.StatusAccepted: s.renderClaimAccepted(w, r, app, msg, "pending") return case status != 0: back("err", msg) return } flash, detail := placeFlash(app, target, "(console)") s.audit(acct, app, addr, "place", detail) back("ok", flash) } // handleFleetPlacePage answers the fleet page's "Place an app here" form: the // same move from the other end — this fleet is fixed, the app is chosen. // // ownerFleet proves the FLEET is this account's; it says nothing about the // app named in the body, so HasAppIn is a second, independent gate here. Its // refusal is accountApp's own "No such app here." 404, which is the same // answer a mistyped name gets: app names are per-account, so this reveals // nothing about another account's apps. func (s *Server) handleFleetPlacePage(w http.ResponseWriter, r *http.Request) { addr, raw, acct, fleet, _, ok := s.ownerFleet(w, r) if !ok { return } if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") return } back := func(kind, msg string) { s.setFlash(w, kind, msg) http.Redirect(w, r, fleetPageURL(acct.Sqid, fleet.Name), http.StatusSeeOther) } app := strings.TrimSpace(r.FormValue("app")) has, err := s.store.HasAppIn(acct.ID, app) if err != nil { log.Printf("console: fleet place %s/%s: has app %s: %v", acct.Sqid, fleet.Name, app, err) back("err", "Could not read your apps — try again.") return } if !has { s.renderMessage(w, r, http.StatusNotFound, "Not found", "No such app here.") return } target := registry.PlacementTarget(fleet.Name) switch status, msg := s.placeAppTarget(r.Context(), acct, app, target); { case status == http.StatusNotFound: http.NotFound(w, r) return case status == http.StatusAccepted: s.renderClaimAccepted(w, r, app, msg, "pending") return case status != 0: back("err", msg) return } flash, detail := placeFlash(app, target, "(console)") s.audit(acct, app, addr, "place", detail) back("ok", flash) } // appPlacementSection reads the app Instances tab's Placement section: where // this app runs now, and every fleet the account owns to offer as somewhere // else it could. // // known is the section's twin of sharedFleetCard.PlacementKnown and exists // for the identical reason: "" is both "this app runs on the shared fleet" // and "the read that would have told us failed", and only the first of those // is a sentence this page has earned. False means the section says it could // not check, rather than making the positive claim — and withholds the form, // whose pre-selected option would otherwise be a guess. func (s *Server) appPlacementSection(acct *Account, app string) (placement string, fleets []string, known bool, warn string) { reg, err := openRegistry(s.cfg.Registry) if err != nil { log.Printf("console: app placement %s/%s: open registry: %v", acct.Sqid, app, err) return "", nil, false, "Could not reach the fleet registry." } placement, err = s.appPlacement(reg, acct, app) if err != nil { log.Printf("console: app placement %s/%s: %v", acct.Sqid, app, err) return "", nil, false, "Could not read where this app runs." } rows, err := reg.ListFleets() if err != nil { log.Printf("console: app placement %s/%s: list fleets: %v", acct.Sqid, app, err) // Placement itself IS known — only the list of alternatives is // missing, which costs the form and nothing else. return placement, nil, true, "Could not list your fleets." } for _, f := range rows { // Fleet names are one flat, cross-account namespace (this file's // package doc comment): this filter is the only thing keeping another // account's fleet names off the page. ListFleets is already ORDER BY // name, so the offered order is stable without a second sort. if f.Account == acct.Sqid { fleets = append(fleets, f.Name) } } return placement, fleets, true, "" } // fleetPlaceableApps is what the fleet page's own form may offer: the // account's live apps that are not ALREADY placed here, derived from the // placed-apps grouping loadFleetPage just walked rather than a second route // pass. A failed apps read costs the form (nil), never the page — the same // soft-fail posture everything else on this page has. func (s *Server) fleetPlaceableApps(acct *Account, placed []fleetPagePlacedApp) []string { apps, err := s.store.AppsIn(acct.ID) if err != nil { log.Printf("console: fleet page %s: list apps: %v", acct.Sqid, err) return nil } here := make(map[string]bool, len(placed)) for _, p := range placed { here[p.App] = true } out := make([]string, 0, len(apps)) for _, a := range apps { if !here[a] { out = append(out, a) } } return out } // ---- the console fleet page (Task 9) -------------------------------------- // // GET /a/{sqid}/fleets/{fleet} is member-readable (any role sees the boxes // and placed apps), and its two mutation forms — rotate, detach — are // owner-gated, CSRF-checked POSTs onto the same registry writes the CLI API // doors above already make (SetFleetBoxToken, SetFleetBoxStatus). Nothing // here is a new capability; it is the same fleet, the same rows, on a // session instead of a bearer token. // fleetPageBox is one box's row on the owner's fleet page: the registry's // own record (Label, Status) joined to its heartbeat BY LABEL — the // "Box.Label equals fleet_boxes.label" identity ruling from Task 6, because // the channel label IS the identity, not a machine pseudonym. // // Reachable covers both halves of "this box has nothing current to show": // no heartbeat has ever joined this row at all (Task 6's KNOWN // DEGRADATION — a box with invalid fleet identity config heartbeats under a // hashed pseudonym label instead, which never joins here), and a heartbeat // joined once but has since gone stale (status.BoxStale against // status.SnapshotPeriod, the same threshold HostRows/view.go uses for the // platform's own operator page). Both cases render as "no numbers", never a // guess — Age distinguishes them for display (see the doc comment on it) // but neither ever carries Metrics. type fleetPageBox struct { Label string Status string // "active" | "detached" — registry.FleetBox.Status, never TokenHash // Reachable is true only for a heartbeat that both joined and is fresh. Reachable bool // Age is how long ago the last heartbeat was seen, pre-rendered // ("1h2m") by humanAge (view.go) — set only when a heartbeat joined but // has gone stale. Empty AND Reachable false together mean no heartbeat // has ever joined this row: "no heartbeat yet", not "0m ago". Age string // Tunnel and Metrics are set only when Reachable — an unreachable or // never-joined box shows no numbers at all, ever (mirrors HostRows). Tunnel string Metrics *status.BoxMetrics // Tags is registry.FleetBox.Tags (2026-08-10, Task 3) split on "," — nil // for an untagged box, never a slice holding one empty string, so the // template's {{if not .Tags}} placeholder check is a plain nil test. Tags []string } // fleetPagePlacedApp is one app placed on this fleet: its name and the // routes (by host) that carry the placement — registry.Route rows whose // Region is registry.PlacementTarget(fleet), grouped by App. type fleetPagePlacedApp struct { App string Hosts []string } // resolveOwnedFleet is the browser routes' fleet-ownership check — the // page-side twin of apiFleet above, and for the identical reason: fleet // names are one flat, cross-account namespace (this file's package doc // comment), so an unknown fleet and one owned by a DIFFERENT account must // answer with the exact same http.NotFound an unrouted URL gets, never a // distinguishable "no such fleet" message that would turn this into a // cross-account oracle. Every browser door onto a {sqid}/{fleet} URL calls // this AFTER its own membership gate (account or owner), never before. func (s *Server) resolveOwnedFleet(w http.ResponseWriter, r *http.Request, acct *Account) (*registry.Fleet, consoleRegistry, bool) { reg, err := openRegistry(s.cfg.Registry) if err != nil { log.Printf("console: fleet page: open registry: %v", err) s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not reach the fleet registry.") return nil, nil, false } name := r.PathValue("fleet") f, err := reg.GetFleet(name) if err != nil { log.Printf("console: fleet page %s/%s: %v", acct.Sqid, name, err) s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read the fleet.") return nil, nil, false } if f == nil || f.Account != acct.Sqid { http.NotFound(w, r) return nil, nil, false } return f, reg, true } // accountFleet gates GET .../fleets/{fleet}: session membership, any role — // a member reads the same page an owner does, just without its forms (the // template's own {{if .IsOwner}} checks, not a second gate here). func (s *Server) accountFleet(w http.ResponseWriter, r *http.Request) (addr, raw string, acct *Account, role string, fleet *registry.Fleet, reg consoleRegistry, ok bool) { addr, raw, acct, role, ok = s.account(w, r) if !ok { return "", "", nil, "", nil, nil, false } fleet, reg, ok = s.resolveOwnedFleet(w, r, acct) if !ok { return "", "", nil, "", nil, nil, false } return addr, raw, acct, role, fleet, reg, true } // ownerFleet gates the two mutation doors (rotate, detach): owner only, // same byte-identical-404 fleet resolution accountFleet uses. func (s *Server) ownerFleet(w http.ResponseWriter, r *http.Request) (addr, raw string, acct *Account, fleet *registry.Fleet, reg consoleRegistry, ok bool) { addr, raw, acct, ok = s.owner(w, r) if !ok { return "", "", nil, nil, nil, false } fleet, reg, ok = s.resolveOwnedFleet(w, r, acct) if !ok { return "", "", nil, nil, nil, false } return addr, raw, acct, fleet, reg, true } // loadFleetPage reads everything the fleet page's body needs beyond the // fleet record itself: every registered box, each joined to its own // heartbeat (read through release.NewPrefixStore(s.cfg.Releases, // "fleets//"), the same rebasing a real fleet box's agent writes // through — release/store.go's doc comment on prefixStore names this page // as its second caller), and every route this account owns whose Region // places it on this fleet, grouped by app. // // Every failure here is soft: a box list, heartbeat read, or route list // that errors is logged and degrades to "nothing to show" for that section // (warn carries the first one, for a flash banner) — this page must never // 500 because one of three independent reads had a bad moment, the same // posture HostRows (view.go) and handleFleet already take. func (s *Server) loadFleetPage(ctx context.Context, reg consoleRegistry, acct *Account, fleet *registry.Fleet) (boxes []fleetPageBox, apps []fleetPagePlacedApp, warn string) { regBoxes, err := reg.ListFleetBoxes(fleet.Name) if err != nil { log.Printf("console: fleet page %s/%s: list boxes: %v", acct.Sqid, fleet.Name, err) warn = "Could not list this fleet's boxes." } // A console with no deployment bucket configured has no heartbeats to // read at all — every box renders as "no heartbeat yet", not an error. heartbeats := map[string]status.Box{} if s.cfg.Releases != nil { hbs, err := status.LoadBoxes(ctx, release.NewPrefixStore(s.cfg.Releases, "fleets/"+fleet.Name+"/")) if err != nil { log.Printf("console: fleet page %s/%s: load heartbeats: %v", acct.Sqid, fleet.Name, err) // A heartbeat read that failed is NOT the same as "no box has // ever heartbeated" — the former is "unknown, could not check", // the latter is a positive claim this page has no business // making when the truth is "S3 is having a moment". Every box // below still renders (degrading softly, per this function's // doc comment), but the flash makes the distinction honest // rather than silently collapsing it into "no heartbeat yet". if warn == "" { warn = "Could not read this fleet's heartbeats." } } else { for _, b := range hbs { heartbeats[b.Label] = b } } } now := time.Now() boxes = make([]fleetPageBox, 0, len(regBoxes)) for _, b := range regBoxes { row := fleetPageBox{Label: b.Label, Status: b.Status} if b.Tags != "" { row.Tags = strings.Split(b.Tags, ",") } // Join key is the label, per the Task 6 identity ruling in this // function's doc comment — an orphan heartbeat under some OTHER // label (a box heartbeating under a hashed pseudonym) simply never // matches here, which is exactly the degrade-safely behaviour this // row is built to have: it falls through to the zero-value // Reachable=false, Age="" case below, "no heartbeat yet". if hb, joined := heartbeats[b.Label]; joined { if status.BoxStale(hb, now, status.SnapshotPeriod) { row.Age = humanAge(now.Sub(hb.GeneratedAt)) } else { row.Reachable = true if hb.Metrics != nil { row.Tunnel = hb.Metrics.Tunnel row.Metrics = hb.Metrics } } } boxes = append(boxes, row) } routes, err := reg.List() if err != nil { log.Printf("console: fleet page %s/%s: list routes: %v", acct.Sqid, fleet.Name, err) if warn == "" { warn = "Could not list this fleet's placed apps." } return boxes, nil, warn } target := registry.PlacementTarget(fleet.Name) byApp := map[string][]string{} for _, rt := range routes { if rt.Account != acct.Sqid || rt.Region != target { continue } byApp[rt.App] = append(byApp[rt.App], rt.Host) } names := make([]string, 0, len(byApp)) for app := range byApp { names = append(names, app) } sort.Strings(names) apps = make([]fleetPagePlacedApp, 0, len(names)) for _, app := range names { hosts := byApp[app] sort.Strings(hosts) apps = append(apps, fleetPagePlacedApp{App: app, Hosts: hosts}) } return boxes, apps, warn } // handleFleetPage serves GET /a/{sqid}/fleets/{fleet}. func (s *Server) handleFleetPage(w http.ResponseWriter, r *http.Request) { addr, raw, acct, role, fleet, reg, ok := s.accountFleet(w, r) if !ok { return } p := s.accountPage(w, r, addr, raw, acct, role) p.Title = fleet.Name p.ActiveNav = "fleet" p.FleetDetail = fleet boxes, apps, warn := s.loadFleetPage(r.Context(), reg, acct, fleet) p.FleetBoxes = boxes p.FleetApps = apps p.FleetPlaceableApps = s.fleetPlaceableApps(acct, apps) if warn != "" && p.FlashErr == "" { p.FlashErr = warn } s.render(w, http.StatusOK, "fleetdetail", p) } // fleetPageURL is where the two mutation POSTs below redirect back to on an // ordinary (non-token) outcome. func fleetPageURL(sqid, fleet string) string { return "/a/" + url.PathEscape(sqid) + "/fleets/" + url.PathEscape(fleet) } // handleFleetRotateBoxPage answers the owner's "Rotate token" form: the // same SetFleetBoxToken write handleAPIFleetRotateBox makes, with the fresh // plaintext shown directly in this response — never redirected, never put // in a flash cookie — so it is on the page exactly once, the way // printFleetToken (fleets.go, the CLI) prints it exactly once to stdout. // ROTATION IS NOT REVOCATION, same warning, same reasoning: see // handleAPIFleetRotateBox's doc comment. func (s *Server) handleFleetRotateBoxPage(w http.ResponseWriter, r *http.Request) { addr, raw, acct, fleet, reg, ok := s.ownerFleet(w, r) if !ok { return } if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") return } back := func(kind, msg string) { s.setFlash(w, kind, msg) http.Redirect(w, r, fleetPageURL(acct.Sqid, fleet.Name), http.StatusSeeOther) } label := strings.TrimSpace(r.FormValue("label")) box, err := findFleetBox(reg, fleet.Name, label) if err != nil { log.Printf("console: fleet rotate %s/%s/%s: %v", acct.Sqid, fleet.Name, label, err) back("err", "Could not read the box.") return } if box == nil { back("err", "No such box.") return } plaintext, hash, err := mintFleetToken() if err != nil { log.Printf("console: fleet rotate %s/%s/%s: mint token: %v", acct.Sqid, fleet.Name, label, err) back("err", "Could not mint a token — try again.") return } if err := reg.SetFleetBoxToken(fleet.Name, label, hash); err != nil { log.Printf("console: fleet rotate %s/%s/%s: %v", acct.Sqid, fleet.Name, label, err) back("err", "Could not rotate the token — try again.") return } s.audit(acct, fleet.Name, addr, "fleet-rotate-box", "rotated the token for "+fleet.Name+"/"+label+" (console)") p := s.accountPage(w, r, addr, raw, acct, RoleOwner) p.Title = fleet.Name p.FleetDetail = fleet boxes, apps, warn := s.loadFleetPage(r.Context(), reg, acct, fleet) p.FleetBoxes = boxes p.FleetApps = apps p.FleetPlaceableApps = s.fleetPlaceableApps(acct, apps) p.FlashErr = warn p.RotatedLabel = label p.RotatedToken = plaintext // This response carries a bearer credential in its body — the same // reason writeJSON (authcli.go) sets this on every JSON response, // including the rotate/add-box token bodies. A cached or // bfcache/session-restored copy of an HTML page is exactly the kind of // second life a JSON response never gets, which makes the header more // load-bearing here, not less. w.Header().Set("Cache-Control", "no-store") s.render(w, http.StatusOK, "fleetdetail", p) } // handleFleetDetachBoxPage answers the owner's "Detach" form: the same // SetFleetBoxStatus write handleAPIFleetDetachBox makes. No secret to show, // so this redirects back to the fleet page with an ordinary flash notice. func (s *Server) handleFleetDetachBoxPage(w http.ResponseWriter, r *http.Request) { addr, raw, acct, fleet, reg, ok := s.ownerFleet(w, r) if !ok { return } if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") return } back := func(kind, msg string) { s.setFlash(w, kind, msg) http.Redirect(w, r, fleetPageURL(acct.Sqid, fleet.Name), http.StatusSeeOther) } label := strings.TrimSpace(r.FormValue("label")) box, err := findFleetBox(reg, fleet.Name, label) if err != nil { log.Printf("console: fleet detach %s/%s/%s: %v", acct.Sqid, fleet.Name, label, err) back("err", "Could not read the box.") return } if box == nil { back("err", "No such box.") return } if err := reg.SetFleetBoxStatus(fleet.Name, label, "detached"); err != nil { log.Printf("console: fleet detach %s/%s/%s: %v", acct.Sqid, fleet.Name, label, err) back("err", "Could not detach the box — try again.") return } s.audit(acct, fleet.Name, addr, "fleet-detach-box", "detached "+fleet.Name+"/"+label+" (console)") back("ok", "Detached "+fleet.Name+"/"+label+".") } // handleFleetTagBoxPage answers the owner's "Tag" form: the same full-replace // SetFleetBoxTags write handleAPIFleetTagBox makes, gate/flash/redirect // skeleton copied from handleFleetRotateBoxPage/handleFleetDetachBoxPage — // ownerFleet then CSRF, then an ordinary redirect-with-flash outcome (no // secret in the body, so there is no reason for this to render directly the // way rotate's response does). // // Grammar (registry.ValidBoxTags) is checked here, before the registry write // ever runs and before the label is even looked up — reject, don't sanitize, // the same order and the same user-facing copy handleAPIFleetTagBox already // uses, so a bad-grammar submission never touches the row regardless of // whether the named box exists. func (s *Server) handleFleetTagBoxPage(w http.ResponseWriter, r *http.Request) { addr, raw, acct, fleet, reg, ok := s.ownerFleet(w, r) if !ok { return } if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") return } back := func(kind, msg string) { s.setFlash(w, kind, msg) http.Redirect(w, r, fleetPageURL(acct.Sqid, fleet.Name), http.StatusSeeOther) } tags := strings.TrimSpace(r.FormValue("tags")) if !registry.ValidBoxTags(tags) { back("err", "invalid tags — comma-separated [a-z0-9-] (hyphens interior), max 8") return } label := strings.TrimSpace(r.FormValue("label")) box, err := findFleetBox(reg, fleet.Name, label) if err != nil { log.Printf("console: fleet tag-box %s/%s/%s: %v", acct.Sqid, fleet.Name, label, err) back("err", "Could not read the box.") return } if box == nil { back("err", "No such box.") return } if err := reg.SetFleetBoxTags(fleet.Name, label, tags); err != nil { log.Printf("console: fleet tag-box %s/%s/%s: %v", acct.Sqid, fleet.Name, label, err) back("err", "Could not tag the box — try again.") return } s.audit(acct, fleet.Name, addr, "fleet-tag-box", "tagged "+fleet.Name+"/"+label+" ["+tags+"] (console)") back("ok", "Tagged "+fleet.Name+"/"+label+".") }