Routing Lab

09 · Terraform

Greenfield Terraform stack

The full infrastructure source for the lab: VPC and routing, VyOS bootstrap, GRE/BYOIP toggles, operator scripts and Lambda handlers.

main.tf
data "http" "terraform_runner_ipv4" {
  count = var.auto_detect_terraform_public_ip ? 1 : 0

  url = "https://api.ipify.org"
}

locals {
  # Legacy BYOIP detector (SSH healthy → withdraw AWS) is mutually exclusive with lab GRE+BYOIP flow.
  legacy_byoip_detector_enabled = var.byoip_failover_detector_enabled && !var.lab_gre_byoip_flow_enabled

  _terraform_runner_ip_raw = var.auto_detect_terraform_public_ip && length(data.http.terraform_runner_ipv4) > 0 ? trimspace(data.http.terraform_runner_ipv4[0].response_body) : ""

  _terraform_runner_cidr = (
    local._terraform_runner_ip_raw != "" && can(regex("^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9])\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9])$", local._terraform_runner_ip_raw))
  ) ? "${local._terraform_runner_ip_raw}/32" : null

  admin_cidrs_effective   = local._terraform_runner_cidr != null ? distinct(concat(var.admin_cidrs, [local._terraform_runner_cidr])) : var.admin_cidrs
  imperva_pm_server_cidrs = [for ip in var.imperva_pm_server_ips : "${ip}/32"]

  # LAN victim SG: SSH stays admin-only; HTTP/HTTPS are intentionally public through GRE/BYOIP.
  lan_server_admin_tcp_ingress = flatten([
    for port in [22] : [
      for c in local.admin_cidrs_effective : {
        port = port
        cidr = c
      }
    ]
  ])

  mandatory_tags = {
    "Owner email"         = "alex.bakshtein@thalesgroup.com"
    "Manager email"       = "david.holmes@thalesgroup.com"
    "Team email"          = "ww.dis.imperva.se-ddos-sme@thalesgroup.com"
    "Description"         = "Lab Router for NW DDoS protection"
    "Environment"         = "Sales environment"
    "Data Classification" = "OPEN"
  }

  aws_default_tags = merge(local.mandatory_tags, var.extra_tags)

  vyos_ssh_line     = var.create_router_keypair ? trimspace(replace(tls_private_key.router[0].public_key_openssh, "\n", " ")) : trimspace(var.vyos_ssh_public_key)
  vyos_key_parts    = split(" ", local.vyos_ssh_line)
  vyos_ssh_key_type = length(local.vyos_key_parts) >= 2 ? local.vyos_key_parts[0] : "ssh-rsa"
  vyos_ssh_key_data = length(local.vyos_key_parts) >= 2 ? local.vyos_key_parts[1] : ""

  vmx_key_name            = var.create_router_keypair ? aws_key_pair.router[0].key_name : var.router_key_name
  router_private_key_path = trimspace(var.router_private_key_output_path) != "" ? trimspace(var.router_private_key_output_path) : "${path.module}/${var.name_prefix}-router.pem"

  router_wan_external_eip      = trimspace(var.router_wan_eip_allocation_id)
  router_wan_uses_external_eip = local.router_wan_external_eip != ""
  router_wan_eip_allocation_id = local.router_wan_uses_external_eip ? data.aws_eip.router_wan_external[0].id : aws_eip.router_wan_managed[0].id
  router_wan_public_ip         = local.router_wan_uses_external_eip ? data.aws_eip.router_wan_external[0].public_ip : aws_eip.router_wan_managed[0].public_ip

  netflow_collector_ip = trimspace(var.netflow_collector_ip)
  netflow_protected_subnet_ids = length(var.netflow_protected_subnet_ids) > 0 ? var.netflow_protected_subnet_ids : [
    aws_subnet.internal.id
  ]
  netflow_protected_subnet_cidrs = length(var.netflow_protected_subnet_cidrs) > 0 ? var.netflow_protected_subnet_cidrs : [
    var.subnet_internal_cidr
  ]

  netflow_bastion_user_data = var.netflow_exporter_enabled && var.netflow_reuse_bastion ? templatefile("${path.module}/user_data/netflow-exporter-bastion.sh.tpl", {
    netflow_mirror_vni     = tostring(var.netflow_mirror_vni)
    netflow_collector_ip   = local.netflow_collector_ip
    netflow_collector_port = tostring(var.netflow_collector_port)
    netflow_version        = tostring(var.netflow_version)
    netflow_sampling_rate  = tostring(var.netflow_sampling_rate)
    exporter_private_ip    = aws_network_interface.netflow_exporter[0].private_ip
    exporter_prefix_len    = local.wan_prefix_len
    wan_gw                 = local.wan_gw
  }) : null
}

