Skip to content

Deploy

Do not copy state, variable files, plans, lock files, or access instructions between these roots.

Before planning, authenticate profile 280469140135_Users and verify that it resolves to account 280469140135 in us-east-1. The AWS Terraform guards also validate the target domain. Authentication failure is a hard stop.

Set every required plan-time input using values obtained through the approved discovery and release process: the exact AMI ID, instance type, 40-character source commit, deployment-manifest version and digest, pinned Node 22 archive URL and SHA-256, pinned Chrome for Testing archive URL and SHA-256, operator_ssh_cidr, and ssh_public_key_path. Never guess an image or artifact digest and never commit credentials, key material, or local variable files.

The currently observed jumpbox public address is 142.127.218.190/32; revalidate it immediately before every plan or apply because it is not permanent. On robin@192.168.2.240, the untracked public key path may be /home/robin/.ssh/id_ed25519.pub. Terraform reads that public key, while its private counterpart remains outside configuration and state.

The worker clones the exact source commit and verifies runtime archives before installation. Client-Side Defense browser scenarios run with headed Chrome under Xvfb.

They capture a sanitized screenshot after every primitive step and a final screenshot, plus a sanitized execution receipt outside Git. Successful execution and screenshots do not guarantee that F5 Distributed Cloud produced a detection; correlate the run window with tenant telemetry.

Headed Chrome and screenshot validation are AWS-runtime operations only. The normal developer command, node --test tests/csd-violations.test.mjs, executes contract tests without launching a browser and prints an explicit AWS-only skip reason. During AWS provisioning, csd-worker-health.service must pass the gated browser integration against the deployed source commit, Chrome, Playwright, Xvfb display, status identity, and runtime output directory before the worker is healthy.

Run Terraform only from terraform/aws/. Its S3 backend uses bucket terraform-tfstate-xc, key f5-sales-demo/traffic-generator-aws.tfstate, region us-east-1, native state locking, and encryption. It is independent of Azure state.

  1. Confirm AWS authentication and the guarded account, profile, primary region, replication region, and target domain.
  2. On the jumpbox, revalidate its current public IPv4 and set operator_ssh_cidr to that exact /32; do not assume the documented example is still current.
  3. Confirm ssh_public_key_path names an existing untracked public key file and contains no private key material.
  4. Initialize the AWS root and review the generated dependency lock file.
  5. Validate the configuration and create a saved plan.
  6. Inspect the saved plan for the public-subnet EC2 worker, direct primary-ENI Elastic IP, restricted SSH, deny-all default security group, public route, service endpoints, encrypted runtime and VPC Flow Logs, EventBridge notifications on all four S3 buckets, primary and replica server access logging, KMS-encrypted/versioned source and replica buckets, and SSE-S3 terminal log sinks in each region.
  7. Confirm the plan contains no NAT Gateway, private worker subnet, separate Elastic IP association resource, broad SSH ingress, automatic public-IP assignment, committed key material, or broad Checkov skip.
  8. Review S3 lifecycle, replica-region KMS and replication IAM permissions, the narrow AWS-required SSE-S3 sink exemptions, plus ongoing CloudWatch, KMS, access-log, replication-transfer, and replica-storage costs.
  9. Obtain explicit operator approval for that exact saved plan before applying it.
  10. Apply only the reviewed plan; do not replace it with an unreviewed plan at apply time.
  11. Use the ssh_command output for direct administration. Retain Systems Manager Session Manager and Run Command for recovery and constrained scenario execution.

After apply, verify SSH from the authorized jumpbox, Systems Manager connectivity, cloud-init/runtime status, exact checked-out source commit, runtime provenance, service health, evidence upload, EventBridge notification configuration on all four buckets, VPC Flow Log delivery, and S3 access-log delivery in both regions. Then verify required source object versions report completed cross-region replication and use the replica KMS key; replication is asynchronous and source upload success alone is insufficient. Run the approved scenario and correlate its timestamps with F5 Distributed Cloud telemetry. Follow the AWS README for guarded destroy; retained source, replica, primary-log, and replica-log versions must be handled deliberately.

A failed bootstrap that requires worker replacement uses two separately approved saved plans. First plan only -var='termination_protection_enabled=false'; require an in-place disable_api_termination change, no replacement, and no unrelated changes. Inspect and hash that plan. Obtain explicit approval for its exact digest, apply only it, and verify the output is false.

After correcting the reviewed bootstrap input or source, create a second saved plan with -var='termination_protection_enabled=true' and the approved replacement trigger, such as -replace=aws_instance.worker. Require disable_api_termination = true on the new worker.

Inspect and hash the replacement plan. Obtain separate approval for its exact digest, then apply only that plan. Do not combine disablement and replacement, change the default, or leave the replacement unprotected. Recovery completes only when the new worker reports ready and passes AWS runtime verification.

All Azure Terraform files remain in the terraform/ directory. Clone the repository and deploy directly:

Terminal window
git clone https://github.com/f5-sales-demo/traffic-generator.git
cd traffic-generator/terraform
cp terraform.tfvars.example terraform.tfvars
# Edit terraform.tfvars with your Azure subscription ID and target FQDN

