mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-07-04 01:07:49 +02: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
@@ -0,0 +1,255 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"tailscale.com/net/netmon"
|
||||||
|
|
||||||
|
"github.com/Control-D-Inc/ctrld"
|
||||||
|
)
|
||||||
|
|
||||||
|
// vpnDNSExemption represents a VPN DNS server that needs pf/WFP exemption,
|
||||||
|
// including the interface it was discovered on. The interface is used on macOS
|
||||||
|
// to create interface-scoped pf exemptions that allow the VPN's local DNS
|
||||||
|
// handler (e.g., Tailscale's MagicDNS Network Extension) to receive queries
|
||||||
|
// from all processes — not just ctrld.
|
||||||
|
type vpnDNSExemption struct {
|
||||||
|
Server string // DNS server IP (e.g., "100.100.100.100")
|
||||||
|
Interface string // Interface name from scutil (e.g., "utun11"), may be empty
|
||||||
|
IsExitMode bool // True if this VPN is in exit/full-tunnel mode (all traffic routed through VPN)
|
||||||
|
}
|
||||||
|
|
||||||
|
// vpnDNSExemptFunc is called when VPN DNS servers change, to update
|
||||||
|
// the intercept layer (WFP/pf) to permit VPN DNS traffic.
|
||||||
|
type vpnDNSExemptFunc func(exemptions []vpnDNSExemption) error
|
||||||
|
|
||||||
|
// vpnDNSManager tracks active VPN DNS configurations and provides
|
||||||
|
// domain-to-upstream routing for VPN split DNS.
|
||||||
|
type vpnDNSManager struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
configs []ctrld.VPNDNSConfig
|
||||||
|
// Map of domain suffix → DNS servers for fast lookup
|
||||||
|
routes map[string][]string
|
||||||
|
// DNS servers from VPN interfaces that have no domain/suffix config.
|
||||||
|
// These are NOT added to the global OS resolver. They're only used
|
||||||
|
// as additional nameservers for queries that match split-DNS rules
|
||||||
|
// (from ctrld config, AD domain, or VPN suffix config).
|
||||||
|
domainlessServers []string
|
||||||
|
// Called when VPN DNS server list changes, to update intercept exemptions.
|
||||||
|
onServersChanged vpnDNSExemptFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
// newVPNDNSManager creates a new manager. Only call when dnsIntercept is active.
|
||||||
|
// exemptFunc is called whenever VPN DNS servers are discovered/changed, to update
|
||||||
|
// the OS-level intercept rules to permit ctrld's outbound queries to those IPs.
|
||||||
|
func newVPNDNSManager(exemptFunc vpnDNSExemptFunc) *vpnDNSManager {
|
||||||
|
return &vpnDNSManager{
|
||||||
|
routes: make(map[string][]string),
|
||||||
|
onServersChanged: exemptFunc,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh re-discovers VPN DNS configs from the OS.
|
||||||
|
// Called on network change events.
|
||||||
|
func (m *vpnDNSManager) Refresh(guardAgainstNoNameservers bool) {
|
||||||
|
logger := mainLog.Load()
|
||||||
|
|
||||||
|
logger.Debug().Msg("Refreshing VPN DNS configurations")
|
||||||
|
configs := ctrld.DiscoverVPNDNS(context.Background())
|
||||||
|
|
||||||
|
// Detect exit mode: if the default route goes through a VPN DNS interface,
|
||||||
|
// the VPN is routing ALL traffic (exit node / full tunnel). This is more
|
||||||
|
// reliable than scutil flag parsing because the routing table is the ground
|
||||||
|
// truth for traffic flow, regardless of how the VPN presents itself in scutil.
|
||||||
|
if dri, err := netmon.DefaultRouteInterface(); err == nil && dri != "" {
|
||||||
|
for i := range configs {
|
||||||
|
if configs[i].InterfaceName == dri {
|
||||||
|
if !configs[i].IsExitMode {
|
||||||
|
logger.Info().Msgf("VPN DNS on %s: default route interface match — EXIT MODE (route-based detection)", dri)
|
||||||
|
}
|
||||||
|
configs[i].IsExitMode = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
m.configs = configs
|
||||||
|
m.routes = make(map[string][]string)
|
||||||
|
|
||||||
|
// Build domain -> DNS servers mapping
|
||||||
|
for _, config := range configs {
|
||||||
|
logger.Debug().Msgf("Processing VPN interface %s with %d domains and %d servers",
|
||||||
|
config.InterfaceName, len(config.Domains), len(config.Servers))
|
||||||
|
|
||||||
|
for _, domain := range config.Domains {
|
||||||
|
// Normalize domain: remove leading dot, Linux routing domain prefix (~),
|
||||||
|
// and convert to lowercase.
|
||||||
|
domain = strings.TrimPrefix(domain, "~")
|
||||||
|
domain = strings.TrimPrefix(domain, ".")
|
||||||
|
domain = strings.ToLower(domain)
|
||||||
|
|
||||||
|
if domain != "" {
|
||||||
|
m.routes[domain] = append([]string{}, config.Servers...)
|
||||||
|
logger.Debug().Msgf("Added VPN DNS route: %s -> %v", domain, config.Servers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect unique VPN DNS exemptions (server + interface) for pf/WFP rules.
|
||||||
|
type exemptionKey struct{ server, iface string }
|
||||||
|
seen := make(map[exemptionKey]bool)
|
||||||
|
var exemptions []vpnDNSExemption
|
||||||
|
for _, config := range configs {
|
||||||
|
for _, server := range config.Servers {
|
||||||
|
key := exemptionKey{server, config.InterfaceName}
|
||||||
|
if !seen[key] {
|
||||||
|
seen[key] = true
|
||||||
|
exemptions = append(exemptions, vpnDNSExemption{
|
||||||
|
Server: server,
|
||||||
|
Interface: config.InterfaceName,
|
||||||
|
IsExitMode: config.IsExitMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect domain-less VPN DNS servers. These are NOT added to the global
|
||||||
|
// OS resolver (that would pollute captive portal / DHCP flows). Instead,
|
||||||
|
// they're stored separately and only used for queries that match existing
|
||||||
|
// split-DNS rules (from ctrld config, AD domain, or VPN suffix config).
|
||||||
|
var domainlessServers []string
|
||||||
|
seen2 := make(map[string]bool)
|
||||||
|
for _, config := range configs {
|
||||||
|
if len(config.Domains) == 0 && len(config.Servers) > 0 {
|
||||||
|
logger.Debug().Msgf("VPN interface %s has DNS servers but no domains, storing as split-rule fallback: %v",
|
||||||
|
config.InterfaceName, config.Servers)
|
||||||
|
for _, s := range config.Servers {
|
||||||
|
if !seen2[s] {
|
||||||
|
seen2[s] = true
|
||||||
|
domainlessServers = append(domainlessServers, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.domainlessServers = domainlessServers
|
||||||
|
|
||||||
|
logger.Debug().Msgf("VPN DNS refresh completed: %d configs, %d routes, %d domainless servers, %d unique exemptions",
|
||||||
|
len(m.configs), len(m.routes), len(m.domainlessServers), len(exemptions))
|
||||||
|
|
||||||
|
// Update intercept rules to permit VPN DNS traffic.
|
||||||
|
// Always call onServersChanged — including when exemptions is empty — so that
|
||||||
|
// stale exemptions from a previous VPN session get cleared on disconnect.
|
||||||
|
if m.onServersChanged != nil {
|
||||||
|
if err := m.onServersChanged(exemptions); err != nil {
|
||||||
|
logger.Error().Err(err).Msg("Failed to update intercept exemptions for VPN DNS servers")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpstreamForDomain checks if the domain matches any VPN search domain.
|
||||||
|
// Returns VPN DNS servers if matched, nil otherwise.
|
||||||
|
func (m *vpnDNSManager) UpstreamForDomain(domain string) []string {
|
||||||
|
if domain == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
|
domain = strings.TrimSuffix(domain, ".")
|
||||||
|
domain = strings.ToLower(domain)
|
||||||
|
|
||||||
|
if servers, ok := m.routes[domain]; ok {
|
||||||
|
return append([]string{}, servers...)
|
||||||
|
}
|
||||||
|
|
||||||
|
for vpnDomain, servers := range m.routes {
|
||||||
|
if strings.HasSuffix(domain, "."+vpnDomain) {
|
||||||
|
return append([]string{}, servers...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DomainlessServers returns VPN DNS servers that have no associated domains.
|
||||||
|
// These should only be used for queries matching split-DNS rules, not for
|
||||||
|
// general OS resolver queries (to avoid polluting captive portal / DHCP flows).
|
||||||
|
func (m *vpnDNSManager) DomainlessServers() []string {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
return append([]string{}, m.domainlessServers...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CurrentServers returns the current set of unique VPN DNS server IPs.
|
||||||
|
func (m *vpnDNSManager) CurrentServers() []string {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
var servers []string
|
||||||
|
for _, ss := range m.routes {
|
||||||
|
for _, s := range ss {
|
||||||
|
if !seen[s] {
|
||||||
|
seen[s] = true
|
||||||
|
servers = append(servers, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return servers
|
||||||
|
}
|
||||||
|
|
||||||
|
// CurrentExemptions returns VPN DNS server + interface pairs for pf exemption rules.
|
||||||
|
func (m *vpnDNSManager) CurrentExemptions() []vpnDNSExemption {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
|
type key struct{ server, iface string }
|
||||||
|
seen := make(map[key]bool)
|
||||||
|
var exemptions []vpnDNSExemption
|
||||||
|
for _, config := range m.configs {
|
||||||
|
for _, server := range config.Servers {
|
||||||
|
k := key{server, config.InterfaceName}
|
||||||
|
if !seen[k] {
|
||||||
|
seen[k] = true
|
||||||
|
exemptions = append(exemptions, vpnDNSExemption{
|
||||||
|
Server: server,
|
||||||
|
Interface: config.InterfaceName,
|
||||||
|
IsExitMode: config.IsExitMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return exemptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routes returns a copy of the current VPN DNS routes for debugging.
|
||||||
|
func (m *vpnDNSManager) Routes() map[string][]string {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
|
routes := make(map[string][]string)
|
||||||
|
for domain, servers := range m.routes {
|
||||||
|
routes[domain] = append([]string{}, servers...)
|
||||||
|
}
|
||||||
|
return routes
|
||||||
|
}
|
||||||
|
|
||||||
|
// upstreamConfigFor creates a legacy upstream configuration for the given VPN DNS server.
|
||||||
|
func (m *vpnDNSManager) upstreamConfigFor(server string) *ctrld.UpstreamConfig {
|
||||||
|
endpoint := server
|
||||||
|
if !strings.Contains(server, ":") {
|
||||||
|
endpoint = server + ":53"
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ctrld.UpstreamConfig{
|
||||||
|
Name: "VPN DNS",
|
||||||
|
Type: ctrld.ResolverTypeLegacy,
|
||||||
|
Endpoint: endpoint,
|
||||||
|
Timeout: 2000,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package ctrld
|
||||||
|
|
||||||
|
// VPNDNSConfig represents DNS configuration discovered from a VPN interface.
|
||||||
|
// Used by the dns-intercept mode to detect VPN split DNS settings and
|
||||||
|
// route matching queries to VPN DNS servers automatically.
|
||||||
|
type VPNDNSConfig struct {
|
||||||
|
InterfaceName string // VPN adapter name (e.g., "F5 Networks VPN")
|
||||||
|
Servers []string // DNS server IPs (e.g., ["10.20.30.1"])
|
||||||
|
Domains []string // Search/match domains (e.g., ["corp.example.com"])
|
||||||
|
IsExitMode bool // True if this VPN is also the system default resolver (exit node mode)
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
//go:build darwin
|
||||||
|
|
||||||
|
package ctrld
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DiscoverVPNDNS discovers DNS servers and search domains from VPN interfaces on macOS.
|
||||||
|
// Parses `scutil --dns` output to find VPN resolver configurations.
|
||||||
|
func DiscoverVPNDNS(ctx context.Context) []VPNDNSConfig {
|
||||||
|
logger := *ProxyLogger.Load()
|
||||||
|
|
||||||
|
Log(ctx, logger.Debug(), "Discovering VPN DNS configurations on macOS")
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "scutil", "--dns")
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
Log(ctx, logger.Error().Err(err), "Failed to execute scutil --dns")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseScutilOutput(ctx, string(output))
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseScutilOutput parses the output of `scutil --dns` to extract VPN DNS configurations.
|
||||||
|
func parseScutilOutput(ctx context.Context, output string) []VPNDNSConfig {
|
||||||
|
logger := *ProxyLogger.Load()
|
||||||
|
|
||||||
|
Log(ctx, logger.Debug(), "Parsing scutil --dns output")
|
||||||
|
|
||||||
|
resolverBlockRe := regexp.MustCompile(`resolver #(\d+)`)
|
||||||
|
searchDomainRe := regexp.MustCompile(`search domain\[\d+\] : (.+)`)
|
||||||
|
// Matches singular "domain : value" entries (e.g., Tailscale per-domain resolvers).
|
||||||
|
singleDomainRe := regexp.MustCompile(`^domain\s+:\s+(.+)`)
|
||||||
|
nameserverRe := regexp.MustCompile(`nameserver\[\d+\] : (.+)`)
|
||||||
|
ifIndexRe := regexp.MustCompile(`if_index : (\d+) \((.+)\)`)
|
||||||
|
|
||||||
|
var vpnConfigs []VPNDNSConfig
|
||||||
|
var currentResolver *resolverInfo
|
||||||
|
var allResolvers []resolverInfo
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(strings.NewReader(output))
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
|
||||||
|
if match := resolverBlockRe.FindStringSubmatch(line); match != nil {
|
||||||
|
if currentResolver != nil {
|
||||||
|
allResolvers = append(allResolvers, *currentResolver)
|
||||||
|
}
|
||||||
|
resolverNum, _ := strconv.Atoi(match[1])
|
||||||
|
currentResolver = &resolverInfo{
|
||||||
|
Number: resolverNum,
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if currentResolver == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if match := searchDomainRe.FindStringSubmatch(line); match != nil {
|
||||||
|
domain := strings.TrimSpace(match[1])
|
||||||
|
if domain != "" {
|
||||||
|
currentResolver.Domains = append(currentResolver.Domains, domain)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse singular "domain : value" (used by Tailscale per-domain resolvers).
|
||||||
|
if match := singleDomainRe.FindStringSubmatch(line); match != nil {
|
||||||
|
domain := strings.TrimSpace(match[1])
|
||||||
|
if domain != "" {
|
||||||
|
currentResolver.Domains = append(currentResolver.Domains, domain)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if match := nameserverRe.FindStringSubmatch(line); match != nil {
|
||||||
|
server := strings.TrimSpace(match[1])
|
||||||
|
if ip := net.ParseIP(server); ip != nil && !ip.IsLoopback() {
|
||||||
|
currentResolver.Servers = append(currentResolver.Servers, server)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if match := ifIndexRe.FindStringSubmatch(line); match != nil {
|
||||||
|
currentResolver.InterfaceName = strings.TrimSpace(match[2])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(line, "flags") {
|
||||||
|
if idx := strings.Index(line, ":"); idx >= 0 {
|
||||||
|
currentResolver.Flags = strings.TrimSpace(line[idx+1:])
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if currentResolver != nil {
|
||||||
|
allResolvers = append(allResolvers, *currentResolver)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, resolver := range allResolvers {
|
||||||
|
if isSplitDNSResolver(ctx, &resolver) {
|
||||||
|
ifaceName := resolver.InterfaceName
|
||||||
|
|
||||||
|
// When scutil doesn't provide if_index (common with Tailscale MagicDNS
|
||||||
|
// per-domain resolvers), look up the outbound interface from the routing
|
||||||
|
// table. This is needed for interface-scoped pf exemptions — without the
|
||||||
|
// interface name, we can't generate rules that let the VPN's Network
|
||||||
|
// Extension handle DNS queries from all processes.
|
||||||
|
if ifaceName == "" && len(resolver.Servers) > 0 {
|
||||||
|
if routeIface := resolveInterfaceForIP(ctx, resolver.Servers[0]); routeIface != "" {
|
||||||
|
ifaceName = routeIface
|
||||||
|
Log(ctx, logger.Debug(), "Resolver #%d: resolved interface %q from routing table for %s",
|
||||||
|
resolver.Number, routeIface, resolver.Servers[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config := VPNDNSConfig{
|
||||||
|
InterfaceName: ifaceName,
|
||||||
|
Servers: resolver.Servers,
|
||||||
|
Domains: resolver.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect exit mode: if a VPN DNS server IP also appears as the system's default
|
||||||
|
// resolver (no search domains, no Supplemental flag), the VPN is routing ALL traffic
|
||||||
|
// (not just specific domains). In exit mode, ctrld must continue intercepting DNS
|
||||||
|
// on the VPN interface to enforce its profile on all queries.
|
||||||
|
defaultResolverIPs := make(map[string]bool)
|
||||||
|
for _, resolver := range allResolvers {
|
||||||
|
if len(resolver.Servers) > 0 && len(resolver.Domains) == 0 &&
|
||||||
|
!strings.Contains(resolver.Flags, "Supplemental") &&
|
||||||
|
!strings.Contains(resolver.Flags, "Scoped") {
|
||||||
|
for _, server := range resolver.Servers {
|
||||||
|
defaultResolverIPs[server] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range vpnConfigs {
|
||||||
|
for _, server := range vpnConfigs[i].Servers {
|
||||||
|
if defaultResolverIPs[server] {
|
||||||
|
vpnConfigs[i].IsExitMode = true
|
||||||
|
Log(ctx, logger.Info(), "VPN DNS config on %s detected as EXIT MODE — server %s is also the system default resolver",
|
||||||
|
vpnConfigs[i].InterfaceName, server)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Log(ctx, logger.Debug(), "VPN DNS discovery completed: found %d VPN interfaces", len(vpnConfigs))
|
||||||
|
return vpnConfigs
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveInterfaceForIP uses the macOS routing table to determine which network
|
||||||
|
// interface would be used to reach the given IP address. This is a fallback for
|
||||||
|
// when scutil --dns doesn't include if_index in the resolver entry (common with
|
||||||
|
// Tailscale MagicDNS per-domain resolvers).
|
||||||
|
//
|
||||||
|
// Runs: route -n get <ip> and parses the "interface:" line from the output.
|
||||||
|
// Returns empty string on any error (callers should treat as "unknown interface").
|
||||||
|
func resolveInterfaceForIP(ctx context.Context, ip string) string {
|
||||||
|
logger := *ProxyLogger.Load()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "route", "-n", "get", ip)
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
Log(ctx, logger.Debug(), "route -n get %s failed: %v", ip, err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse "interface: utun11" from route output.
|
||||||
|
for _, line := range strings.Split(string(output), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(line, "interface:") {
|
||||||
|
iface := strings.TrimSpace(strings.TrimPrefix(line, "interface:"))
|
||||||
|
if iface != "" && iface != "lo0" {
|
||||||
|
return iface
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
type resolverInfo struct {
|
||||||
|
Number int
|
||||||
|
InterfaceName string
|
||||||
|
Servers []string
|
||||||
|
Domains []string
|
||||||
|
Flags string // Raw flags line (e.g., "Supplemental, Request A records")
|
||||||
|
}
|
||||||
|
|
||||||
|
// isSplitDNSResolver reports whether a scutil --dns resolver entry represents a
|
||||||
|
// split DNS configuration that ctrld should forward to. Any resolver with both
|
||||||
|
// non-loopback DNS servers and search domains qualifies — this covers VPN adapters
|
||||||
|
// (F5, Tailscale, Cisco AnyConnect, etc.) and any other virtual interface that
|
||||||
|
// registers search domains (e.g., corporate proxies, containers).
|
||||||
|
//
|
||||||
|
// We intentionally avoid heuristics about interface names or domain suffixes:
|
||||||
|
// if an interface declares "these domains resolve via these servers," we honor it.
|
||||||
|
// The only exclusions are mDNS entries (bare ".local" without an interface binding).
|
||||||
|
//
|
||||||
|
// Note: loopback servers are already filtered out during parsing in parseScutilOutput.
|
||||||
|
func isSplitDNSResolver(ctx context.Context, resolver *resolverInfo) bool {
|
||||||
|
logger := *ProxyLogger.Load()
|
||||||
|
|
||||||
|
// Must have both DNS servers and search domains to be a useful split DNS route.
|
||||||
|
if len(resolver.Servers) == 0 || len(resolver.Domains) == 0 {
|
||||||
|
Log(ctx, logger.Debug(), "Resolver #%d: skipping — no servers (%d) or no domains (%d)",
|
||||||
|
resolver.Number, len(resolver.Servers), len(resolver.Domains))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip multicast DNS entries. scutil --dns shows a resolver for ".local" that
|
||||||
|
// handles mDNS — it has no interface binding and the sole domain is "local".
|
||||||
|
// Real VPN entries with ".local" suffix (e.g., "corp.example.com") will have an
|
||||||
|
// interface name or additional domains.
|
||||||
|
if len(resolver.Domains) == 1 {
|
||||||
|
domain := strings.ToLower(strings.TrimSpace(resolver.Domains[0]))
|
||||||
|
if domain == "local" || domain == ".local" {
|
||||||
|
Log(ctx, logger.Debug(), "Resolver #%d: skipping — mDNS resolver", resolver.Number)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Log(ctx, logger.Debug(), "Resolver #%d: split DNS resolver — interface: %q, servers: %v, domains: %v",
|
||||||
|
resolver.Number, resolver.InterfaceName, resolver.Servers, resolver.Domains)
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
//go:build !windows && !darwin && !linux
|
||||||
|
|
||||||
|
package ctrld
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DiscoverVPNDNS is a stub implementation for unsupported platforms.
|
||||||
|
// Returns nil to indicate no VPN DNS configurations found.
|
||||||
|
func DiscoverVPNDNS(ctx context.Context) []VPNDNSConfig {
|
||||||
|
logger := *ProxyLogger.Load()
|
||||||
|
Log(ctx, logger.Debug(), "VPN DNS discovery not implemented for this platform")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package ctrld
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"golang.zx2c4.com/wireguard/windows/tunnel/winipcfg"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DiscoverVPNDNS discovers DNS servers and search domains from non-physical (VPN) interfaces.
|
||||||
|
// Only called when dnsIntercept is active.
|
||||||
|
func DiscoverVPNDNS(ctx context.Context) []VPNDNSConfig {
|
||||||
|
logger := *ProxyLogger.Load()
|
||||||
|
|
||||||
|
Log(ctx, logger.Debug(), "Discovering VPN DNS configurations on Windows")
|
||||||
|
|
||||||
|
flags := winipcfg.GAAFlagIncludeGateways | winipcfg.GAAFlagIncludePrefix
|
||||||
|
aas, err := winipcfg.GetAdaptersAddresses(syscall.AF_UNSPEC, flags)
|
||||||
|
if err != nil {
|
||||||
|
Log(ctx, logger.Error().Err(err), "Failed to get adapters addresses")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
Log(ctx, logger.Debug(), "Found %d network adapters", len(aas))
|
||||||
|
|
||||||
|
// Get valid (physical/hardware) interfaces to filter them out
|
||||||
|
validInterfacesMap := validInterfaces()
|
||||||
|
|
||||||
|
var vpnConfigs []VPNDNSConfig
|
||||||
|
|
||||||
|
for _, aa := range aas {
|
||||||
|
if aa.OperStatus != winipcfg.IfOperStatusUp {
|
||||||
|
Log(ctx, logger.Debug(), "Skipping adapter %s - not up, status: %d",
|
||||||
|
aa.FriendlyName(), aa.OperStatus)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if aa.IfType == winipcfg.IfTypeSoftwareLoopback {
|
||||||
|
Log(ctx, logger.Debug(), "Skipping %s (software loopback)", aa.FriendlyName())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// INVERT the validInterfaces filter: we want non-physical/non-hardware adapters
|
||||||
|
_, isValidPhysical := validInterfacesMap[aa.FriendlyName()]
|
||||||
|
if isValidPhysical {
|
||||||
|
Log(ctx, logger.Debug(), "Skipping %s (physical/hardware adapter)", aa.FriendlyName())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip adapters that have no routable unicast addresses. An adapter
|
||||||
|
// with only link-local (fe80::) or APIPA (169.254.x.x) addresses is
|
||||||
|
// not actually connected — its DNS servers are stale. This prevents
|
||||||
|
// picking up e.g. Tailscale's adapter when the app is installed but
|
||||||
|
// disconnected (OperStatus reports Up but only APIPA addresses exist).
|
||||||
|
hasRoutableAddr := false
|
||||||
|
for a := aa.FirstUnicastAddress; a != nil; a = a.Next {
|
||||||
|
ip := a.Address.IP()
|
||||||
|
if ip == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !ip.IsLinkLocalUnicast() {
|
||||||
|
hasRoutableAddr = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasRoutableAddr {
|
||||||
|
Log(ctx, logger.Debug(), "Skipping %s - no routable addresses (likely disconnected)", aa.FriendlyName())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var servers []string
|
||||||
|
for dns := aa.FirstDNSServerAddress; dns != nil; dns = dns.Next {
|
||||||
|
ip := dns.Address.IP()
|
||||||
|
if ip == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
ipStr := ip.String()
|
||||||
|
if ip.IsLoopback() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
servers = append(servers, ipStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check adapter-specific (connection-specific) DNS suffix first,
|
||||||
|
// since we want to map per-adapter DNS servers to per-adapter suffixes.
|
||||||
|
// This is what most traditional VPNs set (F5, Cisco AnyConnect, GlobalProtect).
|
||||||
|
var domains []string
|
||||||
|
if connSuffix := strings.TrimSpace(aa.DNSSuffix()); connSuffix != "" {
|
||||||
|
domains = append(domains, connSuffix)
|
||||||
|
Log(ctx, logger.Debug(), "Using connection-specific DNS suffix for %s: %s",
|
||||||
|
aa.FriendlyName(), connSuffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then check supplemental DNS suffix list (used by Tailscale and
|
||||||
|
// VPN clients that register search domains via the DNS Client API).
|
||||||
|
for suffix := aa.FirstDNSSuffix; suffix != nil; suffix = suffix.Next {
|
||||||
|
domain := strings.TrimSpace(suffix.String())
|
||||||
|
if domain != "" {
|
||||||
|
domains = append(domains, domain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept VPN adapters with DNS servers even without domains.
|
||||||
|
// Domain-less configs still provide useful DNS server IPs that
|
||||||
|
// can serve existing split-rules and OS resolver queries.
|
||||||
|
if len(servers) > 0 {
|
||||||
|
config := VPNDNSConfig{
|
||||||
|
InterfaceName: aa.FriendlyName(),
|
||||||
|
Servers: servers,
|
||||||
|
Domains: 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)
|
||||||
|
} else {
|
||||||
|
Log(ctx, logger.Debug(), "Skipping %s - no DNS servers found",
|
||||||
|
aa.FriendlyName())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Log(ctx, logger.Debug(), "VPN DNS discovery completed: found %d VPN interfaces", len(vpnConfigs))
|
||||||
|
return vpnConfigs
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user