When working with hybrid cloud architectures, DNS resolution can become a complex challenge, especially when dealing with Azure Private Link endpoints and distributed domain controllers. Recently, I encountered a scenario that required careful DNS configuration to ensure seamless connectivity between on-premises and cloud resources.
The Challenge
In my environment, I had domain controllers split between two locations:
- On-cloud domain controllers - Running in Azure with access to Azure DNS
- Off-cloud domain controllers - Running on-premises or in other cloud providers
The challenge arose when trying to resolve Azure Private Link endpoints from the off-cloud environment. Private Link endpoints use specific DNS zones (like blob.core.windows.net) that need to resolve to private IP addresses rather than public endpoints.
Conditional Forwarder Zones
The key to solving this problem lies in understanding the DNS resolution flow:
- Off-cloud clients query their local domain controllers for Private Link DNS records
- Off-cloud domain controllers forward these queries to on-cloud domain controllers via conditional forwarders
- On-cloud domain controllers use Azure DNS to resolve the Private Link endpoints to their private IP addresses
- The resolution flows back through the chain to the original client
This approach ensures that Private Link endpoints resolve correctly regardless of where the client is located in your hybrid infrastructure.
Important Configuration Requirements
There's a critical configuration detail that I learned the hard way: the conditional forwarder zones must be non-Active Directory integrated, why you say, well?
- Active Directory-integrated zones replicate across all domain controllers in the forest
- If you create AD-integrated conditional forwarders on off-cloud DCs, they'll replicate to on-cloud DCs
- This creates a circular reference where on-cloud DCs try to forward queries back to themselves
- Non-AD integrated zones remain local to the specific domain controllers where they're created
Automating the Process
Managing multiple conditional forwarder zones manually can be time-consuming and error-prone. To streamline this process, I created a PowerShell script that:
- Reads zone names from a text file
- Randomizes the master server order for load distribution
- Provides detailed logging and console output
The script takes a list of Private Link DNS zones and creates conditional forwarders pointing to the on-cloud domain controllers. Each zone gets a randomized order of master servers to distribute the DNS query load.
Implementation Steps
- Identify your Private Link zones - If you are using privatelink.blob.core.windows.net then the name of the zone is blob.core.windows.net
- Prepare your zone list - Create a text file with one zone name per line
- Configure the script - Update the master server IPs to point to your on-cloud domain controllers
- Execute on off-cloud DCs only - Run the script on your off-cloud domain controllers, ensuring the zones are created as non-AD integrated
Script : Create-DNSForwarders.ps1
Remember to update the variables in bold for your environment requirement this will include the $MasterServerPool, $ExcludeSitePattern to your unique requirements.
# DNS Conditional Forwarder Zone Creation Script
# Automatically targets domain controllers not in sites starting with "Azure"
# Can read zone names from file or process a single zone via parameter
param(
[string]$ZoneName = "",
[string]$ZoneFile = "",
[switch]$Help
)
# Show help if requested
if ($Help) {
Write-Host @"
DNS Conditional Forwarder Zone Creation Script
Usage:
.\script.ps1 # Uses conditionalfowarder.txt from script directory
.\script.ps1 -ZoneFile "c:\path\file.txt" # Uses custom zone file
.\script.ps1 -ZoneName "example.com" # Creates single zone
.\script.ps1 -Help # Shows this help
The script automatically targets all domain controllers in sites that do NOT start with "Azure"
"@
exit 0
}
# Import Active Directory module
try {
Import-Module ActiveDirectory -ErrorAction Stop
} catch {
Write-Error "Failed to import Active Directory module. Please ensure RSAT-AD-PowerShell is installed."
exit 1
}
# Set up variables
$ScriptPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
$DefaultZoneFile = Join-Path $ScriptPath "conditionalfowarder.txt"
$LogFile = Join-Path $ScriptPath "dns_forwarder_log_$(Get-Date -Format 'yyyyMMdd_HHmmss').log"
$MasterServerPool = @("<cloud_dc1>", "<cloud_dc2>", "<cloud_dc3>")
$ExcludeSitePattern = "BearCloud*"
# Function to write to both console and log file
function Write-LogOutput {
param(
[string]$Message,
[string]$Level = "INFO"
)
$TimeStamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$LogEntry = "[$TimeStamp] [$Level] $Message"
# Write to console with color coding
switch ($Level) {
"ERROR" { Write-Host $LogEntry -ForegroundColor Red }
"SUCCESS" { Write-Host $LogEntry -ForegroundColor Green }
"WARNING" { Write-Host $LogEntry -ForegroundColor Yellow }
default { Write-Host $LogEntry -ForegroundColor White }
}
# Write to log file
Add-Content -Path $LogFile -Value $LogEntry -ErrorAction SilentlyContinue
}
# Function to randomize master servers
function Get-RandomizedMasterServers {
param([array]$ServerPool)
# Create a copy and shuffle it
$ShuffledServers = $ServerPool | Sort-Object { Get-Random }
return $ShuffledServers
}
# Function to get domain controllers not in Azure sites
function Get-TargetDomainControllers {
try {
Write-LogOutput "Querying Active Directory for domain controllers..." "INFO"
# Get all domain controllers with their sites
$AllDCs = Get-ADDomainController -Filter * | Select-Object Name, HostName, Site
Write-LogOutput "Found $($AllDCs.Count) total domain controllers" "INFO"
# Filter out DCs in sites matching the exclude pattern
$TargetDCs = $AllDCs | Where-Object {
$_.Site -notlike $ExcludeSitePattern
}
Write-LogOutput "Filtered to $($TargetDCs.Count) domain controllers (excluding Azure* sites)" "SUCCESS"
# Log the DCs we're targeting
foreach ($DC in $TargetDCs) {
Write-LogOutput " Target DC: $($DC.Name) (Site: $($DC.Site))" "INFO"
}
# Log the DCs we're excluding
$ExcludedDCs = $AllDCs | Where-Object { $_.Site -like $ExcludeSitePattern }
if ($ExcludedDCs.Count -gt 0) {
Write-LogOutput "Excluded $($ExcludedDCs.Count) domain controllers in $ExcludeSitePattern sites:" "INFO"
foreach ($DC in $ExcludedDCs) {
Write-LogOutput " Excluded DC: $($DC.Name) (Site: $($DC.Site))" "WARNING"
}
}
return $TargetDCs
} catch {
Write-LogOutput "ERROR: Failed to query domain controllers - $($_.Exception.Message)" "ERROR"
return @()
}
}
# Start script execution
Write-LogOutput "=== DNS Conditional Forwarder Zone Creation Script Started ===" "INFO"
Write-LogOutput "Script Path: $ScriptPath" "INFO"
Write-LogOutput "Log File: $LogFile" "INFO"
# Determine zone source and get zone names
$ZoneNames = @()
if ($ZoneName) {
# Single zone mode
Write-LogOutput "Single zone mode: Processing zone '$ZoneName'" "INFO"
$ZoneNames = @($ZoneName)
} else {
# File mode
$FileToUse = if ($ZoneFile) { $ZoneFile } else { $DefaultZoneFile }
Write-LogOutput "File mode: Using zone file '$FileToUse'" "INFO"
# Check if zone file exists
if (-not (Test-Path $FileToUse)) {
Write-LogOutput "ERROR: Zone file '$FileToUse' not found!" "ERROR"
if (-not $ZoneFile) {
Write-LogOutput "Please ensure 'conditionalfowarder.txt' exists in the same directory as this script, or use -ZoneName parameter." "ERROR"
}
exit 1
}
# Read zone names from file
try {
$ZoneNames = Get-Content $FileToUse | Where-Object { $_.Trim() -ne "" } | ForEach-Object { $_.Trim() }
Write-LogOutput "Successfully read $($ZoneNames.Count) zone names from file" "SUCCESS"
} catch {
Write-LogOutput "ERROR: Failed to read zone file - $($_.Exception.Message)" "ERROR"
exit 1
}
}
if ($ZoneNames.Count -eq 0) {
Write-LogOutput "WARNING: No zone names found" "WARNING"
exit 0
}
# Get target domain controllers
$TargetDCs = Get-TargetDomainControllers
if ($TargetDCs.Count -eq 0) {
Write-LogOutput "ERROR: No target domain controllers found!" "ERROR"
exit 1
}
# Process each zone on each target domain controller
$TotalOperations = $ZoneNames.Count * $TargetDCs.Count
$SuccessCount = 0
$ErrorCount = 0
$CurrentOperation = 0
Write-LogOutput "=== Starting zone creation process ===" "INFO"
Write-LogOutput "Will create $($ZoneNames.Count) zones on $($TargetDCs.Count) domain controllers ($TotalOperations total operations)" "INFO"
foreach ($Zone in $ZoneNames) {
if ([string]::IsNullOrEmpty($Zone)) {
continue
}
Write-LogOutput "--- Processing zone: '$Zone' ---" "INFO"
foreach ($DC in $TargetDCs) {
$CurrentOperation++
# Get randomized master servers for this operation
$RandomizedMasterServers = Get-RandomizedMasterServers -ServerPool $MasterServerPool
Write-LogOutput "[$CurrentOperation/$TotalOperations] Creating zone '$Zone' on DC '$($DC.Name)' with master servers: $($RandomizedMasterServers -join ',')" "INFO"
try {
# Execute the DNS command on the specific domain controller
$Result = Add-DnsServerConditionalForwarderZone -Name $Zone -ComputerName $DC.Name -MasterServers $RandomizedMasterServers -PassThru
if ($Result) {
Write-LogOutput "SUCCESS: Created conditional forwarder zone '$Zone' on '$($DC.Name)'" "SUCCESS"
Write-LogOutput " Zone Name: $($Result.ZoneName)" "INFO"
Write-LogOutput " Zone Type: $($Result.ZoneType)" "INFO"
Write-LogOutput " Master Servers: $($Result.MasterServers -join ', ')" "INFO"
$SuccessCount++
}
} catch {
Write-LogOutput "ERROR: Failed to create zone '$Zone' on '$($DC.Name)' - $($_.Exception.Message)" "ERROR"
$ErrorCount++
}
# Add a small delay between operations
Start-Sleep -Milliseconds 200
}
}
# Summary
Write-LogOutput "=== Script Execution Completed ===" "INFO"
Write-LogOutput "Total operations: $TotalOperations" "INFO"
Write-LogOutput "Zones processed: $($ZoneNames.Count)" "INFO"
Write-LogOutput "Domain controllers targeted: $($TargetDCs.Count)" "INFO"
Write-LogOutput "Successfully created: $SuccessCount" "SUCCESS"
Write-LogOutput "Errors encountered: $ErrorCount" $(if ($ErrorCount -gt 0) { "ERROR" } else { "INFO" })
Write-LogOutput "Log file saved to: $LogFile" "INFO"
if ($ErrorCount -eq 0) {
Write-LogOutput "All operations completed successfully!" "SUCCESS"
exit 0
} else {
Write-LogOutput "Script completed with $ErrorCount errors. Please review the log file." "WARNING"
exit 1
}
Usage
# Process all zones from default file on all non-Azure DCs
.\Create-DNSForwarders.ps1
# Create single zone on all non-Azure DCs
.\Create-DNSForwarders.ps1 -ZoneName "blob2.core.windows.net"
# Use custom zone file
.\Create-DNSForwarders.ps1 -ZoneFile "C:\temp\my-zones.txt"
# Show help
.\Create-DNSForwarders.ps1 -Help
Conclusion
Hybrid cloud DNS management requires careful planning, especially when dealing with Azure Private Link endpoints. By implementing non-AD integrated conditional forwarders on off-cloud domain controllers, you can ensure consistent and reliable DNS resolution across your entire infrastructure.
The key takeaway is understanding that Azure handles Private Link resolution automatically at the network level. You only need to get the DNS queries to domain controllers running inside Azure - the rest happens automatically, regardless of their DNS forwarder configuration.
This approach has worked reliably in my environment and should scale well as you add more Private Link endpoints to your Azure infrastructure.