The terraform directory contains 9 files following the Demo Resource Standard:

  • versions.tf — Terraform and provider version constraints (azurerm ~> 4.0, azuread ~> 3.0)
  • providers.tf — Azure RM and Azure AD provider configuration
  • data.tf — Azure AD data sources for deployer auto-resolution
  • locals.tf — Deployer resolution, Azure Cloud Adoption Framework resource naming, standard tags
  • main.tf — Resource group (named rg-traffic-generator-{environment}-{deployer})
  • variables.tf — All input variables (2 required, 9 optional) with deployer auto-resolution via Azure AD
  • network.tf — VNet (10.201.0.0/16), subnet, public IP, NSG (port 22 SSH), NIC
  • vm.tf — Ubuntu 24.04 VM with cloud-init via templatefile()
  • outputs.tf — 17 outputs: 15 standard outputs shared by all demo resources (deployer, public_ip, private_ip, ssh_command, resource_group_name, vm_name, nsg_name, vnet_name, subnet_id, component, environment, resource_group_id, vm_id, nsg_id, location) plus 2 component-specific outputs (target_fqdn, status_check)

main.tf configures the Azure provider:

resource "azurerm_resource_group" "main" {
name = local.name.resource_group
location = var.location
tags = local.tags
}

variables.tf defines all configurable parameters. The vm_size default is Standard_F16s_v2 (16 vCPU compute-optimized) — see VM Sizing for scaling guidance:

# ---------------------------------------------------------
# General
# ---------------------------------------------------------
variable "subscription_id" {
description = "Azure subscription ID"
type = string
}
variable "deployer" {
description = "Override for deployer identifier (auto-resolved from Azure AD if empty). Required for service principal or managed identity authentication."
type = string
default = ""
}
variable "location" {
description = "Azure region for all resources"
type = string
default = "eastus2"
}
variable "environment" {
description = "Environment label used in resource group naming and tags"
type = string
default = "lab"
}
variable "tags" {
description = "Additional tags merged with standard tags (component, environment, deployer, managed_by)"
type = map(string)
default = {}
}
# ---------------------------------------------------------
# Compute
# ---------------------------------------------------------
variable "vm_size" {
description = "Azure VM size (F16s_v2: 16 vCPU compute-optimized, validated by benchmark)"
type = string
default = "Standard_F16s_v2"
}
variable "admin_username" {
description = "SSH admin username for the VM"
type = string
default = "azureuser"
}
variable "ssh_public_key_path" {
description = "Path to the SSH public key file"
type = string
default = "~/.ssh/id_ed25519.pub"
}
variable "disk_size_gb" {
description = "OS disk size in GB"
type = number
default = 64
}
# ---------------------------------------------------------
# Component-Specific
# ---------------------------------------------------------
variable "target_fqdn" {
description = "FQDN of the F5 XC load balancer to target"
type = string
}
variable "target_origin_ip" {
description = "Direct origin IP for bypass testing"
type = string
default = ""
}
variable "tool_tier" {
description = "Tool installation tier: standard (default) or full (includes ZAP, Metasploit)"
type = string
default = "standard"
validation {
condition = contains(["standard", "full"], var.tool_tier)
error_message = "tool_tier must be \"standard\" or \"full\"."
}
}

network.tf creates the VNet, subnet, public IP, NSG (port 22 SSH), and NIC:

resource "azurerm_virtual_network" "main" {
name = local.name.virtual_network
address_space = ["10.201.0.0/16"]
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
tags = azurerm_resource_group.main.tags
}
resource "azurerm_subnet" "main" {
name = local.name.subnet
resource_group_name = azurerm_resource_group.main.name
virtual_network_name = azurerm_virtual_network.main.name
address_prefixes = ["10.201.1.0/24"]
}
resource "azurerm_public_ip" "main" {
name = local.name.public_ip
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
allocation_method = "Static"
sku = "Standard"
tags = azurerm_resource_group.main.tags
}
resource "azurerm_network_security_group" "main" {
name = local.name.nsg
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
security_rule {
name = "AllowSSH"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "22"
source_address_prefix = "*"
destination_address_prefix = "*"
}
tags = azurerm_resource_group.main.tags
}
resource "azurerm_network_interface" "main" {
name = local.name.network_interface
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
ip_configuration {
name = "internal"
subnet_id = azurerm_subnet.main.id
private_ip_address_allocation = "Dynamic"
public_ip_address_id = azurerm_public_ip.main.id
}
tags = azurerm_resource_group.main.tags
}
resource "azurerm_network_interface_security_group_association" "main" {
network_interface_id = azurerm_network_interface.main.id
network_security_group_id = azurerm_network_security_group.main.id
}

vm.tf creates the Ubuntu 24.04 VM and passes the cloud-init template with target and tooling variables:

resource "azurerm_linux_virtual_machine" "main" {
name = local.name.virtual_machine
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
size = var.vm_size
admin_username = var.admin_username
disable_password_authentication = true
admin_ssh_key {
username = var.admin_username
public_key = file(pathexpand(var.ssh_public_key_path))
}
network_interface_ids = [azurerm_network_interface.main.id]
os_disk {
caching = "ReadWrite"
storage_account_type = "Premium_LRS"
disk_size_gb = var.disk_size_gb
}
source_image_reference {
publisher = "Canonical"
offer = "ubuntu-24_04-lts"
sku = "server"
version = "latest"
}
custom_data = base64encode(templatefile("${path.module}/cloud-init.yaml", {
target_fqdn = var.target_fqdn
target_origin_ip = var.target_origin_ip
tool_tier = var.tool_tier
}))
tags = azurerm_resource_group.main.tags
}

