windows: adopt GP-managed NRPT catch-all

This commit is contained in:
Dev Scribe
2026-08-12 14:32:13 +07:00
committed by Cuong Manh Le
parent 58a5ea56af
commit e4e1c0abb9
13 changed files with 3618 additions and 287 deletions
+44 -23
View File
@@ -67,28 +67,27 @@ Separating them into modes means most users get `dns` mode (safe, can never brea
#### Startup Sequence (dns mode)
1. Creates NRPT catch-all registry rule (`.``127.0.0.1`) under `HKLM\...\DnsPolicyConfig\CtrldCatchAll`
2. Triggers Group Policy refresh via `RefreshPolicyEx` (userenv.dll) so DNS Client loads NRPT immediately
3. Flushes DNS cache to clear stale 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 → `127.0.0.1` path (see [Loopback WFP Protect](#loopback-wfp-protect) below). Non-fatal if this fails.
5. Starts NRPT health monitor (30s periodic check)
6. Launches async NRPT probe-and-heal to verify NRPT is actually routing queries
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. Creates NRPT catch-all rule + GP refresh + DNS flush (same 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 NRPT health monitor (also verifies WFP sublayer in hard mode)
9. Launches async NRPT probe-and-heal
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 must succeed before WFP starts. If NRPT fails, WFP is not attempted. If WFP fails, NRPT is rolled back. This prevents DNS blackholes where WFP blocks everything but nothing routes to ctrld.
**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: stops health monitor, removes NRPT rule, flushes DNS, then (hard mode only) removes all WFP filters and closes engine.
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
@@ -103,7 +102,27 @@ The **Name Resolution Policy Table** is a Windows feature (originally for Direct
**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)` from `userenv.dll` to trigger an immediate refresh. Falls back to `gpupdate /target:computer /force` if the DLL call fails.
**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
@@ -145,17 +164,19 @@ See: [Issue #526](https://gitlab.int.windscribe.com/controld/clients/ctrld/-/iss
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 probe (async, non-blocking):** After NRPT setup, an async goroutine probes with escalating remediation: (1) immediate probe, (2) GP refresh + retry, (3) DNS Client service restart + retry, (4) final retry. Only one probe sequence runs at a time.
**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.
**DNS Client restart (nuclear option):** If GP refresh alone isn't enough, ctrld restarts the `Dnscache` service to force full NRPT re-initialization. This briefly interrupts all DNS (~100ms) but only fires when NRPT is already not working.
**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. **Registry check:** If the NRPT catch-all rule is missing from the registry, restore it + GP refresh + probe-and-heal
2. **Active probe:** If the rule exists, send a probe query to verify it's actually routing — catches cases where the registry key is present but DNS Client hasn't loaded it
3. **(hard mode)** Verify WFP sublayer exists; full restart on loss
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.
+51 -55
View File
@@ -7,9 +7,7 @@ On Windows, DNS intercept mode uses a two-layer architecture:
- **`dns` mode (default)**: NRPT only — graceful DNS routing via the Windows DNS Client service
- **`hard` mode**: NRPT + WFP — full enforcement with kernel-level block filters
This dual-mode design ensures that `dns` mode can never break DNS (at worst, a VPN
overwrites NRPT and queries bypass ctrld temporarily), while `hard` mode provides
the same enforcement guarantees as macOS pf.
`dns` mode avoids ctrld's outbound block filters and therefore degrades more gracefully when owned NRPT is removed. Resolution still depends on the active NRPT target being reachable; an administrator-owned GP catch-all intentionally remains fail-closed if its loopback listener is stopped.
## Architecture: dns vs hard Mode
@@ -24,9 +22,8 @@ the same enforcement guarantees as macOS pf.
│ localhost, CLEAR_ACTION_RIGHT) prevent third-party VPN WFP │
│ blocks (e.g., OpenVPN block-outside-dns) from breaking NRPT. │
│ │
If VPN clears NRPT: health monitor re-adds within 30s
Worst case: queries go to VPN DNS until NRPT restored
│ DNS never breaks — graceful degradation │
Owned rule missing → restore; GP missing → owned fallback
GP rule + dead listener remains intentionally fail-closed
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
@@ -37,9 +34,8 @@ the same enforcement guarantees as macOS pf.
│ Bypass attempt (raw 8.8.8.8:53) → WFP BLOCK filter │
│ VPN DNS on private IP → WFP subnet PERMIT filter → allowed │
│ │
│ NRPT must be active before WFP starts (atomic guarantee)
If NRPT fails → WFP not started (avoids DNS blackhole)
│ If WFP fails → NRPT rolled back (all-or-nothing) │
│ NRPT route is established before WFP starts
WFP failure rolls back only ctrld-owned NRPT; GP is untouched
└─────────────────────────────────────────────────────────────────┘
```
@@ -79,7 +75,7 @@ unreliable. If we write to the GP path, DNS Client enters GP mode but the rules
never activate — resulting in `Get-DnsClientNrptPolicy` returning empty even though
`Get-DnsClientNrptRule` shows the rule in registry.
ctrld uses an adaptive strategy (matching [Tailscale's approach](https://github.com/tailscale/tailscale/blob/main/net/dns/nrpt_windows.go)):
ctrld uses an adaptive strategy (matching [Tailscale's approach](https://github.com/tailscale/tailscale/blob/main/net/dns/nrpt_windows.go)) when it owns NRPT:
1. **Always write to the local path** using a deterministic GUID key name
(`{B2E9A3C1-7F4D-4A8E-9D6B-5C1E0F3A2B8D}`). This is the baseline that works
@@ -91,6 +87,23 @@ ctrld uses an adaptive strategy (matching [Tailscale's approach](https://github.
the empty GP parent key. This ensures DNS Client stays in "local mode" where
the local-path rule activates immediately via `paramchange`.
### Adopting an Organization-Owned GP Catch-All
Before applying that ctrld-owned strategy, service startup looks for a non-ctrld GP child with exactly `Name=["."]` and one `GenericDNSServers` value equal to the actual listener IP. The registry match is only an ownership candidate: ctrld sends its unique DNS Client probe before any NRPT write and re-reads the same child afterward.
Intercept state stays unpublished while that happens. Startup publishes nothing until it has fully succeeded, which is why the probe and heal flows take the state as an argument instead of reading the published field — publishing early would expose a half-built `wfpState`, with no engine handle and filter IDs still being assigned, to callers such as the VPN DNS exemption path. The one deliberate exception is hard mode when WFP setup fails while GP-managed NRPT is verified routing: that state is published so the health monitor can keep retrying WFP, and the service start is still reported as failed.
When the rule remains unchanged and the probe arrives, Group Policy owns NRPT:
- the startup adapter reset is skipped;
- no ctrld NRPT key is created;
- `RefreshPolicyEx`, Dnscache `paramchange`, and cache flush are not used for that policy;
- shutdown/uninstall leave the GP child untouched.
A matching GP child that remains present but fails its probe is reported as ineffective and is not rewritten. If the child disappears, ctrld activates its normal owned fallback. A GP catch-all that instead changes to another resolver is reported as a conflict; ctrld does not create a second ambiguous catch-all. If the matching rule later returns, ctrld probes first, removes only its deterministic fallback keys, signals the ownership transition once, and resumes observing Group Policy.
**Deployment ordering:** apply the GPO before starting ctrld to guarantee adapter DNS is never reset. Remove/unlink it before intentionally stopping or uninstalling ctrld. A GP catch-all still targeting loopback with no listener running is a deliberate fail-closed state and will break DNS.
### Reproducing the Empty GP Parent Case
This is a production code reference, so the temporary repro script is not kept in
@@ -209,28 +222,28 @@ by the VPN's own WFP rules.
**Startup (hard mode):**
```
1. Add NRPT catch-all rule + GP refresh + DNS flush
1. Adopt a proven matching GP catch-all, or install ctrld-owned NRPT
2. FwpmEngineOpen0() with RPC_C_AUTHN_DEFAULT (0xFFFFFFFF)
3. Delete stale sublayer (crash recovery)
4. FwpmSubLayerAdd0() — weight 0xFFFF
5. Add 4 localhost permit filters
6. Add 4 block filters
7. Add RFC1918 + CGNAT subnet permits
8. Start NRPT health monitor goroutine
8. Start ownership-aware NRPT/WFP health monitor
```
**Startup (dns mode):**
```
1. Add NRPT catch-all rule + GP refresh + DNS flush
1. Adopt a proven matching GP catch-all, or install ctrld-owned NRPT
2. Activate loopback WFP protect (4 hard-permit filters for localhost DNS)
3. Start NRPT health monitor goroutine
3. Start ownership-aware NRPT health monitor
```
**Shutdown:**
```
1. Stop NRPT health monitor
2. Remove NRPT catch-all rule + DNS flush
3. (hard mode only) Clean up all WFP filters, sublayer, close engine
2. Remove + signal only ctrld-owned NRPT; leave GP-managed policy untouched
3. Clean up ctrld WFP filters, sublayer, and engine session
```
**Crash Recovery:**
@@ -259,42 +272,24 @@ Windows DNS Client path to verify NRPT is actually working:
4. ctrld's DNS handler recognizes the probe prefix and signals success
5. If the probe times out (2s), NRPT isn't loaded yet → retry with remediation
### Startup Probe (Async)
### Startup Probes
After NRPT setup, an async goroutine runs the probe-and-heal sequence without
blocking startup:
For a matching GP candidate, startup blocks for one 2-second probe before any NRPT mutation and then re-reads the same child. A received query plus the unchanged rule proves both routing and external ownership. If that first probe fails, ctrld installs its normal WFP protection and performs one more ownership-safe probe before advertising startup readiness.
ctrld-owned NRPT retains the asynchronous sequence:
```
Probe attempt 1 (2s timeout)
Immediate probe
├─ Success → "NRPT verified working", done
└─ Timeout → GP refresh + DNS flush, sleep 1s
Probe attempt 2 (2s timeout)
├─ Success → done
└─ Timeout → Restart DNS Client service (nuclear), sleep 2s
Re-add NRPT + GP refresh + DNS flush
Probe attempt 3 (2s timeout)
├─ Success → done
└─ Timeout → GP refresh + DNS flush, sleep 4s
Probe attempt 4 (2s timeout)
├─ Success → done
└─ Timeout → log error, continue
└─ Timeout
├─ Empty GP parent → clean once, signal once, re-probe
└─ Otherwise → bounded 1s/2s/4s signal + probe retries
└─ Still failing → two-phase remove/signal/re-add/final probe
```
### DNS Client Restart (Nuclear Option)
### GP-Managed Probe Failure
If GP refresh alone isn't enough, ctrld restarts the Windows DNS Client service
(`Dnscache`). This forces the DNS Client to fully re-initialize, including
re-reading all NRPT rules from the registry. This is the equivalent of macOS
`forceReloadPFMainRuleset()`.
**Trade-offs:**
- Briefly interrupts ALL DNS resolution (few hundred ms during restart)
- Clears the system DNS cache (all apps need to re-resolve)
- VPN NRPT rules survive (they're in registry, re-read on restart)
- Enterprise security tools may log the service restart event
This only fires as attempt #3 after two GP refresh attempts fail — at that point
DNS isn't working through ctrld anyway, so a brief DNS blip is acceptable.
A matching GP child still owns Windows' effective NRPT store even when its probe fails. Writing a local rule cannot override that precedence, and rewriting the GP child would violate administrator ownership. ctrld therefore retries only its loopback WFP permit protection, reports the ineffective external policy, and leaves NRPT registry values and policy signals untouched.
### Health Monitor Integration
@@ -302,19 +297,20 @@ The 30s periodic health monitor now does actual probing, not just registry check
```
Every 30s:
├─ Registry check: nrptCatchAllRuleExists()?
│ ├─ Missing → re-add + GP refresh + flush + probe-and-heal
Present → probe to verify it's actually routing
├─ Probe success → OK
│ └─ Probe failure → probe-and-heal cycle
├─ GP-managed owner
│ ├─ Matching child + probe success → observe only
Matching child + probe failure → WFP-only retry; no NRPT mutation
└─ Matching child gone → activate ctrld-owned fallback + verify
(hard mode only) Check: wfpSublayerExists()?
├─ Missing → full restart (stopDNSIntercept + startDNSIntercept)
Present → OK
ctrld-owned owner
├─ Working matching GP child returns → remove only ctrld keys; adopt GP
ctrld key missing → restore + signal + verify
│ └─ ctrld key present → probe; run owned heal sequence on failure
└─ (hard mode) Check WFP sublayer; full intercept restart if missing
```
**Singleton guard:** Only one probe-and-heal sequence runs at a time (atomic bool).
The startup probe and health monitor cannot overlap.
**Singleton guard:** Only one asynchronous probe-and-heal sequence runs at a time (atomic bool). Startup's GP-candidate probe completes before the health monitor starts; direct periodic probes finish before they schedule a heal sequence.
**Why periodic, not just network-event?** VPN software or Group Policy updates can
clear NRPT at any time, not just during network changes. A 30s periodic check ensures