feat: add macOS pf DNS interception

This commit is contained in:
Codescribe
2026-03-05 04:50:12 -05:00
committed by Cuong Manh Le
parent f76a332329
commit 3442331695
14 changed files with 4738 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,127 @@
//go:build darwin
package cli
import (
"strings"
"testing"
)
// =============================================================================
// buildPFAnchorRules tests
// =============================================================================
func TestPFBuildAnchorRules_Basic(t *testing.T) {
p := &prog{}
rules := p.buildPFAnchorRules(nil)
// rdr (translation) must come before pass (filtering)
rdrIdx := strings.Index(rules, "rdr pass on lo0")
passRouteIdx := strings.Index(rules, "pass out quick on ! lo0 route-to lo0")
passInIdx := strings.Index(rules, "pass in quick on lo0")
if rdrIdx < 0 {
t.Fatal("missing rdr rule")
}
if passRouteIdx < 0 {
t.Fatal("missing pass out route-to rule")
}
if passInIdx < 0 {
t.Fatal("missing pass in on lo0 rule")
}
if rdrIdx >= passRouteIdx {
t.Error("rdr rules must come before pass out route-to rules")
}
if passRouteIdx >= passInIdx {
t.Error("pass out route-to must come before pass in on lo0")
}
// Both UDP and TCP rdr rules
if !strings.Contains(rules, "proto udp") || !strings.Contains(rules, "proto tcp") {
t.Error("must have both UDP and TCP rdr rules")
}
}
func TestPFBuildAnchorRules_WithVPNServers(t *testing.T) {
p := &prog{}
vpnServers := []string{"10.8.0.1", "10.8.0.2"}
rules := p.buildPFAnchorRules(vpnServers)
// VPN exemption rules must appear
for _, s := range vpnServers {
if !strings.Contains(rules, s) {
t.Errorf("missing VPN exemption for %s", s)
}
}
// VPN exemptions must come before route-to
exemptIdx := strings.Index(rules, "10.8.0.1")
routeIdx := strings.Index(rules, "route-to lo0")
if exemptIdx >= routeIdx {
t.Error("VPN exemptions must come before route-to rules")
}
}
func TestPFBuildAnchorRules_IPv4AndIPv6VPN(t *testing.T) {
p := &prog{}
vpnServers := []string{"10.8.0.1", "fd00::1"}
rules := p.buildPFAnchorRules(vpnServers)
// IPv4 server should use "inet"
lines := strings.Split(rules, "\n")
for _, line := range lines {
if strings.Contains(line, "10.8.0.1") {
if !strings.Contains(line, "inet ") {
t.Error("IPv4 VPN server rule should contain 'inet'")
}
if strings.Contains(line, "inet6") {
t.Error("IPv4 VPN server rule should not contain 'inet6'")
}
}
if strings.Contains(line, "fd00::1") {
if !strings.Contains(line, "inet6") {
t.Error("IPv6 VPN server rule should contain 'inet6'")
}
}
}
}
func TestPFBuildAnchorRules_Ordering(t *testing.T) {
p := &prog{}
vpnServers := []string{"10.8.0.1"}
rules := p.buildPFAnchorRules(vpnServers)
// Verify ordering: rdr → exemptions → route-to → pass in on lo0
rdrIdx := strings.Index(rules, "rdr pass on lo0")
exemptIdx := strings.Index(rules, "pass out quick on ! lo0 inet proto { udp, tcp } from any to 10.8.0.1")
routeIdx := strings.Index(rules, "pass out quick on ! lo0 route-to lo0")
passInIdx := strings.Index(rules, "pass in quick on lo0")
if rdrIdx < 0 || exemptIdx < 0 || routeIdx < 0 || passInIdx < 0 {
t.Fatalf("missing expected rules: rdr=%d exempt=%d route=%d passIn=%d", rdrIdx, exemptIdx, routeIdx, passInIdx)
}
if !(rdrIdx < exemptIdx && exemptIdx < routeIdx && routeIdx < passInIdx) {
t.Errorf("incorrect rule ordering: rdr(%d) < exempt(%d) < route(%d) < passIn(%d)", rdrIdx, exemptIdx, routeIdx, passInIdx)
}
}
// TestPFAddressFamily tests the pfAddressFamily helper.
func TestPFAddressFamily(t *testing.T) {
tests := []struct {
ip string
want string
}{
{"10.0.0.1", "inet"},
{"192.168.1.1", "inet"},
{"127.0.0.1", "inet"},
{"::1", "inet6"},
{"fd00::1", "inet6"},
{"2001:db8::1", "inet6"},
}
for _, tt := range tests {
if got := pfAddressFamily(tt.ip); got != tt.want {
t.Errorf("pfAddressFamily(%q) = %q, want %q", tt.ip, got, tt.want)
}
}
}

298
docs/pf-dns-intercept.md Normal file
View File

