mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-05-27 12:52:27 +02:00
feat: add VPN DNS split routing
This commit is contained in:
committed by
Cuong Manh Le
parent
b9fb3b9176
commit
0a7bbb99e8
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user