From 0d307a1678896ec25cdced784af4f8806204801a Mon Sep 17 00:00:00 2001 From: Fernando Antivero Date: Wed, 13 May 2026 19:25:34 -0300 Subject: [PATCH 1/2] add e2e validation steps via VPN tunnel Document how to verify the deployment end-to-end from the mock on-premises VM through the VPN tunnel and firewall DNAT: - Option 1: Bastion RDP + browser to firewall private IP - Option 2: CLI using az vm run-command from on-prem VM Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- solutions/secure-hybrid-network/README.md | 27 +++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/solutions/secure-hybrid-network/README.md b/solutions/secure-hybrid-network/README.md index efb31f4b..fe2841d8 100644 --- a/solutions/secure-hybrid-network/README.md +++ b/solutions/secure-hybrid-network/README.md @@ -105,6 +105,33 @@ az deployment sub create -n secure-hybrid-network --location eastus2 --template- | localNetworkGateway | string | Name of the mock on-prem local network gateway. | local-gateway-moc-prem | | location | string | Location to be used for all resources. | rg location | +## Validate deployment + +After the deployment completes, verify end-to-end connectivity by accessing the IIS web server from the mock on-premises VM through the VPN tunnel. Traffic flows through the Azure Firewall via a DNAT rule that translates requests to the internal load balancer. + +### Option 1: Azure Bastion + +Connect to the mock on-premises virtual machine using the included Azure Bastion host, open a web browser, and navigate to the Azure Firewall's private IP address (`http://`). The firewall translates the request to the application's internal load balancer. + +### Option 2: CLI + +```azurecli-interactive +# Get the Azure Firewall private IP (DNAT entry point) +FW_IP=$(az network firewall show \ + -g rg-site-to-site-azure-network-eastus2 \ + -n AzureFirewall \ + --query "ipConfigurations[0].privateIpAddress" -o tsv) + +# From the mock on-prem VM, reach IIS through the VPN tunnel via firewall DNAT +az vm run-command invoke \ + -g rg-site-to-site-mock-prem-eastus2 \ + -n vm-windows \ + --command-id RunPowerShellScript \ + --scripts "Invoke-WebRequest -Uri http://$FW_IP -UseBasicParsing | Select-Object -Property StatusCode" +``` + +A successful response returns `StatusCode: 200`, confirming the full path: on-prem VM → VPN → hub → firewall (DNAT) → spoke → load balancer → VMSS (IIS). + ## Clean Up ```azurecli-interactive From 40dd11df2feed7dfed88a6bd23191e95c33ee0bb Mon Sep 17 00:00:00 2001 From: Fernando Antivero Date: Fri, 15 May 2026 15:12:42 -0300 Subject: [PATCH 2/2] fix DNAT validation with separate v2 deployment step Azure validates DNAT destinationAddresses against the firewall's assigned IP during resource creation, but the IP isn't available yet on fresh deploys. Extract DNAT into a separate deployment step (v2) that runs after the base infrastructure is provisioned. - Add azure-network-azuredeploy-v2.bicep/json using existing resources - Remove inline DNAT and firewallPrivateIp variable from base template - Update README with v2 deployment step and parameter table Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- solutions/secure-hybrid-network/README.md | 24 ++- .../azure-network-azuredeploy-v2.bicep | 115 ++++++++++++++ .../azure-network-azuredeploy-v2.json | 144 ++++++++++++++++++ 3 files changed, 281 insertions(+), 2 deletions(-) create mode 100644 solutions/secure-hybrid-network/nestedtemplates/azure-network-azuredeploy-v2.bicep create mode 100644 solutions/secure-hybrid-network/nestedtemplates/azure-network-azuredeploy-v2.json diff --git a/solutions/secure-hybrid-network/README.md b/solutions/secure-hybrid-network/README.md index fe2841d8..8cd2ec97 100644 --- a/solutions/secure-hybrid-network/README.md +++ b/solutions/secure-hybrid-network/README.md @@ -36,10 +36,21 @@ cd samples/solutions/secure-hybrid-network Run the following commands to initiate the deployment. When prompted, enter values for an admin username and password. These values are used to log into the included virtual machines. ```azurecli-interactive -# Resources will be created on deployment region +# Deploy the base infrastructure: hub, spoke, firewall, VPN, and VMSS workload az deployment sub create -n secure-hybrid-network --location eastus2 --template-file azuredeploy.bicep -p mocOnPremResourceGroup=rg-site-to-site-mock-prem-eastus2 azureNetworkResourceGroup=rg-site-to-site-azure-network-eastus2 ``` +Now that the on-premises site has joined the network, update the hub firewall with a DNAT rule so it can reach the spoke workloads: + +```azurecli-interactive +# Get the firewall and load balancer private IPs +FW_IP=$(az network firewall show -g rg-site-to-site-azure-network-eastus2 -n AzureFirewall --query "ipConfigurations[0].privateIPAddress" -o tsv) +LB_IP=$(az network lb frontend-ip list -g rg-site-to-site-azure-network-eastus2 --lb-name lb-internal --query "[0].privateIPAddress" -o tsv) + +# Add DNAT rules for on-premises to spoke traffic +az deployment group create -n firewallDnat -g rg-site-to-site-azure-network-eastus2 --template-file nestedtemplates/azure-network-azuredeploy-v2.bicep -p firewallName=AzureFirewall firewallPrivateIp=$FW_IP internalLoadBalancerPrivateIp=$LB_IP +``` + ## Solution deployment parameters **azuredeploy.bicep** @@ -105,6 +116,15 @@ az deployment sub create -n secure-hybrid-network --location eastus2 --template- | localNetworkGateway | string | Name of the mock on-prem local network gateway. | local-gateway-moc-prem | | location | string | Location to be used for all resources. | rg location | +**nestedtemplates/azure-network-azuredeploy-v2.bicep** + +| Parameter | Type | Description | Default | +|---|---|---|--| +| firewallName | string | Name of the Azure Firewall. | null | +| firewallPrivateIp | string | Private IP address of the firewall. | null | +| internalLoadBalancerPrivateIp | string | Private IP address of the internal load balancer. | null | +| location | string | Location for the resource. | rg location | + ## Validate deployment After the deployment completes, verify end-to-end connectivity by accessing the IIS web server from the mock on-premises VM through the VPN tunnel. Traffic flows through the Azure Firewall via a DNAT rule that translates requests to the internal load balancer. @@ -120,7 +140,7 @@ Connect to the mock on-premises virtual machine using the included Azure Bastion FW_IP=$(az network firewall show \ -g rg-site-to-site-azure-network-eastus2 \ -n AzureFirewall \ - --query "ipConfigurations[0].privateIpAddress" -o tsv) + --query "ipConfigurations[0].privateIPAddress" -o tsv) # From the mock on-prem VM, reach IIS through the VPN tunnel via firewall DNAT az vm run-command invoke \ diff --git a/solutions/secure-hybrid-network/nestedtemplates/azure-network-azuredeploy-v2.bicep b/solutions/secure-hybrid-network/nestedtemplates/azure-network-azuredeploy-v2.bicep new file mode 100644 index 00000000..26089079 --- /dev/null +++ b/solutions/secure-hybrid-network/nestedtemplates/azure-network-azuredeploy-v2.bicep @@ -0,0 +1,115 @@ +// Once an on-premises site joins the network, this template updates the hub +// firewall with a DNAT rule so inbound traffic can reach the spoke workloads. + +param location string = resourceGroup().location + +@description('Name of the Azure Firewall') +param firewallName string + +@description('Private IP address of the firewall') +param firewallPrivateIp string + +@description('Private IP address of the internal load balancer') +param internalLoadBalancerPrivateIp string + +@description('Name of the hub virtual network') +param hubVnetName string = 'vnet-hub' + +@description('Name of the firewall public IP') +param firewallPublicIpName string = 'pip-firewall' + +@description('Spoke network address prefix for source filtering') +param spokeAddressPrefix string = '10.100.0.0/16' + +resource hubVnet 'Microsoft.Network/virtualNetworks@2024-05-01' existing = { + name: hubVnetName +} + +resource firewallPublicIp 'Microsoft.Network/publicIPAddresses@2024-05-01' existing = { + name: firewallPublicIpName +} + +resource firewallDnat 'Microsoft.Network/azureFirewalls@2024-05-01' = { + name: firewallName + location: location + properties: { + sku: { + name: 'AZFW_VNet' + tier: 'Standard' + } + threatIntelMode: 'Alert' + ipConfigurations: [ + { + name: firewallName + properties: { + publicIPAddress: { + id: firewallPublicIp.id + } + subnet: { + id: resourceId('Microsoft.Network/virtualNetworks/subnets', hubVnet.name, 'AzureFirewallSubnet') + } + } + } + ] + applicationRuleCollections: [ + { + name: 'spoke-outbound' + properties: { + priority: 100 + action: { + type: 'Allow' + } + rules: [ + { + name: 'windows-update' + protocols: [ + { + protocolType: 'Https' + port: 443 + } + ] + targetFqdns: [ + '*.update.microsoft.com' + '*.windowsupdate.com' + '*.download.windowsupdate.com' + ] + sourceAddresses: [ + spokeAddressPrefix + ] + } + ] + } + } + ] + natRuleCollections: [ + { + name: 'dnat-onprem-to-spoke' + properties: { + priority: 100 + action: { + type: 'Dnat' + } + rules: [ + { + name: 'onprem-to-web' + protocols: [ + 'TCP' + ] + sourceAddresses: [ + '192.168.0.0/16' + ] + destinationAddresses: [ + firewallPrivateIp + ] + destinationPorts: [ + '80' + ] + translatedAddress: internalLoadBalancerPrivateIp + translatedPort: '80' + } + ] + } + } + ] + } +} diff --git a/solutions/secure-hybrid-network/nestedtemplates/azure-network-azuredeploy-v2.json b/solutions/secure-hybrid-network/nestedtemplates/azure-network-azuredeploy-v2.json new file mode 100644 index 00000000..3a105078 --- /dev/null +++ b/solutions/secure-hybrid-network/nestedtemplates/azure-network-azuredeploy-v2.json @@ -0,0 +1,144 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.39.26.7824", + "templateHash": "11434533286408150086" + } + }, + "parameters": { + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]" + }, + "firewallName": { + "type": "string", + "metadata": { + "description": "Name of the Azure Firewall" + } + }, + "firewallPrivateIp": { + "type": "string", + "metadata": { + "description": "Private IP address of the firewall" + } + }, + "internalLoadBalancerPrivateIp": { + "type": "string", + "metadata": { + "description": "Private IP address of the internal load balancer" + } + }, + "hubVnetName": { + "type": "string", + "defaultValue": "vnet-hub", + "metadata": { + "description": "Name of the hub virtual network" + } + }, + "firewallPublicIpName": { + "type": "string", + "defaultValue": "pip-firewall", + "metadata": { + "description": "Name of the firewall public IP" + } + }, + "spokeAddressPrefix": { + "type": "string", + "defaultValue": "10.100.0.0/16", + "metadata": { + "description": "Spoke network address prefix for source filtering" + } + } + }, + "resources": [ + { + "type": "Microsoft.Network/azureFirewalls", + "apiVersion": "2024-05-01", + "name": "[parameters('firewallName')]", + "location": "[parameters('location')]", + "properties": { + "sku": { + "name": "AZFW_VNet", + "tier": "Standard" + }, + "threatIntelMode": "Alert", + "ipConfigurations": [ + { + "name": "[parameters('firewallName')]", + "properties": { + "publicIPAddress": { + "id": "[resourceId('Microsoft.Network/publicIPAddresses', parameters('firewallPublicIpName'))]" + }, + "subnet": { + "id": "[resourceId('Microsoft.Network/virtualNetworks/subnets', parameters('hubVnetName'), 'AzureFirewallSubnet')]" + } + } + } + ], + "applicationRuleCollections": [ + { + "name": "spoke-outbound", + "properties": { + "priority": 100, + "action": { + "type": "Allow" + }, + "rules": [ + { + "name": "windows-update", + "protocols": [ + { + "protocolType": "Https", + "port": 443 + } + ], + "targetFqdns": [ + "*.update.microsoft.com", + "*.windowsupdate.com", + "*.download.windowsupdate.com" + ], + "sourceAddresses": [ + "[parameters('spokeAddressPrefix')]" + ] + } + ] + } + } + ], + "natRuleCollections": [ + { + "name": "dnat-onprem-to-spoke", + "properties": { + "priority": 100, + "action": { + "type": "Dnat" + }, + "rules": [ + { + "name": "onprem-to-web", + "protocols": [ + "TCP" + ], + "sourceAddresses": [ + "192.168.0.0/16" + ], + "destinationAddresses": [ + "[parameters('firewallPrivateIp')]" + ], + "destinationPorts": [ + "80" + ], + "translatedAddress": "[parameters('internalLoadBalancerPrivateIp')]", + "translatedPort": "80" + } + ] + } + } + ] + } + } + ] +} \ No newline at end of file