diff --git a/cmd/cli/cli.go b/cmd/cli/cli.go index 39b5035..7bdd132 100644 --- a/cmd/cli/cli.go +++ b/cmd/cli/cli.go @@ -1238,10 +1238,98 @@ func updateListenerConfig(cfg *ctrld.Config, notifyToLogServerFunc func()) bool return updated } +// tryUpdateListenerConfigIntercept handles listener binding for dns-intercept mode on macOS. +// In intercept mode, pf redirects all outbound port-53 traffic to ctrld's listener, +// so ctrld can safely listen on a non-standard port if port 53 is unavailable +// (e.g., mDNSResponder holds *:53). +// +// Flow: +// 1. If config has explicit (non-default) IP:port → use exactly that, no fallback +// 2. Otherwise → try 127.0.0.1:53, then 127.0.0.1:5354, then fatal +func tryUpdateListenerConfigIntercept(cfg *ctrld.Config, notifyFunc func(), fatal bool) (updated, ok bool) { + ok = true + lc := cfg.FirstListener() + if lc == nil { + return false, true + } + + hasExplicitConfig := lc.IP != "" && lc.IP != "0.0.0.0" && lc.Port != 0 + if !hasExplicitConfig { + // Set defaults for intercept mode + if lc.IP == "" || lc.IP == "0.0.0.0" { + lc.IP = "127.0.0.1" + updated = true + } + if lc.Port == 0 { + lc.Port = 53 + updated = true + } + } + + tryListen := func(ip string, port int) bool { + addr := net.JoinHostPort(ip, strconv.Itoa(port)) + udpLn, udpErr := net.ListenPacket("udp", addr) + if udpLn != nil { + udpLn.Close() + } + tcpLn, tcpErr := net.Listen("tcp", addr) + if tcpLn != nil { + tcpLn.Close() + } + return udpErr == nil && tcpErr == nil + } + + addr := net.JoinHostPort(lc.IP, strconv.Itoa(lc.Port)) + if tryListen(lc.IP, lc.Port) { + mainLog.Load().Debug().Msgf("DNS intercept: listener available at %s", addr) + return updated, true + } + + mainLog.Load().Info().Msgf("DNS intercept: cannot bind %s", addr) + + if hasExplicitConfig { + // User specified explicit address — don't guess, just fail + if fatal { + notifyFunc() + mainLog.Load().Fatal().Msgf("DNS intercept: cannot listen on configured address %s", addr) + } + return updated, false + } + + // Fallback: try port 5354 (mDNSResponder likely holds *:53) + if tryListen("127.0.0.1", 5354) { + mainLog.Load().Info().Msg("DNS intercept: port 53 unavailable (likely mDNSResponder), using 127.0.0.1:5354") + lc.IP = "127.0.0.1" + lc.Port = 5354 + return true, true + } + + if fatal { + notifyFunc() + mainLog.Load().Fatal().Msg("DNS intercept: cannot bind 127.0.0.1:53 or 127.0.0.1:5354") + } + return updated, false +} + // tryUpdateListenerConfig tries updating listener config with a working one. // If fatal is true, and there's listen address conflicted, the function do // fatal error. func tryUpdateListenerConfig(cfg *ctrld.Config, notifyFunc func(), fatal bool) (updated, ok bool) { + // In intercept mode (macOS), pf redirects all port-53 traffic to ctrld's listener, + // so ctrld can safely listen on a non-standard port. Use a simple two-attempt flow: + // 1. If config has explicit non-default IP:port, use exactly that + // 2. Otherwise: try 127.0.0.1:53, then 127.0.0.1:5354, then fatal + // This bypasses the full cd-mode listener probing loop entirely. + // Check interceptMode (CLI flag) first, then fall back to config value. + // dnsIntercept bool is derived later in prog.run(), but we need to know + // the intercept mode here to select the right listener probing strategy. + im := interceptMode + if im == "" || im == "off" { + im = cfg.Service.InterceptMode + } + if (im == "dns" || im == "hard") && runtime.GOOS == "darwin" { + return tryUpdateListenerConfigIntercept(cfg, notifyFunc, fatal) + } ok = true lcc := make(map[string]*listenerConfigCheck) cdMode := cdUID != "" diff --git a/cmd/cli/dns_intercept_darwin.go b/cmd/cli/dns_intercept_darwin.go index 24f9d53..5740b41 100644 --- a/cmd/cli/dns_intercept_darwin.go +++ b/cmd/cli/dns_intercept_darwin.go @@ -291,7 +291,12 @@ func (p *prog) startDNSIntercept() error { p.lastTunnelIfaces = discoverTunnelInterfaces() p.mu.Unlock() - mainLog.Load().Info().Msgf("DNS intercept: pf redirect active — all outbound DNS (port 53) redirected to 127.0.0.1:53 via anchor %q", pfAnchorName) + lc := p.cfg.FirstListener() + if lc != nil { + mainLog.Load().Info().Msgf("DNS intercept: pf redirect active — all outbound DNS (port 53) redirected to %s:%d via anchor %q", lc.IP, lc.Port, pfAnchorName) + } else { + mainLog.Load().Info().Msgf("DNS intercept: pf redirect active — all outbound DNS (port 53) redirected via anchor %q", pfAnchorName) + } // Start the pf watchdog to detect and restore rules if another program // (e.g., Windscribe desktop, macOS configd) replaces the pf ruleset. @@ -738,13 +743,31 @@ func (p *prog) validateDNSIntercept() error { // // pf requires strict rule ordering: translation (rdr) BEFORE filtering (pass). func (p *prog) buildPFAnchorRules(vpnExemptions []vpnDNSExemption) string { + // Read the actual listener address from config. In intercept mode, ctrld may + // be on a non-standard port (e.g., 127.0.0.1:5354) if mDNSResponder holds *:53. + // The pf rdr rules must redirect to wherever ctrld is actually listening. + listenerIP := "127.0.0.1" + listenerPort := 53 + if lc := p.cfg.FirstListener(); lc != nil { + if lc.IP != "" && lc.IP != "0.0.0.0" && lc.IP != "::" { + listenerIP = lc.IP + } else if lc.IP == "0.0.0.0" || lc.IP == "::" { + mainLog.Load().Warn().Str("configured_ip", lc.IP). + Msg("DNS intercept: listener configured with wildcard IP, using 127.0.0.1 for pf rules") + } + if lc.Port != 0 { + listenerPort = lc.Port + } + } + listenerAddr := fmt.Sprintf("%s port %d", listenerIP, listenerPort) + var rules strings.Builder rules.WriteString("# ctrld DNS Intercept Mode\n") rules.WriteString("# Intercepts locally-originated DNS (port 53) via route-to + rdr on lo0.\n") rules.WriteString("#\n") rules.WriteString("# How it works:\n") rules.WriteString("# 1. \"pass out route-to lo0\" forces outbound DNS through the loopback interface\n") - rules.WriteString("# 2. \"rdr on lo0\" catches it on loopback and redirects to ctrld at 127.0.0.1:53\n") + rules.WriteString(fmt.Sprintf("# 2. \"rdr on lo0\" catches it on loopback and redirects to ctrld at %s\n", listenerAddr)) rules.WriteString("#\n") rules.WriteString("# All ctrld traffic is blanket-exempted via \"pass out quick group " + pfGroupName + "\",\n") rules.WriteString("# ensuring ctrld's DoH/DoT upstream connections and DNS queries are never\n") @@ -759,10 +782,9 @@ func (p *prog) buildPFAnchorRules(vpnExemptions []vpnDNSExemption) string { // evaluation, and its implicit state alone is insufficient for response delivery — // proven by commit 51cf029 where responses were silently dropped. rules.WriteString("# --- Translation rules (rdr) ---\n") - rules.WriteString("# Redirect DNS traffic arriving on loopback (from route-to) to ctrld's listener.\n") - rules.WriteString("# Uses rdr (not rdr pass) — filter rules must evaluate to create response state.\n") - rules.WriteString("rdr on lo0 inet proto udp from any to ! 127.0.0.1 port 53 -> 127.0.0.1 port 53\n") - rules.WriteString("rdr on lo0 inet proto tcp from any to ! 127.0.0.1 port 53 -> 127.0.0.1 port 53\n\n") + rules.WriteString("# Redirect DNS on loopback to ctrld's listener.\n") + rules.WriteString(fmt.Sprintf("rdr on lo0 inet proto udp from any to ! %s port 53 -> %s\n", listenerIP, listenerAddr)) + rules.WriteString(fmt.Sprintf("rdr on lo0 inet proto tcp from any to ! %s port 53 -> %s\n\n", listenerIP, listenerAddr)) // --- Filtering rules --- rules.WriteString("# --- Filtering rules (pass) ---\n\n") @@ -899,8 +921,8 @@ func (p *prog) buildPFAnchorRules(vpnExemptions []vpnDNSExemption) string { rules.WriteString(fmt.Sprintf("# Skipped %s — VPN DNS interface (passthrough rules handle this)\n", iface)) continue } - rules.WriteString(fmt.Sprintf("pass out quick on %s route-to lo0 inet proto udp from any to ! 127.0.0.1 port 53\n", iface)) - rules.WriteString(fmt.Sprintf("pass out quick on %s route-to lo0 inet proto tcp from any to ! 127.0.0.1 port 53\n", iface)) + rules.WriteString(fmt.Sprintf("pass out quick on %s route-to lo0 inet proto udp from any to ! %s port 53\n", iface, listenerIP)) + rules.WriteString(fmt.Sprintf("pass out quick on %s route-to lo0 inet proto tcp from any to ! %s port 53\n", iface, listenerIP)) } rules.WriteString("\n") } @@ -910,8 +932,8 @@ func (p *prog) buildPFAnchorRules(vpnExemptions []vpnDNSExemption) string { // (matches on any interface), but "pass out on lo0 no state" below ensures no state // is created on the lo0 outbound path, allowing rdr to fire on lo0 inbound. rules.WriteString("# Force remaining outbound IPv4 DNS through loopback for interception.\n") - rules.WriteString("pass out quick on ! lo0 route-to lo0 inet proto udp from any to ! 127.0.0.1 port 53\n") - rules.WriteString("pass out quick on ! lo0 route-to lo0 inet proto tcp from any to ! 127.0.0.1 port 53\n\n") + rules.WriteString(fmt.Sprintf("pass out quick on ! lo0 route-to lo0 inet proto udp from any to ! %s port 53\n", listenerIP)) + rules.WriteString(fmt.Sprintf("pass out quick on ! lo0 route-to lo0 inet proto tcp from any to ! %s port 53\n\n", listenerIP)) // Allow route-to'd DNS packets to pass outbound on lo0. // Without this, VPN firewalls with "block drop all" (e.g., Windscribe) drop the packet @@ -921,8 +943,8 @@ func (p *prog) buildPFAnchorRules(vpnExemptions []vpnDNSExemption) string { // the packet when it reflects inbound on lo0, causing pf to fast-path it and bypass // rdr entirely. With "no state", the inbound packet gets fresh evaluation and rdr fires. rules.WriteString("# Pass route-to'd DNS outbound on lo0 — no state to avoid bypassing rdr inbound.\n") - rules.WriteString("pass out quick on lo0 inet proto udp from any to ! 127.0.0.1 port 53 no state\n") - rules.WriteString("pass out quick on lo0 inet proto tcp from any to ! 127.0.0.1 port 53 no state\n\n") + rules.WriteString(fmt.Sprintf("pass out quick on lo0 inet proto udp from any to ! %s port 53 no state\n", listenerIP)) + rules.WriteString(fmt.Sprintf("pass out quick on lo0 inet proto tcp from any to ! %s port 53 no state\n\n", listenerIP)) // Allow the redirected traffic through on loopback (inbound after rdr). // @@ -936,7 +958,7 @@ func (p *prog) buildPFAnchorRules(vpnExemptions []vpnDNSExemption) string { // the response. The rdr NAT state handles the address rewrite on the response // (source 127.0.0.1 → original DNS server IP, e.g., 10.255.255.3). rules.WriteString("# Accept redirected DNS — reply-to lo0 forces response through loopback.\n") - rules.WriteString("pass in quick on lo0 reply-to lo0 inet proto { udp, tcp } from any to 127.0.0.1 port 53\n") + rules.WriteString(fmt.Sprintf("pass in quick on lo0 reply-to lo0 inet proto { udp, tcp } from any to %s\n", listenerAddr)) return rules.String() }