diff --git a/testenv/.gitignore b/testenv/.gitignore new file mode 100644 index 0000000..19e62f7 --- /dev/null +++ b/testenv/.gitignore @@ -0,0 +1,6 @@ +# Terraform working files (keep .terraform.lock.hcl tracked) +.terraform/ +*.tfstate +*.tfstate.* +*.tfvars +crash.log diff --git a/testenv/.terraform.lock.hcl b/testenv/.terraform.lock.hcl new file mode 100644 index 0000000..29b26f3 --- /dev/null +++ b/testenv/.terraform.lock.hcl @@ -0,0 +1,47 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.54.0" + constraints = ">= 5.0.0" + hashes = [ + "h1:i89hWLGo3bPwJKYBv3+S67qbfr0tydFqs70Hq5VcoYs=", + "zh:0557777baa82faa7907adce61a2a3f9b2a487070bb03f29df20a7502edfe966f", + "zh:0767256c36b9c4d4b66d4af882de480c6535ff9eac26410fca253e87b89cb478", + "zh:0be7595ef70273af5eb72d850d45eb264d39796d0578c7f3eda9988d7f0781e5", + "zh:0fd81edad00c8da35b37aa310c7466f7a8d61056b95b37b349c510d21a8a52f6", + "zh:11a976648c864d126a70eb2d40182a17ecfeb3c6c3bf790c4f7eafa5e4c204ba", + "zh:14065875294ea597303ae8e462189041067de3b1f5d97c9f2b04b5d14aeb4f7c", + "zh:55a76258b109f8394cad20c9e07f376fe3051920931bf43d1b62f4d7242f20c8", + "zh:69b04cc363dda4df3d703c3954ae87ef36b0fea8fe45a236db89f8647a879274", + "zh:79e3425219f59e285f128e88a7c294a6d33c7bfa73c57a42e31ac0d8daa2f20a", + "zh:8d6c46c8a215f73c3c1109695a9bfd8894852d0b1c592516f09efb8a84e08b0c", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:9db8f8fac77d6e999a2886df52bebb106b93ba8dfc4fea1e04decc8fda8070cd", + "zh:b4fc365f093b232531905547a50d4f62dbd9d5a843707867520b8b04e8b36e4f", + "zh:c9135045fbc561d30ec72f0b9335fcfe7590eed7e6b4eb8988455e6c8b4d348a", + "zh:db5f51f9f7037428cb7e8f8d43a63e0d3de498c9730fe98a3c539a75cc49208e", + "zh:f807b2a66174b20749c11d58cf7b2ffa8e89b4ec60dd70382959074f8490c387", + ] +} + +provider "registry.terraform.io/hashicorp/random" { + version = "3.9.0" + constraints = ">= 3.5.0" + hashes = [ + "h1:OO+IuvQJSPmWdN8AyyIEvPJbLvDQpgX/zbktoa9KsJE=", + "zh:161ad0bd9a75768c82f53fb6e7172a9d8be2d4889b012645a34795031aaf1bf1", + "zh:19dc9a5b17729725ccfc4f45b0500af0ee5bc6b6b160c7adb8f2bf617d2c80ea", + "zh:269eda8fe42daa7974d5a34d166c3ba9defe80cde86c01e4dadcfdf2e1f05e5f", + "zh:373f7c65566f8f2cc7f45d698654feb9d988996957e1266a69ca00c52d6d16d0", + "zh:5599d16804c41c83009ec621b6d6b6f74e102f5827678a4750f8809055546b61", + "zh:583be0440469a22bff70dcfa56593b01566860b29607437264adb51060cf46fc", + "zh:5f211d8ec3f2e1f414870d9584bfe26e6995560ef81c748f8447a48164767398", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:7b547fd16216761ef86efc3ed516ac5ac0c5c42b7c7eb24a08cef2d93f69ed5e", + "zh:7e7c0679daf2a382151d05068c8c3f0dae6b7b7dccf818827b73dd08638df2ef", + "zh:8089dec888a8038b9b4fb23b3df7e1057293dbc5b60b42cc47ff690d69d4b61b", + "zh:c51f15a031edfd6f23ce8ced3446ca7f8d8d647e2499890d7d5d10d5016d7257", + "zh:c94784f005708890dc6895afd53636ec00ec1e430b15d41e5aebfb1d4b39bd04", + ] +} diff --git a/testenv/README.md b/testenv/README.md new file mode 100644 index 0000000..2f84644 --- /dev/null +++ b/testenv/README.md @@ -0,0 +1,49 @@ +# testenv — free-tier private topology + +A small, **fully private** topology (Terraform) that gives `reachr` real ENIs, a +three-tier security-group chain, and a VPC endpoint to design the `topology.json` +schema against and to seed the golden fixture. Not part of the shipped tool — +it's a development/test environment. + +``` +Custom VPC 10.20.0.0/16 (no IGW / no NAT) + └─ 2 private subnets (2 AZs) + ├─ internal ALB [alb-sg] :80 ─▶ target group :8080 + │ └─ 2× EC2 t2.micro [app-sg] (stands in for ECS) + ├─ RDS db.t3.micro Postgres [db-sg] + └─ S3 gateway endpoint (free) → prefix-list route +SG chain: alb-sg ─:8080▶ app-sg ─:5432▶ db-sg (SG-references-SG) +``` + +## Cost + +Designed for the **12-month AWS Free Tier**: EC2 `t2.micro`, RDS `db.t3.micro`, +and one ALB are each within the 750 hrs/month allowance; the S3 gateway endpoint +is always free. There is **no NAT gateway or interface endpoint** (the usual cost +drivers). Still — **run `terraform destroy` once you've captured the fixture.** +Two app instances share the 750-hr EC2 allowance, so don't leave it running. + +## Usage + +```sh +cd testenv +terraform init +terraform apply # ~10 min (RDS is the slow part) + +# Scan it (from the repo root, using the scan --raw command from Slice 1): +cd .. +go run . scan --raw --region ap-southeast-2 --vpc "$(terraform -chdir=testenv output -raw vpc_id)" > raw.json + +# When done: +terraform -chdir=testenv destroy +``` + +`terraform output scan_command` prints the exact scan command with the VPC id +filled in. + +## Notes + +- Apps don't serve traffic; ALB targets will show unhealthy. That's fine — reachr + scans **structure**, and v1 doesn't evaluate target health. +- The RDS master password is generated (`random_password`) and never needed; the + DB is not reachable and exists only for its network shape. diff --git a/testenv/compute.tf b/testenv/compute.tf new file mode 100644 index 0000000..557d760 --- /dev/null +++ b/testenv/compute.tf @@ -0,0 +1,84 @@ +data "aws_ami" "al2023" { + most_recent = true + owners = ["amazon"] + filter { + name = "name" + values = ["al2023-ami-*-x86_64"] + } +} + +# App tier: two instances (one per AZ) standing in for ECS tasks. They don't need +# to serve traffic — reachr scans structure (ENIs/SGs/target associations), and +# v1 does not evaluate target health. +resource "aws_instance" "app" { + count = 2 + ami = data.aws_ami.al2023.id + instance_type = var.instance_type + subnet_id = aws_subnet.private[count.index].id + vpc_security_group_ids = [aws_security_group.app.id] + associate_public_ip_address = false + tags = { Name = "reachr-testenv-app-${count.index}" } +} + +resource "aws_lb" "app" { + name = "reachr-testenv-alb" + internal = true + load_balancer_type = "application" + security_groups = [aws_security_group.alb.id] + subnets = aws_subnet.private[*].id + tags = { Name = "reachr-testenv-alb" } +} + +resource "aws_lb_target_group" "app" { + name = "reachr-testenv-app" + port = var.app_port + protocol = "HTTP" + vpc_id = aws_vpc.this.id + target_type = "instance" + tags = { Name = "reachr-testenv-app" } +} + +resource "aws_lb_target_group_attachment" "app" { + count = length(aws_instance.app) + target_group_arn = aws_lb_target_group.app.arn + target_id = aws_instance.app[count.index].id + port = var.app_port +} + +resource "aws_lb_listener" "http" { + load_balancer_arn = aws_lb.app.arn + port = 80 + protocol = "HTTP" + default_action { + type = "forward" + target_group_arn = aws_lb_target_group.app.arn + } +} + +# DB tier: RDS Postgres (db.t3.micro is RDS free-tier eligible). +resource "random_password" "db" { + length = 20 + special = false +} + +resource "aws_db_subnet_group" "this" { + name = "reachr-testenv" + subnet_ids = aws_subnet.private[*].id + tags = { Name = "reachr-testenv" } +} + +resource "aws_db_instance" "this" { + identifier = "reachr-testenv" + engine = "postgres" + instance_class = "db.t3.micro" + allocated_storage = 20 + db_name = "reachr" + username = "reachr" + password = random_password.db.result + db_subnet_group_name = aws_db_subnet_group.this.name + vpc_security_group_ids = [aws_security_group.db.id] + publicly_accessible = false + multi_az = false + skip_final_snapshot = true + tags = { Name = "reachr-testenv-db" } +} diff --git a/testenv/network.tf b/testenv/network.tf new file mode 100644 index 0000000..e781955 --- /dev/null +++ b/testenv/network.tf @@ -0,0 +1,44 @@ +data "aws_availability_zones" "available" { + state = "available" +} + +locals { + azs = slice(data.aws_availability_zones.available.names, 0, 2) +} + +resource "aws_vpc" "this" { + cidr_block = var.vpc_cidr + enable_dns_support = true + enable_dns_hostnames = true + tags = { Name = "reachr-testenv" } +} + +# Private subnets only — no IGW, no NAT. Fully private topology. +resource "aws_subnet" "private" { + count = length(local.azs) + vpc_id = aws_vpc.this.id + cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index) + availability_zone = local.azs[count.index] + tags = { Name = "reachr-testenv-private-${local.azs[count.index]}" } +} + +resource "aws_route_table" "private" { + vpc_id = aws_vpc.this.id + tags = { Name = "reachr-testenv-private" } +} + +resource "aws_route_table_association" "private" { + count = length(aws_subnet.private) + subnet_id = aws_subnet.private[count.index].id + route_table_id = aws_route_table.private.id +} + +# Free S3 gateway endpoint: adds a prefix-list route to the private route table +# (exercises the route engine's gateway-endpoint / prefix-list path). +resource "aws_vpc_endpoint" "s3" { + vpc_id = aws_vpc.this.id + service_name = "com.amazonaws.${var.region}.s3" + vpc_endpoint_type = "Gateway" + route_table_ids = [aws_route_table.private.id] + tags = { Name = "reachr-testenv-s3" } +} diff --git a/testenv/outputs.tf b/testenv/outputs.tf new file mode 100644 index 0000000..559004b --- /dev/null +++ b/testenv/outputs.tf @@ -0,0 +1,14 @@ +output "vpc_id" { + description = "The test VPC id" + value = aws_vpc.this.id +} + +output "scan_command" { + description = "Ready-to-run exploratory scan for this VPC (run from the repo root)" + value = "go run . scan --raw --region ${var.region} --vpc ${aws_vpc.this.id} > raw.json" +} + +output "alb_dns_name" { + description = "Internal ALB DNS name" + value = aws_lb.app.dns_name +} diff --git a/testenv/security.tf b/testenv/security.tf new file mode 100644 index 0000000..3e720b7 --- /dev/null +++ b/testenv/security.tf @@ -0,0 +1,71 @@ +# Three-tier SG chain: alb -> app -> db, using SG-references-SG rules (the +# rung-1 reachability engine's key case). + +resource "aws_security_group" "alb" { + name = "reachr-testenv-alb" + description = "ALB tier" + vpc_id = aws_vpc.this.id + tags = { Name = "reachr-testenv-alb" } +} + +resource "aws_security_group" "app" { + name = "reachr-testenv-app" + description = "App tier (stands in for ECS)" + vpc_id = aws_vpc.this.id + tags = { Name = "reachr-testenv-app" } +} + +resource "aws_security_group" "db" { + name = "reachr-testenv-db" + description = "RDS tier" + vpc_id = aws_vpc.this.id + tags = { Name = "reachr-testenv-db" } +} + +# ALB: HTTP in from within the VPC (no external client in a fully private env). +resource "aws_vpc_security_group_ingress_rule" "alb_http" { + security_group_id = aws_security_group.alb.id + description = "HTTP from within VPC" + cidr_ipv4 = var.vpc_cidr + from_port = 80 + to_port = 80 + ip_protocol = "tcp" +} + +resource "aws_vpc_security_group_egress_rule" "alb_to_app" { + security_group_id = aws_security_group.alb.id + description = "To app tier" + referenced_security_group_id = aws_security_group.app.id + from_port = var.app_port + to_port = var.app_port + ip_protocol = "tcp" +} + +# App: in from ALB on app_port (SG-references-SG). +resource "aws_vpc_security_group_ingress_rule" "app_from_alb" { + security_group_id = aws_security_group.app.id + description = "From ALB" + referenced_security_group_id = aws_security_group.alb.id + from_port = var.app_port + to_port = var.app_port + ip_protocol = "tcp" +} + +resource "aws_vpc_security_group_egress_rule" "app_to_db" { + security_group_id = aws_security_group.app.id + description = "To DB" + referenced_security_group_id = aws_security_group.db.id + from_port = 5432 + to_port = 5432 + ip_protocol = "tcp" +} + +# DB: Postgres in from app (SG-references-SG). +resource "aws_vpc_security_group_ingress_rule" "db_from_app" { + security_group_id = aws_security_group.db.id + description = "Postgres from app" + referenced_security_group_id = aws_security_group.app.id + from_port = 5432 + to_port = 5432 + ip_protocol = "tcp" +} diff --git a/testenv/variables.tf b/testenv/variables.tf new file mode 100644 index 0000000..55362db --- /dev/null +++ b/testenv/variables.tf @@ -0,0 +1,29 @@ +variable "region" { + description = "AWS region for the test environment" + type = string + default = "ap-southeast-2" +} + +variable "project_tag" { + description = "Value of the project tag (used by reachr's scope selector and for teardown)" + type = string + default = "reachr-testenv" +} + +variable "vpc_cidr" { + description = "CIDR block for the test VPC" + type = string + default = "10.20.0.0/16" +} + +variable "instance_type" { + description = "App-tier EC2 instance type (t2.micro is free-tier in ap-southeast-2)" + type = string + default = "t2.micro" +} + +variable "app_port" { + description = "Port the app tier listens on behind the ALB" + type = number + default = 8080 +} diff --git a/testenv/versions.tf b/testenv/versions.tf new file mode 100644 index 0000000..b9dbf44 --- /dev/null +++ b/testenv/versions.tf @@ -0,0 +1,26 @@ +terraform { + required_version = ">= 1.5" + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + random = { + source = "hashicorp/random" + version = ">= 3.5" + } + } +} + +provider "aws" { + region = var.region + + # Everything is tagged so reachr's scope selector has real data and teardown + # is trivial (filter/destroy by project=reachr-testenv). + default_tags { + tags = { + project = var.project_tag + managedBy = "terraform" + } + } +}