feat: add VPN DNS split routing

This commit is contained in:
Codescribe
2026-03-05 04:50:23 -05:00
committed by Cuong Manh Le
parent b9fb3b9176
commit 0a7bbb99e8
6 changed files with 847 additions and 0 deletions

236
cmd/cli/vpn_dns.go Normal file
View File

@@ -0,0 +1,236 @@
package cli
import (
"context"
"strings"
"sync"
"sync/atomic"
"tailscale.com/net/netmon"
"github.com/Control-D-Inc/ctrld"
)
// 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
// handler (e.g., Tailscale's MagicDNS Network Extension) to receive queries
// from all processes — not just ctrld. Without the interface scope, VPN DNS
// handlers that operate at the packet level (Network Extensions) never see
// the queries because pf intercepts them first.
type vpnDNSExemption struct {
Server string // DNS server IP (e.g., "100.100.100.100")
Interface string // Interface name from scutil (e.g., "utun11"), may be empty
IsExitMode bool // True if this VPN is in exit/full-tunnel mode (all traffic routed through VPN)
}
// vpnDNSExemptFunc is called when VPN DNS servers change, to update
// the intercept layer (WFP/pf) to permit VPN DNS traffic.
// On macOS, exemptions are interface-scoped to allow VPN local DNS handlers
// (e.g., Tailscale MagicDNS) to receive queries from all processes.
type vpnDNSExemptFunc func(exemptions []vpnDNSExemption) error
// vpnDNSManager tracks active VPN DNS configurations and provides
// domain-to-upstream routing for VPN split DNS.
type vpnDNSManager struct {
mu sync.RWMutex
configs []ctrld.VPNDNSConfig
// Map of domain suffix → DNS servers for fast lookup
routes map[string][]string
logger *atomic.Pointer[ctrld.Logger]
// Called when VPN DNS server list changes, to update intercept exemptions.
onServersChanged vpnDNSExemptFunc
}
// newVPNDNSManager creates a new manager. Only call when dnsIntercept is active.
// exemptFunc is called whenever VPN DNS servers are discovered/changed, to update
// the OS-level intercept rules to permit ctrld's outbound queries to those IPs.
func newVPNDNSManager(logger *atomic.Pointer[ctrld.Logger], exemptFunc vpnDNSExemptFunc) *vpnDNSManager {
return &vpnDNSManager{
routes: make(map[string][]string),
logger: logger,
onServersChanged: exemptFunc,
}
}
// Refresh re-discovers VPN DNS configs from the OS.
// Called on network change events.
func (m *vpnDNSManager) Refresh(ctx context.Context) {
logger := ctrld.LoggerFromCtx(ctx)
ctrld.Log(ctx, logger.Debug(), "Refreshing VPN DNS configurations")
configs := ctrld.DiscoverVPNDNS(ctx)
// Detect exit mode: if the default route goes through a VPN DNS interface,
// the VPN is routing ALL traffic (exit node / full tunnel). This is more
// reliable than scutil flag parsing because the routing table is the ground
// truth for traffic flow.
if dri, err := netmon.DefaultRouteInterface(); err == nil && dri != "" {
for i := range configs {
if configs[i].InterfaceName == dri {
if !configs[i].IsExitMode {
ctrld.Log(ctx, logger.Info(), "VPN DNS on %s: default route interface match — EXIT MODE (route-based detection)", dri)
}
configs[i].IsExitMode = true
}
}
}
m.mu.Lock()
defer m.mu.Unlock()
m.configs = configs
m.routes = make(map[string][]string)
// Build domain -> DNS servers mapping
for _, config := range configs {
ctrld.Log(ctx, logger.Debug(), "Processing VPN interface %s with %d domains and %d servers",
config.InterfaceName, len(config.Domains), len(config.Servers))
for _, domain := range config.Domains {
// Normalize domain: remove leading dot, Linux routing domain prefix (~),
// and convert to lowercase.
domain = strings.TrimPrefix(domain, "~") // Linux resolvectl routing domain prefix
domain = strings.TrimPrefix(domain, ".")
domain = strings.ToLower(domain)
if domain != "" {
m.routes[domain] = append([]string{}, config.Servers...)
ctrld.Log(ctx, logger.Debug(), "Added VPN DNS route: %s -> %v", domain, config.Servers)
}
}
}
// Collect unique VPN DNS exemptions (server + interface) for pf/WFP rules.
// We track server+interface pairs because the same server IP on different
// interfaces needs separate exemptions (interface-scoped on macOS).
type exemptionKey struct{ server, iface string }
seen := make(map[exemptionKey]bool)
var exemptions []vpnDNSExemption
for _, config := range configs {
for _, server := range config.Servers {
key := exemptionKey{server, config.InterfaceName}
if !seen[key] {
seen[key] = true
exemptions = append(exemptions, vpnDNSExemption{
Server: server,
Interface: config.InterfaceName,
IsExitMode: config.IsExitMode,
})
}
}
}
ctrld.Log(ctx, logger.Debug(), "VPN DNS refresh completed: %d configs, %d routes, %d unique exemptions",
len(m.configs), len(m.routes), len(exemptions))
// Update intercept rules to permit VPN DNS traffic.
// Always call onServersChanged — including when exemptions is empty — so that
// stale exemptions from a previous VPN session get cleared on disconnect.
if m.onServersChanged != nil {
if err := m.onServersChanged(exemptions); err != nil {
ctrld.Log(ctx, logger.Error().Err(err), "Failed to update intercept exemptions for VPN DNS servers")
}
}
}
// 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"
func (m *vpnDNSManager) UpstreamForDomain(domain string) []string {
if domain == "" {
return nil
}
m.mu.RLock()
defer m.mu.RUnlock()
// Normalize domain (remove trailing dot, convert to lowercase)
domain = strings.TrimSuffix(domain, ".")
domain = strings.ToLower(domain)
// First try exact match
if servers, ok := m.routes[domain]; ok {
return append([]string{}, servers...) // Return copy to avoid race conditions
}
// Try suffix matching - check if domain ends with any of our VPN domains
for vpnDomain, servers := range m.routes {
if strings.HasSuffix(domain, "."+vpnDomain) {
return append([]string{}, servers...) // Return copy
}
}
return nil
}
// 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 {
m.mu.RLock()
defer m.mu.RUnlock()
seen := make(map[string]bool)
var servers []string
for _, ss := range m.routes {
for _, s := range ss {
if !seen[s] {
seen[s] = true
servers = append(servers, s)
}
}
}
return servers
}
// CurrentExemptions returns VPN DNS server + interface pairs for pf exemption rules.
// Used by pf anchor rebuild paths that need interface-scoped exemptions.
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
}
// Routes returns a copy of the current VPN DNS routes for debugging.
func (m *vpnDNSManager) Routes() map[string][]string {
m.mu.RLock()
defer m.mu.RUnlock()
routes := make(map[string][]string)
for domain, servers := range m.routes {
routes[domain] = append([]string{}, servers...)
}
return routes
}
// upstreamConfigFor creates a legacy upstream configuration for the given VPN DNS server.
func (m *vpnDNSManager) upstreamConfigFor(server string) *ctrld.UpstreamConfig {
endpoint := server
if !strings.Contains(server, ":") {
endpoint = server + ":53"
}
return &ctrld.UpstreamConfig{
Name: "VPN DNS",
Type: ctrld.ResolverTypeLegacy,
Endpoint: endpoint,
Timeout: 2000, // 2 second timeout for VPN DNS queries
}
}

