//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 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 }