Files
ctrld/test-scripts/darwin/validate-pf-rules.sh
2026-03-10 17:17:45 +07:00

273 lines
9.0 KiB
Bash
Executable File

#!/bin/bash
# validate-pf-rules.sh
# Standalone test of the pf redirect rules for dns-intercept mode.
# Does NOT require ctrld. Loads the pf anchor, validates interception, cleans up.
# Run as root (sudo).
set -e
GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
ok() { echo -e "${GREEN}[OK]${NC} $1"; }
fail() { echo -e "${RED}[FAIL]${NC} $1"; FAILURES=$((FAILURES+1)); }
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
FAILURES=0
ANCHOR="com.controld.ctrld.test"
ANCHOR_FILE="/tmp/pf-dns-intercept-test.conf"
# Use a local DNS listener to prove redirect works (python one-liner)
LISTENER_PID=""
cleanup() {
echo ""
echo -e "${CYAN}--- Cleanup ---${NC}"
# Remove anchor rules
pfctl -a "$ANCHOR" -F all 2>/dev/null && echo " Flushed anchor $ANCHOR" || true
# Remove anchor file
rm -f "$ANCHOR_FILE" "/tmp/pf-combined-test.conf" && echo " Removed temp files" || true
# Reload original pf.conf to remove anchor reference
pfctl -f /etc/pf.conf 2>/dev/null && echo " Reloaded original pf.conf" || true
# Kill test listener
if [ -n "$LISTENER_PID" ]; then
kill "$LISTENER_PID" 2>/dev/null && echo " Stopped test DNS listener" || true
fi
echo " Cleanup complete"
}
trap cleanup EXIT
resolve() {
dig "@${1}" "$2" A +short +timeout=3 +tries=1 2>/dev/null | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | head -1
}
echo -e "${CYAN}=== pf DNS Redirect Rule Validation ===${NC}"
echo " This loads the exact pf rules from the dns-intercept MR,"
echo " starts a tiny DNS listener on 127.0.0.1:53, and verifies"
echo " that queries to external IPs get redirected."
echo ""
# 0. Check we're root
if [ "$(id -u)" -ne 0 ]; then
fail "Must run as root (sudo)"
exit 1
fi
# 1. Start a minimal DNS listener on 127.0.0.1:53
# Uses socat to echo a fixed response — enough to prove redirect works.
# If port 53 is already in use (mDNSResponder), we'll use that instead.
echo "--- Step 1: DNS Listener on 127.0.0.1:53 ---"
if lsof -i :53 -sTCP:LISTEN 2>/dev/null | grep -q "." || lsof -i UDP:53 2>/dev/null | grep -q "."; then
ok "Something already listening on port 53 (likely mDNSResponder or ctrld)"
HAVE_LISTENER=true
else
# Start a simple Python DNS proxy that forwards to 1.1.1.1
python3 -c "
import socket, threading, sys
def proxy(data, addr, sock):
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(3)
s.sendto(data, ('1.1.1.1', 53))
resp, _ = s.recvfrom(4096)
sock.sendto(resp, addr)
s.close()
except: pass
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(('127.0.0.1', 53))
print('READY', flush=True)
while True:
data, addr = sock.recvfrom(4096)
threading.Thread(target=proxy, args=(data, addr, sock), daemon=True).start()
" &
LISTENER_PID=$!
sleep 1
if kill -0 "$LISTENER_PID" 2>/dev/null; then
ok "Started test DNS proxy on 127.0.0.1:53 (PID $LISTENER_PID, forwards to 1.1.1.1)"
HAVE_LISTENER=true
else
fail "Could not start DNS listener on port 53 — port may be in use"
HAVE_LISTENER=false
fi
fi
echo ""
# 2. Verify baseline: direct query to 8.8.8.8 works (before pf rules)
echo "--- Step 2: Baseline (before pf rules) ---"
IP=$(resolve "8.8.8.8" "example.com")
if [ -n "$IP" ]; then
ok "Direct DNS to 8.8.8.8 works (baseline): $IP"
else
warn "Direct DNS to 8.8.8.8 failed — may be blocked by existing firewall"
fi
echo ""
# 3. Write and load the pf anchor (exact rules from MR)
echo "--- Step 3: Load pf Anchor Rules ---"
TEST_UPSTREAM="1.1.1.1"
cat > "$ANCHOR_FILE" << PFRULES
# ctrld DNS Intercept Mode (test anchor)
# Two-step: route-to lo0 + rdr on lo0
#
# In production, ctrld uses DoH (port 443) for upstreams so they're not
# affected by port 53 rules. For this test, we exempt our upstream ($TEST_UPSTREAM)
# explicitly — same mechanism ctrld uses for OS resolver exemptions.
# --- Translation rules (rdr) ---
rdr pass on lo0 inet proto udp from any to ! 127.0.0.1 port 53 -> 127.0.0.1 port 53
rdr pass on lo0 inet proto tcp from any to ! 127.0.0.1 port 53 -> 127.0.0.1 port 53
# --- Filtering rules (pass) ---
# Exempt test upstream (in production: ctrld uses DoH, so this isn't needed).
pass out quick on ! lo0 inet proto { udp, tcp } from any to $TEST_UPSTREAM port 53
# Force remaining outbound DNS through loopback for interception.
pass out quick on ! lo0 route-to lo0 inet proto udp from any to ! 127.0.0.1 port 53 no state
pass out quick on ! lo0 route-to lo0 inet proto tcp from any to ! 127.0.0.1 port 53 no state
# Allow redirected traffic through on loopback.
pass in quick on lo0 inet proto { udp, tcp } from any to 127.0.0.1 port 53 no state
PFRULES
ok "Wrote anchor file: $ANCHOR_FILE"
cat "$ANCHOR_FILE" | sed 's/^/ /'
echo ""
# Load anchor
OUTPUT=$(pfctl -a "$ANCHOR" -f "$ANCHOR_FILE" 2>&1) || {
fail "Failed to load anchor: $OUTPUT"
exit 1
}
ok "Loaded anchor: $ANCHOR"
# Inject anchor references into running pf config.
# pf enforces strict rule ordering: options, normalization, queueing, translation, filtering.
# We must insert rdr-anchor with other rdr-anchors and anchor with other anchors.
TMPCONF="/tmp/pf-combined-test.conf"
python3 -c "
import sys
lines = open('/etc/pf.conf').read().splitlines()
anchor = '$ANCHOR'
rdr_ref = 'rdr-anchor \"' + anchor + '\"'
anchor_ref = 'anchor \"' + anchor + '\"'
out = []
rdr_done = False
anc_done = False
for line in lines:
s = line.strip()
# Insert our rdr-anchor before the first existing rdr-anchor
if not rdr_done and s.startswith('rdr-anchor'):
out.append(rdr_ref)
rdr_done = True
# Insert our anchor before the first existing anchor (filter-phase)
if not anc_done and s.startswith('anchor') and not s.startswith('anchor \"com.apple'):
out.append(anchor_ref)
anc_done = True
out.append(line)
# Fallback if no existing anchors found
if not rdr_done:
# Insert before first non-comment, non-blank after any 'set' or 'scrub' lines
out.insert(0, rdr_ref)
if not anc_done:
out.append(anchor_ref)
open('$TMPCONF', 'w').write('\n'.join(out) + '\n')
" || { fail "Failed to build combined pf config"; exit 1; }
INJECT_OUT=$(pfctl -f "$TMPCONF" 2>&1) || {
fail "Failed to inject anchor reference: $INJECT_OUT"
rm -f "$TMPCONF"
exit 1
}
rm -f "$TMPCONF"
ok "Injected anchor references into running pf ruleset"
# Enable pf
pfctl -e 2>/dev/null || true
# Show loaded rules
echo ""
echo " Active NAT rules:"
pfctl -a "$ANCHOR" -sn 2>/dev/null | sed 's/^/ /'
echo " Active filter rules:"
pfctl -a "$ANCHOR" -sr 2>/dev/null | sed 's/^/ /'
echo ""
# 4. Test: DNS to 8.8.8.8 should now be redirected to 127.0.0.1:53
echo "--- Step 4: Redirect Test ---"
if [ "$HAVE_LISTENER" = true ]; then
IP=$(resolve "8.8.8.8" "example.com" 5)
if [ -n "$IP" ]; then
ok "DNS to 8.8.8.8 redirected through 127.0.0.1:53: $IP"
else
fail "DNS to 8.8.8.8 failed — redirect may not be working"
fi
# Also test another random IP
IP2=$(resolve "9.9.9.9" "example.com" 5)
if [ -n "$IP2" ]; then
ok "DNS to 9.9.9.9 also redirected: $IP2"
else
fail "DNS to 9.9.9.9 failed"
fi
else
warn "No listener on port 53 — cannot test redirect"
fi
echo ""
# 5. Test: DNS to 127.0.0.1 still works (not double-redirected)
echo "--- Step 5: Localhost DNS (no loop) ---"
if [ "$HAVE_LISTENER" = true ]; then
IP=$(resolve "127.0.0.1" "example.com" 5)
if [ -n "$IP" ]; then
ok "DNS to 127.0.0.1 works normally (not caught by redirect): $IP"
else
fail "DNS to 127.0.0.1 failed — possible redirect loop"
fi
fi
echo ""
# 6. Simulate VPN DNS override
echo "--- Step 6: VPN DNS Override Simulation ---"
IFACE=$(route -n get default 2>/dev/null | awk '/interface:/{print $2}')
SVC=""
for try_svc in "Wi-Fi" "Ethernet" "Thunderbolt Ethernet"; do
if networksetup -getdnsservers "$try_svc" 2>/dev/null >/dev/null; then
SVC="$try_svc"
break
fi
done
if [ -n "$SVC" ] && [ "$HAVE_LISTENER" = true ]; then
ORIG_DNS=$(networksetup -getdnsservers "$SVC" 2>/dev/null || echo "")
echo " Service: $SVC"
echo " Current DNS: $ORIG_DNS"
networksetup -setdnsservers "$SVC" 10.50.10.77
dscacheutil -flushcache 2>/dev/null || true
killall -HUP mDNSResponder 2>/dev/null || true
echo " Set DNS to 10.50.10.77 (simulating F5 VPN)"
sleep 2
IP=$(resolve "10.50.10.77" "google.com" 5)
if [ -n "$IP" ]; then
ok "Query to fake VPN DNS (10.50.10.77) redirected to ctrld: $IP"
else
fail "Query to fake VPN DNS failed"
fi
# Restore
if echo "$ORIG_DNS" | grep -q "There aren't any DNS Servers"; then
networksetup -setdnsservers "$SVC" Empty
else
networksetup -setdnsservers "$SVC" $ORIG_DNS
fi
echo " Restored DNS"
else
warn "Skipping VPN simulation (no service found or no listener)"
fi
echo ""
if [ "$FAILURES" -eq 0 ]; then
echo -e "${GREEN}=== All tests passed ===${NC}"
else
echo -e "${RED}=== $FAILURES test(s) failed ===${NC}"
fi