From 0a7bbb99e8fa9a74fbb831c54754f70edbcd042b Mon Sep 17 00:00:00 2001 From: Codescribe Date: Thu, 5 Mar 2026 04:50:23 -0500 Subject: [PATCH] feat: add VPN DNS split routing --- cmd/cli/vpn_dns.go | 236 +++++++++++++++++++++++++++++++++++++++++++ vpn_dns_config.go | 11 ++ vpn_dns_darwin.go | 244 +++++++++++++++++++++++++++++++++++++++++++++ vpn_dns_linux.go | 240 ++++++++++++++++++++++++++++++++++++++++++++ vpn_dns_others.go | 15 +++ vpn_dns_windows.go | 101 +++++++++++++++++++ 6 files changed, 847 insertions(+) create mode 100644 cmd/cli/vpn_dns.go create mode 100644 vpn_dns_config.go create mode 100644 vpn_dns_darwin.go create mode 100644 vpn_dns_linux.go create mode 100644 vpn_dns_others.go create mode 100644 vpn_dns_windows.go diff --git a/cmd/cli/vpn_dns.go b/cmd/cli/vpn_dns.go new file mode 100644 index 0000000..6b5fb88 --- /dev/null +++ b/cmd/cli/vpn_dns.go @@ -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 + } +} diff --git a/vpn_dns_config.go b/vpn_dns_config.go new file mode 100644 index 0000000..f1bf91d --- /dev/null +++ b/vpn_dns_config.go @@ -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) +} diff --git a/vpn_dns_darwin.go b/vpn_dns_darwin.go new file mode 100644 index 0000000..86b7293 --- /dev/null +++ b/vpn_dns_darwin.go @@ -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 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 +} diff --git a/vpn_dns_linux.go b/vpn_dns_linux.go new file mode 100644 index 0000000..dbff6cc --- /dev/null +++ b/vpn_dns_linux.go @@ -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") +} \ No newline at end of file diff --git a/vpn_dns_others.go b/vpn_dns_others.go new file mode 100644 index 0000000..8bf8b9e --- /dev/null +++ b/vpn_dns_others.go @@ -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 +} \ No newline at end of file diff --git a/vpn_dns_windows.go b/vpn_dns_windows.go new file mode 100644 index 0000000..f5a76e1 --- /dev/null +++ b/vpn_dns_windows.go @@ -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 +} \ No newline at end of file