mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-04-07 12:32:04 +02:00
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:
committed by
Cuong Manh Le
parent
c55e2a722c
commit
3f59cdad1a
@@ -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()
|
||||
}
|
||||
|
||||
@@ -211,11 +211,7 @@ func (p *prog) serveDNS(listenerNum string) error {
|
||||
proto := proto
|
||||
if needLocalIPv6Listener(p.cfg.Service.InterceptMode) {
|
||||
g.Go(func() error {
|
||||
var ipv6Handler dns.Handler = handler
|
||||
if proto == "udp" {
|
||||
ipv6Handler = wrapIPv6Handler(handler)
|
||||
}
|
||||
s, errCh := runDNSServer(net.JoinHostPort("::1", strconv.Itoa(listenerConfig.Port)), proto, ipv6Handler)
|
||||
s, errCh := runDNSServer(net.JoinHostPort("::1", strconv.Itoa(listenerConfig.Port)), proto, handler)
|
||||
defer s.Shutdown()
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
@@ -907,16 +903,13 @@ func needLocalIPv6Listener(interceptMode string) bool {
|
||||
mainLog.Load().Debug().Msg("IPv6 listener: enabled (Windows)")
|
||||
return true
|
||||
}
|
||||
// On macOS in intercept mode, pf can't redirect IPv6 DNS to an IPv4 listener (cross-AF rdr
|
||||
// not supported), and blocking IPv6 DNS causes ~1s timeouts (BSD doesn't deliver ICMP errors
|
||||
// to unconnected UDP sockets). Listening on [::1] lets us intercept IPv6 DNS directly.
|
||||
//
|
||||
// NOTE: We accept the intercept mode string as a parameter instead of reading the global
|
||||
// dnsIntercept bool, because dnsIntercept is derived later in prog.run() — after the
|
||||
// listener goroutines are already spawned. Same pattern as the port 5354 fallback fix (MR !860).
|
||||
if (interceptMode == "dns" || interceptMode == "hard") && runtime.GOOS == "darwin" {
|
||||
mainLog.Load().Debug().Msg("IPv6 listener: enabled (macOS intercept mode)")
|
||||
return true
|
||||
// macOS: IPv6 DNS is blocked at the pf level (not intercepted). The [::1] listener
|
||||
// is not needed — macOS falls back to IPv4 DNS automatically. See #507 and
|
||||
// docs/pf-dns-intercept.md for why IPv6 interception on macOS is not feasible
|
||||
// (sendmsg EINVAL from ::1 to global unicast, nat-on-lo0 doesn't fire for route-to).
|
||||
if runtime.GOOS == "darwin" {
|
||||
mainLog.Load().Debug().Msg("IPv6 listener: not needed (macOS — IPv6 DNS blocked at pf, fallback to IPv4)")
|
||||
return false
|
||||
}
|
||||
mainLog.Load().Debug().Str("os", runtime.GOOS).Str("interceptMode", interceptMode).Msg("IPv6 listener: not needed")
|
||||
return false
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
//go:build darwin
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"net"
|
||||
"syscall"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// wrapIPv6Handler wraps a DNS handler so that UDP responses on the [::1] listener
|
||||
// are sent via raw IPv6 sockets instead of the normal sendmsg path. This is needed
|
||||
// because macOS rejects sendmsg from [::1] to global unicast IPv6 addresses (EINVAL).
|
||||
func wrapIPv6Handler(h dns.Handler) dns.Handler {
|
||||
return dns.HandlerFunc(func(w dns.ResponseWriter, r *dns.Msg) {
|
||||
h.ServeDNS(&rawIPv6Writer{ResponseWriter: w}, r)
|
||||
})
|
||||
}
|
||||
|
||||
// rawIPv6Writer wraps a dns.ResponseWriter for the [::1] IPv6 listener on macOS.
|
||||
// When pf redirects IPv6 DNS traffic via route-to + rdr to [::1]:53, the original
|
||||
// client source address is a global unicast IPv6 (e.g., 2607:f0c8:...). macOS
|
||||
// rejects sendmsg from [::1] to any non-loopback address (EINVAL), so the normal
|
||||
// WriteMsg fails. This wrapper intercepts UDP writes and sends the response via a
|
||||
// raw IPv6 socket on lo0, bypassing the kernel's routing validation.
|
||||
//
|
||||
// TCP is not handled — IPv6 TCP DNS is blocked by pf rules and falls back to IPv4.
|
||||
type rawIPv6Writer struct {
|
||||
dns.ResponseWriter
|
||||
}
|
||||
|
||||
// WriteMsg packs the DNS message and sends it via raw socket.
|
||||
func (w *rawIPv6Writer) WriteMsg(m *dns.Msg) error {
|
||||
data, err := m.Pack()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = w.Write(data)
|
||||
return err
|
||||
}
|
||||
|
||||
// Write sends raw DNS response bytes via a raw IPv6/UDP socket on lo0.
|
||||
// It constructs a UDP packet (header + payload) and sends it using
|
||||
// IPPROTO_RAW-like behavior via IPV6_HDRINCL-free raw UDP socket.
|
||||
//
|
||||
// pf's rdr state table will reverse-translate the addresses on the response:
|
||||
// - src [::1]:53 → original DNS server IPv6
|
||||
// - dst [client]:port → unchanged
|
||||
func (w *rawIPv6Writer) Write(payload []byte) (int, error) {
|
||||
localAddr := w.ResponseWriter.LocalAddr()
|
||||
remoteAddr := w.ResponseWriter.RemoteAddr()
|
||||
|
||||
srcIP, srcPort, err := parseAddrPort(localAddr)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("rawIPv6Writer: parse local addr %s: %w", localAddr, err)
|
||||
}
|
||||
dstIP, dstPort, err := parseAddrPort(remoteAddr)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("rawIPv6Writer: parse remote addr %s: %w", remoteAddr, err)
|
||||
}
|
||||
|
||||
// Build UDP packet: 8-byte header + DNS payload.
|
||||
udpLen := 8 + len(payload)
|
||||
udpPacket := make([]byte, udpLen)
|
||||
binary.BigEndian.PutUint16(udpPacket[0:2], uint16(srcPort))
|
||||
binary.BigEndian.PutUint16(udpPacket[2:4], uint16(dstPort))
|
||||
binary.BigEndian.PutUint16(udpPacket[4:6], uint16(udpLen))
|
||||
// Checksum placeholder — filled below.
|
||||
binary.BigEndian.PutUint16(udpPacket[6:8], 0)
|
||||
copy(udpPacket[8:], payload)
|
||||
|
||||
// Compute UDP checksum over IPv6 pseudo-header + UDP packet.
|
||||
// For IPv6, UDP checksum is mandatory (unlike IPv4 where it's optional).
|
||||
csum := udp6Checksum(srcIP, dstIP, udpPacket)
|
||||
binary.BigEndian.PutUint16(udpPacket[6:8], csum)
|
||||
|
||||
// Open raw UDP socket. SOCK_RAW with IPPROTO_UDP lets us send
|
||||
// hand-crafted UDP packets. The kernel adds the IPv6 header.
|
||||
fd, err := syscall.Socket(syscall.AF_INET6, syscall.SOCK_RAW, syscall.IPPROTO_UDP)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("rawIPv6Writer: socket: %w", err)
|
||||
}
|
||||
defer syscall.Close(fd)
|
||||
|
||||
// Bind to lo0 interface so the packet exits on loopback where pf can
|
||||
// reverse-translate via its rdr state table.
|
||||
if err := bindToLoopback6(fd); err != nil {
|
||||
return 0, fmt.Errorf("rawIPv6Writer: bind to lo0: %w", err)
|
||||
}
|
||||
|
||||
// Send to the client's address.
|
||||
sa := &syscall.SockaddrInet6{Port: 0} // Port is in the UDP header, not the sockaddr for raw sockets.
|
||||
copy(sa.Addr[:], dstIP.To16())
|
||||
|
||||
if err := syscall.Sendto(fd, udpPacket, 0, sa); err != nil {
|
||||
return 0, fmt.Errorf("rawIPv6Writer: sendto [%s]:%d: %w", dstIP, dstPort, err)
|
||||
}
|
||||
|
||||
return len(payload), nil
|
||||
}
|
||||
|
||||
// parseAddrPort extracts IP and port from a net.Addr (supports *net.UDPAddr and string parsing).
|
||||
func parseAddrPort(addr net.Addr) (net.IP, int, error) {
|
||||
if ua, ok := addr.(*net.UDPAddr); ok {
|
||||
return ua.IP, ua.Port, nil
|
||||
}
|
||||
host, portStr, err := net.SplitHostPort(addr.String())
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil {
|
||||
return nil, 0, fmt.Errorf("invalid IP: %s", host)
|
||||
}
|
||||
port, err := net.LookupPort("udp", portStr)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return ip, port, nil
|
||||
}
|
||||
|
||||
// udp6Checksum computes the UDP checksum over the IPv6 pseudo-header and UDP packet.
|
||||
// The pseudo-header includes: src IP (16), dst IP (16), UDP length (4), next header (4).
|
||||
func udp6Checksum(src, dst net.IP, udpPacket []byte) uint16 {
|
||||
// IPv6 pseudo-header for checksum:
|
||||
// Source Address (16 bytes)
|
||||
// Destination Address (16 bytes)
|
||||
// UDP Length (4 bytes, upper layer packet length)
|
||||
// Zero (3 bytes) + Next Header (1 byte) = 17 (UDP)
|
||||
psh := make([]byte, 40)
|
||||
copy(psh[0:16], src.To16())
|
||||
copy(psh[16:32], dst.To16())
|
||||
binary.BigEndian.PutUint32(psh[32:36], uint32(len(udpPacket)))
|
||||
psh[39] = 17 // Next Header: UDP
|
||||
|
||||
// Checksum over pseudo-header + UDP packet.
|
||||
var sum uint32
|
||||
data := append(psh, udpPacket...)
|
||||
for i := 0; i+1 < len(data); i += 2 {
|
||||
sum += uint32(binary.BigEndian.Uint16(data[i : i+2]))
|
||||
}
|
||||
if len(data)%2 == 1 {
|
||||
sum += uint32(data[len(data)-1]) << 8
|
||||
}
|
||||
for sum > 0xffff {
|
||||
sum = (sum >> 16) + (sum & 0xffff)
|
||||
}
|
||||
return ^uint16(sum)
|
||||
}
|
||||
|
||||
// bindToLoopback6 binds a raw IPv6 socket to the loopback interface (lo0)
|
||||
// and sets the source address to ::1. This ensures the packet exits on lo0
|
||||
// where pf's rdr state can reverse-translate the addresses.
|
||||
func bindToLoopback6(fd int) error {
|
||||
// Bind source to ::1 — this is the address ctrld is listening on,
|
||||
// and what pf's rdr state expects as the source of the response.
|
||||
sa := &syscall.SockaddrInet6{Port: 0}
|
||||
copy(sa.Addr[:], net.IPv6loopback.To16())
|
||||
return syscall.Bind(fd, sa)
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
//go:build !darwin
|
||||
|
||||
package cli
|
||||
|
||||
import "github.com/miekg/dns"
|
||||
|
||||
// wrapIPv6Handler is a no-op on non-darwin platforms. The raw IPv6 response
|
||||
// writer is only needed on macOS where pf's rdr preserves the original global
|
||||
// unicast source address, and the kernel rejects sendmsg from [::1] to it.
|
||||
func wrapIPv6Handler(h dns.Handler) dns.Handler {
|
||||
return h
|
||||
}
|
||||
Reference in New Issue
Block a user