@@ -0,0 +1,298 @@
# macOS pf DNS Interception — Technical Reference
## Overview
ctrld uses macOS's built-in packet filter (pf) to intercept all DNS traffic at the kernel level, redirecting it to ctrld's local listener at `127.0.0.1:53`. This operates below interface DNS settings, making it immune to VPN software (F5, Cisco, GlobalProtect, etc.) that overwrites DNS on network interfaces.
## How pf Works (Relevant Basics)
pf is a stateful packet filter built into macOS (and BSD). It processes packets through a pipeline with **strict rule ordering**:
```
options (set) → normalization (scrub) → queueing → translation (nat/rdr) → filtering (pass/block)
```
**Anchors** are named rule containers that allow programs to manage their own rules without modifying the global ruleset. Each anchor type must appear in the correct section:
| Anchor Type | Section | Purpose |
|-------------|---------|---------|
| `scrub-anchor` | Normalization | Packet normalization |
| `nat-anchor` | Translation | NAT rules |
| `rdr-anchor` | Translation | Redirect rules |
| `anchor` | Filtering | Pass/block rules |
**Critical constraint:** If you place a `rdr-anchor` line after an `anchor` line, pf rejects the entire config with "Rules must be in order."
## Why We Can't Just Use `rdr on ! lo0`
The obvious approach:
```
rdr pass on ! lo0 proto udp from any to any port 53 -> 127.0.0.1 port 53
```
**This doesn't work.** macOS pf `rdr` rules only apply to *forwarded/routed* traffic — packets passing through the machine to another destination. DNS queries originating from the machine itself (locally-originated) are never matched by `rdr` on non-loopback interfaces.
This is a well-known pf limitation on macOS/BSD. It means the VPN client's DNS queries would be redirected (if routed through the machine), but the user's own applications querying DNS directly would not.
## Our Approach: route-to + rdr (Two-Step)
We use a two-step technique to intercept locally-originated DNS:
```
Step 1: Force outbound DNS through loopback
pass out quick on ! lo0 route-to lo0 inet proto udp from any to ! 127.0.0.1 port 53
Step 2: Pass the packet outbound on lo0 (needed when VPN firewalls have "block drop all")
pass out quick on lo0 inet proto udp from any to ! 127.0.0.1 port 53 no state
Step 3: Redirect it on loopback to ctrld's listener
rdr on lo0 inet proto udp from any to ! 127.0.0.1 port 53 -> 127.0.0.1 port 53
Step 4: Accept and create state for response routing
pass in quick on lo0 reply-to lo0 inet proto { udp, tcp } from any to 127.0.0.1 port 53
```
> **State handling is critical for VPN firewall coexistence:**
> - **route-to**: `keep state` (default). State is interface-bound on macOS — doesn't match on lo0.
> - **pass out lo0**: `no state`. If this created state, it would match inbound on lo0 and bypass rdr.
> - **rdr**: no `pass` keyword. Packet must go through filter so `pass in` can create response state.
> - **pass in lo0**: `keep state` (default). Creates the ONLY state on lo0 — handles response routing.
### Packet Flow
```
Application queries 10.255.255.3:53 (e.g., VPN DNS server)
Kernel: outbound on en0 (or utun420 for VPN)
pf filter: "pass out route-to lo0 ... port 53" → redirects to lo0, creates state on en0
pf filter (outbound lo0): "pass out on lo0 ... no state" → passes, NO state created
Loopback reflects packet inbound on lo0
pf rdr (inbound lo0): "rdr on lo0 ... port 53 -> 127.0.0.1:53" → rewrites destination
pf filter (inbound lo0): "pass in reply-to lo0 ... to 127.0.0.1:53" → creates state + reply route
ctrld receives query on 127.0.0.1:53
ctrld resolves via DoH (port 443, exempted by group _ctrld)
Response from ctrld: 127.0.0.1:53 → 100.94.163.168:54851
reply-to lo0: forces response through lo0 (without this, kernel routes via utun420 → lost in VPN tunnel)
pf applies rdr reverse NAT: src 127.0.0.1 → 10.255.255.3
Application receives response from 10.255.255.3:53 ✓
```
### Why This Works
1. `route-to lo0` forces the packet onto loopback at the filter stage
2. `pass out on lo0 no state` gets past VPN "block drop all" without creating state
3. No state on lo0 means rdr gets fresh evaluation on the inbound pass
4. `reply-to lo0` on `pass in` forces the response through lo0 — without it, the kernel routes the response to VPN tunnel IPs via the VPN interface and it's lost
4. `rdr` (without `pass`) redirects then hands off to filter rules
5. `pass in keep state` creates the response state — the only state on the lo0 path
6. Traffic already destined for `127.0.0.1` is excluded (`to ! 127.0.0.1`) to prevent loops
7. ctrld's own upstream queries use DoH (port 443), bypassing port 53 rules entirely
### Why Each State Decision Matters
| Rule | State | Why |
|------|-------|-----|
| route-to on en0/utun | keep state | Needed for return routing. Interface-bound, won't match on lo0. |
| pass out on lo0 | **no state** | If stateful, it would match inbound lo0 → bypass rdr → DNS broken |
| rdr on lo0 | N/A (no pass) | Must go through filter so pass-in creates response state |
| pass in on lo0 | keep state + reply-to lo0 | Creates lo0 state. `reply-to` forces response through lo0 (not VPN tunnel). |
## Rule Ordering Within the Anchor
pf requires translation rules before filter rules, even within an anchor:
```pf
# === Translation rules (MUST come first) ===
rdr on lo0 inet proto udp from any to ! 127.0.0.1 port 53 -> 127.0.0.1 port 53
rdr on lo0 inet proto tcp from any to ! 127.0.0.1 port 53 -> 127.0.0.1 port 53
# === Exemptions (filter phase, scoped to _ctrld group) ===
pass out quick on ! lo0 inet proto { udp, tcp } from any to <OS_RESOLVER_IP> port 53 group _ctrld
pass out quick on ! lo0 inet proto { udp, tcp } from any to <VPN_DNS_IP> port 53 group _ctrld
# === Main intercept (filter phase) ===
pass out quick on ! lo0 route-to lo0 inet proto udp from any to ! 127.0.0.1 port 53
pass out quick on ! lo0 route-to lo0 inet proto tcp from any to ! 127.0.0.1 port 53
# === Allow redirected traffic on loopback ===
pass in quick on lo0 reply-to lo0 inet proto { udp, tcp } from any to 127.0.0.1 port 53
```
### Exemption Mechanism (Group-Scoped)
Some IPs must bypass the redirect:
- **OS resolver nameservers** (e.g., DHCP-assigned DNS): ctrld's recovery/bootstrap path may query these on port 53. Without exemption, these queries loop back to ctrld.
- **VPN DNS servers**: When ctrld forwards VPN-specific domains (split DNS) to the VPN's internal DNS, those queries must reach the VPN DNS server directly.
Exemptions use `pass out quick` with `group _ctrld` **before** the `route-to` rule. The `group _ctrld` constraint ensures that **only ctrld's own process** can bypass the redirect — other applications cannot circumvent DNS interception by querying the exempted IPs directly. Because pf evaluates filter rules in order and `quick` terminates evaluation, the exempted packet goes directly out the real interface and never hits the `route-to` or `rdr`.
### The `_ctrld` Group
To scope pf exemptions to ctrld's process only, we use a dedicated macOS system group:
1. **Creation**: On startup, `ensureCtrldGroup()` creates a `_ctrld` system group via `dscl` (macOS Directory Services) if it doesn't already exist. The GID is chosen from the 350-450 range to avoid conflicts with Apple's reserved ranges. The function is idempotent.
2. **Process GID**: Before loading pf rules, ctrld sets its effective GID to `_ctrld` via `syscall.Setegid()`. All sockets created by ctrld after this point are tagged with this GID.
3. **pf matching**: Exemption rules include `group _ctrld`, so pf only allows bypass for packets from processes with this effective GID. Other processes querying the same exempt IPs are still redirected to ctrld.
4. **Lifecycle**: The group is **never removed** on shutdown or uninstall. It's a harmless system group, and leaving it avoids race conditions during rapid restart cycles. It is recreated (no-op if exists) on every start.
## Anchor Injection into pf.conf
The trickiest part. macOS only processes anchors declared in the active pf ruleset. We must inject our anchor references into the running config.
### What We Do
1. Read `/etc/pf.conf`
2. If our anchor reference already exists, reload as-is
3. Otherwise, inject `rdr-anchor "com.controld.ctrld"` in the translation section and `anchor "com.controld.ctrld"` in the filter section
4. Write to a **temp file** and load with `pfctl -f <tmpfile>`
5. **We never modify `/etc/pf.conf` on disk** — changes are runtime-only and don't survive reboot (ctrld re-injects on every start)
### Injection Logic
Finding the right insertion point requires understanding the existing pf.conf structure. The algorithm:
1. **Scan** for existing `rdr-anchor`/`nat-anchor`/`binat-anchor` lines (translation section) and `anchor` lines (filter section)
2. **Insert `rdr-anchor`**:
- Before the first existing `rdr-anchor` line (if any exist)
- Else before the first `anchor` line (translation must come before filtering)
- Else before the first `pass`/`block` line
- Last resort: append (but this should never happen with a valid pf.conf)
3. **Insert `anchor`**:
- Before the first existing `anchor` line (if any)
- Else before the first `pass`/`block` line
- Last resort: append
### Real-World pf.conf Scenarios
We test against these configurations:
#### Default macOS (Sequoia/Sonoma)
```
scrub-anchor "com.apple/*"
nat-anchor "com.apple/*"
rdr-anchor "com.apple/*"
anchor "com.apple/*"
load anchor "com.apple" from "/etc/pf.anchors/com.apple"
```
Our `rdr-anchor` goes before `rdr-anchor "com.apple/*"`, our `anchor` goes before `anchor "com.apple/*"`.
#### Little Snitch
Adds `rdr-anchor "com.obdev.littlesnitch"` and `anchor "com.obdev.littlesnitch"` in the appropriate sections. Our anchors coexist — pf processes multiple anchors in order.
#### Lulu Firewall (Objective-See)
Adds `anchor "com.objective-see.lulu"`. We insert `rdr-anchor` before it (translation before filtering) and `anchor` before it.
#### Cisco AnyConnect
Adds `nat-anchor "com.cisco.anyconnect"`, `rdr-anchor "com.cisco.anyconnect"`, `anchor "com.cisco.anyconnect"`. Our anchors insert alongside Cisco's in their respective sections.
#### Minimal pf.conf (no anchors)
Just `set skip on lo0` and `pass all`. We insert `rdr-anchor` and `anchor` before the `pass` line.
#### Empty pf.conf
Both anchors appended. This is a degenerate case that shouldn't occur in practice.
## Failure Modes and Safety
### What happens if our injection fails?
- `ensurePFAnchorReference` returns an error, logged as a warning
- ctrld continues running but DNS interception may not work
- The anchor file and rules are cleaned up on shutdown
- **No damage to existing pf config** — we never modify files on disk
### What happens if ctrld crashes (SIGKILL)?
- pf anchor rules persist in kernel memory
- DNS is redirected to 127.0.0.1:53 but nothing is listening → DNS breaks
- On next `ctrld start`, we detect the stale anchor file, flush the anchor, and start fresh
- Without ctrld restart: `sudo pfctl -a com.controld.ctrld -F all` manually clears it
### What if another program flushes all pf rules?
- Our anchor references are removed from the running config
- DNS interception stops (traffic goes direct again — fails open, not closed)
- The periodic watchdog (30s) detects missing rules and restores them
- ctrld continues working for queries sent to 127.0.0.1 directly
### What if another program reloads pf.conf (corrupting translation state)?
Programs like Parallels Desktop reload `/etc/pf.conf` when creating or destroying
virtual network interfaces (bridge100, vmenet0). This can corrupt pf's internal
translation engine — **rdr rules survive in text form but stop evaluating**.
The watchdog's rule-text checks say "intact" while DNS is silently broken.
**Detection:** ctrld detects interface appearance/disappearance in the network
change handler and spawns an asynchronous interception probe monitor:
1. A subprocess sends a DNS query WITHOUT the `_ctrld` group GID, so pf
intercept rules apply to it
2. If ctrld receives the query → pf interception is working
3. If the query times out (1s) → pf translation is broken
4. On failure: `forceReloadPFMainRuleset()` does `pfctl -f -` with the current
running ruleset, resetting pf's translation engine
The monitor probes with exponential backoff (0, 0.5, 1, 2, 4s) to win the race
against async pf reloads. Only one monitor runs at a time (singleton). The
watchdog also runs the probe every 30s as a safety net.
The full pf reload is VPN-safe: it reassembles from `pfctl -sr` + `pfctl -sn`
(the current running state), preserving all existing anchors and rules.
### What if another program adds conflicting rdr rules?
- pf processes anchors in declaration order
- If another program redirects port 53 before our anchor, their redirect wins
- If after, ours wins (first match with `quick` or `rdr pass`)
- Our maximum-weight sublayer approach on Windows (WFP) doesn't apply to pf — pf uses rule ordering, not weights
### What about `set skip on lo0`?
Some pf.conf files include `set skip on lo0` which tells pf to skip ALL processing on loopback. **This would break our approach** since both the `rdr on lo0` and `pass in on lo0` rules would be skipped.
**Mitigation:** When injecting anchor references via `ensurePFAnchorReference()`,
we strip `lo0` from any `set skip on` directives before reloading. The watchdog
also checks for `set skip on lo0` and triggers a restore if detected. The
interception probe provides an additional safety net — if `set skip on lo0` gets
re-applied by another program, the probe will fail and trigger a full reload.
## Cleanup
On shutdown (`stopDNSIntercept`):
1. `pfctl -a com.controld.ctrld -F all` — flush all rules from our anchor
2. Remove `/etc/pf.anchors/com.controld.ctrld` anchor file
3. `pfctl -f /etc/pf.conf` — reload original pf.conf, removing our injected anchor references from the running config
This is clean: no files modified on disk, no residual rules.
## Comparison with Other Approaches
| Approach | Intercepts local DNS? | Survives VPN DNS override? | Risk of loops? | Complexity |
|----------|----------------------|---------------------------|----------------|------------|
| `rdr on ! lo0` | ❌ No | Yes | Low | Low |
| `route-to lo0` + `rdr on lo0` | ✅ Yes | Yes | Medium (need exemptions) | Medium |
| `/etc/resolver/` | Partial (per-domain only) | No (VPN can overwrite) | Low | Low |
| `NEDNSProxyProvider` | ✅ Yes | Yes | Low | High (needs app bundle) |
| NRPT (Windows only) | N/A | Partial | Low | Medium |
We chose `route-to + rdr` as the best balance of effectiveness and deployability (no app bundle needed, no kernel extension, works with existing ctrld binary).
## Key pf Nuances Learned
1. **`rdr` doesn't match locally-originated traffic** — this is the biggest gotcha
2. **Rule ordering is enforced** — translation before filtering, always
3. **Anchors must be declared in the main ruleset** — just loading an anchor file isn't enough
4. **`rdr` without `pass`** — redirected packets must go through filter rules so `pass in keep state` can create response state. `rdr pass` alone is insufficient for response delivery.
5. **State handling is nuanced** — route-to uses `keep state` (state is floating). `pass out on lo0` must use `no state` (prevents rdr bypass). `pass in on lo0` uses `keep state` + `reply-to lo0` (creates response state AND forces response through loopback instead of VPN tunnel). Getting any of these wrong breaks either the forward or return path.
6. **`quick` terminates evaluation** — exemption rules must use `quick` and appear before the route-to rule
7. **Piping to `pfctl -f -` can fail** — special characters in pf.conf content cause issues; use temp files
8. **`set skip on lo0` would break us** — but it's not in default macOS pf.conf
9. **`pass out quick` exemptions work with route-to** — they fire in the same phase (filter), so `quick` + rule ordering means exempted packets never hit the route-to rule

