// Control-plane apps: the flag that says "this app IS the platform". // // It gates DESTRUCTION, not promotion, and the asymmetry is the whole design. // Deploying the console should be as ordinary as deploying any app — that is // what this slice exists to make true — so a promote gate would defeat it: // `stable`'s fresh-auth 403 has no API-side step-up, and `carlos deploy` // log.Fatalfs on a failed promote AFTER the ship has already landed // (deploy.go), so any gate there would leave every console deploy half-done. // Destroying the console is the act that deserves a wall, and it had none: // handlePurgeApp will delete apps//console/** — the release history the // fleet is currently running — behind an owner check, a typed name and a // passkey, none of which knows what this app is. // // Refused OUTRIGHT rather than behind fresh auth. A gesture proves intent, // and intent is not the thing in doubt: an operator who means to delete the // console can clear the flag first, which is a separate, deliberate act with // its own record. // // The one promote-side restriction is about WHO, not whether: a person with // ops membership may roll the control plane, a service credential may not. // // The CLEAR door lives here too (handleAppControlPlaneClear), rather than in // appsettings.go with the settings tab's other writes, because the refusals it // unlocks are in this file and the two must be read together: every refusal // above ends "clear the control-plane flag first", and this is the only place // that sentence can be acted on. package console import ( "bytes" "context" "crypto/subtle" "debug/buildinfo" "debug/elf" "errors" "fmt" "log" "net/http" "runtime/debug" "strings" ) // controlPlaneRefusal answers "may this door proceed?" for a destructive verb. // msg is "" when the app is not the control plane and the caller may carry on; // otherwise msg is the sentence to show and status the code a JSON door should // send with it. // // The sentence NAMES THE PAGE that can clear the flag. An operator meeting one // of these refusals is by definition someone the platform has just stopped, // and "clear the control-plane flag first" without saying where is the same // stranded-operator problem the clear door was built to end. // // Callers MUST be past their own membership gate first — the byte-identical // 404 protects account membership, and consulting the flag before it would // turn this refusal into an oracle for which accounts hold a control-plane // app. // // A lookup failure REFUSES too, with a 503 rather than a 403: the alternative // — treating an unreadable row as "not the control plane" — makes a database // blip the way past the guard. The error itself is logged, never returned: // the caller gets one fixed sentence, as with every other store failure here. func (s *Server) controlPlaneRefusal(acct *Account, app, verb string) (msg string, status int) { cp, _, err := s.store.AppFlags(acct.ID, app) if err != nil { log.Printf("console: control-plane check %s in account %s: %v", app, acct.Sqid, err) return "Could not check whether " + app + " is the control plane — nothing was changed. Try again.", http.StatusServiceUnavailable } if !cp { return "", 0 } return app + " is marked as the control plane, so it cannot be " + verb + " — clear the flag first on the app's Settings tab (" + s.appSettingsURL(acct, app) + ") if you really mean to.", http.StatusForbidden } // appSettingsURL is where both halves of the flag live, said in full: a CLI // caller has no page to be already looking at, and a browser flash pointing at // an absolute URL costs nothing. func (s *Server) appSettingsURL(acct *Account, app string) string { return s.cfg.Origin + appSettingsURL(acct.Sqid, app, "advanced") } // refuseControlPlane is controlPlaneRefusal for the JSON doors: it writes the // refusal and returns true when the caller must stop. Browser doors render // pages and set flashes, so they call controlPlaneRefusal directly and hand // msg to their own back()/fail() closure. func (s *Server) refuseControlPlane(w http.ResponseWriter, acct *Account, app, verb string) bool { msg, status := s.controlPlaneRefusal(acct, app, verb) if msg == "" { return false } jsonError(w, status, msg) return true } // refuseServiceControlPlane is the release-authority gate: a service // credential cannot move a control-plane app's channel pointer, nor shorten // the history behind it; a PERSON with ops membership can, and must be able // to — gating a person on promote would break `carlos deploy -app console`, // the command this work exists to make ordinary. // // action is a whole CLAUSE, not a participle ("promote it", "roll it back", // "change its release retention"), because the sentence it lands in has to // read as English for three different verbs and "cannot roll back it" is not // English. // // The flag is only read for a service, so a person's promote does not grow a // database round trip it has no use for. func (s *Server) refuseServiceControlPlane(w http.ResponseWriter, p Principal, accountID int64, app, action string) bool { if p.Kind == PrincipalUser { return false } cp, _, err := s.store.AppFlags(accountID, app) if err != nil { log.Printf("console: control-plane check %s in account %d: %v", app, accountID, err) jsonError(w, http.StatusServiceUnavailable, "could not check this app's control-plane status — nothing was changed") return true } if !cp { return false } jsonError(w, http.StatusForbidden, app+" is the control plane — a service credential cannot "+action+"; a person must") return true } // controlPlaneAppsIn lists the apps in an account carrying the control-plane // flag, TRASHED ROWS INCLUDED: a trashed app still owns its prefix in the // bucket — the release history the fleet is running is exactly what a trash // marker does not destroy — so it is still a reason to refuse the account's // retirement. (AppCount, handleDeleteAccount's older guard, counts the same // way for the same reason.) func (s *Store) controlPlaneAppsIn(accountID int64) ([]string, error) { rows, err := s.db.Query(`SELECT app FROM apps WHERE account_id = ? AND control_plane = 1 ORDER BY app`, accountID) if err != nil { return nil, err } defer rows.Close() var out []string for rows.Next() { var app string if err := rows.Scan(&app); err != nil { return nil, err } out = append(out, app) } return out, rows.Err() } // controlPlaneAccountRefusal is the same guard one level up: an account // holding a control-plane app cannot be RETIRED either. Refusing the app's own // destructive doors while the account above it could still be deleted would be // a guard with a published bypass, which reads as protection and is not. // // msg is "" when the account is free to go. A lookup failure refuses, on the // same reasoning as controlPlaneRefusal: a database blip must not be the way // past the guard. // // In practice handleDeleteAccount's AppCount refusal already catches every // account this could fire on, since an account holding ANY app row is refused // — but that guard is about cascading deletes, is written to change for its // own reasons, and sits BELOW the passkey. This one is about the flag, names // the app, and refuses before a gesture is spent. func (s *Server) controlPlaneAccountRefusal(accountID int64) string { apps, err := s.store.controlPlaneAppsIn(accountID) if err != nil { log.Printf("console: control-plane check for account %d: %v", accountID, err) return "Could not check whether this account holds a control-plane app. Nothing was deleted." } if len(apps) == 0 { return "" } return "“" + strings.Join(apps, "”, “") + "” " + isare(len(apps)) + " marked as the control plane, so this account cannot be deleted — clear the control-plane flag first if you really mean to." } func isare(n int) string { if n == 1 { return "is" } return "are" } // handleAppControlPlaneClear takes the flag off, which is the only way past // every refusal in this file. // // BROWSER ONLY, and that is the design rather than a gap (spec §6). Setting // the flag is easy — the CLI, at claim time — and clearing it is hard: the // owner, in a browser, with a passkey. There is deliberately no API door: the // console's fresh-auth 403 is a dead end for an API caller (the same defect // that makes a promote gate unworkable), so a CLI clear would have to be // either ungated, which defeats the flag, or unusable, which is worse than // absent. // // The gate order is handleAppRename's, unchanged: // // accountApp — session, then the app's byte-identical 404. // CSRF — constant-time, before any pending action can be parked. // owner — a member probing this gets a stranger's answer. // nothing to clear — a cheap refusal, so an already-clear app never costs a // passkey touch. // requireFreshAuth — LAST, so no redirect into Keymail ever happens for // someone who should have got a 404. // // No serial bump: the flag is this console's own row and no box parses it. func (s *Server) handleAppControlPlaneClear(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, appSettingsURL(acct.Sqid, app, "advanced"), http.StatusSeeOther) } // Fail CLOSED on an unreadable row, like every other reader of this flag: // clearing what may already be clear is harmless, but a lookup error here // would otherwise be reported to the operator as a successful clear. cp, _, err := s.store.AppFlags(acct.ID, app) if err != nil { log.Printf("console: control-plane clear read %s/%s: %v", acct.Sqid, app, err) back("err", "Could not read this app's control-plane setting — nothing was changed. Try again.") return } if !cp { back("err", app+" is not marked as the control plane, so there is nothing to clear.") return } if !s.requireFreshAuth(w, r, addr, raw) { return } if err := s.claimAppMutation(r.Context(), acct, app, "protection", func(ctx context.Context) error { return s.store.SetAppControlPlaneContext(ctx, acct.ID, app, false) }); err != nil { log.Printf("console: control-plane clear %s/%s: %v", acct.Sqid, app, err) back("err", "Could not clear the control-plane flag — nothing was changed. Try again.") return } s.audit(acct, app, addr, "control-plane", "cleared the control-plane flag") back("ok", "Cleared the control-plane flag on "+app+" — it can now be renamed, moved and deleted like any other app.") } // handleAppControlPlaneSet is the other direction, and the reason the flag is // not a trap: an owner who clears it must be able to put it back without a // database write on a box — the platform's own rule is that a job with no // API/CLI door is a product gap, not a license to open sqlite. // // Deliberately NO fresh auth (spec §6). The asymmetry is the whole design: // setting the flag only ADDS refusals, so the dangerous direction is clearing, // and that is the one that costs a passkey. Making setting expensive would // discourage the safe act and teach nobody anything. // // Owner-only all the same: declaring an app to BE the platform decides what // the account's other owners can no longer delete, which is not a member's // call. func (s *Server) handleAppControlPlaneSet(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, appSettingsURL(acct.Sqid, app, "advanced"), http.StatusSeeOther) } defer s.lockApp(acct.Sqid, app)() if msg, _ := s.temporaryRefusal(acct, app, "mark it as the control plane"); msg != "" { back("err", msg) return } cp, _, err := s.store.AppFlags(acct.ID, app) if err != nil { log.Printf("console: control-plane set read %s/%s: %v", acct.Sqid, app, err) back("err", "Could not read this app's control-plane setting — nothing was changed. Try again.") return } if cp { back("err", app+" is already marked as the control plane.") return } if err := s.claimAppMutation(r.Context(), acct, app, "protection", func(ctx context.Context) error { return s.store.SetAppControlPlaneContext(ctx, acct.ID, app, true) }); err != nil { if errors.Is(err, ErrTemporaryApp) { // A keep cannot race it: under the lock above, nothing can turn a // permanent app temporary, so a fresh ErrTemporaryApp here means // the lock did its job and this is the message to show. msg, _ := s.temporaryRefusal(acct, app, "mark it as the control plane") back("err", msg) return } log.Printf("console: control-plane set %s/%s: %v", acct.Sqid, app, err) back("err", "Could not mark this app as the control plane — nothing was changed. Try again.") return } s.audit(acct, app, addr, "control-plane", "marked "+app+" as the control plane") back("ok", "Marked "+app+" as the control plane — trashing, deleting, renaming and moving it are refused until the flag is cleared.") } // --- Ship-time refusals ----------------------------------------------------- // // The flag gates DESTRUCTION above; it also buys two REFUSALS at the ship // door, and they are here rather than in apicli.go for the same reason the // clear door is: they are the flag's meaning, and the file that says what the // flag means is this one. // targetMachine maps an artifact part name — which IS the declared target, // "linux-arm64" and friends — to the ELF machine a binary for it must carry. // Unknown targets are not refused: this guard exists to catch a wrong build of // a target we know, not to bound the target vocabulary, which targetRe // already does. var targetMachine = map[string]elf.Machine{ "linux-arm64": elf.EM_AARCH64, "linux-amd64": elf.EM_X86_64, } // buildSymbol is the symbol the release recipe stamps. Matched as a SUFFIX of // the symbol path rather than the whole of it, so a vendored or renamed module // prefix does not silently turn every artifact into "carries no // version.Build" — but as a suffix of the PATH only, never of the whole // argument (see embeddedBuild). const buildSymbol = "internal/version.Build" // checkControlPlaneArtifacts refuses two ways a control-plane ship can be // wrong in a manner nothing downstream would catch. // // ARCHITECTURE. An amd64 binary on a Graviton box has happened and briefly // broke one. `carlos deploy` defaults -target to linux-arm64, which protects // the careful caller and nobody else; this closes the same door against a // hand-run `carlos ship`, a CI job, or a script written next year. // // EMBEDDED BUILD. version.Build is set by ldflags the operator has to // remember. Forget it and the artifact says "dev" forever: the roll SUCCEEDS, // the deploy watch's probe never matches, and a perfectly healthy deploy // reports failure — the exact inverse of the lie the probe exists to kill. // Caught here it is one sentence at the door instead of a timeout nobody can // explain. // // Both read the bytes that actually arrived, and both are stdlib. Verified // against a real arm64 console build that `-s -w` strips neither the ELF // header nor the build info: debug/buildinfo still reports // Settings["-ldflags"] verbatim. // // CONTROL-PLANE APPS ONLY, and that scoping is the ruling, not an oversight: // a tenant may legitimately ship an artifact we would guess wrong about, and // refusing their ship on our inference is not our call. The console is // different because we own the boxes it lands on and know what they are. // // version is the RESOLVED version of this ship — after minting, never the // meta part's field, which is empty on a target-minted ship. func checkControlPlaneArtifacts(files map[string][]byte, version string) error { for name, b := range files { want, known := targetMachine[name] if !known { continue } // No Close: elf.NewFile over a bytes.Reader has nothing to close — // the file half of debug/elf is elf.Open's, and calling Close here // would only ever return nil. f, err := elf.NewFile(bytes.NewReader(b)) if err != nil { return fmt.Errorf("artifact %s is not a readable ELF binary: %v", name, err) } // Machine against machine, not machine against target name: the // operator's fix is a GOARCH, so the sentence has to compare the two // things a GOARCH can be. if f.Machine != want { return fmt.Errorf("artifact %s is %s machine code, but target %s wants %s — rebuild it with GOOS/GOARCH matching the target it is shipped under", name, machineName(f.Machine), name, machineName(want)) } bi, err := buildinfo.Read(bytes.NewReader(b)) if err != nil { return fmt.Errorf("artifact %s carries no Go build info: %v", name, err) } // The empty case gets its own sentence because it is the COMMON one: // a build with the ldflag simply forgotten. "was built with // version.Build=\"\"" would be true and would still leave the // operator working out that a link flag is missing. got := embeddedBuild(bi) if got == "" { return fmt.Errorf("artifact %s was linked without its build stamp, so it would report %q forever and the deploy watch would never go green — rebuild with -ldflags %q", name, "dev", `-s -w -X github.com/carlosframework/platform/internal/version.Build=`+version) } if got != version { return fmt.Errorf("artifact %s was built with version.Build=%q but is being shipped as %q — rebuild with -ldflags %q, or ship it under the version it was built as", name, got, version, `-s -w -X github.com/carlosframework/platform/internal/version.Build=`+version) } } return nil } // embeddedBuild pulls internal/version.Build out of a binary's recorded // -ldflags, returning "" when the flag is absent — which is exactly the build // that reports "dev" on the wire. // // Scanned FIELD BY FIELD rather than with one search over the whole string, so // that both spellings the linker accepts are read the same way: `-X` and its // assignment as separate arguments, and `-X=path=value` as one. // // The match is ANCHORED: each field is cut at its FIRST `=`, and the suffix // test applies to the symbol path on the left. Searching the raw field // instead — which an earlier version did — would let the needle be found // inside another flag's VALUE (`-X main.Notes=internal/version.Build=decoy`), // so a binary carrying no build stamp at all could report someone else's // string as its build. func embeddedBuild(bi *debug.BuildInfo) string { for _, s := range bi.Settings { if s.Key != "-ldflags" { continue } for _, f := range strings.Fields(s.Value) { // The one-argument spelling; the two-argument one arrives here // as the assignment on its own, with the bare `-X` skipped by // the Cut below. path, val, ok := strings.Cut(strings.TrimPrefix(f, "-X="), "=") if !ok { continue } if path == buildSymbol || strings.HasSuffix(path, "/"+buildSymbol) { return val } } } return "" } // machineName says an ELF machine the way the operator's build command says // it. Anything outside the two targets the platform ships falls back to the // elf constant, which is still more use than a number. func machineName(m elf.Machine) string { switch m { case elf.EM_AARCH64: return "arm64" case elf.EM_X86_64: return "amd64" } return m.String() }