mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-07-16 13:17:19 +02:00
fix: port Windows VPN DNS settling fix to master
This commit is contained in:
committed by
Cuong Manh Le
parent
0d183feddb
commit
3c740b9693
@@ -1407,7 +1407,7 @@ func (p *prog) scheduleDelayedRechecks() {
|
|||||||
ctx := ctrld.LoggerCtx(context.Background(), p.logger.Load())
|
ctx := ctrld.LoggerCtx(context.Background(), p.logger.Load())
|
||||||
ctrld.InitializeOsResolver(ctx, true)
|
ctrld.InitializeOsResolver(ctx, true)
|
||||||
if p.vpnDNS != nil {
|
if p.vpnDNS != nil {
|
||||||
p.vpnDNS.Refresh(ctx)
|
p.vpnDNS.Refresh(ctx, true)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1623,7 +1623,7 @@ func (p *prog) scheduleDelayedRechecks() {
|
|||||||
ctx := ctrld.LoggerCtx(context.Background(), p.logger.Load())
|
ctx := ctrld.LoggerCtx(context.Background(), p.logger.Load())
|
||||||
ctrld.InitializeOsResolver(ctx, true)
|
ctrld.InitializeOsResolver(ctx, true)
|
||||||
if p.vpnDNS != nil {
|
if p.vpnDNS != nil {
|
||||||
p.vpnDNS.Refresh(ctx)
|
p.vpnDNS.Refresh(ctx, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NRPT watchdog: some VPN software clears NRPT policy rules on
|
// NRPT watchdog: some VPN software clears NRPT policy rules on
|
||||||
|
|||||||
+79
-6
@@ -639,13 +639,14 @@ func (p *prog) proxy(ctx context.Context, req *proxyRequest) *proxyResponse {
|
|||||||
if vpnServers := p.vpnDNS.UpstreamForDomain(domain); len(vpnServers) > 0 {
|
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)
|
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 {
|
for _, server := range vpnServers {
|
||||||
upstreamConfig := p.vpnDNS.upstreamConfigFor(server)
|
upstreamConfig := p.vpnDNS.upstreamConfigFor(server)
|
||||||
ctrld.Log(ctx, p.Debug(), "Querying VPN DNS server: %s", server)
|
ctrld.Log(ctx, p.Debug(), "Querying VPN DNS server: %s", server)
|
||||||
|
|
||||||
answer := p.queryUpstream(ctx, req, "vpn-dns", upstreamConfig)
|
answer := p.queryUpstream(ctx, req, "vpn-dns", upstreamConfig)
|
||||||
if answer != nil {
|
if answer != nil {
|
||||||
|
p.vpnDNS.VPNDNSReachable()
|
||||||
ctrld.Log(ctx, p.Debug(), "VPN DNS query successful")
|
ctrld.Log(ctx, p.Debug(), "VPN DNS query successful")
|
||||||
|
|
||||||
// Update cache if enabled
|
// Update cache if enabled
|
||||||
@@ -654,15 +655,87 @@ func (p *prog) proxy(ctx context.Context, req *proxyRequest) *proxyResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &proxyResponse{answer: answer, cached: false}
|
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")
|
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")
|
ctrld.Log(ctx, p.Debug(), "No cache hit, trying upstreams")
|
||||||
if res := p.tryUpstreams(ctx, req, upstreams, upstreamConfigs); res != nil {
|
if res := p.tryUpstreams(ctx, req, upstreams, upstreamConfigs); res != nil {
|
||||||
ctrld.Log(ctx, p.Debug(), "Upstream query successful")
|
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
|
// even though the physical interface didn't change. Runs after tunnel checks
|
||||||
// so the pf anchor rebuild includes current VPN DNS exemptions.
|
// so the pf anchor rebuild includes current VPN DNS exemptions.
|
||||||
if dnsIntercept && p.vpnDNS != nil {
|
if dnsIntercept && p.vpnDNS != nil {
|
||||||
p.vpnDNS.Refresh(ctx)
|
p.vpnDNS.Refresh(ctx, true)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1749,7 +1822,7 @@ func (p *prog) monitorNetworkChanges(ctx context.Context) error {
|
|||||||
// Refresh VPN DNS routes — runs after tunnel checks so the anchor
|
// Refresh VPN DNS routes — runs after tunnel checks so the anchor
|
||||||
// rebuild includes current VPN DNS exemptions.
|
// rebuild includes current VPN DNS exemptions.
|
||||||
if p.vpnDNS != nil {
|
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.
|
// Schedule delayed re-checks to catch async VPN teardown changes.
|
||||||
p.scheduleDelayedRechecks()
|
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
|
// This also re-exempts VPN DNS servers (which may have changed) and
|
||||||
// removes any DHCP exemptions that were added during recovery.
|
// removes any DHCP exemptions that were added during recovery.
|
||||||
if p.vpnDNS != nil {
|
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.
|
// Reinitialize OS resolver for the recovered state.
|
||||||
|
|||||||
+133
-21
@@ -3,6 +3,7 @@ package cli
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net"
|
"net"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -12,6 +13,8 @@ import (
|
|||||||
"github.com/Control-D-Inc/ctrld"
|
"github.com/Control-D-Inc/ctrld"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var vpnDNSSettlingEnabled = runtime.GOOS == "windows"
|
||||||
|
|
||||||
// vpnDNSExemption represents a VPN DNS server that needs pf/WFP exemption,
|
// vpnDNSExemption represents a VPN DNS server that needs pf/WFP exemption,
|
||||||
// including the interface it was discovered on. The interface is used on macOS
|
// 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
|
// 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
|
// Map of domain suffix → DNS servers for fast lookup
|
||||||
routes map[string][]string
|
routes map[string][]string
|
||||||
logger *atomic.Pointer[ctrld.Logger]
|
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.
|
// Called when VPN DNS server list changes, to update intercept exemptions.
|
||||||
onServersChanged vpnDNSExemptFunc
|
onServersChanged vpnDNSExemptFunc
|
||||||
}
|
}
|
||||||
@@ -56,8 +69,9 @@ func newVPNDNSManager(logger *atomic.Pointer[ctrld.Logger], exemptFunc vpnDNSExe
|
|||||||
|
|
||||||
// Refresh re-discovers VPN DNS configs from the OS.
|
// Refresh re-discovers VPN DNS configs from the OS.
|
||||||
// Called on network change events.
|
// 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)
|
logger := ctrld.LoggerFromCtx(ctx)
|
||||||
|
guardedRefresh := len(guardAgainstNoNameservers) > 0 && guardAgainstNoNameservers[0]
|
||||||
|
|
||||||
ctrld.Log(ctx, logger.Debug(), "Refreshing VPN DNS configurations")
|
ctrld.Log(ctx, logger.Debug(), "Refreshing VPN DNS configurations")
|
||||||
configs := ctrld.DiscoverVPNDNS(ctx)
|
configs := ctrld.DiscoverVPNDNS(ctx)
|
||||||
@@ -80,6 +94,29 @@ func (m *vpnDNSManager) Refresh(ctx context.Context) {
|
|||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
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.configs = configs
|
||||||
m.routes = make(map[string][]string)
|
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",
|
// Collect domain-less VPN DNS servers. These are NOT added to the global
|
||||||
len(m.configs), len(m.routes), len(exemptions))
|
// 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.
|
// Update intercept rules to permit VPN DNS traffic.
|
||||||
// Always call onServersChanged — including when exemptions is empty — so that
|
// 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.
|
// UpstreamForDomain checks if the domain matches any VPN search domain.
|
||||||
// Returns VPN DNS servers if matched, nil otherwise.
|
// Returns VPN DNS servers if matched, nil otherwise.
|
||||||
// Uses suffix matching: "foo.provisur.local" matches "provisur.local"
|
// Uses suffix matching: "foo.provisur.local" matches "provisur.local"
|
||||||
@@ -165,6 +285,15 @@ func (m *vpnDNSManager) UpstreamForDomain(domain string) []string {
|
|||||||
return nil
|
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.
|
// 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().
|
// Used by pf anchor rebuild to include VPN DNS exemptions without a full Refresh().
|
||||||
func (m *vpnDNSManager) CurrentServers() []string {
|
func (m *vpnDNSManager) CurrentServers() []string {
|
||||||
@@ -189,24 +318,7 @@ func (m *vpnDNSManager) CurrentServers() []string {
|
|||||||
func (m *vpnDNSManager) CurrentExemptions() []vpnDNSExemption {
|
func (m *vpnDNSManager) CurrentExemptions() []vpnDNSExemption {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
|
return m.currentExemptionsLocked()
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Routes returns a copy of the current VPN DNS routes for debugging.
|
// Routes returns a copy of the current VPN DNS routes for debugging.
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+207
-8
@@ -8,6 +8,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
@@ -37,6 +38,30 @@ const (
|
|||||||
DS_IP_REQUIRED = 0x00000200
|
DS_IP_REQUIRED = 0x00000200
|
||||||
DS_IS_DNS_NAME = 0x00020000
|
DS_IS_DNS_NAME = 0x00020000
|
||||||
DS_RETURN_DNS_NAME = 0x40000000
|
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 {
|
type DomainControllerInfo struct {
|
||||||
@@ -124,15 +149,16 @@ func getDNSServers(ctx context.Context) ([]string, error) {
|
|||||||
var dcServers []string
|
var dcServers []string
|
||||||
var adDomain string
|
var adDomain string
|
||||||
isDomain := checkDomainJoined(ctx)
|
isDomain := checkDomainJoined(ctx)
|
||||||
|
if !isDomain {
|
||||||
|
cancelDCRetry()
|
||||||
|
}
|
||||||
if isDomain {
|
if isDomain {
|
||||||
domainName, err := system.GetActiveDirectoryDomain()
|
domainName, err := system.GetActiveDirectoryDomain()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Debug().Msgf("Failed to get local AD domain: %v", err)
|
logger.Debug().Msgf("Failed to get local AD domain: %v", err)
|
||||||
} else {
|
} else {
|
||||||
adDomain = domainName
|
adDomain = domainName
|
||||||
// Load netapi32.dll
|
cancelDCRetryForOtherDomain(domainName)
|
||||||
netapi32 := windows.NewLazySystemDLL("netapi32.dll")
|
|
||||||
dsDcName := netapi32.NewProc("DsGetDcNameW")
|
|
||||||
|
|
||||||
var info *DomainControllerInfo
|
var info *DomainControllerInfo
|
||||||
flags := uint32(DS_RETURN_DNS_NAME | DS_IP_REQUIRED | DS_IS_DNS_NAME)
|
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)
|
logger.Debug().Msgf("Attempting to get DC for domain: %s with flags: 0x%x", domainName, flags)
|
||||||
|
|
||||||
// Call DsGetDcNameW with domain name
|
// Call DsGetDcNameW with domain name
|
||||||
ret, _, err := dsDcName.Call(
|
ret, _, err := dsGetDcNameW.Call(
|
||||||
0, // ComputerName - can be NULL
|
0, // ComputerName - can be NULL
|
||||||
uintptr(unsafe.Pointer(domainUTF16)), // DomainName
|
uintptr(unsafe.Pointer(domainUTF16)), // DomainName
|
||||||
0, // DomainGuid - not needed
|
0, // DomainGuid - not needed
|
||||||
@@ -154,17 +180,22 @@ func getDNSServers(ctx context.Context) ([]string, error) {
|
|||||||
|
|
||||||
if ret != 0 {
|
if ret != 0 {
|
||||||
switch ret {
|
switch ret {
|
||||||
case 1355: // ERROR_NO_SUCH_DOMAIN
|
case errNoSuchDomain:
|
||||||
logger.Debug().Msgf("Domain not found: %s (%d)", domainName, ret)
|
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)
|
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)
|
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)
|
logger.Debug().Msgf("RPC server unavailable for domain: %s (%d)", domainName, ret)
|
||||||
default:
|
default:
|
||||||
logger.Debug().Msgf("Failed to get domain controller info for domain %s: %d, %v", domainName, ret, err)
|
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 {
|
} else if info != nil {
|
||||||
defer windows.NetApiBufferFree((*byte)(unsafe.Pointer(info)))
|
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)
|
logger.Debug().Msgf("Found domain controller address: %s", dcAddr)
|
||||||
if ip := net.ParseIP(dcAddr); ip != nil {
|
if ip := net.ParseIP(dcAddr); ip != nil {
|
||||||
dcServers = append(dcServers, ip.String())
|
dcServers = append(dcServers, ip.String())
|
||||||
|
cancelDCRetry()
|
||||||
logger.Debug().Msgf("Added domain controller DNS servers: %v", dcServers)
|
logger.Debug().Msgf("Added domain controller DNS servers: %v", dcServers)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -336,6 +368,173 @@ func checkDomainJoined(ctx context.Context) bool {
|
|||||||
return isDomain
|
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.
|
// 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.
|
// It filters interfaces to include only physical, hardware-based adapters using WMI queries.
|
||||||
func ValidInterfaces(ctx context.Context) map[string]struct{} {
|
func ValidInterfaces(ctx context.Context) map[string]struct{} {
|
||||||
|
|||||||
+60
@@ -139,6 +139,66 @@ func OsResolverNameservers() []string {
|
|||||||
return nss
|
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.
|
// initializeOsResolver performs logic for choosing OS resolver nameserver.
|
||||||
// The logic:
|
// The logic:
|
||||||
//
|
//
|
||||||
|
|||||||
Reference in New Issue
Block a user