AWS Deployment (Turnkey Cloud Ranges)¶
The lab runs on AWS as a full 6-node mirror of the Proxmox estate, so you can stand a range up
in the cloud with no on-prem hypervisor — and stand up N isolated ranges in parallel (the
AWS analog of Proxmox multi-estate GROUP_ID). This is a validated turnkey path:
terraform apply → one provisioning command per range → green (verify-lab 62/0 +
verify-chain 13/13 + verify-aipostex ~393/1), with a between-wave reset-wave and proven
cross-range isolation.
Proxmox remains the primary path for multi-estate workshop hosting; AWS gives you portable, self-contained ranges (CI, a remote demo box, workshop overflow, or resilience).
Topology (per range K → subnet 10.0.(K+1).0/24)¶
| Instance | Role | Type | Private IP (range K) |
|---|---|---|---|
| ailab-dev | Developer Workstation (Ollama) | c6i.xlarge | 10.0.(K+1).10 |
| ailab-ml | ML Platform (13 mocks + Ray) | t3.large | 10.0.(K+1).20 |
| ailab-ds | Data Science (Ollama) | c6i.xlarge | 10.0.(K+1).30 |
| ailab-app | Shared AI Apps / agents | t3.small | 10.0.(K+1).40 |
| ailab-k8s | single-node k3s "ACME cluster" | t3.medium | 10.0.(K+1).50 |
| ailab-attack | Attack Box (deploy origin) | t3.large | 10.0.(K+1).99 |
All ranges share one VPC (10.0.0.0/16). Range 0 uses subnet 10.0.1.0/24 and unsuffixed
Name tags (aipostex-lab-<role>) for lab-ready-AMI back-compat; range K>0 uses 10.0.(K+1).0/24
and aipostex-lab-r<K>-<role>. LAB_SUBNET=10.0.(K+1) threads through inventory.sh, so every
host IP and peer config resolves within the range with no lab-script changes — the same
native provision.sh / seed.sh as on Proxmox.
Only Ollama (dev + ds) is real CPU inference → non-burstable c6i.xlarge (4 vCPU; c6i.large
timed out ~13/25 under a 25-simultaneous inference load test). A 6-node range is 16 vCPU
(4+4+2+2+2+2).
Prerequisites¶
- AWS profile with EC2 + VPC permissions. Configure locally (
aws configure --profile aipostex) andexport AWS_PROFILE=<your-aws-profile>. - SSH keypair whose public key becomes the operator +
labadminkey. The scripts (aws-provision.sh,aws-verify-isolation.sh,aws-reset-wave.sh) default to~/.ssh/aipostex_rec— if you use a different key you must alsoexport AWS_LAB_KEY=~/.ssh/<your-key>, or terraform will succeed and every script will then fail. - terraform ≥ 1.5, aws-cli v2, jq.
wireguard-tools(wg) andqrencode— only if you are generating attendee tunnel configs;gen-attendee-access.shexits immediately without them.- A linux/amd64
aipostexbinary for the attack box, cross-compiled from the tool repo (professor-moody/aipostex, a separate checkout from this one):
vCPU quota (the multi-range ceiling)¶
Each 6-node range is 16 vCPU (dev + ds are 4-vCPU c6i.xlarge; the other four are 2 vCPU).
The AWS account's Running On-Demand Standard quota (L-1216C47, us-east-1) is what caps how
many ranges you can run: at the default 32 vCPU you get N=2 exactly, with zero headroom
(2 × 16 = 32). N=3 needs 48. To run N=5, raise L-1216C47 to 80 via the Service Quotas console (the lab IAM user typically
lacks servicequotas:*, so do it as an account admin). See also
subset ranges — attendee ranges that drop the presenter-only k8s node
fit more per quota.
1. Provision the infrastructure¶
cd terraform
export AWS_PROFILE=<your-aws-profile>
terraform init
# Single range (range 0) — the default:
terraform apply -var="ssh_public_key_path=$HOME/.ssh/<your-key>.pub" \
-var="allowed_ssh_cidr=$(curl -s https://checkip.amazonaws.com)/32"
# N isolated ranges (e.g. a 2-estate workshop under the default quota):
terraform apply -var="ssh_public_key_path=$HOME/.ssh/<your-key>.pub" \
-var="allowed_ssh_cidr=$(curl -s https://checkip.amazonaws.com)/32" -var='range_ids=[0,1]'
allowed_ssh_cidr is required (no default) and rejects 0.0.0.0/0 — set it to your operator IP
(-var="allowed_ssh_cidr=$(curl -s https://checkip.amazonaws.com)/32"). Attendees reach the box over
the WireGuard entrance, not SSH (see Access model). cloud-init creates labadmin
with your key and a range-scoped /etc/hosts (a range never name-resolves another estate's peers).
New-account note: a brand-new account may return
PendingVerification/VcpuLimitExceededon first launch. Re-runterraform applyonce the account clears — it creates only the missing instances.
2. Provision each range¶
One command per range from the dev machine (the range's attack box is the deploy origin, reaching the role hosts by private IP inside the range's subnet). Instances are discovered by EC2 Name tag, so this is independent of the terraform output shape:
AIPOSTEX_BIN=/tmp/aipostex-linux-amd64 RANGE=0 bash scripts/aws-provision.sh
AIPOSTEX_BIN=/tmp/aipostex-linux-amd64 RANGE=1 bash scripts/aws-provision.sh # ... per range
Each run waits for labadmin SSH, stages lab-scripts + the <your-key> key + the binary onto
that range's attack box, runs attack-box/provision.sh (lab-listener :9000, mcp-configs), then runs
LAB_SUBNET=10.0.(K+1) deploy-all.sh (in a detached tmux, tolerant of SSH drops) to bring the role
hosts up and seed them. Ranges are independent — provision them in parallel.
3. Verify green¶
# on each range's attack box (RANGE 0 shown):
cd ~/lab && LAB_SUBNET=10.0.1 bash verify-lab.sh # expect 62 passed / 0 failed
cd ~/lab && LAB_SUBNET=10.0.1 bash ctf/verify-chain.sh # expect 13/13
LAB_SUBNET=10.0.1 bash ~/lab/attack-box/verify-aipostex.sh --layer all # expect ~393/1 (1 known transient)
Cross-range isolation — prove estates can't reach each other's service ports (each range's SG admits only its own /24):
RANGES="0 1" bash scripts/aws-verify-isolation.sh
# control: each range reaches its own services (rc 0); isolation: a probe to another
# range's service port times out (rc 28 = SG drop, not "refused"). Expect all PASS.
4. Bake the lab-ready AMIs + between-wave reset¶
Snapshot the green instances so a between-wave reset is a fast disk-restore (the cloud equivalent of
the lab-ready Proxmox snapshot + reset-wave):
RANGE=0 STAMP=$(date +%Y%m%d%H%M) bash scripts/aws-bake-ami.sh # tags LabReady=r0,Role=<role>
RANGE=1 STAMP=$(date +%Y%m%d%H%M) bash scripts/aws-bake-ami.sh # ... per range
# reset any/all ranges in parallel from their baked AMIs (restore root volume -> re-arm -> verify):
RANGES="0 1" bash scripts/aws-reset-wave.sh # expect each PASS (62/0 + 13/13)
aws-reset-wave.sh uses create-replace-root-volume-task (the direct analog of qm rollback),
keeping the instance, ENI, private IP, and type, then re-arms the state a disk restore can't hold
(seed ml, restart acme-mcp, warm Ollama) and re-verifies.
5. Con-day bring-up (from parked standby)¶
The backup estate is normally parked = instances stopped (EBS + AMIs only). Bringing it live
takes ~3 minutes. The attack box's public IP is ephemeral — it changes on every stop/start, so
the WireGuard client .conf (whose Endpoint is that IP) is generated at bring-up, not before.
export AWS_PROFILE=aipostex
# 1. Start every stopped lab instance (discover by the Project tag).
# Piping through xargs splits the IDs the same in bash and zsh.
aws ec2 describe-instances \
--filters "Name=tag:Project,Values=aipostex-lab" "Name=instance-state-name,Values=stopped" \
--query 'Reservations[].Instances[].InstanceId' --output text \
| xargs aws ec2 start-instances --instance-ids
# 2. Re-arm from the baked AMIs and re-verify (seed ml, restart mocks, warm Ollama)
RANGES="0" bash scripts/aws-reset-wave.sh # expect PASS (62/0 + 13/13)
# 3. Rediscover the NEW attack-box public IP
BOX=$(aws ec2 describe-instances \
--filters "Name=tag:Name,Values=aipostex-lab-ailab-attack" "Name=instance-state-name,Values=running" \
--query 'Reservations[0].Instances[0].PublicIpAddress' --output text)
echo "attack box: $BOX"
Generate + hand out the WireGuard configs (this is the file you import into a WireGuard client):
# On the dev machine — fully offline; produces ./attendee-access/wireguard/*.conf + relay-peers.conf
ESTATE_SCHEME=aws bash scripts/gen-attendee-access.sh --groups 0 --per-group-only \
--seats 25 --attack-box "$BOX" --relay-endpoint "${BOX}:51820" --out ./attendee-access
# Register the peers on the relay (attack box) and bring wg0 up
scp -r ./attendee-access labadmin@"$BOX":~/
ssh labadmin@"$BOX" 'sudo ESTATE_SCHEME=aws bash ~/lab/scripts/provision-relay.sh --groups 0 \
--server-key ~/attendee-access/wireguard/relay-server.key \
--peers ~/attendee-access/wireguard/relay-peers.conf'
With --per-group-only the generator emits one config for the whole wave,
./attendee-access/wireguard/group0.conf (+ group0.png), not one per seat — --seats 25 only sets
the headcount printed on the card. For per-participant configs use --per-group N instead, which
names them group0-p1.conf, group0-p2.conf, …
Why WireGuard rather than opening SSH to the room
At a venue you cannot predict attendee source addresses — the WiFi NATs to an address you don't
control, and people tether. So allowed_ssh_cidr (SSH/22) stays scoped to you, and attendees
come in over WireGuard instead. wg_ingress_cidr defaults to 0.0.0.0/0 on purpose: WireGuard is
the public entrance and its security is the key exchange plus each peer's AllowedIPs, not IP
filtering. A peer works from any network without you knowing its address in advance.
On AWS the relay also NATs the tunnel behind the box's own estate IP (provision-relay.sh
adds a POSTROUTING MASQUERADE when ESTATE_SCHEME=aws). That is required: without it the EC2
ENI source/dest check drops the forwarded packet, the range security group won't admit the tunnel
CIDR, and nothing routes the reply back. With it, traffic reaches the estate as 10.0.(K+1).99,
which is already inside the SG. Per-group isolation still holds — the FORWARD rules scope each
tunnel to its own estate /24 and drop cross-estate before NAT is applied. On Proxmox no NAT is
used, so the estate keeps seeing real per-seat tunnel IPs.
Smoke-test the tunnel by hitting a service on the estate, e.g. curl http://10.0.1.20:8265 (the Ray
dashboard) or run the guided chain from p01. Park again when done:
aws ec2 describe-instances --filters "Name=tag:Project,Values=aipostex-lab" \
"Name=instance-state-name,Values=running" --query 'Reservations[].Instances[].InstanceId' \
--output text | xargs aws ec2 stop-instances --instance-ids
Access model¶
Provisioning uses a public IP + SSH (lock allowed_ssh_cidr to your operator IP). For the
attendee-facing / standing deployment, move to a VPN entrance and drop the public-SSH rule:
- WireGuard entrance (built): the attack box terminates a WireGuard endpoint on UDP 51820, opened
by the
wg_ingress_cidrSG rule (default0.0.0.0/0— WG's security is the key exchange, not IP filtering). Generate per-attendee.confs withESTATE_SCHEME=aws bash scripts/gen-attendee-access.shand bring up the relay withESTATE_SCHEME=aws bash scripts/provision-relay.shon the box; each.conf'sAllowedIPsplus the relay iptables scope a tunnel to only its range's10.0.(K+1).0/24. SSH/22 stays operator-CIDR-only. This mirrors the Ludus/RTV per-range model (docs/deployment/ludus.md). - Tailscale standing entrance (operator): install Tailscale on the attack box so it joins your tailnet outbound, before baking the AMI, then drop the SG SSH rule:
Isolation & hardening¶
Each range is a network island:
- Its own subnet + security group; the SG admits only that range's own /24 (all ports) plus
operator SSH, so estates are isolated on every service port (proven by
aws-verify-isolation.sh). - The EC2 instances carry no IAM instance profile — a popped box has zero AWS-account reach.
- Every resource is tagged
Project=aipostex-labfor clean teardown.
Subset (tactic) ranges¶
The hands-on attendee tactic is the credential chain, which needs only dev, ml, ds, app, attack
(5 VMs, no k8s — the k8s coda is a presenter/sandbox beat). Deploying attendee ranges without the
k8s node saves 2 vCPU each, fitting 3 chain ranges under the default 32-vCPU quota vs 2 full.
Profile-based subset deployment (var.ranges profiles + deploy-all.sh --only) is a documented
follow-on — see the multi-range/profiles plan.
Teardown¶
cd terraform && terraform destroy \
-var="ssh_public_key_path=$HOME/.ssh/aipostex_rec.pub" \
-var="allowed_ssh_cidr=$(curl -s https://checkip.amazonaws.com)/32" \
-var='range_ids=[0,1]'
Deregister any transient baked AMIs + their snapshots you no longer need (keep the r0 baseline for
fast redeploy):