provider "aws" {
  region = var.region

  default_tags {
    tags = local.aws_default_tags
  }
}

check "manual_keypair_when_needed" {
  assert {
    condition = var.create_router_keypair || (
      length(trimspace(var.router_key_name)) > 0 && length(trimspace(var.vyos_ssh_public_key)) > 0
    )
    error_message = "When create_router_keypair is false, set both router_key_name and vyos_ssh_public_key (full ssh-rsa/ssh-ed25519 line)."
  }
}

check "byoip_detector_ssh_key" {
  assert {
    condition     = !var.byoip_failover_detector_enabled || var.create_router_keypair || length(trimspace(var.byoip_detector_ssh_private_key_secret_arn)) > 0
    error_message = "When byoip_failover_detector_enabled=true and create_router_keypair=false, set byoip_detector_ssh_private_key_secret_arn (Secrets Manager ARN of the PEM for VyOS SSH)."
  }
}

check "lab_gre_byoip_flow_ssh_when_needed" {
  assert {
    condition = !var.lab_gre_byoip_flow_enabled || var.create_router_keypair || (
      length(trimspace(var.lab_gre_byoip_flow_ssh_private_key_secret_arn)) > 0 || length(trimspace(var.byoip_detector_ssh_private_key_secret_arn)) > 0
    )
    error_message = "When lab_gre_byoip_flow_enabled=true and create_router_keypair=false, set lab_gre_byoip_flow_ssh_private_key_secret_arn or byoip_detector_ssh_private_key_secret_arn (Secrets Manager ARN of the PEM for VyOS SSH)."
  }
}

check "terraform_runner_ip_authorized" {
  assert {
    condition     = !var.auto_detect_terraform_public_ip || local._terraform_runner_cidr != null
    error_message = "auto_detect_terraform_public_ip=true, but this runner's public IPv4 could not be detected or was not a valid IPv4 address. Set auto_detect_terraform_public_ip=false for non-interactive or air-gapped runs, or add your current public /32 manually to admin_cidrs."
  }
}

check "netflow_exporter_configuration" {
  assert {
    condition = !var.netflow_exporter_enabled || (
      var.netflow_reuse_bastion &&
      var.create_ssh_bastion &&
      local.netflow_collector_ip != ""
    )
    error_message = "When netflow_exporter_enabled=true, set netflow_collector_ip and keep netflow_reuse_bastion=true with create_ssh_bastion=true."
  }
}

check "netflow_auto_mirror_not_enabled" {
  assert {
    condition     = !var.netflow_auto_mirror_subnet_enis
    error_message = "netflow_auto_mirror_subnet_enis is reserved for future EventBridge/Lambda automation. Add service ENIs to netflow_mirror_managed_eni_ids for now."
  }
}

# Reliable AL2023 AMI ID per region (name-based data.aws_ami filters often break)
data "aws_ssm_parameter" "lan_al2023_ami" {
  name = "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64"
}

resource "tls_private_key" "router" {
  count = var.create_router_keypair ? 1 : 0

  algorithm = "RSA"
  rsa_bits  = 4096
}

resource "aws_key_pair" "router" {
  count = var.create_router_keypair ? 1 : 0

  key_name   = "${var.name_prefix}-router-ec2"
  public_key = tls_private_key.router[0].public_key_openssh

  tags = {
    Name = "${var.name_prefix}-router-ec2-key"
  }
}

resource "local_sensitive_file" "router_private_key" {
  count = var.create_router_keypair ? 1 : 0

  filename        = local.router_private_key_path
  content         = tls_private_key.router[0].private_key_pem
  file_permission = "0600"
}

data "aws_availability_zones" "available" {
  state = "available"
}

