fix: port Windows VPN DNS settling fix to master

This commit is contained in:
Codescribe
2026-05-27 03:08:02 -04:00
committed by Cuong Manh Le
parent 0d183feddb
commit 3c740b9693
7 changed files with 569 additions and 37 deletions
+79 -6
View File
@@ -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.