feat: add macOS pf DNS interception

Implement DNS interception on macOS using pf (packet filter):
- Anchor injection into running ruleset (not /etc/pf.conf)
- route-to lo0 + rdr rules for locally-originated DNS capture
- _ctrld group exemption so ctrld's own queries bypass interception
- Watchdog to detect and restore wiped anchor rules
- Probe-based auto-heal for Parallels VM pf corruption
- IPv6 DNS blocking and block-return for clean timeouts
- Interface-specific tunnel detection for VPN coexistence
- Port 5354 fallback in intercept mode

Includes pf technical reference docs and test scripts.

Squashed from intercept mode development on v1.0 branch (#497).
This commit is contained in:
Codescribe
2026-03-03 02:07:11 -05:00
committed by Cuong Manh Le
parent 1e8240bd1c
commit 289a46dc2c
13 changed files with 4820 additions and 0 deletions
+556
View File
@@ -0,0 +1,556 @@
#!/bin/bash
# =============================================================================
# DNS Intercept Mode Test Script — macOS (pf)
# =============================================================================
# Run as root: sudo bash test-dns-intercept-mac.sh
#
# Tests the dns-intercept feature end-to-end with validation at each step.
# Logs are read from /tmp/dns.log (ctrld log location on test machine).
#
# Manual steps marked with [MANUAL] require human interaction.
# =============================================================================
set -euo pipefail
CTRLD_LOG="/tmp/dns.log"
PF_ANCHOR="com.controld.ctrld"
PASS=0
FAIL=0
WARN=0
RESULTS=()
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
header() { echo -e "\n${CYAN}${BOLD}━━━ $1 ━━━${NC}"; }
info() { echo -e " ${BOLD}${NC} $1"; }
pass() { echo -e " ${GREEN}✅ PASS${NC}: $1"; PASS=$((PASS+1)); RESULTS+=("PASS: $1"); }
fail() { echo -e " ${RED}❌ FAIL${NC}: $1"; FAIL=$((FAIL+1)); RESULTS+=("FAIL: $1"); }
warn() { echo -e " ${YELLOW}⚠️ WARN${NC}: $1"; WARN=$((WARN+1)); RESULTS+=("WARN: $1"); }
manual() { echo -e " ${YELLOW}[MANUAL]${NC} $1"; }
separator() { echo -e "${CYAN}─────────────────────────────────────────────────────${NC}"; }
check_root() {
if [[ $EUID -ne 0 ]]; then
echo -e "${RED}This script must be run as root (sudo).${NC}"
exit 1
fi
}
wait_for_key() {
echo -e "\n Press ${BOLD}Enter${NC} to continue..."
read -r
}
# Grep recent log entries (last N lines)
log_grep() {
local pattern="$1"
local lines="${2:-200}"
tail -n "$lines" "$CTRLD_LOG" 2>/dev/null | grep -i "$pattern" 2>/dev/null || true
}
log_grep_count() {
local pattern="$1"
local lines="${2:-200}"
tail -n "$lines" "$CTRLD_LOG" 2>/dev/null | grep -ci "$pattern" 2>/dev/null || echo "0"
}
# =============================================================================
# TEST SECTIONS
# =============================================================================
test_prereqs() {
header "0. Prerequisites"
if command -v pfctl &>/dev/null; then
pass "pfctl available"
else
fail "pfctl not found"
exit 1
fi
if [[ -f "$CTRLD_LOG" ]]; then
pass "ctrld log exists at $CTRLD_LOG"
else
warn "ctrld log not found at $CTRLD_LOG — log checks will be skipped"
fi
if command -v dig &>/dev/null; then
pass "dig available"
else
fail "dig not found — install bind tools"
exit 1
fi
info "Default route interface: $(route -n get default 2>/dev/null | grep interface | awk '{print $2}' || echo 'unknown')"
info "Current DNS servers:"
scutil --dns | grep "nameserver\[" | head -5 | sed 's/^/ /'
}
test_pf_state() {
header "1. PF State Validation"
# Is pf enabled?
local pf_status
pf_status=$(pfctl -si 2>&1 | grep "Status:" || true)
if echo "$pf_status" | grep -q "Enabled"; then
pass "pf is enabled"
else
fail "pf is NOT enabled (status: $pf_status)"
fi
# Is our anchor referenced in the running ruleset?
local sr_match sn_match
sr_match=$(pfctl -sr 2>&1 | grep "$PF_ANCHOR" || true)
sn_match=$(pfctl -sn 2>&1 | grep "$PF_ANCHOR" || true)
if [[ -n "$sr_match" ]]; then
pass "anchor '$PF_ANCHOR' found in filter rules (pfctl -sr)"
info " $sr_match"
else
fail "anchor '$PF_ANCHOR' NOT in filter rules — main ruleset doesn't reference it"
fi
if [[ -n "$sn_match" ]]; then
pass "rdr-anchor '$PF_ANCHOR' found in NAT rules (pfctl -sn)"
info " $sn_match"
else
fail "rdr-anchor '$PF_ANCHOR' NOT in NAT rules — redirect won't work"
fi
# Check anchor rules
separator
info "Anchor filter rules (pfctl -a '$PF_ANCHOR' -sr):"
local anchor_sr
anchor_sr=$(pfctl -a "$PF_ANCHOR" -sr 2>&1 | grep -v "ALTQ" || true)
if [[ -n "$anchor_sr" ]]; then
echo "$anchor_sr" | sed 's/^/ /'
# Check for route-to rules
if echo "$anchor_sr" | grep -q "route-to"; then
pass "route-to lo0 rules present (needed for local traffic interception)"
else
warn "No route-to rules found — local DNS may not be intercepted"
fi
else
fail "No filter rules in anchor"
fi
info "Anchor redirect rules (pfctl -a '$PF_ANCHOR' -sn):"
local anchor_sn
anchor_sn=$(pfctl -a "$PF_ANCHOR" -sn 2>&1 | grep -v "ALTQ" || true)
if [[ -n "$anchor_sn" ]]; then
echo "$anchor_sn" | sed 's/^/ /'
if echo "$anchor_sn" | grep -q "rdr.*lo0.*port = 53"; then
pass "rdr rules on lo0 present (redirect DNS to ctrld)"
else
warn "rdr rules don't match expected pattern"
fi
else
fail "No redirect rules in anchor"
fi
# Check anchor file exists
if [[ -f "/etc/pf.anchors/$PF_ANCHOR" ]]; then
pass "Anchor file exists: /etc/pf.anchors/$PF_ANCHOR"
else
fail "Anchor file missing: /etc/pf.anchors/$PF_ANCHOR"
fi
# Check pf.conf was NOT modified
if grep -q "$PF_ANCHOR" /etc/pf.conf 2>/dev/null; then
warn "pf.conf contains '$PF_ANCHOR' reference — should NOT be modified on disk"
else
pass "pf.conf NOT modified on disk (anchor injected at runtime only)"
fi
}
test_dns_interception() {
header "2. DNS Interception Tests"
# Mark position in log
local log_lines_before=0
if [[ -f "$CTRLD_LOG" ]]; then
log_lines_before=$(wc -l < "$CTRLD_LOG")
fi
# Test 1: Query to external resolver should be intercepted
info "Test: dig @8.8.8.8 example.com (should be intercepted by ctrld)"
local dig_result
dig_result=$(dig @8.8.8.8 example.com +short +timeout=5 2>&1 || true)
if [[ -n "$dig_result" ]] && ! echo "$dig_result" | grep -q "timed out"; then
pass "dig @8.8.8.8 returned result: $dig_result"
else
fail "dig @8.8.8.8 failed or timed out"
fi
# Check if ctrld logged the query
sleep 1
if [[ -f "$CTRLD_LOG" ]]; then
local intercepted
intercepted=$(tail -n +$((log_lines_before+1)) "$CTRLD_LOG" | grep -c "example.com" || echo "0")
if [[ "$intercepted" -gt 0 ]]; then
pass "ctrld logged the intercepted query for example.com"
else
fail "ctrld did NOT log query for example.com — interception may not be working"
fi
fi
# Check dig reports ctrld answered (not 8.8.8.8)
local full_dig
full_dig=$(dig @8.8.8.8 example.com +timeout=5 2>&1 || true)
local server_line
server_line=$(echo "$full_dig" | grep "SERVER:" || true)
info "dig SERVER line: $server_line"
if echo "$server_line" | grep -q "127.0.0.1"; then
pass "Response came from 127.0.0.1 (ctrld intercepted)"
elif echo "$server_line" | grep -q "8.8.8.8"; then
fail "Response came from 8.8.8.8 directly — NOT intercepted"
else
warn "Could not determine response server from dig output"
fi
separator
# Test 2: Query to another external resolver
info "Test: dig @1.1.1.1 cloudflare.com (should also be intercepted)"
local dig2
dig2=$(dig @1.1.1.1 cloudflare.com +short +timeout=5 2>&1 || true)
if [[ -n "$dig2" ]] && ! echo "$dig2" | grep -q "timed out"; then
pass "dig @1.1.1.1 returned result"
else
fail "dig @1.1.1.1 failed or timed out"
fi
separator
# Test 3: Query to localhost should work (not double-redirected)
info "Test: dig @127.0.0.1 example.org (direct to ctrld, should NOT be redirected)"
local dig3
dig3=$(dig @127.0.0.1 example.org +short +timeout=5 2>&1 || true)
if [[ -n "$dig3" ]] && ! echo "$dig3" | grep -q "timed out"; then
pass "dig @127.0.0.1 works (no loop)"
else
fail "dig @127.0.0.1 failed — possible redirect loop"
fi
separator
# Test 4: System DNS resolution
info "Test: host example.net (system resolver, should go through ctrld)"
local host_result
host_result=$(host example.net 2>&1 || true)
if echo "$host_result" | grep -q "has address"; then
pass "System DNS resolution works via host command"
else
fail "System DNS resolution failed"
fi
separator
# Test 5: TCP DNS query
info "Test: dig @9.9.9.9 example.com +tcp (TCP DNS should also be intercepted)"
local dig_tcp
dig_tcp=$(dig @9.9.9.9 example.com +tcp +short +timeout=5 2>&1 || true)
if [[ -n "$dig_tcp" ]] && ! echo "$dig_tcp" | grep -q "timed out"; then
pass "TCP DNS query intercepted and resolved"
else
warn "TCP DNS query failed (may not be critical if UDP works)"
fi
}
test_non_dns_unaffected() {
header "3. Non-DNS Traffic Unaffected"
# HTTPS should work fine
info "Test: curl https://example.com (HTTPS port 443 should NOT be affected)"
local curl_result
curl_result=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 https://example.com 2>&1 || echo "000")
if [[ "$curl_result" == "200" ]] || [[ "$curl_result" == "301" ]] || [[ "$curl_result" == "302" ]]; then
pass "HTTPS works (HTTP $curl_result)"
else
fail "HTTPS failed (HTTP $curl_result) — pf may be affecting non-DNS traffic"
fi
# SSH-style connection test (port 22 should be unaffected)
info "Test: nc -z -w5 github.com 22 (SSH port should NOT be affected)"
if nc -z -w5 github.com 22 2>/dev/null; then
pass "SSH port reachable (non-DNS traffic unaffected)"
else
warn "SSH port unreachable (may be firewall, not necessarily our fault)"
fi
}
test_ctrld_log_health() {
header "4. ctrld Log Health Check"
if [[ ! -f "$CTRLD_LOG" ]]; then
warn "Skipping log checks — $CTRLD_LOG not found"
return
fi
# Check for intercept initialization
if log_grep "DNS intercept.*initializing" 500 | grep -q "."; then
pass "DNS intercept initialization logged"
else
fail "No DNS intercept initialization in recent logs"
fi
# Check for successful anchor load
if log_grep "pf anchor.*active" 500 | grep -q "."; then
pass "PF anchor reported as active"
else
fail "PF anchor not reported as active"
fi
# Check for anchor reference injection
if log_grep "anchor reference active" 500 | grep -q "."; then
pass "Anchor reference injected into running ruleset"
else
fail "Anchor reference NOT injected — this is the critical step"
fi
# Check for errors
separator
info "Recent errors/warnings in ctrld log:"
local errors
errors=$(log_grep '"level":"error"' 500)
if [[ -n "$errors" ]]; then
echo "$errors" | tail -5 | sed 's/^/ /'
warn "Errors found in recent logs (see above)"
else
pass "No errors in recent logs"
fi
local warnings
warnings=$(log_grep '"level":"warn"' 500 | grep -v "skipping self-upgrade" || true)
if [[ -n "$warnings" ]]; then
echo "$warnings" | tail -5 | sed 's/^/ /'
info "(warnings above may be expected)"
fi
# Check for recovery bypass state
if log_grep "recoveryBypass\|recovery bypass\|prepareForRecovery" 500 | grep -q "."; then
info "Recovery bypass activity detected in logs"
log_grep "recovery" 500 | tail -3 | sed 's/^/ /'
fi
# Check for VPN DNS detection
if log_grep "VPN DNS" 500 | grep -q "."; then
info "VPN DNS activity in logs:"
log_grep "VPN DNS" 500 | tail -5 | sed 's/^/ /'
else
info "No VPN DNS activity (expected if no VPN is connected)"
fi
}
test_pf_counters() {
header "5. PF Statistics & Counters"
info "PF info (pfctl -si):"
pfctl -si 2>&1 | grep -v "ALTQ" | head -15 | sed 's/^/ /'
info "PF state table entries:"
pfctl -ss 2>&1 | grep -c "." | sed 's/^/ States: /'
# Count evaluations of our anchor
info "Anchor-specific stats (if available):"
local anchor_info
anchor_info=$(pfctl -a "$PF_ANCHOR" -si 2>&1 | grep -v "ALTQ" || true)
if [[ -n "$anchor_info" ]]; then
echo "$anchor_info" | head -10 | sed 's/^/ /'
else
info " (no per-anchor stats available)"
fi
}
test_cleanup_on_stop() {
header "6. Cleanup Validation (After ctrld Stop)"
manual "Stop ctrld now (Ctrl+C or 'ctrld stop'), then press Enter"
wait_for_key
# Check anchor is flushed
local anchor_rules_after
anchor_rules_after=$(pfctl -a "$PF_ANCHOR" -sr 2>&1 | grep -v "ALTQ" | grep -v "^$" || true)
if [[ -z "$anchor_rules_after" ]]; then
pass "Anchor filter rules flushed after stop"
else
fail "Anchor filter rules still present after stop"
echo "$anchor_rules_after" | sed 's/^/ /'
fi
local anchor_rdr_after
anchor_rdr_after=$(pfctl -a "$PF_ANCHOR" -sn 2>&1 | grep -v "ALTQ" | grep -v "^$" || true)
if [[ -z "$anchor_rdr_after" ]]; then
pass "Anchor redirect rules flushed after stop"
else
fail "Anchor redirect rules still present after stop"
fi
# Check anchor file removed
if [[ ! -f "/etc/pf.anchors/$PF_ANCHOR" ]]; then
pass "Anchor file removed after stop"
else
fail "Anchor file still exists: /etc/pf.anchors/$PF_ANCHOR"
fi
# Check pf.conf is clean
if ! grep -q "$PF_ANCHOR" /etc/pf.conf 2>/dev/null; then
pass "pf.conf is clean (no ctrld references)"
else
fail "pf.conf still has ctrld references after stop"
fi
# DNS should work normally without ctrld
info "Test: dig example.com (should resolve via system DNS)"
local dig_after
dig_after=$(dig example.com +short +timeout=5 2>&1 || true)
if [[ -n "$dig_after" ]] && ! echo "$dig_after" | grep -q "timed out"; then
pass "DNS works after ctrld stop"
else
fail "DNS broken after ctrld stop — cleanup may have failed"
fi
}
test_restart_resilience() {
header "7. Restart Resilience"
manual "Start ctrld again with --dns-intercept, then press Enter"
wait_for_key
sleep 3
# Re-run pf state checks
local sr_match sn_match
sr_match=$(pfctl -sr 2>&1 | grep "$PF_ANCHOR" || true)
sn_match=$(pfctl -sn 2>&1 | grep "$PF_ANCHOR" || true)
if [[ -n "$sr_match" ]] && [[ -n "$sn_match" ]]; then
pass "Anchor references restored after restart"
else
fail "Anchor references NOT restored after restart"
fi
# Quick interception test
local dig_after_restart
dig_after_restart=$(dig @8.8.8.8 example.com +short +timeout=5 2>&1 || true)
if [[ -n "$dig_after_restart" ]] && ! echo "$dig_after_restart" | grep -q "timed out"; then
pass "DNS interception works after restart"
else
fail "DNS interception broken after restart"
fi
}
test_network_change() {
header "8. Network Change Recovery"
info "This test verifies recovery after network changes."
manual "Switch Wi-Fi networks (or disconnect/reconnect Ethernet), then press Enter"
wait_for_key
sleep 5
# Check pf rules still active
local sr_after sn_after
sr_after=$(pfctl -sr 2>&1 | grep "$PF_ANCHOR" || true)
sn_after=$(pfctl -sn 2>&1 | grep "$PF_ANCHOR" || true)
if [[ -n "$sr_after" ]] && [[ -n "$sn_after" ]]; then
pass "Anchor references survived network change"
else
fail "Anchor references lost after network change"
fi
# Check interception still works
local dig_after_net
dig_after_net=$(dig @8.8.8.8 example.com +short +timeout=10 2>&1 || true)
if [[ -n "$dig_after_net" ]] && ! echo "$dig_after_net" | grep -q "timed out"; then
pass "DNS interception works after network change"
else
fail "DNS interception broken after network change"
fi
# Check logs for recovery bypass activity
if [[ -f "$CTRLD_LOG" ]]; then
local recovery_logs
recovery_logs=$(log_grep "recovery\|network change\|network monitor" 100)
if [[ -n "$recovery_logs" ]]; then
info "Recovery/network change log entries:"
echo "$recovery_logs" | tail -5 | sed 's/^/ /'
fi
fi
}
# =============================================================================
# SUMMARY
# =============================================================================
print_summary() {
header "TEST SUMMARY"
echo ""
for r in "${RESULTS[@]}"; do
if [[ "$r" == PASS* ]]; then
echo -e " ${GREEN}${NC} ${r#PASS: }"
elif [[ "$r" == FAIL* ]]; then
echo -e " ${RED}${NC} ${r#FAIL: }"
elif [[ "$r" == WARN* ]]; then
echo -e " ${YELLOW}⚠️${NC} ${r#WARN: }"
fi
done
echo ""
separator
echo -e " ${GREEN}Passed: $PASS${NC} | ${RED}Failed: $FAIL${NC} | ${YELLOW}Warnings: $WARN${NC}"
separator
if [[ $FAIL -gt 0 ]]; then
echo -e "\n ${RED}${BOLD}Some tests failed.${NC} Check output above for details."
echo -e " Useful debug commands:"
echo -e " pfctl -a '$PF_ANCHOR' -sr # anchor filter rules"
echo -e " pfctl -a '$PF_ANCHOR' -sn # anchor redirect rules"
echo -e " pfctl -sr | grep controld # main ruleset references"
echo -e " tail -100 $CTRLD_LOG # recent ctrld logs"
else
echo -e "\n ${GREEN}${BOLD}All tests passed!${NC}"
fi
}
# =============================================================================
# MAIN
# =============================================================================
echo -e "${BOLD}╔═══════════════════════════════════════════════════════╗${NC}"
echo -e "${BOLD}║ ctrld DNS Intercept Mode — macOS Test Suite ║${NC}"
echo -e "${BOLD}║ Tests pf-based DNS interception (route-to + rdr) ║${NC}"
echo -e "${BOLD}╚═══════════════════════════════════════════════════════╝${NC}"
check_root
echo ""
echo "Make sure ctrld is running with --dns-intercept before starting."
echo "Log location: $CTRLD_LOG"
wait_for_key
test_prereqs
test_pf_state
test_dns_interception
test_non_dns_unaffected
test_ctrld_log_health
test_pf_counters
separator
echo ""
echo "The next tests require manual steps (stop/start ctrld, network changes)."
echo "Press Enter to continue, or Ctrl+C to skip and see results so far."
wait_for_key
test_cleanup_on_stop
test_restart_resilience
test_network_change
print_summary