locals {
  az = var.availability_zone != "" ? var.availability_zone : data.aws_availability_zones.available.names[0]

  wan_gw      = cidrhost(var.subnet_wan_cidr, 1)
  mgmt_gw     = cidrhost(var.subnet_mgmt_cidr, 1)
  internal_gw = cidrhost(var.subnet_internal_cidr, 1)

  loopback_cidr = format(
    "%s/%s",
    cidrhost(var.advertised_prefix_cidr, tonumber(var.loopback_host_octet)),
    split("/", var.advertised_prefix_cidr)[1]
  )

  mgmt_prefix_len     = split("/", var.subnet_mgmt_cidr)[1]
  wan_prefix_len      = split("/", var.subnet_wan_cidr)[1]
  internal_prefix_len = split("/", var.subnet_internal_cidr)[1]

  gre_inner_ip = split("/", var.customer_gre_inner_ip_cidr)[0]

  # Single map for vyos-cloud-init.yaml.tpl + vyos-recovery-configure.tpl + outputs
  vyos_cloud_init_vars = {
    vyos_hostname            = var.vyos_hostname
    ssh_key_type             = local.vyos_ssh_key_type
    ssh_key_data             = local.vyos_ssh_key_data
    mgmt_ip                  = var.router_mgmt_private_ip
    mgmt_pl                  = local.mgmt_prefix_len
    mgmt_gw                  = local.mgmt_gw
    wan_ip                   = var.router_wan_private_ip
    wan_pl                   = local.wan_prefix_len
    internal_ip              = var.router_internal_private_ip
    internal_pl              = local.internal_prefix_len
    wan_gw                   = local.wan_gw
    internal_gw              = local.internal_gw
    imperva_public_ip        = var.imperva_gre_public_ip
    imperva_pm_server_ips    = var.imperva_pm_server_ips
    gre_inner_cidr           = var.customer_gre_inner_ip_cidr
    gre_mtu                  = var.gre_mtu
    imperva_bgp_neighbor     = var.imperva_bgp_neighbor_ip
    bgp_local_asn            = var.bgp_local_asn
    bgp_peer_asn             = var.bgp_peer_asn
    advertised_prefix        = var.advertised_prefix_cidr
    gre_inner_ip             = local.gre_inner_ip
    lan_service_enabled      = var.create_lan_server
    lan_server_private_ip    = var.lan_server_private_ip
    lan_server_advertised_ip = cidrhost(var.advertised_prefix_cidr, 100)
  }

  lab_vyos_advertise_local_b64 = base64encode(templatefile("${path.module}/user_data/lab-vyos-advertise-local.vbash.tpl", local.vyos_cloud_init_vars))

  # Post-boot script holds GRE/BGP/statics so EC2 user-data stays under ~16KB (truncation was dropping routes/BGP).
  vyos_postconfig_bootup = templatefile("${path.module}/user_data/vyos-postconfig-bootup.sh.tpl", local.vyos_cloud_init_vars)
  vyos_user_data = templatefile("${path.module}/user_data/vyos-cloud-init.yaml.tpl", merge(local.vyos_cloud_init_vars, {
    postconfig_bootup_b64        = base64encode(local.vyos_postconfig_bootup)
    lab_vyos_advertise_local_b64 = local.lab_vyos_advertise_local_b64
  }))

  # Recovery template omits SSH material so terraform output is not marked sensitive.
  vyos_recovery_template_vars = {
    for k, v in local.vyos_cloud_init_vars : k => v
    if !contains(["ssh_key_type", "ssh_key_data"], k)
  }
}

# -----------------------------------------------------------------------------
# VPC
# -----------------------------------------------------------------------------
resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = {
    Name = "${var.name_prefix}-vpc"
  }
}

resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "${var.name_prefix}-igw"
  }
}

resource "aws_subnet" "wan" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = var.subnet_wan_cidr
  availability_zone       = local.az
  map_public_ip_on_launch = true

  tags = {
    Name = "${var.name_prefix}-subnet-wan"
  }
}

resource "aws_subnet" "mgmt" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = var.subnet_mgmt_cidr
  availability_zone = local.az

  tags = {
    Name = "${var.name_prefix}-subnet-mgmt"
  }
}

resource "aws_subnet" "internal" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = var.subnet_internal_cidr
  availability_zone = local.az

  tags = {
    Name = "${var.name_prefix}-subnet-internal"
  }

  # Internal subnet deletion waits for Lambda-managed ENIs to disappear.
  timeouts {
    delete = "45m"
  }
}

resource "aws_eip" "nat" {
  domain = "vpc"

  tags = {
    Name = "${var.name_prefix}-nat-eip"
  }

  depends_on = [aws_internet_gateway.main]
}

