diff --git a/cmd/cli/dns_intercept_darwin.go b/cmd/cli/dns_intercept_darwin.go index 7b80ca0..96fc7e9 100644 --- a/cmd/cli/dns_intercept_darwin.go +++ b/cmd/cli/dns_intercept_darwin.go @@ -1407,7 +1407,7 @@ func (p *prog) scheduleDelayedRechecks() { ctx := ctrld.LoggerCtx(context.Background(), p.logger.Load()) ctrld.InitializeOsResolver(ctx, true) if p.vpnDNS != nil { - p.vpnDNS.Refresh(ctx) + p.vpnDNS.Refresh(ctx, true) } }) } diff --git a/cmd/cli/dns_intercept_windows.go b/cmd/cli/dns_intercept_windows.go index 208f0e2..708ee30 100644 --- a/cmd/cli/dns_intercept_windows.go +++ b/cmd/cli/dns_intercept_windows.go @@ -1623,7 +1623,7 @@ func (p *prog) scheduleDelayedRechecks() { ctx := ctrld.LoggerCtx(context.Background(), p.logger.Load()) ctrld.InitializeOsResolver(ctx, true) if p.vpnDNS != nil { - p.vpnDNS.Refresh(ctx) + p.vpnDNS.Refresh(ctx, true) } // NRPT watchdog: some VPN software clears NRPT policy rules on diff --git a/cmd/cli/dns_proxy.go b/cmd/cli/dns_proxy.go index c8a2b74..2a1a24e 100644 --- a/cmd/cli/dns_proxy.go +++ b/cmd/cli/dns_proxy.go @@ -639,13 +639,14 @@ func (p *prog) proxy(ctx context.Context, req *proxyRequest) *proxyResponse { if vpnServers := p.vpnDNS.UpstreamForDomain(domain); len(vpnServers) > 0 { ctrld.Log(ctx, p.Debug(), "VPN DNS route matched for domain %s, using servers: %v", domain, vpnServers) - // Try each VPN DNS server + var gotTransportFailure bool for _, server := range vpnServers { upstreamConfig := p.vpnDNS.upstreamConfigFor(server) ctrld.Log(ctx, p.Debug(), "Querying VPN DNS server: %s", server) answer := p.queryUpstream(ctx, req, "vpn-dns", upstreamConfig) if answer != nil { + p.vpnDNS.VPNDNSReachable() ctrld.Log(ctx, p.Debug(), "VPN DNS query successful") // Update cache if enabled @@ -654,15 +655,87 @@ func (p *prog) proxy(ctx context.Context, req *proxyRequest) *proxyResponse { } return &proxyResponse{answer: answer, cached: false} - } else { - ctrld.Log(ctx, p.Debug(), "VPN DNS server %s failed", server) } + gotTransportFailure = true + ctrld.Log(ctx, p.Debug(), "VPN DNS server %s failed", server) + } + + // Explicit VPN DNS routes are authoritative for their suffix. If all + // routed servers fail at the transport layer while Windows is serving + // retained VPN DNS state, fail closed instead of leaking VPN/internal + // names to normal upstreams. + if gotTransportFailure && p.vpnDNS.ShouldFailClosedAfterVPNDNSTransportFailure(domain, vpnServers) { + ctrld.Log(ctx, p.Debug(), + "All VPN DNS servers had transport failures for %s; returning SERVFAIL while retained VPN DNS state is active", domain) + answer := new(dns.Msg) + answer.SetRcode(req.msg, dns.RcodeServerFailure) + return &proxyResponse{answer: answer, cached: false} } ctrld.Log(ctx, p.Debug(), "All VPN DNS servers failed, falling back to normal upstreams") } } + // Domain-less VPN DNS fallback: when a query is going to upstream.os via a + // split-rule (matched policy) and we have VPN DNS servers with no associated + // domains, try those servers for this query. This handles cases like F5 VPN + // where the VPN doesn't advertise DNS search domains but its DNS servers + // know the internal zones referenced by split-rules (e.g., *.provisur.local). + // These servers are NOT used for general OS resolver queries to avoid + // polluting captive portal / DHCP flows. + if dnsIntercept && p.vpnDNS != nil && req.ufr.matched && + len(upstreams) > 0 && upstreams[0] == upstreamOS && + len(req.msg.Question) > 0 { + if dlServers := p.vpnDNS.DomainlessServers(); len(dlServers) > 0 { + domain := req.msg.Question[0].Name + ctrld.Log(ctx, p.Debug(), + "Split-rule query %s going to upstream.os, trying %d domain-less VPN DNS servers first: %v", + domain, len(dlServers), dlServers) + + var gotDNSAnswer bool + var gotTransportFailure bool + for _, server := range dlServers { + upstreamConfig := p.vpnDNS.upstreamConfigFor(server) + ctrld.Log(ctx, p.Debug(), "Querying domain-less VPN DNS server: %s", server) + + answer := p.queryUpstream(ctx, req, "vpn-dns", upstreamConfig) + if answer != nil { + gotDNSAnswer = true + p.vpnDNS.VPNDNSReachable() + } + if answer != nil && answer.Rcode == dns.RcodeSuccess { + ctrld.Log(ctx, p.Debug(), + "Domain-less VPN DNS server %s answered %s successfully", server, domain) + return &proxyResponse{answer: answer, cached: false} + } + if answer != nil { + ctrld.Log(ctx, p.Debug(), + "Domain-less VPN DNS server %s returned %s for %s, trying next", + server, dns.RcodeToString[answer.Rcode], domain) + } else { + gotTransportFailure = true + ctrld.Log(ctx, p.Debug(), "Domain-less VPN DNS server %s failed for %s", server, domain) + } + } + + // If every domainless VPN DNS attempt failed before receiving a DNS + // packet while Windows is serving retained VPN DNS state, fail closed + // instead of asking LAN/public DNS about internal split-rule names and + // caching false negatives. Reachable negative DNS responses still fall + // through to the old OS fallback behavior below. + if !gotDNSAnswer && gotTransportFailure && p.vpnDNS.ShouldFailClosedAfterVPNDNSTransportFailure(domain, dlServers) { + ctrld.Log(ctx, p.Debug(), + "All domain-less VPN DNS servers had transport failures for %s; returning SERVFAIL while retained VPN DNS state is active", domain) + answer := new(dns.Msg) + answer.SetRcode(req.msg, dns.RcodeServerFailure) + return &proxyResponse{answer: answer, cached: false} + } + + ctrld.Log(ctx, p.Debug(), + "All domain-less VPN DNS servers failed for %s, falling back to OS resolver", domain) + } + } + ctrld.Log(ctx, p.Debug(), "No cache hit, trying upstreams") if res := p.tryUpstreams(ctx, req, upstreams, upstreamConfigs); res != nil { ctrld.Log(ctx, p.Debug(), "Upstream query successful") @@ -1662,7 +1735,7 @@ func (p *prog) monitorNetworkChanges(ctx context.Context) error { // even though the physical interface didn't change. Runs after tunnel checks // so the pf anchor rebuild includes current VPN DNS exemptions. if dnsIntercept && p.vpnDNS != nil { - p.vpnDNS.Refresh(ctx) + p.vpnDNS.Refresh(ctx, true) } return } @@ -1749,7 +1822,7 @@ func (p *prog) monitorNetworkChanges(ctx context.Context) error { // Refresh VPN DNS routes — runs after tunnel checks so the anchor // rebuild includes current VPN DNS exemptions. if p.vpnDNS != nil { - p.vpnDNS.Refresh(ctrld.LoggerCtx(ctx, p.logger.Load())) + p.vpnDNS.Refresh(ctrld.LoggerCtx(ctx, p.logger.Load()), true) } // Schedule delayed re-checks to catch async VPN teardown changes. p.scheduleDelayedRechecks() @@ -2077,7 +2150,7 @@ func (p *prog) completeRecovery(reason RecoveryReason, recovered string) error { // This also re-exempts VPN DNS servers (which may have changed) and // removes any DHCP exemptions that were added during recovery. if p.vpnDNS != nil { - p.vpnDNS.Refresh(ctrld.LoggerCtx(context.Background(), p.logger.Load())) + p.vpnDNS.Refresh(ctrld.LoggerCtx(context.Background(), p.logger.Load()), true) } // Reinitialize OS resolver for the recovered state. diff --git a/cmd/cli/vpn_dns.go b/cmd/cli/vpn_dns.go index e117ffc..707e2ab 100644 --- a/cmd/cli/vpn_dns.go +++ b/cmd/cli/vpn_dns.go @@ -3,6 +3,7 @@ package cli import ( "context" "net" + "runtime" "strings" "sync" "sync/atomic" @@ -12,6 +13,8 @@ import ( "github.com/Control-D-Inc/ctrld" ) +var vpnDNSSettlingEnabled = runtime.GOOS == "windows" + // vpnDNSExemption represents a VPN DNS server that needs pf/WFP exemption, // including the interface it was discovered on. The interface is used on macOS // to create interface-scoped pf exemptions that allow the VPN's local DNS @@ -39,6 +42,16 @@ type vpnDNSManager struct { // Map of domain suffix → DNS servers for fast lookup routes map[string][]string logger *atomic.Pointer[ctrld.Logger] + // DNS servers from VPN interfaces that have no domain/suffix config. + // These are NOT added to the global OS resolver. They're only used + // as additional nameservers for queries that match split-DNS rules + // (from ctrld config, AD domain, or VPN suffix config). + domainlessServers []string + // retainedAfterEmptyDiscovery means Windows reported an empty VPN DNS + // snapshot once while previous VPN DNS state existed. We keep that last-known + // state for one guarded refresh cycle because Windows can briefly report an + // intermediate empty adapter/DNS state after sleep/wake or reconnect. + retainedAfterEmptyDiscovery bool // Called when VPN DNS server list changes, to update intercept exemptions. onServersChanged vpnDNSExemptFunc } @@ -56,8 +69,9 @@ func newVPNDNSManager(logger *atomic.Pointer[ctrld.Logger], exemptFunc vpnDNSExe // Refresh re-discovers VPN DNS configs from the OS. // Called on network change events. -func (m *vpnDNSManager) Refresh(ctx context.Context) { +func (m *vpnDNSManager) Refresh(ctx context.Context, guardAgainstNoNameservers ...bool) { logger := ctrld.LoggerFromCtx(ctx) + guardedRefresh := len(guardAgainstNoNameservers) > 0 && guardAgainstNoNameservers[0] ctrld.Log(ctx, logger.Debug(), "Refreshing VPN DNS configurations") configs := ctrld.DiscoverVPNDNS(ctx) @@ -80,6 +94,29 @@ func (m *vpnDNSManager) Refresh(ctx context.Context) { m.mu.Lock() defer m.mu.Unlock() + if vpnDNSSettlingEnabled && len(configs) == 0 && guardedRefresh && m.hasVPNDNSStateLocked() { + if !m.retainedAfterEmptyDiscovery { + exemptions := m.currentExemptionsLocked() + m.retainedAfterEmptyDiscovery = true + ctrld.Log(ctx, logger.Debug(), + "VPN DNS discovery empty; retaining last-known VPN DNS state for one guarded refresh (%d domainless servers, %d exemptions)", + len(m.domainlessServers), len(exemptions)) + if m.onServersChanged != nil { + if err := m.onServersChanged(exemptions); err != nil { + ctrld.Log(ctx, logger.Error().Err(err), "Failed to re-apply retained VPN DNS exemptions") + } + } + return + } + ctrld.Log(ctx, logger.Debug(), + "VPN DNS discovery still empty on next guarded refresh; clearing retained VPN DNS state (%d domainless servers)", + len(m.domainlessServers)) + } + + // Any discovery path that does not return with retained state clears the + // settling marker: non-empty discovery replaces old servers immediately, and + // an unguarded/second empty discovery clears stale state below. + m.retainedAfterEmptyDiscovery = false m.configs = configs m.routes = make(map[string][]string) @@ -122,8 +159,28 @@ func (m *vpnDNSManager) Refresh(ctx context.Context) { } } - ctrld.Log(ctx, logger.Debug(), "VPN DNS refresh completed: %d configs, %d routes, %d unique exemptions", - len(m.configs), len(m.routes), len(exemptions)) + // Collect domain-less VPN DNS servers. These are NOT added to the global + // OS resolver (that would pollute captive portal / DHCP flows). Instead, + // they're stored separately and only used for queries that match existing + // split-DNS rules (from ctrld config, AD domain, or VPN suffix config). + var domainlessServers []string + seenDomainless := make(map[string]bool) + for _, config := range configs { + if len(config.Domains) == 0 && len(config.Servers) > 0 { + ctrld.Log(ctx, logger.Debug(), "VPN interface %s has DNS servers but no domains, storing as split-rule fallback: %v", + config.InterfaceName, config.Servers) + for _, server := range config.Servers { + if !seenDomainless[server] { + seenDomainless[server] = true + domainlessServers = append(domainlessServers, server) + } + } + } + } + m.domainlessServers = domainlessServers + + ctrld.Log(ctx, logger.Debug(), "VPN DNS refresh completed: %d configs, %d routes, %d domainless servers, %d unique exemptions", + len(m.configs), len(m.routes), len(m.domainlessServers), len(exemptions)) // Update intercept rules to permit VPN DNS traffic. // Always call onServersChanged — including when exemptions is empty — so that @@ -135,6 +192,69 @@ func (m *vpnDNSManager) Refresh(ctx context.Context) { } } +func (m *vpnDNSManager) hasVPNDNSStateLocked() bool { + return len(m.configs) > 0 || len(m.routes) > 0 || len(m.domainlessServers) > 0 +} + +func (m *vpnDNSManager) currentExemptionsLocked() []vpnDNSExemption { + type key struct{ server, iface string } + seen := make(map[key]bool) + var exemptions []vpnDNSExemption + for _, config := range m.configs { + for _, server := range config.Servers { + k := key{server, config.InterfaceName} + if seen[k] { + continue + } + seen[k] = true + exemptions = append(exemptions, vpnDNSExemption{ + Server: server, + Interface: config.InterfaceName, + IsExitMode: config.IsExitMode, + }) + } + } + return exemptions +} + +// ShouldFailClosedAfterVPNDNSTransportFailure reports whether split-rule +// queries should fail closed instead of falling back to OS/public DNS after +// every candidate VPN DNS server failed before returning a DNS packet. This is +// Windows-only and only active while serving retained VPN DNS state from a +// guarded empty discovery, which is the short window where Windows can report +// VPN DNS before routes to those servers are usable after wake/reconnect. +func (m *vpnDNSManager) ShouldFailClosedAfterVPNDNSTransportFailure(domain string, servers []string) bool { + m.mu.RLock() + defer m.mu.RUnlock() + + if !vpnDNSSettlingEnabled || len(servers) == 0 || !m.retainedAfterEmptyDiscovery || !m.hasVPNDNSStateLocked() { + return false + } + + logger := m.logger.Load() + if logger != nil { + logger.Debug().Msgf( + "VPN DNS transport failed for %s while retained VPN DNS state is active; suppressing OS fallback for this query (servers=%v)", + domain, servers) + } + return true +} + +// VPNDNSReachable records that a VPN DNS server returned a DNS response. The +// response may be negative (NXDOMAIN/SERVFAIL); the important signal is that +// the VPN DNS transport is reachable again. +func (m *vpnDNSManager) VPNDNSReachable() { + m.mu.Lock() + defer m.mu.Unlock() + if m.retainedAfterEmptyDiscovery { + logger := m.logger.Load() + if logger != nil { + logger.Debug().Msg("VPN DNS transport recovered; clearing retained-empty-discovery state") + } + } + m.retainedAfterEmptyDiscovery = false +} + // UpstreamForDomain checks if the domain matches any VPN search domain. // Returns VPN DNS servers if matched, nil otherwise. // Uses suffix matching: "foo.provisur.local" matches "provisur.local" @@ -165,6 +285,15 @@ func (m *vpnDNSManager) UpstreamForDomain(domain string) []string { return nil } +// DomainlessServers returns VPN DNS servers that have no associated domains. +// These should only be used for queries matching split-DNS rules, not for +// general OS resolver queries (to avoid polluting captive portal / DHCP flows). +func (m *vpnDNSManager) DomainlessServers() []string { + m.mu.RLock() + defer m.mu.RUnlock() + return append([]string{}, m.domainlessServers...) +} + // CurrentServers returns the current set of unique VPN DNS server IPs. // Used by pf anchor rebuild to include VPN DNS exemptions without a full Refresh(). func (m *vpnDNSManager) CurrentServers() []string { @@ -189,24 +318,7 @@ func (m *vpnDNSManager) CurrentServers() []string { func (m *vpnDNSManager) CurrentExemptions() []vpnDNSExemption { m.mu.RLock() defer m.mu.RUnlock() - - type key struct{ server, iface string } - seen := make(map[key]bool) - var exemptions []vpnDNSExemption - for _, config := range m.configs { - for _, server := range config.Servers { - k := key{server, config.InterfaceName} - if !seen[k] { - seen[k] = true - exemptions = append(exemptions, vpnDNSExemption{ - Server: server, - Interface: config.InterfaceName, - IsExitMode: config.IsExitMode, - }) - } - } - } - return exemptions + return m.currentExemptionsLocked() } // Routes returns a copy of the current VPN DNS routes for debugging. diff --git a/cmd/cli/vpn_dns_test.go b/cmd/cli/vpn_dns_test.go new file mode 100644 index 0000000..de893ec --- /dev/null +++ b/cmd/cli/vpn_dns_test.go @@ -0,0 +1,88 @@ +package cli + +import ( + "context" + "testing" + + "github.com/Control-D-Inc/ctrld" +) + +func withVPNDNSSettlingEnabled(t *testing.T) { + t.Helper() + old := vpnDNSSettlingEnabled + vpnDNSSettlingEnabled = true + t.Cleanup(func() { vpnDNSSettlingEnabled = old }) +} + +func TestVPNDNSRefreshRetainsStateForOneGuardedEmptyDiscovery(t *testing.T) { + withVPNDNSSettlingEnabled(t) + var gotExemptions []vpnDNSExemption + m := newVPNDNSManager(&mainLog, func(exemptions []vpnDNSExemption) error { + gotExemptions = exemptions + return nil + }) + m.configs = []ctrld.VPNDNSConfig{{ + InterfaceName: "Ethernet 6", + Servers: []string{"10.25.37.21", "10.25.37.22"}, + }} + m.domainlessServers = []string{"10.25.37.21", "10.25.37.22"} + + m.Refresh(context.Background(), true) + + if got := m.DomainlessServers(); len(got) != 2 { + t.Fatalf("expected retained domainless servers, got %v", got) + } + if len(gotExemptions) != 2 { + t.Fatalf("expected retained exemptions to be re-applied, got %v", gotExemptions) + } + if !m.retainedAfterEmptyDiscovery { + t.Fatal("expected empty discovery retention to be marked") + } +} + +func TestVPNDNSRefreshClearsOnSecondGuardedEmptyDiscovery(t *testing.T) { + withVPNDNSSettlingEnabled(t) + var gotExemptions []vpnDNSExemption + m := newVPNDNSManager(&mainLog, func(exemptions []vpnDNSExemption) error { + gotExemptions = exemptions + return nil + }) + m.configs = []ctrld.VPNDNSConfig{{ + InterfaceName: "Ethernet 6", + Servers: []string{"10.25.37.21"}, + }} + m.domainlessServers = []string{"10.25.37.21"} + m.retainedAfterEmptyDiscovery = true + + m.Refresh(context.Background(), true) + + if got := m.DomainlessServers(); len(got) != 0 { + t.Fatalf("expected domainless servers to be cleared on second empty discovery, got %v", got) + } + if len(gotExemptions) != 0 { + t.Fatalf("expected empty exemptions after clearing stale state, got %v", gotExemptions) + } + if m.retainedAfterEmptyDiscovery { + t.Fatal("expected retained empty-discovery marker to be cleared with stale state") + } +} + +func TestVPNDNSTransportFailureSuppressesFallbackOnlyWhileRetainingState(t *testing.T) { + withVPNDNSSettlingEnabled(t) + m := newVPNDNSManager(&mainLog, nil) + m.domainlessServers = []string{"10.25.37.21"} + + if m.ShouldFailClosedAfterVPNDNSTransportFailure("splunk.aws.arena.net.", []string{"10.25.37.21"}) { + t.Fatal("did not expect transport failure to suppress OS fallback outside retained empty-discovery state") + } + + m.retainedAfterEmptyDiscovery = true + if !m.ShouldFailClosedAfterVPNDNSTransportFailure("splunk.aws.arena.net.", []string{"10.25.37.21"}) { + t.Fatal("expected transport failure to suppress OS fallback while retained state is active") + } + + m.VPNDNSReachable() + if m.retainedAfterEmptyDiscovery { + t.Fatal("expected reachable DNS response to clear retained empty-discovery state") + } +} diff --git a/nameservers_windows.go b/nameservers_windows.go index 92c3d01..ea92aaa 100644 --- a/nameservers_windows.go +++ b/nameservers_windows.go @@ -8,6 +8,7 @@ import ( "net" "os" "strings" + "sync" "syscall" "time" "unsafe" @@ -37,6 +38,30 @@ const ( DS_IP_REQUIRED = 0x00000200 DS_IS_DNS_NAME = 0x00020000 DS_RETURN_DNS_NAME = 0x40000000 + + // AD DC retry constants. + dcRetryInitialDelay = 1 * time.Second + dcRetryMaxDelay = 30 * time.Second + dcRetryMaxAttempts = 10 + + // DsGetDcName error codes. + errNoSuchDomain uintptr = 1355 + errNoLogonServers uintptr = 1311 + errDCNotFound uintptr = 1004 + errRPCUnavailable uintptr = 1722 + errConnReset uintptr = 10054 + errNetUnreachable uintptr = 1231 +) + +var ( + dcRetryMu sync.Mutex + dcRetryCancel context.CancelFunc + dcRetryDomain string + dcRetryID uint64 + + // Lazy-loaded netapi32 for DsGetDcNameW calls. + netapi32DLL = windows.NewLazySystemDLL("netapi32.dll") + dsGetDcNameW = netapi32DLL.NewProc("DsGetDcNameW") ) type DomainControllerInfo struct { @@ -124,15 +149,16 @@ func getDNSServers(ctx context.Context) ([]string, error) { var dcServers []string var adDomain string isDomain := checkDomainJoined(ctx) + if !isDomain { + cancelDCRetry() + } if isDomain { domainName, err := system.GetActiveDirectoryDomain() if err != nil { logger.Debug().Msgf("Failed to get local AD domain: %v", err) } else { adDomain = domainName - // Load netapi32.dll - netapi32 := windows.NewLazySystemDLL("netapi32.dll") - dsDcName := netapi32.NewProc("DsGetDcNameW") + cancelDCRetryForOtherDomain(domainName) var info *DomainControllerInfo flags := uint32(DS_RETURN_DNS_NAME | DS_IP_REQUIRED | DS_IS_DNS_NAME) @@ -144,7 +170,7 @@ func getDNSServers(ctx context.Context) ([]string, error) { logger.Debug().Msgf("Attempting to get DC for domain: %s with flags: 0x%x", domainName, flags) // Call DsGetDcNameW with domain name - ret, _, err := dsDcName.Call( + ret, _, err := dsGetDcNameW.Call( 0, // ComputerName - can be NULL uintptr(unsafe.Pointer(domainUTF16)), // DomainName 0, // DomainGuid - not needed @@ -154,17 +180,22 @@ func getDNSServers(ctx context.Context) ([]string, error) { if ret != 0 { switch ret { - case 1355: // ERROR_NO_SUCH_DOMAIN + case errNoSuchDomain: logger.Debug().Msgf("Domain not found: %s (%d)", domainName, ret) - case 1311: // ERROR_NO_LOGON_SERVERS + case errNoLogonServers: logger.Debug().Msgf("No logon servers available for domain: %s (%d)", domainName, ret) - case 1004: // ERROR_DC_NOT_FOUND + case errDCNotFound: logger.Debug().Msgf("Domain controller not found for domain: %s (%d)", domainName, ret) - case 1722: // RPC_S_SERVER_UNAVAILABLE + case errRPCUnavailable: logger.Debug().Msgf("RPC server unavailable for domain: %s (%d)", domainName, ret) default: logger.Debug().Msgf("Failed to get domain controller info for domain %s: %d, %v", domainName, ret, err) } + // Start background retry for transient DC errors. + if isTransientDCError(ret) { + logger.Info().Msgf("AD DC detection failed with retryable error %d for %s, ensuring background retry", ret, domainName) + startDCRetry(domainName) + } } else if info != nil { defer windows.NetApiBufferFree((*byte)(unsafe.Pointer(info))) @@ -177,6 +208,7 @@ func getDNSServers(ctx context.Context) ([]string, error) { logger.Debug().Msgf("Found domain controller address: %s", dcAddr) if ip := net.ParseIP(dcAddr); ip != nil { dcServers = append(dcServers, ip.String()) + cancelDCRetry() logger.Debug().Msgf("Added domain controller DNS servers: %v", dcServers) } } else { @@ -336,6 +368,173 @@ func checkDomainJoined(ctx context.Context) bool { return isDomain } +// isTransientDCError returns true if the DsGetDcName error code indicates +// a transient failure that may succeed on retry. ERROR_NO_SUCH_DOMAIN is +// retryable here because we only call this path after Windows already reported +// the machine is domain joined and returned a local AD domain name; during VPN +// DNS churn, DC locator can temporarily fail to resolve that known domain. +func isTransientDCError(code uintptr) bool { + switch code { + case errNoSuchDomain, errConnReset, errRPCUnavailable, errNoLogonServers, errDCNotFound, errNetUnreachable: + return true + default: + return false + } +} + +// cancelDCRetry cancels any in-flight DC retry goroutine. +func cancelDCRetry() { + dcRetryMu.Lock() + defer dcRetryMu.Unlock() + if dcRetryCancel != nil { + dcRetryCancel() + dcRetryCancel = nil + dcRetryDomain = "" + dcRetryID = 0 + } +} + +// cancelDCRetryForOtherDomain keeps an existing retry alive during noisy +// network-change refreshes, but stops it if Windows reports a different AD +// domain. This avoids the start/cancel storm seen when DsGetDcName briefly +// returns ERROR_NO_SUCH_DOMAIN while VPN DNS is still settling. +func cancelDCRetryForOtherDomain(domainName string) { + dcRetryMu.Lock() + defer dcRetryMu.Unlock() + if dcRetryCancel == nil || dcRetryDomain == "" || strings.EqualFold(dcRetryDomain, domainName) { + return + } + dcRetryCancel() + dcRetryCancel = nil + dcRetryDomain = "" + dcRetryID = 0 +} + +// startDCRetry spawns a background goroutine that retries DsGetDcName with +// exponential backoff. On success it appends the DC IP to the OS resolver. +func startDCRetry(domainName string) { + dcRetryMu.Lock() + if dcRetryCancel != nil && strings.EqualFold(dcRetryDomain, domainName) { + LoggerFromCtx(context.Background()).Debug().Msgf("AD DC retry already running for domain %s", domainName) + dcRetryMu.Unlock() + return + } + if dcRetryCancel != nil { + dcRetryCancel() + } + ctx, cancel := context.WithCancel(context.Background()) + dcRetryID++ + retryID := dcRetryID + dcRetryCancel = cancel + dcRetryDomain = domainName + dcRetryMu.Unlock() + + go func(retryID uint64) { + logger := LoggerFromCtx(context.Background()) + defer clearDCRetryIfCurrent(domainName, retryID) + delay := dcRetryInitialDelay + + for attempt := 1; attempt <= dcRetryMaxAttempts; attempt++ { + select { + case <-ctx.Done(): + logger.Debug().Msgf("AD DC retry cancelled for domain %s", domainName) + return + case <-time.After(delay): + } + + logger.Debug().Msgf("AD DC retry attempt %d/%d for domain %s (delay was %v)", + attempt, dcRetryMaxAttempts, domainName, delay) + + dcIP, errCode := tryGetDCAddress(domainName) + if dcIP != "" { + logger.Info().Msgf("AD DC retry succeeded: found DC at %s for domain %s (attempt %d)", + dcIP, domainName, attempt) + if AppendOsResolverNameservers([]string{dcIP}) { + logger.Info().Msgf("Added DC %s to OS resolver nameservers", dcIP) + } else { + logger.Warn().Msgf("AD DC retry: OS resolver not initialized, DC IP %s was not added", dcIP) + } + return + } + + // Permanent error or unexpected empty result — stop retrying. + if errCode != 0 && !isTransientDCError(errCode) { + logger.Debug().Msgf("AD DC retry stopping: permanent error %d for domain %s", errCode, domainName) + return + } + if errCode == 0 { + // DsGetDcName returned success but no usable address — don't retry. + logger.Debug().Msgf("AD DC retry stopping: DsGetDcName returned no address for domain %s", domainName) + return + } + + // Exponential backoff. + delay *= 2 + if delay > dcRetryMaxDelay { + delay = dcRetryMaxDelay + } + } + + logger.Warn().Msgf("AD DC retry exhausted %d attempts for domain %s", dcRetryMaxAttempts, domainName) + }(retryID) +} + +func clearDCRetryIfCurrent(domainName string, retryID uint64) { + dcRetryMu.Lock() + defer dcRetryMu.Unlock() + if dcRetryCancel != nil && dcRetryID == retryID && strings.EqualFold(dcRetryDomain, domainName) { + dcRetryCancel = nil + dcRetryDomain = "" + dcRetryID = 0 + } +} + +// tryGetDCAddress attempts a single DsGetDcName call and returns the DC IP on success, +// or empty string and the error code on failure. +func tryGetDCAddress(domainName string) (string, uintptr) { + logger := LoggerFromCtx(context.Background()) + + var info *DomainControllerInfo + // Use DS_FORCE_REDISCOVERY on retries to bypass the DC locator cache, + // which may have cached the initial transient failure. + flags := uint32(DS_RETURN_DNS_NAME | DS_IP_REQUIRED | DS_IS_DNS_NAME | DS_FORCE_REDISCOVERY) + + domainUTF16, err := windows.UTF16PtrFromString(domainName) + if err != nil { + logger.Debug().Msgf("Failed to convert domain name to UTF16: %v", err) + return "", 0 + } + + ret, _, _ := dsGetDcNameW.Call( + 0, + uintptr(unsafe.Pointer(domainUTF16)), + 0, + 0, + uintptr(flags), + uintptr(unsafe.Pointer(&info))) + + if ret != 0 { + logger.Debug().Msgf("DsGetDcName retry failed for %s: error %d", domainName, ret) + return "", ret + } + + if info == nil { + return "", 0 + } + defer windows.NetApiBufferFree((*byte)(unsafe.Pointer(info))) + + if info.DomainControllerAddress == nil { + return "", 0 + } + + dcAddr := windows.UTF16PtrToString(info.DomainControllerAddress) + dcAddr = strings.TrimPrefix(dcAddr, "\\\\") + if ip := net.ParseIP(dcAddr); ip != nil { + return ip.String(), 0 + } + return "", 0 +} + // ValidInterfaces returns a map of valid network interface names as keys with empty struct values. // It filters interfaces to include only physical, hardware-based adapters using WMI queries. func ValidInterfaces(ctx context.Context) map[string]struct{} { diff --git a/resolver.go b/resolver.go index 1fdda12..f87525c 100644 --- a/resolver.go +++ b/resolver.go @@ -139,6 +139,66 @@ func OsResolverNameservers() []string { return nss } +// AppendOsResolverNameservers adds additional nameservers to the existing OS resolver +// without reinitializing it. This is used for late-arriving nameservers such as AD +// domain controller IPs discovered via background retry. +// Returns true if nameservers were actually added. +func AppendOsResolverNameservers(servers []string) bool { + if len(servers) == 0 { + return false + } + resolverMutex.Lock() + defer resolverMutex.Unlock() + if or == nil { + return false + } + + // Collect existing nameservers to avoid duplicates. + existing := make(map[string]bool) + if lan := or.lanServers.Load(); lan != nil { + for _, server := range *lan { + existing[server] = true + } + } + if pub := or.publicServers.Load(); pub != nil { + for _, server := range *pub { + existing[server] = true + } + } + + var added bool + for _, server := range servers { + // Normalize to host:port format. + if _, _, err := net.SplitHostPort(server); err != nil { + server = net.JoinHostPort(server, "53") + } + if existing[server] { + continue + } + existing[server] = true + added = true + + ip, _, _ := net.SplitHostPort(server) + addr, _ := netip.ParseAddr(ip) + if isLanAddr(addr) { + var newLan []string + if lan := or.lanServers.Load(); lan != nil { + newLan = append(newLan, (*lan)...) + } + newLan = append(newLan, server) + or.lanServers.Store(&newLan) + } else { + var newPub []string + if pub := or.publicServers.Load(); pub != nil { + newPub = append(newPub, (*pub)...) + } + newPub = append(newPub, server) + or.publicServers.Store(&newPub) + } + } + return added +} + // initializeOsResolver performs logic for choosing OS resolver nameserver. // The logic: //