mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-03-13 10:26:06 +00:00
240 lines
7.0 KiB
Go
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")
|
|
} |