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

245 lines
8.4 KiB
Go

//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 := LoggerFromCtx(ctx)
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 := LoggerFromCtx(ctx)
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 := LoggerFromCtx(ctx)
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 ""
}
// resolverInfo holds information about a resolver block from scutil --dns output.
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 := LoggerFromCtx(ctx)
// 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., "provisur.local") 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
}