44
test-scripts/README.md Normal file
View File

@@ -0,0 +1,44 @@
# DNS Intercept Test Scripts
Manual test scripts for verifying DNS intercept mode behavior. These require root/admin privileges and a running ctrld instance.
## Structure
```
test-scripts/
├── darwin/
│ ├── test-recovery-bypass.sh # Captive portal recovery simulation
│ ├── test-dns-intercept.sh # Basic pf intercept verification
│ ├── test-pf-group-exemption.sh # Group-based pf exemption test
│ └── validate-pf-rules.sh # Dry-run pf rule validation
└── windows/
├── test-recovery-bypass.ps1 # Captive portal recovery simulation
└── test-dns-intercept.ps1 # Basic WFP intercept verification
```
## Prerequisites
- ctrld running with `--intercept-mode dns` (or `--intercept-mode hard`)
- Verbose logging: `-v 1 --log /tmp/dns.log` (macOS) or `--log C:\temp\dns.log` (Windows)
- Root (macOS) or Administrator (Windows)
- For recovery tests: disconnect VPNs (e.g., Tailscale) that provide alternative routes
## Recovery Bypass Test
Simulates a captive portal by blackholing ctrld's upstream DoH IPs and cycling wifi. Verifies that ctrld's recovery bypass activates, discovers DHCP nameservers, and forwards queries to them until the upstream recovers.
### macOS
```bash
sudo bash test-scripts/darwin/test-recovery-bypass.sh en0
```
### Windows (PowerShell as Administrator)
```powershell
.\test-scripts\windows\test-recovery-bypass.ps1 -WifiAdapter "Wi-Fi"
```
## Safety
All scripts clean up on exit (including Ctrl+C):
- **macOS**: Removes route blackholes, re-enables wifi
- **Windows**: Removes firewall rules, re-enables adapter

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

