Files
ctrld/cmd/cli/dns_intercept_windows.go
T
Cuong Manh Le 891d5d9821 cmd/cli: drop stale intercept enforcement at startup, before API bootstrap
A host carrying orphaned WFP filters from a ctrld build that predates
session-scoped ownership cannot recover on its own. In Firewall Mode those
filters block all non-allowlisted outbound traffic machine-wide, which denies
the replacement ctrld's own API bootstrap. API-managed startup then sits in the
resolver-config retry loop forever and never reaches startWFPFilters, where the
only stale-sublayer cleanup lived. Cleanup needs startup, startup needs the
network, the network needs cleanup - the host stays locked out until a reboot.

Add cleanupStaleDNSInterceptState() and call it early in run(), before the
network-up wait and before the API preflight. On Windows it deletes ctrld's WFP
sublayer, which takes its child filters with it, so a previous process's
enforcement is gone before this process makes its first connection. Objects
owned by a live session cannot be deleted, so a running ctrld is unaffected and
"nothing to clean up" stays at debug level; an actual removal logs a warning,
since it means a previous ctrld left machine-wide enforcement installed.

macOS and the other platforms get no-op implementations: pf enforcement does not
outlive the process, and startDNSIntercept already flushes the anchor and
removes a stale anchor file before loading rules.

The cleanup inside startWFPFilters stays as a second line of defense.

The cleanup session is deliberately NOT dynamic. FwpmSubLayerDeleteByKey0 is
documented to fail with FWP_E_DYNAMIC_SESSION_IN_PROGRESS when called from a
dynamic session for an object that was not added in one, and the only orphans
that can exist are exactly those: a ctrld predating session-scoped ownership
added its sublayer statically. Anything a newer ctrld leaves behind is removed by
the OS when its session ends. Opening this session dynamically would have made
the cleanup a no-op in the one case it exists for, while still logging "no stale
WFP state".

A concurrently running ctrld is protected by documented ownership rather than by
the child-filter question below: a session-scoped ctrld's sublayer belongs to a
different dynamic session, so the delete fails with FWP_E_WRONG_SESSION. That
matters because this call is made unconditionally at startup, in every intercept
mode, so an interactive "ctrld run" alongside a healthy service reaches it.

A ctrld predating session scoping has no such protection: it holds a non-dynamic
sublayer, which is exactly what this targets and is indistinguishable from an
orphan. Two guards cover that instead. Elevation, because opening a WFP engine
and deleting ctrld's sublayer must not be reachable from an unprivileged local
process - FwpmEngineOpen0 is expected to fail without elevation, but that is a
property of the API rather than something this code checked, and it was the only
barrier. And interactive invocation, since a service start is not interactive: the
deadlock case still gets cleaned, while a hand-run "ctrld run" beside a live
service does not strip its enforcement. That second guard requires positive
evidence of absence - ctrldServiceLiveness answers unknown for an unreachable SCM
or a service mid-stop, and unknown skips the cleanup exactly as running does,
because "could not be observed" is not "not there".

Both are asserted against the deletion itself, not only against the predicate: a
caller-level test substitutes the WFP delete and the guard inputs, so a future
change that stops consulting the guard, or consults it and deletes anyway, fails
rather than staying green.

Delete failures are no longer collapsed into "nothing to clean up". Only
FWP_E_SUBLAYER_NOT_FOUND means that; any other code means state exists under our
GUID that we could not remove, which is the lockout condition itself, so it is
logged as a warning naming the code.

Whether deleting the sublayer is sufficient is left explicitly UNRESOLVED rather
than asserted. It is sufficient only if the delete also removes the filters
inside it. FwpmSubLayerDeleteByKey0's Remarks say nothing about child filters
either way, while object management states that an object cannot be deleted until
everything referencing it has been - and FWP_E_IN_USE exists for that. Whether a
filter's subLayerKey counts as such a reference is not documented, and this code
cannot be exercised off-Windows, so the earlier claim that the delete "takes its
child filters with it" is removed from the comment here and from
docs/wfp-dns-intercept.md, which carried it from before this branch.

The behaviour is safe under both readings: the delete is attempted, and
FWP_E_IN_USE is reported rather than counted as success, so a support log
distinguishes "cleared it" from "could not clear it". If a live Windows check
shows FWP_E_IN_USE against orphaned filters, the cleanup must enumerate and
delete those filters first. That is deliberately not written blind:
FWPM_FILTER_ENUM_TEMPLATE0 has no sublayer field, so selecting ctrld's own
filters means reading subLayerKey at a computed offset in FWPM_FILTER0, and
getting that offset wrong would delete other software's filters - a worse failure
than not cleaning up.

Refs: https://learn.microsoft.com/en-us/windows/win32/api/fwpmu/nf-fwpmu-fwpmsublayerdeletebykey0
Refs: https://learn.microsoft.com/en-us/windows/win32/fwp/object-management
2026-08-14 15:29:06 +07:00

3560 lines
150 KiB
Go

