Skip to content

Instantly share code, notes, and snippets.

@machv
Last active July 31, 2020 13:35
Show Gist options
  • Save machv/c21aa50a3cc94e5fefaa2aad87548794 to your computer and use it in GitHub Desktop.
Save machv/c21aa50a3cc94e5fefaa2aad87548794 to your computer and use it in GitHub Desktop.
Synchronize Private DNS Zones in Azure between each other

Synchronize Azure Private DNS Zone records

Prerequisities

Make sure that in addition to Az PowerShell module you also have Az.PrivateDns module installed.

About

Function Sync-DnsZone synchronizes resource records from source DNS zone to destination DNS zone, if you want to achive two-way sync you need to execute the same function twice with switched source and destination.

Customization

Configuration of the sync scope is done by variable $synchronizeTypes where you can specify which resource records should be synchronized between zones.

Function uses Metadata of resource record to mark from which DNS Zone the record was synchronized, you can customize name of the Metadata property in variable $metadataSyncPropertyName. This metadata property is created only on destination resource records to mark that they have been synchronized from another zone.

Verbose output

For debugging purposes you can set $VerbosePreference variable to "SilentlyContinue" or set -Verbose parameter on Sync-DnsZone function.

Example usage

In the example below we are assuming that private DNS zones defined in array $zoneNames exists in all locations (formed by Subscription ID and Resource Group name) and should be synchronized between each other.

# These DNS zones should exist in all locations
$zoneNames = @(
    "privatelink.file.core.windows.net",
    "privatelink.blob.core.windows.net"
)

# Locations that contains all zones defined above
$hubs = @(
    [PSCustomObject]@{
        SubscriptionId = "43859f48-0000-0000-0000-84b2b43e8228" # A
        ResourceGroupName = "hub.zone1"
    },
    [PSCustomObject]@{
        SubscriptionId = "7193ebab-0000-0000-0000-6fe7b472f608" # B
        ResourceGroupName = "hub.zone2"
    },
    [PSCustomObject]@{
        SubscriptionId = "7193ebab-0000-0000-0000-6fe7b472f608" # C
        ResourceGroupName = "hub.zone3"
    }
)

