mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-09-04 13:36:35 +02:00
3313 lines
136 KiB
Go
3313 lines
136 KiB
Go
//go:build windows
|
|
|
|
package cli
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"math/rand"
|
|
"net"
|
|
"os/exec"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
"unsafe"
|
|
|
|
"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)
|
|
|
|
// 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 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
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// 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).Dur("timeout", nrptSignalExecTimeout).
|
|
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.
|
|
// Uses DnsFlushResolverCache from dnsapi.dll + RefreshPolicyEx from userenv.dll.
|
|
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()
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
|
|
// 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
|
|
|
|
// 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)", engineHandle)
|
|
|
|
// Clean up any stale sublayer from a previous unclean shutdown.
|
|
// If ctrld crashed or was killed, the non-dynamic WFP session may have left
|
|
// orphaned filters. Deleting the sublayer removes all its child filters.
|
|
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")
|
|
}
|
|
// r1 != 0 means sublayer didn't exist — that's fine, nothing to clean up.
|
|
|
|
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 // host byte order
|
|
mask uint32 // host byte order
|
|
}{
|
|
{"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}, // CGNAT (includes Tailscale)
|
|
}
|
|
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
|
|
}
|
|
// Free the returned sublayer struct.
|
|
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.
|
|
//
|
|
// See: https://gitlab.int.windscribe.com/controld/clients/ctrld/-/issues/526
|
|
func (p *prog) activateLoopbackWFPProtect(state *wfpState) error {
|
|
state.mu.Lock()
|
|
defer state.mu.Unlock()
|
|
|
|
if state.loopbackProtectActive {
|
|
mainLog.Load().Debug().Msg("DNS intercept: loopback WFP protect already active")
|
|
return nil
|
|
}
|
|
// Only activate in DNS mode. Hard mode manages its own full WFP state
|
|
// (block + permit filters in the same sublayer). Activating loopback
|
|
// protect would delete the hard mode sublayer and all its filters.
|
|
if hardIntercept {
|
|
mainLog.Load().Debug().Msg("DNS intercept: skipping loopback WFP protect in hard mode")
|
|
return nil
|
|
}
|
|
|
|
mainLog.Load().Info().Msg("DNS intercept: activating loopback WFP protect (countering third-party DNS block filters)")
|
|
|
|
// Open WFP engine if not already open (DNS mode doesn't open it normally).
|
|
if state.engineHandle == 0 {
|
|
var engineHandle uintptr
|
|
session := fwpmSession0{}
|
|
sessionName, _ := windows.UTF16PtrFromString("ctrld DNS Loopback Protect")
|
|
session.displayData.name = sessionName
|
|
|
|
const rpcCAuthnDefault = 0xFFFFFFFF
|
|
r1, _, _ := procFwpmEngineOpen0.Call(
|
|
0,
|
|
uintptr(rpcCAuthnDefault),
|
|
0,
|
|
uintptr(unsafe.Pointer(&session)),
|
|
uintptr(unsafe.Pointer(&engineHandle)),
|
|
)
|
|
if r1 != 0 {
|
|
return fmt.Errorf("FwpmEngineOpen0 failed: HRESULT 0x%x", r1)
|
|
}
|
|
mainLog.Load().Info().Msgf("DNS intercept: WFP engine opened for loopback protect (handle: 0x%x)", engineHandle)
|
|
state.engineHandle = engineHandle
|
|
}
|
|
|
|
// Clean up any stale sublayer from a previous session.
|
|
procFwpmSubLayerDeleteByKey0.Call(
|
|
state.engineHandle,
|
|
uintptr(unsafe.Pointer(&ctrldSubLayerGUID)),
|
|
)
|
|
|
|
// Create sublayer at maximum priority.
|
|
sublayer := fwpmSublayer0{
|
|
subLayerKey: ctrldSubLayerGUID,
|
|
weight: 0xFFFF,
|
|
}
|
|
sublayerName, _ := windows.UTF16PtrFromString("ctrld DNS Loopback Protect Sublayer")
|
|
sublayerDesc, _ := windows.UTF16PtrFromString("Permits DNS to localhost, overriding third-party VPN block filters")
|
|
sublayer.displayData.name = sublayerName
|
|
sublayer.displayData.description = sublayerDesc
|
|
|
|
r1, _, _ := procFwpmSubLayerAdd0.Call(
|
|
state.engineHandle,
|
|
uintptr(unsafe.Pointer(&sublayer)),
|
|
0,
|
|
)
|
|
if r1 != 0 {
|
|
return fmt.Errorf("FwpmSubLayerAdd0 failed: HRESULT 0x%x", r1)
|
|
}
|
|
|
|
// Add hard permit filters for loopback DNS (v4+v6, UDP+TCP).
|
|
permitFilters := []struct {
|
|
name string
|
|
layer windows.GUID
|
|
proto uint8
|
|
}{
|
|
{"Loopback Protect: Permit DNS to localhost (IPv4/UDP)", fwpmLayerALEAuthConnectV4, ipprotoUDP},
|
|
{"Loopback Protect: Permit DNS to localhost (IPv4/TCP)", fwpmLayerALEAuthConnectV4, ipprotoTCP},
|
|
{"Loopback Protect: Permit DNS to localhost (IPv6/UDP)", fwpmLayerALEAuthConnectV6, ipprotoUDP},
|
|
{"Loopback Protect: Permit DNS to localhost (IPv6/TCP)", fwpmLayerALEAuthConnectV6, ipprotoTCP},
|
|
}
|
|
|
|
for _, pf := range permitFilters {
|
|
filterID, err := p.addWFPHardPermitLocalhostFilter(state.engineHandle, pf.name, pf.layer, pf.proto, state.listenerIP)
|
|
if err != nil {
|
|
// Partial failure — clean up what we added (already holding mu).
|
|
p.deactivateLoopbackWFPProtectLocked(state)
|
|
return fmt.Errorf("failed to add loopback protect filter %q: %w", pf.name, err)
|
|
}
|
|
state.loopbackPermitIDs = append(state.loopbackPermitIDs, filterID)
|
|
mainLog.Load().Debug().Str("filter", pf.name).Uint64("id", filterID).Msg("DNS intercept: added loopback protect filter")
|
|
}
|
|
|
|
state.loopbackProtectActive = true
|
|
mainLog.Load().Info().Int("filters", len(state.loopbackPermitIDs)).
|
|
Msg("DNS intercept: loopback WFP protect activated — localhost DNS permitted with CLEAR_ACTION_RIGHT")
|
|
return nil
|
|
}
|
|
|
|
// 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
|
|
// servers (VPN disconnected), it just removes the old permits — leaving only
|
|
// the localhost permits and block-all filters active.
|
|
//
|
|
// 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 (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.
|
|
ctrld.InitializeOsResolver(true)
|
|
if p.vpnDNS != nil {
|
|
p.vpnDNS.Refresh(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().Dur("remaining", wait).
|
|
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() {}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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().Dur("remaining", wait).
|
|
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).Dur("delay", delay).
|
|
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.
|
|
// See: https://gitlab.int.windscribe.com/controld/clients/ctrld/-/issues/526
|
|
// 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
|
|
}
|