//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") }