11
vpn_dns_config.go Normal file
View File

@@ -0,0 +1,11 @@
package ctrld
// VPNDNSConfig represents DNS configuration discovered from a VPN interface.
// Used by the dns-intercept mode to detect VPN split DNS settings and
// route matching queries to VPN DNS servers automatically.
type VPNDNSConfig struct {
InterfaceName string // VPN adapter name (e.g., "F5 Networks VPN")
Servers []string // DNS server IPs (e.g., ["10.50.10.77"])
Domains []string // Search/match domains (e.g., ["provisur.local"])
IsExitMode bool // True if this VPN is also the system default resolver (exit node mode)
}

244
vpn_dns_darwin.go Normal file
View File

@@ -0,0 +1,244 @@
//go:build darwin
package ctrld
import (
"bufio"
"context"
"net"
"os/exec"
"regexp"
"strconv"
"strings"
)
// DiscoverVPNDNS discovers DNS servers and search domains from VPN interfaces on macOS.
// Parses `scutil --dns` output to find VPN resolver configurations.
func DiscoverVPNDNS(ctx context.Context) []VPNDNSConfig {
logger := LoggerFromCtx(ctx)
Log(ctx, logger.Debug(), "Discovering VPN DNS configurations on macOS")
cmd := exec.CommandContext(ctx, "scutil", "--dns")
output, err := cmd.Output()
if err != nil {
Log(ctx, logger.Error().Err(err), "Failed to execute scutil --dns")
return nil
}
return parseScutilOutput(ctx, string(output))
}
// parseScutilOutput parses the output of `scutil --dns` to extract VPN DNS configurations.
func parseScutilOutput(ctx context.Context, output string) []VPNDNSConfig {
logger := LoggerFromCtx(ctx)
Log(ctx, logger.Debug(), "Parsing scutil --dns output")
resolverBlockRe := regexp.MustCompile(`resolver #(\d+)`)
searchDomainRe := regexp.MustCompile(`search domain\[\d+\] : (.+)`)
// Matches singular "domain : value" entries (e.g., Tailscale per-domain resolvers).
singleDomainRe := regexp.MustCompile(`^domain\s+:\s+(.+)`)
nameserverRe := regexp.MustCompile(`nameserver\[\d+\] : (.+)`)
ifIndexRe := regexp.MustCompile(`if_index : (\d+) \((.+)\)`)
var vpnConfigs []VPNDNSConfig
var currentResolver *resolverInfo
var allResolvers []resolverInfo
scanner := bufio.NewScanner(strings.NewReader(output))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if match := resolverBlockRe.FindStringSubmatch(line); match != nil {
if currentResolver != nil {
allResolvers = append(allResolvers, *currentResolver)
}
resolverNum, _ := strconv.Atoi(match[1])
currentResolver = &resolverInfo{
Number: resolverNum,
}
continue
}
if currentResolver == nil {
continue
}
if match := searchDomainRe.FindStringSubmatch(line); match != nil {
domain := strings.TrimSpace(match[1])
if domain != "" {
currentResolver.Domains = append(currentResolver.Domains, domain)
}
continue
}
// Parse singular "domain : value" (used by Tailscale per-domain resolvers).
if match := singleDomainRe.FindStringSubmatch(line); match != nil {
domain := strings.TrimSpace(match[1])
if domain != "" {
currentResolver.Domains = append(currentResolver.Domains, domain)
}
continue
}
if match := nameserverRe.FindStringSubmatch(line); match != nil {
server := strings.TrimSpace(match[1])
if ip := net.ParseIP(server); ip != nil && !ip.IsLoopback() {
currentResolver.Servers = append(currentResolver.Servers, server)
}
continue
}
if match := ifIndexRe.FindStringSubmatch(line); match != nil {
currentResolver.InterfaceName = strings.TrimSpace(match[2])
continue
}
if strings.HasPrefix(line, "flags") {
if idx := strings.Index(line, ":"); idx >= 0 {
currentResolver.Flags = strings.TrimSpace(line[idx+1:])
}
continue
}
}
if currentResolver != nil {
allResolvers = append(allResolvers, *currentResolver)
}
for _, resolver := range allResolvers {
if isSplitDNSResolver(ctx, &resolver) {
ifaceName := resolver.InterfaceName
// When scutil doesn't provide if_index (common with Tailscale MagicDNS
// per-domain resolvers), look up the outbound interface from the routing
// table. This is needed for interface-scoped pf exemptions — without the
// interface name, we can't generate rules that let the VPN's Network
// Extension handle DNS queries from all processes.
if ifaceName == "" && len(resolver.Servers) > 0 {
if routeIface := resolveInterfaceForIP(ctx, resolver.Servers[0]); routeIface != "" {
ifaceName = routeIface
Log(ctx, logger.Debug(), "Resolver #%d: resolved interface %q from routing table for %s",
resolver.Number, routeIface, resolver.Servers[0])
}
}
config := VPNDNSConfig{
InterfaceName: ifaceName,
Servers: resolver.Servers,
Domains: resolver.Domains,
}
vpnConfigs = append(vpnConfigs, config)
Log(ctx, logger.Debug(), "Found VPN DNS config - Interface: %s, Servers: %v, Domains: %v",
config.InterfaceName, config.Servers, config.Domains)
}
}
// Detect exit mode: if a VPN DNS server IP also appears as the system's default
// resolver (no search domains, no Supplemental flag), the VPN is routing ALL traffic
// (not just specific domains). In exit mode, ctrld must continue intercepting DNS
// on the VPN interface to enforce its profile on all queries.
defaultResolverIPs := make(map[string]bool)
for _, resolver := range allResolvers {
if len(resolver.Servers) > 0 && len(resolver.Domains) == 0 &&
!strings.Contains(resolver.Flags, "Supplemental") &&
!strings.Contains(resolver.Flags, "Scoped") {
for _, server := range resolver.Servers {
defaultResolverIPs[server] = true
}
}
}
for i := range vpnConfigs {
for _, server := range vpnConfigs[i].Servers {
if defaultResolverIPs[server] {
vpnConfigs[i].IsExitMode = true
Log(ctx, logger.Info(), "VPN DNS config on %s detected as EXIT MODE — server %s is also the system default resolver",
vpnConfigs[i].InterfaceName, server)
break
}
}
}
Log(ctx, logger.Debug(), "VPN DNS discovery completed: found %d VPN interfaces", len(vpnConfigs))
return vpnConfigs
}
// resolveInterfaceForIP uses the macOS routing table to determine which network
// interface would be used to reach the given IP address. This is a fallback for
// when scutil --dns doesn't include if_index in the resolver entry (common with
// Tailscale MagicDNS per-domain resolvers).
//
// Runs: route -n get <ip> and parses the "interface:" line from the output.
// Returns empty string on any error (callers should treat as "unknown interface").
func resolveInterfaceForIP(ctx context.Context, ip string) string {
logger := LoggerFromCtx(ctx)
cmd := exec.CommandContext(ctx, "route", "-n", "get", ip)
output, err := cmd.Output()
if err != nil {
Log(ctx, logger.Debug(), "route -n get %s failed: %v", ip, err)
return ""
}
// Parse "interface: utun11" from route output.
for _, line := range strings.Split(string(output), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "interface:") {
iface := strings.TrimSpace(strings.TrimPrefix(line, "interface:"))
if iface != "" && iface != "lo0" {
return iface
}
}
}
return ""
}
// resolverInfo holds information about a resolver block from scutil --dns output.
type resolverInfo struct {
Number int
InterfaceName string
Servers []string
Domains []string
Flags string // Raw flags line (e.g., "Supplemental, Request A records")
}
// isSplitDNSResolver reports whether a scutil --dns resolver entry represents a
// split DNS configuration that ctrld should forward to. Any resolver with both
// non-loopback DNS servers and search domains qualifies — this covers VPN adapters
// (F5, Tailscale, Cisco AnyConnect, etc.) and any other virtual interface that
// registers search domains (e.g., corporate proxies, containers).
//
// We intentionally avoid heuristics about interface names or domain suffixes:
// if an interface declares "these domains resolve via these servers," we honor it.
// The only exclusions are mDNS entries (bare ".local" without an interface binding).
//
// Note: loopback servers are already filtered out during parsing in parseScutilOutput.
func isSplitDNSResolver(ctx context.Context, resolver *resolverInfo) bool {
logger := LoggerFromCtx(ctx)
// Must have both DNS servers and search domains to be a useful split DNS route.
if len(resolver.Servers) == 0 || len(resolver.Domains) == 0 {
Log(ctx, logger.Debug(), "Resolver #%d: skipping — no servers (%d) or no domains (%d)",
resolver.Number, len(resolver.Servers), len(resolver.Domains))
return false
}
// Skip multicast DNS entries. scutil --dns shows a resolver for ".local" that
// handles mDNS — it has no interface binding and the sole domain is "local".
// Real VPN entries with ".local" suffix (e.g., "provisur.local") will have an
// interface name or additional domains.
if len(resolver.Domains) == 1 {
domain := strings.ToLower(strings.TrimSpace(resolver.Domains[0]))
if domain == "local" || domain == ".local" {
Log(ctx, logger.Debug(), "Resolver #%d: skipping — mDNS resolver", resolver.Number)
return false
}
}
Log(ctx, logger.Debug(), "Resolver #%d: split DNS resolver — interface: %q, servers: %v, domains: %v",
resolver.Number, resolver.InterfaceName, resolver.Servers, resolver.Domains)
return true
}

