03 / ROUTING, DNS, AND TELEMETRY · LESSON 10

Compare DNS record sets

Compare saved A, AAAA, and CNAME records using semantic identity instead of row order or spelling.

15 min · learn at your pace

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

Read the official reference.

Try an example.ps1
Get-Content '/home/user/dns-observed.json' -Raw | ConvertFrom-Json | Select-Object Owner,Type,Value,TTL
YOUR TURN

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
One way to solve it.ps1
. '/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.