resource "aws_nat_gateway" "main" {
  allocation_id = aws_eip.nat.id
  subnet_id     = aws_subnet.wan.id

  tags = {
    Name = "${var.name_prefix}-nat"
  }

  depends_on = [aws_internet_gateway.main]
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.main.id
  }

  tags = {
    Name = "${var.name_prefix}-rt-public"
  }
}

resource "aws_route_table_association" "wan" {
  subnet_id      = aws_subnet.wan.id
  route_table_id = aws_route_table.public.id
}

resource "aws_route_table" "private" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block           = "0.0.0.0/0"
    network_interface_id = aws_network_interface.router_internal.id
  }

  lifecycle {
    # Divert/revert intentionally move this default route between VyOS and IGW.
    ignore_changes = [route]
  }

  tags = {
    Name = "${var.name_prefix}-rt-private"
  }
}

# Mgmt subnet must use IGW (public RT), not NAT: router has EIP on mgmt ENI for SSH.
# If default route were NAT, SYN-ACK for inbound SSH would be SNAT'd to NAT EIP → TCP hang.
resource "aws_route_table_association" "mgmt" {
  subnet_id      = aws_subnet.mgmt.id
  route_table_id = aws_route_table.public.id
}

resource "aws_route_table_association" "internal" {
  subnet_id      = aws_subnet.internal.id
  route_table_id = aws_route_table.private.id
}

# -----------------------------------------------------------------------------
# Security groups
# -----------------------------------------------------------------------------
resource "aws_security_group" "router_wan" {
  name_prefix            = "${var.name_prefix}-router-wan-"
  description            = "WAN ENI: ICMP, egress (GRE is ${var.name_prefix}-router-wan-gre when split)"
  vpc_id                 = aws_vpc.main.id
  revoke_rules_on_delete = true

  ingress {
    description = "ICMP from Imperva (PMTUD)"
    from_port   = -1
    to_port     = -1
    protocol    = "icmp"
    cidr_blocks = ["${var.imperva_gre_public_ip}/32"]
  }

  dynamic "ingress" {
    for_each = local.imperva_pm_server_cidrs
    content {
      description = "ICMP from Imperva PM server"
      from_port   = -1
      to_port     = -1
      protocol    = "icmp"
      cidr_blocks = [ingress.value]
    }
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "${var.name_prefix}-sg-router-wan"
  }

  lifecycle {
    create_before_destroy = true
  }

  timeouts {
    delete = "10m"
  }
}

# GRE-only SG: attach/detach from WAN ENI via Lambda to break/restore tunnel without VyOS changes.
resource "aws_security_group" "router_wan_gre" {
  name_prefix            = "${var.name_prefix}-router-wan-gre-"
  description            = "WAN ENI: GRE (protocol 47) only from Imperva POP; toggle via lab GRE Lambda"
  vpc_id                 = aws_vpc.main.id
  revoke_rules_on_delete = true

  ingress {
    description = "GRE (IP protocol 47) from Imperva POP"
    from_port   = 0
    to_port     = 0
    protocol    = "47"
    cidr_blocks = ["${var.imperva_gre_public_ip}/32"]
  }

  egress {
    description = "Required by VPC SG (unused for GRE-only path)"
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "${var.name_prefix}-sg-router-wan-gre"
  }

  lifecycle {
    create_before_destroy = true
  }

  timeouts {
    delete = "10m"
  }
}

resource "aws_security_group" "router_mgmt" {
  name_prefix            = "${var.name_prefix}-router-mgmt-"
  description            = "Mgmt ENI: SSH from admin"
  vpc_id                 = aws_vpc.main.id
  revoke_rules_on_delete = true

  dynamic "ingress" {
    for_each = local.admin_cidrs_effective
    content {
      description = "SSH from admin"
      from_port   = 22
      to_port     = 22
      protocol    = "tcp"
      cidr_blocks = [ingress.value]
    }
  }

  dynamic "ingress" {
    for_each = var.create_ssh_bastion ? [1] : []
    content {
      description     = "SSH from bastion instance (jump host to VyOS private mgmt)"
      from_port       = 22
      to_port         = 22
      protocol        = "tcp"
      security_groups = [aws_security_group.ssh_bastion[0].id]
    }
  }

  dynamic "ingress" {
    for_each = local.legacy_byoip_detector_enabled ? [1] : []
    content {
      description     = "SSH from BYOIP detector Lambda (VPC)"
      from_port       = 22
      to_port         = 22
      protocol        = "tcp"
      security_groups = [aws_security_group.byoip_detector_lambda[0].id]
    }
  }

  dynamic "ingress" {
    for_each = var.lab_gre_byoip_flow_enabled ? [1] : []
    content {
      description     = "SSH from lab traffic path controller Lambda (VPC)"
      from_port       = 22
      to_port         = 22
      protocol        = "tcp"
      security_groups = [aws_security_group.lab_byoip_flow_lambda[0].id]
    }
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "${var.name_prefix}-sg-router-mgmt"
  }

  lifecycle {
    create_before_destroy = true
  }

  timeouts {
    delete = "10m"
  }
}