240
vpn_dns_linux.go Normal file
View File

@@ -0,0 +1,240 @@
//go:build linux
package ctrld
import (
"bufio"
"context"
"net"
"os/exec"
"regexp"
"strings"
)
// DiscoverVPNDNS discovers DNS servers and search domains from VPN interfaces on Linux.
// Uses resolvectl status to find per-link DNS configurations.
func DiscoverVPNDNS(ctx context.Context) []VPNDNSConfig {
logger := LoggerFromCtx(ctx)
Log(ctx, logger.Debug(), "Discovering VPN DNS configurations on Linux")
// Try resolvectl first (systemd-resolved)
if configs := parseResolvectlStatus(ctx); len(configs) > 0 {
return configs
}
// Fallback: check for VPN interfaces with DNS in /etc/resolv.conf
Log(ctx, logger.Debug(), "resolvectl not available or no results, trying fallback method")
return parseVPNInterfacesDNS(ctx)
}
// parseResolvectlStatus parses the output of `resolvectl status` to extract VPN DNS configurations.
func parseResolvectlStatus(ctx context.Context) []VPNDNSConfig {
logger := LoggerFromCtx(ctx)
cmd := exec.CommandContext(ctx, "resolvectl", "status")
output, err := cmd.Output()
if err != nil {
Log(ctx, logger.Debug(), "Failed to execute resolvectl status: %v", err)
return nil
}
Log(ctx, logger.Debug(), "Parsing resolvectl status output")
// Regular expressions to match link sections and their properties
linkRe := regexp.MustCompile(`^Link (\d+) \((.+)\):`)
dnsServersRe := regexp.MustCompile(`^\s+DNS Servers?: (.+)`)
dnsDomainsRe := regexp.MustCompile(`^\s+DNS Domain: (.+)`)
var vpnConfigs []VPNDNSConfig
var currentLink *linkInfo
scanner := bufio.NewScanner(strings.NewReader(string(output)))
for scanner.Scan() {
line := scanner.Text()
// Check for new link section
if match := linkRe.FindStringSubmatch(line); match != nil {
// Process previous link if it's a VPN
if currentLink != nil && isVPNLink(ctx, currentLink) {
config := VPNDNSConfig{
InterfaceName: currentLink.InterfaceName,
Servers: currentLink.Servers,
Domains: currentLink.Domains,
}
vpnConfigs = append(vpnConfigs, config)
Log(ctx, logger.Debug(), "Found VPN DNS config - Interface: %s, Servers: %v, Domains: %v",
config.InterfaceName, config.Servers, config.Domains)
}
// Start new link
currentLink = &linkInfo{
InterfaceName: strings.TrimSpace(match[2]),
}
continue
}
if currentLink == nil {
continue
}
// Parse DNS servers
if match := dnsServersRe.FindStringSubmatch(line); match != nil {
serverList := strings.TrimSpace(match[1])
for _, server := range strings.Fields(serverList) {
if ip := net.ParseIP(server); ip != nil && !ip.IsLoopback() {
currentLink.Servers = append(currentLink.Servers, server)
}
}
continue
}
// Parse DNS domains
if match := dnsDomainsRe.FindStringSubmatch(line); match != nil {
domainList := strings.TrimSpace(match[1])
for _, domain := range strings.Fields(domainList) {
domain = strings.TrimSpace(domain)
if domain != "" {
currentLink.Domains = append(currentLink.Domains, domain)
}
}
continue
}
}
// Don't forget the last link
if currentLink != nil && isVPNLink(ctx, currentLink) {
config := VPNDNSConfig{
InterfaceName: currentLink.InterfaceName,
Servers: currentLink.Servers,
Domains: currentLink.Domains,
}
vpnConfigs = append(vpnConfigs, config)
Log(ctx, logger.Debug(), "Found VPN DNS config - Interface: %s, Servers: %v, Domains: %v",
config.InterfaceName, config.Servers, config.Domains)
}
Log(ctx, logger.Debug(), "resolvectl parsing completed: found %d VPN interfaces", len(vpnConfigs))
return vpnConfigs
}
// parseVPNInterfacesDNS is a fallback method that looks for VPN interfaces and tries to
// find their DNS configuration from various sources.
func parseVPNInterfacesDNS(ctx context.Context) []VPNDNSConfig {
logger := LoggerFromCtx(ctx)
Log(ctx, logger.Debug(), "Using fallback method to detect VPN DNS")
// Get list of network interfaces
interfaces, err := net.Interfaces()
if err != nil {
Log(ctx, logger.Error().Err(err), "Failed to get network interfaces")
return nil
}
var vpnConfigs []VPNDNSConfig
for _, iface := range interfaces {
if !isVPNInterfaceName(iface.Name) {
continue
}
// Check if interface is up
if iface.Flags&net.FlagUp == 0 {
continue
}
Log(ctx, logger.Debug(), "Found potential VPN interface: %s", iface.Name)
// For VPN interfaces, we can't easily determine their specific DNS settings
// without more complex parsing of network manager configurations.
// This is a basic implementation that could be extended.
// For now, we'll skip this fallback as it's complex and platform-specific
Log(ctx, logger.Debug(), "Fallback DNS detection not implemented for interface: %s", iface.Name)
}
Log(ctx, logger.Debug(), "Fallback method completed: found %d VPN interfaces", len(vpnConfigs))
return vpnConfigs
}
// linkInfo holds information about a network link from resolvectl status.
type linkInfo struct {
InterfaceName string
Servers []string
Domains []string
}
// isVPNLink determines if a network link configuration looks like it belongs to a VPN.
func isVPNLink(ctx context.Context, link *linkInfo) bool {
logger := LoggerFromCtx(ctx)
// Must have both DNS servers and domains
if len(link.Servers) == 0 || len(link.Domains) == 0 {
Log(ctx, logger.Debug(), "Link %s: insufficient config (servers: %d, domains: %d)",
link.InterfaceName, len(link.Servers), len(link.Domains))
return false
}
// Check interface name patterns
if isVPNInterfaceName(link.InterfaceName) {
Log(ctx, logger.Debug(), "Link %s: identified as VPN based on interface name", link.InterfaceName)
return true
}
// Look for routing domains (prefixed with ~)
hasRoutingDomain := false
for _, domain := range link.Domains {
if strings.HasPrefix(domain, "~") {
hasRoutingDomain = true
break
}
}
if hasRoutingDomain {
Log(ctx, logger.Debug(), "Link %s: identified as VPN based on routing domain", link.InterfaceName)
return true
}
// Additional heuristics similar to macOS
hasPrivateDNS := false
for _, server := range link.Servers {
if ip := net.ParseIP(server); ip != nil && ip.IsPrivate() {
hasPrivateDNS = true
break
}
}
hasVPNDomains := false
for _, domain := range link.Domains {
domain = strings.ToLower(strings.TrimPrefix(domain, "~"))
if strings.HasSuffix(domain, ".local") ||
strings.HasSuffix(domain, ".corp") ||
strings.HasSuffix(domain, ".internal") ||
strings.Contains(domain, "vpn") {
hasVPNDomains = true
break
}
}
if hasPrivateDNS && hasVPNDomains {
Log(ctx, logger.Debug(), "Link %s: identified as VPN based on private DNS + VPN domains", link.InterfaceName)
return true
}
Log(ctx, logger.Debug(), "Link %s: not identified as VPN link", link.InterfaceName)
return false
}
// isVPNInterfaceName checks if an interface name looks like a VPN interface.
func isVPNInterfaceName(name string) bool {
name = strings.ToLower(name)
return strings.HasPrefix(name, "tun") ||
strings.HasPrefix(name, "tap") ||
strings.HasPrefix(name, "ppp") ||
strings.HasPrefix(name, "vpn") ||
strings.Contains(name, "vpn")
}

