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