mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-03-13 10:26:06 +00:00
273 lines
9.0 KiB
Bash
Executable File
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
|