From 8cb383d87e6c2002c17c41088482cadab8842b88 Mon Sep 17 00:00:00 2001 From: CodeScribe Date: Wed, 29 Apr 2026 07:59:56 +0000 Subject: [PATCH] dns_intercept: add WFP loopback protect for VPN block-outside-dns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When third-party VPN software (e.g., OpenVPN) installs WFP block filters via block-outside-dns, all DNS traffic to non-tunnel interfaces is blocked — including DNS to 127.0.0.1 (ctrld's NRPT target). This breaks DNS mode interception because the NRPT catch-all rule routes queries to loopback, but WFP blocks the connection before it reaches ctrld's listener. Fix: after exhausting all NRPT recovery attempts, activate a minimal WFP session with "hard permit" filters (FWPM_FILTER_FLAG_CLEAR_ACTION_RIGHT) for DNS to localhost in a max-priority sublayer (weight 0xFFFF). This overrides the VPN's block for loopback DNS only, while preserving the VPN's DNS leak protection for all other (non-loopback) DNS traffic. The loopback protect is: - Only activated when NRPT probes fail (not preemptively) - Harmless when no conflicting WFP blocks exist (permit-only, no blocks) - Persistent until ctrld shutdown (survives VPN reconnect cycles) - Cleaned up by the existing cleanupWFPFilters path on shutdown --- cmd/cli/dns_intercept_windows.go | 285 ++++++++++++++++++++++++++++--- docs/dns-intercept-mode.md | 33 +++- docs/known-issues.md | 23 +++ docs/wfp-dns-intercept.md | 20 ++- 4 files changed, 330 insertions(+), 31 deletions(-) diff --git a/cmd/cli/dns_intercept_windows.go b/cmd/cli/dns_intercept_windows.go index fb56782..bfe07e1 100644 --- a/cmd/cli/dns_intercept_windows.go +++ b/cmd/cli/dns_intercept_windows.go @@ -9,6 +9,7 @@ import ( "net" "os/exec" "runtime" + "sync" "sync/atomic" "time" "unsafe" @@ -118,6 +119,14 @@ const ( // DNS port. dnsPort uint16 = 53 + + // FWPM_FILTER_FLAG constants from fwpmtypes.h. + // See: https://learn.microsoft.com/en-us/windows/win32/api/fwpmtypes/ns-fwpmtypes-fwpm_filter0 + // + // FWPM_FILTER_FLAG_CLEAR_ACTION_RIGHT (0x08) prevents lower-weight sublayers + // from overriding this filter's PERMIT action ("hard permit"). Used in DNS + // mode to override third-party WFP blocks (e.g., OpenVPN's block-outside-dns). + fwpmFilterFlagClearActionRight uint32 = 0x00000008 ) // WFP API structures. These mirror the C structures from fwpmtypes.h and fwptypes.h. @@ -258,6 +267,17 @@ type wfpState struct { listenerIP string // stopCh is used to shut down the NRPT health monitor goroutine. stopCh chan struct{} + // mu protects loopbackProtectActive, loopbackPermitIDs, and engineHandle + // from concurrent access between nrptProbeAndHeal (goroutine) and + // stopDNSIntercept / cleanupWFPFilters (main goroutine). + mu sync.Mutex + // loopbackProtectActive is true when DNS mode has activated a minimal WFP + // session to permit loopback DNS. This counters third-party WFP block filters + // (e.g., OpenVPN's block-outside-dns) that prevent NRPT from routing queries + // to ctrld's listener on 127.0.0.1. See issue #526. + loopbackProtectActive bool + // loopbackPermitIDs stores the filter IDs for the loopback protect permits. + loopbackPermitIDs []uint64 } // Lazy-loaded WFP DLL procedures. @@ -607,6 +627,17 @@ func (p *prog) startDNSIntercept() error { } } else { mainLog.Load().Info().Msg("DNS intercept: dns mode — NRPT only, no WFP filters (graceful)") + // Proactively add loopback WFP permit filters to protect the NRPT + // → 127.0.0.1 path from third-party DNS block filters (e.g., OpenVPN's + // block-outside-dns). These are narrowly scoped (port 53 to localhost + // only) and use CLEAR_ACTION_RIGHT to override any block from other + // sublayers. Adding them at startup eliminates the DNS outage window + // that would otherwise occur between VPN connect and reactive activation. + if err := p.activateLoopbackWFPProtect(state); err != nil { + // Non-fatal: loopback protect is a defense-in-depth measure. + // NRPT still works when no third-party WFP blocks are present. + mainLog.Load().Warn().Err(err).Msg("DNS intercept: failed to activate proactive loopback WFP protect — will retry on probe failure") + } } p.dnsInterceptState = state @@ -878,14 +909,13 @@ func (p *prog) addWFPPermitLocalhostFilter(engineHandle uintptr, name string, la return filterID, nil } -// addWFPPermitSubnetFilter adds a WFP filter that permits outbound DNS to a given -// IPv4 subnet (addr/mask in host byte order). Used to exempt RFC1918 and CGNAT ranges -// so VPN DNS servers on private IPs are not blocked. -func (p *prog) addWFPPermitSubnetFilter(engineHandle uintptr, name string, proto uint8, addr, mask uint32) (uint64, error) { +// addWFPPermitDNSFilter is the unified helper for adding a WFP permit filter for +// outbound DNS (port 53) with caller-specified address condition, flags, and weight. +// Both subnet permits (RFC1918/CGNAT, flags=0, weight=10) and hard loopback permits +// (CLEAR_ACTION_RIGHT, weight=15) use this to avoid code drift. +func (p *prog) addWFPPermitDNSFilter(engineHandle uintptr, name string, layerKey windows.GUID, proto uint8, addrCond fwpmFilterCondition0, flags uint32, weight uint8) (uint64, error) { filterName, _ := windows.UTF16PtrFromString("ctrld: " + name) - addrMask := fwpV4AddrAndMask{addr: addr, mask: mask} - conditions := make([]fwpmFilterCondition0, 3) conditions[0] = fwpmFilterCondition0{ @@ -902,22 +932,18 @@ func (p *prog) addWFPPermitSubnetFilter(engineHandle uintptr, name string, proto conditions[1].condValue.valueType = fwpUint16 conditions[1].condValue.value = uint64(dnsPort) - conditions[2] = fwpmFilterCondition0{ - fieldKey: fwpmConditionIPRemoteAddress, - matchType: fwpMatchEqual, - } - conditions[2].condValue.valueType = fwpV4AddrMask - conditions[2].condValue.value = uint64(uintptr(unsafe.Pointer(&addrMask))) + conditions[2] = addrCond filter := fwpmFilter0{ - layerKey: fwpmLayerALEAuthConnectV4, + flags: flags, + layerKey: layerKey, subLayerKey: ctrldSubLayerGUID, numFilterConds: 3, filterCondition: &conditions[0], } filter.displayData.name = filterName filter.weight.valueType = fwpUint8 - filter.weight.value = 10 + filter.weight.value = uint64(weight) filter.action.actionType = fwpActionPermit var filterID uint64 @@ -927,7 +953,6 @@ func (p *prog) addWFPPermitSubnetFilter(engineHandle uintptr, name string, proto 0, uintptr(unsafe.Pointer(&filterID)), ) - runtime.KeepAlive(&addrMask) runtime.KeepAlive(conditions) if r1 != 0 { return 0, fmt.Errorf("FwpmFilterAdd0 failed: HRESULT 0x%x", r1) @@ -935,6 +960,24 @@ func (p *prog) addWFPPermitSubnetFilter(engineHandle uintptr, name string, proto return filterID, nil } +// addWFPPermitSubnetFilter adds a WFP filter that permits outbound DNS to a given +// IPv4 subnet (addr/mask in host byte order). Used to exempt RFC1918 and CGNAT ranges +// so VPN DNS servers on private IPs are not blocked. +func (p *prog) addWFPPermitSubnetFilter(engineHandle uintptr, name string, proto uint8, addr, mask uint32) (uint64, error) { + addrMask := fwpV4AddrAndMask{addr: addr, mask: mask} + + addrCond := fwpmFilterCondition0{ + fieldKey: fwpmConditionIPRemoteAddress, + matchType: fwpMatchEqual, + } + addrCond.condValue.valueType = fwpV4AddrMask + addrCond.condValue.value = uint64(uintptr(unsafe.Pointer(&addrMask))) + + filterID, err := p.addWFPPermitDNSFilter(engineHandle, name, fwpmLayerALEAuthConnectV4, proto, addrCond, 0, 10) + runtime.KeepAlive(&addrMask) + return filterID, err +} + // wfpSublayerExists checks whether our WFP sublayer still exists in the engine. // Used by the watchdog to detect if another program removed our filters. func wfpSublayerExists(engineHandle uintptr) bool { @@ -962,6 +1005,21 @@ func (p *prog) cleanupWFPFilters(state *wfpState) { return } + // Clean up loopback protect filters (DNS mode VPN workaround). + state.mu.Lock() + loopbackIDs := state.loopbackPermitIDs + state.loopbackPermitIDs = nil + state.loopbackProtectActive = false + state.mu.Unlock() + for _, filterID := range loopbackIDs { + r1, _, _ := procFwpmFilterDeleteById0.Call(state.engineHandle, uintptr(filterID)) + if r1 != 0 { + mainLog.Load().Warn().Msgf("DNS intercept: failed to remove loopback protect filter (ID: %d, code: 0x%x)", filterID, r1) + } else { + mainLog.Load().Debug().Msgf("DNS intercept: removed loopback protect filter (ID: %d)", filterID) + } + } + for _, filterID := range state.vpnPermitFilterIDs { r1, _, _ := procFwpmFilterDeleteById0.Call(state.engineHandle, uintptr(filterID)) if r1 != 0 { @@ -1024,6 +1082,154 @@ func (p *prog) cleanupWFPFilters(state *wfpState) { } } +// activateLoopbackWFPProtect opens a minimal WFP session and adds "hard permit" +// filters for DNS to localhost. This is used in DNS mode when NRPT probe failures +// are detected, typically caused by third-party VPN software (e.g., OpenVPN) that +// installs WFP block filters via block-outside-dns. The hard permit (with +// FWPM_FILTER_FLAG_CLEAR_ACTION_RIGHT) in a max-weight sublayer overrides the +// third-party blocks without affecting their protection for non-loopback DNS. +// +// See: https://gitlab.int.windscribe.com/controld/clients/ctrld/-/issues/526 +func (p *prog) activateLoopbackWFPProtect(state *wfpState) error { + state.mu.Lock() + defer state.mu.Unlock() + + if state.loopbackProtectActive { + mainLog.Load().Debug().Msg("DNS intercept: loopback WFP protect already active") + return nil + } + // Only activate in DNS mode. Hard mode manages its own full WFP state + // (block + permit filters in the same sublayer). Activating loopback + // protect would delete the hard mode sublayer and all its filters. + if hardIntercept { + mainLog.Load().Debug().Msg("DNS intercept: skipping loopback WFP protect in hard mode") + return nil + } + + mainLog.Load().Info().Msg("DNS intercept: activating loopback WFP protect (countering third-party DNS block filters)") + + // Open WFP engine if not already open (DNS mode doesn't open it normally). + if state.engineHandle == 0 { + var engineHandle uintptr + session := fwpmSession0{} + sessionName, _ := windows.UTF16PtrFromString("ctrld DNS Loopback Protect") + session.displayData.name = sessionName + + const rpcCAuthnDefault = 0xFFFFFFFF + r1, _, _ := procFwpmEngineOpen0.Call( + 0, + uintptr(rpcCAuthnDefault), + 0, + uintptr(unsafe.Pointer(&session)), + uintptr(unsafe.Pointer(&engineHandle)), + ) + if r1 != 0 { + return fmt.Errorf("FwpmEngineOpen0 failed: HRESULT 0x%x", r1) + } + mainLog.Load().Info().Msgf("DNS intercept: WFP engine opened for loopback protect (handle: 0x%x)", engineHandle) + state.engineHandle = engineHandle + } + + // Clean up any stale sublayer from a previous session. + procFwpmSubLayerDeleteByKey0.Call( + state.engineHandle, + uintptr(unsafe.Pointer(&ctrldSubLayerGUID)), + ) + + // Create sublayer at maximum priority. + sublayer := fwpmSublayer0{ + subLayerKey: ctrldSubLayerGUID, + weight: 0xFFFF, + } + sublayerName, _ := windows.UTF16PtrFromString("ctrld DNS Loopback Protect Sublayer") + sublayerDesc, _ := windows.UTF16PtrFromString("Permits DNS to localhost, overriding third-party VPN block filters") + sublayer.displayData.name = sublayerName + sublayer.displayData.description = sublayerDesc + + r1, _, _ := procFwpmSubLayerAdd0.Call( + state.engineHandle, + uintptr(unsafe.Pointer(&sublayer)), + 0, + ) + if r1 != 0 { + return fmt.Errorf("FwpmSubLayerAdd0 failed: HRESULT 0x%x", r1) + } + + // Add hard permit filters for loopback DNS (v4+v6, UDP+TCP). + permitFilters := []struct { + name string + layer windows.GUID + proto uint8 + }{ + {"Loopback Protect: Permit DNS to localhost (IPv4/UDP)", fwpmLayerALEAuthConnectV4, ipprotoUDP}, + {"Loopback Protect: Permit DNS to localhost (IPv4/TCP)", fwpmLayerALEAuthConnectV4, ipprotoTCP}, + {"Loopback Protect: Permit DNS to localhost (IPv6/UDP)", fwpmLayerALEAuthConnectV6, ipprotoUDP}, + {"Loopback Protect: Permit DNS to localhost (IPv6/TCP)", fwpmLayerALEAuthConnectV6, ipprotoTCP}, + } + + for _, pf := range permitFilters { + filterID, err := p.addWFPHardPermitLocalhostFilter(state.engineHandle, pf.name, pf.layer, pf.proto, state.listenerIP) + if err != nil { + // Partial failure — clean up what we added (already holding mu). + p.deactivateLoopbackWFPProtectLocked(state) + return fmt.Errorf("failed to add loopback protect filter %q: %w", pf.name, err) + } + state.loopbackPermitIDs = append(state.loopbackPermitIDs, filterID) + mainLog.Load().Debug().Str("filter", pf.name).Uint64("id", filterID).Msg("DNS intercept: added loopback protect filter") + } + + state.loopbackProtectActive = true + mainLog.Load().Info().Int("filters", len(state.loopbackPermitIDs)). + Msg("DNS intercept: loopback WFP protect activated — localhost DNS permitted with CLEAR_ACTION_RIGHT") + return nil +} + +// deactivateLoopbackWFPProtectLocked is the lock-free inner implementation. +// Caller must hold state.mu. +func (p *prog) deactivateLoopbackWFPProtectLocked(state *wfpState) { + if !state.loopbackProtectActive && len(state.loopbackPermitIDs) == 0 { + return + } + + for _, filterID := range state.loopbackPermitIDs { + if state.engineHandle != 0 { + r1, _, _ := procFwpmFilterDeleteById0.Call(state.engineHandle, uintptr(filterID)) + if r1 != 0 { + mainLog.Load().Warn().Msgf("DNS intercept: failed to remove loopback protect filter (ID: %d, code: 0x%x)", filterID, r1) + } + } + } + state.loopbackPermitIDs = nil + state.loopbackProtectActive = false + mainLog.Load().Info().Msg("DNS intercept: loopback WFP protect deactivated") +} + +// addWFPHardPermitLocalhostFilter adds a WFP permit filter for DNS to localhost with +// FWPM_FILTER_FLAG_CLEAR_ACTION_RIGHT. This "hard permit" prevents lower-priority +// sublayers (e.g., OpenVPN's block-outside-dns sublayer) from blocking DNS to +// ctrld's loopback listener. Weight is set to 15 (above hard mode's permit=10). +// For IPv4, the address is derived from listenerIP (e.g., 127.0.0.1 or 127.0.0.2). +func (p *prog) addWFPHardPermitLocalhostFilter(engineHandle uintptr, name string, layerKey windows.GUID, proto uint8, listenerIP string) (uint64, error) { + addrCond := fwpmFilterCondition0{ + fieldKey: fwpmConditionIPRemoteAddress, + matchType: fwpMatchEqual, + } + + ipv6Loopback := [16]byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1} + + if layerKey == fwpmLayerALEAuthConnectV4 { + addrCond.condValue.valueType = fwpUint32 + addrCond.condValue.value = uint64(parseIPv4AsUint32(listenerIP)) + } else { + addrCond.condValue.valueType = fwpByteArray16Type + addrCond.condValue.value = uint64(uintptr(unsafe.Pointer(&ipv6Loopback))) + } + + filterID, err := p.addWFPPermitDNSFilter(engineHandle, name, layerKey, proto, addrCond, fwpmFilterFlagClearActionRight, 15) + runtime.KeepAlive(&ipv6Loopback) + return filterID, err +} + // stopDNSIntercept removes all WFP filters and shuts down the DNS interception. func (p *prog) stopDNSIntercept() error { if p.dnsInterceptState == nil { @@ -1050,7 +1256,7 @@ func (p *prog) stopDNSIntercept() error { state.nrptActive = false } - // Only clean up WFP if we actually opened the engine (hard mode). + // Clean up WFP if the engine was opened (hard mode or loopback protect). if state.engineHandle != 0 { mainLog.Load().Info().Msg("DNS intercept: shutting down WFP filters") p.cleanupWFPFilters(state) @@ -1079,10 +1285,13 @@ func (p *prog) exemptVPNDNSServers(exemptions []vpnDNSExemption) error { if !ok || state == nil { return fmt.Errorf("DNS intercept state not available") } - // In dns mode (no WFP), VPN DNS exemptions are not needed — there are no - // block filters to exempt from. - if state.engineHandle == 0 { - mainLog.Load().Debug().Msg("DNS intercept: dns mode — skipping VPN DNS exemptions (no WFP filters)") + // In dns mode (no WFP) or loopback-protect-only mode, VPN DNS exemptions + // are not needed — there are no ctrld block filters to exempt from. + // Loopback protect only adds hard-permit filters for localhost DNS; + // VPN DNS traffic uses the tunnel interface and is already permitted by + // the VPN's own WFP rules. + if state.engineHandle == 0 || state.loopbackProtectActive { + mainLog.Load().Debug().Msg("DNS intercept: dns mode — skipping VPN DNS exemptions (no WFP block filters)") return nil } @@ -1634,6 +1843,40 @@ func (p *prog) nrptProbeAndHeal() { } logNRPTParentKeyState("probe-failed-final") - mainLog.Load().Error().Msg("DNS intercept: NRPT verification failed after all retries including two-phase recovery — " + + mainLog.Load().Warn().Msg("DNS intercept: NRPT verification failed after all retries including two-phase recovery") + + // Last resort: activate WFP loopback protection. + // Third-party VPN software (e.g., OpenVPN with block-outside-dns) may have + // installed WFP filters that block DNS to non-tunnel interfaces, including + // loopback. A high-priority "hard permit" for localhost DNS overrides these + // blocks and restores NRPT routing to ctrld's listener. + // See: https://gitlab.int.windscribe.com/controld/clients/ctrld/-/issues/526 + loopbackState, ok := p.dnsInterceptState.(*wfpState) + if !ok || loopbackState == nil { + mainLog.Load().Error().Msg("DNS intercept: no state available for loopback WFP protect") + return + } + + // Bail out if shutdown is in progress — avoid racing with cleanupWFPFilters. + select { + case <-loopbackState.stopCh: + mainLog.Load().Info().Msg("DNS intercept: shutdown in progress, skipping loopback WFP protect activation") + return + default: + } + + if err := p.activateLoopbackWFPProtect(loopbackState); err != nil { + mainLog.Load().Error().Err(err).Msg("DNS intercept: failed to activate loopback WFP protect — " + + "DNS queries may not be routed through ctrld. A network interface toggle may be needed.") + return + } + + // Retry NRPT probe now that loopback DNS is explicitly permitted through WFP. + time.Sleep(500 * time.Millisecond) + if p.probeNRPT() { + mainLog.Load().Info().Msg("DNS intercept: NRPT verified working after loopback WFP protect activation") + return + } + mainLog.Load().Error().Msg("DNS intercept: NRPT probe still failing after loopback WFP protect — " + "DNS queries may not be routed through ctrld. A network interface toggle may be needed.") } diff --git a/docs/dns-intercept-mode.md b/docs/dns-intercept-mode.md index 41dae1f..c089ea1 100644 --- a/docs/dns-intercept-mode.md +++ b/docs/dns-intercept-mode.md @@ -56,7 +56,7 @@ ctrld run --intercept-mode hard --cd Windows DNS intercept uses a two-tier architecture with mode-dependent enforcement: -- **`dns` mode**: NRPT only — graceful DNS routing through the Windows DNS Client service. At worst, a VPN overwrites NRPT and queries bypass ctrld temporarily. DNS never breaks. +- **`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? @@ -70,8 +70,9 @@ Separating them into modes means most users get `dns` mode (safe, can never brea 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. Starts NRPT health monitor (30s periodic check) -5. Launches async NRPT probe-and-heal to verify NRPT is actually routing queries +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 #### Startup Sequence (hard mode) @@ -112,6 +113,32 @@ The **Name Resolution Policy Table** is a Windows feature (originally for Direct **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. diff --git a/docs/known-issues.md b/docs/known-issues.md index e9a897b..77e09ce 100644 --- a/docs/known-issues.md +++ b/docs/known-issues.md @@ -50,6 +50,29 @@ To isolate the signals, avoid running the log viewer in the same window as the d * **Window B:** Open a new SSH connection to run `ctrld log tail`. Because Window B has a different **Session ID** and **Process Group ID**, pressing `Ctrl+C` in Window B will not affect the process in Window A. +## Windows Issues + +### VPN `block-outside-dns` Breaks DNS When Using ctrld in DNS Mode + +**Issue**: VPN software that uses OpenVPN's `block-outside-dns` directive installs WFP (Windows Filtering Platform) block filters that prevent DNS queries from reaching ctrld's loopback listener. + +**Status**: Fixed in v1.5.1 + +**Description**: When a VPN connects with `block-outside-dns` enabled, OpenVPN adds WFP filters that block all DNS traffic to non-tunnel interfaces — including loopback (`127.0.0.1`). Since ctrld's NRPT catch-all rule routes DNS through the Windows DNS Client to `127.0.0.1:53`, the WFP block filters prevent DNS Client from reaching ctrld, causing all DNS queries to time out. + +This affects any VPN client that implements `block-outside-dns` via WFP, including: +- OpenVPN GUI (community) +- Securepoint SSL VPN +- Any OpenVPN-based client that honors the `block-outside-dns` push directive + +**Fix**: ctrld now proactively adds WFP "hard permit" filters for DNS to localhost at startup. These use `FWPM_FILTER_FLAG_CLEAR_ACTION_RIGHT` to override block decisions from any other WFP sublayer, ensuring the NRPT → loopback path is always available regardless of VPN state. See `docs/dns-intercept-mode.md` for technical details. + +**Affected Versions**: ctrld ≤ v1.5.0 in `dns` intercept mode on Windows + +**Last Updated**: 04/28/2026 + +--- + ## Contributing to Known Issues If you encounter an issue not listed here, please: diff --git a/docs/wfp-dns-intercept.md b/docs/wfp-dns-intercept.md index 6b9c3b5..2e7ece2 100644 --- a/docs/wfp-dns-intercept.md +++ b/docs/wfp-dns-intercept.md @@ -15,11 +15,15 @@ the same enforcement guarantees as macOS pf. ``` ┌─────────────────────────────────────────────────────────────────┐ -│ dns mode (NRPT only) │ +│ dns mode (NRPT + loopback WFP protect) │ │ │ │ App DNS query → DNS Client service → NRPT lookup │ │ → "." catch-all matches → forward to 127.0.0.1 (ctrld) │ │ │ +│ Loopback WFP protect: 4 hard-permit filters (port 53 to │ +│ 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 │ @@ -182,8 +186,10 @@ When `vpnDNSManager.Refresh()` discovers VPN DNS servers on public IPs: - Both UDP and TCP for each IP 3. Store new filter IDs for next cleanup cycle -**In `dns` mode, VPN DNS exemptions are skipped** — there are no WFP block -filters to exempt from. +**In `dns` mode, VPN DNS exemptions are skipped** — there are no ctrld WFP block +filters to exempt from. The loopback WFP protect filters only permit localhost +DNS; VPN DNS traffic goes through the tunnel interface and is already permitted +by the VPN's own WFP rules. ### Session Lifecycle @@ -202,8 +208,8 @@ filters to exempt from. **Startup (dns mode):** ``` 1. Add NRPT catch-all rule + GP refresh + DNS flush -2. Start NRPT health monitor goroutine -3. (No WFP — done) +2. Activate loopback WFP protect (4 hard-permit filters for localhost DNS) +3. Start NRPT health monitor goroutine ``` **Shutdown:** @@ -338,9 +344,9 @@ breaking DNS. | Aspect | macOS (pf) | Windows dns mode | Windows hard mode | |--------|-----------|------------------|-------------------| | **Routing** | `rdr` redirect | NRPT policy | NRPT policy | -| **Enforcement** | `route-to` + block rules | None (graceful) | WFP block filters | +| **Enforcement** | `route-to` + block rules | Loopback WFP protect | WFP block filters | | **Can break DNS?** | Yes (pf corruption) | No | Yes (if NRPT lost) | -| **VPN coexistence** | Watchdog + stabilization | NRPT most-specific-match | Same + WFP permits | +| **VPN coexistence** | Watchdog + stabilization | NRPT + loopback hard-permit | Same + WFP permits | | **Bypass protection** | pf catches all packets | None | WFP catches all connections | | **Recovery** | Probe + auto-heal | Health monitor re-adds | Full restart on sublayer loss |