Intro

In this article, I will guide you through the process of dynamically adding the public IP address of an Azure DevOps hosted agent to a network security group (NSG) rule, allowing it to connect to a service on the network. I will show how to add WinRM to ensure my pipeline can access a virtual machine. Adding this NSG rule allows Packer to run and create the AVD image I want to build.

The process, in short, involves obtaining the public IP address from the running DevOps agent, adding the public IP address to the NSG rule, running Packer, and then removing the public IP address from the NSG once the process is complete, thereby securing the network again.

Bicep template for the NSG

I will be using Bicep code to update my NSG, as this allows me to have a relatively simple code that I can reuse for multiple projects. The code below will accept four inputs, with only two of them being mandatory. If I provide an IP address as a parameter, it will update the NSG to allow WinRM access from the IP provided. If I do not give an IP address, the code removes the WinRM rule. The code also blocks any inbound traffic, meaning no VNet-to-VNet traffic and no load balancer traffic is allowed. I believe this should be the standard setting in most cases, so it is my default. If I need traffic into my subnet, I will create a dedicated rule for it. There are exceptions, but secure by default is key.

param nsgName string 
param location string 
param allowedIp string = ''
param priority int = 500

resource nsg 'Microsoft.Network/networkSecurityGroups@2024-07-01' = {
  name: nsgName
  location: location
  properties: {
    securityRules: allowedIp != '' ? [
      {
        name: 'Allow_AzureDevOps_IP_for_WinRM'
        properties: {
          priority: priority
          direction: 'Inbound'
          access: 'Allow'
          protocol: 'TCP'
          sourceAddressPrefix: allowedIp
          sourcePortRange: '*'
          destinationAddressPrefix: '*'
          destinationPortRange: '5985-5986'
        }
      }
      {
        name: 'Deny_All_Inbound'
        properties: {
          priority: 4096
          direction: 'Inbound'
          access: 'Deny'
          protocol: '*'
          sourceAddressPrefix: '*'
          sourcePortRange: '*'
          destinationAddressPrefix: '*'
          destinationPortRange: '*'
        }
      }
    ] : [
      {
        name: 'Deny_All_Inbound'
        properties: {
          priority: 4096
          direction: 'Inbound'
          access: 'Deny'
          protocol: '*'
          sourceAddressPrefix: '*'
          sourcePortRange: '*'
          destinationAddressPrefix: '*'
          destinationPortRange: '*'
        }
      }
    ]
  }
}

When deployed the NSG will be configured as shown below.

Azure DevOps pipeline

I am using Azure DevOps pipelines with a yaml file. Yaml allows me to have all my pipelines as code, meaning I can easily recreate any pipeline from code if needed.

For this pipeline, I will fetch the public IP address from the agent and then export it as a variable that I can use later in the pipeline. I then use the variable for updating the NSG using the Bicep template I created.

This pipeline uses Packer, so the next step is to initialize Packer and run the build. The final step in the process is to clean up the NSG rules so that I don’t inadvertently open any doors to dynamic IP addresses.

trigger: none
pr: none

variables:
- group: Packer
- name: AzureServiceConnection
  value: 'SP-AzureDevOps'
- name: ResourceGroupName
  value: 'rg-avd-network-p'
- name: nsgName
  value: 'nsg-snet-avd-build'
- name: location
  value: 'westeurope'