15
vpn_dns_others.go Normal file
View File

@@ -0,0 +1,15 @@
//go:build !windows && !darwin && !linux
package ctrld
import (
"context"
)
// DiscoverVPNDNS is a stub implementation for unsupported platforms.
// Returns nil to indicate no VPN DNS configurations found.
func DiscoverVPNDNS(ctx context.Context) []VPNDNSConfig {
logger := LoggerFromCtx(ctx)
Log(ctx, logger.Debug(), "VPN DNS discovery not implemented for this platform")
return nil
}

101
vpn_dns_windows.go Normal file
View File

@@ -0,0 +1,101 @@
//go:build windows
package ctrld
import (
"context"
"strings"
"syscall"
"golang.zx2c4.com/wireguard/windows/tunnel/winipcfg"
)
// DiscoverVPNDNS discovers DNS servers and search domains from non-physical (VPN) interfaces.
// Only called when dnsIntercept is active.
func DiscoverVPNDNS(ctx context.Context) []VPNDNSConfig {
logger := LoggerFromCtx(ctx)
Log(ctx, logger.Debug(), "Discovering VPN DNS configurations on Windows")
flags := winipcfg.GAAFlagIncludeGateways | winipcfg.GAAFlagIncludePrefix
aas, err := winipcfg.GetAdaptersAddresses(syscall.AF_UNSPEC, flags)
if err != nil {
Log(ctx, logger.Error().Err(err), "Failed to get adapters addresses")
return nil
}
Log(ctx, logger.Debug(), "Found %d network adapters", len(aas))
// Get valid (physical/hardware) interfaces to filter them out
validInterfacesMap := ValidInterfaces(ctx)
var vpnConfigs []VPNDNSConfig
for _, aa := range aas {
// Skip adapters that are not up
if aa.OperStatus != winipcfg.IfOperStatusUp {
Log(ctx, logger.Debug(), "Skipping adapter %s - not up, status: %d",
aa.FriendlyName(), aa.OperStatus)
continue
}
// Skip software loopback
if aa.IfType == winipcfg.IfTypeSoftwareLoopback {
Log(ctx, logger.Debug(), "Skipping %s (software loopback)", aa.FriendlyName())
continue
}
// INVERT the ValidInterfaces filter: we want non-physical/non-hardware adapters
// that are UP and have DNS servers AND DNS suffixes
_, isValidPhysical := validInterfacesMap[aa.FriendlyName()]
if isValidPhysical {
Log(ctx, logger.Debug(), "Skipping %s (physical/hardware adapter)", aa.FriendlyName())
continue
}
// Collect DNS servers
var servers []string
for dns := aa.FirstDNSServerAddress; dns != nil; dns = dns.Next {
ip := dns.Address.IP()
if ip == nil {
continue
}
ipStr := ip.String()
if ip.IsLoopback() {
continue
}
servers = append(servers, ipStr)
}
// Collect DNS suffixes (search/match domains)
var domains []string
for suffix := aa.FirstDNSSuffix; suffix != nil; suffix = suffix.Next {
domain := strings.TrimSpace(suffix.String())
if domain != "" {
domains = append(domains, domain)
}
}
// Only include interfaces that have BOTH DNS servers AND search domains
if len(servers) > 0 && len(domains) > 0 {
config := VPNDNSConfig{
InterfaceName: aa.FriendlyName(),
Servers: servers,
Domains: domains,
}
vpnConfigs = append(vpnConfigs, config)
Log(ctx, logger.Debug(), "Found VPN DNS config - Interface: %s, Servers: %v, Domains: %v",
config.InterfaceName, config.Servers, config.Domains)
} else {
Log(ctx, logger.Debug(), "Skipping %s - insufficient DNS config (servers: %d, domains: %d)",
aa.FriendlyName(), len(servers), len(domains))
}
}
Log(ctx, logger.Debug(), "VPN DNS discovery completed: found %d VPN interfaces", len(vpnConfigs))
return vpnConfigs
}