| 1 | # region-host: one region of a CARLOS deployment — a single Graviton box |
| 2 | # running `carlos agent`, behind an Elastic IP. The box is disposable by |
| 3 | # construction: all durable state is in the buckets, so there is no backup |
| 4 | # story for hosts at all, only for buckets. |
| 5 | # |
| 6 | # Residency is enforced by construction: the host role reaches the |
| 7 | # deployment bucket plus ITS OWN region's data bucket and nothing else. A |
| 8 | # host in another region gets a different module instance whose role does |
| 9 | # not include this region's bucket — a wrong-region wake fails loudly at |
| 10 | # the IAM layer. |
| 11 | |
| 12 | data "aws_region" "current" {} |
| 13 | |
| 14 | data "aws_ami" "debian_arm64" { |
| 15 | most_recent = true |
| 16 | owners = [var.ami_owner] |
| 17 | filter { |
| 18 | name = "name" |
| 19 | values = [var.ami_name_pattern] |
| 20 | } |
| 21 | filter { |
| 22 | name = "architecture" |
| 23 | values = ["arm64"] |
| 24 | } |
| 25 | } |
| 26 | |
| 27 | # Does this region have a default VPC at all? Asked with aws_vpcs (a LIST) |
| 28 | # rather than aws_vpc (a lookup), on purpose: the lookup ERRORS when nothing |
| 29 | # matches, and an error inside a data source cannot be turned into a useful |
| 30 | # message. The list comes back empty instead, so the precondition below can |
| 31 | # say what to do about it. Not evaluated at all when vpc_id is set. |
| 32 | data "aws_vpcs" "default" { |
| 33 | count = var.vpc_id == null ? 1 : 0 |
| 34 | filter { |
| 35 | name = "is-default" |
| 36 | values = ["true"] |
| 37 | } |
| 38 | } |
| 39 | |
| 40 | # The VPC's own CIDR, for output vpc_cidr — CARLOS_RELAY_DENY_CIDRS needs it |
| 41 | # so the relay never proxies traffic that is already inside the VPC. |
| 42 | data "aws_vpc" "host" { |
| 43 | id = var.vpc_id != null ? var.vpc_id : one(data.aws_vpcs.default[0].ids) |
| 44 | } |
| 45 | |
| 46 | locals { |
| 47 | # Inline, like the SG's other ingress: a standalone |
| 48 | # aws_vpc_security_group_ingress_rule would fight the in-line set and be |
| 49 | # stripped on every apply (see extra_ingress in variables.tf). |
| 50 | relay_ingress = var.relay ? [ |
| 51 | { |
| 52 | description = "relay stun/turn udp" |
| 53 | from_port = 3478 |
| 54 | to_port = 3478 |
| 55 | protocol = "udp" |
| 56 | cidr_blocks = ["0.0.0.0/0"] |
| 57 | }, |
| 58 | { |
| 59 | description = "relay stun/turn tcp" |
| 60 | from_port = 3478 |
| 61 | to_port = 3478 |
| 62 | protocol = "tcp" |
| 63 | cidr_blocks = ["0.0.0.0/0"] |
| 64 | }, |
| 65 | { |
| 66 | description = "relay allocations" |
| 67 | from_port = 49152 |
| 68 | to_port = 65535 |
| 69 | protocol = "udp" |
| 70 | cidr_blocks = ["0.0.0.0/0"] |
| 71 | }, |
| 72 | ] : [] |
| 73 | } |
| 74 | |
| 75 | resource "aws_security_group" "host" { |
| 76 | name = var.name |
| 77 | description = var.sg_description |
| 78 | # Null = the region's default VPC, which is what this module did before it |
| 79 | # took the input at all. |
| 80 | vpc_id = var.vpc_id |
| 81 | |
| 82 | lifecycle { |
| 83 | # Both preconditions exist to move a failure from the middle of an apply |
| 84 | # to the start of a plan. The buckets and the IAM role are created before |
| 85 | # the instance, so a mid-apply failure here leaves a half-built region |
| 86 | # that the next run has to reconcile. |
| 87 | precondition { |
| 88 | condition = var.vpc_id != null || length(try(data.aws_vpcs.default[0].ids, [])) > 0 |
| 89 | error_message = "This region has no default VPC, so the host has nowhere to go. Pass vpc_id and subnet_id naming the VPC and subnet this box should live in. (A default VPC is created per region per account and deleting them is ordinary hygiene; without this check the apply fails half-way through with `VPCIdNotSpecified: No default VPC for this user`, after the buckets and IAM already exist.)" |
| 90 | } |
| 91 | precondition { |
| 92 | condition = (var.vpc_id == null) == (var.subnet_id == null) |
| 93 | error_message = "vpc_id and subnet_id go together: name both, or neither. A security group in your VPC and an instance in the default one is an apply that fails at the EC2 layer with a much worse message than this one." |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | ingress { |
| 98 | from_port = 80 |
| 99 | to_port = 80 |
| 100 | protocol = "tcp" |
| 101 | cidr_blocks = ["0.0.0.0/0"] |
| 102 | } |
| 103 | ingress { |
| 104 | from_port = 443 |
| 105 | to_port = 443 |
| 106 | protocol = "tcp" |
| 107 | cidr_blocks = ["0.0.0.0/0"] |
| 108 | } |
| 109 | dynamic "ingress" { |
| 110 | for_each = var.extra_ingress |
| 111 | content { |
| 112 | description = ingress.value.description |
| 113 | from_port = ingress.value.from_port |
| 114 | to_port = ingress.value.to_port |
| 115 | protocol = ingress.value.protocol |
| 116 | cidr_blocks = ingress.value.cidr_blocks |
| 117 | } |
| 118 | } |
| 119 | dynamic "ingress" { |
| 120 | for_each = local.relay_ingress |
| 121 | content { |
| 122 | description = ingress.value.description |
| 123 | from_port = ingress.value.from_port |
| 124 | to_port = ingress.value.to_port |
| 125 | protocol = ingress.value.protocol |
| 126 | cidr_blocks = ingress.value.cidr_blocks |
| 127 | } |
| 128 | } |
| 129 | # No SSH ingress: admin access is SSM Session Manager only, and the |
| 130 | # carlos binary arrives from the deployment bucket, not over scp. |
| 131 | egress { |
| 132 | from_port = 0 |
| 133 | to_port = 0 |
| 134 | protocol = "-1" |
| 135 | cidr_blocks = ["0.0.0.0/0"] |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | resource "aws_iam_role" "host" { |
| 140 | name = var.iam_name |
| 141 | assume_role_policy = jsonencode({ |
| 142 | Version = "2012-10-17" |
| 143 | Statement = [{ |
| 144 | Effect = "Allow" |
| 145 | Principal = { Service = "ec2.amazonaws.com" } |
| 146 | Action = "sts:AssumeRole" |
| 147 | }] |
| 148 | }) |
| 149 | } |
| 150 | |
| 151 | # Scoped to exactly the deployment bucket, this region's data bucket, and |
| 152 | # the deployment's SSM parameter prefix. |
| 153 | resource "aws_iam_role_policy" "host_buckets" { |
| 154 | name = "${var.iam_name}-buckets" |
| 155 | role = aws_iam_role.host.id |
| 156 | policy = jsonencode({ |
| 157 | Version = "2012-10-17" |
| 158 | # The two sibling-staging statement groups (instance-move spec, issue |
| 159 | # #331) are appended only when sibling_staging_arns is non-empty: |
| 160 | # push-only into moves/* — a box can stage a residence move TOWARD a |
| 161 | # sibling region but can never read that region's data or touch its |
| 162 | # live instances/* replica lineage. |
| 163 | Statement = concat([ |
| 164 | { |
| 165 | Effect = "Allow" |
| 166 | Action = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"] |
| 167 | Resource = [ |
| 168 | "${var.deployment_bucket_arn}/*", |
| 169 | "${var.data_bucket_arn}/*", |
| 170 | ] |
| 171 | }, |
| 172 | { |
| 173 | Effect = "Allow" |
| 174 | Action = ["s3:ListBucket", "s3:GetBucketLocation"] |
| 175 | Resource = [var.deployment_bucket_arn, var.data_bucket_arn] |
| 176 | }, |
| 177 | # Host lifecycle claim, Plan 2 (spec C3, §6.2, §6.5): exact-version |
| 178 | # list, read and delete on this box's OWN data bucket. Sibling-region |
| 179 | # moves/* grants are unchanged: the destination region retires its |
| 180 | # own staging (spec N13). |
| 181 | { |
| 182 | Sid = "PullDoorObjectVersions" |
| 183 | Effect = "Allow" |
| 184 | Action = ["s3:GetObjectVersion"] |
| 185 | Resource = ["${var.data_bucket_arn}/*"] |
| 186 | }, |
| 187 | { |
| 188 | Effect = "Allow" |
| 189 | Action = ["s3:DeleteObjectVersion"] |
| 190 | Resource = ["${var.data_bucket_arn}/*"] |
| 191 | }, |
| 192 | { |
| 193 | Sid = "PullDoorVersionList" |
| 194 | Effect = "Allow" |
| 195 | Action = ["s3:ListBucketVersions"] |
| 196 | Resource = [var.data_bucket_arn] |
| 197 | }, |
| 198 | { |
| 199 | Effect = "Allow" |
| 200 | Action = ["ssm:GetParameter"] |
| 201 | Resource = var.ssm_parameter_arn |
| 202 | }, |
| 203 | # Infrastructure inventory (2026-08-19-fleet-architecture-diagram-design.md |
| 204 | # §8): what the console's hourly infra pass needs to draw |
| 205 | # /fleet/architecture. Read-only in the strictest sense — every action |
| 206 | # here is a List/Describe/Get of a resource's SHAPE. No write action, |
| 207 | # no s3:GetObject, and deliberately no iam:GetPolicyVersion: the page |
| 208 | # shows that a role exists and how many policies are attached to it, |
| 209 | # never what those policies say. |
| 210 | { |
| 211 | Effect = "Allow" |
| 212 | Action = [ |
| 213 | "ec2:DescribeInstances", |
| 214 | "ec2:DescribeVolumes", |
| 215 | "ec2:DescribeAddresses", |
| 216 | "ec2:DescribeSecurityGroups", |
| 217 | "ec2:DescribeVpcs", |
| 218 | "ec2:DescribeSubnets", |
| 219 | "ec2:DescribeRegions", |
| 220 | "tag:GetResources", |
| 221 | "s3:ListAllMyBuckets", |
| 222 | "s3:GetBucketLocation", |
| 223 | "s3:GetBucketVersioning", |
| 224 | "s3:GetEncryptionConfiguration", |
| 225 | "s3:GetBucketPublicAccessBlock", |
| 226 | "route53:ListHostedZones", |
| 227 | "route53:ListResourceRecordSets", |
| 228 | "iam:ListRoles", |
| 229 | "iam:ListUsers", |
| 230 | "iam:ListAttachedRolePolicies", |
| 231 | "cloudwatch:DescribeAlarms", |
| 232 | "acm:ListCertificates", |
| 233 | "sts:GetCallerIdentity", |
| 234 | ] |
| 235 | Resource = "*" |
| 236 | }, |
| 237 | ], |
| 238 | [for arn in var.sibling_staging_arns : { |
| 239 | Effect = "Allow" |
| 240 | Action = ["s3:PutObject", "s3:DeleteObject"] |
| 241 | Resource = "${arn}/moves/*" |
| 242 | }], |
| 243 | [for arn in var.sibling_staging_arns : { |
| 244 | Effect = "Allow" |
| 245 | Action = ["s3:ListBucket"] |
| 246 | Resource = arn |
| 247 | Condition = { StringLike = { "s3:prefix" = "moves/*" } } |
| 248 | }] |
| 249 | ) |
| 250 | }) |
| 251 | } |
| 252 | |
| 253 | # Wildcard-cert DNS-01 (edge wildcard.go, CARLOS_DNS_PROVIDER=route53): |
| 254 | # write challenge TXTs into the named zones only. ListHostedZonesByName has |
| 255 | # no resource-level scoping in IAM, hence the split statement. |
| 256 | resource "aws_iam_role_policy" "host_route53" { |
| 257 | count = length(var.route53_zone_ids) > 0 ? 1 : 0 |
| 258 | name = "${var.iam_name}-route53" |
| 259 | role = aws_iam_role.host.id |
| 260 | policy = jsonencode({ |
| 261 | Version = "2012-10-17" |
| 262 | Statement = [ |
| 263 | { |
| 264 | Effect = "Allow" |
| 265 | Action = ["route53:ChangeResourceRecordSets"] |
| 266 | Resource = [for z in var.route53_zone_ids : "arn:aws:route53:::hostedzone/${z}"] |
| 267 | # The box may touch ONLY _acme-challenge TXT records, and only via the |
| 268 | # two operations the edge issues (UPSERT in present, DELETE in cleanup). |
| 269 | # Without these conditions a zone grant is the whole zone — apex, MX, |
| 270 | # NS — and this policy is the only bound on the box's DNS authority. |
| 271 | Condition = { |
| 272 | "ForAllValues:StringEquals" = { |
| 273 | "route53:ChangeResourceRecordSetsRecordTypes" = ["TXT"] |
| 274 | "route53:ChangeResourceRecordSetsActions" = ["UPSERT", "DELETE"] |
| 275 | } |
| 276 | "ForAllValues:StringLike" = { |
| 277 | "route53:ChangeResourceRecordSetsNormalizedRecordNames" = ["_acme-challenge.*"] |
| 278 | } |
| 279 | } |
| 280 | }, |
| 281 | { |
| 282 | Effect = "Allow" |
| 283 | Action = ["route53:GetChange"] |
| 284 | Resource = "arn:aws:route53:::change/*" |
| 285 | }, |
| 286 | { |
| 287 | Effect = "Allow" |
| 288 | Action = ["route53:ListHostedZonesByName"] |
| 289 | Resource = "*" |
| 290 | } |
| 291 | ] |
| 292 | }) |
| 293 | } |
| 294 | |
| 295 | # DNS steering (internal/steering/route53.go, run as a housekeeper duty on |
| 296 | # whichever box holds the lease): latency A records per armed edge, each bound |
| 297 | # to a Route53 health check on that edge's EIP. Every action here is one the |
| 298 | # converger's route53API interface actually calls — nothing is granted "for |
| 299 | # later", and nothing it calls is missing (a missing action is not a partial |
| 300 | # feature: the whole pass aborts AccessDenied and no record ever converges). |
| 301 | resource "aws_iam_role_policy" "host_steering" { |
| 302 | count = length(var.steering_zone_ids) > 0 ? 1 : 0 |
| 303 | name = "${var.iam_name}-steering" |
| 304 | role = aws_iam_role.host.id |
| 305 | policy = jsonencode({ |
| 306 | Version = "2012-10-17" |
| 307 | Statement = [ |
| 308 | { |
| 309 | # The write. Two bounds, and it is worth being exact about what each |
| 310 | # one does and does not buy: |
| 311 | # |
| 312 | # - TYPE and ACTION are bounded always: the converger only ever |
| 313 | # UPSERTs or DELETEs A records (desiredRecord is a latency A set; |
| 314 | # converge builds no other type or action), so the zone's NS, SOA, |
| 315 | # MX, TXT and CNAME records are out of this role's reach by IAM. |
| 316 | # - NAME is bounded only if steering_record_names is set. WITHOUT it |
| 317 | # this grant covers every A record in the zone — the APEX A |
| 318 | # included, which on both our zones is a live site — and the only |
| 319 | # thing standing in the way is the converger's own code |
| 320 | # (isManaged, conflictedNames). That is a code bound, not an IAM |
| 321 | # one. Set the variable to "*.<apps-domain>": it covers the |
| 322 | # steering hostname and every steered platform host, and steered |
| 323 | # CUSTOM domains need no grant here at all — they reach steering |
| 324 | # through a CNAME in the customer's own zone. |
| 325 | Effect = "Allow" |
| 326 | Action = ["route53:ChangeResourceRecordSets"] |
| 327 | Resource = [for z in var.steering_zone_ids : "arn:aws:route53:::hostedzone/${z}"] |
| 328 | Condition = merge( |
| 329 | { |
| 330 | "ForAllValues:StringEquals" = { |
| 331 | "route53:ChangeResourceRecordSetsRecordTypes" = ["A"] |
| 332 | "route53:ChangeResourceRecordSetsActions" = ["UPSERT", "DELETE"] |
| 333 | } |
| 334 | }, |
| 335 | length(var.steering_record_names) > 0 ? { |
| 336 | "ForAllValues:StringLike" = { |
| 337 | "route53:ChangeResourceRecordSetsNormalizedRecordNames" = var.steering_record_names |
| 338 | } |
| 339 | } : {}, |
| 340 | ) |
| 341 | }, |
| 342 | { |
| 343 | # The read, split out because ListResourceRecordSets supports none of |
| 344 | # the condition keys above — folded into one statement they would be |
| 345 | # decoration on this action, which is worse than saying so here. |
| 346 | Effect = "Allow" |
| 347 | Action = ["route53:ListResourceRecordSets"] |
| 348 | Resource = [for z in var.steering_zone_ids : "arn:aws:route53:::hostedzone/${z}"] |
| 349 | }, |
| 350 | { |
| 351 | # Health checks are NOT zone-scoped and Route53 supports NO |
| 352 | # tag-based condition keys on them (the service authorization |
| 353 | # reference lists no condition keys for the healthcheck resource |
| 354 | # type, and CreateHealthCheck takes no tags at all — the converger |
| 355 | # tags in a second call, ChangeTagsForResource). Said plainly rather |
| 356 | # than dressed up: within this account, this role may create, tag, |
| 357 | # read the tags of, and delete ANY Route53 health check. The |
| 358 | # carlos-steering=<zone-id> tag is the converger's OWN ownership |
| 359 | # rule (ownedHealthChecks / gcHealthChecks refuse to delete anything |
| 360 | # else); it is a code bound, not an IAM one, and it cannot be made |
| 361 | # one here. |
| 362 | # |
| 363 | # ListHealthChecks and CreateHealthCheck have no resource ARN to name |
| 364 | # at all, hence "*" for the pair; the per-check operations are held to |
| 365 | # the healthcheck ARN space. |
| 366 | Effect = "Allow" |
| 367 | Action = ["route53:ListHealthChecks", "route53:CreateHealthCheck"] |
| 368 | Resource = "*" |
| 369 | }, |
| 370 | { |
| 371 | Effect = "Allow" |
| 372 | Action = [ |
| 373 | "route53:DeleteHealthCheck", |
| 374 | "route53:ChangeTagsForResource", |
| 375 | # PLURAL — the batched form (ListTagsForResources) is what the |
| 376 | # converger calls, ten checks at a time. The singular |
| 377 | # ListTagsForResource grants it nothing. |
| 378 | "route53:ListTagsForResources", |
| 379 | ] |
| 380 | Resource = "arn:aws:route53:::healthcheck/*" |
| 381 | }, |
| 382 | ] |
| 383 | }) |
| 384 | } |
| 385 | |
| 386 | # Cost Explorer read for the economics dashboard's daily bill pull |
| 387 | # (internal/console/cepull.go). Off by default: only the box that runs the |
| 388 | # console needs it, and ce:GetCostAndUsage has no resource-level scoping — |
| 389 | # it reads the whole account's cost lines, so the grant is deliberate. |
| 390 | resource "aws_iam_role_policy" "host_costexplorer" { |
| 391 | count = var.enable_cost_explorer ? 1 : 0 |
| 392 | name = "${var.iam_name}-costexplorer" |
| 393 | role = aws_iam_role.host.id |
| 394 | policy = jsonencode({ |
| 395 | Version = "2012-10-17" |
| 396 | Statement = [ |
| 397 | { |
| 398 | Effect = "Allow" |
| 399 | Action = ["ce:GetCostAndUsage"] |
| 400 | Resource = "*" |
| 401 | }, |
| 402 | ] |
| 403 | }) |
| 404 | } |
| 405 | |
| 406 | # The agent's memwatch duty (internal/host/memwatch.go) publishes |
| 407 | # MemAvailablePercent and MemoryPressureSome60 once a minute. PutMetricData |
| 408 | # has no resource ARN; the namespace condition is the whole bound — this |
| 409 | # role can write Carlos/Host and nothing else. |
| 410 | resource "aws_iam_role_policy" "host_metrics" { |
| 411 | name = "${var.iam_name}-metrics" |
| 412 | role = aws_iam_role.host.id |
| 413 | policy = jsonencode({ |
| 414 | Version = "2012-10-17" |
| 415 | Statement = [ |
| 416 | { |
| 417 | Effect = "Allow" |
| 418 | Action = ["cloudwatch:PutMetricData"] |
| 419 | Resource = "*" |
| 420 | Condition = { |
| 421 | StringEquals = { "cloudwatch:namespace" = "Carlos/Host" } |
| 422 | } |
| 423 | }, |
| 424 | ] |
| 425 | }) |
| 426 | } |
| 427 | |
| 428 | # Session Manager. This is the ONLY admin path to these boxes — there is no |
| 429 | # SSH ingress at all — so the managed policy is attached rather than |
| 430 | # hand-rolled: getting its action list wrong locks everyone out of a fleet |
| 431 | # whose recovery path is the thing that just broke. |
| 432 | resource "aws_iam_role_policy_attachment" "host_ssm" { |
| 433 | role = aws_iam_role.host.name |
| 434 | policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore" |
| 435 | } |
| 436 | |
| 437 | # ...and the hole that comes with it, closed. |
| 438 | # |
| 439 | # AmazonSSMManagedInstanceCore grants `ssm:GetParameter*` on `Resource: "*"`. |
| 440 | # So the Allow above — one parameter ARN pattern, the variable named |
| 441 | # ssm_parameter_arn, whose whole description is about scoping — bounds |
| 442 | # nothing at all on its own: every host can read every SSM parameter in the |
| 443 | # account. On one deployment that included /carlos/release-key, the root |
| 444 | # release-signing key, readable by an internet-facing edge box. Verified with |
| 445 | # simulate-principal-policy; found standing up a fleet, not in review |
| 446 | # (discussion #7). |
| 447 | # |
| 448 | # An explicit Deny is the fix that does not risk the admin path: IAM's |
| 449 | # explicit deny wins over any Allow, including a managed policy's, and this |
| 450 | # one names ONLY the four parameter-read actions. Session Manager's channel |
| 451 | # actions, inventory, association and patch actions are untouched, so an |
| 452 | # operator's `aws ssm start-session` behaves exactly as it did. |
| 453 | # |
| 454 | # NotResource, not Resource: the deny covers everything the Allow does not |
| 455 | # name, which is the shape that stays correct when ssm_parameter_arn changes. |
| 456 | resource "aws_iam_role_policy" "host_ssm_parameter_scope" { |
| 457 | count = var.scope_ssm_parameter_reads ? 1 : 0 |
| 458 | name = "${var.iam_name}-ssm-parameter-scope" |
| 459 | role = aws_iam_role.host.id |
| 460 | policy = jsonencode({ |
| 461 | Version = "2012-10-17" |
| 462 | Statement = [ |
| 463 | { |
| 464 | Effect = "Deny" |
| 465 | Action = [ |
| 466 | "ssm:GetParameter", |
| 467 | "ssm:GetParameters", |
| 468 | "ssm:GetParameterHistory", |
| 469 | "ssm:GetParametersByPath", |
| 470 | ] |
| 471 | NotResource = [var.ssm_parameter_arn] |
| 472 | }, |
| 473 | ] |
| 474 | }) |
| 475 | } |
| 476 | |
| 477 | resource "aws_iam_instance_profile" "host" { |
| 478 | name = var.iam_name |
| 479 | role = aws_iam_role.host.name |
| 480 | } |
| 481 | |
| 482 | resource "aws_instance" "host" { |
| 483 | ami = data.aws_ami.debian_arm64.id |
| 484 | instance_type = var.instance_type |
| 485 | # Null = the default VPC's default subnet, EC2's own behaviour and this |
| 486 | # module's original one. The security group's precondition guarantees this |
| 487 | # is only null when a default VPC exists to hold it. |
| 488 | subnet_id = var.subnet_id |
| 489 | vpc_security_group_ids = [aws_security_group.host.id] |
| 490 | iam_instance_profile = aws_iam_instance_profile.host.name |
| 491 | user_data = var.user_data |
| 492 | user_data_replace_on_change = false |
| 493 | # IMDSv2 only, one hop. With the block absent EC2 leaves IMDSv1 on, and a |
| 494 | # plain GET from any request-forgery bug in a tenant app reads this |
| 495 | # role's credentials; v2 needs a PUT with a header first, which those |
| 496 | # bugs rarely give an attacker. Hop limit 1 keeps the token from leaving |
| 497 | # the box. Changing these on a running instance is in place, no restart. |
| 498 | # The flagship boxes were switched by hand on 2026-10-23; this keeps a |
| 499 | # replaced box from coming back on v1. The scanner host has done the same |
| 500 | # since it was written. |
| 501 | metadata_options { |
| 502 | http_endpoint = "enabled" |
| 503 | http_tokens = "required" |
| 504 | http_put_response_hop_limit = 1 |
| 505 | } |
| 506 | root_block_device { |
| 507 | volume_size = var.root_volume_size |
| 508 | volume_type = "gp3" |
| 509 | encrypted = true |
| 510 | # A terminated box must leave no disk behind: termination is the proof |
| 511 | # that its instances may run elsewhere (region HA spec 1 §5.9). |
| 512 | delete_on_termination = true |
| 513 | } |
| 514 | tags = { Name = var.name } |
| 515 | # The AMI data source floats (most_recent): without this, a newer |
| 516 | # Debian image silently turns any consumer's `tofu apply` into |
| 517 | # "replace the region host" — seen live 2026-08-03, when a routine |
| 518 | # plan in tito's deployment wanted to destroy the production box. |
| 519 | # AMI updates are explicit-replace territory: |
| 520 | # `tofu apply -replace=module.<host>.aws_instance.host` when intended. |
| 521 | lifecycle { |
| 522 | ignore_changes = [ami] |
| 523 | } |
| 524 | } |
| 525 | |
| 526 | resource "aws_eip" "host" { |
| 527 | domain = "vpc" |
| 528 | instance = aws_instance.host.id |
| 529 | tags = { Name = var.name } |
| 530 | } |
| 531 | |
| 532 | # HA ladder rung 0 (2026-08-12 spec): system-status failure recovers the |
| 533 | # instance onto healthy hardware, keeping instance id, EIP and volumes. |
| 534 | resource "aws_cloudwatch_metric_alarm" "host_auto_recover" { |
| 535 | count = var.enable_auto_recovery ? 1 : 0 |
| 536 | alarm_name = "${var.name}-auto-recover" |
| 537 | alarm_description = "EC2 system-status-check failure — auto-recovers ${var.name} onto healthy hardware." |
| 538 | namespace = "AWS/EC2" |
| 539 | metric_name = "StatusCheckFailed_System" |
| 540 | statistic = "Maximum" |
| 541 | period = 60 |
| 542 | evaluation_periods = 2 |
| 543 | threshold = 1 |
| 544 | comparison_operator = "GreaterThanOrEqualToThreshold" |
| 545 | alarm_actions = ["arn:aws:automate:${data.aws_region.current.region}:ec2:recover"] |
| 546 | dimensions = { |
| 547 | InstanceId = aws_instance.host.id |
| 548 | } |
| 549 | tags = { Name = "${var.name}-auto-recover" } |
| 550 | } |
| 551 | |
| 552 | # Memory-pressure spec §2.4: the early warning the status checks can't give. |
| 553 | # On 2026-08-28 the flagship thrashed for 15 minutes with EC2 status ok/ok; |
| 554 | # MemAvailable would have crossed 15% minutes earlier. Missing data is |
| 555 | # BREACHING: a box that stops reporting is a box in trouble (or an agent |
| 556 | # rolled without CARLOS_HOST_METRICS — either way, look). Which is also |
| 557 | # why the caller should set alerts_email only once the box is publishing |
| 558 | # (spec §4): wiring it first buys days of ALARM mail about nothing. |
| 559 | # |
| 560 | # The topic lives here, in the host's region, because an alarm can only |
| 561 | # notify a same-region topic — one account-wide topic passed to four |
| 562 | # regional hosts would apply and then never deliver for three of them. |
| 563 | resource "aws_sns_topic" "alerts" { |
| 564 | count = var.alerts_email != "" ? 1 : 0 |
| 565 | name = "${var.name}-alerts" |
| 566 | } |
| 567 | |
| 568 | resource "aws_sns_topic_subscription" "alerts_email" { |
| 569 | count = var.alerts_email != "" ? 1 : 0 |
| 570 | topic_arn = aws_sns_topic.alerts[0].arn |
| 571 | protocol = "email" |
| 572 | endpoint = var.alerts_email |
| 573 | } |
| 574 | |
| 575 | resource "aws_cloudwatch_metric_alarm" "host_memory_low" { |
| 576 | count = var.alerts_email != "" ? 1 : 0 |
| 577 | alarm_name = "${var.name}-memory-low" |
| 578 | alarm_description = "MemAvailable below 15% for 3 minutes on ${var.name} — a tenant is eating the box; see the memory-pressure spec §4 for what to look at." |
| 579 | namespace = "Carlos/Host" |
| 580 | metric_name = "MemAvailablePercent" |
| 581 | statistic = "Minimum" |
| 582 | period = 60 |
| 583 | evaluation_periods = 3 |
| 584 | datapoints_to_alarm = 3 |
| 585 | threshold = 15 |
| 586 | comparison_operator = "LessThanThreshold" |
| 587 | treat_missing_data = "breaching" |
| 588 | alarm_actions = [aws_sns_topic.alerts[0].arn] |
| 589 | ok_actions = [aws_sns_topic.alerts[0].arn] |
| 590 | dimensions = { |
| 591 | InstanceId = aws_instance.host.id |
| 592 | } |
| 593 | tags = { Name = "${var.name}-memory-low" } |
| 594 | } |
| 595 | |
| 596 | # ---- object storage provisioning (issue #368) ------------------------------ |
| 597 | # |
| 598 | # Everything below exists only when store_bucket_prefix is set. A deployment |
| 599 | # that has not switched object storage on gets none of it, which is why the |
| 600 | # console's own storeBackend() treats an unset CARLOS_STORE_BACKEND as a legal |
| 601 | # deployment rather than a misconfiguration. |
| 602 | # |
| 603 | # The shape is two halves that only make sense together: a grant wide enough |
| 604 | # to provision, and a ceiling narrow enough that the grant cannot be turned |
| 605 | # into anything else. |
| 606 | |
| 607 | data "aws_caller_identity" "current" {} |
| 608 | |
| 609 | # The ceiling. Every store user is created under this boundary (objstore's |
| 610 | # EnsureUser stamps it, and the role policy below REFUSES CreateUser without |
| 611 | # it), so a store credential can never do more than S3 on this deployment's |
| 612 | # own store buckets — no matter what inline policy the console writes. |
| 613 | # |
| 614 | # This is the answer to the escalation the grant would otherwise open: a role |
| 615 | # that may CreateUser and PutUserPolicy can mint a principal wider than |
| 616 | # itself, and scoping those calls to an IAM path does not help, because a path |
| 617 | # says which user is made and nothing about what it may do. |
| 618 | resource "aws_iam_policy" "store_user_boundary" { |
| 619 | count = var.store_bucket_prefix == "" ? 0 : 1 |
| 620 | name = "${var.iam_name}-store-boundary" |
| 621 | description = "Permissions ceiling for CARLOS object-store users. Intersected with each user's inline policy, which narrows it to that user's one bucket." |
| 622 | policy = jsonencode({ |
| 623 | Version = "2012-10-17" |
| 624 | Statement = [ |
| 625 | { |
| 626 | Effect = "Allow" |
| 627 | Action = ["s3:ListBucket", "s3:GetBucketLocation"] |
| 628 | Resource = "arn:aws:s3:::${var.store_bucket_prefix}*" |
| 629 | }, |
| 630 | { |
| 631 | Effect = "Allow" |
| 632 | Action = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"] |
| 633 | Resource = "arn:aws:s3:::${var.store_bucket_prefix}*/*" |
| 634 | }, |
| 635 | ] |
| 636 | }) |
| 637 | } |
| 638 | |
| 639 | resource "aws_iam_role_policy" "host_store" { |
| 640 | count = var.store_bucket_prefix == "" ? 0 : 1 |
| 641 | name = "${var.iam_name}-store" |
| 642 | role = aws_iam_role.host.id |
| 643 | policy = jsonencode({ |
| 644 | Version = "2012-10-17" |
| 645 | Statement = [ |
| 646 | # Bucket lifecycle, prefix-scoped. EmptyBucket's paginated delete needs |
| 647 | # ListBucket on the bucket and DeleteObject under it; teardown needs |
| 648 | # DeleteBucket. No GetObject: the console provisions stores, it does not |
| 649 | # read tenant data, and the economics sweep sizes buckets with ListBucket. |
| 650 | # (Scanning is the exception, and it is a separate policy behind |
| 651 | # store_scanning, below.) |
| 652 | { |
| 653 | Effect = "Allow" |
| 654 | Action = ["s3:CreateBucket", "s3:DeleteBucket", "s3:ListBucket", "s3:GetBucketLocation"] |
| 655 | Resource = "arn:aws:s3:::${var.store_bucket_prefix}*" |
| 656 | }, |
| 657 | { |
| 658 | Effect = "Allow" |
| 659 | Action = ["s3:DeleteObject"] |
| 660 | Resource = "arn:aws:s3:::${var.store_bucket_prefix}*/*" |
| 661 | }, |
| 662 | # A deployment whose store prefix also matches its OWN buckets — the |
| 663 | # obvious "carlos-" does exactly that against carlos-<deployment>-<acct> |
| 664 | # — would otherwise hand the box DeleteBucket on the control plane it |
| 665 | # runs from. An explicit Deny beats every Allow in IAM, so this holds |
| 666 | # whatever prefix an operator chooses, including a careless one. |
| 667 | { |
| 668 | Effect = "Deny" |
| 669 | Action = ["s3:DeleteBucket", "s3:DeleteObject", "s3:CreateBucket"] |
| 670 | Resource = [ |
| 671 | var.deployment_bucket_arn, |
| 672 | "${var.deployment_bucket_arn}/*", |
| 673 | var.data_bucket_arn, |
| 674 | "${var.data_bucket_arn}/*", |
| 675 | ] |
| 676 | }, |
| 677 | # The two calls that could escalate, each refused unless the user being |
| 678 | # made or modified carries the boundary above. Conditioning PutUserPolicy |
| 679 | # as well as CreateUser matters: without it the console could attach a |
| 680 | # wide inline policy to some pre-existing unbounded user under the path. |
| 681 | { |
| 682 | Effect = "Allow" |
| 683 | Action = ["iam:CreateUser", "iam:PutUserPolicy"] |
| 684 | Resource = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:user${var.store_user_path}*" |
| 685 | Condition = { |
| 686 | StringEquals = { "iam:PermissionsBoundary" = aws_iam_policy.store_user_boundary[0].arn } |
| 687 | } |
| 688 | }, |
| 689 | # The rest of the user lifecycle. None of these can widen a principal, |
| 690 | # so none is boundary-conditioned — but all stay pinned to the store |
| 691 | # user path, so the box can never touch a role or a person's user. |
| 692 | # Deliberately absent: iam:PutUserPermissionsBoundary and |
| 693 | # iam:DeleteUserPermissionsBoundary, which would let the console remove |
| 694 | # the ceiling, and iam:AttachUserPolicy, which would let it bypass the |
| 695 | # inline document entirely. |
| 696 | { |
| 697 | Effect = "Allow" |
| 698 | Action = [ |
| 699 | "iam:GetUser", |
| 700 | "iam:DeleteUser", |
| 701 | "iam:DeleteUserPolicy", |
| 702 | "iam:CreateAccessKey", |
| 703 | "iam:DeleteAccessKey", |
| 704 | "iam:ListAccessKeys", |
| 705 | ] |
| 706 | Resource = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:user${var.store_user_path}*" |
| 707 | }, |
| 708 | ] |
| 709 | }) |
| 710 | } |
| 711 | |
| 712 | # The console's own email (invite fallback, console-invite-delivery spec §4). |
| 713 | # One identity, one From address. The configuration set and tenant are the |
| 714 | # ops account's ("carlos-bab"), which SES checks on a tenant-attributed send. |
| 715 | resource "aws_iam_role_policy" "host_console_mail" { |
| 716 | count = var.console_mail_from == "" ? 0 : 1 |
| 717 | name = "${var.iam_name}-console-mail" |
| 718 | role = aws_iam_role.host.id |
| 719 | policy = jsonencode({ |
| 720 | Version = "2012-10-17" |
| 721 | Statement = [{ |
| 722 | Effect = "Allow" |
| 723 | Action = ["ses:SendEmail"] |
| 724 | Resource = [ |
| 725 | "arn:aws:ses:${var.console_mail_region}:${data.aws_caller_identity.current.account_id}:identity/${split("@", var.console_mail_from)[1]}", |
| 726 | "arn:aws:ses:${var.console_mail_region}:${data.aws_caller_identity.current.account_id}:configuration-set/carlos-bab", |
| 727 | "arn:aws:ses:${var.console_mail_region}:${data.aws_caller_identity.current.account_id}:tenant/carlos-bab/*", |
| 728 | ] |
| 729 | Condition = { |
| 730 | StringEquals = { "ses:FromAddress" = var.console_mail_from } |
| 731 | } |
| 732 | }] |
| 733 | }) |
| 734 | lifecycle { |
| 735 | precondition { |
| 736 | condition = var.console_mail_region != "" |
| 737 | error_message = "console_mail_region must be set when console_mail_from is (it must equal the console's CARLOS_MAIL_REGION)." |
| 738 | } |
| 739 | } |
| 740 | } |
| 741 | |
| 742 | # ---- object storage scanning (opt-in) -------------------------------------- |
| 743 | # |
| 744 | # The one grant that lets this role read tenant object bytes, so it is its own |
| 745 | # policy behind its own switch: a deployment that has not turned scanning on |
| 746 | # plans no change here, and the sentence in host_store above ("it does not |
| 747 | # read tenant data") stays true for it. |
| 748 | # |
| 749 | # Exactly the calls the scanner makes. HeadObject is authorised by GetObject; |
| 750 | # ListObjectsV2 is already in host_store. Store buckets use SSE-S3, so there is |
| 751 | # no KMS statement. PutObjectTagging is how a verdict is written; the store |
| 752 | # user boundary deliberately lacks it, so an app cannot write one itself. |
| 753 | resource "aws_iam_role_policy" "host_store_scan" { |
| 754 | count = var.store_scanning ? 1 : 0 |
| 755 | name = "${var.iam_name}-store-scan" |
| 756 | role = aws_iam_role.host.id |
| 757 | policy = jsonencode({ |
| 758 | Version = "2012-10-17" |
| 759 | Statement = [ |
| 760 | { |
| 761 | Effect = "Allow" |
| 762 | Action = ["s3:GetObject", "s3:GetObjectTagging", "s3:PutObjectTagging"] |
| 763 | Resource = "arn:aws:s3:::${var.store_bucket_prefix}*/*" |
| 764 | }, |
| 765 | ] |
| 766 | }) |
| 767 | lifecycle { |
| 768 | precondition { |
| 769 | condition = var.store_bucket_prefix != "" |
| 770 | error_message = "store_scanning needs store_bucket_prefix: with no prefix there are no object stores on this deployment to scan, and the grant would name every bucket in the account." |
| 771 | } |
| 772 | } |
| 773 | } |
| 774 | |
| 775 | # --- Termination evidence (region HA spec 1 §5.9) --------------------------- |
| 776 | # EC2 tells EventBridge when an instance in this account and region reaches |
| 777 | # "terminated"; the rule forwards that to a queue the region's boxes read. |
| 778 | # Delivery is best-effort on AWS's side: the queue keeps what arrived, and a |
| 779 | # box that finds nothing here still asks EC2 directly. |
| 780 | |
| 781 | resource "aws_sqs_queue" "terminations" { |
| 782 | name = "${var.iam_name}-terminations" |
| 783 | message_retention_seconds = 1209600 # 14 days, the maximum |
| 784 | sqs_managed_sse_enabled = true |
| 785 | } |
| 786 | |
| 787 | resource "aws_cloudwatch_event_rule" "terminations" { |
| 788 | name = "${var.iam_name}-terminations" |
| 789 | description = "EC2 instances reaching terminated, for region HA failover." |
| 790 | event_pattern = jsonencode({ |
| 791 | source = ["aws.ec2"] |
| 792 | "detail-type" = ["EC2 Instance State-change Notification"] |
| 793 | detail = { state = ["terminated"] } |
| 794 | }) |
| 795 | } |
| 796 | |
| 797 | resource "aws_cloudwatch_event_target" "terminations" { |
| 798 | rule = aws_cloudwatch_event_rule.terminations.name |
| 799 | arn = aws_sqs_queue.terminations.arn |
| 800 | } |
| 801 | |
| 802 | # Only this rule may put a message on the queue. A message is evidence, so |
| 803 | # who can write one matters as much as who can read one. |
| 804 | # |
| 805 | # The Allow alone is not enough: a principal in this account with its own |
| 806 | # sqs:SendMessage grant (an admin role, a wildcard policy) could still post |
| 807 | # a forged event, because an identity policy and a queue policy add up. |
| 808 | # The Deny closes that. It matches every send whose source is not this |
| 809 | # rule, including one with no source at all, which is what a direct |
| 810 | # SendMessage from a role looks like. |
| 811 | resource "aws_sqs_queue_policy" "terminations" { |
| 812 | queue_url = aws_sqs_queue.terminations.id |
| 813 | policy = jsonencode({ |
| 814 | Version = "2012-10-17" |
| 815 | Statement = [ |
| 816 | { |
| 817 | Sid = "TheTerminationRuleMaySend" |
| 818 | Effect = "Allow" |
| 819 | Principal = { Service = "events.amazonaws.com" } |
| 820 | Action = "sqs:SendMessage" |
| 821 | Resource = aws_sqs_queue.terminations.arn |
| 822 | Condition = { ArnEquals = { "aws:SourceArn" = aws_cloudwatch_event_rule.terminations.arn } } |
| 823 | }, |
| 824 | { |
| 825 | Sid = "NobodyElseMaySend" |
| 826 | Effect = "Deny" |
| 827 | Principal = "*" |
| 828 | Action = "sqs:SendMessage" |
| 829 | Resource = aws_sqs_queue.terminations.arn |
| 830 | Condition = { ArnNotEquals = { "aws:SourceArn" = aws_cloudwatch_event_rule.terminations.arn } } |
| 831 | }, |
| 832 | ] |
| 833 | }) |
| 834 | } |
| 835 | |
| 836 | resource "aws_iam_role_policy" "host_terminations" { |
| 837 | name = "${var.iam_name}-terminations" |
| 838 | role = aws_iam_role.host.id |
| 839 | policy = jsonencode({ |
| 840 | Version = "2012-10-17" |
| 841 | Statement = [{ |
| 842 | Effect = "Allow" |
| 843 | Action = ["sqs:ReceiveMessage", "sqs:DeleteMessage", "sqs:GetQueueAttributes"] |
| 844 | Resource = aws_sqs_queue.terminations.arn |
| 845 | }] |
| 846 | }) |
| 847 | } |
| 848 | |