//go:build windows
package cli
import (
"context"
"fmt"
"math/rand"
"net"
"os/exec"
"runtime"
"strings"
"sync"
"sync/atomic"
"time"
"unsafe"
"github.com/kardianos/service"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
"github.com/Control-D-Inc/ctrld"
)
// DNS Intercept Mode — Windows Implementation (WFP)
//
// This file implements DNS interception using Windows Filtering Platform (WFP).
// WFP is a kernel-level network filtering framework that allows applications to
// inspect and modify network traffic at various layers of the TCP/IP stack.
//
// Strategy:
// - Create a WFP sublayer at maximum priority (weight 0xFFFF)
// - Add PERMIT filters (weight 10) for DNS to localhost (ctrld's listener)
// - Add BLOCK filters (weight 1) for all other outbound DNS
// - Dynamically add/remove PERMIT filters for VPN DNS server exemptions
//
// This means even if VPN software overwrites adapter DNS settings, the OS
// cannot reach those DNS servers on port 53 — all DNS must flow through ctrld.
//
// Key advantages over macOS pf:
// - WFP filters are per-process kernel objects — other apps can't wipe them
// - No watchdog or stabilization needed
// - Connection-level filtering — no packet state/return-path complications
// - Full IPv4 + IPv6 support
//
// See docs/wfp-dns-intercept.md for architecture diagrams and debugging tips.
// WFP GUIDs and constants for DNS interception.
// These are defined by Microsoft's Windows Filtering Platform API.
var (
// ctrldSubLayerGUID is a unique GUID for ctrld's WFP sublayer.
// Generated specifically for ctrld DNS intercept mode.
ctrldSubLayerGUID = windows.GUID{
Data1: 0x7a4e5b6c,
Data2: 0x3d2f,
Data3: 0x4a1e,
Data4: [8]byte{0x9b, 0x8c, 0x1d, 0x2e, 0x3f, 0x4a, 0x5b, 0x6c},
}
// Well-known WFP layer GUIDs from Microsoft documentation.
// FWPM_LAYER_ALE_AUTH_CONNECT_V4: filters outbound IPv4 connection attempts.
fwpmLayerALEAuthConnectV4 = windows.GUID{
Data1: 0xc38d57d1,
Data2: 0x05a7,
Data3: 0x4c33,
Data4: [8]byte{0x90, 0x4f, 0x7f, 0xbc, 0xee, 0xe6, 0x0e, 0x82},
}
// FWPM_LAYER_ALE_AUTH_CONNECT_V6: filters outbound IPv6 connection attempts.
fwpmLayerALEAuthConnectV6 = windows.GUID{
Data1: 0x4a72393b,
Data2: 0x319f,
Data3: 0x44bc,
Data4: [8]byte{0x84, 0xc3, 0xba, 0x54, 0xdc, 0xb3, 0xb6, 0xb4},
}
// FWPM_CONDITION_IP_REMOTE_PORT: condition matching on remote port.
fwpmConditionIPRemotePort = windows.GUID{
Data1: 0xc35a604d,
Data2: 0xd22b,
Data3: 0x4e1a,
Data4: [8]byte{0x91, 0xb4, 0x68, 0xf6, 0x74, 0xee, 0x67, 0x4b},
}
// FWPM_CONDITION_IP_REMOTE_ADDRESS: condition matching on remote address.
fwpmConditionIPRemoteAddress = windows.GUID{
Data1: 0xb235ae9a,
Data2: 0x1d64,
Data3: 0x49b8,
Data4: [8]byte{0xa4, 0x4c, 0x5f, 0xf3, 0xd9, 0x09, 0x50, 0x45},
}
// FWPM_CONDITION_IP_PROTOCOL: condition matching on IP protocol.
fwpmConditionIPProtocol = windows.GUID{
Data1: 0x3971ef2b,
Data2: 0x623e,
Data3: 0x4f9a,
Data4: [8]byte{0x8c, 0xb1, 0x6e, 0x79, 0xb8, 0x06, 0xb9, 0xa7},
}
)
const (
// WFP action constants. These combine a base action with the TERMINATING flag.
// See: https://docs.microsoft.com/en-us/windows/win32/api/fwptypes/ne-fwptypes-fwp_action_type
fwpActionFlagTerminating uint32 = 0x00001000
fwpActionBlock uint32 = 0x00000001 | fwpActionFlagTerminating // 0x00001001
fwpActionPermit uint32 = 0x00000002 | fwpActionFlagTerminating // 0x00001002
// FWP_MATCH_EQUAL is the match type for exact value comparison.
fwpMatchEqual uint32 = 0 // FWP_MATCH_EQUAL
// FWP_DATA_TYPE constants for condition values.
// Enum starts at FWP_EMPTY=0, so FWP_UINT8=1, etc.
// See: https://learn.microsoft.com/en-us/windows/win32/api/fwptypes/ne-fwptypes-fwp_data_type
fwpUint8 uint32 = 1 // FWP_UINT8
fwpUint16 uint32 = 2 // FWP_UINT16
fwpUint32 uint32 = 3 // FWP_UINT32
fwpByteArray16Type uint32 = 11 // FWP_BYTE_ARRAY16_TYPE
fwpV4AddrMask uint32 = 0x100 // FWP_V4_ADDR_MASK (after FWP_SINGLE_DATA_TYPE_MAX=0xff)
fwpV6AddrMask uint32 = 0x101 // FWP_V6_ADDR_MASK
// IP protocol numbers.
ipprotoUDP uint8 = 17
ipprotoTCP uint8 = 6
// 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 error codes from winerror.h, needed to tell "there was nothing to clean
// up" apart from "there was, and we could not remove it" - which is the lockout
// condition itself and must not be logged as success.
// See: https://learn.microsoft.com/en-us/windows/win32/fwp/wfp-error-codes
//
// FWP_E_SUBLAYER_NOT_FOUND: the sub-layer does not exist.
fwpESubLayerNotFound uintptr = 0x80320007
// FWP_E_IN_USE: the object is referenced by other objects, so it cannot be
// deleted. Whether a sublayer's own filters count as such references is not
// documented - see the UNRESOLVED note on cleanupStaleDNSInterceptState. It is
// handled because if it does occur, deleting the sublayer alone did not clear the
// orphaned enforcement.
fwpEInUse uintptr = 0x8032000A
// FWP_E_DYNAMIC_SESSION_IN_PROGRESS: the call is not allowed from within a
// dynamic session. Returned when deleting an object that was NOT added in a
// dynamic session - i.e. exactly the stale objects this cleanup targets.
fwpEDynamicSessionInProgress uintptr = 0x8032000B
// FWP_E_WRONG_SESSION: the call was made from the wrong session. Returned when
// deleting an object added by a *different* dynamic session, i.e. one owned by
// another live ctrld.
fwpEWrongSession uintptr = 0x8032000C
// fwpmSessionFlagDynamic is FWPM_SESSION_FLAG_DYNAMIC from fwpmtypes.h.
//
// Every WFP object added through a dynamic session is owned by that session and
// is deleted by the OS when the engine handle closes - including when the process
// exits, crashes, or is killed. ctrld relies on this so its filters can never
// outlive the process that installed them.
//
// This matters most in Firewall Mode: its block-all filters are machine-wide, so
// an orphaned set silently denies outbound traffic for every process on the host
// (browsers, other users, even a replacement ctrld's own API bootstrap) until a
// reboot. Session-scoped ownership makes the OS clean that up for us instead of
// depending on ctrld reaching its own shutdown or startup cleanup path.
// See: https://learn.microsoft.com/en-us/windows/win32/api/fwpmtypes/ns-fwpmtypes-fwpm_session0
fwpmSessionFlagDynamic uint32 = 0x00000001
)
// WFP API structures. These mirror the C structures from fwpmtypes.h and fwptypes.h.
// We define them here because golang.org/x/sys/windows doesn't include WFP types.
//
// IMPORTANT: These struct layouts must match the C ABI exactly (64-bit Windows).
// Field alignment and padding are critical. Any mismatch will cause access violations
// or silent corruption. The layouts below are for AMD64 only.
// If issues arise, verify against the Windows SDK headers with offsetof() checks.
// fwpmSession0 represents FWPM_SESSION0 for opening a WFP engine handle.
type fwpmSession0 struct {
sessionKey windows.GUID
displayData fwpmDisplayData0
flags uint32
txnWaitTimeoutInMSec uint32
processId uint32
sid *windows.SID
username *uint16
kernelMode int32 // Windows BOOL is int32, not Go bool
_ [4]byte // padding to next 8-byte boundary
}
// fwpmDisplayData0 represents FWPM_DISPLAY_DATA0 for naming WFP objects.
type fwpmDisplayData0 struct {
name *uint16
description *uint16
}
// fwpmSublayer0 represents FWPM_SUBLAYER0 for creating a WFP sublayer.
type fwpmSublayer0 struct {
subLayerKey windows.GUID
displayData fwpmDisplayData0
flags uint32
_ [4]byte // padding
providerKey *windows.GUID
providerData fwpByteBlob
weight uint16
_ [6]byte // padding
}
// fwpByteBlob represents FWP_BYTE_BLOB for raw data blobs.
type fwpByteBlob struct {
size uint32
_ [4]byte // padding
data *byte
}
// fwpmFilter0 represents FWPM_FILTER0 for adding WFP filters.
type fwpmFilter0 struct {
filterKey windows.GUID
displayData fwpmDisplayData0
flags uint32
_ [4]byte // padding
providerKey *windows.GUID
providerData fwpByteBlob
layerKey windows.GUID
subLayerKey windows.GUID
weight fwpValue0
numFilterConds uint32
_ [4]byte // padding
filterCondition *fwpmFilterCondition0
action fwpmAction0
// After action is a union of UINT64 (rawContext) and GUID (providerContextKey).
// GUID is 16 bytes, UINT64 is 8 bytes. Union size = 16 bytes.
rawContext uint64 // first 8 bytes of the union
_rawContextPad uint64 // remaining 8 bytes (unused, for GUID alignment)
reserved *windows.GUID
filterId uint64
effectiveWeight fwpValue0
}
// fwpValue0 represents FWP_VALUE0, a tagged union for filter weights and values.
type fwpValue0 struct {
valueType uint32
_ [4]byte // padding
value uint64 // union: uint8/uint16/uint32/uint64/pointer
}
// fwpmFilterCondition0 represents FWPM_FILTER_CONDITION0 for filter match conditions.
type fwpmFilterCondition0 struct {
fieldKey windows.GUID
matchType uint32
_ [4]byte // padding
condValue fwpConditionValue0
}
// fwpConditionValue0 represents FWP_CONDITION_VALUE0, the value to match against.
type fwpConditionValue0 struct {
valueType uint32
_ [4]byte // padding
value uint64 // union
}
// fwpV4AddrAndMask represents FWP_V4_ADDR_AND_MASK for subnet matching.
// Both addr and mask are in host byte order.
type fwpV4AddrAndMask struct {
addr uint32
mask uint32
}
// fwpV6AddrAndMask represents FWP_V6_ADDR_AND_MASK for IPv6 subnet matching.
type fwpV6AddrAndMask struct {
addr [16]byte
prefixLength uint8
}
// fwpmAction0 represents FWPM_ACTION0 for specifying what happens on match.
// Size: 20 bytes (uint32 + GUID). No padding needed — GUID has 4-byte alignment.
type fwpmAction0 struct {
actionType uint32
filterType windows.GUID // union: filterType or calloutKey
}
type nrptRuleOwner uint8
const (
nrptRuleOwnerNone nrptRuleOwner = iota
nrptRuleOwnerCtrld
nrptRuleOwnerGroupPolicy
)
// wfpState holds the state of the WFP DNS interception filters.
// It tracks the engine handle and all filter IDs for cleanup on shutdown.
// All filter IDs are stored so we can remove them individually without
// needing to enumerate the sublayer's filters via WFP API.
//
// In "dns" mode, engineHandle is 0 (no WFP filters) and only NRPT is active.
// In "hard" mode, both NRPT and WFP filters are active.
//
// The engine handle is opened once at startup and kept for the lifetime
// of the ctrld process. Filter additions/removals happen through this handle.
type wfpState struct {
engineHandle uintptr
filterIDv4UDP uint64
filterIDv4TCP uint64
filterIDv6UDP uint64
filterIDv6TCP uint64
// Permit filter IDs for localhost traffic (prevent blocking ctrld's own listener).
permitIDv4UDP uint64
permitIDv4TCP uint64
permitIDv6UDP uint64
permitIDv6TCP uint64
// Dynamic permit filter IDs for VPN DNS server IPs.
vpnPermitFilterIDs []uint64
// Static permit filter IDs for RFC1918/CGNAT subnet ranges.
// These allow VPN DNS servers on private IPs to work without dynamic exemptions.
subnetPermitFilterIDs []uint64
// nrptOwner distinguishes a ctrld-created rule from a GP rule that ctrld is
// only observing. Shutdown and recovery must never delete the latter.
nrptOwner nrptRuleOwner
// externalGPRuleName is the GP child key currently routing the catch-all to
// listenerIP. It is diagnostic identity, not an ownership claim.
externalGPRuleName string
// listenerIP is the actual IP address ctrld is listening on (e.g., "127.0.0.1"
// or "127.0.0.2" on AD DC). Used by NRPT rule creation and health monitor to
// ensure NRPT points to the correct address.
listenerIP string
// stopCh is used to shut down the NRPT health monitor goroutine.
stopCh chan struct{}
// mu protects NRPT ownership, externalGPRuleName, loopbackProtectActive,
// loopbackPermitIDs, and engineHandle from concurrent monitor/recovery/stop use.
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
// nrptRecoveryLimiter prevents repeated Windows policy/Dnscache signaling
// when another agent keeps putting NRPT back into a broken state.
nrptRecoveryLimiter nrptRecoveryLimiter
// handbackAttempts records, per external rule name, when ctrld last removed its own
// catch-all to test whether that rule routes on its own. Protected by mu.
//
// It is a map rather than one (rule, time) pair because Group Policy can alternate
// between two rule names: with a single slot each swap erases the memory of the other
// one, and every swap costs another removal of the live rule.
handbackAttempts map[string]time.Time
}
// handbackAllowed reports whether ctrld may test external rule ruleName again, without
// recording anything.
//
// Each attempt takes ctrld's rule out of the way for a probe, so a rule that never routes
// would otherwise cost a brief DNS outage on every health tick - in hard mode a window
// where WFP blocks DNS with nothing redirecting it. A different rule name means the
// administrator changed policy, which is worth testing immediately; the same name is held
// off for minInterval.
func (s *wfpState) handbackAllowed(now time.Time, ruleName string, minInterval time.Duration) bool {
s.mu.Lock()
defer s.mu.Unlock()
last, ok := s.handbackAttempts[ruleName]
return !ok || now.Sub(last) >= minInterval
}
// recordHandbackAttempt spends ruleName's budget. Callers record only once an attempt is
// actually about to disturb NRPT, so a cheap abort - a pre-probe that shows nothing is
// routing - does not cost the rule its next 15 minutes.
func (s *wfpState) recordHandbackAttempt(now time.Time, ruleName string, minInterval time.Duration) {
s.mu.Lock()
defer s.mu.Unlock()
if s.handbackAttempts == nil {
s.handbackAttempts = make(map[string]time.Time, 2)
}
// Drop entries whose window has passed, so a churning GP store cannot grow this map.
for rule, at := range s.handbackAttempts {
if now.Sub(at) >= minInterval {
delete(s.handbackAttempts, rule)
}
}
s.handbackAttempts[ruleName] = now
}
func (s *wfpState) nrptPolicyOwner() (nrptRuleOwner, string) {
s.mu.Lock()
defer s.mu.Unlock()
return s.nrptOwner, s.externalGPRuleName
}
func (s *wfpState) setNRPTPolicyOwner(owner nrptRuleOwner, externalGPRuleName string) {
s.mu.Lock()
defer s.mu.Unlock()
s.nrptOwner = owner
s.externalGPRuleName = externalGPRuleName
}
// Lazy-loaded WFP DLL procedures.
var (
fwpuclntDLL = windows.NewLazySystemDLL("fwpuclnt.dll")
procFwpmEngineOpen0 = fwpuclntDLL.NewProc("FwpmEngineOpen0")
procFwpmEngineClose0 = fwpuclntDLL.NewProc("FwpmEngineClose0")
procFwpmSubLayerAdd0 = fwpuclntDLL.NewProc("FwpmSubLayerAdd0")
procFwpmSubLayerDeleteByKey0 = fwpuclntDLL.NewProc("FwpmSubLayerDeleteByKey0")
procFwpmFilterAdd0 = fwpuclntDLL.NewProc("FwpmFilterAdd0")
procFwpmFilterDeleteById0 = fwpuclntDLL.NewProc("FwpmFilterDeleteById0")
procFwpmSubLayerGetByKey0 = fwpuclntDLL.NewProc("FwpmSubLayerGetByKey0")
procFwpmFreeMemory0 = fwpuclntDLL.NewProc("FwpmFreeMemory0")
)
// Lazy-loaded dnsapi.dll for flushing the DNS Client cache after NRPT changes.
var (
dnsapiDLL = windows.NewLazySystemDLL("dnsapi.dll")
procDnsFlushResolverCache = dnsapiDLL.NewProc("DnsFlushResolverCache")
)
// Lazy-loaded userenv.dll for triggering Group Policy refresh so DNS Client
// picks up new NRPT registry entries without waiting for the next GP cycle.
var (
userenvDLL = windows.NewLazySystemDLL("userenv.dll")
procRefreshPolicyEx = userenvDLL.NewProc("RefreshPolicyEx")
)
// NRPT (Name Resolution Policy Table) Registry Constants
//
// NRPT tells the Windows DNS Client service where to send queries for specific
// namespaces. We add a catch-all rule ("." matches everything) that directs all
// DNS queries to ctrld's listener (typically 127.0.0.1, but may be 127.0.0.x on AD DC).
//
// This complements the WFP block filters:
// - NRPT: tells Windows DNS Client to send queries to ctrld (positive routing)
// - WFP: blocks any DNS that somehow bypasses NRPT (enforcement backstop)
//
// Without NRPT, WFP blocks outbound DNS but doesn't redirect it — applications
// would just see DNS failures instead of getting answers from ctrld.
const (
// nrptBaseKey is the GP registry path where Windows stores NRPT policy rules.
nrptBaseKey = `SOFTWARE\Policies\Microsoft\Windows NT\DNSClient\DnsPolicyConfig`
// nrptDirectKey is the local service store path. The DNS Client reads NRPT
// from both locations, but on some machines (including stock Win11) it only
// honors the direct path. This is the same path Add-DnsClientNrptRule uses.
nrptDirectKey = `SYSTEM\CurrentControlSet\Services\Dnscache\Parameters\DnsPolicyConfig`
// nrptDirectRuleName is the key name for the direct service store path.
// The DNS Client requires direct-path rules to use GUID-in-braces format.
// Using a plain name like "CtrldCatchAll" makes the rule visible in
// Get-DnsClientNrptRule but DNS Client won't apply it for resolution
// (Get-DnsClientNrptPolicy returns empty). This is a deterministic GUID
// so we can reliably find and clean up our own rule.
nrptDirectRuleName = `{B2E9A3C1-7F4D-4A8E-9D6B-5C1E0F3A2B8D}`
)
func (p *prog) nrptListenerIP() string {
listenerIP := "127.0.0.1"
if lc := p.cfg.FirstListener(); lc != nil && lc.IP != "" && lc.IP != "0.0.0.0" && lc.IP != "::" {
listenerIP = lc.IP
}
return listenerIP
}
// skipInitialDNSReset is the first half of GP-rule adoption. postRun normally
// resets adapter DNS before setDNS starts intercept mode; doing that first would
// violate externally managed policy even if startDNSIntercept adopted the GP rule
// a few milliseconds later. This is only a read-only candidate check. The rule is
// not trusted until a DNS Client probe reaches the listener and the same child is
// re-read by startDNSIntercept.
func (p *prog) skipInitialDNSReset() bool {
mode := p.configuredInterceptMode()
if mode != "dns" && mode != "hard" {
return false
}
if ruleName := findMatchingGPNRPTRule(p.nrptListenerIP()); ruleName != "" {
mainLog.Load().Info().Str("rule", ruleName).
Msg("DNS intercept: matching GP-managed NRPT candidate found - preserving adapter DNS until functional verification")
return true
}
return false
}
// findMatchingGPNRPTRule returns the first non-ctrld GP child that is exactly a
// catch-all for listenerIP. Multiple namespaces or nameservers are deliberately
// rejected: ctrld must not infer exclusive routing from a broader policy shape.
func findMatchingGPNRPTRule(listenerIP string) string {
parent, err := registry.OpenKey(registry.LOCAL_MACHINE, nrptBaseKey, registry.ENUMERATE_SUB_KEYS)
if err != nil {
return ""
}
names, err := parent.ReadSubKeyNames(-1)
parent.Close()
if err != nil {
return ""
}
for _, name := range names {
if gpNRPTRuleMatches(name, listenerIP) {
return name
}
}
return ""
}
func gpNRPTRuleMatches(ruleName, listenerIP string) bool {
namespaces, dnsServers, ok := readGPNRPTRule(ruleName)
return ok && isMatchingGPNRPTRule(ruleName, namespaces, dnsServers, listenerIP)
}
func readGPNRPTRule(ruleName string) ([]string, string, bool) {
if ruleName == "" || strings.EqualFold(ruleName, nrptRuleName) {
return nil, "", false
}
key, err := registry.OpenKey(registry.LOCAL_MACHINE, nrptBaseKey+`\`+ruleName, registry.QUERY_VALUE)
if err != nil {
return nil, "", false
}
defer key.Close()
namespaces, _, err := key.GetStringsValue("Name")
if err != nil {
return nil, "", false
}
dnsServers, _, err := key.GetStringValue("GenericDNSServers")
if err != nil {
// A malformed external catch-all is still authoritative enough to block
// ctrld from creating a second catch-all; it simply cannot be adopted.
dnsServers = ""
}
return namespaces, dnsServers, true
}
// findConflictingGPCatchAll reports an administrator-owned catch-all that no
// longer targets ctrld. Adding another GP catch-all beside it would create the
// same ambiguous policy class as a competing local-store rule, so callers leave
// policy untouched and wait for the administrator to restore/remove it.
func findConflictingGPCatchAll(listenerIP string) (string, string) {
parent, err := registry.OpenKey(registry.LOCAL_MACHINE, nrptBaseKey, registry.ENUMERATE_SUB_KEYS)
if err != nil {
return "", ""
}
names, err := parent.ReadSubKeyNames(-1)
parent.Close()
if err != nil {
return "", ""
}
for _, name := range names {
namespaces, dnsServers, ok := readGPNRPTRule(name)
if !ok || !isExternalGPCatchAll(name, namespaces) {
continue
}
if !isMatchingGPNRPTRule(name, namespaces, dnsServers, listenerIP) {
return name, dnsServers
}
}
return "", ""
}
// addNRPTCatchAllRule creates an NRPT catch-all rule that directs all DNS queries
// to the specified listener IP.
//
// Windows NRPT has two registry paths with all-or-nothing precedence:
// - GP path: SOFTWARE\Policies\...\DnsPolicyConfig (Group Policy)
// - Local path: SYSTEM\CurrentControlSet\...\DnsPolicyConfig (service store)
//
// If the GP path contains real rules (from IT policy, VPN, MDM, etc.), DNS
// Client enters "GP mode" and ignores ALL local-path rules entirely. An empty GP
// parent key is worse: it still puts DNS Client in GP mode, but contributes no
// usable rule, so our local catch-all is hidden until that empty parent is gone.
//
// Strategy (matching Tailscale's approach):
// - Always write to the local path (baseline for non-domain machines).
// - Check if OTHER software has GP rules. If yes, also write to the GP path
// so our rule isn't invisible. If no, clean our stale GP rules and delete the
// empty GP key to stay in "local mode".
// - After GP writes, call RefreshPolicyEx to activate.
func addNRPTCatchAllRule(listenerIP string) error {
// Always write to local/direct service store path.
if err := writeNRPTRule(nrptDirectKey+`\`+nrptDirectRuleName, listenerIP); err != nil {
return fmt.Errorf("failed to write NRPT local path rule: %w", err)
}
// Check if other software has GP NRPT rules. If so, we must also write
// to the GP path — otherwise DNS Client's "GP mode" hides our local rule.
if otherGPRulesExist() {
mainLog.Load().Info().Msg("DNS intercept: other GP NRPT rules detected — also writing to GP path")
if err := writeNRPTRule(nrptBaseKey+`\`+nrptRuleName, listenerIP); err != nil {
mainLog.Load().Warn().Err(err).Msg("DNS intercept: failed to write NRPT GP rule (local rule still active if GP clears)")
}
} else {
// No other GP rules — clean our stale GP entry and delete the empty
// GP parent key so DNS Client stays in "local mode".
cleanGPPath()
}
return nil
}
// otherGPRulesExist checks if non-ctrld NRPT rules exist in the GP path.
// When other software (IT policy, VPN, MDM) has GP rules, DNS Client enters
// "GP mode" and ignores ALL local-path rules.
func otherGPRulesExist() bool {
k, err := registry.OpenKey(registry.LOCAL_MACHINE, nrptBaseKey, registry.ENUMERATE_SUB_KEYS)
if err != nil {
return false // GP key doesn't exist — no GP rules.
}
names, err := k.ReadSubKeyNames(-1)
k.Close()
if err != nil {
return false
}
for _, name := range names {
if name != nrptRuleName { // Not our CtrldCatchAll
return true
}
}
return false
}
// cleanGPPath removes only ctrld's GP-path rule and deletes the GP parent when
// no rules remain. The return value tells callers whether the parent key was
// actually deleted, which means DNS Client should be signaled once.
//
// Do not leave an empty GP parent behind: Windows treats the parent key itself
// as the policy store boundary, so an empty key can still hide local-path rules.
func cleanGPPath() bool {
// Delete our specific rule.
registry.DeleteKey(registry.LOCAL_MACHINE, nrptBaseKey+`\`+nrptRuleName)
// If the GP parent key is now empty, delete it entirely to exit "GP mode".
k, err := registry.OpenKey(registry.LOCAL_MACHINE, nrptBaseKey, registry.ENUMERATE_SUB_KEYS)
if err != nil {
return false // Key doesn't exist — clean state.
}
names, err := k.ReadSubKeyNames(-1)
k.Close()
if err != nil || len(names) > 0 {
if len(names) > 0 {
mainLog.Load().Debug().Strs("remaining", names).Msg("DNS intercept: GP path has other rules, leaving parent key")
}
return false
}
// Empty — delete it to exit "GP mode".
if err := registry.DeleteKey(registry.LOCAL_MACHINE, nrptBaseKey); err == nil {
mainLog.Load().Info().Msg("DNS intercept: deleted empty GP DnsPolicyConfig key (exits GP mode)")
return true
}
return false
}
// writeNRPTRule writes a single NRPT catch-all rule at the given registry keyPath.
func writeNRPTRule(keyPath, listenerIP string) error {
k, _, err := registry.CreateKey(registry.LOCAL_MACHINE, keyPath, registry.SET_VALUE)
if err != nil {
return fmt.Errorf("failed to create NRPT registry key %q: %w", keyPath, err)
}
defer k.Close()
// Name (REG_MULTI_SZ): namespace patterns to match. "." = catch-all.
if err := k.SetStringsValue("Name", []string{"."}); err != nil {
return fmt.Errorf("failed to set NRPT Name value: %w", err)
}
// GenericDNSServers (REG_SZ): DNS server(s) to use for matching queries.
if err := k.SetStringValue("GenericDNSServers", listenerIP); err != nil {
return fmt.Errorf("failed to set NRPT GenericDNSServers value: %w", err)
}
// ConfigOptions (REG_DWORD): 0x8 = use standard DNS resolution (no DirectAccess).
if err := k.SetDWordValue("ConfigOptions", 0x8); err != nil {
return fmt.Errorf("failed to set NRPT ConfigOptions value: %w", err)
}
// Version (REG_DWORD): 0x2 = NRPT rule version 2.
if err := k.SetDWordValue("Version", 0x2); err != nil {
return fmt.Errorf("failed to set NRPT Version value: %w", err)
}
// Match the exact fields Add-DnsClientNrptRule creates. The DNS Client CIM
// provider writes these as empty strings; their absence may cause the service
// to skip the rule on some Windows builds.
k.SetStringValue("Comment", "")
k.SetStringValue("DisplayName", "")
k.SetStringValue("IPSECCARestriction", "")
return nil
}
// removeNRPTCatchAllRule deletes the ctrld NRPT catch-all registry key and
// cleans up the empty parent key if no other NRPT rules remain.
//
// The empty parent cleanup is critical: an empty DnsPolicyConfig key causes
// DNS Client to cache a "no rules" state. On next start, DNS Client ignores
// newly written rules because it still has the cached empty state. By deleting
// the empty parent on stop, we ensure a clean slate for the next start.
func removeNRPTCatchAllRule() error {
// Remove our GUID-named rule from local/direct path.
if err := registry.DeleteKey(registry.LOCAL_MACHINE, nrptDirectKey+`\`+nrptDirectRuleName); err != nil {
if err != registry.ErrNotExist {
return fmt.Errorf("failed to delete NRPT local rule: %w", err)
}
}
deleteEmptyParentKey(nrptDirectKey)
// Clean up legacy rules from earlier builds (plain name in direct path, GP path rules).
registry.DeleteKey(registry.LOCAL_MACHINE, nrptDirectKey+`\`+nrptRuleName)
cleanGPPath()
return nil
}
// deleteEmptyParentKey removes a registry key if it exists but has no subkeys.
func deleteEmptyParentKey(keyPath string) {
k, err := registry.OpenKey(registry.LOCAL_MACHINE, keyPath, registry.ENUMERATE_SUB_KEYS)
if err != nil {
return
}
names, err := k.ReadSubKeyNames(-1)
k.Close()
if err != nil || len(names) > 0 {
return
}
registry.DeleteKey(registry.LOCAL_MACHINE, keyPath)
}
// nrptCatchAllRuleExists checks whether our NRPT catch-all rule exists
// in either the local or GP path.
func nrptCatchAllRuleExists() bool {
for _, path := range []string{
nrptDirectKey + `\` + nrptDirectRuleName,
nrptBaseKey + `\` + nrptRuleName,
} {
k, err := registry.OpenKey(registry.LOCAL_MACHINE, path, registry.QUERY_VALUE)
if err == nil {
k.Close()
return true
}
}
return false
}
// refreshNRPTPolicy triggers a machine Group Policy refresh so the DNS Client
// service picks up new/changed NRPT registry entries immediately. Without this,
// NRPT changes only take effect on the next GP cycle (default: 90 minutes).
//
// Uses RefreshPolicyEx(bMachine=TRUE, dwOptions=RP_FORCE=1) from userenv.dll.
// See: https://learn.microsoft.com/en-us/windows/win32/api/userenv/nf-userenv-refreshpolicyex
// nrptSignalExecTimeout bounds every helper process the NRPT signalling path shells out
// to. These run while a transition holds nrptTransitionMu, and a service stop takes that
// same lock: "gpupdate /force" against a slow or unreachable domain controller can take
// tens of seconds, which is exactly the Service Control Manager timeout the locking exists
// to avoid. A signal that cannot finish in this window has already failed as a nudge.
const nrptSignalExecTimeout = 10 * time.Second
// runBoundedNRPTExec runs one signalling helper with a hard deadline and reports its
// combined output.
func runBoundedNRPTExec(name string, args ...string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), nrptSignalExecTimeout)
defer cancel()
out, err := exec.CommandContext(ctx, name, args...).CombinedOutput()
if ctx.Err() != nil {
mainLog.Load().Warn().Str("command", name).Str("timeout", nrptSignalExecTimeout.String()).
Msg("DNS intercept: NRPT signalling helper timed out and was killed")
}
return out, err
}
func runGPUpdate() {
if out, err := runBoundedNRPTExec("gpupdate", "/target:computer", "/force"); err != nil {
mainLog.Load().Debug().Msgf("DNS intercept: gpupdate failed: %v: %s", err, string(out))
} else {
mainLog.Load().Debug().Msg("DNS intercept: triggered GP refresh via gpupdate")
}
}
func refreshNRPTPolicy() {
if err := userenvDLL.Load(); err != nil {
mainLog.Load().Debug().Err(err).Msg("DNS intercept: userenv.dll not available, falling back to gpupdate")
runGPUpdate()
return
}
if err := procRefreshPolicyEx.Find(); err != nil {
mainLog.Load().Debug().Err(err).Msg("DNS intercept: RefreshPolicyEx not found, falling back to gpupdate")
runGPUpdate()
return
}
// RefreshPolicyEx(BOOL bMachine, DWORD dwOptions)
// bMachine=1 (TRUE) = refresh computer policy, dwOptions=1 (RP_FORCE) = force refresh.
// This one only asks the policy engine to refresh and returns; it does not wait for a
// domain controller, which is why it is preferred over gpupdate.
ret, _, _ := procRefreshPolicyEx.Call(1, 1)
if ret != 0 {
mainLog.Load().Debug().Msg("DNS intercept: triggered machine GP refresh via RefreshPolicyEx")
} else {
mainLog.Load().Debug().Msg("DNS intercept: RefreshPolicyEx returned FALSE, falling back to gpupdate")
runGPUpdate()
}
}
// flushDNSCache flushes the Windows DNS Client resolver cache and triggers a
// Group Policy refresh so NRPT changes take effect immediately.
func flushDNSCache() {
refreshNRPTPolicy()
flushDNSCacheOnly()
}
func flushDNSCacheOnly() {
if err := dnsapiDLL.Load(); err == nil {
if err := procDnsFlushResolverCache.Find(); err == nil {
ret, _, _ := procDnsFlushResolverCache.Call()
if ret != 0 {
mainLog.Load().Debug().Msg("DNS intercept: flushed DNS resolver cache via DnsFlushResolverCache")
return
}
}
}
if out, err := runBoundedNRPTExec("ipconfig", "/flushdns"); err != nil {
mainLog.Load().Debug().Msgf("DNS intercept: ipconfig /flushdns failed: %v: %s", err, string(out))
} else {
mainLog.Load().Debug().Msg("DNS intercept: flushed DNS resolver cache via ipconfig /flushdns")
}
}
func signalNRPTChange() {
refreshNRPTPolicy()
sendParamChange()
flushDNSCacheOnly()
}
// sendParamChange sends SERVICE_CONTROL_PARAMCHANGE to the DNS Client (Dnscache)
// service, signaling it to re-read its configuration including NRPT rules from
// the registry. This is the standard mechanism used by FortiClient, Tailscale,
// and other DNS-aware software — it's reliable and non-disruptive unlike
// restarting the Dnscache service (which always fails on modern Windows because
// Dnscache is a protected shared svchost service).
func sendParamChange() {
if out, err := runBoundedNRPTExec("sc", "control", "dnscache", "paramchange"); err != nil {
mainLog.Load().Debug().Err(err).Str("output", string(out)).Msg("DNS intercept: sc control dnscache paramchange failed")
} else {
mainLog.Load().Debug().Msg("DNS intercept: sent paramchange to Dnscache service")
}
}
// cleanEmptyNRPTParent removes empty NRPT parent keys that block activation.
// Empty GP and local parents have different failure shapes:
// - empty GP parent: DNS Client is in GP mode and ignores local-path rules;
// - empty local parent: DNS Client can cache an empty local policy store.
//
// This helper only changes registry state. The caller sends the single
// RefreshPolicyEx/paramchange/flush signal after it knows cleanup occurred.
//
// Returns true if cleanup was performed (caller should signal DNS Client).
func cleanEmptyNRPTParent() bool {
// Always clean the GP path — its existence blocks local path activation.
cleaned := cleanGPPath()
// Clean empty local/direct path parent key.
if !nrptParentKeyEmpty(nrptDirectKey) {
return cleaned
}
mainLog.Load().Warn().Msg("DNS intercept: found empty NRPT local parent key (blocks activation) — removing")
if err := registry.DeleteKey(registry.LOCAL_MACHINE, nrptDirectKey); err != nil {
mainLog.Load().Warn().Err(err).Msg("DNS intercept: failed to delete empty NRPT local parent key")
return cleaned
}
return true
}
func nrptParentKeyEmpty(keyPath string) bool {
k, err := registry.OpenKey(registry.LOCAL_MACHINE, keyPath, registry.ENUMERATE_SUB_KEYS)
if err != nil {
return false
}
names, err := k.ReadSubKeyNames(-1)
k.Close()
return err == nil && len(names) == 0
}
// logNRPTParentKeyState logs the state of both NRPT registry paths for diagnostics.
func logNRPTParentKeyState(context string) {
for _, path := range []struct {
name string
key string
}{
{"GP", nrptBaseKey},
{"local", nrptDirectKey},
} {
k, err := registry.OpenKey(registry.LOCAL_MACHINE, path.key, registry.ENUMERATE_SUB_KEYS)
if err != nil {
mainLog.Load().Debug().Str("context", context).Str("path", path.name).
Msg("DNS intercept: NRPT parent key does not exist")
continue
}
names, err := k.ReadSubKeyNames(-1)
k.Close()
if err != nil {
continue
}
if len(names) == 0 {
mainLog.Load().Warn().Str("context", context).Str("path", path.name).
Msg("DNS intercept: NRPT parent key exists but is EMPTY — blocks activation")
} else {
mainLog.Load().Debug().Str("context", context).Str("path", path.name).
Int("subkeys", len(names)).Strs("names", names).
Msg("DNS intercept: NRPT parent key state")
}
}
}
// startDNSIntercept activates WFP-based DNS interception on Windows.
// It creates a WFP sublayer and adds filters that block all outbound DNS (port 53)
// traffic except to localhost (127.0.0.1/::1), ensuring all DNS queries must go
// through ctrld's local listener. This eliminates the race condition with VPN
// software that overwrites interface DNS settings.
//
// The approach:
// 1. Permit outbound DNS to 127.0.0.1/::1 (ctrld's listener)
// 2. Block all other outbound DNS (port 53 UDP+TCP)
//
// This means even if a VPN overwrites DNS settings to its own servers,
// the OS cannot reach those servers on port 53 — queries fail and fall back
// to ctrld via the loopback address.
func (p *prog) startDNSIntercept() error {
p.dnsInterceptMu.Lock()
defer p.dnsInterceptMu.Unlock()
return p.startDNSInterceptLocked()
}
// startDNSInterceptLocked is startDNSIntercept with p.dnsInterceptMu already held, so
// the rebuild path can make teardown and re-create one atomic transition.
//
// Nothing it calls may take p.dnsInterceptMu. It runs nrptProbeAndHeal synchronously,
// and that whole family - nrptProbeAndHeal, activateCtrldNRPTFallback,
// tryAdoptMatchingGPNRPT - stays lock-free on purpose and uses interceptStateRevoked
// instead: those flows wait seconds between probes, and holding the lifecycle lock
// across them would make a service stop wait just as long.
func (p *prog) startDNSInterceptLocked() error {
ops := p.nrptOps()
listenerIP := p.nrptListenerIP()
if lc := p.cfg.FirstListener(); lc != nil && (lc.IP == "0.0.0.0" || lc.IP == "::") {
mainLog.Load().Warn().Str("configured_ip", lc.IP).
Msg("DNS intercept: listener configured with wildcard IP, using 127.0.0.1 for NRPT rules")
}
state := &wfpState{
stopCh: make(chan struct{}),
listenerIP: listenerIP,
}
// The probe and heal flows take state as an argument rather than reading the
// published field, so nothing is published until startup has fully succeeded.
// Publishing a provisional state would expose a half-built wfpState - no engine
// handle yet, filter IDs still being assigned - to exemptVPNDNSServers, which the
// VPN DNS manager can call at any time.
mainLog.Load().Info().Msgf("DNS intercept: initializing (mode: %s)", interceptMode)
logNRPTParentKeyState("pre-write")
// GP adoption is a two-part proof. Registry shape establishes ownership; the
// DNS Client probe establishes that the policy actually routes to this listener.
// Re-reading the same child after the probe prevents adopting a rule replaced
// during a concurrent Group Policy refresh.
externalProbeOK := false
if ruleName := ops.findGPRule(listenerIP); ruleName != "" {
// Adoption goes through the same handback transition the running service uses.
// A rule left behind by an earlier unclean exit points at this very listener, so
// a probe taken with it still installed proves nothing about the GP rule; the
// transition removes ctrld's keys first, and puts them back if the GP rule
// cannot carry DNS on its own.
switch p.nrptHandbackToExternal(state, ruleName, "startup GP-managed catch-all candidate") {
case nrptHandbackVerified:
externalProbeOK = true
mainLog.Load().Info().Str("rule", ruleName).Str("listener", listenerIP).
Msg("DNS intercept: adopted working GP-managed NRPT catch-all; ctrld will not modify NRPT policy")
case nrptHandbackUnverified:
mainLog.Load().Warn().Str("rule", ruleName).Str("listener", listenerIP).
Msg("DNS intercept: GP-managed NRPT catch-all is present but the probe did not reach ctrld; leaving external policy untouched")
case nrptHandbackKeptCtrld:
mainLog.Load().Warn().Str("rule", ruleName).Str("listener", listenerIP).
Msg("DNS intercept: GP-managed NRPT catch-all could not carry DNS alone; keeping the ctrld-owned rule from the previous run")
case nrptHandbackConflict:
// The candidate turned out to target another resolver. Startup then refuses to
// write a competing rule below, which is a hard startup failure by design.
mainLog.Load().Error().Str("rule", ruleName).Str("listener", listenerIP).
Msg("DNS intercept: GP catch-all does not target ctrld; refusing to write a competing NRPT rule")
case nrptHandbackAborted:
// Undecided, not decided: nothing was proved about the candidate and no
// ownership was recorded. Say so, because the fall-through below writes
// ctrld's rule, and an unlogged fall-through here is indistinguishable from
// "no external policy exists".
mainLog.Load().Warn().Str("rule", ruleName).Str("listener", listenerIP).
Msg("DNS intercept: GP-managed NRPT candidate could not be tested at startup; continuing without external ownership")
}
}
owner, _ := state.nrptPolicyOwner()
if owner == nrptRuleOwnerNone {
// No working external ownership contract exists. Preserve the current ctrld
// path unless another GP catch-all already owns the namespace; writing a
// second catch-all would create an ambiguous policy rather than recovery.
if gpCatchAllConflictBlocksFallback(state, "startup GP catch-all does not target ctrld") {
return fmt.Errorf("dns intercept: conflicting GP NRPT catch-all targets another resolver")
}
// A matching candidate that is still there means the handback above came back
// undecided - typically because the pre-probe ran while the DNS Client was still
// settling at boot. Give it one more pass before writing anything: the DNS Client
// has had the startup work since, and a decision here avoids writing beside an
// administrator rule that no probe has tested.
if ruleName := ops.findGPRule(listenerIP); ruleName != "" {
switch p.nrptHandbackToExternal(state, ruleName, "startup retry of an undecided GP candidate") {
case nrptHandbackVerified:
externalProbeOK = true
case nrptHandbackUnverified, nrptHandbackConflict, nrptHandbackKeptCtrld:
// Ownership is recorded by the transition (or ctrld's own rule was put
// back), so the write below is neither needed nor safe.
}
}
}
owner, _ = state.nrptPolicyOwner()
if owner == nrptRuleOwnerNone {
if ops.ruleExists() {
// A rule from an earlier run already points at this listener. Adopt it rather
// than writing a second time: with a GP child present, addNRPTCatchAllRule
// would also write ctrld's GP-path sibling.
state.setNRPTPolicyOwner(nrptRuleOwnerCtrld, "")
mainLog.Load().Info().Str("listener", listenerIP).
Msg("DNS intercept: adopting the ctrld NRPT catch-all already present from an earlier run")
} else {
if ops.cleanParent() {
ops.signal()
}
if err := ops.addRule(listenerIP); err != nil {
return fmt.Errorf("dns intercept: failed to add NRPT catch-all rule: %w", err)
}
logNRPTParentKeyState("post-write")
state.setNRPTPolicyOwner(nrptRuleOwnerCtrld, "")
ops.signal()
mainLog.Load().Info().Msgf("DNS intercept: NRPT catch-all rule active - all DNS queries directed to %s", listenerIP)
}
}
// In hard mode, also set up WFP filters to block non-local DNS.
if hardIntercept {
if err := ops.startWFP(state); err != nil {
owner, _ := state.nrptPolicyOwner()
if owner == nrptRuleOwnerGroupPolicy && externalProbeOK {
// A GP rule the probe proved is routing keeps DNS flowing through ctrld
// even with no WFP filters, and rewriting adapter DNS would violate that
// external policy - so this is not a fall-back-to-adapter-DNS failure.
//
// It is still a hard-mode enforcement gap: with no block filters, raw DNS
// to a public resolver, DoH clients and apps with their own resolver are
// not filtered at all. Publish the state and start the health monitor so
// repairMissingWFP keeps retrying WFP instead of the process running
// unenforced for its whole life on one error line. Ownership stays with
// Group Policy so the monitor never writes NRPT policy here.
mainLog.Load().Error().Err(err).
Msg("DNS intercept: WFP setup failed while GP-managed NRPT is verified routing - DNS resolves through ctrld but hard-mode enforcement is OFF; retrying WFP in the background")
p.dnsInterceptState = state
go p.nrptHealthMonitor(state)
// The service start is still reported as failed (setDnsOK stays false in
// setDNS): a hard-mode process with no block filters must not read as a
// healthy start, even though name resolution works.
return fmt.Errorf("dns intercept: WFP setup failed: %w: %w", err, errGPNRPTVerified)
}
if owner == nrptRuleOwnerCtrld {
mainLog.Load().Error().Err(err).Msg("DNS intercept: WFP setup failed, rolling back ctrld-owned NRPT")
_ = ops.removeRule()
ops.flush()
} else {
mainLog.Load().Error().Err(err).Msg("DNS intercept: WFP setup failed; leaving GP-managed NRPT untouched")
}
state.setNRPTPolicyOwner(nrptRuleOwnerNone, "")
return fmt.Errorf("dns intercept: WFP setup failed: %w", err)
}
} 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 := ops.loopback(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")
}
}
owner, externalRuleName := state.nrptPolicyOwner()
if owner == nrptRuleOwnerGroupPolicy && !externalProbeOK {
// The first probe ran before loopback WFP protection existed. Verify once
// more synchronously after WFP setup so service readiness does not race an
// async proof; this path is ownership-aware and never mutates GP NRPT.
externalProbeOK = p.nrptProbeAndHeal(state)
}
// Everything the host needs is in place: publish, then start the goroutines that
// keep it that way. They receive state directly, so this ordering is only about
// when the rest of ctrld may observe intercept mode as active.
p.dnsInterceptState = state
go p.nrptHealthMonitor(state)
owner, _ = state.nrptPolicyOwner()
if owner == nrptRuleOwnerCtrld {
// ctrld-owned policy keeps the existing asynchronous activation/heal path.
go p.nrptProbeAndHeal(state)
}
if owner == nrptRuleOwnerGroupPolicy && !externalProbeOK {
// External policy owns the namespace and neither synchronous proof reached ctrld.
// The recovery state and monitor stay up - the rule may start routing once the DNS
// Client settles, and only external policy may fix it - but this start is not
// ready: the DNS Client is not delivering queries to ctrld, adapter DNS was
// deliberately preserved, and no owned fallback may be written beside an
// administrator's catch-all. Reporting success here would publish readiness while
// nothing is filtering, and in hard mode WFP is simultaneously blocking every
// other resolver, which is an outage rather than degraded health.
mainLog.Load().Error().Str("rule", externalRuleName).Str("listener", listenerIP).
Msg("DNS intercept: GP-managed NRPT owns the namespace but no probe reached ctrld; leaving adapter DNS untouched and reporting a failed start until a probe succeeds")
return fmt.Errorf("dns intercept: %w (rule %q)", errGPNRPTIneffective, externalRuleName)
}
return nil
}
// removeOrphanedCtrldNRPTRule deletes a ctrld-owned NRPT catch-all that no running
// ctrld is backing. It is safe against external policy: the rule is found by ctrld's
// own deterministic GUID, never by shape.
//
// Without this, one unclean exit can strand a rule that later takes the whole machine
// off DNS. ctrld dies without a stop while it owns policy, so the GUID rule stays in
// the local store pointing at 127.0.0.1. The org then deploys a GP catch-all: from that
// point every start adopts the GP rule and every stop takes the GP branch, so nothing
// ever looks at the local store - including the stop during uninstall. The orphan stays
// invisible, because any rule in the GP store puts the DNS Client in GP mode where the
// local store is ignored entirely. When the admin eventually removes the GP rule -
// most plausibly while cleaning up after uninstalling ctrld - the DNS Client leaves GP
// mode, reads the local store again, and every query on the machine goes to a listener
// that has not existed for months. It is also miserable to diagnose: local-store rules
// do not appear in Get-DnsClientNrptPolicy, so the standard tooling reports no policy
// at all while nothing resolves.
func (p *prog) removeOrphanedCtrldNRPTRule(reason string) {
ops := p.nrptOps()
if !ops.ruleExists() {
return
}
mainLog.Load().Warn().Str("reason", reason).
Msg("DNS intercept: removing orphaned ctrld NRPT catch-all left by an earlier run")
if err := ops.removeRule(); err != nil {
mainLog.Load().Warn().Err(err).Msg("DNS intercept: failed to remove orphaned ctrld NRPT catch-all")
return
}
ops.signal()
}
// cleanupStaleDNSInterceptState removes ctrld-owned WFP objects left behind by a
// previous process. It runs at startup before anything that needs network access.
//
// Filters installed by a ctrld that predates session-scoped ownership (or by any
// process whose session was not dynamic) survive that process's death. In Firewall
// Mode those include machine-wide block-all filters, so the whole host - browsers,
// other users, and a replacement ctrld's own API bootstrap - stays denied outbound
// traffic until a reboot. A replacement that cannot reach the API never finishes
// preflight, so it never reaches the cleanup inside startWFPFilters and the host
// stays locked out. Deleting the sublayer here is the attempt to break that deadlock
// before the first API call.
//
// RELEASE GATE: whether deleting the sublayer is sufficient must be answered on a live
// host that carries real pre-fix orphaned filters - run this binary there and record the
// HRESULT. If it is FWP_E_IN_USE, the delete did not remove the child filters, the block-all
// stays active, and this needs the enumerate-and-delete fallback described below before the
// self-heal can be claimed to work.
//
// UNRESOLVED until then: whether deleting the sublayer is sufficient. It is sufficient only if
// the delete also removes the filters inside it. FwpmSubLayerDeleteByKey0's Remarks
// say nothing about child filters either way, while Object Management states the
// general rule that "an object cannot be deleted until all objects that reference it
// have first been deleted" - and FWP_E_IN_USE exists for exactly that. Whether a
// filter's subLayerKey counts as such a reference is not documented, and this is
// Windows-only syscall code that cannot be exercised off-Windows, so it is not
// asserted here in either direction. What the code does is safe under both readings:
// it attempts the delete and, on FWP_E_IN_USE, says so instead of reporting success,
// so a support log distinguishes "cleared it" from "could not clear it". If a live
// Windows check shows FWP_E_IN_USE against orphaned filters, this needs to enumerate
// and delete them first - which is deliberately not written blind, because
// FWPM_FILTER_ENUM_TEMPLATE0 has no sublayer field, so selecting ctrld's own filters
// means reading subLayerKey at a computed offset in FWPM_FILTER0, and getting that
// offset wrong would delete other software's filters.
//
// Deleting objects owned by a live session is expected to fail; those are cleaned up
// by the OS when that session closes. Every failure is therefore debug-level: no
// stale state is the normal case.
//
// Two guards decide whether this may run at all. Elevation, because opening a WFP engine
// and deleting ctrld's sublayer must not be reachable from an unprivileged local process:
// FwpmEngineOpen0 is expected to fail without elevation, but that is a documented
// property of the API rather than something this code checks, and it is the only barrier.
// And interactive invocation, because a live ctrld built before session-scoped ownership
// holds a *non-dynamic* sublayer - exactly what this targets - so the HRESULTs cannot
// tell that service apart from an orphan. A service start is not interactive, so the
// deadlock case still gets cleaned; a hand-run "ctrld run" alongside a live service does
// not strip its enforcement. That second guard requires positive evidence that nothing is
// live: an SCM that cannot be queried is not an absent service, so an unknown answer skips
// the cleanup exactly as a running one does.
func cleanupStaleDNSInterceptState() {
if elevated, err := staleCleanupElevatedFn(); err != nil || !elevated {
mainLog.Load().Debug().Err(err).Msg("DNS intercept: skipping stale WFP state cleanup - not elevated")
return
}
interactive := staleCleanupInteractiveFn()
liveness := serviceLivenessStopped
if interactive {
liveness = staleCleanupLivenessFn()
}
if !staleCleanupAllowed(interactive, liveness) {
reason := "the ctrld service is running"
if liveness == serviceLivenessUnknown {
reason = "the ctrld service's state could not be determined"
}
mainLog.Load().Info().Msgf("DNS intercept: skipping stale WFP state cleanup - %s and a pre-session-scoped build's sublayer is indistinguishable from an orphan", reason)
return
}
deleteStaleWFPSublayerFn()
}
// Seams for the guard decision and the deletion it protects. The delete is a WFP syscall
// that cannot be run in a test even on Windows - it would remove real filters - so the
// only way to assert that the guard is consulted *before* anything is deleted is to
// substitute both ends.
var (
staleCleanupElevatedFn = hasElevatedPrivilege
staleCleanupInteractiveFn = service.Interactive
staleCleanupLivenessFn = ctrldServiceLiveness
deleteStaleWFPSublayerFn = deleteStaleWFPSublayer
)
// deleteStaleWFPSublayer opens a WFP engine and deletes ctrld's sublayer. Callers must
// have established that no live ctrld owns it; see cleanupStaleDNSInterceptState.
func deleteStaleWFPSublayer() {
var engineHandle uintptr
session := fwpmSession0{}
sessionName, _ := windows.UTF16PtrFromString("ctrld Stale State Cleanup")
session.displayData.name = sessionName
// Deliberately NOT a dynamic session. FwpmSubLayerDeleteByKey0 is documented to
// fail with FWP_E_DYNAMIC_SESSION_IN_PROGRESS when called from a dynamic session
// for an object that was not added in one - and the only orphans that can exist
// are exactly those: a ctrld built before session-scoped ownership added its
// sublayer statically. Anything a *newer* ctrld leaves behind is removed by the OS
// when its session ends, so there is nothing for this to clean there. Opening this
// session dynamically would therefore make the whole cleanup a no-op in the one
// case it exists for.
//
// What protects a live ctrld is documented ownership, not the child-filter question
// above: a session-scoped ctrld's sublayer belongs to a different dynamic session, so
// the delete fails with FWP_E_WRONG_SESSION. A ctrld that predates session scoping has
// no such protection here - it holds a non-dynamic sublayer, indistinguishable from an
// orphan - which is why the caller refuses to run this from an interactive invocation
// unless the service is provably not live. FWP_E_IN_USE may refuse the delete as well,
// but whether it does is the open question, so nothing relies on it.
//
// This session adds no objects, so it needs no automatic teardown of its own.
const rpcCAuthnDefault = 0xFFFFFFFF
r1, _, _ := procFwpmEngineOpen0.Call(
0,
uintptr(rpcCAuthnDefault),
0,
uintptr(unsafe.Pointer(&session)),
uintptr(unsafe.Pointer(&engineHandle)),
)
if r1 != 0 {
mainLog.Load().Debug().Msgf("DNS intercept: could not open WFP engine for stale state cleanup (HRESULT 0x%x)", r1)
return
}
defer procFwpmEngineClose0.Call(engineHandle)
r1, _, _ = procFwpmSubLayerDeleteByKey0.Call(
engineHandle,
uintptr(unsafe.Pointer(&ctrldSubLayerGUID)),
)
switch r1 {
case 0:
// Warn, not info: this means a previous ctrld left machine-wide enforcement
// installed, which is worth seeing in a support log.
mainLog.Load().Warn().Msg("DNS intercept: removed WFP filters left by a previous ctrld process (including any Firewall Mode block-all) before startup")
case fwpESubLayerNotFound:
// The normal case: nothing was left behind.
mainLog.Load().Debug().Msg("DNS intercept: no stale WFP state from a previous process")
default:
// Something is installed under our GUID and we could not remove it. Never
// report this as "nothing to clean up": if it is orphaned enforcement, this is
// the lockout condition, and the operator needs the code to act on.
// FWP_E_WRONG_SESSION means another live ctrld owns it, which is benign.
// FWP_E_IN_USE is the answer the release gate above is waiting for: the delete was
// refused while something still references the sublayer, which would mean the
// enumerate-and-delete fallback is required before this self-heal works at all.
mainLog.Load().Warn().Msgf("DNS intercept: found WFP state under ctrld's sublayer but could not remove it (%s); if ctrld is not already running, enforcement from a previous process may still be active",
wfpDeleteErrString(r1))
}
}
// staleCleanupAllowed reports whether the stale-state cleanup may run.
//
// A service start (not interactive) always may: that is the deadlock this exists to break,
// and nothing else of ctrld's is live at that point in startup.
//
// A hand-run "ctrld run" may only proceed on positive evidence that nothing is live. A
// pre-session-scoped build's sublayer is indistinguishable from an orphan, so cleaning
// while that service runs would strip its enforcement. Unknown is therefore treated like
// running, not like stopped - an SCM that cannot be queried is not an absent service.
func staleCleanupAllowed(interactive bool, liveness serviceLiveness) bool {
if !interactive {
return true
}
return liveness == serviceLivenessStopped
}
// wfpDeleteErrString names the delete failures that carry a specific meaning for stale
// state, so a support log says which case was hit rather than only a raw HRESULT.
func wfpDeleteErrString(r1 uintptr) string {
switch r1 {
case fwpEInUse:
return "FWP_E_IN_USE: refused while the sublayer is still referenced (see the release gate at cleanupStaleDNSInterceptState)"
case fwpEDynamicSessionInProgress:
return "FWP_E_DYNAMIC_SESSION_IN_PROGRESS: cannot delete a non-dynamic object from a dynamic session"
case fwpEWrongSession:
return "FWP_E_WRONG_SESSION: owned by another live session"
default:
return fmt.Sprintf("HRESULT 0x%x", r1)
}
}
// startWFPFilters opens the WFP engine and adds all block/permit filters.
// Called only in hard intercept mode.
func (p *prog) startWFPFilters(state *wfpState) error {
mainLog.Load().Info().Msg("DNS intercept: initializing Windows Filtering Platform (WFP)")
var engineHandle uintptr
session := fwpmSession0{}
sessionName, _ := windows.UTF16PtrFromString("ctrld DNS Intercept")
session.displayData.name = sessionName
// Session-scoped ownership: if this process dies without running its shutdown
// path, Windows removes our filters (including Firewall Mode's machine-wide
// block-all) instead of leaving the host enforced by a dead ctrld.
session.flags = fwpmSessionFlagDynamic
// RPC_C_AUTHN_DEFAULT (0xFFFFFFFF) lets the system pick the appropriate
// authentication service. RPC_C_AUTHN_NONE (0) returns ERROR_NOT_SUPPORTED
// on some Windows configurations (e.g., Parallels VMs).
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 (handle: 0x%x, session-scoped)", engineHandle)
// Clean up a sublayer left over from an earlier session.
//
// Note this runs on the dynamic session opened above, and
// FwpmSubLayerDeleteByKey0 is documented to fail with
// FWP_E_DYNAMIC_SESSION_IN_PROGRESS from a dynamic session when the object was not
// added in one. So this cannot clear state left by a build that predates
// session-scoped ownership - cleanupStaleDNSInterceptState(), which opens a
// non-dynamic session at startup, is what handles that. What remains for this call
// is a leftover from a previous dynamic session whose teardown had not completed
// when we opened ours.
r1, _, _ = procFwpmSubLayerDeleteByKey0.Call(
engineHandle,
uintptr(unsafe.Pointer(&ctrldSubLayerGUID)),
)
if r1 == 0 {
mainLog.Load().Info().Msg("DNS intercept: cleaned up stale WFP sublayer from previous session")
}
// A non-zero r1 is not necessarily "nothing to clean up": it is also
// FWP_E_SUBLAYER_NOT_FOUND (the normal case), FWP_E_WRONG_SESSION for a sublayer a
// live ctrld owns, or FWP_E_DYNAMIC_SESSION_IN_PROGRESS for a non-dynamic one this
// dynamic session may not delete. None of them need handling here: the add below
// fails cleanly if the sublayer really is still present, and the non-dynamic case is
// what cleanupStaleDNSInterceptState handles at startup.
sublayer := fwpmSublayer0{
subLayerKey: ctrldSubLayerGUID,
weight: 0xFFFF,
}
sublayerName, _ := windows.UTF16PtrFromString("ctrld DNS Intercept Sublayer")
sublayerDesc, _ := windows.UTF16PtrFromString("Blocks outbound DNS except to ctrld listener. Prevents VPN DNS conflicts.")
sublayer.displayData.name = sublayerName
sublayer.displayData.description = sublayerDesc
r1, _, _ = procFwpmSubLayerAdd0.Call(
engineHandle,
uintptr(unsafe.Pointer(&sublayer)),
0,
)
if r1 != 0 {
procFwpmEngineClose0.Call(engineHandle)
return fmt.Errorf("FwpmSubLayerAdd0 failed: HRESULT 0x%x", r1)
}
mainLog.Load().Info().Msg("DNS intercept: WFP sublayer created (weight: 0xFFFF — maximum priority)")
state.engineHandle = engineHandle
permitFilters := []struct {
name string
layer windows.GUID
proto uint8
idField *uint64
}{
{"Permit DNS to localhost (IPv4/UDP)", fwpmLayerALEAuthConnectV4, ipprotoUDP, &state.permitIDv4UDP},
{"Permit DNS to localhost (IPv4/TCP)", fwpmLayerALEAuthConnectV4, ipprotoTCP, &state.permitIDv4TCP},
{"Permit DNS to localhost (IPv6/UDP)", fwpmLayerALEAuthConnectV6, ipprotoUDP, &state.permitIDv6UDP},
{"Permit DNS to localhost (IPv6/TCP)", fwpmLayerALEAuthConnectV6, ipprotoTCP, &state.permitIDv6TCP},
}
for _, pf := range permitFilters {
filterID, err := p.addWFPPermitLocalhostFilter(engineHandle, pf.name, pf.layer, pf.proto)
if err != nil {
p.cleanupWFPFilters(state)
return fmt.Errorf("failed to add permit filter %q: %w", pf.name, err)
}
*pf.idField = filterID
mainLog.Load().Debug().Msgf("DNS intercept: added permit filter %q (ID: %d)", pf.name, filterID)
}
blockFilters := []struct {
name string
layer windows.GUID
proto uint8
idField *uint64
}{
{"Block outbound DNS (IPv4/UDP)", fwpmLayerALEAuthConnectV4, ipprotoUDP, &state.filterIDv4UDP},
{"Block outbound DNS (IPv4/TCP)", fwpmLayerALEAuthConnectV4, ipprotoTCP, &state.filterIDv4TCP},
{"Block outbound DNS (IPv6/UDP)", fwpmLayerALEAuthConnectV6, ipprotoUDP, &state.filterIDv6UDP},
{"Block outbound DNS (IPv6/TCP)", fwpmLayerALEAuthConnectV6, ipprotoTCP, &state.filterIDv6TCP},
}
for _, bf := range blockFilters {
filterID, err := p.addWFPBlockDNSFilter(engineHandle, bf.name, bf.layer, bf.proto)
if err != nil {
p.cleanupWFPFilters(state)
return fmt.Errorf("failed to add block filter %q: %w", bf.name, err)
}
*bf.idField = filterID
mainLog.Load().Debug().Msgf("DNS intercept: added block filter %q (ID: %d)", bf.name, filterID)
}
// Add static permit filters for RFC1918 + CGNAT ranges (UDP + TCP).
// This allows VPN DNS servers on private IPs (MagicDNS upstreams, F5, Windscribe, etc.)
// to work without dynamic per-server exemptions.
privateRanges := []struct {
name string
addr uint32
mask uint32
}{
{"10.0.0.0/8", 0x0A000000, 0xFF000000},
{"172.16.0.0/12", 0xAC100000, 0xFFF00000},
{"192.168.0.0/16", 0xC0A80000, 0xFFFF0000},
{"100.64.0.0/10", 0x64400000, 0xFFC00000},
}
for _, r := range privateRanges {
for _, proto := range []struct {
num uint8
name string
}{{ipprotoUDP, "UDP"}, {ipprotoTCP, "TCP"}} {
filterName := fmt.Sprintf("Permit DNS to %s (%s)", r.name, proto.name)
filterID, err := p.addWFPPermitSubnetFilter(engineHandle, filterName, proto.num, r.addr, r.mask)
if err != nil {
mainLog.Load().Warn().Err(err).Msgf("DNS intercept: failed to add subnet permit for %s/%s", r.name, proto.name)
continue
}
state.subnetPermitFilterIDs = append(state.subnetPermitFilterIDs, filterID)
mainLog.Load().Debug().Msgf("DNS intercept: added subnet permit %q (ID: %d)", filterName, filterID)
}
}
mainLog.Load().Info().Msgf("DNS intercept: %d subnet permit filters active (RFC1918 + CGNAT)", len(state.subnetPermitFilterIDs))
mainLog.Load().Info().Msgf("DNS intercept: WFP filters active — all outbound DNS (port 53) blocked except to localhost and private ranges. "+
"Filter IDs: v4UDP=%d, v4TCP=%d, v6UDP=%d, v6TCP=%d (block), "+
"v4UDP=%d, v4TCP=%d, v6UDP=%d, v6TCP=%d (permit localhost)",
state.filterIDv4UDP, state.filterIDv4TCP, state.filterIDv6UDP, state.filterIDv6TCP,
state.permitIDv4UDP, state.permitIDv4TCP, state.permitIDv6UDP, state.permitIDv6TCP)
return nil
}
// addWFPBlockDNSFilter adds a WFP filter that blocks outbound DNS traffic (port 53)
// for the given protocol (UDP or TCP) on the specified layer (V4 or V6).
func (p *prog) addWFPBlockDNSFilter(engineHandle uintptr, name string, layerKey windows.GUID, proto uint8) (uint64, error) {
filterName, _ := windows.UTF16PtrFromString("ctrld: " + name)
conditions := make([]fwpmFilterCondition0, 2)
conditions[0] = fwpmFilterCondition0{
fieldKey: fwpmConditionIPProtocol,
matchType: fwpMatchEqual,
}
conditions[0].condValue.valueType = fwpUint8
conditions[0].condValue.value = uint64(proto)
conditions[1] = fwpmFilterCondition0{
fieldKey: fwpmConditionIPRemotePort,
matchType: fwpMatchEqual,
}
conditions[1].condValue.valueType = fwpUint16
conditions[1].condValue.value = uint64(dnsPort)
filter := fwpmFilter0{
layerKey: layerKey,
subLayerKey: ctrldSubLayerGUID,
numFilterConds: 2,
filterCondition: &conditions[0],
}
filter.displayData.name = filterName
filter.weight.valueType = fwpUint8
filter.weight.value = 1
filter.action.actionType = fwpActionBlock
var filterID uint64
r1, _, _ := procFwpmFilterAdd0.Call(
engineHandle,
uintptr(unsafe.Pointer(&filter)),
0,
uintptr(unsafe.Pointer(&filterID)),
)
runtime.KeepAlive(conditions)
if r1 != 0 {
return 0, fmt.Errorf("FwpmFilterAdd0 failed: HRESULT 0x%x", r1)
}
return filterID, nil
}
// addWFPPermitLocalhostFilter adds a WFP filter that permits outbound DNS to localhost.
// This ensures ctrld's listener at 127.0.0.1/::1 can receive DNS queries.
//
// TODO: On AD DC where ctrld listens on 127.0.0.x, this filter should match
// the actual listener IP instead of hardcoded 127.0.0.1. Currently hard mode
// is unlikely on AD DC (NRPT dns mode is preferred), but if needed, this must
// be parameterized like addNRPTCatchAllRule.
// These filters have higher weight than block filters so they're matched first.
func (p *prog) addWFPPermitLocalhostFilter(engineHandle uintptr, name string, layerKey windows.GUID, proto uint8) (uint64, error) {
filterName, _ := windows.UTF16PtrFromString("ctrld: " + name)
ipv6Loopback := [16]byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}
conditions := make([]fwpmFilterCondition0, 3)
conditions[0] = fwpmFilterCondition0{
fieldKey: fwpmConditionIPProtocol,
matchType: fwpMatchEqual,
}
conditions[0].condValue.valueType = fwpUint8
conditions[0].condValue.value = uint64(proto)
conditions[1] = fwpmFilterCondition0{
fieldKey: fwpmConditionIPRemotePort,
matchType: fwpMatchEqual,
}
conditions[1].condValue.valueType = fwpUint16
conditions[1].condValue.value = uint64(dnsPort)
conditions[2] = fwpmFilterCondition0{
fieldKey: fwpmConditionIPRemoteAddress,
matchType: fwpMatchEqual,
}
if layerKey == fwpmLayerALEAuthConnectV4 {
conditions[2].condValue.valueType = fwpUint32
conditions[2].condValue.value = 0x7F000001
} else {
conditions[2].condValue.valueType = fwpByteArray16Type
conditions[2].condValue.value = uint64(uintptr(unsafe.Pointer(&ipv6Loopback)))
}
filter := fwpmFilter0{
layerKey: layerKey,
subLayerKey: ctrldSubLayerGUID,
numFilterConds: 3,
filterCondition: &conditions[0],
}
filter.displayData.name = filterName
filter.weight.valueType = fwpUint8
filter.weight.value = 10
filter.action.actionType = fwpActionPermit
var filterID uint64
r1, _, _ := procFwpmFilterAdd0.Call(
engineHandle,
uintptr(unsafe.Pointer(&filter)),
0,
uintptr(unsafe.Pointer(&filterID)),
)
runtime.KeepAlive(&ipv6Loopback)
runtime.KeepAlive(conditions)
if r1 != 0 {
return 0, fmt.Errorf("FwpmFilterAdd0 failed: HRESULT 0x%x", r1)
}
return filterID, nil
}
// 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)
conditions := make([]fwpmFilterCondition0, 3)
conditions[0] = fwpmFilterCondition0{
fieldKey: fwpmConditionIPProtocol,
matchType: fwpMatchEqual,
}
conditions[0].condValue.valueType = fwpUint8
conditions[0].condValue.value = uint64(proto)
conditions[1] = fwpmFilterCondition0{
fieldKey: fwpmConditionIPRemotePort,
matchType: fwpMatchEqual,
}
conditions[1].condValue.valueType = fwpUint16
conditions[1].condValue.value = uint64(dnsPort)
conditions[2] = addrCond
filter := fwpmFilter0{
flags: flags,
layerKey: layerKey,
subLayerKey: ctrldSubLayerGUID,
numFilterConds: 3,
filterCondition: &conditions[0],
}
filter.displayData.name = filterName
filter.weight.valueType = fwpUint8
filter.weight.value = uint64(weight)
filter.action.actionType = fwpActionPermit
var filterID uint64
r1, _, _ := procFwpmFilterAdd0.Call(
engineHandle,
uintptr(unsafe.Pointer(&filter)),
0,
uintptr(unsafe.Pointer(&filterID)),
)
runtime.KeepAlive(conditions)
if r1 != 0 {
return 0, fmt.Errorf("FwpmFilterAdd0 failed: HRESULT 0x%x", r1)
}
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 {
var sublayerPtr uintptr
r1, _, _ := procFwpmSubLayerGetByKey0.Call(
engineHandle,
uintptr(unsafe.Pointer(&ctrldSubLayerGUID)),
uintptr(unsafe.Pointer(&sublayerPtr)),
)
if r1 != 0 {
return false
}
if sublayerPtr != 0 {
procFwpmFreeMemory0.Call(uintptr(unsafe.Pointer(&sublayerPtr)))
}
return true
}
// cleanupWFPFilters removes all WFP filters and the sublayer, then closes the engine.
// It logs each step and continues cleanup even if individual removals fail,
// to ensure maximum cleanup on shutdown.
func (p *prog) cleanupWFPFilters(state *wfpState) {
if state == nil || state.engineHandle == 0 {
return
}
// Hold state.mu across the whole teardown: engineHandle and every filter ID slice
// below is shared with the VPN DNS exemption path and the recovery flows.
state.mu.Lock()
defer state.mu.Unlock()
// Clean up loopback protect filters (DNS mode VPN workaround).
loopbackIDs := state.loopbackPermitIDs
state.loopbackPermitIDs = nil
state.loopbackProtectActive = false
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 {
mainLog.Load().Warn().Msgf("DNS intercept: failed to remove VPN permit filter (ID: %d, code: 0x%x)", filterID, r1)
} else {
mainLog.Load().Debug().Msgf("DNS intercept: removed VPN permit filter (ID: %d)", filterID)
}
}
for _, filterID := range state.subnetPermitFilterIDs {
r1, _, _ := procFwpmFilterDeleteById0.Call(state.engineHandle, uintptr(filterID))
if r1 != 0 {
mainLog.Load().Warn().Msgf("DNS intercept: failed to remove subnet permit filter (ID: %d, code: 0x%x)", filterID, r1)
} else {
mainLog.Load().Debug().Msgf("DNS intercept: removed subnet permit filter (ID: %d)", filterID)
}
}
filterIDs := []struct {
name string
id uint64
}{
{"permit v4 UDP", state.permitIDv4UDP},
{"permit v4 TCP", state.permitIDv4TCP},
{"permit v6 UDP", state.permitIDv6UDP},
{"permit v6 TCP", state.permitIDv6TCP},
{"block v4 UDP", state.filterIDv4UDP},
{"block v4 TCP", state.filterIDv4TCP},
{"block v6 UDP", state.filterIDv6UDP},
{"block v6 TCP", state.filterIDv6TCP},
}
for _, f := range filterIDs {
if f.id == 0 {
continue
}
r1, _, _ := procFwpmFilterDeleteById0.Call(state.engineHandle, uintptr(f.id))
if r1 != 0 {
mainLog.Load().Warn().Msgf("DNS intercept: failed to remove WFP filter %q (ID: %d, code: 0x%x)", f.name, f.id, r1)
} else {
mainLog.Load().Debug().Msgf("DNS intercept: removed WFP filter %q (ID: %d)", f.name, f.id)
}
}
r1, _, _ := procFwpmSubLayerDeleteByKey0.Call(
state.engineHandle,
uintptr(unsafe.Pointer(&ctrldSubLayerGUID)),
)
if r1 != 0 {
mainLog.Load().Warn().Msgf("DNS intercept: failed to remove WFP sublayer (code: 0x%x)", r1)
} else {
mainLog.Load().Debug().Msg("DNS intercept: removed WFP sublayer")
}
r1, _, _ = procFwpmEngineClose0.Call(state.engineHandle)
if r1 != 0 {
mainLog.Load().Warn().Msgf("DNS intercept: failed to close WFP engine (code: 0x%x)", r1)
} else {
mainLog.Load().Debug().Msg("DNS intercept: WFP engine closed")
}
}
// 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.
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
// Session-scoped, like the hard-intercept engine: no ctrld filter should
// outlive the process that installed it.
session.flags = fwpmSessionFlagDynamic
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
}
// osHealthcheckSuppressed reports whether the upstream.os healthcheck should
// be skipped because DNS intercept mode is active and the WFP loopback protect
// has been engaged. Loopback protect is only activated when an external WFP
// block filter (e.g. OpenVPN's block-outside-dns) is interfering with DNS,
// which is the same condition that makes the OS resolver healthcheck fail
// every 2s with i/o timeout — so suppressing the check avoids the log spam
// described in issue #526.
func (p *prog) osHealthcheckSuppressed() bool {
if !dnsIntercept || p.dnsInterceptState == nil {
return false
}
state, ok := p.dnsInterceptState.(*wfpState)
if !ok || state == nil {
return false
}
state.mu.Lock()
defer state.mu.Unlock()
return state.loopbackProtectActive
}
// 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 {
// Announce the stop before waiting for the lifecycle lock. A rebuild that holds it
// runs a full start, whose NRPT verification can sit in probe backoffs for seconds;
// the flag is what lets those flows abandon their work instead of making the stop
// wait. A stop that waits too long is not a delay but a failure mode: the Service
// Control Manager kills ctrld on timeout, and then nothing is cleaned up at all.
p.dnsInterceptStopRequested.Store(true)
p.dnsInterceptMu.Lock()
defer p.dnsInterceptMu.Unlock()
defer p.dnsInterceptStopRequested.Store(false)
return p.stopDNSInterceptLocked()
}
// stopDNSInterceptLocked is stopDNSIntercept with p.dnsInterceptMu already held.
//
// It revokes the state before removing anything. Teardown deletes the WFP sublayer, and
// a missing sublayer is exactly what the health monitor reads as "our filters were
// wiped, rebuild everything" - so a monitor tick landing inside the shutdown window
// would otherwise re-add the NRPT catch-all and the WFP filters moments before the
// process exits, leaving Windows resolving through a ctrld that is gone. Revoking first
// means every such flow sees a retired state and stands down.
func (p *prog) stopDNSInterceptLocked() error {
if p.dnsInterceptState == nil {
mainLog.Load().Debug().Msg("DNS intercept: no state to clean up")
return nil
}
state := p.dnsInterceptState.(*wfpState)
// Revoke first, then remove. Both signals are what the monitor, the delayed
// rechecks and the NRPT heal flows read to decide whether they may still write
// host DNS state.
p.dnsInterceptState = nil
// Stop the health monitor goroutine.
if state.stopCh != nil {
close(state.stopCh)
}
// Remove only ctrld-owned NRPT state. A GP-owned catch-all is an external
// deployment contract and must survive service stop, restart, and uninstall.
//
// Hold the transition lock across the removal so an in-flight NRPT transition
// finishes first and any later one sees the revoked state under the same lock. The
// stop-requested flag is already set, so an in-flight transition abandons its probes
// rather than making this wait.
p.nrptTransitionMu.Lock()
defer p.nrptTransitionMu.Unlock()
ops := p.nrptOps()
owner, externalRuleName := state.nrptPolicyOwner()
switch owner {
case nrptRuleOwnerCtrld:
if err := ops.removeRule(); err != nil {
mainLog.Load().Warn().Err(err).Msg("DNS intercept: failed to remove ctrld-owned NRPT catch-all rule")
} else {
mainLog.Load().Info().Msg("DNS intercept: removed ctrld-owned NRPT catch-all rule")
}
ops.flush()
case nrptRuleOwnerGroupPolicy:
mainLog.Load().Info().Str("rule", externalRuleName).
Msg("DNS intercept: leaving GP-managed NRPT catch-all untouched during shutdown")
// External policy stays, but a ctrld rule from an earlier unclean exit must
// not: while GP mode hides the local store, this stop is the last chance
// anything will look there. See removeOrphanedCtrldNRPTRule.
p.removeOrphanedCtrldNRPTRule("shutdown with externally owned NRPT policy")
case nrptRuleOwnerNone:
// No ownership was ever established this run - an activation that failed, or a
// start that never got that far. A ctrld rule found here is still ours.
p.removeOrphanedCtrldNRPTRule("shutdown with no NRPT owner")
}
state.setNRPTPolicyOwner(nrptRuleOwnerNone, "")
// 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)
mainLog.Load().Info().Msg("DNS intercept: WFP shutdown complete")
}
mainLog.Load().Info().Msg("DNS intercept: shutdown complete")
return nil
}
// interceptRebuildResult reports what rebuildDNSIntercept did.
type interceptRebuildResult int
const (
// interceptRebuildRetired means the caller's state was no longer the live one -
// shutdown revoked it, or an earlier rebuild replaced it - so nothing was touched.
interceptRebuildRetired interceptRebuildResult = iota
// interceptRebuildDone means the intercept was torn down and re-created.
interceptRebuildDone
// interceptRebuildFailed means teardown ran but the re-create returned an error.
// dnsInterceptState is nil afterwards, except on the one path that publishes a
// partial intercept on purpose - hard mode with verified GP NRPT but no WFP - which
// leaves a health monitor running to keep retrying.
interceptRebuildFailed
)
// rebuildDNSIntercept tears the intercept down and creates it again, for callers that
// found our filters wiped from underneath us.
//
// It refuses unless state is still the published intercept state. That check is what
// stops a health monitor tick from resurrecting DNS interception during or after a
// service stop: shutdown revokes the state under this same lock before it removes
// anything, so a monitor arriving late finds its state retired and does nothing.
// Holding the lock across teardown and create also means a stop that arrives
// mid-rebuild waits, then tears down whatever the rebuild published - never a
// half-built intercept.
//
// Whatever the result, the caller's state is dead afterwards: the monitor goroutine
// that owns it must exit.
// rebuildDNSInterceptFn is the rebuild entry point. Indirected so tests can assert which
// conditions ask for a rebuild without running a real teardown and start. Assigned in
// init because the rebuild reaches back here through the health monitor.
var rebuildDNSInterceptFn func(*prog, *wfpState, string) interceptRebuildResult
func init() {
rebuildDNSInterceptFn = (*prog).rebuildDNSIntercept
}
func (p *prog) rebuildDNSIntercept(state *wfpState, reason string) interceptRebuildResult {
p.dnsInterceptMu.Lock()
defer p.dnsInterceptMu.Unlock()
if live, ok := p.dnsInterceptState.(*wfpState); !ok || live != state {
mainLog.Load().Info().Str("reason", reason).
Msg("DNS intercept: not rebuilding - this intercept was already retired by shutdown or an earlier rebuild")
return interceptRebuildRetired
}
mainLog.Load().Warn().Str("reason", reason).Msg("DNS intercept: rebuilding interception")
_ = p.stopDNSInterceptLocked()
if err := p.startDNSInterceptLocked(); err != nil {
mainLog.Load().Error().Err(err).Str("reason", reason).Msg("DNS intercept: rebuild failed")
return interceptRebuildFailed
}
return interceptRebuildDone
}
// interceptStateRevoked reports whether state has been retired, meaning nothing may
// write host DNS state on its behalf any more.
//
// stopDNSInterceptLocked closes stopCh before it removes the NRPT rule or the WFP
// filters, and a stop waiting for the lifecycle lock sets dnsInterceptStopRequested
// first. Together they are the cheap shutdown signal for the flows that must not take
// p.dnsInterceptMu: the NRPT probe and heal sequences, which wait seconds between
// probes and also run from inside the locked start path.
func (p *prog) interceptStateRevoked(state *wfpState) bool {
if state == nil || state.stopCh == nil {
return true
}
if p.dnsInterceptStopRequested.Load() {
return true
}
select {
case <-state.stopCh:
return true
default:
return false
}
}
// interceptRevocationPollInterval is how quickly the recovery flows notice a stop that
// is waiting for the lifecycle lock. A pending stop can only set a flag - it cannot
// close stopCh until it owns the lock - so waits poll instead of selecting on a channel.
const interceptRevocationPollInterval = 100 * time.Millisecond
// interceptWait waits for d, or until the intercept is retired, whichever comes first.
// It reports whether the caller may keep working.
//
// Every wait in the NRPT recovery flows goes through this. Those flows can be holding
// the lifecycle lock - startDNSInterceptLocked runs one synchronously, and a rebuild
// holds the lock across the whole start - and their backoffs add up to tens of seconds.
// A plain time.Sleep there makes a service stop wait that long for the lock, risking
// the Service Control Manager killing ctrld before it removes the NRPT rule and the WFP
// filters, which is the unclean shutdown the locking exists to prevent. Bounding each
// wait by the shutdown signal keeps a stop's wait to about one poll interval plus
// whatever uncancellable Windows call is in flight.
func (p *prog) interceptWait(state *wfpState, d time.Duration) bool {
deadline := time.Now().Add(d)
for {
if p.interceptStateRevoked(state) {
return false
}
remaining := time.Until(deadline)
if remaining <= 0 {
return true
}
if remaining > interceptRevocationPollInterval {
remaining = interceptRevocationPollInterval
}
timer := time.NewTimer(remaining)
select {
case <-state.stopCh:
timer.Stop()
return false
case <-timer.C:
}
}
}
// exemptVPNDNSServers updates the WFP filters to permit outbound DNS to the given
// VPN DNS server IPs. This prevents the block filters from intercepting ctrld's own
// forwarded queries to VPN DNS servers (split DNS routing).
//
// The function is idempotent: it first removes ALL existing VPN permit filters,
// then adds new ones for the current server list. When called with nil/empty
// exemptions (VPN disconnected), it just removes the old permits — leaving only
// the localhost permits and block-all filters active.
//
// On Windows, WFP filters are process-scoped (not interface-scoped like macOS pf),
// so we only use the server IPs from the exemptions.
//
// Supports both IPv4 and IPv6 VPN DNS servers.
//
// Called by vpnDNSManager.onServersChanged() whenever VPN DNS servers change.
func (p *prog) exemptVPNDNSServers(exemptions []vpnDNSExemption) error {
state, ok := p.dnsInterceptState.(*wfpState)
if !ok || state == nil {
return fmt.Errorf("DNS intercept state not available")
}
// engineHandle, loopbackProtectActive and vpnPermitFilterIDs are all shared with the
// monitor, the recovery flows and teardown, so this runs under state.mu like every
// other reader and writer of them.
state.mu.Lock()
defer state.mu.Unlock()
// 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
}
for _, filterID := range state.vpnPermitFilterIDs {
r1, _, _ := procFwpmFilterDeleteById0.Call(state.engineHandle, uintptr(filterID))
if r1 != 0 {
mainLog.Load().Warn().Msgf("DNS intercept: failed to remove old VPN permit filter (ID: %d, code: 0x%x)", filterID, r1)
}
}
state.vpnPermitFilterIDs = nil
// Extract unique server IPs from exemptions (WFP doesn't need interface info).
seen := make(map[string]bool)
var servers []string
for _, ex := range exemptions {
if !seen[ex.Server] {
seen[ex.Server] = true
servers = append(servers, ex.Server)
}
}
for _, server := range servers {
ipv4 := parseIPv4AsUint32(server)
isIPv6 := ipv4 == 0
for _, proto := range []uint8{ipprotoUDP, ipprotoTCP} {
protoName := "UDP"
if proto == ipprotoTCP {
protoName = "TCP"
}
filterName := fmt.Sprintf("ctrld: Permit VPN DNS to %s (%s)", server, protoName)
var filterID uint64
var err error
if isIPv6 {
ipv6Bytes := parseIPv6AsBytes(server)
if ipv6Bytes == nil {
mainLog.Load().Warn().Msgf("DNS intercept: skipping invalid VPN DNS server: %s", server)
continue
}
filterID, err = p.addWFPPermitIPv6Filter(state.engineHandle, filterName, fwpmLayerALEAuthConnectV6, proto, ipv6Bytes)
} else {
filterID, err = p.addWFPPermitIPFilter(state.engineHandle, filterName, fwpmLayerALEAuthConnectV4, proto, ipv4)
}
if err != nil {
return fmt.Errorf("failed to add VPN DNS permit filter for %s/%s: %w", server, protoName, err)
}
state.vpnPermitFilterIDs = append(state.vpnPermitFilterIDs, filterID)
mainLog.Load().Debug().Msgf("DNS intercept: added VPN DNS permit filter for %s/%s (ID: %d)", server, protoName, filterID)
}
}
mainLog.Load().Info().Msgf("DNS intercept: exempted %d VPN DNS servers from WFP block (%d filters)", len(servers), len(state.vpnPermitFilterIDs))
return nil
}
// addWFPPermitIPFilter adds a WFP permit filter for outbound DNS to a specific IPv4 address.
func (p *prog) addWFPPermitIPFilter(engineHandle uintptr, name string, layerKey windows.GUID, proto uint8, ipAddr uint32) (uint64, error) {
filterName, _ := windows.UTF16PtrFromString(name)
conditions := make([]fwpmFilterCondition0, 3)
conditions[0] = fwpmFilterCondition0{
fieldKey: fwpmConditionIPProtocol,
matchType: fwpMatchEqual,
}
conditions[0].condValue.valueType = fwpUint8
conditions[0].condValue.value = uint64(proto)
conditions[1] = fwpmFilterCondition0{
fieldKey: fwpmConditionIPRemotePort,
matchType: fwpMatchEqual,
}
conditions[1].condValue.valueType = fwpUint16
conditions[1].condValue.value = uint64(dnsPort)
conditions[2] = fwpmFilterCondition0{
fieldKey: fwpmConditionIPRemoteAddress,
matchType: fwpMatchEqual,
}
conditions[2].condValue.valueType = fwpUint32
conditions[2].condValue.value = uint64(ipAddr)
filter := fwpmFilter0{
layerKey: layerKey,
subLayerKey: ctrldSubLayerGUID,
numFilterConds: 3,
filterCondition: &conditions[0],
}
filter.displayData.name = filterName
filter.weight.valueType = fwpUint8
filter.weight.value = 10
filter.action.actionType = fwpActionPermit
var filterID uint64
r1, _, _ := procFwpmFilterAdd0.Call(
engineHandle,
uintptr(unsafe.Pointer(&filter)),
0,
uintptr(unsafe.Pointer(&filterID)),
)
runtime.KeepAlive(conditions)
if r1 != 0 {
return 0, fmt.Errorf("FwpmFilterAdd0 failed: HRESULT 0x%x", r1)
}
return filterID, nil
}
// addWFPPermitIPv6Filter adds a WFP permit filter for outbound DNS to a specific IPv6 address.
func (p *prog) addWFPPermitIPv6Filter(engineHandle uintptr, name string, layerKey windows.GUID, proto uint8, ipAddr *[16]byte) (uint64, error) {
filterName, _ := windows.UTF16PtrFromString(name)
conditions := make([]fwpmFilterCondition0, 3)
conditions[0] = fwpmFilterCondition0{
fieldKey: fwpmConditionIPProtocol,
matchType: fwpMatchEqual,
}
conditions[0].condValue.valueType = fwpUint8
conditions[0].condValue.value = uint64(proto)
conditions[1] = fwpmFilterCondition0{
fieldKey: fwpmConditionIPRemotePort,
matchType: fwpMatchEqual,
}
conditions[1].condValue.valueType = fwpUint16
conditions[1].condValue.value = uint64(dnsPort)
conditions[2] = fwpmFilterCondition0{
fieldKey: fwpmConditionIPRemoteAddress,
matchType: fwpMatchEqual,
}
conditions[2].condValue.valueType = fwpByteArray16Type
conditions[2].condValue.value = uint64(uintptr(unsafe.Pointer(ipAddr)))
filter := fwpmFilter0{
layerKey: layerKey,
subLayerKey: ctrldSubLayerGUID,
numFilterConds: 3,
filterCondition: &conditions[0],
}
filter.displayData.name = filterName
filter.weight.valueType = fwpUint8
filter.weight.value = 10
filter.action.actionType = fwpActionPermit
var filterID uint64
r1, _, _ := procFwpmFilterAdd0.Call(
engineHandle,
uintptr(unsafe.Pointer(&filter)),
0,
uintptr(unsafe.Pointer(&filterID)),
)
runtime.KeepAlive(ipAddr)
runtime.KeepAlive(conditions)
if r1 != 0 {
return 0, fmt.Errorf("FwpmFilterAdd0 failed: HRESULT 0x%x", r1)
}
return filterID, nil
}
// parseIPv6AsBytes parses an IPv6 address string into a 16-byte array for WFP.
// Returns nil if the string is not a valid IPv6 address.
func parseIPv6AsBytes(ipStr string) *[16]byte {
ip := net.ParseIP(ipStr)
if ip == nil {
return nil
}
ip = ip.To16()
if ip == nil || ip.To4() != nil {
// It's IPv4, not IPv6
return nil
}
var result [16]byte
copy(result[:], ip)
return &result
}
// parseIPv4AsUint32 converts an IPv4 string to a uint32 in host byte order for WFP.
func parseIPv4AsUint32(ipStr string) uint32 {
parts := [4]byte{}
n := 0
val := uint32(0)
for i := 0; i < len(ipStr) && n < 4; i++ {
if ipStr[i] == '.' {
parts[n] = byte(val)
n++
val = 0
} else if ipStr[i] >= '0' && ipStr[i] <= '9' {
val = val*10 + uint32(ipStr[i]-'0')
} else {
return 0
}
}
if n == 3 {
parts[3] = byte(val)
return uint32(parts[0])<<24 | uint32(parts[1])<<16 | uint32(parts[2])<<8 | uint32(parts[3])
}
return 0
}
// ensurePFAnchorActive is a no-op on Windows (WFP handles intercept differently).
func (p *prog) ensurePFAnchorActive() pfAnchorCheckResult {
return pfAnchorCheckSkipped
}
// checkTunnelInterfaceChanges is a no-op on Windows (WFP handles intercept differently).
func (p *prog) checkTunnelInterfaceChanges() bool {
return false
}
// Windows preserves the existing immediate reconciliation behavior. NRPT/WFP
// and adapter DNS settling have different lifecycle requirements from macOS pf.
func (p *prog) dnsInterceptIgnoredChangeReconcileDue(time.Time) bool {
return true
}
// pfAnchorRecheckDelay is the delay for deferred pf anchor re-checks.
// Defined here as a stub for Windows (referenced from dns_proxy.go).
const pfAnchorRecheckDelay = 2 * time.Second
// pfAnchorRecheckDelayLong is the longer delayed re-check for slower VPN teardowns.
const pfAnchorRecheckDelayLong = 4 * time.Second
func gpCatchAllConflictBlocksFallback(state *wfpState, reason string) bool {
ruleName, dnsServers := findConflictingGPCatchAll(state.listenerIP)
if ruleName == "" {
return false
}
mainLog.Load().Error().Str("rule", ruleName).Str("nameservers", dnsServers).Str("reason", reason).
Msg("DNS intercept: GP catch-all targets another resolver; refusing to create a competing fallback rule")
return true
}
// nrptOps is the seam between NRPT ownership decisions and the Windows side effects
// they cause. Tests substitute it to drive a transition - probe outcomes, registry
// state, concurrency - without touching the host's registry or DNS Client.
type nrptOps struct {
probe func(state *wfpState) bool
ruleExists func() bool
addRule func(listenerIP string) error
removeRule func() error
signal func()
findGPRule func(listenerIP string) string
gpRuleMatches func(ruleName, listenerIP string) bool
gpConflicts func(state *wfpState, reason string) bool
loopback func(state *wfpState) error
wait func(state *wfpState, d time.Duration) bool
flush func()
parentEmpty func(keyPath string) bool
cleanParent func() bool
startWFP func(state *wfpState) error
}
// nrptOpsForTest overrides the NRPT side effects. Windows tests only.
var nrptOpsForTest *nrptOps
func (p *prog) nrptOps() nrptOps {
if nrptOpsForTest != nil {
return *nrptOpsForTest
}
return nrptOps{
probe: p.probeNRPT,
ruleExists: nrptCatchAllRuleExists,
addRule: addNRPTCatchAllRule,
removeRule: removeNRPTCatchAllRule,
signal: signalNRPTChange,
findGPRule: findMatchingGPNRPTRule,
gpRuleMatches: gpNRPTRuleMatches,
gpConflicts: p.gpCatchAllConflictBlocksFallbackOps,
loopback: p.activateLoopbackWFPProtect,
wait: p.interceptWait,
flush: flushDNSCache,
parentEmpty: nrptParentKeyEmpty,
cleanParent: cleanEmptyNRPTParent,
startWFP: p.startWFPFilters,
}
}
func (p *prog) gpCatchAllConflictBlocksFallbackOps(state *wfpState, reason string) bool {
return gpCatchAllConflictBlocksFallback(state, reason)
}
// nrptHandbackResult reports how a handback attempt ended.
type nrptHandbackResult int
const (
// nrptHandbackVerified: external policy owns NRPT and proved, with ctrld's own keys
// gone, that it routes to this listener.
nrptHandbackVerified nrptHandbackResult = iota
// nrptHandbackUnverified: external policy owns the namespace but is not routing.
// Nothing of ctrld's was removed, so there was nothing to lose by recording it.
nrptHandbackUnverified
// nrptHandbackKeptCtrld: the proof failed with ctrld's rule removed, so the rule was
// restored and ctrld keeps ownership.
nrptHandbackKeptCtrld
// nrptHandbackAborted: shutdown landed, a registry step failed, the attempt was
// throttled, or the candidate child is no longer there. Nothing was decided.
nrptHandbackAborted
// nrptHandbackConflict: an administrator-owned catch-all is present that does not
// target ctrld. External policy owns the namespace and ctrld must not write beside it.
nrptHandbackConflict
)
// nrptHandbackRetryInterval bounds how often ctrld will take its own rule out of the way
// to re-test the same external catch-all. Each attempt briefly removes the only working
// route, so retrying on every 30s health tick would be its own outage. A rule the
// administrator has changed is retested immediately regardless.
const nrptHandbackRetryInterval = 15 * time.Minute
// nrptHandbackProbePasses bounds how many probes one handback spends chasing a Group
// Policy store that keeps changing under it. Each pass costs a probe timeout, and the
// budget being spent is not an excuse to guess: see externalAfterRemoval.
const nrptHandbackProbePasses = 2
// nrptHandbackToExternal is the only path that records external (Group Policy)
// ownership of NRPT.
//
// The proof has to be produced with ctrld's own keys gone. A probe taken while the ctrld
// fallback is still installed can be answered by that fallback, so a present-but-
// ineffective GP child would otherwise let ctrld delete the last working route and then
// declare external ownership. In hard mode that is a machine-wide DNS outage: WFP keeps
// blocking outbound DNS with nothing redirecting it to ctrld.
//
// The transition is: remove only ctrld's own keys, signal, probe again, re-read the same
// GP child - and if that second probe fails, put ctrld's rule back and keep ctrld
// ownership. Everything runs under nrptTransitionMu so a stop cannot interleave with it.
func (p *prog) nrptHandbackToExternal(state *wfpState, ruleName, reason string) nrptHandbackResult {
ops := p.nrptOps()
p.nrptTransitionMu.Lock()
defer p.nrptTransitionMu.Unlock()
if p.interceptStateRevoked(state) {
return nrptHandbackAborted
}
if !ops.gpRuleMatches(ruleName, state.listenerIP) {
return nrptHandbackAborted
}
// Nothing of ours in the way: a probe already measures external policy alone, and
// refusing would mean writing a competing catch-all beside an administrator's rule.
if !ops.ruleExists() {
child, class, routes := p.externalAfterRemoval(ops, state, ruleName, reason)
switch {
case class == gpChildSameExact && routes:
state.setNRPTPolicyOwner(nrptRuleOwnerGroupPolicy, child)
state.nrptRecoveryLimiter.recordStableSuccess()
mainLog.Load().Info().Str("rule", child).Str("reason", reason).
Msg("DNS intercept: GP-managed catch-all verified routing to ctrld; external policy owns NRPT")
return nrptHandbackVerified
case class == gpChildSameExact:
state.setNRPTPolicyOwner(nrptRuleOwnerGroupPolicy, child)
mainLog.Load().Warn().Str("rule", child).Str("reason", reason).
Msg("DNS intercept: GP-managed catch-all owns the namespace but is not routing; leaving external policy untouched")
return nrptHandbackUnverified
case class == gpChildConflicting:
// An administrator-owned catch-all now targets another resolver, or is
// malformed. It owns the namespace and ctrld must not write a sibling.
state.setNRPTPolicyOwner(nrptRuleOwnerNone, "")
mainLog.Load().Error().Str("rule", ruleName).Str("reason", reason).
Msg("DNS intercept: GP catch-all changed to one that does not target ctrld; refusing to write a competing rule")
return nrptHandbackConflict
default:
// External policy is gone, or the store is still churning. Decide nothing:
// the caller re-reads and retries.
return nrptHandbackAborted
}
}
// ctrld's rule is installed. Taking it out to test the external one is disruptive, so
// it is throttled: an external rule that never routes would otherwise cost a brief
// outage on every health tick. The check comes before the probe so a throttled tick
// costs nothing, and the budget is only spent below, once the attempt is real.
now := time.Now()
if !state.handbackAllowed(now, ruleName, nrptHandbackRetryInterval) {
return nrptHandbackAborted
}
// Pre-probe: this measures whatever routes DNS today, ctrld's own rule included, so
// it can never prove anything about external policy - it only says whether there is a
// working route here to risk. If nothing is routing there is nothing to protect and
// nothing to compare against, so leave it to the heal cycle rather than start
// deleting rules.
if !ops.probe(state) {
return nrptHandbackAborted
}
state.recordHandbackAttempt(now, ruleName, nrptHandbackRetryInterval)
if err := ops.removeRule(); err != nil {
mainLog.Load().Warn().Err(err).Str("rule", ruleName).Str("reason", reason).
Msg("DNS intercept: GP catch-all found but ctrld's own rule could not be removed for the handback probe")
return nrptHandbackAborted
}
ops.signal()
if !ops.wait(state, nrptHandbackSettleDelay) {
// Shutdown landed. NRPT is clean, which is the right state to leave behind.
return nrptHandbackAborted
}
// Post-removal probe and classification, always - not only when the probe succeeded.
// Group Policy can refresh during the probe, and what it changed into decides whether
// restoring ctrld's rule is right or would create a sibling that must never be written.
child, class, routes := p.externalAfterRemoval(ops, state, ruleName, reason)
switch {
case class == gpChildSameExact && routes:
state.setNRPTPolicyOwner(nrptRuleOwnerGroupPolicy, child)
state.nrptRecoveryLimiter.recordStableSuccess()
mainLog.Load().Info().Str("rule", child).Str("listener", state.listenerIP).Str("reason", reason).
Msg("DNS intercept: GP-managed catch-all carried DNS without ctrld's rule; returned NRPT ownership to Group Policy")
return nrptHandbackVerified
case class == gpChildConflicting:
// The child changed into a catch-all that does not target ctrld. It owns the
// namespace, so ctrld's rule stays off: restoring it here is exactly the
// competing sibling beside administrator policy that is forbidden elsewhere.
state.setNRPTPolicyOwner(nrptRuleOwnerNone, "")
mainLog.Load().Error().Str("rule", ruleName).Str("reason", reason).
Msg("DNS intercept: GP catch-all changed to one that does not target ctrld during the handback probe; leaving NRPT to Group Policy and not restoring the ctrld rule")
return nrptHandbackConflict
case class == gpChildSameExact && child != ruleName:
// A different administrator catch-all took the namespace while ctrld's keys were
// off, and its own pass says it is not routing yet. ctrld's rule still must not
// come back: addNRPTCatchAllRule writes ctrld's GP catch-all whenever another GP
// rule exists, so restoring would put a sibling beside a catch-all that was not
// even there when this transition started. Record external ownership and let the
// health monitor keep watching the new child.
state.setNRPTPolicyOwner(nrptRuleOwnerGroupPolicy, child)
mainLog.Load().Warn().Str("old_rule", ruleName).Str("rule", child).Str("reason", reason).
Msg("DNS intercept: a different GP catch-all took the namespace during the handback probe and is not routing yet; leaving NRPT to Group Policy without restoring the ctrld rule")
return nrptHandbackUnverified
}
// The original child is still exact but cannot carry DNS, or external policy is gone
// altogether: ctrld's route is the one that has to come back. This only restores the
// state the transition started from, so it creates no new sibling.
if err := ops.addRule(state.listenerIP); err != nil {
mainLog.Load().Error().Err(err).Str("rule", ruleName).
Msg("DNS intercept: handback probe failed and the ctrld NRPT rule could not be restored; the health monitor will retry")
state.setNRPTPolicyOwner(nrptRuleOwnerNone, "")
return nrptHandbackAborted
}
ops.signal()
state.setNRPTPolicyOwner(nrptRuleOwnerCtrld, "")
if class == gpChildGone {
mainLog.Load().Warn().Str("rule", ruleName).Str("reason", reason).
Msg("DNS intercept: GP-managed catch-all disappeared during the handback probe; restored the ctrld fallback and kept ctrld ownership")
} else {
mainLog.Load().Warn().Str("rule", ruleName).Str("reason", reason).
Msg("DNS intercept: GP-managed catch-all did not carry DNS without ctrld's rule; restored the ctrld fallback and kept ctrld ownership")
}
return nrptHandbackKeptCtrld
}
// gpChildClass is what an external NRPT catch-all looks like at the moment ctrld checks,
// which is not necessarily what it looked like when a probe was sent: Group Policy can
// refresh while the probe is in flight.
type gpChildClass int
const (
// gpChildSameExact: the same child still names exactly this listener.
gpChildSameExact gpChildClass = iota
// gpChildReplaced: a different child now matches this listener exactly. It has not
// proved anything yet, so it is not adopted on this pass.
gpChildReplaced
// gpChildConflicting: an administrator-owned catch-all is present that does not target
// ctrld - another resolver, or malformed. ctrld must not write a rule beside it.
gpChildConflicting
// gpChildGone: no external catch-all owns the namespace any more.
gpChildGone
)
// externalAfterRemoval probes external policy with ctrld's keys already gone, then says
// which child the result belongs to, what state that child is in, and whether it routed.
// It writes nothing: the caller decides what the verdict means.
//
// A probe result can only ever be attributed to the child that was on disk for the whole
// probe. When Group Policy swaps the child mid-probe the old result is void, so the
// replacement gets one pass of its own here rather than inheriting a verdict it never
// earned. Two passes is the limit; a store that keeps changing is reported as still
// churning so the caller can retry instead of guessing.
func (p *prog) externalAfterRemoval(ops nrptOps, state *wfpState, candidate, reason string) (string, gpChildClass, bool) {
for pass := 0; pass < nrptHandbackProbePasses; pass++ {
routes := ops.probe(state)
class := p.classifyGPChild(ops, state, candidate, reason)
if class != gpChildReplaced {
return candidate, class, routes
}
if pass == nrptHandbackProbePasses-1 {
break
}
next := ops.findGPRule(state.listenerIP)
if next == "" {
return candidate, gpChildGone, routes
}
mainLog.Load().Warn().Str("old_rule", candidate).Str("rule", next).Str("reason", reason).
Msg("DNS intercept: a different GP catch-all appeared during the probe; testing that one instead")
candidate = next
}
// The probe budget is spent and the store is still moving. Report the store as it is
// now, with no route proved, rather than reporting churn: "undecided" would let
// startup and owned recovery fall through to writing ctrld's rule, and
// addNRPTCatchAllRule puts that in the GP path beside whatever exact catch-all is
// there - the sibling that must never exist. Failing safe from the current store
// keeps every outcome terminal for those callers unless the namespace is genuinely
// free.
mainLog.Load().Warn().Str("rule", candidate).Str("reason", reason).
Msg("DNS intercept: GP catch-alls kept changing during the handback probe; classifying the store as it stands with no route proved")
if current := ops.findGPRule(state.listenerIP); current != "" {
return current, gpChildSameExact, false
}
if ops.gpConflicts(state, reason) {
return candidate, gpChildConflicting, false
}
return candidate, gpChildGone, false
}
// classifyGPChild re-reads the GP store and says what state the external catch-all is in.
// Every post-probe decision goes through it, so a child that changed mid-probe can never
// be treated as the child that was measured.
func (p *prog) classifyGPChild(ops nrptOps, state *wfpState, ruleName, reason string) gpChildClass {
if ops.gpRuleMatches(ruleName, state.listenerIP) {
return gpChildSameExact
}
if other := ops.findGPRule(state.listenerIP); other != "" {
return gpChildReplaced
}
if ops.gpConflicts(state, reason) {
return gpChildConflicting
}
return gpChildGone
}
// nrptHandbackSettleDelay gives the DNS Client a moment to drop ctrld's removed rule
// before the handback probe decides whether external policy routes on its own.
const nrptHandbackSettleDelay = 1 * time.Second
// deferToExternalCatchAll stops ctrld-owned recovery when an administrator catch-all owns
// the namespace, and reports whether the caller must stop.
//
// It hands back where a verdict is possible. Where one is not - the handback needs a
// working route to compare against, and during a heal cycle there often is none - the
// presence of an exact external catch-all is itself the ownership signal, so ownership is
// recorded without proof and recovery stops anyway. Continuing would mean signalling the
// DNS Client, or deleting and recreating ctrld's rule, while administrator policy owns the
// namespace: the two things #576 forbids. The health monitor keeps testing the rule and
// can hand back properly once it routes.
func (p *prog) deferToExternalCatchAll(ops nrptOps, state *wfpState, reason string) bool {
ruleName := ops.findGPRule(state.listenerIP)
if ruleName == "" || !ops.gpRuleMatches(ruleName, state.listenerIP) {
return false
}
switch result := p.nrptHandbackToExternal(state, ruleName, reason); {
case result == nrptHandbackKeptCtrld:
// The external rule was proved unable to carry DNS and ctrld's rule is back, so
// owned recovery is exactly what should continue.
return false
case nrptExternalOwns(result):
if result == nrptHandbackUnverified {
p.healBlockedLoopbackDNS(state, reason)
}
return true
default:
state.setNRPTPolicyOwner(nrptRuleOwnerGroupPolicy, ruleName)
mainLog.Load().Warn().Str("rule", ruleName).Str("reason", reason).
Msg("DNS intercept: a GP catch-all owns the namespace but could not be tested; stopping ctrld-owned recovery and leaving external policy untouched")
p.healBlockedLoopbackDNS(state, reason)
return true
}
}
// nrptExternalOwns reports whether a handback left external policy owning the namespace.
//
// All three outcomes count, not just Verified: an administrator's catch-all owns the
// namespace whether it routes to ctrld (Verified), does not route at all (Unverified), or
// points somewhere else entirely (Conflict). Each is terminal for ctrld-owned recovery,
// because continuing would signal the DNS Client and delete and recreate ctrld's rule
// beside that catch-all - the competing, ambiguous policy #576 exists to avoid. Where the
// external rule is present but ineffective, loopback WFP protect is the only remediation
// left.
func nrptExternalOwns(result nrptHandbackResult) bool {
switch result {
case nrptHandbackVerified, nrptHandbackUnverified, nrptHandbackConflict:
return true
default:
return false
}
}
// nrptTransition runs one complete NRPT mutation - write or delete, signal, record
// owner - with the transition lock held, and reports whether it ran.
//
// Checking interceptStateRevoked and then mutating is not enough on its own: a stop can
// land in between, revoke the state, remove NRPT and finish, after which the mutation
// would write a catch-all pointing at a listener that no longer exists. The stop takes
// this same lock around its own NRPT removal, so holding it across the whole
// observe-mutate-signal step is what makes the two mutually exclusive. Callers must keep
// probe backoffs outside fn: the lock is for a single transition, not for a heal cycle.
func (p *prog) nrptTransition(state *wfpState, fn func()) bool {
p.nrptTransitionMu.Lock()
defer p.nrptTransitionMu.Unlock()
if p.interceptStateRevoked(state) {
return false
}
fn()
return true
}
func (p *prog) activateCtrldNRPTFallback(state *wfpState, reason string) bool {
ops := p.nrptOps()
// Close the observation-to-write race in both directions. Group Policy can refresh
// between the monitor's missing-rule check and this call, and again between this
// check and the write - so the write path re-checks under the transition lock and
// sends us back here when a matching child has appeared. Two passes is enough: the
// second either hands back or writes with the store checked under the lock.
for attempt := 0; attempt < 2; attempt++ {
if ruleName := ops.findGPRule(state.listenerIP); ruleName != "" &&
ops.gpRuleMatches(ruleName, state.listenerIP) {
// A matching child owns the namespace, so hand back rather than create a
// sibling rule. The handback runs its own transition, so it cannot be called
// with the lock held.
if p.nrptHandbackToExternal(state, ruleName, reason) == nrptHandbackKeptCtrld {
// The handback restored ctrld's rule, which is what this call wanted.
return true
}
// Every other outcome means external policy owns the namespace, or that
// nothing could be decided. Either way this must not write a rule beside it.
return false
}
wrote, gpAppeared := p.writeCtrldFallback(ops, state, reason)
if !gpAppeared {
return wrote
}
}
return false
}
// writeCtrldFallback writes ctrld's own catch-all under the transition lock. It reports
// whether it wrote, and whether it stood down because a matching GP child appeared - in
// which case the caller must take the handback path instead.
func (p *prog) writeCtrldFallback(ops nrptOps, state *wfpState, reason string) (wrote, gpAppeared bool) {
p.nrptTransitionMu.Lock()
defer p.nrptTransitionMu.Unlock()
// Re-check under the transition lock. A retired state must not write NRPT policy:
// shutdown has already removed the ctrld-owned catch-all, and re-adding it
// afterwards leaves the DNS Client routing every query to a listener that no longer
// exists - a machine-wide resolution outage, not a cosmetic leftover. Checking
// before the lock cannot close that gap, because a stop can land between the check
// and the write; the stop takes this same lock around its own NRPT removal.
if p.interceptStateRevoked(state) {
mainLog.Load().Debug().Str("reason", reason).
Msg("DNS intercept: skipping ctrld NRPT fallback - intercept was retired")
return false, false
}
// The same reasoning applies to the GP store, which the caller read before taking
// this lock. gpConflicts alone does not cover it: findConflictingGPCatchAll skips a
// *matching* child by design, so without this re-read a policy refresh inside that
// window would let the write land beside an administrator catch-all that no probe has
// tested.
if ruleName := ops.findGPRule(state.listenerIP); ruleName != "" &&
ops.gpRuleMatches(ruleName, state.listenerIP) {
mainLog.Load().Info().Str("rule", ruleName).Str("reason", reason).
Msg("DNS intercept: a matching GP catch-all appeared before the fallback write; handing back instead of writing beside it")
return false, true
}
if ops.gpConflicts(state, reason) {
return false, false
}
// Another health or delayed-recheck path may have written the rule while this one
// waited for the lock; do not duplicate the write and the signalling.
if ops.ruleExists() {
state.setNRPTPolicyOwner(nrptRuleOwnerCtrld, "")
mainLog.Load().Debug().Str("reason", reason).
Msg("DNS intercept: ctrld NRPT rule was already restored by a concurrent transition")
return false, false
}
if err := ops.addRule(state.listenerIP); err != nil {
mainLog.Load().Error().Err(err).Str("reason", reason).
Msg("DNS intercept: failed to activate ctrld-owned NRPT fallback; the health monitor will retry")
state.setNRPTPolicyOwner(nrptRuleOwnerNone, "")
return false, false
}
state.setNRPTPolicyOwner(nrptRuleOwnerCtrld, "")
ops.signal()
mainLog.Load().Warn().Str("reason", reason).
Msg("DNS intercept: GP-managed catch-all unavailable - activated ctrld-owned NRPT fallback")
return true, false
}
// tryAdoptMatchingGPNRPT hands NRPT ownership back to Group Policy when a matching
// external catch-all exists. It reports whether external policy now owns NRPT.
//
// The proof lives in nrptHandbackToExternal: a probe taken while ctrld's fallback is
// still installed proves nothing about the external rule, so the decision is always
// made with ctrld's keys removed.
func (p *prog) tryAdoptMatchingGPNRPT(state *wfpState) bool {
if p.interceptStateRevoked(state) {
return false
}
ruleName := p.nrptOps().findGPRule(state.listenerIP)
if ruleName == "" {
return false
}
switch p.nrptHandbackToExternal(state, ruleName, "matching GP-managed catch-all detected") {
case nrptHandbackVerified:
return true
case nrptHandbackUnverified:
// External policy owns the namespace but is not routing. ctrld must not write a
// competing rule, so the only remediation left is loopback WFP protect. Report
// external ownership: the caller must not fall through to owned recovery.
p.healBlockedLoopbackDNS(state, "GP-managed catch-all owns the namespace but is not routing")
return true
case nrptHandbackConflict:
// External policy now points somewhere other than ctrld. It owns the namespace,
// so report external ownership: owned recovery must not write beside it.
return true
case nrptHandbackKeptCtrld:
// The external rule could not carry DNS alone. A third-party WFP block dropping
// DNS below NRPT looks exactly like this, so try the one remediation that leaves
// external policy untouched; the next handback attempt can then succeed.
p.healBlockedLoopbackDNS(state, "GP-managed catch-all did not carry DNS on its own")
return false
}
return false
}
// nrptNeedsCtrldActivation reports whether ctrld should write its own NRPT catch-all,
// given who owns policy and whether a ctrld rule is currently present.
//
// Owner None is the interesting case: it means an earlier write failed. Nothing else
// re-arms it - nrptProbeAndHeal returns early without ctrld ownership, and the health
// monitor used to skip the owner-None tick entirely - so without retrying, the machine
// keeps no NRPT rule for the rest of the process lifetime. In hard mode that is a full
// DNS outage rather than degraded interception: WFP goes on blocking outbound DNS while
// nothing redirects it to ctrld, and only a restart recovers.
func nrptNeedsCtrldActivation(owner nrptRuleOwner, ruleExists bool) bool {
switch owner {
case nrptRuleOwnerNone:
return true
case nrptRuleOwnerCtrld:
return !ruleExists
default:
// nrptRuleOwnerGroupPolicy: external policy owns the namespace, and a competing
// ctrld catch-all beside it would be ambiguous policy, not recovery.
return false
}
}
// loopbackProtectSettleDelay gives WFP a moment to apply the loopback permits before
// the retry probe goes out.
const loopbackProtectSettleDelay = 500 * time.Millisecond
// healBlockedLoopbackDNS retries the NRPT probe from behind loopback WFP protection and
// reports whether the probe then succeeded.
//
// A failed probe does not always mean the NRPT rule is wrong. Third-party WFP filters -
// OpenVPN's block-outside-dns is the common one - can drop DNS below NRPT, so the
// policy is correct and the packets never arrive. Loopback protect is the one
// remediation that fixes this while leaving externally owned policy untouched. Without
// this attempt, a GP rule blocked that way is never adopted and never healed: the
// monitor keeps finding a "present but not routing" rule for the life of the process.
// It is also the only remediation ctrld may run while external policy owns NRPT.
// signalNRPTChange is not a content-neutral nudge - it forces machine Group Policy via
// RefreshPolicyEx, sends Dnscache paramchange and flushes the resolver cache - so #576's
// contract for a present-but-ineffective GP rule is a warning plus WFP-only retries, with
// no refresh/paramchange/flush loop.
func (p *prog) healBlockedLoopbackDNS(state *wfpState, reason string) bool {
ops := p.nrptOps()
if p.interceptStateRevoked(state) {
return false
}
if hardIntercept {
// Hard mode owns the whole sublayer; loopback protect deliberately does
// nothing there, so a retry probe would only burn the probe timeout.
return false
}
if err := ops.loopback(state); err != nil {
mainLog.Load().Warn().Err(err).Str("reason", reason).
Msg("DNS intercept: could not activate loopback WFP protect while retrying a failed probe")
return false
}
if !ops.wait(state, loopbackProtectSettleDelay) {
return false
}
if !ops.probe(state) {
return false
}
mainLog.Load().Info().Str("reason", reason).
Msg("DNS intercept: probe recovered behind loopback WFP protect - a third-party WFP block was dropping DNS below NRPT")
return true
}
// scheduleDelayedRechecks schedules delayed OS resolver and VPN DNS refreshes after
// network change events. While WFP filters don't get wiped like pf anchors, the OS
// resolver and VPN DNS state can still be stale after VPN disconnect (same issue as macOS).
func (p *prog) scheduleDelayedRechecks() {
for _, delay := range []time.Duration{pfAnchorRecheckDelay, pfAnchorRecheckDelayLong} {
time.AfterFunc(delay, func() {
if p.dnsInterceptState == nil {
return
}
// Refresh OS resolver — VPN may have finished DNS cleanup since the
// immediate handler ran.
ctx := ctrld.LoggerCtx(context.Background(), p.logger.Load())
ctrld.InitializeOsResolver(ctx, true)
if p.vpnDNS != nil {
p.vpnDNS.Refresh(ctx, true)
}
// Delayed rechecks must respect NRPT ownership. The old path looked only
// for ctrld's deterministic key, so it recreated that key immediately
// after startup had correctly adopted a GP-managed catch-all.
state, ok := p.dnsInterceptState.(*wfpState)
if ok && !p.interceptStateRevoked(state) {
owner, _ := state.nrptPolicyOwner()
switch owner {
case nrptRuleOwnerGroupPolicy:
if findMatchingGPNRPTRule(state.listenerIP) == "" {
if p.activateCtrldNRPTFallback(state, "matching GP rule disappeared during delayed network recheck") {
go p.nrptProbeAndHeal(state)
}
}
case nrptRuleOwnerNone, nrptRuleOwnerCtrld:
if nrptNeedsCtrldActivation(owner, nrptCatchAllRuleExists()) {
mainLog.Load().Warn().Msg("DNS intercept: no ctrld NRPT catch-all in place - re-adding")
if p.activateCtrldNRPTFallback(state, "ctrld NRPT rule missing during delayed network recheck") {
go p.nrptProbeAndHeal(state)
}
}
}
}
// WFP watchdog: verify our sublayer still exists. If another program
// or a crash removed it, the block filters are gone too. A timer that
// fires during shutdown must not rebuild what teardown removed, so this
// goes through rebuildDNSIntercept's ownership check.
if ok && !p.interceptStateRevoked(state) && state.engineHandle != 0 && !wfpSublayerExists(state.engineHandle) {
mainLog.Load().Warn().Msg("DNS intercept: WFP sublayer was removed externally — re-creating all filters")
rebuildDNSInterceptFn(p, state, "WFP sublayer removed externally (delayed network recheck)")
}
})
}
}
// repairMissingWFP re-creates the intercept when hard mode has no WFP enforcement -
// because the sublayer disappeared, or because the engine never opened in the first place.
// It reports whether the caller's monitor goroutine must stop.
func (p *prog) repairMissingWFP(state *wfpState) bool {
// Never interrogate WFP for a retired state: shutdown deletes our sublayer, so a
// tick landing in the shutdown window would read "missing" and try to rebuild what
// stopDNSIntercept just removed.
if p.interceptStateRevoked(state) {
return true
}
reason := ""
switch {
case state.engineHandle == 0:
// In dns mode a closed engine is the normal state: loopback protect opens one
// only when it is needed. In hard mode it means startWFPFilters never got the
// engine open, so nothing is being blocked at all. That is the state a startup
// WFP failure leaves behind, and this is what retries it - without this entry
// point the process would run unenforced for its whole life.
if !hardIntercept {
return false
}
reason = "hard mode has no WFP engine - enforcement never started"
case wfpSublayerExists(state.engineHandle):
return false
default:
reason = "WFP sublayer missing during health check"
}
mainLog.Load().Warn().Str("reason", reason).Msg("DNS intercept: WFP health check - re-initializing all filters")
if rebuildDNSInterceptFn(p, state, reason) == interceptRebuildDone {
mainLog.Load().Info().Msg("DNS intercept: WFP filters restored by health monitor")
}
return true
}
// nrptHealthMonitor periodically checks that the NRPT catch-all rule is still
// present and re-adds it if removed by VPN software or Group Policy updates.
// In hard mode, it also verifies the WFP sublayer exists and re-initializes
// all filters if they were removed.
//
// One monitor belongs to one intercept state, and it exits when that state is retired:
// nothing here may act on state after stopDNSIntercept or a rebuild has moved on.
func (p *prog) nrptHealthMonitor(state *wfpState) {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-state.stopCh:
return
case <-ticker.C:
// A tick and a shutdown can become ready together and select picks either,
// so re-check before doing any health work for a retired intercept.
if p.interceptStateRevoked(state) {
return
}
owner, externalRuleName := state.nrptPolicyOwner()
switch owner {
case nrptRuleOwnerNone:
// No owner means an earlier NRPT write failed. Nothing else re-arms
// it - nrptProbeAndHeal returns early without ctrld ownership, and the
// missing-rule branch below used to be unreachable from here - so
// without this retry the machine keeps no NRPT rule for the rest of the
// process lifetime. In hard mode that is a full DNS outage: WFP still
// blocks outbound DNS while nothing redirects it to ctrld. Fall through
// to the activation path below.
case nrptRuleOwnerGroupPolicy:
currentRule := p.nrptOps().findGPRule(state.listenerIP)
if currentRule == "" {
if p.activateCtrldNRPTFallback(state, "matching GP rule disappeared during health check") {
go p.nrptProbeAndHeal(state)
}
continue
}
// Confirm through the handback transition so the verdict is always about
// external policy alone, never about a ctrld rule left behind by an
// earlier run that happens to answer the probe.
switch p.nrptHandbackToExternal(state, currentRule, "GP-managed NRPT health check") {
case nrptHandbackVerified:
// Ownership and stable-success are recorded by the transition.
case nrptHandbackKeptCtrld:
// External policy could not carry DNS; ctrld owns the rule again.
// The next tick continues as ctrld-owned.
case nrptHandbackConflict:
// The administrator's catch-all no longer targets ctrld. Nothing to
// remediate: ctrld may not write beside it, and the conflict is
// already logged by the transition.
default:
mainLog.Load().Warn().Str("rule", externalRuleName).
Msg("DNS intercept: GP-managed NRPT rule is present but its probe failed; leaving external policy untouched")
// The only remediation allowed over external policy: a third-party
// WFP block dropping DNS below NRPT looks exactly like an ineffective
// rule. No policy refresh, paramchange or cache flush here - see
// healBlockedLoopbackDNS.
if !p.healBlockedLoopbackDNS(state, "GP-managed NRPT rule present but not routing") {
go p.nrptProbeAndHeal(state)
}
}
if p.repairMissingWFP(state) {
return
}
continue
case nrptRuleOwnerCtrld:
// Group Policy may have returned after ctrld activated its fallback.
// Prefer the externally owned rule once it proves the same route without
// ctrld's rule installed.
if p.tryAdoptMatchingGPNRPT(state) {
continue
}
}
if nrptNeedsCtrldActivation(owner, nrptCatchAllRuleExists()) {
now := time.Now()
if ok, wait := state.nrptRecoveryLimiter.allow(now, p.cfg); !ok {
if state.nrptRecoveryLimiter.shouldLogSkip(now) {
mainLog.Load().Warn().Str("remaining", wait.String()).
Msg("DNS intercept: NRPT rule restore suppressed after repeated recovery flows")
}
continue
}
reason := "ctrld-owned rule missing during health check"
if owner == nrptRuleOwnerNone {
reason = "no NRPT owner during health check - retrying a failed activation"
}
if p.activateCtrldNRPTFallback(state, reason) {
state.nrptRecoveryLimiter.recordRecoveryFlow(time.Now(), p.cfg)
go p.nrptProbeAndHeal(state)
} else if owner == nrptRuleOwnerNone && hardIntercept {
// Worth shouting about: hard mode keeps blocking outbound DNS
// whether or not NRPT redirects it, so a machine stuck here has no
// working resolver until activation succeeds.
mainLog.Load().Error().
Msg("DNS intercept: hard mode is blocking DNS but no NRPT rule could be activated - DNS will not resolve until this recovers")
}
continue
}
if !p.nrptOps().probe(state) {
mainLog.Load().Warn().Msg("DNS intercept: ctrld-owned NRPT rule present but probe failed, running heal cycle")
go p.nrptProbeAndHeal(state)
} else {
state.nrptRecoveryLimiter.recordStableSuccess()
}
if p.repairMissingWFP(state) {
return // our state was retired: shutdown, or a rebuild that started a new monitor
}
}
}
}
// pfInterceptMonitor is a no-op on Windows — WFP filters are kernel objects
// and don't suffer from the pf translation state corruption that macOS has.
func (p *prog) pfInterceptMonitor() {}
// reconcileForwardedSources is a no-op on Windows — forwarded-workload DNS
// interception is macOS-pf-only; Windows Firewall Mode is tracked separately.
func (p *prog) reconcileForwardedSources() {}
const (
// nrptProbeDomain is the suffix used for NRPT verification probe queries.
// Probes use "_nrpt-probe-<hex>.<nrptProbeDomain>" — ctrld recognizes the
// prefix in the DNS handler and responds immediately without upstream forwarding.
nrptProbeDomain = "nrpt-probe.ctrld.test"
// nrptProbeTimeout is how long to wait for a single probe query to arrive.
nrptProbeTimeout = 2 * time.Second
)
// nrptProbeRunning ensures only one NRPT probe sequence runs at a time.
// Prevents the health monitor and startup from overlapping.
var nrptProbeRunning atomic.Bool
// probeNRPT tests whether the NRPT catch-all rule is actually routing DNS queries
// to ctrld's listener. It sends a DNS query for a synthetic probe domain through
// the Windows DNS Client service (via Go's net.Resolver / GetAddrInfoW). If ctrld
// receives the query on its listener, NRPT is working.
//
// Returns true if NRPT is verified working, false if the probe timed out or a shutdown
// arrived first. Reporting an abandoned probe as "not working" is safe: every recovery
// action a failed probe can trigger checks for revocation before it writes anything.
//
// state is passed explicitly rather than read from p.dnsInterceptState so that startup
// can probe before it publishes, and so a probe belongs to exactly one intercept.
func (p *prog) probeNRPT(state *wfpState) bool {
if state == nil {
return true
}
// Generate unique probe domain to defeat DNS caching.
probeID := fmt.Sprintf("_nrpt-probe-%x.%s", rand.Uint32(), nrptProbeDomain)
// Register this attempt's own domain so overlapping probes - a health tick, a
// handback and a heal cycle can each have one out - cannot cancel each other.
probeCh, deregister := p.registerInterceptProbe(probeID)
defer deregister()
mainLog.Load().Debug().Str("domain", probeID).Msg("DNS intercept: sending NRPT verification probe")
// Use Go's default resolver which calls GetAddrInfoW → DNS Client service → NRPT.
// If NRPT is active, the DNS Client routes this to 127.0.0.1 → ctrld receives it.
// If NRPT isn't loaded, the query goes to interface DNS → times out or NXDOMAIN.
ctx, cancel := context.WithTimeout(context.Background(), nrptProbeTimeout)
defer cancel()
go func() {
resolver := &net.Resolver{}
// We don't care about the result — only whether ctrld's handler receives it.
_, _ = resolver.LookupHost(ctx, probeID)
}()
// Poll for a pending stop so a service stop never waits out the probe timeout on
// top of the lifecycle lock.
shutdownPoll := time.NewTicker(interceptRevocationPollInterval)
defer shutdownPoll.Stop()
for {
select {
case <-probeCh:
mainLog.Load().Debug().Str("domain", probeID).Msg("DNS intercept: NRPT probe received - interception verified")
return true
case <-ctx.Done():
mainLog.Load().Debug().Str("domain", probeID).Msg("DNS intercept: NRPT probe timed out - interception not working")
return false
case <-shutdownPoll.C:
if p.interceptStateRevoked(state) {
mainLog.Load().Debug().Str("domain", probeID).Msg("DNS intercept: abandoning NRPT probe - shutdown in progress")
return false
}
}
}
}
// nrptProbeAndHeal runs the NRPT probe with retries and escalating remediation.
// Called asynchronously after startup and from the health monitor.
//
// Retry sequence:
// 1. Immediate probe.
// 2. If the GP parent is empty, clean it immediately, signal once, then probe.
// This is intentionally before the normal retry loop: policy refresh and
// Dnscache paramchange cannot make local rules visible while GP mode is
// selected by an empty GP parent.
// 3. Otherwise, signal DNS Client with increasing backoff between probes.
//
// state is passed in rather than read from p.dnsInterceptState: this runs from the
// locked start path before anything is published, and it must keep acting on the
// intercept it was launched for and no other.
// It reports whether the cycle ended with a probe that reached ctrld. Callers that need a
// readiness answer - startup's synchronous verification - use that; the asynchronous
// callers ignore it.
func (p *prog) nrptProbeAndHeal(state *wfpState) bool {
if p.interceptStateRevoked(state) {
return false
}
if !nrptProbeRunning.CompareAndSwap(false, true) {
mainLog.Load().Debug().Msg("DNS intercept: NRPT probe already running, skipping")
return false
}
defer nrptProbeRunning.Store(false)
ops := p.nrptOps()
owner, externalRuleName := state.nrptPolicyOwner()
if owner == nrptRuleOwnerGroupPolicy {
currentRule := ops.findGPRule(state.listenerIP)
if currentRule == "" {
if !p.activateCtrldNRPTFallback(state, "matching GP rule disappeared before verification") {
return false
}
owner = nrptRuleOwnerCtrld
} else {
switch p.nrptHandbackToExternal(state, currentRule, "GP-managed NRPT verification") {
case nrptHandbackVerified:
mainLog.Load().Info().Str("rule", currentRule).
Msg("DNS intercept: GP-managed NRPT verified working")
return true
case nrptHandbackKeptCtrld:
// External policy could not carry DNS without ctrld's rule, which the
// transition has restored. Continue as the ctrld-owned flow below.
owner = nrptRuleOwnerCtrld
case nrptHandbackConflict:
// External policy owns the namespace and points elsewhere. Owned recovery
// must not write beside it, so this heal cycle ends here.
return false
default:
if ops.findGPRule(state.listenerIP) == "" {
// The GP child went away while we were looking at it.
if !p.activateCtrldNRPTFallback(state, "matching GP rule disappeared during verification") {
return false
}
owner = nrptRuleOwnerCtrld
break
}
// A matching external rule owns the GP store, so ctrld may not rewrite
// policy or signal the DNS Client here. Loopback WFP protect is the one
// ctrld-owned remediation left: a third-party WFP block dropping DNS
// below NRPT presents exactly as an ineffective rule.
if p.healBlockedLoopbackDNS(state, "GP-managed NRPT present but not routing") {
mainLog.Load().Info().Str("rule", currentRule).
Msg("DNS intercept: GP-managed NRPT verified after loopback WFP protection")
return true
}
mainLog.Load().Error().Str("rule", externalRuleName).
Msg("DNS intercept: GP-managed NRPT remains present but ineffective; no NRPT recovery actions were taken")
return false
}
}
}
if owner != nrptRuleOwnerCtrld {
return false
}
now := time.Now()
if ok, wait := state.nrptRecoveryLimiter.allow(now, p.cfg); !ok {
if state.nrptRecoveryLimiter.shouldLogSkip(now) {
mainLog.Load().Warn().Str("remaining", wait.String()).
Msg("DNS intercept: NRPT recovery suppressed after repeated failed recovery flows")
}
return false
}
remediated := false
defer func() {
if remediated && state != nil {
state.nrptRecoveryLimiter.recordRecoveryFlow(time.Now(), p.cfg)
}
}()
mainLog.Load().Info().Msg("DNS intercept: starting NRPT verification probe sequence")
// Log parent key state for diagnostics.
logNRPTParentKeyState("probe-start")
// Attempt 1: immediate probe
if ops.probe(state) {
mainLog.Load().Info().Msg("DNS intercept: NRPT verified working")
return true
}
// Group Policy can appear while a ctrld-owned heal is running. Once an exact
// administrator catch-all is there, this cycle stops: everything below signals the
// DNS Client or rewrites ctrld's rule, and neither may happen beside external policy.
if p.deferToExternalCatchAll(ops, state, "GP catch-all appeared during ctrld recovery") {
mainLog.Load().Warn().Msg("DNS intercept: matching GP catch-all present during ctrld recovery; stopping NRPT mutations and deferring to Group Policy")
return false
}
remediated = true
// If the GP parent exists but is empty, do not burn retries on Windows
// signaling. Those retries create SIEM noise but cannot succeed because DNS
// Client is still reading the empty GP store instead of the populated local
// store. Delete the blocker, send one notification, then re-probe.
if ops.parentEmpty(nrptBaseKey) {
mainLog.Load().Warn().Msg("DNS intercept: NRPT probe failed with empty GP parent — cleaning before retry signaling")
if ops.cleanParent() {
ops.signal()
if !ops.wait(state, nrptHandbackSettleDelay) {
return false
}
logNRPTParentKeyState("empty-gp-after-clean")
if ops.probe(state) {
mainLog.Load().Info().Msg("DNS intercept: NRPT verified working after empty GP parent cleanup")
return true
}
}
if ops.parentEmpty(nrptBaseKey) {
mainLog.Load().Warn().Msg("DNS intercept: empty GP NRPT parent still present after cleanup; skipping redundant policy refresh retries")
return false
}
}
// Attempts 2-4: signal DNS Client with increasing backoff between probes.
delays := []time.Duration{1 * time.Second, 2 * time.Second, 4 * time.Second}
for i, delay := range delays {
attempt := i + 2
// Each round signals Windows and then waits; a stop must not have the whole
// remaining backoff added to the time it waits for the lifecycle lock.
if p.interceptStateRevoked(state) {
mainLog.Load().Debug().Int("attempt", attempt).
Msg("DNS intercept: intercept retired - abandoning NRPT probe retries")
return false
}
mainLog.Load().Info().Int("attempt", attempt).Str("delay", delay.String()).
Msg("DNS intercept: NRPT probe failed, retrying with policy refresh + paramchange")
logNRPTParentKeyState(fmt.Sprintf("probe-attempt-%d", attempt))
ops.signal()
if !ops.wait(state, delay) {
return false
}
if ops.probe(state) {
mainLog.Load().Info().Int("attempt", attempt).
Msg("DNS intercept: NRPT verified working")
return true
}
}
// Re-check external ownership before the destructive two-phase recovery. A GP refresh
// can land during the bounded retry waits above, and the delete half of that recovery
// must not run once it has: it would strand the machine mid-recovery under policy
// ctrld does not own.
if p.deferToExternalCatchAll(ops, state, "GP catch-all appeared during ctrld retries") {
mainLog.Load().Warn().Msg("DNS intercept: matching GP catch-all present after the retries; skipping ctrld two-phase recovery")
return false
}
if gpCatchAllConflictBlocksFallback(state, "GP catch-all changed during ctrld recovery") {
return false
}
// A stop can land during the retry waits above, and its own NRPT removal has
// already run: deleting and re-adding from here would leave the rule behind.
if p.interceptStateRevoked(state) {
mainLog.Load().Debug().Msg("DNS intercept: intercept retired during probe retries - skipping two-phase NRPT recovery")
return false
}
// Nuclear option: two-phase delete → re-add cycle.
// DNS Client may have cached a stale "no rules" state. Delete our rule,
// signal DNS Client to forget it, wait, then re-add and signal again.
mainLog.Load().Warn().Msg("DNS intercept: all probes failed — attempting two-phase NRPT recovery (delete → signal → re-add)")
listenerIP := state.listenerIP
// Phase 1: Remove our rule and the parent key if now empty. Each phase is its own
// transition: serialized against a stop and against other NRPT writers, but the lock
// is released across the wait between them.
if !p.nrptTransition(state, func() {
_ = ops.removeRule()
// If parent key is now empty after removing our rule, delete it too.
ops.cleanParent()
ops.signal()
logNRPTParentKeyState("nuclear-after-delete")
}) {
mainLog.Load().Debug().Msg("DNS intercept: intercept retired - skipping two-phase NRPT recovery")
return false
}
// Wait for DNS Client to process the deletion. Stopping here is the clean outcome:
// phase 1 left no ctrld rule behind.
if !ops.wait(state, nrptHandbackSettleDelay) {
mainLog.Load().Debug().Msg("DNS intercept: intercept retired mid-recovery - leaving NRPT removed")
return false
}
// Group Policy may refresh while the ctrld-owned rule is absent. Never re-create our
// catch-all beside a newly authoritative GP catch-all. With ctrld's rule already
// gone, this is the one place a probe measures external policy on its own.
if p.deferToExternalCatchAll(ops, state, "GP catch-all appeared during two-phase recovery") {
mainLog.Load().Warn().Msg("DNS intercept: matching GP catch-all appeared during two-phase recovery; skipping ctrld re-add")
return false
}
if ops.gpConflicts(state, "GP catch-all appeared during two-phase recovery") {
return false
}
// Phase 2: Re-add the rule. Phase 1 left NRPT clean, so if a stop arrived during the
// wait the transition refuses and the host stays clean.
readded := false
if !p.nrptTransition(state, func() {
// Re-validate the ownership picture here, inside the lock. The checks above ran
// before this transition queued for nrptTransitionMu, and another health or
// delayed path can complete a whole handback while this one waits: it may have
// given the namespace to a GP child that arrived meanwhile and recorded
// GroupPolicy. Re-adding on top of that would plant the competing catch-all this
// work exists to prevent, and then stamp ctrld ownership over the administrator's.
if owner, ruleName := state.nrptPolicyOwner(); owner == nrptRuleOwnerGroupPolicy {
mainLog.Load().Warn().Str("rule", ruleName).
Msg("DNS intercept: ownership moved to Group Policy while the two-phase re-add waited for the transition lock; leaving NRPT to external policy")
return
}
if ruleName := ops.findGPRule(state.listenerIP); ruleName != "" &&
ops.gpRuleMatches(ruleName, state.listenerIP) {
mainLog.Load().Warn().Str("rule", ruleName).
Msg("DNS intercept: a matching GP catch-all appeared while the two-phase re-add waited for the transition lock; skipping the ctrld re-add")
return
}
if ops.gpConflicts(state, "two-phase re-add revalidation") {
return
}
if err := ops.addRule(listenerIP); err != nil {
mainLog.Load().Error().Err(err).Msg("DNS intercept: failed to re-add NRPT after nuclear recovery")
return
}
ops.signal()
state.setNRPTPolicyOwner(nrptRuleOwnerCtrld, "")
logNRPTParentKeyState("nuclear-after-readd")
readded = true
}) || !readded {
mainLog.Load().Debug().Msg("DNS intercept: two-phase recovery did not restore the ctrld NRPT rule")
return false
}
// Final probe after recovery.
if !ops.wait(state, nrptHandbackSettleDelay) {
// This cycle is retired. Do not clean up from here: the deterministic ctrld key is
// process-global, and by now it can belong to a successor intercept that a rebuild
// started while this goroutine waited - deleting it would take out the successor's
// route and leave hard mode blocking DNS with nothing redirecting it. Teardown of
// the state this cycle belonged to already owns that cleanup.
mainLog.Load().Debug().Msg("DNS intercept: intercept retired after the two-phase re-add - leaving cleanup to the owning teardown")
return false
}
if ops.probe(state) {
mainLog.Load().Info().Msg("DNS intercept: NRPT verified working after two-phase recovery")
return true
}
logNRPTParentKeyState("probe-failed-final")
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.
// Bail out if shutdown is in progress — avoid racing with cleanupWFPFilters.
if p.interceptStateRevoked(state) {
mainLog.Load().Info().Msg("DNS intercept: shutdown in progress, skipping loopback WFP protect activation")
return false
}
if err := p.activateLoopbackWFPProtect(state); 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 false
}
// Retry NRPT probe now that loopback DNS is explicitly permitted through WFP.
if !ops.wait(state, loopbackProtectSettleDelay) {
return false
}
if ops.probe(state) {
mainLog.Load().Info().Msg("DNS intercept: NRPT verified working after loopback WFP protect activation")
return true
}
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.")
return false
}