############################################################################### # Lab 02 — The Hardened Subnet That Broke Everything # # A NACL change intended to restrict inbound access to the data tier instead # breaks every connection to it. The apply succeeds, both instances are # healthy, security groups permit the traffic, and routing is correct. # # terraform init # terraform apply # terraform output -raw start_session_command # # No NAT Gateway and no interface endpoints: the failing flow is internal, and # diagnosis runs through VPC Flow Logs rather than a shell on the data host. # That keeps the lab to two t3.micro instances. # # COST: two t3.micro instances plus a small volume of CloudWatch Logs. Cents # for a 25-minute session. Run `terraform destroy` when 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 = "02-nacl-return-path" 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-02" } data "aws_availability_zones" "available" { state = "available" } locals { az = data.aws_availability_zones.available.names[0] app_cidr = "10.0.0.0/24" data_cidr = "10.0.11.0/24" service_port = 5432 } ############################################################################### # VPC ############################################################################### resource "aws_vpc" "lab" { cidr_block = "10.0.0.0/16" enable_dns_support = true enable_dns_hostnames = true tags = { Name = "${var.name}-vpc" } } resource "aws_internet_gateway" "lab" { vpc_id = aws_vpc.lab.id tags = { Name = "${var.name}-igw" } } # App tier. Public so Session Manager reaches it without interface endpoints — # this is the lab harness, not a production pattern. resource "aws_subnet" "app" { vpc_id = aws_vpc.lab.id cidr_block = local.app_cidr availability_zone = local.az map_public_ip_on_launch = true tags = { Name = "${var.name}-app-a", Tier = "app" } } # Data tier. No route off the VPC by design. resource "aws_subnet" "data" { vpc_id = aws_vpc.lab.id cidr_block = local.data_cidr availability_zone = local.az tags = { Name = "${var.name}-data-a", Tier = "data" } } resource "aws_route_table" "app" { vpc_id = aws_vpc.lab.id route { cidr_block = "0.0.0.0/0" gateway_id = aws_internet_gateway.lab.id } tags = { Name = "${var.name}-rtb-app" } } resource "aws_route_table_association" "app" { subnet_id = aws_subnet.app.id route_table_id = aws_route_table.app.id } resource "aws_route_table" "data" { vpc_id = aws_vpc.lab.id tags = { Name = "${var.name}-rtb-data" } } resource "aws_route_table_association" "data" { subnet_id = aws_subnet.data.id route_table_id = aws_route_table.data.id } ############################################################################### # Network ACLs # # The app subnet keeps a permissive NACL. The data subnet carries the # "hardening" change from the ticket. ############################################################################### resource "aws_network_acl" "app" { vpc_id = aws_vpc.lab.id subnet_ids = [aws_subnet.app.id] ingress { rule_no = 100 action = "allow" protocol = "-1" from_port = 0 to_port = 0 cidr_block = "0.0.0.0/0" } egress { rule_no = 100 action = "allow" protocol = "-1" from_port = 0 to_port = 0 cidr_block = "0.0.0.0/0" } tags = { Name = "${var.name}-nacl-app" } } resource "aws_network_acl" "data" { vpc_id = aws_vpc.lab.id subnet_ids = [aws_subnet.data.id] # Inbound: permit the database port from inside the VPC. Reads as a correct, # tight inbound policy — and it is. ingress { rule_no = 100 action = "allow" protocol = "tcp" from_port = local.service_port to_port = local.service_port cidr_block = aws_vpc.lab.cidr_block } # Outbound: scoped to the same port the service listens on. egress { rule_no = 100 action = "allow" protocol = "tcp" from_port = local.service_port to_port = local.service_port cidr_block = aws_vpc.lab.cidr_block } tags = { Name = "${var.name}-nacl-data" } } ############################################################################### # Security groups ############################################################################### resource "aws_security_group" "app" { name = "${var.name}-app" description = "App tier instance" vpc_id = aws_vpc.lab.id egress { description = "All outbound" from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } tags = { Name = "${var.name}-sg-app" } } resource "aws_security_group" "data" { name = "${var.name}-data" description = "Data tier instance" vpc_id = aws_vpc.lab.id ingress { description = "Service port from the app tier" from_port = local.service_port to_port = local.service_port protocol = "tcp" security_groups = [aws_security_group.app.id] } egress { description = "All outbound" from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } tags = { Name = "${var.name}-sg-data" } } ############################################################################### # VPC Flow Logs # # The diagnostic surface for this lab. NACL denies are invisible from both # hosts; flow logs are where they show up. ############################################################################### resource "aws_cloudwatch_log_group" "flow" { name = "/${var.name}/vpc-flow-logs" retention_in_days = 1 } data "aws_iam_policy_document" "flow_assume" { statement { actions = ["sts:AssumeRole"] principals { type = "Service" identifiers = ["vpc-flow-logs.amazonaws.com"] } } } data "aws_iam_policy_document" "flow_publish" { statement { actions = [ "logs:CreateLogStream", "logs:PutLogEvents", "logs:DescribeLogGroups", "logs:DescribeLogStreams", ] resources = ["${aws_cloudwatch_log_group.flow.arn}:*"] } } resource "aws_iam_role" "flow" { name = "${var.name}-flow-logs-role" assume_role_policy = data.aws_iam_policy_document.flow_assume.json } resource "aws_iam_role_policy" "flow" { name = "${var.name}-flow-logs-publish" role = aws_iam_role.flow.id policy = data.aws_iam_policy_document.flow_publish.json } resource "aws_flow_log" "lab" { vpc_id = aws_vpc.lab.id traffic_type = "ALL" iam_role_arn = aws_iam_role.flow.arn log_destination = aws_cloudwatch_log_group.flow.arn max_aggregation_interval = 60 # Compact custom format. The default format works too, but positional fields # make the CLI output in this lab much harder to read. log_format = "$${srcaddr} $${srcport} $${dstaddr} $${dstport} $${protocol} $${packets} $${action} $${log-status}" tags = { Name = "${var.name}-flow-logs" } } ############################################################################### # 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_instance" "app" { ami = data.aws_ssm_parameter.al2023.value instance_type = "t3.micro" subnet_id = aws_subnet.app.id vpc_security_group_ids = [aws_security_group.app.id] iam_instance_profile = aws_iam_instance_profile.ssm.name # This host has internet access, so it can pull the tools you need. user_data = <<-EOF #!/bin/bash dnf install -y nmap-ncat tcpdump EOF metadata_options { http_tokens = "required" } tags = { Name = "${var.name}-app" } } resource "aws_instance" "data" { ami = data.aws_ssm_parameter.al2023.value instance_type = "t3.micro" subnet_id = aws_subnet.data.id vpc_security_group_ids = [aws_security_group.data.id] # No instance profile: this host has no route off the VPC, so the SSM agent # could not register anyway. You are not meant to get a shell here. # # The listener is written in Python because it ships with AL2023 — `dnf # install` would fail on a subnet with no egress. user_data = <<-EOF #!/bin/bash cat >/usr/local/bin/lab-listener.py <<'PY' import socket server = socket.socket() server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind(("0.0.0.0", ${local.service_port})) server.listen(16) while True: conn, _ = server.accept() conn.sendall(b"vn-lab-02 data tier\n") conn.close() PY nohup python3 /usr/local/bin/lab-listener.py >/var/log/lab-listener.log 2>&1 & EOF metadata_options { http_tokens = "required" } tags = { Name = "${var.name}-data" } } ############################################################################### # Outputs ############################################################################### output "start_session_command" { description = "Shell on the app tier instance." value = "aws ssm start-session --region ${var.region} --target ${aws_instance.app.id}" } output "app_instance_id" { value = aws_instance.app.id } output "data_private_ip" { description = "Connect to this from the app instance on port 5432." value = aws_instance.data.private_ip } output "service_port" { value = local.service_port } output "data_network_acl_id" { value = aws_network_acl.data.id } output "flow_log_group" { value = aws_cloudwatch_log_group.flow.name }