jobs:
  - job: "Packer_Build"
    timeoutInMinutes: 0

    pool:
      vmImage: 'windows-latest'

    steps:
    - task: PowerShell@2
      displayName: 'Get Public IP Address'
      inputs:
        targetType: 'inline'
        script: |
          $publicIP = (Invoke-WebRequest -uri "http://ifconfig.me/ip").Content.Trim()
          Write-Host "Public IP Address: $publicIP"
          Write-Host "##vso[task.setvariable variable=PublicIP;isOutput=true]$publicIP"          
        pwsh: true
      name: 'GetIP'
      
    - task: AzurePowerShell@5
      displayName: 'Add NSG rule'
      inputs:
        azureSubscription: '$(AzureServiceConnection)'
        ScriptType: 'InlineScript'
        Inline: |
          # Get the public IP from previous step
          $publicIP = "$(GetIP.PublicIP)"
          Write-Host "Using Public IP: $publicIP for deployment"
          
          # Set deployment parameters
          $resourceGroupName = "$(ResourceGroupName)"
          $templateFile = "$(Build.SourcesDirectory)/Modules/network_security_group.bicep"
          $deploymentName = "packer-deployment-$(Build.BuildId)"
          
          # Create parameter object with IP address
          $templateParameters = @{
            allowedIp = $publicIP
            location = "$(location)"
            nsgName = "$(nsgName)"
          }
          
          # Deploy Bicep template
          try {
            Write-Host "Deploying Bicep template to Resource Group: $resourceGroupName"
            $deployment = New-AzResourceGroupDeployment `
              -ResourceGroupName $resourceGroupName `
              -TemplateFile $templateFile `
              -TemplateParameterObject $templateParameters `
              -Name $deploymentName `
              -Verbose
            
            Write-Host "Deployment completed successfully"
            Write-Host "Deployment State: $($deployment.ProvisioningState)"
            
            # Output deployment results for subsequent tasks
            if ($deployment.Outputs) {
              foreach ($output in $deployment.Outputs.GetEnumerator()) {
                $outputName = $output.Key
                $outputValue = $output.Value.Value
                Write-Host "##vso[task.setvariable variable=$outputName;isOutput=true]$outputValue"
                Write-Host "Output $outputName = $outputValue"
              }
            }
          }
          catch {
            Write-Error "Deployment failed: $($_.Exception.Message)"
            throw
          }          
        azurePowerShellVersion: 'LatestVersion'
        pwsh: true
      name: 'DeployBicep'
      
    - task: PowerShell@2
      displayName: 'Initialize Packer'
      inputs:
        targetType: 'inline'
        script: 'packer init Packer/templates/w11-avd/init.pkr.hcl'
        pwsh: true
        
    - task: PowerShell@2
      displayName: 'Build Image'
      inputs:
        targetType: 'inline'
        script: |          
          packer build `
            -var client_id=$(ClientId) `
            -var client_secret=$(clientSecret) `
            Packer/templates/w11-avd/
        pwsh: true
        
    - task: AzurePowerShell@5
      displayName: 'Cleanup NSG rules'
      inputs:
        azureSubscription: '$(AzureServiceConnection)'
        ScriptType: 'InlineScript'
        Inline: |          
          # Set deployment parameters
          $resourceGroupName = "$(ResourceGroupName)"
          $templateFile = "$(Build.SourcesDirectory)/Modules/network_security_group.bicep"
          $deploymentName = "packer-deployment-$(Build.BuildId)"
          
          # Create parameter object with IP address
          $templateParameters = @{
            location = "$(location)"
            nsgName = "$(nsgName)"
          }
          
          # Deploy Bicep template
          try {
            Write-Host "Deploying Bicep template to Resource Group: $resourceGroupName"
            $deployment = New-AzResourceGroupDeployment `
              -ResourceGroupName $resourceGroupName `
              -TemplateFile $templateFile `
              -TemplateParameterObject $templateParameters `
              -Name $deploymentName `
              -Verbose
            
            Write-Host "Deployment completed successfully"
            Write-Host "Deployment State: $($deployment.ProvisioningState)"
            
            # Output deployment results for subsequent tasks
            if ($deployment.Outputs) {
              foreach ($output in $deployment.Outputs.GetEnumerator()) {
                $outputName = $output.Key
                $outputValue = $output.Value.Value
                Write-Host "##vso[task.setvariable variable=$outputName;isOutput=true]$outputValue"
                Write-Host "Output $outputName = $outputValue"
              }
            }
          }
          catch {
            Write-Error "Deployment failed: $($_.Exception.Message)"
            throw
          }
        azurePowerShellVersion: 'LatestVersion'
        pwsh: true
      name: 'Cleanup'
      condition: always() # Ensure cleanup runs even if previous steps fail

The images below show the pipeline logs, where I can see the public IP address of the DevOps agent and how it is utilizing the Bicep parameters.

Summary

This post is brief, but I believe it’s a good one to ensure your environment is as safe as possible. Besides having NSG rules in place to protect your environment, you also need to consider whether a VNet peering is required, and if so, use Azure Firewall (or any other firewall) to protect traffic between VNets. In my environment, the network is not peered, but I still want to protect it since I have a storage account with installation files and licenses that is only accessible from this VNet.

Using these dynamic rules ensures that the network is exposed as little as possible and for as short a time as possible. The Azure DevOps pipeline will always run the cleanup step and remove any NSG rules created during the pipeline, ensuring that even if a Packer build fails, the NSG will be updated and locked down upon pipeline completion.

This article describes how to update an NSG, but the same process can be applied to any service in Azure that uses IP restrictions, such as Azure Firewall, NAT Gateway, or Storage Accounts.

I hope this article offers you some valuable insights. You are always welcome to reach out to me on LinkedIn or through other social media channels.