Files
ctrld/vpn_dns_linux.go
2026-03-10 17:18:23 +07:00

240 lines
7.0 KiB
Go

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