resource "aws_security_group" "router_internal" {
  name_prefix            = "${var.name_prefix}-router-internal-"
  description            = "Internal ENI: lab traffic"
  vpc_id                 = aws_vpc.main.id
  revoke_rules_on_delete = true

  ingress {
    description = "VPC internal to lab router"
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = [var.vpc_cidr]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "${var.name_prefix}-sg-router-internal"
  }

  lifecycle {
    create_before_destroy = true
  }

  timeouts {
    delete = "10m"
  }
}

data "aws_iam_policy_document" "ssh_bastion_ec2_assume" {
  statement {
    effect = "Allow"
    principals {
      type        = "Service"
      identifiers = ["ec2.amazonaws.com"]
    }
    actions = ["sts:AssumeRole"]
  }
}

resource "aws_iam_role" "ssh_bastion" {
  count              = var.create_ssh_bastion ? 1 : 0
  name_prefix        = "${var.name_prefix}-bastion-"
  assume_role_policy = data.aws_iam_policy_document.ssh_bastion_ec2_assume.json

  tags = {
    Name = "${var.name_prefix}-bastion-ssm-role"
  }
}

resource "aws_iam_role_policy_attachment" "ssh_bastion_ssm" {
  count      = var.create_ssh_bastion ? 1 : 0
  role       = aws_iam_role.ssh_bastion[0].name
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}

resource "aws_iam_instance_profile" "ssh_bastion" {
  count       = var.create_ssh_bastion ? 1 : 0
  name_prefix = "${var.name_prefix}-bastion-"
  role        = aws_iam_role.ssh_bastion[0].name

  tags = {
    Name = "${var.name_prefix}-bastion-instance-profile"
  }
}

# Jump host: SSH to VyOS private mgmt IP (bypasses broken return path for mgmt EIP).
resource "aws_security_group" "ssh_bastion" {
  count                  = var.create_ssh_bastion ? 1 : 0
  name_prefix            = "${var.name_prefix}-ssh-bastion-"
  description            = "SSH from admin_cidrs only; SSM works without inbound 22"
  vpc_id                 = aws_vpc.main.id
  revoke_rules_on_delete = true

  dynamic "ingress" {
    for_each = local.admin_cidrs_effective
    content {
      description = "SSH to jump host"
      from_port   = 22
      to_port     = 22
      protocol    = "tcp"
      cidr_blocks = [ingress.value]
    }
  }

  dynamic "ingress" {
    for_each = var.netflow_exporter_enabled && var.netflow_reuse_bastion ? local.netflow_protected_subnet_cidrs : []
    content {
      description = "AWS Traffic Mirror VXLAN from protected subnet"
      from_port   = 4789
      to_port     = 4789
      protocol    = "udp"
      cidr_blocks = [ingress.value]
    }
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "${var.name_prefix}-sg-ssh-bastion"
  }

  lifecycle {
    create_before_destroy = true
  }

  timeouts {
    delete = "10m"
  }
}

resource "aws_instance" "ssh_bastion" {
  count                       = var.create_ssh_bastion ? 1 : 0
  ami                         = data.aws_ssm_parameter.lan_al2023_ami.value
  instance_type               = var.ssh_bastion_instance_type
  subnet_id                   = aws_subnet.wan.id
  vpc_security_group_ids      = [aws_security_group.ssh_bastion[0].id]
  key_name                    = local.vmx_key_name
  associate_public_ip_address = true
  iam_instance_profile        = aws_iam_instance_profile.ssh_bastion[0].name
  user_data                   = local.netflow_bastion_user_data
  user_data_replace_on_change = true

  root_block_device {
    encrypted = true
  }

  metadata_options {
    http_tokens = "required"
  }

  tags = {
    Name = "${var.name_prefix}-ssh-bastion"
    Role = "ssh-bastion"
  }

  depends_on = [aws_iam_role_policy_attachment.ssh_bastion_ssm]
}

