From 36845506ff3b6648a3d130d95c3e1f8866e47760 Mon Sep 17 00:00:00 2001 From: dmauser Date: Wed, 12 Aug 2026 20:23:42 -0500 Subject: [PATCH 1/6] Fix public IP allocation for retired Basic SKU in az CLI scripts az network public-ip create now defaults to --sku Standard, which rejects Dynamic allocation, so these scripts fail at the public IP step. Switch to Static/Standard explicitly. Standard SKU public IPs are also closed to inbound traffic until an NSG allows it, so deploylinuxnva and deploylinuxnvabgp now create the same default-nsg (SSH from the caller's public IP + VirtualNetwork breakout) that deploylinuxnvabgpnp already used, otherwise the NVA would be unreachable after deployment. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: cd9ed807-66b2-43e3-8bf9-ed7eb24a46dd --- deploylinuxnva.azcli | 9 ++++++++- deploylinuxnvabgp.azcli | 9 ++++++++- notes.txt | 2 +- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/deploylinuxnva.azcli b/deploylinuxnva.azcli index 510abd1..1ee48ad 100644 --- a/deploylinuxnva.azcli +++ b/deploylinuxnva.azcli @@ -6,6 +6,7 @@ password=Msft123Msft123 vnetname=vnet1 subnetname=nvasubnet nvaname=linux-nva +mypip=$(curl -s -4 ifconfig.io) #your public IP, used to allow inbound SSH #Resource Group az group create --name $rg --location $location @@ -14,8 +15,14 @@ az group create --name $rg --location $location az network vnet create --resource-group $rg --name $vnetname --location $location --address-prefixes 10.1.0.0/16 --subnet-name $subnetname --subnet-prefix 10.1.10.0/24 -o none az network vnet subnet create --address-prefix 10.1.0.0/24 --name nvasubnet --resource-group $rg --vnet-name $vnetname -o none +# NSG: Standard SKU public IPs are closed to inbound traffic until an NSG allows it. +az network nsg create --resource-group $rg --name default-nsg-$location --location $location -o none +az network nsg rule create -g $rg --nsg-name default-nsg-$location -n 'default-allow-ssh' --direction Inbound --priority 100 --source-address-prefixes $mypip --source-port-ranges '*' --destination-address-prefixes '*' --destination-port-ranges 22 --access Allow --protocol Tcp --description "Allow inbound SSH" --output none +az network nsg rule create -g $rg --nsg-name default-nsg-$location -n 'allow-nva-breakout' --direction Inbound --priority 200 --source-address-prefixes VirtualNetwork --source-port-ranges '*' --destination-address-prefixes '*' --destination-port-ranges "*" --access Allow --protocol "*" --description "Allow NVA single NIC use Internet Breakout" --output none +az network vnet subnet update -g $rg --name $subnetname --vnet-name $vnetname --network-security-group default-nsg-$location -o none + # NVA + Config script to enable NAT -az network public-ip create --name $nvaname-pip --resource-group $rg --location $location --allocation-method Dynamic +az network public-ip create --name $nvaname-pip --resource-group $rg --location $location --allocation-method Static --sku Standard az network nic create --name $nvaname-nic --resource-group $rg --subnet $subnetname --vnet $vnetname --public-ip-address $nvaname-pip --ip-forwarding true -o none az vm create --resource-group $rg --location $location --name $nvaname --size Standard_B1s --nics $nvaname-nic --image UbuntuLTS --admin-username $username --admin-password $password -o none # Enable routing and NAT on Linux NVA: diff --git a/deploylinuxnvabgp.azcli b/deploylinuxnvabgp.azcli index 4d944b3..d0e10e0 100644 --- a/deploylinuxnvabgp.azcli +++ b/deploylinuxnvabgp.azcli @@ -6,6 +6,7 @@ password=Msft123Msft123 vnetname=vnet1 subnetname=nvasubnet nvaname=linux-nva +mypip=$(curl -s -4 ifconfig.io) #your public IP, used to allow inbound SSH #Resource Group az group create --name $rg --location $location @@ -13,8 +14,14 @@ az group create --name $rg --location $location ## Create VNET and NVA az network vnet create --resource-group $rg --name $vnetname --location $location --address-prefixes 10.1.0.0/16 --subnet-name $subnetname --subnet-prefix 10.1.10.0/24 -o none +# NSG: Standard SKU public IPs are closed to inbound traffic until an NSG allows it. +az network nsg create --resource-group $rg --name default-nsg-$location --location $location -o none +az network nsg rule create -g $rg --nsg-name default-nsg-$location -n 'default-allow-ssh' --direction Inbound --priority 100 --source-address-prefixes $mypip --source-port-ranges '*' --destination-address-prefixes '*' --destination-port-ranges 22 --access Allow --protocol Tcp --description "Allow inbound SSH" --output none +az network nsg rule create -g $rg --nsg-name default-nsg-$location -n 'allow-nva-breakout' --direction Inbound --priority 200 --source-address-prefixes VirtualNetwork --source-port-ranges '*' --destination-address-prefixes '*' --destination-port-ranges "*" --access Allow --protocol "*" --description "Allow NVA single NIC use Internet Breakout" --output none +az network vnet subnet update -g $rg --name $subnetname --vnet-name $vnetname --network-security-group default-nsg-$location -o none + # Enable routing, NAT and BGP on Linux NVA: -az network public-ip create --name $nvaname-pip --resource-group $rg --location $location --allocation-method Dynamic --output none +az network public-ip create --name $nvaname-pip --resource-group $rg --location $location --allocation-method Static --sku Standard --output none az network nic create --name $nvaname-nic --resource-group $rg --subnet $subnetname --vnet $vnetname --public-ip-address $nvaname-pip --ip-forwarding true -o none az vm create --resource-group $rg --location $location --name $nvaname --size Standard_B1s --nics $nvaname-nic --image UbuntuLTS --admin-username $username --admin-password $password -o none diff --git a/notes.txt b/notes.txt index e035db8..cf6a629 100644 --- a/notes.txt +++ b/notes.txt @@ -7,6 +7,6 @@ az vm create \ --custom-data cloud-init.txt -az network public-ip create --name $spoke1name-vm-pip --resource-group $rg --location $vhub1location --allocation-method Dynamic --output none +az network public-ip create --name $spoke1name-vm-pip --resource-group $rg --location $vhub1location --allocation-method Static --sku Standard --output none az network nic create --resource-group $rg -n $spoke1name-vm-nic --location $vhub1location --subnet vmsubnet --vnet-name $spoke1name-vnet --public-ip-address $spoke1name-vm-pip --output none az vm create -n $spoke1name-vm -g $rg --image UbuntuLTS --size Standard_B1s --admin-username $username --admin-password $password --nics $spoke1name-vm-nic --no-wait --location $vhub1location --output none From beecb07521ef6b3bcf796c72a79615378c700113 Mon Sep 17 00:00:00 2001 From: dmauser Date: Wed, 12 Aug 2026 20:27:21 -0500 Subject: [PATCH 2/6] Fix remaining removed image alias and duplicate subnet in az CLI scripts - deploylinuxnvabgpnp.azcli: the 'ubuntults' alias was removed from the Azure CLI, so both az vm create calls failed. Use Ubuntu2404 and match the new image offer in the nettools VM filter. - deploylinuxnva.azcli: drop the second subnet create, which tried to recreate the subnet already created by az network vnet create under a different prefix and failed with 'already exists'. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: cd9ed807-66b2-43e3-8bf9-ed7eb24a46dd --- deploylinuxnva.azcli | 1 - deploylinuxnvabgpnp.azcli | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/deploylinuxnva.azcli b/deploylinuxnva.azcli index 1ee48ad..001e282 100644 --- a/deploylinuxnva.azcli +++ b/deploylinuxnva.azcli @@ -13,7 +13,6 @@ az group create --name $rg --location $location ## Create VNET and NVA az network vnet create --resource-group $rg --name $vnetname --location $location --address-prefixes 10.1.0.0/16 --subnet-name $subnetname --subnet-prefix 10.1.10.0/24 -o none -az network vnet subnet create --address-prefix 10.1.0.0/24 --name nvasubnet --resource-group $rg --vnet-name $vnetname -o none # NSG: Standard SKU public IPs are closed to inbound traffic until an NSG allows it. az network nsg create --resource-group $rg --name default-nsg-$location --location $location -o none diff --git a/deploylinuxnvabgpnp.azcli b/deploylinuxnvabgpnp.azcli index 412f97d..a476103 100644 --- a/deploylinuxnvabgpnp.azcli +++ b/deploylinuxnvabgpnp.azcli @@ -21,7 +21,7 @@ az network vnet create --resource-group $rg --name $vnetname --location $locatio az network vnet subnet create --address-prefix "10.1.10.0/24" --name $nvasubnetname --resource-group $rg --vnet-name $vnetname --output none # Create Subnet1 VM: -az vm create -n $vnetname-vm1 -g $rg --image ubuntults --public-ip-sku Standard --size $vmsize -l $location --subnet subnet1 --vnet-name $vnetname --admin-username $username --admin-password $password --nsg "" --no-wait +az vm create -n $vnetname-vm1 -g $rg --image Ubuntu2404 --public-ip-sku Standard --size $vmsize -l $location --subnet subnet1 --vnet-name $vnetname --admin-username $username --admin-password $password --nsg "" --no-wait # NSG: az network nsg create --resource-group $rg --name default-nsg-$location --location $location -o none @@ -68,7 +68,7 @@ nvanames=$(i=1;while [ $i -le $instances ];do echo $vnetname-$nvaname$i; ((i++)) for nvaname in $nvanames do # Enable routing, NAT and BGP on Linux NVA: - az vm create -n $nvaname -g $rg --image ubuntults --public-ip-sku Standard --size $vmsize -l $location --subnet $nvasubnetname --vnet-name $vnetname --admin-username $username --admin-password $password --nsg "" --output none + az vm create -n $nvaname -g $rg --image Ubuntu2404 --public-ip-sku Standard --size $vmsize -l $location --subnet $nvasubnetname --vnet-name $vnetname --admin-username $username --admin-password $password --nsg "" --output none #NVA BGP config variables (do not change) bgp_routerId=$(az network nic show --name "$nvaname"VMNic --resource-group $rg --query ipConfigurations[0].privateIpAddress -o tsv) @@ -111,7 +111,7 @@ az vm boot-diagnostics enable --storage $stguri --ids $(az vm list -g $rg --quer echo Installing tools for networking connectivity validation such as traceroute, tcptraceroute, iperf and others nettoolsuri="https://raw.githubusercontent.com/dmauser/azure-vm-net-tools/main/script/nettools.sh" -for vm in `az vm list -g $rg --query "[?storageProfile.imageReference.offer=='UbuntuServer'].name" -o tsv` +for vm in `az vm list -g $rg --query "[?contains(storageProfile.imageReference.offer, 'ubuntu') || contains(storageProfile.imageReference.offer, 'Ubuntu')].name" -o tsv` do az vm extension set \ --resource-group $rg \ From 1c80185cb40cfd8d49efd805e14eacf4f7b58573 Mon Sep 17 00:00:00 2001 From: dmauser Date: Wed, 12 Aug 2026 20:57:44 -0500 Subject: [PATCH 3/6] Wait for cloud-init before apt operations in VM bootstrap scripts The Custom Script Extension can start while cloud-init is still rewriting /etc/apt/sources.list. apt is then left holding package indexes for the superseded mirror, so installs fail with 'Unable to locate package netfilter-persistent' and the script dies writing /etc/iptables/rules.v4. Reproduced on a 22.04 deployment in Azure: the extension failed, and the identical commands succeeded on that same VM once cloud-init reported done. Redeploying with this wait in place succeeded. linuxrouter.sh here also carries the apt changes from PR #5 so this branch matches the configuration that was validated end to end. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: cd9ed807-66b2-43e3-8bf9-ed7eb24a46dd --- linuxrouter.sh | 22 +++++++++++++++++----- linuxrouterbgp.sh | 4 ++++ linuxrouterbgpnh.sh | 4 ++++ linuxrouterv2.sh | 4 ++++ scripts/linuxrouterbgpfrr.sh | 4 ++++ scripts/linuxrouterbgpfrr2.sh | 4 ++++ scripts/linuxrouterbgpfrr2nh.sh | 4 ++++ 7 files changed, 41 insertions(+), 5 deletions(-) diff --git a/linuxrouter.sh b/linuxrouter.sh index 7b92317..0881819 100644 --- a/linuxrouter.sh +++ b/linuxrouter.sh @@ -9,12 +9,24 @@ sed -i "/net.ipv6.conf.all.forwarding=1/ s/# *//" /etc/sysctl.conf sed -i "/net.ipv4.conf.all.accept_redirects = 0/ s/# *//" /etc/sysctl.conf sed -i "/net.ipv6.conf.all.accept_redirects = 0/ s/# *//" /etc/sysctl.conf +# On Ubuntu 20.04 and later, iptables uses the nftables backend (iptables-nft), +# so the same commands work unchanged on 18.04, 20.04, 22.04 and 24.04. +# The script runs as root (Custom Script Extension), so no sudo is needed. +export DEBIAN_FRONTEND=noninteractive + +# The Custom Script Extension can start before cloud-init has finished setting +# up the apt sources. Installing then fails with "Unable to locate package" +# because the package indexes on disk belong to the superseded mirror. +echo "Waiting for cloud-init to complete" +cloud-init status --wait >/dev/null 2>&1 || true + echo "Updating repositories" -sudo apt-get update -y --fix-missing -echo "Installing IPTables-Persistent" -echo iptables-persistent iptables-persistent/autosave_v4 boolean false | sudo debconf-set-selections -echo iptables-persistent iptables-persistent/autosave_v6 boolean false | sudo debconf-set-selections -sudo apt-get -y install iptables-persistent +apt-get update -y --fix-missing + +echo "Installing Netfilter-Persistent & IPTables-Persistent" +echo iptables-persistent iptables-persistent/autosave_v4 boolean false | debconf-set-selections +echo iptables-persistent iptables-persistent/autosave_v6 boolean false | debconf-set-selections +apt-get -y install netfilter-persistent iptables-persistent # Enable NAT to Internet iptables -t nat -A POSTROUTING -d 10.0.0.0/8 -j ACCEPT diff --git a/linuxrouterbgp.sh b/linuxrouterbgp.sh index 0465692..596f61f 100644 --- a/linuxrouterbgp.sh +++ b/linuxrouterbgp.sh @@ -18,6 +18,10 @@ bgp_network1=$3 routeserver_IP1=$4 routeserver_IP2=$5 +# Wait for cloud-init to finish so apt does not run against superseded +# package indexes, which fails with "Unable to locate package". +cloud-init status --wait >/dev/null 2>&1 || true + sudo apt-get -y update ## Install the Quagga routing daemon diff --git a/linuxrouterbgpnh.sh b/linuxrouterbgpnh.sh index efaf6cf..f7eb06f 100644 --- a/linuxrouterbgpnh.sh +++ b/linuxrouterbgpnh.sh @@ -13,6 +13,10 @@ routeserver_IP1=$4 routeserver_IP2=$5 nexthopip=$6 +# Wait for cloud-init to finish so apt does not run against superseded +# package indexes, which fails with "Unable to locate package". +cloud-init status --wait >/dev/null 2>&1 || true + sudo apt-get -y update ## Install the Quagga routing daemon diff --git a/linuxrouterv2.sh b/linuxrouterv2.sh index a39245a..f92ffc5 100644 --- a/linuxrouterv2.sh +++ b/linuxrouterv2.sh @@ -28,6 +28,10 @@ sysctl --system >/dev/null # --- Packages (non-interactive) --- export DEBIAN_FRONTEND=noninteractive +# Wait for cloud-init to finish so apt does not run against superseded +# package indexes, which fails with "Unable to locate package". +cloud-init status --wait >/dev/null 2>&1 || true + apt-get update -y --fix-missing # Preseed iptables-persistent to disable autosave prompts echo "iptables-persistent iptables-persistent/autosave_v4 boolean false" | debconf-set-selections diff --git a/scripts/linuxrouterbgpfrr.sh b/scripts/linuxrouterbgpfrr.sh index ccaed29..fd0352b 100644 --- a/scripts/linuxrouterbgpfrr.sh +++ b/scripts/linuxrouterbgpfrr.sh @@ -18,6 +18,10 @@ curl -s https://deb.frrouting.org/frr/keys.asc | sudo apt-key add - FRRVER="frr-stable" echo deb https://deb.frrouting.org/frr $(lsb_release -s -c) $FRRVER | sudo tee -a /etc/apt/sources.list.d/frr.list +# Wait for cloud-init to finish so apt does not run against superseded +# package indexes, which fails with "Unable to locate package". +cloud-init status --wait >/dev/null 2>&1 || true + apt-get -y update apt-get -y install frr frr-pythontools diff --git a/scripts/linuxrouterbgpfrr2.sh b/scripts/linuxrouterbgpfrr2.sh index d2d59c1..3aa1fa9 100644 --- a/scripts/linuxrouterbgpfrr2.sh +++ b/scripts/linuxrouterbgpfrr2.sh @@ -19,6 +19,10 @@ curl -s https://deb.frrouting.org/frr/keys.asc | sudo apt-key add - FRRVER="frr-stable" echo deb https://deb.frrouting.org/frr $(lsb_release -s -c) $FRRVER | sudo tee -a /etc/apt/sources.list.d/frr.list +# Wait for cloud-init to finish so apt does not run against superseded +# package indexes, which fails with "Unable to locate package". +cloud-init status --wait >/dev/null 2>&1 || true + apt-get -y update apt-get -y install frr frr-pythontools diff --git a/scripts/linuxrouterbgpfrr2nh.sh b/scripts/linuxrouterbgpfrr2nh.sh index 8d6a4c1..5331cd5 100644 --- a/scripts/linuxrouterbgpfrr2nh.sh +++ b/scripts/linuxrouterbgpfrr2nh.sh @@ -20,6 +20,10 @@ curl -s https://deb.frrouting.org/frr/keys.asc | sudo apt-key add - FRRVER="frr-stable" echo deb https://deb.frrouting.org/frr $(lsb_release -s -c) $FRRVER | sudo tee -a /etc/apt/sources.list.d/frr.list +# Wait for cloud-init to finish so apt does not run against superseded +# package indexes, which fails with "Unable to locate package". +cloud-init status --wait >/dev/null 2>&1 || true + apt-get -y update apt-get -y install frr frr-pythontools From e39fedfc9ecfebf02f65e177f22de0a90d6543ce Mon Sep 17 00:00:00 2001 From: dmauser Date: Wed, 12 Aug 2026 21:08:38 -0500 Subject: [PATCH 4/6] Add optional allowSshFromAddressPrefix parameter to Linux router templates Standard SKU public IPs deny inbound traffic by default, so a one-click deployment produced a VM that could not be reached over SSH. Both templates now accept an optional source prefix for TCP 22. LinuxRouter.bicep creates a NIC-level NSG when the parameter is set, leaving the pre-existing subnet's own NSG untouched. It carries the same RFC 1918 allow rule as the new-subnet template so forwarded traffic keeps flowing. LinuxRouter-newsubnet.bicep appends the SSH rule to the NSG it already creates. The parameter defaults to empty, which preserves the previous behaviour. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: cd9ed807-66b2-43e3-8bf9-ed7eb24a46dd --- LinuxRouter-newsubnet.bicep | 23 +++++++++++++-- LinuxRouter-newsubnet.json | 32 ++++++++------------- LinuxRouter.bicep | 47 +++++++++++++++++++++++++++++++ LinuxRouter.json | 56 +++++++++++++++++++++++++++++++++++-- README.md | 2 +- 5 files changed, 134 insertions(+), 26 deletions(-) diff --git a/LinuxRouter-newsubnet.bicep b/LinuxRouter-newsubnet.bicep index 886a7ca..762c21b 100644 --- a/LinuxRouter-newsubnet.bicep +++ b/LinuxRouter-newsubnet.bicep @@ -44,10 +44,29 @@ param location string = resourceGroup().location @description('Deploy Public IP Address') param deployPublicIpAddress bool = true +@description('Source address prefix allowed to reach the VM on TCP 22, for example 203.0.113.4/32. Standard SKU public IPs deny inbound traffic by default, so leave this empty only if you do not need SSH from the internet. Use Internet to allow any source (not recommended).') +param allowSshFromAddressPrefix string = '' + var extensionName = 'CustomScript' var nicName = '${virtualMachineName}-NIC' var publicIPAddressName = '${virtualMachineName}-PublicIP' +var sshSecurityRules = empty(allowSshFromAddressPrefix) ? [] : [ + { + name: 'Allow-SSH-Inbound' + properties: { + priority: 200 + protocol: 'Tcp' + access: 'Allow' + direction: 'Inbound' + sourceAddressPrefix: allowSshFromAddressPrefix + sourcePortRange: '*' + destinationAddressPrefix: '*' + destinationPortRange: '22' + } + } +] + var osVersionDefinitions = { '22.04': { publisher: 'Canonical' @@ -67,7 +86,7 @@ resource default_nsg 'Microsoft.Network/networkSecurityGroups@2024-05-01' = { name: 'default-nsg' location: location properties: { - securityRules: [ + securityRules: concat(sshSecurityRules, [ { name: 'Allow-Traffic-RFC-1918' properties: { @@ -85,7 +104,7 @@ resource default_nsg 'Microsoft.Network/networkSecurityGroups@2024-05-01' = { destinationPortRange: '*' } } - ] + ]) } } diff --git a/LinuxRouter-newsubnet.json b/LinuxRouter-newsubnet.json index 6b7cd47..44f8606 100644 --- a/LinuxRouter-newsubnet.json +++ b/LinuxRouter-newsubnet.json @@ -4,8 +4,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.44.1.10279", - "templateHash": "8094453408631886471" + "version": "0.46.1.21595", + "templateHash": "18251987747692485582" } }, "parameters": { @@ -103,12 +103,20 @@ "metadata": { "description": "Deploy Public IP Address" } + }, + "allowSshFromAddressPrefix": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Source address prefix allowed to reach the VM on TCP 22, for example 203.0.113.4/32. Standard SKU public IPs deny inbound traffic by default, so leave this empty only if you do not need SSH from the internet. Use Internet to allow any source (not recommended)." + } } }, "variables": { "extensionName": "CustomScript", "nicName": "[format('{0}-NIC', parameters('virtualMachineName'))]", "publicIPAddressName": "[format('{0}-PublicIP', parameters('virtualMachineName'))]", + "sshSecurityRules": "[if(empty(parameters('allowSshFromAddressPrefix')), createArray(), createArray(createObject('name', 'Allow-SSH-Inbound', 'properties', createObject('priority', 200, 'protocol', 'Tcp', 'access', 'Allow', 'direction', 'Inbound', 'sourceAddressPrefix', parameters('allowSshFromAddressPrefix'), 'sourcePortRange', '*', 'destinationAddressPrefix', '*', 'destinationPortRange', '22'))))]", "osVersionDefinitions": { "22.04": { "publisher": "Canonical", @@ -131,25 +139,7 @@ "name": "default-nsg", "location": "[parameters('location')]", "properties": { - "securityRules": [ - { - "name": "Allow-Traffic-RFC-1918", - "properties": { - "priority": 300, - "protocol": "*", - "access": "Allow", - "direction": "Inbound", - "sourceAddressPrefixes": [ - "10.0.0.0/8", - "172.16.0.0/12", - "192.168.0.0/16" - ], - "sourcePortRange": "*", - "destinationAddressPrefix": "*", - "destinationPortRange": "*" - } - } - ] + "securityRules": "[concat(variables('sshSecurityRules'), createArray(createObject('name', 'Allow-Traffic-RFC-1918', 'properties', createObject('priority', 300, 'protocol', '*', 'access', 'Allow', 'direction', 'Inbound', 'sourceAddressPrefixes', createArray('10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'), 'sourcePortRange', '*', 'destinationAddressPrefix', '*', 'destinationPortRange', '*'))))]" } }, { diff --git a/LinuxRouter.bicep b/LinuxRouter.bicep index ca04448..3e5be5e 100644 --- a/LinuxRouter.bicep +++ b/LinuxRouter.bicep @@ -41,10 +41,15 @@ param location string = resourceGroup().location @description('Deploy Public IP Address') param deployPublicIpAddress bool = true +@description('Source address prefix allowed to reach the VM on TCP 22, for example 203.0.113.4/32. Standard SKU public IPs deny inbound traffic by default, so leave this empty only if you do not need SSH from the internet. Use Internet to allow any source (not recommended).') +param allowSshFromAddressPrefix string = '' + var extensionName = 'CustomScript' var nicName = '${virtualMachineName}-NIC' +var nsgName = '${virtualMachineName}-NSG' var publicIPAddressName = '${virtualMachineName}-PublicIP' var subnetResourceId = resourceId('Microsoft.Network/virtualNetworks/subnets', existingVirtualNetworkName, existingSubnet) +var deployNetworkSecurityGroup = !empty(allowSshFromAddressPrefix) var osVersionDefinitions = { '22.04': { @@ -97,11 +102,53 @@ resource virtualMachine 'Microsoft.Compute/virtualMachines@2024-07-01' = { } } +resource networkSecurityGroup 'Microsoft.Network/networkSecurityGroups@2024-05-01' = if (deployNetworkSecurityGroup) { + name: nsgName + location: location + properties: { + securityRules: [ + { + name: 'Allow-SSH-Inbound' + properties: { + priority: 200 + protocol: 'Tcp' + access: 'Allow' + direction: 'Inbound' + sourceAddressPrefix: allowSshFromAddressPrefix + sourcePortRange: '*' + destinationAddressPrefix: '*' + destinationPortRange: '22' + } + } + { + name: 'Allow-Traffic-RFC-1918' + properties: { + priority: 300 + protocol: '*' + access: 'Allow' + direction: 'Inbound' + sourceAddressPrefixes: [ + '10.0.0.0/8' + '172.16.0.0/12' + '192.168.0.0/16' + ] + sourcePortRange: '*' + destinationAddressPrefix: '*' + destinationPortRange: '*' + } + } + ] + } +} + resource nic 'Microsoft.Network/networkInterfaces@2024-05-01' = { name: nicName location: location properties: { enableIPForwarding: true + networkSecurityGroup: deployNetworkSecurityGroup ? { + id: networkSecurityGroup.id + } : null ipConfigurations: [ { name: 'ipconfig1' diff --git a/LinuxRouter.json b/LinuxRouter.json index 12597fd..61d8846 100644 --- a/LinuxRouter.json +++ b/LinuxRouter.json @@ -4,8 +4,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.44.1.10279", - "templateHash": "17441905376390406829" + "version": "0.46.1.21595", + "templateHash": "12254805934774540914" } }, "parameters": { @@ -96,13 +96,22 @@ "metadata": { "description": "Deploy Public IP Address" } + }, + "allowSshFromAddressPrefix": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Source address prefix allowed to reach the VM on TCP 22, for example 203.0.113.4/32. Standard SKU public IPs deny inbound traffic by default, so leave this empty only if you do not need SSH from the internet. Use Internet to allow any source (not recommended)." + } } }, "variables": { "extensionName": "CustomScript", "nicName": "[format('{0}-NIC', parameters('virtualMachineName'))]", + "nsgName": "[format('{0}-NSG', parameters('virtualMachineName'))]", "publicIPAddressName": "[format('{0}-PublicIP', parameters('virtualMachineName'))]", "subnetResourceId": "[resourceId('Microsoft.Network/virtualNetworks/subnets', parameters('existingVirtualNetworkName'), parameters('existingSubnet'))]", + "deployNetworkSecurityGroup": "[not(empty(parameters('allowSshFromAddressPrefix')))]", "osVersionDefinitions": { "22.04": { "publisher": "Canonical", @@ -159,6 +168,47 @@ "[resourceId('Microsoft.Network/networkInterfaces', variables('nicName'))]" ] }, + { + "condition": "[variables('deployNetworkSecurityGroup')]", + "type": "Microsoft.Network/networkSecurityGroups", + "apiVersion": "2024-05-01", + "name": "[variables('nsgName')]", + "location": "[parameters('location')]", + "properties": { + "securityRules": [ + { + "name": "Allow-SSH-Inbound", + "properties": { + "priority": 200, + "protocol": "Tcp", + "access": "Allow", + "direction": "Inbound", + "sourceAddressPrefix": "[parameters('allowSshFromAddressPrefix')]", + "sourcePortRange": "*", + "destinationAddressPrefix": "*", + "destinationPortRange": "22" + } + }, + { + "name": "Allow-Traffic-RFC-1918", + "properties": { + "priority": 300, + "protocol": "*", + "access": "Allow", + "direction": "Inbound", + "sourceAddressPrefixes": [ + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16" + ], + "sourcePortRange": "*", + "destinationAddressPrefix": "*", + "destinationPortRange": "*" + } + } + ] + } + }, { "type": "Microsoft.Network/networkInterfaces", "apiVersion": "2024-05-01", @@ -166,6 +216,7 @@ "location": "[parameters('location')]", "properties": { "enableIPForwarding": true, + "networkSecurityGroup": "[if(variables('deployNetworkSecurityGroup'), createObject('id', resourceId('Microsoft.Network/networkSecurityGroups', variables('nsgName'))), null())]", "ipConfigurations": [ { "name": "ipconfig1", @@ -180,6 +231,7 @@ ] }, "dependsOn": [ + "[resourceId('Microsoft.Network/networkSecurityGroups', variables('nsgName'))]", "[resourceId('Microsoft.Network/publicIPAddresses', variables('publicIPAddressName'))]" ] }, diff --git a/README.md b/README.md index b15eaf3..6a14b58 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ This template deploys a Linux Router (Ubuntu 24.04 LTS by default, 22.04 LTS sel The templates deploy a **Standard SKU** public IP (the previously used Basic SKU was retired by Azure in September 2025). Standard public IPs are *secure by default*, which changes the out-of-the-box connectivity compared to older versions of these templates: -- **Inbound traffic from the Internet is blocked** unless a Network Security Group (NSG) explicitly allows it. This means you cannot SSH into the router from the Internet right after deployment. To manage the VM, either connect from inside your network (the `LinuxRouter-newsubnet.json` template deploys an NSG that allows inbound traffic from RFC 1918 private ranges), use Azure Bastion, or add an NSG rule that allows SSH from your trusted IP addresses. +- **Inbound traffic from the Internet is blocked** unless a Network Security Group (NSG) explicitly allows it. To enable SSH from the Internet, set the **`allowSshFromAddressPrefix`** parameter to your trusted source — for example `203.0.113.4/32` for a single address, or a CIDR range for an office network. Both templates then create an NSG with a rule allowing TCP 22 from that prefix. Leaving the parameter empty (the default) keeps the previous behaviour: `LinuxRouter.json` creates no NSG, and `LinuxRouter-newsubnet.json` creates one that only allows inbound traffic from RFC 1918 private ranges. In that case, manage the VM from inside your network or via Azure Bastion. Setting the value to `Internet` or `*` allows SSH from anywhere and is not recommended. - **Outbound traffic to the Internet is allowed.** NSGs permit outbound traffic by default, and the attached public IP provides an *explicit* outbound method (SNAT), so the setup script can install packages during provisioning. This also keeps the templates working after Azure's retirement of *default outbound access* for new deployments. - **If you disable the public IP** (`deployPublicIpAddress=false`), make sure the subnet has another explicit outbound method — e.g. a NAT Gateway, an Azure Firewall / NVA route, or Load Balancer outbound rules. On virtual networks without default outbound access (the default for newly created VNets), the VM otherwise has no Internet access and the setup script cannot install its packages. From 4d658ff0909003f666291e7f1b907bb4f528d170 Mon Sep 17 00:00:00 2001 From: dmauser Date: Wed, 12 Aug 2026 21:28:37 -0500 Subject: [PATCH 5/6] Reorganize repository, modernize Windows router and rewrite README Restructure the repository into infra/ (bicep sources + generated ARM), scripts/ (linux + windows CSE payloads), labs/ (azcli lab builds) and docs/, with consistent kebab-case file naming. Template scriptUri defaults now use relative paths (../../scripts/...) so they continue to resolve against the branch or fork a template is deployed from. Add WinRouter as a Bicep source, moving it off the retired Basic SKU public IP and onto Windows Server 2025 Core (small disk, Gen 2) with Trusted Launch, an allowRdpFromAddressPrefix parameter and current API versions. Add a GitHub Actions workflow that lints the Bicep, rebuilds the ARM JSON and fails on drift, and checks shell scripts for syntax errors and CRLF endings. Add .gitattributes pinning shell scripts to LF, since CRLF breaks the shebang when the Custom Script Extension runs them. Rewrite README.md with a table of contents, template comparison and parameter tables, network security guidance, full coverage of every script in the repo, a summary of recent improvements and an expanded roadmap. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: cd9ed807-66b2-43e3-8bf9-ed7eb24a46dd --- .gitattributes | 18 + .github/workflows/validate-templates.yml | 59 ++++ README.md | 309 +++++++++++++++++- WinRouter.json | 177 ---------- notes.txt => docs/notes.md | 0 .../arm/linux-router-newsubnet.json | 4 +- .../arm/linux-router.json | 4 +- infra/arm/windows-router.json | 287 ++++++++++++++++ .../bicep/linux-router-newsubnet.bicep | 2 +- .../bicep/linux-router.bicep | 2 +- infra/bicep/windows-router.bicep | 209 ++++++++++++ {conf => labs/conf}/1000-bgproutes.txt | 0 {conf => labs/conf}/1024-bgproutes.txt | 0 {conf => labs/conf}/10240-bgproutes.txt | 0 {conf => labs/conf}/4000-bgproutes.txt | 0 {conf => labs/conf}/4001-bgproutes.txt | 0 {conf => labs/conf}/4096-bgproutes.txt | 0 {conf => labs/conf}/5000-bgproutes.txt | 0 {conf => labs/conf}/6000-bgproutes.txt | 0 {conf => labs/conf}/7000-bgproutes.txt | 0 {conf => labs/conf}/9960-bgproutes.txt | 0 {conf => labs/conf}/999-bgproutes.txt | 0 .../deploylinuxnva.azcli | 2 +- .../deploylinuxnvabgp.azcli | 2 +- .../deploylinuxnvabgpnp.azcli | 2 +- .../linux/cloud-init.txt | 0 .../linux/linuxrouter.sh | 0 .../linux/linuxrouterbgp.sh | 0 scripts/{ => linux}/linuxrouterbgpfrr.sh | 0 scripts/{ => linux}/linuxrouterbgpfrr2.sh | 0 scripts/{ => linux}/linuxrouterbgpfrr2nh.sh | 0 .../linux/linuxrouterbgpnh.sh | 0 .../linux/linuxrouteronly.sh | 0 .../linux/linuxrouterv2.sh | 0 .../windows/winrouter.ps1 | 0 35 files changed, 874 insertions(+), 203 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/workflows/validate-templates.yml delete mode 100644 WinRouter.json rename notes.txt => docs/notes.md (100%) rename LinuxRouter-newsubnet.json => infra/arm/linux-router-newsubnet.json (98%) rename LinuxRouter.json => infra/arm/linux-router.json (98%) create mode 100644 infra/arm/windows-router.json rename LinuxRouter-newsubnet.bicep => infra/bicep/linux-router-newsubnet.bicep (99%) rename LinuxRouter.bicep => infra/bicep/linux-router.bicep (99%) create mode 100644 infra/bicep/windows-router.bicep rename {conf => labs/conf}/1000-bgproutes.txt (100%) rename {conf => labs/conf}/1024-bgproutes.txt (100%) rename {conf => labs/conf}/10240-bgproutes.txt (100%) rename {conf => labs/conf}/4000-bgproutes.txt (100%) rename {conf => labs/conf}/4001-bgproutes.txt (100%) rename {conf => labs/conf}/4096-bgproutes.txt (100%) rename {conf => labs/conf}/5000-bgproutes.txt (100%) rename {conf => labs/conf}/6000-bgproutes.txt (100%) rename {conf => labs/conf}/7000-bgproutes.txt (100%) rename {conf => labs/conf}/9960-bgproutes.txt (100%) rename {conf => labs/conf}/999-bgproutes.txt (100%) rename deploylinuxnva.azcli => labs/deploylinuxnva.azcli (98%) rename deploylinuxnvabgp.azcli => labs/deploylinuxnvabgp.azcli (98%) rename deploylinuxnvabgpnp.azcli => labs/deploylinuxnvabgpnp.azcli (99%) rename cloud-init.txt => scripts/linux/cloud-init.txt (100%) rename linuxrouter.sh => scripts/linux/linuxrouter.sh (100%) rename linuxrouterbgp.sh => scripts/linux/linuxrouterbgp.sh (100%) rename scripts/{ => linux}/linuxrouterbgpfrr.sh (100%) rename scripts/{ => linux}/linuxrouterbgpfrr2.sh (100%) rename scripts/{ => linux}/linuxrouterbgpfrr2nh.sh (100%) rename linuxrouterbgpnh.sh => scripts/linux/linuxrouterbgpnh.sh (100%) rename linuxrouteronly.sh => scripts/linux/linuxrouteronly.sh (100%) rename linuxrouterv2.sh => scripts/linux/linuxrouterv2.sh (100%) rename WinRouter.ps1 => scripts/windows/winrouter.ps1 (100%) diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..7baa614 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,18 @@ +# Normalize line endings on commit for text files. +* text=auto + +# Scripts executed on Linux VMs (via Custom Script Extension / cloud-init) must +# always be checked out with LF endings. CRLF causes the shebang to fail with +# "bad interpreter: No such file or directory". +*.sh text eol=lf +*.azcli text eol=lf +cloud-init.txt text eol=lf + +# Windows scripts keep CRLF. +*.ps1 text eol=crlf + +# Templates and docs. +*.bicep text eol=lf +*.json text eol=lf +*.md text eol=lf +*.yml text eol=lf diff --git a/.github/workflows/validate-templates.yml b/.github/workflows/validate-templates.yml new file mode 100644 index 0000000..d6cc516 --- /dev/null +++ b/.github/workflows/validate-templates.yml @@ -0,0 +1,59 @@ +name: Validate templates + +on: + push: + branches: [master] + paths: ['infra/**', 'scripts/**', '.github/workflows/validate-templates.yml'] + pull_request: + paths: ['infra/**', 'scripts/**', '.github/workflows/validate-templates.yml'] + workflow_dispatch: + +permissions: + contents: read + +jobs: + bicep: + name: Build, lint and check ARM output is in sync + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Bicep CLI + run: | + curl -sLo bicep https://github.com/Azure/bicep/releases/latest/download/bicep-linux-x64 + chmod +x bicep && sudo mv bicep /usr/local/bin/bicep + bicep --version + + - name: Lint Bicep sources + run: bicep lint infra/bicep/*.bicep + + # The ARM JSON under infra/arm/ is generated, never hand-edited. Rebuild it + # and fail if the result differs from what is committed. + - name: Rebuild ARM templates and verify they match the committed output + run: | + for src in infra/bicep/*.bicep; do + out="infra/arm/$(basename "${src%.bicep}").json" + bicep build "$src" --outfile "$out" + done + if ! git diff --exit-code -- infra/arm; then + echo "::error::infra/arm/*.json is out of sync with infra/bicep/*.bicep. Run 'az bicep build' and commit the result." + exit 1 + fi + + shell: + name: Shell script syntax and line endings + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # CRLF in a script executed by the Custom Script Extension breaks the + # shebang with "bad interpreter: No such file or directory". + - name: Reject CRLF line endings + run: | + if grep -rlU $'\r' scripts/linux labs; then + echo "::error::Files above contain CRLF line endings; they must be LF." + exit 1 + fi + + - name: Check syntax + run: for f in scripts/linux/*.sh; do bash -n "$f"; done diff --git a/README.md b/README.md index 6a14b58..600d0c1 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,305 @@ -# Deploying Azure VM as Router +

Azure VM as Router

-Deploy Azure VM (Linux or Windows) with IP forwarder enabled to be used as Router. All deployments in this document assumes you have already and existing Virtual Network (VNET) and Subnet. +

+ Deploy an Azure VM (Linux or Windows) with IP forwarding enabled, to be used as a router / Network Virtual Appliance (NVA). +

-## Deploy Linux VM as Router (IPv4 and IPv6) + NAT to Internet +

+ Validate templates + Bicep + Ubuntu + Windows Server + License +

-This template deploys a Linux Router (Ubuntu 24.04 LTS by default, 22.04 LTS selectable via the `osVersion` parameter) to an existing Virtual Network (VNET)/Subnet using a Single NIC + IP Forwarding Enabled. The ARM templates (`LinuxRouter.json`, `LinuxRouter-newsubnet.json`) are generated from the Bicep sources (`LinuxRouter.bicep`, `LinuxRouter-newsubnet.bicep`). +--- -[![Deploy To Azure](https://raw.githubusercontent.com/Azure/azure-quickstart-templates/master/1-CONTRIBUTION-GUIDE/images/deploytoazure.svg?sanitize=true)](https://portal.azure.com/#create/Microsoft.Template/uri/https%3A%2F%2Fraw.githubusercontent.com%2Fdmauser%2FAzureVM-Router%2Fmaster%2FLinuxRouter.json) -[![Visualize](https://raw.githubusercontent.com/Azure/azure-quickstart-templates/master/1-CONTRIBUTION-GUIDE/images/visualizebutton.svg?sanitize=true)](http://armviz.io/#/?load=https%3A%2F%2Fraw.githubusercontent.com%2Fdmauser%2FAzureVM-Router%2Fmaster%2FLinuxRouter.json) +## Table of contents -### Network security defaults +- [Overview](#overview) +- [Quick start](#quick-start) +- [Repository structure](#repository-structure) +- [Linux router](#linux-router) + - [Choosing a template](#choosing-a-template) + - [Parameters](#linux-parameters) +- [Windows router](#windows-router) + - [Parameters](#windows-parameters) +- [Network security defaults](#network-security-defaults) +- [Using the router](#using-the-router) +- [Deploying from the command line](#deploying-from-the-command-line) +- [Setup scripts](#setup-scripts) +- [Lab deployment scripts](#lab-deployment-scripts) +- [Working with the templates](#working-with-the-templates) +- [Recent improvements](#recent-improvements) +- [Roadmap](#roadmap) +- [Contributing](#contributing) +- [License](#license) -The templates deploy a **Standard SKU** public IP (the previously used Basic SKU was retired by Azure in September 2025). Standard public IPs are *secure by default*, which changes the out-of-the-box connectivity compared to older versions of these templates: +--- -- **Inbound traffic from the Internet is blocked** unless a Network Security Group (NSG) explicitly allows it. To enable SSH from the Internet, set the **`allowSshFromAddressPrefix`** parameter to your trusted source — for example `203.0.113.4/32` for a single address, or a CIDR range for an office network. Both templates then create an NSG with a rule allowing TCP 22 from that prefix. Leaving the parameter empty (the default) keeps the previous behaviour: `LinuxRouter.json` creates no NSG, and `LinuxRouter-newsubnet.json` creates one that only allows inbound traffic from RFC 1918 private ranges. In that case, manage the VM from inside your network or via Azure Bastion. Setting the value to `Internet` or `*` allows SSH from anywhere and is not recommended. -- **Outbound traffic to the Internet is allowed.** NSGs permit outbound traffic by default, and the attached public IP provides an *explicit* outbound method (SNAT), so the setup script can install packages during provisioning. This also keeps the templates working after Azure's retirement of *default outbound access* for new deployments. -- **If you disable the public IP** (`deployPublicIpAddress=false`), make sure the subnet has another explicit outbound method — e.g. a NAT Gateway, an Azure Firewall / NVA route, or Load Balancer outbound rules. On virtual networks without default outbound access (the default for newly created VNets), the VM otherwise has no Internet access and the setup script cannot install its packages. +## Overview -## Deploy Windows VM as Router (IPv4 and IPv6) +These templates build a single-NIC virtual machine with **IP forwarding enabled** on both the Azure NIC and inside the guest OS, so it can route traffic on behalf of other subnets. A Custom Script Extension applies the in-guest configuration at provisioning time. -This template deploys a Windows (Server 2019 Core - Small Disk) Router to an existing Virtual Network (VNET)/Subnet using a Single NIC + IP Forwarding Enabled. +| | Linux router | Windows router | +|---|---|---| +| Operating system | Ubuntu 24.04 LTS (default) or 22.04 LTS | Windows Server 2025 / 2022 / 2019, Server Core, small disk, Gen 2 | +| IPv4 + IPv6 forwarding | Yes | Yes | +| NAT / SNAT to the internet | Yes (`iptables` masquerade, persisted) | No | +| ICMP echo reply enabled | Yes | Yes (Windows Firewall rule enabled by the script) | +| Trusted Launch | No | Yes (Secure Boot + vTPM) | +| Default size | `Standard_B2s` | `Standard_B2s` | -[![Deploy To Azure](https://raw.githubusercontent.com/Azure/azure-quickstart-templates/master/1-CONTRIBUTION-GUIDE/images/deploytoazure.svg?sanitize=true)](https://portal.azure.com/#create/Microsoft.Template/uri/https%3A%2F%2Fraw.githubusercontent.com%2Fdmauser%2FAzureVM-Router%2Fmaster%2FWinRouter.json) -[![Visualize](https://raw.githubusercontent.com/Azure/azure-quickstart-templates/master/1-CONTRIBUTION-GUIDE/images/visualizebutton.svg?sanitize=true)](http://armviz.io/#/?load=https%3A%2F%2Fraw.githubusercontent.com%2Fdmauser%2FAzureVM-Router%2Fmaster%2FWinRouter.json) +> [!NOTE] +> The ARM JSON under `infra/arm/` is **generated** from the Bicep sources under `infra/bicep/`. Edit the Bicep, then rebuild — never hand-edit the JSON. CI enforces this. + +--- + +## Quick start + +Pick a template and deploy straight to the portal: + +| Template | Use when | Deploy | Visualize | +|---|---|---|---| +| **Linux — existing subnet** | You already have the VNET **and** the subnet | [![Deploy To Azure](https://raw.githubusercontent.com/Azure/azure-quickstart-templates/master/1-CONTRIBUTION-GUIDE/images/deploytoazure.svg?sanitize=true)](https://portal.azure.com/#create/Microsoft.Template/uri/https%3A%2F%2Fraw.githubusercontent.com%2Fdmauser%2FAzureVM-Router%2Fmaster%2Finfra%2Farm%2Flinux-router.json) | [![Visualize](https://raw.githubusercontent.com/Azure/azure-quickstart-templates/master/1-CONTRIBUTION-GUIDE/images/visualizebutton.svg?sanitize=true)](http://armviz.io/#/?load=https%3A%2F%2Fraw.githubusercontent.com%2Fdmauser%2FAzureVM-Router%2Fmaster%2Finfra%2Farm%2Flinux-router.json) | +| **Linux — new subnet** | You have the VNET and want the template to create a dedicated NVA subnet | [![Deploy To Azure](https://raw.githubusercontent.com/Azure/azure-quickstart-templates/master/1-CONTRIBUTION-GUIDE/images/deploytoazure.svg?sanitize=true)](https://portal.azure.com/#create/Microsoft.Template/uri/https%3A%2F%2Fraw.githubusercontent.com%2Fdmauser%2FAzureVM-Router%2Fmaster%2Finfra%2Farm%2Flinux-router-newsubnet.json) | [![Visualize](https://raw.githubusercontent.com/Azure/azure-quickstart-templates/master/1-CONTRIBUTION-GUIDE/images/visualizebutton.svg?sanitize=true)](http://armviz.io/#/?load=https%3A%2F%2Fraw.githubusercontent.com%2Fdmauser%2FAzureVM-Router%2Fmaster%2Finfra%2Farm%2Flinux-router-newsubnet.json) | +| **Windows — existing subnet** | You want a Windows Server router | [![Deploy To Azure](https://raw.githubusercontent.com/Azure/azure-quickstart-templates/master/1-CONTRIBUTION-GUIDE/images/deploytoazure.svg?sanitize=true)](https://portal.azure.com/#create/Microsoft.Template/uri/https%3A%2F%2Fraw.githubusercontent.com%2Fdmauser%2FAzureVM-Router%2Fmaster%2Finfra%2Farm%2Fwindows-router.json) | [![Visualize](https://raw.githubusercontent.com/Azure/azure-quickstart-templates/master/1-CONTRIBUTION-GUIDE/images/visualizebutton.svg?sanitize=true)](http://armviz.io/#/?load=https%3A%2F%2Fraw.githubusercontent.com%2Fdmauser%2FAzureVM-Router%2Fmaster%2Finfra%2Farm%2Fwindows-router.json) | + +> [!IMPORTANT] +> Set **`allowSshFromAddressPrefix`** (Linux) or **`allowRdpFromAddressPrefix`** (Windows) to your own public IP, e.g. `203.0.113.4/32`. Standard SKU public IPs block **all** inbound traffic unless an NSG allows it. See [Network security defaults](#network-security-defaults). + +--- + +## Repository structure + +``` +. +├── .github/workflows/ CI: Bicep build + lint, ARM drift check, shell syntax / CRLF check +├── infra/ +│ ├── bicep/ Template sources — edit these +│ └── arm/ Generated ARM JSON — deploy these, never hand-edit +├── scripts/ +│ ├── linux/ Custom Script Extension payloads (.sh) and cloud-init +│ └── windows/ Custom Script Extension payload (.ps1) +├── labs/ +│ ├── *.azcli End-to-end Azure CLI lab builds +│ └── conf/ Large BGP route lists used for scale testing +├── docs/ Supporting notes +└── README.md +``` + +--- + +## Linux router + +Deploys an Ubuntu router with a single NIC and IP forwarding enabled. The setup script enables IPv4 and IPv6 forwarding, disables ICMP redirects, configures `iptables` SNAT (masquerade) to the internet for private-range sources, and persists all of it across reboots with `netfilter-persistent`. + +### Choosing a template + +| | `linux-router.json` | `linux-router-newsubnet.json` | +|---|---|---| +| Requires an existing VNET | Yes | Yes | +| Requires an existing subnet | Yes (`existingSubnet`) | No — creates it (`subnetName`, `subnetPrefix`) | +| NSG placement | On the **NIC**, so an existing subnet NSG is never overwritten | On the **new subnet** it creates | +| NSG created when `allowSshFromAddressPrefix` is empty | No | Yes, allowing RFC 1918 inbound only | + + + +### Parameters + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `virtualMachineName` | string | *(required)* | Name of the router VM. | +| `adminUsername` | string | *(required)* | Local admin user name. | +| `adminPassword` | secure string | *(required)* | Local admin password. | +| `existingVirtualNetworkName` | string | *(required)* | Name of the existing VNET. | +| `existingSubnet` | string | *(required)* | Existing subnet name. **`linux-router` only.** | +| `subnetName` | string | `lxnva-subnet` | Subnet to create. **`linux-router-newsubnet` only.** | +| `subnetPrefix` | string | *(required)* | CIDR for the new subnet, can be as small as /29. **`linux-router-newsubnet` only.** | +| `osVersion` | string | `24.04` | Ubuntu LTS version — `24.04` or `22.04`. | +| `virtualMachineSize` | string | `Standard_B2s` | VM size. | +| `osDiskType` | string | `Standard_LRS` | `Premium_LRS`, `StandardSSD_LRS` or `Standard_LRS`. | +| `deployPublicIpAddress` | bool | `true` | Create a Standard SKU static public IP. | +| `allowSshFromAddressPrefix` | string | `''` | Source prefix allowed inbound on TCP 22. Empty means no SSH rule. | +| `scriptUri` | string | resolved from the template's own URL | Setup script to run. | +| `scriptCmd` | string | `sh linuxrouter.sh` | Command used to run the script. | +| `location` | string | resource group location | Azure region. | + +--- + +## Windows router + +Deploys a **Windows Server Core, small disk, Generation 2** router with Trusted Launch (Secure Boot + vTPM) enabled. The setup script enables IPv4 and IPv6 forwarding on all interfaces and enables the inbound ICMPv4/ICMPv6 echo request firewall rules, which Windows blocks by default. + +> [!NOTE] +> The Windows router forwards traffic but does **not** perform NAT. If you need SNAT to the internet, use the Linux router or add Routing and Remote Access / NAT separately. + + + +### Parameters + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `virtualMachineName` | string | *(required)* | Name of the router VM. | +| `adminUsername` | string | *(required)* | Local admin user name. | +| `adminPassword` | secure string | *(required)* | Local admin password. | +| `existingVirtualNetworkName` | string | *(required)* | Name of the existing VNET. | +| `existingSubnet` | string | *(required)* | Existing subnet name. | +| `osVersion` | string | `2025` | Windows Server version — `2025`, `2022` or `2019`. | +| `virtualMachineSize` | string | `Standard_B2s` | VM size. | +| `osDiskType` | string | `Standard_LRS` | `Premium_LRS`, `StandardSSD_LRS` or `Standard_LRS`. | +| `deployPublicIpAddress` | bool | `true` | Create a Standard SKU static public IP. | +| `allowRdpFromAddressPrefix` | string | `''` | Source prefix allowed inbound on TCP 3389. Empty means no RDP rule. | +| `scriptUri` | string | resolved from the template's own URL | Setup script to run. | +| `scriptCmd` | string | `powershell.exe -ExecutionPolicy Unrestricted -File winrouter.ps1` | Command used to run the script. | +| `location` | string | resource group location | Azure region. | + +--- + +## Network security defaults + +The templates deploy a **Standard SKU** public IP, because the Basic SKU was retired by Azure in September 2025. Standard public IPs are *secure by default*, which changes the out-of-the-box behaviour compared to older versions of these templates: + +- **Inbound internet traffic is blocked** unless an NSG explicitly allows it. Set `allowSshFromAddressPrefix` / `allowRdpFromAddressPrefix` to a trusted source — `203.0.113.4/32` for a single address, or a CIDR range for an office network. The template then creates an NSG with the matching management rule (priority 200) plus an RFC 1918 allow rule (priority 300) so forwarded traffic keeps flowing under the new default-deny. Leaving the parameter empty keeps the previous behaviour and means you manage the VM from inside your network or through Azure Bastion. Setting it to `Internet` or `*` allows access from anywhere and is **not recommended**. +- **Outbound internet traffic is allowed.** NSGs permit outbound by default, and the attached public IP provides an *explicit* outbound method (SNAT), so the setup script can install packages during provisioning. This also keeps the templates working after Azure's retirement of default outbound access for new deployments. +- **If you set `deployPublicIpAddress=false`**, make sure the subnet has another explicit outbound method — a NAT Gateway, a route through Azure Firewall or another NVA, or Load Balancer outbound rules. Without one, the VM has no internet access on a modern VNET and the setup script cannot install its packages. + +--- + +## Using the router + +Deploying the VM does not by itself send any traffic through it. To route traffic: + +1. Create a **route table** and add a user-defined route, for example `0.0.0.0/0` with next hop type **Virtual appliance** and the router's **private** IP as the next hop address. +2. Associate the route table with the source subnets whose traffic should traverse the router. +3. Make sure the router's NSG allows the traffic you intend to forward — the templates add an RFC 1918 allow rule for exactly this reason. + +> [!TIP] +> Do **not** associate the route table with the router's own subnet using a default route pointing at itself; that creates a routing loop. + +--- + +## Deploying from the command line + +Deploy the generated ARM template directly from GitHub: + +```bash +az group create -n rg-nva -l eastus + +az deployment group create \ + -g rg-nva \ + --template-uri https://raw.githubusercontent.com/dmauser/AzureVM-Router/master/infra/arm/linux-router.json \ + --parameters \ + virtualMachineName=nva1 \ + adminUsername=azureuser \ + adminPassword='' \ + existingVirtualNetworkName=vnet1 \ + existingSubnet=nva-subnet \ + allowSshFromAddressPrefix="$(curl -s ifconfig.me)/32" +``` + +> [!WARNING] +> `scriptUri` defaults to a path resolved **relative to the template's own URL**, so it automatically follows the branch or fork you deploy from. That resolution relies on `deployment().properties.templateLink`, which does **not** exist when you deploy a local file with `--template-file`. In that case pass the script location explicitly: +> +> ```bash +> --parameters scriptUri=https://raw.githubusercontent.com/dmauser/AzureVM-Router/master/scripts/linux/linuxrouter.sh +> ``` + +--- + +## Setup scripts + +Custom Script Extension payloads under `scripts/`. + +| Script | Purpose | +|---|---| +| `linux/linuxrouter.sh` | **Default.** IPv4/IPv6 forwarding, no ICMP redirects, `iptables` SNAT to the internet, persisted with `netfilter-persistent`. | +| `linux/linuxrouterv2.sh` | Same as above, using `/etc/sysctl.d/` drop-ins instead of editing `/etc/sysctl.conf`. | +| `linux/linuxrouteronly.sh` | Minimal — enables forwarding only, no NAT and no packages installed. | +| `linux/linuxrouterbgp.sh` | Router plus **Quagga** BGP, peering with two route server / peer IPs. | +| `linux/linuxrouterbgpnh.sh` | Quagga BGP with an explicit **next-hop** override for advertised routes. | +| `linux/linuxrouterbgpfrr.sh` | Router plus **FRRouting** BGP. | +| `linux/linuxrouterbgpfrr2.sh` | FRRouting variant used for the second NVA in dual-NVA labs. | +| `linux/linuxrouterbgpfrr2nh.sh` | FRRouting second-NVA variant with a next-hop override. | +| `linux/cloud-init.txt` | cloud-init alternative to the Custom Script Extension. | +| `windows/winrouter.ps1` | Enables forwarding on all interfaces and allows inbound ICMP echo. | + +> [!NOTE] +> Every script that installs packages first runs `cloud-init status --wait`. Without it the extension can race cloud-init while it is still switching the VM to the regional Azure apt mirror, which leaves the on-disk package indexes pointing at the superseded mirror and makes installs fail with `Unable to locate package`. + +--- + +## Lab deployment scripts + +End-to-end environment builds under `labs/`, intended to be run interactively line by line. + +| Script | Builds | +|---|---| +| `deploylinuxnva.azcli` | A VNET with a Linux NVA plus spoke/test VMs and UDRs to validate routing through it. | +| `deploylinuxnvabgp.azcli` | A Linux NVA running BGP, peered with an Azure Route Server. | +| `deploylinuxnvabgpnp.azcli` | The BGP lab with a custom next-hop, plus network test tooling on the test VMs. | +| `conf/*-bgproutes.txt` | Pre-generated route lists (999 to 10240 prefixes) for BGP scale testing. | + +--- + +## Working with the templates + +Rebuild the ARM JSON after changing any Bicep file: + +```bash +az bicep build --file infra/bicep/linux-router.bicep --outfile infra/arm/linux-router.json +az bicep build --file infra/bicep/linux-router-newsubnet.bicep --outfile infra/arm/linux-router-newsubnet.json +az bicep build --file infra/bicep/windows-router.bicep --outfile infra/arm/windows-router.json +``` + +CI runs `bicep lint`, rebuilds every template and fails if `infra/arm/` differs from the committed output. It also checks the shell scripts for syntax errors and rejects CRLF line endings, which break the shebang when the Custom Script Extension runs a script on Linux. + +--- + +## Recent improvements + +The templates and scripts were modernised after several Azure platform retirements broke the original versions. + +| Area | What changed | +|---|---| +| **Infrastructure as code** | Templates converted to **Bicep**; the ARM JSON is now generated output, kept in sync by CI. | +| **Operating systems** | Ubuntu 18.04 and the retired `UbuntuLTS` / `ubuntults` CLI aliases replaced with **Ubuntu 24.04 LTS** (default) and 22.04 LTS. Windows moved from Server 2019 to **Server 2025** Core / small disk / Gen 2, with Trusted Launch. | +| **Public IP** | Basic SKU (retired September 2025) replaced with **Standard SKU, static allocation** across all templates and lab scripts. | +| **Network security** | Added `allowSshFromAddressPrefix` / `allowRdpFromAddressPrefix` so the templates can create the NSG that Standard SKU public IPs now require, together with an RFC 1918 rule so forwarded traffic still flows. Lab scripts that previously created no NSG now create one. | +| **Provisioning reliability** | Fixed a latent **cloud-init race** that intermittently failed package installation with `Unable to locate package netfilter-persistent`. All package-installing scripts now wait for cloud-init to finish first. | +| **Repository layout** | Reorganised into `infra/`, `scripts/`, `labs/` and `docs/`, with consistent file naming. | +| **Quality gates** | Added GitHub Actions validation and a `.gitattributes` that pins shell scripts to LF. | +| **Documentation** | Rewrote this README with parameter references, template comparisons, security guidance and coverage of every script in the repository. | + +All templates and the affected lab scripts were verified by deploying them to Azure and confirming NSG placement, inbound reachability, extension success, in-guest forwarding and NAT state, end-to-end egress through the NVA, and persistence across a reboot. + +--- ## Roadmap -- Add VMSS option for both Linux and Windows deployments -- Add Accelerated Networking option +### Add a VMSS option for both Linux and Windows deployments + +Replace the single-VM deployment with a **Virtual Machine Scale Set in Flexible orchestration mode** so the router tier can scale out and survive the loss of an instance. + +- Place the scale set behind an **internal Standard Load Balancer** with an **HA Ports** rule, so all protocols and ports are distributed, and a health probe that removes unhealthy instances from rotation. +- Point user-defined routes at the **load balancer's frontend IP** instead of a single VM's private IP, so the next hop stays valid as instances come and go. +- Spread instances across **availability zones** for zone resilience, and apply the existing setup scripts through the scale set's extension profile so every new instance is configured identically. +- Design consideration: stateful features such as `iptables` SNAT require **flow symmetry**, so return traffic must reach the same instance that handled the outbound flow. The NAT-to-internet scenario therefore needs per-instance outbound addressing or a NAT Gateway on the subnet rather than per-instance masquerade. Pure forwarding and BGP scenarios do not have this constraint. + +### Add an Accelerated Networking option + +Expose an `acceleratedNetworking` parameter that sets `enableAcceleratedNetworking` on the NIC. Accelerated Networking gives the VM **SR-IOV**, bypassing the host virtual switch to deliver substantially lower latency and jitter, far higher packets-per-second, and lower CPU utilisation per gigabit — all of which are the main throughput limits for a software NVA. + +- Requires a **supported VM size**. The current `Standard_B2s` default is a burstable size and does **not** support Accelerated Networking, so enabling it also means moving to a size such as `Standard_D2s_v5` or larger. +- Should ship with clear guidance mapping expected throughput to VM size, since the NIC setting alone does not lift the size's own bandwidth cap. +- Plan to validate the flag against every supported OS image, since enabling it on an unsupported size or image causes the deployment to fail rather than silently degrade. + +--- + +## Contributing + +Issues and pull requests are welcome. When changing a template, edit the Bicep under `infra/bicep/`, rebuild the ARM JSON, and commit both — CI will fail if they drift apart. + +## License + +Released under the [MIT License](LICENSE). diff --git a/WinRouter.json b/WinRouter.json deleted file mode 100644 index d7a7873..0000000 --- a/WinRouter.json +++ /dev/null @@ -1,177 +0,0 @@ -{ - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "virtualMachineSize": { - "type": "string", - "defaultValue": "Standard_B2s", - "metadata": { - "description": "VM size" - } - }, - "virtualMachineName": { - "type": "string", - "metadata": { - "description": "Windows Router Manchine Name" - } - }, - "osDiskType": { - "type": "string", - "defaultValue": "Standard_LRS", - "allowedValues": [ - "Standard_LRS", - "StandardSSD_LRS", - "Premium_LRS" - ], - "metadata": { - "description": "Select Disk Type: Premium SSD (Premium_LRS), Standard SSD (StandardSSD_LRS), Standard HDD (Standard_LRS)" - } - }, - "adminUsername": { - "type": "string", - "metadata": { - "description": "Admin username" - } - }, - "adminPassword": { - "type": "securestring", - "metadata": { - "description": "Admin password" - } - }, - "existingVirtualNetworkName": { - "type": "string", - "metadata": { - "description": "Existing Virtual Nework Name" - } - }, - "existingSubnet": { - "type": "string", - "metadata": { - "description": "Type Existing Subnet Name" - } - }, - "scriptUri": { - "defaultValue": "[uri(deployment().properties.templateLink.uri, 'WinRouter.ps1')]", - "type": "string", - "metadata": { "description": "Script that will be executed" } - }, - "scriptCmd": { - "defaultValue": "powershell.exe -ExecutionPolicy Unrestricted -File WinRouter.ps1", - "type": "string", - "metadata": { "description": "Command to run the script" } - } - }, - "variables": { - "extensionName":"CustomScript", - "NIC": "[concat(parameters('virtualMachineName'),'-NIC')]", - "publicIPAddressName": "[concat(parameters('virtualMachineName'),'-PublicIP')]", - "subnet1Ref": "[resourceId('Microsoft.Network/virtualNetworks/subnets', parameters('existingVirtualNetworkName'), parameters('existingSubnet'))]", - "location": "[resourceGroup().location]" - }, - "resources": [ - { - "name": "[parameters('virtualMachineName')]", - "type": "Microsoft.Compute/virtualMachines", - "apiVersion": "2017-03-30", - "location": "[variables('location')]", - "comments": "This is the virtual machine that you're building.", - "dependsOn": [ - "[variables('NIC')]" - ], - "properties": { - "osProfile": { - "computerName": "[parameters('virtualMachineName')]", - "adminUsername": "[parameters('adminUsername')]", - "adminPassword": "[parameters('adminPassword')]" - }, - "hardwareProfile": { - "vmSize": "[parameters('virtualMachineSize')]" - }, - "storageProfile": { - "imageReference": { - "publisher": "MicrosoftWindowsServer", - "offer": "WindowsServer", - "sku": "2019-Datacenter-Core-smalldisk", - "version": "latest" - }, - "osDisk": { - "createOption": "FromImage", - "managedDisk": { - "storageAccountType": "[parameters('osDiskType')]" - } - }, - "dataDisks": [] - }, - "networkProfile": { - "networkInterfaces": [ - { - "properties": { - "primary": true - }, - "id": "[resourceId('Microsoft.Network/networkInterfaces', variables('NIC'))]" - } - ] - } - } - }, - { - "name": "[variables('NIC')]", - "type": "Microsoft.Network/networkInterfaces", - "apiVersion": "2017-06-01", - "location": "[variables('location')]", - "dependsOn": [ - "[variables('publicIpAddressName')]" - ], - "properties": { - "enableIPForwarding": true, - "ipConfigurations": [ - { - "name": "ipconfig1", - "properties": { - "subnet": { - "id": "[variables('subnet1Ref')]" - }, - "privateIPAllocationMethod": "Dynamic", - "publicIpAddress": { - "id": "[resourceId('Microsoft.Network/publicIpAddresses', variables('publicIpAddressName'))]" - } - } - } - ] - } - }, - { - "name": "[variables('publicIpAddressName')]", - "type": "Microsoft.Network/publicIPAddresses", - "apiVersion": "2017-06-01", - "location": "[variables('location')]", - "comments": "Public IP for your Primary NIC", - "properties": { - "publicIPAllocationMethod": "Dynamic" - } - }, - { - "type": "Microsoft.Compute/virtualMachines/extensions", - "name": "[concat(parameters('virtualMachineName'), '/', variables('extensionName'))]", - "apiVersion": "2015-06-15", - "location": "[variables('location')]", - "dependsOn": [ - "[concat('Microsoft.Compute/virtualMachines/', parameters('virtualMachineName'))]" - ], - "properties": { - "publisher": "Microsoft.Compute", - "type": "CustomScriptExtension", - "typeHandlerVersion": "1.10", - "autoUpgradeMinorVersion": true, - "settings": { - "fileUris": [ - "[parameters('scriptUri')]" - ], - "commandToExecute": "[parameters('scriptCmd')]" - } - } - } - ], - "outputs": {} -} \ No newline at end of file diff --git a/notes.txt b/docs/notes.md similarity index 100% rename from notes.txt rename to docs/notes.md diff --git a/LinuxRouter-newsubnet.json b/infra/arm/linux-router-newsubnet.json similarity index 98% rename from LinuxRouter-newsubnet.json rename to infra/arm/linux-router-newsubnet.json index 44f8606..db43b74 100644 --- a/LinuxRouter-newsubnet.json +++ b/infra/arm/linux-router-newsubnet.json @@ -5,7 +5,7 @@ "_generator": { "name": "bicep", "version": "0.46.1.21595", - "templateHash": "18251987747692485582" + "templateHash": "2884941074303245104" } }, "parameters": { @@ -78,7 +78,7 @@ }, "scriptUri": { "type": "string", - "defaultValue": "[uri(deployment().properties.templateLink.uri, 'linuxrouter.sh')]", + "defaultValue": "[uri(deployment().properties.templateLink.uri, '../../scripts/linux/linuxrouter.sh')]", "metadata": { "description": "Script that will be executed" } diff --git a/LinuxRouter.json b/infra/arm/linux-router.json similarity index 98% rename from LinuxRouter.json rename to infra/arm/linux-router.json index 61d8846..ee7149f 100644 --- a/LinuxRouter.json +++ b/infra/arm/linux-router.json @@ -5,7 +5,7 @@ "_generator": { "name": "bicep", "version": "0.46.1.21595", - "templateHash": "12254805934774540914" + "templateHash": "8873189144452211355" } }, "parameters": { @@ -71,7 +71,7 @@ }, "scriptUri": { "type": "string", - "defaultValue": "[uri(deployment().properties.templateLink.uri, 'linuxrouter.sh')]", + "defaultValue": "[uri(deployment().properties.templateLink.uri, '../../scripts/linux/linuxrouter.sh')]", "metadata": { "description": "Script that will be executed" } diff --git a/infra/arm/windows-router.json b/infra/arm/windows-router.json new file mode 100644 index 0000000..d201878 --- /dev/null +++ b/infra/arm/windows-router.json @@ -0,0 +1,287 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.46.1.21595", + "templateHash": "8395921165082925479" + } + }, + "parameters": { + "virtualMachineSize": { + "type": "string", + "defaultValue": "Standard_B2s", + "metadata": { + "description": "VM size" + } + }, + "virtualMachineName": { + "type": "string", + "metadata": { + "description": "Windows Router Machine Name" + } + }, + "osDiskType": { + "type": "string", + "defaultValue": "Standard_LRS", + "allowedValues": [ + "Standard_LRS", + "StandardSSD_LRS", + "Premium_LRS" + ], + "metadata": { + "description": "Select Disk Type: Premium SSD (Premium_LRS), Standard SSD (StandardSSD_LRS), Standard HDD (Standard_LRS)" + } + }, + "osVersion": { + "type": "string", + "defaultValue": "2025", + "allowedValues": [ + "2019", + "2022", + "2025" + ], + "metadata": { + "description": "Windows Server version. All options are Server Core, small disk, Generation 2 images." + } + }, + "adminUsername": { + "type": "string", + "metadata": { + "description": "Admin username" + } + }, + "adminPassword": { + "type": "securestring", + "metadata": { + "description": "Admin password" + } + }, + "existingVirtualNetworkName": { + "type": "string", + "metadata": { + "description": "Existing Virtual Network Name" + } + }, + "existingSubnet": { + "type": "string", + "metadata": { + "description": "Type Existing Subnet Name" + } + }, + "scriptUri": { + "type": "string", + "defaultValue": "[uri(deployment().properties.templateLink.uri, '../../scripts/windows/winrouter.ps1')]", + "metadata": { + "description": "Script that will be executed" + } + }, + "scriptCmd": { + "type": "string", + "defaultValue": "powershell.exe -ExecutionPolicy Unrestricted -File winrouter.ps1", + "metadata": { + "description": "Command to run the script" + } + }, + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]", + "metadata": { + "description": "Azure region for all resources." + } + }, + "deployPublicIpAddress": { + "type": "bool", + "defaultValue": true, + "metadata": { + "description": "Deploy Public IP Address" + } + }, + "allowRdpFromAddressPrefix": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Source address prefix allowed to reach the VM on TCP 3389, for example 203.0.113.4/32. Standard SKU public IPs deny inbound traffic by default, so leave this empty only if you do not need RDP from the internet. Use Internet to allow any source (not recommended)." + } + } + }, + "variables": { + "extensionName": "CustomScript", + "nicName": "[format('{0}-NIC', parameters('virtualMachineName'))]", + "nsgName": "[format('{0}-NSG', parameters('virtualMachineName'))]", + "publicIPAddressName": "[format('{0}-PublicIP', parameters('virtualMachineName'))]", + "subnetResourceId": "[resourceId('Microsoft.Network/virtualNetworks/subnets', parameters('existingVirtualNetworkName'), parameters('existingSubnet'))]", + "deployNetworkSecurityGroup": "[not(empty(parameters('allowRdpFromAddressPrefix')))]", + "osVersionDefinitions": { + "2019": { + "publisher": "MicrosoftWindowsServer", + "offer": "WindowsServer", + "sku": "2019-datacenter-core-smalldisk-g2", + "version": "latest" + }, + "2022": { + "publisher": "MicrosoftWindowsServer", + "offer": "WindowsServer", + "sku": "2022-datacenter-core-smalldisk-g2", + "version": "latest" + }, + "2025": { + "publisher": "MicrosoftWindowsServer", + "offer": "WindowsServer", + "sku": "2025-datacenter-core-smalldisk-g2", + "version": "latest" + } + } + }, + "resources": [ + { + "condition": "[variables('deployNetworkSecurityGroup')]", + "type": "Microsoft.Network/networkSecurityGroups", + "apiVersion": "2024-05-01", + "name": "[variables('nsgName')]", + "location": "[parameters('location')]", + "properties": { + "securityRules": [ + { + "name": "Allow-RDP-Inbound", + "properties": { + "priority": 200, + "protocol": "Tcp", + "access": "Allow", + "direction": "Inbound", + "sourceAddressPrefix": "[parameters('allowRdpFromAddressPrefix')]", + "sourcePortRange": "*", + "destinationAddressPrefix": "*", + "destinationPortRange": "3389" + } + }, + { + "name": "Allow-Traffic-RFC-1918", + "properties": { + "priority": 300, + "protocol": "*", + "access": "Allow", + "direction": "Inbound", + "sourceAddressPrefixes": [ + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16" + ], + "sourcePortRange": "*", + "destinationAddressPrefix": "*", + "destinationPortRange": "*" + } + } + ] + } + }, + { + "type": "Microsoft.Compute/virtualMachines", + "apiVersion": "2024-07-01", + "name": "[parameters('virtualMachineName')]", + "location": "[parameters('location')]", + "properties": { + "osProfile": { + "computerName": "[parameters('virtualMachineName')]", + "adminUsername": "[parameters('adminUsername')]", + "adminPassword": "[parameters('adminPassword')]" + }, + "hardwareProfile": { + "vmSize": "[parameters('virtualMachineSize')]" + }, + "securityProfile": { + "securityType": "TrustedLaunch", + "uefiSettings": { + "secureBootEnabled": true, + "vTpmEnabled": true + } + }, + "storageProfile": { + "imageReference": "[variables('osVersionDefinitions')[parameters('osVersion')]]", + "osDisk": { + "createOption": "FromImage", + "name": "[format('{0}-OSDisk', parameters('virtualMachineName'))]", + "managedDisk": { + "storageAccountType": "[parameters('osDiskType')]" + } + }, + "dataDisks": [] + }, + "networkProfile": { + "networkInterfaces": [ + { + "properties": { + "primary": true + }, + "id": "[resourceId('Microsoft.Network/networkInterfaces', variables('nicName'))]" + } + ] + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/networkInterfaces', variables('nicName'))]" + ] + }, + { + "type": "Microsoft.Network/networkInterfaces", + "apiVersion": "2024-05-01", + "name": "[variables('nicName')]", + "location": "[parameters('location')]", + "properties": { + "enableIPForwarding": true, + "networkSecurityGroup": "[if(variables('deployNetworkSecurityGroup'), createObject('id', resourceId('Microsoft.Network/networkSecurityGroups', variables('nsgName'))), null())]", + "ipConfigurations": [ + { + "name": "ipconfig1", + "properties": { + "subnet": { + "id": "[variables('subnetResourceId')]" + }, + "privateIPAllocationMethod": "Dynamic", + "publicIPAddress": "[if(parameters('deployPublicIpAddress'), createObject('id', resourceId('Microsoft.Network/publicIPAddresses', variables('publicIPAddressName'))), null())]" + } + } + ] + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/networkSecurityGroups', variables('nsgName'))]", + "[resourceId('Microsoft.Network/publicIPAddresses', variables('publicIPAddressName'))]" + ] + }, + { + "condition": "[parameters('deployPublicIpAddress')]", + "type": "Microsoft.Network/publicIPAddresses", + "apiVersion": "2024-05-01", + "name": "[variables('publicIPAddressName')]", + "location": "[parameters('location')]", + "sku": { + "name": "Standard" + }, + "properties": { + "publicIPAllocationMethod": "Static" + } + }, + { + "type": "Microsoft.Compute/virtualMachines/extensions", + "apiVersion": "2024-07-01", + "name": "[format('{0}/{1}', parameters('virtualMachineName'), variables('extensionName'))]", + "location": "[parameters('location')]", + "properties": { + "publisher": "Microsoft.Compute", + "type": "CustomScriptExtension", + "typeHandlerVersion": "1.10", + "autoUpgradeMinorVersion": true, + "settings": { + "fileUris": [ + "[parameters('scriptUri')]" + ], + "commandToExecute": "[parameters('scriptCmd')]" + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Compute/virtualMachines', parameters('virtualMachineName'))]" + ] + } + ] +} \ No newline at end of file diff --git a/LinuxRouter-newsubnet.bicep b/infra/bicep/linux-router-newsubnet.bicep similarity index 99% rename from LinuxRouter-newsubnet.bicep rename to infra/bicep/linux-router-newsubnet.bicep index 762c21b..5a4d2ca 100644 --- a/LinuxRouter-newsubnet.bicep +++ b/infra/bicep/linux-router-newsubnet.bicep @@ -33,7 +33,7 @@ param subnetName string = 'lxnva-subnet' param subnetPrefix string @description('Script that will be executed') -param scriptUri string = uri(deployment().properties.templateLink.uri, 'linuxrouter.sh') +param scriptUri string = uri(deployment().properties.templateLink.uri, '../../scripts/linux/linuxrouter.sh') @description('Command to run the script') param scriptCmd string = 'sh linuxrouter.sh' diff --git a/LinuxRouter.bicep b/infra/bicep/linux-router.bicep similarity index 99% rename from LinuxRouter.bicep rename to infra/bicep/linux-router.bicep index 3e5be5e..52693fa 100644 --- a/LinuxRouter.bicep +++ b/infra/bicep/linux-router.bicep @@ -30,7 +30,7 @@ param existingVirtualNetworkName string param existingSubnet string @description('Script that will be executed') -param scriptUri string = uri(deployment().properties.templateLink.uri, 'linuxrouter.sh') +param scriptUri string = uri(deployment().properties.templateLink.uri, '../../scripts/linux/linuxrouter.sh') @description('Command to run the script') param scriptCmd string = 'sh linuxrouter.sh' diff --git a/infra/bicep/windows-router.bicep b/infra/bicep/windows-router.bicep new file mode 100644 index 0000000..648e428 --- /dev/null +++ b/infra/bicep/windows-router.bicep @@ -0,0 +1,209 @@ +@description('VM size') +param virtualMachineSize string = 'Standard_B2s' + +@description('Windows Router Machine Name') +param virtualMachineName string + +@description('Select Disk Type: Premium SSD (Premium_LRS), Standard SSD (StandardSSD_LRS), Standard HDD (Standard_LRS)') +@allowed([ + 'Standard_LRS' + 'StandardSSD_LRS' + 'Premium_LRS' +]) +param osDiskType string = 'Standard_LRS' + +@description('Windows Server version. All options are Server Core, small disk, Generation 2 images.') +@allowed(['2019', '2022', '2025']) +param osVersion string = '2025' + +@description('Admin username') +param adminUsername string + +@description('Admin password') +@secure() +param adminPassword string + +@description('Existing Virtual Network Name') +param existingVirtualNetworkName string + +@description('Type Existing Subnet Name') +param existingSubnet string + +@description('Script that will be executed') +param scriptUri string = uri(deployment().properties.templateLink.uri, '../../scripts/windows/winrouter.ps1') + +@description('Command to run the script') +param scriptCmd string = 'powershell.exe -ExecutionPolicy Unrestricted -File winrouter.ps1' + +@description('Azure region for all resources.') +param location string = resourceGroup().location + +@description('Deploy Public IP Address') +param deployPublicIpAddress bool = true + +@description('Source address prefix allowed to reach the VM on TCP 3389, for example 203.0.113.4/32. Standard SKU public IPs deny inbound traffic by default, so leave this empty only if you do not need RDP from the internet. Use Internet to allow any source (not recommended).') +param allowRdpFromAddressPrefix string = '' + +var extensionName = 'CustomScript' +var nicName = '${virtualMachineName}-NIC' +var nsgName = '${virtualMachineName}-NSG' +var publicIPAddressName = '${virtualMachineName}-PublicIP' +var subnetResourceId = resourceId('Microsoft.Network/virtualNetworks/subnets', existingVirtualNetworkName, existingSubnet) +var deployNetworkSecurityGroup = !empty(allowRdpFromAddressPrefix) + +var osVersionDefinitions = { + '2019': { + publisher: 'MicrosoftWindowsServer' + offer: 'WindowsServer' + sku: '2019-datacenter-core-smalldisk-g2' + version: 'latest' + } + '2022': { + publisher: 'MicrosoftWindowsServer' + offer: 'WindowsServer' + sku: '2022-datacenter-core-smalldisk-g2' + version: 'latest' + } + '2025': { + publisher: 'MicrosoftWindowsServer' + offer: 'WindowsServer' + sku: '2025-datacenter-core-smalldisk-g2' + version: 'latest' + } +} + +resource networkSecurityGroup 'Microsoft.Network/networkSecurityGroups@2024-05-01' = if (deployNetworkSecurityGroup) { + name: nsgName + location: location + properties: { + securityRules: [ + { + name: 'Allow-RDP-Inbound' + properties: { + priority: 200 + protocol: 'Tcp' + access: 'Allow' + direction: 'Inbound' + sourceAddressPrefix: allowRdpFromAddressPrefix + sourcePortRange: '*' + destinationAddressPrefix: '*' + destinationPortRange: '3389' + } + } + { + name: 'Allow-Traffic-RFC-1918' + properties: { + priority: 300 + protocol: '*' + access: 'Allow' + direction: 'Inbound' + sourceAddressPrefixes: [ + '10.0.0.0/8' + '172.16.0.0/12' + '192.168.0.0/16' + ] + sourcePortRange: '*' + destinationAddressPrefix: '*' + destinationPortRange: '*' + } + } + ] + } +} + +resource virtualMachine 'Microsoft.Compute/virtualMachines@2024-07-01' = { + name: virtualMachineName + location: location + properties: { + osProfile: { + computerName: virtualMachineName + adminUsername: adminUsername + adminPassword: adminPassword + } + hardwareProfile: { + vmSize: virtualMachineSize + } + securityProfile: { + securityType: 'TrustedLaunch' + uefiSettings: { + secureBootEnabled: true + vTpmEnabled: true + } + } + storageProfile: { + imageReference: osVersionDefinitions[osVersion] + osDisk: { + createOption: 'FromImage' + name: '${virtualMachineName}-OSDisk' + managedDisk: { + storageAccountType: osDiskType + } + } + dataDisks: [] + } + networkProfile: { + networkInterfaces: [ + { + properties: { + primary: true + } + id: nic.id + } + ] + } + } +} + +resource nic 'Microsoft.Network/networkInterfaces@2024-05-01' = { + name: nicName + location: location + properties: { + enableIPForwarding: true + networkSecurityGroup: deployNetworkSecurityGroup ? { + id: networkSecurityGroup.id + } : null + ipConfigurations: [ + { + name: 'ipconfig1' + properties: { + subnet: { + id: subnetResourceId + } + privateIPAllocationMethod: 'Dynamic' + publicIPAddress: deployPublicIpAddress ? { + id: publicIpAddress.id + } : null + } + } + ] + } +} + +resource publicIpAddress 'Microsoft.Network/publicIPAddresses@2024-05-01' = if (deployPublicIpAddress) { + name: publicIPAddressName + location: location + sku: { + name: 'Standard' + } + properties: { + publicIPAllocationMethod: 'Static' + } +} + +resource virtualMachineExtension 'Microsoft.Compute/virtualMachines/extensions@2024-07-01' = { + parent: virtualMachine + name: extensionName + location: location + properties: { + publisher: 'Microsoft.Compute' + type: 'CustomScriptExtension' + typeHandlerVersion: '1.10' + autoUpgradeMinorVersion: true + settings: { + fileUris: [ + scriptUri + ] + commandToExecute: scriptCmd + } + } +} diff --git a/conf/1000-bgproutes.txt b/labs/conf/1000-bgproutes.txt similarity index 100% rename from conf/1000-bgproutes.txt rename to labs/conf/1000-bgproutes.txt diff --git a/conf/1024-bgproutes.txt b/labs/conf/1024-bgproutes.txt similarity index 100% rename from conf/1024-bgproutes.txt rename to labs/conf/1024-bgproutes.txt diff --git a/conf/10240-bgproutes.txt b/labs/conf/10240-bgproutes.txt similarity index 100% rename from conf/10240-bgproutes.txt rename to labs/conf/10240-bgproutes.txt diff --git a/conf/4000-bgproutes.txt b/labs/conf/4000-bgproutes.txt similarity index 100% rename from conf/4000-bgproutes.txt rename to labs/conf/4000-bgproutes.txt diff --git a/conf/4001-bgproutes.txt b/labs/conf/4001-bgproutes.txt similarity index 100% rename from conf/4001-bgproutes.txt rename to labs/conf/4001-bgproutes.txt diff --git a/conf/4096-bgproutes.txt b/labs/conf/4096-bgproutes.txt similarity index 100% rename from conf/4096-bgproutes.txt rename to labs/conf/4096-bgproutes.txt diff --git a/conf/5000-bgproutes.txt b/labs/conf/5000-bgproutes.txt similarity index 100% rename from conf/5000-bgproutes.txt rename to labs/conf/5000-bgproutes.txt diff --git a/conf/6000-bgproutes.txt b/labs/conf/6000-bgproutes.txt similarity index 100% rename from conf/6000-bgproutes.txt rename to labs/conf/6000-bgproutes.txt diff --git a/conf/7000-bgproutes.txt b/labs/conf/7000-bgproutes.txt similarity index 100% rename from conf/7000-bgproutes.txt rename to labs/conf/7000-bgproutes.txt diff --git a/conf/9960-bgproutes.txt b/labs/conf/9960-bgproutes.txt similarity index 100% rename from conf/9960-bgproutes.txt rename to labs/conf/9960-bgproutes.txt diff --git a/conf/999-bgproutes.txt b/labs/conf/999-bgproutes.txt similarity index 100% rename from conf/999-bgproutes.txt rename to labs/conf/999-bgproutes.txt diff --git a/deploylinuxnva.azcli b/labs/deploylinuxnva.azcli similarity index 98% rename from deploylinuxnva.azcli rename to labs/deploylinuxnva.azcli index c5f0ff5..1f280e0 100644 --- a/deploylinuxnva.azcli +++ b/labs/deploylinuxnva.azcli @@ -25,7 +25,7 @@ az network public-ip create --name $nvaname-pip --resource-group $rg --location az network nic create --name $nvaname-nic --resource-group $rg --subnet $subnetname --vnet $vnetname --public-ip-address $nvaname-pip --ip-forwarding true -o none az vm create --resource-group $rg --location $location --name $nvaname --size Standard_B1s --nics $nvaname-nic --image Ubuntu2404 --admin-username $username --admin-password $password -o none # Enable routing and NAT on Linux NVA: -scripturi="https://raw.githubusercontent.com/dmauser/AzureVM-Router/master/linuxrouter.sh" +scripturi="https://raw.githubusercontent.com/dmauser/AzureVM-Router/master/scripts/linux/linuxrouter.sh" az vm extension set --resource-group $rg --vm-name $nvaname --name customScript --publisher Microsoft.Azure.Extensions \ --protected-settings "{\"fileUris\": [\"$scripturi\"],\"commandToExecute\": \"./linuxrouter.sh\"}" \ --no-wait diff --git a/deploylinuxnvabgp.azcli b/labs/deploylinuxnvabgp.azcli similarity index 98% rename from deploylinuxnvabgp.azcli rename to labs/deploylinuxnvabgp.azcli index c017faf..ad8af0b 100644 --- a/deploylinuxnvabgp.azcli +++ b/labs/deploylinuxnvabgp.azcli @@ -43,7 +43,7 @@ routeserver_IP1=$(az network routeserver list --resource-group $rg --query '{IPs routeserver_IP2=$(az network routeserver list --resource-group $rg --query '{IPs:[0].virtualRouterIps[1]}' -o tsv) # Enable routing and NAT on Linux NVA: -scripturi="https://raw.githubusercontent.com/dmauser/AzureVM-Router/master/linuxrouterbgp.sh" +scripturi="https://raw.githubusercontent.com/dmauser/AzureVM-Router/master/scripts/linux/linuxrouterbgp.sh" az vm extension set --resource-group $rg --vm-name $nvaname --name customScript --publisher Microsoft.Azure.Extensions \ --protected-settings "{\"fileUris\": [\"$scripturi\"],\"commandToExecute\": \"./linuxrouterbgp.sh $asn_quagga $bgp_routerId $bgp_network1 $routeserver_IP1 $routeserver_IP2\"}" \ --no-wait diff --git a/deploylinuxnvabgpnp.azcli b/labs/deploylinuxnvabgpnp.azcli similarity index 99% rename from deploylinuxnvabgpnp.azcli rename to labs/deploylinuxnvabgpnp.azcli index a476103..a5ad917 100644 --- a/deploylinuxnvabgpnp.azcli +++ b/labs/deploylinuxnvabgpnp.azcli @@ -77,7 +77,7 @@ do # Enable routing and NAT on Linux NVA: # Enable routing, NAT and BGP on Linux NVA: - scripturi="https://raw.githubusercontent.com/dmauser/AzureVM-Router/master/linuxrouterbgpnh.sh" + scripturi="https://raw.githubusercontent.com/dmauser/AzureVM-Router/master/scripts/linux/linuxrouterbgpnh.sh" az vm extension set --resource-group $rg --vm-name $nvaname --name customScript --publisher Microsoft.Azure.Extensions \ --protected-settings "{\"fileUris\": [\"$scripturi\"],\"commandToExecute\": \"./linuxrouterbgpnh.sh $asn_quagga $bgp_routerId $bgp_network1 $routeserver_IP1 $routeserver_IP2 $nexthopip\"}" \ --no-wait diff --git a/cloud-init.txt b/scripts/linux/cloud-init.txt similarity index 100% rename from cloud-init.txt rename to scripts/linux/cloud-init.txt diff --git a/linuxrouter.sh b/scripts/linux/linuxrouter.sh similarity index 100% rename from linuxrouter.sh rename to scripts/linux/linuxrouter.sh diff --git a/linuxrouterbgp.sh b/scripts/linux/linuxrouterbgp.sh similarity index 100% rename from linuxrouterbgp.sh rename to scripts/linux/linuxrouterbgp.sh diff --git a/scripts/linuxrouterbgpfrr.sh b/scripts/linux/linuxrouterbgpfrr.sh similarity index 100% rename from scripts/linuxrouterbgpfrr.sh rename to scripts/linux/linuxrouterbgpfrr.sh diff --git a/scripts/linuxrouterbgpfrr2.sh b/scripts/linux/linuxrouterbgpfrr2.sh similarity index 100% rename from scripts/linuxrouterbgpfrr2.sh rename to scripts/linux/linuxrouterbgpfrr2.sh diff --git a/scripts/linuxrouterbgpfrr2nh.sh b/scripts/linux/linuxrouterbgpfrr2nh.sh similarity index 100% rename from scripts/linuxrouterbgpfrr2nh.sh rename to scripts/linux/linuxrouterbgpfrr2nh.sh diff --git a/linuxrouterbgpnh.sh b/scripts/linux/linuxrouterbgpnh.sh similarity index 100% rename from linuxrouterbgpnh.sh rename to scripts/linux/linuxrouterbgpnh.sh diff --git a/linuxrouteronly.sh b/scripts/linux/linuxrouteronly.sh similarity index 100% rename from linuxrouteronly.sh rename to scripts/linux/linuxrouteronly.sh diff --git a/linuxrouterv2.sh b/scripts/linux/linuxrouterv2.sh similarity index 100% rename from linuxrouterv2.sh rename to scripts/linux/linuxrouterv2.sh diff --git a/WinRouter.ps1 b/scripts/windows/winrouter.ps1 similarity index 100% rename from WinRouter.ps1 rename to scripts/windows/winrouter.ps1 From badfe81159b4a251bd90a991cc50af69261646e2 Mon Sep 17 00:00:00 2001 From: dmauser Date: Wed, 12 Aug 2026 21:39:35 -0500 Subject: [PATCH 6/6] Fix CI: bicep lint accepts a single file, not a glob Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: cd9ed807-66b2-43e3-8bf9-ed7eb24a46dd --- .github/workflows/validate-templates.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/validate-templates.yml b/.github/workflows/validate-templates.yml index d6cc516..cb76261 100644 --- a/.github/workflows/validate-templates.yml +++ b/.github/workflows/validate-templates.yml @@ -25,7 +25,11 @@ jobs: bicep --version - name: Lint Bicep sources - run: bicep lint infra/bicep/*.bicep + run: | + for src in infra/bicep/*.bicep; do + echo "Linting $src" + bicep lint "$src" + done # The ARM JSON under infra/arm/ is generated, never hand-edited. Rebuild it # and fail if the result differs from what is committed.