mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-04-20 00:36:37 +02:00
fix(darwin): support non-standard listener port in intercept mode
When port 53 is taken (e.g. by mDNSResponder), ctrld failed with 'could not find available listen ip and port' instead of falling back to port 5354. Root cause: tryUpdateListenerConfig() checked the dnsIntercept bool, which is derived in prog.run() AFTER listener config is resolved. Fix: check interceptMode string directly (CLI flag + config fallback) in a new tryUpdateListenerConfigIntercept() that tries 127.0.0.1:53 then 127.0.0.1:5354. Also updates buildPFAnchorRules() to use the actual listener IP/port from config instead of hardcoded 127.0.0.1:53, so pf rules redirect to wherever ctrld is actually listening.
This commit is contained in:
committed by
Cuong Manh Le
parent
3548947ef0
commit
e6677b39a4
@@ -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 != ""
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user