mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-07-04 01:07:49 +02:00
fix: use raw IPv6 socket for DNS responses in macOS intercept mode
macOS rejects sendmsg from [::1] to global unicast IPv6 (EINVAL), and nat on lo0 doesn't fire for route-to'd packets (pf skips translation on the second interface pass). ULA addresses on lo0 also fail (EHOSTUNREACH - kernel segregates lo0 routing). Solution: wrap the [::1] UDP listener's ResponseWriter with rawIPv6Writer that sends responses via SOCK_RAW (IPPROTO_UDP) on lo0, bypassing the kernel's routing validation. pf's rdr state reverses the address translation on the response path. Changes: - Add rawipv6_darwin.go: rawIPv6Writer wraps dns.ResponseWriter, sends UDP responses via raw IPv6 socket with proper checksum calculation - Add rawipv6_other.go: no-op wrapIPv6Handler for non-darwin platforms - Remove nat rules from pf anchor (no longer needed) - Block IPv6 TCP DNS (block return) - falls back to IPv4 (~1s, rare) - Remove IPv6 TCP rdr/route-to/pass rules (only UDP intercepted)
This commit is contained in:
committed by
Cuong Manh Le
parent
95dd871e2d
commit
22a796f673
@@ -321,14 +321,13 @@ func (p *prog) startDNSIntercept() error {
|
||||
// options → normalization (scrub) → queueing → translation (nat/rdr) → filtering (pass/block/anchor)
|
||||
//
|
||||
// "pfctl -sr" returns BOTH scrub-anchor (normalization) AND anchor/pass/block (filter) rules.
|
||||
// "pfctl -sn" returns nat-anchor AND rdr-anchor (translation) rules.
|
||||
// "pfctl -sn" returns rdr-anchor (translation) rules.
|
||||
// Both commands emit "No ALTQ support in kernel" warnings on stderr.
|
||||
//
|
||||
// We must reassemble in correct order: scrub → nat/rdr → filter.
|
||||
//
|
||||
// The anchor reference does not survive a reboot, but ctrld re-adds it on every start.
|
||||
func (p *prog) ensurePFAnchorReference() error {
|
||||
natAnchorRef := fmt.Sprintf("nat-anchor \"%s\"", pfAnchorName)
|
||||
rdrAnchorRef := fmt.Sprintf("rdr-anchor \"%s\"", pfAnchorName)
|
||||
anchorRef := fmt.Sprintf("anchor \"%s\"", pfAnchorName)
|
||||
|
||||
@@ -347,11 +346,10 @@ func (p *prog) ensurePFAnchorReference() error {
|
||||
natLines := pfFilterRuleLines(string(natOut))
|
||||
filterLines := pfFilterRuleLines(string(filterOut))
|
||||
|
||||
hasNatAnchor := pfContainsRule(natLines, natAnchorRef)
|
||||
hasRdrAnchor := pfContainsRule(natLines, rdrAnchorRef)
|
||||
hasAnchor := pfContainsRule(filterLines, anchorRef)
|
||||
|
||||
if hasNatAnchor && hasRdrAnchor && hasAnchor {
|
||||
if hasRdrAnchor && hasAnchor {
|
||||
// Verify anchor ordering: our anchor should appear before other anchors
|
||||
// for reliable DNS interception priority. Log a warning if out of order,
|
||||
// but don't force a reload (the interface-specific rules in our anchor
|
||||
@@ -380,15 +378,8 @@ func (p *prog) ensurePFAnchorReference() error {
|
||||
// rules in whichever anchor appears first win. By prepending, our DNS
|
||||
// intercept rules match port 53 traffic before a VPN app's broader
|
||||
// "pass out quick on <iface> all" rules in their anchor.
|
||||
if !hasNatAnchor || !hasRdrAnchor {
|
||||
var newRefs []string
|
||||
if !hasNatAnchor {
|
||||
newRefs = append(newRefs, natAnchorRef)
|
||||
}
|
||||
if !hasRdrAnchor {
|
||||
newRefs = append(newRefs, rdrAnchorRef)
|
||||
}
|
||||
natLines = append(newRefs, natLines...)
|
||||
if !hasRdrAnchor {
|
||||
natLines = append([]string{rdrAnchorRef}, natLines...)
|
||||
}
|
||||
if !hasAnchor {
|
||||
pureFilterLines = append([]string{anchorRef}, pureFilterLines...)
|
||||
@@ -590,7 +581,6 @@ func (p *prog) stopDNSIntercept() error {
|
||||
// The anchor itself is already flushed by stopDNSIntercept, so even if removal
|
||||
// fails, the empty anchor is a no-op.
|
||||
func (p *prog) removePFAnchorReference() error {
|
||||
natAnchorRef := fmt.Sprintf("nat-anchor \"%s\"", pfAnchorName)
|
||||
rdrAnchorRef := fmt.Sprintf("rdr-anchor \"%s\"", pfAnchorName)
|
||||
anchorRef := fmt.Sprintf("anchor \"%s\"", pfAnchorName)
|
||||
|
||||
@@ -609,7 +599,7 @@ func (p *prog) removePFAnchorReference() error {
|
||||
|
||||
var cleanNat []string
|
||||
for _, line := range natLines {
|
||||
if !strings.Contains(line, rdrAnchorRef) && !strings.Contains(line, natAnchorRef) {
|
||||
if !strings.Contains(line, rdrAnchorRef) {
|
||||
cleanNat = append(cleanNat, line)
|
||||
}
|
||||
}
|
||||
@@ -804,23 +794,13 @@ func (p *prog) buildPFAnchorRules(vpnExemptions []vpnDNSExemption) string {
|
||||
// a stateful entry that handles response routing. Using "rdr pass" would skip filter
|
||||
// evaluation, and its implicit state alone is insufficient for response delivery —
|
||||
// proven by commit 51cf029 where responses were silently dropped.
|
||||
rules.WriteString("# --- Translation rules (nat + rdr) ---\n")
|
||||
rules.WriteString("# --- Translation rules (rdr) ---\n")
|
||||
|
||||
// NAT source to ::1 for IPv6 DNS on loopback. macOS/BSD rejects sendmsg from
|
||||
// [::1] to a global unicast IPv6 address (EINVAL), unlike IPv4 where sendmsg from
|
||||
// 127.0.0.1 to local private IPs works fine. The rdr rewrites the destination but
|
||||
// preserves the original source (machine's global IPv6). Without nat, ctrld cannot
|
||||
// reply. pf reverses both translations on the response path.
|
||||
// Note: nat must appear before rdr (pf evaluates nat first in translation phase).
|
||||
listenerAddr6 := fmt.Sprintf("::1 port %d", listenerPort)
|
||||
rules.WriteString("nat on lo0 inet6 proto udp from ! ::1 to ! ::1 port 53 -> ::1\n")
|
||||
rules.WriteString("nat on lo0 inet6 proto tcp from ! ::1 to ! ::1 port 53 -> ::1\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", listenerIP, listenerAddr))
|
||||
rules.WriteString(fmt.Sprintf("rdr on lo0 inet6 proto udp from any to ! ::1 port 53 -> %s\n", listenerAddr6))
|
||||
rules.WriteString(fmt.Sprintf("rdr on lo0 inet6 proto tcp from any to ! ::1 port 53 -> %s\n\n", listenerAddr6))
|
||||
rules.WriteString(fmt.Sprintf("rdr on lo0 inet6 proto udp from any to ! ::1 port 53 -> %s\n\n", listenerAddr6))
|
||||
|
||||
// --- Filtering rules ---
|
||||
rules.WriteString("# --- Filtering rules (pass) ---\n\n")
|
||||
@@ -983,7 +963,6 @@ 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))
|
||||
rules.WriteString(fmt.Sprintf("pass out quick on %s route-to lo0 inet6 proto tcp from any to ! ::1 port 53\n", iface))
|
||||
}
|
||||
rules.WriteString("\n")
|
||||
}
|
||||
@@ -1003,10 +982,13 @@ 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 DNS through loopback for interception.
|
||||
rules.WriteString("# Force remaining outbound IPv6 DNS through loopback for interception.\n")
|
||||
// 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("pass out quick on ! lo0 route-to lo0 inet6 proto tcp from any to ! ::1 port 53\n\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")
|
||||
|
||||
// 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
|
||||
@@ -1018,8 +1000,7 @@ 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")
|
||||
rules.WriteString("pass out quick on lo0 inet6 proto tcp from any to ! ::1 port 53 no state\n\n")
|
||||
rules.WriteString("pass out quick on lo0 inet6 proto udp from any to ! ::1 port 53 no state\n\n")
|
||||
|
||||
// Allow the redirected traffic through on loopback (inbound after rdr).
|
||||
//
|
||||
@@ -1034,7 +1015,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, tcp } from any to %s\n", listenerAddr6))
|
||||
rules.WriteString(fmt.Sprintf("pass in quick on lo0 reply-to lo0 inet6 proto udp from any to %s\n", listenerAddr6))
|
||||
|
||||
return rules.String()
|
||||
}
|
||||
@@ -1043,12 +1024,11 @@ func (p *prog) buildPFAnchorRules(vpnExemptions []vpnDNSExemption) string {
|
||||
// It verifies both the anchor references in the main ruleset and the rules within
|
||||
// our anchor. Failures are logged at ERROR level to make them impossible to miss.
|
||||
func (p *prog) verifyPFState() {
|
||||
natAnchorRef := fmt.Sprintf("nat-anchor \"%s\"", pfAnchorName)
|
||||
rdrAnchorRef := fmt.Sprintf("rdr-anchor \"%s\"", pfAnchorName)
|
||||
anchorRef := fmt.Sprintf("anchor \"%s\"", pfAnchorName)
|
||||
verified := true
|
||||
|
||||
// Check main ruleset for anchor references (nat-anchor + rdr-anchor in translation rules).
|
||||
// Check main ruleset for anchor references (rdr-anchor in translation rules).
|
||||
natOut, err := exec.Command("pfctl", "-sn").CombinedOutput()
|
||||
if err != nil {
|
||||
mainLog.Load().Error().Err(err).Msg("DNS intercept: VERIFICATION FAILED — could not dump NAT rules")
|
||||
@@ -1059,10 +1039,6 @@ func (p *prog) verifyPFState() {
|
||||
mainLog.Load().Error().Msg("DNS intercept: VERIFICATION FAILED — rdr-anchor reference missing from running NAT rules")
|
||||
verified = false
|
||||
}
|
||||
if !strings.Contains(natStr, natAnchorRef) {
|
||||
mainLog.Load().Error().Msg("DNS intercept: VERIFICATION FAILED — nat-anchor reference missing from running NAT rules")
|
||||
verified = false
|
||||
}
|
||||
}
|
||||
|
||||
filterOut, err := exec.Command("pfctl", "-sr").CombinedOutput()
|
||||
@@ -1229,6 +1205,7 @@ func stringSlicesEqual(a, b []string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
// pfStartStabilization enters stabilization mode, suppressing all pf restores
|
||||
// until the VPN's ruleset stops changing. This prevents a death spiral where
|
||||
// ctrld and the VPN repeatedly overwrite each other's pf rules.
|
||||
@@ -1347,7 +1324,6 @@ func (p *prog) ensurePFAnchorActive() bool {
|
||||
}
|
||||
}
|
||||
|
||||
natAnchorRef := fmt.Sprintf("nat-anchor \"%s\"", pfAnchorName)
|
||||
rdrAnchorRef := fmt.Sprintf("rdr-anchor \"%s\"", pfAnchorName)
|
||||
anchorRef := fmt.Sprintf("anchor \"%s\"", pfAnchorName)
|
||||
needsRestore := false
|
||||
@@ -1363,10 +1339,6 @@ func (p *prog) ensurePFAnchorActive() bool {
|
||||
mainLog.Load().Warn().Msg("DNS intercept watchdog: rdr-anchor reference missing from running ruleset")
|
||||
needsRestore = true
|
||||
}
|
||||
if !strings.Contains(natStr, natAnchorRef) {
|
||||
mainLog.Load().Warn().Msg("DNS intercept watchdog: nat-anchor reference missing from running ruleset")
|
||||
needsRestore = true
|
||||
}
|
||||
|
||||
if !needsRestore {
|
||||
filterOut, err := exec.Command("pfctl", "-sr").CombinedOutput()
|
||||
@@ -1762,7 +1734,6 @@ func (p *prog) pfInterceptMonitor() {
|
||||
// The reload is safe for VPN interop because it reassembles from the current running
|
||||
// ruleset (pfctl -sr/-sn), preserving all existing anchors and rules.
|
||||
func (p *prog) forceReloadPFMainRuleset() {
|
||||
natAnchorRef := fmt.Sprintf("nat-anchor \"%s\"", pfAnchorName)
|
||||
rdrAnchorRef := fmt.Sprintf("rdr-anchor \"%s\"", pfAnchorName)
|
||||
anchorRef := fmt.Sprintf("anchor \"%s\"", pfAnchorName)
|
||||
|
||||
@@ -1793,9 +1764,6 @@ func (p *prog) forceReloadPFMainRuleset() {
|
||||
}
|
||||
|
||||
// Ensure our anchor references are present (they may have been wiped).
|
||||
if !pfContainsRule(natLines, natAnchorRef) {
|
||||
natLines = append([]string{natAnchorRef}, natLines...)
|
||||
}
|
||||
if !pfContainsRule(natLines, rdrAnchorRef) {
|
||||
natLines = append([]string{rdrAnchorRef}, natLines...)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user