############################################################################### # Lab 03 — The Spoke That Could Not Be Answered # # Hub-and-spoke Transit Gateway with two spokes built from the same module. # One reaches shared services. The other does not. Every attachment reports # available and both VPCs' route tables are identical in shape. # # terraform init # terraform apply # terraform output start_session_commands # # COST WARNING: Transit Gateway attachments bill at roughly $0.05/hour EACH, # and this lab creates three of them (~$0.15/hour) on top of three t3.micro # instances. That is a few cents for a 30-minute session, but it accrues # whether or not traffic flows and it is easy to forget. Run # `terraform destroy` when you are done. ############################################################################### terraform { required_version = ">= 1.5.0" required_providers { aws = { source = "hashicorp/aws" version = "~> 5.0" } } } provider "aws" { region = var.region default_tags { tags = { Project = "venerable-networks-lab" Lab = "03-tgw-propagation-gap" ManagedBy = "terraform" } } } variable "region" { description = "Region to deploy the lab into." type = string default = "us-east-1" } variable "name" { description = "Name prefix for lab resources." type = string default = "vn-lab-03" } data "aws_availability_zones" "available" { state = "available" } locals { az = data.aws_availability_zones.available.names[0] # All three VPCs are built from the same definition below, so nothing in the # VPC layer distinguishes the working spoke from the broken one. vpcs = { shared = { cidr = "10.0.0.0/16" subnet_cidr = "10.0.1.0/24" role = "shared services" } "spoke-a" = { cidr = "10.1.0.0/16" subnet_cidr = "10.1.1.0/24" role = "application spoke" } "spoke-b" = { cidr = "10.2.0.0/16" subnet_cidr = "10.2.1.0/24" role = "application spoke" } } # Summary route each VPC uses to send private traffic at the Transit Gateway. # Less specific than every VPC's local route, so intra-VPC traffic is # unaffected. private_summary = "10.0.0.0/8" service_port = 8080 } ############################################################################### # VPCs # # Identical for all three. The public subnet and internet gateway exist only so # Session Manager can reach the instances without interface endpoints — this is # lab harness, not a production topology. ############################################################################### resource "aws_vpc" "lab" { for_each = local.vpcs cidr_block = each.value.cidr enable_dns_support = true enable_dns_hostnames = true tags = { Name = "${var.name}-${each.key}", Role = each.value.role } } resource "aws_internet_gateway" "lab" { for_each = local.vpcs vpc_id = aws_vpc.lab[each.key].id tags = { Name = "${var.name}-${each.key}-igw" } } resource "aws_subnet" "lab" { for_each = local.vpcs vpc_id = aws_vpc.lab[each.key].id cidr_block = each.value.subnet_cidr availability_zone = local.az map_public_ip_on_launch = true tags = { Name = "${var.name}-${each.key}-a" } } resource "aws_route_table" "lab" { for_each = local.vpcs vpc_id = aws_vpc.lab[each.key].id route { cidr_block = "0.0.0.0/0" gateway_id = aws_internet_gateway.lab[each.key].id } # Private inter-VPC traffic goes to the Transit Gateway. Identical in all # three VPCs. route { cidr_block = local.private_summary transit_gateway_id = aws_ec2_transit_gateway.lab.id } tags = { Name = "${var.name}-${each.key}-rtb" } depends_on = [aws_ec2_transit_gateway_vpc_attachment.lab] } resource "aws_route_table_association" "lab" { for_each = local.vpcs subnet_id = aws_subnet.lab[each.key].id route_table_id = aws_route_table.lab[each.key].id } ############################################################################### # Transit Gateway ############################################################################### resource "aws_ec2_transit_gateway" "lab" { description = "${var.name} hub" # Explicit association and propagation only. Leaving the defaults enabled # would silently connect everything to everything and hide the topology. default_route_table_association = "disable" default_route_table_propagation = "disable" tags = { Name = "${var.name}-tgw" } } resource "aws_ec2_transit_gateway_vpc_attachment" "lab" { for_each = local.vpcs transit_gateway_id = aws_ec2_transit_gateway.lab.id vpc_id = aws_vpc.lab[each.key].id subnet_ids = [aws_subnet.lab[each.key].id] transit_gateway_default_route_table_association = false transit_gateway_default_route_table_propagation = false tags = { Name = "${var.name}-attach-${each.key}" } } ############################################################################### # Transit Gateway route tables # # Two route tables implementing a standard hub-and-spoke policy: spokes may # reach shared services, but not each other. ############################################################################### # Consulted for traffic arriving FROM a spoke. resource "aws_ec2_transit_gateway_route_table" "spokes" { transit_gateway_id = aws_ec2_transit_gateway.lab.id tags = { Name = "${var.name}-tgw-rt-spokes" } } # Consulted for traffic arriving FROM shared services. resource "aws_ec2_transit_gateway_route_table" "shared" { transit_gateway_id = aws_ec2_transit_gateway.lab.id tags = { Name = "${var.name}-tgw-rt-shared" } } # --- Associations: which route table each attachment consults on ingress ----- resource "aws_ec2_transit_gateway_route_table_association" "spoke_a" { transit_gateway_attachment_id = aws_ec2_transit_gateway_vpc_attachment.lab["spoke-a"].id transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.spokes.id } resource "aws_ec2_transit_gateway_route_table_association" "spoke_b" { transit_gateway_attachment_id = aws_ec2_transit_gateway_vpc_attachment.lab["spoke-b"].id transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.spokes.id } resource "aws_ec2_transit_gateway_route_table_association" "shared" { transit_gateway_attachment_id = aws_ec2_transit_gateway_vpc_attachment.lab["shared"].id transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.shared.id } # --- Propagations: which CIDRs get advertised INTO each route table ---------- # Spokes learn how to reach shared services. resource "aws_ec2_transit_gateway_route_table_propagation" "shared_into_spokes" { transit_gateway_attachment_id = aws_ec2_transit_gateway_vpc_attachment.lab["shared"].id transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.spokes.id } # Shared services learns how to reach spoke-a. resource "aws_ec2_transit_gateway_route_table_propagation" "spoke_a_into_shared" { transit_gateway_attachment_id = aws_ec2_transit_gateway_vpc_attachment.lab["spoke-a"].id transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.shared.id } # NOTE: association and propagation are independent. Every attachment above is # associated with a route table, which is what lets traffic be evaluated on # ingress. Propagation is what puts a CIDR INTO a route table so it can be # selected as a destination. An attachment can be fully associated and still be # unreachable. ############################################################################### # Instances ############################################################################### data "aws_iam_policy_document" "ec2_assume" { statement { actions = ["sts:AssumeRole"] principals { type = "Service" identifiers = ["ec2.amazonaws.com"] } } } resource "aws_iam_role" "ssm" { name = "${var.name}-ssm-role" assume_role_policy = data.aws_iam_policy_document.ec2_assume.json } resource "aws_iam_role_policy_attachment" "ssm" { role = aws_iam_role.ssm.name policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore" } resource "aws_iam_instance_profile" "ssm" { name = "${var.name}-ssm-profile" role = aws_iam_role.ssm.name } data "aws_ssm_parameter" "al2023" { name = "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64" } resource "aws_security_group" "lab" { for_each = local.vpcs name = "${var.name}-${each.key}" description = "Lab instance in ${each.key}" vpc_id = aws_vpc.lab[each.key].id # Every host runs the same listener, so you can probe in any direction. ingress { description = "Service port from anywhere in the private summary range" from_port = local.service_port to_port = local.service_port protocol = "tcp" cidr_blocks = [local.private_summary] } ingress { description = "ICMP from the private summary range, for path testing" from_port = -1 to_port = -1 protocol = "icmp" cidr_blocks = [local.private_summary] } egress { description = "All outbound" from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } tags = { Name = "${var.name}-sg-${each.key}" } } resource "aws_instance" "lab" { for_each = local.vpcs ami = data.aws_ssm_parameter.al2023.value instance_type = "t3.micro" subnet_id = aws_subnet.lab[each.key].id vpc_security_group_ids = [aws_security_group.lab[each.key].id] iam_instance_profile = aws_iam_instance_profile.ssm.name user_data = <<-EOF #!/bin/bash dnf install -y nmap-ncat tcpdump >/dev/null 2>&1 mkdir -p /opt/lab echo "vn-lab-03 ${each.key} ok" > /opt/lab/index.html nohup python3 -m http.server ${local.service_port} \ --bind 0.0.0.0 --directory /opt/lab \ >/var/log/lab-listener.log 2>&1 & EOF metadata_options { http_tokens = "required" } tags = { Name = "${var.name}-${each.key}", Role = each.value.role } } ############################################################################### # Outputs ############################################################################### output "start_session_commands" { description = "Shell on each host." value = { for key, instance in aws_instance.lab : key => "aws ssm start-session --region ${var.region} --target ${instance.id}" } } output "private_ips" { description = "Probe these on the service port from any other host." value = { for key, instance in aws_instance.lab : key => instance.private_ip } } output "service_port" { value = local.service_port } output "tgw_route_table_ids" { value = { spokes = aws_ec2_transit_gateway_route_table.spokes.id shared = aws_ec2_transit_gateway_route_table.shared.id } } output "tgw_attachment_ids" { value = { for key, attachment in aws_ec2_transit_gateway_vpc_attachment.lab : key => attachment.id } }