From e77baedba1485a9886e8bdba7d9fa0c6209bc671 Mon Sep 17 00:00:00 2001 From: Daniel Henley Date: Wed, 12 Aug 2026 12:28:17 +0800 Subject: [PATCH] infra: Terraform module for the vpcopilot-lab (AWAF BIG-IP + Larkspur) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds infra/vpcopilot-lab/ — the copilot's own validation lab as Terraform, adapted from nimbus-demo/infra for the new AWS account. Fixes the "no IaC in the repo" gap so the estate is codified and travels to the new laptop. - Advanced-WAF BIG-IP VE (AMI auto-resolved from F5's Marketplace owner, or pinned), 2-NIC, public VIP EIP, management reachable ONLY via an SSM tunnel. - Larkspur origin (t3.small, Docker) behind the appliance, fixed 10.30.10.22 so `vpcopilot bigip-lab create` examples copy-paste. - Dedicated VPC 10.30.0.0/16, deliberately separate from nimbus-demo. - onboard/bigip-onboard.sh scripts the two documented traps: poll mcp-state before provisioning asm:nominal, then curl-install AS3. - Makefile cost controls (lab-up / lab-down / lab-nuke), tag-scoped to Project=vpcopilot-lab so a stop can never touch another estate. - No load generators; no credentials or state in the tree (see .gitignore). terraform validate passes; plan = 20 to add against account 113938649684. Co-Authored-By: Claude Opus 4.8 --- infra/vpcopilot-lab/.gitignore | 17 +++ infra/vpcopilot-lab/Makefile | 105 +++++++++++++ infra/vpcopilot-lab/README.md | 89 +++++++++++ infra/vpcopilot-lab/bigip.tf | 80 ++++++++++ infra/vpcopilot-lab/data.tf | 46 ++++++ infra/vpcopilot-lab/iam.tf | 31 ++++ infra/vpcopilot-lab/keys.tf | 11 ++ infra/vpcopilot-lab/network.tf | 67 ++++++++ infra/vpcopilot-lab/onboard/bigip-onboard.sh | 67 ++++++++ infra/vpcopilot-lab/origin.tf | 32 ++++ infra/vpcopilot-lab/outputs.tf | 54 +++++++ infra/vpcopilot-lab/providers.tf | 15 ++ infra/vpcopilot-lab/security.tf | 131 ++++++++++++++++ infra/vpcopilot-lab/terraform.tfvars.example | 24 +++ infra/vpcopilot-lab/user_data/origin.sh | 14 ++ infra/vpcopilot-lab/variables.tf | 151 +++++++++++++++++++ infra/vpcopilot-lab/versions.tf | 10 ++ 17 files changed, 944 insertions(+) create mode 100644 infra/vpcopilot-lab/.gitignore create mode 100644 infra/vpcopilot-lab/Makefile create mode 100644 infra/vpcopilot-lab/README.md create mode 100644 infra/vpcopilot-lab/bigip.tf create mode 100644 infra/vpcopilot-lab/data.tf create mode 100644 infra/vpcopilot-lab/iam.tf create mode 100644 infra/vpcopilot-lab/keys.tf create mode 100644 infra/vpcopilot-lab/network.tf create mode 100644 infra/vpcopilot-lab/onboard/bigip-onboard.sh create mode 100644 infra/vpcopilot-lab/origin.tf create mode 100644 infra/vpcopilot-lab/outputs.tf create mode 100644 infra/vpcopilot-lab/providers.tf create mode 100644 infra/vpcopilot-lab/security.tf create mode 100644 infra/vpcopilot-lab/terraform.tfvars.example create mode 100644 infra/vpcopilot-lab/user_data/origin.sh create mode 100644 infra/vpcopilot-lab/variables.tf create mode 100644 infra/vpcopilot-lab/versions.tf diff --git a/infra/vpcopilot-lab/.gitignore b/infra/vpcopilot-lab/.gitignore new file mode 100644 index 0000000..a56f97d --- /dev/null +++ b/infra/vpcopilot-lab/.gitignore @@ -0,0 +1,17 @@ +# Tooling scratch — never commit. +.claude-flow/ +.swarm/ + +# Secrets and local state — never commit. +.secrets/ +*.pem +*.pub +terraform.tfvars +*.tfstate +*.tfstate.* +.terraform/ +.terraform.lock.hcl +*.tfplan +tfplan +crash.log +crash.*.log diff --git a/infra/vpcopilot-lab/Makefile b/infra/vpcopilot-lab/Makefile new file mode 100644 index 0000000..f20cc5e --- /dev/null +++ b/infra/vpcopilot-lab/Makefile @@ -0,0 +1,105 @@ +# vpcopilot-lab — provision + COST CONTROL for the Advanced-WAF BIG-IP lab. +# Every stop/start is scoped to Project=$(PROJECT) so it can never touch another +# estate. Override on the command line, e.g. make lab-down AWS_PROFILE=vpcopilot +# +# make check verify tooling + profile + subscription readiness +# make plan/apply stand the lab up (Terraform) +# make lab-down STOP the instances (kills EC2 + PAYG hourly; keeps config) +# make lab-up START them again (no re-onboarding needed) +# make lab-status show instance states +# make tunnel open the SSM port-forward to BIG-IP management +# make origin-deploy build+run Larkspur on the origin over SSM +# make origin-shell SSM shell on the origin +# make lab-nuke terraform destroy (zero idle cost; re-onboard on next apply) + +AWS_PROFILE ?= vpcopilot +REGION ?= us-east-2 +PROJECT ?= vpcopilot-lab +APP_PORT ?= 8080 +MGMT_PORT ?= 443 +LOCAL_PORT ?= 18443 + +AWS := aws --profile $(AWS_PROFILE) --region $(REGION) +TAGF := Name=tag:Project,Values=$(PROJECT) + +.PHONY: check init plan apply lab-status lab-down lab-up tunnel origin-deploy origin-shell lab-nuke whoami keypair + +check: + @echo "profile : $(AWS_PROFILE)"; echo "region : $(REGION)" + @command -v terraform >/dev/null && echo "terraform: $$(terraform version | head -1)" || echo "terraform: MISSING" + @command -v session-manager-plugin >/dev/null && echo "ssm-plugin: present" || echo "ssm-plugin: MISSING (brew install --cask session-manager-plugin)" + @$(AWS) sts get-caller-identity --query 'Account' --output text >/dev/null 2>&1 \ + && echo "identity : $$($(AWS) sts get-caller-identity --query 'Account' --output text)" \ + || { echo "identity : profile '$(AWS_PROFILE)' not configured — run: aws configure --profile $(AWS_PROFILE)"; exit 1; } + @echo -n "awaf ami : "; $(AWS) ec2 describe-images --owners 679593333241 \ + --filters 'Name=name,Values=*Adv WAF*' --query 'length(Images)' --output text 2>/dev/null \ + | awk '{if ($$1+0>0) print $$1" Advanced-WAF images visible (subscription OK)"; else print "0 — accept the AWAF Marketplace subscription first"}' + +keypair: + @test -f .secrets/vpcopilot_lab.pub && echo ".secrets/ keypair already present" || { \ + mkdir -p .secrets && ssh-keygen -t rsa -b 4096 -N '' -f .secrets/vpcopilot_lab.pem \ + && mv .secrets/vpcopilot_lab.pem.pub .secrets/vpcopilot_lab.pub && echo "generated .secrets/vpcopilot_lab.{pem,pub}"; } + +init: + terraform init + +plan: keypair + terraform plan + +apply: keypair + terraform apply + +whoami: + @$(AWS) sts get-caller-identity --output table + +lab-status: + @$(AWS) ec2 describe-instances --filters "$(TAGF)" \ + --query 'Reservations[].Instances[].{ID:InstanceId,Name:Tags[?Key==`Name`]|[0].Value,State:State.Name,Type:InstanceType}' \ + --output table + +lab-down: + @ids=$$($(AWS) ec2 describe-instances --filters "$(TAGF)" "Name=instance-state-name,Values=running,pending" \ + --query 'Reservations[].Instances[].InstanceId' --output text); \ + if [ -z "$$ids" ]; then echo "no running $(PROJECT) instances"; else \ + echo "stopping (Project=$(PROJECT)): $$ids"; \ + $(AWS) ec2 stop-instances --instance-ids $$ids \ + --query 'StoppingInstances[].{id:InstanceId,state:CurrentState.Name}' --output table; fi + +lab-up: + @ids=$$($(AWS) ec2 describe-instances --filters "$(TAGF)" "Name=instance-state-name,Values=stopped,stopping" \ + --query 'Reservations[].Instances[].InstanceId' --output text); \ + if [ -z "$$ids" ]; then echo "no stopped $(PROJECT) instances"; else \ + echo "starting: $$ids"; $(AWS) ec2 start-instances --instance-ids $$ids >/dev/null; \ + $(AWS) ec2 wait instance-running --instance-ids $$ids; \ + echo "running. open the mgmt tunnel with: make tunnel"; fi + +# --- origin id / bigip mgmt ip resolved at run time (from tags / terraform state) +ORIGIN_ID = $$($(AWS) ec2 describe-instances --filters "$(TAGF)" "Name=tag:Role,Values=origin" \ + "Name=instance-state-name,Values=running" --query 'Reservations[0].Instances[0].InstanceId' --output text) + +tunnel: + @oid=$(ORIGIN_ID); mgmt=$$(terraform output -raw bigip_mgmt_private_ip); \ + echo "forwarding 127.0.0.1:$(LOCAL_PORT) -> $$mgmt:$(MGMT_PORT) via $$oid"; \ + echo "then: export BIGIP_URL=https://127.0.0.1:$(LOCAL_PORT)"; \ + $(AWS) ssm start-session --target $$oid \ + --document-name AWS-StartPortForwardingSessionToRemoteHost \ + --parameters "host=$$mgmt,portNumber=$(MGMT_PORT),localPortNumber=$(LOCAL_PORT)" + +origin-shell: + @$(AWS) ssm start-session --target $(ORIGIN_ID) + +# Ship labs/larkspur-bank (two levels up) to the origin over SSM and run it. The +# tree is a handful of small files, well within the RunShellScript payload limit. +origin-deploy: + @oid=$(ORIGIN_ID); \ + echo "packaging labs/larkspur-bank -> $$oid"; \ + tar czf /tmp/larkspur.tgz -C ../../labs larkspur-bank; \ + b64=$$(base64 < /tmp/larkspur.tgz | tr -d '\n'); \ + cid=$$($(AWS) ssm send-command --instance-ids $$oid --document-name AWS-RunShellScript \ + --comment "deploy larkspur" \ + --parameters commands="[\"set -e\",\"mkdir -p /opt/vpcopilot\",\"echo $$b64 | base64 -d > /tmp/larkspur.tgz\",\"tar xzf /tmp/larkspur.tgz -C /opt/vpcopilot\",\"cd /opt/vpcopilot/larkspur-bank\",\"docker build -t larkspur .\",\"docker rm -f larkspur 2>/dev/null || true\",\"docker run -d --restart unless-stopped --name larkspur -p $(APP_PORT):8080 larkspur\"]" \ + --query 'Command.CommandId' --output text); \ + echo "command $$cid dispatched; tail with: $(AWS) ssm get-command-invocation --command-id $$cid --instance-id $$oid" + +lab-nuke: + terraform destroy diff --git a/infra/vpcopilot-lab/README.md b/infra/vpcopilot-lab/README.md new file mode 100644 index 0000000..8335aee --- /dev/null +++ b/infra/vpcopilot-lab/README.md @@ -0,0 +1,89 @@ +# infra/vpcopilot-lab — the copilot's Advanced-WAF BIG-IP lab (Terraform) + +Stands up the copilot's own validation estate in **us-east-2**, all tagged +`Project=vpcopilot-lab`: + +- **BIG-IP Advanced WAF VE** (`m5.xlarge`, F5 PAYG AWAF image) — two NICs, a public + VIP Elastic IP, management reachable **only through an SSM port-forward** (never + internet-published). +- **Larkspur Bank origin** (`t3.small`, Docker) behind the BIG-IP, on `10.30.10.22:8080`. +- Dedicated VPC `10.30.0.0/16`, deliberately separate from `nimbus-demo`. + +It is adapted from `nimbus-demo/infra` with three deliberate changes: the **AWAF** +SKU (not GOOD — GOOD has no ASM and can't validate the WAF emitter), **Larkspur** +as the origin, and **no load generators**. The BIG-IP dataplane/WAF build is done +out-of-band by `onboard/bigip-onboard.sh` — nothing sensitive lands in state. + +Credentials never live in this repo: the AWS profile is in `~/.aws/credentials`, +the SSH private key stays under `.secrets/` (gitignored), and Terraform state is +gitignored. + +## 1. New-account prerequisites + +```sh +# a named profile for the NEW account (credentials only ever land here) +aws configure --profile vpcopilot # region us-east-2 + +# the AWAF Marketplace subscription must be accepted in the new account +# Marketplace → "F5 BIG-IP Advanced WAF ... PAYG" → Subscribe +brew install --cask session-manager-plugin # for the mgmt tunnel + +make check # verifies profile, plugin, and that AWAF AMIs are visible +``` + +`make check` reports `awaf ami : 0 — accept the AWAF Marketplace subscription +first` until the subscription is live — that's the one prerequisite Terraform +can't create for you. + +## 2. Apply + +```sh +cp terraform.tfvars.example terraform.tfvars # set aws_profile, xc_re_cidrs +make init +make plan # generates .secrets/ keypair first +make apply +``` + +Key outputs: `bigip_vip_eip` (re-point the KEPT XC tenant's copilot-lab origin pool +here — **DNS is unchanged**; `banknimbus.com` hostnames resolve to XC's edge, not to +this EIP), `ssm_tunnel_command`, and `bigip_lab_create_hint`. + +## 3. Onboard the BIG-IP (out-of-band) + +The PAYG AMI self-licenses on boot, but ASM must be provisioned and AS3 installed — +`onboard/bigip-onboard.sh` does both and closes the two traps that cost real time +the first time (ASM provisioned before `mcpd` is up; AS3 absent from the image): + +```sh +make tunnel # SSM forward to BIG-IP mgmt; leave it running +# in another shell, push+run the onboard script over the tunnel (see script header) +``` + +Then deploy the origin app and build the AS3 tenant: + +```sh +make origin-deploy # build+run Larkspur on the origin over SSM +export BIGIP_URL=https://127.0.0.1:18443 +vpcopilot bigip-lab create --origin 10.30.10.22:8080 --virtual-address 10.30.10.190 +``` + +## 4. Cost control — turn it off when idle + +The AWAF appliance bills for EC2 **and** a PAYG software fee, but only while +`running`. Every target below is scoped to `Project=vpcopilot-lab`. + +| command | effect | idle cost | +|---|---|---| +| `make lab-down` | **stop** both instances — kills EC2 + PAYG hourly, keeps the onboarded config on EBS (no re-onboarding) | EBS + ~2 EIPs (≈$7/mo) | +| `make lab-up` | start them again; fixed IPs and EIPs persist | — | +| `make lab-nuke` | `terraform destroy` — releases EIPs, deletes volumes | ~$0 (re-onboard next apply) | + +Daily rhythm: `make lab-up` to work, `make lab-down` when done. `make lab-nuke` +for breaks of days or more. Set `bigip_mgmt_eip = false` to shave one EIP. + +## Plugs into the migration runbook + +This module is **P3** of the migration. It assumes the kept XC tenant +(`f5-amer-ent` / `d-henley`) — populate `xc_re_cidrs` from it and point its origin +pool at `bigip_vip_eip`. The origin's fixed IPs match the documented CLI examples +so `vpcopilot bigip-lab create` copy-pastes. diff --git a/infra/vpcopilot-lab/bigip.tf b/infra/vpcopilot-lab/bigip.tf new file mode 100644 index 0000000..6c6b467 --- /dev/null +++ b/infra/vpcopilot-lab/bigip.tf @@ -0,0 +1,80 @@ +# BIG-IP Advanced WAF VE, two interfaces: +# eth0 (device_index 0) = management -> mgmt subnet, no public inbound (SSM tunnel) +# eth1 (device_index 1) = external -> dataplane subnet, self-IP + VIP secondary +# The public virtual server (443) is built by the copilot's AS3 tenant +# (`vpcopilot bigip-lab create`) after ASM is provisioned and AS3 is installed by +# onboard/bigip-onboard.sh. None of that lands in Terraform state. + +resource "aws_network_interface" "bigip_mgmt" { + subnet_id = aws_subnet.mgmt.id + security_groups = [aws_security_group.bigip_mgmt.id] + description = "BIG-IP management (eth0)" + + tags = { + Name = "${var.project}-bigip-mgmt" + } +} + +resource "aws_network_interface" "bigip_external" { + subnet_id = aws_subnet.external.id + security_groups = [aws_security_group.bigip_external.id] + source_dest_check = false + description = "BIG-IP external/dataplane (eth1): self-IP + VIP secondary IP" + + # [0] primary = the self-IP; [1] secondary = the VIP the Elastic IP maps to. + private_ip_list_enabled = true + private_ip_list = [var.bigip_external_self_ip, var.bigip_vip_ip] + + tags = { + Name = "${var.project}-bigip-external" + } +} + +resource "aws_instance" "bigip" { + ami = local.bigip_ami + instance_type = var.bigip_instance_type + key_name = aws_key_pair.lab.key_name + + network_interface { + device_index = 0 + network_interface_id = aws_network_interface.bigip_mgmt.id + } + + network_interface { + device_index = 1 + network_interface_id = aws_network_interface.bigip_external.id + } + + tags = { + Name = "${var.project}-bigip" + Role = "bigip" # the Makefile filters stop/start targets on this + } +} + +# Public VIP Elastic IP -> the external interface's secondary (VIP) private IP. +# The XC origin pool / DNS points here. +resource "aws_eip" "bigip_vip" { + domain = "vpc" + network_interface = aws_network_interface.bigip_external.id + associate_with_private_ip = var.bigip_vip_ip + + tags = { + Name = "${var.project}-bigip-vip-eip" + } + + depends_on = [aws_instance.bigip] +} + +# Optional management Elastic IP for guaranteed egress (AS3 download, updates). +# Inbound stays closed to the internet via the mgmt SG. Toggle with bigip_mgmt_eip. +resource "aws_eip" "bigip_mgmt" { + count = var.bigip_mgmt_eip ? 1 : 0 + domain = "vpc" + network_interface = aws_network_interface.bigip_mgmt.id + + tags = { + Name = "${var.project}-bigip-mgmt-eip" + } + + depends_on = [aws_instance.bigip] +} diff --git a/infra/vpcopilot-lab/data.tf b/infra/vpcopilot-lab/data.tf new file mode 100644 index 0000000..74a1b8c --- /dev/null +++ b/infra/vpcopilot-lab/data.tf @@ -0,0 +1,46 @@ +# Ubuntu 22.04 for the Larkspur origin (Docker host + SSM tunnel target). +data "aws_ami" "ubuntu" { + most_recent = true + owners = ["099720109477"] # Canonical + + filter { + name = "name" + values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"] + } + filter { + name = "virtualization-type" + values = ["hvm"] + } + filter { + name = "architecture" + values = ["x86_64"] + } +} + +# Resolve the F5 Advanced-WAF PAYG image ONLY when bigip_ami is not pinned. Gating +# with count means a stale/unsubscribed name filter can't error a plan that pins +# the AMI explicitly. If the filter matches nothing, the plan fails loudly (better +# than silently landing on the wrong SKU) — usually it means the AWAF Marketplace +# subscription isn't accepted in this account yet. +data "aws_ami" "bigip" { + count = var.bigip_ami == "" ? 1 : 0 + most_recent = true + owners = [var.bigip_ami_owner] + + filter { + name = "name" + values = [var.bigip_ami_name_filter] + } + filter { + name = "virtualization-type" + values = ["hvm"] + } + filter { + name = "architecture" + values = ["x86_64"] + } +} + +locals { + bigip_ami = var.bigip_ami != "" ? var.bigip_ami : data.aws_ami.bigip[0].id +} diff --git a/infra/vpcopilot-lab/iam.tf b/infra/vpcopilot-lab/iam.tf new file mode 100644 index 0000000..35ddb79 --- /dev/null +++ b/infra/vpcopilot-lab/iam.tf @@ -0,0 +1,31 @@ +# The origin carries an SSM instance role so it can be an `aws ssm start-session` +# target — it is both the shell host and the near end of the BIG-IP mgmt tunnel. +# No inbound SSH rule is ever needed. +data "aws_iam_policy_document" "assume" { + statement { + actions = ["sts:AssumeRole"] + principals { + type = "Service" + identifiers = ["ec2.amazonaws.com"] + } + } +} + +resource "aws_iam_role" "origin" { + name = "${var.project}-origin-role" + assume_role_policy = data.aws_iam_policy_document.assume.json + + tags = { + Name = "${var.project}-origin-role" + } +} + +resource "aws_iam_role_policy_attachment" "ssm" { + role = aws_iam_role.origin.name + policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore" +} + +resource "aws_iam_instance_profile" "origin" { + name = "${var.project}-origin-profile" + role = aws_iam_role.origin.name +} diff --git a/infra/vpcopilot-lab/keys.tf b/infra/vpcopilot-lab/keys.tf new file mode 100644 index 0000000..6c6bc1e --- /dev/null +++ b/infra/vpcopilot-lab/keys.tf @@ -0,0 +1,11 @@ +# SSH keypair for BIG-IP admin (reached over the SSM tunnel). The private half +# stays under ./.secrets/ (gitignored) and never enters Terraform state. The +# origin uses SSM Session Manager, so it needs no key. +resource "aws_key_pair" "lab" { + key_name = "${var.project}-key" + public_key = file(var.ssh_public_key_path) + + tags = { + Name = "${var.project}-key" + } +} diff --git a/infra/vpcopilot-lab/network.tf b/infra/vpcopilot-lab/network.tf new file mode 100644 index 0000000..fba76b7 --- /dev/null +++ b/infra/vpcopilot-lab/network.tf @@ -0,0 +1,67 @@ +# Dedicated lab VPC (10.30.0.0/16), one AZ. The external subnet carries both the +# BIG-IP dataplane interface and the Larkspur origin; the mgmt subnet carries the +# BIG-IP management interface (no public inbound — reached via the SSM tunnel). + +resource "aws_vpc" "lab" { + cidr_block = var.vpc_cidr + enable_dns_support = true + enable_dns_hostnames = true + + tags = { + Name = "${var.project}-vpc" + } +} + +resource "aws_internet_gateway" "lab" { + vpc_id = aws_vpc.lab.id + + tags = { + Name = "${var.project}-igw" + } +} + +resource "aws_subnet" "external" { + vpc_id = aws_vpc.lab.id + cidr_block = var.external_subnet_cidr + availability_zone = var.az + map_public_ip_on_launch = true # origin gets a public IP for egress (docker pull, SSM) — inbound is still SG-locked + + tags = { + Name = "${var.project}-external" + } +} + +resource "aws_subnet" "mgmt" { + vpc_id = aws_vpc.lab.id + cidr_block = var.mgmt_subnet_cidr + availability_zone = var.az + + tags = { + Name = "${var.project}-mgmt" + } +} + +resource "aws_route_table" "public" { + vpc_id = aws_vpc.lab.id + + route { + cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.lab.id + } + + tags = { + Name = "${var.project}-public-rt" + } +} + +# Both subnets route to the IGW: the origin egresses for docker/SSM, and the BIG-IP +# mgmt EIP (when enabled) egresses for AS3 download / updates. +resource "aws_route_table_association" "external" { + subnet_id = aws_subnet.external.id + route_table_id = aws_route_table.public.id +} + +resource "aws_route_table_association" "mgmt" { + subnet_id = aws_subnet.mgmt.id + route_table_id = aws_route_table.public.id +} diff --git a/infra/vpcopilot-lab/onboard/bigip-onboard.sh b/infra/vpcopilot-lab/onboard/bigip-onboard.sh new file mode 100644 index 0000000..e0a6c13 --- /dev/null +++ b/infra/vpcopilot-lab/onboard/bigip-onboard.sh @@ -0,0 +1,67 @@ +#!/bin/bash +# vpcopilot-lab BIG-IP Advanced-WAF onboarding — run ON the BIG-IP after apply, +# over the SSM tunnel. This is the AWAF superset of the nimbus LTM onboard: it +# closes the two documented traps (ASM provisioned before mcpd is up; AS3 absent +# from the PAYG image), then lays the base dataplane so the copilot's AS3 tenant +# (`vpcopilot bigip-lab create`) has a VLAN/self-IP/route to attach the VIP to. +# +# Transfer + run from your laptop through the tunnel (avoids tmsh nested-quoting): +# ssh -i .secrets/vpcopilot_lab.pem admin@127.0.0.1 -p 22 'run util bash -c "cat > /var/tmp/onboard.sh"' < onboard/bigip-onboard.sh +# ssh -i .secrets/vpcopilot_lab.pem admin@127.0.0.1 'run util bash -c "bash /var/tmp/onboard.sh"' +# (127.0.0.1:22 is the near end of an SSM forward to the BIG-IP mgmt :22, or use +# `make tunnel` for the :443 iControl path and drive AS3 over REST instead.) +set -x + +# Match the Terraform defaults; override via env if you changed the variables. +EXTERNAL_IFACE="${EXTERNAL_IFACE:-1.1}" +SELF_CIDR="${SELF_CIDR:-10.30.10.10/24}" +GATEWAY="${GATEWAY:-10.30.10.1}" +ADMIN_PW="${BIGIP_ADMIN_PASSWORD:-admin}" +AS3_VERSION="${AS3_VERSION:-3.56.0}" +# NOTE: verify the exact asset name on the release page — the build suffix moves. +# https://github.com/F5Networks/f5-appsvcs-extension/releases +AS3_RPM="${AS3_RPM:-f5-appsvcs-${AS3_VERSION}-4.noarch.rpm}" +AS3_URL="https://github.com/F5Networks/f5-appsvcs-extension/releases/download/v${AS3_VERSION}/${AS3_RPM}" + +wait_mcpd() { + # TRAP #1: user-data runs before mcpd is up (~89s); a tmsh/provision call then + # fails silently and leaves WAF unprovisioned while EC2 reports status-ok. + local i state + for i in $(seq 1 90); do + state=$(tmsh show sys mcp-state field-fmt 2>/dev/null | awk -F: '/phase/{gsub(/ /,"",$2); print $2; exit}') + echo " mcp-state phase=${state:-unknown} (try $i)" + [ "$state" = "running" ] && return 0 + sleep 10 + done + echo "!! mcpd did not reach 'running' — aborting before it fails silently"; return 1 +} + +echo "== 1. wait for mcpd ==" +wait_mcpd || exit 1 + +echo "== 2. provision ASM (Advanced WAF) nominal — restarts daemons, budget ~10-15 min ==" +tmsh modify sys provision asm level nominal +sleep 30 +wait_mcpd || exit 1 +tmsh show sys provision | grep -i -E 'asm|ltm' + +echo "== 3. install AS3 ${AS3_VERSION} (TRAP #2: absent from the PAYG image; scp is broken on the tmsh shell, so the box fetches it itself) ==" +curl -fsSL -o "/var/tmp/${AS3_RPM}" "${AS3_URL}" +task=$(curl -sk -u "admin:${ADMIN_PW}" -H "Content-Type: application/json" \ + -X POST https://localhost/mgmt/shared/iapp/package-management-tasks \ + -d "{\"operation\":\"INSTALL\",\"packageFilePath\":\"/var/tmp/${AS3_RPM}\"}") +echo " install task: ${task}" +for i in $(seq 1 30); do + info=$(curl -sk -u "admin:${ADMIN_PW}" https://localhost/mgmt/shared/appsvcs/info 2>/dev/null) + echo " as3 info (try $i): ${info}" + echo "${info}" | grep -q '"version"' && break + sleep 10 +done + +echo "== 4. base dataplane so AS3 has a VLAN/self-IP/route to build the app VIP on ==" +tmsh create net vlan external interfaces add { ${EXTERNAL_IFACE} } 2>/dev/null || true +tmsh create net self vpcopilot_self address ${SELF_CIDR} vlan external allow-service default 2>/dev/null || true +tmsh create net route default_gw network default gw ${GATEWAY} 2>/dev/null || true +tmsh save sys config + +echo "VPCOPILOT_BIGIP_ONBOARD_DONE — now open the tunnel and run: vpcopilot bigip-lab create ..." diff --git a/infra/vpcopilot-lab/origin.tf b/infra/vpcopilot-lab/origin.tf new file mode 100644 index 0000000..53b52b9 --- /dev/null +++ b/infra/vpcopilot-lab/origin.tf @@ -0,0 +1,32 @@ +# Larkspur Bank origin — Docker host behind the BIG-IP, and the SSM tunnel target. +# Fixed private IP so `vpcopilot bigip-lab create --origin :` is stable. +resource "aws_instance" "origin" { + ami = data.aws_ami.ubuntu.id + instance_type = var.origin_instance_type + subnet_id = aws_subnet.external.id + private_ip = var.origin_private_ip + vpc_security_group_ids = [aws_security_group.origin.id] + iam_instance_profile = aws_iam_instance_profile.origin.name + key_name = aws_key_pair.lab.key_name + user_data = file("${path.module}/user_data/origin.sh") + + # Re-bootstrap (replace the box) if the user-data changes. + user_data_replace_on_change = true + + metadata_options { + http_endpoint = "enabled" + http_tokens = "required" # IMDSv2 only + } + + root_block_device { + volume_type = "gp3" + volume_size = 20 + encrypted = true + delete_on_termination = true + } + + tags = { + Name = "${var.project}-origin" + Role = "origin" # the Makefile filters stop/start/tunnel targets on this + } +} diff --git a/infra/vpcopilot-lab/outputs.tf b/infra/vpcopilot-lab/outputs.tf new file mode 100644 index 0000000..7ebb741 --- /dev/null +++ b/infra/vpcopilot-lab/outputs.tf @@ -0,0 +1,54 @@ +output "vpc_id" { + description = "The lab VPC id." + value = aws_vpc.lab.id +} + +output "origin_instance_id" { + description = "Larkspur origin EC2 id — the SSM tunnel target and `make origin-deploy`/`origin-shell` target." + value = aws_instance.origin.id +} + +output "origin_private_ip" { + description = "Larkspur origin private IP — the BIG-IP pool member / `bigip-lab create --origin`." + value = aws_instance.origin.private_ip +} + +output "bigip_instance_id" { + description = "BIG-IP EC2 id." + value = aws_instance.bigip.id +} + +output "bigip_ami_used" { + description = "The AMI the BIG-IP launched from (pinned var or the resolved AWAF Marketplace image)." + value = local.bigip_ami +} + +output "bigip_vip_eip" { + description = "Public Elastic IP of the BIG-IP virtual server. Re-point the KEPT XC tenant's copilot-lab origin pool HERE (one XC-side change). DNS does NOT change — banknimbus.com hostnames resolve to XC's edge, not to this EIP." + value = aws_eip.bigip_vip.public_ip +} + +output "bigip_vip_private_ip" { + description = "External-interface secondary IP hosting the virtual server (the `--virtual-address`)." + value = var.bigip_vip_ip +} + +output "bigip_mgmt_private_ip" { + description = "BIG-IP management private IP — the remote host for the SSM port-forward." + value = aws_network_interface.bigip_mgmt.private_ip +} + +output "xc_origin_target" { + description = "What to point the KEPT XC tenant's origin pool at (the public VIP)." + value = "https://${aws_eip.bigip_vip.public_ip}" +} + +output "ssm_tunnel_command" { + description = "Open the BIG-IP management tunnel (then BIGIP_URL=https://127.0.0.1:18443)." + value = "aws --profile ${var.aws_profile} --region ${var.region} ssm start-session --target ${aws_instance.origin.id} --document-name AWS-StartPortForwardingSessionToRemoteHost --parameters 'host=${aws_network_interface.bigip_mgmt.private_ip},portNumber=443,localPortNumber=18443'" +} + +output "bigip_lab_create_hint" { + description = "Build the AS3 app tenant once onboarding is done and the tunnel is open." + value = "vpcopilot bigip-lab create --origin ${var.origin_private_ip}:${var.app_port} --virtual-address ${var.bigip_vip_ip}" +} diff --git a/infra/vpcopilot-lab/providers.tf b/infra/vpcopilot-lab/providers.tf new file mode 100644 index 0000000..13c9f4d --- /dev/null +++ b/infra/vpcopilot-lab/providers.tf @@ -0,0 +1,15 @@ +# Run from the operator's laptop under a named AWS profile for the NEW account. +# The profile lives in ~/.aws/credentials — credentials are NEVER placed in this +# repo, in tfvars, or in Terraform state. `terraform.tfvars` sets aws_profile. +provider "aws" { + region = var.region + profile = var.aws_profile + + default_tags { + tags = { + Project = var.project + ManagedBy = "terraform" + Context = "vpcopilot-lab" + } + } +} diff --git a/infra/vpcopilot-lab/security.tf b/infra/vpcopilot-lab/security.tf new file mode 100644 index 0000000..7503f9a --- /dev/null +++ b/infra/vpcopilot-lab/security.tf @@ -0,0 +1,131 @@ +# --- Origin SG: Larkspur is reachable ONLY through the BIG-IP + from within the +# VPC. Never internet-published, or the "exploit stops at the appliance" demo +# breaks. Egress open for docker pull and the SSM agent. +resource "aws_security_group" "origin" { + name_prefix = "${var.project}-origin-" + description = "Larkspur origin: app port from the BIG-IP and the VPC only." + vpc_id = aws_vpc.lab.id + + ingress { + description = "app port from the BIG-IP external SG" + from_port = var.app_port + to_port = var.app_port + protocol = "tcp" + security_groups = [aws_security_group.bigip_external.id] + } + + ingress { + description = "app port from within the VPC (verification)" + from_port = var.app_port + to_port = var.app_port + protocol = "tcp" + cidr_blocks = [var.vpc_cidr] + } + + egress { + description = "all outbound (docker pull, SSM agent)" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { Name = "${var.project}-origin-sg" } +} + +# --- BIG-IP external SG: fronts the public virtual server on 443. Reachable from +# the XC Regional Edge ranges (the kept tenant), optionally the VPC and admin. +resource "aws_security_group" "bigip_external" { + name_prefix = "${var.project}-bigip-ext-" + description = "BIG-IP external/dataplane: HTTPS to the public virtual server." + vpc_id = aws_vpc.lab.id + + dynamic "ingress" { + for_each = toset(var.xc_re_cidrs) + content { + description = "VIP 443 from XC Regional Edge ${ingress.value}" + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = [ingress.value] + } + } + + dynamic "ingress" { + for_each = var.allow_admin_to_vip ? toset(var.admin_cidrs) : toset([]) + content { + description = "VIP 443 from admin ${ingress.value} (direct testing)" + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = [ingress.value] + } + } + + dynamic "ingress" { + for_each = var.allow_vpc_to_vip ? [1] : [] + content { + description = "VIP 443 from within the VPC (verification)" + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = [var.vpc_cidr] + } + } + + egress { + description = "all outbound (origin pool over the VPC, plus internet)" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { Name = "${var.project}-bigip-external-sg" } +} + +# --- BIG-IP management SG: NOT internet-published. Inbound only from within the +# VPC (the origin host, which is the SSM port-forward's near end). An optional +# break-glass admin rule is off by default. +resource "aws_security_group" "bigip_mgmt" { + name_prefix = "${var.project}-bigip-mgmt-" + description = "BIG-IP management: reachable from within the VPC (SSM tunnel) only." + vpc_id = aws_vpc.lab.id + + ingress { + description = "mgmt 443 from within the VPC (SSM tunnel origin)" + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = [var.vpc_cidr] + } + + ingress { + description = "mgmt 22 from within the VPC (SSM tunnel origin)" + from_port = 22 + to_port = 22 + protocol = "tcp" + cidr_blocks = [var.vpc_cidr] + } + + dynamic "ingress" { + for_each = var.allow_admin_to_mgmt ? toset(var.admin_cidrs) : toset([]) + content { + description = "mgmt 443 break-glass from admin ${ingress.value}" + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = [ingress.value] + } + } + + egress { + description = "all outbound (AS3 download, updates, licensing)" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { Name = "${var.project}-bigip-mgmt-sg" } +} diff --git a/infra/vpcopilot-lab/terraform.tfvars.example b/infra/vpcopilot-lab/terraform.tfvars.example new file mode 100644 index 0000000..e496e2b --- /dev/null +++ b/infra/vpcopilot-lab/terraform.tfvars.example @@ -0,0 +1,24 @@ +# Copy to terraform.tfvars (gitignored) and adjust. Everything has a working +# default in variables.tf; override only what you need. NEVER put an AWS key here +# — credentials live only in ~/.aws/credentials under the profile named below. + +# The named AWS profile for the NEW account (see README step 1). +aws_profile = "vpcopilot" + +# region / az stay at us-east-2 / us-east-2a so the F5 AWAF Marketplace AMI is valid. + +# --- BIG-IP Advanced WAF AMI ------------------------------------------------- +# Leave bigip_ami = "" to auto-resolve the latest AWAF PAYG image by name. Pin it +# once you know the id (the ROADMAP lab used the 25Mbps Adv-WAF image in us-east-2): +# bigip_ami = "ami-0161c65f0d64dff79" + +# --- XC Regional Edge ranges (from the KEPT tenant f5-amer-ent / d-henley) ----- +# Populate so the VIP is reachable through XC. Get them from the XC tenant. +# xc_re_cidrs = ["x.x.x.x/28", "y.y.y.y/28"] + +# --- Optional direct testing (default: reach the VIP via XC only) ------------- +# admin_cidrs = ["/32"] +# allow_admin_to_vip = true + +# Save ~$3.60/mo by dropping the mgmt EIP if AS3 install works over the dataplane: +# bigip_mgmt_eip = false diff --git a/infra/vpcopilot-lab/user_data/origin.sh b/infra/vpcopilot-lab/user_data/origin.sh new file mode 100644 index 0000000..b533d23 --- /dev/null +++ b/infra/vpcopilot-lab/user_data/origin.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# vpcopilot-lab origin bootstrap. Installs Docker; the SSM agent already ships and +# runs on the Ubuntu 22.04 AWS image (via snap), so with the instance role the box +# is reachable through Session Manager with no inbound SSH rule. Larkspur itself is +# deployed post-boot by `make origin-deploy` (over SSM), not baked into user-data. +set -eux +export DEBIAN_FRONTEND=noninteractive + +apt-get update +apt-get install -y docker.io +systemctl enable --now docker +usermod -aG docker ubuntu || true + +echo "vpcopilot-lab origin ready $(date -u +%FT%TZ)" > /var/log/vpcopilot-origin.log diff --git a/infra/vpcopilot-lab/variables.tf b/infra/vpcopilot-lab/variables.tf new file mode 100644 index 0000000..6a0baed --- /dev/null +++ b/infra/vpcopilot-lab/variables.tf @@ -0,0 +1,151 @@ +# --------------------------------------------------------------------------- +# vpcopilot-lab — the copilot's own Advanced-WAF BIG-IP lab + Larkspur origin. +# Adapted from nimbus-demo/infra: AWAF (not GOOD) SKU, VPC 10.30.x, management +# reachable ONLY via an SSM port-forward (never public), and no load generators. +# --------------------------------------------------------------------------- + +variable "project" { + description = "Name/tag prefix. All resources carry Project=; the Makefile and `lab-down` scope stop/start to it, so it must be unique to this lab." + type = string + default = "vpcopilot-lab" +} + +variable "aws_profile" { + description = "AWS CLI/SDK named profile for the NEW account (in ~/.aws/credentials). Replaces the old account's Users-409239147779 profile. Never a raw key." + type = string + default = "vpcopilot" +} + +variable "region" { + description = "AWS region. Kept at us-east-2 so the F5 Marketplace AMI stays valid." + type = string + default = "us-east-2" +} + +variable "az" { + description = "Single AZ for the lab (BIG-IP and origin share a zone)." + type = string + default = "us-east-2a" +} + +# --- Network (matches the documented 10.30.x addressing so CLI examples copy-paste) --- + +variable "vpc_cidr" { + description = "CIDR for the lab VPC. Deliberately separate from nimbus-demo's 10.20.0.0/16." + type = string + default = "10.30.0.0/16" +} + +variable "external_subnet_cidr" { + description = "Dataplane subnet holding the BIG-IP external interface AND the Larkspur origin." + type = string + default = "10.30.10.0/24" +} + +variable "mgmt_subnet_cidr" { + description = "BIG-IP management subnet (not internet-published; reached via the SSM tunnel)." + type = string + default = "10.30.20.0/24" +} + +variable "origin_private_ip" { + description = "Fixed private IP of the Larkspur origin. Matches the ROADMAP example so `vpcopilot bigip-lab create --origin 10.30.10.22:8080` still copy-pastes." + type = string + default = "10.30.10.22" +} + +variable "bigip_external_self_ip" { + description = "BIG-IP external self-IP (primary private IP on eth1). Inside external_subnet_cidr." + type = string + default = "10.30.10.10" +} + +variable "bigip_vip_ip" { + description = "BIG-IP virtual-server private IP (secondary on eth1). The VIP Elastic IP maps to it; the XC origin pool / `--virtual-address` targets it." + type = string + default = "10.30.10.190" +} + +variable "app_port" { + description = "Port Larkspur listens on inside the origin (Docker publishes it). The BIG-IP pool forwards here." + type = number + default = 8080 +} + +# --- BIG-IP Advanced WAF ----------------------------------------------------- + +variable "bigip_ami" { + description = "Explicit BIG-IP Advanced-WAF AMI id. Leave \"\" to auto-resolve the latest matching bigip_ami_name_filter from F5's Marketplace owner. Set explicitly to pin (the ROADMAP lab used ami-0161c65f0d64dff79 = PAYG-Adv WAF Plus 25Mbps in us-east-2)." + type = string + default = "" +} + +variable "bigip_ami_owner" { + description = "AWS account that owns the F5 Marketplace AMIs." + type = string + default = "679593333241" +} + +variable "bigip_ami_name_filter" { + description = "Name glob to resolve the AWAF PAYG image. MUST be the Advanced-WAF SKU (has ASM), not GOOD/LTM. Verify the exact name with: aws ec2 describe-images --owners 679593333241 --filters 'Name=name,Values=*Adv WAF*' --query 'Images[].Name'." + type = string + default = "*PAYG-Adv WAF Plus 25Mbps*" +} + +variable "bigip_instance_type" { + description = "BIG-IP instance type. Advanced WAF + LTM needs >= 8 GiB; m5.xlarge (16 GiB) is the multi-NIC floor." + type = string + default = "m5.xlarge" +} + +variable "bigip_mgmt_eip" { + description = "Attach an Elastic IP to the BIG-IP management interface for guaranteed egress (AS3 download, updates). Inbound is still closed to the internet by the mgmt SG. Set false to save ~$3.60/mo if PAYG licensing + AS3 install work over the dataplane path in your setup." + type = bool + default = true +} + +# --- Origin ------------------------------------------------------------------ + +variable "origin_instance_type" { + description = "Larkspur origin instance type." + type = string + default = "t3.small" +} + +# --- Access ------------------------------------------------------------------ + +variable "ssh_public_key_path" { + description = "Public half of the SSH keypair for BIG-IP admin. Private half stays under ./.secrets/ (gitignored) and never enters state. Regenerate on the new laptop: ssh-keygen -t rsa -b 4096 -N '' -f .secrets/vpcopilot_lab.pem." + type = string + default = "./.secrets/vpcopilot_lab.pub" +} + +variable "admin_cidrs" { + description = "Operator egress CIDRs for OPTIONAL direct testing of the VIP / break-glass mgmt. Empty by default — the tool reaches the VIP through XC and management through the SSM tunnel, so no operator IP is required." + type = list(string) + default = [] +} + +variable "xc_re_cidrs" { + description = "F5 XC Regional Edge egress CIDRs allowed to reach the BIG-IP VIP on 443. Sourced from the KEPT XC tenant (f5-amer-ent / d-henley). Populate so the origin is reachable through XC; leave empty only during bring-up." + type = list(string) + default = [] +} + +variable "allow_vpc_to_vip" { + description = "Allow the VPC CIDR to reach the BIG-IP VIP on 443 (origin-side verification curls)." + type = bool + default = true +} + +variable "allow_admin_to_vip" { + description = "Allow admin_cidrs to reach the VIP on 443 for direct (non-XC) testing." + type = bool + default = false +} + +variable "allow_admin_to_mgmt" { + description = "Break-glass: allow admin_cidrs to reach BIG-IP management on 443 directly. Default false — management is reached via the SSM tunnel, not the internet." + type = bool + default = false +} diff --git a/infra/vpcopilot-lab/versions.tf b/infra/vpcopilot-lab/versions.tf new file mode 100644 index 0000000..9121a5f --- /dev/null +++ b/infra/vpcopilot-lab/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.5.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +}