// 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)
}