// Command console is the CARLOS platform console — the app behind // https://platform.carlosframework.com. Sign up / sign in with Keymail, see // your apps and their live platform state; with a signing key configured, // promote, roll back, and run canaries from the browser. Registry access // stays read-only; the only platform state the console writes is signed // channel pointers in the deployment bucket. // // Verbs: // // console serve run the HTTP server // console grant
make address an owner of app // console revoke
remove that ownership // console grant -operator
make address a fleet operator // console revoke -operator
remove that operator role // console owners list every owner and their apps // console member -add
[-role owner|member] // console member -remove
// console member -list // membership IS authorization in the // accounts model — a member can deploy // every app in the account — so adding // and REMOVING one is box-side authority // console migrate-app -app -to // re-home an app onto another account — // the console half of // `carlos accounts migrate` // // serve reads: CONSOLE_ADDR (default 127.0.0.1:8090), CONSOLE_DB (default // /data/console/console.db), CONSOLE_ORIGIN (required, e.g. // https://platform.carlosframework.com), CONSOLE_KEYMAIL (default // https://keymail.dev), CARLOS_REGISTRY (default /data/registry.db), // CARLOS_DEPLOYMENT_BUCKET or CARLOS_DEPLOYMENT_DIR, CARLOS_RELEASE_PUBKEY, // and optionally CARLOS_RELEASE_KEY (enables promote/rollback from the // console; unset = read-only), CARLOS_SECRETS_PUBKEY (SERVED to CLI // clients so `carlos secrets set` can seal with a login alone; never used // here), PLATFORM_APPS_DOMAIN (the same variable the edge and agent read; // enables the CLI's member-facing `carlos instances enable`, whose manifest // domain is pinned to .), and CONSOLE_REDIRECT_HOSTS // (comma-separated retired hostnames this console 301/308s to CONSOLE_ORIGIN // — opt-in per host, so a host the API must keep answering on, like // platform.carlosframework.com for CLI tokens, is simply left off). // // Object storage (issue #55) is off unless CARLOS_STORE_BACKEND is set, in // which case it must be "aws" and CARLOS_STORE_REGION, // CARLOS_STORE_BUCKET_PREFIX and CARLOS_STORE_USER_BOUNDARY are required // (CARLOS_STORE_ENDPOINT is optional — set it for an S3-compatible backend). CARLOS_STORE_OPERATORS // (comma-separated addresses) gates the GRANT door only — rotate is the // member's own verb and destroy is the account owner's browser ceremony; // it is read whether or not a backend is configured. // // CARLOS_TEMPORARY_APPS=1 turns on temporary apps (carlos deploy // --temporary); off by default, see the temporary static sites rollout. // // CARLOS_SIDECAR_ACCOUNTS (comma-separated account sqids) pauses native // sidecars for every account it does not name: enabling sidecars and // creating one answer 403 sidecars-paused, while list, get, wake and delete // keep working and existing sidecars keep running. Native sidecars run on // the flat tier, outside the nested tenants' isolation, until discussion // #370 lands. Unset or empty is no gate. An entry that is not a sqid, or a // value that names no sqid at all, stops the console at startup. // // Tenant email (task 17) reads CARLOS_MAIL_OPERATORS (comma-separated // addresses; narrows the fleet-operator bit further for the pause/cap // door only — see mailOperators' doc comment), CARLOS_MAIL_REGION, // CARLOS_MAIL_PLATFORM_SUFFIX, CARLOS_MAIL_DMARC_RUA, // CARLOS_MAIL_SNS_TOPIC_ARN and CARLOS_MAIL_USER_BOUNDARY (below). All six // are read regardless of whether a mail Provisioner is wired. // CARLOS_MAIL_REGION set turns it on: mailBackends // constructs a real AWS SES/IAM Provisioner, and every email door stops // 404ing (mailProvisionerGate); unset leaves the Provisioner nil, same // "config costs nothing to hold ahead of the backend" reasoning as // CARLOS_STORE_OPERATORS above. CARLOS_MAIL_PLATFORM_SUFFIX set additionally // turns on a Route53 DNS Writer, so CARLOS publishes DNS for domains under // that suffix itself rather than handing the member records to publish by // hand. Unlike object storage, a construction failure for either (bad // region, no AWS credentials, an AWS outage) is logged and leaves the // field nil rather than stopping the console from booting — see // mailBackends' doc comment. // // CARLOS_MAIL_USER_BOUNDARY is the one mail setting that IS fatal. With // CARLOS_MAIL_REGION set it is required: the ARN of the IAM managed policy // every sending user (/carlos-mail/ path) is created under, as // CARLOS_STORE_USER_BOUNDARY is for store users (mailUserBoundary). The // boundary should allow ses:SendRawEmail (the one action the inline policy, // mail.SendPolicyJSON, grants) and nothing else; each user's inline policy // narrows it to that app's identities. A user created before the boundary // existed is given it the next time EnsureUser or MintKey runs for it // (`carlos email enable`, a standalone credential, a domain change // refreshing the app's policies, or a key rotation), and // CARLOS_MAIL_BOUNDARY_SWEEP=1 sweeps every /carlos-mail/ user once at // start (sweepMailBoundaries) for the ones no walk reaches. So the console // role needs iam:PutUserPermissionsBoundary on user/carlos-mail/* beside // CreateUser, GetUser, PutUserPolicy and the key calls, and iam:ListUsers // (account-wide, Resource "*") for the sweep. Condition // PutUserPermissionsBoundary on iam:PermissionsBoundary equal to this ARN // from the start, or the role can set any boundary at all. Once every // console runs a build that sets the boundary and the sweep reports every // user bounded, condition CreateUser and PutUserPolicy on it as well. // EnsureUser sets the boundary before it writes the policy, so a // conditioned PutUserPolicy does not block the backfill. The rollout order // is in README.md. // Never grant iam:DeleteUserPermissionsBoundary; nothing here calls it. // // The console's own email (the invite fallback when Keymail cannot deliver) // sends From CARLOS_CONSOLE_MAIL_FROM, defaulting to // noreply@console-mail.bab. when the suffix is set. // It needs CARLOS_MAIL_REGION (a mail Provisioner) and the host role's // ses:SendEmail grant from infra/modules/region-host's console_mail_from and // console_mail_region variables, which must equal the resolved From and // CARLOS_MAIL_REGION. With neither a From nor a suffix there is no console // email, and invites go by Keymail or by hand. If the domain is already an // SES identity the console did not create, mail.EnsureDomain refuses it // until a _carlos-mail. TXT proof is published (#216). There is no // console email while CARLOS_HOST_CLAIM is on, because the console mailer // does not yet go through the claim mail gate (#250). // CARLOS_CONSOLE_NAME is how the console's emails name it ("Your sign-in link // for "); unset, they say "the CARLOS platform". // // grant/revoke/owners need only CONSOLE_DB. package main import ( "context" "crypto/ed25519" "errors" "flag" "fmt" "log" "math/rand/v2" "net" "os" "path/filepath" "sort" "strconv" "strings" "time" "unicode/utf8" "github.com/carlosframework/platform/internal/activator" "github.com/carlosframework/platform/internal/alerting" "github.com/carlosframework/platform/internal/appconfig" "github.com/carlosframework/platform/internal/console" "github.com/carlosframework/platform/internal/dnsrec" "github.com/carlosframework/platform/internal/domains" "github.com/carlosframework/platform/internal/geo" "github.com/carlosframework/platform/internal/geoip" "github.com/carlosframework/platform/internal/lease" "github.com/carlosframework/platform/internal/mail" "github.com/carlosframework/platform/internal/moves" "github.com/carlosframework/platform/internal/objstore" "github.com/carlosframework/platform/internal/plans" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/rootaws" "github.com/carlosframework/platform/internal/status" "github.com/carlosframework/platform/internal/steering" ) func main() { rootaws.ScrubEnvironment() log.SetFlags(0) if len(os.Args) < 2 { usage() } switch os.Args[1] { case console.GeoipVerifyVerb: // The GeoIP verifier child (internal/console/geoipverify.go): parses // an untrusted .mmdb so the serving process never has to. os.Exit(console.RunGeoipVerify(os.Args[2:], os.Stdout)) case "serve": serve() case "grant", "revoke": verb := os.Args[1] fs := flag.NewFlagSet(verb, flag.ExitOnError) operator := fs.Bool("operator", false, "grant/revoke the fleet-operator role instead of an app") fs.Parse(os.Args[2:]) args := fs.Args() st := openConsoleDB() var err error switch { case *operator: // Box-side only, exactly like an app grant: no app argument, and // it works whether or not the address has ever signed in. if len(args) != 1 { log.Fatalf("usage: console %s -operator
", verb) } err = st.SetOperator(args[0], verb == "grant") default: if len(args) != 2 { log.Fatalf("usage: console %s
", verb) } if verb == "grant" { err = st.Grant(args[0], args[1]) } else { err = st.Revoke(args[0], args[1]) } } if err != nil { log.Fatal(err) } case "member": runMember(os.Args[2:]) case "migrate-app": migrateApp(os.Args[2:]) case "owners": st := openConsoleDB() owners, err := st.Owners() if err != nil { log.Fatal(err) } addrs := make([]string, 0, len(owners)) for a := range owners { addrs = append(addrs, a) } sort.Strings(addrs) for _, a := range addrs { fmt.Printf("%s\t%s\n", a, strings.Join(owners[a], " ")) } default: usage() } } func usage() { log.Fatal("usage: console serve | grant [-operator]
[] | revoke [-operator]
[] | owners | " + "member -add|-remove
[-role owner|member] | member -list | " + "migrate-app -app -to [-from ]") } // migrateApp completes the second half of `carlos accounts migrate`: that // verb moves the app's bucket objects and re-stamps its registry routes, but // the console's own app→account row is CONSOLE state, and the carlos binary // has no business opening this database. So the move finishes here, box-side, // exactly like `console grant`. // // Idempotent, so an operator who is unsure whether it landed can run it // again; an unknown app or account is refused before anything is written. func migrateApp(args []string) { fs := flag.NewFlagSet("migrate-app", flag.ExitOnError) app := fs.String("app", "", "app to move") to := fs.String("to", "", "destination account sqid") from := fs.String("from", "", "source account sqid (default: the one account that claims the app)") fs.Parse(args) if *app == "" || *to == "" { log.Fatal("usage: console migrate-app -app -to [-from ]") } st := openConsoleDB() defer st.Close() dst, err := st.AccountBySqid(*to) if err != nil { log.Fatal(err) } if dst == nil { log.Fatalf("no account %q in this console — create it first (the switcher's \"New account\", or carlos accounts create)", *to) } holders, err := st.AppAccounts(*app) if err != nil { log.Fatal(err) } if len(holders) == 0 { log.Fatalf("no app %q in this console", *app) } if len(holders) == 1 && holders[0] == dst.ID { fmt.Printf("%s is already in %s (%s) — nothing to do\n", *app, *to, dst.Name) return } var src int64 switch { case *from != "": a, err := st.AccountBySqid(*from) if err != nil { log.Fatal(err) } if a == nil { log.Fatalf("no account %q in this console", *from) } src = a.ID case len(holders) == 1: src = holders[0] default: // Several accounts claim this name — legitimate, since names are // unique per account rather than globally. Picking one would re-home // the wrong tenant's app, so say which ones and stop. var names []string for _, id := range holders { if a, _ := st.AccountByID(id); a != nil { names = append(names, a.Sqid+" ("+a.Name+")") } } log.Fatalf("%q is claimed by several accounts — pass -from : %s", *app, strings.Join(names, ", ")) } if err := st.MoveApp(*app, src, dst.ID); err != nil { log.Fatal(err) } fmt.Printf("moved %s to %s (%s)\n", *app, *to, dst.Name) } func serve() { origin := strings.TrimSuffix(os.Getenv("CONSOLE_ORIGIN"), "/") if origin == "" { log.Fatal("set CONSOLE_ORIGIN (e.g. https://platform.carlosframework.com)") } pub, err := release.ParsePublicKey(os.Getenv("CARLOS_RELEASE_PUBKEY"), "CARLOS_RELEASE_PUBKEY") if err != nil { log.Fatal(err) } // The signing key is optional: without it the console runs read-only and // the promote/rollback surface is disabled. Never logged, never rendered. var key ed25519.PrivateKey if os.Getenv("CARLOS_RELEASE_KEY") != "" { // A SCOPED key here would be a silent trap: the console signs every // promotion as if it held the root key, so a scoped seed would produce // pointers with no grant attached, which nothing in the fleet accepts // — and the operator would learn about it from a box that stopped // adopting. Scoped keys are for automation; the console promotes with // the deployment's own authority or not at all. if release.IsScopedSeed(os.Getenv("CARLOS_RELEASE_KEY")) { 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") } key, err = release.ParseSigningKey(os.Getenv("CARLOS_RELEASE_KEY"), "CARLOS_RELEASE_KEY") if err != nil { log.Fatal(err) } } else { log.Print("console: CARLOS_RELEASE_KEY unset — promotions disabled, running read-only") } // Before anything is opened: a typo in the allowlist must stop the // console rather than quietly pause, or unpause, the wrong accounts. sidecarAccts, err := sidecarAccounts(os.Getenv) if err != nil { log.Fatalf("console: %v", err) } hostClaim := hostClaimEnabled(os.Getenv) st := openServeDB(hostClaim) storeProvisioner, storeDeployment := storeBackend() storeScanner, seedAntivirus := storeScannerFor(storeDeployment) catalog, err := planCatalog() if err != nil { // A broken catalog must never quietly mean "no limits". log.Fatalf("console: CARLOS_PLAN_CATALOG: %v", err) } var callbackSigner *console.CallbackSigner if os.Getenv("CARLOS_SCAN_WEBHOOK_KEY") == "" && os.Getenv("CARLOS_SCAN_WEBHOOK_KEY_PREVIOUS") != "" { log.Fatal("console: CARLOS_SCAN_WEBHOOK_KEY_PREVIOUS is set without CARLOS_SCAN_WEBHOOK_KEY") } if os.Getenv("CARLOS_SCAN_WEBHOOK_KEY") != "" { callbackSigner, err = console.NewCallbackSigner(os.Getenv("CARLOS_SCAN_WEBHOOK_KEY"), os.Getenv("CARLOS_SCAN_WEBHOOK_KEY_PREVIOUS")) if err != nil { log.Fatal(err) } } mailDep := mailDeployment() mailBoundary, err := mailUserBoundary(mailDep.Region, os.Getenv("CARLOS_MAIL_USER_BOUNDARY")) if err != nil { log.Fatal(err) } newMailProvisioner := func(ctx context.Context, region string) (mail.Provisioner, error) { return mail.NewAWSProvisioner(ctx, region, mailBoundary) } mailProvisioner, dnsWriter := mailBackends(mailDep, newMailProvisioner, dnsrec.NewRoute53Writer) // One release.Store, named, because the senders sweep below needs the // same one the server got. rel := openStore() consoleMail, ensureConsoleMail := consoleMailer(mailDep, rel, mailProvisioner, dnsWriter, mail.NewSESEmailSender, hostClaim) dbBucket, dbRegion, dbPrefix, placement := dbDoorWiring() var dbVersions lease.VersionedStore if hostClaim { buckets := map[string]string{} if dbBucket != "" { buckets[dbBucket] = dbRegion } if controlBucket := strings.TrimSpace(os.Getenv("CARLOS_DEPLOYMENT_BUCKET")); controlBucket != "" { // Match release.NewS3Store's AWS region/default-config resolution. if _, known := buckets[controlBucket]; !known { buckets[controlBucket] = strings.TrimSpace(os.Getenv("AWS_REGION")) } } dbVersions, err = lease.NewVersionedS3(context.Background(), buckets) if err != nil { log.Fatalf("console: versioned database reader: %v", err) } } // The seed doors write seeds/ at the root of the same data bucket the db // door reads and the box's activator writes (seeded-instances spec §3.1). var seedData release.Store if dbBucket != "" { if sd, err := release.NewS3StoreIn(context.Background(), dbBucket, dbRegion); err != nil { log.Printf("console: seed data store: %v (the seed doors will answer 503)", err) } else { seedData = sd } } // Before the server exists: every door that seals reads this switch. appconfig.SetBoundSealing(secretsEnvelopeV2()) geoReader := consoleGeoReader(strings.TrimSpace(os.Getenv("CONSOLE_GEOIP_DB"))) cfg := console.Config{ Origin: origin, Keymail: envOr("CONSOLE_KEYMAIL", "https://keymail.dev"), Registry: envOr("CARLOS_REGISTRY", "/data/registry.db"), Releases: rel, // The deployment bucket's NAME, from the same variable openStore() // opens it with — /fleet/architecture draws the box -> bucket edge // from it. Empty on a CARLOS_DEPLOYMENT_DIR deployment, which // correctly draws no bucket node. Bucket: os.Getenv("CARLOS_DEPLOYMENT_BUCKET"), HomeRegion: strings.TrimSpace(os.Getenv("CONSOLE_HOME_REGION")), RegionHA: regionHAEnabled(os.Getenv), // The deployment's AWS account id, the trust anchor of the Phase 1 // move door's box identity check (unset: no box verifies). AWSAccount: strings.TrimSpace(os.Getenv("CARLOS_AWS_ACCOUNT")), AppRegions: appRegionsSwitch(os.Getenv("CONSOLE_APP_REGIONS")), // GeoIP (geoip spec §11.3): the CONSOLE_GEOIP_DB override when set, // otherwise the follower of this box's own edge mirror. GeoIP: geoLocator(geoReader), GeoIPReader: geoReader, // Next to CONSOLE_DB, on disk (an upload can be 1 GiB), and used // for nothing else: NewServer clears it (geoip spec §4.2). GeoipStagingDir: filepath.Join(filepath.Dir(envOr("CONSOLE_DB", "/data/console/console.db")), "geoip-staging"), // --from restores and import spools, next to CONSOLE_DB on disk; // NewServer clears the pool's own leftovers from it. SeedScratchDir: filepath.Join(filepath.Dir(envOr("CONSOLE_DB", "/data/console/console.db")), "scratch", "seeds"), SeedData: seedData, // The feed (geoip spec §10.1). The key goes nowhere but MaxMind. GeoipFeed: os.Getenv("CONSOLE_GEOIP_FEED"), GeoipMaxMindAccount: strings.TrimSpace(os.Getenv("CONSOLE_GEOIP_MAXMIND_ACCOUNT")), GeoipMaxMindKey: strings.TrimSpace(os.Getenv("CONSOLE_GEOIP_MAXMIND_KEY")), BoxLabel: consoleBoxLabel(), // The carlos db door's data bucket and this box's placement // (dbDoorWiring). HostClaim: hostClaim, RunKeysEnabled: os.Getenv("CARLOS_RUN_KEYS") == "on", // keyed run creates, once every box carries run_keys HostConsoleID: os.Getenv("CARLOS_CONSOLE_ID"), DBVersions: dbVersions, HibernateBucket: dbBucket, HibernateRegion: dbRegion, HibernatePrefix: dbPrefix, Region: placement, DBForward: dbForward(), ReleasePub: pub, Key: key, EdgeIPs: edgeIPs(), SecretsPub: secretsPub(), AppsDomain: appsDomain(), AppsDomainAliasAccounts: strings.FieldsFunc(os.Getenv("CONSOLE_APPS_DOMAIN_ALIAS_ACCOUNTS"), func(r rune) bool { return r == ',' || r == ' ' }), SteeringHost: steeringHost(), RedirectHosts: redirectHosts(), DenyDomains: denyDomains(), TunnelHost: tunnelHost(), ReservePlatformDomains: reservePlatformDomains(), DomainProof: domainProof(), StoreProvisioner: storeProvisioner, StoreScanner: storeScanner, ScanSeedFromDeprecatedEnv: seedAntivirus, ScanBudget: envDuration("CARLOS_STORE_SCAN_BUDGET", time.Minute), ScanInterval: envDuration("CARLOS_STORE_SCAN_INTERVAL", 24*time.Hour), ScanMaxSigAge: envDuration("CARLOS_CLAMD_MAX_SIG_AGE", 168*time.Hour), CallbackSigner: callbackSigner, StoreDeployment: storeDeployment, StoreMaxPerApp: envCount("CARLOS_STORE_MAX_PER_APP", 0), StoreOperators: storeOperators(), PlanCatalog: catalog, TemporaryApps: os.Getenv("CARLOS_TEMPORARY_APPS") == "1", // CARLOS_ACME_DELEGATION_ZONE — the same variable the edge reads // (internal/edge/wildcard.go's newWildcardFromEnv) for the same // delegated-ACME story; NewServer normalizes it (lowercase, trim). // Deliberately independent of PLATFORM_APPS_DOMAIN (#167's blind // spot), so it is read raw here rather than folded into appsDomain(). DelegationZone: os.Getenv("CARLOS_ACME_DELEGATION_ZONE"), // MailDeployment/MailOperators/MailProvisioner/DNSWriter // (tenant-email task 17): mailBackends above constructs the last // two, and only when the corresponding coordinate is configured — // CARLOS_MAIL_REGION for the SES/IAM Provisioner, // CARLOS_MAIL_PLATFORM_SUFFIX for the Route53 Writer. An unset // deployment (every deployment as of this change) gets both nil, // exactly as before this wiring: no AWS call at boot, every email // door still 404s (mailProvisionerGate). Once a region is set, // mailProvisionerGate opens and the doors route through a real SES // estate; a construction failure there (bad region, missing // credentials, an AWS outage) is logged loudly and leaves the // field nil rather than failing console boot — see mailBackends' // own doc comment for why that direction, unlike storeBackend's, // is deliberate. A nil DNSWriter with a platform suffix set is // also a supported, non-degraded shape on its own: platform email // domains emit records for the member to publish instead of // CARLOS publishing them (mailDeploymentFor's doc comment in // internal/console/apimail.go). MailDeployment: mailDep, MailOperators: mailOperators(), MailProvisioner: mailProvisioner, DNSWriter: dnsWriter, // ConsoleMail is the console's own email sender, the invite fallback // when Keymail cannot deliver (consoleMailer above). nil on every // deployment with no From address or no mail Provisioner — invites // then fall through to the hand-off link. ConsoleMail: consoleMail, // ConsoleName is how the console's emails name it (CARLOS_CONSOLE_NAME). ConsoleName: os.Getenv("CARLOS_CONSOLE_NAME"), // The STUN/TURN relay (relay spec §5). The region list is the whole // switch: unset is every deployment until an operator arms one, and // it leaves the relay doors refusing and no background loop running. RelayRegions: relayRegions(), RelayOperators: relayOperators(), // Who may roll the platform's own binary besides the ops account's // owners (discussion #176). Unset is the owner-only console. SystemOperators: systemOperators(), PlatformDomain: platformDomain(), // The sidecar pause (internal/console/sidecarsgate.go). Unset is no // gate; set, only these accounts may enable or create a sidecar. SidecarAccounts: sidecarAccts, } // The Phase 1 moves switch (CARLOS_MOVES_PHASE1, off by default, index // D13): applied after the literal so a test can prove it reaches the // config (applyMovesSwitch). applyMovesSwitch(&cfg, os.Getenv) if cfg.HostClaim { cfg.WakeRetryReset = console.NewProductionWakeRetryReset() } srv, err := console.NewServer(cfg, st) if err != nil { log.Fatal(err) } // The console mailer's identity ensure (consoleMailer above) — makes or // re-reads the sending domain's SES/DKIM identity. Started here, not in // NewServer, same reasoning as the alert watcher below: constructing a // Server never starts a goroutine or sends mail. Bounded: the AWS SDK has // no overall timeout, and an ensure that never returns would hold the // mailer for good (a send gives up at its own deadline, but every one // would). go func() { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() ensureConsoleMail(ctx) }() // The senders migration's tail (tenant-email senders design §5): once a // release has passed with every console writing senders/app.json, an // operator sets CARLOS_MAIL_LEGACY_SWEEP=1 and the next start deletes the // email.json objects that have already been replaced. Off by default, and // it never touches an app that has not been migrated. if os.Getenv("CARLOS_MAIL_LEGACY_SWEEP") == "1" { go sweepLegacyMail(cfg.HostClaim, rel, st) } // The mail user boundary backfill (CARLOS_MAIL_USER_BOUNDARY): an // operator sets CARLOS_MAIL_BOUNDARY_SWEEP=1 and the next start puts the // boundary on every /carlos-mail/ user that lacks it, including users no // walk will reach again, and logs whether all of them now carry it. Off // by default. It only ever sets the configured boundary, never removes // one, and touches no bucket record. Refused under CARLOS_HOST_CLAIM, as // the legacy sweep is: it would act on IAM users outside the claim mail // gate and the apps' permits. if os.Getenv("CARLOS_MAIL_BOUNDARY_SWEEP") == "1" { go sweepMailBoundaries(context.Background(), cfg.HostClaim, mailProvisioner, log.Printf) } // The S3 change index's reader (CARLOS_CHANGE_INDEX=read, change-index // design §4.6): the run and seed sweeps read their records through a // view over the deployment store, rebuilt at start and every backstop. // Armed before any worker starts. Unset is nil: every sweep walks the // bucket as before. if v := armChangeIndex(os.Getenv, cfg.HostClaim, rel); v != nil { srv.SetRecordView(v) go recordViewBackstopLoop(context.Background(), v) } // The move pass's pages (moves Plan 5 Task 7): the console's alert sinks, // armed before the claim runtime binds the pass, so no pass runs unarmed. armMovesPaging(context.Background(), cfg, os.Getenv) if err := srv.ConfigureClaimRuntime(context.Background()); err != nil { log.Fatalf("console: claim recovery: %v", err) } // The alert watcher polls alerts/boxes/*.json every minute and keymails // operators on each fire/clear (internal/console/alertwatch.go). Started // here rather than in NewServer so constructing a Server — which every // console test does — never starts a goroutine or sends mail. go srv.RunClaimWorker(context.Background(), "StartHostReports") go srv.RunClaimWorker(context.Background(), "StartAlertWatcher") // The geoip orphan sweep and D1 expiry, at startup and daily. go srv.RunClaimWorker(context.Background(), "StartGeoipJobs") // The moves console record (moves Task 13): only with the switch on, // best-effort, logged by the method itself. go srv.RunClaimWorker(context.Background(), "PublishMovesConsole") // The economics pass writes the daily usage/census/storage/statement // objects and pulls the AWS bill (internal/console/economicspass.go). // Same placement rule as the alert watcher: started here, never in // NewServer, so constructing a Server stays goroutine-free. go srv.RunClaimWorker(context.Background(), "StartClaimRecoveryPass") go srv.RunClaimWorker(context.Background(), "StartEconomicsPass") // The infrastructure inventory pass writes infra/inventory.json for // /fleet/architecture (internal/console/infrapass.go). Same placement // rule as the economics pass: started here, never in NewServer. go srv.RunClaimWorker(context.Background(), "StartInfraPass") // The object-storage scan pass advances each opted-in store's coverage // (internal/console/scanpass.go). Same placement rule again: started // here, never in NewServer. It returns immediately when no engine is // configured, which is every deployment that has not opted in. go srv.RunClaimWorker(context.Background(), "StartScanPass") // The on-demand drain, on its own clock (spec §7.2). Separate goroutine // from the sweep above and not a phase inside it: a queue phase behind a // sequential per-app budget would starve for as long as the sweep takes. go srv.RunClaimWorker(context.Background(), "StartScanDrain") // The temporary-app expiry pass (internal/console/expirypass.go). Same // placement rule as every pass above: started here, never in NewServer. go srv.RunClaimWorker(context.Background(), "StartExpiryPass") // The plan limits pass (internal/console/planlimits.go): publishes each // account's limits record. A no-op with CARLOS_PLAN_CATALOG unset. go srv.RunClaimWorker(context.Background(), "StartPlanLimitsPass") // The plans deployment marker (internal/console/plansmarker.go): once per // start. Writes nothing on a deployment that never set a catalog. go srv.RunClaimWorker(context.Background(), "PublishPlansMarker") // The run sweep (internal/console/runsweep.go): expires queued runs, // ends lost ones, deletes a day-old run's objects. Same placement rule. go srv.RunClaimWorker(context.Background(), "StartRunSweep") // The seeds sweep (internal/console/seedsweep.go, seeded-instances spec // §3.1, §5.6): abandons stale reservations and imports, finishes // rolled-back seeds, deletes snapshots no live seed needs. Same // placement rule as every pass above. go srv.RunClaimWorker(context.Background(), "StartSeedsPass") // The relay's writer lease and its converging reconcile // (internal/console/relayops.go). Same placement rule as every pass // above, and it returns immediately when CARLOS_RELAY_REGIONS is empty — // which is every deployment that has not armed a relay. go srv.RunClaimWorker(context.Background(), "StartRelay") // The email-events push (internal/console/mailpush.go): delivers the // SES outcomes an app asked to be told about. Same placement rule as // every pass above; with nothing queued, a pass is two indexed queries. go srv.RunClaimWorker(context.Background(), "StartMailEventPush") // Door keys (#175 P7): re-sign every app's record before its 72-hour // expiry, once at startup and then every 12 hours. Inert without a // signing key or a deployment bucket. go srv.RunClaimWorker(context.Background(), "StartDoorKeyResign") // Once, at boot: log any route already sitting at another app's platform // apex (internal/console/apex.go). Reports; repairs nothing. go srv.RunClaimWorker(context.Background(), "ReportApexSquats") // Spec §5.7: after any roll, list possible placement discrepancies for // operator review. Read-only; never blocks startup. go srv.RunClaimWorker(context.Background(), "LogPlacementDiscrepancies") addr := envOr("CONSOLE_ADDR", "127.0.0.1:8090") ln, err := net.Listen("tcp", addr) if err != nil { log.Fatalf("console: listen %s: %v", addr, err) } log.Printf("console: %s listening on %s", origin, addr) // ReadHeaderTimeout guards against Slowloris-style slow-header connection // exhaustion — same value and reasoning as internal/edge's public // listeners (edge.go). This console holds no long-lived proxied // connections (no WebSockets), unlike the edge, so unlike the edge it // also sets ReadTimeout: a body-size cap (sesEventMaxBody, // apiShipMaxBytes, …) bounds BYTES, not TIME, and a connection trickling // one byte at a time never trips it. Task 15 added the console's first // unauthenticated body-reading route (sesevents.go) — the SES ingest // endpoint has no login to slow an attacker down before they reach it — // which is what makes a slow-body hold worth closing off here rather // than leaving it as a theoretical gap. 5 minutes is generous enough for // the largest `carlos ship` upload (apiShipMaxBytes, 256 MiB) over a // genuinely slow connection, while still bounding a connection that // would otherwise sit open forever. // // No Addr: the listener above is already open and Serve takes it // directly, so Addr would be a field nothing reads. httpSrv := console.NewHTTPServer(srv) // AFTER the listener is open and the store has migrated (openConsoleDB // and NewServer both ran above), so readiness means what it says. With // console.service declared Type=notify, a build that dies before this // line fails its restart — and the adopt pass rolls the symlink back and // starts the previous version, instead of stamping a build that never // served (internal/release/adopt.go). if err := NotifyReady(); err != nil { log.Printf("console: notifying systemd of readiness: %v", err) } log.Fatal(httpSrv.Serve(ln)) } // openServeDB is openConsoleDB for the serving console. With // CARLOS_HOST_CLAIM on, the database opens gated: the schema migrates on the // trusted boot seam, the table census runs, and every connection is armed // before NewServer, so no handler or pass ever sees an unarmed connection. // Flag-off it is openConsoleDB. func openServeDB(hostClaim bool) *console.Store { if !hostClaim { return openConsoleDB() } st, err := console.OpenGated(envOr("CONSOLE_DB", "/data/console/console.db")) if err != nil { log.Fatal(err) } return st } func openConsoleDB() *console.Store { // Administrative verbs share the structural SQL boundary. Without an // app permit, legacy app grants and migration refuse in enabled mode. opener := console.Open if hostClaimEnabled(os.Getenv) { opener = console.OpenGated } st, err := opener(envOr("CONSOLE_DB", "/data/console/console.db")) if err != nil { log.Fatal(err) } return st } // optionalStore is openStore for the verbs that would LIKE the deployment // bucket but must work without it. `console member` reads account manifests // out of it to catch up on accounts minted by `carlos accounts create`; a box // with neither variable set is a legal deployment (grant/revoke/owners have // always needed only CONSOLE_DB), and refusing to run there would take away // the membership tooling on exactly the boxes least able to spare it. func optionalStore() release.Store { if os.Getenv("CARLOS_DEPLOYMENT_BUCKET") == "" && os.Getenv("CARLOS_DEPLOYMENT_DIR") == "" { return nil } return openStore() } // openStore mirrors the carlos CLI's ~10-line store-open helper (cli.go); // duplicated here because cmd/console cannot import package main. func openStore() release.Store { if b := os.Getenv("CARLOS_DEPLOYMENT_BUCKET"); b != "" { s, err := release.NewDeploymentS3Store(context.Background(), b) if err != nil { log.Fatal(err) } return s } if d := os.Getenv("CARLOS_DEPLOYMENT_DIR"); d != "" { ds := release.NewDirStore(d) ds.EnableChangeIndex() return ds } log.Fatal("set CARLOS_DEPLOYMENT_BUCKET (S3) or CARLOS_DEPLOYMENT_DIR (local)") return nil } // secretsPub reads CARLOS_SECRETS_PUBKEY for the console to SERVE to CLI // clients (the config door) — never to USE. The console has no business // sealing or opening anything: secrets are sealed on the operator's machine // and opened on the box that holds CARLOS_SECRETS_KEY, and this half only // encrypts, which is why it is safe to hand out at all. // // Optional, like CARLOS_RELEASE_KEY: unset simply omits the field from the // config response and leaves every `carlos secrets set` needing the env var // in its own environment. Garbage is refused HERE, at boot, rather than // served to every CLI to choke on — a pubkey that doesn't parse would // otherwise surface as a seal failure on somebody else's machine, hours // later, with nothing pointing back at this variable. func secretsPub() string { raw := strings.TrimSpace(os.Getenv("CARLOS_SECRETS_PUBKEY")) if raw == "" { return "" } if _, err := appconfig.ParsePublicKey(raw, "CARLOS_SECRETS_PUBKEY"); err != nil { log.Fatal(err) } return raw } // appsDomain reads PLATFORM_APPS_DOMAIN — the same variable the edge and // the host agent read — for the CLI's member-facing `carlos instances // enable` door, which pins manifests to .. Garbage is // refused HERE, at boot, rather than stamped into a manifest every future // instance host gets validated against (the secretsPub precedent): the // suffix rides into bucket keys verbatim, so "*.oncarlos.com" copied off a // DNS record would produce records the reconcile pass skips — and protects // — forever. Unset is a legal deployment: the door answers 503 naming the // store-mode operator act. // steeringHost derives this deployment's DNS-steering hostname — // edge., the name the steering converger keeps one latency A // record per armed edge on (multi-region ingress spec, Part 4) — for the // console's custom-domain guidance. // // The armed test is steering.SteeringZoneID(): the SAME read, with the same // validation, that the converger itself performs on the box // (internal/host/steering_loop.go). That is deliberate — it is what stops // this console advertising a CNAME target no pass maintains. Unset (every // deployment today) or malformed leaves this "" and every guidance string // stays byte-for-byte the A-record sentence it has always been. func steeringHost() string { if steering.SteeringZoneID() == "" { return "" } d := appsDomain() if d == "" { 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") return "" } return "edge." + d } func appsDomain() string { d := strings.ToLower(strings.TrimSpace(os.Getenv("PLATFORM_APPS_DOMAIN"))) if d == "" { return "" } if !provision.ValidHostShape(d) { log.Fatalf("console: PLATFORM_APPS_DOMAIN %q is not a valid domain (dot-joined DNS labels, no wildcard, no leading dot)", d) } return d } // storeBackend builds the object-storage half of the console's config from // CARLOS_STORE_* (issue #55): the Provisioner that creates buckets and mints // keys, and the deployment-wide coordinates every store inherits. Unset // CARLOS_STORE_BACKEND is a legal deployment — members may still declare // stores, and the operator doors refuse with a nil provisioner. // // EVERY DEFECT IS FATAL HERE, at boot, rather than surfaced as a refusal // later, and the backend name most of all: a typo'd backend that fell back to // "unconfigured" would boot a console that answers "this deployment has no // object storage" to an operator who configured one — the single most // confusing answer available, on the box least able to explain it. The // coordinates are fatal for a harder reason: region and bucket prefix are // stamped into the bucket names and IAM policies this console creates, so a // console booted without them would create an estate somewhere the operator // did not choose, and the RECORD would then say that is where the app's data // lives. CARLOS_STORE_ENDPOINT stays optional: an empty endpoint is what // "real AWS S3" means (objstore.NewAWSProvisioner). func storeBackend() (objstore.Provisioner, objstore.Deployment) { backend := strings.TrimSpace(os.Getenv("CARLOS_STORE_BACKEND")) if backend == "" { return nil, objstore.Deployment{} } if backend != "aws" { 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) } region := strings.TrimSpace(os.Getenv("CARLOS_STORE_REGION")) if region == "" { log.Fatal("console: CARLOS_STORE_BACKEND is set — also set CARLOS_STORE_REGION (the region buckets are created in, e.g. eu-west-1)") } prefix := strings.TrimSpace(os.Getenv("CARLOS_STORE_BUCKET_PREFIX")) if prefix == "" { log.Fatal("console: CARLOS_STORE_BACKEND is set — also set CARLOS_STORE_BUCKET_PREFIX (bucket names are globally unique; e.g. carlos-)") } // The prefix is the head of every bucket name this console will ever mint // (objstore.MintedBucket), so a prefix that cannot BEGIN a legal S3 name — // "Carlos-", "carlos_", a leading dot — is a first grant that fails deep // inside the walk, against a live cloud account, for whoever tries first. // Checked against the shortest name it could produce (a 4-char account // sqid and a 1-char app), because that is the tightest case the same // function will judge later. if !objstore.ValidBucketName(prefix + "aaaa-a") { 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) } // The permissions boundary is fatal for the hardest reason on this list. // Provisioning a store means CreateUser and PutUserPolicy, and a role // holding those two without a boundary can mint a principal wider than // itself — the console box's own credentials become a route to the whole // cloud account. The deployment's IAM conditions both calls on this ARN // (infra/modules/region-host publishes it as store_user_boundary_arn), so // a console booted without it would fail every grant at the CreateUser // step anyway. Failing here says why, once, instead of once per tenant. boundary := strings.TrimSpace(os.Getenv("CARLOS_STORE_USER_BOUNDARY")) if boundary == "" { 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)") } endpoint := strings.TrimSpace(os.Getenv("CARLOS_STORE_ENDPOINT")) p, err := objstore.NewAWSProvisioner(context.Background(), region, endpoint, boundary) if err != nil { log.Fatal(err) } return p, objstore.Deployment{Backend: backend, Region: region, Endpoint: endpoint, BucketPrefix: prefix} } // storeOperators parses CARLOS_STORE_OPERATORS (comma-separated addresses) — // who may drive the GRANT door for an app's store, and only that one door; // rotate is the member's own verb and destroy is the account owner's browser // ceremony. Read REGARDLESS of the backend, and deliberately so: the list is // an authority statement about people, it costs nothing to hold, and an // operator who sets it before wiring the backend should not have to remember // to come back for it. // // Unset is fail-closed rather than fail-open — no operators means no grants — // which is the right direction for a list whose whole purpose is to gate // spending money in the deployment's own cloud account. func storeOperators() []string { var out []string for _, a := range strings.Split(os.Getenv("CARLOS_STORE_OPERATORS"), ",") { if a = strings.TrimSpace(a); a != "" { out = append(out, a) } } return out } // relayRegions parses CARLOS_RELAY_REGIONS (comma-separated AWS region // names, e.g. "eu-west-1,us-east-1") — the regions this deployment runs a // STUN/TURN relay in, IN THE ORDER an enabled app's CARLOS_RELAY lists them. // The order is the operator's, verbatim: it is the order every browser tries // the relays in, so sorting it here would quietly re-home calls. // // Unset is the ordinary shape and means this deployment has no relay: the // enable door refuses with a sentence naming this variable, and no relay // background loop starts at all. Nothing here is fatal — an unknown region // name costs an app a relay it cannot reach, not an estate somewhere the // operator did not choose, which is the line storeBackend's fatals draw. func relayRegions() []string { var out []string for _, r := range strings.Split(os.Getenv("CARLOS_RELAY_REGIONS"), ",") { if r = strings.ToLower(strings.TrimSpace(r)); r != "" { out = append(out, r) } } return out } // relayOperators parses CARLOS_RELAY_OPERATORS (comma-separated addresses) — // who may run the relay's rotate verb, the one that moves a region's issuer // pointer to a newly staged master and re-issues every enabled app's key. // // Same shape and same direction as storeOperators and mailOperators: unset is // fail-closed. The authority here is over the DEPLOYMENT's relay key // material, not over any one tenant's app, which is why it is its own list // rather than account ownership. func relayOperators() []string { var out []string for _, a := range strings.Split(os.Getenv("CARLOS_RELAY_OPERATORS"), ",") { if a = strings.TrimSpace(a); a != "" { out = append(out, a) } } return out } // systemOperators parses CARLOS_SYSTEM_OPERATORS (comma-separated addresses): // who may run `carlos system status`, `update` from the signed release feed // and `rollback` without owning the ops account (discussion #176). // // The same shape as relayOperators, and unset is the same direction: the // doors stay the owners' alone, exactly as before the list existed. Its own // list rather than CARLOS_STORE_OPERATORS, because that one reads tenant bytes // through store grants and scanning and rolling releases should not. func systemOperators() []string { var out []string for _, a := range strings.Split(os.Getenv("CARLOS_SYSTEM_OPERATORS"), ",") { if a = strings.TrimSpace(a); a != "" { out = append(out, a) } } return out } // sidecarAccounts parses CARLOS_SIDECAR_ACCOUNTS (comma-separated account // sqids): the accounts that may still enable and create native sidecars // while every other account is paused (internal/console/sidecarsgate.go, // discussion #370). Each entry is trimmed and lower-cased, and must be a // sqid (domains.ValidSqid), never an account name: "ops" is not one, and a // name that matched nothing would pause the very account it meant to keep. // An invalid entry is an error for serve to stop on. // // Unset or the empty string is nil, and nil is no gate: every account may // enable and create sidecars, exactly as before the list existed. This is // the opposite direction to the operator lists above on purpose, so a // deployment that never sets it is unaffected. Any other value that names // no sqid at all ("," or " , ") is an error too: it reads as a list, and // parsing it to nil would quietly turn the gate off. func sidecarAccounts(getenv func(string) string) ([]string, error) { raw := getenv("CARLOS_SIDECAR_ACCOUNTS") if raw == "" { return nil, nil } var out []string for _, a := range strings.Split(raw, ",") { a = strings.ToLower(strings.TrimSpace(a)) if a == "" { continue } if !domains.ValidSqid(a) { return nil, fmt.Errorf("CARLOS_SIDECAR_ACCOUNTS: %q is not an account sqid (the account's id, e.g. bab, never its name)", a) } out = append(out, a) } if len(out) == 0 { return nil, fmt.Errorf("CARLOS_SIDECAR_ACCOUNTS: %q names no account sqid; unset it for no gate, or list at least one sqid", raw) } return out, nil } // platformDomain reads CARLOS_PLATFORM_DOMAIN — the domain the relay's own // hostnames hang off (relay..), which an enabled app is // handed as its ICE server URLs. // // Its own variable rather than PLATFORM_APPS_DOMAIN, for the caution // DelegationZone and mailDeployment both already state (#167's blind spot): // the apps domain is the edge's routing zone, and a deployment may perfectly // well serve apps under one name and run its relay under another. Unset is // legal and falls back to the apps domain with a leading "apps." label // stripped (console.Server.relayDomain) — the one relationship between the // two this console can derive without guessing. // // Shape-checked at boot, like appsDomain and for the same reason: the value // rides verbatim into every enabled app's config, where a typo would be a // STUN URL that resolves to nothing and a call that silently never connects. func platformDomain() string { d := strings.ToLower(strings.TrimSpace(os.Getenv("CARLOS_PLATFORM_DOMAIN"))) if d == "" { return "" } if !provision.ValidHostShape(d) { log.Fatalf("console: CARLOS_PLATFORM_DOMAIN %q is not a valid domain (dot-joined DNS labels, no wildcard, no leading dot)", d) } return d } // mailOperators parses CARLOS_MAIL_OPERATORS (comma-separated addresses) — // who may drive the email pause/cap door (apimail.go's handleAPIEmailPause // and mailOperator's own doc comment explain why this is a SEPARATE list // from the fleet-operator bit console grant -operator sets, not a // replacement for it: pausing a tenant's shared sending reputation is a // narrower authority than "can see the fleet bill," and a deployment may // want to grant the two to different people). // // Same shape as storeOperators, for the same reason: unset is fail-closed, // not fail-open — no addresses means NO fleet operator may drive this // door, not "any fleet operator may." That is the right direction for a // list whose purpose is a per-tenant abuse brake, not merely a spending // gate. func mailOperators() []string { var out []string for _, a := range strings.Split(os.Getenv("CARLOS_MAIL_OPERATORS"), ",") { if a = strings.TrimSpace(a); a != "" { out = append(out, a) } } return out } // mailDeployment builds the tenant-email half of the console's config from // CARLOS_MAIL_* (tenant-email spec §7, §9): the region and platform suffix // every mail walk needs, plus the optional DMARC aggregate-report address // and SNS topic ARN that feed the bounce/complaint counts mailcounts.go // enforces caps against. // // DELIBERATELY INDEPENDENT OF PLATFORM_APPS_DOMAIN, the same caution // DelegationZone's own comment above already applies to a different zone // (#167's blind spot): PLATFORM_APPS_DOMAIN is the edge's routing zone, // and conflating it with the zone SES sending identities are verified // under would repeat the exact class of bug #167 already cost this // codebase once, just for a new pair of variables. // // NOTHING HERE IS FATAL, unlike storeBackend's coordinates (the user // boundary, CARLOS_MAIL_USER_BOUNDARY, is read and checked separately by // mailUserBoundary, and that one is fatal), and — unlike // the reasoning that used to sit in this comment — that stays true even // now that mailBackends actually constructs a Provisioner and DNS writer // from Region and PlatformSuffix. A store deployment booted with the wrong // region stamps that region into bucket names and IAM policies it then // actually creates — a real, misplaced estate the operator is stuck with. // A blank or wrong mail Region/PlatformSuffix cannot do that: blank simply // leaves mailBackends' constructors uncalled (MailProvisioner/DNSWriter // stay nil, mailProvisionerGate's 404 covers the rest, same as a // deployment with no mail estate at all), and a WRONG-but-set region fails // the SES/STS call inside NewAWSProvisioner instead of silently creating // anything — mailBackends logs that failure and leaves the field nil // rather than crashing console boot (unlike storeBackend's // log.Fatal — see mailBackends' own doc comment for why mail chose the // other direction). Garbage still costs an operator a debugging session, // just never an AWS estate in the wrong place, which is the asymmetry that // keeps these fields non-fatal where storeBackend's are not. func mailDeployment() mail.Deployment { return mail.Deployment{ Region: strings.TrimSpace(os.Getenv("CARLOS_MAIL_REGION")), PlatformSuffix: strings.ToLower(strings.TrimSpace(os.Getenv("CARLOS_MAIL_PLATFORM_SUFFIX"))), DMARCRua: strings.TrimSpace(os.Getenv("CARLOS_MAIL_DMARC_RUA")), SNSTopicARN: strings.TrimSpace(os.Getenv("CARLOS_MAIL_SNS_TOPIC_ARN")), // The proof-of-control lookup for a domain whose SES identity this // account did not create (#216). Read-only DNS, no AWS. Resolver: net.DefaultResolver, } } // mailUserBoundary checks CARLOS_MAIL_USER_BOUNDARY (raw) against the mail // region and returns it trimmed. It is the one mail setting that IS fatal, // for storeBackend's CARLOS_STORE_USER_BOUNDARY reason: the mail provisioner // calls CreateUser and PutUserPolicy, and a role holding those without a // boundary can make a user wider than itself, which turns the console's // credentials into a route to the whole AWS account. Without a region there // is no provisioner, so the boundary is not needed and not checked. func mailUserBoundary(region, raw string) (string, error) { if region == "" { return "", nil } boundary := strings.TrimSpace(raw) if boundary == "" { 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)") } if err := mail.CheckUserBoundary(boundary); err != nil { return "", fmt.Errorf("console: CARLOS_MAIL_USER_BOUNDARY: %w", err) } return boundary, nil } // mailBackends constructs the tenant-email Provisioner and DNS Writer from // dep's coordinates — the wiring storeBackend has had since issue #55, now // extended to mail (task 17, "the last code step before the feature can be // exercised against real AWS"). newProvisioner and newWriter are seams for // tests: production always passes mail.NewAWSProvisioner and // dnsrec.NewRoute53Writer, a test passes a fake that never touches the // network. // // Each half switches on ITS OWN coordinate, independently: // // - dep.Region == "" means no SES estate this deployment owns — leave // MailProvisioner nil, call newProvisioner not at all. Every email door // keeps 404ing via mailProvisionerGate, exactly as before this function // existed. // - dep.PlatformSuffix == "" means no zone CARLOS owns to publish into — // leave DNSWriter nil, call newWriter not at all. Platform domains then // emit records for the member to publish themselves instead of CARLOS // publishing them, which is a supported shape // (mailDeploymentFor's doc comment in internal/console/apimail.go), not // a degraded one — so a deployment can have a Provisioner and no // Writer on purpose. // // A CONSTRUCTION FAILURE IS NOT FATAL, unlike storeBackend's log.Fatal on // the same kind of error. Both constructors do network I/O (SES/STS, // Route53) and can fail for reasons that have nothing to do with whether // the console itself is healthy: no AWS credentials on this box, a bad // region, an AWS outage. storeBackend's coordinates get baked into bucket // names and IAM policies the moment they are used, so a console that boots // with the wrong ones would go on to create a real, misplaced estate — // log.Fatal there stops that before it happens. Mail has no equivalent: a // failed construction here simply means email stays unavailable, which // mailProvisionerGate already reports as a clean 404 to every caller. A // console that refuses to start at all because SES was briefly // unreachable would take down every OTHER door — apps, domains, routes, // billing — over one feature's dependency, which is a far worse outage // than email alone being down. So: log loudly, naming what failed and the // consequence, leave the field nil, and keep booting. func mailBackends( dep mail.Deployment, newProvisioner func(context.Context, string) (mail.Provisioner, error), newWriter func(context.Context) (dnsrec.Writer, error), ) (mail.Provisioner, dnsrec.Writer) { var provisioner mail.Provisioner if dep.Region != "" { p, err := newProvisioner(context.Background(), dep.Region) if err != nil { log.Printf("console: email doors will refuse — SES provisioner unavailable (region %s): %v", dep.Region, err) } else { provisioner = p } } var writer dnsrec.Writer if dep.PlatformSuffix != "" { w, err := newWriter(context.Background()) if err != nil { log.Printf("console: platform email domains will emit records for the member instead of CARLOS publishing them — Route53 DNS writer unavailable: %v", err) } else { writer = w } } return provisioner, writer } // consoleMailer builds the console's own email sender, the invite fallback // when Keymail cannot deliver (internal/console/consolemail.go). From is // CARLOS_CONSOLE_MAIL_FROM, else noreply on the ops account's platform name. // Like mailBackends, nothing here is fatal: no From, no provisioner or no // SES client leaves invites on Keymail and the hand-off link. func consoleMailer(dep mail.Deployment, releases release.Store, p mail.Provisioner, w dnsrec.Writer, newSES func(context.Context, string) (mail.EmailSender, error), hostClaim bool) (console.ConsoleMailer, func(context.Context)) { if hostClaim { 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") return nil, func(context.Context) {} } from := console.ConsoleMailFrom(os.Getenv("CARLOS_CONSOLE_MAIL_FROM"), dep.PlatformSuffix) if from == "" || p == nil { 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") return nil, func(context.Context) {} } ses, err := newSES(context.Background(), dep.Region) if err != nil { log.Printf("console: no console email, SES client unavailable: %v", err) return nil, func(context.Context) {} } return console.NewConsoleMailer(from, releases, p, w, dep, ses) } // edgeIPs parses PLATFORM_EDGE_IPS (comma-separated) — the SAME variable the // agent's domains reconcile loop reads, so the console's point-your-DNS-here // guidance and the agent's DNS health checks can never name different // addresses. Unset is legal: guidance falls back to naming "this // deployment's edge IP" in prose. func edgeIPs() []string { var out []string for _, ip := range strings.Split(os.Getenv("PLATFORM_EDGE_IPS"), ",") { if ip = strings.TrimSpace(ip); ip != "" { out = append(out, ip) } } return out } // redirectHosts parses CONSOLE_REDIRECT_HOSTS (comma-separated) — retired // hostnames this console answers with a permanent redirect to CONSOLE_ORIGIN // (server.go's ServeHTTP), rather than serving the app in place. Opt-in per // host on purpose: a host that must keep serving the API — CLI bearer // tokens are host-agnostic, and a POST redirected through an unexpected hop // is how clients break — is simply left off this list, unlike EdgeIPs above // there's no single deployment-wide default to fall back to. func redirectHosts() []string { var out []string for _, h := range strings.Split(os.Getenv("CONSOLE_REDIRECT_HOSTS"), ",") { if h = strings.TrimSpace(h); h != "" { out = append(out, h) } } return out } // secretsEnvelopeV2 parses CONSOLE_SECRETS_ENVELOPE_V2: whether secrets this // console seals (relay, mail, object storage) and the CLI seals through it // are bound v2 envelopes (appconfig.Binding, discussion #220). Off by // default, because a box on a binary that predates v2 cannot open one and // its whole config render for that app stalls. Turn it on once every box // runs a build that reads v2. A value strconv.ParseBool cannot read stops // the boot, as for the other boolean settings. func secretsEnvelopeV2() bool { v := strings.TrimSpace(os.Getenv("CONSOLE_SECRETS_ENVELOPE_V2")) if v == "" { return false } b, err := strconv.ParseBool(v) if err != nil { log.Fatalf("console: CONSOLE_SECRETS_ENVELOPE_V2 %q is not a boolean (use true or false)", v) } return b } // dbForward parses CARLOS_DB_FORWARD: "1" turns on forwarding a carlos db // read to the region that holds the data (db region forward spec §11.1). // Unset, every db door answer is byte-identical to main. Anything else stops // the boot, as for the other switches. func dbForward() bool { switch v := strings.TrimSpace(os.Getenv("CARLOS_DB_FORWARD")); v { case "": return false case "1": return true default: log.Fatalf("console: CARLOS_DB_FORWARD %q is not 1 (or unset)", v) return false } } // reservePlatformDomains parses CONSOLE_RESERVE_PLATFORM_DOMAINS, the opt-in // for a deployment that hosts apps for other people (the flagship, carloku). // When it is set, `carlos domains attach` also refuses everything under // PLATFORM_APPS_DOMAIN and under the console's own hostnames // (console.Config.ReservePlatformDomains). Unset means false. A value that // strconv.ParseBool cannot read stops the boot. A typo in a security setting // must not run the console as if the setting were off. func reservePlatformDomains() bool { v := strings.TrimSpace(os.Getenv("CONSOLE_RESERVE_PLATFORM_DOMAINS")) if v == "" { return false } b, err := strconv.ParseBool(v) if err != nil { log.Fatalf("console: CONSOLE_RESERVE_PLATFORM_DOMAINS %q is not a boolean (use true or false)", v) } return b } // domainProof parses CONSOLE_DOMAIN_PROOF, the opt-in for proof of control // on custom hostnames (#215, console.Config.DomainProof). Unset means false; // a value strconv.ParseBool cannot read stops the boot, for the reason // reservePlatformDomains gives. func domainProof() bool { v := strings.TrimSpace(os.Getenv("CONSOLE_DOMAIN_PROOF")) if v == "" { return false } b, err := strconv.ParseBool(v) if err != nil { log.Fatalf("console: CONSOLE_DOMAIN_PROOF %q is not a boolean (use true or false)", v) } return b } // denyDomains parses CONSOLE_DENY_DOMAINS (comma-separated) — hostnames, and // everything under them, that `carlos domains attach` refuses (issue #151). // This is the operator's list of names the console cannot derive on its own: // retired console origins, any other platform-managed domain. A platform // operator may still attach one of these to an app with --platform (#209). // The door itself adds the console's own hostnames, the tunnel host // (tunnelHost) and, with CONSOLE_RESERVE_PLATFORM_DOMAINS, // PLATFORM_APPS_DOMAIN (reservedDomains, internal/console/apidomains.go). An // unset variable is therefore a legal deployment. Blank entries are dropped: // an empty string in the list would be a deny entry no hostname could avoid // matching. func denyDomains() []string { var out []string for _, d := range strings.Split(os.Getenv("CONSOLE_DENY_DOMAINS"), ",") { if d = strings.ToLower(strings.TrimSpace(d)); d != "" { out = append(out, d) } } return out } // tunnelHost is the fleet hub's reserved hostname (CARLOS_TUNNEL_HOST — the // edge's variable, but host.env is one file per box). The edge Allows it and // no registry route names it, so no derived deny entry covers it; a tenant // must not attach it. #151 made it the operator's job to list. Passing it // to the console here makes the MUST hold without anyone remembering, and // passing it apart from CONSOLE_DENY_DOMAINS keeps it out of reach of the // operator's own --platform door (#209). Env is read at this layer only, as // every other console setting is. func tunnelHost() string { return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(os.Getenv("CARLOS_TUNNEL_HOST"))), ".") } // storeScannerFor builds the object-storage virus-scanning engine from the // environment, or returns nil — which is the ordinary shape. Scanning is // opt-in and off by default: a deployment that sets nothing gets a nil // Scanner, no pass goroutine, and doors that refuse. // // CARLOS_CLAMD_ADDR host:port, or a path when it begins "/" // CARLOS_CLAMD_SOCKET accepted alias for the same thing // CARLOS_STORE_SCANNER deprecated: "clamav" still wires the default socket // CARLOS_CLAMD_MAX_BYTES default 25 MB — mirror clamd's own StreamMaxLength // // WIRING ONLY. Whether scanning is ON lives in system/antivirus.json, so an // operator turns it on with `carlos system antivirus enable` rather than by // editing a unit file and restarting the control plane. func storeScannerFor(d objstore.Deployment) (objstore.Scanner, bool) { addr, seed, err := scannerWiring(os.Getenv) if err != nil { log.Fatalf("console: %v", err) } if addr == "" { return nil, false } // LOUDLY, on every boot, as the migration says. The variable still works, // which is exactly why it needs saying: a deployment can sit on it // indefinitely and nobody would learn that the seed only fires while it is // set, or that the switch is now a command rather than an edit to a unit // file. One line an operator greps for beats a release note nobody reads. if seed { log.Printf("console: CARLOS_STORE_SCANNER is DEPRECATED and is being read as the clamd address (%s). "+ "Set CARLOS_CLAMD_ADDR instead, and use `carlos system antivirus enable|disable` for the switch — "+ "the first tick seeds system/antivirus.json from this variable, and only while it is still set.", addr) } // A scanner with no store to scan is a misconfiguration, and one loud // failure at boot beats a pass that silently finds nothing forever. if d.Backend == "" { log.Fatal("console: a clamd address is configured but CARLOS_STORE_BACKEND is not — there are no object stores on this deployment to scan") } maxBytes := envBytes("CARLOS_CLAMD_MAX_BYTES", 25<<20) // THE TAG KEY IS NOT OPTIONAL, and a missing one is fatal here rather than // a scanner that runs without it. // // A verdict is only worth anything because it carries a signature this // deployment issued: on several S3 implementations an app can write the // carlos-scan tag on its own object whatever the IAM policy says (a // browser-direct POST-policy upload does it on MinIO), so authenticity is // the property and not write-permission. A scanner with no key would write // verdicts nothing can verify and read every forgery as genuine — strictly // worse than no scanner at all, because it would report them as clean. // // Fatal, and at boot: an operator who has wired clamd and not the key gets // one line naming the variable, before any tenant byte is read. signer, err := objstore.NewTagSigner( os.Getenv("CARLOS_SCAN_TAG_KEY"), os.Getenv("CARLOS_SCAN_TAG_KEY_PREVIOUS"), ) if err != nil { log.Fatalf("console: a clamd address is configured but the scan tag key is not usable: %v — "+ "generate one (32 random bytes is plenty), put it in the deployment's secret store as "+ "CARLOS_SCAN_TAG_KEY, and keep it: every stored verdict is signed with it", err) } sc, err := objstore.NewClamAVScanner(context.Background(), d.Region, d.Endpoint, addr, maxBytes, signer) if err != nil { log.Fatal(err) } return sc, seed } // scannerWiring resolves the clamd address from the environment, and reports // whether the deprecated variable was the thing that supplied it. // // Pure, and takes its own getenv, so the refusals are testable — a fatal path // nobody can exercise is a fatal path nobody has checked. // // The deprecated variable REMAINS A WIRING SOURCE, retaining today's socket // default. Without that, a deployment that set only CARLOS_STORE_SCANNER=clamav // would get no address, no Scanner, no pass goroutine, and scanning would stop // silently on upgrade — the exact failure this file already refuses to permit // elsewhere. The second return is what lets the first pass tick seed // system/antivirus.json and carry the operator's existing intent forward. func scannerWiring(getenv func(string) string) (addr string, fromDeprecated bool, err error) { trimmed := func(k string) string { return strings.TrimSpace(getenv(k)) } explicit, socket := trimmed("CARLOS_CLAMD_ADDR"), trimmed("CARLOS_CLAMD_SOCKET") if explicit != "" && socket != "" && explicit != socket { return "", false, fmt.Errorf("CARLOS_CLAMD_ADDR (%s) and CARLOS_CLAMD_SOCKET (%s) disagree — set one", explicit, socket) } if explicit == "" { explicit = socket } switch engine := trimmed("CARLOS_STORE_SCANNER"); engine { case "": return explicit, false, nil case "clamav": // GuardDuty is deliberately not wired in this build: its // 25-protected-buckets-per-account-per-Region quota is not adjustable, // so it cannot be the primary engine for a platform whose unit of // tenancy is a bucket. if explicit == "" { explicit = "/run/clamav/clamd.ctl" } return explicit, true, nil default: 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) } } // envDuration reads a Go duration ("90s", "12h"). A value that does not parse // is fatal rather than silently defaulted: a deployment that meant to scan // hourly and typed it wrong should be told, not quietly given 24h. func envDuration(k string, def time.Duration) time.Duration { v := strings.TrimSpace(os.Getenv(k)) if v == "" { return def } d, err := time.ParseDuration(v) if err != nil { log.Fatalf("console: %s %q is not a duration (e.g. 90s, 30m, 24h): %v", k, v, err) } if d <= 0 { log.Fatalf("console: %s %q must be positive", k, v) } return d } // envCount reads a positive count. Zero from an UNSET variable means "the // console's own default" (the caller supplies def = 0 and the server side // substitutes); an explicitly set zero or negative is fatal, because a // deployment that meant to cap something and typed it wrong should be told // rather than quietly given the default it was trying to change. func envCount(k string, def int) int { v := strings.TrimSpace(os.Getenv(k)) if v == "" { return def } n, err := strconv.Atoi(v) if err != nil || n <= 0 { log.Fatalf("console: %s %q must be a positive whole number", k, v) } return n } // envBytes reads a plain byte count. Same fatal-on-garbage posture. func envBytes(k string, def int64) int64 { v := strings.TrimSpace(os.Getenv(k)) if v == "" { return def } n, err := strconv.ParseInt(v, 10, 64) if err != nil || n <= 0 { log.Fatalf("console: %s %q must be a positive number of bytes", k, v) } return n } // appRegionsSwitch reads CONSOLE_APP_REGIONS: exactly "on" (any case, // trimmed) turns app regions on. func appRegionsSwitch(v string) bool { return strings.EqualFold(strings.TrimSpace(v), "on") } // openGeoIP opens the optional CONSOLE_GEOIP_DB. Unset, unopenable or not // an mmdb means the IP step is off, logged once and never fatal. Returns an // untyped nil on every failure, never a nil *geo.MMDB in the interface. // // The mapping sits behind a geo.Swappable so D1 can close it (geoip spec §0 // D1, §11.3, codex R10-1): a GeoLite2 override already past its 30 days is // refused here, and one that expires while the console runs is closed by // watchGeoIPExpiry. The file is the operator's; the platform never deletes // it, and the log says to replace it. func openGeoIP(path string) geo.Locator { sw := openGeoIPAt(path, time.Now()) if sw == nil { return nil } go watchGeoIPExpiry(sw, path, time.Minute, time.Now) return sw } func openGeoIPAt(path string, now time.Time) *geo.Swappable { if path == "" { return nil } // Parsed first in the verifier child: a malformed file must not crash // the console (code review R9), only turn the IP step off. vctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) err := console.VerifyGeoIPFile(vctx, path) cancel() var m *geo.MMDB if err == nil { m, err = geo.OpenMMDBTrusted(path) } if err != nil { log.Printf("console: CONSOLE_GEOIP_DB %s is unusable, so the IP step of region guesses is off: %v", path, firstLineCapped(err)) return nil } if geoipExpired(m.Source(), m.BuildEpoch(), now) { m.Close() 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) return nil } log.Printf("console: GeoIP database loaded from %s", path) sw := geo.NewSwappable() sw.Swap(m) return sw } // watchGeoIPExpiry closes the override once D1 says so. It returns when it // has closed it (nothing left to watch). func watchGeoIPExpiry(sw *geo.Swappable, path string, every time.Duration, now func() time.Time) { t := time.NewTicker(every) defer t.Stop() for range t.C { if geoIPExpiryTick(sw, path, now()) { return } } } // geoipExpired is D1's rule; a var so a test can apply it to the GeoIP2 // fixtures, which never expire. var geoipExpired = geoip.Expired // geoIPExpiryTick is one check; true once the reader has been closed. func geoIPExpiryTick(sw *geo.Swappable, path string, now time.Time) bool { src, build, ok := sw.Loaded() if !ok { return true } if !geoipExpired(src, build, now) { return false } sw.Swap(nil) 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) return true } // maxLoggedErrBytes caps what firstLineCapped returns. const maxLoggedErrBytes = 200 // firstLineCapped reduces an error to its first line, at most // maxLoggedErrBytes bytes (never splitting a rune). OpenMMDB's error can // carry multi-line decoded database contents, which the log must not. func firstLineCapped(err error) string { if err == nil { return "" } s := err.Error() if i := strings.IndexAny(s, "\r\n"); i >= 0 { s = s[:i] } if len(s) > maxLoggedErrBytes { cut := maxLoggedErrBytes for cut > 0 && !utf8.RuneStart(s[cut]) { cut-- } s = s[:cut] } return s } func envOr(k, def string) string { if v := os.Getenv(k); v != "" { return v } return def } // dbDoorWiring reads what the `carlos db` door needs (internal/console // apidb.go; spec 2026-09-02 §6): the region data bucket triple, parsed by // the activator's own ConfigFromEnv so the console reads exactly the // prefixes the agent on this box writes, plus CARLOS_REGION, this box's // placement, which an instance record's region must equal. An empty bucket // leaves the door answering 503. A config the agent itself would refuse // turns the door off rather than failing console boot: the console's job is // not to police the agent's idle timer. func dbDoorWiring() (bucket, region, prefix, placement string) { placement = strings.TrimSpace(os.Getenv("CARLOS_REGION")) cfg, err := activator.ConfigFromEnv() if err != nil { log.Printf("console: %v; the carlos db door is off", err) return "", "", "", placement } if cfg == nil { return "", "", "", placement } return cfg.Bucket, cfg.Region, cfg.Prefix, placement } // recordViewWalkTimeout bounds one backstop rebuild, so a stuck index poll // or listing never holds the record view (and with it the sweeps' refresh) // for good. It is the run sweep's pass budget, which has always held one // full record walk. const recordViewWalkTimeout = 5 * time.Minute // armChangeIndex returns the console's record view over rel when // CARLOS_CHANGE_INDEX is exactly "read" (change-index design §7), or nil. A // host-claim console stays disarmed: the run and seed sweeps, the view's // only readers, are off there, so a view would only cost its backstop walks. func armChangeIndex(getenv func(string) string, hostClaim bool, rel release.Store) *provision.RecordView { if getenv("CARLOS_CHANGE_INDEX") != "read" { return nil } if hostClaim { log.Printf("console: change index: CARLOS_CHANGE_INDEX=read is ignored on a host-claim console, whose sweeps are off") return nil } v := provision.NewRecordView(rel, release.NewChangeWatch(rel)) log.Printf("console: change index armed: the run and seed sweeps read the record view (backstop %s)", release.ChangeIndexBackstop) return v } // recordViewBackstopLoop is the view's backstop (design §4.4): Rebuild at // start and every release.ChangeIndexBackstop, jittered by +-10%, each // bounded by recordViewWalkTimeout. A failed rebuild leaves the view unable // to vouch, so the sweeps walk the bucket until one succeeds. func recordViewBackstopLoop(ctx context.Context, v *provision.RecordView) { for { rctx, cancel := context.WithTimeout(ctx, recordViewWalkTimeout) if err := v.Rebuild(rctx); err != nil { log.Printf("console: record view backstop: %v", err) } cancel() b := release.ChangeIndexBackstop select { case <-ctx.Done(): return case <-time.After(b - b/10 + rand.N(b/5)): } } } // applyMovesSwitch sets console.Config.MovesEnabled from the Phase 1 moves // switch, moves.SwitchEnv. Off (the default) leaves the console answering // exactly as main does today. func applyMovesSwitch(cfg *console.Config, getenv func(string) string) { cfg.MovesEnabled = moves.Enabled(getenv) } // armMovesPaging arms alerting's sinks from CARLOS_ALERT_WEBHOOK and // CARLOS_ALERT_SNS_TOPIC_ARN for claim recovery and its optional move pass. // Claim loss pages run even when moves are disabled. func armMovesPaging(ctx context.Context, cfg console.Config, getenv func(string) string) bool { if !cfg.HostClaim { return false } alerting.Arm(alerting.FromEnv(ctx, getenv, nil)) return true } func hostClaimEnabled(getenv func(string) string) bool { return strings.TrimSpace(getenv("CARLOS_HOST_CLAIM")) != "" } // consoleBoxLabel is the label this box's agent publishes its heartbeat // and alerts under (status.Label of /etc/machine-id, as internal/host does // for a platform box): the console names it in the geoip feed's status so // exactly one box, this one, alerts on feed failure. func consoleBoxLabel() string { b, err := os.ReadFile("/etc/machine-id") if err != nil { return "" } return status.Label(strings.TrimSpace(string(b))) } // consoleGeoReader is the console's one GeoIP reader. With CONSOLE_GEOIP_DB // set it is that override (opened once, D1-checked, closed at expiry); // otherwise it follows the generation this box's edge mirrors, under // CARLOS_DATA_DIR (default /data) as carlos/geoip, once a minute. func consoleGeoReader(override string) *geo.Swappable { if override != "" { if loc, ok := openGeoIP(override).(*geo.Swappable); ok { return loc } return nil } sw := geo.NewSwappable() f := console.NewGeoipFollower(filepath.Join(envOr("CARLOS_DATA_DIR", "/data"), "carlos", "geoip"), sw) go f.Run() return sw } // geoLocator is sw as a geo.Locator, or an untyped nil: a nil *Swappable in // the interface would be non-nil to every `GeoIP != nil` check and panic on // the first Locate. func geoLocator(sw *geo.Swappable) geo.Locator { if sw == nil { return nil } return sw } func sweepLegacyMail(claims bool, rel release.Store, st *console.Store) { if claims { log.Printf("console: mail legacy sweep: %v", mail.ErrClaimMailPending) return } n, err := mail.SweepLegacyRecords(context.Background(), rel, true) if err != nil { log.Printf("console: mail legacy sweep: %v", err) return } log.Printf("console: mail legacy sweep -> %d email.json objects removed", n) if err := st.DropLegacyMailCounts(true); err != nil { log.Printf("console: mail legacy sweep: dropping mail_counts -> %v", err) } } // sweepMailBoundaries runs the mail provisioner's user boundary sweep once // and logs what it did: each user it set the boundary on, each it could not, // and a last line saying either that all N mail users are bounded or which // are not. The last line is what an operator reads before requiring the // boundary on iam:CreateUser and iam:PutUserPolicy. // // Under host claims it refuses before any backend call, exactly as // sweepLegacyMail does: p here is the raw provisioner, and every IAM change // to a sending user in claim mode has to go through the shared mail gate // and the owning app's permit (internal/console/claim_backends.go), which // a fleet-wide sweep has neither of. func sweepMailBoundaries(ctx context.Context, claims bool, p mail.Provisioner, logf func(string, ...any)) { if claims { logf("console: mail boundary sweep: refused while CARLOS_HOST_CLAIM is on: %v", mail.ErrClaimMailPending) return } sw, ok := p.(mail.UserBoundarySweeper) if !ok { logf("console: mail boundary sweep: skipped, no mail provisioner with user boundaries (set CARLOS_MAIL_REGION and CARLOS_MAIL_USER_BOUNDARY)") return } res, err := sw.SweepUserBoundaries(ctx) for _, u := range res.Fixed { logf("console: mail boundary sweep: set the boundary on %s", u) } var failed []string for _, f := range res.Failed { failed = append(failed, f.User) logf("console: mail boundary sweep: %s NOT bounded: %v", f.User, f.Err) } if err != nil { 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) return } if len(failed) > 0 { logf("console: mail boundary sweep: %d of %d mail users NOT bounded: %s", len(failed), res.Total, strings.Join(failed, ", ")) return } logf("console: mail boundary sweep: all %d mail users bounded (%d already, %d fixed)", res.Total, res.Bounded, len(res.Fixed)) } // regionHAEnabled is CARLOS_REGION_HA as strconv.ParseBool reads it: on // for "1", "t", "true" in any case, off for "", "0", "false" and anything // unparseable. The same rule as the box's (internal/host/boxrecord.go). func regionHAEnabled(getenv func(string) string) bool { on, err := strconv.ParseBool(strings.TrimSpace(getenv("CARLOS_REGION_HA"))) return err == nil && on } // planCatalog loads the plan catalog named by CARLOS_PLAN_CATALOG // (plans-caps-packs spec §4.1). Unset means plans are off: nil, nil. func planCatalog() (*plans.Catalog, error) { path := strings.TrimSpace(os.Getenv("CARLOS_PLAN_CATALOG")) if path == "" { return nil, nil } return plans.LoadCatalog(path) }