mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-03-13 10:26:06 +00:00
feat: add macOS pf DNS interception
This commit is contained in:
committed by
Cuong Manh Le
parent
f76a332329
commit
3442331695
131
test-scripts/windows/diag-intercept.ps1
Normal file
131
test-scripts/windows/diag-intercept.ps1
Normal file
@@ -0,0 +1,131 @@
|
||||
# diag-intercept.ps1 — Windows DNS Intercept Mode Diagnostic
|
||||
# Run as Administrator in the same elevated prompt as ctrld
|
||||
# Usage: .\diag-intercept.ps1
|
||||
|
||||
Write-Host "=== CTRLD INTERCEPT MODE DIAGNOSTIC ===" -ForegroundColor Cyan
|
||||
Write-Host "Timestamp: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
|
||||
Write-Host ""
|
||||
|
||||
# 1. Check NRPT rules
|
||||
Write-Host "--- 1. NRPT Rules ---" -ForegroundColor Yellow
|
||||
try {
|
||||
$nrptRules = Get-DnsClientNrptRule -ErrorAction Stop
|
||||
if ($nrptRules) {
|
||||
$nrptRules | Format-Table Namespace, NameServers, DisplayName -AutoSize
|
||||
} else {
|
||||
Write-Host " NO NRPT RULES FOUND — this is the problem!" -ForegroundColor Red
|
||||
}
|
||||
} catch {
|
||||
Write-Host " Get-DnsClientNrptRule failed: $_" -ForegroundColor Red
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
# 2. Check NRPT registry directly
|
||||
Write-Host "--- 2. NRPT Registry ---" -ForegroundColor Yellow
|
||||
$regPath = "HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\DNSClient\DnsPolicyConfig\CtrldCatchAll"
|
||||
if (Test-Path $regPath) {
|
||||
Write-Host " Registry key EXISTS" -ForegroundColor Green
|
||||
Get-ItemProperty $regPath | Format-List Name, GenericDNSServers, ConfigOptions, Version
|
||||
} else {
|
||||
Write-Host " Registry key MISSING at $regPath" -ForegroundColor Red
|
||||
# Check parent
|
||||
$parentPath = "HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\DNSClient\DnsPolicyConfig"
|
||||
if (Test-Path $parentPath) {
|
||||
Write-Host " Parent key exists. Children:"
|
||||
Get-ChildItem $parentPath | ForEach-Object { Write-Host " $($_.PSChildName)" }
|
||||
} else {
|
||||
Write-Host " Parent DnsPolicyConfig key also missing" -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
# 3. DNS Client service status
|
||||
Write-Host "--- 3. DNS Client Service ---" -ForegroundColor Yellow
|
||||
$dnsSvc = Get-Service Dnscache
|
||||
Write-Host " Status: $($dnsSvc.Status) StartType: $($dnsSvc.StartType)"
|
||||
Write-Host ""
|
||||
|
||||
# 4. Interface DNS servers
|
||||
Write-Host "--- 4. Interface DNS Servers ---" -ForegroundColor Yellow
|
||||
Get-DnsClientServerAddress | Format-Table InterfaceAlias, InterfaceIndex, AddressFamily, ServerAddresses -AutoSize
|
||||
Write-Host ""
|
||||
|
||||
# 5. WFP filters check
|
||||
Write-Host "--- 5. WFP Filters (ctrld sublayer) ---" -ForegroundColor Yellow
|
||||
try {
|
||||
$wfpOutput = netsh wfp show filters
|
||||
if (Test-Path "filters.xml") {
|
||||
$xml = [xml](Get-Content "filters.xml")
|
||||
$ctrldFilters = $xml.wfpdiag.filters.item | Where-Object {
|
||||
$_.displayData.name -like "ctrld:*"
|
||||
}
|
||||
if ($ctrldFilters) {
|
||||
Write-Host " Found $($ctrldFilters.Count) ctrld WFP filter(s):" -ForegroundColor Green
|
||||
$ctrldFilters | ForEach-Object {
|
||||
Write-Host " $($_.displayData.name) — action: $($_.action.type)"
|
||||
}
|
||||
} else {
|
||||
Write-Host " NO ctrld WFP filters found" -ForegroundColor Red
|
||||
}
|
||||
Remove-Item "filters.xml" -ErrorAction SilentlyContinue
|
||||
}
|
||||
} catch {
|
||||
Write-Host " WFP check failed: $_" -ForegroundColor Red
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
# 6. DNS resolution tests
|
||||
Write-Host "--- 6. DNS Resolution Tests ---" -ForegroundColor Yellow
|
||||
|
||||
# Test A: Resolve-DnsName (uses DNS Client = respects NRPT)
|
||||
Write-Host " Test A: Resolve-DnsName google.com (DNS Client path)" -ForegroundColor White
|
||||
try {
|
||||
$result = Resolve-DnsName google.com -Type A -DnsOnly -ErrorAction Stop
|
||||
Write-Host " OK: $($result.IPAddress -join ', ')" -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Host " FAILED: $_" -ForegroundColor Red
|
||||
}
|
||||
|
||||
# Test B: Resolve-DnsName to specific server (127.0.0.1)
|
||||
Write-Host " Test B: Resolve-DnsName google.com -Server 127.0.0.1" -ForegroundColor White
|
||||
try {
|
||||
$result = Resolve-DnsName google.com -Type A -Server 127.0.0.1 -DnsOnly -ErrorAction Stop
|
||||
Write-Host " OK: $($result.IPAddress -join ', ')" -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Host " FAILED: $_" -ForegroundColor Red
|
||||
}
|
||||
|
||||
# Test C: Resolve-DnsName blocked domain (should return 0.0.0.0 or NXDOMAIN via Control D)
|
||||
Write-Host " Test C: Resolve-DnsName popads.net (should be blocked by Control D)" -ForegroundColor White
|
||||
try {
|
||||
$result = Resolve-DnsName popads.net -Type A -DnsOnly -ErrorAction Stop
|
||||
Write-Host " Result: $($result.IPAddress -join ', ')" -ForegroundColor Yellow
|
||||
} catch {
|
||||
Write-Host " FAILED/Blocked: $_" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
# Test D: nslookup (bypasses NRPT - expected to fail with intercept)
|
||||
Write-Host " Test D: nslookup google.com 127.0.0.1 (direct, bypasses NRPT)" -ForegroundColor White
|
||||
$nslookup = & nslookup google.com 127.0.0.1 2>&1
|
||||
Write-Host " $($nslookup -join "`n ")"
|
||||
|
||||
Write-Host ""
|
||||
|
||||
# 7. Try forcing NRPT reload
|
||||
Write-Host "--- 7. Force NRPT Reload ---" -ForegroundColor Yellow
|
||||
Write-Host " Running: gpupdate /target:computer /force" -ForegroundColor White
|
||||
& gpupdate /target:computer /force 2>&1 | ForEach-Object { Write-Host " $_" }
|
||||
Write-Host ""
|
||||
|
||||
# Re-test after gpupdate
|
||||
Write-Host " Re-test: Resolve-DnsName google.com" -ForegroundColor White
|
||||
try {
|
||||
$result = Resolve-DnsName google.com -Type A -DnsOnly -ErrorAction Stop
|
||||
Write-Host " OK: $($result.IPAddress -join ', ')" -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Host " STILL FAILED: $_" -ForegroundColor Red
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "=== DIAGNOSTIC COMPLETE ===" -ForegroundColor Cyan
|
||||
Write-Host "Copy all output above and send it back."
|
||||
544
test-scripts/windows/test-dns-intercept.ps1
Normal file
544
test-scripts/windows/test-dns-intercept.ps1
Normal file
@@ -0,0 +1,544 @@
|
||||
# =============================================================================
|
||||
# DNS Intercept Mode Test Script — Windows (WFP)
|
||||
# =============================================================================
|
||||
# Run as Administrator: powershell -ExecutionPolicy Bypass -File test-dns-intercept-win.ps1
|
||||
#
|
||||
# Tests the dns-intercept feature end-to-end with validation at each step.
|
||||
# Logs are read from C:\tmp\dns.log (ctrld log location on test machine).
|
||||
#
|
||||
# Manual steps marked with [MANUAL] require human interaction.
|
||||
# =============================================================================
|
||||
|
||||
$ErrorActionPreference = "Continue"
|
||||
|
||||
$CtrldLog = "C:\tmp\dns.log"
|
||||
$WfpSubLayerName = "ctrld DNS Intercept"
|
||||
$Pass = 0
|
||||
$Fail = 0
|
||||
$Warn = 0
|
||||
$Results = @()
|
||||
|
||||
# --- Helpers ---
|
||||
|
||||
function Header($text) { Write-Host "`n━━━ $text ━━━" -ForegroundColor Cyan }
|
||||
function Info($text) { Write-Host " ℹ $text" }
|
||||
function Manual($text) { Write-Host " [MANUAL] $text" -ForegroundColor Yellow }
|
||||
function Separator() { Write-Host "─────────────────────────────────────────────────────" -ForegroundColor Cyan }
|
||||
|
||||
function Pass($text) {
|
||||
Write-Host " ✅ PASS: $text" -ForegroundColor Green
|
||||
$script:Pass++
|
||||
$script:Results += "PASS: $text"
|
||||
}
|
||||
|
||||
function Fail($text) {
|
||||
Write-Host " ❌ FAIL: $text" -ForegroundColor Red
|
||||
$script:Fail++
|
||||
$script:Results += "FAIL: $text"
|
||||
}
|
||||
|
||||
function Warn($text) {
|
||||
Write-Host " ⚠️ WARN: $text" -ForegroundColor Yellow
|
||||
$script:Warn++
|
||||
$script:Results += "WARN: $text"
|
||||
}
|
||||
|
||||
function WaitForKey {
|
||||
Write-Host "`n Press Enter to continue..." -NoNewline
|
||||
Read-Host
|
||||
}
|
||||
|
||||
function LogGrep($pattern, $lines = 200) {
|
||||
if (Test-Path $CtrldLog) {
|
||||
Get-Content $CtrldLog -Tail $lines -ErrorAction SilentlyContinue |
|
||||
Select-String -Pattern $pattern -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
function LogGrepCount($pattern, $lines = 200) {
|
||||
$matches = LogGrep $pattern $lines
|
||||
if ($matches) { return @($matches).Count } else { return 0 }
|
||||
}
|
||||
|
||||
# --- Check Admin ---
|
||||
|
||||
function Check-Admin {
|
||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
|
||||
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||
Write-Host "This script must be run as Administrator." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# TEST SECTIONS
|
||||
# =============================================================================
|
||||
|
||||
function Test-Prereqs {
|
||||
Header "0. Prerequisites"
|
||||
|
||||
if (Get-Command nslookup -ErrorAction SilentlyContinue) {
|
||||
Pass "nslookup available"
|
||||
} else {
|
||||
Fail "nslookup not found"
|
||||
}
|
||||
|
||||
if (Get-Command netsh -ErrorAction SilentlyContinue) {
|
||||
Pass "netsh available"
|
||||
} else {
|
||||
Fail "netsh not found"
|
||||
}
|
||||
|
||||
if (Test-Path $CtrldLog) {
|
||||
Pass "ctrld log exists at $CtrldLog"
|
||||
} else {
|
||||
Warn "ctrld log not found at $CtrldLog — log checks will be skipped"
|
||||
}
|
||||
|
||||
# Show current DNS config
|
||||
Info "Current DNS servers:"
|
||||
Get-DnsClientServerAddress -AddressFamily IPv4 |
|
||||
Where-Object { $_.ServerAddresses.Count -gt 0 } |
|
||||
Format-Table InterfaceAlias, ServerAddresses -AutoSize |
|
||||
Out-String | ForEach-Object { $_.Trim() } | Write-Host
|
||||
}
|
||||
|
||||
function Test-WfpState {
|
||||
Header "1. WFP State Validation"
|
||||
|
||||
# Export WFP filters and check for ctrld's sublayer/filters
|
||||
$wfpExport = "$env:TEMP\wfp_filters.xml"
|
||||
Info "Exporting WFP filters (this may take a few seconds)..."
|
||||
|
||||
try {
|
||||
netsh wfp show filters file=$wfpExport 2>$null | Out-Null
|
||||
|
||||
if (Test-Path $wfpExport) {
|
||||
$wfpContent = Get-Content $wfpExport -Raw -ErrorAction SilentlyContinue
|
||||
|
||||
# Check for ctrld sublayer
|
||||
if ($wfpContent -match "ctrld") {
|
||||
Pass "WFP filters contain 'ctrld' references"
|
||||
|
||||
# Count filters
|
||||
$filterMatches = ([regex]::Matches($wfpContent, "ctrld")).Count
|
||||
Info "Found $filterMatches 'ctrld' references in WFP export"
|
||||
} else {
|
||||
Fail "No 'ctrld' references found in WFP filters"
|
||||
}
|
||||
|
||||
# Check for DNS port 53 filters
|
||||
if ($wfpContent -match "port.*53" -or $wfpContent -match "0x0035") {
|
||||
Pass "Port 53 filter conditions found in WFP"
|
||||
} else {
|
||||
Warn "Could not confirm port 53 filters in WFP export"
|
||||
}
|
||||
|
||||
Remove-Item $wfpExport -ErrorAction SilentlyContinue
|
||||
} else {
|
||||
Warn "WFP export file not created"
|
||||
}
|
||||
} catch {
|
||||
Warn "Could not export WFP filters: $_"
|
||||
}
|
||||
|
||||
Separator
|
||||
|
||||
# Alternative: Check via PowerShell WFP cmdlets if available
|
||||
Info "Checking WFP via netsh wfp show state..."
|
||||
$wfpState = netsh wfp show state 2>$null
|
||||
if ($wfpState) {
|
||||
Info "WFP state export completed (check $env:TEMP for details)"
|
||||
}
|
||||
|
||||
# Check Windows Firewall service is running
|
||||
$fwService = Get-Service -Name "mpssvc" -ErrorAction SilentlyContinue
|
||||
if ($fwService -and $fwService.Status -eq "Running") {
|
||||
Pass "Windows Firewall service (BFE/WFP) is running"
|
||||
} else {
|
||||
Fail "Windows Firewall service not running — WFP won't work"
|
||||
}
|
||||
|
||||
# Check BFE (Base Filtering Engine)
|
||||
$bfeService = Get-Service -Name "BFE" -ErrorAction SilentlyContinue
|
||||
if ($bfeService -and $bfeService.Status -eq "Running") {
|
||||
Pass "Base Filtering Engine (BFE) is running"
|
||||
} else {
|
||||
Fail "BFE not running — WFP requires this service"
|
||||
}
|
||||
}
|
||||
|
||||
function Test-DnsInterception {
|
||||
Header "2. DNS Interception Tests"
|
||||
|
||||
# Mark log position
|
||||
$logLinesBefore = 0
|
||||
if (Test-Path $CtrldLog) {
|
||||
$logLinesBefore = @(Get-Content $CtrldLog -ErrorAction SilentlyContinue).Count
|
||||
}
|
||||
|
||||
# Test 1: Query to external resolver should be intercepted
|
||||
Info "Test: nslookup example.com 8.8.8.8 (should be intercepted by ctrld)"
|
||||
$result = $null
|
||||
try {
|
||||
$result = nslookup example.com 8.8.8.8 2>&1 | Out-String
|
||||
} catch { }
|
||||
|
||||
if ($result -and $result -match "\d+\.\d+\.\d+\.\d+") {
|
||||
Pass "nslookup @8.8.8.8 returned a result"
|
||||
|
||||
# Check which server answered
|
||||
if ($result -match "Server:\s+(\S+)") {
|
||||
$server = $Matches[1]
|
||||
Info "Answered by server: $server"
|
||||
if ($server -match "127\.0\.0\.1|localhost") {
|
||||
Pass "Response came from localhost (ctrld intercepted)"
|
||||
} elseif ($server -match "8\.8\.8\.8") {
|
||||
Fail "Response came from 8.8.8.8 directly — NOT intercepted"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Fail "nslookup @8.8.8.8 failed or returned no address"
|
||||
}
|
||||
|
||||
# Check ctrld logged it
|
||||
Start-Sleep -Seconds 1
|
||||
if (Test-Path $CtrldLog) {
|
||||
$newLines = Get-Content $CtrldLog -ErrorAction SilentlyContinue |
|
||||
Select-Object -Skip $logLinesBefore
|
||||
$intercepted = $newLines | Select-String "example.com" -ErrorAction SilentlyContinue
|
||||
if ($intercepted) {
|
||||
Pass "ctrld logged the intercepted query for example.com"
|
||||
} else {
|
||||
Fail "ctrld did NOT log query for example.com"
|
||||
}
|
||||
}
|
||||
|
||||
Separator
|
||||
|
||||
# Test 2: Another external resolver
|
||||
Info "Test: nslookup cloudflare.com 1.1.1.1 (should also be intercepted)"
|
||||
try {
|
||||
$result2 = nslookup cloudflare.com 1.1.1.1 2>&1 | Out-String
|
||||
if ($result2 -match "\d+\.\d+\.\d+\.\d+") {
|
||||
Pass "nslookup @1.1.1.1 returned result"
|
||||
} else {
|
||||
Fail "nslookup @1.1.1.1 failed"
|
||||
}
|
||||
} catch {
|
||||
Fail "nslookup @1.1.1.1 threw exception"
|
||||
}
|
||||
|
||||
Separator
|
||||
|
||||
# Test 3: Query to localhost should work (no loop)
|
||||
Info "Test: nslookup example.org 127.0.0.1 (direct to ctrld, no loop)"
|
||||
try {
|
||||
$result3 = nslookup example.org 127.0.0.1 2>&1 | Out-String
|
||||
if ($result3 -match "\d+\.\d+\.\d+\.\d+") {
|
||||
Pass "nslookup @127.0.0.1 works (no loop)"
|
||||
} else {
|
||||
Fail "nslookup @127.0.0.1 failed — possible loop"
|
||||
}
|
||||
} catch {
|
||||
Fail "nslookup @127.0.0.1 exception — possible loop"
|
||||
}
|
||||
|
||||
Separator
|
||||
|
||||
# Test 4: System DNS via Resolve-DnsName
|
||||
Info "Test: Resolve-DnsName example.net (system resolver)"
|
||||
try {
|
||||
$result4 = Resolve-DnsName example.net -Type A -ErrorAction Stop
|
||||
if ($result4) {
|
||||
Pass "System DNS resolution works (Resolve-DnsName)"
|
||||
}
|
||||
} catch {
|
||||
Fail "System DNS resolution failed: $_"
|
||||
}
|
||||
|
||||
Separator
|
||||
|
||||
# Test 5: TCP DNS
|
||||
Info "Test: nslookup -vc example.com 9.9.9.9 (TCP DNS)"
|
||||
try {
|
||||
$result5 = nslookup -vc example.com 9.9.9.9 2>&1 | Out-String
|
||||
if ($result5 -match "\d+\.\d+\.\d+\.\d+") {
|
||||
Pass "TCP DNS query intercepted and resolved"
|
||||
} else {
|
||||
Warn "TCP DNS query may not have been intercepted"
|
||||
}
|
||||
} catch {
|
||||
Warn "TCP DNS test inconclusive"
|
||||
}
|
||||
}
|
||||
|
||||
function Test-NonDnsUnaffected {
|
||||
Header "3. Non-DNS Traffic Unaffected"
|
||||
|
||||
# HTTPS
|
||||
Info "Test: Invoke-WebRequest https://example.com (HTTPS should NOT be affected)"
|
||||
try {
|
||||
$web = Invoke-WebRequest -Uri "https://example.com" -UseBasicParsing -TimeoutSec 10 -ErrorAction Stop
|
||||
if ($web.StatusCode -eq 200) {
|
||||
Pass "HTTPS works (HTTP 200)"
|
||||
} else {
|
||||
Pass "HTTPS returned HTTP $($web.StatusCode)"
|
||||
}
|
||||
} catch {
|
||||
Fail "HTTPS failed: $_"
|
||||
}
|
||||
|
||||
# Test non-53 port connectivity
|
||||
Info "Test: Test-NetConnection to github.com:443 (non-DNS port)"
|
||||
try {
|
||||
$nc = Test-NetConnection -ComputerName "github.com" -Port 443 -WarningAction SilentlyContinue
|
||||
if ($nc.TcpTestSucceeded) {
|
||||
Pass "Port 443 reachable (non-DNS traffic unaffected)"
|
||||
} else {
|
||||
Warn "Port 443 unreachable (may be firewall)"
|
||||
}
|
||||
} catch {
|
||||
Warn "Test-NetConnection failed: $_"
|
||||
}
|
||||
}
|
||||
|
||||
function Test-CtrldLogHealth {
|
||||
Header "4. ctrld Log Health Check"
|
||||
|
||||
if (-not (Test-Path $CtrldLog)) {
|
||||
Warn "Skipping log checks — $CtrldLog not found"
|
||||
return
|
||||
}
|
||||
|
||||
# Check for WFP initialization
|
||||
if (LogGrepCount "initializing Windows Filtering Platform" 500) {
|
||||
Pass "WFP initialization logged"
|
||||
} else {
|
||||
Fail "No WFP initialization in recent logs"
|
||||
}
|
||||
|
||||
# Check for successful WFP engine open
|
||||
if (LogGrepCount "WFP engine opened" 500) {
|
||||
Pass "WFP engine opened successfully"
|
||||
} else {
|
||||
Fail "WFP engine open not found in logs"
|
||||
}
|
||||
|
||||
# Check for sublayer creation
|
||||
if (LogGrepCount "WFP sublayer created" 500) {
|
||||
Pass "WFP sublayer created"
|
||||
} else {
|
||||
Fail "WFP sublayer creation not logged"
|
||||
}
|
||||
|
||||
# Check for filter creation
|
||||
$filterCount = LogGrepCount "added WFP.*filter" 500
|
||||
if ($filterCount -gt 0) {
|
||||
Pass "WFP filters added ($filterCount filter log entries)"
|
||||
} else {
|
||||
Fail "No WFP filter creation logged"
|
||||
}
|
||||
|
||||
# Check for permit-localhost filters
|
||||
if (LogGrepCount "permit.*localhost\|permit.*127\.0\.0\.1" 500) {
|
||||
Pass "Localhost permit filters logged"
|
||||
} else {
|
||||
Warn "Localhost permit filters not explicitly logged"
|
||||
}
|
||||
|
||||
Separator
|
||||
|
||||
# Check for errors
|
||||
Info "Recent errors in ctrld log:"
|
||||
$errors = LogGrep '"level":"error"' 500
|
||||
if ($errors) {
|
||||
$errors | Select-Object -Last 5 | ForEach-Object { Write-Host " $_" }
|
||||
Warn "Errors found in recent logs"
|
||||
} else {
|
||||
Pass "No errors in recent logs"
|
||||
}
|
||||
|
||||
# Warnings (excluding expected ones)
|
||||
$warnings = LogGrep '"level":"warn"' 500 | Where-Object {
|
||||
$_ -notmatch "skipping self-upgrade"
|
||||
}
|
||||
if ($warnings) {
|
||||
Info "Warnings:"
|
||||
$warnings | Select-Object -Last 5 | ForEach-Object { Write-Host " $_" }
|
||||
}
|
||||
|
||||
# VPN DNS detection
|
||||
$vpnLogs = LogGrep "VPN DNS" 500
|
||||
if ($vpnLogs) {
|
||||
Info "VPN DNS activity:"
|
||||
$vpnLogs | Select-Object -Last 5 | ForEach-Object { Write-Host " $_" }
|
||||
} else {
|
||||
Info "No VPN DNS activity (expected if no VPN connected)"
|
||||
}
|
||||
}
|
||||
|
||||
function Test-CleanupOnStop {
|
||||
Header "5. Cleanup Validation (After ctrld Stop)"
|
||||
|
||||
Manual "Stop ctrld now (ctrld stop or Ctrl+C), then press Enter"
|
||||
WaitForKey
|
||||
|
||||
Start-Sleep -Seconds 2
|
||||
|
||||
# Check WFP filters are removed
|
||||
$wfpExport = "$env:TEMP\wfp_after_stop.xml"
|
||||
try {
|
||||
netsh wfp show filters file=$wfpExport 2>$null | Out-Null
|
||||
if (Test-Path $wfpExport) {
|
||||
$content = Get-Content $wfpExport -Raw -ErrorAction SilentlyContinue
|
||||
if ($content -match "ctrld") {
|
||||
Fail "WFP still contains 'ctrld' filters after stop"
|
||||
} else {
|
||||
Pass "WFP filters cleaned up after stop"
|
||||
}
|
||||
Remove-Item $wfpExport -ErrorAction SilentlyContinue
|
||||
}
|
||||
} catch {
|
||||
Warn "Could not verify WFP cleanup"
|
||||
}
|
||||
|
||||
# DNS should work normally
|
||||
Info "Test: nslookup example.com (should work via system DNS)"
|
||||
try {
|
||||
$result = nslookup example.com 2>&1 | Out-String
|
||||
if ($result -match "\d+\.\d+\.\d+\.\d+") {
|
||||
Pass "DNS works after ctrld stop"
|
||||
} else {
|
||||
Fail "DNS broken after ctrld stop"
|
||||
}
|
||||
} catch {
|
||||
Fail "DNS exception after ctrld stop"
|
||||
}
|
||||
}
|
||||
|
||||
function Test-RestartResilience {
|
||||
Header "6. Restart Resilience"
|
||||
|
||||
Manual "Start ctrld again with --dns-intercept, then press Enter"
|
||||
WaitForKey
|
||||
|
||||
Start-Sleep -Seconds 3
|
||||
|
||||
# Quick interception test
|
||||
Info "Test: nslookup example.com 8.8.8.8 (should be intercepted after restart)"
|
||||
try {
|
||||
$result = nslookup example.com 8.8.8.8 2>&1 | Out-String
|
||||
if ($result -match "\d+\.\d+\.\d+\.\d+") {
|
||||
Pass "DNS interception works after restart"
|
||||
} else {
|
||||
Fail "DNS interception broken after restart"
|
||||
}
|
||||
} catch {
|
||||
Fail "DNS test failed after restart"
|
||||
}
|
||||
|
||||
# Check WFP filters restored
|
||||
if (LogGrepCount "WFP engine opened" 100) {
|
||||
Pass "WFP re-initialized after restart"
|
||||
}
|
||||
}
|
||||
|
||||
function Test-NetworkChange {
|
||||
Header "7. Network Change Recovery"
|
||||
|
||||
Info "This test verifies recovery after network changes."
|
||||
Manual "Switch Wi-Fi networks, or disable/re-enable network adapter, then press Enter"
|
||||
WaitForKey
|
||||
|
||||
Start-Sleep -Seconds 5
|
||||
|
||||
# Test interception still works
|
||||
Info "Test: nslookup example.com 8.8.8.8 (should still be intercepted)"
|
||||
try {
|
||||
$result = nslookup example.com 8.8.8.8 2>&1 | Out-String
|
||||
if ($result -match "\d+\.\d+\.\d+\.\d+") {
|
||||
Pass "DNS interception works after network change"
|
||||
} else {
|
||||
Fail "DNS interception broken after network change"
|
||||
}
|
||||
} catch {
|
||||
Fail "DNS test failed after network change"
|
||||
}
|
||||
|
||||
# Check logs for recovery/network events
|
||||
if (Test-Path $CtrldLog) {
|
||||
$recoveryLogs = LogGrep "recovery|network change|network monitor" 100
|
||||
if ($recoveryLogs) {
|
||||
Info "Recovery/network log entries:"
|
||||
$recoveryLogs | Select-Object -Last 5 | ForEach-Object { Write-Host " $_" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# SUMMARY
|
||||
# =============================================================================
|
||||
|
||||
function Print-Summary {
|
||||
Header "TEST SUMMARY"
|
||||
Write-Host ""
|
||||
foreach ($r in $Results) {
|
||||
if ($r.StartsWith("PASS")) {
|
||||
Write-Host " ✅ $($r.Substring(6))" -ForegroundColor Green
|
||||
} elseif ($r.StartsWith("FAIL")) {
|
||||
Write-Host " ❌ $($r.Substring(6))" -ForegroundColor Red
|
||||
} elseif ($r.StartsWith("WARN")) {
|
||||
Write-Host " ⚠️ $($r.Substring(6))" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
Write-Host ""
|
||||
Separator
|
||||
Write-Host " Passed: $Pass | Failed: $Fail | Warnings: $Warn"
|
||||
Separator
|
||||
|
||||
if ($Fail -gt 0) {
|
||||
Write-Host "`n Some tests failed. Debug commands:" -ForegroundColor Red
|
||||
Write-Host " netsh wfp show filters # dump all WFP filters"
|
||||
Write-Host " Get-Content $CtrldLog -Tail 100 # recent ctrld logs"
|
||||
Write-Host " Get-DnsClientServerAddress # current DNS config"
|
||||
Write-Host " netsh wfp show state # WFP state dump"
|
||||
} else {
|
||||
Write-Host "`n All tests passed!" -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# MAIN
|
||||
# =============================================================================
|
||||
|
||||
Write-Host "╔═══════════════════════════════════════════════════════╗" -ForegroundColor White
|
||||
Write-Host "║ ctrld DNS Intercept Mode — Windows Test Suite ║" -ForegroundColor White
|
||||
Write-Host "║ Tests WFP-based DNS interception ║" -ForegroundColor White
|
||||
Write-Host "╚═══════════════════════════════════════════════════════╝" -ForegroundColor White
|
||||
|
||||
Check-Admin
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Make sure ctrld is running with --dns-intercept before starting."
|
||||
Write-Host "Log location: $CtrldLog"
|
||||
WaitForKey
|
||||
|
||||
Test-Prereqs
|
||||
Test-WfpState
|
||||
Test-DnsInterception
|
||||
Test-NonDnsUnaffected
|
||||
Test-CtrldLogHealth
|
||||
|
||||
Separator
|
||||
Write-Host ""
|
||||
Write-Host "The next tests require manual steps (stop/start ctrld, network changes)."
|
||||
Write-Host "Press Enter to continue, or Ctrl+C to skip and see results so far."
|
||||
WaitForKey
|
||||
|
||||
Test-CleanupOnStop
|
||||
Test-RestartResilience
|
||||
Test-NetworkChange
|
||||
|
||||
Print-Summary
|
||||
289
test-scripts/windows/test-recovery-bypass.ps1
Normal file
289
test-scripts/windows/test-recovery-bypass.ps1
Normal file
@@ -0,0 +1,289 @@
|
||||
# test-recovery-bypass.ps1 — Test DNS intercept recovery bypass (captive portal simulation)
|
||||
#
|
||||
# Simulates a captive portal by:
|
||||
# 1. Discovering ctrld's upstream IPs from active connections
|
||||
# 2. Blocking them via Windows Firewall rules
|
||||
# 3. Disabling/re-enabling the wifi adapter to trigger network change
|
||||
# 4. Verifying recovery bypass forwards to OS/DHCP resolver
|
||||
# 5. Removing firewall rules and verifying normal operation resumes
|
||||
#
|
||||
# SAFE: Uses named firewall rules that are cleaned up on exit.
|
||||
#
|
||||
# Usage (run as Administrator):
|
||||
# .\test-recovery-bypass.ps1 [-WifiAdapter "Wi-Fi"] [-CtrldLog "C:\temp\dns.log"]
|
||||
#
|
||||
# Prerequisites:
|
||||
# - ctrld running with --dns-intercept and -v 1 --log C:\temp\dns.log
|
||||
# - Run as Administrator
|
||||
|
||||
param(
|
||||
[string]$WifiAdapter = "Wi-Fi",
|
||||
[string]$CtrldLog = "C:\temp\dns.log",
|
||||
[int]$BlockDurationSec = 60
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$FwRulePrefix = "ctrld-test-recovery-block"
|
||||
$BlockedIPs = @()
|
||||
|
||||
function Log($msg) { Write-Host "[$(Get-Date -Format 'HH:mm:ss')] $msg" -ForegroundColor Cyan }
|
||||
function Pass($msg) { Write-Host "[PASS] $msg" -ForegroundColor Green }
|
||||
function Fail($msg) { Write-Host "[FAIL] $msg" -ForegroundColor Red }
|
||||
function Warn($msg) { Write-Host "[WARN] $msg" -ForegroundColor Yellow }
|
||||
|
||||
# ── Safety: cleanup function ─────────────────────────────────────────────────
|
||||
function Cleanup {
|
||||
Log "═══ CLEANUP ═══"
|
||||
|
||||
# Ensure wifi is enabled
|
||||
Log "Ensuring wifi adapter is enabled..."
|
||||
try { Enable-NetAdapter -Name $WifiAdapter -Confirm:$false -ErrorAction SilentlyContinue } catch {}
|
||||
|
||||
# Remove all test firewall rules
|
||||
Log "Removing test firewall rules..."
|
||||
Get-NetFirewallRule -DisplayName "$FwRulePrefix*" -ErrorAction SilentlyContinue |
|
||||
Remove-NetFirewallRule -ErrorAction SilentlyContinue
|
||||
Log "Cleanup complete."
|
||||
}
|
||||
|
||||
# Register cleanup on script exit
|
||||
$null = Register-EngineEvent -SourceIdentifier PowerShell.Exiting -Action { Cleanup } -ErrorAction SilentlyContinue
|
||||
trap { Cleanup; break }
|
||||
|
||||
# ── Pre-checks ───────────────────────────────────────────────────────────────
|
||||
$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
if (-not $isAdmin) {
|
||||
Fail "Run as Administrator!"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not (Test-Path $CtrldLog)) {
|
||||
Fail "ctrld log not found at $CtrldLog"
|
||||
Write-Host "Start ctrld with: ctrld run --dns-intercept --cd <uid> -v 1 --log $CtrldLog"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Check wifi adapter exists
|
||||
$adapter = Get-NetAdapter -Name $WifiAdapter -ErrorAction SilentlyContinue
|
||||
if (-not $adapter) {
|
||||
Fail "Wifi adapter '$WifiAdapter' not found"
|
||||
Write-Host "Available adapters:"
|
||||
Get-NetAdapter | Format-Table Name, Status, InterfaceDescription
|
||||
exit 1
|
||||
}
|
||||
|
||||
Log "═══════════════════════════════════════════════════════════"
|
||||
Log " Recovery Bypass Test (Captive Portal Simulation)"
|
||||
Log "═══════════════════════════════════════════════════════════"
|
||||
Log "Wifi adapter: $WifiAdapter"
|
||||
Log "ctrld log: $CtrldLog"
|
||||
Write-Host ""
|
||||
|
||||
# ── Phase 1: Discover upstream IPs ──────────────────────────────────────────
|
||||
Log "Phase 1: Discovering ctrld upstream IPs from active connections"
|
||||
|
||||
$ctrldConns = Get-NetTCPConnection -OwningProcess (Get-Process ctrld* -ErrorAction SilentlyContinue).Id -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.State -eq "Established" -and $_.RemotePort -eq 443 }
|
||||
|
||||
$upstreamIPs = @()
|
||||
if ($ctrldConns) {
|
||||
$upstreamIPs = $ctrldConns | Select-Object -ExpandProperty RemoteAddress -Unique |
|
||||
Where-Object { $_ -notmatch "^(127\.|10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.)" }
|
||||
|
||||
foreach ($conn in $ctrldConns) {
|
||||
Log " $($conn.LocalAddress):$($conn.LocalPort) -> $($conn.RemoteAddress):$($conn.RemotePort)"
|
||||
}
|
||||
}
|
||||
|
||||
# Also resolve known Control D endpoints
|
||||
foreach ($host_ in @("dns.controld.com", "freedns.controld.com")) {
|
||||
try {
|
||||
$resolved = Resolve-DnsName $host_ -Type A -ErrorAction SilentlyContinue
|
||||
$resolved | ForEach-Object { if ($_.IPAddress) { $upstreamIPs += $_.IPAddress } }
|
||||
} catch {}
|
||||
}
|
||||
|
||||
$upstreamIPs = $upstreamIPs | Sort-Object -Unique
|
||||
|
||||
if ($upstreamIPs.Count -eq 0) {
|
||||
Fail "Could not discover any upstream IPs!"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Log "Found $($upstreamIPs.Count) upstream IP(s):"
|
||||
foreach ($ip in $upstreamIPs) { Log " $ip" }
|
||||
Write-Host ""
|
||||
|
||||
# ── Phase 2: Baseline ───────────────────────────────────────────────────────
|
||||
Log "Phase 2: Baseline — verify DNS works normally"
|
||||
$baseline = Resolve-DnsName example.com -Server 127.0.0.1 -Type A -ErrorAction SilentlyContinue
|
||||
if ($baseline) {
|
||||
Pass "Baseline: example.com -> $($baseline[0].IPAddress)"
|
||||
} else {
|
||||
Fail "DNS not working!"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$logLinesBefore = (Get-Content $CtrldLog).Count
|
||||
Log "Log position: line $logLinesBefore"
|
||||
Write-Host ""
|
||||
|
||||
# ── Phase 3: Block upstream IPs via Windows Firewall ────────────────────────
|
||||
Log "Phase 3: Blocking upstream IPs via Windows Firewall"
|
||||
foreach ($ip in $upstreamIPs) {
|
||||
$ruleName = "$FwRulePrefix-$ip"
|
||||
# Remove existing rule if any
|
||||
Remove-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue
|
||||
# Block outbound to this IP
|
||||
New-NetFirewallRule -DisplayName $ruleName -Direction Outbound -Action Block `
|
||||
-RemoteAddress $ip -Protocol TCP -RemotePort 443 `
|
||||
-Description "Temporary test rule for ctrld recovery bypass test" | Out-Null
|
||||
$BlockedIPs += $ip
|
||||
Log " Blocked: $ip (outbound TCP 443)"
|
||||
}
|
||||
Pass "All $($upstreamIPs.Count) upstream IPs blocked"
|
||||
Write-Host ""
|
||||
|
||||
# ── Phase 4: Cycle wifi ─────────────────────────────────────────────────────
|
||||
Log "Phase 4: Cycling wifi to trigger network change event"
|
||||
Log " Disabling $WifiAdapter..."
|
||||
Disable-NetAdapter -Name $WifiAdapter -Confirm:$false
|
||||
Start-Sleep -Seconds 3
|
||||
|
||||
Log " Enabling $WifiAdapter..."
|
||||
Enable-NetAdapter -Name $WifiAdapter -Confirm:$false
|
||||
|
||||
Log " Waiting for wifi to reconnect (up to 20s)..."
|
||||
$wifiUp = $false
|
||||
for ($i = 0; $i -lt 20; $i++) {
|
||||
$status = (Get-NetAdapter -Name $WifiAdapter).Status
|
||||
if ($status -eq "Up") {
|
||||
# Check for IP
|
||||
$ipAddr = (Get-NetIPAddress -InterfaceAlias $WifiAdapter -AddressFamily IPv4 -ErrorAction SilentlyContinue).IPAddress
|
||||
if ($ipAddr) {
|
||||
$wifiUp = $true
|
||||
Pass "Wifi reconnected: $WifiAdapter -> $ipAddr"
|
||||
break
|
||||
}
|
||||
}
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
|
||||
if (-not $wifiUp) {
|
||||
Fail "Wifi did not reconnect in 20s!"
|
||||
Cleanup
|
||||
exit 1
|
||||
}
|
||||
|
||||
Log " Waiting 5s for ctrld network monitor..."
|
||||
Start-Sleep -Seconds 5
|
||||
Write-Host ""
|
||||
|
||||
# ── Phase 5: Query and watch for recovery ────────────────────────────────────
|
||||
Log "Phase 5: Sending queries — upstream blocked, recovery should activate"
|
||||
Write-Host ""
|
||||
|
||||
$recoveryDetected = $false
|
||||
$bypassActive = $false
|
||||
$dnsDuringBypass = $false
|
||||
|
||||
for ($q = 1; $q -le 30; $q++) {
|
||||
$result = $null
|
||||
try {
|
||||
$result = Resolve-DnsName "example.com" -Server 127.0.0.1 -Type A -DnsOnly -ErrorAction SilentlyContinue
|
||||
} catch {}
|
||||
|
||||
if ($result) {
|
||||
Log " Query #$q`: example.com -> $($result[0].IPAddress) ✓"
|
||||
} else {
|
||||
Log " Query #$q`: example.com -> FAIL ✗"
|
||||
}
|
||||
|
||||
# Check ctrld log for recovery
|
||||
$newLogs = Get-Content $CtrldLog | Select-Object -Skip $logLinesBefore
|
||||
$logText = $newLogs -join "`n"
|
||||
|
||||
if (-not $recoveryDetected -and ($logText -match "enabling DHCP bypass|triggering recovery|No healthy")) {
|
||||
Write-Host ""
|
||||
Pass "🎯 Recovery flow triggered!"
|
||||
$recoveryDetected = $true
|
||||
}
|
||||
|
||||
if (-not $bypassActive -and ($logText -match "Recovery bypass active")) {
|
||||
Pass "🔄 Recovery bypass forwarding to OS/DHCP resolver"
|
||||
$bypassActive = $true
|
||||
}
|
||||
|
||||
if ($recoveryDetected -and $result) {
|
||||
Pass "✅ DNS resolves during recovery: example.com -> $($result[0].IPAddress)"
|
||||
$dnsDuringBypass = $true
|
||||
break
|
||||
}
|
||||
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
|
||||
# ── Phase 6: Show log entries ────────────────────────────────────────────────
|
||||
Write-Host ""
|
||||
Log "Phase 6: Recovery-related ctrld log entries"
|
||||
Log "────────────────────────────────────────────"
|
||||
$newLogs = Get-Content $CtrldLog | Select-Object -Skip $logLinesBefore
|
||||
$relevant = $newLogs | Where-Object { $_ -match "recovery|bypass|DHCP|unhealthy|upstream.*fail|No healthy|network change|OS resolver" }
|
||||
if ($relevant) {
|
||||
$relevant | Select-Object -First 30 | ForEach-Object { Write-Host " $_" }
|
||||
} else {
|
||||
Warn "No recovery-related log entries found"
|
||||
Get-Content $CtrldLog | Select-Object -Last 10 | ForEach-Object { Write-Host " $_" }
|
||||
}
|
||||
|
||||
# ── Phase 7: Unblock and verify ─────────────────────────────────────────────
|
||||
Write-Host ""
|
||||
Log "Phase 7: Removing firewall blocks"
|
||||
Get-NetFirewallRule -DisplayName "$FwRulePrefix*" -ErrorAction SilentlyContinue |
|
||||
Remove-NetFirewallRule -ErrorAction SilentlyContinue
|
||||
$BlockedIPs = @()
|
||||
Pass "Firewall rules removed"
|
||||
|
||||
Log "Waiting for recovery (up to 30s)..."
|
||||
$logLinesUnblock = (Get-Content $CtrldLog).Count
|
||||
$recoveryComplete = $false
|
||||
|
||||
for ($i = 0; $i -lt 15; $i++) {
|
||||
try { Resolve-DnsName example.com -Server 127.0.0.1 -Type A -DnsOnly -ErrorAction SilentlyContinue } catch {}
|
||||
$postLogs = (Get-Content $CtrldLog | Select-Object -Skip $logLinesUnblock) -join "`n"
|
||||
if ($postLogs -match "recovery complete|disabling DHCP bypass|Upstream.*recovered") {
|
||||
$recoveryComplete = $true
|
||||
Pass "ctrld recovered — normal operation resumed"
|
||||
break
|
||||
}
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
|
||||
if (-not $recoveryComplete) { Warn "Recovery completion not detected (may need more time)" }
|
||||
|
||||
# ── Phase 8: Final check ────────────────────────────────────────────────────
|
||||
Write-Host ""
|
||||
Log "Phase 8: Final DNS verification"
|
||||
Start-Sleep -Seconds 2
|
||||
$final = Resolve-DnsName example.com -Server 127.0.0.1 -Type A -ErrorAction SilentlyContinue
|
||||
if ($final) {
|
||||
Pass "DNS working: example.com -> $($final[0].IPAddress)"
|
||||
} else {
|
||||
Fail "DNS not resolving"
|
||||
}
|
||||
|
||||
# ── Summary ──────────────────────────────────────────────────────────────────
|
||||
Write-Host ""
|
||||
Log "═══════════════════════════════════════════════════════════"
|
||||
Log " Test Summary"
|
||||
Log "═══════════════════════════════════════════════════════════"
|
||||
if ($recoveryDetected) { Pass "Recovery bypass activated" } else { Fail "Recovery bypass NOT activated" }
|
||||
if ($bypassActive) { Pass "Queries forwarded to OS/DHCP" } else { Warn "OS resolver forwarding not confirmed" }
|
||||
if ($dnsDuringBypass) { Pass "DNS resolved during bypass" } else { Warn "DNS during bypass not confirmed" }
|
||||
if ($recoveryComplete) { Pass "Normal operation resumed" } else { Warn "Recovery completion not confirmed" }
|
||||
if ($final) { Pass "DNS functional at end of test" } else { Fail "DNS broken at end of test" }
|
||||
Write-Host ""
|
||||
Log "Full log: Get-Content $CtrldLog | Select-Object -Skip $logLinesBefore"
|
||||
|
||||
# Cleanup runs via trap
|
||||
Cleanup
|
||||
Reference in New Issue
Block a user