# Generate all possible pairs of hubs for synchronization
for($i = 0; $i -lt $hubs.Count; $i++) {
    for($j = 0; $j -lt $hubs.Count; $j++) {
        if($i -eq $j) {
            continue # don't synchronize between same location
        }

        $source = $hubs[$i]
        $destination = $hubs[$j]
        Write-Information "Synchronizing [$i] $($source.ResourceGroupName) ($($source.SubscriptionId)) with [$j] $($destination.ResourceGroupName) ($($destination.SubscriptionId))"

        # Synchronize all zones between these hubs
        foreach($zone in $zoneNames) {
            Write-Information " * Zone $zone"
            Sync-DnsZone -SourceSubscriptionId $source.SubscriptionId -SourceResourceGroupName $source.ResourceGroupName -SourceZoneName $zone `
                         -DestinationSubscriptionId $destination.SubscriptionId -DestinationResourceGroupName $destination.ResourceGroupName -DestinationZoneName $zone
        }
    }
}

#region Functions
# Configuration values
$synchronizeTypes = "A", "CNAME"
$metadataSyncPropertyName = "recordSource"
function Get-DnsZoneInfo {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$ResourceGroupName,
[Parameter(Mandatory = $true)]
[string]$ZoneName,
[string]$SubscriptionId
)
process {
Write-Verbose "Loading zone $ZoneName in resource group $ResourceGroupName"
if($SubscriptionId) {
$currentContext = Get-AzContext
if($currentContext.Subscription.Id -ne $SubscriptionId) {
Write-Verbose "Switching context from $($currentContext.Subscription.Id) to $SubscriptionId"
Set-AzContext -Subscription $SubscriptionId | Out-Null
}
}
$zone = Get-AzPrivateDnsZone -ResourceGroupName $ResourceGroupName -Name $ZoneName -ErrorAction SilentlyContinue
if(-not $zone) {
return
}
$zoneInfo = [PSCustomObject]@{
Zone = $zone
Records = Get-AzPrivateDnsRecordSet -Zone $zone
RecordsToSync = @()
RecordsSyncedFromOther = @()
RecordsSkipped = @()
}
foreach($record in $zoneInfo.Records) {
if($record.RecordType -notin $synchronizeTypes) {
Write-Verbose " - skipping record '$($record.Name)' of type $($record.RecordType) as is not in allowed record types."
$zoneInfo.RecordsSkipped += $record
continue
}
if($record.Metadata -and $record.Metadata.ContainsKey($metadataSyncPropertyName)) {
$zoneInfo.RecordsSyncedFromOther += $record
} else {
$zoneInfo.RecordsToSync += $record
}
}
Write-Verbose "Records to synchronize: $(($zoneInfo.RecordsToSync | Measure-Object).Count)"
Write-Verbose "Records synchronized from other zones: $(($zoneInfo.RecordsSyncedFromOther | Measure-Object).Count)"
Write-Verbose "Records skipped: $(($zoneInfo.RecordsSkipped | Measure-Object).Count)"
# Return back to original context if needed
if($SubscriptionId -and $currentContext.Subscription.Id -ne $SubscriptionId) {
Write-Verbose "Switching context back to $($currentContext.Subscription.Id)"
Set-AzContext -Context $currentContext | Out-Null
}
$zoneInfo
}
}
function Sync-DnsZone {
[CmdletBinding()]
param(
[string]$SourceSubscriptionId,
[Parameter(Mandatory = $true)]
[string]$SourceResourceGroupName,
[Parameter(Mandatory = $true)]
[string]$SourceZoneName,
[string]$DestinationSubscriptionId,
[Parameter(Mandatory = $true)]
[string]$DestinationResourceGroupName,
[Parameter(Mandatory = $true)]
[string]$DestinationZoneName
)
process {
$sourceZone = Get-DnsZoneInfo -SubscriptionId $sourceSubscriptionId -ResourceGroupName $sourceResourceGroupName -ZoneName $sourceZoneName
if(-not $sourceZone) {
throw "Source zone $sourceZoneName in RG $SourceResourceGroupName not found"
}
$destinationZone = Get-DnsZoneInfo -SubscriptionId $destinationSubscriptionId -ResourceGroupName $destinationResourceGroupName -ZoneName $destinationZoneName
if(-not $destinationZone) {
throw "Source zone $destinationZoneName in RG $destinationResourceGroupName not found"
}
$recordsVerifiedInDestination = @()
$recordsToCreateInDestination = @()
$recordsToUpdateInDestination = @()
foreach($record in $sourceZone.RecordsToSync) {
$recordInDestination = $destinationZone.Records | Where-Object { $_.Name -eq $record.Name }
if(-not $recordInDestination) {
Write-Verbose " [+] record $($record.Name) is missing in destination zone"
$recordsToCreateInDestination += $record
} elseif (-not $recordInDestination.Metadata -or -not $recordInDestination.Metadata.ContainsKey($metadataSyncPropertyName)) {
Write-Warning " [!] record $($record.Name) already exists in destination zone but is not originating from this source zone -> skipping"
} elseif($recordInDestination.Metadata -and $recordInDestination.Metadata.ContainsKey($metadataSyncPropertyName) -and $recordInDestination.Metadata[$metadataSyncPropertyName] -eq $sourceZone.Zone.ResourceId) {
# record already exists and is originating from this source zone
$sourceRecords = [array]($record.Records | Select-Object -ExpandProperty Ipv4Address)
$destRecords = [array]($recordInDestination.Records | Select-Object -ExpandProperty Ipv4Address)
# compare if IPs match
$result = Compare-Object -ReferenceObject @($sourceRecords | Select-Object) -DifferenceObject @($destRecords | Select-Object)
if($result) {
Write-Verbose " [*] values in existing record $($record.Name) has changed, record should be updated"
$recordsToUpdateInDestination += @{ SourceRecord = $record; DestinationRecord = $recordInDestination }
} else {
Write-Verbose " [✔] record $($record.Name) is already synchronized"
}
$recordsVerifiedInDestination += $recordInDestination
} else {
Write-Verbose " [?] unexpected state of the record $($record.Name)"
}
}
$areChangesInDestinationPending = $recordsVerifiedInDestination.Count -gt 0 -or $recordsToCreateInDestination.Count -gt 0 -or $recordsToUpdateInDestination.Count -gt 0
# switch to destination subscription if are any pending changes on the destination
if($areChangesInDestinationPending -and $DestinationSubscriptionId) {
$currentContext = Get-AzContext
if($currentContext.Subscription.Id -ne $DestinationSubscriptionId) {
Write-Verbose "Switching context from $($currentContext.Subscription.Id) to $DestinationSubscriptionId"
Set-AzContext -Subscription $DestinationSubscriptionId | Out-Null
}
}
foreach($record in $recordsToCreateInDestination) {
Write-Verbose " [+] creating a new record $($record.Name) in destination zone"
$metadata = @{
$metadataSyncPropertyName = $sourceZone.Zone.ResourceId
}
New-AzPrivateDnsRecordSet -Name $record.Name -RecordType $record.RecordType -Zone $destinationZone.Zone -Ttl $record.Ttl -PrivateDnsRecord $record.Records -Metadata $metadata | Out-Null
}
foreach($record in $recordsToUpdateInDestination) {
Write-Verbose " [*] updating a record set for $($record.Name) in destination zone"
for($i = 0; $i -lt $record.DestinationRecord.Records.Count; $i++) {
Remove-AzPrivateDnsRecordConfig -RecordSet $record.DestinationRecord -Ipv4Address $recordInDestination.Records[$i].Ipv4Address | Out-Null
}
foreach($config in $record.SourceRecord.Records) {
Add-AzPrivateDnsRecordConfig -RecordSet $record.DestinationRecord -Ipv4Address $config.Ipv4Address | Out-Null
}
Set-AzPrivateDnsRecordSet -RecordSet $record.DestinationRecord | Out-Null
}
$recordsVerifiedInDestinationNames = $recordsVerifiedInDestination | Select-Object -ExpandProperty Name
foreach($record in $destinationZone.RecordsSyncedFromOther) {
if(-not $record.Metadata -or -not $record.Metadata.ContainsKey($metadataSyncPropertyName) -or $record.Metadata[$metadataSyncPropertyName] -ne $sourceZone.Zone.ResourceId) {
continue # we are interested only in records with metadata pointing to source zone
}
if($record.Name -notin $recordsVerifiedInDestinationNames) {
Write-Verbose " [-] removing record $($record.Name) from destination as was removed in source"
Remove-AzPrivateDnsRecordSet -RecordSet $record
}
}
# Return back to original context if needed
if($areChangesInDestinationPending -and $DestinationSubscriptionId -and $currentContext.Subscription.Id -ne $SubscriptionId) {
Write-Verbose "Switching context back to $($currentContext.Subscription.Id)"
Set-AzContext -Context $currentContext | Out-Null
}
}
}
#endregion
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment