mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-03-25 23:30:41 +01:00
feat: add VPN DNS split routing
Implement VPN DNS discovery and split routing for intercept mode: - Discover VPN DNS servers from F5 BIG-IP, Tailscale, Network Extension VPNs, and traditional VPN adapters - Exit mode detection (split vs full tunnel) via routing table - Interface-scoped pf exemptions for VPN DNS traffic (macOS) - Windows VPN adapter filtering with routable address check - AD domain controller detection with retry on transient failure - Cleanup of stale exemptions on VPN disconnect Squashed from intercept mode development on v1.0 branch (#497).
This commit is contained in:
committed by
Cuong Manh Le
parent
768cc81855
commit
e7040bd9f9
211
vpn_dns_linux.go
Normal file
211
vpn_dns_linux.go
Normal file
@@ -0,0 +1,211 @@
|
||||
//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 := *ProxyLogger.Load()
|
||||
|
||||
Log(ctx, logger.Debug(), "Discovering VPN DNS configurations on Linux")
|
||||
|
||||
if configs := parseResolvectlStatus(ctx); len(configs) > 0 {
|
||||
return configs
|
||||
}
|
||||
|
||||
Log(ctx, logger.Debug(), "resolvectl not available or no results, trying fallback method")
|
||||
return parseVPNInterfacesDNS(ctx)
|
||||
}
|
||||
|
||||
func parseResolvectlStatus(ctx context.Context) []VPNDNSConfig {
|
||||
logger := *ProxyLogger.Load()
|
||||
|
||||
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")
|
||||
|
||||
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()
|
||||
|
||||
if match := linkRe.FindStringSubmatch(line); match != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
currentLink = &linkInfo{
|
||||
InterfaceName: strings.TrimSpace(match[2]),
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if currentLink == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func parseVPNInterfacesDNS(ctx context.Context) []VPNDNSConfig {
|
||||
logger := *ProxyLogger.Load()
|
||||
|
||||
Log(ctx, logger.Debug(), "Using fallback method to detect VPN DNS")
|
||||
|
||||
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
|
||||
}
|
||||
if iface.Flags&net.FlagUp == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
Log(ctx, logger.Debug(), "Found potential VPN interface: %s", iface.Name)
|
||||
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
|
||||
}
|
||||
|
||||
type linkInfo struct {
|
||||
InterfaceName string
|
||||
Servers []string
|
||||
Domains []string
|
||||
}
|
||||
|
||||
func isVPNLink(ctx context.Context, link *linkInfo) bool {
|
||||
logger := *ProxyLogger.Load()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if isVPNInterfaceName(link.InterfaceName) {
|
||||
Log(ctx, logger.Debug(), "Link %s: identified as VPN based on interface name", link.InterfaceName)
|
||||
return true
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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