carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Command console is the CARLOS platform console — the app behind
2// https://platform.carlosframework.com. Sign up / sign in with Keymail, see
3// your apps and their live platform state; with a signing key configured,
4// promote, roll back, and run canaries from the browser. Registry access
5// stays read-only; the only platform state the console writes is signed
6// channel pointers in the deployment bucket.
7//
8// Verbs:
9//
10// console serve run the HTTP server
11// console grant <address> <app> make address an owner of app
12// console revoke <address> <app> remove that ownership
13// console grant -operator <address> make address a fleet operator
14// console revoke -operator <address> remove that operator role
15// console owners list every owner and their apps
16// console member -add <address> <account-sqid> [-role owner|member]
17// console member -remove <address> <account-sqid>
18// console member -list <account-sqid>
19// membership IS authorization in the
20// accounts model — a member can deploy
21// every app in the account — so adding
22// and REMOVING one is box-side authority
23// console migrate-app -app <a> -to <sqid>
24// re-home an app onto another account —
25// the console half of
26// `carlos accounts migrate`
27//
28// serve reads: CONSOLE_ADDR (default 127.0.0.1:8090), CONSOLE_DB (default
29// /data/console/console.db), CONSOLE_ORIGIN (required, e.g.
30// https://platform.carlosframework.com), CONSOLE_KEYMAIL (default
31// https://keymail.dev), CARLOS_REGISTRY (default /data/registry.db),
32// CARLOS_DEPLOYMENT_BUCKET or CARLOS_DEPLOYMENT_DIR, CARLOS_RELEASE_PUBKEY,
33// and optionally CARLOS_RELEASE_KEY (enables promote/rollback from the
34// console; unset = read-only), CARLOS_SECRETS_PUBKEY (SERVED to CLI
35// clients so `carlos secrets set` can seal with a login alone; never used
36// here), PLATFORM_APPS_DOMAIN (the same variable the edge and agent read;
37// enables the CLI's member-facing `carlos instances enable`, whose manifest
38// domain is pinned to <sqid>.<this domain>), and CONSOLE_REDIRECT_HOSTS
39// (comma-separated retired hostnames this console 301/308s to CONSOLE_ORIGIN
40// — opt-in per host, so a host the API must keep answering on, like
41// platform.carlosframework.com for CLI tokens, is simply left off).
42//
43// Object storage (issue #55) is off unless CARLOS_STORE_BACKEND is set, in
44// which case it must be "aws" and CARLOS_STORE_REGION,
45// CARLOS_STORE_BUCKET_PREFIX and CARLOS_STORE_USER_BOUNDARY are required
46// (CARLOS_STORE_ENDPOINT is optional — set it for an S3-compatible backend). CARLOS_STORE_OPERATORS
47// (comma-separated addresses) gates the GRANT door only — rotate is the
48// member's own verb and destroy is the account owner's browser ceremony;
49// it is read whether or not a backend is configured.
50//
51// CARLOS_TEMPORARY_APPS=1 turns on temporary apps (carlos deploy
52// --temporary); off by default, see the temporary static sites rollout.
53//
54// CARLOS_SIDECAR_ACCOUNTS (comma-separated account sqids) pauses native
55// sidecars for every account it does not name: enabling sidecars and
56// creating one answer 403 sidecars-paused, while list, get, wake and delete
57// keep working and existing sidecars keep running. Native sidecars run on
58// the flat tier, outside the nested tenants' isolation, until discussion
59// #370 lands. Unset or empty is no gate. An entry that is not a sqid, or a
60// value that names no sqid at all, stops the console at startup.
61//
62// Tenant email (task 17) reads CARLOS_MAIL_OPERATORS (comma-separated
63// addresses; narrows the fleet-operator bit further for the pause/cap
64// door only — see mailOperators' doc comment), CARLOS_MAIL_REGION,
65// CARLOS_MAIL_PLATFORM_SUFFIX, CARLOS_MAIL_DMARC_RUA,
66// CARLOS_MAIL_SNS_TOPIC_ARN and CARLOS_MAIL_USER_BOUNDARY (below). All six
67// are read regardless of whether a mail Provisioner is wired.
68// CARLOS_MAIL_REGION set turns it on: mailBackends
69// constructs a real AWS SES/IAM Provisioner, and every email door stops
70// 404ing (mailProvisionerGate); unset leaves the Provisioner nil, same
71// "config costs nothing to hold ahead of the backend" reasoning as
72// CARLOS_STORE_OPERATORS above. CARLOS_MAIL_PLATFORM_SUFFIX set additionally
73// turns on a Route53 DNS Writer, so CARLOS publishes DNS for domains under
74// that suffix itself rather than handing the member records to publish by
75// hand. Unlike object storage, a construction failure for either (bad
76// region, no AWS credentials, an AWS outage) is logged and leaves the
77// field nil rather than stopping the console from booting — see
78// mailBackends' doc comment.
79//
80// CARLOS_MAIL_USER_BOUNDARY is the one mail setting that IS fatal. With
81// CARLOS_MAIL_REGION set it is required: the ARN of the IAM managed policy
82// every sending user (/carlos-mail/ path) is created under, as
83// CARLOS_STORE_USER_BOUNDARY is for store users (mailUserBoundary). The
84// boundary should allow ses:SendRawEmail (the one action the inline policy,
85// mail.SendPolicyJSON, grants) and nothing else; each user's inline policy
86// narrows it to that app's identities. A user created before the boundary
87// existed is given it the next time EnsureUser or MintKey runs for it
88// (`carlos email enable`, a standalone credential, a domain change
89// refreshing the app's policies, or a key rotation), and
90// CARLOS_MAIL_BOUNDARY_SWEEP=1 sweeps every /carlos-mail/ user once at
91// start (sweepMailBoundaries) for the ones no walk reaches. So the console
92// role needs iam:PutUserPermissionsBoundary on user/carlos-mail/* beside
93// CreateUser, GetUser, PutUserPolicy and the key calls, and iam:ListUsers
94// (account-wide, Resource "*") for the sweep. Condition
95// PutUserPermissionsBoundary on iam:PermissionsBoundary equal to this ARN
96// from the start, or the role can set any boundary at all. Once every
97// console runs a build that sets the boundary and the sweep reports every
98// user bounded, condition CreateUser and PutUserPolicy on it as well.
99// EnsureUser sets the boundary before it writes the policy, so a
100// conditioned PutUserPolicy does not block the backfill. The rollout order
101// is in README.md.
102// Never grant iam:DeleteUserPermissionsBoundary; nothing here calls it.
103//
104// The console's own email (the invite fallback when Keymail cannot deliver)
105// sends From CARLOS_CONSOLE_MAIL_FROM, defaulting to
106// noreply@console-mail.bab.<CARLOS_MAIL_PLATFORM_SUFFIX> when the suffix is set.
107// It needs CARLOS_MAIL_REGION (a mail Provisioner) and the host role's
108// ses:SendEmail grant from infra/modules/region-host's console_mail_from and
109// console_mail_region variables, which must equal the resolved From and
110// CARLOS_MAIL_REGION. With neither a From nor a suffix there is no console
111// email, and invites go by Keymail or by hand. If the domain is already an
112// SES identity the console did not create, mail.EnsureDomain refuses it
113// until a _carlos-mail.<domain> TXT proof is published (#216). There is no
114// console email while CARLOS_HOST_CLAIM is on, because the console mailer
115// does not yet go through the claim mail gate (#250).
116// CARLOS_CONSOLE_NAME is how the console's emails name it ("Your sign-in link
117// for <name>"); unset, they say "the CARLOS platform".
118//
119// grant/revoke/owners need only CONSOLE_DB.
120package main
121
122import (
123 "context"
124 "crypto/ed25519"
125 "errors"
126 "flag"
127 "fmt"
128 "log"
129 "math/rand/v2"
130 "net"
131 "os"
132 "path/filepath"
133 "sort"
134 "strconv"
135 "strings"
136 "time"
137 "unicode/utf8"
138
139 "github.com/carlosframework/platform/internal/activator"
140 "github.com/carlosframework/platform/internal/alerting"
141 "github.com/carlosframework/platform/internal/appconfig"
142 "github.com/carlosframework/platform/internal/console"
143 "github.com/carlosframework/platform/internal/dnsrec"
144 "github.com/carlosframework/platform/internal/domains"
145 "github.com/carlosframework/platform/internal/geo"
146 "github.com/carlosframework/platform/internal/geoip"
147 "github.com/carlosframework/platform/internal/lease"
148 "github.com/carlosframework/platform/internal/mail"
149 "github.com/carlosframework/platform/internal/moves"
150 "github.com/carlosframework/platform/internal/objstore"
151 "github.com/carlosframework/platform/internal/plans"
152 "github.com/carlosframework/platform/internal/provision"
153 "github.com/carlosframework/platform/internal/release"
154 "github.com/carlosframework/platform/internal/rootaws"
155 "github.com/carlosframework/platform/internal/status"
156 "github.com/carlosframework/platform/internal/steering"
157)
158
159func main() {
160 rootaws.ScrubEnvironment()
161 log.SetFlags(0)
162 if len(os.Args) < 2 {
163 usage()
164 }
165 switch os.Args[1] {
166 case console.GeoipVerifyVerb:
167 // The GeoIP verifier child (internal/console/geoipverify.go): parses
168 // an untrusted .mmdb so the serving process never has to.
169 os.Exit(console.RunGeoipVerify(os.Args[2:], os.Stdout))
170 case "serve":
171 serve()
172 case "grant", "revoke":
173 verb := os.Args[1]
174 fs := flag.NewFlagSet(verb, flag.ExitOnError)
175 operator := fs.Bool("operator", false, "grant/revoke the fleet-operator role instead of an app")
176 fs.Parse(os.Args[2:])
177 args := fs.Args()
178 st := openConsoleDB()
179 var err error
180 switch {
181 case *operator:
182 // Box-side only, exactly like an app grant: no app argument, and
183 // it works whether or not the address has ever signed in.
184 if len(args) != 1 {
185 log.Fatalf("usage: console %s -operator <address>", verb)
186 }
187 err = st.SetOperator(args[0], verb == "grant")
188 default:
189 if len(args) != 2 {
190 log.Fatalf("usage: console %s <address> <app>", verb)
191 }
192 if verb == "grant" {
193 err = st.Grant(args[0], args[1])
194 } else {
195 err = st.Revoke(args[0], args[1])
196 }
197 }
198 if err != nil {
199 log.Fatal(err)
200 }
201 case "member":
202 runMember(os.Args[2:])
203 case "migrate-app":
204 migrateApp(os.Args[2:])
205 case "owners":
206 st := openConsoleDB()
207 owners, err := st.Owners()
208 if err != nil {
209 log.Fatal(err)
210 }
211 addrs := make([]string, 0, len(owners))
212 for a := range owners {
213 addrs = append(addrs, a)
214 }
215 sort.Strings(addrs)
216 for _, a := range addrs {
217 fmt.Printf("%s\t%s\n", a, strings.Join(owners[a], " "))
218 }
219 default:
220 usage()
221 }
222}
223
224func usage() {
225 log.Fatal("usage: console serve | grant [-operator] <address> [<app>] | revoke [-operator] <address> [<app>] | owners | " +
226 "member -add|-remove <address> <account-sqid> [-role owner|member] | member -list <account-sqid> | " +
227 "migrate-app -app <app> -to <sqid> [-from <sqid>]")
228}
229
230// migrateApp completes the second half of `carlos accounts migrate`: that
231// verb moves the app's bucket objects and re-stamps its registry routes, but
232// the console's own app→account row is CONSOLE state, and the carlos binary
233// has no business opening this database. So the move finishes here, box-side,
234// exactly like `console grant`.
235//
236// Idempotent, so an operator who is unsure whether it landed can run it
237// again; an unknown app or account is refused before anything is written.
238func migrateApp(args []string) {
239 fs := flag.NewFlagSet("migrate-app", flag.ExitOnError)
240 app := fs.String("app", "", "app to move")
241 to := fs.String("to", "", "destination account sqid")
242 from := fs.String("from", "", "source account sqid (default: the one account that claims the app)")
243 fs.Parse(args)
244 if *app == "" || *to == "" {
245 log.Fatal("usage: console migrate-app -app <app> -to <sqid> [-from <sqid>]")
246 }
247 st := openConsoleDB()
248 defer st.Close()
249
250 dst, err := st.AccountBySqid(*to)
251 if err != nil {
252 log.Fatal(err)
253 }
254 if dst == nil {
255 log.Fatalf("no account %q in this console — create it first (the switcher's \"New account\", or carlos accounts create)", *to)
256 }
257 holders, err := st.AppAccounts(*app)
258 if err != nil {
259 log.Fatal(err)
260 }
261 if len(holders) == 0 {
262 log.Fatalf("no app %q in this console", *app)
263 }
264 if len(holders) == 1 && holders[0] == dst.ID {
265 fmt.Printf("%s is already in %s (%s) — nothing to do\n", *app, *to, dst.Name)
266 return
267 }
268
269 var src int64
270 switch {
271 case *from != "":
272 a, err := st.AccountBySqid(*from)
273 if err != nil {
274 log.Fatal(err)
275 }
276 if a == nil {
277 log.Fatalf("no account %q in this console", *from)
278 }
279 src = a.ID
280 case len(holders) == 1:
281 src = holders[0]
282 default:
283 // Several accounts claim this name — legitimate, since names are
284 // unique per account rather than globally. Picking one would re-home
285 // the wrong tenant's app, so say which ones and stop.
286 var names []string
287 for _, id := range holders {
288 if a, _ := st.AccountByID(id); a != nil {
289 names = append(names, a.Sqid+" ("+a.Name+")")
290 }
291 }
292 log.Fatalf("%q is claimed by several accounts — pass -from <sqid>: %s", *app, strings.Join(names, ", "))
293 }
294 if err := st.MoveApp(*app, src, dst.ID); err != nil {
295 log.Fatal(err)
296 }
297 fmt.Printf("moved %s to %s (%s)\n", *app, *to, dst.Name)
298}
299
300func serve() {
301 origin := strings.TrimSuffix(os.Getenv("CONSOLE_ORIGIN"), "/")
302 if origin == "" {
303 log.Fatal("set CONSOLE_ORIGIN (e.g. https://platform.carlosframework.com)")
304 }
305 pub, err := release.ParsePublicKey(os.Getenv("CARLOS_RELEASE_PUBKEY"), "CARLOS_RELEASE_PUBKEY")
306 if err != nil {
307 log.Fatal(err)
308 }
309 // The signing key is optional: without it the console runs read-only and
310 // the promote/rollback surface is disabled. Never logged, never rendered.
311 var key ed25519.PrivateKey
312 if os.Getenv("CARLOS_RELEASE_KEY") != "" {
313 // A SCOPED key here would be a silent trap: the console signs every
314 // promotion as if it held the root key, so a scoped seed would produce
315 // pointers with no grant attached, which nothing in the fleet accepts
316 // — and the operator would learn about it from a box that stopped
317 // adopting. Scoped keys are for automation; the console promotes with
318 // the deployment's own authority or not at all.
319 if release.IsScopedSeed(os.Getenv("CARLOS_RELEASE_KEY")) {
320 log.Fatal("console: CARLOS_RELEASE_KEY holds a scoped key — the console promotes with the deployment's ROOT key or runs read-only with none; a scoped key would sign pointers the fleet refuses")
321 }
322 key, err = release.ParseSigningKey(os.Getenv("CARLOS_RELEASE_KEY"), "CARLOS_RELEASE_KEY")
323 if err != nil {
324 log.Fatal(err)
325 }
326 } else {
327 log.Print("console: CARLOS_RELEASE_KEY unset — promotions disabled, running read-only")
328 }
329 // Before anything is opened: a typo in the allowlist must stop the
330 // console rather than quietly pause, or unpause, the wrong accounts.
331 sidecarAccts, err := sidecarAccounts(os.Getenv)
332 if err != nil {
333 log.Fatalf("console: %v", err)
334 }
335 hostClaim := hostClaimEnabled(os.Getenv)
336 st := openServeDB(hostClaim)
337 storeProvisioner, storeDeployment := storeBackend()
338 storeScanner, seedAntivirus := storeScannerFor(storeDeployment)
339 catalog, err := planCatalog()
340 if err != nil {
341 // A broken catalog must never quietly mean "no limits".
342 log.Fatalf("console: CARLOS_PLAN_CATALOG: %v", err)
343 }
344 var callbackSigner *console.CallbackSigner
345 if os.Getenv("CARLOS_SCAN_WEBHOOK_KEY") == "" && os.Getenv("CARLOS_SCAN_WEBHOOK_KEY_PREVIOUS") != "" {
346 log.Fatal("console: CARLOS_SCAN_WEBHOOK_KEY_PREVIOUS is set without CARLOS_SCAN_WEBHOOK_KEY")
347 }
348 if os.Getenv("CARLOS_SCAN_WEBHOOK_KEY") != "" {
349 callbackSigner, err = console.NewCallbackSigner(os.Getenv("CARLOS_SCAN_WEBHOOK_KEY"), os.Getenv("CARLOS_SCAN_WEBHOOK_KEY_PREVIOUS"))
350 if err != nil {
351 log.Fatal(err)
352 }
353 }
354 mailDep := mailDeployment()
355 mailBoundary, err := mailUserBoundary(mailDep.Region, os.Getenv("CARLOS_MAIL_USER_BOUNDARY"))
356 if err != nil {
357 log.Fatal(err)
358 }
359 newMailProvisioner := func(ctx context.Context, region string) (mail.Provisioner, error) {
360 return mail.NewAWSProvisioner(ctx, region, mailBoundary)
361 }
362 mailProvisioner, dnsWriter := mailBackends(mailDep, newMailProvisioner, dnsrec.NewRoute53Writer)
363 // One release.Store, named, because the senders sweep below needs the
364 // same one the server got.
365 rel := openStore()
366 consoleMail, ensureConsoleMail := consoleMailer(mailDep, rel, mailProvisioner, dnsWriter, mail.NewSESEmailSender, hostClaim)
367 dbBucket, dbRegion, dbPrefix, placement := dbDoorWiring()
368 var dbVersions lease.VersionedStore
369 if hostClaim {
370 buckets := map[string]string{}
371 if dbBucket != "" {
372 buckets[dbBucket] = dbRegion
373 }
374 if controlBucket := strings.TrimSpace(os.Getenv("CARLOS_DEPLOYMENT_BUCKET")); controlBucket != "" {
375 // Match release.NewS3Store's AWS region/default-config resolution.
376 if _, known := buckets[controlBucket]; !known {
377 buckets[controlBucket] = strings.TrimSpace(os.Getenv("AWS_REGION"))
378 }
379 }
380 dbVersions, err = lease.NewVersionedS3(context.Background(), buckets)
381 if err != nil {
382 log.Fatalf("console: versioned database reader: %v", err)
383 }
384 }
385 // The seed doors write seeds/ at the root of the same data bucket the db
386 // door reads and the box's activator writes (seeded-instances spec §3.1).
387 var seedData release.Store
388 if dbBucket != "" {
389 if sd, err := release.NewS3StoreIn(context.Background(), dbBucket, dbRegion); err != nil {
390 log.Printf("console: seed data store: %v (the seed doors will answer 503)", err)
391 } else {
392 seedData = sd
393 }
394 }
395 // Before the server exists: every door that seals reads this switch.
396 appconfig.SetBoundSealing(secretsEnvelopeV2())
397 geoReader := consoleGeoReader(strings.TrimSpace(os.Getenv("CONSOLE_GEOIP_DB")))
398 cfg := console.Config{
399 Origin: origin,
400 Keymail: envOr("CONSOLE_KEYMAIL", "https://keymail.dev"),
401 Registry: envOr("CARLOS_REGISTRY", "/data/registry.db"),
402 Releases: rel,
403 // The deployment bucket's NAME, from the same variable openStore()
404 // opens it with — /fleet/architecture draws the box -> bucket edge
405 // from it. Empty on a CARLOS_DEPLOYMENT_DIR deployment, which
406 // correctly draws no bucket node.
407 Bucket: os.Getenv("CARLOS_DEPLOYMENT_BUCKET"),
408 HomeRegion: strings.TrimSpace(os.Getenv("CONSOLE_HOME_REGION")),
409 RegionHA: regionHAEnabled(os.Getenv),
410 // The deployment's AWS account id, the trust anchor of the Phase 1
411 // move door's box identity check (unset: no box verifies).
412 AWSAccount: strings.TrimSpace(os.Getenv("CARLOS_AWS_ACCOUNT")),
413 AppRegions: appRegionsSwitch(os.Getenv("CONSOLE_APP_REGIONS")),
414 // GeoIP (geoip spec §11.3): the CONSOLE_GEOIP_DB override when set,
415 // otherwise the follower of this box's own edge mirror.
416 GeoIP: geoLocator(geoReader),
417 GeoIPReader: geoReader,
418 // Next to CONSOLE_DB, on disk (an upload can be 1 GiB), and used
419 // for nothing else: NewServer clears it (geoip spec §4.2).
420 GeoipStagingDir: filepath.Join(filepath.Dir(envOr("CONSOLE_DB", "/data/console/console.db")), "geoip-staging"),
421 // --from restores and import spools, next to CONSOLE_DB on disk;
422 // NewServer clears the pool's own leftovers from it.
423 SeedScratchDir: filepath.Join(filepath.Dir(envOr("CONSOLE_DB", "/data/console/console.db")), "scratch", "seeds"),
424 SeedData: seedData,
425 // The feed (geoip spec §10.1). The key goes nowhere but MaxMind.
426 GeoipFeed: os.Getenv("CONSOLE_GEOIP_FEED"),
427 GeoipMaxMindAccount: strings.TrimSpace(os.Getenv("CONSOLE_GEOIP_MAXMIND_ACCOUNT")),
428 GeoipMaxMindKey: strings.TrimSpace(os.Getenv("CONSOLE_GEOIP_MAXMIND_KEY")),
429 BoxLabel: consoleBoxLabel(),
430 // The carlos db door's data bucket and this box's placement
431 // (dbDoorWiring).
432 HostClaim: hostClaim,
433 RunKeysEnabled: os.Getenv("CARLOS_RUN_KEYS") == "on", // keyed run creates, once every box carries run_keys
434 HostConsoleID: os.Getenv("CARLOS_CONSOLE_ID"),
435 DBVersions: dbVersions,
436 HibernateBucket: dbBucket,
437 HibernateRegion: dbRegion,
438 HibernatePrefix: dbPrefix,
439 Region: placement,
440 DBForward: dbForward(),
441 ReleasePub: pub,
442 Key: key,
443 EdgeIPs: edgeIPs(),
444 SecretsPub: secretsPub(),
445 AppsDomain: appsDomain(),
446 AppsDomainAliasAccounts: strings.FieldsFunc(os.Getenv("CONSOLE_APPS_DOMAIN_ALIAS_ACCOUNTS"), func(r rune) bool { return r == ',' || r == ' ' }),
447 SteeringHost: steeringHost(),
448 RedirectHosts: redirectHosts(),
449 DenyDomains: denyDomains(),
450 TunnelHost: tunnelHost(),
451 ReservePlatformDomains: reservePlatformDomains(),
452 DomainProof: domainProof(),
453 StoreProvisioner: storeProvisioner,
454 StoreScanner: storeScanner,
455 ScanSeedFromDeprecatedEnv: seedAntivirus,
456 ScanBudget: envDuration("CARLOS_STORE_SCAN_BUDGET", time.Minute),
457 ScanInterval: envDuration("CARLOS_STORE_SCAN_INTERVAL", 24*time.Hour),
458 ScanMaxSigAge: envDuration("CARLOS_CLAMD_MAX_SIG_AGE", 168*time.Hour),
459 CallbackSigner: callbackSigner,
460 StoreDeployment: storeDeployment,
461 StoreMaxPerApp: envCount("CARLOS_STORE_MAX_PER_APP", 0),
462 StoreOperators: storeOperators(),
463 PlanCatalog: catalog,
464 TemporaryApps: os.Getenv("CARLOS_TEMPORARY_APPS") == "1",
465 // CARLOS_ACME_DELEGATION_ZONE — the same variable the edge reads
466 // (internal/edge/wildcard.go's newWildcardFromEnv) for the same
467 // delegated-ACME story; NewServer normalizes it (lowercase, trim).
468 // Deliberately independent of PLATFORM_APPS_DOMAIN (#167's blind
469 // spot), so it is read raw here rather than folded into appsDomain().
470 DelegationZone: os.Getenv("CARLOS_ACME_DELEGATION_ZONE"),
471 // MailDeployment/MailOperators/MailProvisioner/DNSWriter
472 // (tenant-email task 17): mailBackends above constructs the last
473 // two, and only when the corresponding coordinate is configured —
474 // CARLOS_MAIL_REGION for the SES/IAM Provisioner,
475 // CARLOS_MAIL_PLATFORM_SUFFIX for the Route53 Writer. An unset
476 // deployment (every deployment as of this change) gets both nil,
477 // exactly as before this wiring: no AWS call at boot, every email
478 // door still 404s (mailProvisionerGate). Once a region is set,
479 // mailProvisionerGate opens and the doors route through a real SES
480 // estate; a construction failure there (bad region, missing
481 // credentials, an AWS outage) is logged loudly and leaves the
482 // field nil rather than failing console boot — see mailBackends'
483 // own doc comment for why that direction, unlike storeBackend's,
484 // is deliberate. A nil DNSWriter with a platform suffix set is
485 // also a supported, non-degraded shape on its own: platform email
486 // domains emit records for the member to publish instead of
487 // CARLOS publishing them (mailDeploymentFor's doc comment in
488 // internal/console/apimail.go).
489 MailDeployment: mailDep,
490 MailOperators: mailOperators(),
491 MailProvisioner: mailProvisioner,
492 DNSWriter: dnsWriter,
493 // ConsoleMail is the console's own email sender, the invite fallback
494 // when Keymail cannot deliver (consoleMailer above). nil on every
495 // deployment with no From address or no mail Provisioner — invites
496 // then fall through to the hand-off link.
497 ConsoleMail: consoleMail,
498 // ConsoleName is how the console's emails name it (CARLOS_CONSOLE_NAME).
499 ConsoleName: os.Getenv("CARLOS_CONSOLE_NAME"),
500 // The STUN/TURN relay (relay spec §5). The region list is the whole
501 // switch: unset is every deployment until an operator arms one, and
502 // it leaves the relay doors refusing and no background loop running.
503 RelayRegions: relayRegions(),
504 RelayOperators: relayOperators(),
505 // Who may roll the platform's own binary besides the ops account's
506 // owners (discussion #176). Unset is the owner-only console.
507 SystemOperators: systemOperators(),
508 PlatformDomain: platformDomain(),
509 // The sidecar pause (internal/console/sidecarsgate.go). Unset is no
510 // gate; set, only these accounts may enable or create a sidecar.
511 SidecarAccounts: sidecarAccts,
512 }
513 // The Phase 1 moves switch (CARLOS_MOVES_PHASE1, off by default, index
514 // D13): applied after the literal so a test can prove it reaches the
515 // config (applyMovesSwitch).
516 applyMovesSwitch(&cfg, os.Getenv)
517 if cfg.HostClaim {
518 cfg.WakeRetryReset = console.NewProductionWakeRetryReset()
519 }
520 srv, err := console.NewServer(cfg, st)
521 if err != nil {
522 log.Fatal(err)
523 }
524 // The console mailer's identity ensure (consoleMailer above) — makes or
525 // re-reads the sending domain's SES/DKIM identity. Started here, not in
526 // NewServer, same reasoning as the alert watcher below: constructing a
527 // Server never starts a goroutine or sends mail. Bounded: the AWS SDK has
528 // no overall timeout, and an ensure that never returns would hold the
529 // mailer for good (a send gives up at its own deadline, but every one
530 // would).
531 go func() {
532 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
533 defer cancel()
534 ensureConsoleMail(ctx)
535 }()
536 // The senders migration's tail (tenant-email senders design §5): once a
537 // release has passed with every console writing senders/app.json, an
538 // operator sets CARLOS_MAIL_LEGACY_SWEEP=1 and the next start deletes the
539 // email.json objects that have already been replaced. Off by default, and
540 // it never touches an app that has not been migrated.
541 if os.Getenv("CARLOS_MAIL_LEGACY_SWEEP") == "1" {
542 go sweepLegacyMail(cfg.HostClaim, rel, st)
543 }
544 // The mail user boundary backfill (CARLOS_MAIL_USER_BOUNDARY): an
545 // operator sets CARLOS_MAIL_BOUNDARY_SWEEP=1 and the next start puts the
546 // boundary on every /carlos-mail/ user that lacks it, including users no
547 // walk will reach again, and logs whether all of them now carry it. Off
548 // by default. It only ever sets the configured boundary, never removes
549 // one, and touches no bucket record. Refused under CARLOS_HOST_CLAIM, as
550 // the legacy sweep is: it would act on IAM users outside the claim mail
551 // gate and the apps' permits.
552 if os.Getenv("CARLOS_MAIL_BOUNDARY_SWEEP") == "1" {
553 go sweepMailBoundaries(context.Background(), cfg.HostClaim, mailProvisioner, log.Printf)
554 }
555 // The S3 change index's reader (CARLOS_CHANGE_INDEX=read, change-index
556 // design §4.6): the run and seed sweeps read their records through a
557 // view over the deployment store, rebuilt at start and every backstop.
558 // Armed before any worker starts. Unset is nil: every sweep walks the
559 // bucket as before.
560 if v := armChangeIndex(os.Getenv, cfg.HostClaim, rel); v != nil {
561 srv.SetRecordView(v)
562 go recordViewBackstopLoop(context.Background(), v)
563 }
564 // The move pass's pages (moves Plan 5 Task 7): the console's alert sinks,
565 // armed before the claim runtime binds the pass, so no pass runs unarmed.
566 armMovesPaging(context.Background(), cfg, os.Getenv)
567 if err := srv.ConfigureClaimRuntime(context.Background()); err != nil {
568 log.Fatalf("console: claim recovery: %v", err)
569 }
570 // The alert watcher polls alerts/boxes/*.json every minute and keymails
571 // operators on each fire/clear (internal/console/alertwatch.go). Started
572 // here rather than in NewServer so constructing a Server — which every
573 // console test does — never starts a goroutine or sends mail.
574 go srv.RunClaimWorker(context.Background(), "StartHostReports")
575 go srv.RunClaimWorker(context.Background(), "StartAlertWatcher")
576 // The geoip orphan sweep and D1 expiry, at startup and daily.
577 go srv.RunClaimWorker(context.Background(), "StartGeoipJobs")
578 // The moves console record (moves Task 13): only with the switch on,
579 // best-effort, logged by the method itself.
580 go srv.RunClaimWorker(context.Background(), "PublishMovesConsole")
581 // The economics pass writes the daily usage/census/storage/statement
582 // objects and pulls the AWS bill (internal/console/economicspass.go).
583 // Same placement rule as the alert watcher: started here, never in
584 // NewServer, so constructing a Server stays goroutine-free.
585 go srv.RunClaimWorker(context.Background(), "StartClaimRecoveryPass")
586 go srv.RunClaimWorker(context.Background(), "StartEconomicsPass")
587 // The infrastructure inventory pass writes infra/inventory.json for
588 // /fleet/architecture (internal/console/infrapass.go). Same placement
589 // rule as the economics pass: started here, never in NewServer.
590 go srv.RunClaimWorker(context.Background(), "StartInfraPass")
591 // The object-storage scan pass advances each opted-in store's coverage
592 // (internal/console/scanpass.go). Same placement rule again: started
593 // here, never in NewServer. It returns immediately when no engine is
594 // configured, which is every deployment that has not opted in.
595 go srv.RunClaimWorker(context.Background(), "StartScanPass")
596 // The on-demand drain, on its own clock (spec §7.2). Separate goroutine
597 // from the sweep above and not a phase inside it: a queue phase behind a
598 // sequential per-app budget would starve for as long as the sweep takes.
599 go srv.RunClaimWorker(context.Background(), "StartScanDrain")
600 // The temporary-app expiry pass (internal/console/expirypass.go). Same
601 // placement rule as every pass above: started here, never in NewServer.
602 go srv.RunClaimWorker(context.Background(), "StartExpiryPass")
603 // The plan limits pass (internal/console/planlimits.go): publishes each
604 // account's limits record. A no-op with CARLOS_PLAN_CATALOG unset.
605 go srv.RunClaimWorker(context.Background(), "StartPlanLimitsPass")
606 // The plans deployment marker (internal/console/plansmarker.go): once per
607 // start. Writes nothing on a deployment that never set a catalog.
608 go srv.RunClaimWorker(context.Background(), "PublishPlansMarker")
609 // The run sweep (internal/console/runsweep.go): expires queued runs,
610 // ends lost ones, deletes a day-old run's objects. Same placement rule.
611 go srv.RunClaimWorker(context.Background(), "StartRunSweep")
612 // The seeds sweep (internal/console/seedsweep.go, seeded-instances spec
613 // §3.1, §5.6): abandons stale reservations and imports, finishes
614 // rolled-back seeds, deletes snapshots no live seed needs. Same
615 // placement rule as every pass above.
616 go srv.RunClaimWorker(context.Background(), "StartSeedsPass")
617 // The relay's writer lease and its converging reconcile
618 // (internal/console/relayops.go). Same placement rule as every pass
619 // above, and it returns immediately when CARLOS_RELAY_REGIONS is empty —
620 // which is every deployment that has not armed a relay.
621 go srv.RunClaimWorker(context.Background(), "StartRelay")
622 // The email-events push (internal/console/mailpush.go): delivers the
623 // SES outcomes an app asked to be told about. Same placement rule as
624 // every pass above; with nothing queued, a pass is two indexed queries.
625 go srv.RunClaimWorker(context.Background(), "StartMailEventPush")
626 // Door keys (#175 P7): re-sign every app's record before its 72-hour
627 // expiry, once at startup and then every 12 hours. Inert without a
628 // signing key or a deployment bucket.
629 go srv.RunClaimWorker(context.Background(), "StartDoorKeyResign")
630 // Once, at boot: log any route already sitting at another app's platform
631 // apex (internal/console/apex.go). Reports; repairs nothing.
632 go srv.RunClaimWorker(context.Background(), "ReportApexSquats")
633 // Spec §5.7: after any roll, list possible placement discrepancies for
634 // operator review. Read-only; never blocks startup.
635 go srv.RunClaimWorker(context.Background(), "LogPlacementDiscrepancies")
636 addr := envOr("CONSOLE_ADDR", "127.0.0.1:8090")
637 ln, err := net.Listen("tcp", addr)
638 if err != nil {
639 log.Fatalf("console: listen %s: %v", addr, err)
640 }
641 log.Printf("console: %s listening on %s", origin, addr)
642 // ReadHeaderTimeout guards against Slowloris-style slow-header connection
643 // exhaustion — same value and reasoning as internal/edge's public
644 // listeners (edge.go). This console holds no long-lived proxied
645 // connections (no WebSockets), unlike the edge, so unlike the edge it
646 // also sets ReadTimeout: a body-size cap (sesEventMaxBody,
647 // apiShipMaxBytes, …) bounds BYTES, not TIME, and a connection trickling
648 // one byte at a time never trips it. Task 15 added the console's first
649 // unauthenticated body-reading route (sesevents.go) — the SES ingest
650 // endpoint has no login to slow an attacker down before they reach it —
651 // which is what makes a slow-body hold worth closing off here rather
652 // than leaving it as a theoretical gap. 5 minutes is generous enough for
653 // the largest `carlos ship` upload (apiShipMaxBytes, 256 MiB) over a
654 // genuinely slow connection, while still bounding a connection that
655 // would otherwise sit open forever.
656 //
657 // No Addr: the listener above is already open and Serve takes it
658 // directly, so Addr would be a field nothing reads.
659 httpSrv := console.NewHTTPServer(srv)
660 // AFTER the listener is open and the store has migrated (openConsoleDB
661 // and NewServer both ran above), so readiness means what it says. With
662 // console.service declared Type=notify, a build that dies before this
663 // line fails its restart — and the adopt pass rolls the symlink back and
664 // starts the previous version, instead of stamping a build that never
665 // served (internal/release/adopt.go).
666 if err := NotifyReady(); err != nil {
667 log.Printf("console: notifying systemd of readiness: %v", err)
668 }
669 log.Fatal(httpSrv.Serve(ln))
670}
671
672// openServeDB is openConsoleDB for the serving console. With
673// CARLOS_HOST_CLAIM on, the database opens gated: the schema migrates on the
674// trusted boot seam, the table census runs, and every connection is armed
675// before NewServer, so no handler or pass ever sees an unarmed connection.
676// Flag-off it is openConsoleDB.
677func openServeDB(hostClaim bool) *console.Store {
678 if !hostClaim {
679 return openConsoleDB()
680 }
681 st, err := console.OpenGated(envOr("CONSOLE_DB", "/data/console/console.db"))
682 if err != nil {
683 log.Fatal(err)
684 }
685 return st
686}
687
688func openConsoleDB() *console.Store {
689 // Administrative verbs share the structural SQL boundary. Without an
690 // app permit, legacy app grants and migration refuse in enabled mode.
691 opener := console.Open
692 if hostClaimEnabled(os.Getenv) {
693 opener = console.OpenGated
694 }
695 st, err := opener(envOr("CONSOLE_DB", "/data/console/console.db"))
696 if err != nil {
697 log.Fatal(err)
698 }
699 return st
700}
701
702// optionalStore is openStore for the verbs that would LIKE the deployment
703// bucket but must work without it. `console member` reads account manifests
704// out of it to catch up on accounts minted by `carlos accounts create`; a box
705// with neither variable set is a legal deployment (grant/revoke/owners have
706// always needed only CONSOLE_DB), and refusing to run there would take away
707// the membership tooling on exactly the boxes least able to spare it.
708func optionalStore() release.Store {
709 if os.Getenv("CARLOS_DEPLOYMENT_BUCKET") == "" && os.Getenv("CARLOS_DEPLOYMENT_DIR") == "" {
710 return nil
711 }
712 return openStore()
713}
714
715// openStore mirrors the carlos CLI's ~10-line store-open helper (cli.go);
716// duplicated here because cmd/console cannot import package main.
717func openStore() release.Store {
718 if b := os.Getenv("CARLOS_DEPLOYMENT_BUCKET"); b != "" {
719 s, err := release.NewDeploymentS3Store(context.Background(), b)
720 if err != nil {
721 log.Fatal(err)
722 }
723 return s
724 }
725 if d := os.Getenv("CARLOS_DEPLOYMENT_DIR"); d != "" {
726 ds := release.NewDirStore(d)
727 ds.EnableChangeIndex()
728 return ds
729 }
730 log.Fatal("set CARLOS_DEPLOYMENT_BUCKET (S3) or CARLOS_DEPLOYMENT_DIR (local)")
731 return nil
732}
733
734// secretsPub reads CARLOS_SECRETS_PUBKEY for the console to SERVE to CLI
735// clients (the config door) — never to USE. The console has no business
736// sealing or opening anything: secrets are sealed on the operator's machine
737// and opened on the box that holds CARLOS_SECRETS_KEY, and this half only
738// encrypts, which is why it is safe to hand out at all.
739//
740// Optional, like CARLOS_RELEASE_KEY: unset simply omits the field from the
741// config response and leaves every `carlos secrets set` needing the env var
742// in its own environment. Garbage is refused HERE, at boot, rather than
743// served to every CLI to choke on — a pubkey that doesn't parse would
744// otherwise surface as a seal failure on somebody else's machine, hours
745// later, with nothing pointing back at this variable.
746func secretsPub() string {
747 raw := strings.TrimSpace(os.Getenv("CARLOS_SECRETS_PUBKEY"))
748 if raw == "" {
749 return ""
750 }
751 if _, err := appconfig.ParsePublicKey(raw, "CARLOS_SECRETS_PUBKEY"); err != nil {
752 log.Fatal(err)
753 }
754 return raw
755}
756
757// appsDomain reads PLATFORM_APPS_DOMAIN — the same variable the edge and
758// the host agent read — for the CLI's member-facing `carlos instances
759// enable` door, which pins manifests to <sqid>.<this domain>. Garbage is
760// refused HERE, at boot, rather than stamped into a manifest every future
761// instance host gets validated against (the secretsPub precedent): the
762// suffix rides into bucket keys verbatim, so "*.oncarlos.com" copied off a
763// DNS record would produce records the reconcile pass skips — and protects
764// — forever. Unset is a legal deployment: the door answers 503 naming the
765// store-mode operator act.
766// steeringHost derives this deployment's DNS-steering hostname —
767// edge.<apps-domain>, the name the steering converger keeps one latency A
768// record per armed edge on (multi-region ingress spec, Part 4) — for the
769// console's custom-domain guidance.
770//
771// The armed test is steering.SteeringZoneID(): the SAME read, with the same
772// validation, that the converger itself performs on the box
773// (internal/host/steering_loop.go). That is deliberate — it is what stops
774// this console advertising a CNAME target no pass maintains. Unset (every
775// deployment today) or malformed leaves this "" and every guidance string
776// stays byte-for-byte the A-record sentence it has always been.
777func steeringHost() string {
778 if steering.SteeringZoneID() == "" {
779 return ""
780 }
781 d := appsDomain()
782 if d == "" {
783 log.Print("console: CARLOS_STEERING_ZONE_ID is set but PLATFORM_APPS_DOMAIN is not — no steering hostname to advertise; custom-domain guidance keeps the A-record form")
784 return ""
785 }
786 return "edge." + d
787}
788
789func appsDomain() string {
790 d := strings.ToLower(strings.TrimSpace(os.Getenv("PLATFORM_APPS_DOMAIN")))
791 if d == "" {
792 return ""
793 }
794 if !provision.ValidHostShape(d) {
795 log.Fatalf("console: PLATFORM_APPS_DOMAIN %q is not a valid domain (dot-joined DNS labels, no wildcard, no leading dot)", d)
796 }
797 return d
798}
799
800// storeBackend builds the object-storage half of the console's config from
801// CARLOS_STORE_* (issue #55): the Provisioner that creates buckets and mints
802// keys, and the deployment-wide coordinates every store inherits. Unset
803// CARLOS_STORE_BACKEND is a legal deployment — members may still declare
804// stores, and the operator doors refuse with a nil provisioner.
805//
806// EVERY DEFECT IS FATAL HERE, at boot, rather than surfaced as a refusal
807// later, and the backend name most of all: a typo'd backend that fell back to
808// "unconfigured" would boot a console that answers "this deployment has no
809// object storage" to an operator who configured one — the single most
810// confusing answer available, on the box least able to explain it. The
811// coordinates are fatal for a harder reason: region and bucket prefix are
812// stamped into the bucket names and IAM policies this console creates, so a
813// console booted without them would create an estate somewhere the operator
814// did not choose, and the RECORD would then say that is where the app's data
815// lives. CARLOS_STORE_ENDPOINT stays optional: an empty endpoint is what
816// "real AWS S3" means (objstore.NewAWSProvisioner).
817func storeBackend() (objstore.Provisioner, objstore.Deployment) {
818 backend := strings.TrimSpace(os.Getenv("CARLOS_STORE_BACKEND"))
819 if backend == "" {
820 return nil, objstore.Deployment{}
821 }
822 if backend != "aws" {
823 log.Fatalf("console: CARLOS_STORE_BACKEND %q is not a backend this build knows — the only value is \"aws\" (leave it unset for a deployment with no object storage)", backend)
824 }
825 region := strings.TrimSpace(os.Getenv("CARLOS_STORE_REGION"))
826 if region == "" {
827 log.Fatal("console: CARLOS_STORE_BACKEND is set — also set CARLOS_STORE_REGION (the region buckets are created in, e.g. eu-west-1)")
828 }
829 prefix := strings.TrimSpace(os.Getenv("CARLOS_STORE_BUCKET_PREFIX"))
830 if prefix == "" {
831 log.Fatal("console: CARLOS_STORE_BACKEND is set — also set CARLOS_STORE_BUCKET_PREFIX (bucket names are globally unique; e.g. carlos-)")
832 }
833 // The prefix is the head of every bucket name this console will ever mint
834 // (objstore.MintedBucket), so a prefix that cannot BEGIN a legal S3 name —
835 // "Carlos-", "carlos_", a leading dot — is a first grant that fails deep
836 // inside the walk, against a live cloud account, for whoever tries first.
837 // Checked against the shortest name it could produce (a 4-char account
838 // sqid and a 1-char app), because that is the tightest case the same
839 // function will judge later.
840 if !objstore.ValidBucketName(prefix + "aaaa-a") {
841 log.Fatalf("console: CARLOS_STORE_BUCKET_PREFIX %q cannot start an S3 bucket name — lowercase letters, digits, dots and hyphens only, starting alphanumeric (e.g. carlos-)", prefix)
842 }
843 // The permissions boundary is fatal for the hardest reason on this list.
844 // Provisioning a store means CreateUser and PutUserPolicy, and a role
845 // holding those two without a boundary can mint a principal wider than
846 // itself — the console box's own credentials become a route to the whole
847 // cloud account. The deployment's IAM conditions both calls on this ARN
848 // (infra/modules/region-host publishes it as store_user_boundary_arn), so
849 // a console booted without it would fail every grant at the CreateUser
850 // step anyway. Failing here says why, once, instead of once per tenant.
851 boundary := strings.TrimSpace(os.Getenv("CARLOS_STORE_USER_BOUNDARY"))
852 if boundary == "" {
853 log.Fatal("console: CARLOS_STORE_BACKEND is set — also set CARLOS_STORE_USER_BOUNDARY (the permissions-boundary policy ARN store users are created under; infra/modules/region-host outputs it as store_user_boundary_arn)")
854 }
855 endpoint := strings.TrimSpace(os.Getenv("CARLOS_STORE_ENDPOINT"))
856 p, err := objstore.NewAWSProvisioner(context.Background(), region, endpoint, boundary)
857 if err != nil {
858 log.Fatal(err)
859 }
860 return p, objstore.Deployment{Backend: backend, Region: region, Endpoint: endpoint, BucketPrefix: prefix}
861}
862
863// storeOperators parses CARLOS_STORE_OPERATORS (comma-separated addresses) —
864// who may drive the GRANT door for an app's store, and only that one door;
865// rotate is the member's own verb and destroy is the account owner's browser
866// ceremony. Read REGARDLESS of the backend, and deliberately so: the list is
867// an authority statement about people, it costs nothing to hold, and an
868// operator who sets it before wiring the backend should not have to remember
869// to come back for it.
870//
871// Unset is fail-closed rather than fail-open — no operators means no grants —
872// which is the right direction for a list whose whole purpose is to gate
873// spending money in the deployment's own cloud account.
874func storeOperators() []string {
875 var out []string
876 for _, a := range strings.Split(os.Getenv("CARLOS_STORE_OPERATORS"), ",") {
877 if a = strings.TrimSpace(a); a != "" {
878 out = append(out, a)
879 }
880 }
881 return out
882}
883
884// relayRegions parses CARLOS_RELAY_REGIONS (comma-separated AWS region
885// names, e.g. "eu-west-1,us-east-1") — the regions this deployment runs a
886// STUN/TURN relay in, IN THE ORDER an enabled app's CARLOS_RELAY lists them.
887// The order is the operator's, verbatim: it is the order every browser tries
888// the relays in, so sorting it here would quietly re-home calls.
889//
890// Unset is the ordinary shape and means this deployment has no relay: the
891// enable door refuses with a sentence naming this variable, and no relay
892// background loop starts at all. Nothing here is fatal — an unknown region
893// name costs an app a relay it cannot reach, not an estate somewhere the
894// operator did not choose, which is the line storeBackend's fatals draw.
895func relayRegions() []string {
896 var out []string
897 for _, r := range strings.Split(os.Getenv("CARLOS_RELAY_REGIONS"), ",") {
898 if r = strings.ToLower(strings.TrimSpace(r)); r != "" {
899 out = append(out, r)
900 }
901 }
902 return out
903}
904
905// relayOperators parses CARLOS_RELAY_OPERATORS (comma-separated addresses) —
906// who may run the relay's rotate verb, the one that moves a region's issuer
907// pointer to a newly staged master and re-issues every enabled app's key.
908//
909// Same shape and same direction as storeOperators and mailOperators: unset is
910// fail-closed. The authority here is over the DEPLOYMENT's relay key
911// material, not over any one tenant's app, which is why it is its own list
912// rather than account ownership.
913func relayOperators() []string {
914 var out []string
915 for _, a := range strings.Split(os.Getenv("CARLOS_RELAY_OPERATORS"), ",") {
916 if a = strings.TrimSpace(a); a != "" {
917 out = append(out, a)
918 }
919 }
920 return out
921}
922
923// systemOperators parses CARLOS_SYSTEM_OPERATORS (comma-separated addresses):
924// who may run `carlos system status`, `update` from the signed release feed
925// and `rollback` without owning the ops account (discussion #176).
926//
927// The same shape as relayOperators, and unset is the same direction: the
928// doors stay the owners' alone, exactly as before the list existed. Its own
929// list rather than CARLOS_STORE_OPERATORS, because that one reads tenant bytes
930// through store grants and scanning and rolling releases should not.
931func systemOperators() []string {
932 var out []string
933 for _, a := range strings.Split(os.Getenv("CARLOS_SYSTEM_OPERATORS"), ",") {
934 if a = strings.TrimSpace(a); a != "" {
935 out = append(out, a)
936 }
937 }
938 return out
939}
940
941// sidecarAccounts parses CARLOS_SIDECAR_ACCOUNTS (comma-separated account
942// sqids): the accounts that may still enable and create native sidecars
943// while every other account is paused (internal/console/sidecarsgate.go,
944// discussion #370). Each entry is trimmed and lower-cased, and must be a
945// sqid (domains.ValidSqid), never an account name: "ops" is not one, and a
946// name that matched nothing would pause the very account it meant to keep.
947// An invalid entry is an error for serve to stop on.
948//
949// Unset or the empty string is nil, and nil is no gate: every account may
950// enable and create sidecars, exactly as before the list existed. This is
951// the opposite direction to the operator lists above on purpose, so a
952// deployment that never sets it is unaffected. Any other value that names
953// no sqid at all ("," or " , ") is an error too: it reads as a list, and
954// parsing it to nil would quietly turn the gate off.
955func sidecarAccounts(getenv func(string) string) ([]string, error) {
956 raw := getenv("CARLOS_SIDECAR_ACCOUNTS")
957 if raw == "" {
958 return nil, nil
959 }
960 var out []string
961 for _, a := range strings.Split(raw, ",") {
962 a = strings.ToLower(strings.TrimSpace(a))
963 if a == "" {
964 continue
965 }
966 if !domains.ValidSqid(a) {
967 return nil, fmt.Errorf("CARLOS_SIDECAR_ACCOUNTS: %q is not an account sqid (the account's id, e.g. bab, never its name)", a)
968 }
969 out = append(out, a)
970 }
971 if len(out) == 0 {
972 return nil, fmt.Errorf("CARLOS_SIDECAR_ACCOUNTS: %q names no account sqid; unset it for no gate, or list at least one sqid", raw)
973 }
974 return out, nil
975}
976
977// platformDomain reads CARLOS_PLATFORM_DOMAIN — the domain the relay's own
978// hostnames hang off (relay.<region>.<domain>), which an enabled app is
979// handed as its ICE server URLs.
980//
981// Its own variable rather than PLATFORM_APPS_DOMAIN, for the caution
982// DelegationZone and mailDeployment both already state (#167's blind spot):
983// the apps domain is the edge's routing zone, and a deployment may perfectly
984// well serve apps under one name and run its relay under another. Unset is
985// legal and falls back to the apps domain with a leading "apps." label
986// stripped (console.Server.relayDomain) — the one relationship between the
987// two this console can derive without guessing.
988//
989// Shape-checked at boot, like appsDomain and for the same reason: the value
990// rides verbatim into every enabled app's config, where a typo would be a
991// STUN URL that resolves to nothing and a call that silently never connects.
992func platformDomain() string {
993 d := strings.ToLower(strings.TrimSpace(os.Getenv("CARLOS_PLATFORM_DOMAIN")))
994 if d == "" {
995 return ""
996 }
997 if !provision.ValidHostShape(d) {
998 log.Fatalf("console: CARLOS_PLATFORM_DOMAIN %q is not a valid domain (dot-joined DNS labels, no wildcard, no leading dot)", d)
999 }
1000 return d
1001}
1002
1003// mailOperators parses CARLOS_MAIL_OPERATORS (comma-separated addresses) —
1004// who may drive the email pause/cap door (apimail.go's handleAPIEmailPause
1005// and mailOperator's own doc comment explain why this is a SEPARATE list
1006// from the fleet-operator bit console grant -operator sets, not a
1007// replacement for it: pausing a tenant's shared sending reputation is a
1008// narrower authority than "can see the fleet bill," and a deployment may
1009// want to grant the two to different people).
1010//
1011// Same shape as storeOperators, for the same reason: unset is fail-closed,
1012// not fail-open — no addresses means NO fleet operator may drive this
1013// door, not "any fleet operator may." That is the right direction for a
1014// list whose purpose is a per-tenant abuse brake, not merely a spending
1015// gate.
1016func mailOperators() []string {
1017 var out []string
1018 for _, a := range strings.Split(os.Getenv("CARLOS_MAIL_OPERATORS"), ",") {
1019 if a = strings.TrimSpace(a); a != "" {
1020 out = append(out, a)
1021 }
1022 }
1023 return out
1024}
1025
1026// mailDeployment builds the tenant-email half of the console's config from
1027// CARLOS_MAIL_* (tenant-email spec §7, §9): the region and platform suffix
1028// every mail walk needs, plus the optional DMARC aggregate-report address
1029// and SNS topic ARN that feed the bounce/complaint counts mailcounts.go
1030// enforces caps against.
1031//
1032// DELIBERATELY INDEPENDENT OF PLATFORM_APPS_DOMAIN, the same caution
1033// DelegationZone's own comment above already applies to a different zone
1034// (#167's blind spot): PLATFORM_APPS_DOMAIN is the edge's routing zone,
1035// and conflating it with the zone SES sending identities are verified
1036// under would repeat the exact class of bug #167 already cost this
1037// codebase once, just for a new pair of variables.
1038//
1039// NOTHING HERE IS FATAL, unlike storeBackend's coordinates (the user
1040// boundary, CARLOS_MAIL_USER_BOUNDARY, is read and checked separately by
1041// mailUserBoundary, and that one is fatal), and — unlike
1042// the reasoning that used to sit in this comment — that stays true even
1043// now that mailBackends actually constructs a Provisioner and DNS writer
1044// from Region and PlatformSuffix. A store deployment booted with the wrong
1045// region stamps that region into bucket names and IAM policies it then
1046// actually creates — a real, misplaced estate the operator is stuck with.
1047// A blank or wrong mail Region/PlatformSuffix cannot do that: blank simply
1048// leaves mailBackends' constructors uncalled (MailProvisioner/DNSWriter
1049// stay nil, mailProvisionerGate's 404 covers the rest, same as a
1050// deployment with no mail estate at all), and a WRONG-but-set region fails
1051// the SES/STS call inside NewAWSProvisioner instead of silently creating
1052// anything — mailBackends logs that failure and leaves the field nil
1053// rather than crashing console boot (unlike storeBackend's
1054// log.Fatal — see mailBackends' own doc comment for why mail chose the
1055// other direction). Garbage still costs an operator a debugging session,
1056// just never an AWS estate in the wrong place, which is the asymmetry that
1057// keeps these fields non-fatal where storeBackend's are not.
1058func mailDeployment() mail.Deployment {
1059 return mail.Deployment{
1060 Region: strings.TrimSpace(os.Getenv("CARLOS_MAIL_REGION")),
1061 PlatformSuffix: strings.ToLower(strings.TrimSpace(os.Getenv("CARLOS_MAIL_PLATFORM_SUFFIX"))),
1062 DMARCRua: strings.TrimSpace(os.Getenv("CARLOS_MAIL_DMARC_RUA")),
1063 SNSTopicARN: strings.TrimSpace(os.Getenv("CARLOS_MAIL_SNS_TOPIC_ARN")),
1064 // The proof-of-control lookup for a domain whose SES identity this
1065 // account did not create (#216). Read-only DNS, no AWS.
1066 Resolver: net.DefaultResolver,
1067 }
1068}
1069
1070// mailUserBoundary checks CARLOS_MAIL_USER_BOUNDARY (raw) against the mail
1071// region and returns it trimmed. It is the one mail setting that IS fatal,
1072// for storeBackend's CARLOS_STORE_USER_BOUNDARY reason: the mail provisioner
1073// calls CreateUser and PutUserPolicy, and a role holding those without a
1074// boundary can make a user wider than itself, which turns the console's
1075// credentials into a route to the whole AWS account. Without a region there
1076// is no provisioner, so the boundary is not needed and not checked.
1077func mailUserBoundary(region, raw string) (string, error) {
1078 if region == "" {
1079 return "", nil
1080 }
1081 boundary := strings.TrimSpace(raw)
1082 if boundary == "" {
1083 return "", errors.New("console: CARLOS_MAIL_REGION is set, so also set CARLOS_MAIL_USER_BOUNDARY (the ARN of the permissions-boundary policy that email sending users are created under; leave CARLOS_MAIL_REGION unset for a console with no tenant email)")
1084 }
1085 if err := mail.CheckUserBoundary(boundary); err != nil {
1086 return "", fmt.Errorf("console: CARLOS_MAIL_USER_BOUNDARY: %w", err)
1087 }
1088 return boundary, nil
1089}
1090
1091// mailBackends constructs the tenant-email Provisioner and DNS Writer from
1092// dep's coordinates — the wiring storeBackend has had since issue #55, now
1093// extended to mail (task 17, "the last code step before the feature can be
1094// exercised against real AWS"). newProvisioner and newWriter are seams for
1095// tests: production always passes mail.NewAWSProvisioner and
1096// dnsrec.NewRoute53Writer, a test passes a fake that never touches the
1097// network.
1098//
1099// Each half switches on ITS OWN coordinate, independently:
1100//
1101// - dep.Region == "" means no SES estate this deployment owns — leave
1102// MailProvisioner nil, call newProvisioner not at all. Every email door
1103// keeps 404ing via mailProvisionerGate, exactly as before this function
1104// existed.
1105// - dep.PlatformSuffix == "" means no zone CARLOS owns to publish into —
1106// leave DNSWriter nil, call newWriter not at all. Platform domains then
1107// emit records for the member to publish themselves instead of CARLOS
1108// publishing them, which is a supported shape
1109// (mailDeploymentFor's doc comment in internal/console/apimail.go), not
1110// a degraded one — so a deployment can have a Provisioner and no
1111// Writer on purpose.
1112//
1113// A CONSTRUCTION FAILURE IS NOT FATAL, unlike storeBackend's log.Fatal on
1114// the same kind of error. Both constructors do network I/O (SES/STS,
1115// Route53) and can fail for reasons that have nothing to do with whether
1116// the console itself is healthy: no AWS credentials on this box, a bad
1117// region, an AWS outage. storeBackend's coordinates get baked into bucket
1118// names and IAM policies the moment they are used, so a console that boots
1119// with the wrong ones would go on to create a real, misplaced estate —
1120// log.Fatal there stops that before it happens. Mail has no equivalent: a
1121// failed construction here simply means email stays unavailable, which
1122// mailProvisionerGate already reports as a clean 404 to every caller. A
1123// console that refuses to start at all because SES was briefly
1124// unreachable would take down every OTHER door — apps, domains, routes,
1125// billing — over one feature's dependency, which is a far worse outage
1126// than email alone being down. So: log loudly, naming what failed and the
1127// consequence, leave the field nil, and keep booting.
1128func mailBackends(
1129 dep mail.Deployment,
1130 newProvisioner func(context.Context, string) (mail.Provisioner, error),
1131 newWriter func(context.Context) (dnsrec.Writer, error),
1132) (mail.Provisioner, dnsrec.Writer) {
1133 var provisioner mail.Provisioner
1134 if dep.Region != "" {
1135 p, err := newProvisioner(context.Background(), dep.Region)
1136 if err != nil {
1137 log.Printf("console: email doors will refuse — SES provisioner unavailable (region %s): %v", dep.Region, err)
1138 } else {
1139 provisioner = p
1140 }
1141 }
1142 var writer dnsrec.Writer
1143 if dep.PlatformSuffix != "" {
1144 w, err := newWriter(context.Background())
1145 if err != nil {
1146 log.Printf("console: platform email domains will emit records for the member instead of CARLOS publishing them — Route53 DNS writer unavailable: %v", err)
1147 } else {
1148 writer = w
1149 }
1150 }
1151 return provisioner, writer
1152}
1153
1154// consoleMailer builds the console's own email sender, the invite fallback
1155// when Keymail cannot deliver (internal/console/consolemail.go). From is
1156// CARLOS_CONSOLE_MAIL_FROM, else noreply on the ops account's platform name.
1157// Like mailBackends, nothing here is fatal: no From, no provisioner or no
1158// SES client leaves invites on Keymail and the hand-off link.
1159func consoleMailer(dep mail.Deployment, releases release.Store, p mail.Provisioner, w dnsrec.Writer,
1160 newSES func(context.Context, string) (mail.EmailSender, error), hostClaim bool) (console.ConsoleMailer, func(context.Context)) {
1161 if hostClaim {
1162 log.Print("console: no console email while CARLOS_HOST_CLAIM is on (the console mailer does not go through the claim mail gate yet; #250); invites go by Keymail or by hand")
1163 return nil, func(context.Context) {}
1164 }
1165 from := console.ConsoleMailFrom(os.Getenv("CARLOS_CONSOLE_MAIL_FROM"), dep.PlatformSuffix)
1166 if from == "" || p == nil {
1167 log.Printf("console: no console email (set CARLOS_CONSOLE_MAIL_FROM or CARLOS_MAIL_PLATFORM_SUFFIX, and CARLOS_MAIL_REGION); invites go by Keymail or by hand")
1168 return nil, func(context.Context) {}
1169 }
1170 ses, err := newSES(context.Background(), dep.Region)
1171 if err != nil {
1172 log.Printf("console: no console email, SES client unavailable: %v", err)
1173 return nil, func(context.Context) {}
1174 }
1175 return console.NewConsoleMailer(from, releases, p, w, dep, ses)
1176}
1177
1178// edgeIPs parses PLATFORM_EDGE_IPS (comma-separated) — the SAME variable the
1179// agent's domains reconcile loop reads, so the console's point-your-DNS-here
1180// guidance and the agent's DNS health checks can never name different
1181// addresses. Unset is legal: guidance falls back to naming "this
1182// deployment's edge IP" in prose.
1183func edgeIPs() []string {
1184 var out []string
1185 for _, ip := range strings.Split(os.Getenv("PLATFORM_EDGE_IPS"), ",") {
1186 if ip = strings.TrimSpace(ip); ip != "" {
1187 out = append(out, ip)
1188 }
1189 }
1190 return out
1191}
1192
1193// redirectHosts parses CONSOLE_REDIRECT_HOSTS (comma-separated) — retired
1194// hostnames this console answers with a permanent redirect to CONSOLE_ORIGIN
1195// (server.go's ServeHTTP), rather than serving the app in place. Opt-in per
1196// host on purpose: a host that must keep serving the API — CLI bearer
1197// tokens are host-agnostic, and a POST redirected through an unexpected hop
1198// is how clients break — is simply left off this list, unlike EdgeIPs above
1199// there's no single deployment-wide default to fall back to.
1200func redirectHosts() []string {
1201 var out []string
1202 for _, h := range strings.Split(os.Getenv("CONSOLE_REDIRECT_HOSTS"), ",") {
1203 if h = strings.TrimSpace(h); h != "" {
1204 out = append(out, h)
1205 }
1206 }
1207 return out
1208}
1209
1210// secretsEnvelopeV2 parses CONSOLE_SECRETS_ENVELOPE_V2: whether secrets this
1211// console seals (relay, mail, object storage) and the CLI seals through it
1212// are bound v2 envelopes (appconfig.Binding, discussion #220). Off by
1213// default, because a box on a binary that predates v2 cannot open one and
1214// its whole config render for that app stalls. Turn it on once every box
1215// runs a build that reads v2. A value strconv.ParseBool cannot read stops
1216// the boot, as for the other boolean settings.
1217func secretsEnvelopeV2() bool {
1218 v := strings.TrimSpace(os.Getenv("CONSOLE_SECRETS_ENVELOPE_V2"))
1219 if v == "" {
1220 return false
1221 }
1222 b, err := strconv.ParseBool(v)
1223 if err != nil {
1224 log.Fatalf("console: CONSOLE_SECRETS_ENVELOPE_V2 %q is not a boolean (use true or false)", v)
1225 }
1226 return b
1227}
1228
1229// dbForward parses CARLOS_DB_FORWARD: "1" turns on forwarding a carlos db
1230// read to the region that holds the data (db region forward spec §11.1).
1231// Unset, every db door answer is byte-identical to main. Anything else stops
1232// the boot, as for the other switches.
1233func dbForward() bool {
1234 switch v := strings.TrimSpace(os.Getenv("CARLOS_DB_FORWARD")); v {
1235 case "":
1236 return false
1237 case "1":
1238 return true
1239 default:
1240 log.Fatalf("console: CARLOS_DB_FORWARD %q is not 1 (or unset)", v)
1241 return false
1242 }
1243}
1244
1245// reservePlatformDomains parses CONSOLE_RESERVE_PLATFORM_DOMAINS, the opt-in
1246// for a deployment that hosts apps for other people (the flagship, carloku).
1247// When it is set, `carlos domains attach` also refuses everything under
1248// PLATFORM_APPS_DOMAIN and under the console's own hostnames
1249// (console.Config.ReservePlatformDomains). Unset means false. A value that
1250// strconv.ParseBool cannot read stops the boot. A typo in a security setting
1251// must not run the console as if the setting were off.
1252func reservePlatformDomains() bool {
1253 v := strings.TrimSpace(os.Getenv("CONSOLE_RESERVE_PLATFORM_DOMAINS"))
1254 if v == "" {
1255 return false
1256 }
1257 b, err := strconv.ParseBool(v)
1258 if err != nil {
1259 log.Fatalf("console: CONSOLE_RESERVE_PLATFORM_DOMAINS %q is not a boolean (use true or false)", v)
1260 }
1261 return b
1262}
1263
1264// domainProof parses CONSOLE_DOMAIN_PROOF, the opt-in for proof of control
1265// on custom hostnames (#215, console.Config.DomainProof). Unset means false;
1266// a value strconv.ParseBool cannot read stops the boot, for the reason
1267// reservePlatformDomains gives.
1268func domainProof() bool {
1269 v := strings.TrimSpace(os.Getenv("CONSOLE_DOMAIN_PROOF"))
1270 if v == "" {
1271 return false
1272 }
1273 b, err := strconv.ParseBool(v)
1274 if err != nil {
1275 log.Fatalf("console: CONSOLE_DOMAIN_PROOF %q is not a boolean (use true or false)", v)
1276 }
1277 return b
1278}
1279
1280// denyDomains parses CONSOLE_DENY_DOMAINS (comma-separated) — hostnames, and
1281// everything under them, that `carlos domains attach` refuses (issue #151).
1282// This is the operator's list of names the console cannot derive on its own:
1283// retired console origins, any other platform-managed domain. A platform
1284// operator may still attach one of these to an app with --platform (#209).
1285// The door itself adds the console's own hostnames, the tunnel host
1286// (tunnelHost) and, with CONSOLE_RESERVE_PLATFORM_DOMAINS,
1287// PLATFORM_APPS_DOMAIN (reservedDomains, internal/console/apidomains.go). An
1288// unset variable is therefore a legal deployment. Blank entries are dropped:
1289// an empty string in the list would be a deny entry no hostname could avoid
1290// matching.
1291func denyDomains() []string {
1292 var out []string
1293 for _, d := range strings.Split(os.Getenv("CONSOLE_DENY_DOMAINS"), ",") {
1294 if d = strings.ToLower(strings.TrimSpace(d)); d != "" {
1295 out = append(out, d)
1296 }
1297 }
1298 return out
1299}
1300
1301// tunnelHost is the fleet hub's reserved hostname (CARLOS_TUNNEL_HOST — the
1302// edge's variable, but host.env is one file per box). The edge Allows it and
1303// no registry route names it, so no derived deny entry covers it; a tenant
1304// must not attach it. #151 made it the operator's job to list. Passing it
1305// to the console here makes the MUST hold without anyone remembering, and
1306// passing it apart from CONSOLE_DENY_DOMAINS keeps it out of reach of the
1307// operator's own --platform door (#209). Env is read at this layer only, as
1308// every other console setting is.
1309func tunnelHost() string {
1310 return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(os.Getenv("CARLOS_TUNNEL_HOST"))), ".")
1311}
1312
1313// storeScannerFor builds the object-storage virus-scanning engine from the
1314// environment, or returns nil — which is the ordinary shape. Scanning is
1315// opt-in and off by default: a deployment that sets nothing gets a nil
1316// Scanner, no pass goroutine, and doors that refuse.
1317//
1318// CARLOS_CLAMD_ADDR host:port, or a path when it begins "/"
1319// CARLOS_CLAMD_SOCKET accepted alias for the same thing
1320// CARLOS_STORE_SCANNER deprecated: "clamav" still wires the default socket
1321// CARLOS_CLAMD_MAX_BYTES default 25 MB — mirror clamd's own StreamMaxLength
1322//
1323// WIRING ONLY. Whether scanning is ON lives in system/antivirus.json, so an
1324// operator turns it on with `carlos system antivirus enable` rather than by
1325// editing a unit file and restarting the control plane.
1326func storeScannerFor(d objstore.Deployment) (objstore.Scanner, bool) {
1327 addr, seed, err := scannerWiring(os.Getenv)
1328 if err != nil {
1329 log.Fatalf("console: %v", err)
1330 }
1331 if addr == "" {
1332 return nil, false
1333 }
1334 // LOUDLY, on every boot, as the migration says. The variable still works,
1335 // which is exactly why it needs saying: a deployment can sit on it
1336 // indefinitely and nobody would learn that the seed only fires while it is
1337 // set, or that the switch is now a command rather than an edit to a unit
1338 // file. One line an operator greps for beats a release note nobody reads.
1339 if seed {
1340 log.Printf("console: CARLOS_STORE_SCANNER is DEPRECATED and is being read as the clamd address (%s). "+
1341 "Set CARLOS_CLAMD_ADDR instead, and use `carlos system antivirus enable|disable` for the switch — "+
1342 "the first tick seeds system/antivirus.json from this variable, and only while it is still set.", addr)
1343 }
1344 // A scanner with no store to scan is a misconfiguration, and one loud
1345 // failure at boot beats a pass that silently finds nothing forever.
1346 if d.Backend == "" {
1347 log.Fatal("console: a clamd address is configured but CARLOS_STORE_BACKEND is not — there are no object stores on this deployment to scan")
1348 }
1349 maxBytes := envBytes("CARLOS_CLAMD_MAX_BYTES", 25<<20)
1350
1351 // THE TAG KEY IS NOT OPTIONAL, and a missing one is fatal here rather than
1352 // a scanner that runs without it.
1353 //
1354 // A verdict is only worth anything because it carries a signature this
1355 // deployment issued: on several S3 implementations an app can write the
1356 // carlos-scan tag on its own object whatever the IAM policy says (a
1357 // browser-direct POST-policy upload does it on MinIO), so authenticity is
1358 // the property and not write-permission. A scanner with no key would write
1359 // verdicts nothing can verify and read every forgery as genuine — strictly
1360 // worse than no scanner at all, because it would report them as clean.
1361 //
1362 // Fatal, and at boot: an operator who has wired clamd and not the key gets
1363 // one line naming the variable, before any tenant byte is read.
1364 signer, err := objstore.NewTagSigner(
1365 os.Getenv("CARLOS_SCAN_TAG_KEY"),
1366 os.Getenv("CARLOS_SCAN_TAG_KEY_PREVIOUS"),
1367 )
1368 if err != nil {
1369 log.Fatalf("console: a clamd address is configured but the scan tag key is not usable: %v — "+
1370 "generate one (32 random bytes is plenty), put it in the deployment's secret store as "+
1371 "CARLOS_SCAN_TAG_KEY, and keep it: every stored verdict is signed with it", err)
1372 }
1373
1374 sc, err := objstore.NewClamAVScanner(context.Background(), d.Region, d.Endpoint, addr, maxBytes, signer)
1375 if err != nil {
1376 log.Fatal(err)
1377 }
1378 return sc, seed
1379}
1380
1381// scannerWiring resolves the clamd address from the environment, and reports
1382// whether the deprecated variable was the thing that supplied it.
1383//
1384// Pure, and takes its own getenv, so the refusals are testable — a fatal path
1385// nobody can exercise is a fatal path nobody has checked.
1386//
1387// The deprecated variable REMAINS A WIRING SOURCE, retaining today's socket
1388// default. Without that, a deployment that set only CARLOS_STORE_SCANNER=clamav
1389// would get no address, no Scanner, no pass goroutine, and scanning would stop
1390// silently on upgrade — the exact failure this file already refuses to permit
1391// elsewhere. The second return is what lets the first pass tick seed
1392// system/antivirus.json and carry the operator's existing intent forward.
1393func scannerWiring(getenv func(string) string) (addr string, fromDeprecated bool, err error) {
1394 trimmed := func(k string) string { return strings.TrimSpace(getenv(k)) }
1395
1396 explicit, socket := trimmed("CARLOS_CLAMD_ADDR"), trimmed("CARLOS_CLAMD_SOCKET")
1397 if explicit != "" && socket != "" && explicit != socket {
1398 return "", false, fmt.Errorf("CARLOS_CLAMD_ADDR (%s) and CARLOS_CLAMD_SOCKET (%s) disagree — set one", explicit, socket)
1399 }
1400 if explicit == "" {
1401 explicit = socket
1402 }
1403
1404 switch engine := trimmed("CARLOS_STORE_SCANNER"); engine {
1405 case "":
1406 return explicit, false, nil
1407 case "clamav":
1408 // GuardDuty is deliberately not wired in this build: its
1409 // 25-protected-buckets-per-account-per-Region quota is not adjustable,
1410 // so it cannot be the primary engine for a platform whose unit of
1411 // tenancy is a bucket.
1412 if explicit == "" {
1413 explicit = "/run/clamav/clamd.ctl"
1414 }
1415 return explicit, true, nil
1416 default:
1417 return "", false, fmt.Errorf("CARLOS_STORE_SCANNER %q is not an engine this build knows — it is deprecated in favour of CARLOS_CLAMD_ADDR, and the only value it ever accepted is \"clamav\"", engine)
1418 }
1419}
1420
1421// envDuration reads a Go duration ("90s", "12h"). A value that does not parse
1422// is fatal rather than silently defaulted: a deployment that meant to scan
1423// hourly and typed it wrong should be told, not quietly given 24h.
1424func envDuration(k string, def time.Duration) time.Duration {
1425 v := strings.TrimSpace(os.Getenv(k))
1426 if v == "" {
1427 return def
1428 }
1429 d, err := time.ParseDuration(v)
1430 if err != nil {
1431 log.Fatalf("console: %s %q is not a duration (e.g. 90s, 30m, 24h): %v", k, v, err)
1432 }
1433 if d <= 0 {
1434 log.Fatalf("console: %s %q must be positive", k, v)
1435 }
1436 return d
1437}
1438
1439// envCount reads a positive count. Zero from an UNSET variable means "the
1440// console's own default" (the caller supplies def = 0 and the server side
1441// substitutes); an explicitly set zero or negative is fatal, because a
1442// deployment that meant to cap something and typed it wrong should be told
1443// rather than quietly given the default it was trying to change.
1444func envCount(k string, def int) int {
1445 v := strings.TrimSpace(os.Getenv(k))
1446 if v == "" {
1447 return def
1448 }
1449 n, err := strconv.Atoi(v)
1450 if err != nil || n <= 0 {
1451 log.Fatalf("console: %s %q must be a positive whole number", k, v)
1452 }
1453 return n
1454}
1455
1456// envBytes reads a plain byte count. Same fatal-on-garbage posture.
1457func envBytes(k string, def int64) int64 {
1458 v := strings.TrimSpace(os.Getenv(k))
1459 if v == "" {
1460 return def
1461 }
1462 n, err := strconv.ParseInt(v, 10, 64)
1463 if err != nil || n <= 0 {
1464 log.Fatalf("console: %s %q must be a positive number of bytes", k, v)
1465 }
1466 return n
1467}
1468
1469// appRegionsSwitch reads CONSOLE_APP_REGIONS: exactly "on" (any case,
1470// trimmed) turns app regions on.
1471func appRegionsSwitch(v string) bool { return strings.EqualFold(strings.TrimSpace(v), "on") }
1472
1473// openGeoIP opens the optional CONSOLE_GEOIP_DB. Unset, unopenable or not
1474// an mmdb means the IP step is off, logged once and never fatal. Returns an
1475// untyped nil on every failure, never a nil *geo.MMDB in the interface.
1476//
1477// The mapping sits behind a geo.Swappable so D1 can close it (geoip spec §0
1478// D1, §11.3, codex R10-1): a GeoLite2 override already past its 30 days is
1479// refused here, and one that expires while the console runs is closed by
1480// watchGeoIPExpiry. The file is the operator's; the platform never deletes
1481// it, and the log says to replace it.
1482func openGeoIP(path string) geo.Locator {
1483 sw := openGeoIPAt(path, time.Now())
1484 if sw == nil {
1485 return nil
1486 }
1487 go watchGeoIPExpiry(sw, path, time.Minute, time.Now)
1488 return sw
1489}
1490
1491func openGeoIPAt(path string, now time.Time) *geo.Swappable {
1492 if path == "" {
1493 return nil
1494 }
1495 // Parsed first in the verifier child: a malformed file must not crash
1496 // the console (code review R9), only turn the IP step off.
1497 vctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
1498 err := console.VerifyGeoIPFile(vctx, path)
1499 cancel()
1500 var m *geo.MMDB
1501 if err == nil {
1502 m, err = geo.OpenMMDBTrusted(path)
1503 }
1504 if err != nil {
1505 log.Printf("console: CONSOLE_GEOIP_DB %s is unusable, so the IP step of region guesses is off: %v", path, firstLineCapped(err))
1506 return nil
1507 }
1508 if geoipExpired(m.Source(), m.BuildEpoch(), now) {
1509 m.Close()
1510 log.Printf("console: CONSOLE_GEOIP_DB %s is a GeoLite2 database more than 30 days old; it was not opened. replace it with a current edition and destroy the old file", path)
1511 return nil
1512 }
1513 log.Printf("console: GeoIP database loaded from %s", path)
1514 sw := geo.NewSwappable()
1515 sw.Swap(m)
1516 return sw
1517}
1518
1519// watchGeoIPExpiry closes the override once D1 says so. It returns when it
1520// has closed it (nothing left to watch).
1521func watchGeoIPExpiry(sw *geo.Swappable, path string, every time.Duration, now func() time.Time) {
1522 t := time.NewTicker(every)
1523 defer t.Stop()
1524 for range t.C {
1525 if geoIPExpiryTick(sw, path, now()) {
1526 return
1527 }
1528 }
1529}
1530
1531// geoipExpired is D1's rule; a var so a test can apply it to the GeoIP2
1532// fixtures, which never expire.
1533var geoipExpired = geoip.Expired
1534
1535// geoIPExpiryTick is one check; true once the reader has been closed.
1536func geoIPExpiryTick(sw *geo.Swappable, path string, now time.Time) bool {
1537 src, build, ok := sw.Loaded()
1538 if !ok {
1539 return true
1540 }
1541 if !geoipExpired(src, build, now) {
1542 return false
1543 }
1544 sw.Swap(nil)
1545 log.Printf("console: CONSOLE_GEOIP_DB %s expired (GeoLite2, 30 days after build) and was closed. replace it with a current edition, destroy the old file, and restart", path)
1546 return true
1547}
1548
1549// maxLoggedErrBytes caps what firstLineCapped returns.
1550const maxLoggedErrBytes = 200
1551
1552// firstLineCapped reduces an error to its first line, at most
1553// maxLoggedErrBytes bytes (never splitting a rune). OpenMMDB's error can
1554// carry multi-line decoded database contents, which the log must not.
1555func firstLineCapped(err error) string {
1556 if err == nil {
1557 return ""
1558 }
1559 s := err.Error()
1560 if i := strings.IndexAny(s, "\r\n"); i >= 0 {
1561 s = s[:i]
1562 }
1563 if len(s) > maxLoggedErrBytes {
1564 cut := maxLoggedErrBytes
1565 for cut > 0 && !utf8.RuneStart(s[cut]) {
1566 cut--
1567 }
1568 s = s[:cut]
1569 }
1570 return s
1571}
1572
1573func envOr(k, def string) string {
1574 if v := os.Getenv(k); v != "" {
1575 return v
1576 }
1577 return def
1578}
1579
1580// dbDoorWiring reads what the `carlos db` door needs (internal/console
1581// apidb.go; spec 2026-09-02 §6): the region data bucket triple, parsed by
1582// the activator's own ConfigFromEnv so the console reads exactly the
1583// prefixes the agent on this box writes, plus CARLOS_REGION, this box's
1584// placement, which an instance record's region must equal. An empty bucket
1585// leaves the door answering 503. A config the agent itself would refuse
1586// turns the door off rather than failing console boot: the console's job is
1587// not to police the agent's idle timer.
1588func dbDoorWiring() (bucket, region, prefix, placement string) {
1589 placement = strings.TrimSpace(os.Getenv("CARLOS_REGION"))
1590 cfg, err := activator.ConfigFromEnv()
1591 if err != nil {
1592 log.Printf("console: %v; the carlos db door is off", err)
1593 return "", "", "", placement
1594 }
1595 if cfg == nil {
1596 return "", "", "", placement
1597 }
1598 return cfg.Bucket, cfg.Region, cfg.Prefix, placement
1599}
1600
1601// recordViewWalkTimeout bounds one backstop rebuild, so a stuck index poll
1602// or listing never holds the record view (and with it the sweeps' refresh)
1603// for good. It is the run sweep's pass budget, which has always held one
1604// full record walk.
1605const recordViewWalkTimeout = 5 * time.Minute
1606
1607// armChangeIndex returns the console's record view over rel when
1608// CARLOS_CHANGE_INDEX is exactly "read" (change-index design §7), or nil. A
1609// host-claim console stays disarmed: the run and seed sweeps, the view's
1610// only readers, are off there, so a view would only cost its backstop walks.
1611func armChangeIndex(getenv func(string) string, hostClaim bool, rel release.Store) *provision.RecordView {
1612 if getenv("CARLOS_CHANGE_INDEX") != "read" {
1613 return nil
1614 }
1615 if hostClaim {
1616 log.Printf("console: change index: CARLOS_CHANGE_INDEX=read is ignored on a host-claim console, whose sweeps are off")
1617 return nil
1618 }
1619 v := provision.NewRecordView(rel, release.NewChangeWatch(rel))
1620 log.Printf("console: change index armed: the run and seed sweeps read the record view (backstop %s)", release.ChangeIndexBackstop)
1621 return v
1622}
1623
1624// recordViewBackstopLoop is the view's backstop (design §4.4): Rebuild at
1625// start and every release.ChangeIndexBackstop, jittered by +-10%, each
1626// bounded by recordViewWalkTimeout. A failed rebuild leaves the view unable
1627// to vouch, so the sweeps walk the bucket until one succeeds.
1628func recordViewBackstopLoop(ctx context.Context, v *provision.RecordView) {
1629 for {
1630 rctx, cancel := context.WithTimeout(ctx, recordViewWalkTimeout)
1631 if err := v.Rebuild(rctx); err != nil {
1632 log.Printf("console: record view backstop: %v", err)
1633 }
1634 cancel()
1635 b := release.ChangeIndexBackstop
1636 select {
1637 case <-ctx.Done():
1638 return
1639 case <-time.After(b - b/10 + rand.N(b/5)):
1640 }
1641 }
1642}
1643
1644// applyMovesSwitch sets console.Config.MovesEnabled from the Phase 1 moves
1645// switch, moves.SwitchEnv. Off (the default) leaves the console answering
1646// exactly as main does today.
1647func applyMovesSwitch(cfg *console.Config, getenv func(string) string) {
1648 cfg.MovesEnabled = moves.Enabled(getenv)
1649}
1650
1651// armMovesPaging arms alerting's sinks from CARLOS_ALERT_WEBHOOK and
1652// CARLOS_ALERT_SNS_TOPIC_ARN for claim recovery and its optional move pass.
1653// Claim loss pages run even when moves are disabled.
1654func armMovesPaging(ctx context.Context, cfg console.Config, getenv func(string) string) bool {
1655 if !cfg.HostClaim {
1656 return false
1657 }
1658 alerting.Arm(alerting.FromEnv(ctx, getenv, nil))
1659 return true
1660}
1661
1662func hostClaimEnabled(getenv func(string) string) bool {
1663 return strings.TrimSpace(getenv("CARLOS_HOST_CLAIM")) != ""
1664}
1665
1666// consoleBoxLabel is the label this box's agent publishes its heartbeat
1667// and alerts under (status.Label of /etc/machine-id, as internal/host does
1668// for a platform box): the console names it in the geoip feed's status so
1669// exactly one box, this one, alerts on feed failure.
1670func consoleBoxLabel() string {
1671 b, err := os.ReadFile("/etc/machine-id")
1672 if err != nil {
1673 return ""
1674 }
1675 return status.Label(strings.TrimSpace(string(b)))
1676}
1677
1678// consoleGeoReader is the console's one GeoIP reader. With CONSOLE_GEOIP_DB
1679// set it is that override (opened once, D1-checked, closed at expiry);
1680// otherwise it follows the generation this box's edge mirrors, under
1681// CARLOS_DATA_DIR (default /data) as carlos/geoip, once a minute.
1682func consoleGeoReader(override string) *geo.Swappable {
1683 if override != "" {
1684 if loc, ok := openGeoIP(override).(*geo.Swappable); ok {
1685 return loc
1686 }
1687 return nil
1688 }
1689 sw := geo.NewSwappable()
1690 f := console.NewGeoipFollower(filepath.Join(envOr("CARLOS_DATA_DIR", "/data"), "carlos", "geoip"), sw)
1691 go f.Run()
1692 return sw
1693}
1694
1695// geoLocator is sw as a geo.Locator, or an untyped nil: a nil *Swappable in
1696// the interface would be non-nil to every `GeoIP != nil` check and panic on
1697// the first Locate.
1698func geoLocator(sw *geo.Swappable) geo.Locator {
1699 if sw == nil {
1700 return nil
1701 }
1702 return sw
1703}
1704
1705func sweepLegacyMail(claims bool, rel release.Store, st *console.Store) {
1706 if claims {
1707 log.Printf("console: mail legacy sweep: %v", mail.ErrClaimMailPending)
1708 return
1709 }
1710 n, err := mail.SweepLegacyRecords(context.Background(), rel, true)
1711 if err != nil {
1712 log.Printf("console: mail legacy sweep: %v", err)
1713 return
1714 }
1715 log.Printf("console: mail legacy sweep -> %d email.json objects removed", n)
1716 if err := st.DropLegacyMailCounts(true); err != nil {
1717 log.Printf("console: mail legacy sweep: dropping mail_counts -> %v", err)
1718 }
1719}
1720
1721// sweepMailBoundaries runs the mail provisioner's user boundary sweep once
1722// and logs what it did: each user it set the boundary on, each it could not,
1723// and a last line saying either that all N mail users are bounded or which
1724// are not. The last line is what an operator reads before requiring the
1725// boundary on iam:CreateUser and iam:PutUserPolicy.
1726//
1727// Under host claims it refuses before any backend call, exactly as
1728// sweepLegacyMail does: p here is the raw provisioner, and every IAM change
1729// to a sending user in claim mode has to go through the shared mail gate
1730// and the owning app's permit (internal/console/claim_backends.go), which
1731// a fleet-wide sweep has neither of.
1732func sweepMailBoundaries(ctx context.Context, claims bool, p mail.Provisioner, logf func(string, ...any)) {
1733 if claims {
1734 logf("console: mail boundary sweep: refused while CARLOS_HOST_CLAIM is on: %v", mail.ErrClaimMailPending)
1735 return
1736 }
1737 sw, ok := p.(mail.UserBoundarySweeper)
1738 if !ok {
1739 logf("console: mail boundary sweep: skipped, no mail provisioner with user boundaries (set CARLOS_MAIL_REGION and CARLOS_MAIL_USER_BOUNDARY)")
1740 return
1741 }
1742 res, err := sw.SweepUserBoundaries(ctx)
1743 for _, u := range res.Fixed {
1744 logf("console: mail boundary sweep: set the boundary on %s", u)
1745 }
1746 var failed []string
1747 for _, f := range res.Failed {
1748 failed = append(failed, f.User)
1749 logf("console: mail boundary sweep: %s NOT bounded: %v", f.User, f.Err)
1750 }
1751 if err != nil {
1752 logf("console: mail boundary sweep: stopped after %d mail users (%d fixed, %d failed), the rest were not checked: %v", res.Total, len(res.Fixed), len(res.Failed), err)
1753 return
1754 }
1755 if len(failed) > 0 {
1756 logf("console: mail boundary sweep: %d of %d mail users NOT bounded: %s", len(failed), res.Total, strings.Join(failed, ", "))
1757 return
1758 }
1759 logf("console: mail boundary sweep: all %d mail users bounded (%d already, %d fixed)", res.Total, res.Bounded, len(res.Fixed))
1760}
1761
1762// regionHAEnabled is CARLOS_REGION_HA as strconv.ParseBool reads it: on
1763// for "1", "t", "true" in any case, off for "", "0", "false" and anything
1764// unparseable. The same rule as the box's (internal/host/boxrecord.go).
1765func regionHAEnabled(getenv func(string) string) bool {
1766 on, err := strconv.ParseBool(strings.TrimSpace(getenv("CARLOS_REGION_HA")))
1767 return err == nil && on
1768}
1769
1770// planCatalog loads the plan catalog named by CARLOS_PLAN_CATALOG
1771// (plans-caps-packs spec §4.1). Unset means plans are off: nil, nil.
1772func planCatalog() (*plans.Catalog, error) {
1773 path := strings.TrimSpace(os.Getenv("CARLOS_PLAN_CATALOG"))
1774 if path == "" {
1775 return nil, nil
1776 }
1777 return plans.LoadCatalog(path)
1778}
1779