cloud-init.yaml is the production-validated configuration verified under 48+ hours of continuous load testing including 4 rounds of origin torture testing. It uses a helper library with retry logic, progress logging to /var/log/cloud-init-progress.log, 9 provisioning phases, and conditional status reporting (ready/degraded in status.json). It provisions:

  • Kernel tuning — somaxconn=131072, tcp_max_tw_buckets=4000000, tcp_fin_timeout=5, 64 MiB socket buffers, file-max=4194304
  • NIC optimization — RPS/RFS across all cores, maximized ring buffers via ethtool, THP=always
  • APT packages — nikto, nmap, masscan, sqlmap, hydra, medusa, ncrack, tshark, hping3, tcpdump, netcat, ngrep, iperf3, mtr, sslscan, socat, dirb, whatweb, wrk, hey, vegeta, ethtool
  • GitHub Releases — nuclei, dalfox, ffuf, gobuster, feroxbuster, subfinder, httpx, amass
  • Python packages — mitmproxy, sslyze, dnsrecon, fierce, theHarvester, playwright, scapy, impacket, arjun
  • Node.js 20.x — Puppeteer with stealth plugin for bot simulation
  • Browser automation — Playwright Chromium for headless browser testing
  • Git clones — testssl.sh, recon-ng, spiderfoot, SecLists, waf-bypass, PayloadsAllTheThings
  • Suite deployment — 19 traffic suites cloned from this repository to /opt/traffic-generator/suites/
  • Config file — config.env with TARGET_FQDN, TARGET_ORIGIN_IP, and CRAPI_PORT

The cloud-init uses Terraform template variables: ${target_fqdn}, ${target_origin_ip}, and ${tool_tier}. Shell variables like $NPROC are escaped as $$NPROC in the Terraform templatefile.

