When managing critical domains, monitoring DNS records for unauthorized or unexpected changes is essential. A single misconfigured A record, modified CNAME, or altered email security record can cause significant service disruption. I needed a solution that would actively monitor these changes and alert me before they became problems.
Example Alert
What the Script Monitors
The script monitors several categories of DNS records:
Infrastructure Records:
- A records (IPv4 addresses)
- CNAME records (canonical names)
- DNSSEC validation (DNSKEY and DS records)
Email Security Records:
- MX records (mail exchange)
- SPF records (Sender Policy Framework)
- DMARC records (Domain-based Message Authentication)
- DKIM records (DomainKeys Identified Mail)
How It Works
The script operates on a baseline comparison model. On the first run, it queries all specified DNS records and stores them in a JSON baseline file. Subsequent runs compare live DNS values against this baseline and alert when discrepancies are detected.
Initial Baseline Creation
Running the script for the first time creates the baseline:
.\Check-DNSHealth.ps1 -DomainName "a6n.co.uk"
This generates a JSON file containing all current DNS values:
{
"Domain": "a6n.co.uk",
"Timestamp": "2025-10-03 14:30:22",
"DNSSEC": {
"Enabled": true,
"HasDNSKEY": true,
"HasDS": true
},
"Subdomains": {
"www": {
"A": ["34.55.229.12"],
"CNAME": []
}
},
"MX": [
{
"Preference": 10,
"MailExchange": "mail.a6n.co.uk"
}
]
}
Monitoring for Changes
The script checks DNS records by querying authoritative nameservers and comparing results to the baseline. When a difference is detected, it generates an alert but does not update the baseline automatically.
This was a deliberate design choice. If the baseline updated automatically after detecting a change, I would only receive a single alert. If that email was missed or filtered, an unauthorized DNS change could go unnoticed indefinitely.
The Approval Mechanism
When changes are detected, the script sends an email alert but leaves the baseline unchanged. The alert will continue on every subsequent run until the changes are explicitly approved.
To approve changes and update the baseline:
.\Check-DNSHealth.ps1 -DomainName "a6n.co.uk" -UpdateBaseline
This command tells the script: "I have reviewed these DNS changes and they are authorized. Update the baseline to reflect the new values."
Email Rate Limiting
To prevent inbox flooding during ongoing DNS issues, the script includes built-in rate limiting. By default, it sends a maximum of 4 alert emails per hour. This ensures persistence without spam.
# Run with custom rate limit (10 emails per hour)
.\Check-DNSHealth.ps1 -DomainName "example.com" `
-SMTPServer "smtprelay.internal.local" `
-EmailFrom "dns-alerts@example.com" `
-EmailTo "admin@example.com" `
-MaxEmailsPerHour 10
The rate limit counter resets on a rolling 60-minute window. If the limit is reached, the script displays:
Email rate limit reached (4/4 in last hour)
Next email available in approximately 23 minute(s)
Configuring Monitored Records
Subdomain Monitoring
By default, the script monitors these subdomains: adfs
, autodiscover
, remote
, www
, www2
. To monitor custom subdomains:
.\Check-DNSHealth.ps1 -DomainName "a6n.co.uk" `
-Subdomains @("mail","vpn","portal","api")
DKIM Selector Configuration
DKIM records require explicit selector specification. The script defaults to selector1 and selector2, but custom selectors are supported:
.\Check-DNSHealth.ps1 -DomainName "example.com" `
-DKIMSelectors @("selector1","selector2","google")
SMTP Configuration
The script supports various SMTP configurations. For internal relay servers that don't require authentication:
.\Check-DNSHealth.ps1 -DomainName "a6n.co.uk" `
-SMTPServer "smtprelay.bear.local" `
-EmailFrom "dns-monitor@bear.local" `
-EmailTo "lee@bear.local"
For external SMTP servers requiring SSL/TLS (like Office 365):
.\Check-DNSHealth.ps1 -DomainName "example.com" `
-SMTPServer "smtp.office365.com" `
-SMTPPort 587 `
-UseSSL `
-EmailFrom "dns-alerts@bythepowerofgreyskull.com" `
-EmailTo "lee@bythepowerofgreyskull.com"
Record Comparison Logic
The script implements intelligent comparison logic to avoid false positives:
- MX Records: Sorted before comparison to prevent alerts when multiple MX records are returned in different orders by DNS servers.
- A/CNAME Records: Sorted alphabetically to handle round-robin DNS configurations.
- TXT Records: Compared as concatenated strings to handle multi-string TXT records correctly.
Scheduled Monitoring
For continuous monitoring, I schedule the script to run every 15 minutes:
$action = New-ScheduledTaskAction -Execute "powershell.exe" `
-Argument "-File C:\Scripts\Check-DNSHealth.ps1 -DomainName 'a6n.co.uk' -SMTPServer 'smtprelay.bear.local' -EmailFrom 'dns-report@bear.local' -EmailTo 'lee@bear.local'"
$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Minutes 15) -RepetitionDuration ([TimeSpan]::MaxValue)
Register-ScheduledTask -TaskName "DNS Monitor - example.com" -Action $action -Trigger $trigger -User "SYSTEM"
DNSSEC Validation
The script validates DNSSEC by checking for both DNSKEY and DS records:
# Checks for DNSKEY at the domain
$dnskey = Resolve-DnsName -Name $Domain -Type DNSKEY -DnssecOk
# Checks for DS records at parent zone
$ds = Resolve-DnsName -Name $Domain -Type DS -Server "8.8.8.8"
DNSSEC is considered enabled if either DNSKEY or DS records are present. The script reports:
DNSSEC Enabled: True
HasDNSKEY: True
HasDS: True
DNSKEYCount: 2
Script Workflow
- Initial setup: Run script to create baseline
- Schedule monitoring: Configure scheduled task to run every 15 minutes
- Receive alert: DNS change detected, email sent
- Investigate: Review the changes in the email
- Approve or remediate:
- If legitimate: Run with
-UpdateBaseline
to approve - If unauthorized: Remediate DNS, alert will stop automatically when records match baseline again
- If legitimate: Run with
The approval mechanism ensures that I cannot ignore DNS changes. The alerts continue until I explicitly acknowledge them, providing a forcing function for security review.
Command Syntax Reference
Basic Syntax
.\Check-DNSHealth.ps1 -DomainName <string>
[-Subdomains <string[]>]
[-DKIMSelectors <string[]>]
[-SMTPServer <string>]
[-SMTPPort <int>]
[-UseSSL]
[-EmailFrom <string>]
[-EmailTo <string[]>]
[-UpdateBaseline]
[-MaxEmailsPerHour <int>]
Script : Check-DNSHealth.ps1
<#
.SYNOPSIS
DNS Health Monitoring Script with DNSSEC validation and baseline comparison
.DESCRIPTION
Monitors DNS records including DNSSEC, A, CNAME, MX, TXT (SPF, DMARC, DKIM)
Creates baseline on first run, compares subsequent runs, and generates HTML email alerts
Requires -UpdateBaseline switch to approve changes and update baseline
Includes email rate limiting to prevent spam
.PARAMETER DomainName
The domain name to check (e.g., example.com)
.PARAMETER Subdomains
Array of subdomains to check (default: adfs, autodiscover, remote, www, www2)
.PARAMETER DKIMSelectors
Array of DKIM selectors to check (default: mailjet, pps1)
.PARAMETER SMTPServer
SMTP server for sending email alerts
.PARAMETER SMTPPort
SMTP port (default: 25)
.PARAMETER UseSSL
Use SSL for SMTP connection
.PARAMETER EmailFrom
Sender email address
.PARAMETER EmailTo
Recipient email address(es)
.PARAMETER UpdateBaseline
Approve current DNS values and update baseline (no email sent)
.PARAMETER MaxEmailsPerHour
Maximum number of alert emails to send per hour (default: 4)
.EXAMPLE
.\Check-DNSHealth.ps1 -DomainName "contoso.com"
.EXAMPLE
.\Check-DNSHealth.ps1 -DomainName "contoso.com" -SMTPServer "smtprelay.internal.local" -EmailFrom "alerts@contoso.com" -EmailTo "admin@contoso.com"
.EXAMPLE
.\Check-DNSHealth.ps1 -DomainName "contoso.com" -UpdateBaseline
#>
[CmdletBinding()]
param(
[Parameter(Mandatory=$true)]
[string]$DomainName,
[Parameter(Mandatory=$false)]
[string[]]$Subdomains = @("adfs", "autodiscover", "remote", "www", "www2"),
[Parameter(Mandatory=$false)]
[string[]]$DKIMSelectors = @("mailjet", "pps1"),
[Parameter(Mandatory=$false)]
[string]$SMTPServer,
[Parameter(Mandatory=$false)]
[int]$SMTPPort = 25,
[Parameter(Mandatory=$false)]
[switch]$UseSSL,
[Parameter(Mandatory=$false)]
[string]$EmailFrom,
[Parameter(Mandatory=$false)]
[string[]]$EmailTo,
[Parameter(Mandatory=$false)]
[switch]$UpdateBaseline,
[Parameter(Mandatory=$false)]
[int]$MaxEmailsPerHour = 4
)
# File paths
$scriptPath = Split-Path -Parent $MyInvocation.MyCommand.Path
$baselineFile = Join-Path $scriptPath "$($DomainName -replace '\.','_')_baseline.json"
$emailLogFile = Join-Path $scriptPath "$($DomainName -replace '\.','_')_email_log.json"
# Initialize results object
$results = @{
Domain = $DomainName
Timestamp = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss")
DNSSEC = @{}
Subdomains = @{}
MX = @()
SPF = @()
DMARC = @()
DKIM = @{}
}
#region Functions
# Email rate limiting function
function Test-EmailRateLimit {
param(
[string]$LogFile,
[int]$MaxPerHour
)
$now = Get-Date
$oneHourAgo = $now.AddHours(-1)
if(Test-Path $LogFile) {
try {
$emailLog = Get-Content $LogFile | ConvertFrom-Json
$recentEmails = $emailLog | Where-Object {
[DateTime]$_.Timestamp -gt $oneHourAgo
}
$emailCount = @($recentEmails).Count
if($emailCount -ge $MaxPerHour) {
$oldestRecentEmail = ($recentEmails | Sort-Object Timestamp | Select-Object -First 1).Timestamp
$waitMinutes = [math]::Ceiling((([DateTime]$oldestRecentEmail).AddHours(1) - $now).TotalMinutes)
Write-Host " Email rate limit reached ($emailCount/$MaxPerHour in last hour)" -ForegroundColor Yellow
Write-Host " Next email available in approximately $waitMinutes minute(s)" -ForegroundColor Yellow
return $false
}
return $true
}
catch {
Write-Host " Warning: Could not read email log, allowing email" -ForegroundColor Yellow
return $true
}
}
return $true
}
# Log email sent
function Add-EmailLog {
param([string]$LogFile, [string]$Domain)
$emailLog = @()
if(Test-Path $LogFile) {
try {
$existingLog = Get-Content $LogFile -Raw | ConvertFrom-Json
# Convert to array if it's not already
if($existingLog -is [array]) {
$emailLog = @($existingLog)
} else {
$emailLog = @($existingLog)
}
}
catch {
$emailLog = @()
}
}
# Add new entry
$newEntry = @{
Timestamp = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss")
Domain = $Domain
}
$emailLog = $emailLog + $newEntry
# Keep only last 24 hours of logs
$oneDayAgo = (Get-Date).AddDays(-1)
$emailLog = @($emailLog | Where-Object {
[DateTime]$_.Timestamp -gt $oneDayAgo
})
# Save back to file
$emailLog | ConvertTo-Json -Depth 10 | Out-File $LogFile -Encoding UTF8
}
# Function to check DNSSEC
function Test-DNSSEC {
param([string]$Domain)
Write-Host "Checking DNSSEC..." -ForegroundColor Yellow
try {
$dnskey = Resolve-DnsName -Name $Domain -Type DNSKEY -DnssecOk -ErrorAction SilentlyContinue
$ds = Resolve-DnsName -Name $Domain -Type DS -Server "8.8.8.8" -ErrorAction SilentlyContinue
$dnssecEnabled = ($null -ne $dnskey) -or ($null -ne $ds)
$dnssecInfo = @{
Enabled = $dnssecEnabled
HasDNSKEY = ($null -ne $dnskey)
HasDS = ($null -ne $ds)
DNSKEYCount = if($dnskey){@($dnskey).Count}else{0}
}
Write-Host " DNSSEC Enabled: $dnssecEnabled" -ForegroundColor $(if($dnssecEnabled){"Green"}else{"Red"})
return $dnssecInfo
}
catch {
Write-Host " Error checking DNSSEC: $($_.Exception.Message)" -ForegroundColor Red
return @{Enabled = $false; Error = $_.Exception.Message}
}
}
# Function to check subdomain records
function Get-SubdomainRecords {
param([string]$Subdomain, [string]$Domain)
$fqdn = if($Subdomain -eq "@") { $Domain } else { "$Subdomain.$Domain" }
try {
$records = @{
FQDN = $fqdn
A = @()
CNAME = @()
}
$aRecords = Resolve-DnsName -Name $fqdn -Type A -ErrorAction SilentlyContinue
if($aRecords) {
$records.A = $aRecords | Where-Object {$_.Type -eq "A"} | ForEach-Object { $_.IPAddress }
}
$cnameRecords = Resolve-DnsName -Name $fqdn -Type CNAME -ErrorAction SilentlyContinue
if($cnameRecords) {
$records.CNAME = $cnameRecords | Where-Object {$_.Type -eq "CNAME"} | ForEach-Object { $_.NameHost }
}
return $records
}
catch {
return @{FQDN = $fqdn; Error = $_.Exception.Message}
}
}
# Function to get MX records
function Get-MXRecords {
param([string]$Domain)
Write-Host "Checking MX records..." -ForegroundColor Yellow
try {
$mx = Resolve-DnsName -Name $Domain -Type MX -ErrorAction SilentlyContinue
$mxRecords = $mx | Where-Object {$_.Type -eq "MX"} | ForEach-Object {
@{
Preference = $_.Preference
MailExchange = $_.NameExchange
}
} | Sort-Object Preference
Write-Host " Found $(@($mxRecords).Count) MX record(s)" -ForegroundColor Green
return $mxRecords
}
catch {
Write-Host " Error checking MX: $($_.Exception.Message)" -ForegroundColor Red
return @()
}
}
# Function to get SPF records
function Get-SPFRecords {
param([string]$Domain)
Write-Host "Checking SPF records..." -ForegroundColor Yellow
try {
$txt = Resolve-DnsName -Name $Domain -Type TXT -ErrorAction SilentlyContinue
$spfRecords = $txt | Where-Object {$_.Strings -like "v=spf1*"} | ForEach-Object { $_.Strings -join "" }
Write-Host " Found $(@($spfRecords).Count) SPF record(s)" -ForegroundColor Green
return $spfRecords
}
catch {
Write-Host " Error checking SPF: $($_.Exception.Message)" -ForegroundColor Red
return @()
}
}
# Function to get DMARC records
function Get-DMARCRecords {
param([string]$Domain)
Write-Host "Checking DMARC records..." -ForegroundColor Yellow
try {
$dmarcDomain = "_dmarc.$Domain"
$txt = Resolve-DnsName -Name $dmarcDomain -Type TXT -ErrorAction SilentlyContinue
$dmarcRecords = $txt | Where-Object {$_.Strings -like "v=DMARC1*"} | ForEach-Object { $_.Strings -join "" }
Write-Host " Found $(@($dmarcRecords).Count) DMARC record(s)" -ForegroundColor Green
return $dmarcRecords
}
catch {
Write-Host " Error checking DMARC: $($_.Exception.Message)" -ForegroundColor Red
return @()
}
}
# Function to get DKIM records
function Get-DKIMRecords {
param([string]$Selector, [string]$Domain)
try {
$dkimDomain = "$Selector._domainkey.$Domain"
$txt = Resolve-DnsName -Name $dkimDomain -Type TXT -ErrorAction SilentlyContinue
$dkimRecord = $txt | Where-Object {$_.Strings -like "*v=DKIM1*"} | ForEach-Object { $_.Strings -join "" }
return @{
Selector = $Selector
Record = if($dkimRecord){$dkimRecord}else{$null}
Found = ($null -ne $dkimRecord)
}
}
catch {
return @{
Selector = $Selector
Record = $null
Found = $false
Error = $_.Exception.Message
}
}
}
#endregion
# Main Script Start
Write-Host "=== DNS Health Check for $DomainName ===" -ForegroundColor Cyan
Write-Host "Timestamp: $($results.Timestamp)" -ForegroundColor Gray
if($UpdateBaseline) {
Write-Host "Mode: APPROVAL - Will update baseline after checks" -ForegroundColor Magenta
} else {
Write-Host "Mode: MONITORING - Baseline will NOT be updated (use -UpdateBaseline to approve changes)" -ForegroundColor Yellow
}
Write-Host ""
# Run all DNS checks
$results.DNSSEC = Test-DNSSEC -Domain $DomainName
Write-Host ""
Write-Host "Checking subdomain records..." -ForegroundColor Yellow
foreach($sub in $Subdomains) {
$subRecords = Get-SubdomainRecords -Subdomain $sub -Domain $DomainName
$results.Subdomains[$sub] = $subRecords
Write-Host " $sub : A=$(@($subRecords.A).Count) CNAME=$(@($subRecords.CNAME).Count)" -ForegroundColor Green
}
Write-Host ""
$results.MX = Get-MXRecords -Domain $DomainName
Write-Host ""
$results.SPF = Get-SPFRecords -Domain $DomainName
Write-Host ""
$results.DMARC = Get-DMARCRecords -Domain $DomainName
Write-Host ""
Write-Host "Checking DKIM records..." -ForegroundColor Yellow
foreach($selector in $DKIMSelectors) {
$dkimResult = Get-DKIMRecords -Selector $selector -Domain $DomainName
$results.DKIM[$selector] = $dkimResult
$status = if($dkimResult.Found){"Found"}else{"Not Found"}
$color = if($dkimResult.Found){"Green"}else{"Yellow"}
Write-Host " $selector : $status" -ForegroundColor $color
}
# Check if baseline exists and compare
if(Test-Path $baselineFile) {
Write-Host ""
Write-Host "=== Comparing with Baseline ===" -ForegroundColor Cyan
$baseline = Get-Content $baselineFile | ConvertFrom-Json
$differences = @()
# Compare DNSSEC
if($results.DNSSEC.Enabled -ne $baseline.DNSSEC.Enabled) {
$differences += @{
Category = "DNSSEC"
Field = "Enabled"
Baseline = $baseline.DNSSEC.Enabled
Current = $results.DNSSEC.Enabled
}
}
# Compare Subdomains
foreach($sub in $Subdomains) {
$currentA = $results.Subdomains.$sub.A | Sort-Object
$baselineA = $baseline.Subdomains.$sub.A | Sort-Object
if(($currentA -join ",") -ne ($baselineA -join ",")) {
$differences += @{
Category = "Subdomain"
Field = "$sub (A Records)"
Baseline = ($baselineA -join ", ")
Current = ($currentA -join ", ")
}
}
$currentCNAME = $results.Subdomains.$sub.CNAME | Sort-Object
$baselineCNAME = $baseline.Subdomains.$sub.CNAME | Sort-Object
if(($currentCNAME -join ",") -ne ($baselineCNAME -join ",")) {
$differences += @{
Category = "Subdomain"
Field = "$sub (CNAME Records)"
Baseline = ($baselineCNAME -join ", ")
Current = ($currentCNAME -join ", ")
}
}
}
# Compare MX (sort to avoid false positives)
$currentMX = ($results.MX | ForEach-Object {"$($_.Preference):$($_.MailExchange)"} | Sort-Object) -join ","
$baselineMX = ($baseline.MX | ForEach-Object {"$($_.Preference):$($_.MailExchange)"} | Sort-Object) -join ","
if($currentMX -ne $baselineMX) {
$differences += @{
Category = "MX"
Field = "MX Records"
Baseline = $baselineMX
Current = $currentMX
}
}
# Compare SPF
if(($results.SPF -join "") -ne ($baseline.SPF -join "")) {
$differences += @{
Category = "SPF"
Field = "SPF Record"
Baseline = ($baseline.SPF -join "")
Current = ($results.SPF -join "")
}
}
# Compare DMARC
if(($results.DMARC -join "") -ne ($baseline.DMARC -join "")) {
$differences += @{
Category = "DMARC"
Field = "DMARC Record"
Baseline = ($baseline.DMARC -join "")
Current = ($results.DMARC -join "")
}
}
# Compare DKIM
foreach($selector in $DKIMSelectors) {
if($results.DKIM.$selector.Record -ne $baseline.DKIM.$selector.Record) {
$differences += @{
Category = "DKIM"
Field = "$selector Selector"
Baseline = $baseline.DKIM.$selector.Record
Current = $results.DKIM.$selector.Record
}
}
}
# Handle differences
if($differences.Count -gt 0) {
Write-Host ""
Write-Host "⚠ CHANGES DETECTED: $($differences.Count) difference(s) found!" -ForegroundColor Red
foreach($diff in $differences) {
Write-Host " [$($diff.Category)] $($diff.Field)" -ForegroundColor Yellow
Write-Host " Baseline: $($diff.Baseline)" -ForegroundColor Gray
Write-Host " Current: $($diff.Current)" -ForegroundColor White
}
if($UpdateBaseline) {
Write-Host ""
Write-Host "✓ -UpdateBaseline specified: Approving changes and updating baseline..." -ForegroundColor Green
$results | ConvertTo-Json -Depth 10 | Out-File $baselineFile -Encoding UTF8
Write-Host " Baseline updated: $baselineFile" -ForegroundColor Green
Write-Host " These changes are now approved and will not trigger future alerts" -ForegroundColor Gray
}
else {
Write-Host ""
Write-Host "⚠ Baseline NOT updated (monitoring mode)" -ForegroundColor Yellow
Write-Host " These changes will continue to generate alerts until approved" -ForegroundColor Gray
Write-Host " To approve these changes, run:" -ForegroundColor Cyan
Write-Host " .\Check-DNSHealth.ps1 -DomainName `"$DomainName`" -UpdateBaseline" -ForegroundColor White
# Send email if configured and not in update mode
if($SMTPServer -and $EmailFrom -and $EmailTo) {
Write-Host ""
if(Test-EmailRateLimit -LogFile $emailLogFile -MaxPerHour $MaxEmailsPerHour) {
Write-Host "Generating email alert..." -ForegroundColor Yellow
$htmlBody = @"
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background-color: #f5f5f5; margin: 0; padding: 20px; }
.container { max-width: 800px; margin: 0 auto; background-color: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.header { background-color: #d32f2f; color: white; padding: 30px; border-radius: 8px 8px 0 0; }
.header h1 { margin: 0; font-size: 24px; font-weight: 500; }
.header p { margin: 10px 0 0 0; opacity: 0.9; font-size: 14px; }
.content { padding: 30px; }
.alert-box { background-color: #fff3e0; border-left: 4px solid #ff9800; padding: 15px; margin-bottom: 25px; border-radius: 4px; }
.alert-box strong { color: #e65100; }
.approval-box { background-color: #e3f2fd; border-left: 4px solid #2196f3; padding: 15px; margin-bottom: 25px; border-radius: 4px; font-family: 'Courier New', monospace; font-size: 13px; }
.approval-box strong { color: #1565c0; display: block; margin-bottom: 8px; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th { background-color: #f5f5f5; padding: 12px; text-align: left; font-weight: 600; color: #333; border-bottom: 2px solid #ddd; }
td { padding: 12px; border-bottom: 1px solid #eee; vertical-align: top; }
.category { font-weight: 600; color: #1976d2; }
.baseline { color: #666; font-family: 'Courier New', monospace; font-size: 13px; }
.current { color: #d32f2f; font-family: 'Courier New', monospace; font-size: 13px; font-weight: 600; }
.footer { padding: 20px 30px; background-color: #f5f5f5; border-radius: 0 0 8px 8px; text-align: center; font-size: 12px; color: #666; }
.timestamp { color: #999; font-size: 11px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>⚠️ DNS Configuration Changes Detected</h1>
<p>Domain: $DomainName</p>
</div>
<div class="content">
<div class="alert-box">
<strong>$($differences.Count) DNS record change(s)</strong> detected compared to baseline
</div>
<div class="approval-box">
<strong>⚠ APPROVAL REQUIRED</strong>
To approve these changes and update the baseline, run:<br>
.\Check-DNSHealth.ps1 -DomainName "$DomainName" -UpdateBaseline
</div>
<table>
<tr>
<th>Category</th>
<th>Record</th>
<th>Baseline Value</th>
<th>Current Value</th>
</tr>
"@
foreach($diff in $differences) {
$baselineEncoded = [System.Web.HttpUtility]::HtmlEncode($diff.Baseline)
$currentEncoded = [System.Web.HttpUtility]::HtmlEncode($diff.Current)
$htmlBody += @"
<tr>
<td class="category">$($diff.Category)</td>
<td>$($diff.Field)</td>
<td class="baseline">$baselineEncoded</td>
<td class="current">$currentEncoded</td>
</tr>
"@
}
$htmlBody += @"
</table>
</div>
<div class="footer">
<p class="timestamp">Check performed: $($results.Timestamp)</p>
<p>Automated DNS Health Monitoring System</p>
</div>
</div>
</body>
</html>
"@
try {
Add-Type -AssemblyName System.Web
$mailParams = @{
From = $EmailFrom
To = $EmailTo
Subject = "DNS Alert: Changes Detected for $DomainName - APPROVAL REQUIRED"
Body = $htmlBody
BodyAsHtml = $true
SmtpServer = $SMTPServer
Port = $SMTPPort
}
if($UseSSL) {
$mailParams.UseSsl = $true
}
Send-MailMessage @mailParams
Write-Host " ✓ Email alert sent successfully!" -ForegroundColor Green
# Log the email
try {
Add-EmailLog -LogFile $emailLogFile -Domain $DomainName
}
catch {
Write-Host " Warning: Could not log email send (non-critical): $($_.Exception.Message)" -ForegroundColor Yellow
}
}
catch {
Write-Host " ✗ Error sending email: $($_.Exception.Message)" -ForegroundColor Red
# Save HTML to file as backup
$htmlFile = Join-Path $scriptPath "$($DomainName -replace '\.','_')_alert_$(Get-Date -Format 'yyyyMMdd_HHmmss').html"
$htmlBody | Out-File $htmlFile -Encoding UTF8
Write-Host " HTML report saved to: $htmlFile" -ForegroundColor Yellow
}
}
else {
Write-Host " ⊘ Email suppressed due to rate limiting" -ForegroundColor Yellow
}
}
}
}
else {
Write-Host ""
Write-Host "✓ No changes detected - all DNS records match baseline" -ForegroundColor Green
if($UpdateBaseline) {
Write-Host " No updates needed (already in sync)" -ForegroundColor Gray
}
}
}
else {
# No baseline exists - create initial baseline
Write-Host ""
Write-Host "=== Creating Initial Baseline ===" -ForegroundColor Cyan
Write-Host "No baseline found. Creating new baseline..." -ForegroundColor Yellow
$results | ConvertTo-Json -Depth 10 | Out-File $baselineFile -Encoding UTF8
Write-Host " ✓ Baseline created: $baselineFile" -ForegroundColor Green
Write-Host " Future runs will compare against this baseline" -ForegroundColor Gray
}
Write-Host ""
Write-Host "=== DNS Health Check Complete ===" -ForegroundColor Cyan
Write-Host ""
Output Files
The script creates files in the same directory where it runs:
{domain}_baseline.json
- Contains the approved DNS configuration
- Only updated when
-UpdateBaseline
is specified - Example:
contoso_com_baseline.json
{domain}_email_log.json
- Tracks email alerts for rate limiting
- Automatically prunes entries older than 24 hours
- Example:
contoso_com_email_log.json
{domain}_alert_{timestamp}.html
(backup only)
- Created only when email delivery fails
- Contains the alert email as HTML
- Example:
contoso_com_alert_20251003_204506.html
The approval mechanism ensures that I cannot ignore DNS changes. The alerts continue until I explicitly acknowledge them, providing a forcing function for security review.