resource "aws_network_interface" "netflow_exporter" {
  count = var.netflow_exporter_enabled && var.netflow_reuse_bastion ? 1 : 0

  subnet_id       = aws_subnet.wan.id
  security_groups = [aws_security_group.ssh_bastion[0].id]

  tags = {
    Name = "${var.name_prefix}-eni-netflow-exporter"
  }
}

resource "aws_network_interface_attachment" "netflow_exporter" {
  count = var.netflow_exporter_enabled && var.netflow_reuse_bastion ? 1 : 0

  instance_id          = aws_instance.ssh_bastion[0].id
  network_interface_id = aws_network_interface.netflow_exporter[0].id
  device_index         = 1
}

resource "aws_eip" "netflow_exporter" {
  count = var.netflow_exporter_enabled && var.netflow_reuse_bastion ? 1 : 0

  domain = "vpc"

  tags = {
    Name = "${var.name_prefix}-netflow-exporter-eip"
  }

  depends_on = [aws_internet_gateway.main]
}

resource "aws_eip_association" "netflow_exporter" {
  count = var.netflow_exporter_enabled && var.netflow_reuse_bastion ? 1 : 0

  allocation_id        = aws_eip.netflow_exporter[0].id
  network_interface_id = aws_network_interface.netflow_exporter[0].id
}

# -----------------------------------------------------------------------------
# Elastic IPs (WAN tunnel source + optional mgmt SSH)
# -----------------------------------------------------------------------------
removed {
  from = aws_eip.router_wan

  lifecycle {
    destroy = false
  }
}

data "aws_eip" "router_wan_external" {
  count = local.router_wan_uses_external_eip ? 1 : 0

  id = local.router_wan_external_eip
}

resource "aws_eip" "router_wan_managed" {
  count = local.router_wan_uses_external_eip ? 0 : 1

  domain = "vpc"

  tags = {
    Name = "${var.name_prefix}-router-wan-eip"
  }

  depends_on = [aws_internet_gateway.main]
}

resource "aws_eip" "router_mgmt" {
  domain = "vpc"

  tags = {
    Name = "${var.name_prefix}-router-mgmt-eip"
  }

  depends_on = [aws_internet_gateway.main]
}

# -----------------------------------------------------------------------------
# Router ENIs (device order: 0 mgmt fxp0, 1 WAN ge-0/0/0, 2 internal ge-0/0/1)
# -----------------------------------------------------------------------------
resource "aws_network_interface" "router_mgmt" {
  subnet_id         = aws_subnet.mgmt.id
  private_ips       = [var.router_mgmt_private_ip]
  security_groups   = [aws_security_group.router_mgmt.id]
  source_dest_check = false

  tags = {
    Name = "${var.name_prefix}-eni-router-mgmt"
  }
}

resource "aws_network_interface" "router_wan" {
  subnet_id         = aws_subnet.wan.id
  private_ips       = [var.router_wan_private_ip]
  security_groups   = [aws_security_group.router_wan.id, aws_security_group.router_wan_gre.id]
  source_dest_check = false

  tags = {
    Name = "${var.name_prefix}-eni-router-wan"
  }

  # Lab GRE Lambda detaches the GRE-only SG for simulation; Terraform must not fight that attachment set.
  lifecycle {
    ignore_changes = [security_groups]
  }
}

resource "aws_network_interface" "router_internal" {
  subnet_id         = aws_subnet.internal.id
  private_ips       = [var.router_internal_private_ip]
  security_groups   = [aws_security_group.router_internal.id]
  source_dest_check = false

  tags = {
    Name = "${var.name_prefix}-eni-router-internal"
  }
}

resource "aws_eip_association" "router_wan" {
  allocation_id        = local.router_wan_eip_allocation_id
  network_interface_id = aws_network_interface.router_wan.id
}

resource "aws_eip_association" "router_mgmt" {
  allocation_id        = aws_eip.router_mgmt.id
  network_interface_id = aws_network_interface.router_mgmt.id
}