#cloud-config
package_update: true
package_upgrade: true
packages:
# System essentials
- ca-certificates
- curl
- gnupg
- lsb-release
- jq
- git
- unzip
- build-essential
- python3
- python3-pip
- python3-venv
- python3-dev
- libffi-dev
- libssl-dev
- libpcap-dev
# Network analysis
- nmap
- masscan
- tshark
- hping3
- socat
- tcpdump
- netcat-openbsd
- ngrep
- iperf3
- mtr-tiny
- whois
- dnsutils
# Web scanners
- nikto
- sqlmap
- dirb
- whatweb
- sslscan
# Password/credential testing
- hydra
- medusa
- ncrack
- john
- hashcat
# Forensics/analysis
- binwalk
- strace
- ltrace
# Performance monitoring
- sysstat
- htop
- iotop
- ethtool
# Load testing
- apache2-utils
- wrk
# Headless Chrome dependencies
- fonts-noto-color-emoji
- fonts-liberation
- libnss3
- libatk-bridge2.0-dev
- libdrm2
- libxkbcommon0
- libgbm1
- libasound2-dev
write_files:
- path: /usr/local/bin/ghlatest
permissions: '0755'
content: |
#!/bin/sh
set -e
REPO="$1"
if [ -z "$REPO" ]; then echo "Usage: ghlatest owner/repo" >&2; exit 1; fi
R=0; M=3
while [ "$R" -lt "$M" ]; do
RESP=$(curl -fsSL -w "\n%%{http_code}" "https://api.github.com/repos/$REPO/releases/latest" 2>/dev/null) || true
HTTP_CODE=$(echo "$RESP" | tail -1)
BODY=$(echo "$RESP" | sed '$d')
if [ "$HTTP_CODE" = "403" ] || [ "$HTTP_CODE" = "429" ]; then
echo "WARN: ghlatest rate-limited ($HTTP_CODE) for $REPO" >&2; exit 1
fi
V=$(echo "$BODY" | grep '"tag_name"' | head -1 | sed 's/.*"tag_name"[[:space:]]*:[[:space:]]*"v\{0,1\}\([^"]*\)".*/\1/')
if [ -n "$V" ]; then echo "$V"; exit 0; fi
R=$((R + 1)); sleep $((R * 3))
done
echo "ERROR: ghlatest failed for $REPO" >&2; exit 1
- path: /etc/sysctl.d/99-traffic-generator.conf
content: |
net.core.somaxconn = 131072
net.ipv4.tcp_max_syn_backlog = 131072
net.core.netdev_max_backlog = 131072
net.ipv4.ip_local_port_range = 1024 65535
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_fin_timeout = 5
net.ipv4.tcp_max_tw_buckets = 4000000
net.ipv4.tcp_keepalive_time = 30
net.ipv4.tcp_keepalive_intvl = 5
net.ipv4.tcp_keepalive_probes = 3
net.core.rmem_max = 67108864
net.core.wmem_max = 67108864
net.ipv4.tcp_rmem = 4096 87380 67108864
net.ipv4.tcp_wmem = 4096 65536 67108864
net.ipv4.tcp_syncookies = 1
net.ipv4.tcp_slow_start_after_idle = 0
net.ipv4.tcp_no_metrics_save = 1
net.ipv4.tcp_timestamps = 1
net.ipv4.tcp_window_scaling = 1
fs.file-max = 4194304
- path: /etc/security/limits.d/99-traffic-generator.conf
content: |
* soft nofile 524288
* hard nofile 524288
root soft nofile 524288
root hard nofile 524288
* soft nproc 65535
* hard nproc 65535
- path: /etc/profile.d/traffic-generator.sh
permissions: '0644'
content: |
export NODE_PATH=/usr/lib/node_modules
export PATH=$PATH:/usr/local/bin
- path: /usr/local/bin/tgen-profile
permissions: '0755'
content: |
#!/bin/bash
set -uo pipefail
report() {
echo "================================================================"
echo " TRAFFIC GENERATOR PLATFORM PROFILE — $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "================================================================"
echo ""
echo "--- CPU ---"
echo "Cores: $(nproc)"
uptime
echo ""
echo "--- Memory ---"
free -h
echo ""
echo "--- Disk I/O ---"
iostat -x 1 1 2>/dev/null | grep -A20 'Device' || echo "iostat not available"
echo ""
echo "--- Network connections ---"
ss -s
echo ""
echo "--- TCP state distribution ---"
ss -tan | awk 'NR>1 {print $1}' | sort | uniq -c | sort -rn
echo ""
echo "--- TIME_WAIT count ---"
ss -tan state time-wait | wc -l
echo ""
echo "--- Ephemeral port usage ---"
RANGE=$(sysctl -n net.ipv4.ip_local_port_range)
LOW=$(echo "$RANGE" | awk '{print $1}')
HIGH=$(echo "$RANGE" | awk '{print $2}')
USED=$(ss -tan | awk -v low="$LOW" -v high="$HIGH" '{split($4,a,":"); p=a[length(a)]; if(p>=low && p<=high) count++} END {print count+0}')
TOTAL=$((HIGH - LOW))
echo " Range: $LOW-$HIGH ($TOTAL ports)"
echo " Used: $USED ($((USED * 100 / TOTAL))%)"
echo ""
echo "--- Open file descriptors ---"
echo " System: $(awk '{print $1}' /proc/sys/fs/file-nr) / $(cat /proc/sys/fs/file-max)"
echo " Limit: $(ulimit -n) (soft) / $(ulimit -Hn) (hard)"
echo ""
echo "--- Kernel TCP tuning ---"
for k in net.core.somaxconn net.ipv4.tcp_max_syn_backlog net.ipv4.ip_local_port_range net.ipv4.tcp_tw_reuse net.ipv4.tcp_fin_timeout net.ipv4.tcp_max_tw_buckets net.core.rmem_max net.core.wmem_max; do
printf " %-40s %s\n" "$k" "$(sysctl -n $k 2>/dev/null || echo N/A)"
done
}
if [ "$${1:-}" = "--watch" ]; then
SECS="$${2:-10}"
while true; do clear; report; sleep "$SECS"; done
else
report
fi
- path: /opt/traffic-generator/config.env
content: |
TARGET_FQDN=${target_fqdn}
TARGET_ORIGIN_IP=${target_origin_ip}
TARGET_PROTOCOL=http
CRAPI_PORT=8888
NODE_PATH=/usr/lib/node_modules
- path: /opt/traffic-generator/status.json
content: |
{"status":"provisioning","tool_tier":"${tool_tier}"}
- path: /usr/local/lib/cloud-init-helpers.sh
permissions: "0644"
content: |
#!/bin/sh
PROGRESS_LOG="/var/log/cloud-init-progress.log"
log_phase() {
_phase="$1"; shift
_msg="$${*:-started}"
_ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
printf '[%s] [%s] %s\n' "$_ts" "$_phase" "$_msg" | tee -a "$PROGRESS_LOG" >&2
}
retry_cmd() {
_max="$1"; _base="$2"; shift 2
_attempt=1
while [ "$_attempt" -le "$_max" ]; do
if "$@"; then return 0; fi
if [ "$_attempt" -lt "$_max" ]; then
_wait=$(( _base * _attempt ))
log_phase "retry" "attempt $_attempt/$_max failed ($1) — retrying in $${_wait}s"
sleep "$_wait"
fi
_attempt=$(( _attempt + 1 ))
done
log_phase "retry" "FAILED after $_max attempts: $1"
return 1
}
fetch_url() {
_url="$1"; _out="$2"; _desc="$${3:-$1}"
log_phase "fetch" "$_desc"
retry_cmd 4 5 curl -fsSL --connect-timeout 15 --max-time 300 -o "$_out" "$_url"
}
install_packages() {
log_phase "apt" "installing: $*"
retry_cmd 3 10 apt-get install -y -o DPkg::Lock::Timeout=60 "$@"
}
clone_repo() {
_url="$1"; _dest="$2"; _depth="$${3:-1}"
log_phase "git" "cloning $_url -> $_dest"
retry_cmd 3 10 git clone --depth "$_depth" --single-branch "$_url" "$_dest"
}
wait_for_http() {
_url="$1"; _max="$2"; _desc="$${3:-$_url}"
log_phase "health" "waiting for $_desc (max $${_max}s)"
_elapsed=0
while [ "$_elapsed" -lt "$_max" ]; do
if curl -sf --max-time 5 "$_url" >/dev/null 2>&1; then
log_phase "health" "$_desc ready after $${_elapsed}s"
return 0
fi
sleep 5; _elapsed=$(( _elapsed + 5 ))
done
log_phase "health" "TIMEOUT: $_desc not ready after $${_max}s"
return 1
}
runcmd:
# --- Phase 0: Kernel tuning, NIC optimization, system limits ---
- sysctl --system
- systemctl daemon-reload
- |
. /usr/local/lib/cloud-init-helpers.sh
log_phase "phase0" "kernel tuning and NIC optimization"
# RPS/RFS: distribute NIC softirqs across all cores
NCPU=$(nproc)
RPS_MASK=$(printf '%x' $(( (1 << NCPU) - 1 )))
NIC=$(ip -o link show | awk -F': ' '/state UP/ && !/lo/{print $2; exit}')
if [ -n "$NIC" ]; then
RFS_ENTRIES=$((32768 * NCPU))
echo "$RFS_ENTRIES" > /proc/sys/net/core/rps_sock_flow_entries
for rxq in /sys/class/net/"$NIC"/queues/rx-*/rps_cpus; do
echo "$RPS_MASK" > "$rxq"
done
for rxq in /sys/class/net/"$NIC"/queues/rx-*/rps_flow_cnt; do
RXQ_COUNT=$(ls -d /sys/class/net/"$NIC"/queues/rx-* | wc -l)
echo "$((RFS_ENTRIES / RXQ_COUNT))" > "$rxq"
done
# Maximize NIC ring buffers
if command -v ethtool >/dev/null 2>&1; then
RX_MAX=$(ethtool -g "$NIC" 2>/dev/null | awk '/Pre-set.*:/,/^$/{if(/RX:/){print $2; exit}}')
TX_MAX=$(ethtool -g "$NIC" 2>/dev/null | awk '/Pre-set.*:/,/^$/{if(/TX:/){print $2; exit}}')
[ -n "$RX_MAX" ] && [ -n "$TX_MAX" ] && ethtool -G "$NIC" rx "$RX_MAX" tx "$TX_MAX" 2>/dev/null
fi
echo "NIC tuning: RPS mask=$RPS_MASK on $NIC"
fi
# Transparent Huge Pages for large connection pools
echo "always" > /sys/kernel/mm/transparent_hugepage/enabled 2>/dev/null || true
# --- Phase 1: Node.js 24 ---
- |
. /usr/local/lib/cloud-init-helpers.sh
log_phase "phase1" "installing Node.js 24"
retry_cmd 3 10 sh -c 'curl -fsSL https://deb.nodesource.com/setup_24.x | bash -'
install_packages nodejs
# --- Phase 2: Python toolchain (uv) ---
- |
. /usr/local/lib/cloud-init-helpers.sh
log_phase "phase2" "installing Python uv"
retry_cmd 4 5 sh -c 'curl -fsSL https://astral.sh/uv/install.sh | sh'
cp /root/.local/bin/uv /usr/local/bin/uv || true
cp /root/.local/bin/uvx /usr/local/bin/uvx || true
# --- Phase 3: Security binaries (Go tools via ghlatest) ---
- |
. /usr/local/lib/cloud-init-helpers.sh
log_phase "phase3" "installing security binaries"
export DPKG_ARCH=$(dpkg --print-architecture)
export PATH=/usr/local/bin:$PATH
echo "Installing nuclei..."
NUCLEI_VER=$(ghlatest projectdiscovery/nuclei)
fetch_url "https://github.com/projectdiscovery/nuclei/releases/download/v$${NUCLEI_VER}/nuclei_$${NUCLEI_VER}_linux_$${DPKG_ARCH}.zip" /tmp/nuclei.zip "nuclei"
unzip -oq /tmp/nuclei.zip nuclei -d /usr/local/bin && rm /tmp/nuclei.zip
echo "Installing subfinder..."
SUBFINDER_VER=$(ghlatest projectdiscovery/subfinder)
fetch_url "https://github.com/projectdiscovery/subfinder/releases/download/v$${SUBFINDER_VER}/subfinder_$${SUBFINDER_VER}_linux_$${DPKG_ARCH}.zip" /tmp/subfinder.zip "subfinder"
unzip -oq /tmp/subfinder.zip subfinder -d /usr/local/bin && rm /tmp/subfinder.zip
echo "Installing httpx..."
HTTPX_VER=$(ghlatest projectdiscovery/httpx)
fetch_url "https://github.com/projectdiscovery/httpx/releases/download/v$${HTTPX_VER}/httpx_$${HTTPX_VER}_linux_$${DPKG_ARCH}.zip" /tmp/httpx.zip "httpx"
unzip -oq /tmp/httpx.zip httpx -d /usr/local/bin && rm /tmp/httpx.zip
echo "Installing ffuf..."
FFUF_VER=$(ghlatest ffuf/ffuf)
retry_cmd 4 5 sh -c 'curl -fsSL "https://github.com/ffuf/ffuf/releases/download/v$${FFUF_VER}/ffuf_$${FFUF_VER}_linux_$${DPKG_ARCH}.tar.gz" | tar -xz -C /usr/local/bin ffuf'
echo "Installing gobuster..."
GOBUSTER_VER=$(ghlatest OJ/gobuster)
retry_cmd 4 5 sh -c 'curl -fsSL "https://github.com/OJ/gobuster/releases/download/v$${GOBUSTER_VER}/gobuster_Linux_$(uname -m).tar.gz" | tar -xz -C /usr/local/bin gobuster'
echo "Installing feroxbuster..."
FEROX_VER=$(ghlatest epi052/feroxbuster)
fetch_url "https://github.com/epi052/feroxbuster/releases/download/v$${FEROX_VER}/feroxbuster_$${DPKG_ARCH}.deb.zip" /tmp/ferox.zip "feroxbuster"
unzip -oq /tmp/ferox.zip -d /tmp && dpkg -i /tmp/feroxbuster_*.deb && rm -f /tmp/ferox.zip /tmp/feroxbuster_*.deb
echo "Installing dalfox..."
DALFOX_VER=$(ghlatest hahwul/dalfox)
fetch_url "https://github.com/hahwul/dalfox/releases/download/v$${DALFOX_VER}/dalfox-linux-$${DPKG_ARCH}.tar.gz" /tmp/dalfox.tar.gz "dalfox"
tar -xzf /tmp/dalfox.tar.gz -C /tmp && mv /tmp/dalfox-linux-$${DPKG_ARCH} /usr/local/bin/dalfox && chmod +x /usr/local/bin/dalfox && rm /tmp/dalfox.tar.gz
echo "Installing amass..."
AMASS_VER=$(ghlatest owasp-amass/amass)
fetch_url "https://github.com/owasp-amass/amass/releases/download/v$${AMASS_VER}/amass_linux_$${DPKG_ARCH}.tar.gz" /tmp/amass.tar.gz "amass"
mkdir -p /tmp/amass && tar -xzf /tmp/amass.tar.gz -C /tmp/amass && cp /tmp/amass/amass_linux_$${DPKG_ARCH}/amass /usr/local/bin/ && rm -rf /tmp/amass /tmp/amass.tar.gz
echo "Phase 3 complete"
# --- Phase 3b: High-performance load testing tools ---
- |
. /usr/local/lib/cloud-init-helpers.sh
log_phase "phase3b" "installing load testing tools"
export DPKG_ARCH=$(dpkg --print-architecture)
export PATH=/usr/local/bin:$PATH
echo "Installing hey (via go install — no prebuilt binaries available)..."
install_packages golang-go
mkdir -p /opt/go/bin
chown -R azureuser:azureuser /opt/go
retry_cmd 3 15 sh -c 'GOPATH=/opt/go go install github.com/rakyll/hey@latest'
cp /opt/go/bin/hey /usr/local/bin/hey
echo "Installing gotestwaf..."
retry_cmd 3 15 sh -c 'GOPATH=/opt/go go install github.com/wallarm/gotestwaf/cmd/gotestwaf@latest'
cp /opt/go/bin/gotestwaf /usr/local/bin/gotestwaf
mkdir -p /opt/gotestwaf
cp -r /opt/go/pkg/mod/github.com/wallarm/gotestwaf@*/config.yaml /opt/gotestwaf/ 2>/dev/null || true
cp -r /opt/go/pkg/mod/github.com/wallarm/gotestwaf@*/testcases /opt/gotestwaf/ 2>/dev/null || true
chown -R azureuser:azureuser /opt/gotestwaf
echo "Installing vegeta..."
VEGETA_VER=$(ghlatest tsenart/vegeta)
retry_cmd 4 5 sh -c 'curl -fsSL "https://github.com/tsenart/vegeta/releases/download/v$${VEGETA_VER}/vegeta_$${VEGETA_VER}_linux_$${DPKG_ARCH}.tar.gz" | tar -xz -C /usr/local/bin vegeta'
echo "Phase 3b complete"
# --- Phase 4: Python security tools ---
- |
. /usr/local/lib/cloud-init-helpers.sh
log_phase "phase4" "installing Python security tools"
retry_cmd 3 10 pip install --retries 3 --break-system-packages scapy impacket arjun hashid pwntools
retry_cmd 3 10 pip install --retries 3 --break-system-packages --ignore-installed typing_extensions mitmproxy sslyze
retry_cmd 3 10 pip install --retries 3 --break-system-packages wfuzz
# --- Phase 5: Browser automation ---
- |
. /usr/local/lib/cloud-init-helpers.sh
log_phase "phase5" "installing browser automation tools"
retry_cmd 3 15 npm install -g playwright puppeteer puppeteer-extra puppeteer-extra-plugin-stealth
- |
. /usr/local/lib/cloud-init-helpers.sh
retry_cmd 3 30 npx --yes playwright install chromium
retry_cmd 3 30 sh -c 'su - azureuser -c "export NODE_PATH=/usr/lib/node_modules && npx --yes playwright install chromium"'
npx --yes playwright install-deps chromium 2>/dev/null || true
# --- Phase 6: Git-cloned tools ---
- |
. /usr/local/lib/cloud-init-helpers.sh
log_phase "phase6" "cloning tool repositories"
clone_repo "https://github.com/drwetter/testssl.sh.git" /opt/testssl.sh
ln -sf /opt/testssl.sh/testssl.sh /usr/local/bin/testssl
clone_repo "https://github.com/danielmiessler/SecLists.git" /opt/SecLists
clone_repo "https://github.com/lanmaster53/recon-ng.git" /opt/recon-ng
clone_repo "https://github.com/smicallef/spiderfoot.git" /opt/spiderfoot
clone_repo "https://github.com/nemesida-waf/waf-bypass.git" /opt/waf-bypass
clone_repo "https://github.com/swisskyrepo/PayloadsAllTheThings.git" /opt/PayloadsAllTheThings
# --- Phase 7: ZAP (standard tier) + heavy frameworks (full tier) ---
- |
. /usr/local/lib/cloud-init-helpers.sh
log_phase "phase7" "installing ZAP and optional tools"
export PATH=/usr/local/bin:$PATH
echo "Installing ZAP..."
ZAP_VER=$(ghlatest zaproxy/zaproxy)
retry_cmd 4 5 sh -c 'curl -fsSL "https://github.com/zaproxy/zaproxy/releases/download/v$${ZAP_VER}/ZAP_$${ZAP_VER}_Linux.tar.gz" | tar -xz -C /opt'
mv /opt/ZAP_$${ZAP_VER} /opt/zaproxy 2>/dev/null || true
printf '#!/bin/sh\nexec /opt/zaproxy/zap.sh "$@"\n' > /usr/local/bin/zap && chmod +x /usr/local/bin/zap
if [ "${tool_tier}" = "full" ]; then
echo "Installing Metasploit (full tier)..."
curl -fsSL https://raw.githubusercontent.com/rapid7/metasploit-omnibus/master/config/templates/metasploit-framework-wrappers/msfupdate.erb > /tmp/msfinstall
chmod +x /tmp/msfinstall && /tmp/msfinstall
fi
# --- Phase 8: Clone traffic-generator suites ---
- mkdir -p /opt/traffic-generator/suites /opt/traffic-generator/results
- |
. /usr/local/lib/cloud-init-helpers.sh
log_phase "phase8" "cloning traffic generator suites"
clone_repo "https://github.com/f5-sales-demo/traffic-generator.git" /opt/traffic-generator/repo 1 || log_phase "warning" "traffic-generator repo clone failed — suites will be from cloud-init"
- |
if [ -d /opt/traffic-generator/repo/suites ]; then
cp -r /opt/traffic-generator/repo/suites/* /opt/traffic-generator/suites/
chmod -R +x /opt/traffic-generator/suites/
fi
- chown -R azureuser:azureuser /opt/traffic-generator
# --- Phase 9: Smoke test and status update ---
- |
. /usr/local/lib/cloud-init-helpers.sh
log_phase "phase9" "running smoke test"
export NODE_PATH=/usr/lib/node_modules
export PATH=/usr/local/bin:$PATH
PASS=0; FAIL=0
for tool in nmap nikto sqlmap nuclei dalfox ffuf gobuster feroxbuster subfinder httpx sslscan hydra john hashcat masscan hping3 tshark wrk hey vegeta ab zap testssl arjun wfuzz gotestwaf node npx playwright; do
if command -v "$tool" >/dev/null 2>&1; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); echo "MISSING: $tool"; fi
done
if [ "$FAIL" -eq 0 ]; then STATUS="ready"; else STATUS="degraded"; fi
log_phase "phase9" "smoke test complete: $PASS passed, $FAIL failed"
printf '{"status":"%s","tool_tier":"${tool_tier}","tools_pass":%d,"tools_fail":%d,"timestamp":"%s"}\n' \
"$STATUS" "$PASS" "$FAIL" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > /opt/traffic-generator/status.json
# --- Completion ---
- |
. /usr/local/lib/cloud-init-helpers.sh
log_phase "complete" "traffic-generator provisioned"

outputs.tf exposes 17 outputs including public/private IP, SSH command, resource identifiers, and component-specific values (target_fqdn, status_check):

# ---------------------------------------------------------
# Standard Outputs (present in every demo resource)
# ---------------------------------------------------------
output "deployer" {
description = "Resolved deployer identifier"
value = local.deployer
}
output "resource_group_name" {
description = "Name of the resource group"
value = azurerm_resource_group.main.name
}
output "resource_group_id" {
description = "Resource ID of the resource group"
value = azurerm_resource_group.main.id
}
output "location" {
description = "Azure region"
value = azurerm_resource_group.main.location
}
output "public_ip" {
description = "Public IP address of the VM"
value = azurerm_public_ip.main.ip_address
}
output "private_ip" {
description = "Private IP address of the VM"
value = azurerm_network_interface.main.private_ip_address
}
output "ssh_command" {
description = "SSH command to connect to the VM"
value = "ssh ${var.admin_username}@${azurerm_public_ip.main.ip_address}"
}
output "vm_name" {
description = "Name of the virtual machine"
value = azurerm_linux_virtual_machine.main.name
}
output "vm_id" {
description = "Resource ID of the virtual machine"
value = azurerm_linux_virtual_machine.main.id
}
output "nsg_name" {
description = "Name of the network security group"
value = azurerm_network_security_group.main.name
}
output "nsg_id" {
description = "Resource ID of the network security group"
value = azurerm_network_security_group.main.id
}
output "vnet_name" {
description = "Name of the virtual network"
value = azurerm_virtual_network.main.name
}
output "subnet_id" {
description = "Resource ID of the subnet"
value = azurerm_subnet.main.id
}
output "component" {
description = "Component name"
value = local.component
}
output "environment" {
description = "Environment label"
value = var.environment
}
# ---------------------------------------------------------
# Component-Specific Outputs
# ---------------------------------------------------------
output "target_fqdn" {
description = "Target FQDN the traffic generator is configured to attack"
value = var.target_fqdn
}
output "status_check" {
description = "SSH command to check provisioning status"
value = "ssh ${var.admin_username}@${azurerm_public_ip.main.ip_address} cat /opt/traffic-generator/status.json"
}

Copy terraform.tfvars.example to terraform.tfvars and fill in your values. The .gitignore excludes terraform.tfvars to prevent committing credentials:

# Copy this file to terraform.tfvars and fill in your values.
# terraform.tfvars is gitignored — never commit real credentials.
# --- Required ---
subscription_id = "00000000-0000-0000-0000-000000000000"
target_fqdn = "demo.example.com"
# --- Optional overrides (defaults shown) ---
# deployer = "" # auto-resolved from Azure AD
# location = "eastus2"
# environment = "lab"
# vm_size = "Standard_F16s_v2"
# disk_size_gb = 64
# admin_username = "azureuser"
# ssh_public_key_path = "~/.ssh/id_ed25519.pub"
# tags = {}
# target_origin_ip = ""
# tool_tier = "standard"

Replace subscription_id with your Azure subscription ID (from az account show --query id -o tsv) and target_fqdn with your F5 XC load balancer domain.

Terminal window
terraform init
terraform plan
terraform apply

Terraform outputs the public IP, SSH command, and target FQDN after successful deployment.

The F-series compute-optimized VMs are recommended for this CPU-bound traffic generation workload. The cloud-init kernel tuning and tool concurrency scale automatically with vCPU count.

VM SKUvCPURAMNetworkUse Case
Standard_F4s_v248 GiB10 GbpsLight lab/demo
Standard_F8s_v2816 GiB12.5 GbpsStandard demos
Standard_F16s_v21632 GiB12.5 GbpsLoad testing (default)
Standard_F32s_v23264 GiB16 GbpsHeavy benchmarking
Standard_D16s_v31664 GiB12.5 GbpsRAM-heavy workloads

Cloud-init provisioning takes 15-20 minutes for standard tier and approximately 25 minutes for full tier. The VM is accessible via SSH immediately, but tools are still being installed until cloud-init completes.

SSH into the VM and wait for provisioning to finish:

Terminal window
ssh azureuser@$(terraform output -raw public_ip)
cloud-init status --wait

Expected output when complete:

status: done

The cloud-init script writes a status file upon completion:

Terminal window
cat /opt/traffic-generator/status.json

Expected output:

{
"status": "ready",
"tier": "standard",
"target_fqdn": "demo.example.com",
"completed": "2026-04-25T14:30:00Z",
"tools_installed": true
}

Run a verification loop to confirm each critical tool is available on PATH:

Terminal window
for tool in nikto sqlmap nuclei dalfox ffuf gobuster feroxbuster nmap masscan \
hydra medusa ncrack sslscan tshark hping3 mitmproxy sslyze subfinder httpx \
amass arjun mtr whatweb dirb socat netcat ngrep iperf3 whois dig wrk hey; do
if command -v "$tool" > /dev/null 2>&1; then
echo "OK: $tool"
else
echo "MISSING: $tool"
fi
done

Confirm the runner and suites are installed:

Terminal window
ls /opt/traffic-generator/suites/runner.sh
ls /opt/traffic-generator/suites/*/
/opt/traffic-generator/suites/runner.sh web-app-attacks --dry-run
OutputDescriptionExample
deployerResolved deployer identifierjsmith
public_ipPublic IP address of the traffic generator VM20.12.78.200
private_ipPrivate IP address of the VM10.201.0.4
ssh_commandSSH command to connect to the VMssh azureuser@20.12.78.200
resource_group_nameResource group namerg-traffic-generator-dev-jsmith
resource_group_idResource group Azure resource ID/subscriptions/.../resourceGroups/rg-traffic-generator-dev-jsmith
vm_nameVirtual machine namevm-traffic-generator-dev-jsmith
vm_idVirtual machine Azure resource ID/subscriptions/.../virtualMachines/vm-traffic-generator-dev-jsmith
nsg_nameNetwork security group namensg-traffic-generator-dev-jsmith
nsg_idNetwork security group Azure resource ID/subscriptions/.../networkSecurityGroups/nsg-traffic-generator-dev-jsmith
vnet_nameVirtual network namevnet-traffic-generator-dev-jsmith
subnet_idSubnet Azure resource ID/subscriptions/.../subnets/snet-traffic-generator-dev-jsmith
componentComponent identifiertraffic-generator
environmentDeployment environmentdev
locationAzure regioneastus2
target_fqdnConfigured target FQDN for traffic suitesdemo.example.com
status_checkCommand to check provisioning statusssh azureuser@20.12.78.200 cat /opt/traffic-generator/status.json

Retrieve any output:

Terminal window
terraform output -raw public_ip
terraform output -raw ssh_command

The traffic generator targets an F5 XC HTTP load balancer that fronts the origin server. Deploy the origin server first, configure F5 XC, then set target_fqdn to the load balancer’s FQDN:

Terminal window
# After deploying origin-server and creating an F5 XC HTTP load balancer:
cat > terraform.tfvars <<EOF
subscription_id = "SUBSCRIPTION_ID"
target_fqdn = "your-xc-load-balancer.example.com"
target_origin_ip = "$(cd ../../origin-server/terraform && terraform output -raw public_ip)"
EOF
Required VariableSourceDescription
target_fqdnF5 XC consoleFQDN of the HTTP load balancer protecting the origin server
target_origin_ipOrigin server public_ip outputDirect origin IP for bypass testing (optional)

Proceed to Tool Catalog for the full tool reference or Suites to start generating traffic.