View File

@@ -0,0 +1,147 @@
#!/bin/bash
# Test: pf group-based exemption for DNS intercept
# Run as root: sudo bash test-pf-group-exemption.sh
set -e
GROUP_NAME="_ctrld"
ANCHOR="com.controld.test"
TEST_DNS="1.1.1.1"
echo "=== Step 1: Create test group ==="
if dscl . -read /Groups/$GROUP_NAME PrimaryGroupID &>/dev/null; then
echo "Group $GROUP_NAME already exists"
else
# Find an unused GID in 350-450 range
USED_GIDS=$(dscl . -list /Groups PrimaryGroupID 2>/dev/null | awk '{print $2}' | sort -n)
GROUP_ID=""
for gid in $(seq 350 450); do
if ! echo "$USED_GIDS" | grep -q "^${gid}$"; then
GROUP_ID=$gid
break
fi
done
if [ -z "$GROUP_ID" ]; then
echo "ERROR: Could not find unused GID in 350-450 range"
exit 1
fi
dscl . -create /Groups/$GROUP_NAME
dscl . -create /Groups/$GROUP_NAME PrimaryGroupID $GROUP_ID
dscl . -create /Groups/$GROUP_NAME RealName "Control D DNS Intercept"
echo "Created group $GROUP_NAME (GID $GROUP_ID)"
fi
ACTUAL_GID=$(dscl . -read /Groups/$GROUP_NAME PrimaryGroupID | awk '{print $2}')
echo "GID: $ACTUAL_GID"
echo ""
echo "=== Step 2: Enable pf ==="
pfctl -e 2>&1 || true
echo ""
echo "=== Step 3: Set up pf anchor with group exemption ==="
cat > /tmp/pf-group-test-anchor.conf << RULES
# Translation: redirect DNS on loopback to our listener
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
# Exemption: only group _ctrld can talk to $TEST_DNS directly
pass out quick on ! lo0 inet proto { udp, tcp } from any to $TEST_DNS port 53 group $GROUP_NAME
# Intercept everything else
pass out quick on ! lo0 route-to lo0 inet proto udp from any to ! 127.0.0.1 port 53
pass out quick on ! lo0 route-to lo0 inet proto tcp from any to ! 127.0.0.1 port 53
pass in quick on lo0 inet proto { udp, tcp } from any to 127.0.0.1 port 53
RULES
pfctl -a $ANCHOR -f /tmp/pf-group-test-anchor.conf 2>/dev/null
echo "Loaded anchor $ANCHOR"
# Inject anchor refs into running ruleset
NAT_RULES=$(pfctl -sn 2>/dev/null | grep -v "ALTQ" | grep -v "^$")
FILTER_RULES=$(pfctl -sr 2>/dev/null | grep -v "ALTQ" | grep -v "^$")
SCRUB_RULES=$(echo "$FILTER_RULES" | grep "^scrub" || true)
PURE_FILTER=$(echo "$FILTER_RULES" | grep -v "^scrub" | grep -v "com.controld.test" || true)
CLEAN_NAT=$(echo "$NAT_RULES" | grep -v "com.controld.test" || true)
{
[ -n "$SCRUB_RULES" ] && echo "$SCRUB_RULES"
[ -n "$CLEAN_NAT" ] && echo "$CLEAN_NAT"
echo "rdr-anchor \"$ANCHOR\""
echo "anchor \"$ANCHOR\""
[ -n "$PURE_FILTER" ] && echo "$PURE_FILTER"
} | pfctl -f - 2>/dev/null
echo "Injected anchor references (no duplicates)"
echo ""
echo "=== Step 4: Verify rules ==="
echo "NAT rules:"
pfctl -sn 2>/dev/null | grep -v ALTQ
echo ""
echo "Anchor filter rules:"
pfctl -a $ANCHOR -sr 2>/dev/null | grep -v ALTQ
echo ""
echo "Anchor NAT rules:"
pfctl -a $ANCHOR -sn 2>/dev/null | grep -v ALTQ
echo ""
echo "=== Step 5: Build setgid test binary ==="
# We need a binary that runs with effective group _ctrld.
# sudo -g doesn't work on macOS, so we use a setgid binary.
cat > /tmp/test-dns-group.c << 'EOF'
#include <unistd.h>
int main() {
char *args[] = {"dig", "+short", "+timeout=3", "+tries=1", "@1.1.1.1", "popads.net", NULL};
execvp("dig", args);
return 1;
}
EOF
cc -o /tmp/test-dns-group /tmp/test-dns-group.c
chgrp $GROUP_NAME /tmp/test-dns-group
chmod g+s /tmp/test-dns-group
echo "Built setgid binary /tmp/test-dns-group (group: $GROUP_NAME)"
echo ""
echo "=== Step 6: Test as regular user (should be INTERCEPTED) ==="
echo "Running: dig @$TEST_DNS popads.net (as root / group wheel — no group exemption)"
echo "If nothing listens on 127.0.0.1:53, this should timeout."
DIG_RESULT=$(dig +short +timeout=3 +tries=1 @$TEST_DNS popads.net 2>&1 || true)
echo "Result: ${DIG_RESULT:-TIMEOUT/INTERCEPTED}"
echo ""
echo "=== Step 7: Test as group _ctrld (should BYPASS) ==="
echo "Running: setgid binary (effective group: $GROUP_NAME)"
BYPASS_RESULT=$(/tmp/test-dns-group 2>&1 || true)
echo "Result: ${BYPASS_RESULT:-TIMEOUT/BLOCKED}"
echo ""
echo "=== Results ==="
PASS=true
if [[ -z "$DIG_RESULT" || "$DIG_RESULT" == *"timed out"* || "$DIG_RESULT" == *"connection refused"* ]]; then
echo "✅ Regular query INTERCEPTED (redirected away from $TEST_DNS)"
else
echo "❌ Regular query NOT intercepted — got: $DIG_RESULT"
PASS=false
fi
if [[ -n "$BYPASS_RESULT" && "$BYPASS_RESULT" != *"timed out"* && "$BYPASS_RESULT" != *"connection refused"* && "$BYPASS_RESULT" != *"TIMEOUT"* ]]; then
echo "✅ Group _ctrld query BYPASSED — got: $BYPASS_RESULT"
else
echo "❌ Group _ctrld query was also intercepted — got: ${BYPASS_RESULT:-TIMEOUT}"
PASS=false
fi
if $PASS; then
echo ""
echo "🎉 GROUP EXEMPTION WORKS — this approach is viable for dns-intercept mode"
fi
echo ""
echo "=== Cleanup ==="
pfctl -a $ANCHOR -F all 2>/dev/null
pfctl -f /etc/pf.conf 2>/dev/null
rm -f /tmp/pf-group-test-anchor.conf /tmp/test-dns-group /tmp/test-dns-group.c
echo "Cleaned up. Group $GROUP_NAME left in place."
echo "To remove: sudo dscl . -delete /Groups/$GROUP_NAME"

