fix: block IPv6 DNS in intercept mode, remove raw socket approach

IPv6 DNS interception on macOS is not feasible with current pf capabilities.
The kernel rejects sendmsg from [::1] to global unicast (EINVAL), nat on lo0
doesn't fire for route-to'd packets, raw sockets bypass routing but pf doesn't
match them against rdr state, and DIOCNATLOOK can't be used because bind()
fails for non-local addresses.

Replace all IPv6 interception code with a simple pf block rule:
  block out quick on ! lo0 inet6 proto { udp, tcp } from any to any port 53

macOS automatically retries DNS over IPv4 when IPv6 is blocked.

Changes:
- Remove rawipv6_darwin.go and rawipv6_other.go
- Remove [::1] listener spawn on macOS (needLocalIPv6Listener returns false)
- Remove IPv6 rdr, route-to, pass, and reply-to pf rules
- Add block rule for all outbound IPv6 DNS
- Update docs/pf-dns-intercept.md with what was tried and why it failed
This commit is contained in:
Codescribe
2026-03-30 20:52:35 -04:00
committed by Cuong Manh Le
parent c55e2a722c
commit 3f59cdad1a
5 changed files with 41 additions and 258 deletions
+14 -12
View File
@@ -796,11 +796,11 @@ func (p *prog) buildPFAnchorRules(vpnExemptions []vpnDNSExemption) string {
// proven by commit 51cf029 where responses were silently dropped.
rules.WriteString("# --- Translation rules (rdr) ---\n")
listenerAddr6 := fmt.Sprintf("::1 port %d", listenerPort)
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", listenerIP, listenerAddr))
rules.WriteString(fmt.Sprintf("rdr on lo0 inet6 proto udp from any to ! ::1 port 53 -> %s\n\n", listenerAddr6))
// No IPv6 rdr — IPv6 DNS is blocked at the filter level (see below).
rules.WriteString("\n")
// --- Filtering rules ---
rules.WriteString("# --- Filtering rules (pass) ---\n\n")
@@ -962,7 +962,7 @@ func (p *prog) buildPFAnchorRules(vpnExemptions []vpnDNSExemption) string {
}
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(fmt.Sprintf("pass out quick on %s route-to lo0 inet6 proto udp from any to ! ::1 port 53\n", iface))
// No IPv6 route-to — IPv6 DNS is blocked, not intercepted.
}
rules.WriteString("\n")
}
@@ -982,13 +982,14 @@ func (p *prog) buildPFAnchorRules(vpnExemptions []vpnDNSExemption) string {
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))
// Force remaining outbound IPv6 UDP DNS through loopback for interception.
// IPv6 TCP DNS is blocked instead — raw socket response injection only handles UDP,
// and TCP DNS is rare (truncated responses, zone transfers). Apps fall back to IPv4 TCP.
rules.WriteString("# Force remaining outbound IPv6 UDP DNS through loopback for interception.\n")
rules.WriteString("pass out quick on ! lo0 route-to lo0 inet6 proto udp from any to ! ::1 port 53\n")
rules.WriteString("# Block IPv6 TCP DNS — raw socket can't handle TCP; apps fall back to IPv4.\n")
rules.WriteString("block return out quick on ! lo0 inet6 proto tcp from any to ! ::1 port 53\n\n")
// Block all outbound IPv6 DNS. ctrld only intercepts IPv4 DNS via the loopback
// redirect. IPv6 DNS interception on macOS is not feasible because the kernel rejects
// sendmsg from [::1] to global unicast IPv6 (EINVAL), and pf's nat-on-lo0 doesn't
// fire for route-to'd packets. Blocking forces macOS to fall back to IPv4 DNS,
// which is fully intercepted. See docs/pf-dns-intercept.md for details.
rules.WriteString("# Block outbound IPv6 DNS — ctrld intercepts IPv4 only.\n")
rules.WriteString("# macOS falls back to IPv4 DNS automatically.\n")
rules.WriteString("block out quick on ! lo0 inet6 proto { udp, tcp } from any to any port 53\n\n")
// 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
@@ -1000,7 +1001,8 @@ func (p *prog) buildPFAnchorRules(vpnExemptions []vpnDNSExemption) string {
rules.WriteString("# Pass route-to'd DNS outbound on lo0 — no state to avoid bypassing rdr inbound.\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", listenerIP))
rules.WriteString("pass out quick on lo0 inet6 proto udp from any to ! ::1 port 53 no state\n\n")
// No IPv6 lo0 pass — IPv6 DNS is blocked, not routed through lo0.
rules.WriteString("\n")
// Allow the redirected traffic through on loopback (inbound after rdr).
//
@@ -1015,7 +1017,7 @@ func (p *prog) buildPFAnchorRules(vpnExemptions []vpnDNSExemption) string {
// (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(fmt.Sprintf("pass in quick on lo0 reply-to lo0 inet proto { udp, tcp } from any to %s\n", listenerAddr))
rules.WriteString(fmt.Sprintf("pass in quick on lo0 reply-to lo0 inet6 proto udp from any to %s\n", listenerAddr6))
// No IPv6 pass-in — IPv6 DNS is blocked, not redirected to [::1].
return rules.String()
}