# DNS Intercept Mode

## Overview

DNS intercept mode is an alternative approach to DNS management that uses OS-level packet interception instead of modifying network interface DNS settings. This eliminates race conditions with VPN software, endpoint security tools, and other programs that also manage DNS.

## The Problem

By default, ctrld sets DNS to `127.0.0.1` on network interfaces so all queries go through ctrld's local listener. However, VPN software (F5 BIG-IP, Cisco AnyConnect, Palo Alto GlobalProtect, etc.) also overwrites interface DNS settings, creating conflicts:

1. **DNS Setting War**: ctrld sets DNS to `127.0.0.1`, VPN overwrites to its DNS servers, ctrld's watchdog detects the change and restores `127.0.0.1`, VPN overwrites again — infinitely.

2. **Bypass Window**: During the watchdog polling interval (up to 20 seconds), DNS queries may go to the VPN's DNS servers, bypassing ctrld's filtering profiles (malware blocking, content filtering, etc.).

3. **Resolution Failures**: During the brief moments when DNS is being rewritten, queries may fail entirely, causing intermittent connectivity loss.

## The Solution

DNS intercept mode works at a lower level than interface settings:

- **Windows**: Uses NRPT (Name Resolution Policy Table) to route all DNS queries to `127.0.0.1` (ctrld's listener) via the Windows DNS Client service. In `hard` mode, additionally uses WFP (Windows Filtering Platform) to block all outbound DNS (port 53) except to localhost and private ranges, preventing any bypass. VPN software can set interface DNS freely — NRPT's most-specific-match ensures VPN-specific domains still resolve correctly while ctrld handles everything else.

- **macOS**: Uses pf (packet filter) to redirect all outbound DNS (port 53) traffic to ctrld's listener at `127.0.0.1:53`. Any DNS query, regardless of which DNS server the OS thinks it's using, gets transparently redirected to ctrld.

## Usage

```bash
# Start ctrld with DNS intercept mode (auto-detects VPN search domains)
ctrld start --intercept-mode dns --cd <resolver-uid>

# Hard intercept: all DNS through ctrld, no VPN split routing
ctrld start --intercept-mode hard --cd <resolver-uid>

# Or with a config file
ctrld start --intercept-mode dns -c /path/to/ctrld.toml

# Run in foreground (debug)
ctrld run --intercept-mode dns --cd <resolver-uid>
ctrld run --intercept-mode hard --cd <resolver-uid>
```

### Intercept Modes

| Flag | DNS Interception | VPN Split Routing | Captive Portal Recovery |
|------|-----------------|-------------------|------------------------|
| `--intercept-mode dns` | ✅ WFP/pf | ✅ Auto-detect & forward | ✅ Active |
| `--intercept-mode hard` | ✅ WFP/pf | ❌ All through ctrld | ✅ Active |

**`--intercept-mode dns`** (recommended): Intercepts all DNS via WFP/pf, but automatically discovers search domains from VPN and virtual network adapters (Tailscale, F5, Cisco AnyConnect, etc.) and forwards matching queries to the DNS server on that interface. This allows VPN internal resources (e.g., `*.corp.local`) to resolve correctly while ctrld handles everything else.

**`--intercept-mode hard`**: Same OS-level interception, but does NOT forward any queries to VPN DNS servers. Every DNS query goes through ctrld's configured upstreams. Use this when you want total DNS control and don't need VPN internal domain resolution. Captive portal recovery still works — network authentication pages are handled automatically.

## How It Works

### Windows (NRPT + WFP)

Windows DNS intercept uses a two-tier architecture with mode-dependent enforcement:

- **`dns` mode**: NRPT + loopback WFP protect — graceful DNS routing through the Windows DNS Client service, with proactive WFP permit filters that protect the NRPT → localhost path from third-party DNS block filters (e.g., OpenVPN's `block-outside-dns`).
- **`hard` mode**: NRPT + WFP — same NRPT routing, plus WFP kernel-level block filters that prevent any outbound DNS bypass. Equivalent enforcement to macOS pf.

#### Why This Design?

WFP can only **block** or **permit** connections — it **cannot redirect** them (redirection requires kernel-mode callout drivers). Without NRPT, WFP blocks outbound DNS but doesn't tell applications where to send queries instead — they see DNS failures. NRPT provides the "positive routing" while WFP provides enforcement.

Separating them into modes means most users get `dns` mode (safe, can never break DNS) while high-security deployments use `hard` mode (full enforcement, same guarantees as macOS pf).

#### Startup Sequence (dns mode)

1. Checks for a non-ctrld GP child whose only namespace is `.` and whose only nameserver is ctrld's actual listener IP.
2. When that candidate exists, preserves adapter DNS, sends a DNS Client probe before any NRPT mutation, and re-reads the same GP child. A matching before/after rule plus a received probe enters **GP-managed mode**; ctrld does not write NRPT, call `RefreshPolicyEx`/`paramchange`, or flush DNS for policy activation.
3. Without a still-matching GP candidate, creates the normal ctrld-owned catch-all, signals DNS Client, and flushes stale cache entries.
4. **Activates loopback WFP protect** — adds 4 permit filters (IPv4/IPv6 × UDP/TCP) for DNS to localhost with `FWPM_FILTER_FLAG_CLEAR_ACTION_RIGHT`. These prevent third-party WFP block filters from blocking the NRPT → listener path (see [Loopback WFP Protect](#loopback-wfp-protect) below). Non-fatal if this fails.
5. Starts the 30-second ownership-aware NRPT health monitor.
6. Re-verifies an initially ineffective GP candidate synchronously after WFP setup; ctrld-owned NRPT uses the asynchronous probe-and-heal sequence.

#### Startup Sequence (hard mode)

1. Establishes NRPT routing using the same GP-managed adoption or ctrld-owned fallback sequence as `dns` mode.
2. Opens WFP engine with `RPC_C_AUTHN_DEFAULT` (0xFFFFFFFF).
3. Cleans up any stale sublayer from a previous unclean shutdown.
4. Creates sublayer with maximum weight (0xFFFF).
5. Adds **permit** filters (weight 10) for DNS to localhost (`127.0.0.1`/`::1` port 53).
6. Adds **permit** filters (weight 10) for DNS to RFC1918 + CGNAT subnets (10/8, 172.16/12, 192.168/16, 100.64/10).
7. Adds **block** filters (weight 1) for all other outbound DNS (port 53 UDP+TCP).
8. Starts the NRPT/WFP health monitor.

**Atomic guarantee:** NRPT routing must exist before WFP starts. If WFP setup fails, ctrld rolls back only a rule it owns. A GP-managed child is never deleted, rewritten, or replaced with interface DNS merely because ctrld's WFP setup failed.

On shutdown, ctrld stops its monitor and WFP session. It removes and signals only ctrld-owned NRPT state; a GP-managed catch-all remains untouched.

#### NRPT Details

The **Name Resolution Policy Table** is a Windows feature (originally for DirectAccess) that tells the DNS Client service to route queries matching specific namespace patterns to specific DNS servers. ctrld adds a catch-all rule:

| Registry Value | Type | Value | Purpose |
|---|---|---|---|
| `Name` | REG_MULTI_SZ | `.` | Namespace pattern (`.` = catch-all, matches everything) |
| `GenericDNSServers` | REG_SZ | `127.0.0.1` | DNS server to use for matching queries |
| `ConfigOptions` | REG_DWORD | `0x8` | Standard DNS resolution (no DirectAccess) |
| `Version` | REG_DWORD | `0x2` | NRPT rule version 2 |

**Registry path**: `HKLM\SOFTWARE\Policies\Microsoft\Windows NT\DNSClient\DnsPolicyConfig\CtrldCatchAll`

**Group Policy refresh**: The DNS Client service only reads NRPT from registry during Group Policy processing cycles (default: every 90 minutes). ctrld calls `RefreshPolicyEx(bMachine=TRUE, dwOptions=RP_FORCE)` when activating or repairing rules it owns. While Group Policy remains the owner, ctrld does not run NRPT activation/heal signaling; the one transition that removes a ctrld fallback is signaled after the external rule has been proven.

#### GP-managed NRPT ownership

Enterprise deployments may install a computer-scoped GP child before starting ctrld with:

- exactly one namespace: `.`;
- exactly one `GenericDNSServers` value; and
- that nameserver equal to ctrld's actual loopback listener (`127.0.0.1` or the alternate loopback selected on an AD DNS server).

At service startup ctrld reads that candidate before the normal adapter reset, probes through Windows DNS Client while its listener is already bound, and re-reads the same child. When the rule remains present and the probe arrives, ctrld records **Group Policy** as the NRPT owner. Adapter DNS stays on the organization's resolvers, and ctrld does not create, delete, refresh, or flush NRPT policy.

The health monitor keeps using functional probes:

- matching GP rule + successful probe: observe only;
- matching GP rule + failed probe: retry loopback WFP protection, then report the external policy as ineffective without running NRPT heal signals;
- matching GP rule disappears: create the normal ctrld-owned fallback and verify it, unless another GP catch-all targets a different resolver;
- GP catch-all targets another resolver: report the conflict and do not create a second ambiguous catch-all;
- matching GP rule returns: prove it with a probe, remove only ctrld's deterministic fallback keys, and return ownership to Group Policy.

Deploy the GPO **before** starting or restarting ctrld if adapter DNS must remain completely untouched. Remove or unlink the GP rule before intentionally removing the ctrld service. A GP catch-all that remains pointed at loopback while no listener is running causes DNS failure by design; ctrld cannot safely delete an administrator-owned policy during uninstall.

#### WFP Filter Architecture

**Filter priority**: Permit filters have weight 10, block filters have weight 1. WFP evaluates higher-weight filters first, so localhost and private-range DNS is always permitted.

**RFC1918 + CGNAT permits**: Static subnet permit filters allow DNS to private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 100.64.0.0/10). This means VPN DNS servers on private IPs (Tailscale MagicDNS on 100.100.100.100, corporate VPN DNS on 10.x.x.x, etc.) work without needing dynamic per-server exemptions.

**VPN coexistence**: VPN software can set DNS to whatever it wants on the interface — for public IPs, the WFP block filter prevents those servers from being reached on port 53. For private IPs, the subnet permits allow it. ctrld handles all DNS routing through NRPT and can forward VPN-specific domains to VPN DNS servers through its own upstream mechanism.

#### Loopback WFP Protect (dns mode)

Third-party VPN software (e.g., OpenVPN, Securepoint SSL VPN) can install WFP block filters via `block-outside-dns` that block **all** DNS traffic to non-tunnel interfaces — including loopback. This breaks the NRPT → `127.0.0.1:53` path that ctrld depends on, causing DNS resolution to time out.

ctrld proactively adds 4 WFP "hard permit" filters at startup:

| Filter | Layer | Protocol |
|---|---|---|
| Permit DNS to localhost (IPv4/UDP) | ALE_AUTH_CONNECT_V4 | UDP |
| Permit DNS to localhost (IPv4/TCP) | ALE_AUTH_CONNECT_V4 | TCP |
| Permit DNS to localhost (IPv6/UDP) | ALE_AUTH_CONNECT_V6 | UDP |
| Permit DNS to localhost (IPv6/TCP) | ALE_AUTH_CONNECT_V6 | TCP |

**Key properties:**
- **Scope**: Port 53 to `127.0.0.1` (or configured listener IP) and `::1` only
- **Flag**: `FWPM_FILTER_FLAG_CLEAR_ACTION_RIGHT` (0x08) — "hard permit" that overrides BLOCK decisions from other sublayers regardless of weight or insertion order
- **Weight**: 15 (above hard mode's permit=10)
- **Sublayer**: ctrld's sublayer at maximum priority (0xFFFF)
- **Lifetime**: Process lifetime — added at startup, removed on shutdown/uninstall

Because `CLEAR_ACTION_RIGHT` is a cross-sublayer override, the order of filter installation doesn't matter — even if a VPN connects hours later and adds its own WFP block filters, ctrld's hard permit for loopback DNS is never overridden.

The reactive fallback in `nrptProbeAndHeal()` is preserved as defense-in-depth for edge cases where proactive activation fails at startup.

See: [Issue #526](https://gitlab.int.windscribe.com/controld/clients/ctrld/-/issues/526)

#### NRPT Probe and Auto-Heal

`RefreshPolicyEx` returns immediately — it does NOT wait for the DNS Client service to actually load the NRPT rule. On cold machines (first boot, fresh install), the DNS Client may take several seconds to process the policy refresh. During this window, the NRPT rule exists in the registry but isn't active.

ctrld verifies NRPT is actually working by sending a probe DNS query (`_nrpt-probe-<hex>.nrpt-probe.ctrld.test`) through Go's `net.Resolver` (which calls `GetAddrInfoW` → DNS Client → NRPT path). If ctrld receives the probe on its listener, NRPT is active.

**Startup probes:** A matching GP candidate is probed synchronously before any NRPT mutation and re-read afterward. ctrld-owned rules keep the asynchronous activation/heal sequence: immediate probe, bounded policy signaling retries, then two-phase delete/re-add recovery. Only one probe sequence runs at a time.

**Ownership boundary:** When the active owner is Group Policy, a failed probe never enters ctrld's NRPT refresh/delete/re-add sequence. ctrld may repair its narrowly scoped loopback WFP permits, but leaves the external registry child and DNS Client policy signaling to the administrator.

#### NRPT Health Monitor

A dedicated background goroutine (`nrptHealthMonitor`) runs every 30 seconds and now performs active probing:

1. **Ownership check:** Distinguish a matching external GP child from ctrld's deterministic local/GP keys.
2. **Active probe:** Verify Windows DNS Client still routes to the listener.
3. **Transition:** If the external child disappears, activate ctrld's normal fallback. If it returns while the fallback is active, prove it before removing only ctrld's keys.
4. **Owned recovery:** Restore/heal only when ctrld owns the NRPT rule.
5. **(hard mode)** Verify the WFP sublayer exists and fully restart intercept state on loss.

This is periodic (not just network-event-driven) because VPN software can clear NRPT at any time. Additionally, `scheduleDelayedRechecks()` (called on network change events) performs immediate NRPT verification at 2s and 4s after changes.

#### Known Caveats

- **`nslookup` bypasses NRPT**: `nslookup.exe` uses its own DNS resolver implementation and does NOT go through the Windows DNS Client service, so it ignores NRPT rules entirely. Use `Resolve-DnsName` (PowerShell) or `ping` to verify DNS resolution through NRPT. This is a well-known Windows behavior, not a ctrld bug.
- **`RPC_C_AUTHN_DEFAULT`**: `FwpmEngineOpen0` requires `RPC_C_AUTHN_DEFAULT` (0xFFFFFFFF) for the authentication service parameter. Using `RPC_C_AUTHN_NONE` (0) returns `ERROR_NOT_SUPPORTED` on some configurations (e.g., Parallels VMs).
- **FWP_DATA_TYPE enum**: The `FWP_DATA_TYPE` enum starts at `FWP_EMPTY=0`, making `FWP_UINT8=1`, `FWP_UINT16=2`, etc. Some documentation examples incorrectly start at 0.

### macOS (pf)

1. ctrld writes a pf anchor file at `/etc/pf.anchors/com.controld.ctrld`
2. Adds the anchor reference to `/etc/pf.conf` (if not present)
3. Loads the anchor with `pfctl -a com.controld.ctrld -f <file>`
4. Enables pf with `pfctl -e` (if not already enabled)
5. The anchor redirects all outbound DNS (port 53) on non-loopback interfaces to `127.0.0.1:53`
6. On shutdown, the anchor is flushed, the file removed, and references cleaned from `pf.conf`

**ctrld's own traffic**: ctrld's upstream queries use DoH (HTTPS on port 443), not plain DNS on port 53, so the pf redirect does not create a loop for DoH upstreams. **Warning:** If an "os" upstream is configured (which uses plain DNS on port 53 to external servers), the pf redirect will capture ctrld's own outbound queries and create a loop. ctrld will log a warning at startup if this is detected. Use DoH upstreams when DNS intercept mode is active.

## What Changes vs Default Mode

| Behavior | Default Mode | DNS Intercept Mode |
|----------|-------------|-------------------|
| Interface DNS settings | Set to `127.0.0.1` | **Not modified** |
| DNS watchdog | Active (polls every 20s) | **Disabled** |
| VPN DNS conflict | Race condition possible | **Eliminated** |
| Profile bypass window | Up to 20 seconds | **Zero** |
| Requires admin/root | Yes | Yes |
| Additional OS requirements | None | WFP (Windows), pf (macOS) |

## Logging

DNS intercept mode produces detailed logs for troubleshooting:

```
DNS intercept: initializing Windows Filtering Platform (WFP)
DNS intercept: WFP engine opened (handle: 0x1a2b3c)
DNS intercept: WFP sublayer created (weight: 0xFFFF — maximum priority)
DNS intercept: added permit filter "Permit DNS to localhost (IPv4/UDP)" (ID: 12345)
DNS intercept: added block filter "Block outbound DNS (IPv4/UDP)" (ID: 12349)
DNS intercept: WFP filters active — all outbound DNS (port 53) blocked except to localhost
```

On macOS:
```
DNS intercept: initializing macOS packet filter (pf) redirect
DNS intercept: wrote pf anchor file: /etc/pf.anchors/com.controld.ctrld
DNS intercept: loaded pf anchor "com.controld.ctrld"
DNS intercept: pf anchor "com.controld.ctrld" active with 3 rules
DNS intercept: pf redirect active — all outbound DNS (port 53) redirected to 127.0.0.1:53
```

## Troubleshooting

### Windows

```powershell
# Check NRPT rules (should show CtrldCatchAll with . → 127.0.0.1)
Get-DnsClientNrptRule

# Check NRPT registry directly
Get-ChildItem "HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\DNSClient\DnsPolicyConfig"

# Force Group Policy refresh (if NRPT not taking effect)
gpupdate /target:computer /force

# Check if WFP filters are active
netsh wfp show filters

# Check ctrld's specific filters (look for "ctrld" in output)
netsh wfp show filters | Select-String "ctrld"

# Test DNS resolution (use Resolve-DnsName, NOT nslookup!)
# nslookup bypasses DNS Client / NRPT — it will NOT reflect NRPT routing
Resolve-DnsName example.com
ping example.com

# If you must use nslookup, specify localhost explicitly:
nslookup example.com 127.0.0.1
```

### macOS

```bash
# Check if pf is enabled
sudo pfctl -si

# Check ctrld's anchor rules
sudo pfctl -a com.controld.ctrld -sr
sudo pfctl -a com.controld.ctrld -sn

# Check pf.conf for anchor reference
cat /etc/pf.conf | grep ctrld

# Test DNS is going through ctrld
dig @127.0.0.1 example.com
```

## Limitations

- **Linux**: Not supported. Linux uses `systemd-resolved` or `/etc/resolv.conf` which don't have the same VPN conflict issues. If needed in the future, `iptables`/`nftables` REDIRECT could be used.

- **Split DNS for VPN internal domains**: In `--intercept-mode dns` mode, VPN search domains are auto-detected from virtual network adapters and forwarded to the VPN's DNS servers automatically. In `--intercept-mode hard` mode, VPN internal domains (e.g., `*.corp.local`) will NOT resolve unless configured as explicit upstream rules in ctrld's configuration.

- **macOS mDNSResponder interaction**: On macOS, ctrld uses a workaround ("mDNSResponder hack") that binds to `0.0.0.0:53` instead of `127.0.0.1:53` and refuses queries from non-localhost sources. In dns-intercept mode, pf's `rdr` rewrites the destination IP to `127.0.0.1:53` but preserves the original source IP (e.g., `192.168.2.73`). The mDNSResponder source-IP check is automatically bypassed in dns-intercept mode because the pf/WFP rules already ensure only legitimate intercepted DNS traffic reaches ctrld's listener.

- **Other WFP/pf users**: If other software (VPN, firewall, endpoint security) also uses WFP or pf for DNS interception, there may be priority conflicts. ctrld uses maximum sublayer weight on Windows and a named anchor on macOS to minimize this risk. See "VPN App Coexistence" below for macOS-specific defenses.

## VPN App Coexistence (macOS)

VPN apps (Windscribe, Cisco AnyConnect, F5 BIG-IP, etc.) often manage pf rules themselves, which can interfere with ctrld's DNS intercept. ctrld uses a multi-layered defense strategy:

### 1. Anchor Priority Enforcement

When injecting our anchor reference into the running pf ruleset, ctrld **prepends** both the `rdr-anchor` and `anchor` references before all other anchors. pf evaluates rules top-to-bottom, so our DNS intercept `quick` rules match port 53 traffic before a VPN app's broader rules in their own anchor.

### 2. Interface-Specific Tunnel Rules

VPN apps commonly add rules like `pass out quick on ipsec0 inet all` that match ALL traffic on the VPN interface. If their anchor is evaluated before ours (e.g., after a ruleset reload), these broad rules capture DNS. ctrld counters this by adding explicit DNS intercept rules for each active tunnel interface (ipsec*, utun*, ppp*, tap*, tun*). These interface-specific rules match port 53 only, so they take priority over the VPN app's broader "all" match even within the same anchor evaluation pass.

### 3. Dynamic Tunnel Interface Detection

The network change monitor (`validInterfacesMap()`) only tracks physical hardware ports (en0, bridge0, etc.) — it doesn't see tunnel interfaces (utun*, ipsec*, etc.) created by VPN software. When a VPN connects and creates a new interface (e.g., utun420 for WireGuard), ctrld detects this through a separate tunnel interface change check and rebuilds the pf anchor to include explicit intercept rules for the new interface. This runs on every network change event, even if no physical interface changed.

### 4. pf Watchdog + Network Change Hooks

A background watchdog (30s interval) plus immediate checks on network change events detect when another program replaces the entire pf ruleset (e.g., Windscribe's `pfctl -f /etc/pf.conf`). When detected, ctrld rebuilds its anchor with up-to-date tunnel interface rules and re-injects the anchor reference at the top of the ruleset. A 2-second delayed re-check catches race conditions where the other program clears rules slightly after the network event.

### 4a. Active Interception Probe (pf Translation State Corruption)

Programs like Parallels Desktop reload `/etc/pf.conf` when creating/destroying virtual network interfaces (bridge100, vmenet0). This can corrupt pf's internal translation engine — rdr rules survive in text form but stop evaluating, causing DNS interception to silently fail while the watchdog reports "intact."

ctrld detects interface appearance/disappearance and spawns an async probe monitor:

1. **Probe mechanism:** A subprocess runs with GID=0 (wheel, not `_ctrld`) and sends a DNS query to the OS resolver. If pf interception is working, the query gets redirected to ctrld (127.0.0.1:53) and is detected in the DNS handler. If broken, it times out after 1s.
2. **Backoff schedule:** Probes at 0, 0.5, 1, 2, 4 seconds (~8s window) to win the race against async pf reloads by the hypervisor. Only one monitor runs at a time (atomic singleton).
3. **Auto-heal:** On probe failure, `forceReloadPFMainRuleset()` dumps the running ruleset and pipes it back through `pfctl -f -`, resetting pf's translation engine. VPN-safe because it reassembles from the current running state.
4. **Watchdog integration:** The 30s watchdog also runs the probe when rule text checks pass, as a safety net for unknown corruption causes.

This approach detects **actual broken DNS** rather than guessing from trigger events, making it robust against future unknown corruption scenarios.

### 5. Proactive DoH Connection Pool Reset

When the watchdog detects a pf ruleset replacement, it force-rebootstraps all upstream transports via `ForceReBootstrap()`. This is necessary because `pfctl -f` flushes the entire pf state table, which kills existing TCP connections (including ctrld's DoH connections to upstream DNS servers like 76.76.2.22:443).

The force-rebootstrap does two things that the lazy `ReBootstrap()` cannot:
1. **Closes idle connections on the old transport** (`CloseIdleConnections()`), causing in-flight HTTP/2 requests on dead connections to fail immediately instead of waiting for the 5s context deadline
2. **Creates the new transport synchronously**, so it's ready before any DNS queries arrive post-wipe

Without this, Go's `http.Transport` keeps trying dead connections until each request's context deadline expires (~5s), then the lazy rebootstrap creates a new transport for the *next* request. With force-rebootstrap, the blackout is reduced from ~5s to ~100ms (one fresh TLS handshake).

### 6. Blanket Process Exemption (group _ctrld)

ctrld creates a macOS system group (`_ctrld`) and sets its effective GID at startup via `syscall.Setegid()`. The pf anchor includes a blanket rule:

```
pass out quick group _ctrld
```

This exempts **all** outbound traffic from the ctrld process — not just DNS (port 53), but also DoH (TCP 443), DoT (TCP 853), health checks, and any other connections. This is essential because VPN firewalls like Windscribe load `block drop all` rulesets that would otherwise block ctrld's upstream connections even after the pf anchor is restored.

Because ctrld's anchor is prepended before all other anchors, and this rule uses `quick`, it evaluates before any VPN firewall rules. The result: ctrld's traffic is never blocked regardless of what other pf rulesets are loaded.

The per-IP exemptions (OS resolver, VPN DNS) remain as defense-in-depth for the DNS redirect loop prevention — the blanket rule handles everything else.

### 7. Loopback Outbound Pass Rule

When `route-to lo0` redirects a DNS packet to loopback, pf re-evaluates the packet **outbound on lo0**. None of the existing route-to rules match on lo0 (they're all `on ! lo0` or `on utunX`), so without an explicit pass rule, the packet falls through to the main ruleset where VPN firewalls' `block drop all` drops it — before it ever reaches the inbound rdr rule.

```
pass out quick on lo0 inet proto udp from any to ! 127.0.0.1 port 53
pass out quick on lo0 inet proto tcp from any to ! 127.0.0.1 port 53
```

This bridges the route-to → rdr gap: route-to sends outbound on lo0 → this rule passes it → loopback reflects it inbound → rdr rewrites destination to 127.0.0.1:53 → ctrld receives the query. Without this rule, DNS intercept fails whenever a `block drop all` firewall (Windscribe, etc.) is active.

### 8. Response Routing via `reply-to lo0`

After rdr redirects DNS to 127.0.0.1:53, ctrld responds to the original client source IP (e.g., 100.94.163.168 — a VPN tunnel IP). Without intervention, the kernel routes this response through the VPN tunnel interface (utun420) based on its routing table, and the response is lost.

```
pass in quick on lo0 reply-to lo0 inet proto { udp, tcp } from any to 127.0.0.1 port 53
```

`reply-to lo0` tells pf to force response packets for this connection back through lo0, overriding the kernel routing table. The response stays local, rdr reverse NAT rewrites the source from 127.0.0.1 back to the original DNS server IP (e.g., 10.255.255.3), and the client process receives a correctly-addressed response.

### 9. VPN DNS Split Routing and Exit Mode Detection

When a VPN like Tailscale MagicDNS is active, two distinct modes require different pf handling:

#### The Problem: DNS Proxy Loop

VPN DNS handlers like Tailscale's MagicDNS run as macOS Network Extensions. MagicDNS
listens on 100.100.100.100 and forwards queries to internal upstream nameservers
(e.g., 10.3.112.11, 10.3.112.12) via the VPN tunnel interface (utun13).

Without special handling, pf's generic `pass out quick on ! lo0 route-to lo0` rule
intercepts MagicDNS's upstream queries on the tunnel interface, routing them back
to ctrld → which matches VPN DNS split routing → forwards to MagicDNS → loop:

```
┌──────────────────────────────────────────────────────────────────────┐
│  THE LOOP (without passthrough rules)                                │
│                                                                      │
│  1. dig gitlab.int.windscribe.com                                    │
│     → pf intercepts → route-to lo0 → rdr → ctrld (127.0.0.1:53)    │
│                                                                      │
│  2. ctrld: VPN DNS match → forward to 100.100.100.100:53            │
│     → group _ctrld exempts → reaches MagicDNS                       │
│                                                                      │
│  3. MagicDNS: forward to upstream 10.3.112.11:53 via utun13         │
│     → pf generic rule matches (utun13 ≠ lo0, 10.3.112.11 ≠ skip)   │
│     → route-to lo0 → rdr → back to ctrld ← LOOP!                   │
└──────────────────────────────────────────────────────────────────────┘
```

#### The Fix: Interface Passthrough + Exit Mode Detection

**Split DNS mode** (VPN handles only specific domains):

ctrld adds passthrough rules for VPN DNS interfaces that let MagicDNS's upstream
queries flow without interception. A `<vpn_dns>` table contains the VPN DNS server
IPs (e.g., 100.100.100.100) — traffic TO those IPs is NOT passed through (still
intercepted by pf → ctrld enforces profile):

```
table <vpn_dns> { 100.100.100.100 }

# MagicDNS upstream queries (to 10.3.112.11 etc.) — pass through
pass out quick on utun13 inet proto udp from any to ! <vpn_dns> port 53
pass out quick on utun13 inet proto tcp from any to ! <vpn_dns> port 53

# Queries TO MagicDNS (100.100.100.100) — not matched above,
# falls through to generic rule → intercepted → ctrld → profile enforced
```

```
┌──────────────────────────────────────────────────────────────────────┐
│  SPLIT DNS MODE (with passthrough rules)                             │
│                                                                      │
│  Non-VPN domain (popads.net):                                        │
│  dig popads.net → system routes to 100.100.100.100 on utun13        │
│  → passthrough rule: dest IS in <vpn_dns> → NOT matched             │
│  → generic rule: route-to lo0 → rdr → ctrld → profile blocks it ✅  │
│                                                                      │
│  VPN domain (gitlab.int.windscribe.com):                             │
│  dig gitlab.int... → pf intercepts → ctrld                          │
│  → VPN DNS match → forward to 100.100.100.100 (group exempt)        │
│  → MagicDNS → upstream 10.3.112.11 on utun13                        │
│  → passthrough rule: dest NOT in <vpn_dns> → MATCHED → passes ✅    │
│  → 10.3.112.11 returns correct internal answer (10.3.112.113)        │
└──────────────────────────────────────────────────────────────────────┘
```

**Exit mode** (all traffic through VPN):

When Tailscale exit node is enabled, MagicDNS becomes the system's **default**
resolver (not just supplemental). If we added passthrough rules, ALL DNS would
bypass ctrld — losing profile enforcement.

Exit mode is detected using two independent signals (either triggers exit mode):

**1. Default route detection (primary, most reliable):**
Uses `netmon.DefaultRouteInterface()` to check if the system's default route
(0.0.0.0/0) goes through a VPN DNS interface. If `DefaultRouteInterface` matches
a VPN DNS interface name (e.g., utun13), the VPN owns the default route — it's
exit mode. This is the ground truth: the routing table directly reflects whether
all traffic flows through the VPN, regardless of how the VPN presents itself in
scutil.

**2. scutil flag detection (secondary, fallback):**
If the VPN DNS server IP appears in a `scutil --dns` resolver entry that has
**no search domains** and **no Supplemental flag**, it's acting as the system's
default resolver (exit mode). This catches edge cases where the default route
hasn't changed yet but scutil already shows the VPN as the default DNS.

```
# Non-exit mode — default route on en0, 100.100.100.100 is Supplemental:
$ route -n get 0.0.0.0 | grep interface
  interface: en0                        ← physical NIC, not VPN
resolver #1
  search domain[0] : int.windscribe.com
  nameserver[0] : 100.100.100.100
  flags    : Supplemental, Request A records

# Exit mode — default route on utun13, 100.100.100.100 is default resolver:
$ route -n get 0.0.0.0 | grep interface
  interface: utun13                     ← VPN interface!
resolver #2
  nameserver[0] : 100.100.100.100      ← MagicDNS is default
  flags    : Request A records          ← no Supplemental!
```

In exit mode, NO passthrough rules are generated. pf intercepts all DNS → ctrld
enforces its profile on everything. VPN search domains still resolve correctly
via ctrld's VPN DNS split routing (forwarded to MagicDNS through the group
exemption).

#### Summary Table

| Scenario | Passthrough | Profile Enforced | VPN Domains |
|----------|-------------|-----------------|-------------|
| No VPN | None | ✅ All traffic | N/A |
| Split DNS (Tailscale non-exit) | ✅ VPN interface | ✅ Non-VPN domains | ✅ Via MagicDNS |
| Exit mode (Tailscale exit node) | ❌ None | ✅ All traffic | ✅ Via ctrld split routing |
| Windscribe | None (different flow) | ✅ All traffic | N/A |
| Hard intercept | None | ✅ All traffic | ❌ Not forwarded |

### Nuclear Option (Future)

If anchor ordering + interface rules prove insufficient, an alternative approach is available: inject DNS intercept rules directly into the **main pf ruleset** (not inside an anchor). Main ruleset rules are evaluated before ALL anchors, making them impossible for another app to override without explicitly removing them. This is more invasive and not currently implemented, but documented here as a known escalation path.

## Known VPN Conflicts

### F5 BIG-IP APM

F5 BIG-IP APM VPN is a known source of DNS conflicts with ctrld (Support ticket #1688001). The conflict occurs because F5's VPN client aggressively manages DNS:

**How the conflict manifests:**

1. ctrld sets system DNS to `127.0.0.1` / `::1` for local forwarding
2. F5 VPN connects and **overwrites DNS on all interfaces** by prepending its own servers (e.g., `10.50.10.77`, `192.168.208.56`)
3. F5 enforces split DNS patterns (e.g., `*.provisur.local`) and activates its DNS Relay Proxy (`F5FltSrv.exe` / `F5FltSrv.sys`)
4. ctrld's watchdog detects the change and restores `127.0.0.1` — F5 overwrites again
5. This loop causes intermittent resolution failures, slow responses, and VPN disconnects

**Why `--intercept-mode dns` solves this:**

- ctrld no longer modifies interface DNS settings — there is nothing for F5 to overwrite
- WFP (Windows) blocks all outbound DNS except to localhost, so F5's prepended DNS servers are unreachable on port 53
- F5's DNS Relay Proxy (`F5FltSrv`) becomes irrelevant since no queries reach it
- In `--intercept-mode dns` mode, F5's split DNS domains (e.g., `*.provisur.local`) are auto-detected from the VPN adapter and forwarded to F5's DNS servers through ctrld's upstream mechanism

**F5-side mitigations (if `--intercept-mode dns` is not available):**

- In APM Network Access DNS settings, enable **"Allow Local DNS Servers"** (`AllowLocalDNSServersAccess = 1`)
- Disable **"Enforce DNS Name Resolution Order"**
- Switch to IP-based split tunneling instead of DNS-pattern-based to avoid activating F5's relay proxy
- Update F5 to version 17.x+ which includes DNS handling fixes (see F5 KB K80231353)

**Additional considerations:**

- CrowdStrike Falcon and similar endpoint security with network inspection can compound the conflict (three-way DNS stomping)
- F5's relay proxy (`F5FltSrv`) performs similar functions to ctrld — they are in direct conflict when both active
- The seemingly random failure pattern is caused by timing-dependent race conditions between ctrld's watchdog, F5's DNS enforcement, and (optionally) endpoint security inspection

### Cisco AnyConnect

Cisco AnyConnect exhibits similar DNS override behavior. `--intercept-mode dns` mode prevents the conflict by operating at the packet filter level rather than competing for interface DNS settings.

### Windscribe Desktop App

Windscribe's macOS firewall implementation (`FirewallController_mac`) replaces the entire pf ruleset when connecting/disconnecting via `pfctl -f`, which wipes ctrld's anchor references and flushes the pf state table (killing active DoH connections). ctrld handles this with multiple defenses:

1. **pf watchdog** detects the wipe and restores anchor rules immediately on network change events (or within 30s via periodic check)
2. **DoH transport force-reset** immediately replaces upstream transports when a pf wipe is detected (closing old connections + creating new ones synchronously), reducing the DNS blackout from ~5s to ~100ms
3. **Tunnel interface detection** adds explicit intercept rules for Windscribe's WireGuard interface (e.g., utun420) when it appears
4. **Dual delayed re-checks** (2s + 4s after network event) catch race conditions where VPN apps modify pf rules and DNS settings asynchronously after the initial network change
5. **Deferred pf restore** waits for VPN to finish its pf modifications before restoring ctrld's rules, preventing the reconnect death spiral
6. **Blanket group exemption** (`pass out quick group _ctrld`) ensures all ctrld traffic (including DoH on port 443) passes through VPN firewalls like Windscribe's `block drop all`

## 7. VPN DNS Lifecycle

When VPN software connects or disconnects, ctrld must track DNS state changes to ensure correct routing and avoid stale state.

### Network Change Event Flow (macOS)

```
Network change detected (netmon callback)
    │
    ├─ Immediate actions:
    │   ├─ ensurePFAnchorActive()      — verify/restore pf anchor references
    │   ├─ checkTunnelInterfaceChanges() — detect new/removed VPN interfaces
    │   │   ├─ New tunnel → pfStartStabilization() (wait for VPN to finish pf changes)
    │   │   └─ Removed tunnel → rebuild anchor immediately (with VPN DNS exemptions)
    │   └─ vpnDNS.Refresh()            — re-discover VPN DNS from scutil --dns
    │
    ├─ Delayed re-check at 2s:
    │   ├─ ensurePFAnchorActive()      — catch async pf wipes
    │   ├─ checkTunnelInterfaceChanges()
    │   ├─ InitializeOsResolver()      — clear stale DNS from scutil
    │   └─ vpnDNS.Refresh()            — clear stale VPN DNS routes
    │
    └─ Delayed re-check at 4s:
        └─ (same as 2s — catches slower VPN teardowns)
```

### VPN Connect Sequence

1. VPN creates tunnel interface (e.g., utun420)
2. Network change fires → `checkTunnelInterfaceChanges()` detects new tunnel
3. **Stabilization mode** activates — suppresses pf restores while VPN modifies rules
4. Stabilization loop polls `pfctl -sr` hash every 1.5s
5. When hash stable for 6s → VPN finished → restore ctrld's pf anchor
6. `vpnDNS.Refresh()` discovers VPN's search domains and DNS servers from `scutil --dns`
7. Anchor rebuild includes VPN DNS exemptions (so ctrld can reach VPN DNS on port 53)

### VPN Disconnect Sequence

1. VPN removes tunnel interface
2. Network change fires → `checkTunnelInterfaceChanges()` detects removal
3. Anchor rebuilt immediately (no stabilization needed for removals)
4. VPN app may asynchronously wipe pf rules (`pfctl -f /etc/pf.conf`)
5. VPN app may asynchronously clean up DNS settings from `scutil --dns`
6. **2s delayed re-check**: restores pf anchor if wiped, refreshes OS resolver
7. **4s delayed re-check**: catches slower VPN teardowns
8. `vpnDNS.Refresh()` returns empty → `onServersChanged(nil)` clears stale exemptions
9. `InitializeOsResolver()` re-reads `scutil --dns` → clears stale LAN nameservers

### Key Design Decisions

- **`buildPFAnchorRules()` receives VPN DNS servers**: All call sites (tunnel rebuild, watchdog restore, stabilization exit) pass `vpnDNS.CurrentServers()` so exemptions are preserved for still-active VPNs.
- **`onServersChanged` called even when server list is empty**: Ensures stale pf exemptions from a previous VPN session are cleaned up on disconnect.
- **OS resolver refresh in delayed re-checks**: VPN apps often finish DNS cleanup 1-3s after the network change event. The delayed `InitializeOsResolver()` call ensures stale LAN nameservers (e.g., Windscribe's 10.255.255.3) don't cause 2s query timeouts.
- **Ordering: tunnel checks → VPN DNS refresh → delayed re-checks**: Ensures anchor rebuilds from tunnel changes include current VPN DNS exemptions.

## Related

- [GitLab Issue #489](https://gitlab.int.windscribe.com/controld/clients/ctrld/-/issues/489) — Original issue and discussion
- F5 BIG-IP APM VPN DNS conflict (Support ticket #1688001)
