carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Control-plane apps: the flag that says "this app IS the platform".
2//
3// It gates DESTRUCTION, not promotion, and the asymmetry is the whole design.
4// Deploying the console should be as ordinary as deploying any app — that is
5// what this slice exists to make true — so a promote gate would defeat it:
6// `stable`'s fresh-auth 403 has no API-side step-up, and `carlos deploy`
7// log.Fatalfs on a failed promote AFTER the ship has already landed
8// (deploy.go), so any gate there would leave every console deploy half-done.
9// Destroying the console is the act that deserves a wall, and it had none:
10// handlePurgeApp will delete apps/<sqid>/console/** — the release history the
11// fleet is currently running — behind an owner check, a typed name and a
12// passkey, none of which knows what this app is.
13//
14// Refused OUTRIGHT rather than behind fresh auth. A gesture proves intent,
15// and intent is not the thing in doubt: an operator who means to delete the
16// console can clear the flag first, which is a separate, deliberate act with
17// its own record.
18//
19// The one promote-side restriction is about WHO, not whether: a person with
20// ops membership may roll the control plane, a service credential may not.
21//
22// The CLEAR door lives here too (handleAppControlPlaneClear), rather than in
23// appsettings.go with the settings tab's other writes, because the refusals it
24// unlocks are in this file and the two must be read together: every refusal
25// above ends "clear the control-plane flag first", and this is the only place
26// that sentence can be acted on.
27package console
28
29import (
30 "bytes"
31 "context"
32 "crypto/subtle"
33 "debug/buildinfo"
34 "debug/elf"
35 "errors"
36 "fmt"
37 "log"
38 "net/http"
39 "runtime/debug"
40 "strings"
41)
42
43// controlPlaneRefusal answers "may this door proceed?" for a destructive verb.
44// msg is "" when the app is not the control plane and the caller may carry on;
45// otherwise msg is the sentence to show and status the code a JSON door should
46// send with it.
47//
48// The sentence NAMES THE PAGE that can clear the flag. An operator meeting one
49// of these refusals is by definition someone the platform has just stopped,
50// and "clear the control-plane flag first" without saying where is the same
51// stranded-operator problem the clear door was built to end.
52//
53// Callers MUST be past their own membership gate first — the byte-identical
54// 404 protects account membership, and consulting the flag before it would
55// turn this refusal into an oracle for which accounts hold a control-plane
56// app.
57//
58// A lookup failure REFUSES too, with a 503 rather than a 403: the alternative
59// — treating an unreadable row as "not the control plane" — makes a database
60// blip the way past the guard. The error itself is logged, never returned:
61// the caller gets one fixed sentence, as with every other store failure here.
62func (s *Server) controlPlaneRefusal(acct *Account, app, verb string) (msg string, status int) {
63 cp, _, err := s.store.AppFlags(acct.ID, app)
64 if err != nil {
65 log.Printf("console: control-plane check %s in account %s: %v", app, acct.Sqid, err)
66 return "Could not check whether " + app + " is the control plane — nothing was changed. Try again.",
67 http.StatusServiceUnavailable
68 }
69 if !cp {
70 return "", 0
71 }
72 return app + " is marked as the control plane, so it cannot be " + verb +
73 " — clear the flag first on the app's Settings tab (" + s.appSettingsURL(acct, app) +
74 ") if you really mean to.", http.StatusForbidden
75}
76
77// appSettingsURL is where both halves of the flag live, said in full: a CLI
78// caller has no page to be already looking at, and a browser flash pointing at
79// an absolute URL costs nothing.
80func (s *Server) appSettingsURL(acct *Account, app string) string {
81 return s.cfg.Origin + appSettingsURL(acct.Sqid, app, "advanced")
82}
83
84// refuseControlPlane is controlPlaneRefusal for the JSON doors: it writes the
85// refusal and returns true when the caller must stop. Browser doors render
86// pages and set flashes, so they call controlPlaneRefusal directly and hand
87// msg to their own back()/fail() closure.
88func (s *Server) refuseControlPlane(w http.ResponseWriter, acct *Account, app, verb string) bool {
89 msg, status := s.controlPlaneRefusal(acct, app, verb)
90 if msg == "" {
91 return false
92 }
93 jsonError(w, status, msg)
94 return true
95}
96
97// refuseServiceControlPlane is the release-authority gate: a service
98// credential cannot move a control-plane app's channel pointer, nor shorten
99// the history behind it; a PERSON with ops membership can, and must be able
100// to — gating a person on promote would break `carlos deploy -app console`,
101// the command this work exists to make ordinary.
102//
103// action is a whole CLAUSE, not a participle ("promote it", "roll it back",
104// "change its release retention"), because the sentence it lands in has to
105// read as English for three different verbs and "cannot roll back it" is not
106// English.
107//
108// The flag is only read for a service, so a person's promote does not grow a
109// database round trip it has no use for.
110func (s *Server) refuseServiceControlPlane(w http.ResponseWriter, p Principal, accountID int64, app, action string) bool {
111 if p.Kind == PrincipalUser {
112 return false
113 }
114 cp, _, err := s.store.AppFlags(accountID, app)
115 if err != nil {
116 log.Printf("console: control-plane check %s in account %d: %v", app, accountID, err)
117 jsonError(w, http.StatusServiceUnavailable,
118 "could not check this app's control-plane status — nothing was changed")
119 return true
120 }
121 if !cp {
122 return false
123 }
124 jsonError(w, http.StatusForbidden,
125 app+" is the control plane — a service credential cannot "+action+"; a person must")
126 return true
127}
128
129// controlPlaneAppsIn lists the apps in an account carrying the control-plane
130// flag, TRASHED ROWS INCLUDED: a trashed app still owns its prefix in the
131// bucket — the release history the fleet is running is exactly what a trash
132// marker does not destroy — so it is still a reason to refuse the account's
133// retirement. (AppCount, handleDeleteAccount's older guard, counts the same
134// way for the same reason.)
135func (s *Store) controlPlaneAppsIn(accountID int64) ([]string, error) {
136 rows, err := s.db.Query(`SELECT app FROM apps WHERE account_id = ? AND control_plane = 1 ORDER BY app`, accountID)
137 if err != nil {
138 return nil, err
139 }
140 defer rows.Close()
141 var out []string
142 for rows.Next() {
143 var app string
144 if err := rows.Scan(&app); err != nil {
145 return nil, err
146 }
147 out = append(out, app)
148 }
149 return out, rows.Err()
150}
151
152// controlPlaneAccountRefusal is the same guard one level up: an account
153// holding a control-plane app cannot be RETIRED either. Refusing the app's own
154// destructive doors while the account above it could still be deleted would be
155// a guard with a published bypass, which reads as protection and is not.
156//
157// msg is "" when the account is free to go. A lookup failure refuses, on the
158// same reasoning as controlPlaneRefusal: a database blip must not be the way
159// past the guard.
160//
161// In practice handleDeleteAccount's AppCount refusal already catches every
162// account this could fire on, since an account holding ANY app row is refused
163// — but that guard is about cascading deletes, is written to change for its
164// own reasons, and sits BELOW the passkey. This one is about the flag, names
165// the app, and refuses before a gesture is spent.
166func (s *Server) controlPlaneAccountRefusal(accountID int64) string {
167 apps, err := s.store.controlPlaneAppsIn(accountID)
168 if err != nil {
169 log.Printf("console: control-plane check for account %d: %v", accountID, err)
170 return "Could not check whether this account holds a control-plane app. Nothing was deleted."
171 }
172 if len(apps) == 0 {
173 return ""
174 }
175 return "“" + strings.Join(apps, "”, “") + "” " + isare(len(apps)) +
176 " marked as the control plane, so this account cannot be deleted — clear the control-plane flag first if you really mean to."
177}
178
179func isare(n int) string {
180 if n == 1 {
181 return "is"
182 }
183 return "are"
184}
185
186// handleAppControlPlaneClear takes the flag off, which is the only way past
187// every refusal in this file.
188//
189// BROWSER ONLY, and that is the design rather than a gap (spec §6). Setting
190// the flag is easy — the CLI, at claim time — and clearing it is hard: the
191// owner, in a browser, with a passkey. There is deliberately no API door: the
192// console's fresh-auth 403 is a dead end for an API caller (the same defect
193// that makes a promote gate unworkable), so a CLI clear would have to be
194// either ungated, which defeats the flag, or unusable, which is worse than
195// absent.
196//
197// The gate order is handleAppRename's, unchanged:
198//
199// accountApp — session, then the app's byte-identical 404.
200// CSRF — constant-time, before any pending action can be parked.
201// owner — a member probing this gets a stranger's answer.
202// nothing to clear — a cheap refusal, so an already-clear app never costs a
203// passkey touch.
204// requireFreshAuth — LAST, so no redirect into Keymail ever happens for
205// someone who should have got a 404.
206//
207// No serial bump: the flag is this console's own row and no box parses it.
208func (s *Server) handleAppControlPlaneClear(w http.ResponseWriter, r *http.Request) {
209 addr, raw, acct, role, app, ok := s.accountApp(w, r)
210 if !ok {
211 return
212 }
213 if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
214 s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
215 return
216 }
217 if role != RoleOwner {
218 http.NotFound(w, r)
219 return
220 }
221 back := func(kind, msg string) {
222 s.setFlash(w, kind, msg)
223 http.Redirect(w, r, appSettingsURL(acct.Sqid, app, "advanced"), http.StatusSeeOther)
224 }
225 // Fail CLOSED on an unreadable row, like every other reader of this flag:
226 // clearing what may already be clear is harmless, but a lookup error here
227 // would otherwise be reported to the operator as a successful clear.
228 cp, _, err := s.store.AppFlags(acct.ID, app)
229 if err != nil {
230 log.Printf("console: control-plane clear read %s/%s: %v", acct.Sqid, app, err)
231 back("err", "Could not read this app's control-plane setting — nothing was changed. Try again.")
232 return
233 }
234 if !cp {
235 back("err", app+" is not marked as the control plane, so there is nothing to clear.")
236 return
237 }
238 if !s.requireFreshAuth(w, r, addr, raw) {
239 return
240 }
241 if err := s.claimAppMutation(r.Context(), acct, app, "protection", func(ctx context.Context) error {
242 return s.store.SetAppControlPlaneContext(ctx, acct.ID, app, false)
243 }); err != nil {
244 log.Printf("console: control-plane clear %s/%s: %v", acct.Sqid, app, err)
245 back("err", "Could not clear the control-plane flag — nothing was changed. Try again.")
246 return
247 }
248 s.audit(acct, app, addr, "control-plane", "cleared the control-plane flag")
249 back("ok", "Cleared the control-plane flag on "+app+" — it can now be renamed, moved and deleted like any other app.")
250}
251
252// handleAppControlPlaneSet is the other direction, and the reason the flag is
253// not a trap: an owner who clears it must be able to put it back without a
254// database write on a box — the platform's own rule is that a job with no
255// API/CLI door is a product gap, not a license to open sqlite.
256//
257// Deliberately NO fresh auth (spec §6). The asymmetry is the whole design:
258// setting the flag only ADDS refusals, so the dangerous direction is clearing,
259// and that is the one that costs a passkey. Making setting expensive would
260// discourage the safe act and teach nobody anything.
261//
262// Owner-only all the same: declaring an app to BE the platform decides what
263// the account's other owners can no longer delete, which is not a member's
264// call.
265func (s *Server) handleAppControlPlaneSet(w http.ResponseWriter, r *http.Request) {
266 addr, raw, acct, role, app, ok := s.accountApp(w, r)
267 if !ok {
268 return
269 }
270 if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
271 s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
272 return
273 }
274 if role != RoleOwner {
275 http.NotFound(w, r)
276 return
277 }
278 back := func(kind, msg string) {
279 s.setFlash(w, kind, msg)
280 http.Redirect(w, r, appSettingsURL(acct.Sqid, app, "advanced"), http.StatusSeeOther)
281 }
282 defer s.lockApp(acct.Sqid, app)()
283 if msg, _ := s.temporaryRefusal(acct, app, "mark it as the control plane"); msg != "" {
284 back("err", msg)
285 return
286 }
287 cp, _, err := s.store.AppFlags(acct.ID, app)
288 if err != nil {
289 log.Printf("console: control-plane set read %s/%s: %v", acct.Sqid, app, err)
290 back("err", "Could not read this app's control-plane setting — nothing was changed. Try again.")
291 return
292 }
293 if cp {
294 back("err", app+" is already marked as the control plane.")
295 return
296 }
297 if err := s.claimAppMutation(r.Context(), acct, app, "protection", func(ctx context.Context) error {
298 return s.store.SetAppControlPlaneContext(ctx, acct.ID, app, true)
299 }); err != nil {
300 if errors.Is(err, ErrTemporaryApp) {
301 // A keep cannot race it: under the lock above, nothing can turn a
302 // permanent app temporary, so a fresh ErrTemporaryApp here means
303 // the lock did its job and this is the message to show.
304 msg, _ := s.temporaryRefusal(acct, app, "mark it as the control plane")
305 back("err", msg)
306 return
307 }
308 log.Printf("console: control-plane set %s/%s: %v", acct.Sqid, app, err)
309 back("err", "Could not mark this app as the control plane — nothing was changed. Try again.")
310 return
311 }
312 s.audit(acct, app, addr, "control-plane", "marked "+app+" as the control plane")
313 back("ok", "Marked "+app+" as the control plane — trashing, deleting, renaming and moving it are refused until the flag is cleared.")
314}
315
316// --- Ship-time refusals -----------------------------------------------------
317//
318// The flag gates DESTRUCTION above; it also buys two REFUSALS at the ship
319// door, and they are here rather than in apicli.go for the same reason the
320// clear door is: they are the flag's meaning, and the file that says what the
321// flag means is this one.
322
323// targetMachine maps an artifact part name — which IS the declared target,
324// "linux-arm64" and friends — to the ELF machine a binary for it must carry.
325// Unknown targets are not refused: this guard exists to catch a wrong build of
326// a target we know, not to bound the target vocabulary, which targetRe
327// already does.
328var targetMachine = map[string]elf.Machine{
329 "linux-arm64": elf.EM_AARCH64,
330 "linux-amd64": elf.EM_X86_64,
331}
332
333// buildSymbol is the symbol the release recipe stamps. Matched as a SUFFIX of
334// the symbol path rather than the whole of it, so a vendored or renamed module
335// prefix does not silently turn every artifact into "carries no
336// version.Build" — but as a suffix of the PATH only, never of the whole
337// argument (see embeddedBuild).
338const buildSymbol = "internal/version.Build"
339
340// checkControlPlaneArtifacts refuses two ways a control-plane ship can be
341// wrong in a manner nothing downstream would catch.
342//
343// ARCHITECTURE. An amd64 binary on a Graviton box has happened and briefly
344// broke one. `carlos deploy` defaults -target to linux-arm64, which protects
345// the careful caller and nobody else; this closes the same door against a
346// hand-run `carlos ship`, a CI job, or a script written next year.
347//
348// EMBEDDED BUILD. version.Build is set by ldflags the operator has to
349// remember. Forget it and the artifact says "dev" forever: the roll SUCCEEDS,
350// the deploy watch's probe never matches, and a perfectly healthy deploy
351// reports failure — the exact inverse of the lie the probe exists to kill.
352// Caught here it is one sentence at the door instead of a timeout nobody can
353// explain.
354//
355// Both read the bytes that actually arrived, and both are stdlib. Verified
356// against a real arm64 console build that `-s -w` strips neither the ELF
357// header nor the build info: debug/buildinfo still reports
358// Settings["-ldflags"] verbatim.
359//
360// CONTROL-PLANE APPS ONLY, and that scoping is the ruling, not an oversight:
361// a tenant may legitimately ship an artifact we would guess wrong about, and
362// refusing their ship on our inference is not our call. The console is
363// different because we own the boxes it lands on and know what they are.
364//
365// version is the RESOLVED version of this ship — after minting, never the
366// meta part's field, which is empty on a target-minted ship.
367func checkControlPlaneArtifacts(files map[string][]byte, version string) error {
368 for name, b := range files {
369 want, known := targetMachine[name]
370 if !known {
371 continue
372 }
373 // No Close: elf.NewFile over a bytes.Reader has nothing to close —
374 // the file half of debug/elf is elf.Open's, and calling Close here
375 // would only ever return nil.
376 f, err := elf.NewFile(bytes.NewReader(b))
377 if err != nil {
378 return fmt.Errorf("artifact %s is not a readable ELF binary: %v", name, err)
379 }
380 // Machine against machine, not machine against target name: the
381 // operator's fix is a GOARCH, so the sentence has to compare the two
382 // things a GOARCH can be.
383 if f.Machine != want {
384 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",
385 name, machineName(f.Machine), name, machineName(want))
386 }
387 bi, err := buildinfo.Read(bytes.NewReader(b))
388 if err != nil {
389 return fmt.Errorf("artifact %s carries no Go build info: %v", name, err)
390 }
391 // The empty case gets its own sentence because it is the COMMON one:
392 // a build with the ldflag simply forgotten. "was built with
393 // version.Build=\"\"" would be true and would still leave the
394 // operator working out that a link flag is missing.
395 got := embeddedBuild(bi)
396 if got == "" {
397 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",
398 name, "dev", `-s -w -X github.com/carlosframework/platform/internal/version.Build=`+version)
399 }
400 if got != version {
401 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",
402 name, got, version, `-s -w -X github.com/carlosframework/platform/internal/version.Build=`+version)
403 }
404 }
405 return nil
406}
407
408// embeddedBuild pulls internal/version.Build out of a binary's recorded
409// -ldflags, returning "" when the flag is absent — which is exactly the build
410// that reports "dev" on the wire.
411//
412// Scanned FIELD BY FIELD rather than with one search over the whole string, so
413// that both spellings the linker accepts are read the same way: `-X` and its
414// assignment as separate arguments, and `-X=path=value` as one.
415//
416// The match is ANCHORED: each field is cut at its FIRST `=`, and the suffix
417// test applies to the symbol path on the left. Searching the raw field
418// instead — which an earlier version did — would let the needle be found
419// inside another flag's VALUE (`-X main.Notes=internal/version.Build=decoy`),
420// so a binary carrying no build stamp at all could report someone else's
421// string as its build.
422func embeddedBuild(bi *debug.BuildInfo) string {
423 for _, s := range bi.Settings {
424 if s.Key != "-ldflags" {
425 continue
426 }
427 for _, f := range strings.Fields(s.Value) {
428 // The one-argument spelling; the two-argument one arrives here
429 // as the assignment on its own, with the bare `-X` skipped by
430 // the Cut below.
431 path, val, ok := strings.Cut(strings.TrimPrefix(f, "-X="), "=")
432 if !ok {
433 continue
434 }
435 if path == buildSymbol || strings.HasSuffix(path, "/"+buildSymbol) {
436 return val
437 }
438 }
439 }
440 return ""
441}
442
443// machineName says an ELF machine the way the operator's build command says
444// it. Anything outside the two targets the platform ships falls back to the
445// elf constant, which is still more use than a number.
446func machineName(m elf.Machine) string {
447 switch m {
448 case elf.EM_AARCH64:
449 return "arm64"
450 case elf.EM_X86_64:
451 return "amd64"
452 }
453 return m.String()
454}
455