Files
ctrld/test-scripts/darwin/test-dns-intercept.sh
2026-03-10 17:17:45 +07:00

557 lines
18 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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