Client VPN — the handover boundary¶
Each client site (a MikroTik router in the field) reaches the platform AWS VPC over an AWS Site-to-Site VPN. This page documents the responsibility boundary: the Terraform that provisions the AWS side of each connection, and the MikroTik RouterOS .rsc config file that is generated from AWS output and handed to the client to paste into their router.
Scope — what this page is and is not
General client networking (routing on the client LAN, their cameras, their WAN) is out of scope — that is standard networking knowledge and it is the client's problem. What is documented here is exactly the seam the platform engineer owns: everything up to and including the .rsc handover file. This is directly analogous to how AWS Site-to-Site VPN hands you an opaque config file for your own CPE ("tin") — here the platform is the AWS side and generates that opaque file for the client's MikroTik.
The responsibility boundary¶
PLATFORM (platform team owns) CLIENT (owns their side)
┌───────────────────────────────┐ ┌───────────────────────────────┐
│ AWS af-south-1, network │ │ Site MikroTik router │
│ │ IPsec/IKEv2 │ (dynamic public IP) │
│ VGW (ASN 64512) │◄────tunnel 1──┤ │
│ ├─ Customer Gateway (cgw) │◄────tunnel 2──┤ Applies the generated .rsc: │
│ ├─ VPN Connection (2 tuns) │ + BGP │ - ipsec profile/peer/ │
│ ├─ PSKs → Secrets Manager │ │ proposal/identity/policy │
│ └─ BGP over 169.254.x.x │ │ - BGP instance + connection │
│ │ │ - loopback inside addresses │
│ generate-vpn-config.py ──────┼──► client.rsc │ - firewall address-lists │
│ (reads AWS, emits .rsc) │ (handover) │ - input/forward filter rules│
└───────────────────────────────┘ └───────────────────────────────┘
| Concern | Owner | Where |
|---|---|---|
| Virtual Private Gateway, Customer Gateways, VPN Connections, tunnel crypto options | Platform | terraform/aws/_modules/vpn |
| Which clients exist, their public IP + BGP ASN | Platform | terraform/aws/network/af-south-1/vpn/terragrunt.hcl |
| PSKs (AWS-generated), tunnel inside/outside addresses | Platform | AWS Secrets Manager vpn/<client>/psk |
The .rsc file itself |
Platform | generate-vpn-config.py |
Pasting the .rsc into the router, keeping the router up, their LAN/cameras |
Client | Their MikroTik |
| Aligning the MikroTik IPsec lifetimes/DPD to the AWS side | Platform (advisory, applied on client tin) | docs/runbooks/vpn-mikrotik-ipsec.md |
The dividing line is the .rsc file. Everything left of it is Terraform + a Python generator that the platform engineer runs. Everything right of it is the client's router.
The AWS side — the vpn module¶
Source: terraform/aws/_modules/vpn (main.tf, variables.tf, outputs.tf).
What it creates¶
- One Virtual Private Gateway (
aws_vpn_gateway.this, taggedvgw-af-south-1), Amazon-side ASN 64512 (set in terragrunt). Route propagation is enabled onto the listed route tables viaaws_vpn_gateway_route_propagation.this. - Per client, one Customer Gateway + one VPN Connection (each with two tunnels), driven by the
var.customersmap. - A CloudWatch log group
/aws/vpn/tunnelsfor tunnel + BGP logs. - Per client, a Secrets Manager secret
vpn/<client>/pskholding both tunnels' PSKs and inside/outside addresses (see below). - CGW-updater automation plumbing: a DynamoDB table
vpn-cgw-stateand (optionally) an IRSA IAM rolevpn-cgw-updaterfor a Kubernetes CronJob that rewrites Customer Gateway IPs when a client's dynamic public IP changes.
The two CGW / two VPN-connection variants¶
Because client routers have dynamic public IPs and AWS auto-generates PSKs, the module splits each resource into two for_each variants selected by per-client booleans. A locals block then merges each pair back into one map (local.customer_gateways, local.vpn_connections) so outputs and the PSK secret are uniform.
| Resource | Variant (default clients) | Variant (OCI-style) | Selector |
|---|---|---|---|
aws_customer_gateway.ignore_ip |
✅ ignores IP changes | ignore_ip_changes = true (default) |
|
aws_customer_gateway.managed_ip |
static IP, create_before_destroy |
ignore_ip_changes = false |
|
aws_vpn_connection.managed_psk |
✅ AWS-managed PSK | ignore_psk_changes = false (default) |
|
aws_vpn_connection.ignore_psk |
external PSK (e.g. OCI) | ignore_psk_changes = true |
ignore_ipCGWs setlifecycle { ignore_changes = [ip_address] }so Terraform does not fight the CGW-updater CronJob when a client's DDNS IP moves. Theip_addressin terragrunt is therefore only a seed for first creation.ignore_pskconnections setignore_changes = [tunnel1_preshared_key, tunnel2_preshared_key]so an externally-managed PSK isn't reverted. All the current af-south-1 clients use the default variants (dynamic IP, AWS-managed PSK).
moved{} blocks
main.tf keeps two moved{} blocks migrating exampletest from the old single-resource names (aws_customer_gateway.this / aws_vpn_connection.this) into the split ignore_ip / managed_psk names. The client-g (OCI Johannesburg) moved blocks were removed 2026-07-03 when client-g was decommissioned to be rebuilt CHR-terminated. Leave the exampletest blocks in place — deleting them would trigger destroy/recreate of that connection.
Tunnel crypto defaults (IKEv2)¶
local.default_tunnel_options sets both tunnels identically, then merges any per-client tunnel1_options / tunnel2_options overrides on top. Key values:
ike_versions = ["ikev2"]
phase1_encryption_algorithms = ["AES256", "AES256-GCM-16"]
phase2_encryption_algorithms = ["AES256", "AES256-GCM-16"]
phase1_integrity_algorithms = ["SHA2-384", "SHA2-512"]
phase2_integrity_algorithms = ["SHA2-256", "SHA2-384", "SHA2-512"]
phase1_dh_group_numbers = [15,16,17,18,19,20,21,22,23,24]
phase1_lifetime_seconds = 28800 # 8h (IKE / Phase 1 SA)
phase2_lifetime_seconds = 3600 # 1h (IPsec / Phase 2 child SA)
rekey_margin_time_seconds = 540 # AWS default; see warning below
rekey_fuzz_percentage = 100
dpd_timeout_seconds = 30
dpd_timeout_action = "restart" # AWS re-initiates on DPD timeout
startup_action = "start" # AWS actively initiates (CGW has dynamic IP)
- Routing is dynamic BGP (
static_routes_only = false), both network CIDRs0.0.0.0/0, so BGP runs over the169.254.x.xtunnel inside addresses. startup_action = "start"is deliberate: the client IP is dynamic, so AWS actively initiates rather than waiting for the client.tunnel1_enable_tunnel_lifecycle_control = trueon both tunnels enables the modify-in-place tunnel replacement the tunnel-trampoline Lambda uses.
Do not raise rekey_margin_time_seconds back toward the Phase-2 lifetime
The extensive comment in main.tf explains this. The old margin of 1800 with fuzz 100 opened the rekey window 1800 * 2 = 3600s before a 3600s Phase-2 expiry — i.e. AWS could try to rekey the child SA almost immediately and jitter-early every cycle, colliding with the MikroTik's own rekeys and leaving stale/half-open child SAs (the blackhole the tunnel-trampoline chases, or a fully-wedged tunnel). 540 (AWS's own default) gives one predictable rekey ~9–18 min before expiry. This was part of the 2026-07-17 incident fix. The MikroTik half of that fix lives in the IPsec runbook.
Module inputs (the client list shape)¶
var.customers is a map(object(...)). The minimum per client is bgp_asn + ip_address; everything else defaults. Full shape (from variables.tf):
customers = {
"acme" = {
bgp_asn = 65001
ip_address = "203.0.113.10" # seed only for default clients (IP ignored after create)
# lifecycle toggles (defaults shown)
ignore_ip_changes = true # CGW-updater manages the IP
ignore_psk_changes = false # AWS manages the PSK
tunnel_log_enabled = true
tunnel_bgp_log_enabled = true
# optional /30 inside CIDRs from 169.254.0.0/16 (else AWS auto-assigns)
tunnel1_inside_cidr = null
tunnel2_inside_cidr = null
# optional per-tunnel crypto overrides (incl. external preshared_key)
tunnel1_options = null
tunnel2_options = null
}
}
Module outputs¶
| Output | Contents |
|---|---|
vpn_gateway_id / vpn_gateway_arn |
The VGW |
customer_gateway_ids |
{ client => cgw-id } |
vpn_connection_ids |
{ client => vpn-id } |
vpn_tunnel_details |
Per client: tunnel1/2_address (AWS outside IPs), tunnel1/2_cgw_inside_address, tunnel1/2_vgw_inside_address |
vpn_psk_secret_arns |
{ client => secret-arn } for vpn/<client>/psk |
cloudwatch_log_group_arn / _name |
/aws/vpn/tunnels |
cgw_updater_role_arn / _name |
IRSA role (null unless enable_cgw_updater_iam) |
cgw_state_table_name / _arn |
vpn-cgw-state DynamoDB table |
The PSK secret (vpn/<client>/psk)¶
aws_secretsmanager_secret_version.vpn_psk writes a JSON blob per client containing both tunnels' PSK, outside address, BGP ASN and inside addresses:
{
"tunnel1_preshared_key": "…", "tunnel1_address": "…",
"tunnel1_bgp_asn": "…",
"tunnel1_cgw_inside_address": "169.254.x.x", "tunnel1_vgw_inside_address": "169.254.x.y",
"tunnel2_preshared_key": "…", "tunnel2_address": "…", "…": "…"
}
The generator does NOT read this secret
generate-vpn-config.py reads the tunnel details and PSKs directly from aws ec2 describe-vpn-connections (the live CustomerGatewayConfiguration XML + Options.TunnelOptions[].PreSharedKey), not from Secrets Manager. The secret is the durable, machine-readable copy for other consumers; the generator goes to the source of truth.
The current client list (af-south-1)¶
Source: terraform/aws/network/af-south-1/vpn/terragrunt.hcl. All use the default variants (dynamic IP + AWS PSK).
| Client key | BGP ASN | Seed IP | MikroTik DDNS endpoint (comment) |
|---|---|---|---|
client-a |
65102 | 198.51.100.5 | hx0000000a1.sn.mynetname.net |
client-b |
65103 | 192.0.2.84 | hx0000000c3.sn.mynetname.net |
client-c |
65104 | 198.51.100.15 | hx0000000b2.sn.mynetname.net |
client-d (site D) |
65105 | 203.0.113.149 | hx0000000e5.sn.mynetname.net |
client-e |
65109 | 192.0.2.146 | b2c3d4e5f6a7.sn.mynetname.net |
client-e-site2 |
65106 | 203.0.113.14 | hx0000000d4.sn.mynetname.net |
client-e-site3 |
65107 | 198.51.100.3 | a1b2c3d4e5f6.sn.mynetname.net |
Also configured in this file: amazon_side_asn = 64512, log_retention_days = 3, vpc_id from the ../vpc dependency, and route_table_ids (prod-inside-a/b, staging-inside-a/b, network-outside-a/b) for VGW route propagation.
Decommissioned / commented-out entries — do not blindly re-add
client-e-site4 (removed 2026-07-03), client-f (ASN 65110, removed manually in AWS), and client-g (OCI Johannesburg, being rebuilt CHR-terminated active/active) are commented out with dated notes. Private-use ASNs live in 64512–65534; the Amazon side (64512) must differ from every client ASN.
The client .rsc — generate-vpn-config.py¶
Source: generate-vpn-config.py. This is the crux of the handover: it turns a live AWS VPN connection into a ready-to-paste MikroTik RouterOS script.
How it runs¶
# By client name (resolved via the vpn-<name>-01 Name tag):
./generate-vpn-config.py client-a
# Or by explicit VPN connection ID:
./generate-vpn-config.py vpn-00fd03bf5da351523
# Positional overrides: <name_or_id> [region] [vpc_cidr]
./generate-vpn-config.py client-c af-south-1 10.161.0.0/16
Defaults: region = af-south-1, vpc_cidr = 10.161.0.0/16, --aws-profile network. It writes <client>.rsc in the current directory.
What it does¶
- Resolves the connection. If the arg matches
vpn-[0-9a-f]+it is used as the ID; otherwise it lists all connections and matches on the extracted name from thevpn-<name>-01Nametag (exact match wins over partial; ambiguity aborts with the candidate list). - Fetches
aws ec2 describe-vpn-connections --vpn-connection-ids <id> --profile network. - Parses the
CustomerGatewayConfigurationXML — the twoipsec_tunnelelements — plusVgwTelemetry[].OutsideIpAddressandOptions.TunnelOptions[].PreSharedKey. From this it derives per tunnel: AWS outside IP, PSK, customer inside IP, AWS inside IP, and the customer/AWS BGP ASNs. The/30inside CIDR is computed ascustomer_inside_ipminus 2 in the last octet. - Emits the
.rsc(see next section).
Optional flags (site-specific behaviour)¶
| Flag | Effect |
|---|---|
--enable-nat-t |
Sets nat-traversal=yes on the IPsec profile and adds my-id=address:<ip> to both identities. Needed when the router sits behind NAT. |
--nat-t-my-id <ip> |
The my-id address (default 198.51.100.3, the Boveland router). |
--enable-camera-capture-nat |
Adds a dst-nat + masquerade pair so camera capture-event traffic (TCP 3333 by default) is forwarded into the VPC. |
--camera-capture-dst-address / --camera-capture-target-address / --camera-capture-port / --camera-capture-comment |
Tune those NAT rules (defaults 10.0.0.4 → 10.161.0.193, port 3333). |
--address-list-entry 'LIST\|SUBNET\|COMMENT' |
Repeatable. Adds extra firewall address-list rows, e.g. camera-bgp\|172.16.61.28/32\|Camera to add to BGP. |
--bgp-output-network-list |
Address-list name referenced by BGP output.network (default BGP). |
What the client receives (contents of the .rsc)¶
The generated file substitutes real values (MikroTik variables only work inside scripts, so the generator inlines everything). A commented header records the ASNs, both tunnels' outside/inside IPs, the VPC CIDR, and whether NAT-T is on. The body configures, in RouterOS syntax:
/ip ipsec profile— one IKEv2 profilehub-aws-ike2:dh-group=ecp384,enc-algorithm=aes-256,hash-algorithm=sha384,prf-algorithm=sha384,lifetime=8h,nat-traversal=<yes|no>./ip ipsec peer—hub-aws-t1andhub-aws-t2, each pointing at one AWS tunnel outside IP,exchange-mode=ike2./ip ipsec proposal—hub-aws-esp:auth-algorithms=sha512,sha256,enc-algorithms=aes-256-cbc,aes-256-gcm,pfs-group=ecp384,lifetime=1h./routing bgp instance—hub-aws-ins1/hub-aws-ins2,as=<customer_asn>, router-id = each tunnel's customer inside IP./ip ipsec identity— one per peer, carrying the PSK (secret="…") andremote-id=address:<aws-outside-ip>(plusmy-idif NAT-T)./ip ipsec policy— tunnel policies for the VPC CIDR and the tunnel inside/30, bound to each peer withproposal=hub-aws-esp tunnel=yes./routing bgp connection—hub-aws-bgp1/hub-aws-bgp2: eBGP, multihop,local.address=<customer_inside>%lo,remote.address=<aws_inside>/32 .as=<aws_asn>,output.network=<BGP list>, redistribute connected./ip address— the two tunnel customer inside IPs bound to thelo(loopback) interface (BGP peers off the loopback so a single tunnel flap doesn't drop the session)./ip firewall address-list—hub-aws-outside(both AWS outside IPs),hub-aws-inside(both AWS inside IPs),hub-aws-vpc(the VPC CIDR), plus any--address-list-entryrows./ip firewall filter— input accepts for IPsec UDP 500 & 4500 and ESP fromhub-aws-outside, BGP TCP 179 fromhub-aws-inside, and forward accepts both directions forhub-aws-vpc./ip firewall nat— only when--enable-camera-capture-natis set (thedst-nat+masqueradepair).
The .rsc contains live pre-shared keys — treat it as a secret
The secret="…" values in the /ip ipsec identity section are the real tunnel PSKs pulled from AWS. Hand the file to the client over a secure channel; do not commit generated .rsc files, paste them into tickets, or email them in the clear.
Tunnel topology (dual-tunnel active/standby)¶
Each VPN connection has two independent tunnels to two different AWS endpoints, and the client router brings up both:
| Tunnel 1 | Tunnel 2 | |
|---|---|---|
| AWS outside IP | tunnel1_address |
tunnel2_address |
| AWS inside (VGW) | tunnel1_vgw_inside_address |
tunnel2_vgw_inside_address |
| Client inside (CGW) | tunnel1_cgw_inside_address |
tunnel2_cgw_inside_address |
| MikroTik peer | hub-aws-t1 |
hub-aws-t2 |
| MikroTik BGP | hub-aws-bgp1 |
hub-aws-bgp2 |
Both tunnels run BGP simultaneously. The design is active/standby failover: traffic uses one tunnel, and if it drops, BGP + DPD reconverge onto the other. DPD (dpd_timeout_action = "restart" AWS-side, dpd-interval=10s / dpd-maximum-failures=3 MikroTik-side) tears down a dead peer so it self-heals. See the MikroTik IPsec runbook for the client-router lifetime/DPD settings that must match the AWS side, and the tunnel-trampoline module for the AWS-side auto-remediation of a wedged tunnel.
How to onboard a new client VPN¶
Prerequisites
AWS CLI configured with the network profile (the VPN lives in the network account, af-south-1). Terragrunt. Python 3. The client's router public IP (a seed) and an agreed BGP ASN in 64512–65534 that is unique and not equal to 64512.
-
Add the client to the customer map. Edit
terraform/aws/network/af-south-1/vpn/terragrunt.hcland add an entry undercustomers, mirroring the existing ones (include the DDNS endpoint as a comment): -
Plan and apply the AWS side (from the vpn terragrunt dir; note network account is applied manually — it is not in CI):
export AWS_PROFILE=network cd terraform/aws/network/af-south-1/vpn terragrunt plan # expect: 1 CGW, 1 VPN connection (2 tunnels), 1 PSK secret terragrunt applyThis creates
cgw-newclient-01,vpn-newclient-01, and thevpn/newclient/psksecret. AWS auto-generates the PSKs and assigns tunnel addresses. -
Generate the client
.rscfrom the live connection:./generate-vpn-config.py newclient # → writes newclient.rsc # Behind NAT? add --enable-nat-t --nat-t-my-id <router-public-ip> # Camera capture events? add --enable-camera-capture-natReview the printed summary (ASNs, both tunnels, VPC CIDR).
-
Hand the
.rscto the client over a secure channel and have them paste it into their MikroTik (e.g./import newclient.rsc, or WinBox terminal). This is the boundary — from here the client owns applying it. It contains live PSKs, so treat it as a secret and do not commit it. -
Ask the client to align IPsec lifetimes/DPD per the MikroTik IPsec runbook (Phase 1
8h, Phase 21h,dpd-interval=10s,dpd-maximum-failures=3) so AWS stays the rekey initiator and a dead peer self-heals. -
Verify both tunnels come up. Check the AWS side and Grafana:
aws ec2 describe-vpn-connections --profile network --region af-south-1 \ --filters Name=tag:Customer,Values=newclient \ --query 'VpnConnections[].VgwTelemetry[].[Status,StatusMessage]' --output tableOn the VPN Tunnels Grafana dashboard, confirm
aws_vpn_tunnel_state_maximum == 1and that BGP routes for the client's subnets appear (route propagation is already enabled on the listed route tables).
How to regenerate a client .rsc (rotation / re-handover)¶
If a client re-images their router, or a PSK/tunnel changes, just re-run the generator — it always reads current AWS state:
export AWS_PROFILE=network
cd terraform/aws/network/af-south-1/vpn
./generate-vpn-config.py <client> # or the vpn-<id>
Then hand the fresh .rsc back to the client. No Terraform change is needed unless the client's BGP ASN or lifecycle behaviour is changing.
How to decommission a client VPN¶
- Remove (or comment out with a dated note, as the existing entries do) the client's block from
customersinterragrunt.hcl. export AWS_PROFILE=network && terragrunt apply— this destroys the CGW, VPN connection and PSK secret for that client.- Note it in the file's comment block (see
client-e-site4/client-f/client-gprecedents) so the history is auditable.
Gotchas¶
- Network account is applied by hand. The
networkaccount is not wired into CI —terragrunt applyhere is a manual,AWS_PROFILE=networkoperation. ip_addressin terragrunt is a seed, not the truth. For the default (dynamic-IP) clients the CGW ignoresip_addressafter creation; the CGW-updater CronJob (IRSA rolevpn-cgw-updater, DynamoDBvpn-cgw-state) keeps it current. Editing the seed IP in terragrunt does not move a live tunnel.- The
.rscinlines PSKs. It is a secret. Don't commit it, don't paste it into tickets. - Keep MikroTik lifetimes equal to (not shorter than) the AWS values so AWS stays the rekey initiator — see the runbook. Raising AWS
phase*_lifetime_secondsmeans raising the client side to match. - Don't remove the
exampletestmoved{}blocks inmain.tf— they migrate state, and removing them forces destroy/recreate. client-dwas noted down in a prior session (site D) — if its tunnel is down, that's a known deferred item rather than a fresh break.