| 1 | // The console's HTTP server: hand-written HTML from Go templates, no |
| 2 | // JavaScript, no build step, no external resources. Auth model: OAuth at |
| 3 | // Keymail is the ONE-TIME sign-up ceremony; returning sign-ins are a |
| 4 | // single-use confirmation link the console delivers into the user's Keymail |
| 5 | // Apps inbox with its stored grant. |
| 6 | package console |
| 7 | |
| 8 | import ( |
| 9 | "context" |
| 10 | "crypto/ed25519" |
| 11 | "crypto/subtle" |
| 12 | "crypto/x509" |
| 13 | "embed" |
| 14 | "encoding/json" |
| 15 | "errors" |
| 16 | "fmt" |
| 17 | "html/template" |
| 18 | "log" |
| 19 | "math" |
| 20 | mrand "math/rand/v2" |
| 21 | "net" |
| 22 | "net/http" |
| 23 | netmail "net/mail" |
| 24 | "net/url" |
| 25 | "os" |
| 26 | "regexp" |
| 27 | "slices" |
| 28 | "sort" |
| 29 | "strings" |
| 30 | "sync" |
| 31 | "sync/atomic" |
| 32 | "time" |
| 33 | |
| 34 | "github.com/aws/aws-sdk-go-v2/service/costexplorer" |
| 35 | "github.com/carlosframework/platform/internal/appconfig" |
| 36 | "github.com/carlosframework/platform/internal/applog" |
| 37 | "github.com/carlosframework/platform/internal/boxid" |
| 38 | "github.com/carlosframework/platform/internal/dbreads" |
| 39 | "github.com/carlosframework/platform/internal/dnsrec" |
| 40 | "github.com/carlosframework/platform/internal/domains" |
| 41 | "github.com/carlosframework/platform/internal/economics" |
| 42 | "github.com/carlosframework/platform/internal/edgesfile" |
| 43 | "github.com/carlosframework/platform/internal/geo" |
| 44 | "github.com/carlosframework/platform/internal/hostclaim" |
| 45 | "github.com/carlosframework/platform/internal/hostops" |
| 46 | "github.com/carlosframework/platform/internal/infraview" |
| 47 | "github.com/carlosframework/platform/internal/lease" |
| 48 | "github.com/carlosframework/platform/internal/mail" |
| 49 | "github.com/carlosframework/platform/internal/moves" |
| 50 | "github.com/carlosframework/platform/internal/objstore" |
| 51 | "github.com/carlosframework/platform/internal/plans" |
| 52 | "github.com/carlosframework/platform/internal/provision" |
| 53 | "github.com/carlosframework/platform/internal/publicrelease" |
| 54 | "github.com/carlosframework/platform/internal/registry" |
| 55 | "github.com/carlosframework/platform/internal/release" |
| 56 | "github.com/carlosframework/platform/internal/status" |
| 57 | "github.com/carlosframework/platform/internal/svcrole" |
| 58 | "github.com/carlosframework/platform/internal/telemetry" |
| 59 | "github.com/carlosframework/platform/internal/usage" |
| 60 | "github.com/carlosframework/platform/internal/version" |
| 61 | ) |
| 62 | |
| 63 | //go:embed templates/*.html |
| 64 | var tmplFS embed.FS |
| 65 | |
| 66 | var templates = template.Must(template.New("").Funcs(template.FuncMap{ |
| 67 | "fmtTime": func(t time.Time) string { return t.UTC().Format("2006-01-02 15:04 UTC") }, |
| 68 | // until renders a future horizon the way the cards speak: "~4h", never a |
| 69 | // timestamp — the safety-delay strip's "goes live ~4h" and Task 6's |
| 70 | // summary strip both read it this way. |
| 71 | "until": untilText, |
| 72 | // ago renders a past instant the way a person scanning a dashboard reads |
| 73 | // one — "2h4m ago" — at humanAge's deliberately coarse resolution. The |
| 74 | // absolute stamp stays available where precision matters (title attrs, |
| 75 | // the Activity tab); a zero time is unknown, so it dashes out. |
| 76 | "ago": func(t time.Time) string { |
| 77 | if t.IsZero() { |
| 78 | return "—" |
| 79 | } |
| 80 | return humanAge(time.Since(t)) + " ago" |
| 81 | }, |
| 82 | // trashCooloffDays is the deletion policy's restore window, in the unit |
| 83 | // the Settings page speaks. One source: release.TrashCooloff. |
| 84 | "trashCooloffDays": func() int { return int(release.TrashCooloff.Hours() / 24) }, |
| 85 | // temporaryCooloffDays is a temporary app's restore window, in days: the |
| 86 | // delete confirmation shows it for an app with an expiry (temporary static |
| 87 | // sites spec §5, "the delete confirmation in the browser"). |
| 88 | "temporaryCooloffDays": func() int { return int(release.TemporaryCooloff.Hours() / 24) }, |
| 89 | // The fleet template's em-dash helpers: fleet.json's null-propagation |
| 90 | // rule (internal/status/fleet.go) means these *string/*int/*bool fields |
| 91 | // are unknown, not zero, and unknown must render as a titled em-dash — |
| 92 | // never a blank cell or a guessed value — same rule index.html's JS |
| 93 | // enforces client-side (see asText/asBool there). |
| 94 | "strOrDash": func(v *string) string { |
| 95 | if v == nil { |
| 96 | return "—" |
| 97 | } |
| 98 | return *v |
| 99 | }, |
| 100 | "intOrDash": func(v *int) string { |
| 101 | if v == nil { |
| 102 | return "—" |
| 103 | } |
| 104 | return fmt.Sprint(*v) |
| 105 | }, |
| 106 | "boolWord": func(v *bool, whenTrue, whenFalse string) string { |
| 107 | if v == nil { |
| 108 | return "—" |
| 109 | } |
| 110 | if *v { |
| 111 | return whenTrue |
| 112 | } |
| 113 | return whenFalse |
| 114 | }, |
| 115 | "emptyOrDash": func(s string) string { |
| 116 | if s == "" { |
| 117 | return "—" |
| 118 | } |
| 119 | return s |
| 120 | }, |
| 121 | // pct renders a fraction (0..1, as CPU/Mem/Disk readings and unit CPU |
| 122 | // fractions are stored — internal/metrics.HostSample/UnitSample) as a |
| 123 | // whole-percent string; nil means no reading, never zero, so it renders |
| 124 | // the same em-dash every other "unknown" field on this console does. |
| 125 | "pct": func(v *float64) string { |
| 126 | if v == nil { |
| 127 | return "—" |
| 128 | } |
| 129 | return fmt.Sprintf("%.0f%%", *v*100) |
| 130 | }, |
| 131 | // alertVal renders one AlertRow's value in ITS OWN rule's unit — |
| 132 | // see alertValueText. LoadMetrics only ever returns firing rules, so an |
| 133 | // alert row always carries a value; there is no nil case to dash out. |
| 134 | "alertVal": alertValueText, |
| 135 | // healthSlug: the Running tab's health-filter links and the hidden |
| 136 | // carried-filter field both need a health word's URL form (listquery.go). |
| 137 | "healthSlug": healthSlug, |
| 138 | "healthTone": healthTone, |
| 139 | "instanceRowPage": func(p page, row InstanceRow) any { |
| 140 | return struct { |
| 141 | Page page |
| 142 | Row InstanceRow |
| 143 | }{p, row} |
| 144 | }, |
| 145 | // logLines: the Logs tab's window folded into the terminal pane's lines |
| 146 | // (logsview.go). The padding is computed across the whole window, so it |
| 147 | // has to be one call over all the records, not one per row. |
| 148 | "logLines": logLines, |
| 149 | // sortedKV: the Analytics tab's three top-key tables (analyticsview.go). |
| 150 | // A Go template ranges a map in key order, which would sort a top-pages |
| 151 | // table alphabetically — the one order that hides which page is the |
| 152 | // popular one. This puts the biggest first and leaves the overflow |
| 153 | // bucket last, wherever its count happens to land. |
| 154 | "sortedKV": sortedKV, |
| 155 | // geoCountries and geoAttributions: the Analytics tab's Countries table |
| 156 | // and the credit its sources require (geoip spec §9, §13). |
| 157 | "geoCountries": geoCountries, |
| 158 | "geoLabel": geoLabel, |
| 159 | "geoAttributions": geoAttributions, |
| 160 | // bytesOrDash renders a byte count in the smallest unit that keeps it |
| 161 | // readable; nil (no reading, from a unit that reported CPU but not |
| 162 | // memory/IO that tick) is "—", never a fabricated 0 B. |
| 163 | "bytesOrDash": func(v *int64) string { |
| 164 | if v == nil { |
| 165 | return "—" |
| 166 | } |
| 167 | b := float64(*v) |
| 168 | switch { |
| 169 | case b >= 1<<30: |
| 170 | return fmt.Sprintf("%.1f GB", b/(1<<30)) |
| 171 | case b >= 1<<20: |
| 172 | return fmt.Sprintf("%.1f MB", b/(1<<20)) |
| 173 | case b >= 1<<10: |
| 174 | return fmt.Sprintf("%.1f KB", b/(1<<10)) |
| 175 | default: |
| 176 | return fmt.Sprintf("%d B", *v) |
| 177 | } |
| 178 | }, |
| 179 | // loadOrDash renders a 1-minute load average: an absolute number of |
| 180 | // runnable tasks, not a fraction, so pct (which multiplies by 100 and |
| 181 | // stamps a %) would be a lie about the same *float64. nil is the same |
| 182 | // em-dash every unknown on this console gets. |
| 183 | "loadOrDash": func(v *float64) string { |
| 184 | if v == nil { |
| 185 | return "—" |
| 186 | } |
| 187 | return fmt.Sprintf("%.2f", *v) |
| 188 | }, |
| 189 | // mapGet reads one named field out of Fleet.Instances — a map so a |
| 190 | // template range would iterate it key-sorted, alphabetically, which |
| 191 | // scrambles the static/resident/hibernating/awake/asleep order the |
| 192 | // public page and the design spec both fix; indexing by name keeps that |
| 193 | // order explicit in the template instead. |
| 194 | "mapGet": func(m map[string]*int, k string) *int { return m[k] }, |
| 195 | // icon inlines one vendored Lucide SVG by slug — see icons.go for the |
| 196 | // set, the licence, and why template.HTML is safe there and nowhere |
| 197 | // else. Chrome is drawn; emoji stay content. |
| 198 | "icon": icon, |
| 199 | // join renders fleetPageBox.Tags back into the tag form's editable |
| 200 | // text input, comma-separated — the exact grammar SetFleetBoxTags |
| 201 | // expects back, so an owner opening the form sees the same string they'd |
| 202 | // type to leave it unchanged. |
| 203 | "join": strings.Join, |
| 204 | // fleetName reads the fleet out of a placement target ("fleet/pi" -> "pi") |
| 205 | // so the Placement section can NAME the fleet and link to its page rather |
| 206 | // than printing the raw Region value at a customer. A target that does not |
| 207 | // parse yields "", which the template's own {{if}} already handles as |
| 208 | // "not placed" — the same reading appPlacement itself takes. |
| 209 | "fleetName": func(target string) string { f, _ := registry.FleetPlacement(target); return f }, |
| 210 | |
| 211 | // ---- /fleet/economics (Task 13): server-side arithmetic mirroring |
| 212 | // internal/economics/compute.go, so the page's initial render needs no |
| 213 | // JS to show real numbers — see economics.html's inline script for the |
| 214 | // live-recompute twin of every function below (same names, JS-cased). |
| 215 | |
| 216 | // usd renders a signed dollar figure — negative (a box or account |
| 217 | // running at a loss under the active scenario) as "-$N.NN", never a |
| 218 | // bare minus glued to the sign the reader expects at the front. |
| 219 | "usd": func(v float64) string { |
| 220 | if math.IsNaN(v) || math.IsInf(v, 0) { |
| 221 | return "—" |
| 222 | } |
| 223 | if v < 0 { |
| 224 | return fmt.Sprintf("-$%.2f", -v) |
| 225 | } |
| 226 | return fmt.Sprintf("$%.2f", v) |
| 227 | }, |
| 228 | // pctf is pct (above) for a plain, never-nil float64 — margin and |
| 229 | // utilization fractions (economics.AccountEconomics.MarginFrac, |
| 230 | // BoxEconomics.Utilization) are always populated, never a *float64 |
| 231 | // "no reading yet" case pct exists to dash out. |
| 232 | "pctf": func(v float64) string { return fmt.Sprintf("%.0f%%", v*100) }, |
| 233 | // marginMultiple renders cost-plus's margin knob the way an operator |
| 234 | // thinks about markup, not the accounting fraction it's stored as: |
| 235 | // multiple = 1/(1-margin), so the default 0.8 reads "5.0× cost" per the |
| 236 | // brief. Out of Validate's legal [0,1) range is "—", never a divide by |
| 237 | // zero or a negative multiple. |
| 238 | "marginMultiple": func(margin float64) string { |
| 239 | if margin < 0 || margin >= 1 { |
| 240 | return "—" |
| 241 | } |
| 242 | return fmt.Sprintf("%.1f×", 1/(1-margin)) |
| 243 | }, |
| 244 | // marginCell renders MarginFrac as pctf normally, but an em-dash for a |
| 245 | // pooled row (FreeTierPos=="pooled") — Task 6's carried ruling: a |
| 246 | // pool/try/""-tier account's revenue is pooled, not individually priced |
| 247 | // (buildEconomicsView's tier override), so its MarginFrac is genuinely |
| 248 | // 0 — but "0%" reads as an account LOSING money, not one that simply |
| 249 | // isn't priced on its own. The JS twin (econ_maths_js's marginCell) |
| 250 | // mirrors this exactly. |
| 251 | "marginCell": func(frac float64, pos string) string { |
| 252 | if pos == "pooled" { |
| 253 | return "—" |
| 254 | } |
| 255 | return fmt.Sprintf("%.0f%%", frac*100) |
| 256 | }, |
| 257 | // tierWord renders a fleet-pool tier (store.go's validAccountTiers) for |
| 258 | // display: "" (never assigned) reads "unassigned" rather than a blank |
| 259 | // chip label; every other value (try/pool/hosted/fleet/dedicated) |
| 260 | // passes through unchanged. Feeds both a tier chip's visible text and |
| 261 | // its "chip-tier-<word>" class (economics_accounts.html, Task 6). |
| 262 | "tierWord": func(tier string) string { |
| 263 | if tier == "" { |
| 264 | return "unassigned" |
| 265 | } |
| 266 | return tier |
| 267 | }, |
| 268 | // tierKnown reports whether acct has a real console account row at all |
| 269 | // (present as a key in EconomicsView.Tiers, via the comma-ok form — |
| 270 | // html/template's "index" can't distinguish absent from an explicit "" |
| 271 | // tier, which is exactly the distinction this exists for). A usage-only |
| 272 | // account (fleet or edge traffic with no matching accounts-table row — |
| 273 | // e.g. a fleet-only account whose owner never signed up) is absent from |
| 274 | // the map entirely: economics_accounts.html renders its tier chip but |
| 275 | // WITHOUT the per-row tier-set form, since POSTing that form's account |
| 276 | // sqid to /fleet/economics/tier can only ever fail with "No such |
| 277 | // account." (handleEconomicsTierSave's AccountBySqid lookup) — fix |
| 278 | // round, Task 6 review, IMPORTANT 2. |
| 279 | "tierKnown": func(tiers map[string]string, acct string) bool { |
| 280 | _, ok := tiers[acct] |
| 281 | return ok |
| 282 | }, |
| 283 | "add": func(vs ...float64) float64 { |
| 284 | var sum float64 |
| 285 | for _, v := range vs { |
| 286 | sum += v |
| 287 | } |
| 288 | return sum |
| 289 | }, |
| 290 | "sub": func(a, b float64) float64 { return a - b }, |
| 291 | "mul": func(a, b float64) float64 { return a * b }, |
| 292 | "div": func(a, b float64) float64 { |
| 293 | if b == 0 { |
| 294 | return 0 |
| 295 | } |
| 296 | return a / b |
| 297 | }, |
| 298 | // project is economics.Project (compute.go), exposed so the headline |
| 299 | // tiles' month-to-date figures can carry a "projected for the month" |
| 300 | // second line — the spec's "month-to-date with projection" promise. Only |
| 301 | // ever called from economics.html's tiles when Period=="mtd" and |
| 302 | // CoveredDays>0; the JS twin (economics.html's econ_maths_js define) is |
| 303 | // the exact transliteration a knob change re-runs. |
| 304 | "project": func(mtd float64, coveredDays, periodDays int) float64 { |
| 305 | return economics.Project(mtd, coveredDays, periodDays) |
| 306 | }, |
| 307 | // marginOf is economics.PriceAccount's margin formula |
| 308 | // ((revenue-cost)/revenue, 0 when revenue is 0), applied to the |
| 309 | // dashboard's period TOTALS rather than one account — the "achieved |
| 310 | // margin" headline tile. |
| 311 | "marginOf": func(revenue, cost float64) float64 { |
| 312 | if revenue <= 0 { |
| 313 | return 0 |
| 314 | } |
| 315 | return (revenue - cost) / revenue |
| 316 | }, |
| 317 | // sumAcctCost/sumAcctRevenue/sumBoxIdle fold EconomicsView.Accounts / |
| 318 | // .Boxes into the headline tiles' period totals: total cost is every |
| 319 | // account's allocated+metered cost, PLUS every box's unallocated idle |
| 320 | // headroom (never smeared across tenants — economics.AllocateBox's own |
| 321 | // doc comment), PLUS fixed overheads (overheadUSD below); total revenue |
| 322 | // is simply the sum of what every account would owe. |
| 323 | "sumAcctCost": func(accts []economics.AccountEconomics) float64 { |
| 324 | var sum float64 |
| 325 | for _, a := range accts { |
| 326 | sum += a.CostUSD |
| 327 | } |
| 328 | return sum |
| 329 | }, |
| 330 | "sumAcctRevenue": func(accts []economics.AccountEconomics) float64 { |
| 331 | var sum float64 |
| 332 | for _, a := range accts { |
| 333 | sum += a.RevenueUSD |
| 334 | } |
| 335 | return sum |
| 336 | }, |
| 337 | "sumBoxIdle": func(boxes []BoxEconomics) float64 { |
| 338 | var sum float64 |
| 339 | for _, b := range boxes { |
| 340 | sum += b.IdleUSD |
| 341 | } |
| 342 | return sum |
| 343 | }, |
| 344 | // overheadUSD prorates the rate card's fixed monthly LineItems |
| 345 | // (domains, DNS, ...) over the period being viewed, by the same |
| 346 | // hours-over-HoursPerMonth proration economics.AllocateBox uses for a |
| 347 | // box's EBS line — periodDays rather than CoveredDays, deliberately: |
| 348 | // overhead is a standing monthly bill that runs whether or not usage |
| 349 | // was fully sampled, so it prorates by calendar days in the period, not |
| 350 | // by how much of it the sweep actually covered. |
| 351 | "overheadUSD": func(sc economics.Scenario, periodDays int) float64 { |
| 352 | var monthly float64 |
| 353 | for _, li := range sc.RateCard.Overheads { |
| 354 | monthly += li.USDMonth |
| 355 | } |
| 356 | return monthly * float64(periodDays) * 24 / economics.HoursPerMonth |
| 357 | }, |
| 358 | // sumMeter totals one named meter (usage.Meter* — "egress_gb", |
| 359 | // "requests_1k", "storage_gb_month", ...) across every account in |
| 360 | // EconomicsView.Meters, for the model-side breakdown's S3/egress lines. |
| 361 | "sumMeter": func(meters map[string]map[string]float64, meter string) float64 { |
| 362 | var sum float64 |
| 363 | for _, mm := range meters { |
| 364 | sum += mm[meter] |
| 365 | } |
| 366 | return sum |
| 367 | }, |
| 368 | // sumBoxesEC2/sumBoxesEBS split each box's total cost (BoxEconomics |
| 369 | // carries the sum, not the two components) back into the rate card's |
| 370 | // two box-cost lines for the model-side breakdown, using the same |
| 371 | // box-then-default spec lookup economics.AllocateBox performs. |
| 372 | "sumBoxesEC2": func(boxes []BoxEconomics, rc economics.RateCard) float64 { |
| 373 | var sum float64 |
| 374 | for _, b := range boxes { |
| 375 | spec, ok := rc.Boxes[b.Box] |
| 376 | if !ok { |
| 377 | spec = rc.Boxes[""] |
| 378 | } |
| 379 | sum += spec.HourUSD * b.Hours |
| 380 | } |
| 381 | return sum |
| 382 | }, |
| 383 | "sumBoxesEBS": func(boxes []BoxEconomics, rc economics.RateCard) float64 { |
| 384 | var sum float64 |
| 385 | for _, b := range boxes { |
| 386 | spec, ok := rc.Boxes[b.Box] |
| 387 | if !ok { |
| 388 | spec = rc.Boxes[""] |
| 389 | } |
| 390 | sum += spec.EBSGB * rc.EBSGBMonthUSD * (b.Hours / economics.HoursPerMonth) |
| 391 | } |
| 392 | return sum |
| 393 | }, |
| 394 | // periodMonth/billForMonth resolve which of EconomicsView.Bills (this |
| 395 | // month + last, always both if present regardless of period — |
| 396 | // loadRecentBills's doc comment) corresponds to the period on screen, |
| 397 | // so the breakdown reconciles against the RIGHT bill: "last" looks at |
| 398 | // last calendar month's, "mtd"/"7d" both look at this one. |
| 399 | "periodMonth": func(period string) string { |
| 400 | now := time.Now().UTC() |
| 401 | if period == "last" { |
| 402 | firstOfThisMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) |
| 403 | return usage.MonthOf(firstOfThisMonth.AddDate(0, -1, 0)) |
| 404 | } |
| 405 | return usage.MonthOf(now) |
| 406 | }, |
| 407 | "billForMonth": func(bills []economics.Bill, month string) *economics.Bill { |
| 408 | for i := range bills { |
| 409 | if bills[i].Month == month { |
| 410 | return &bills[i] |
| 411 | } |
| 412 | } |
| 413 | return nil |
| 414 | }, |
| 415 | // billServiceChecked is the "counts as CARLOS" default rule, ONE place |
| 416 | // for both the checkbox render and the delta sum below: an EMPTY |
| 417 | // Scenario.BillServices counts every service (an old scenario, or one |
| 418 | // nobody has ever unchecked anything on), a NON-empty one counts only |
| 419 | // what's named. The inline JS mirrors this exact rule (billServiceChecked) |
| 420 | // when a knob or checkbox changes. |
| 421 | "billServiceChecked": func(billServices []string, service string) bool { |
| 422 | if len(billServices) == 0 { |
| 423 | return true |
| 424 | } |
| 425 | for _, s := range billServices { |
| 426 | if s == service { |
| 427 | return true |
| 428 | } |
| 429 | } |
| 430 | return false |
| 431 | }, |
| 432 | // sumBillServicesChecked totals a bill's Services map, restricted to the |
| 433 | // ones billServiceChecked keeps — the breakdown's bill-side total. |
| 434 | "sumBillServicesChecked": func(services map[string]float64, billServices []string) float64 { |
| 435 | var sum float64 |
| 436 | for name, v := range services { |
| 437 | if len(billServices) == 0 { |
| 438 | sum += v |
| 439 | continue |
| 440 | } |
| 441 | for _, s := range billServices { |
| 442 | if s == name { |
| 443 | sum += v |
| 444 | break |
| 445 | } |
| 446 | } |
| 447 | } |
| 448 | return sum |
| 449 | }, |
| 450 | // scenarioJSON marshals one economics.Scenario for the save-scenario |
| 451 | // form's hidden field — the value the door at /fleet/economics/scenarios |
| 452 | // parses back (handleEconomicsScenarioSave). html/template still |
| 453 | // attribute-escapes this like any other string substitution; that's |
| 454 | // correct and wanted here (a form value), unlike DataJSON's <script> |
| 455 | // embed which needs the OPPOSITE (unescaped) treatment — see |
| 456 | // economicsPayload's doc comment for why that one stays a raw |
| 457 | // template.JS. |
| 458 | "scenarioJSON": func(sc economics.Scenario) (string, error) { |
| 459 | raw, err := json.Marshal(sc) |
| 460 | return string(raw), err |
| 461 | }, |
| 462 | // econPeriodURL: /fleet/economics/accounts's period-switch nav |
| 463 | // (economics_accounts.html) — see econlistquery.go's doc comment on the |
| 464 | // func itself for why it preserves Q/Pos but drops Cursor. |
| 465 | "econPeriodURL": econPeriodURL, |
| 466 | }).ParseFS(tmplFS, "templates/*.html")) |
| 467 | |
| 468 | const ( |
| 469 | sessionCookie = "carlos_console_session" |
| 470 | authCookie = "carlos_console_auth" |
| 471 | welcomeCookie = "carlos_console_welcome" |
| 472 | authFlowTTL = 10 * time.Minute |
| 473 | ) |
| 474 | |
| 475 | // Config is everything the server reads its world through. |
| 476 | type Config struct { |
| 477 | HostClaimOps *hostops.Controller |
| 478 | HostClaimRecovery *hostops.Recovery |
| 479 | ClaimMoveRecovery func(context.Context, *hostops.MoveRecoveryGrant) error |
| 480 | ClaimAuthority *hostops.ProductionAuthority |
| 481 | ClaimPurge *ClaimPurgeCoordinator |
| 482 | ClaimPlacement *ClaimPlacementCoordinator |
| 483 | HostConsoleID string |
| 484 | |
| 485 | HostClaim bool |
| 486 | WakeRetryReset *WakeRetryResetDoor |
| 487 | // RunKeysEnabled admits keyed run creates (CARLOS_RUN_KEYS=on; service |
| 488 | // runs spec § 3). Off, a create that carries an Idempotency-Key is |
| 489 | // refused 503 keys_not_ready before anything is written, because a box |
| 490 | // binary older than the record's run_keys would drop the key on its |
| 491 | // next record write. It is turned on once every box runs a build that |
| 492 | // carries run_keys through. |
| 493 | RunKeysEnabled bool |
| 494 | // SeedScratchDir is <console data>/scratch/seeds: --from restores and |
| 495 | // import spools. Start-up removes the pool's own leftovers from it. |
| 496 | // Empty: the seed doors answer 507. |
| 497 | SeedScratchDir string |
| 498 | // SeedData is the home region's data bucket, where seeds/ lives (the |
| 499 | // bucket HibernateBucket names, at its root). nil: the seed doors |
| 500 | // answer 503. |
| 501 | SeedData release.Store |
| 502 | DBVersions lease.VersionedStore |
| 503 | Origin string // the console's public origin — the OAuth client_id |
| 504 | Keymail string // keymail base URL |
| 505 | // Registry is the path to the platform registry DB. Read-mostly (routes, |
| 506 | // domains, alerts), but fleets.go's fleet/box/placement doors (customer |
| 507 | // fleets plan, Task 7) genuinely write through it via openRegistry |
| 508 | // (view.go) — the same cached handle every reader here already uses. |
| 509 | Registry string |
| 510 | Releases release.Store // the deployment bucket |
| 511 | // SystemFeed is where `carlos system update` fetches a public release |
| 512 | // from. Zero value = the vendor feed; tests point it at a stub. |
| 513 | SystemFeed publicrelease.Feed |
| 514 | // Bucket is that same deployment bucket's NAME — CARLOS_DEPLOYMENT_BUCKET, |
| 515 | // the one bucket relationship this console can state as fact. |
| 516 | // /fleet/architecture draws the box -> bucket edge from it (archgraph.go's |
| 517 | // ArchInput.Bucket). It rides on Config rather than being read from the |
| 518 | // environment in the handler because it is a deployment fact, and a |
| 519 | // handler reaching for os.Getenv would hide it from every reader of this |
| 520 | // struct. "" is legal — a local CARLOS_DEPLOYMENT_DIR deployment has no |
| 521 | // bucket name to draw — and simply draws no bucket node. |
| 522 | Bucket string |
| 523 | // RegionHA is CARLOS_REGION_HA as strconv.ParseBool reads it (region HA |
| 524 | // spec 1 §8 step 2): with it off the console chooses no executor at |
| 525 | // create and relocation has no console half. A deployment fact, on |
| 526 | // Config for the same reason Bucket is. |
| 527 | RegionHA bool |
| 528 | // HomeRegion NAMES the deployment's home placement (CONSOLE_HOME_REGION, |
| 529 | // e.g. "ie") for consumer pickers (issue #346). It is a NAME, not a |
| 530 | // scope: records for the home keep region "" (the unconstrained value |
| 531 | // reconcile has always matched) and the create door normalizes this |
| 532 | // code to "" on the way in — so the fleet's boxes never see the code |
| 533 | // and nothing about placement changes when it is set. "" is legal: the |
| 534 | // regions endpoint then reports no home row and an empty default. |
| 535 | HomeRegion string |
| 536 | // AppRegions is CONSOLE_APP_REGIONS=on (nearest-region spec §5.6). Off |
| 537 | // never stops the create door's fill for rows already holding a region. |
| 538 | AppRegions bool |
| 539 | // GeoIP is the optional CONSOLE_GEOIP_DB locator; nil = IP step off. |
| 540 | GeoIP geo.Locator |
| 541 | // GeoIPReader is the same reader as GeoIP when it is a Swappable (the |
| 542 | // CONSOLE_GEOIP_DB override, or the follower of this box's edge, geoip |
| 543 | // spec §11.3): the /geo endpoint and attribution need its snapshots, |
| 544 | // which a bare Locator cannot give. nil = no GeoIP on this console. |
| 545 | GeoIPReader *geo.Swappable |
| 546 | // GeoipStagingDir is where `carlos system geoip publish` uploads are |
| 547 | // staged (geoip spec §4.2): cleared when the server is constructed, so a |
| 548 | // crash never leaves an upload behind for good. "" = the OS temp dir, |
| 549 | // not cleared (tests). |
| 550 | GeoipStagingDir string |
| 551 | // The GeoIP feed (geoip spec §10.1): CONSOLE_GEOIP_FEED and, for |
| 552 | // GeoLite2, the MaxMind account and key. BoxLabel is this console's |
| 553 | // box, named in feed.json so only that box's mirror alerts on feed |
| 554 | // failure. |
| 555 | GeoipFeed string |
| 556 | GeoipMaxMindAccount string |
| 557 | GeoipMaxMindKey string |
| 558 | BoxLabel string |
| 559 | // HibernateBucket/Region/Prefix name the region data bucket the |
| 560 | // `carlos db` door reads (apidb.go; spec 2026-09-02 §6, Option A): the |
| 561 | // same CARLOS_HIBERNATE_* triple the activator on this box writes |
| 562 | // instances/<host>/ with, read through activator.ConfigFromEnv so the |
| 563 | // two cannot parse it differently. Read-only, in-region, LTX files by |
| 564 | // coordinate only. An empty bucket disables the door (503) rather than |
| 565 | // failing it open. |
| 566 | HibernateBucket string |
| 567 | HibernateRegion string |
| 568 | HibernatePrefix string |
| 569 | // Region is this console's own placement (CARLOS_REGION, the value the |
| 570 | // box agent beside it reconciles with), compared against an instance |
| 571 | // record's Region before any S3 call. Empty on the home region, whose |
| 572 | // records keep region "" too (see HomeRegion). |
| 573 | Region string |
| 574 | // DBForward is CARLOS_DB_FORWARD=1: a carlos db read of an instance in |
| 575 | // another region is forwarded to a capable box there (db region |
| 576 | // forward spec). It also needs Key to be the deployment root's. |
| 577 | DBForward bool |
| 578 | ReleasePub ed25519.PublicKey // pinned channel-pointer verification key |
| 579 | // Key signs channel pointers for promote/rollback (CARLOS_RELEASE_KEY). |
| 580 | // nil is a legal deployment: the console runs read-only, the promotion |
| 581 | // UI renders disabled, and the mutation handlers refuse with 503. |
| 582 | Key ed25519.PrivateKey |
| 583 | // AppClient fetches app-internal instance reports (INSTANCES.md); nil |
| 584 | // uses a default client with the contract's short timeout. |
| 585 | AppClient *http.Client |
| 586 | // AppScheme is the scheme apps' public hosts are reached on — "https" |
| 587 | // (the default) everywhere but tests. |
| 588 | AppScheme string |
| 589 | // EdgeIPs is this deployment's edge address list (PLATFORM_EDGE_IPS, |
| 590 | // read at console start) — surfaced in the Domains section's |
| 591 | // point-your-DNS-here guidance. nil is legal: guidance then says |
| 592 | // "this deployment's edge IP" instead of inventing an address. |
| 593 | EdgeIPs []string |
| 594 | // SecretsPub is the deployment's CARLOS_SECRETS_PUBKEY, served (never |
| 595 | // used) by the CLI config door so `carlos secrets set` can seal |
| 596 | // client-side with `carlos auth login` alone. Public material by |
| 597 | // design — it only encrypts; the console cannot decrypt anything with |
| 598 | // or without it. Optional: "" omits the field from the API response |
| 599 | // and the CLI falls back to requiring the env var. |
| 600 | SecretsPub string |
| 601 | // AppsDomain is the deployment's platform apps domain |
| 602 | // (PLATFORM_APPS_DOMAIN, the same variable the edge and the host |
| 603 | // agent read; flagship: "oncarlos.com"). The CLI's instances-manifest |
| 604 | // door pins a member-written manifest's domain to |
| 605 | // <account-sqid>.<AppsDomain> — never a domain the request names. "" |
| 606 | // follows the litestream convention: the member-facing enable door |
| 607 | // does not exist, and the handler refuses with a pointer at the |
| 608 | // operator's store-mode `carlos instances enable`. |
| 609 | AppsDomain string |
| 610 | AppsDomainAliasAccounts []string |
| 611 | // RedirectHosts are request hosts this console answers with a permanent |
| 612 | // redirect to Origin (CONSOLE_REDIRECT_HOSTS, comma-separated) — retired |
| 613 | // hostnames after a canonical-origin move. Opt-in per host: hostnames |
| 614 | // that must keep serving the API in place (CLI bearer tokens are |
| 615 | // host-agnostic, and POSTs through an unexpected redirect are how |
| 616 | // clients break) simply aren't listed. |
| 617 | RedirectHosts []string |
| 618 | // DenyDomains are hostnames (and every host under them) the /domains |
| 619 | // door refuses to attach — CONSOLE_DENY_DOMAINS, comma-separated. The |
| 620 | // deployment's own names, in other words: the edge's reserved tunnel |
| 621 | // hostname, retired console origins, anything platform-managed that is |
| 622 | // not already derivable from this config. Empty is legal. The console's |
| 623 | // own hostnames are refused regardless (consoleHosts, apidomains.go). |
| 624 | DenyDomains []string |
| 625 | // TunnelHost is the fleet hub's reserved hostname (CARLOS_TUNNEL_HOST). |
| 626 | // The /domains door refuses it, and everything under it, to everyone. |
| 627 | // It is kept apart from DenyDomains because the operator's platform |
| 628 | // door (#209) lifts DenyDomains and must never lift this: the edge |
| 629 | // Allows the name and no registry route backs it, so a claim on it |
| 630 | // would take the fleet's own address. |
| 631 | TunnelHost string |
| 632 | // ReservePlatformDomains (CONSOLE_RESERVE_PLATFORM_DOMAINS) is the |
| 633 | // opt-in for a deployment that hosts apps for other people, such as the |
| 634 | // flagship or carloku. With it set, the /domains door also refuses |
| 635 | // everything under AppsDomain and under the console's own hostnames, |
| 636 | // because on a shared platform those names belong to the operator and |
| 637 | // not to a tenant. It is off by default. A self-hosted deployment |
| 638 | // usually keeps its console, its apps and its custom domains in one |
| 639 | // zone that one person owns, and the reserved rules refused ordinary |
| 640 | // names there. See deniedDomains. |
| 641 | ReservePlatformDomains bool |
| 642 | // MovesEnabled is the Phase 1 moves switch (moves.Enabled, from |
| 643 | // CARLOS_MOVES_PHASE1). Off, the console behaves exactly as it did |
| 644 | // before Phase 1: the legacy move door answers and nothing new is |
| 645 | // reachable. |
| 646 | MovesEnabled bool |
| 647 | // AWSAccount (CARLOS_AWS_ACCOUNT) is the deployment's AWS account id, |
| 648 | // the trust anchor the move door checks every box's signed identity |
| 649 | // record against (boxid.Verifier.Account). Unset, no box verifies and |
| 650 | // no region can take a move. Only the Phase 1 move door reads it. |
| 651 | AWSAccount string |
| 652 | // DomainProof (CONSOLE_DOMAIN_PROOF) is the opt-in for proof of control |
| 653 | // on custom hostnames (#215). With it set, a member's attach is stored |
| 654 | // pending until a TXT record at _carlos-challenge.<host> carries the |
| 655 | // account's token, and a pending claim is neither routed nor certified. |
| 656 | // Off by default, and off means exactly the behaviour before #215: |
| 657 | // every claim is written verified and no TXT lookup is ever made. That |
| 658 | // default keeps the 2026-08-26/27 scope ruling (first-writer-wins plus |
| 659 | // DNS-pointing-as-evidence) until a deployment chooses otherwise, and it |
| 660 | // keeps a fleet that still runs pre-#215 boxes safe: such a box would |
| 661 | // read a pending claim as live. Roll the fleet before turning it on. |
| 662 | DomainProof bool |
| 663 | // LookupTXT resolves the ownership-proof records the /domains doors |
| 664 | // check (#215: domains.ChallengeName). nil means net.DefaultResolver; |
| 665 | // tests set it so no attach ever reaches a real resolver. |
| 666 | LookupTXT func(ctx context.Context, name string) ([]string, error) |
| 667 | // StoreProvisioner is the backend the object-storage doors provision |
| 668 | // through (issue #55), built at boot from CARLOS_STORE_BACKEND. nil is an |
| 669 | // ordinary deployment shape, not a broken one: members may still DECLARE |
| 670 | // a store — a declaration is a request an operator fulfils, and a member |
| 671 | // cannot see whether a backend exists — while the operator doors that |
| 672 | // create buckets and mint keys refuse. The console holds the identity |
| 673 | // that MINTS app credentials and never an app's own. |
| 674 | StoreProvisioner objstore.Provisioner |
| 675 | // StoreScanner is the virus-scanning engine, built at boot from |
| 676 | // CARLOS_STORE_SCANNER. nil is the ordinary shape — scanning is opt-in and |
| 677 | // off by default — and it means the sweep never runs and the operator |
| 678 | // doors that enable an engine refuse. It does NOT stop a member RECORDING |
| 679 | // a request, for the same reason a nil StoreProvisioner does not stop a |
| 680 | // declaration: a member cannot see whether their deployment has one. |
| 681 | // |
| 682 | // Teardown needs it too. A store with scanning enabled must have its |
| 683 | // engine released before its bucket goes, so a nil scanner over an enabled |
| 684 | // record is a refusal rather than a skip. |
| 685 | StoreScanner objstore.Scanner |
| 686 | // ScanSeedFromDeprecatedEnv says the retired CARLOS_STORE_SCANNER supplied |
| 687 | // this deployment's clamd address. The first scan-pass tick then seeds |
| 688 | // system/antivirus.json enabled, once, carrying forward an intent the |
| 689 | // operator already expressed. |
| 690 | // |
| 691 | // Read at the first tick rather than at boot because cmd/console builds |
| 692 | // the Scanner before it opens the release store, so there is nowhere to |
| 693 | // write when the decision is made. |
| 694 | ScanSeedFromDeprecatedEnv bool |
| 695 | // ScanBudget bounds how much scanning ONE app gets per tick |
| 696 | // (CARLOS_STORE_SCAN_BUDGET, default 60s). The arithmetic matters and is |
| 697 | // stated in the spec: an hourly tick at 60s is 24 minutes of scanning per |
| 698 | // app per day, so at a few hundred objects a minute over INSTREAM a |
| 699 | // million-object bucket takes months. The default is honest for small and |
| 700 | // medium stores; a deployment with large ones raises it or shortens the |
| 701 | // tick. |
| 702 | ScanBudget time.Duration |
| 703 | // ScanInterval is the floor before a COMPLETED sweep is started again |
| 704 | // (CARLOS_STORE_SCAN_INTERVAL, default 24h). A sweep still in flight |
| 705 | // ignores it — that is how a bucket bigger than one budget finishes. |
| 706 | ScanInterval time.Duration |
| 707 | // ScanMaxSigAge is how old an engine's signature database may be before |
| 708 | // `store scan status` warns (CARLOS_CLAMD_MAX_SIG_AGE, default 168h). A |
| 709 | // clean verdict from three-month-old signatures is not the same claim as |
| 710 | // a fresh one, and an audit answer that cannot tell them apart is worth |
| 711 | // less than it looks. |
| 712 | ScanMaxSigAge time.Duration |
| 713 | // CallbackSigner retains the deployment master because callbacks are signed |
| 714 | // after grant. nil is a supported deployment shape; callback enrollment and |
| 715 | // key delivery refuse rather than ever falling back to unsigned delivery. |
| 716 | CallbackSigner *CallbackSigner |
| 717 | // CallbackTransport is the scan callback test seam. nil uses the production |
| 718 | // transport, including ordinary TLS certificate and hostname verification. |
| 719 | CallbackTransport http.RoundTripper |
| 720 | // StoreDeployment is the deployment-wide half of every store: which |
| 721 | // backend, which region and endpoint buckets are made in, and the prefix |
| 722 | // minted bucket names carry. Threaded into every walk so this config is |
| 723 | // the single place that knows where this deployment's object estate |
| 724 | // lives — a walk that guessed would create the next bucket somewhere |
| 725 | // else, and the record would say so afterwards. |
| 726 | StoreDeployment objstore.Deployment |
| 727 | // StoreMaxPerApp caps how many ENVIRONMENT stores one app may have |
| 728 | // (CARLOS_STORE_MAX_PER_APP; 0 = storeMaxPerAppDefault). The app's own |
| 729 | // store is not one of them and is never capped. |
| 730 | // |
| 731 | // It exists because #412 made a store something a signup path can mint: |
| 732 | // a bucket plus an IAM user per instance is a bigger footprint than an |
| 733 | // instance, and IAM's limits are per cloud account, so the automatic |
| 734 | // path needs a bar it stops at. Enforced on the MANUAL door too |
| 735 | // (handleAPIStoreCreate) — a ceiling only the automatic path respected |
| 736 | // would be a ceiling anyone could step over by hand. |
| 737 | StoreMaxPerApp int |
| 738 | // PlanCatalog is the deployment's plan catalog (CARLOS_PLAN_CATALOG, |
| 739 | // plans-caps-packs spec §4.1). nil means plans are off: no account has |
| 740 | // limits, no limits record is published, and the plan door answers 409. |
| 741 | PlanCatalog *plans.Catalog |
| 742 | // TemporaryApps turns on POST /api/cli/apps/{account}/new-temporary |
| 743 | // (temporary static sites spec §3). Off by default, and only switched on |
| 744 | // after every box runs a binary that carries the tmp- reservation and |
| 745 | // every registry has been scanned clean (the rollout steps). The expiry |
| 746 | // pass runs either way, so apps created while it was on still expire. |
| 747 | TemporaryApps bool |
| 748 | // StoreOperators are the addresses allowed to drive the GRANT door, and |
| 749 | // only that one (CARLOS_STORE_OPERATORS). Its own list rather than |
| 750 | // console ownership on purpose: granting CREATES backend estate and |
| 751 | // spends real money in the DEPLOYMENT's cloud account, so the authority |
| 752 | // belongs to whoever runs the deployment, not to a tenant who owns an |
| 753 | // account inside it. Empty is fail-closed — nobody may grant. |
| 754 | // |
| 755 | // The other two store verbs are deliberately NOT gated on this list. |
| 756 | // Rotate and finish are the member's: they re-key an estate that already |
| 757 | // exists, cost nothing, and only the member knows when every host has |
| 758 | // picked the new pair up. Destroy is the account OWNER's, in the browser, |
| 759 | // behind the typed bucket name and a passkey — it spends no money and |
| 760 | // destroys only that account's own data. |
| 761 | StoreOperators []string |
| 762 | // DelegationZone is CARLOS_ACME_DELEGATION_ZONE — this deployment's own |
| 763 | // zone that a wildcard-enabled claim's _acme-challenge CNAME must |
| 764 | // delegate to (the edge's newWildcardFromEnv reads the same variable for |
| 765 | // the same reason: claim-derived-wildcards spec §4). Deliberately |
| 766 | // independent of AppsDomain — #167's blind spot was conflating the two. |
| 767 | // "" is a legal deployment: wildcard enable then refuses every request |
| 768 | // with "deployment has no delegation zone configured" rather than |
| 769 | // guessing. Normalized (lowercase, trimmed) into Server.delegationZone at |
| 770 | // construction, same as RedirectHosts is normalized into `redirects`. |
| 771 | DelegationZone string |
| 772 | // SteeringHost is the deployment's DNS-steering hostname — the |
| 773 | // edge.<apps-domain> name the steering converger keeps one latency A |
| 774 | // record per armed edge on (multi-region ingress spec, Part 4). Set only |
| 775 | // where the deployment actually steers: cmd/console derives it from |
| 776 | // CARLOS_STEERING_ZONE_ID (the same variable the converger reads, so the |
| 777 | // console cannot advertise a hostname no pass maintains) plus |
| 778 | // PLATFORM_APPS_DOMAIN. "" — every deployment today — leaves custom-domain |
| 779 | // guidance byte-for-byte what it has always been. Normalized (lowercase, |
| 780 | // trimmed, no trailing dot) into Server.steeringHost at construction. |
| 781 | SteeringHost string |
| 782 | // MailProvisioner is the backend the email doors provision against. nil |
| 783 | // disables every email door — a deployment with no SES estate is a valid |
| 784 | // deployment, and the doors must 404 rather than panic. |
| 785 | MailProvisioner mail.Provisioner |
| 786 | // DNSWriter publishes durable records for platform sending domains. nil |
| 787 | // means platform domains behave like custom ones: records are emitted for |
| 788 | // the member instead of published. |
| 789 | DNSWriter dnsrec.Writer |
| 790 | // MailDeployment carries the region, the platform suffix, the DMARC rua |
| 791 | // and the SNS topic. |
| 792 | MailDeployment mail.Deployment |
| 793 | // MailOperators are the addresses allowed to drive the email pause/cap |
| 794 | // door (CARLOS_MAIL_OPERATORS, apimail.go): pausing or resuming a |
| 795 | // tenant's sending, and raising its daily cap above what its tier |
| 796 | // grants automatically. Its own list rather than StoreOperators' or |
| 797 | // console ownership, for the same reason StoreOperators is its own |
| 798 | // list (that field's doc comment): the authority here is specific to |
| 799 | // this deployment's shared SENDING REPUTATION, not to spending its |
| 800 | // cloud bill, and the two should be grantable to different people. |
| 801 | // Empty is fail-closed — nobody may pause, resume or raise a cap |
| 802 | // through this door. Automatic abuse enforcement (mailcounts.go) is |
| 803 | // unaffected either way: it calls mail.SetPaused directly, never |
| 804 | // through this door. |
| 805 | MailOperators []string |
| 806 | // ConsoleMail is the console's own email sender (consolemail.go), the |
| 807 | // invite fallback when Keymail cannot deliver. nil: no email fallback. |
| 808 | ConsoleMail ConsoleMailer |
| 809 | // ConsoleName is how this console names itself in the emails it sends |
| 810 | // (CARLOS_CONSOLE_NAME). Empty reads "the CARLOS platform". |
| 811 | ConsoleName string |
| 812 | // RelayRegions are the AWS regions this deployment runs a STUN/TURN |
| 813 | // relay in (CARLOS_RELAY_REGIONS, comma-separated), IN THE ORDER an |
| 814 | // enabled app's CARLOS_RELAY lists them — the order every browser will |
| 815 | // try them in, so it is the operator's list verbatim, not a set. Empty |
| 816 | // is a legal deployment and the one this platform has had until now: |
| 817 | // `carlos relay enable` refuses with a sentence naming the variable, |
| 818 | // and no relay background loop starts at all. |
| 819 | // |
| 820 | // These are AWS region names (eu-west-1), not placement codes (ie, |
| 821 | // fleet/x): they are the vocabulary of the relay hostnames |
| 822 | // (relay.<region>.<platform domain>) and of the per-region master |
| 823 | // secrets, and an app may sit in several placements at once (relay |
| 824 | // spec §5, step 3). |
| 825 | RelayRegions []string |
| 826 | // RelayOperators are the addresses allowed to drive `carlos relay |
| 827 | // rotate` (CARLOS_RELAY_OPERATORS) — the operator verb that moves a |
| 828 | // region's issuer pointer to a freshly staged master and re-issues |
| 829 | // every enabled app's key. Its own list for the same reason |
| 830 | // StoreOperators is: rotating a region's master is an authority over |
| 831 | // the DEPLOYMENT's key material, not over any one tenant's app, and a |
| 832 | // deployment may want to grant it to different people. Empty is |
| 833 | // fail-closed — nobody may rotate. |
| 834 | RelayOperators []string |
| 835 | // SystemOperators are the addresses allowed to roll the platform's own |
| 836 | // binary (CARLOS_SYSTEM_OPERATORS, discussion #176): `carlos system |
| 837 | // status`, `update` from the signed release feed, its job, and |
| 838 | // `rollback`. Owners of the ops account have all of that already; this |
| 839 | // list is for a person who should roll releases without owning the |
| 840 | // account, which would also hand them member removal and ownership |
| 841 | // transfer. Its own list rather than StoreOperators', |
| 842 | // because that one reads tenant bytes through grants and scanning, and |
| 843 | // rolling releases should not. |
| 844 | // |
| 845 | // The upload door stays the owner's. The console signs whatever it is |
| 846 | // uploaded with the deployment key and every box runs it, so a listed |
| 847 | // operator chooses WHICH upstream-signed release the fleet runs, and |
| 848 | // only an owner can put an unreleased build on it. Empty is the |
| 849 | // owner-only console that existed before the list. |
| 850 | SystemOperators []string |
| 851 | // WorkerHeads lists the deployment's maintenance worker heads (#132) |
| 852 | // for the layout door's revert interlock (layoutRevertBlocked). Nil |
| 853 | // means no reader is wired, which is every console today: the head |
| 854 | // store has no key or reader on main yet. |
| 855 | WorkerHeads func(ctx context.Context) ([]release.WorkerHead, error) |
| 856 | // PlatformDomain is the domain relay hostnames are built under |
| 857 | // (CARLOS_PLATFORM_DOMAIN; flagship: "oncarlos.com"), so an enabled |
| 858 | // app is handed stun:relay.<region>.<PlatformDomain>:3478 and its two |
| 859 | // TURN siblings. Optional: unset falls back to AppsDomain, with a |
| 860 | // leading "apps." label stripped when the apps domain is a subdomain |
| 861 | // of the platform's own — see relayDomain(). Set it explicitly |
| 862 | // wherever the two differ by anything but that label; the relay's DNS |
| 863 | // names are operator-managed and this console cannot discover them. |
| 864 | PlatformDomain string |
| 865 | // SidecarAccounts is the operator allowlist of account sqids that may |
| 866 | // still enable and create native sidecars (CARLOS_SIDECAR_ACCOUNTS, |
| 867 | // normalized by cmd/console: trimmed, lower-cased, each a valid sqid). |
| 868 | // Empty is no gate at all, every deployment's shape before the list |
| 869 | // existed. Set, every other account meets sidecarsPaused on the two |
| 870 | // doors that bring a sidecar into being (sidecarsgate.go). |
| 871 | SidecarAccounts []string |
| 872 | } |
| 873 | |
| 874 | // Server is the console's http.Handler. |
| 875 | type Server struct { |
| 876 | claimLossPageMu sync.Mutex |
| 877 | claimLossPageDone chan struct{} |
| 878 | |
| 879 | // noCapacity limits the no-capacity alert to once per region an hour |
| 880 | // in this process (region HA spec 2 §7, H27, nocapacity.go). |
| 881 | noCapacity noCapacityAlerts |
| 882 | // Enabled claim runtime (ConfigureClaimRuntime). Unset flag-off. |
| 883 | claimRuntimeConfigured bool |
| 884 | claimOwner hostops.RegistrationOwner |
| 885 | claimMutationObserver func(hostops.MutationEvent) // fixture recorder only |
| 886 | claimBackendObserver func(backendEvent) // fixture recorder only |
| 887 | claimEntries claimInventory |
| 888 | claimWorkers map[string]func(context.Context) |
| 889 | // The console's move pass (movesrecovery.go): its clock (nil is |
| 890 | // time.Now; a TEST SEAM) and when each close was first seen due. |
| 891 | movesClock func() time.Time |
| 892 | moveCloseMu sync.Mutex |
| 893 | moveCloseSeen map[string]time.Time |
| 894 | // moveReleaseFault, when set, fails the pass's claim release just before |
| 895 | // it is made (a TEST SEAM: a release that fails after its log entry is |
| 896 | // queued, movesrecovery.go). |
| 897 | moveReleaseFault func() error |
| 898 | // moveLogSettleHook, when set, runs as the drainer settles an |
| 899 | // unconfirmed declared_lost row, after it read the claim (a TEST SEAM: |
| 900 | // a declaration that commits in between, movesrecovery.go). |
| 901 | moveLogSettleHook func() |
| 902 | // moveFenceCall, when set, runs as the pass calls the grant's |
| 903 | // ReleaseFencedTarget, with the fence it names (a TEST SEAM: which |
| 904 | // passes call the target's fence, movesrecovery.go). |
| 905 | moveFenceCall func(hostclaim.FenceRef) |
| 906 | dbHandleKey []byte |
| 907 | seedScratch *seedScratch |
| 908 | // seedCopies counts --from copies and import uploads running detached |
| 909 | // from their request (apiinstanceseed.go, apiinstanceimport.go), so a |
| 910 | // test can wait for one to finish. |
| 911 | seedCopies sync.WaitGroup |
| 912 | cfg Config |
| 913 | store *Store |
| 914 | // appRegionOf and beforeClaimInsert are TEST SEAMS (nil in |
| 915 | // production): a store failure on the app-region read (C20), and a |
| 916 | // competing insert landing after the region was resolved. |
| 917 | appRegionOf func(accountID int64, app string) (string, string, error) |
| 918 | beforeClaimInsert func() |
| 919 | // claims folds the deployment's domain claims for the read paths — the |
| 920 | // Settings render and the domains list door — reading only the claims |
| 921 | // whose bucket stamp moved since the last fold (#173). Process-lifetime |
| 922 | // and safe for concurrent handlers; see domains.ClaimCache. |
| 923 | claims domains.ClaimCache |
| 924 | km *Keymail |
| 925 | mux *http.ServeMux |
| 926 | instances *instanceFetcher |
| 927 | secure bool // Secure cookies iff the origin is https |
| 928 | cli *cliLimits // rate limits on the CLI-identity endpoints (authcli.go) |
| 929 | // boxCerts are AWS's instance-identity signing certificates by AWS |
| 930 | // region (boxid.EmbeddedCerts), loaded once at construction. The Phase |
| 931 | // 1 move door verifies each box's identity record with them; nil (a |
| 932 | // load failure, logged) verifies nothing. |
| 933 | boxCerts map[string]*x509.Certificate |
| 934 | // dbReplica opens the read side of one instance's replica for the |
| 935 | // `carlos db` door (apidb.go). NewServer points it at a small cache of |
| 936 | // lease.ReadReplicaClient values; tests point it at a file replica. |
| 937 | dbReplica func(ctx context.Context, prefix string) (replicaReader, error) |
| 938 | dbClients dbReplicaCache |
| 939 | // dbStreams bounds the db door's active file streams, and dbStreamIdle |
| 940 | // is how long one may go without moving a byte (apidb.go). |
| 941 | dbStreams *dbStreamLimits |
| 942 | dbStreamIdle time.Duration |
| 943 | // dbReads bounds open forwarded reads per person and per console, held |
| 944 | // by each read's request (spec §5.7, J5); dbf is its timing. |
| 945 | dbReads *dbStreamLimits |
| 946 | dbf dbForwardTimes |
| 947 | // cliRoles is pattern -> allowed roles, recorded by cliRoute as it |
| 948 | // registers each /api/cli/ door (authcli.go). Written once, during |
| 949 | // registerCLIRoutes, before the server serves anything, and only read |
| 950 | // afterwards. It exists for two readers: the coverage tests, which |
| 951 | // assert that every door names its roles and that the closed ones stay |
| 952 | // closed, and the services API, which hands an operator the door list |
| 953 | // for the role they just granted so they can see what they gave away |
| 954 | // without reading a spec. |
| 955 | cliRoles map[string]svcrole.Set |
| 956 | // redirects is cfg.RedirectHosts, normalized (lowercase, no port) into a |
| 957 | // lookup set — ServeHTTP checks it before the mux sees the request. |
| 958 | redirects map[string]bool |
| 959 | // cliNotified, when non-nil, is signalled after each CLI-token issuance |
| 960 | // notification attempt. Tests only — the send is off the request |
| 961 | // goroutine, and a test that asserted on it without this would be racing. |
| 962 | cliNotified chan struct{} |
| 963 | // storeWalks serialises the object-storage walks per app (storelock.go). |
| 964 | // objstore's own teardown reasoning assumes this exists — "The console |
| 965 | // door serialises these verbs per app" (objstore/walk.go:560) — and until |
| 966 | // #381 nothing did. |
| 967 | storeWalks *storeWalkLocks |
| 968 | // doorPublish is the door-key publication lock per app, keyed |
| 969 | // "<sqid>/<app>" (apidoorkeys.go): one publisher per app at a time, so a |
| 970 | // renewal can never land an older key set after a revoke. Built lazily |
| 971 | // under doorPublishMu, so the zero Server needs no setup. |
| 972 | doorPublishMu sync.Mutex |
| 973 | doorPublish map[string]*sync.Mutex |
| 974 | // sendApp is how the alert watcher (alertwatch.go) delivers keymail: |
| 975 | // km.SendApp in production, a capture in tests. A field rather than a |
| 976 | // direct call so an alertPass assertion needs no keymail server. |
| 977 | sendApp func(ctx context.Context, token, to, subject, body string) (string, error) |
| 978 | // Invite delivery (handleInvite): Keymail federation first, then the |
| 979 | // console's own email. Fields so tests can substitute and shrink them. |
| 980 | sendFederated func(ctx context.Context, to, subject, body string) (string, error) |
| 981 | consoleMail ConsoleMailer |
| 982 | consoleNameCfg string |
| 983 | // limits are email sign-in's rate limits (emailsignin_limits.go): in |
| 984 | // memory, one console process per deployment. |
| 985 | limits emailLimits |
| 986 | // flowLocks serialise /auth/continue per sign-in flow (flowLock). |
| 987 | flowLocks [256]sync.Mutex |
| 988 | // challengeSem bounds the sign-in and step-up sends in flight |
| 989 | // (sendChallengeAsync). challengeSent, when non-nil, is signalled after |
| 990 | // each one finishes or is dropped. Tests only, as cliNotified. |
| 991 | challengeSem chan struct{} |
| 992 | challengeSent chan struct{} |
| 993 | inviteTimeout time.Duration |
| 994 | inviteKeymailShare time.Duration |
| 995 | // inviteRate is each account's invites created in the last hour |
| 996 | // (maxInvitesPerHour). In memory: a restart forgets it, and the pending |
| 997 | // cap still holds. |
| 998 | inviteRate inviteWindow |
| 999 | // metricsCache holds the result of LoadMetrics's last successful bucket |
| 1000 | // sweep (metricsview.go). Concurrent callers within metricsCacheTTL share |
| 1001 | // it outright; callers arriving later still get it immediately (stale- |
| 1002 | // while-revalidate, up to metricsStaleServeCeiling) so the operator |
| 1003 | // dashboard and every owner's app-metrics page don't each re-sweep 48h of |
| 1004 | // chunks per request. |
| 1005 | metricsCache metricsCache |
| 1006 | // economicsCache holds LoadEconomics's last successful sweep per period |
| 1007 | // ("mtd", "last", "7d" — economicsview.go). Same shape as metricsCache, |
| 1008 | // keyed by period so a cold sweep for one period never blocks a caller |
| 1009 | // asking for another. |
| 1010 | economicsCache economicsCache |
| 1011 | // storeSizer is the storage pass's test seam for sizing a provisioned |
| 1012 | // tenant object store: nil means "use the real S3 sizer" |
| 1013 | // (sizeProvisionedStore, economicspass.go); tests inject a fake so a |
| 1014 | // sweep can be proven to degrade ("unswept") without live AWS. |
| 1015 | storeSizer func(ctx context.Context, p *objstore.Provisioned) (int64, error) |
| 1016 | |
| 1017 | // storeListerFor is the scan pass's test seam for listing a provisioned |
| 1018 | // bucket, the same shape and for the same reason as storeSizer above: the |
| 1019 | // sweep is driven against a MemProvisioner's buckets with no S3 and no |
| 1020 | // credentials. |
| 1021 | storeListerFor func(ctx context.Context, p *objstore.Provisioned) (objstore.ObjectLister, error) |
| 1022 | |
| 1023 | // scanQueue is which apps have on-demand scan work waiting (spec §7.1). |
| 1024 | // |
| 1025 | // SOFT STATE, and never the queue itself: the items are objects in each |
| 1026 | // app's own prefix, and this only says where to look. Losing it costs |
| 1027 | // latency, not items — the boot rebuild finds anything stranded and the |
| 1028 | // hourly sweep reaches every object regardless. |
| 1029 | // |
| 1030 | // Built in NewServer rather than lazily, because both writers reach it |
| 1031 | // without coordination: the enqueue door bumps it from a request |
| 1032 | // goroutine while the drain reads it from its own. A nil map assigned on |
| 1033 | // first use would be the race, and a Server that always has one cannot |
| 1034 | // have it. |
| 1035 | scanQueue *objstore.QueueIndex |
| 1036 | scanDelivery *scanDeliveryPool |
| 1037 | // scanWaits is the in-process wake the status door's long-poll selects on |
| 1038 | // (scandrain.go). Zero value is usable, which is why it is a value rather |
| 1039 | // than a pointer: a Server built by a test that never starts the drain |
| 1040 | // still answers scanWait and scanNotify rather than panicking. |
| 1041 | scanWaits scanWaiters |
| 1042 | // ceCosts is billPass's test/fake seam for Cost Explorer (cepull.go, |
| 1043 | // economicspass.go): nil means "use the real, lazily-built CE client" |
| 1044 | // (s.realCECosts); tests inject a fake so a pull can be proven to |
| 1045 | // succeed, or to degrade quietly, without live AWS billing IAM. |
| 1046 | ceCosts func(ctx context.Context, start, end time.Time) (map[string]float64, error) |
| 1047 | // ceClientOnce/ceClient/ceWarnOnce/ceDisabled cache s.realCECosts' |
| 1048 | // Cost Explorer client and its process-lifetime disabled state — see |
| 1049 | // cepull.go's doc comment for why a mutex isn't needed here. |
| 1050 | ceClientOnce sync.Once |
| 1051 | ceClient *costexplorer.Client |
| 1052 | ceWarnOnce sync.Once |
| 1053 | ceDisabled bool |
| 1054 | // The infrastructure inventory seam (infrapass.go), built exactly like |
| 1055 | // the Cost Explorer seam above it: tests set inventory to a fake, the |
| 1056 | // real path lazily constructs an awsinv.Provider once. infraDisabled |
| 1057 | // latches an auth-shaped failure off for the process — a deployment |
| 1058 | // with no inventory IAM grant answers "ungranted" once, not hourly. |
| 1059 | // Unlike ceDisabled above, this one IS reached from more than the |
| 1060 | // single-goroutine hourly ticker: loadInventory reads it to serve an |
| 1061 | // HTTP handler, and the refresh door (a later task) calls infraPass |
| 1062 | // directly from another — so it is atomic.Bool, not a plain bool. |
| 1063 | inventory func(ctx context.Context) (infraview.Inventory, error) |
| 1064 | infraOnce sync.Once |
| 1065 | infraProvider infraview.Provider |
| 1066 | infraDisabled atomic.Bool |
| 1067 | // infraRefreshing single-flights concurrent refresh-door callers |
| 1068 | // against EACH OTHER only — it says nothing about the ticker, which is |
| 1069 | // why infraDisabled above needs its own atomicity and this field |
| 1070 | // cannot stand in for that fix. |
| 1071 | infraRefreshing atomic.Bool |
| 1072 | // shipSem bounds how many `carlos ship` uploads (apicli.go, |
| 1073 | // handleAPIShip) may run AT ONCE, as opposed to s.cli.ship, which |
| 1074 | // bounds them over TIME. A rate limiter is a counter; it says nothing |
| 1075 | // about concurrency, and a ship holds a goroutine plus a substantial |
| 1076 | // buffer open for as long as the client trickles bytes in — a handful |
| 1077 | // of slow uploads run in parallel is a resource-exhaustion path the |
| 1078 | // counter alone does nothing to close. Always initialized (never nil): |
| 1079 | // unlike cliNotified below this is load-bearing in production, not a |
| 1080 | // test hook. |
| 1081 | shipSem chan struct{} |
| 1082 | // shipStarted, when non-nil, is signalled after a ship request |
| 1083 | // acquires shipSem, before it does any (possibly slow, possibly |
| 1084 | // blocking) reading of the request body. Tests only, same reasoning as |
| 1085 | // cliNotified: without this a test racing to fill shipSem would have no |
| 1086 | // way to know the earlier requests actually hold it yet. |
| 1087 | shipStarted chan struct{} |
| 1088 | // delegationZone and lookupCNAME are the wildcard-capability door's two |
| 1089 | // seams (apidomains.go). delegationZone is cfg.DelegationZone, shape- |
| 1090 | // validated and normalized once here (normalizeDelegationZone); |
| 1091 | // lookupCNAME takes the request's own ctx — the same ctx-first idiom |
| 1092 | // internal/edge/wildcard.go already uses for its DNS lookups — and |
| 1093 | // defaults to net.DefaultResolver.LookupCNAME. Tests stub it directly on |
| 1094 | // a constructed Server, the same way they override fx.srv.cfg.DenyDomains |
| 1095 | // post-construction. |
| 1096 | delegationZone string |
| 1097 | lookupCNAME func(ctx context.Context, host string) (string, error) |
| 1098 | // lookupTXT is cfg.LookupTXT, defaulted to net.DefaultResolver: the |
| 1099 | // one TXT seam for both ownership proofs, an instance catch-all's |
| 1100 | // (domains.CatchallProofName) and a claim's (#215, domainProver). |
| 1101 | // Stubbed by tests the same way lookupCNAME is. |
| 1102 | lookupTXT func(ctx context.Context, name string) ([]string, error) |
| 1103 | // steeringHost is cfg.SteeringHost, normalized once here — see |
| 1104 | // dnsTargets, the one reader. |
| 1105 | steeringHost string |
| 1106 | // snsVerify is the SES ingest door's test seam (sesevents.go) — nil |
| 1107 | // means "do the real signature verification", which fetches the signing |
| 1108 | // certificate over the network. The seam covers signature verification |
| 1109 | // ONLY: it never substitutes for the TopicArn or SigningCertURL checks, |
| 1110 | // which every caller — production and test alike — runs for real. |
| 1111 | snsVerify func(snsEnvelope) error |
| 1112 | // mailPushTarget is the email-events push's test seam (mailpush.go): |
| 1113 | // nil means "the app's own platform host, over https", which is the |
| 1114 | // only place production ever sends. A test points it at an httptest |
| 1115 | // server instead. The path still goes through the same validation. |
| 1116 | mailPushTarget func(acct, app, path string) string |
| 1117 | // snsCerts caches a verified signing certificate's RSA public key by URL |
| 1118 | // (sesevents.go) — otherwise every request bearing the deployment's |
| 1119 | // TopicArn, which is not a secret, triggers a fresh outbound HTTPS fetch |
| 1120 | // and full chain verification. Zero value is ready to use. |
| 1121 | snsCerts snsCertCache |
| 1122 | // refusedTopics bounds the SES ingest door's refusal log (sesevents.go's |
| 1123 | // logRefusedTopic): the door is unauthenticated and internet-reachable, |
| 1124 | // so the diagnostic has to be rate-limited rather than omitted. Zero |
| 1125 | // value is ready to use. |
| 1126 | refusedTopicsMu sync.Mutex |
| 1127 | refusedTopics map[string]time.Time |
| 1128 | // edgeFold caches the deployment's folded live edge addresses — every |
| 1129 | // box's control/edges/<box>.json joined against its status snapshot — |
| 1130 | // so the guidance strings can name every armed edge rather than only |
| 1131 | // the ones this console's own PLATFORM_EDGE_IPS lists. Cached because |
| 1132 | // dnsTargets is on the request path and is called once PER CLAIM inside |
| 1133 | // the domains-list loop: an uncached fold would put a LoadBoxes sweep |
| 1134 | // plus one Get per edges file behind every row of every listing. Zero |
| 1135 | // value is ready to use. |
| 1136 | edgeFold edgeFoldCache |
| 1137 | // edgeProof is the mesh-key cache and the seen latch behind clientIP's |
| 1138 | // loopback check (edgeproof.go). Zero value is ready to use. |
| 1139 | edgeProof edgeProofState |
| 1140 | // foldEdgeIPs is edgeFoldCache's test seam: nil means the real fold |
| 1141 | // over cfg.Releases (s.realFoldEdgeIPs). Tests inject a counter so the |
| 1142 | // TTL can be proven without a bucket, the same way storeSizer and |
| 1143 | // ceCosts stand in for AWS above. |
| 1144 | foldEdgeIPs func(ctx context.Context) []string |
| 1145 | // The `carlos system` promote's in-memory registries (apisystem.go): |
| 1146 | // systemJobs is the running/finished update jobs an operator polls, |
| 1147 | // systemRunning is "<component>/<channel>" -> the id of the job holding |
| 1148 | // that channel's one in-flight slot, and systemUploads is the single |
| 1149 | // upload slot a `--file` promote parks its bytes in between the PUT that |
| 1150 | // carries them and the job that consumes them. All three are |
| 1151 | // process-lifetime and deliberately NOT durable: a console restart loses |
| 1152 | // a job's step list, and re-running the promote is the recovery, because |
| 1153 | // every step of it is idempotent. |
| 1154 | systemMu sync.Mutex |
| 1155 | systemJobs map[string]*systemJob |
| 1156 | // geoip is the single writer of control/geoip/ (geoip spec §3); nil |
| 1157 | // when this console has no deployment bucket. |
| 1158 | geoip *geoipPublisher |
| 1159 | systemRunning map[string]string |
| 1160 | systemUploads map[string]systemUpload |
| 1161 | |
| 1162 | // The relay writer's three fields (relayops.go). relayHolder is this |
| 1163 | // PROCESS's identity in control/relay-writer.json, drawn once at |
| 1164 | // construction; relayHeld is whether the lease is currently ours, read |
| 1165 | // before every relay bucket write by requireRelayLease; relayAppLocks |
| 1166 | // serialises operations on one app ("<sqid>/<app>" -> *sync.Mutex) |
| 1167 | // inside the holder, which is the second half of spec §5's one-writer |
| 1168 | // rule — the lease keeps two CONSOLES apart, this keeps two handlers |
| 1169 | // of the same console apart. |
| 1170 | relayHolder string |
| 1171 | relayHeld atomic.Bool |
| 1172 | relayAppLocks sync.Map |
| 1173 | // appLocks and acctLocks are the temporary-app lifecycle locks |
| 1174 | // (applocks.go). Zero values are ready. |
| 1175 | appLocks keyedLocks |
| 1176 | acctLocks keyedLocks |
| 1177 | // relayNow is the relay paths' clock seam: nil means time.Now. Tests |
| 1178 | // drive lease expiry with it; nothing in production sets it. |
| 1179 | relayNow func() time.Time |
| 1180 | |
| 1181 | // recordView is the record view the run and seed sweeps read through |
| 1182 | // (recordview.go), set by SetRecordView when CARLOS_CHANGE_INDEX=read |
| 1183 | // arms it; nil (disarmed) is every sweep walking the bucket as before |
| 1184 | // the index. |
| 1185 | recordView *provision.RecordView |
| 1186 | // viewUse is the last recordWalkStore outcome, so a switch between the |
| 1187 | // view and the bucket is logged once, not on every pass. |
| 1188 | viewUse atomic.Int32 |
| 1189 | // lastOrphanFull is when the run sweep's orphan housekeeping last swept |
| 1190 | // every app with records (runsweep.go); zero until the first, which is |
| 1191 | // therefore full. Only the run sweep's one goroutine touches it. |
| 1192 | lastOrphanFull time.Time |
| 1193 | } |
| 1194 | |
| 1195 | // edgeFoldTTL is how long one fold of the deployment's edges files stands |
| 1196 | // for. Edges arm and retire on the scale of a deployment, not a request, and |
| 1197 | // every box republishes its own file on a 5min+jitter loop — so a minute is |
| 1198 | // far below the rate at which the answer can actually change while still |
| 1199 | // keeping the domains-list door off the bucket for all but one request a |
| 1200 | // minute. |
| 1201 | const edgeFoldTTL = time.Minute |
| 1202 | |
| 1203 | // edgeFoldTimeout bounds one fold, mirroring the agent's own closure. |
| 1204 | // dnsTargets takes no context of its own (it is called from deep inside view |
| 1205 | // assembly), so without this a hung store would stall the domains-list door |
| 1206 | // indefinitely rather than degrading to the configured list. |
| 1207 | const edgeFoldTimeout = 30 * time.Second |
| 1208 | |
| 1209 | // edgeFoldCache is the folded set plus the instant it was folded. The mutex |
| 1210 | // is held ACROSS the refresh deliberately: it is what makes concurrent |
| 1211 | // callers share one fold instead of stampeding the bucket, and the fold is |
| 1212 | // bounded by edgeFoldTimeout so the worst case is a bounded wait, not a |
| 1213 | // wedge. |
| 1214 | type edgeFoldCache struct { |
| 1215 | mu sync.Mutex |
| 1216 | at time.Time |
| 1217 | ips []string |
| 1218 | } |
| 1219 | |
| 1220 | // consoleName is what the console's emails call it: the operator's |
| 1221 | // CARLOS_CONSOLE_NAME, else today's wording. |
| 1222 | func (s *Server) consoleName() string { |
| 1223 | if n := strings.TrimSpace(s.consoleNameCfg); n != "" { |
| 1224 | return n |
| 1225 | } |
| 1226 | return "the CARLOS platform" |
| 1227 | } |
| 1228 | |
| 1229 | // brandName is the console's own name in its page chrome (the header, the |
| 1230 | // tab title, the front page): CARLOS_CONSOLE_NAME when the deployment sets |
| 1231 | // one, so carloku.com reads "Carloku" throughout, else "CARLOS console". |
| 1232 | func (s *Server) brandName() string { |
| 1233 | if n := strings.TrimSpace(s.consoleNameCfg); n != "" { |
| 1234 | return n |
| 1235 | } |
| 1236 | return "CARLOS console" |
| 1237 | } |
| 1238 | |
| 1239 | // chrome fills the deployment's names on any page before it renders. |
| 1240 | func (s *Server) chrome(p *page) { |
| 1241 | p.Brand = s.brandName() |
| 1242 | p.ConsoleName = s.consoleName() |
| 1243 | } |
| 1244 | |
| 1245 | // dnsTargets is what every custom-domain guidance string in this console is |
| 1246 | // built from (view.go's domainGuidance): the deployment's edge addresses and |
| 1247 | // its steering hostname, read from ONE place so the app page, the Settings |
| 1248 | // page and the /domains API can never disagree about where a member should |
| 1249 | // point DNS. |
| 1250 | func (s *Server) dnsTargets() DNSTargets { |
| 1251 | return DNSTargets{EdgeIPs: s.edgeIPs(), SteeringHost: s.steeringHost} |
| 1252 | } |
| 1253 | |
| 1254 | // edgeIPs is the deployment's edge addresses as this console will tell a |
| 1255 | // member to point at: the operator's configured list UNIONED with the live |
| 1256 | // fold, configured addresses first. |
| 1257 | // |
| 1258 | // Union, not replacement, in both directions. A console whose bucket read |
| 1259 | // fails still offers PLATFORM_EDGE_IPS rather than none — an empty guidance |
| 1260 | // string is worse than a partial one. And a multi-region deployment's other |
| 1261 | // edges appear even though this console's env var predates them, which is |
| 1262 | // the whole point: the same set the domains reconcile pass now judges |
| 1263 | // "pointing" against, so guidance and verdict cannot disagree. |
| 1264 | func (s *Server) edgeIPs() []string { |
| 1265 | out := append([]string(nil), s.cfg.EdgeIPs...) |
| 1266 | for _, ip := range s.foldedEdgeIPs() { |
| 1267 | if ip != "" && !slices.Contains(out, ip) { |
| 1268 | out = append(out, ip) |
| 1269 | } |
| 1270 | } |
| 1271 | return out |
| 1272 | } |
| 1273 | |
| 1274 | // foldedEdgeIPs answers the cached fold, refreshing it when it has aged past |
| 1275 | // edgeFoldTTL. |
| 1276 | // |
| 1277 | // A refresh that comes back with NOTHING leaves the previous answer standing: |
| 1278 | // an empty fold is a bucket that could not be read this minute far more often |
| 1279 | // than it is a deployment that lost every edge, and emptying the set would |
| 1280 | // silently narrow the guidance every member is shown. The timestamp still |
| 1281 | // moves, so a persistent outage costs one bounded attempt per TTL rather than |
| 1282 | // one per request. |
| 1283 | func (s *Server) foldedEdgeIPs() []string { |
| 1284 | c := &s.edgeFold |
| 1285 | c.mu.Lock() |
| 1286 | defer c.mu.Unlock() |
| 1287 | if !c.at.IsZero() && time.Since(c.at) < edgeFoldTTL { |
| 1288 | return c.ips |
| 1289 | } |
| 1290 | fold := s.foldEdgeIPs |
| 1291 | if fold == nil { |
| 1292 | fold = s.realFoldEdgeIPs |
| 1293 | } |
| 1294 | ctx, cancel := context.WithTimeout(context.Background(), edgeFoldTimeout) |
| 1295 | defer cancel() |
| 1296 | ips := fold(ctx) |
| 1297 | c.at = time.Now() |
| 1298 | if len(ips) > 0 { |
| 1299 | c.ips = ips |
| 1300 | } |
| 1301 | return c.ips |
| 1302 | } |
| 1303 | |
| 1304 | // realFoldEdgeIPs folds the deployment bucket's edges files. env is nil here, |
| 1305 | // not cfg.EdgeIPs: edgeIPs above does the union, so the configured list is |
| 1306 | // added exactly once and the cache holds only what was actually read from the |
| 1307 | // bucket — which is what lets an empty fold be recognised as a failed read. |
| 1308 | // Sorted so a listing's guidance sentence does not reshuffle between calls on |
| 1309 | // the store's arbitrary iteration order. |
| 1310 | func (s *Server) realFoldEdgeIPs(ctx context.Context) []string { |
| 1311 | if s.cfg.Releases == nil { |
| 1312 | return nil |
| 1313 | } |
| 1314 | ips := edgesfile.EdgeIPs(ctx, s.cfg.Releases, nil, time.Now()) |
| 1315 | slices.Sort(ips) |
| 1316 | return ips |
| 1317 | } |
| 1318 | |
| 1319 | // normalizeSteeringHost lowercases, trims and shape-validates the steering |
| 1320 | // hostname (Config.SteeringHost). Same posture as normalizeDelegationZone |
| 1321 | // below and for the same reason: a value that is not a legal hostname |
| 1322 | // degrades to "" — guidance keeps the A-record form, which always works — |
| 1323 | // rather than being pasted verbatim into a sentence every member of this |
| 1324 | // deployment is shown. Loud, because a deployment that armed steering and |
| 1325 | // typo'd the variable would otherwise mislead silently. |
| 1326 | func normalizeSteeringHost(raw string) string { |
| 1327 | h := strings.ToLower(strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(raw), "."))) |
| 1328 | if h != "" && !provision.ValidHostShape(h) { |
| 1329 | log.Printf("console: steering hostname %q is not a valid domain — custom-domain guidance keeps the A-record form", raw) |
| 1330 | return "" |
| 1331 | } |
| 1332 | return h |
| 1333 | } |
| 1334 | |
| 1335 | // normalizeDelegationZone lowercases, trims, and shape-validates raw (from |
| 1336 | // CARLOS_ACME_DELEGATION_ZONE): a value that isn't a legal hostname degrades |
| 1337 | // to "" — delegation off — rather than being handed to lookupCNAME verbatim |
| 1338 | // or failing boot. Mirrors the edge's own validation of the same variable |
| 1339 | // (internal/edge/wildcard.go's newWildcardFromEnv), so a zone the edge would |
| 1340 | // refuse never gets ACCEPTED here only to make every wildcard-enable request |
| 1341 | // dial a garbage DNS query. "" already has a well-defined, honest answer — |
| 1342 | // every enable request refuses with "deployment has no delegation zone |
| 1343 | // configured" — so a badly-typed zone gets exactly that, not a crash or a |
| 1344 | // distinguishable error. |
| 1345 | func normalizeDelegationZone(raw string) string { |
| 1346 | zone := strings.ToLower(strings.TrimSpace(raw)) |
| 1347 | if zone != "" && !provision.ValidHostShape(zone) { |
| 1348 | log.Printf("console: CARLOS_ACME_DELEGATION_ZONE %q is not a valid domain — delegation off, wildcard-enable requests refuse with \"no delegation zone configured\" until it's fixed", raw) |
| 1349 | return "" |
| 1350 | } |
| 1351 | return zone |
| 1352 | } |
| 1353 | |
| 1354 | // NewServer wires a server over an opened Store, loading (or minting) the |
| 1355 | // console's Keymail signing key. |
| 1356 | func NewServer(cfg Config, st *Store) (*Server, error) { |
| 1357 | if cfg.HostClaim && !st.claimGated() { |
| 1358 | return nil, fmt.Errorf("CARLOS_HOST_CLAIM: %s", "the console database must be opened gated (console.OpenGated)") |
| 1359 | } |
| 1360 | if cfg.HostClaim && cfg.HibernateBucket != "" && cfg.dbClaimRegion() == "" { |
| 1361 | return nil, fmt.Errorf("CARLOS_HOST_CLAIM: %s", "region is required") |
| 1362 | } |
| 1363 | cfg.Origin = strings.TrimSuffix(cfg.Origin, "/") |
| 1364 | // originHost is what a RedirectHosts entry must never equal: redirecting |
| 1365 | // the console's own origin to itself is a loop, not a retirement. |
| 1366 | originHost := "" |
| 1367 | if u, err := url.Parse(cfg.Origin); err == nil { |
| 1368 | originHost = normalizeHost(u.Host) |
| 1369 | } |
| 1370 | // An origin with no host in it is fatal ONCE there are hosts to retire, |
| 1371 | // never before: it is both the loop guard's only reference — an origin |
| 1372 | // that parsed to nothing silently permits the very entry the guard exists |
| 1373 | // to refuse — and the prefix every Location is built on, where "" would |
| 1374 | // hand back the request's own path (a redirect to the retired host) or a |
| 1375 | // scheme-relative "//host/…" whose destination the request chooses. |
| 1376 | // Deployments with nothing to retire keep whatever origin they had. |
| 1377 | if len(cfg.RedirectHosts) > 0 && originHost == "" { |
| 1378 | return nil, fmt.Errorf("console: CONSOLE_REDIRECT_HOSTS is set, but CONSOLE_ORIGIN %q has no host to redirect to — it needs a scheme, e.g. https://console.example.com", cfg.Origin) |
| 1379 | } |
| 1380 | redirects := make(map[string]bool, len(cfg.RedirectHosts)) |
| 1381 | for _, h := range cfg.RedirectHosts { |
| 1382 | h = normalizeHost(h) |
| 1383 | if h == "" { |
| 1384 | continue |
| 1385 | } |
| 1386 | if h == originHost { |
| 1387 | return nil, fmt.Errorf("console: CONSOLE_REDIRECT_HOSTS lists %q, which is this console's own origin host — that config would redirect-loop", h) |
| 1388 | } |
| 1389 | redirects[h] = true |
| 1390 | } |
| 1391 | km := NewKeymail(cfg.Keymail, cfg.Origin) |
| 1392 | key, err := st.SigningKey() |
| 1393 | if err != nil { |
| 1394 | return nil, err |
| 1395 | } |
| 1396 | km.SetSigningKey(key) |
| 1397 | s := &Server{ |
| 1398 | cfg: cfg, |
| 1399 | store: st, |
| 1400 | km: km, |
| 1401 | mux: http.NewServeMux(), |
| 1402 | instances: newInstanceFetcher(cfg.AppClient, cfg.AppScheme), |
| 1403 | secure: strings.HasPrefix(cfg.Origin, "https://"), |
| 1404 | cli: newCLILimits(), |
| 1405 | redirects: redirects, |
| 1406 | // Two concurrent ships at a time: roomy for one operator's CI plus a |
| 1407 | // human running `carlos ship` by hand, tight enough that a fleet of |
| 1408 | // slow-loris uploads against a stolen or shared token can't pile up |
| 1409 | // goroutines and buffers without bound. |
| 1410 | shipSem: make(chan struct{}, 2), |
| 1411 | delegationZone: normalizeDelegationZone(cfg.DelegationZone), |
| 1412 | steeringHost: normalizeSteeringHost(cfg.SteeringHost), |
| 1413 | lookupCNAME: net.DefaultResolver.LookupCNAME, |
| 1414 | lookupTXT: cfg.LookupTXT, |
| 1415 | storeWalks: newStoreWalkLocks(), |
| 1416 | consoleMail: cfg.ConsoleMail, |
| 1417 | consoleNameCfg: cfg.ConsoleName, |
| 1418 | challengeSem: make(chan struct{}, challengeSendSlots), |
| 1419 | inviteTimeout: 20 * time.Second, |
| 1420 | inviteKeymailShare: 12 * time.Second, |
| 1421 | // This process's identity in the relay writer lease. Random rather |
| 1422 | // than derived from the host: two consoles on one box during a |
| 1423 | // deploy overlap are two writers, and they must be able to tell |
| 1424 | // each other apart (relayops.go). |
| 1425 | relayHolder: relayHolderID(), |
| 1426 | scanQueue: objstore.NewQueueIndex(), |
| 1427 | } |
| 1428 | s.seedScratch = startSeedScratch(cfg.SeedScratchDir) |
| 1429 | if s.lookupTXT == nil { |
| 1430 | s.lookupTXT = net.DefaultResolver.LookupTXT |
| 1431 | } |
| 1432 | if certs, err := boxid.EmbeddedCerts(); err != nil { |
| 1433 | log.Printf("console: AWS identity certificates did not load (%v), so no box verifies for a move", err) |
| 1434 | } else { |
| 1435 | s.boxCerts = certs |
| 1436 | } |
| 1437 | if s.km != nil { |
| 1438 | s.sendApp = s.km.SendApp |
| 1439 | s.sendFederated = s.km.SendFederated |
| 1440 | } |
| 1441 | // No goroutine is started here: StartAlertWatcher is serve()'s job, so a |
| 1442 | // test that constructs a Server gets a Server and nothing else. |
| 1443 | s.registerRoute("GET /{$}", s.handleHome) |
| 1444 | s.registerRoute("POST /auth/continue", s.handleContinue) |
| 1445 | s.registerRoute("GET /auth/email/link", s.handleEmailLinkPage) |
| 1446 | s.registerRoute("POST /auth/email/link", s.handleEmailLink) |
| 1447 | s.registerRoute("POST /auth/email/code", s.handleEmailCode) |
| 1448 | s.registerRoute("POST /auth/email/stepup/code", s.handleStepUpEmailCode) |
| 1449 | s.registerRoute("GET /auth/signin", s.handleSignin) |
| 1450 | s.registerRoute("GET /auth/callback", s.handleCallback) |
| 1451 | // Passkey step-up (stepup.go): where a re-authentication lands, on its own |
| 1452 | // route so a step-up code and a sign-in code are never interchangeable. |
| 1453 | s.registerRoute("GET /auth/stepup/callback", s.handleStepUpCallback) |
| 1454 | s.registerRoute("GET /auth/confirm", s.handleConfirm) |
| 1455 | s.registerRoute("POST /auth/signout", s.handleSignout) |
| 1456 | s.registerRoute("GET /fleet", s.handleFleet) |
| 1457 | s.registerRoute("GET /fleet/store", s.handleStoreApprovalsPage) |
| 1458 | s.registerRoute("POST /fleet/store/{account}/{decision}", s.handleStoreDecisionPage) |
| 1459 | s.registerRoute("POST /a/{sqid}/store/apply", s.handleStoreApplyPage) |
| 1460 | s.registerRoute("GET /fleet/metrics", s.handleFleetMetrics) |
| 1461 | s.registerRoute("GET /fleet/economics", s.handleEconomics) |
| 1462 | // The deployment's map (archpage.go), same operatorOnly gate. BOTH are |
| 1463 | // registered WITHOUT a method prefix, for the reason spelled out below |
| 1464 | // the economics doors: a "GET /path" pattern answers a POST with 405 + |
| 1465 | // Allow, which distinguishes this route from an unrouted one to anyone |
| 1466 | // who merely guessed the URL — the exact probe the gate exists to |
| 1467 | // prevent. Each handler checks r.Method itself, first thing. |
| 1468 | s.registerRoute("/fleet/architecture", s.handleArchitecture) |
| 1469 | s.registerRoute("/fleet/architecture/refresh", s.handleArchitectureRefresh) |
| 1470 | // Per-account table (Task 14): same gate, its own search/filter/cursor |
| 1471 | // controls (econlistquery.go) over the same LoadEconomics rows. |
| 1472 | s.registerRoute("GET /fleet/economics/accounts", s.handleEconomicsAccounts) |
| 1473 | // Unit economics (Task 12): scenario/bill writes, operator-only, same |
| 1474 | // gate shape as handleFleet above. GET /fleet/economics is handled |
| 1475 | // above by handleEconomics (Task 13). Registered WITHOUT a method |
| 1476 | // prefix, deliberately: a |
| 1477 | // "POST /path" pattern answers a GET with 405 + Allow, which leaks that |
| 1478 | // the route exists to someone who merely guessed the URL — the exact |
| 1479 | // probe handleFleet's 404-to-everyone gate exists to prevent. Each |
| 1480 | // handler below checks r.Method itself, before anything else, so a |
| 1481 | // wrong-method request is byte-identical to an unknown route. |
| 1482 | s.registerRoute("/fleet/economics/scenarios", s.handleEconomicsScenarioSave) |
| 1483 | s.registerRoute("/fleet/economics/bill", s.handleEconomicsBillSave) |
| 1484 | s.registerRoute("/api/economics/bill", s.handleAPIEconomicsBill) |
| 1485 | s.registerRoute("/api/economics/backhaul", s.handleAPIEconomicsBackhaul) |
| 1486 | // Account tier (economics-v2, Task 4): same bare-registration/method- |
| 1487 | // first reasoning as the two doors above. |
| 1488 | s.registerRoute("/fleet/economics/tier", s.handleEconomicsTierSave) |
| 1489 | // CLI identity: the device-code login `carlos auth login` drives, the |
| 1490 | // approval page, and the tokens an owner can see and kill (authcli.go). |
| 1491 | // The db door's production replica reader (apidb.go); tests repoint it. |
| 1492 | if cfg.HostClaim { |
| 1493 | s.dbHandleKey = dbHandleSecret(key) |
| 1494 | } |
| 1495 | s.dbReplica = s.cachedReadReplica |
| 1496 | s.dbStreams = newDBStreamLimits(dbMaxStreams, dbMaxStreamsPerPrincipal) |
| 1497 | s.dbStreamIdle = dbStreamIdleDefault |
| 1498 | s.dbReads = newDBStreamLimits(dbreads.OpenPerConsole, dbreads.OpenPerPerson) |
| 1499 | s.dbf = defaultDBForwardTimes() |
| 1500 | s.registerCLIRoutes() |
| 1501 | // The email API's ten doors (apimail.go, tenant-email task 17) — |
| 1502 | // registered separately from registerCLIRoutes because this task's file |
| 1503 | // scope is server.go and apimail.go only. |
| 1504 | s.registerEmailRoutes() |
| 1505 | // Account-scoped and explicit: /a/<sqid>/apps/<app> rather than |
| 1506 | // account-by-session, so a link a teammate opens lands where you meant. |
| 1507 | s.registerRoute("GET /a/{sqid}/{$}", s.handleAccountHome) |
| 1508 | s.registerRoute("GET /a/{sqid}/fleet", s.handleAccountFleetTab) |
| 1509 | s.registerRoute("GET /a/{sqid}/fleet/byo", s.handleFleetBYO) |
| 1510 | s.registerRoute("GET /a/{sqid}/team", s.handleTeam) |
| 1511 | s.registerRoute("GET /a/{sqid}/settings", s.handleAccountSettings) |
| 1512 | s.registerRoute("POST /a/{sqid}/errors/level", s.handleAccountErrorLevel) |
| 1513 | s.registerRoute("POST /a/{sqid}/analytics/setting", s.handleAccountAnalyticsSetting) |
| 1514 | // Go 1.22's mux prefers this literal over apps/{app} below — exactly why |
| 1515 | // "new" must be a reserved app name (claimApp): an app so named could |
| 1516 | // never be viewed. |
| 1517 | s.registerRoute("GET /a/{sqid}/apps/new", s.handleNewAppForm) |
| 1518 | s.registerRoute("POST /a/{sqid}/apps/new", s.handleNewApp) |
| 1519 | s.registerRoute("POST /a/{sqid}/apps/new/region", s.handleNewAppRegion) |
| 1520 | s.registerRoute("GET /a/{sqid}/apps/{app}", s.handleApp) |
| 1521 | s.registerRoute("GET /a/{sqid}/apps/{app}/releases", s.handleAppReleases) |
| 1522 | s.registerRoute("GET /a/{sqid}/apps/{app}/deployments", s.handleAppReleases) |
| 1523 | s.registerRoute("GET /a/{sqid}/apps/{app}/account-review", s.handleAppAccountReview) |
| 1524 | s.registerRoute("GET /a/{sqid}/apps/{app}/instances", s.handleAppInstances) |
| 1525 | s.registerRoute("GET /a/{sqid}/apps/{app}/instances/{host}", s.handleAppInstance) |
| 1526 | s.registerRoute("POST /a/{sqid}/apps/{app}/restart", s.handleAppRestart) |
| 1527 | s.registerRoute("POST /a/{sqid}/apps/{app}/instances/{host}/restart", s.handleInstanceRestart) |
| 1528 | s.registerRoute("POST /a/{sqid}/apps/{app}/instances/{host}/retry", s.handleInstanceRetry) |
| 1529 | s.registerRoute("POST /a/{sqid}/apps/{app}/instances/{host}/updates", s.handleBrowserUpdates) |
| 1530 | s.registerRoute("POST /a/{sqid}/apps/{app}/instances/{host}/updates/install", s.handleBrowserUpdateNow) |
| 1531 | s.registerRoute("GET /a/{sqid}/apps/{app}/logs", s.handleAppLogs) |
| 1532 | s.registerRoute("GET /a/{sqid}/apps/{app}/logs/data", s.handleAppLogsData) |
| 1533 | // The Errors tab and its two setting doors (errorsview.go): the tab is |
| 1534 | // member-readable like every other app tab; the level is the owner's, |
| 1535 | // at app and account level, and both POSTs go through the console gate |
| 1536 | // (CSRF, then owner, then the byte-identical 404). |
| 1537 | s.registerRoute("GET /a/{sqid}/apps/{app}/errors", s.handleAppErrors) |
| 1538 | s.registerRoute("POST /a/{sqid}/apps/{app}/errors/level", s.handleAppErrorLevel) |
| 1539 | // Availability (errorsview.go): the Errors tab's sibling for the |
| 1540 | // failures that are the instance's, not the request's — edge 502/503/504 |
| 1541 | // by cause and the platform's lifecycle events. Member-readable, no |
| 1542 | // setting of its own. |
| 1543 | s.registerRoute("GET /a/{sqid}/apps/{app}/availability", s.handleAppAvailability) |
| 1544 | // The Analytics tab (analyticsview.go) and the switch beside it. The tab |
| 1545 | // is member-readable like every other app tab; the switch rides the same |
| 1546 | // gate the errors level does at both levels, and bumps the serial where |
| 1547 | // the errors level does not: a box acts on whether the edge counts. |
| 1548 | s.registerRoute("GET /a/{sqid}/apps/{app}/analytics", s.handleAppAnalytics) |
| 1549 | s.registerRoute("POST /a/{sqid}/apps/{app}/analytics/setting", s.handleAppAnalyticsSetting) |
| 1550 | s.registerRoute("GET /a/{sqid}/apps/{app}/activity", s.handleAppActivity) |
| 1551 | s.registerRoute("GET /a/{sqid}/apps/{app}/settings", s.handleAppSettings) |
| 1552 | s.registerRoute("GET /a/{sqid}/apps/{app}/settings/{section}", s.handleAppSettings) |
| 1553 | // The pipeline settings page (pipelinepage.go, release pipelines v2 §5): |
| 1554 | // GET renders the current (or default) channel list as an editable |
| 1555 | // form; POST goes through the same guarded path the API PUT does. |
| 1556 | s.registerRoute("GET /a/{sqid}/apps/{app}/pipeline", s.handlePipelineSettings) |
| 1557 | s.registerRoute("POST /a/{sqid}/apps/{app}/pipeline", s.handlePipelineSettingsSave) |
| 1558 | s.registerRoute("POST /a/{sqid}/apps/{app}/promote", s.handlePromote) |
| 1559 | s.registerRoute("POST /a/{sqid}/apps/{app}/rollback", s.handleRollback) |
| 1560 | s.registerRoute("POST /a/{sqid}/apps/{app}/production", s.handleProduction) |
| 1561 | s.registerRoute("POST /a/{sqid}/apps/{app}/fasttrack", s.handleFastTrack) |
| 1562 | // The pending-change doors (pendinghandlers.go, release pipelines v2 §4): |
| 1563 | // confirm is member-gated with the passkey step-up LAST, reject is the |
| 1564 | // proposer's or an owner's and deliberately carries no step-up — refusing |
| 1565 | // authority is not exercising it. |
| 1566 | s.registerRoute("POST /a/{sqid}/apps/{app}/pending/{id}/confirm", s.handlePendingConfirm) |
| 1567 | s.registerRoute("POST /a/{sqid}/apps/{app}/pending/{id}/reject", s.handlePendingReject) |
| 1568 | s.registerRoute("POST /a/{sqid}/apps/{app}/canary", s.handleNewCanary) |
| 1569 | s.registerRoute("POST /a/{sqid}/apps/{app}/canary/retire", s.handleRetireCanary) |
| 1570 | // The app Settings writes (appsettings.go): owner-only, and delete/move |
| 1571 | // behind passkey step-up besides. |
| 1572 | s.registerRoute("POST /a/{sqid}/apps/{app}/place", s.handleAppPlace) |
| 1573 | // The password-gate form (appsettings.go's handleAppGate): member-gated, |
| 1574 | // unlike the owner-only writes above it. |
| 1575 | s.registerRoute("POST /a/{sqid}/apps/{app}/gate", s.handleAppGate) |
| 1576 | // The Deploys card's form (appswap.go): member-gated, like the gate. |
| 1577 | s.registerRoute("POST /a/{sqid}/apps/{app}/swap", s.handleAppSwap) |
| 1578 | s.registerRoute("POST /a/{sqid}/apps/{app}/rename", s.handleAppRename) |
| 1579 | s.registerRoute("POST /a/{sqid}/apps/{app}/keep", s.handleAppKeep) |
| 1580 | s.registerRoute("POST /a/{sqid}/apps/{app}/delete", s.handleAppDelete) |
| 1581 | s.registerRoute("POST /a/{sqid}/apps/{app}/move", s.handleAppMoveAccount) |
| 1582 | // The control-plane flag's two directions, both browser-only and both |
| 1583 | // owner-only. Clearing is passkey-gated, setting is not — the asymmetry |
| 1584 | // is spec §6's, and there is deliberately no API sibling for either |
| 1585 | // (controlplane.go). |
| 1586 | s.registerRoute("POST /a/{sqid}/apps/{app}/control-plane/clear", s.handleAppControlPlaneClear) |
| 1587 | s.registerRoute("POST /a/{sqid}/apps/{app}/control-plane/set", s.handleAppControlPlaneSet) |
| 1588 | // Custom domain attach/detach (appdomains.go, #82): owner-only, no |
| 1589 | // passkey step-up (spec §6) — detach's guard is the typed-host confirm. |
| 1590 | s.registerRoute("POST /a/{sqid}/apps/{app}/domains/attach", s.handleAppDomainAttach) |
| 1591 | s.registerRoute("POST /a/{sqid}/apps/{app}/domains/detach", s.handleAppDomainDetach) |
| 1592 | |
| 1593 | // Customer fleets (Task 9): the owner's own remote boxes and what's |
| 1594 | // placed on them — member-readable, owner-gated for rotate/detach |
| 1595 | // (fleets.go). |
| 1596 | s.registerRoute("GET /a/{sqid}/fleets/{fleet}", s.handleFleetPage) |
| 1597 | s.registerRoute("POST /a/{sqid}/fleets/{fleet}/rotate", s.handleFleetRotateBoxPage) |
| 1598 | s.registerRoute("POST /a/{sqid}/fleets/{fleet}/detach", s.handleFleetDetachBoxPage) |
| 1599 | s.registerRoute("POST /a/{sqid}/fleets/{fleet}/tags", s.handleFleetTagBoxPage) |
| 1600 | s.registerRoute("POST /a/{sqid}/fleets/{fleet}/place", s.handleFleetPlacePage) |
| 1601 | // The object store's destroy door (appsettings.go, issue #55): the account |
| 1602 | // owner, the bucket name typed out, and a passkey. It is the only console |
| 1603 | // route that destroys a customer's objects outside the app trash, and |
| 1604 | // unlike the trash there is no cooloff behind it. |
| 1605 | s.registerRoute("POST /a/{sqid}/apps/{app}/store/destroy", s.handleStoreDestroy) |
| 1606 | s.registerRoute("POST /a/{sqid}/invite", s.handleInvite) |
| 1607 | s.registerRoute("POST /a/{sqid}/invite/revoke", s.handleRevokeInvite) |
| 1608 | // Owner-only account administration, every one of them behind passkey |
| 1609 | // step-up as well as ownership and CSRF (ownership.go, spec §7a). |
| 1610 | s.registerRoute("POST /a/{sqid}/members/remove", s.handleRemoveMember) |
| 1611 | s.registerRoute("POST /a/{sqid}/members/transfer", s.handleTransferOwnership) |
| 1612 | s.registerRoute("POST /a/{sqid}/delete", s.handleDeleteAccount) |
| 1613 | // The service-credential pane's writes (svcpage.go, issue #220): |
| 1614 | // owner-only, and create/rotate behind passkey step-up besides. Revoke |
| 1615 | // deliberately is NOT — it is the emergency control, and it must not sit |
| 1616 | // behind a round trip that fails closed. See svcpage.go's doc comment. |
| 1617 | s.registerRoute("POST /a/{sqid}/services/new", s.handleServiceCreatePage) |
| 1618 | s.registerRoute("POST /a/{sqid}/services/rotate", s.handleServiceRotatePage) |
| 1619 | s.registerRoute("POST /a/{sqid}/services/revoke", s.handleServiceRevokePage) |
| 1620 | s.registerRoute("GET /a/{sqid}/trash", s.handleTrash) |
| 1621 | s.registerRoute("POST /a/{sqid}/trash/restore", s.handleRestoreApp) |
| 1622 | s.registerRoute("POST /a/{sqid}/trash/delete", s.handlePurgeApp) |
| 1623 | // GET only shows the invitation; joining is the POST its button sends |
| 1624 | // (discussion #219, item 4). |
| 1625 | s.registerRoute("GET /invite/{token}", s.handleInvitePage) |
| 1626 | s.registerRoute("POST /invite/{token}", s.handleAcceptInvite) |
| 1627 | s.registerRoute("GET /accounts/new", s.handleNewAccountForm) |
| 1628 | s.registerRoute("POST /accounts/new", s.handleNewAccount) |
| 1629 | // The pre-accounts URLs, kept as redirects so old links and bookmarks |
| 1630 | // still land somewhere sensible rather than 404ing. |
| 1631 | s.registerRoute("GET /apps", s.handleLegacyApps) |
| 1632 | s.registerRoute("GET /apps/{app}", s.handleLegacyApp) |
| 1633 | // "ok <build>" — the build comes from THIS PROCESS (ldflags, at link |
| 1634 | // time), which is the whole point: X-Carlos-Version is stamped by the |
| 1635 | // edge from the route row, so it reports the version adoption INTENDED |
| 1636 | // from the moment the symlink moves, whether or not anything restarted. |
| 1637 | // `carlos deploy` polls this instead for any app declaring a build probe. |
| 1638 | // |
| 1639 | // "ok" stays the first token: internal/vet and the edge health probe read |
| 1640 | // this endpoint and check status, not body, but keeping the prefix means |
| 1641 | // a human or a script eyeballing it sees what it always saw. |
| 1642 | s.registerRoute("GET /healthz", func(w http.ResponseWriter, r *http.Request) { |
| 1643 | fmt.Fprintln(w, "ok", version.Build) |
| 1644 | }) |
| 1645 | // SES event ingest (sesevents.go, tenant-email spec §9): UNAUTHENTICATED, |
| 1646 | // like healthz above — SNS is not a signed-in member, so there is no |
| 1647 | // session, no CSRF and no owner check ahead of it. Its whole security |
| 1648 | // model lives inside handleSESEvents: a TopicArn allowlist, a |
| 1649 | // label-aware SigningCertURL host check, and a verified signature, |
| 1650 | // all run before any field of the message is trusted. |
| 1651 | s.registerRoute("POST /api/ses/events", s.handleSESEvents) |
| 1652 | // Catch up on accounts minted outside this database — `carlos accounts |
| 1653 | // create` writes the bucket manifest and the registry row and never opens |
| 1654 | // console state. Startup is the natural place: the account switcher, the |
| 1655 | // membership gate and every /a/<sqid>/… route resolve through the accounts |
| 1656 | // table, so an account the console has not materialized is invisible to |
| 1657 | // all of them. Best-effort; a bucket hiccup must not stop the console |
| 1658 | // booting. |
| 1659 | if cfg.HostClaim { |
| 1660 | if err := st.migrateClaimAppIdentity(); err != nil { |
| 1661 | return nil, err |
| 1662 | } |
| 1663 | } |
| 1664 | materializeQuietly(context.Background(), cfg.Releases, st, cfg.HostClaim) |
| 1665 | // The pre-accounts backfill: operators become owners of ops, every granted |
| 1666 | // app name is claimed there, and non-operator grant holders are left out |
| 1667 | // and REPORTED — a per-app grant was never authority over ops. Additive and |
| 1668 | // idempotent, so it is cheap enough to run on every start. The report is |
| 1669 | // logged rather than acted on: only an operator can decide who gets an |
| 1670 | // account of their own, but nobody can decide it if nobody is told. |
| 1671 | needAccounts, err := st.BackfillOpsMemberships(time.Now()) |
| 1672 | if err != nil { |
| 1673 | return nil, err |
| 1674 | } |
| 1675 | if advice := grantHolderAdvice(needAccounts); advice != "" { |
| 1676 | log.Print(advice) |
| 1677 | } |
| 1678 | logRegionConfig(cfg) |
| 1679 | s.registerClaimWorkers() |
| 1680 | if cfg.Releases != nil { |
| 1681 | s.geoip = newGeoipPublisher(cfg.Releases, cfg.GeoipStagingDir) |
| 1682 | } |
| 1683 | return s, nil |
| 1684 | } |
| 1685 | |
| 1686 | // PublishMovesConsole writes control/moves-console.json: the moves |
| 1687 | // protocol this console speaks and the fingerprint of its |
| 1688 | // CARLOS_SECRETS_PUBKEY (empty when unset), so a box can check the console |
| 1689 | // it is moving for (moves Task 13). Only with the moves switch on (index |
| 1690 | // D13: off publishes nothing). Called once per start, by the console's |
| 1691 | // main, never by NewServer, so constructing a Server in a test writes |
| 1692 | // nothing. A later start replaces the record. Best-effort: a failure is |
| 1693 | // logged and returned, and the console serves regardless. |
| 1694 | func (s *Server) PublishMovesConsole(ctx context.Context) error { |
| 1695 | if !s.cfg.MovesEnabled || s.cfg.Releases == nil { |
| 1696 | return nil |
| 1697 | } |
| 1698 | fp, _ := appconfig.PublicFingerprint(s.cfg.SecretsPub) |
| 1699 | body, err := json.Marshal(moves.ConsoleRecord{V: 1, Protocol: moves.ProtocolVersion, SecretsKeyFP: fp, At: time.Now().UTC()}) |
| 1700 | if err == nil { |
| 1701 | err = moves.PutReplace(ctx, s.cfg.Releases, moves.ConsoleKey, body) |
| 1702 | } |
| 1703 | if err != nil { |
| 1704 | log.Printf("console: could not publish %s: %v", moves.ConsoleKey, err) |
| 1705 | return err |
| 1706 | } |
| 1707 | return nil |
| 1708 | } |
| 1709 | |
| 1710 | // StartGeoipJobs runs the orphan sweep and D1 expiry at startup and then |
| 1711 | // daily (geoip spec §3, D1), until ctx ends. It is the recorded worker |
| 1712 | // "StartGeoipJobs" (claim_workers.go) and, like every worker, blocks: cmd/console |
| 1713 | // starts it through RunClaimWorker, never NewServer, so constructing a Server in |
| 1714 | // a test never starts a goroutine. Every goroutine it starts is joined before it |
| 1715 | // returns, so a test that bounds it with a context leaves nothing behind. No |
| 1716 | // publisher (no bucket) means GeoIP is off here and it returns at once. |
| 1717 | func (s *Server) StartGeoipJobs(ctx context.Context) { |
| 1718 | if s.geoip == nil { |
| 1719 | return |
| 1720 | } |
| 1721 | var wg sync.WaitGroup |
| 1722 | defer wg.Wait() |
| 1723 | if f, ok := newGeoipFeeder(s.cfg, s.geoip); ok { |
| 1724 | log.Printf("console: geoip feed %s armed (first check in 5 minutes, then daily)", f.spec.Name) |
| 1725 | wg.Add(1) |
| 1726 | go func() { defer wg.Done(); f.run(ctx) }() |
| 1727 | } else { |
| 1728 | // Off the startup path, and bounded: a stalled bucket must not |
| 1729 | // hold the console's start (code review R4-1). |
| 1730 | wg.Add(1) |
| 1731 | go func() { defer wg.Done(); retryClearFeedStatus(ctx, s.cfg.Releases, 30*time.Second, 10*time.Minute) }() |
| 1732 | } |
| 1733 | for { |
| 1734 | s.geoipPass(ctx) |
| 1735 | t := time.NewTimer(24*time.Hour + mrand.N(time.Hour)) |
| 1736 | select { |
| 1737 | case <-ctx.Done(): |
| 1738 | t.Stop() |
| 1739 | return |
| 1740 | case <-t.C: |
| 1741 | } |
| 1742 | } |
| 1743 | } |
| 1744 | |
| 1745 | // geoipPass is one synchronous orphan sweep and D1 expiry check. Each is |
| 1746 | // bounded: these hold the publish slot, and a stalled bucket must not leave |
| 1747 | // publish and off answering 409 forever. |
| 1748 | func (s *Server) geoipPass(ctx context.Context) { |
| 1749 | sctx, cancel := context.WithTimeout(ctx, 2*time.Minute) |
| 1750 | if err := s.geoip.Sweep(sctx); err != nil { |
| 1751 | log.Printf("console: geoip sweep: %v", err) |
| 1752 | } |
| 1753 | cancel() |
| 1754 | ectx, cancel := context.WithTimeout(ctx, 2*time.Minute) |
| 1755 | if _, err := s.geoip.ExpireIfDue(ectx, time.Now()); err != nil { |
| 1756 | log.Printf("console: geoip expiry check: %v", err) |
| 1757 | } |
| 1758 | cancel() |
| 1759 | } |
| 1760 | |
| 1761 | // ServeHTTP checks the host-based redirect BEFORE the mux on purpose: it's |
| 1762 | // purely host-based, touches no session/account state, so it sits in front |
| 1763 | // of the console's security gates without weakening them (the gates |
| 1764 | // ordering rule concerns account-existence leaks; a host-level redirect |
| 1765 | // reveals nothing). |
| 1766 | func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| 1767 | if s.redirects[normalizeHost(r.Host)] { |
| 1768 | code := http.StatusMovedPermanently |
| 1769 | if r.Method != http.MethodGet && r.Method != http.MethodHead { |
| 1770 | code = http.StatusPermanentRedirect // 308 keeps the method |
| 1771 | } |
| 1772 | http.Redirect(w, r, s.cfg.Origin+redirectTarget(r.URL), code) |
| 1773 | return |
| 1774 | } |
| 1775 | s.mux.ServeHTTP(w, r) |
| 1776 | } |
| 1777 | |
| 1778 | // redirectTarget is the path and query to hang off Origin: the request's own |
| 1779 | // RequestURI, but only where that is the rooted path a browser sends. A |
| 1780 | // request target may legally be rootless — `GET mailto:x@evil.com HTTP/1.1` |
| 1781 | // parses to an opaque URL whose RequestURI is "x@evil.com" — and pasting that |
| 1782 | // onto the origin spells https://console.example.com@evil.com, an open |
| 1783 | // redirect wearing the console's own hostname in the userinfo. The console |
| 1784 | // serves nothing at a rootless URL, so anything unrooted goes to the front |
| 1785 | // door instead. |
| 1786 | func redirectTarget(u *url.URL) string { |
| 1787 | if uri := u.RequestURI(); strings.HasPrefix(uri, "/") { |
| 1788 | return uri |
| 1789 | } |
| 1790 | return "/" |
| 1791 | } |
| 1792 | |
| 1793 | // normalizeHost reduces a request Host header (or a configured origin/ |
| 1794 | // redirect host) to the bare, lowercase hostname the RedirectHosts |
| 1795 | // comparison keys on. net.SplitHostPort errors when there's no port to |
| 1796 | // strip — a bare hostname, or an unbracketed IPv6 literal — and then the |
| 1797 | // only thing left to remove is the brackets it would have taken off with the |
| 1798 | // port, so that "[::1]" and "[::1]:8080" key the same. |
| 1799 | func normalizeHost(host string) string { |
| 1800 | host = strings.ToLower(strings.TrimSpace(host)) |
| 1801 | if h, _, err := net.SplitHostPort(host); err == nil { |
| 1802 | return h |
| 1803 | } |
| 1804 | return strings.TrimSuffix(strings.TrimPrefix(host, "["), "]") |
| 1805 | } |
| 1806 | |
| 1807 | // ---- pages ------------------------------------------------------------------ |
| 1808 | |
| 1809 | // page is the one bag every template reads from; unused fields are ignored. |
| 1810 | type page struct { |
| 1811 | RegionPicker *regionPickerView // new-app page: nil = no picker |
| 1812 | |
| 1813 | AccountStore *accountStoreView |
| 1814 | StoreQueue *storeQueueView |
| 1815 | StorePending *int |
| 1816 | StoreQueueUnknown bool |
| 1817 | |
| 1818 | // RegionLine is the Instances tab's app-region note (C7 or C8, spec |
| 1819 | // §10); "" shows nothing. RegionLineWarn marks C8. |
| 1820 | RegionLine string |
| 1821 | RegionLineWarn bool |
| 1822 | |
| 1823 | InstanceDetail *instanceDetail |
| 1824 | InstanceGroups []instanceGroup |
| 1825 | InstanceChannels []string |
| 1826 | InstanceUnknownChannel bool |
| 1827 | InstanceListURL string |
| 1828 | DeploymentTargets []deploymentTarget |
| 1829 | |
| 1830 | Title string |
| 1831 | Address string // signed-in address, "" when signed out |
| 1832 | CSRF string // the session token, double-submitted on the signout POST |
| 1833 | Error string |
| 1834 | Welcome bool // one-shot "account created" banner |
| 1835 | BrowserRestartCopy BrowserRestartCopy |
| 1836 | StatusCopy StatusCopy |
| 1837 | |
| 1838 | AccountReview AccountReview |
| 1839 | MismatchCopy MismatchCopy |
| 1840 | |
| 1841 | ActiveNav string // account nav section this page sits in: "apps" | "fleet" | "team" | "settings" |
| 1842 | |
| 1843 | ActiveTab string // app pages: "overview" | "releases" | "instances" | "activity" | "settings" |
| 1844 | AppName string // app pages: the {app} segment, for the tab bar and eyebrow |
| 1845 | |
| 1846 | // The signed-out front page and "Check your email" (emailsignin.go). |
| 1847 | // Prefill is the address the form starts with; Next the safe local path |
| 1848 | // carried as a hidden field; Banner a note above the form (an invite, a |
| 1849 | // terminal); NoMail shows home.no_mail; CheckSent is check.sent, filled; |
| 1850 | // CodeError a refusal of a typed code. SigninCopy is the approved copy, |
| 1851 | // set by render. |
| 1852 | Prefill, Next, Banner string |
| 1853 | NoMail bool |
| 1854 | CheckSent, CodeError string |
| 1855 | SigninCopy signinView |
| 1856 | // Brand and ConsoleName are the deployment's names (render fills them): |
| 1857 | // Brand in the page chrome, ConsoleName in prose ("Welcome to ..."). |
| 1858 | Brand string |
| 1859 | ConsoleName string |
| 1860 | // The emailed link's confirm page: the token and the login-CSRF nonce |
| 1861 | // its form POSTs; StepUpLink when the link re-confirms a session |
| 1862 | // rather than signing in. |
| 1863 | LinkToken, LinkNonce string |
| 1864 | StepUpLink bool |
| 1865 | // ConfirmNote is the team page's note on actions that ask you to |
| 1866 | // confirm it's you (team.confirm_note). |
| 1867 | ConfirmNote string |
| 1868 | // InvitePlaceholder and InviteNote are the team page's invite form |
| 1869 | // hints (team.invite_placeholder, team.invite_note). |
| 1870 | InvitePlaceholder, InviteNote string |
| 1871 | |
| 1872 | Msg string // generic message page body |
| 1873 | |
| 1874 | Flash string // one-shot success notice (cookie-carried across a redirect) |
| 1875 | FlashErr string // one-shot error notice |
| 1876 | |
| 1877 | Apps []appRow // account home |
| 1878 | Detail *AppView // /a/{sqid}/apps/{app} |
| 1879 | Versions []VersionView // app page: shipped releases, newest first |
| 1880 | Tags []release.Tag // releases tab: every tag this app has cut, changelog and all |
| 1881 | RetentionKeep int // releases tab: >0 when an ambient retention policy is set (spec 2026-08-10-releases-retention-design.md §7) |
| 1882 | // ReleaseSummary is the Releases tab's lede strip: what is live and |
| 1883 | // what is next, folded from Detail.Channels once the tab has them. |
| 1884 | ReleaseSummary ReleaseSummary |
| 1885 | // PromoteChannels is the per-row promote select's option list: the |
| 1886 | // ladder rungs (edge, beta, stable) so a fresh app with zero channels |
| 1887 | // still has somewhere to promote its first release, unioned with any |
| 1888 | // channels (including canary/*) the app already has. |
| 1889 | PromoteChannels []string |
| 1890 | Instances *InstanceReport // app page: the app's self-report, nil when unpolled |
| 1891 | Events []Event // app page: recent console actions, newest first |
| 1892 | ActivityCopy activityCopy // app activity: reviewed labels and validation copy |
| 1893 | CanPromote bool // a signing key is configured on this host |
| 1894 | |
| 1895 | // Pending is the app page's pending-changes card (release pipelines v2 |
| 1896 | // §4, pendinghandlers.go): every unexpired, unexecuted change this app |
| 1897 | // has, oldest first — a proposed promote, policy edit or fast-track, |
| 1898 | // with its quorum progress and the confirm/reject buttons that post to |
| 1899 | // Task 8's routes. |
| 1900 | Pending []PendingChangeView |
| 1901 | |
| 1902 | // The pipeline settings page (pipelinepage.go), GET/POST |
| 1903 | // /a/{sqid}/apps/{app}/pipeline: PipelineRows is the current (or |
| 1904 | // mid-edit draft) channel list as editable form rows, indexed ch.<i>.*; |
| 1905 | // PipelineIsDefault is whether it's still the untouched one-channel |
| 1906 | // default a fresh app is created with, which is when the two starter |
| 1907 | // templates (Edge → Production, Full ladder) are worth offering. |
| 1908 | PipelineRows []PipelineFormRow |
| 1909 | PipelineIsDefault bool |
| 1910 | SettingsSection string // app Settings sub-page selected by the URL |
| 1911 | SettingsRenameTo string // retained draft after a General form refusal |
| 1912 | SettingsDomain string // retained Domains attach draft |
| 1913 | SettingsChannel string // retained Domains attach draft |
| 1914 | SettingsNav []SettingsSectionLink |
| 1915 | |
| 1916 | // App page: how a canary channel gets a URL, rendered as commands the |
| 1917 | // reader can actually run from where they are reading. Computed |
| 1918 | // server-side (attachCommands) because the host shape comes from the |
| 1919 | // app's instances manifest. AttachEnable is non-empty only when |
| 1920 | // instances are PROVEN off, so the copy leads with the enable step. |
| 1921 | AttachCmd string |
| 1922 | AttachEnable string |
| 1923 | |
| 1924 | // The account chrome every /a/{sqid}/… page carries. |
| 1925 | Account *Account // the account being viewed |
| 1926 | Role string // the viewer's role in it: owner | member |
| 1927 | Base string // "/a/<sqid>" — what every form on the page posts under |
| 1928 | Nav string // "/a/<name-or-sqid>" — what LINKS use; forms keep posting to Base |
| 1929 | Accounts []Account // the switcher: every account the viewer belongs to |
| 1930 | Members []Account // team page: memberships (Name carries the address) |
| 1931 | Invites []Invite // team page, owners only: pending invitations |
| 1932 | HasApps bool // account settings: an account with apps cannot be deleted |
| 1933 | |
| 1934 | // The Trash (ownership.go): the account's deleted-but-restorable apps on |
| 1935 | // /a/{sqid}/trash, and the count the account home links with. Zero hides |
| 1936 | // the link entirely — an empty trash is not a place worth sending anyone. |
| 1937 | Trashed []TrashedAppView |
| 1938 | TrashN int |
| 1939 | |
| 1940 | // ExpiresAt is when a temporary app moves to the Trash; zero for a |
| 1941 | // permanent one. appnav shows it with the Keep button. |
| 1942 | ExpiresAt time.Time |
| 1943 | |
| 1944 | // The picker: /a/{seg}/ where seg matches more than one of the viewer's |
| 1945 | // accounts, as a name, a sqid, or one of each. |
| 1946 | PickerSeg string // the ambiguous segment the picker is disambiguating |
| 1947 | PickerAccounts []Account // every account of the viewer's that segment matches |
| 1948 | |
| 1949 | Fleet *status.Fleet // /fleet: nil means no snapshot published yet |
| 1950 | |
| 1951 | // /fleet, below the published boxes table: the per-host PRIVATE detail |
| 1952 | // read straight out of status/boxes/ (HostRows, view.go). Raw numbers |
| 1953 | // render here and nowhere public — this page is session-gated to |
| 1954 | // operators, fleet.json is not. Nil renders no section at all: no boxes |
| 1955 | // yet, no bucket handle, or a boxes read that failed are all "no detail |
| 1956 | // to show", never a reason to fail the page. |
| 1957 | Hosts []HostRow |
| 1958 | |
| 1959 | // /fleet/metrics: the operator dashboard (Task 11), both from LoadMetrics |
| 1960 | // (metricsview.go) and both nil-safe to render — no boxes and no firing |
| 1961 | // alerts are the normal, healthy, empty state, not an error. |
| 1962 | Metrics []BoxMetricsView |
| 1963 | MetricAlerts []AlertRow |
| 1964 | |
| 1965 | // /fleet/architecture (archgraph.go): the deployment's map. Arch is nil |
| 1966 | // on every other page, and the template's {{with .Arch}} is what keeps |
| 1967 | // this page's blocks off them. |
| 1968 | // |
| 1969 | // There is no pre-rendered SVG here: an estate is drawn as one picture |
| 1970 | // per region band and the number of bands is a property of the estate, |
| 1971 | // so the template ranges over Arch.CARLOS.Bands / Arch.Rest.Bands and |
| 1972 | // each band renders itself. |
| 1973 | // |
| 1974 | // ArchBoxesUnread is why the box list is empty: an empty Boxes with a |
| 1975 | // failed fleet/host read behind it must say "could not be read", never |
| 1976 | // "no box has reported". The distinction cannot be made from Boxes |
| 1977 | // alone — both cases are len 0 — so the handler carries it here. |
| 1978 | Arch *ArchView |
| 1979 | ArchBoxesUnread bool |
| 1980 | |
| 1981 | // /fleet/economics: the operator unit-economics dashboard (Task 13), |
| 1982 | // from LoadEconomics (economicsview.go). nil only on a rendering path |
| 1983 | // this handler never takes (handleEconomics always loads one before |
| 1984 | // rendering) — every field on it is itself nil-safe, matching Metrics |
| 1985 | // above. |
| 1986 | Economics *EconomicsView |
| 1987 | |
| 1988 | // /fleet/economics/accounts: the per-account table (Task 14) — |
| 1989 | // EconAccounts is the computed page (rows, counts, links, pager), |
| 1990 | // EconQuery is the parsed request so the search box and filter links |
| 1991 | // re-render what the viewer asked for. Economics (above) is ALSO set on |
| 1992 | // this page: the template embeds the same econ-data payload so the |
| 1993 | // shared knob drawer recomputes this page's rows too. |
| 1994 | EconAccounts econAccountsPage |
| 1995 | EconQuery econAccountQuery |
| 1996 | |
| 1997 | // App page, "Resources": this app's OWN instances' unit readings (Task |
| 1998 | // 12), one row per route host. Nil renders no section at all — metrics |
| 1999 | // are decoration on this page and must never be the reason it fails. |
| 2000 | AppMetrics []InstanceMetrics |
| 2001 | |
| 2002 | // App Overview: the dashboard's vital statistics (overview.go), derived |
| 2003 | // from reads the page does anyway. |
| 2004 | Overview OverviewStats |
| 2005 | |
| 2006 | // App Overview: the same reads drawn as the pipeline they describe |
| 2007 | // (flow.go). Zero-valued (Show false) renders nothing at all. |
| 2008 | Flow FlowView |
| 2009 | |
| 2010 | // App Instances: the one table — routes joined with their readings. |
| 2011 | InstanceRows []InstanceRow |
| 2012 | CanRestartApp bool |
| 2013 | |
| 2014 | // App Instances: the search/health-filter/cursor controls (Task 8) — |
| 2015 | // InstancePage is the computed page (rows, counts, links, pager), |
| 2016 | // InstanceQuery is the parsed request so the form and links re-render |
| 2017 | // what the viewer asked for. |
| 2018 | InstancePage instancePage |
| 2019 | InstanceQuery instanceQuery |
| 2020 | |
| 2021 | // App Logs (apilogs.go's sibling page): the merged timeline plus the |
| 2022 | // filter state the form re-renders with. Empty LogRows with LogsErr "" |
| 2023 | // is the normal nothing-logged-yet state, never an error. |
| 2024 | LogRows []applog.Record |
| 2025 | LogStream string |
| 2026 | LogSince string |
| 2027 | LogHost string |
| 2028 | LogLevel string |
| 2029 | LogGrep string |
| 2030 | LogLegacyGrep string |
| 2031 | LogFollow bool |
| 2032 | LogHosts []string |
| 2033 | LogCopy appLogsCopy |
| 2034 | LogWidenURL string |
| 2035 | LogCursor string |
| 2036 | LogTruncated bool |
| 2037 | LogsErr string |
| 2038 | |
| 2039 | // App Errors (errorsview.go's page, apierrors.go's sibling): the |
| 2040 | // assembled window plus the filter state the form re-renders with. Empty |
| 2041 | // ErrWin with ErrsErr "" is the normal nothing-failed-yet state, never an |
| 2042 | // error. |
| 2043 | ErrWin errorsWindow |
| 2044 | ErrSince string // "1h" | "24h" | "168h", or any duration a URL named |
| 2045 | ErrSource string |
| 2046 | ErrLevel string // "" = the setting |
| 2047 | ErrHost string |
| 2048 | ErrFP string // expanded group |
| 2049 | ErrsErr string |
| 2050 | // ErrLevelSetting is the resolved log level for the two SETTINGS pages |
| 2051 | // (app and account), which is where the level is changed rather than |
| 2052 | // read past. Zero everywhere else. |
| 2053 | // |
| 2054 | // ErrLevelUnread says the resolve FAILED, so the zero Resolved beside it |
| 2055 | // must not be shown as if the defaults were the setting: a form |
| 2056 | // pre-selecting "error" over a level that is really "warn" invites an |
| 2057 | // owner to save a change they did not mean to make. The section then |
| 2058 | // says it could not be read, and offers no form at all. |
| 2059 | ErrLevelSetting telemetry.Resolved |
| 2060 | ErrLevelUnread bool |
| 2061 | |
| 2062 | // AnalyticsSetting is the same resolved object, under the name the |
| 2063 | // Analytics section of those two pages reads it by — telemetry.Resolve |
| 2064 | // returns both halves at once, so the pages fill these from the ONE call |
| 2065 | // they already make. AnalyticsUnread carries the same refusal to show |
| 2066 | // defaults as if they were the setting. |
| 2067 | AnalyticsSetting telemetry.Resolved |
| 2068 | AnalyticsUnread bool |
| 2069 | |
| 2070 | // SwapPolicy is the app's swap policy for the settings page's Deploys |
| 2071 | // card (zero-downtime spec §4.1, §9.2). SwapUnread says the read failed, |
| 2072 | // and the card then offers no form, for ErrLevelUnread's reason. |
| 2073 | SwapPolicy appconfig.SwapPolicy |
| 2074 | SwapUnread bool |
| 2075 | |
| 2076 | // App Analytics (analyticsview.go's tab, apianalytics.go's sibling): the |
| 2077 | // assembled window plus the range the form re-renders with. A zero |
| 2078 | // AnalyticsWin with AnalyticsErr "" is the normal nothing-counted-yet |
| 2079 | // state, never an error — and the window carries its own resolved |
| 2080 | // setting, because the tab has to say whether counting is on. |
| 2081 | AnalyticsWin analyticsWindow |
| 2082 | AnalyticsRange string // "today" | "7d" | "30d" |
| 2083 | AnalyticsErr string |
| 2084 | |
| 2085 | // Action confirm interstitial (confirm.go): what is about to happen, |
| 2086 | // carried fields and all. Nil everywhere except the confirm page. |
| 2087 | Confirm *ConfirmView |
| 2088 | |
| 2089 | // App Settings (appsettings.go): what the tab knows beyond the AppView. |
| 2090 | Settings *SettingsView |
| 2091 | |
| 2092 | // /a/{sqid}/fleets/{fleet} (Task 9): the owner's own remote boxes, |
| 2093 | // joined to their heartbeats, and the account's apps placed on this |
| 2094 | // fleet. FleetDetail carries only Name/Account/Bucket — registry.Fleet |
| 2095 | // has no TokenHash on it at all (that lives on FleetBox, never echoed |
| 2096 | // here — see fleets.go's package doc comment), so putting it straight |
| 2097 | // on the page is safe. |
| 2098 | FleetDetail *registry.Fleet |
| 2099 | FleetBoxes []fleetPageBox |
| 2100 | FleetApps []fleetPagePlacedApp |
| 2101 | // FleetPlaceableApps is the fleet page's "Place an app here" form: the |
| 2102 | // account's apps that are not already placed on THIS fleet |
| 2103 | // (fleetPlaceableApps). Empty means the form renders nothing to choose, |
| 2104 | // so the form itself is withheld — never an empty select. |
| 2105 | FleetPlaceableApps []string |
| 2106 | |
| 2107 | // App Instances tab, "Placement" (Task 6): where this app runs |
| 2108 | // ("fleet/<name>", or "" for the shared fleet), and the account's own |
| 2109 | // fleets as the owner's move options. AppPlacementKnown false means the |
| 2110 | // read FAILED — the section then says so instead of claiming the shared |
| 2111 | // fleet, and the move form is withheld rather than pre-selecting a guess. |
| 2112 | AppPlacement string |
| 2113 | AppPlacementKnown bool |
| 2114 | OwnedFleetNames []string |
| 2115 | |
| 2116 | // /a/{sqid}/fleet (accountfleet.go): the account-level Fleet TAB, which |
| 2117 | // is neither of the two pages above — the shared platform fleet as this |
| 2118 | // account sees it (built from the PUBLIC status/fleet.json aggregate, |
| 2119 | // never the raw per-box snapshots) and an index of the account's own |
| 2120 | // fleets. Set only by handleAccountFleetTab; every other page leaves |
| 2121 | // SharedFleet nil and renders no such section. |
| 2122 | // FleetsKnown says the AccountFleets read SUCCEEDED, so an empty slice |
| 2123 | // may be rendered as "no fleets yet" rather than "unknown" — the twin of |
| 2124 | // sharedFleetCard.PlacementKnown, and load-bearing for the same reason: |
| 2125 | // nil is otherwise both answers at once. |
| 2126 | SharedFleet *sharedFleetCard |
| 2127 | AccountFleets []accountFleetCard |
| 2128 | FleetsKnown bool |
| 2129 | // RotatedLabel/RotatedToken are set only on the response to a rotate |
| 2130 | // POST, to show the fresh bearer token exactly once — never stored, |
| 2131 | // never re-derivable from a later view of this same page. |
| 2132 | RotatedLabel string |
| 2133 | RotatedToken string |
| 2134 | |
| 2135 | // The service-credential pane on /a/{sqid}/settings (svcpage.go, issue |
| 2136 | // #220). Filled ONLY for an owner, which is why the pane needs no new |
| 2137 | // template condition — it sits inside the {{if .IsOwner}} block that |
| 2138 | // already exists. A member therefore sees the page WITHOUT the section |
| 2139 | // rather than with an empty one: an empty section reads as "this account |
| 2140 | // has no service credentials", a different and possibly false statement. |
| 2141 | // |
| 2142 | // Services carries no secret and never can: only a credential's sha256 is |
| 2143 | // stored, so there is nothing in a listed row to leak. ServicesUnknown |
| 2144 | // says the listing READ FAILED, so the pane can say so instead of |
| 2145 | // rendering an empty table as "none" — nil is otherwise both answers at |
| 2146 | // once. |
| 2147 | Services []CLIToken |
| 2148 | ServicesUnknown bool |
| 2149 | ServiceRoles []string // the mint form's role choices, from svcrole.All |
| 2150 | ServiceRungs []string // the mint form's rung choices, deploy only |
| 2151 | ServiceApps []string // the mint form's scope choices: this account's apps |
| 2152 | // ServiceSecret is the once-only block, set ONLY on the response to the |
| 2153 | // create or rotate POST that produced it — never redirected to, never |
| 2154 | // flashed (a flash is a cookie, and a credential does not belong in one). |
| 2155 | ServiceSecret *ServiceSecretView |
| 2156 | } |
| 2157 | |
| 2158 | type SettingsSectionLink struct { |
| 2159 | Key, Label, Href string |
| 2160 | Current bool |
| 2161 | } |
| 2162 | |
| 2163 | type appLogsCopy struct { |
| 2164 | Instance, AllInstances, Search, AllLevels, Follow, Pause, Widen, FollowError, Refreshed string |
| 2165 | } |
| 2166 | |
| 2167 | // IsOwner is the template's one permission question. |
| 2168 | func (p page) IsOwner() bool { return p.Role == RoleOwner } |
| 2169 | |
| 2170 | // appRow is one line of the "your apps" table: the app, where it's running, |
| 2171 | // whether anything the REGISTRY tracks needs attention, and what its owned |
| 2172 | // routes report serving. |
| 2173 | // The verdict here is registry-scope by design — process liveness only |
| 2174 | // exists in the per-app metrics sweep, which is too heavy to run across a |
| 2175 | // whole account's list. So the healthy word is "Deployed", never "All |
| 2176 | // good": the list claims exactly what it knows, and the app's Overview |
| 2177 | // (which has the sweep) gives the full verdict. Publishing per-app liveness |
| 2178 | // into the aggregate feed so this list can say more is a filed follow-up. |
| 2179 | type appRow struct { |
| 2180 | App string |
| 2181 | URL string // apex host, "" when no routes |
| 2182 | Verdict string // ok | attention | notdeployed | unknown |
| 2183 | VerdictLabel string // Deployed | Needs attention | Not deployed yet | Can't tell right now |
| 2184 | Deployment DeploymentStatus |
| 2185 | CanaryN int // count of canary/* channels |
| 2186 | } |
| 2187 | |
| 2188 | func (s *Server) render(w http.ResponseWriter, status int, name string, p page) { |
| 2189 | p.StatusCopy = approvedStatusCopy |
| 2190 | p.BrowserRestartCopy = approvedBrowserRestartCopy |
| 2191 | p.MismatchCopy = approvedMismatchCopy |
| 2192 | p.SigninCopy = approvedSigninView |
| 2193 | s.chrome(&p) |
| 2194 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 2195 | w.WriteHeader(status) |
| 2196 | if err := templates.ExecuteTemplate(w, name, p); err != nil { |
| 2197 | log.Printf("console: render %s: %v", name, err) |
| 2198 | } |
| 2199 | } |
| 2200 | |
| 2201 | func (s *Server) renderMessage(w http.ResponseWriter, r *http.Request, status int, title, msg string) { |
| 2202 | p := s.basePage(r) |
| 2203 | p.Title = title |
| 2204 | p.Msg = msg |
| 2205 | s.render(w, status, "message", p) |
| 2206 | } |
| 2207 | |
| 2208 | // basePage fills the fields the layout chrome needs (signed-in address and |
| 2209 | // the signout CSRF token). |
| 2210 | func (s *Server) basePage(r *http.Request) page { |
| 2211 | addr, raw, ok := s.session(r) |
| 2212 | if !ok { |
| 2213 | return page{} |
| 2214 | } |
| 2215 | return page{Address: addr, CSRF: raw} |
| 2216 | } |
| 2217 | |
| 2218 | // ---- session plumbing ------------------------------------------------------- |
| 2219 | |
| 2220 | func (s *Server) session(r *http.Request) (address, rawToken string, ok bool) { |
| 2221 | c, err := r.Cookie(sessionCookie) |
| 2222 | if err != nil || c.Value == "" { |
| 2223 | return "", "", false |
| 2224 | } |
| 2225 | addr, ok := s.store.SessionAddress(c.Value, time.Now()) |
| 2226 | if !ok { |
| 2227 | return "", "", false |
| 2228 | } |
| 2229 | return addr, c.Value, true |
| 2230 | } |
| 2231 | |
| 2232 | func (s *Server) setCookie(w http.ResponseWriter, name, value string, maxAge int) { |
| 2233 | http.SetCookie(w, &http.Cookie{ |
| 2234 | Name: name, Value: value, Path: "/", |
| 2235 | MaxAge: maxAge, HttpOnly: true, Secure: s.secure, |
| 2236 | SameSite: http.SameSiteLaxMode, |
| 2237 | }) |
| 2238 | } |
| 2239 | |
| 2240 | func (s *Server) clearCookie(w http.ResponseWriter, name string) { |
| 2241 | s.setCookie(w, name, "", -1) |
| 2242 | } |
| 2243 | |
| 2244 | // ---- flash notices ---------------------------------------------------------- |
| 2245 | |
| 2246 | const flashCookie = "carlos_console_flash" |
| 2247 | |
| 2248 | // setFlash parks a one-shot notice ("ok" or "err") in a cookie for the page |
| 2249 | // after the redirect — no query-string flashes an outsider could spoof into |
| 2250 | // a link. |
| 2251 | func (s *Server) setFlash(w http.ResponseWriter, kind, msg string) { |
| 2252 | s.setCookie(w, flashCookie, url.QueryEscape(kind+"|"+msg), 300) |
| 2253 | } |
| 2254 | |
| 2255 | // takeFlash reads and clears the notice. |
| 2256 | func (s *Server) takeFlash(w http.ResponseWriter, r *http.Request) (flash, flashErr string) { |
| 2257 | c, err := r.Cookie(flashCookie) |
| 2258 | if err != nil || c.Value == "" { |
| 2259 | return "", "" |
| 2260 | } |
| 2261 | s.clearCookie(w, flashCookie) |
| 2262 | v, err := url.QueryUnescape(c.Value) |
| 2263 | if err != nil { |
| 2264 | return "", "" |
| 2265 | } |
| 2266 | kind, msg, _ := strings.Cut(v, "|") |
| 2267 | if kind == "err" { |
| 2268 | return "", msg |
| 2269 | } |
| 2270 | return msg, "" |
| 2271 | } |
| 2272 | |
| 2273 | // ---- auth handlers ---------------------------------------------------------- |
| 2274 | |
| 2275 | func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) { |
| 2276 | if addr, _, ok := s.session(r); ok { |
| 2277 | s.redirectToCurrentAccount(w, r, addr, http.StatusFound) |
| 2278 | return |
| 2279 | } |
| 2280 | s.renderHome(w, r, homeForm{Next: r.URL.Query().Get("next")}) |
| 2281 | } |
| 2282 | |
| 2283 | // redirectToCurrentAccount sends a signed-in user to the account they were |
| 2284 | // last in — else their first membership by creation order, else a personal |
| 2285 | // account minted on the spot. Nobody signed in ever has nowhere to land. |
| 2286 | func (s *Server) redirectToCurrentAccount(w http.ResponseWriter, r *http.Request, addr string, code int) { |
| 2287 | sqid, err := s.currentAccount(r.Context(), addr) |
| 2288 | if err != nil { |
| 2289 | log.Printf("console: landing for %s: %v", addr, err) |
| 2290 | s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not open your account. Please try again.") |
| 2291 | return |
| 2292 | } |
| 2293 | seg := sqid |
| 2294 | if accts, err := s.store.AccountsFor(addr); err == nil { |
| 2295 | for i := range accts { |
| 2296 | if accts[i].Sqid == sqid { |
| 2297 | seg = preferredSeg(accts, &accts[i]) |
| 2298 | break |
| 2299 | } |
| 2300 | } |
| 2301 | } |
| 2302 | http.Redirect(w, r, "/a/"+url.PathEscape(seg)+"/", code) |
| 2303 | } |
| 2304 | |
| 2305 | // currentAccount resolves the three landing cases in order: MRU history (only |
| 2306 | // if still a member — an account you were removed from must not strand you), |
| 2307 | // first membership, or mint. |
| 2308 | func (s *Server) currentAccount(ctx context.Context, addr string) (string, error) { |
| 2309 | a, err := s.memberAccount(addr) |
| 2310 | if err != nil { |
| 2311 | return "", err |
| 2312 | } |
| 2313 | if a != nil { |
| 2314 | return a.Sqid, nil |
| 2315 | } |
| 2316 | m, err := s.mintAccount(ctx, addr, addr) |
| 2317 | if err != nil { |
| 2318 | return "", err |
| 2319 | } |
| 2320 | return m.Sqid, nil |
| 2321 | } |
| 2322 | |
| 2323 | // memberAccount is currentAccount's first two cases WITHOUT the mint: the MRU |
| 2324 | // account if the viewer is still a member of it, else their first membership |
| 2325 | // by creation order, else nil. Split out because pages that merely want the |
| 2326 | // header's account context — /accounts/new — must not create an account as a |
| 2327 | // side effect of being looked at. Only landing mints. |
| 2328 | func (s *Server) memberAccount(addr string) (*Account, error) { |
| 2329 | if id, err := s.store.LastAccount(addr); err != nil { |
| 2330 | return nil, err |
| 2331 | } else if id != 0 { |
| 2332 | if role, err := s.store.Role(id, addr); err != nil { |
| 2333 | return nil, err |
| 2334 | } else if role != "" { |
| 2335 | a, err := s.store.AccountByID(id) |
| 2336 | if err != nil { |
| 2337 | return nil, err |
| 2338 | } |
| 2339 | if a != nil { |
| 2340 | a.Role = role |
| 2341 | return a, nil |
| 2342 | } |
| 2343 | } |
| 2344 | } |
| 2345 | accts, err := s.store.AccountsFor(addr) |
| 2346 | if err != nil { |
| 2347 | return nil, err |
| 2348 | } |
| 2349 | if len(accts) > 0 { |
| 2350 | return &accts[0], nil |
| 2351 | } |
| 2352 | return nil, nil |
| 2353 | } |
| 2354 | |
| 2355 | // mintAccount creates an account owned by addr: the next free id allocated in |
| 2356 | // the bucket, its frozen sqid, and the console DB rows. |
| 2357 | // |
| 2358 | // The bucket write comes FIRST and is no longer best-effort, because it is now |
| 2359 | // the allocator: the id is claimed by a create-only manifest write, which is |
| 2360 | // what stops two boxes minting the same integer at the same moment. A console |
| 2361 | // row whose manifest never landed would be an account that vanishes on the |
| 2362 | // next rebuild — worse than a "new account" button that reports the outage. |
| 2363 | // |
| 2364 | // The name is NOT validated here: auto-mint names personal accounts by |
| 2365 | // keymail address, and landing must never refuse to mint (spec §1). |
| 2366 | func (s *Server) mintAccount(ctx context.Context, name, ownerAddr string) (*Account, error) { |
| 2367 | now := time.Now() |
| 2368 | id, sqid, err := domains.MintAccount(ctx, s.cfg.Releases, name, ownerAddr, now) |
| 2369 | if err != nil { |
| 2370 | return nil, err |
| 2371 | } |
| 2372 | if err := s.store.CreateAccount(id, sqid, name, ownerAddr, now); err != nil { |
| 2373 | // The manifest already landed, so the id is claimed and there is now an |
| 2374 | // account in the bucket with no console row and no owner. It is |
| 2375 | // unreachable rather than dangerous (no membership means no access, and |
| 2376 | // materialization gives it no members either), and the next mint simply |
| 2377 | // takes the id after it. But without this line the only trace is a 500 |
| 2378 | // in the user's browser: name the sqid so the stranded account can be |
| 2379 | // found and either adopted with `console member -add … -role owner` or |
| 2380 | // deleted from the bucket. |
| 2381 | log.Printf("console: minted account %s (id %d, %q) into the bucket but could not record it "+ |
| 2382 | "locally: %v — accounts/%s/account.json is stranded with no owner", sqid, id, name, err, sqid) |
| 2383 | return nil, err |
| 2384 | } |
| 2385 | if err := s.store.SetLastAccount(ownerAddr, id); err != nil { |
| 2386 | log.Printf("console: set last account for %s: %v", ownerAddr, err) |
| 2387 | } |
| 2388 | return &Account{ID: id, Sqid: sqid, Name: name, Role: RoleOwner, CreatedAt: now}, nil |
| 2389 | } |
| 2390 | |
| 2391 | // account is the gate on every /a/{seg}/… route: a session, and a segment |
| 2392 | // that resolves — as a sqid OR a name, symmetrically — to an account the |
| 2393 | // viewer belongs to. Resolution and membership proof are deliberately the |
| 2394 | // same lookup (the membership set IS the resolution scope), so an unknown |
| 2395 | // account, someone else's account, and a name that resolves to nothing all |
| 2396 | // answer with the BYTE-IDENTICAL http.NotFound an unrouted URL gets — |
| 2397 | // account existence is not probeable, matching /fleet's operator gate. |
| 2398 | // Several matches (a shared name, or a name colliding with a sqid) render |
| 2399 | // the picker on GET and the same 404 otherwise — forms always post to sqid |
| 2400 | // paths, so an ambiguous mutation has no legitimate author. |
| 2401 | // |
| 2402 | // Signed-out users are sent to the front page instead, which reveals nothing |
| 2403 | // either way because it happens for every segment, real or not. |
| 2404 | func (s *Server) account(w http.ResponseWriter, r *http.Request) (addr, raw string, acct *Account, role string, ok bool) { |
| 2405 | addr, raw, sok := s.session(r) |
| 2406 | if !sok { |
| 2407 | http.Redirect(w, r, "/", http.StatusFound) |
| 2408 | return "", "", nil, "", false |
| 2409 | } |
| 2410 | accts, err := s.store.AccountsFor(addr) |
| 2411 | if err != nil { |
| 2412 | log.Printf("console: account lookup: %v", err) |
| 2413 | http.NotFound(w, r) |
| 2414 | return "", "", nil, "", false |
| 2415 | } |
| 2416 | seg := r.PathValue("sqid") |
| 2417 | matches := matchAccounts(accts, seg) |
| 2418 | switch len(matches) { |
| 2419 | case 1: |
| 2420 | a := matches[0] |
| 2421 | return addr, raw, a, a.Role, true |
| 2422 | case 0: |
| 2423 | http.NotFound(w, r) |
| 2424 | return "", "", nil, "", false |
| 2425 | default: |
| 2426 | if r.Method != http.MethodGet { |
| 2427 | http.NotFound(w, r) |
| 2428 | return "", "", nil, "", false |
| 2429 | } |
| 2430 | p := s.basePage(r) |
| 2431 | p.Title = "Which " + seg + "?" |
| 2432 | p.PickerSeg = seg |
| 2433 | for _, m := range matches { |
| 2434 | p.PickerAccounts = append(p.PickerAccounts, *m) |
| 2435 | } |
| 2436 | s.render(w, http.StatusOK, "picker", p) |
| 2437 | return "", "", nil, "", false |
| 2438 | } |
| 2439 | } |
| 2440 | |
| 2441 | // owner is account plus the owner-only gate. A member who probes an |
| 2442 | // owner-only action gets the same 404 a non-member does: the difference |
| 2443 | // between "not yours" and "not allowed" is not information this hands out. |
| 2444 | func (s *Server) owner(w http.ResponseWriter, r *http.Request) (addr, raw string, acct *Account, ok bool) { |
| 2445 | addr, raw, acct, role, ok := s.account(w, r) |
| 2446 | if !ok { |
| 2447 | return "", "", nil, false |
| 2448 | } |
| 2449 | if role != RoleOwner { |
| 2450 | http.NotFound(w, r) |
| 2451 | return "", "", nil, false |
| 2452 | } |
| 2453 | return addr, raw, acct, true |
| 2454 | } |
| 2455 | |
| 2456 | // handleLegacyApps / handleLegacyApp keep the pre-accounts URLs alive as |
| 2457 | // redirects into the current account. |
| 2458 | func (s *Server) handleLegacyApps(w http.ResponseWriter, r *http.Request) { |
| 2459 | addr, _, ok := s.session(r) |
| 2460 | if !ok { |
| 2461 | http.Redirect(w, r, "/", http.StatusFound) |
| 2462 | return |
| 2463 | } |
| 2464 | s.redirectToCurrentAccount(w, r, addr, http.StatusFound) |
| 2465 | } |
| 2466 | |
| 2467 | func (s *Server) handleLegacyApp(w http.ResponseWriter, r *http.Request) { |
| 2468 | addr, _, ok := s.session(r) |
| 2469 | if !ok { |
| 2470 | http.Redirect(w, r, "/", http.StatusFound) |
| 2471 | return |
| 2472 | } |
| 2473 | sqid, err := s.currentAccount(r.Context(), addr) |
| 2474 | if err != nil { |
| 2475 | http.NotFound(w, r) |
| 2476 | return |
| 2477 | } |
| 2478 | http.Redirect(w, r, "/a/"+url.PathEscape(sqid)+"/apps/"+url.PathEscape(r.PathValue("app")), http.StatusFound) |
| 2479 | } |
| 2480 | |
| 2481 | // handleNewAccountForm is the "New account" screen: its own gated page at its |
| 2482 | // own URL, linked from the switcher, rather than a text box wedged into the |
| 2483 | // header. It takes no account parameter of any kind — there is nothing here |
| 2484 | // to point at another account, so it cannot become a way to probe one — and |
| 2485 | // it deliberately does NOT record an MRU: you are not "in" an account here, |
| 2486 | // and moving the pointer would send the next sign-in somewhere never chosen. |
| 2487 | func (s *Server) handleNewAccountForm(w http.ResponseWriter, r *http.Request) { |
| 2488 | addr, raw, ok := s.session(r) |
| 2489 | if !ok { |
| 2490 | http.Redirect(w, r, "/", http.StatusFound) |
| 2491 | return |
| 2492 | } |
| 2493 | p := page{Title: "New account", Address: addr, CSRF: raw} |
| 2494 | p.Flash, p.FlashErr = s.takeFlash(w, r) |
| 2495 | if accts, err := s.store.AccountsFor(addr); err != nil { |
| 2496 | log.Printf("console: switcher: %v", err) |
| 2497 | } else { |
| 2498 | p.Accounts = accts |
| 2499 | } |
| 2500 | for i := range p.Accounts { |
| 2501 | p.Accounts[i].Seg = preferredSeg(p.Accounts, &p.Accounts[i]) |
| 2502 | } |
| 2503 | // The header chrome wants an account to name in the switcher's trigger. |
| 2504 | // memberAccount, not currentAccount: looking at this page must never mint. |
| 2505 | // A user with no memberships at all simply gets the wordmark header. |
| 2506 | if a, err := s.memberAccount(addr); err != nil { |
| 2507 | log.Printf("console: new-account chrome: %v", err) |
| 2508 | } else if a != nil { |
| 2509 | p.Account = a |
| 2510 | p.Role = a.Role |
| 2511 | p.Base = "/a/" + url.PathEscape(a.Sqid) |
| 2512 | if len(p.Accounts) == 0 { |
| 2513 | p.Nav = p.Base |
| 2514 | } else { |
| 2515 | p.Nav = "/a/" + url.PathEscape(preferredSeg(p.Accounts, a)) |
| 2516 | } |
| 2517 | } |
| 2518 | s.render(w, http.StatusOK, "newaccount", p) |
| 2519 | } |
| 2520 | |
| 2521 | // handleNewAccount creates an account for any signed-in user. |
| 2522 | func (s *Server) handleNewAccount(w http.ResponseWriter, r *http.Request) { |
| 2523 | addr, raw, ok := s.session(r) |
| 2524 | if !ok { |
| 2525 | http.Redirect(w, r, "/", http.StatusFound) |
| 2526 | return |
| 2527 | } |
| 2528 | if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| 2529 | s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| 2530 | return |
| 2531 | } |
| 2532 | name := strings.TrimSpace(r.FormValue("name")) |
| 2533 | if !domains.ValidAccountName(name) { |
| 2534 | // Back to the screen the mistake was made on, not off to some other |
| 2535 | // account: the form is a page now, and a page can show its own error. |
| 2536 | s.setFlash(w, "err", "Account names appear in URLs: 1–63 characters of a-z, 0-9 and hyphen, no leading or trailing hyphen.") |
| 2537 | http.Redirect(w, r, "/accounts/new", http.StatusSeeOther) |
| 2538 | return |
| 2539 | } |
| 2540 | // Plans 3a: one free account per person, checked at creation only (spec |
| 2541 | // §8.1). A person's first account, minted on landing, is never refused. |
| 2542 | defer s.planPersonLock(addr)() |
| 2543 | if err := s.checkNewFreeAccount(addr); err != nil { |
| 2544 | var planCap *planCapError |
| 2545 | if errors.As(err, &planCap) { |
| 2546 | s.setFlash(w, "err", planCap.Browser()) |
| 2547 | http.Redirect(w, r, "/accounts/new", http.StatusSeeOther) |
| 2548 | return |
| 2549 | } |
| 2550 | log.Printf("console: create account for %s: plan cap: %v", addr, err) |
| 2551 | s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not create the account. Please try again.") |
| 2552 | return |
| 2553 | } |
| 2554 | a, err := s.mintAccount(r.Context(), name, addr) |
| 2555 | if err != nil { |
| 2556 | log.Printf("console: create account for %s: %v", addr, err) |
| 2557 | s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not create the account. Please try again.") |
| 2558 | return |
| 2559 | } |
| 2560 | s.setFlash(w, "ok", "Created account "+name+" — you're its owner.") |
| 2561 | http.Redirect(w, r, "/a/"+url.PathEscape(a.Sqid)+"/", http.StatusSeeOther) |
| 2562 | } |
| 2563 | |
| 2564 | // handleInvite creates a single-use invitation and delivers its link: |
| 2565 | // Keymail federation first (into the invitee's main inbox, as the console |
| 2566 | // itself, no grant involved), then the console's own email, then the link |
| 2567 | // shown to the owner to pass on. The invite row exists whichever way it |
| 2568 | // went, so a failed delivery is a flash, not a rollback. |
| 2569 | func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) { |
| 2570 | addr, raw, acct, ok := s.owner(w, r) |
| 2571 | if !ok { |
| 2572 | return |
| 2573 | } |
| 2574 | if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| 2575 | s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| 2576 | return |
| 2577 | } |
| 2578 | back := func(kind, msg string) { |
| 2579 | s.setFlash(w, kind, msg) |
| 2580 | http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/team", http.StatusSeeOther) |
| 2581 | } |
| 2582 | to := strings.ToLower(strings.TrimSpace(r.FormValue("address"))) |
| 2583 | // ValidAddress alone lets through "a@x.com,b@y.com" and "x<v@y.com>", |
| 2584 | // which an email To header reads as two recipients or a display name. |
| 2585 | // One bare address only. |
| 2586 | if parsed, err := netmail.ParseAddress(to); !ValidAddress(to) || err != nil || parsed.Address != to { |
| 2587 | back("err", copyText("team.bad_address")) |
| 2588 | return |
| 2589 | } |
| 2590 | if role, err := s.store.Role(acct.ID, to); err != nil { |
| 2591 | back("err", "Could not check that address. Please try again.") |
| 2592 | return |
| 2593 | } else if role != "" { |
| 2594 | back("err", to+" is already a member of this account.") |
| 2595 | return |
| 2596 | } |
| 2597 | // Volume limits (each invite is a send as the platform): first the |
| 2598 | // account's waiting invites, then its last hour. Both refuse before a row |
| 2599 | // exists or any delivery is tried. |
| 2600 | if n, err := s.store.CountPendingInvites(acct.ID, time.Now()); err != nil { |
| 2601 | log.Printf("console: count pending invites: %v", err) |
| 2602 | back("err", "Could not create the invitation. Please try again.") |
| 2603 | return |
| 2604 | } else if n >= maxPendingInvites { |
| 2605 | back("err", inviteFlashTooManyPending()) |
| 2606 | return |
| 2607 | } |
| 2608 | if !s.inviteRate.take(acct.ID) { |
| 2609 | back("err", inviteFlashTooManyPerHour()) |
| 2610 | return |
| 2611 | } |
| 2612 | tok, err := s.store.CreateInvite(acct.ID, to, addr, time.Now()) |
| 2613 | if err != nil { |
| 2614 | s.inviteRate.giveBack(acct.ID) |
| 2615 | log.Printf("console: create invite: %v", err) |
| 2616 | back("err", "Could not create the invitation. Please try again.") |
| 2617 | return |
| 2618 | } |
| 2619 | link := s.cfg.Origin + "/invite/" + url.PathEscape(tok) |
| 2620 | subject, body := inviteMessage(addr, acct.Name, to, link, s.consoleName()) |
| 2621 | ctx, cancel := context.WithTimeout(r.Context(), s.inviteTimeout) |
| 2622 | defer cancel() |
| 2623 | switch ch, err := s.deliver(ctx, to, subject, body, s.inviteKeymailShare); { |
| 2624 | case err != nil: |
| 2625 | log.Printf("console: invite to %s not delivered: %v", to, err) |
| 2626 | back("err", inviteFlashFailed(to, link)) |
| 2627 | case ch == deliveredKeymail: |
| 2628 | back("ok", inviteFlashKeymail(to)) |
| 2629 | default: |
| 2630 | back("ok", inviteFlashEmail(to)) |
| 2631 | } |
| 2632 | } |
| 2633 | |
| 2634 | // inviteMessage is the subject and body for an invitation, whichever channel |
| 2635 | // carries it (Keymail federation or the console's own email). |
| 2636 | func inviteMessage(inviter, account, to, link, console string) (subject, body string) { |
| 2637 | return "You've been invited to an account on " + console, |
| 2638 | copyText("email.invite.body", "inviter", inviter, "account", account, |
| 2639 | "console", console, "to", to, "link", link) |
| 2640 | } |
| 2641 | |
| 2642 | func inviteFlashKeymail(to string) string { |
| 2643 | return "Invited " + to + ". The link is in their Keymail inbox." |
| 2644 | } |
| 2645 | func inviteFlashEmail(to string) string { return "Invited " + to + ". We emailed them the link." } |
| 2646 | func inviteFlashFailed(to, link string) string { |
| 2647 | return "Invited " + to + ", but we couldn't deliver the link. Send it to them yourself: " + link |
| 2648 | } |
| 2649 | |
| 2650 | // The two volume refusals. DRAFTS under copy review: the approved text goes |
| 2651 | // here and in TestInviteCopyHelpers. |
| 2652 | func inviteFlashTooManyPending() string { |
| 2653 | return "This account already has 20 invites waiting. Revoke some before you send more." |
| 2654 | } |
| 2655 | func inviteFlashTooManyPerHour() string { |
| 2656 | return "This account has sent 20 invites in the last hour. Try again later." |
| 2657 | } |
| 2658 | |
| 2659 | // Invite volume limits. Every invite is a message sent as the platform, to |
| 2660 | // an address of the owner's choosing, from an account anyone signed in can |
| 2661 | // create. |
| 2662 | const ( |
| 2663 | maxPendingInvites = 20 |
| 2664 | maxInvitesPerHour = 20 |
| 2665 | ) |
| 2666 | |
| 2667 | // inviteWindow is a per-account sliding hour of invite creations. The zero |
| 2668 | // value is ready to use. |
| 2669 | type inviteWindow struct { |
| 2670 | mu sync.Mutex |
| 2671 | now func() time.Time // nil: time.Now |
| 2672 | sent map[int64][]time.Time |
| 2673 | } |
| 2674 | |
| 2675 | // take records one invite for acct and reports true, or reports false |
| 2676 | // without recording when acct already has maxInvitesPerHour in the last hour. |
| 2677 | // Check and record are one step, so concurrent invites cannot both slip |
| 2678 | // under the limit. |
| 2679 | func (w *inviteWindow) take(acct int64) bool { |
| 2680 | w.mu.Lock() |
| 2681 | defer w.mu.Unlock() |
| 2682 | now := time.Now() |
| 2683 | if w.now != nil { |
| 2684 | now = w.now() |
| 2685 | } |
| 2686 | cutoff := now.Add(-time.Hour) |
| 2687 | kept := w.sent[acct][:0] |
| 2688 | for _, t := range w.sent[acct] { |
| 2689 | if t.After(cutoff) { |
| 2690 | kept = append(kept, t) |
| 2691 | } |
| 2692 | } |
| 2693 | if len(kept) >= maxInvitesPerHour { |
| 2694 | w.sent[acct] = kept |
| 2695 | return false |
| 2696 | } |
| 2697 | if w.sent == nil { |
| 2698 | w.sent = map[int64][]time.Time{} |
| 2699 | } |
| 2700 | w.sent[acct] = append(kept, now) |
| 2701 | return true |
| 2702 | } |
| 2703 | |
| 2704 | // giveBack undoes the latest take for acct: the row it was for was never |
| 2705 | // created. |
| 2706 | func (w *inviteWindow) giveBack(acct int64) { |
| 2707 | w.mu.Lock() |
| 2708 | defer w.mu.Unlock() |
| 2709 | if ts := w.sent[acct]; len(ts) > 0 { |
| 2710 | w.sent[acct] = ts[:len(ts)-1] |
| 2711 | } |
| 2712 | } |
| 2713 | |
| 2714 | func (s *Server) handleRevokeInvite(w http.ResponseWriter, r *http.Request) { |
| 2715 | _, raw, acct, ok := s.owner(w, r) |
| 2716 | if !ok { |
| 2717 | return |
| 2718 | } |
| 2719 | if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| 2720 | s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| 2721 | return |
| 2722 | } |
| 2723 | tok := r.FormValue("token") |
| 2724 | // Scoped to THIS account: an owner of one account must not be able to |
| 2725 | // revoke another account's invite by pasting its token. |
| 2726 | pending, err := s.store.PendingInvites(acct.ID) |
| 2727 | if err != nil { |
| 2728 | s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read the invitations.") |
| 2729 | return |
| 2730 | } |
| 2731 | for _, inv := range pending { |
| 2732 | if inv.Token == tok { |
| 2733 | if err := s.store.RevokeInvite(tok); err != nil { |
| 2734 | log.Printf("console: revoke invite: %v", err) |
| 2735 | } else { |
| 2736 | s.setFlash(w, "ok", "Revoked the invitation to "+inv.Address+".") |
| 2737 | } |
| 2738 | break |
| 2739 | } |
| 2740 | } |
| 2741 | http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/team", http.StatusSeeOther) |
| 2742 | } |
| 2743 | |
| 2744 | // invitationGone is every refusal of an invite link, on the page and on the |
| 2745 | // accept alike. |
| 2746 | func (s *Server) invitationGone(w http.ResponseWriter, r *http.Request) { |
| 2747 | s.renderMessage(w, r, http.StatusGone, "Invitation unavailable", |
| 2748 | "That invitation has expired, was already used, or was sent to a different address. Ask for a fresh one.") |
| 2749 | } |
| 2750 | |
| 2751 | // handleInvitePage is where an invite link lands: it says which account the |
| 2752 | // invitation is for and offers a button that joins it. It changes nothing. |
| 2753 | // Joining used to happen right here, on the GET, which made any page able to |
| 2754 | // add a signed-in person to the inviter's account and switch them into it |
| 2755 | // with a link or a redirect: SameSite=Lax sends the session cookie on a |
| 2756 | // top-level navigation (discussion #219, item 4). Links already sent keep |
| 2757 | // working; they land on this page. |
| 2758 | // |
| 2759 | // It requires a session as the invited address: the link reaches one |
| 2760 | // address and is worthless to anyone else. Signed out, it is the front page |
| 2761 | // with the invited address filled in and next set back to this link, so |
| 2762 | // either sign-in door returns here (spec 8). An unknown or spent token gets |
| 2763 | // the plain front page. |
| 2764 | func (s *Server) handleInvitePage(w http.ResponseWriter, r *http.Request) { |
| 2765 | token := r.PathValue("token") |
| 2766 | addr, raw, ok := s.session(r) |
| 2767 | if !ok { |
| 2768 | invited, found := s.store.PendingInviteAddress(token, time.Now()) |
| 2769 | if !found { |
| 2770 | s.renderHome(w, r, homeForm{}) |
| 2771 | return |
| 2772 | } |
| 2773 | s.renderHome(w, r, homeForm{ |
| 2774 | Prefill: invited, |
| 2775 | Next: "/invite/" + url.PathEscape(token), |
| 2776 | Banner: copyText("home.invite_prompt", "address", invited), |
| 2777 | }) |
| 2778 | return |
| 2779 | } |
| 2780 | inv, err := s.store.PendingInviteFor(token, addr, time.Now()) |
| 2781 | if err != nil { |
| 2782 | s.invitationGone(w, r) |
| 2783 | return |
| 2784 | } |
| 2785 | a, err := s.store.AccountByID(inv.AccountID) |
| 2786 | if err != nil || a == nil { |
| 2787 | s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read the invitation. Try again.") |
| 2788 | return |
| 2789 | } |
| 2790 | p := s.basePage(r) |
| 2791 | p.Title = "Join " + a.Name + "?" |
| 2792 | p.AppName = a.Name |
| 2793 | p.Confirm = &ConfirmView{ |
| 2794 | Title: "Join " + a.Name + "?", |
| 2795 | Lines: []string{inv.InvitedBy + " invited you to join " + a.Name + " as a member."}, |
| 2796 | Action: "/invite/" + url.PathEscape(token), |
| 2797 | Fields: [][2]string{{"csrf", raw}}, |
| 2798 | Button: "Join " + a.Name, |
| 2799 | CancelURL: "/", |
| 2800 | } |
| 2801 | s.render(w, http.StatusOK, "confirm_action", p) |
| 2802 | } |
| 2803 | |
| 2804 | // handleAcceptInvite is the confirm page's button: it consumes the invite and |
| 2805 | // makes the account the person's current one. Session, then CSRF, then the |
| 2806 | // invite's own gates (AcceptInvite: unused, addressed to this session, |
| 2807 | // unexpired), which play the owner check's part. No passkey step: joining |
| 2808 | // gives the joiner nothing over anybody else, and it is their own choice. |
| 2809 | func (s *Server) handleAcceptInvite(w http.ResponseWriter, r *http.Request) { |
| 2810 | addr, raw, ok := s.session(r) |
| 2811 | if !ok { |
| 2812 | http.Redirect(w, r, "/", http.StatusFound) |
| 2813 | return |
| 2814 | } |
| 2815 | if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| 2816 | s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| 2817 | return |
| 2818 | } |
| 2819 | now := time.Now() |
| 2820 | accountID, err := s.store.AcceptInvite(r.PathValue("token"), addr, now) |
| 2821 | if err != nil { |
| 2822 | s.invitationGone(w, r) |
| 2823 | return |
| 2824 | } |
| 2825 | a, err := s.store.AccountByID(accountID) |
| 2826 | if err != nil || a == nil { |
| 2827 | s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Joined, but could not open the account. Try the front page.") |
| 2828 | return |
| 2829 | } |
| 2830 | if err := s.store.SetLastAccount(addr, accountID); err != nil { |
| 2831 | log.Printf("console: set last account: %v", err) |
| 2832 | } |
| 2833 | s.setFlash(w, "ok", "You've joined "+a.Name+".") |
| 2834 | http.Redirect(w, r, "/a/"+url.PathEscape(a.Sqid)+"/", http.StatusSeeOther) |
| 2835 | } |
| 2836 | |
| 2837 | // handleContinue is the front page's one form (spec 4): it finds the door |
| 2838 | // from the address alone. A Keymail address goes to Keymail's sign-in; any |
| 2839 | // other gets an emailed link and code. Every email-bound outcome renders the |
| 2840 | // same "Check your email" page and sets the same flow cookie, whether a |
| 2841 | // message was sent or the request was refused, so neither the page nor any |
| 2842 | // later code typed on it reveals an account or a rate limit. |
| 2843 | // |
| 2844 | // The order is load-bearing: the per-IP submission cap is noted first; the |
| 2845 | // Keymail host's own addresses go to Keymail whatever the cap (that costs no |
| 2846 | // lookup); Keymail's lookup is asked only under the cap; an email-bound |
| 2847 | // address with no ready mailer is told so before any send limit (spec 4.2); |
| 2848 | // and a request over the submission cap or the send limits is refused: no |
| 2849 | // email, and no challenge the mailbox could redeem (signinChallenge). |
| 2850 | func (s *Server) handleContinue(w http.ResponseWriter, r *http.Request) { |
| 2851 | if !s.sameOrigin(r) { |
| 2852 | s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| 2853 | return |
| 2854 | } |
| 2855 | now := time.Now() |
| 2856 | s.store.PurgeExpired(now) // housekeeping on the write path |
| 2857 | flow := s.requestFlow(r) |
| 2858 | if flow == "" { |
| 2859 | s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not start the sign-in flow. Please try again.") |
| 2860 | return |
| 2861 | } |
| 2862 | typed := strings.TrimSpace(r.FormValue("address")) |
| 2863 | next := safeNext(r.FormValue("next")) |
| 2864 | addr, ok := canonicalAddress(typed) |
| 2865 | if !ok { |
| 2866 | s.renderHome(w, r, homeForm{Prefill: typed, Next: next, Error: copyText("home.bad_address"), flow: flow}) |
| 2867 | return |
| 2868 | } |
| 2869 | ip := s.clientIP(r) |
| 2870 | underCap := s.limits.submit(ip) |
| 2871 | if !underCap { |
| 2872 | log.Printf("console: sign-in submissions from %s over the hourly cap; no lookup, no send", ip) |
| 2873 | } |
| 2874 | if s.keymailAddress(r.Context(), addr, underCap) { |
| 2875 | s.setFlow(w, flow) |
| 2876 | target := "/auth/signin" |
| 2877 | if next != "" { |
| 2878 | target += "?next=" + url.QueryEscape(next) |
| 2879 | } |
| 2880 | http.Redirect(w, r, target, http.StatusSeeOther) |
| 2881 | return |
| 2882 | } |
| 2883 | if !s.mailReady(r.Context()) { |
| 2884 | s.renderHome(w, r, homeForm{Prefill: addr, Next: next, NoMail: true, flow: flow}) |
| 2885 | return |
| 2886 | } |
| 2887 | link, code, sent, err := s.signinChallenge(flow, addr, next, ip, underCap, now) |
| 2888 | if err != nil { |
| 2889 | log.Printf("console: sign-in challenge for %s: %v", addr, err) |
| 2890 | s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not start the sign-in flow. Please try again.") |
| 2891 | return |
| 2892 | } |
| 2893 | if sent { |
| 2894 | console := s.consoleName() |
| 2895 | s.sendChallengeAsync(addr, |
| 2896 | copyText("email.signin.subject", "console", console), |
| 2897 | copyText("email.signin.body", "console", console, "address", addr, |
| 2898 | "link", s.cfg.Origin+"/auth/email/link?t="+link, "code", code)) |
| 2899 | } |
| 2900 | s.renderCheck(w, flow, addr, next) |
| 2901 | } |
| 2902 | |
| 2903 | // signinChallenge is the front door's limits and challenge write for one |
| 2904 | // flow, under that flow's lock, so overlapping submissions from one browser |
| 2905 | // take turns. Accepted (under the submission cap and every send limit), it |
| 2906 | // records a new sign-in challenge for the flow, replacing the flow's earlier |
| 2907 | // ones, and returns its link and code to send. Refused, it sends nothing and |
| 2908 | // leaves the flow's live challenge, if any, untouched, so the email already |
| 2909 | // sent keeps working. Over the submission cap it writes nothing at all: that |
| 2910 | // throttle is the requester's own IP and says nothing about anyone else, so |
| 2911 | // the code box may answer "used up" there. Refused by the send limits (which |
| 2912 | // the submission cap bounds to 30 an hour per IP), a flow with no live |
| 2913 | // challenge gets one nobody is ever told the link or code of, so a code |
| 2914 | // typed next answers exactly as it would against a sent one. |
| 2915 | func (s *Server) signinChallenge(flow, addr, next, ip string, underCap bool, now time.Time) (link, code string, sent bool, err error) { |
| 2916 | mu := s.flowLock(flow) |
| 2917 | mu.Lock() |
| 2918 | defer mu.Unlock() |
| 2919 | if !underCap { |
| 2920 | return "", "", false, nil |
| 2921 | } |
| 2922 | c := EmailChallenge{Purpose: ChallengeSignIn, Address: addr, Next: next, FlowHash: hashToken(flow)} |
| 2923 | if !s.limits.signinSend(addr, ip) { |
| 2924 | log.Printf("console: sign-in email to %s from %s refused by its limits; not sent", addr, ip) |
| 2925 | return "", "", false, s.store.HoldEmailChallenge(c, now) |
| 2926 | } |
| 2927 | link, code, err = s.store.CreateEmailChallenge(c, now) |
| 2928 | if err != nil { |
| 2929 | return "", "", false, err |
| 2930 | } |
| 2931 | return link, code, true, nil |
| 2932 | } |
| 2933 | |
| 2934 | // ValidAddress is the console's one shape check on a keymail address: |
| 2935 | // <local>@<host>, no whitespace. Exported because the box-side verbs |
| 2936 | // (`console member`) must reject a typo with the SAME rule the web surface |
| 2937 | // uses — a membership created from the command line for an address the sign-in |
| 2938 | // form would refuse is a grant nobody can ever claim. |
| 2939 | // |
| 2940 | // The rule itself now lives in internal/domains, next to the account manifest's |
| 2941 | // `owner` field, so `carlos accounts create -owner` can apply it too. The carlos |
| 2942 | // binary must never open the console's database, but it must not invent a |
| 2943 | // second definition of a valid address either: an address one tool accepts and |
| 2944 | // another refuses is exactly how an owner ends up recorded and unable to sign |
| 2945 | // in. Delegating keeps one rule with two doors, not two rules. |
| 2946 | func ValidAddress(a string) bool { return domains.ValidAddress(a) } |
| 2947 | |
| 2948 | // handleSignin starts the one-time OAuth ceremony: state + PKCE verifier in |
| 2949 | // a short-lived cookie, browser off to Keymail's consent page. |
| 2950 | func (s *Server) handleSignin(w http.ResponseWriter, r *http.Request) { |
| 2951 | state, err := NewVerifier() |
| 2952 | if err != nil { |
| 2953 | s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not start the sign-in flow. Please try again.") |
| 2954 | return |
| 2955 | } |
| 2956 | verifier, err := NewVerifier() |
| 2957 | if err != nil { |
| 2958 | s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not start the sign-in flow. Please try again.") |
| 2959 | return |
| 2960 | } |
| 2961 | s.setCookie(w, authCookie, state+"."+verifier, int(authFlowTTL.Seconds())) |
| 2962 | // Where to land afterwards. Only a safe local path is kept; a stale one |
| 2963 | // from an earlier attempt must not leak into this one. |
| 2964 | if n := safeNext(r.URL.Query().Get("next")); n != "" { |
| 2965 | s.setCookie(w, nextCookie, n, int(authFlowTTL.Seconds())) |
| 2966 | } else { |
| 2967 | s.clearCookie(w, nextCookie) |
| 2968 | } |
| 2969 | http.Redirect(w, r, s.km.AuthorizeURL(state, ChallengeS256(verifier)), http.StatusSeeOther) |
| 2970 | } |
| 2971 | |
| 2972 | // handleCallback finishes the ceremony: verify state, exchange the code |
| 2973 | // (with PKCE verifier and the console's sign_pub), and either sign the user |
| 2974 | // up (new address) or in. The grant token is stored per user — it is the |
| 2975 | // instrument of future confirmation-link sign-ins. |
| 2976 | func (s *Server) handleCallback(w http.ResponseWriter, r *http.Request) { |
| 2977 | q := r.URL.Query() |
| 2978 | if e := q.Get("error"); e != "" { |
| 2979 | s.renderHome(w, r, homeForm{Error: "Sign-in was cancelled at Keymail (" + e + ")."}) |
| 2980 | return |
| 2981 | } |
| 2982 | c, err := r.Cookie(authCookie) |
| 2983 | if err != nil || c.Value == "" { |
| 2984 | s.renderMessage(w, r, http.StatusBadRequest, "Sign-in flow expired", |
| 2985 | "This sign-in attempt is stale or was started elsewhere. Please start again.") |
| 2986 | return |
| 2987 | } |
| 2988 | state, verifier, ok := strings.Cut(c.Value, ".") |
| 2989 | if !ok || state == "" || |
| 2990 | subtle.ConstantTimeCompare([]byte(state), []byte(q.Get("state"))) != 1 { |
| 2991 | s.renderMessage(w, r, http.StatusBadRequest, "Sign-in flow mismatch", |
| 2992 | "The sign-in response didn't match this browser's sign-in attempt. Please start again.") |
| 2993 | return |
| 2994 | } |
| 2995 | code := q.Get("code") |
| 2996 | if code == "" { |
| 2997 | s.renderMessage(w, r, http.StatusBadRequest, "Sign-in failed", "Keymail sent no authorization code. Please start again.") |
| 2998 | return |
| 2999 | } |
| 3000 | ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) |
| 3001 | defer cancel() |
| 3002 | address, token, err := s.km.Exchange(ctx, code, verifier) |
| 3003 | if err != nil { |
| 3004 | log.Printf("console: token exchange: %v", err) |
| 3005 | s.renderMessage(w, r, http.StatusBadGateway, "Sign-in failed", |
| 3006 | "Keymail did not accept the sign-in. Please start again.") |
| 3007 | return |
| 3008 | } |
| 3009 | var next string |
| 3010 | if nc, err := r.Cookie(nextCookie); err == nil { |
| 3011 | next = nc.Value |
| 3012 | } |
| 3013 | s.clearCookie(w, nextCookie) |
| 3014 | s.finishSignin(w, r, address, token, MethodKeymail, next) |
| 3015 | } |
| 3016 | |
| 3017 | // handleConfirm is the retired Apps-inbox confirmation link (spec 11): |
| 3018 | // nothing mints those links any more, so every token gets the expired |
| 3019 | // message and nothing is read or spent. The route stays so an old email still |
| 3020 | // lands somewhere that explains itself. |
| 3021 | // |
| 3022 | // It answers 200, not the 410 of an expired email link: the claim census |
| 3023 | // accepts only a success from a read's probe, and this route is now a read. |
| 3024 | func (s *Server) handleConfirm(w http.ResponseWriter, r *http.Request) { |
| 3025 | s.renderMessage(w, r, http.StatusOK, "Link expired", copyText("link.expired")) |
| 3026 | } |
| 3027 | |
| 3028 | // handleSignout deletes the console session. The Keymail grant deliberately |
| 3029 | // survives — it is how the next sign-in reaches the user; revoking it lives |
| 3030 | // in Keymail's own Connected-apps settings. |
| 3031 | func (s *Server) handleSignout(w http.ResponseWriter, r *http.Request) { |
| 3032 | _, raw, ok := s.session(r) |
| 3033 | if !ok { |
| 3034 | http.Redirect(w, r, "/", http.StatusSeeOther) |
| 3035 | return |
| 3036 | } |
| 3037 | // CSRF: the session token double-submitted as a hidden form field. |
| 3038 | if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| 3039 | s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| 3040 | return |
| 3041 | } |
| 3042 | s.store.DeleteSession(raw) |
| 3043 | // Presence dies with the session it was proved for (stepup.go). Belt and |
| 3044 | // braces — the step-up row is keyed by the session hash, which no future |
| 3045 | // session can reproduce — but leaving proof-of-presence lying around after |
| 3046 | // a deliberate sign-out is not a thing to be relaxed about. |
| 3047 | s.store.ClearStepUp(raw) |
| 3048 | s.clearCookie(w, sessionCookie) |
| 3049 | http.Redirect(w, r, "/", http.StatusSeeOther) |
| 3050 | } |
| 3051 | |
| 3052 | // ---- fleet (operator-only) --------------------------------------------------- |
| 3053 | |
| 3054 | // operatorOnly is the gate every /fleet page and door shares: signed in, |
| 3055 | // and an operator. Every failure path — signed out, signed in but not an |
| 3056 | // operator, or a store error reading the operator bit — gets exactly |
| 3057 | // http.NotFound, the identical response an unregistered URL gets from this |
| 3058 | // mux. Role existence is not probeable: a styled "not found" page (like |
| 3059 | // ownedApp's) would itself reveal that /fleet is a real, gated route, so |
| 3060 | // this deliberately skips renderMessage. |
| 3061 | // |
| 3062 | // One copy rather than one per handler, because the promise being kept is |
| 3063 | // byte-identity with a missing route — a promise that only survives being |
| 3064 | // written once. ok=false means the response is already written and the |
| 3065 | // caller must return immediately. |
| 3066 | func (s *Server) operatorOnly(w http.ResponseWriter, r *http.Request) (address, rawToken string, ok bool) { |
| 3067 | addr, raw, ok := s.session(r) |
| 3068 | if !ok { |
| 3069 | http.NotFound(w, r) |
| 3070 | return "", "", false |
| 3071 | } |
| 3072 | isOp, err := s.store.IsOperator(addr) |
| 3073 | if err != nil { |
| 3074 | log.Printf("console: IsOperator %s: %v", addr, err) |
| 3075 | http.NotFound(w, r) |
| 3076 | return "", "", false |
| 3077 | } |
| 3078 | if !isOp { |
| 3079 | http.NotFound(w, r) |
| 3080 | return "", "", false |
| 3081 | } |
| 3082 | return addr, raw, true |
| 3083 | } |
| 3084 | |
| 3085 | // handleFleet serves the private fleet view: everything status/fleet.json |
| 3086 | // carries, server-rendered, for signed-in operators only — behind |
| 3087 | // operatorOnly's 404-to-everyone gate above. |
| 3088 | func (s *Server) handleFleet(w http.ResponseWriter, r *http.Request) { |
| 3089 | addr, raw, ok := s.operatorOnly(w, r) |
| 3090 | if !ok { |
| 3091 | return |
| 3092 | } |
| 3093 | f, err := FleetView(r.Context(), s.cfg.Releases) |
| 3094 | if err != nil { |
| 3095 | log.Printf("console: fleet view: %v", err) |
| 3096 | s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read fleet status. Please try again.") |
| 3097 | return |
| 3098 | } |
| 3099 | // The private host detail is an EXTRA read on a page that already has its |
| 3100 | // answer: a bucket that won't list the boxes costs the operator the |
| 3101 | // per-host block, not the whole fleet page, so this degrades with a log |
| 3102 | // line rather than turning a readable page into a 500. |
| 3103 | hosts, err := HostRows(r.Context(), s.cfg.Releases, time.Now()) |
| 3104 | if err != nil { |
| 3105 | log.Printf("console: fleet hosts: %v", err) |
| 3106 | hosts = nil |
| 3107 | } |
| 3108 | p := page{Title: "Fleet status", Address: addr, CSRF: raw, Fleet: f, Hosts: hosts} |
| 3109 | if s.storeOperator(addr) { |
| 3110 | p.StoreQueueUnknown = true |
| 3111 | if s.cfg.Releases != nil { |
| 3112 | ctx, cancel := context.WithTimeout(r.Context(), storeTimeout) |
| 3113 | applications, err := s.pendingStoreApplications(ctx) |
| 3114 | cancel() |
| 3115 | if err == nil { |
| 3116 | n := len(applications) |
| 3117 | p.StorePending = &n |
| 3118 | p.StoreQueueUnknown = false |
| 3119 | } |
| 3120 | } |
| 3121 | } |
| 3122 | s.render(w, http.StatusOK, "fleet", p) |
| 3123 | } |
| 3124 | |
| 3125 | // handleFleetMetrics serves the operator host/process monitoring dashboard — |
| 3126 | // same gate as handleFleet, same reasoning: every non-operator path is a |
| 3127 | // plain http.NotFound so the route's existence is not probeable. |
| 3128 | func (s *Server) handleFleetMetrics(w http.ResponseWriter, r *http.Request) { |
| 3129 | addr, raw, ok := s.operatorOnly(w, r) |
| 3130 | if !ok { |
| 3131 | return |
| 3132 | } |
| 3133 | boxes, alerts, err := s.LoadMetrics(r.Context()) |
| 3134 | if err != nil { |
| 3135 | log.Printf("console: fleet metrics: %v", err) |
| 3136 | s.renderMessage(w, r, http.StatusInternalServerError, "Unavailable", "Metrics could not be read.") |
| 3137 | return |
| 3138 | } |
| 3139 | s.render(w, http.StatusOK, "metrics", page{Title: "Fleet metrics", Address: addr, CSRF: raw, Metrics: boxes, MetricAlerts: alerts}) |
| 3140 | } |
| 3141 | |
| 3142 | // handleEconomics serves the operator unit-economics dashboard (Task 13) — |
| 3143 | // handleFleetMetrics's shape verbatim: same gate (session -> IsOperator -> |
| 3144 | // bare http.NotFound, so a non-operator's probe is byte-identical to a |
| 3145 | // missing route), same read-only LoadEconomics call keyed off the period |
| 3146 | // query param, same render-and-done body. Flash is threaded through because |
| 3147 | // this page is also where the scenario-save and bill-save POST doors |
| 3148 | // (economicspass.go's handleEconomicsScenarioSave/handleEconomicsBillSave) |
| 3149 | // redirect back to. |
| 3150 | func (s *Server) handleEconomics(w http.ResponseWriter, r *http.Request) { |
| 3151 | addr, raw, ok := s.operatorOnly(w, r) |
| 3152 | if !ok { |
| 3153 | return |
| 3154 | } |
| 3155 | view, err := s.LoadEconomics(r.Context(), r.URL.Query().Get("period")) |
| 3156 | if err != nil { |
| 3157 | log.Printf("console: economics: %v", err) |
| 3158 | s.renderMessage(w, r, http.StatusInternalServerError, "Unavailable", "Economics could not be read.") |
| 3159 | return |
| 3160 | } |
| 3161 | p := page{Title: "Fleet economics", Address: addr, CSRF: raw, Economics: view} |
| 3162 | p.Flash, p.FlashErr = s.takeFlash(w, r) |
| 3163 | s.render(w, http.StatusOK, "economics", p) |
| 3164 | } |
| 3165 | |
| 3166 | // handleEconomicsAccounts serves the per-account economics table (Task 14) |
| 3167 | // — handleEconomics's gate and LoadEconomics call verbatim, joined with |
| 3168 | // econlistquery.go's search/free-tier-position/cursor controls. view.Accounts |
| 3169 | // (priced, CostUSD-desc from buildEconomicsView) and view.Meters (raw |
| 3170 | // per-account readings, same account keys) are joined into econAccountRow |
| 3171 | // here; pageEconAccounts re-sorts by account name for a stable cursor order, |
| 3172 | // per its own doc comment. |
| 3173 | func (s *Server) handleEconomicsAccounts(w http.ResponseWriter, r *http.Request) { |
| 3174 | addr, raw, ok := s.session(r) |
| 3175 | if !ok { |
| 3176 | http.NotFound(w, r) |
| 3177 | return |
| 3178 | } |
| 3179 | isOp, err := s.store.IsOperator(addr) |
| 3180 | if err != nil { |
| 3181 | log.Printf("console: economics accounts: is-operator: %v", err) |
| 3182 | http.NotFound(w, r) |
| 3183 | return |
| 3184 | } |
| 3185 | if !isOp { |
| 3186 | http.NotFound(w, r) |
| 3187 | return |
| 3188 | } |
| 3189 | view, err := s.LoadEconomics(r.Context(), r.URL.Query().Get("period")) |
| 3190 | if err != nil { |
| 3191 | log.Printf("console: economics accounts: %v", err) |
| 3192 | s.renderMessage(w, r, http.StatusInternalServerError, "Unavailable", "Economics could not be read.") |
| 3193 | return |
| 3194 | } |
| 3195 | rows := make([]econAccountRow, 0, len(view.Accounts)) |
| 3196 | for _, ae := range view.Accounts { |
| 3197 | rows = append(rows, econAccountRow{Account: ae.Account, Name: view.Names[ae.Account], Meters: view.Meters[ae.Account], Econ: ae}) |
| 3198 | } |
| 3199 | q := parseEconAccountQuery(r.URL.Query()) |
| 3200 | // Period is the NORMALIZED value LoadEconomics actually priced rows |
| 3201 | // under (view.Period), not the raw query param — every link this page |
| 3202 | // builds (search form, pos chips, cursor pager, period nav) must carry |
| 3203 | // the period that produced what's on screen, or "Next 50" on a non-mtd |
| 3204 | // period would resume a cursor computed against a different period's |
| 3205 | // row order. See econAccountQuery.Period's doc comment. |
| 3206 | q.Period = view.Period |
| 3207 | pg := pageEconAccounts(rows, q, econAccountPageSize) |
| 3208 | p := page{Title: "Fleet economics: accounts", Address: addr, CSRF: raw, Economics: view, EconAccounts: pg, EconQuery: q} |
| 3209 | p.Flash, p.FlashErr = s.takeFlash(w, r) |
| 3210 | s.render(w, http.StatusOK, "economics_accounts", p) |
| 3211 | } |
| 3212 | |
| 3213 | // ---- app pages -------------------------------------------------------------- |
| 3214 | |
| 3215 | // handleAccountHome is the account's app list and switcher — the page / |
| 3216 | // lands you on. Members and pending invitations live on /team (handleTeam). |
| 3217 | func (s *Server) handleAccountHome(w http.ResponseWriter, r *http.Request) { |
| 3218 | addr, raw, acct, role, ok := s.account(w, r) |
| 3219 | if !ok { |
| 3220 | return |
| 3221 | } |
| 3222 | p := s.accountPage(w, r, addr, raw, acct, role) |
| 3223 | p.Title = acct.Name |
| 3224 | p.ActiveNav = "apps" |
| 3225 | if c, err := r.Cookie(welcomeCookie); err == nil && c.Value == "1" { |
| 3226 | p.Welcome = true |
| 3227 | s.clearCookie(w, welcomeCookie) |
| 3228 | } |
| 3229 | apps, err := s.store.AppsIn(acct.ID) |
| 3230 | if err != nil { |
| 3231 | s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read your apps. Please try again.") |
| 3232 | return |
| 3233 | } |
| 3234 | if len(apps) > 0 { |
| 3235 | views, err := Assemble(s.cfg.Registry, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, apps, s.dnsTargets()) |
| 3236 | if err != nil { |
| 3237 | log.Printf("console: assemble: %v", err) |
| 3238 | s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read platform state. Please try again.") |
| 3239 | return |
| 3240 | } |
| 3241 | for _, v := range views { |
| 3242 | p.Apps = append(p.Apps, summarize(v, acct.Sqid)) |
| 3243 | } |
| 3244 | } |
| 3245 | // The Trash link, and only when there is something in it. Best-effort: a |
| 3246 | // failed count costs a link, never the page. |
| 3247 | if trashed, err := s.store.TrashedAppsIn(acct.ID); err != nil { |
| 3248 | log.Printf("console: trashed apps: %v", err) |
| 3249 | } else { |
| 3250 | p.TrashN = len(trashed) |
| 3251 | } |
| 3252 | s.render(w, http.StatusOK, "apps", p) |
| 3253 | } |
| 3254 | |
| 3255 | // handleTeam lists the account's members and, for owners, the invite |
| 3256 | // machinery. The mutations it hosts all POST to their existing paths. |
| 3257 | func (s *Server) handleTeam(w http.ResponseWriter, r *http.Request) { |
| 3258 | addr, raw, acct, role, ok := s.account(w, r) |
| 3259 | if !ok { |
| 3260 | return |
| 3261 | } |
| 3262 | p := s.accountPage(w, r, addr, raw, acct, role) |
| 3263 | p.Title = "Team" |
| 3264 | p.ActiveNav = "team" |
| 3265 | p.ConfirmNote = copyText("team.confirm_note") |
| 3266 | p.InvitePlaceholder = copyText("team.invite_placeholder") |
| 3267 | p.InviteNote = copyText("team.invite_note") |
| 3268 | if members, err := s.store.Members(acct.ID); err != nil { |
| 3269 | log.Printf("console: members: %v", err) |
| 3270 | } else { |
| 3271 | p.Members = members |
| 3272 | } |
| 3273 | if role == RoleOwner { |
| 3274 | if invites, err := s.store.PendingInvites(acct.ID); err != nil { |
| 3275 | log.Printf("console: pending invites: %v", err) |
| 3276 | } else { |
| 3277 | p.Invites = invites |
| 3278 | } |
| 3279 | } |
| 3280 | s.render(w, http.StatusOK, "team", p) |
| 3281 | } |
| 3282 | |
| 3283 | // handleAccountSettings is the account itself — identity, and its end. |
| 3284 | // Deletion stays owner-only and zero-apps-only; the POST is unchanged. |
| 3285 | func (s *Server) handleAccountSettings(w http.ResponseWriter, r *http.Request) { |
| 3286 | addr, raw, acct, role, ok := s.account(w, r) |
| 3287 | if !ok { |
| 3288 | return |
| 3289 | } |
| 3290 | p := s.accountPage(w, r, addr, raw, acct, role) |
| 3291 | p.Title = "Account settings" |
| 3292 | p.ActiveNav = "settings" |
| 3293 | n, err := s.store.AppCount(acct.ID) |
| 3294 | if err != nil { |
| 3295 | s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read your apps. Please try again.") |
| 3296 | return |
| 3297 | } |
| 3298 | p.HasApps = n > 0 |
| 3299 | p.AccountStore = s.accountStorePane(r.Context(), acct) |
| 3300 | // The service-credential pane (svcpage.go), owners only — see page's |
| 3301 | // Services field for why a member gets no section rather than an empty |
| 3302 | // one. |
| 3303 | if role == RoleOwner { |
| 3304 | s.servicePane(&p, acct) |
| 3305 | } |
| 3306 | // The account's own Errors log level. Resolved with an EMPTY app name on |
| 3307 | // purpose: AppKey(sqid, "") names a key no app ever writes, so the app |
| 3308 | // layer contributes nothing and what comes back is the account setting |
| 3309 | // or the default — which is exactly what this page sets. A failed read |
| 3310 | // is said rather than defaulted (see ErrLevelUnread), and never fails |
| 3311 | // the page. |
| 3312 | if res, err := telemetry.Resolve(r.Context(), s.cfg.Releases, acct.Sqid, ""); err != nil { |
| 3313 | log.Printf("console: account telemetry %s: %v", acct.Sqid, err) |
| 3314 | p.ErrLevelUnread = true |
| 3315 | } else { |
| 3316 | p.ErrLevelSetting = res |
| 3317 | } |
| 3318 | // The analytics switch is the OTHER half of that same resolved object — |
| 3319 | // one settings file, one read — under the name its own section uses. |
| 3320 | p.AnalyticsSetting, p.AnalyticsUnread = p.ErrLevelSetting, p.ErrLevelUnread |
| 3321 | s.render(w, http.StatusOK, "account_settings", p) |
| 3322 | } |
| 3323 | |
| 3324 | // accountPage fills the chrome every account-scoped page shares — the |
| 3325 | // switcher, the base URL its forms post to, the viewer's role — and records |
| 3326 | // the MRU, so signing in lands you back on the account you were last in. |
| 3327 | func (s *Server) accountPage(w http.ResponseWriter, r *http.Request, addr, raw string, acct *Account, role string) page { |
| 3328 | if err := s.store.SetLastAccount(addr, acct.ID); err != nil { |
| 3329 | log.Printf("console: set last account: %v", err) |
| 3330 | } |
| 3331 | p := page{ |
| 3332 | Address: addr, CSRF: raw, |
| 3333 | Account: acct, Role: role, Base: "/a/" + url.PathEscape(acct.Sqid), |
| 3334 | CanPromote: s.cfg.Key != nil, |
| 3335 | } |
| 3336 | p.Flash, p.FlashErr = s.takeFlash(w, r) |
| 3337 | if accts, err := s.store.AccountsFor(addr); err != nil { |
| 3338 | log.Printf("console: switcher: %v", err) |
| 3339 | } else { |
| 3340 | p.Accounts = accts |
| 3341 | } |
| 3342 | // The dropdown's trigger names the current account, so the menu must |
| 3343 | // contain it — a list that lost it (a failed query) would open onto a |
| 3344 | // menu with no "you are here" row at all. The gate already proved the |
| 3345 | // membership, so this is a display fallback, not a permission decision. |
| 3346 | if len(p.Accounts) == 0 { |
| 3347 | p.Accounts = []Account{*acct} |
| 3348 | } |
| 3349 | for i := range p.Accounts { |
| 3350 | p.Accounts[i].Seg = preferredSeg(p.Accounts, &p.Accounts[i]) |
| 3351 | } |
| 3352 | p.Nav = "/a/" + url.PathEscape(preferredSeg(p.Accounts, acct)) |
| 3353 | return p |
| 3354 | } |
| 3355 | |
| 3356 | // accountApp gates the per-app pages and mutations: membership in the |
| 3357 | // account (any role — a member can deploy), and an app that account actually |
| 3358 | // claims. An app that is not this account's and one that does not exist look |
| 3359 | // identical on purpose. |
| 3360 | func (s *Server) accountApp(w http.ResponseWriter, r *http.Request) (addr, raw string, acct *Account, role, app string, ok bool) { |
| 3361 | addr, raw, acct, role, ok = s.account(w, r) |
| 3362 | if !ok { |
| 3363 | return "", "", nil, "", "", false |
| 3364 | } |
| 3365 | app = r.PathValue("app") |
| 3366 | has, err := s.store.HasAppIn(acct.ID, app) |
| 3367 | if err != nil { |
| 3368 | s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read your apps. Please try again.") |
| 3369 | return "", "", nil, "", "", false |
| 3370 | } |
| 3371 | if !has { |
| 3372 | s.renderMessage(w, r, http.StatusNotFound, "Not found", "No such app here.") |
| 3373 | return "", "", nil, "", "", false |
| 3374 | } |
| 3375 | return addr, raw, acct, role, app, true |
| 3376 | } |
| 3377 | |
| 3378 | // appPage gates an app page, assembles its platform view, and fills the |
| 3379 | // chrome every app tab shares. Callers add their own tab's data on top. |
| 3380 | func (s *Server) appPage(w http.ResponseWriter, r *http.Request, tab string) (page, *Account, bool) { |
| 3381 | addr, raw, acct, role, app, ok := s.accountApp(w, r) |
| 3382 | if !ok { |
| 3383 | return page{}, nil, false |
| 3384 | } |
| 3385 | // The account manifest is re-attempted on view: minting writes it, but a |
| 3386 | // bucket hiccup there is deliberately non-fatal, and this is where it gets |
| 3387 | // another chance. Best-effort — a page view never fails on it. |
| 3388 | s.ensureAccountManifest(r.Context(), acct) |
| 3389 | views, err := Assemble(s.cfg.Registry, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, []string{app}, s.dnsTargets()) |
| 3390 | if err != nil || len(views) != 1 { |
| 3391 | log.Printf("console: assemble %s: %v", app, err) |
| 3392 | s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read platform state. Please try again.") |
| 3393 | return page{}, nil, false |
| 3394 | } |
| 3395 | p := s.accountPage(w, r, addr, raw, acct, role) |
| 3396 | p.Title = app |
| 3397 | p.AppName = app |
| 3398 | p.ActiveTab = tab |
| 3399 | p.ActiveNav = "apps" |
| 3400 | p.Detail = &views[0] |
| 3401 | if life, err := s.store.AppLife(acct.ID, app); err != nil { |
| 3402 | log.Printf("console: app lifecycle %s/%s: %v", acct.Sqid, app, err) |
| 3403 | } else if life != nil && life.Temporary() { |
| 3404 | p.ExpiresAt = time.Unix(life.ExpiresAt, 0).UTC() |
| 3405 | } |
| 3406 | if review, all, err := s.appAccountReview(addr, acct, app); err != nil { |
| 3407 | log.Printf("console: account review %s/%s: %v", acct.Sqid, app, err) |
| 3408 | } else { |
| 3409 | p.AccountReview = review |
| 3410 | p.AccountReview.NoInstances = review.Owned == 0 && hasPublishedChannel(p.Detail.Channels) |
| 3411 | for _, group := range review.Groups { |
| 3412 | for _, host := range group.Hosts { |
| 3413 | p.Detail.Mismatched = append(p.Detail.Mismatched, MismatchedRoute{Host: host, Account: group.Account.Sqid}) |
| 3414 | } |
| 3415 | } |
| 3416 | p.Detail.AllMismatched = all |
| 3417 | if all { |
| 3418 | p.Detail.RouteAccount = review.Groups[0].Account.Sqid |
| 3419 | } |
| 3420 | } |
| 3421 | s.applyPipelineView(r.Context(), p.Detail, acct) |
| 3422 | return p, acct, true |
| 3423 | } |
| 3424 | |
| 3425 | func hasPublishedChannel(channels []ChannelView) bool { |
| 3426 | for _, channel := range channels { |
| 3427 | if channel.Err == "" && channel.Version != "" { |
| 3428 | return true |
| 3429 | } |
| 3430 | } |
| 3431 | return false |
| 3432 | } |
| 3433 | |
| 3434 | // applyPipelineView is the one place a single app's view becomes |
| 3435 | // pipeline-aware (release pipelines v2 §2, §5) — deliberately not inside |
| 3436 | // Assemble (AppView.Pipeline's own doc comment: the account home page calls |
| 3437 | // Assemble for a whole app list and never reads Ladder or Pipeline, so a |
| 3438 | // pipeline GET per app there would be a bucket read nobody looks at; appPage |
| 3439 | // is the one place every app-scoped handler funnels through). |
| 3440 | // |
| 3441 | // It corrects each ChannelView's BakedUntil from the pipeline's OWN window |
| 3442 | // (holdWindow) in place of the legacy per-app table Assemble already |
| 3443 | // computed — the fix for the Task 9 review's disagreement between the |
| 3444 | // Channels table and the pipeline card, both of which read Channels — and |
| 3445 | // then rebuilds Ladder from those corrected channels, so the two can never |
| 3446 | // disagree again by construction. Best-effort and view-only, like every |
| 3447 | // other read in this family: an unreadable pipeline degrades the render to |
| 3448 | // the legacy ladder rather than failing the page. |
| 3449 | func (s *Server) applyPipelineView(ctx context.Context, view *AppView, acct *Account) { |
| 3450 | ctx, cancel := context.WithTimeout(ctx, pipelineStoreTimeout) |
| 3451 | defer cancel() |
| 3452 | pipe, err := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, view.App) |
| 3453 | if err != nil { |
| 3454 | log.Printf("console: app page %s/%s: read pipeline: %v", acct.Sqid, view.App, err) |
| 3455 | return |
| 3456 | } |
| 3457 | if pipe == nil { |
| 3458 | return |
| 3459 | } |
| 3460 | view.Pipeline = pipe |
| 3461 | now := time.Now() |
| 3462 | for i := range view.Channels { |
| 3463 | view.Channels[i].BakedUntil = time.Time{} |
| 3464 | if view.Channels[i].Err != "" || view.Channels[i].FastTrack { |
| 3465 | continue |
| 3466 | } |
| 3467 | if d := holdWindow(pipe, view.Channels[i].Name); d > 0 { |
| 3468 | if until := view.Channels[i].PromotedAt.Add(d); now.Before(until) { |
| 3469 | view.Channels[i].BakedUntil = until |
| 3470 | } |
| 3471 | } |
| 3472 | } |
| 3473 | view.Ladder = assembleLadder(view.Channels, view.Routes, pipe, now) |
| 3474 | } |
| 3475 | |
| 3476 | // appResourceMetrics sweeps the app's own instances only. The allowlist is |
| 3477 | // built HERE, from the routes this account's app owns and nothing else — |
| 3478 | // that slice is the entire tenant boundary on the metrics read, so it must |
| 3479 | // never be widened to "all routes" or taken from the request. A failed |
| 3480 | // sweep costs the section, never the page. |
| 3481 | // |
| 3482 | // The account comparison is load-bearing and not redundant with the |
| 3483 | // accountApp gate above: Assemble groups routes by app NAME, and app |
| 3484 | // names are unique per ACCOUNT, not globally (Store.AppAccounts) — two |
| 3485 | // accounts may each own an app called "api", and both accounts' routes |
| 3486 | // arrive in this one AppView. Comparing each route's own account is what |
| 3487 | // keeps one tenant's cgroup readings off the other's page. It fails |
| 3488 | // closed: a route stamped with an account the console record disagrees |
| 3489 | // with (the half-migrated state AccountMismatch describes) yields no |
| 3490 | // metrics rather than someone else's. |
| 3491 | func (s *Server) appResourceMetrics(ctx context.Context, view *AppView, acct *Account) []InstanceMetrics { |
| 3492 | hosts := make([]string, 0, len(view.Routes)) |
| 3493 | hib := make(map[string]bool, len(view.Routes)) |
| 3494 | unitByHost := make(map[string]string, len(view.Routes)) |
| 3495 | for _, rt := range view.Routes { |
| 3496 | if rt.Account != acct.Sqid { |
| 3497 | continue |
| 3498 | } |
| 3499 | hosts = append(hosts, rt.Host) |
| 3500 | hib[rt.Host] = rt.Hibernate |
| 3501 | if rt.Unit != "" { |
| 3502 | unitByHost[rt.Host] = rt.Unit |
| 3503 | } |
| 3504 | } |
| 3505 | am, err := s.OwnerAppMetrics(ctx, hosts, unitByHost) |
| 3506 | if err != nil { |
| 3507 | log.Printf("console: app metrics %s: %v", view.App, err) |
| 3508 | return nil |
| 3509 | } |
| 3510 | for i := range am { |
| 3511 | am[i].Hibernate = hib[am[i].Host] |
| 3512 | } |
| 3513 | return am |
| 3514 | } |
| 3515 | |
| 3516 | // handleApp is the Overview dashboard: the vital statistics, from reads the |
| 3517 | // app pages already do — no self-report poll here, so the landing tab never |
| 3518 | // waits on a tenant's own HTTP endpoint. |
| 3519 | func (s *Server) handleApp(w http.ResponseWriter, r *http.Request) { |
| 3520 | p, acct, ok := s.appPage(w, r, "overview") |
| 3521 | if !ok { |
| 3522 | return |
| 3523 | } |
| 3524 | app := p.Detail.App |
| 3525 | versions, err := Versions(s.cfg.Releases, acct.Sqid, app) |
| 3526 | if err != nil { |
| 3527 | log.Printf("console: versions %s: %v", app, err) |
| 3528 | p.FlashErr = "Could not list this app's releases." |
| 3529 | } |
| 3530 | p.Versions = versions |
| 3531 | metrics := s.appResourceMetrics(r.Context(), p.Detail, acct) |
| 3532 | // The Database tile's sizes are best-effort: a slow status read costs the |
| 3533 | // tile its number (the em-dash), never the page. |
| 3534 | joinCtx, cancelJoin := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| 3535 | dbJoin := loadBoxJoin(joinCtx, s.cfg.Releases, time.Now()) |
| 3536 | cancelJoin() |
| 3537 | p.Overview = assembleOverview(p.Detail, acct.Sqid, metrics, versions, dbJoin) |
| 3538 | // The diagram is drawn from the SAME tenant-filtered rows the Running tab |
| 3539 | // renders, so the two can never disagree about which addresses exist or |
| 3540 | // how they are doing — see flow.go's package comment for why it is not |
| 3541 | // built from Detail.Routes. |
| 3542 | p.InstanceRows = assembleInstanceRows(p.Detail, acct, metrics) |
| 3543 | var latest, latestAt string |
| 3544 | if len(versions) > 0 { |
| 3545 | latest, latestAt = versions[0].Version, versions[0].BuiltAt |
| 3546 | } |
| 3547 | p.Flow = assembleFlow(p.Detail, p.InstanceRows, latest, latestAt, p.Nav+"/apps/"+p.AppName+"/instances") |
| 3548 | if events, err := s.store.Events(acct.ID, app, overviewActivityCap); err != nil { |
| 3549 | log.Printf("console: events %s: %v", app, err) |
| 3550 | } else { |
| 3551 | p.Events = events |
| 3552 | } |
| 3553 | if pcs, err := s.store.PendingChangesForContext(r.Context(), acct.ID, app, time.Now()); err != nil { |
| 3554 | log.Printf("console: pending changes %s/%s: %v", acct.Sqid, app, err) |
| 3555 | } else if len(pcs) > 0 { |
| 3556 | // One pipeline read for the whole card: the rows show the quorum |
| 3557 | // EXECUTION will require, not the one they were proposed under. |
| 3558 | p.Pending = s.pendingChangeViews(pcs, s.pendingPipeline(r.Context(), acct, app)) |
| 3559 | } |
| 3560 | s.render(w, http.StatusOK, "app_overview", p) |
| 3561 | } |
| 3562 | |
| 3563 | // overviewActivityCap bounds the Overview's recent-activity strip; the |
| 3564 | // Activity tab has the room this strip never will. |
| 3565 | const overviewActivityCap = 5 |
| 3566 | |
| 3567 | // handleAppReleases is the deploy surface: every shipped release, the |
| 3568 | // channels they promote onto, and the canaries riding ahead. |
| 3569 | func (s *Server) handleAppReleases(w http.ResponseWriter, r *http.Request) { |
| 3570 | p, acct, ok := s.appPage(w, r, "releases") |
| 3571 | if !ok { |
| 3572 | return |
| 3573 | } |
| 3574 | if strings.HasSuffix(r.URL.Path, "/deployments") { |
| 3575 | p.DeploymentTargets = assembleDeploymentTargets(p.Detail, acct.Sqid) |
| 3576 | if pcs, err := s.store.PendingChangesForContext(r.Context(), acct.ID, p.AppName, time.Now()); err != nil { |
| 3577 | log.Printf("console: pending changes %s/%s: %v", acct.Sqid, p.AppName, err) |
| 3578 | } else if len(pcs) > 0 { |
| 3579 | p.Pending = s.pendingChangeViews(pcs, s.pendingPipeline(r.Context(), acct, p.AppName)) |
| 3580 | } |
| 3581 | s.render(w, http.StatusOK, "app_deployments", p) |
| 3582 | return |
| 3583 | } |
| 3584 | p.ReleaseSummary = assembleReleaseSummary(p.Detail.Channels, p.Detail.Routes, acct.Sqid) |
| 3585 | p.PromoteChannels = promoteChannelOptions(p.Detail.Channels, p.Detail.Pipeline) |
| 3586 | versions, err := Versions(s.cfg.Releases, acct.Sqid, p.AppName) |
| 3587 | if err != nil { |
| 3588 | log.Printf("console: versions %s: %v", p.AppName, err) |
| 3589 | p.FlashErr = "Could not list this app's releases." |
| 3590 | } |
| 3591 | // Versions itself never reads ListTags (view.go), and neither does |
| 3592 | // assembleChannels any more — a full tag sweep thrown away by every |
| 3593 | // OTHER app-page tab and the account home page's whole app list would |
| 3594 | // be a needless read on every one of those renders (release versioning |
| 3595 | // spec, 2026-08-08; view.go's tagByRelease doc comment has the full |
| 3596 | // story). This tab is the one place that sweep pays for itself: ONE |
| 3597 | // ListTags read serves the Tags section below (raw records, changelog |
| 3598 | // and all, capped at tagsCap — ListTags' own semver-descending order), |
| 3599 | // the Releases table's pill (view.go's tagIndex, the pure fold with no |
| 3600 | // read of its own), and the Channels table's pill, backfilled onto |
| 3601 | // Assemble's already-built ChannelView rows since assembleChannels |
| 3602 | // itself leaves Tag "". A failed read degrades this tab's tag |
| 3603 | // decoration only; the page still renders. |
| 3604 | if tags, terr := release.ListTags(context.Background(), s.cfg.Releases, acct.Sqid, p.AppName); terr != nil { |
| 3605 | log.Printf("console: tags %s: %v", p.AppName, terr) |
| 3606 | } else { |
| 3607 | byRelease := tagIndex(tags) |
| 3608 | for i := range versions { |
| 3609 | versions[i].Tag = byRelease[versions[i].Version] |
| 3610 | } |
| 3611 | for i := range p.Detail.Channels { |
| 3612 | p.Detail.Channels[i].Tag = byRelease[p.Detail.Channels[i].Version] |
| 3613 | } |
| 3614 | if len(tags) > tagsCap { |
| 3615 | tags = tags[:tagsCap] |
| 3616 | } |
| 3617 | p.Tags = tags |
| 3618 | } |
| 3619 | p.Versions = versions |
| 3620 | // Read-only in v1: the policy is set via `carlos releases retention`; |
| 3621 | // this line just keeps the page honest about why old versions vanish. |
| 3622 | if ret, err := release.GetRetention(r.Context(), s.cfg.Releases, acct.Sqid, p.AppName); err != nil { |
| 3623 | log.Printf("console: retention %s: %v", p.AppName, err) |
| 3624 | } else if ret != nil { |
| 3625 | p.RetentionKeep = ret.Keep |
| 3626 | } |
| 3627 | // The "how do I give this a URL" copy — not shared chrome (it's computed |
| 3628 | // per app page, not carried by appPage), but the same fill the Instances |
| 3629 | // tab's empty state shows: attachHint below. |
| 3630 | p.AttachCmd, p.AttachEnable = s.attachHint(r.Context(), acct.Sqid, p.AppName, "canary/<name>") |
| 3631 | s.render(w, http.StatusOK, "app_releases", p) |
| 3632 | } |
| 3633 | |
| 3634 | // attachHint is the "give this channel a URL" advice shown on both the |
| 3635 | // Releases tab (the canary form's copy, channel "canary/<name>") and the |
| 3636 | // Instances tab's empty state (channel "stable" — the Running tab's empty |
| 3637 | // state should not be steering members onto a canary) — one bucket read and |
| 3638 | // one command shape per caller. Bounded and best-effort like every other |
| 3639 | // store read behind an app page: an unreadable instances manifest costs the |
| 3640 | // concrete host shape, never the page. |
| 3641 | func (s *Server) attachHint(ctx context.Context, sqid, app, channel string) (cmd, enable string) { |
| 3642 | mctx, mcancel := context.WithTimeout(ctx, instanceStoreTimeout) |
| 3643 | defer mcancel() |
| 3644 | domain, absent := s.instanceDomainFor(mctx, sqid, app) |
| 3645 | return attachCommands(app, channel, domain, s.cfg.AppsDomain, sqid, absent) |
| 3646 | } |
| 3647 | |
| 3648 | // handleAppInstances is what the app is actually running: one row per |
| 3649 | // instance — host, the channel it follows, what it is running right now, and |
| 3650 | // its readings — plus the app's own self-report below, for apps that manage |
| 3651 | // internal instances of their own. |
| 3652 | func (s *Server) handleAppInstances(w http.ResponseWriter, r *http.Request) { |
| 3653 | p, acct, ok := s.appPage(w, r, "instances") |
| 3654 | if !ok { |
| 3655 | return |
| 3656 | } |
| 3657 | p.Instances = s.instances.Report(r.Context(), ownedInstanceRoutes(p.Detail.Routes, acct.Sqid)) |
| 3658 | p.AppMetrics = s.appResourceMetrics(r.Context(), p.Detail, acct) |
| 3659 | q := parseInstanceQuery(r.URL.Query()) |
| 3660 | rows := assembleInstanceRows(p.Detail, acct, p.AppMetrics) |
| 3661 | for _, row := range rows { |
| 3662 | if row.Restartable { |
| 3663 | p.CanRestartApp = true |
| 3664 | break |
| 3665 | } |
| 3666 | } |
| 3667 | pg := pageInstanceRows(rows, q, instancePageSize) |
| 3668 | p.InstanceRows = pg.Rows |
| 3669 | p.InstancePage = pg |
| 3670 | p.InstanceQuery = q |
| 3671 | p.InstanceChannels = instanceChannels(rows) |
| 3672 | p.InstanceUnknownChannel = q.Channel != "" && !hasInstanceChannel(p.InstanceChannels, q.Channel) |
| 3673 | p.InstanceGroups = groupInstanceRows(pg.Rows, p.Detail.Channels, pg.Matching) |
| 3674 | p.InstanceListURL = p.Nav + "/apps/" + p.AppName + "/instances" + instanceQuerySuffix(q) |
| 3675 | // Placement (Task 6): where this app runs, and where else it could. Soft |
| 3676 | // like everything else on this tab — a failed read costs the section's |
| 3677 | // certainty (AppPlacementKnown) and a flash, never the page. |
| 3678 | placement, fleets, known, warn := s.appPlacementSection(acct, p.AppName) |
| 3679 | p.AppPlacement = placement |
| 3680 | p.OwnedFleetNames = fleets |
| 3681 | p.AppPlacementKnown = known |
| 3682 | if warn != "" && p.FlashErr == "" { |
| 3683 | p.FlashErr = warn |
| 3684 | } |
| 3685 | // The app-region note (spec §10). Available is nil for a fleet app and |
| 3686 | // for a policy, edges or stored region that could not be read, so those |
| 3687 | // show nothing: unreadable never reads as available. Pooled is excluded |
| 3688 | // by name. |
| 3689 | if d := s.appDefault(r.Context(), acct, p.AppName); d.Available != nil && d.Reason != "pooled" { |
| 3690 | switch { |
| 3691 | case !*d.Available: |
| 3692 | p.RegionLine, p.RegionLineWarn = fmt.Sprintf(regionCopy.CantStart, d.Name), true |
| 3693 | case d.Region != s.cfg.HomeRegion: |
| 3694 | p.RegionLine = fmt.Sprintf(regionCopy.StartsIn, d.Name) |
| 3695 | default: |
| 3696 | // A home app is told where it starts only when the home has a |
| 3697 | // sibling to be told apart from. |
| 3698 | if files, err := s.liveEdges(r.Context()); err == nil && eligibleCount(s.regionCands(files)) >= 2 { |
| 3699 | p.RegionLine = fmt.Sprintf(regionCopy.StartsIn, d.Name) |
| 3700 | } |
| 3701 | } |
| 3702 | } |
| 3703 | // The empty state keys off the app's UNFILTERED rows, not the filtered |
| 3704 | // page: a filter that matches nothing shows "no matches" (below), never |
| 3705 | // the attach-onboarding copy — that copy is only true when the app has |
| 3706 | // no instances at all. |
| 3707 | if len(rows) == 0 { |
| 3708 | p.AttachCmd, p.AttachEnable = s.attachHint(r.Context(), acct.Sqid, p.AppName, "stable") |
| 3709 | } |
| 3710 | s.render(w, http.StatusOK, "app_instances", p) |
| 3711 | } |
| 3712 | |
| 3713 | // appLogsPageCap bounds the Logs tab to what a page can usefully show; the |
| 3714 | // CLI (`carlos logs`) is the door for more, and the page says so. |
| 3715 | const appLogsPageCap = 200 |
| 3716 | |
| 3717 | // appErrorsPageCap bounds the Errors tab the same way, on both of its |
| 3718 | // shapes: the number of GROUPS in the table, and the number of OCCURRENCE |
| 3719 | // cards in an expanded group. readErrors collects up to logsCollectCap |
| 3720 | // records per family, so without this a busy app's tab is a page nobody can |
| 3721 | // read and a render nobody asked for. `carlos errors` is the door for more. |
| 3722 | const appErrorsPageCap = 200 |
| 3723 | |
| 3724 | // handleAppLogs renders the app's merged log timeline — the same window the |
| 3725 | // CLI reads (apilogs.go), tailed to the newest appLogsPageCap records, with |
| 3726 | // stream and window filters as plain GET params so a view is a URL. |
| 3727 | func (s *Server) handleAppLogs(w http.ResponseWriter, r *http.Request) { |
| 3728 | p, acct, ok := s.appPage(w, r, "logs") |
| 3729 | if !ok { |
| 3730 | return |
| 3731 | } |
| 3732 | q := r.URL.Query() |
| 3733 | p.LogCopy = appLogsCopy{ |
| 3734 | Instance: "Instances", AllInstances: "All instances", Search: "Search logs", |
| 3735 | AllLevels: "All levels", Follow: "Follow live", Pause: "Pause", |
| 3736 | Widen: "Show the last 24 hours", |
| 3737 | FollowError: "Live updates failed. Follow live again to retry.", Refreshed: "Updated", |
| 3738 | } |
| 3739 | p.LogStream = q.Get("stream") |
| 3740 | p.LogSince = q.Get("since") |
| 3741 | if p.LogSince == "" { |
| 3742 | p.LogSince = "1h" |
| 3743 | } |
| 3744 | p.LogHost = q.Get("host") |
| 3745 | p.LogLevel = q.Get("level") |
| 3746 | p.LogGrep = q.Get("text") |
| 3747 | p.LogLegacyGrep = q.Get("grep") |
| 3748 | p.LogFollow = q.Get("follow") == "1" |
| 3749 | widen, _ := url.ParseQuery(q.Encode()) |
| 3750 | widen.Set("since", "24h") |
| 3751 | widen.Del("cursor") |
| 3752 | widen.Del("follow") |
| 3753 | p.LogWidenURL = p.Nav + "/apps/" + url.PathEscape(p.AppName) + "/logs?" + widen.Encode() |
| 3754 | now := time.Now() |
| 3755 | f, _, bad := browserLogsFilterFromQuery(r, now) |
| 3756 | // Cursor is a data-endpoint concern. A hand-written page URL carrying one |
| 3757 | // must not turn the page's normal one-hour default into an all-history read. |
| 3758 | if f.Since.IsZero() { |
| 3759 | f.Since = now.Add(-logsDefaultWindow) |
| 3760 | } |
| 3761 | if bad != "" { |
| 3762 | p.LogsErr = bad |
| 3763 | } |
| 3764 | seenHosts := map[string]bool{} |
| 3765 | if p.LogHost != "" { |
| 3766 | seenHosts[p.LogHost] = true |
| 3767 | p.LogHosts = append(p.LogHosts, p.LogHost) |
| 3768 | } |
| 3769 | for _, rt := range p.Detail.Routes { |
| 3770 | if rt.Account == acct.Sqid && !seenHosts[rt.Host] { |
| 3771 | seenHosts[rt.Host] = true |
| 3772 | p.LogHosts = append(p.LogHosts, rt.Host) |
| 3773 | } |
| 3774 | } |
| 3775 | if s.cfg.Releases != nil { |
| 3776 | ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| 3777 | recs, _, err := provision.ListRecords(ctx, s.cfg.Releases, acct.Sqid, p.AppName, instanceListMax) |
| 3778 | cancel() |
| 3779 | if err == nil { |
| 3780 | for _, rec := range recs { |
| 3781 | if !seenHosts[rec.Host] { |
| 3782 | seenHosts[rec.Host] = true |
| 3783 | p.LogHosts = append(p.LogHosts, rec.Host) |
| 3784 | } |
| 3785 | } |
| 3786 | } |
| 3787 | } |
| 3788 | sort.Strings(p.LogHosts) |
| 3789 | |
| 3790 | if s.cfg.Releases != nil && bad == "" { |
| 3791 | ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| 3792 | defer cancel() |
| 3793 | recs, trimmed, err := applog.ReadWindow(ctx, s.cfg.Releases, acct.Sqid, p.AppName, f, logsCollectCap) |
| 3794 | if err != nil { |
| 3795 | log.Printf("console: app logs %s/%s: %v", acct.Sqid, p.AppName, err) |
| 3796 | p.LogsErr = "The logs could not be read just now — try again in a moment." |
| 3797 | } |
| 3798 | p.LogTruncated = trimmed |
| 3799 | if len(recs) > appLogsPageCap { |
| 3800 | recs = recs[len(recs)-appLogsPageCap:] |
| 3801 | p.LogTruncated = true |
| 3802 | } |
| 3803 | p.LogRows = recs |
| 3804 | if n := len(recs); n > 0 { |
| 3805 | p.LogCursor = applog.EncodeCursor(recs[n-1]) |
| 3806 | } |
| 3807 | } |
| 3808 | s.render(w, http.StatusOK, "app_logs", p) |
| 3809 | } |
| 3810 | |
| 3811 | type appLogsDataLine struct { |
| 3812 | Time string `json:"time"` |
| 3813 | Stream string `json:"stream"` |
| 3814 | Host string `json:"host,omitempty"` |
| 3815 | Msg string `json:"msg"` |
| 3816 | Fields string `json:"fields,omitempty"` |
| 3817 | Tone string `json:"tone,omitempty"` |
| 3818 | } |
| 3819 | |
| 3820 | type appLogsDataResponse struct { |
| 3821 | Lines []appLogsDataLine `json:"lines"` |
| 3822 | Truncated bool `json:"truncated"` |
| 3823 | Gap bool `json:"gap,omitempty"` |
| 3824 | Cursor string `json:"cursor,omitempty"` |
| 3825 | } |
| 3826 | |
| 3827 | // handleAppLogsData is the browser's follow endpoint. It uses the session and |
| 3828 | // app-membership gate, then the API reader's filter/cursor semantics; browser |
| 3829 | // JavaScript never receives or manufactures a bearer credential. |
| 3830 | func (s *Server) handleAppLogsData(w http.ResponseWriter, r *http.Request) { |
| 3831 | w.Header().Set("Cache-Control", "no-store") |
| 3832 | _, _, acct, _, app, ok := s.accountApp(w, r) |
| 3833 | if !ok { |
| 3834 | return |
| 3835 | } |
| 3836 | now := time.Now() |
| 3837 | f, limit, bad := browserLogsFilterFromQuery(r, now) |
| 3838 | if bad != "" { |
| 3839 | jsonError(w, http.StatusBadRequest, bad) |
| 3840 | return |
| 3841 | } |
| 3842 | if s.cfg.Releases == nil { |
| 3843 | jsonError(w, http.StatusServiceUnavailable, "could not read the logs") |
| 3844 | return |
| 3845 | } |
| 3846 | cursorText := r.URL.Query().Get("cursor") |
| 3847 | cur, hasCursor := applog.DecodeCursor(cursorText) |
| 3848 | if cursorText != "" && !hasCursor { |
| 3849 | writeJSON(w, http.StatusOK, appLogsDataResponse{Lines: []appLogsDataLine{}, Gap: true}) |
| 3850 | return |
| 3851 | } |
| 3852 | if hasCursor { |
| 3853 | if !f.Since.IsZero() && cur.TS.Before(f.Since) { |
| 3854 | writeJSON(w, http.StatusOK, appLogsDataResponse{Lines: []appLogsDataLine{}, Gap: true}) |
| 3855 | return |
| 3856 | } |
| 3857 | cursorSince := cur.TS.Add(-time.Second) |
| 3858 | if f.Since.IsZero() || cursorSince.After(f.Since) { |
| 3859 | f.Since = cursorSince |
| 3860 | } |
| 3861 | } |
| 3862 | limit = min(limit, appLogsPageCap) |
| 3863 | ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| 3864 | defer cancel() |
| 3865 | recs, trimmed, err := applog.ReadWindow(ctx, s.cfg.Releases, acct.Sqid, app, f, logsCollectCap) |
| 3866 | if err != nil { |
| 3867 | log.Printf("console: app logs data %s/%s: %v", acct.Sqid, app, err) |
| 3868 | jsonError(w, http.StatusBadGateway, "could not read the logs") |
| 3869 | return |
| 3870 | } |
| 3871 | resp := appLogsDataResponse{Gap: trimmed && hasCursor} |
| 3872 | if hasCursor { |
| 3873 | var after []applog.Record |
| 3874 | for _, rec := range recs { |
| 3875 | if cur.Before(rec) { |
| 3876 | after = append(after, rec) |
| 3877 | } |
| 3878 | } |
| 3879 | if len(after) > limit { |
| 3880 | after = after[:limit] |
| 3881 | resp.Truncated = true |
| 3882 | } |
| 3883 | recs = after |
| 3884 | } else if len(recs) > limit { |
| 3885 | recs = recs[len(recs)-limit:] |
| 3886 | resp.Truncated = true |
| 3887 | } |
| 3888 | for _, line := range logLines(recs) { |
| 3889 | resp.Lines = append(resp.Lines, appLogsDataLine{ |
| 3890 | Time: line.Time, Stream: line.Stream, Host: line.Host, |
| 3891 | Msg: line.Msg, Fields: line.Fields, Tone: line.Tone, |
| 3892 | }) |
| 3893 | } |
| 3894 | if n := len(recs); n > 0 { |
| 3895 | resp.Cursor = applog.EncodeCursor(recs[n-1]) |
| 3896 | } else { |
| 3897 | resp.Cursor = cursorText |
| 3898 | } |
| 3899 | if resp.Lines == nil { |
| 3900 | resp.Lines = []appLogsDataLine{} |
| 3901 | } |
| 3902 | writeJSON(w, http.StatusOK, resp) |
| 3903 | } |
| 3904 | |
| 3905 | // browserLogsFilterFromQuery keeps the CLI's filter parser as the one source |
| 3906 | // of validation while making the console's search box literal. A stale |
| 3907 | // console URL carrying grep continues to use its original regular expression. |
| 3908 | func browserLogsFilterFromQuery(r *http.Request, now time.Time) (applog.Filter, int, string) { |
| 3909 | text := r.URL.Query().Get("text") |
| 3910 | if text == "" { |
| 3911 | return logsFilterFromQuery(r, now) |
| 3912 | } |
| 3913 | r2 := r.Clone(r.Context()) |
| 3914 | u := *r.URL |
| 3915 | q := u.Query() |
| 3916 | q.Set("grep", regexp.QuoteMeta(text)) |
| 3917 | q.Del("text") |
| 3918 | u.RawQuery = q.Encode() |
| 3919 | r2.URL = &u |
| 3920 | return logsFilterFromQuery(r2, now) |
| 3921 | } |
| 3922 | |
| 3923 | // handleAppActivity is the console's own audit trail for this app. |
| 3924 | func (s *Server) handleAppActivity(w http.ResponseWriter, r *http.Request) { |
| 3925 | p, acct, ok := s.appPage(w, r, "activity") |
| 3926 | if !ok { |
| 3927 | return |
| 3928 | } |
| 3929 | p.ActivityCopy = activityText |
| 3930 | if events, err := s.store.Events(acct.ID, p.AppName, activityCap); err != nil { |
| 3931 | log.Printf("console: events %s: %v", p.AppName, err) |
| 3932 | } else { |
| 3933 | // Only link targets this account's assembled view proves still exist. |
| 3934 | // Deleted instances and same-named routes owned by another account stay |
| 3935 | // plain text; an activity link must never cross the membership boundary. |
| 3936 | instances := make(map[string]bool) |
| 3937 | for _, route := range p.Detail.Routes { |
| 3938 | if route.Account == acct.Sqid && !route.Platform { |
| 3939 | instances[route.Host] = true |
| 3940 | } |
| 3941 | } |
| 3942 | for i := range events { |
| 3943 | if events[i].TargetType == "release" && events[i].Target != "" { |
| 3944 | events[i].TargetURL = p.Nav + "/apps/" + url.PathEscape(p.AppName) + "/releases#version-" + url.QueryEscape(events[i].Target) |
| 3945 | } |
| 3946 | if events[i].TargetType == "channel" && events[i].Target != "" { |
| 3947 | events[i].TargetURL = p.Nav + "/apps/" + url.PathEscape(p.AppName) + "/deployments" |
| 3948 | } |
| 3949 | if events[i].TargetType == "instance" && instances[events[i].Target] { |
| 3950 | events[i].TargetURL = p.Nav + "/apps/" + url.PathEscape(p.AppName) + |
| 3951 | "/instances/" + url.PathEscape(events[i].Target) |
| 3952 | } |
| 3953 | } |
| 3954 | p.Events = events |
| 3955 | } |
| 3956 | s.render(w, http.StatusOK, "app_activity", p) |
| 3957 | } |
| 3958 | |
| 3959 | // handleAppSettings is the app's own record and the controls over it: |
| 3960 | // identity (and renaming it, while that is still only a record), domains, |
| 3961 | // the account-move repair, and deletion. |
| 3962 | func (s *Server) handleAppSettings(w http.ResponseWriter, r *http.Request) { |
| 3963 | p, acct, ok := s.appPage(w, r, "settings") |
| 3964 | if !ok { |
| 3965 | return |
| 3966 | } |
| 3967 | section := r.PathValue("section") |
| 3968 | if section == "" { |
| 3969 | section = "general" |
| 3970 | } |
| 3971 | if !appSettingsSections[section] { |
| 3972 | http.NotFound(w, r) |
| 3973 | return |
| 3974 | } |
| 3975 | if section == "pipeline" { |
| 3976 | s.handlePipelineSettingsPage(w, r, p, acct) |
| 3977 | return |
| 3978 | } |
| 3979 | setAppSettingsSection(&p, section) |
| 3980 | s.populateAppSettings(r, &p, acct) |
| 3981 | s.render(w, http.StatusOK, "app_settings", p) |
| 3982 | } |
| 3983 | |
| 3984 | func setAppSettingsSection(p *page, section string) { |
| 3985 | p.SettingsSection = section |
| 3986 | for _, item := range []struct{ key, label string }{ |
| 3987 | {"general", "General"}, |
| 3988 | {"domains", "Domains"}, |
| 3989 | {"pipeline", "Release pipeline"}, |
| 3990 | {"environments", "Environments"}, |
| 3991 | {"storage", "Storage"}, |
| 3992 | {"email", "Email"}, |
| 3993 | {"features", "Feature flags"}, |
| 3994 | {"advanced", "Advanced"}, |
| 3995 | {"delete", "Delete"}, |
| 3996 | } { |
| 3997 | href := p.Nav + "/apps/" + url.PathEscape(p.AppName) + "/settings" |
| 3998 | if item.key != "general" { |
| 3999 | href += "/" + item.key |
| 4000 | } |
| 4001 | p.SettingsNav = append(p.SettingsNav, SettingsSectionLink{ |
| 4002 | Key: item.key, Label: item.label, Href: href, Current: item.key == section, |
| 4003 | }) |
| 4004 | } |
| 4005 | } |
| 4006 | |
| 4007 | func (s *Server) populateAppSettings(r *http.Request, p *page, acct *Account) { |
| 4008 | p.Settings = s.assembleSettings(r.Context(), p, acct) |
| 4009 | // The Errors log level this app resolves to, and where it comes from — |
| 4010 | // the one setting the tab itself cannot change. A failed read is said |
| 4011 | // rather than defaulted (see ErrLevelUnread), and never fails a page |
| 4012 | // that is mostly about something else. |
| 4013 | if res, err := telemetry.Resolve(r.Context(), s.cfg.Releases, acct.Sqid, p.AppName); err != nil { |
| 4014 | log.Printf("console: telemetry %s/%s: %v", acct.Sqid, p.AppName, err) |
| 4015 | p.ErrLevelUnread = true |
| 4016 | } else { |
| 4017 | p.ErrLevelSetting = res |
| 4018 | } |
| 4019 | // The analytics switch is the OTHER half of that same resolved object — |
| 4020 | // one settings file, one read — under the name its own section uses. |
| 4021 | p.AnalyticsSetting, p.AnalyticsUnread = p.ErrLevelSetting, p.ErrLevelUnread |
| 4022 | s.loadSwapPolicy(r.Context(), p, acct) |
| 4023 | } |
| 4024 | |
| 4025 | func (s *Server) renderAppSettingsForm(w http.ResponseWriter, r *http.Request, status int, section, msg string, drafts map[string]string) { |
| 4026 | p, acct, ok := s.appPage(w, r, "settings") |
| 4027 | if !ok { |
| 4028 | return |
| 4029 | } |
| 4030 | setAppSettingsSection(&p, section) |
| 4031 | p.FlashErr = msg |
| 4032 | p.SettingsRenameTo = drafts["rename"] |
| 4033 | p.SettingsDomain = drafts["domain"] |
| 4034 | p.SettingsChannel = drafts["channel"] |
| 4035 | s.populateAppSettings(r, &p, acct) |
| 4036 | s.render(w, status, "app_settings", p) |
| 4037 | } |
| 4038 | |
| 4039 | // activityCap bounds the activity tab's own listing. |
| 4040 | const activityCap = 50 // the activity tab has the room the overview strip never did |
| 4041 | |
| 4042 | // audit best-effort-records an owner action: a failed audit line is logged, |
| 4043 | // never surfaced, and never fails the action it records. |
| 4044 | func (s *Server) audit(acct *Account, app, addr, action, detail string) { |
| 4045 | if err := s.store.RecordEvent(acct.ID, app, addr, action, detail, time.Now()); err != nil { |
| 4046 | log.Printf("console: audit %s %s: %v", action, app, err) |
| 4047 | } |
| 4048 | } |
| 4049 | |
| 4050 | // auditActivity records server-derived structure separately from optional |
| 4051 | // caller context. Authorisation and mutation must already have succeeded. |
| 4052 | func (s *Server) auditActivity(acct *Account, app, addr string, activity Activity) { |
| 4053 | if err := s.store.RecordActivity(acct.ID, app, addr, activity, time.Now()); err != nil { |
| 4054 | log.Printf("console: audit %s %s: %v", activity.Action, app, err) |
| 4055 | } |
| 4056 | } |
| 4057 | |
| 4058 | // ensureAccountManifest is the view-time backfill for account identity: write |
| 4059 | // accounts/<sqid>/account.json only when none exists. An existing manifest is |
| 4060 | // what every box has already materialized — a page view must never clobber |
| 4061 | // it, because rewriting a different id there would re-home every platform |
| 4062 | // domain the account owns. It also covers the accounts that predate this |
| 4063 | // feature (the built-in ops account, and any minted while the bucket was |
| 4064 | // unreachable). Best-effort: failures are logged, never fail the page — which |
| 4065 | // a bucketless console (s.cfg.Releases == nil) is the extreme case of: there |
| 4066 | // is nothing to read OR backfill, so every appPage view on such a console |
| 4067 | // panicked here until #82 Task 5's Settings-without-a-bucket coverage caught |
| 4068 | // it; nil is now just another "nothing to do" the caller never sees. |
| 4069 | func (s *Server) ensureAccountManifest(ctx context.Context, acct *Account) { |
| 4070 | if s.cfg.Releases == nil { |
| 4071 | return |
| 4072 | } |
| 4073 | key := AccountManifestKey(acct.Sqid) |
| 4074 | _, _, err := s.cfg.Releases.Get(ctx, key) |
| 4075 | if err == nil { |
| 4076 | return // already there — nothing to backfill |
| 4077 | } |
| 4078 | if !errors.Is(err, os.ErrNotExist) { |
| 4079 | log.Printf("console: account manifest %s: %v", key, err) |
| 4080 | return |
| 4081 | } |
| 4082 | if err := WriteAccountManifest(ctx, s.cfg.Releases, acct.ID, acct.Sqid, acct.Name, acct.CreatedAt); err != nil { |
| 4083 | log.Printf("console: backfill account manifest %s: %v", key, err) |
| 4084 | return |
| 4085 | } |
| 4086 | log.Printf("console: backfilled account manifest for %s (%s)", acct.Sqid, acct.Name) |
| 4087 | } |
| 4088 | |
| 4089 | // claimOutcome is how one claim attempt ended: the shared vocabulary of the |
| 4090 | // two claim doors (the browser form's handleNewApp, the CLI API's |
| 4091 | // handleAPICreateApp), each of which maps every outcome onto its own wire |
| 4092 | // shape. The distinct failure outcomes exist because the browser door has |
| 4093 | // always named which step failed ("that name" / "the release store" / "the |
| 4094 | // app") and collapsing them here would change its copy. |
| 4095 | type claimOutcome int |
| 4096 | |
| 4097 | const ( |
| 4098 | claimOK claimOutcome = iota |
| 4099 | claimInvalid // not a ValidApp name |
| 4100 | claimReserved // the literal name the console's own routes shadow (e.g. "new") |
| 4101 | claimTaken // any flavour of "not free" — table row, grant, bucket objects, lost race |
| 4102 | claimCheckFailed // a database lookup failed |
| 4103 | claimStoreFailed // the release-store listing failed |
| 4104 | claimCreateFailed // the insert itself failed |
| 4105 | claimApexHeld // another app already holds the name's platform apex (apex.go) |
| 4106 | claimTemporaryReserved // a tmp- name at a permanent door (temporary static sites spec ruling 7) |
| 4107 | ) |
| 4108 | |
| 4109 | // normalizeAppName is the one normalization both claim doors apply before |
| 4110 | // claimApp sees the name. |
| 4111 | func normalizeAppName(name string) string { |
| 4112 | return strings.ToLower(strings.TrimSpace(name)) |
| 4113 | } |
| 4114 | |
| 4115 | // validBuildProbe bounds the deploy watch's probe path to what it actually |
| 4116 | // dials: an absolute path, no query, no fragment, no protocol-relative |
| 4117 | // "//host/..." escape, and printable ASCII only (no control characters, no |
| 4118 | // bytes a Go net/http client would refuse or mangle). The watch appends its |
| 4119 | // own cache-busting query parameter (deploy.go), so a probe carrying one |
| 4120 | // would produce a URL with two — and a probe starting "//" (or a full URL) |
| 4121 | // would silently point the watch at another host entirely, which is the one |
| 4122 | // mistake here that could report a DIFFERENT deployment's build as this |
| 4123 | // one's. The client mirror (validBuildProbe, apps.go) must stay |
| 4124 | // byte-identical to this rule — a typo the server refuses must cost nothing |
| 4125 | // against the create verb's own scarce per-day budget, and a typo the |
| 4126 | // server ACCEPTS but the client refuses would just be a confusing false |
| 4127 | // refusal. |
| 4128 | // |
| 4129 | // This is no longer only a create-time gate: the SAME rule is what keeps the |
| 4130 | // deploy watch on-host. runDeploy re-checks the echoed probe with the client |
| 4131 | // mirror before dialing it (deploy.go), so loosening this "just for creates" |
| 4132 | // silently loosens the watch too — and the shape that matters there is a |
| 4133 | // value not starting with "/": "https://" + host + "@evil.example/x" parses |
| 4134 | // host as USERINFO and evil.example as the HOST, which is this comment's |
| 4135 | // "a DIFFERENT deployment's build" failure arriving by a second route. |
| 4136 | func validBuildProbe(p string) bool { |
| 4137 | if !strings.HasPrefix(p, "/") || strings.HasPrefix(p, "//") || strings.ContainsAny(p, "?#") { |
| 4138 | return false |
| 4139 | } |
| 4140 | if len(p) > 256 { |
| 4141 | return false |
| 4142 | } |
| 4143 | for i := 0; i < len(p); i++ { |
| 4144 | if p[i] < 0x21 || p[i] > 0x7e { |
| 4145 | return false |
| 4146 | } |
| 4147 | } |
| 4148 | return true |
| 4149 | } |
| 4150 | |
| 4151 | // ownerExempt reports whether the #79 owner exemption covers prefix, given |
| 4152 | // the account's own app prefix. It is an allowlist of exactly one prefix: a |
| 4153 | // prefix it has never been told about is NOT exempt, so a third prefix added |
| 4154 | // to claimApp's list is refused for an owner until someone deliberately names |
| 4155 | // it here. See the call site for why that default matters. |
| 4156 | func ownerExempt(prefix, ownPrefix string) bool { return prefix == ownPrefix } |
| 4157 | |
| 4158 | // reservedAppName reports whether no app in acct may take name, by create or |
| 4159 | // by rename. |
| 4160 | // |
| 4161 | // "new" is the literal path segment GET /a/{sqid}/apps/new resolves to — |
| 4162 | // Go 1.22's mux prefers it over apps/{app}, so an app named "new" could |
| 4163 | // never be viewed. |
| 4164 | // |
| 4165 | // The console's own mail identity hangs off consoleMailApp in the ops |
| 4166 | // account (consolemail.go). The mail layer refuses to tear that domain down |
| 4167 | // whatever app holds the name, but an app wearing it would still read as |
| 4168 | // the domain's owner everywhere else. |
| 4169 | func reservedAppName(acct *Account, name string) bool { |
| 4170 | return name == "new" || (acct.Sqid == release.OpsAccount && name == consoleMailApp) |
| 4171 | } |
| 4172 | |
| 4173 | // claimApp is the permanent claim every existing door makes: claimAppWith |
| 4174 | // with a zero expiresAt. |
| 4175 | func (s *Server) claimApp(ctx context.Context, acct *Account, name, addr, placement string, ownerClaim bool, rq regionReq) (outcome claimOutcome, placed string, held *apexHold) { |
| 4176 | return s.claimAppWith(ctx, acct, name, addr, placement, ownerClaim, rq, time.Time{}) |
| 4177 | } |
| 4178 | |
| 4179 | // claimAppWith claims an app name for acct — once, ever. A name is refused when |
| 4180 | // the apps table has it, when ANY owner exists for it (CLI-granted apps stay |
| 4181 | // claim-proof), or when the release store already holds anything under |
| 4182 | // apps/<name>/. This is the ONLY place the claim checks live: both doors |
| 4183 | // call it, so they cannot drift apart. Authentication, rate limits, the |
| 4184 | // audit row and the response are the callers' business. |
| 4185 | // |
| 4186 | // placement is an ALREADY-VALIDATED "" or "fleet/<name>" string — the |
| 4187 | // caller (handleAPICreateApp) checks grammar and fleet ownership itself, |
| 4188 | // the same inline checks placeAppTarget's target uses, BEFORE calling this, |
| 4189 | // so an invalid or foreign placement never reaches here at all and never |
| 4190 | // costs a claim attempt. handleNewApp (the browser door) has no placement |
| 4191 | // concept and always passes "". |
| 4192 | // |
| 4193 | // placed, the second return, is what actually ended up stored: placement |
| 4194 | // itself on a successful write, "" on a failed one (or when placement was |
| 4195 | // "" to begin with — nothing was ever asked for). On a successful claim the |
| 4196 | // placement is stamped as a SEPARATE store write, after CreateAppIn — not |
| 4197 | // threaded into that INSERT — because CreateAppIn has many other, unrelated |
| 4198 | // callers; a failure of this second write is logged and does NOT undo the |
| 4199 | // claim (the same best-effort shape ensureAccountManifest below already |
| 4200 | // uses): an app that exists but isn't placed is recoverable with `carlos |
| 4201 | // apps place`, while failing the whole request after the row is already |
| 4202 | // committed would leave the caller unable to tell whether retrying `apps |
| 4203 | // create` means "taken" or "try again". `placed` is what lets the CALLER |
| 4204 | // (handleAPICreateApp, echoed to the wire as "placement") make that failure |
| 4205 | // OBSERVABLE instead of silently trusting the request — a caller that never |
| 4206 | // checks the echo cannot tell "placed" from "claim succeeded, placement |
| 4207 | // silently didn't". |
| 4208 | // |
| 4209 | // ownerClaim exempts the ACCOUNT-QUALIFIED prefix check below, and nothing |
| 4210 | // else. The guard exists so a stranger cannot inherit objects somebody else |
| 4211 | // shipped — but apps/<sqid>/<app>/ is namespaced BY ACCOUNT, so objects under |
| 4212 | // a given sqid can only be that account's. An OWNER of that account claiming |
| 4213 | // a name under its own prefix is adopting their own release history, which is |
| 4214 | // the whole of issue #79: an app first shipped direct-to-bucket (direct mode |
| 4215 | // skips the claim) could otherwise never be given a row, and so stayed |
| 4216 | // invisible to the console forever with no recovery but deleting its history. |
| 4217 | // |
| 4218 | // It is OWNER, not member: adopting existing release history is an ownership |
| 4219 | // act. It is FALSE for every service principal — a service has no role at |
| 4220 | // all, and an account-scoped one legitimately reaches this function, so |
| 4221 | // without an explicit pin the exemption would hand every CI credential the |
| 4222 | // same power. |
| 4223 | // |
| 4224 | // held is set only with claimApexHeld: who holds the name's platform apex, so |
| 4225 | // a door can say so without asking again. |
| 4226 | // |
| 4227 | // A zero expiresAt is the permanent claim every existing door makes, and it |
| 4228 | // refuses a tmp- name: nothing permanent may be created in that namespace |
| 4229 | // (spec ruling 7). A non-zero one is /new-temporary's claim: it refuses any |
| 4230 | // other name, runs with ownerClaim false whatever the caller's role (spec |
| 4231 | // §3, so any object under the prefix refuses), and inserts the row with its |
| 4232 | // expiry in the same statement that claims the name. |
| 4233 | func (s *Server) claimAppWith(ctx context.Context, acct *Account, name, addr, placement string, ownerClaim bool, rq regionReq, expiresAt time.Time) (outcome claimOutcome, placed string, held *apexHold) { |
| 4234 | if !release.ValidApp(name) { |
| 4235 | return claimInvalid, "", nil |
| 4236 | } |
| 4237 | // Reserved before any store write, same as an invalid name. |
| 4238 | if reservedAppName(acct, name) { |
| 4239 | return claimReserved, "", nil |
| 4240 | } |
| 4241 | temporary := !expiresAt.IsZero() |
| 4242 | if release.IsTemporaryName(name) != temporary { |
| 4243 | if temporary { |
| 4244 | return claimInvalid, "", nil |
| 4245 | } |
| 4246 | return claimTemporaryReserved, "", nil |
| 4247 | } |
| 4248 | if temporary { |
| 4249 | ownerClaim = false |
| 4250 | } |
| 4251 | // Taken means taken IN THIS ACCOUNT — app names are unique per account, |
| 4252 | // never globally, so another account's "blog" is not a conflict. |
| 4253 | if has, err := s.store.HasAppIn(acct.ID, name); err != nil { |
| 4254 | log.Printf("console: claim check failed: account=%q app=%q op=%q err=%v", acct.Sqid, name, "HasAppIn", err) |
| 4255 | return claimCheckFailed, "", nil |
| 4256 | } else if has { |
| 4257 | return claimTaken, "", nil |
| 4258 | } |
| 4259 | // The pre-accounts checks still apply to the OPS account, where every |
| 4260 | // CLI-granted and pre-accounts app lives: a name with grants or bucket |
| 4261 | // objects there stays claim-proof. |
| 4262 | if acct.ID == OpsAccountID { |
| 4263 | if has, err := s.store.HasOwners(name); err != nil { |
| 4264 | log.Printf("console: claim check failed: account=%q app=%q op=%q err=%v", acct.Sqid, name, "HasOwners", err) |
| 4265 | return claimCheckFailed, "", nil |
| 4266 | } else if has { |
| 4267 | return claimTaken, "", nil |
| 4268 | } |
| 4269 | } |
| 4270 | // The account's own prefix, plus — for ops only — the legacy unqualified |
| 4271 | // one an unmigrated app's objects are still at. This one legacy read |
| 4272 | // OUTLIVES the retired read-fallback, and deliberately: it is explicit |
| 4273 | // rather than a resolution path, and a name whose legacy objects still |
| 4274 | // exist is not free. Handing it out would put a stranger's app in the way |
| 4275 | // of the `accounts migrate -from legacy` that is still owed to those |
| 4276 | // objects. For a non-ops account the legacy prefix belongs to somebody |
| 4277 | // else's app and must NOT block the name. |
| 4278 | ownPrefix := release.AppPrefix(acct.Sqid, name) |
| 4279 | adoptingExistingApp := false |
| 4280 | prefixes := []string{ownPrefix} |
| 4281 | if acct.ID == OpsAccountID { |
| 4282 | prefixes = append(prefixes, release.LegacyAppPrefix(name)) |
| 4283 | } |
| 4284 | // The release-store deadline lives HERE, not in the doors, so both doors |
| 4285 | // give the bucket the same ten seconds the browser form always has — |
| 4286 | // and the database checks above stay outside it, as they always were. |
| 4287 | ctx, cancel := context.WithTimeout(ctx, 10*time.Second) |
| 4288 | defer cancel() |
| 4289 | // A name whose platform apex another app of the account already holds is |
| 4290 | // not free either, whatever the tables say: the new app would never get |
| 4291 | // its own URL (apex.go). |
| 4292 | if hold, err := s.apexHolder(ctx, acct, name); err != nil { |
| 4293 | if apexBucketFailure(err) { |
| 4294 | return claimStoreFailed, "", nil |
| 4295 | } |
| 4296 | log.Printf("console: claim check failed: account=%q app=%q op=%q err=%v", acct.Sqid, name, "apexHolder", err) |
| 4297 | return claimCheckFailed, "", nil |
| 4298 | } else if hold != nil { |
| 4299 | return claimApexHeld, "", hold |
| 4300 | } |
| 4301 | for _, prefix := range prefixes { |
| 4302 | keys, err := s.cfg.Releases.List(ctx, prefix) |
| 4303 | if err != nil { |
| 4304 | return claimStoreFailed, "", nil |
| 4305 | } |
| 4306 | if s.cfg.HostClaim && prefix == ownPrefix { |
| 4307 | free, err := s.claimAdmissionNameFree(ctx, acct.Sqid, name) |
| 4308 | if err != nil { |
| 4309 | return claimStoreFailed, "", nil |
| 4310 | } |
| 4311 | if free { |
| 4312 | continue |
| 4313 | } |
| 4314 | } |
| 4315 | if len(keys) == 0 { |
| 4316 | continue |
| 4317 | } |
| 4318 | // The owner exemption is an ALLOWLIST: it NAMES the one prefix it |
| 4319 | // covers — the account's own, apps/<sqid>/<app>/ — rather than |
| 4320 | // excluding the one it doesn't. The two forms are identical over |
| 4321 | // today's two prefixes; they differ in what happens to a THIRD one |
| 4322 | // added later. Written as an exclusion, any prefix appended to the |
| 4323 | // slice above would be exempted for an owner by default, fail-open |
| 4324 | // in a guard whose whole job is refusing. Written this way a new |
| 4325 | // prefix is refused until someone deliberately adds it here. |
| 4326 | // |
| 4327 | // The legacy unqualified prefix (ops only) is therefore outside the |
| 4328 | // exemption and still refuses whoever is asking: those objects are |
| 4329 | // owed an `accounts migrate -from legacy`, and a name handed out |
| 4330 | // over them puts an app in that migration's way. |
| 4331 | if ownerClaim && ownerExempt(prefix, ownPrefix) { |
| 4332 | adoptingExistingApp = true |
| 4333 | continue |
| 4334 | } |
| 4335 | return claimTaken, "", nil |
| 4336 | } |
| 4337 | region, source := s.resolveNewAppRegion(ctx, rq, adoptingExistingApp) |
| 4338 | if s.beforeClaimInsert != nil { |
| 4339 | s.beforeClaimInsert() |
| 4340 | } |
| 4341 | created, err := s.createClaimAppRow(ctx, acct, name, func(st *Store) (bool, error) { |
| 4342 | if temporary { |
| 4343 | return st.CreateTemporaryAppInRegion(acct.ID, name, addr, region, source, time.Now(), expiresAt) |
| 4344 | } |
| 4345 | return st.CreateAppInRegion(acct.ID, name, addr, region, source, time.Now()) |
| 4346 | }) |
| 4347 | if err != nil { |
| 4348 | return claimCreateFailed, "", nil |
| 4349 | } |
| 4350 | if !created { // lost a claim race after the checks |
| 4351 | return claimTaken, "", nil |
| 4352 | } |
| 4353 | // Spec §6.5: one line per creation, in every switch state (codex |
| 4354 | // plan-R6 1). Only reached once the row is ours. |
| 4355 | log.Printf("console: app region %s/%s: %s (%s)", acct.Sqid, name, s.homeRegionOut(region), source) |
| 4356 | if placement != "" { |
| 4357 | if err := s.store.SetAppPlacementContext(ctx, acct.ID, name, placement); err != nil { |
| 4358 | // Best-effort, like ensureAccountManifest below: the claim itself |
| 4359 | // already succeeded and must not be undone by this — see this |
| 4360 | // function's doc comment for why. `placed` stays "" so the |
| 4361 | // caller's echo tells the truth about it. |
| 4362 | log.Printf("console: claim %s/%s: set placement %s: %v", acct.Sqid, name, placement, err) |
| 4363 | } else { |
| 4364 | placed = placement |
| 4365 | } |
| 4366 | } |
| 4367 | // The app→account linkage needs no manifest of its own any more: it IS the |
| 4368 | // bucket key path (apps/<sqid>/<app>/…), written by the first ship. The |
| 4369 | // account's own manifest is what the agent materializes, and minting |
| 4370 | // already wrote it. |
| 4371 | s.ensureAccountManifest(ctx, acct) |
| 4372 | // Existing objects belong to an app whose console record is being |
| 4373 | // restored. Preserve its pipeline, including absence (the legacy ladder). |
| 4374 | // Only a new app gets the default edge pipeline (pipelines v2 spec §2). |
| 4375 | if !adoptingExistingApp { |
| 4376 | if _, err := release.SetPipeline(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, name, release.DefaultPipelineChannels(), addr); err != nil { |
| 4377 | log.Printf("console: claim %s/%s: set default pipeline: %v", acct.Sqid, name, err) |
| 4378 | } |
| 4379 | } |
| 4380 | return claimOK, placed, nil |
| 4381 | } |
| 4382 | |
| 4383 | // handleNewAppForm is the create-app page; the claim itself stays on |
| 4384 | // POST /a/{sqid}/apps/new (handleNewApp). |
| 4385 | func (s *Server) handleNewAppForm(w http.ResponseWriter, r *http.Request) { |
| 4386 | addr, raw, acct, role, ok := s.account(w, r) |
| 4387 | if !ok { |
| 4388 | return |
| 4389 | } |
| 4390 | p := s.accountPage(w, r, addr, raw, acct, role) |
| 4391 | p.Title = "Create an app" |
| 4392 | p.ActiveNav = "apps" |
| 4393 | p.RegionPicker = s.newAppPicker(r.Context(), s.personIP(r)) |
| 4394 | s.render(w, http.StatusOK, "app_new", p) |
| 4395 | } |
| 4396 | |
| 4397 | // handleNewApp is the browser door onto claimApp: the account page's New app |
| 4398 | // form. Any member may claim a FREE name — creation grants nothing promotion |
| 4399 | // doesn't already gate — and unlike the CLI door it carries no rate limit: a |
| 4400 | // session behind CSRF is not the stolen-credential shape the API budget |
| 4401 | // exists for, and this form is the escape hatch when that budget is honestly |
| 4402 | // spent. |
| 4403 | // |
| 4404 | // An OWNER may additionally claim a name whose objects already exist under |
| 4405 | // this account's own prefix (#79, claimApp's ownerClaim). A plain member |
| 4406 | // cannot: adopting existing release history is an ownership act. |
| 4407 | func (s *Server) handleNewApp(w http.ResponseWriter, r *http.Request) { |
| 4408 | addr, raw, acct, role, ok := s.account(w, r) |
| 4409 | if !ok { |
| 4410 | return |
| 4411 | } |
| 4412 | if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| 4413 | s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| 4414 | return |
| 4415 | } |
| 4416 | home := "/a/" + url.PathEscape(acct.Sqid) + "/" |
| 4417 | formPage := home + "apps/new" |
| 4418 | fail := func(msg string) { |
| 4419 | s.setFlash(w, "err", msg) |
| 4420 | http.Redirect(w, r, formPage, http.StatusSeeOther) |
| 4421 | } |
| 4422 | name := normalizeAppName(r.FormValue("name")) |
| 4423 | // The region is resolved inside claimApp, once it knows the claim is not |
| 4424 | // an adoption; the timezone and the address only ever feed that guess. |
| 4425 | rq, regionOK := s.pickedRegion(r.Context(), r) |
| 4426 | if !regionOK { |
| 4427 | fail(regionCopy.Unavailable) |
| 4428 | return |
| 4429 | } |
| 4430 | // The browser door never places at create time, so the second return |
| 4431 | // (what actually stuck) is always "" here and there is nothing to echo |
| 4432 | // it to — this form has no placement concept at all. |
| 4433 | defer s.planLock(acct)() |
| 4434 | if err := s.checkCreateApp(r.Context(), acct, name); err != nil { |
| 4435 | var planCap *planCapError |
| 4436 | if errors.As(err, &planCap) { |
| 4437 | fail(planCap.Browser()) |
| 4438 | return |
| 4439 | } |
| 4440 | log.Printf("console: create %s/%s: plan cap: %v", acct.Sqid, name, err) |
| 4441 | fail("Could not create the app. Please try again.") |
| 4442 | return |
| 4443 | } |
| 4444 | outcome, _, held := s.claimApp(r.Context(), acct, name, addr, "", role == RoleOwner, rq) |
| 4445 | switch outcome { |
| 4446 | case claimInvalid: |
| 4447 | fail("Invalid app name — lowercase letters, digits, and dashes, starting with a letter.") |
| 4448 | case claimReserved: |
| 4449 | fail("That name is reserved.") |
| 4450 | case claimTemporaryReserved: |
| 4451 | fail("Names starting with tmp- are kept for temporary sites. Choose another name.") |
| 4452 | case claimTaken: |
| 4453 | fail("That name is taken.") |
| 4454 | case claimApexHeld: |
| 4455 | fail("That name is not free: " + apexHeldSentence(s.apexHost(acct.Sqid, name), held) + ".") |
| 4456 | case claimCheckFailed: |
| 4457 | fail("Could not check that name. Please try again.") |
| 4458 | case claimStoreFailed: |
| 4459 | fail("Could not check the release store. Please try again.") |
| 4460 | case claimCreateFailed: |
| 4461 | fail("Could not create the app. Please try again.") |
| 4462 | case claimOK: |
| 4463 | detail := "created app " + name |
| 4464 | if s.appRegionsOn() { |
| 4465 | region, _, _ := s.store.AppRegion(acct.ID, name) |
| 4466 | files, _ := s.liveEdges(r.Context()) |
| 4467 | detail = fmt.Sprintf(regionCopy.AuditCreated, name, s.placeOf(region, s.regionCands(files))) |
| 4468 | } |
| 4469 | s.audit(acct, name, addr, "create", detail) |
| 4470 | s.setFlash(w, "ok", "Created app "+name+" — it's yours.") |
| 4471 | http.Redirect(w, r, home+"apps/"+url.PathEscape(name), http.StatusSeeOther) |
| 4472 | } |
| 4473 | } |
| 4474 | |
| 4475 | // tagSuffix names what a stable promotion's tag ended up as, or why it |
| 4476 | // carries none (release versioning spec, 2026-08-08) — shared between the |
| 4477 | // fresh-promote and no-op flashes in handlePromote below it, because |
| 4478 | // PromoteOnLadder's stableTag runs on EVERY landing on stable, no-op |
| 4479 | // included: a repeat promotion can heal a release that ended up nameless |
| 4480 | // from a previous attempt's failed write, and the flash must say so exactly |
| 4481 | // as loudly as the promotion that cut the tag the first time would have. |
| 4482 | // "" off stable and for a version that carries no tag either way. |
| 4483 | func tagSuffix(res release.PromoteResult) string { |
| 4484 | suffix := "" |
| 4485 | if res.Tag != "" { |
| 4486 | suffix += " — tagged " + res.Tag |
| 4487 | } |
| 4488 | if res.TagWarning != "" { |
| 4489 | suffix += " (" + res.TagWarning + ")" |
| 4490 | } |
| 4491 | return suffix |
| 4492 | } |
| 4493 | |
| 4494 | // handlePromote points a channel at a shipped version. It is the app's ONE |
| 4495 | // promote door — the releases table posts to it, and so does every step of the |
| 4496 | // ladder section — which is what makes the ladder and the passkey gate below |
| 4497 | // worth anything: a hand-written POST meets exactly the doors a button does. |
| 4498 | func (s *Server) handlePromote(w http.ResponseWriter, r *http.Request) { |
| 4499 | addr, raw, acct, role, app, ok := s.accountApp(w, r) |
| 4500 | if !ok { |
| 4501 | return |
| 4502 | } |
| 4503 | if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| 4504 | s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| 4505 | return |
| 4506 | } |
| 4507 | if s.cfg.Key == nil { |
| 4508 | s.renderMessage(w, r, http.StatusServiceUnavailable, "Promotions disabled", |
| 4509 | "No signing key on this host — the console is running read-only.") |
| 4510 | return |
| 4511 | } |
| 4512 | back := func(kind, msg string) { |
| 4513 | s.setFlash(w, kind, msg) |
| 4514 | dest := "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) |
| 4515 | if tab := releaseBackTab(r); tab != "" { |
| 4516 | dest += "/" + tab |
| 4517 | } |
| 4518 | http.Redirect(w, r, dest, http.StatusSeeOther) |
| 4519 | } |
| 4520 | channel := strings.TrimSpace(r.FormValue("new_channel")) |
| 4521 | if channel == "" { |
| 4522 | channel = strings.TrimSpace(r.FormValue("channel")) |
| 4523 | } |
| 4524 | if !release.ValidChannel(channel) { |
| 4525 | back("err", "Invalid channel name.") |
| 4526 | return |
| 4527 | } |
| 4528 | version := strings.TrimSpace(r.FormValue("version")) |
| 4529 | ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) |
| 4530 | defer cancel() |
| 4531 | if !release.ValidVersion(version) { |
| 4532 | back("err", "Invalid version.") |
| 4533 | return |
| 4534 | } |
| 4535 | if _, err := release.GetManifest(ctx, s.cfg.Releases, acct.Sqid, app, version); err != nil { |
| 4536 | back("err", "Version "+version+" is not a shipped release of "+app+".") |
| 4537 | return |
| 4538 | } |
| 4539 | // Which gate this promotion meets is the pipeline's answer (release |
| 4540 | // pipelines v2 §5), so the pipeline is read before any of them. An |
| 4541 | // unreadable one refuses: "not safely nil" must never fall back to the |
| 4542 | // legacy ladder under the app that opted out of it. |
| 4543 | pipe, perr := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app) |
| 4544 | if perr != nil { |
| 4545 | log.Printf("console: promote %s/%s: read pipeline: %v", acct.Sqid, app, perr) |
| 4546 | back("err", "Could not read the pipeline for "+app+". Nothing has been changed.") |
| 4547 | return |
| 4548 | } |
| 4549 | // The stable rung's owner gate fires BEFORE the confirm card: a member |
| 4550 | // probing an owner-only promote must meet the byte-identical 404, never a |
| 4551 | // card that concedes the action would otherwise proceed. promoteGate |
| 4552 | // re-checks this for owners on the confirmed POST; the duplication is the |
| 4553 | // point — the confirm step must sit strictly between owner 404 and |
| 4554 | // requireFreshAuth without weakening either. |
| 4555 | // |
| 4556 | // Legacy path only. A PIPELINED app's channel named "stable" is an |
| 4557 | // app-defined channel that happens to share a rung's name, and its gates |
| 4558 | // are the ones its pipeline declares (spec §7: the unconditional |
| 4559 | // stable-passkey becomes legacy-path-only) — reading the rung table for |
| 4560 | // it would impose an owner rule the account never asked for and cannot |
| 4561 | // remove. |
| 4562 | if rung, _ := release.Rung(channel); pipe == nil && rung == release.RungStable && role != RoleOwner { |
| 4563 | http.NotFound(w, r) |
| 4564 | return |
| 4565 | } |
| 4566 | if !confirmed(r) { |
| 4567 | s.confirmPromote(w, r, addr, raw, acct, role, app, channel, version, pipe) |
| 4568 | return |
| 4569 | } |
| 4570 | // The channel's own gate, LAST — after membership, after the app check, |
| 4571 | // after CSRF, and after every refusal that costs nothing. Order is |
| 4572 | // load-bearing: a non-member must meet the byte-identical 404 before |
| 4573 | // step-up is ever mentioned, or the redirect into Keymail would confirm |
| 4574 | // what the 404 is there to hide. |
| 4575 | if !s.promoteGate(w, r, addr, raw, role, channel, pipe) { |
| 4576 | return |
| 4577 | } |
| 4578 | // An approvals rule turns the promotion into a proposal — but only now, |
| 4579 | // with the proposer's presence just proved by promoteGate's step-up, |
| 4580 | // which is what earns their own confirmation its place in the quorum |
| 4581 | // (spec §4). Order is checked first, so a proposal that could never |
| 4582 | // execute never goes out for signatures. |
| 4583 | // |
| 4584 | // `pipe != nil` is already implied by a nonzero rule — channelRules |
| 4585 | // answers the zero ChannelRules for a nil pipeline — and is written out |
| 4586 | // anyway, because VersionStage below DEREFERENCES the pipeline it is |
| 4587 | // handed and would panic on nil. |
| 4588 | if rules := channelRules(pipe, channel); pipe != nil && rules.PromoteApprovals >= 1 { |
| 4589 | stage, serr := release.VersionStage(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, version, pipe) |
| 4590 | if serr != nil { |
| 4591 | log.Printf("console: promote %s/%s: stage: %v", acct.Sqid, app, serr) |
| 4592 | back("err", "Could not work out where "+version+" sits in the pipeline. Nothing has been changed.") |
| 4593 | return |
| 4594 | } |
| 4595 | if aerr := release.PipelinePromotionAllowed(pipe, stage, channel); aerr != nil { |
| 4596 | back("err", "Promote refused: "+aerr.Error()) |
| 4597 | return |
| 4598 | } |
| 4599 | // Pin the target channel's head at propose time (spec §4: a head |
| 4600 | // that moved underneath a proposal invalidates it); "" when the |
| 4601 | // channel has no pointer yet. An unreadable head refuses — a pin |
| 4602 | // that cannot be read cannot be recorded. |
| 4603 | head, herr := s.channelHead(ctx, acct, app, channel) |
| 4604 | if herr != nil { |
| 4605 | log.Printf("console: promote %s/%s: read channel %s: %v", acct.Sqid, app, channel, herr) |
| 4606 | back("err", "Could not read channel "+channel+". Nothing has been changed.") |
| 4607 | return |
| 4608 | } |
| 4609 | kind, msg := s.proposeThenExecute(ctx, acct, app, kindPromote, |
| 4610 | promotePayload{Version: version, Channel: channel, ObservedHead: head}, rules.PromoteApprovals, addr) |
| 4611 | back(kind, msg) |
| 4612 | return |
| 4613 | } |
| 4614 | // PromoteOnLadder, not Promote: the ladder is enforced here, not merely |
| 4615 | // drawn. It also records the promotion on the pointer it moves — what |
| 4616 | // moved, from which rung to which, and which member said so. |
| 4617 | res, err := release.PromoteOnLadder(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, channel, version, |
| 4618 | release.PromoteOptions{Actor: addr}) |
| 4619 | if err != nil { |
| 4620 | log.Printf("console: promote %s %s -> %s: %v", app, version, channel, err) |
| 4621 | // The ladder's own refusals are the useful text here — they name the |
| 4622 | // rung this build is on and the one it would have to come through. |
| 4623 | back("err", "Promote refused: "+err.Error()) |
| 4624 | return |
| 4625 | } |
| 4626 | if res.NoOp { |
| 4627 | back("ok", channel+" already serves "+version+tagSuffix(res)+" — nothing to do.") |
| 4628 | return |
| 4629 | } |
| 4630 | s.auditActivity(acct, app, addr, Activity{Action: "promote", TargetType: "channel", Target: channel, Detail: "promoted " + version + " to " + channel}) |
| 4631 | msg := "Promoted " + version + " to " + channel + tagSuffix(res) + "." |
| 4632 | // Whether this app bakes at all is the production flag's business (bake |
| 4633 | // controls spec §2): a non-production app's boxes adopt a held channel |
| 4634 | // immediately, so the window note below would be a lie for them. Read the |
| 4635 | // flag before the checkbox, which may still turn it on in this same |
| 4636 | // request. |
| 4637 | flag, ferr := release.GetProduction(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app) |
| 4638 | if ferr != nil { |
| 4639 | log.Printf("console: production flag %s: %v", app, ferr) |
| 4640 | } |
| 4641 | // An unreadable flag fails TOWARD baking, exactly as the adopt path does |
| 4642 | // (spec §4) — so the note is still the truth about what the boxes will do. |
| 4643 | baking := ferr != nil || (flag != nil && flag.Production) |
| 4644 | // The spec §7 prompt: promote-to-stable asked "is this app in production |
| 4645 | // now?" and the human said yes — in the same passkey-confirmed action. |
| 4646 | // Turning ON is any member's anyway, so no extra gate; flips only when |
| 4647 | // the flag is actually off, so a stray field on a repeat is a no-op. |
| 4648 | // Both failure paths SAY so. The human ticked a box in a passkey-confirmed |
| 4649 | // action; a log line they will never read is not an answer, and a silent |
| 4650 | // skip leaves them believing the app bakes when it does not. |
| 4651 | if r.FormValue("enable_bake") == "1" { |
| 4652 | switch { |
| 4653 | case ferr != nil: |
| 4654 | // The read failed, so the flip was never attempted: writing over a |
| 4655 | // flag nobody could read is the repair path's job (the app page's |
| 4656 | // re-declare control), not a promote's side effect. baking is true |
| 4657 | // here only because an unreadable flag fails TOWARD baking — which |
| 4658 | // settles nothing about what this app has declared. |
| 4659 | msg += " The production flag could not be read, so the safety delay was not enabled — use the toggle on the app's Versions tab." |
| 4660 | case baking: |
| 4661 | // Already on: a stray checkbox on a repeat is a no-op, as before. |
| 4662 | default: |
| 4663 | if tmsg, _ := s.temporaryRefusal(acct, app, "turn its safety delay on"); tmsg != "" { |
| 4664 | msg += " " + tmsg |
| 4665 | break |
| 4666 | } |
| 4667 | if err := s.claimAppMutation(ctx, acct, app, "protection", func(ctx context.Context) error { |
| 4668 | return release.SetProduction(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, true, addr) |
| 4669 | }); err != nil { |
| 4670 | log.Printf("console: enable_bake %s: %v", app, err) |
| 4671 | msg += " Turning the safety delay on failed — use the toggle on the app's Versions tab." |
| 4672 | } else { |
| 4673 | s.audit(acct, app, addr, "production", "enabled the safety delay (production: on, at stable promote)") |
| 4674 | msg += " Safety delay enabled." |
| 4675 | baking = true |
| 4676 | } |
| 4677 | } |
| 4678 | } |
| 4679 | // One announcement (carlos-deploy §3), placed after the optional flag flip |
| 4680 | // rather than beside the promote's own audit row above, so it covers BOTH |
| 4681 | // of this handler's box-consumed writes — the pointer and, when the |
| 4682 | // checkbox was ticked, the production flag — in a single bump. |
| 4683 | s.bumpSerial(ctx) |
| 4684 | // Whether the boxes will actually hold this, said the same way the CLI |
| 4685 | // door's `held` field says it (channelHeld, apicli.go): the window comes |
| 4686 | // from the pipeline when there is one, and a PIPELINED app never consults |
| 4687 | // the production flag at all — its bake policy is the pipeline's alone |
| 4688 | // (spec §3), which is exactly what adopt.go does. |
| 4689 | if holdWindow(pipe, channel) > 0 && (pipe != nil || baking) { |
| 4690 | msg += " It goes live everywhere after the safety delay — rollbacks skip it." |
| 4691 | } |
| 4692 | back("ok", msg) |
| 4693 | } |
| 4694 | |
| 4695 | // promoteGate applies the gate the TARGET CHANNEL carries. Which table it |
| 4696 | // reads is the pipeline's answer, and the two are deliberately exclusive: |
| 4697 | // |
| 4698 | // pipe == nil — the legacy ladder (pipelines spec §3's gate table). Below |
| 4699 | // stable, membership — which the caller has already proved — is the gate, |
| 4700 | // and this waves the promotion through. Stable is different in kind: it |
| 4701 | // puts a build in front of every user of the app, so it takes an account |
| 4702 | // OWNER, and a live session is not sufficient proof that the owner is the |
| 4703 | // one asking. |
| 4704 | // pipe != nil — the channel's own declared rules (release pipelines v2 §2). |
| 4705 | // A passkey rule, or an approvals rule (whose every confirmation is |
| 4706 | // passkey-backed, the proposer's included), demands the step-up; a channel |
| 4707 | // with neither — and every channel outside the pipeline, canary/* included |
| 4708 | // — is waved through on membership. There is no owner rule on this path: |
| 4709 | // the pipeline says what a channel costs, and ownership is not one of the |
| 4710 | // things it can say (spec §2). That places the weight on WHO MAY WRITE A |
| 4711 | // PIPELINE — any member of the account, or an admin-role service |
| 4712 | // credential (handleAPIPipelinePut) — so an account that wants a channel |
| 4713 | // only its owner can promote onto expresses that as a passkey plus |
| 4714 | // approvals, not by naming the channel after a rung. |
| 4715 | // |
| 4716 | // The ORDER is the same on both paths and is the load-bearing part: the |
| 4717 | // byte-identical 404 first, requireFreshAuth strictly last. It returns false |
| 4718 | // when it has already written the response — either the 404 or the step-up |
| 4719 | // redirect — and the caller must simply return. |
| 4720 | func (s *Server) promoteGate(w http.ResponseWriter, r *http.Request, addr, raw, role, channel string, pipe *release.Pipeline) bool { |
| 4721 | if pipe != nil { |
| 4722 | rules := channelRules(pipe, channel) |
| 4723 | if !rules.Passkey && rules.PromoteApprovals < 1 { |
| 4724 | return true |
| 4725 | } |
| 4726 | return s.requireFreshAuth(w, r, addr, raw) |
| 4727 | } |
| 4728 | if rung, _ := release.Rung(channel); rung != release.RungStable { |
| 4729 | return true |
| 4730 | } |
| 4731 | if role != RoleOwner { |
| 4732 | http.NotFound(w, r) |
| 4733 | return false |
| 4734 | } |
| 4735 | return s.requireFreshAuth(w, r, addr, raw) |
| 4736 | } |
| 4737 | |
| 4738 | // handleRollback swaps a channel back to its previous version. On a pipelined |
| 4739 | // channel it meets that channel's own rules first (see the gate below the |
| 4740 | // confirm card): the same passkey and the same quorum a promotion onto it |
| 4741 | // would meet, because it is the same pointer and the same blast radius. |
| 4742 | func (s *Server) handleRollback(w http.ResponseWriter, r *http.Request) { |
| 4743 | addr, raw, acct, role, app, ok := s.accountApp(w, r) |
| 4744 | if !ok { |
| 4745 | return |
| 4746 | } |
| 4747 | if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| 4748 | s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| 4749 | return |
| 4750 | } |
| 4751 | if s.cfg.Key == nil { |
| 4752 | s.renderMessage(w, r, http.StatusServiceUnavailable, "Promotions disabled", |
| 4753 | "No signing key on this host — the console is running read-only.") |
| 4754 | return |
| 4755 | } |
| 4756 | back := func(kind, msg string) { |
| 4757 | s.setFlash(w, kind, msg) |
| 4758 | dest := "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) |
| 4759 | if tab := releaseBackTab(r); tab != "" { |
| 4760 | dest += "/" + tab |
| 4761 | } |
| 4762 | http.Redirect(w, r, dest, http.StatusSeeOther) |
| 4763 | } |
| 4764 | channel := strings.TrimSpace(r.FormValue("channel")) |
| 4765 | if !release.ValidChannel(channel) { |
| 4766 | back("err", "Invalid channel name.") |
| 4767 | return |
| 4768 | } |
| 4769 | ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) |
| 4770 | defer cancel() |
| 4771 | // Which gate this rollback meets is the pipeline's answer, so it is read |
| 4772 | // before the confirm card — the same reasoning, and the same order, as |
| 4773 | // handlePromote. |
| 4774 | pipe, perr := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app) |
| 4775 | if perr != nil { |
| 4776 | log.Printf("console: rollback %s/%s: read pipeline: %v", acct.Sqid, app, perr) |
| 4777 | back("err", "Could not read the pipeline for "+app+". Nothing has been changed.") |
| 4778 | return |
| 4779 | } |
| 4780 | // The stable rung's owner gate fires BEFORE the confirm card (Task 9 |
| 4781 | // review, IMPORTANT 1 — mirroring handlePromote's own comment on this |
| 4782 | // exact point): a non-owner member probing a legacy stable rollback must |
| 4783 | // meet the byte-identical 404, never a card that concedes the action |
| 4784 | // would otherwise proceed. Legacy path only — a PIPELINED app's channel |
| 4785 | // named "stable" is governed by its own declared rules below, not this |
| 4786 | // rung table (pre-existing gap fix, Task 8/9 review). |
| 4787 | if rung, _ := release.Rung(channel); pipe == nil && rung == release.RungStable && role != RoleOwner { |
| 4788 | http.NotFound(w, r) |
| 4789 | return |
| 4790 | } |
| 4791 | cur, err := release.Channel(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, channel) |
| 4792 | if err != nil { |
| 4793 | back("err", "Could not read channel "+channel+".") |
| 4794 | return |
| 4795 | } |
| 4796 | if len(cur.History) == 0 { |
| 4797 | back("err", "Channel "+channel+" has no previous version to roll back to.") |
| 4798 | return |
| 4799 | } |
| 4800 | target := cur.History[0] |
| 4801 | if !confirmed(r) { |
| 4802 | s.confirmRollback(w, r, addr, raw, acct, role, app, channel, cur.Version, target) |
| 4803 | return |
| 4804 | } |
| 4805 | // The card pinned where it promised to go; a channel whose history moved |
| 4806 | // since then must be looked at again, not rolled to wherever it points |
| 4807 | // now. Checked only when the pin is present: a pinless POST (the CLI |
| 4808 | // shape) rolls back the current head exactly as it always has. |
| 4809 | if pinned := strings.TrimSpace(r.FormValue("target")); pinned != "" && pinned != target { |
| 4810 | back("err", "Channel "+channel+" changed while you were confirming — review the rollback again.") |
| 4811 | return |
| 4812 | } |
| 4813 | // A pipelined channel's own rules gate a rollback exactly as they gate a |
| 4814 | // promotion (release pipelines v2 §2): a rollback is a pointer write, and |
| 4815 | // the build it puts in front of every user of the app is one the |
| 4816 | // channel's rules exist to govern. requireFreshAuth stays LAST, after the |
| 4817 | // confirm card and the pin — and a channel with no rules, or an app with |
| 4818 | // no pipeline, behaves exactly as it did. |
| 4819 | if rules := channelRules(pipe, channel); rules.Passkey || rules.PromoteApprovals >= 1 { |
| 4820 | if !s.requireFreshAuth(w, r, addr, raw) { |
| 4821 | return |
| 4822 | } |
| 4823 | if rules.PromoteApprovals >= 1 { |
| 4824 | // Pinned to the version this rollback lands on, and flagged |
| 4825 | // hotfix: a rollback is out-of-order by nature, so the order |
| 4826 | // re-check at execution is the one check it must not meet. The |
| 4827 | // pointer ends up serving the pinned build either way; what |
| 4828 | // differs from release.Rollback is the shape of the trail it |
| 4829 | // leaves — a promotion of the older build, confirmed by a |
| 4830 | // quorum, rather than a history pop. |
| 4831 | kind, msg := s.proposeThenExecute(ctx, acct, app, kindPromote, |
| 4832 | promotePayload{Version: target, Channel: channel, Hotfix: true, Rollback: true, |
| 4833 | ObservedHead: cur.Version}, |
| 4834 | rules.PromoteApprovals, addr) |
| 4835 | back(kind, msg) |
| 4836 | return |
| 4837 | } |
| 4838 | } else if pipe == nil { |
| 4839 | // Pre-existing gap (Task 8 review): the legacy ladder's stable rung |
| 4840 | // carried NO gate at all on this door, while promoteGate takes |
| 4841 | // owner+passkey for a PROMOTION onto it and the API door 403s a |
| 4842 | // rollback of it outright — a rollback lands the same pointer write |
| 4843 | // in front of the same users. Mirror promoteGate's legacy-path shape |
| 4844 | // exactly: byte-identical 404 for a non-owner (already refused |
| 4845 | // above, before the confirm card), requireFreshAuth last here — the |
| 4846 | // re-check below is deliberate duplication of the owner condition |
| 4847 | // (handlePromote's own comment: the confirm step must sit strictly |
| 4848 | // between owner 404 and requireFreshAuth without weakening either), |
| 4849 | // not a second gate. Every other legacy rung is unchanged — |
| 4850 | // membership alone, as before. |
| 4851 | if rung, _ := release.Rung(channel); rung == release.RungStable { |
| 4852 | if !s.requireFreshAuth(w, r, addr, raw) { |
| 4853 | return |
| 4854 | } |
| 4855 | } |
| 4856 | } |
| 4857 | if err := release.Rollback(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, channel); err != nil { |
| 4858 | log.Printf("console: rollback %s %s: %v", app, channel, err) |
| 4859 | back("err", "Rollback failed: "+err.Error()) |
| 4860 | return |
| 4861 | } |
| 4862 | s.auditActivity(acct, app, addr, Activity{Action: "rollback", TargetType: "channel", Target: channel, Detail: "rolled back " + channel + " to " + target}) |
| 4863 | s.bumpSerial(ctx) |
| 4864 | back("ok", "Rolled back "+channel+" to "+target+".") |
| 4865 | } |
| 4866 | |
| 4867 | // handleProduction flips the app's production flag (2026-08-04 console bake |
| 4868 | // controls spec §7). ON is any member's to do, instantly — the safe |
| 4869 | // direction stays cheap. OFF removes a supply-chain guard from every |
| 4870 | // subscriber of the app, so it is the owner's, behind the passkey, and the |
| 4871 | // fleet ratchets it in on each box's own clock. |
| 4872 | // |
| 4873 | // Gate order is the mandated one, and the two owner-side gates apply to the |
| 4874 | // OFF direction ONLY: session/membership (accountApp) → CSRF → signing-key |
| 4875 | // availability → value parse → for OFF: owner (byte-identical 404) → |
| 4876 | // requireFreshAuth LAST. The refusals that cost nothing and reveal nothing |
| 4877 | // come first; the 404 comes before any step-up redirect, which would |
| 4878 | // otherwise confirm to a member-probing-as-stranger that the account exists. |
| 4879 | func (s *Server) handleProduction(w http.ResponseWriter, r *http.Request) { |
| 4880 | addr, raw, acct, role, app, ok := s.accountApp(w, r) |
| 4881 | if !ok { |
| 4882 | return |
| 4883 | } |
| 4884 | if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| 4885 | s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| 4886 | return |
| 4887 | } |
| 4888 | if s.cfg.Key == nil { |
| 4889 | s.renderMessage(w, r, http.StatusServiceUnavailable, "Promotions disabled", |
| 4890 | "No signing key on this host — the console is running read-only.") |
| 4891 | return |
| 4892 | } |
| 4893 | back := func(kind, msg string) { |
| 4894 | s.setFlash(w, kind, msg) |
| 4895 | http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app), http.StatusSeeOther) |
| 4896 | } |
| 4897 | var on bool |
| 4898 | switch r.FormValue("production") { |
| 4899 | case "on": |
| 4900 | on = true |
| 4901 | case "off": |
| 4902 | on = false |
| 4903 | default: |
| 4904 | back("err", "Invalid production value.") |
| 4905 | return |
| 4906 | } |
| 4907 | if on { |
| 4908 | defer s.lockApp(acct.Sqid, app)() |
| 4909 | if msg, _ := s.temporaryRefusal(acct, app, "mark it as production"); msg != "" { |
| 4910 | back("err", msg) |
| 4911 | return |
| 4912 | } |
| 4913 | } |
| 4914 | if !on { |
| 4915 | if role != RoleOwner { |
| 4916 | http.NotFound(w, r) |
| 4917 | return |
| 4918 | } |
| 4919 | if !s.requireFreshAuth(w, r, addr, raw) { |
| 4920 | return |
| 4921 | } |
| 4922 | } |
| 4923 | ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) |
| 4924 | defer cancel() |
| 4925 | if err := s.claimAppMutation(ctx, acct, app, "protection", func(ctx context.Context) error { |
| 4926 | return release.SetProduction(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, on, addr) |
| 4927 | }); err != nil { |
| 4928 | log.Printf("console: production %s -> %v: %v", app, on, err) |
| 4929 | back("err", "Could not change the safety delay: "+err.Error()) |
| 4930 | return |
| 4931 | } |
| 4932 | // The flag is read on every box's adopt path (it decides whether a channel |
| 4933 | // bakes at all), so both directions are box-consumed. OFF especially: it |
| 4934 | // releases held versions, and that is the direction people wait on. |
| 4935 | s.bumpSerial(ctx) |
| 4936 | if on { |
| 4937 | s.audit(acct, app, addr, "production", "enabled the safety delay (production: on)") |
| 4938 | back("ok", "Safety delay enabled — new versions now wait on beta and stable before going live.") |
| 4939 | } else { |
| 4940 | s.audit(acct, app, addr, "production", "disabled the safety delay (production: off)") |
| 4941 | back("ok", "Safety delay turning off — takes effect after ~72h.") |
| 4942 | } |
| 4943 | } |
| 4944 | |
| 4945 | // handleFastTrack waives the bake window for one channel's CURRENT head — |
| 4946 | // owner + passkey, always: this is the loud instant path past the window |
| 4947 | // (spec §5, §9), and the pointer wears the badge forever. |
| 4948 | func (s *Server) handleFastTrack(w http.ResponseWriter, r *http.Request) { |
| 4949 | addr, raw, acct, role, app, ok := s.accountApp(w, r) |
| 4950 | if !ok { |
| 4951 | return |
| 4952 | } |
| 4953 | if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| 4954 | s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| 4955 | return |
| 4956 | } |
| 4957 | if s.cfg.Key == nil { |
| 4958 | s.renderMessage(w, r, http.StatusServiceUnavailable, "Promotions disabled", |
| 4959 | "No signing key on this host — the console is running read-only.") |
| 4960 | return |
| 4961 | } |
| 4962 | back := func(kind, msg string) { |
| 4963 | s.setFlash(w, kind, msg) |
| 4964 | http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app), http.StatusSeeOther) |
| 4965 | } |
| 4966 | channel := strings.TrimSpace(r.FormValue("channel")) |
| 4967 | if !release.ValidChannel(channel) { |
| 4968 | back("err", "Invalid channel name.") |
| 4969 | return |
| 4970 | } |
| 4971 | if role != RoleOwner { |
| 4972 | http.NotFound(w, r) |
| 4973 | return |
| 4974 | } |
| 4975 | // The confirm card sits strictly between the owner 404 and the passkey: |
| 4976 | // only owners ever see it, and the confirmed replay still meets |
| 4977 | // requireFreshAuth below. |
| 4978 | if !confirmed(r) { |
| 4979 | s.confirmFastTrack(w, r, addr, raw, acct, role, app, channel) |
| 4980 | return |
| 4981 | } |
| 4982 | if !s.requireFreshAuth(w, r, addr, raw) { |
| 4983 | return |
| 4984 | } |
| 4985 | // A channel with no bake window has nothing to waive, and the waiver is |
| 4986 | // not inert on it: FastTrack re-signs the pointer, which re-stamps |
| 4987 | // PromotedAt — and PromotedAt is what CanaryExpired reads, so waiving a |
| 4988 | // canary would silently renew its TTL lease and badge a channel that |
| 4989 | // never bakes as "fast-tracked past the bake window", forever. |
| 4990 | // |
| 4991 | // Placed HERE, after both owner gates, and not with the cheap refusals |
| 4992 | // above: the window is pure vocabulary and leaks nothing on its own, but a |
| 4993 | // refusal that fires before the owner check would make this endpoint's |
| 4994 | // response vary with the channel name — and now with the app's PIPELINE — |
| 4995 | // for a NON-OWNER, who must see the byte-identical 404 and nothing else. |
| 4996 | // The mandated order (session → CSRF → owner 404 → requireFreshAuth LAST) |
| 4997 | // then leaves only this spot. The cost is a passkey tap spent on a |
| 4998 | // refusal; the alternative is an oracle. |
| 4999 | ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) |
| 5000 | defer cancel() |
| 5001 | // holdWindow, not HoldFor: a pipelined channel's window is its own, for a |
| 5002 | // name the legacy table may never have heard of — and an app that |
| 5003 | // declared a 0s bake on a channel has nothing to waive there either. |
| 5004 | // An unreadable pipeline refuses rather than falling back to the legacy |
| 5005 | // table, which could offer a waiver on a channel the pipeline gives no |
| 5006 | // window at all. |
| 5007 | pipe, perr := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app) |
| 5008 | if perr != nil { |
| 5009 | log.Printf("console: fast-track %s/%s: read pipeline: %v", acct.Sqid, app, perr) |
| 5010 | back("err", "Could not read the pipeline for "+app+". Nothing has been changed.") |
| 5011 | return |
| 5012 | } |
| 5013 | if holdWindow(pipe, channel) == 0 { |
| 5014 | back("err", "Channel "+channel+" has no safety delay running — there is nothing to skip.") |
| 5015 | return |
| 5016 | } |
| 5017 | // The head is read here rather than only when a pin is present, because a |
| 5018 | // proposal has to PIN the build it waives: what gets confirmed must be a |
| 5019 | // specific version, not "whatever this channel carries whenever somebody |
| 5020 | // gets round to it". |
| 5021 | cur, err := release.Channel(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, channel) |
| 5022 | if err != nil { |
| 5023 | back("err", "Could not read channel "+channel+".") |
| 5024 | return |
| 5025 | } |
| 5026 | // The card pinned the version whose window it offered to waive; if the |
| 5027 | // channel has moved since, the waiver must not land on the newcomer. |
| 5028 | // Pin-present-only, like the rollback pin: a pinless POST waives the |
| 5029 | // current head, which is FastTrack's own contract. |
| 5030 | if pinned := strings.TrimSpace(r.FormValue("version")); pinned != "" && cur.Version != pinned { |
| 5031 | back("err", "Channel "+channel+" changed while you were confirming — review skipping the delay again.") |
| 5032 | return |
| 5033 | } |
| 5034 | // change_approvals covers overriding a channel's bake as well as editing |
| 5035 | // its entry (spec §2: "one field covers both changing and overriding"). |
| 5036 | // The step-up above already ran, so this proposal counts its proposer. |
| 5037 | if rules := channelRules(pipe, channel); rules.ChangeApprovals >= 1 { |
| 5038 | kind, msg := s.proposeThenExecute(ctx, acct, app, kindFastTrack, |
| 5039 | fastTrackPayload{Channel: channel, Version: cur.Version}, rules.ChangeApprovals, addr) |
| 5040 | back(kind, msg) |
| 5041 | return |
| 5042 | } |
| 5043 | ver, err := release.FastTrack(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, channel, addr) |
| 5044 | if err != nil { |
| 5045 | log.Printf("console: fast-track %s %s: %v", app, channel, err) |
| 5046 | back("err", "Skipping the delay failed: "+err.Error()) |
| 5047 | return |
| 5048 | } |
| 5049 | s.audit(acct, app, addr, "fast-track", "skipped the safety delay for "+ver+" on "+channel) |
| 5050 | // A waiver re-signs the pointer to say "adopt this now" — the one write |
| 5051 | // whose entire point is that boxes act on it immediately. |
| 5052 | s.bumpSerial(ctx) |
| 5053 | back("ok", "Skipped the delay for "+ver+" on "+channel+" — it rolls out within a couple of minutes.") |
| 5054 | } |
| 5055 | |
| 5056 | // ---- canaries --------------------------------------------------------------- |
| 5057 | |
| 5058 | // canaryNameRe pins canary names to the same boring slugs as app names — the |
| 5059 | // name becomes a channel path segment and an on-box state file name. |
| 5060 | var canaryNameRe = regexp.MustCompile(`^[a-z][a-z0-9-]{0,31}$`) |
| 5061 | |
| 5062 | // channelHosts lists the registry hosts whose routes follow app's channel, |
| 5063 | // and reports whether the registry could be read at all. The two call sites |
| 5064 | // deliberately treat that error differently: handleNewCanary's use is |
| 5065 | // cosmetic (which URL to show in a success flash) and downgrades an error to |
| 5066 | // "no hosts", but handleRetireCanary's use is load-bearing — a canary with |
| 5067 | // followers must never be deleted — so it must fail CLOSED on a registry |
| 5068 | // hiccup rather than silently reading the error as "safe to retire". |
| 5069 | func (s *Server) channelHosts(app, channel string) ([]string, error) { |
| 5070 | reg, err := openRegistry(s.cfg.Registry) |
| 5071 | if err != nil { |
| 5072 | return nil, err |
| 5073 | } |
| 5074 | routes, err := reg.List() |
| 5075 | if err != nil { |
| 5076 | return nil, err |
| 5077 | } |
| 5078 | var out []string |
| 5079 | for _, rt := range routes { |
| 5080 | if rt.App == app && rt.Channel == channel { |
| 5081 | out = append(out, rt.Host) |
| 5082 | } |
| 5083 | } |
| 5084 | return out, nil |
| 5085 | } |
| 5086 | |
| 5087 | // handleNewCanary creates a canary: a brand-new canary/<name> channel |
| 5088 | // pointed at a chosen shipped version. Canary channels bypass bake by |
| 5089 | // design, so the canary serves the moment a host follows its channel. |
| 5090 | func (s *Server) handleNewCanary(w http.ResponseWriter, r *http.Request) { |
| 5091 | addr, raw, acct, _, app, ok := s.accountApp(w, r) |
| 5092 | if !ok { |
| 5093 | return |
| 5094 | } |
| 5095 | if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| 5096 | s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| 5097 | return |
| 5098 | } |
| 5099 | if s.cfg.Key == nil { |
| 5100 | s.renderMessage(w, r, http.StatusServiceUnavailable, "Promotions disabled", |
| 5101 | "No signing key on this host — the console is running read-only.") |
| 5102 | return |
| 5103 | } |
| 5104 | back := func(kind, msg string) { |
| 5105 | s.setFlash(w, kind, msg) |
| 5106 | dest := "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) |
| 5107 | if tab := releaseBackTab(r); tab != "" { |
| 5108 | dest += "/" + tab |
| 5109 | } |
| 5110 | http.Redirect(w, r, dest, http.StatusSeeOther) |
| 5111 | } |
| 5112 | name := strings.ToLower(strings.TrimSpace(r.FormValue("name"))) |
| 5113 | if !canaryNameRe.MatchString(name) { |
| 5114 | back("err", "Invalid canary name — lowercase letters, digits, and dashes, starting with a letter.") |
| 5115 | return |
| 5116 | } |
| 5117 | channel := "canary/" + name |
| 5118 | version := strings.TrimSpace(r.FormValue("version")) |
| 5119 | if !release.ValidVersion(version) { |
| 5120 | back("err", "Invalid version.") |
| 5121 | return |
| 5122 | } |
| 5123 | ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) |
| 5124 | defer cancel() |
| 5125 | if _, err := release.GetManifest(ctx, s.cfg.Releases, acct.Sqid, app, version); err != nil { |
| 5126 | back("err", "Version "+version+" is not a shipped release of "+app+".") |
| 5127 | return |
| 5128 | } |
| 5129 | // New means new: an existing canary is promoted to (releases table) or |
| 5130 | // retired, never quietly re-created. |
| 5131 | if _, _, err := s.cfg.Releases.Get(ctx, release.ChannelKey(acct.Sqid, app, channel)); err == nil { |
| 5132 | back("err", "Canary "+name+" already exists — promote to it from the releases table, or retire it first.") |
| 5133 | return |
| 5134 | } else if !errors.Is(err, os.ErrNotExist) { |
| 5135 | back("err", "Could not check the release store. Please try again.") |
| 5136 | return |
| 5137 | } |
| 5138 | // Plain Promote, deliberately, where handlePromote uses PromoteOnLadder: |
| 5139 | // the target is always canary/<name>, the one rung the ladder never |
| 5140 | // refuses in either direction, so enforcement here could only turn the |
| 5141 | // "new means new" refusal above into a less useful message. Nothing |
| 5142 | // reachable from this handler can name another rung. |
| 5143 | // |
| 5144 | // Creating a canary is still a promotion onto the canary rung — recorded |
| 5145 | // like any other, so "who put this build in front of users" has an answer |
| 5146 | // for the rung most likely to be wired to production. |
| 5147 | rec := release.Promotion{To: release.RungCanary, Actor: addr} |
| 5148 | if err := release.Promote(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, channel, version, rec); err != nil { |
| 5149 | log.Printf("console: canary %s %s -> %s: %v", app, version, channel, err) |
| 5150 | back("err", "Creating the canary failed: "+err.Error()) |
| 5151 | return |
| 5152 | } |
| 5153 | s.auditActivity(acct, app, addr, Activity{Action: "canary-create", TargetType: "channel", Target: "canary/" + name, Detail: "created canary " + name + " at " + version}) |
| 5154 | // A channel pointer like any other: a route already following canary/<name> |
| 5155 | // (attach-then-create is a real order) adopts it on the announced pass. |
| 5156 | s.bumpSerial(ctx) |
| 5157 | // Best-effort here: which URL to show in the success flash is cosmetic, |
| 5158 | // so a registry hiccup just falls back to the generic "attach one" copy |
| 5159 | // rather than failing the create that already succeeded. |
| 5160 | if hosts, _ := s.channelHosts(app, channel); len(hosts) > 0 { |
| 5161 | back("ok", "Created canary "+name+" — live at https://"+hosts[0]+" serving "+version+" (test versions have no safety delay).") |
| 5162 | return |
| 5163 | } |
| 5164 | domain, absent := s.instanceDomainFor(ctx, acct.Sqid, app) |
| 5165 | create, enable := attachCommands(app, channel, domain, s.cfg.AppsDomain, acct.Sqid, absent) |
| 5166 | if enable != "" { |
| 5167 | create = enable + " && " + create |
| 5168 | } |
| 5169 | back("ok", "Created canary "+name+" — "+channel+" serves "+version+" the moment an address follows it, no safety delay. Attach one: "+create) |
| 5170 | } |
| 5171 | |
| 5172 | // instanceDomainFor answers "where do this app's instance hosts live?" — the |
| 5173 | // app's instances-manifest domain when one exists, and whether its absence is |
| 5174 | // PROVEN. Both doors that tell a reader how to give a channel a URL ask this, |
| 5175 | // so both name the same command. |
| 5176 | // |
| 5177 | // The three-way answer is load-bearing: a manifest that merely could not be |
| 5178 | // READ is not "instances are off". The enable door writes guarded |
| 5179 | // (PutManifestIfMatch) and answers 409 over a standing manifest under another |
| 5180 | // domain, so telling someone to run `carlos instances enable` on the strength |
| 5181 | // of a transient bucket error sends them at a door that will refuse them. |
| 5182 | // Unreadable therefore renders the neutral form, never the enable step. |
| 5183 | func (s *Server) instanceDomainFor(ctx context.Context, sqid, app string) (domain string, absent bool) { |
| 5184 | m, err := provision.GetManifest(ctx, s.cfg.Releases, sqid, app) |
| 5185 | switch { |
| 5186 | case err == nil: |
| 5187 | return m.Domain, false |
| 5188 | case errors.Is(err, os.ErrNotExist): |
| 5189 | return "", true |
| 5190 | default: |
| 5191 | log.Printf("console: instances manifest %s/%s: %v", sqid, app, err) |
| 5192 | return "", false |
| 5193 | } |
| 5194 | } |
| 5195 | |
| 5196 | // attachCommands renders the "give this channel a URL" advice for one app: |
| 5197 | // the create command, plus the enable command that has to precede it when |
| 5198 | // instances are proven off ("" otherwise). |
| 5199 | // |
| 5200 | // It names `carlos instances create`, deliberately NOT `carlos add`. Both |
| 5201 | // place a route, but `carlos add` opens THIS box's registry on local disk |
| 5202 | // (cli.go's defaultRegistry) — it cannot be run from wherever a dashboard is |
| 5203 | // being read, and an operator who followed that hint from a browser landed on |
| 5204 | // a box-local verb that could not work for them (2026-08-07). The API-backed |
| 5205 | // path is the one a login credential can drive: spec §12a, and the record it |
| 5206 | // writes materializes as a route on the box's next provision pass, carrying |
| 5207 | // this channel through (provision.Reconcile's mint). |
| 5208 | func attachCommands(app, channel, domain, appsDomain, sqid string, absent bool) (create, enable string) { |
| 5209 | // An instance host is exactly one label under the manifest's domain |
| 5210 | // (provision.ValidHost), so the placeholder stands in for the label only. |
| 5211 | host := "<host>" |
| 5212 | switch { |
| 5213 | case domain != "": |
| 5214 | host = "<name>." + domain |
| 5215 | case absent && appsDomain != "": |
| 5216 | // Not enabled yet: the member-facing enable door pins the manifest to |
| 5217 | // the account's own platform subdomain (§12a), so that is where the |
| 5218 | // host will have to live once they run it. |
| 5219 | host = "<name>." + sqid + "." + appsDomain |
| 5220 | } |
| 5221 | create = "carlos instances create --app " + app + " --host " + host + " --channel " + channel |
| 5222 | if absent { |
| 5223 | enable = "carlos instances enable --app " + app |
| 5224 | } |
| 5225 | return create, enable |
| 5226 | } |
| 5227 | |
| 5228 | // handleRetireCanary removes a canary's channel pointer. Shipped releases |
| 5229 | // are untouched — retirement un-points, it never deletes bits. |
| 5230 | func (s *Server) handleRetireCanary(w http.ResponseWriter, r *http.Request) { |
| 5231 | addr, raw, acct, role, app, ok := s.accountApp(w, r) |
| 5232 | if !ok { |
| 5233 | return |
| 5234 | } |
| 5235 | if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| 5236 | s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| 5237 | return |
| 5238 | } |
| 5239 | if s.cfg.Key == nil { |
| 5240 | s.renderMessage(w, r, http.StatusServiceUnavailable, "Promotions disabled", |
| 5241 | "No signing key on this host — the console is running read-only.") |
| 5242 | return |
| 5243 | } |
| 5244 | back := func(kind, msg string) { |
| 5245 | s.setFlash(w, kind, msg) |
| 5246 | dest := "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) |
| 5247 | if tab := releaseBackTab(r); tab != "" { |
| 5248 | dest += "/" + tab |
| 5249 | } |
| 5250 | http.Redirect(w, r, dest, http.StatusSeeOther) |
| 5251 | } |
| 5252 | name := strings.ToLower(strings.TrimSpace(r.FormValue("name"))) |
| 5253 | if !canaryNameRe.MatchString(name) { |
| 5254 | back("err", "Invalid canary name.") |
| 5255 | return |
| 5256 | } |
| 5257 | channel := "canary/" + name |
| 5258 | // A canary with followers is load-bearing: retiring its pointer would |
| 5259 | // strand every host on it. Fail CLOSED on a registry error — an "I |
| 5260 | // couldn't check" must never be read as "nobody follows this channel". |
| 5261 | hosts, err := s.channelHosts(app, channel) |
| 5262 | if err != nil { |
| 5263 | log.Printf("console: retire %s %s: check followers: %v", app, channel, err) |
| 5264 | s.renderMessage(w, r, http.StatusInternalServerError, "Error", |
| 5265 | "Could not verify no hosts follow this channel — try again.") |
| 5266 | return |
| 5267 | } |
| 5268 | if len(hosts) > 0 { |
| 5269 | back("err", "Canary "+name+" still has a host ("+hosts[0]+") following it — remove or repoint that route first.") |
| 5270 | return |
| 5271 | } |
| 5272 | ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) |
| 5273 | defer cancel() |
| 5274 | key := release.ChannelKey(acct.Sqid, app, channel) |
| 5275 | if _, _, err := s.cfg.Releases.Get(ctx, key); errors.Is(err, os.ErrNotExist) { |
| 5276 | back("err", "No canary named "+name+" here.") |
| 5277 | return |
| 5278 | } else if err != nil { |
| 5279 | back("err", "Could not read the release store. Please try again.") |
| 5280 | return |
| 5281 | } |
| 5282 | if !confirmed(r) { |
| 5283 | s.confirmRetireCanary(w, r, addr, raw, acct, role, app, name) |
| 5284 | return |
| 5285 | } |
| 5286 | if err := s.appMutation(ctx, acct, app, "canary-retire", func(ctx context.Context) error { |
| 5287 | return s.cfg.Releases.Delete(ctx, key) |
| 5288 | }); err != nil { |
| 5289 | log.Printf("console: retire %s %s: %v", app, channel, err) |
| 5290 | back("err", "Retiring the canary failed: "+err.Error()) |
| 5291 | return |
| 5292 | } |
| 5293 | s.auditActivity(acct, app, addr, Activity{Action: "canary-retire", TargetType: "channel", Target: "canary/" + name, Detail: "retired canary " + name}) |
| 5294 | back("ok", "Retired canary "+name+" — its channel pointer is gone; shipped releases are untouched.") |
| 5295 | } |
| 5296 | |
| 5297 | // promoteChannelOptions is the per-row promote select's option list. |
| 5298 | // |
| 5299 | // For a pipelined app (pipe != nil), it's the pipeline's own declared |
| 5300 | // channels, IN PIPELINE ORDER — the app's promotion order comes from the |
| 5301 | // pipeline now (spec §5), not the frozen rungs, and offering a rung name a |
| 5302 | // pipelined app never declared would let a member promote somewhere the |
| 5303 | // pipeline itself refuses. Any existing channel outside the pipeline |
| 5304 | // (canary/*, a -hotfix target) is still offered after them, in existing |
| 5305 | // order, for the same reason the legacy path offers canary/*: it is a real, |
| 5306 | // already-published channel, whatever governs promoting onto it. |
| 5307 | // |
| 5308 | // For a legacy app (pipe == nil), it's the ladder rungs first (so a fresh |
| 5309 | // app with zero channels — nothing in existing — still has somewhere to |
| 5310 | // promote its first release), then any of the app's existing channels not |
| 5311 | // already a rung (canary/* and the like), in their existing order. Ladder |
| 5312 | // rungs are included whether or not the app has them yet — promoting onto a |
| 5313 | // rung that doesn't exist is exactly how it comes to exist. |
| 5314 | func promoteChannelOptions(existing []ChannelView, pipe *release.Pipeline) []string { |
| 5315 | var first []string |
| 5316 | if pipe != nil { |
| 5317 | first = make([]string, len(pipe.Channels)) |
| 5318 | for i, c := range pipe.Channels { |
| 5319 | first[i] = c.Name |
| 5320 | } |
| 5321 | } else { |
| 5322 | first = []string{release.RungEdge, release.RungBeta, release.RungStable} |
| 5323 | } |
| 5324 | seen := make(map[string]bool, len(first)) |
| 5325 | options := make([]string, 0, len(first)+len(existing)) |
| 5326 | for _, name := range first { |
| 5327 | options = append(options, name) |
| 5328 | seen[name] = true |
| 5329 | } |
| 5330 | for _, ch := range existing { |
| 5331 | if seen[ch.Name] { |
| 5332 | continue |
| 5333 | } |
| 5334 | seen[ch.Name] = true |
| 5335 | options = append(options, ch.Name) |
| 5336 | } |
| 5337 | return options |
| 5338 | } |
| 5339 | |
| 5340 | // summarize folds one AppView into its "your apps" table row. Routes can |
| 5341 | // legitimately belong to another account (RouteView.Account's doc comment) |
| 5342 | // so both the apex-host pick and the failed-route scan are filtered to the |
| 5343 | // viewing account first — otherwise a foreign route can pick this app's URL |
| 5344 | // or trip its "needs attention" verdict. |
| 5345 | func summarize(v AppView, acct string) appRow { |
| 5346 | var mine []RouteView |
| 5347 | for _, rt := range v.Routes { |
| 5348 | if rt.Account == acct { |
| 5349 | mine = append(mine, rt) |
| 5350 | } |
| 5351 | } |
| 5352 | deployment := observedDeployment(v.Routes, acct) |
| 5353 | row := appRow{App: v.App, URL: apexHost(mine), Deployment: deployment} |
| 5354 | attention := false |
| 5355 | for _, rt := range mine { |
| 5356 | if !rt.Platform && rt.Status == "failed" { |
| 5357 | attention = true |
| 5358 | } |
| 5359 | } |
| 5360 | for _, ch := range v.Channels { |
| 5361 | if ch.Err == "" && strings.HasPrefix(ch.Name, "canary/") { |
| 5362 | row.CanaryN++ |
| 5363 | } |
| 5364 | } |
| 5365 | switch { |
| 5366 | case !deployment.HasRoutes(): |
| 5367 | row.Verdict, row.VerdictLabel = "notdeployed", "Not deployed yet" |
| 5368 | case attention: |
| 5369 | row.Verdict, row.VerdictLabel = "attention", "Needs attention" |
| 5370 | case deployment.ReportableCount == 0: |
| 5371 | row.Verdict, row.VerdictLabel = "ok", "Deployed" |
| 5372 | case !deployment.HasReportedVersion(): |
| 5373 | row.Verdict, row.VerdictLabel = "unknown", "Can't tell right now" |
| 5374 | case deployment.UnknownCount > 0: |
| 5375 | row.Verdict, row.VerdictLabel = "unknown", "Can't tell right now" |
| 5376 | default: |
| 5377 | row.Verdict, row.VerdictLabel = "ok", "Deployed" |
| 5378 | } |
| 5379 | return row |
| 5380 | } |
| 5381 | |