View File

@@ -0,0 +1,301 @@
#!/bin/bash
# test-recovery-bypass.sh — Test DNS intercept recovery bypass (captive portal simulation)
#
# Simulates a captive portal by:
# 1. Discovering ctrld's upstream IPs from active connections
# 2. Blackholing ALL of them via route table
# 3. Cycling wifi to trigger network change → recovery flow
# 4. Verifying recovery bypass forwards to OS/DHCP resolver
# 5. Unblocking and verifying normal operation resumes
#
# SAFE: Uses route add/delete + networksetup — cleaned up on exit (including Ctrl+C).
#
# Usage: sudo bash test-recovery-bypass.sh [wifi_interface]
# wifi_interface defaults to en0
#
# Prerequisites:
# - ctrld running with --dns-intercept and -v 1 --log /tmp/dns.log
# - Run as root (sudo)
set -euo pipefail
WIFI_IFACE="${1:-en0}"
CTRLD_LOG="/tmp/dns.log"
BLOCKED_IPS=()
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
log() { echo -e "${CYAN}[$(date +%H:%M:%S)]${NC} $*"; }
pass() { echo -e "${GREEN}[PASS]${NC} $*"; }
fail() { echo -e "${RED}[FAIL]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
# ── Safety: always clean up on exit ──────────────────────────────────────────
cleanup() {
echo ""
log "═══ CLEANUP ═══"
# Ensure wifi is on
log "Ensuring wifi is on..."
networksetup -setairportpower "$WIFI_IFACE" on 2>/dev/null || true
# Remove all blackhole routes
for ip in "${BLOCKED_IPS[@]}"; do
route delete -host "$ip" 2>/dev/null && log "Removed route for $ip" || true
done
log "Cleanup complete. Internet should be restored."
log "(If not, run: sudo networksetup -setairportpower $WIFI_IFACE on)"
}
trap cleanup EXIT INT TERM
# ── Pre-checks ───────────────────────────────────────────────────────────────
if [[ $EUID -ne 0 ]]; then
echo "Run as root: sudo bash $0 $*"
exit 1
fi
if [[ ! -f "$CTRLD_LOG" ]]; then
fail "ctrld log not found at $CTRLD_LOG"
echo "Start ctrld with: ctrld run --dns-intercept --cd <uid> -v 1 --log $CTRLD_LOG"
exit 1
fi
# Check wifi interface exists
if ! networksetup -getairportpower "$WIFI_IFACE" >/dev/null 2>&1; then
fail "Wifi interface $WIFI_IFACE not found"
echo "Try: networksetup -listallhardwareports"
exit 1
fi
log "═══════════════════════════════════════════════════════════"
log " Recovery Bypass Test (Captive Portal Simulation)"
log "═══════════════════════════════════════════════════════════"
log "Wifi interface: $WIFI_IFACE"
log "ctrld log: $CTRLD_LOG"
echo ""
# ── Phase 1: Discover upstream IPs ──────────────────────────────────────────
log "Phase 1: Discovering ctrld upstream IPs from active connections"
# Find ctrld's established connections (DoH uses port 443)
CTRLD_CONNS=$(lsof -i -n -P 2>/dev/null | grep -i ctrld | grep ESTABLISHED || true)
if [[ -z "$CTRLD_CONNS" ]]; then
warn "No established ctrld connections found via lsof"
warn "Trying: ss/netstat fallback..."
CTRLD_CONNS=$(netstat -an 2>/dev/null | grep "\.443 " | grep ESTABLISHED || true)
fi
echo "$CTRLD_CONNS" | head -10 | while read -r line; do
log " $line"
done
# Extract unique remote IPs from ctrld connections
UPSTREAM_IPS=()
while IFS= read -r ip; do
[[ -n "$ip" ]] && UPSTREAM_IPS+=("$ip")
done < <(echo "$CTRLD_CONNS" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | sort -u | while read -r ip; do
# Filter out local/private IPs — we only want the upstream DoH server IPs
if [[ ! "$ip" =~ ^(127\.|10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.) ]]; then
echo "$ip"
fi
done)
# Also try to resolve known Control D DoH endpoints
for host in dns.controld.com freedns.controld.com; do
for ip in $(dig +short "$host" 2>/dev/null || true); do
if [[ "$ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
UPSTREAM_IPS+=("$ip")
fi
done
done
# Deduplicate
UPSTREAM_IPS=($(printf '%s\n' "${UPSTREAM_IPS[@]}" | sort -u))
if [[ ${#UPSTREAM_IPS[@]} -eq 0 ]]; then
fail "Could not discover any upstream IPs!"
echo "Check: lsof -i -n -P | grep ctrld"
exit 1
fi
log "Found ${#UPSTREAM_IPS[@]} upstream IP(s):"
for ip in "${UPSTREAM_IPS[@]}"; do
log " $ip"
done
echo ""
# ── Phase 2: Baseline check ─────────────────────────────────────────────────
log "Phase 2: Baseline — verify DNS works normally"
BASELINE=$(dig +short +timeout=5 example.com @127.0.0.1 2>/dev/null || true)
if [[ -z "$BASELINE" ]]; then
fail "DNS not working before test!"
exit 1
fi
pass "Baseline: example.com → $BASELINE"
LOG_LINES_BEFORE=$(wc -l < "$CTRLD_LOG" | tr -d ' ')
log "Log position: line $LOG_LINES_BEFORE"
echo ""
# ── Phase 3: Block all upstream IPs ─────────────────────────────────────────
log "Phase 3: Blackholing all upstream IPs"
for ip in "${UPSTREAM_IPS[@]}"; do
route delete -host "$ip" 2>/dev/null || true # clean slate
route add -host "$ip" 127.0.0.1 2>/dev/null
BLOCKED_IPS+=("$ip")
log " Blocked: $ip → 127.0.0.1"
done
pass "All ${#UPSTREAM_IPS[@]} upstream IPs blackholed"
echo ""
# ── Phase 4: Cycle wifi to trigger network change ───────────────────────────
log "Phase 4: Cycling wifi to trigger network change event"
log " Turning wifi OFF..."
networksetup -setairportpower "$WIFI_IFACE" off
sleep 3
log " Turning wifi ON..."
networksetup -setairportpower "$WIFI_IFACE" on
log " Waiting for wifi to reconnect (up to 15s)..."
WIFI_UP=false
for i in $(seq 1 15); do
# Check if we have an IP on the wifi interface
IF_IP=$(ipconfig getifaddr "$WIFI_IFACE" 2>/dev/null || true)
if [[ -n "$IF_IP" ]]; then
WIFI_UP=true
pass "Wifi reconnected: $WIFI_IFACE$IF_IP"
break
fi
sleep 1
done
if [[ "$WIFI_UP" == "false" ]]; then
fail "Wifi did not reconnect in 15s!"
warn "Cleaning up and exiting..."
exit 1
fi
log " Waiting 5s for ctrld network monitor to fire..."
sleep 5
echo ""
# ── Phase 5: Query and watch for recovery ────────────────────────────────────
log "Phase 5: Sending queries — upstream is blocked, recovery should activate"
log " (ctrld should detect upstream failure → enable recovery bypass → use DHCP DNS)"
echo ""
RECOVERY_DETECTED=false
BYPASS_ACTIVE=false
DNS_DURING_BYPASS=false
QUERY_COUNT=0
for i in $(seq 1 30); do
QUERY_COUNT=$((QUERY_COUNT + 1))
RESULT=$(dig +short +timeout=3 "example.com" @127.0.0.1 2>/dev/null || true)
if [[ -n "$RESULT" ]]; then
log " Query #$QUERY_COUNT: example.com → $RESULT"
else
log " Query #$QUERY_COUNT: example.com → FAIL ✗"
fi
# Check logs
NEW_LOGS=$(tail -n +$((LOG_LINES_BEFORE + 1)) "$CTRLD_LOG" 2>/dev/null || true)
if [[ "$RECOVERY_DETECTED" == "false" ]] && echo "$NEW_LOGS" | grep -qiE "enabling DHCP bypass|triggering recovery|No healthy"; then
echo ""
pass "🎯 Recovery flow triggered!"
RECOVERY_DETECTED=true
echo "$NEW_LOGS" | grep -iE "recovery|bypass|DHCP|No healthy|network change" | tail -8 | while read -r line; do
echo " 📋 $line"
done
echo ""
fi
if [[ "$BYPASS_ACTIVE" == "false" ]] && echo "$NEW_LOGS" | grep -qi "Recovery bypass active"; then
pass "🔄 Recovery bypass is forwarding queries to OS/DHCP resolver"
BYPASS_ACTIVE=true
fi
if [[ "$RECOVERY_DETECTED" == "true" && -n "$RESULT" ]]; then
pass "✅ DNS resolves during recovery bypass: example.com → $RESULT"
DNS_DURING_BYPASS=true
break
fi
sleep 2
done
# ── Phase 6: Show all recovery-related log entries ──────────────────────────
echo ""
log "Phase 6: All recovery-related ctrld log entries"
log "────────────────────────────────────────────────"
NEW_LOGS=$(tail -n +$((LOG_LINES_BEFORE + 1)) "$CTRLD_LOG" 2>/dev/null || true)
RELEVANT=$(echo "$NEW_LOGS" | grep -iE "recovery|bypass|DHCP|unhealthy|upstream.*fail|No healthy|network change|network monitor|OS resolver" || true)
if [[ -n "$RELEVANT" ]]; then
echo "$RELEVANT" | head -40 | while read -r line; do
echo " $line"
done
else
warn "No recovery-related log entries found!"
log "Last 15 lines of ctrld log:"
tail -15 "$CTRLD_LOG" | while read -r line; do
echo " $line"
done
fi
# ── Phase 7: Unblock and verify full recovery ───────────────────────────────
echo ""
log "Phase 7: Unblocking upstream IPs"
for ip in "${BLOCKED_IPS[@]}"; do
route delete -host "$ip" 2>/dev/null && log " Unblocked: $ip" || true
done
BLOCKED_IPS=() # clear so cleanup doesn't double-delete
pass "All upstream IPs unblocked"
log "Waiting for ctrld to recover (up to 30s)..."
LOG_LINES_UNBLOCK=$(wc -l < "$CTRLD_LOG" | tr -d ' ')
RECOVERY_COMPLETE=false
for i in $(seq 1 15); do
dig +short +timeout=3 example.com @127.0.0.1 >/dev/null 2>&1 || true
POST_LOGS=$(tail -n +$((LOG_LINES_UNBLOCK + 1)) "$CTRLD_LOG" 2>/dev/null || true)
if echo "$POST_LOGS" | grep -qiE "recovery complete|disabling DHCP bypass|Upstream.*recovered"; then
RECOVERY_COMPLETE=true
pass "ctrld recovered — normal operation resumed"
echo "$POST_LOGS" | grep -iE "recovery|recovered|bypass|disabling" | head -5 | while read -r line; do
echo " 📋 $line"
done
break
fi
sleep 2
done
[[ "$RECOVERY_COMPLETE" == "false" ]] && warn "Recovery completion not detected (may need more time)"
# Final check
echo ""
log "Phase 8: Final DNS verification"
sleep 2
FINAL=$(dig +short +timeout=5 example.com @127.0.0.1 2>/dev/null || true)
if [[ -n "$FINAL" ]]; then
pass "DNS working: example.com → $FINAL"
else
fail "DNS not resolving"
fi
# ── Summary ──────────────────────────────────────────────────────────────────
echo ""
log "═══════════════════════════════════════════════════════════"
log " Test Summary"
log "═══════════════════════════════════════════════════════════"
[[ "$RECOVERY_DETECTED" == "true" ]] && pass "Recovery bypass activated" || fail "Recovery bypass NOT activated"
[[ "$BYPASS_ACTIVE" == "true" ]] && pass "Queries forwarded to OS/DHCP resolver" || warn "OS resolver forwarding not confirmed"
[[ "$DNS_DURING_BYPASS" == "true" ]] && pass "DNS resolved during bypass (proof of OS resolver leak)" || warn "DNS during bypass not confirmed"
[[ "$RECOVERY_COMPLETE" == "true" ]] && pass "Normal operation resumed after unblock" || warn "Recovery completion not confirmed"
[[ -n "${FINAL:-}" ]] && pass "DNS functional at end of test" || fail "DNS broken at end of test"
echo ""
log "Full log since test: tail -n +$LOG_LINES_BEFORE $CTRLD_LOG"
log "Recovery entries: tail -n +$LOG_LINES_BEFORE $CTRLD_LOG | grep -i recovery"

View File

@@ -0,0 +1,272 @@
#!/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

View File

@@ -0,0 +1,40 @@
#!/bin/bash
# diag-lo0-capture.sh — Capture DNS on lo0 to see where the pf chain breaks
# Usage: sudo bash diag-lo0-capture.sh
# Run while Windscribe + ctrld are both active, then dig from another terminal
set -u
PCAP="/tmp/lo0-dns-$(date +%s).pcap"
echo "=== lo0 DNS Packet Capture ==="
echo "Capturing to: $PCAP"
echo ""
# Show current rules (verify build)
echo "--- ctrld anchor rdr rules ---"
pfctl -a com.controld.ctrld -sn 2>/dev/null
echo ""
echo "--- ctrld anchor filter rules (lo0 only) ---"
pfctl -a com.controld.ctrld -sr 2>/dev/null | grep lo0
echo ""
# Check pf state table for port 53 before
echo "--- port 53 states BEFORE dig ---"
pfctl -ss 2>/dev/null | grep ':53' | head -10
echo "(total: $(pfctl -ss 2>/dev/null | grep -c ':53'))"
echo ""
# Start capture on lo0
echo "Starting tcpdump on lo0 port 53..."
echo ">>> In another terminal, run: dig example.com"
echo ">>> Then press Ctrl-C here"
echo ""
tcpdump -i lo0 -n -v port 53 -w "$PCAP" 2>&1 &
TCPDUMP_PID=$!
# Also show live output
tcpdump -i lo0 -n port 53 2>&1 &
LIVE_PID=$!
# Wait for Ctrl-C
trap "kill $TCPDUMP_PID $LIVE_PID 2>/dev/null; echo ''; echo '--- port 53 states AFTER dig ---'; pfctl -ss 2>/dev/null | grep ':53' | head -20; echo '(total: '$(pfctl -ss 2>/dev/null | grep -c ':53')')'; echo ''; echo 'Capture saved to: $PCAP'; echo 'Read with: tcpdump -r $PCAP -n -v'; exit 0" INT
wait

View File

@@ -0,0 +1,62 @@
#!/bin/bash
# diag-pf-poll.sh — Polls pf rules, options, states, and DNS every 2s
# Usage: sudo bash diag-pf-poll.sh | tee /tmp/pf-poll.log
# Steps: 1) Run script 2) Connect Windscribe 3) Start ctrld 4) Ctrl-C when done
set -u
LOG="/tmp/pf-poll-$(date +%s).log"
echo "=== PF Poll Diagnostic — logging to $LOG ==="
echo "Press Ctrl-C to stop"
echo ""
poll() {
local ts=$(date '+%H:%M:%S.%3N')
echo "======== [$ts] POLL ========"
# 1. pf options — looking for "set skip on lo0"
echo "--- pf options ---"
pfctl -so 2>/dev/null | grep -i skip || echo "(no skip rules)"
# 2. Main ruleset anchors — where is ctrld relative to block drop all?
echo "--- main filter rules (summary) ---"
pfctl -sr 2>/dev/null | head -30
# 3. Main NAT/rdr rules
echo "--- main nat/rdr rules (summary) ---"
pfctl -sn 2>/dev/null | head -20
# 4. ctrld anchor content
echo "--- ctrld anchor (filter) ---"
pfctl -a com.apple.internet-sharing/ctrld -sr 2>/dev/null || echo "(no anchor)"
echo "--- ctrld anchor (nat/rdr) ---"
pfctl -a com.apple.internet-sharing/ctrld -sn 2>/dev/null || echo "(no anchor)"
# 5. State count for rdr target (10.255.255.3) and loopback
echo "--- states summary ---"
local total=$(pfctl -ss 2>/dev/null | wc -l | tr -d ' ')
local rdr=$(pfctl -ss 2>/dev/null | grep -c '10\.255\.255\.3' || true)
local lo0=$(pfctl -ss 2>/dev/null | grep -c 'lo0' || true)
echo "total=$total rdr_target=$rdr lo0=$lo0"
# 6. Quick DNS test (1s timeout)
echo "--- DNS tests ---"
local direct=$(dig +short +time=1 +tries=1 example.com @127.0.0.1 2>&1 | head -1)
local system=$(dig +short +time=1 +tries=1 example.com 2>&1 | head -1)
echo "direct @127.0.0.1: $direct"
echo "system DNS: $system"
# 7. Windscribe tunnel interface
echo "--- tunnel interfaces ---"
ifconfig -l | tr ' ' '\n' | grep -E '^utun' | while read iface; do
echo -n "$iface: "
ifconfig "$iface" 2>/dev/null | grep 'inet ' | awk '{print $2}' || echo "no ip"
done
echo ""
}
# Main loop
while true; do
poll 2>&1 | tee -a "$LOG"
sleep 2
done

View File

@@ -0,0 +1,183 @@
#!/bin/bash
# diag-windscribe-connect.sh — Diagnostic script for testing ctrld dns-intercept
# during Windscribe VPN connection on macOS.
#
# Usage: sudo ./diag-windscribe-connect.sh
#
# Run this BEFORE connecting Windscribe. It polls every 0.5s and captures:
# 1. pf anchor state (are ctrld anchors present?)
# 2. pf state table entries (rdr interception working?)
# 3. ctrld log events (watchdog, rebootstrap, errors)
# 4. scutil DNS resolver state
# 5. Active tunnel interfaces
# 6. dig test query results
#
# Output goes to /tmp/diag-windscribe-<timestamp>/
# Press Ctrl-C to stop. A summary is printed at the end.
set -e
if [ "$(id -u)" -ne 0 ]; then
echo "ERROR: Must run as root (sudo)"
exit 1
fi
CTRLD_LOG="${CTRLD_LOG:-/tmp/dns.log}"
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
OUTDIR="/tmp/diag-windscribe-${TIMESTAMP}"
mkdir -p "$OUTDIR"
echo "=== Windscribe + ctrld DNS Intercept Diagnostic ==="
echo "Output: $OUTDIR"
echo "ctrld log: $CTRLD_LOG"
echo ""
echo "1. Start this script"
echo "2. Connect Windscribe"
echo "3. Wait ~30 seconds"
echo "4. Try: dig popads.net / dig @127.0.0.1 popads.net"
echo "5. Ctrl-C to stop and see summary"
echo ""
echo "Polling every 0.5s... Press Ctrl-C to stop."
echo ""
# Track ctrld log position
if [ -f "$CTRLD_LOG" ]; then
LOG_START_LINE=$(wc -l < "$CTRLD_LOG")
else
LOG_START_LINE=0
fi
ITER=0
DIG_FAIL=0
DIG_OK=0
ANCHOR_MISSING=0
ANCHOR_PRESENT=0
PF_WIPE_COUNT=0
FORCE_REBOOT_COUNT=0
LAST_TUNNEL_IFACES=""
cleanup() {
echo ""
echo "=== Stopping diagnostic ==="
# Capture final state
echo "--- Final pf state ---" > "$OUTDIR/final-pfctl.txt"
pfctl -sa 2>/dev/null >> "$OUTDIR/final-pfctl.txt" 2>&1 || true
echo "--- Final scutil ---" > "$OUTDIR/final-scutil.txt"
scutil --dns >> "$OUTDIR/final-scutil.txt" 2>&1 || true
# Extract ctrld log events since start
if [ -f "$CTRLD_LOG" ]; then
tail -n +$((LOG_START_LINE + 1)) "$CTRLD_LOG" > "$OUTDIR/ctrld-events.log" 2>/dev/null || true
# Extract key events
echo "--- Watchdog events ---" > "$OUTDIR/summary-watchdog.txt"
grep -i "watchdog\|anchor.*missing\|anchor.*restored\|force-reset\|re-bootstrapping\|force re-bootstrapping" "$OUTDIR/ctrld-events.log" >> "$OUTDIR/summary-watchdog.txt" 2>/dev/null || true
echo "--- Errors ---" > "$OUTDIR/summary-errors.txt"
grep '"level":"error"' "$OUTDIR/ctrld-events.log" >> "$OUTDIR/summary-errors.txt" 2>/dev/null || true
echo "--- Network changes ---" > "$OUTDIR/summary-network.txt"
grep -i "Network change\|tunnel interface\|Ignoring interface" "$OUTDIR/ctrld-events.log" >> "$OUTDIR/summary-network.txt" 2>/dev/null || true
echo "--- Transport resets ---" > "$OUTDIR/summary-transport.txt"
grep -i "re-bootstrap\|force.*bootstrap\|dialing to\|connected to" "$OUTDIR/ctrld-events.log" >> "$OUTDIR/summary-transport.txt" 2>/dev/null || true
# Count key events
PF_WIPE_COUNT=$(grep -c "anchor.*missing\|restoring pf" "$OUTDIR/ctrld-events.log" 2>/dev/null || echo 0)
FORCE_REBOOT_COUNT=$(grep -c "force re-bootstrapping\|force-reset" "$OUTDIR/ctrld-events.log" 2>/dev/null || echo 0)
DEADLINE_COUNT=$(grep -c "context deadline exceeded" "$OUTDIR/ctrld-events.log" 2>/dev/null || echo 0)
FALLBACK_COUNT=$(grep -c "OS resolver retry query successful" "$OUTDIR/ctrld-events.log" 2>/dev/null || echo 0)
fi
echo ""
echo "========================================="
echo " DIAGNOSTIC SUMMARY"
echo "========================================="
echo "Duration: $ITER iterations (~$((ITER / 2))s)"
echo ""
echo "pf Anchor Status:"
echo " Present: $ANCHOR_PRESENT times"
echo " Missing: $ANCHOR_MISSING times"
echo ""
echo "dig Tests (popads.net):"
echo " Success: $DIG_OK"
echo " Failed: $DIG_FAIL"
echo ""
echo "ctrld Log Events:"
echo " pf wipes detected: $PF_WIPE_COUNT"
echo " Force rebootstraps: $FORCE_REBOOT_COUNT"
echo " Context deadline errors: ${DEADLINE_COUNT:-0}"
echo " OS resolver fallbacks: ${FALLBACK_COUNT:-0}"
echo ""
echo "Last tunnel interfaces: ${LAST_TUNNEL_IFACES:-none}"
echo ""
echo "Files saved to: $OUTDIR/"
echo " final-pfctl.txt — full pfctl -sa at exit"
echo " final-scutil.txt — scutil --dns at exit"
echo " ctrld-events.log — ctrld log during test"
echo " summary-watchdog.txt — watchdog events"
echo " summary-errors.txt — errors"
echo " summary-transport.txt — transport reset events"
echo " timeline.log — per-iteration state"
echo "========================================="
exit 0
}
trap cleanup INT TERM
while true; do
ITER=$((ITER + 1))
NOW=$(date '+%H:%M:%S.%3N' 2>/dev/null || date '+%H:%M:%S')
# 1. Check pf anchor presence
ANCHOR_STATUS="MISSING"
if pfctl -sr 2>/dev/null | grep -q "com.controld.ctrld"; then
ANCHOR_STATUS="PRESENT"
ANCHOR_PRESENT=$((ANCHOR_PRESENT + 1))
else
ANCHOR_MISSING=$((ANCHOR_MISSING + 1))
fi
# 2. Check tunnel interfaces
TUNNEL_IFACES=$(ifconfig -l 2>/dev/null | tr ' ' '\n' | grep -E '^(utun|ipsec|ppp|tap|tun)' | \
while read iface; do
# Only list interfaces that are UP and have an IP
if ifconfig "$iface" 2>/dev/null | grep -q "inet "; then
echo -n "$iface "
fi
done)
TUNNEL_IFACES=$(echo "$TUNNEL_IFACES" | xargs) # trim
if [ -n "$TUNNEL_IFACES" ]; then
LAST_TUNNEL_IFACES="$TUNNEL_IFACES"
fi
# 3. Count rdr states (three-part = intercepted)
RDR_COUNT=$(pfctl -ss 2>/dev/null | grep -c "127.0.0.1:53 <-" || echo 0)
# 4. Quick dig test (0.5s timeout)
DIG_RESULT="SKIP"
if [ $((ITER % 4)) -eq 0 ]; then # every 2 seconds
if dig +time=1 +tries=1 popads.net A @127.0.0.1 +short >/dev/null 2>&1; then
DIG_RESULT="OK"
DIG_OK=$((DIG_OK + 1))
else
DIG_RESULT="FAIL"
DIG_FAIL=$((DIG_FAIL + 1))
fi
fi
# 5. Check latest ctrld log for recent errors
RECENT_ERR=""
if [ -f "$CTRLD_LOG" ]; then
RECENT_ERR=$(tail -5 "$CTRLD_LOG" 2>/dev/null | grep -o '"message":"[^"]*deadline[^"]*"' | tail -1 || true)
fi
# Output timeline
LINE="[$NOW] anchor=$ANCHOR_STATUS rdr_states=$RDR_COUNT tunnels=[$TUNNEL_IFACES] dig=$DIG_RESULT $RECENT_ERR"
echo "$LINE"
echo "$LINE" >> "$OUTDIR/timeline.log"
sleep 0.5
done

View 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."

View 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

View 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