Compare DNS record sets
Compare saved A, AAAA, and CNAME records using semantic identity instead of row order or spelling.
Normalise only the fields that have those semantics
These JSON exports contain IN-class A, AAAA, and CNAME records. Every owner and CNAME target is already an absolute ASCII DNS name, optionally ending in a single root dot. Lowercase those name fields and remove that optional dot. Do not apply this rule blindly to relative names or TXT content in a general zone-file parser.
A records use strict dotted IPv4. AAAA addresses can have different equivalent spellings; parse them as IPv6 and use the address object's canonical ToString() form. No DNS lookup is needed to parse an address.
([Net.IPAddress]::Parse('2001:0db8:0:0:0:0:0:1')).ToString()
Compare sets, not individual rows
A record's identity here is Owner, Type, and normalised Value. Multiple A records for the same owner form a set; row order and repeated identical rows do not create changes. Report entries missing from the observed set and unexpected entries absent from the expected set.
TTL is deliberately outside this comparison. TTL 0 is valid, and changes or inconsistencies in TTL deserve their own policy rather than being mistaken for a changed destination. This task will not report TTL-only differences.
All input is saved data. Successful comparison says nothing about live resolver caches, delegation, propagation, or which answer a client currently receives.
Go deeper
Get-Content '/home/user/dns-observed.json' -Raw | ConvertFrom-Json | Select-Object Owner,Type,Value,TTL
Put it into practice.
Dot-source AddressMath.ps1 and define Get-DnsRecordDrift -Expected -Observed for arrays of A, AAAA, and CNAME records. Normalise absolute ASCII names (case/root dot), IPv6 spelling, and type case; validate A addresses strictly. Compare unique Owner/Type/Value sets, ignoring TTL. Emit Change (Missing or Unexpected), Owner, Type, Value. Support empty arrays and reject unsupported record types.
See an explained solution
. '/home/user/AddressMath.ps1'
function Get-DnsRecordDrift {
param([AllowEmptyCollection()][object[]]$Expected,[AllowEmptyCollection()][object[]]$Observed)
function ConvertTo-RecordSet($Rows) {
$set = @{}
foreach ($row in $Rows) {
$owner = $row.Owner.TrimEnd('.').ToLowerInvariant()
$type = $row.Type.ToUpperInvariant()
$value = switch ($type) {
'A' { $null = ConvertTo-IPv4Number $row.Value; $row.Value }
'AAAA' { $ip=[Net.IPAddress]::Parse($row.Value); if ($ip.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetworkV6) { throw 'Expected IPv6 RDATA.' }; $ip.ToString() }
'CNAME' { $row.Value.TrimEnd('.').ToLowerInvariant() }
default { throw 'Unsupported record type.' }
}
$set["$owner|$type|$value"] = [pscustomobject]@{ Owner=$owner; Type=$type; Value=$value }
}
return $set
}
$wanted = ConvertTo-RecordSet $Expected
$seen = ConvertTo-RecordSet $Observed
foreach ($key in $wanted.Keys) { if (-not $seen.ContainsKey($key)) { [pscustomobject]@{ Change='Missing'; Owner=$wanted[$key].Owner; Type=$wanted[$key].Type; Value=$wanted[$key].Value } } }
foreach ($key in $seen.Keys) { if (-not $wanted.ContainsKey($key)) { [pscustomobject]@{ Change='Unexpected'; Owner=$seen[$key].Owner; Type=$seen[$key].Type; Value=$seen[$key].Value } } }
}
Normalised keys handle name case, equivalent IPv6 spellings, multiple answers, and duplicate rows. TTL-only changes remain outside this deliberately narrow record-identity review.