resource "aws_instance" "vmx" {
  ami           = var.router_ami_id
  instance_type = var.router_instance_type
  key_name      = local.vmx_key_name

  disable_api_termination = false

  network_interface {
    device_index         = 0
    network_interface_id = aws_network_interface.router_mgmt.id
  }

  network_interface {
    device_index         = 1
    network_interface_id = aws_network_interface.router_wan.id
  }

  network_interface {
    device_index         = 2
    network_interface_id = aws_network_interface.router_internal.id
  }

  user_data                   = local.vyos_user_data
  user_data_replace_on_change = false

  root_block_device {
    encrypted = true
  }

  lifecycle {
    # Keep day-0 user_data in Terraform for new builds/recovery outputs, but do
    # not recycle the live router when the bootstrap templates evolve.
    ignore_changes = [user_data]

    precondition {
      condition     = length(local.vyos_user_data) <= 15360
      error_message = "VyOS user_data exceeds ~15KB; EC2 limit is 16KB and truncation breaks cloud-init (partial config, no default route/BGP). Shorten SSH key or template."
    }
  }

  tags = {
    Name = "${var.name_prefix}-vyos"
    Role = "vyos-router"
  }

  depends_on = [
    aws_eip_association.router_wan,
    aws_eip_association.router_mgmt
  ]
}

# -----------------------------------------------------------------------------
# LAN server (internal subnet) + BYOIP .100 from advertised prefix
# -----------------------------------------------------------------------------
resource "aws_security_group" "lan_server" {
  count = var.create_lan_server ? 1 : 0

  name_prefix            = "${var.name_prefix}-lan-server-"
  description            = "LAN victim: TCP 22/80/443 and ICMP echo only from admin_cidrs"
  vpc_id                 = aws_vpc.main.id
  revoke_rules_on_delete = true

  ingress {
    description = "HTTP from internet through GRE/BYOIP"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    description = "HTTPS from internet through GRE/BYOIP"
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  dynamic "ingress" {
    for_each = local.lan_server_admin_tcp_ingress
    content {
      description = "TCP ${ingress.value.port} from admin_cidrs"
      from_port   = ingress.value.port
      to_port     = ingress.value.port
      protocol    = "tcp"
      cidr_blocks = [ingress.value.cidr]
    }
  }

  dynamic "ingress" {
    for_each = local.admin_cidrs_effective
    content {
      description = "ICMP echo from admin_cidrs"
      from_port   = 8
      to_port     = 0
      protocol    = "icmp"
      cidr_blocks = [ingress.value]
    }
  }

  # Bastion connects to LAN private IP with source = bastion private IP (not your public admin /32).
  dynamic "ingress" {
    for_each = var.create_lan_server && var.create_ssh_bastion ? [22, 80, 443] : []
    content {
      description     = "TCP ${ingress.value} from bastion SG (private IP path)"
      from_port       = ingress.value
      to_port         = ingress.value
      protocol        = "tcp"
      security_groups = [aws_security_group.ssh_bastion[0].id]
    }
  }

  dynamic "ingress" {
    for_each = var.create_lan_server && var.create_ssh_bastion ? [1] : []
    content {
      description     = "ICMP echo from bastion SG"
      from_port       = 8
      to_port         = 0
      protocol        = "icmp"
      security_groups = [aws_security_group.ssh_bastion[0].id]
    }
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "${var.name_prefix}-sg-lan-server"
  }

  lifecycle {
    create_before_destroy = true
  }

  timeouts {
    delete = "10m"
  }
}

resource "aws_instance" "lan_server" {
  count = var.create_lan_server ? 1 : 0

  ami                    = var.lan_server_ami_id != "" ? var.lan_server_ami_id : data.aws_ssm_parameter.lan_al2023_ami.value
  instance_type          = var.lan_server_instance_type
  subnet_id              = aws_subnet.internal.id
  vpc_security_group_ids = [aws_security_group.lan_server[0].id]
  key_name               = local.vmx_key_name

  private_ip = var.lan_server_private_ip

  root_block_device {
    encrypted = true
  }

  metadata_options {
    http_tokens = "required"
  }

  user_data = "${file("${path.module}/user_data/lan-server-bootstrap.sh")}\n# replace-trigger=${var.lan_server_replace_trigger}\n"
  user_data_replace_on_change = true

  tags = {
    Name = "${var.name_prefix}-lan-server"
    Role = "lan-by100"
  }

  lifecycle {
    ignore_changes = [ami]
  }
}

data "aws_eip" "lan_byoip" {
  count = var.create_lan_server && trimspace(var.lan_server_byoip_allocation_id) != "" ? 1 : 0
  id    = trimspace(var.lan_server_byoip_allocation_id)
}