Common/Resolve-DnsRecord.ps1
|
<# .SYNOPSIS Cross-platform DNS record resolver for M365-Assess. .DESCRIPTION Wraps Resolve-DnsName (Windows) and dig (macOS/Linux) behind a unified interface so DNS lookups work on any platform. Returns PSCustomObjects with the same property shapes the rest of the codebase expects: - TXT records → .Strings ([string[]]) - CNAME records → .NameHost ([string]) .PARAMETER Name The DNS name to query (e.g. 'contoso.com', '_dmarc.contoso.com'). .PARAMETER Type Record type — TXT or CNAME. .PARAMETER Server Optional DNS server IP to query (e.g. '8.8.8.8'). .PARAMETER DnsOnly Accepted for call-site compatibility with Resolve-DnsName but ignored on the dig path (dig always uses DNS-only resolution). .EXAMPLE Resolve-DnsRecord -Name contoso.com -Type TXT Resolve-DnsRecord -Name '_dmarc.contoso.com' -Type TXT -Server 8.8.8.8 #> function Resolve-DnsRecord { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Name, [Parameter(Mandatory)] [ValidateSet('TXT', 'CNAME', 'MX')] [string]$Type, [string]$Server, [switch]$DnsOnly ) # ── One-time backend detection (cached for session) ────────────── if ($null -eq $script:DnsBackend) { if (Get-Command -Name Resolve-DnsName -ErrorAction SilentlyContinue) { $script:DnsBackend = 'ResolveDnsName' } elseif (Get-Command -Name dig -ErrorAction SilentlyContinue) { $script:DnsBackend = 'Dig' } else { $script:DnsBackend = 'None' Write-Warning 'Resolve-DnsRecord: Neither Resolve-DnsName (Windows) nor dig (macOS/Linux) is available. DNS lookups will be skipped. Install dig via: brew install bind (macOS) or apt install dnsutils (Linux).' } } # ── Windows: delegate to Resolve-DnsName ───────────────────────── if ($script:DnsBackend -eq 'ResolveDnsName') { $params = @{ Name = $Name Type = $Type DnsOnly = $true ErrorAction = $ErrorActionPreference } if ($Server) { $params['Server'] = $Server } return @(Resolve-DnsName @params) } # ── macOS / Linux: parse dig output ────────────────────────────── if ($script:DnsBackend -eq 'Dig') { $digArgs = @('+short', $Type, $Name) if ($Server) { $digArgs = @("@$Server") + $digArgs } try { $raw = & dig @digArgs 2>&1 if ($LASTEXITCODE -ne 0) { if ($ErrorActionPreference -eq 'Stop') { throw "dig query failed for $Name ($Type): $raw" } return $null } $lines = @($raw | Where-Object { $_ -and $_ -notmatch '^\s*$' -and $_ -notmatch '^;;' }) if ($lines.Count -eq 0) { return $null } switch ($Type) { 'TXT' { foreach ($line in $lines) { # dig +short returns TXT data in quotes, possibly # split across multiple quoted segments on one line. # Reassemble them into a single string array entry # to match Resolve-DnsName .Strings behaviour. $segments = @([regex]::Matches($line, '"([^"]*)"') | ForEach-Object { $_.Groups[1].Value }) if ($segments.Count -eq 0) { # Unquoted fallback (shouldn't happen with dig +short TXT) $segments = @($line.Trim()) } [PSCustomObject]@{ Name = $Name Type = 'TXT' Strings = [string[]]$segments } } } 'CNAME' { # dig +short CNAME returns a single line like: # selector1-contoso._domainkey.contoso.onmicrosoft.com. $target = $lines[0].TrimEnd('.') [PSCustomObject]@{ Name = $Name Type = 'CNAME' NameHost = $target } } 'MX' { # dig +short MX returns lines like: # 10 contoso-com.mail.protection.outlook.com. foreach ($line in $lines) { $parts = ($line.Trim()) -split '\s+', 2 if ($parts.Count -eq 2) { [PSCustomObject]@{ Name = $Name Type = 'MX' Preference = [int]$parts[0] NameExchange = $parts[1].TrimEnd('.') } } } } } } catch { if ($ErrorActionPreference -eq 'Stop') { throw } return $null } return } # ── No backend available ───────────────────────────────────────── if ($ErrorActionPreference -eq 'Stop') { throw "No DNS resolution backend available. Cannot resolve $Name ($Type)." } return $null } function Test-DnsZoneAvailable { <# .SYNOPSIS Returns $true if the DNS zone responds to queries, $false on SERVFAIL. .DESCRIPTION Probes the zone with an SOA query. A SERVFAIL response (Win32 error 9002 on Windows; status: SERVFAIL in dig output) means the zone is delegated but its authoritative nameservers are not responding. NXDOMAIN and no-records errors mean the nameservers replied successfully and are NOT treated as SERVFAIL. .PARAMETER Name DNS zone name to probe (e.g. 'contoso.com'). .PARAMETER Server Optional DNS server IP to query. .EXAMPLE if (-not (Test-DnsZoneAvailable -Name 'broken.example.com')) { Write-Warning 'Zone is not responding (SERVFAIL)' } #> [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory)] [string]$Name, [string]$Server ) # Ensure backend detection has run (mirrors Resolve-DnsRecord initialization) if ($null -eq $script:DnsBackend) { if (Get-Command -Name Resolve-DnsName -ErrorAction SilentlyContinue) { $script:DnsBackend = 'ResolveDnsName' } elseif (Get-Command -Name dig -ErrorAction SilentlyContinue) { $script:DnsBackend = 'Dig' } else { $script:DnsBackend = 'None' } } if ($script:DnsBackend -eq 'ResolveDnsName') { try { $params = @{ Name = $Name; Type = 'SOA'; DnsOnly = $true; ErrorAction = 'Stop' } if ($Server) { $params['Server'] = $Server } Resolve-DnsName @params | Out-Null return $true } catch { # Win32 error 9002 (DNS_ERROR_RCODE_SERVER_FAILURE) produces "server failure" # in the exception message. NXDOMAIN ("does not exist") and no-records errors # mean the nameservers replied, so the zone is considered available. return $_.Exception.Message -notmatch 'server failure|SERVFAIL' } } if ($script:DnsBackend -eq 'Dig') { try { $digArgs = @('+noall', '+comments', 'SOA', $Name) if ($Server) { $digArgs = @("@$Server") + $digArgs } $raw = (& dig @digArgs 2>&1) -join ' ' return $raw -notmatch 'status:\s+SERVFAIL' } catch { return $true # cannot determine; assume available } } return $true # no backend — assume available } |