From 20ee23c1c32a0c636e4e58a4e8f127e10a28f2d2 Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Mon, 2 Mar 2026 12:04:36 -0600 Subject: [PATCH] fix: use socks5h:// for macOS daemon DNS resolution through proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS getaddrinfo() uses mDNSResponder via Mach IPC and does NOT fall back to direct UDP DNS when those services are blocked — it simply fails with EAI_NONAME. This made DNS resolution fail for all sandboxed processes in daemon mode. Switch to setting ALL_PROXY=socks5h:// env var so proxy-aware apps (curl, git, etc.) resolve hostnames through the SOCKS5 proxy. The "h" suffix means "resolve hostname at proxy side". Only ALL_PROXY is set (not HTTP_PROXY) to avoid breaking apps like Bun/Node.js. Other changes: - Revert opendirectoryd.libinfo and configd mach service blocks - Exclude loopback (127.0.0.0/8) from pf TCP route-to to prevent double-proxying when ALL_PROXY connects directly to local proxy - Always create DNS relay with default upstream (127.0.0.1:42053) - Use always-on logging in DNS relay (not debug-only) - Force IPv4 (udp4) for DNS relay upstream connections - Log tunnel cleanup errors instead of silently discarding them --- internal/daemon/dns.go | 34 +++++++++++------------------- internal/daemon/server.go | 44 ++++++++++++++++++++------------------- internal/daemon/tun.go | 29 ++++++++++++-------------- internal/sandbox/macos.go | 26 +++++++++++++++++------ 4 files changed, 68 insertions(+), 65 deletions(-) diff --git a/internal/daemon/dns.go b/internal/daemon/dns.go index 646ea9f..85b3f92 100644 --- a/internal/daemon/dns.go +++ b/internal/daemon/dns.go @@ -5,7 +5,6 @@ package daemon import ( "fmt" "net" - "os" "sync" "time" ) @@ -77,9 +76,7 @@ func (d *DNSRelay) ListenAddr() string { // listening socket and spawns a goroutine per query to forward it to the // upstream DNS server and relay the response back. func (d *DNSRelay) Start() error { - if d.debug { - fmt.Fprintf(os.Stderr, "[greywall:dns-relay] Listening on %s, forwarding to %s\n", d.listenAddr, d.targetAddr) - } + Logf("DNS relay listening on %s, forwarding to %s", d.listenAddr, d.targetAddr) d.wg.Add(1) go d.readLoop() @@ -94,9 +91,7 @@ func (d *DNSRelay) Stop() { _ = d.udpConn.Close() d.wg.Wait() - if d.debug { - fmt.Fprintf(os.Stderr, "[greywall:dns-relay] Stopped\n") - } + Logf("DNS relay stopped") } // readLoop is the main loop that reads incoming DNS queries from the listening socket. @@ -112,7 +107,7 @@ func (d *DNSRelay) readLoop() { // Shutting down, expected error from closed socket. return default: - fmt.Fprintf(os.Stderr, "[greywall:dns-relay] Read error: %v\n", err) + Logf("DNS relay: read error: %v", err) continue } } @@ -136,36 +131,33 @@ func (d *DNSRelay) readLoop() { func (d *DNSRelay) handleQuery(query []byte, clientAddr *net.UDPAddr) { defer d.wg.Done() - if d.debug { - fmt.Fprintf(os.Stderr, "[greywall:dns-relay] Query from %s (%d bytes)\n", clientAddr, len(query)) - } + Logf("DNS relay: query from %s (%d bytes)", clientAddr, len(query)) // Create a dedicated UDP connection to the upstream DNS server. - upstreamConn, err := net.Dial("udp", d.targetAddr) + // Use "udp4" to force IPv4, since the upstream may only listen on 127.0.0.1. + upstreamConn, err := net.Dial("udp4", d.targetAddr) if err != nil { - fmt.Fprintf(os.Stderr, "[greywall:dns-relay] Failed to connect to upstream %s: %v\n", d.targetAddr, err) + Logf("DNS relay: failed to connect to upstream %s: %v", d.targetAddr, err) return } defer upstreamConn.Close() //nolint:errcheck // best-effort cleanup of per-query UDP connection // Send the query to the upstream server. if _, err := upstreamConn.Write(query); err != nil { - fmt.Fprintf(os.Stderr, "[greywall:dns-relay] Failed to send query to upstream: %v\n", err) + Logf("DNS relay: failed to send query to upstream: %v", err) return } // Wait for the response with a timeout. if err := upstreamConn.SetReadDeadline(time.Now().Add(upstreamTimeout)); err != nil { - fmt.Fprintf(os.Stderr, "[greywall:dns-relay] Failed to set read deadline: %v\n", err) + Logf("DNS relay: failed to set read deadline: %v", err) return } resp := make([]byte, maxDNSPacketSize) n, err := upstreamConn.Read(resp) if err != nil { - if d.debug { - fmt.Fprintf(os.Stderr, "[greywall:dns-relay] Upstream response error: %v\n", err) - } + Logf("DNS relay: upstream response error from %s: %v", d.targetAddr, err) return } @@ -176,11 +168,9 @@ func (d *DNSRelay) handleQuery(query []byte, clientAddr *net.UDPAddr) { case <-d.done: return default: - fmt.Fprintf(os.Stderr, "[greywall:dns-relay] Failed to send response to %s: %v\n", clientAddr, err) + Logf("DNS relay: failed to send response to %s: %v", clientAddr, err) } } - if d.debug { - fmt.Fprintf(os.Stderr, "[greywall:dns-relay] Response to %s (%d bytes)\n", clientAddr, n) - } + Logf("DNS relay: response to %s (%d bytes)", clientAddr, n) } diff --git a/internal/daemon/server.go b/internal/daemon/server.go index a7269ec..cef5257 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -295,19 +295,27 @@ func (s *Server) handleCreateSession(req Request) Response { return Response{OK: false, Error: fmt.Sprintf("failed to start tunnel: %v", err)} } - // Step 2: Create DNS relay if dns_addr is provided. - var dr *DNSRelay - if req.DNSAddr != "" { - var err error - dr, err = NewDNSRelay(dnsRelayIP+":"+dnsRelayPort, req.DNSAddr, s.debug) - if err != nil { - _ = tm.Stop() // best-effort cleanup - return Response{OK: false, Error: fmt.Sprintf("failed to create DNS relay: %v", err)} + // Step 2: Create DNS relay. pf rules always redirect DNS (UDP:53) from + // the sandbox group to the relay address, so we must always start the + // relay when a proxy session is active. If no explicit DNS address was + // provided, default to the proxy's DNS resolver. + dnsTarget := req.DNSAddr + if dnsTarget == "" { + dnsTarget = defaultDNSTarget + Logf("No dns_addr provided, defaulting DNS relay upstream to %s", dnsTarget) + } + dr, err := NewDNSRelay(dnsRelayIP+":"+dnsRelayPort, dnsTarget, s.debug) + if err != nil { + if stopErr := tm.Stop(); stopErr != nil { + Logf("Warning: failed to stop tunnel during cleanup: %v", stopErr) } - if err := dr.Start(); err != nil { - _ = tm.Stop() // best-effort cleanup - return Response{OK: false, Error: fmt.Sprintf("failed to start DNS relay: %v", err)} + return Response{OK: false, Error: fmt.Sprintf("failed to create DNS relay: %v", err)} + } + if err := dr.Start(); err != nil { + if stopErr := tm.Stop(); stopErr != nil { + Logf("Warning: failed to stop tunnel during cleanup: %v", stopErr) } + return Response{OK: false, Error: fmt.Sprintf("failed to start DNS relay: %v", err)} } // Step 3: Resolve the sandbox group GID. pfctl in the LaunchDaemon @@ -318,9 +326,7 @@ func (s *Server) handleCreateSession(req Request) Response { grp, err := user.LookupGroup(SandboxGroupName) if err != nil { _ = tm.Stop() - if dr != nil { - dr.Stop() - } + dr.Stop() return Response{OK: false, Error: fmt.Sprintf("failed to resolve group %s: %v", SandboxGroupName, err)} } sandboxGID = grp.Gid @@ -328,9 +334,7 @@ func (s *Server) handleCreateSession(req Request) Response { } Logf("Loading pf rules for group %s (GID %s)", SandboxGroupName, sandboxGID) if err := tm.LoadPFRules(sandboxGID); err != nil { - if dr != nil { - dr.Stop() - } + dr.Stop() _ = tm.Stop() // best-effort cleanup return Response{OK: false, Error: fmt.Sprintf("failed to load pf rules: %v", err)} } @@ -338,9 +342,7 @@ func (s *Server) handleCreateSession(req Request) Response { // Step 4: Generate session ID and store. sessionID, err := generateSessionID() if err != nil { - if dr != nil { - dr.Stop() - } + dr.Stop() _ = tm.UnloadPFRules() // best-effort cleanup _ = tm.Stop() // best-effort cleanup return Response{OK: false, Error: fmt.Sprintf("failed to generate session ID: %v", err)} @@ -349,7 +351,7 @@ func (s *Server) handleCreateSession(req Request) Response { session := &Session{ ID: sessionID, ProxyURL: req.ProxyURL, - DNSAddr: req.DNSAddr, + DNSAddr: dnsTarget, CreatedAt: time.Now(), } s.sessions[sessionID] = session diff --git a/internal/daemon/tun.go b/internal/daemon/tun.go index d26cb8c..bfc4257 100644 --- a/internal/daemon/tun.go +++ b/internal/daemon/tun.go @@ -15,10 +15,11 @@ import ( ) const ( - tunIP = "198.18.0.1" - dnsRelayIP = "127.0.0.2" - dnsRelayPort = "15353" // high port to avoid conflicts with system DNS (mDNSResponder, Docker/Lima) - pfAnchorName = "co.greyhaven.greywall" + tunIP = "198.18.0.1" + dnsRelayIP = "127.0.0.2" + dnsRelayPort = "15353" // high port; pf rdr rewrites port 53 → this port + defaultDNSTarget = "127.0.0.1:42053" // proxy's DNS resolver (UDP), used when dnsAddr is not configured + pfAnchorName = "co.greyhaven.greywall" // tun2socksStopGracePeriod is the time to wait for tun2socks to exit // after SIGTERM before sending SIGKILL. @@ -158,19 +159,15 @@ func (t *TunManager) LoadPFRules(sandboxGroup string) error { return fmt.Errorf("failed to ensure pf anchor: %w", err) } - // Build the anchor rules. pf requires strict ordering: - // translation (rdr) before filtering (pass). - // Note: macOS pf does not support "group" in rdr rules, so DNS - // redirection uses a two-step approach: - // 1. rdr on lo0 — redirects DNS arriving on loopback to our relay - // 2. pass out route-to lo0 — sends sandbox group's DNS to loopback - // 3. pass out route-to utun — sends sandbox group's TCP through tunnel + // Build pf anchor rules for the sandbox group: + // 1. Route all non-loopback TCP through the utun → tun2socks → SOCKS proxy. + // Loopback (127.0.0.0/8) is excluded so that ALL_PROXY=socks5h:// + // connections to the local proxy don't get double-proxied. + // 2. (DNS is handled via ALL_PROXY=socks5h:// env var, not via pf, + // because macOS getaddrinfo uses mDNSResponder via Mach IPC and + // blocking those services doesn't cause a UDP DNS fallback.) rules := fmt.Sprintf( - "rdr on lo0 proto udp from any to any port 53 -> %s port %s\n"+ - "pass out on !lo0 route-to (lo0 127.0.0.1) proto udp from any to any port 53 group %s\n"+ - "pass out route-to (%s %s) proto tcp from any to any group %s\n", - dnsRelayIP, dnsRelayPort, - sandboxGroup, + "pass out route-to (%s %s) proto tcp from any to !127.0.0.0/8 group %s\n", t.tunDevice, tunIP, sandboxGroup, ) diff --git a/internal/sandbox/macos.go b/internal/sandbox/macos.go index 9593374..b5d006e 100644 --- a/internal/sandbox/macos.go +++ b/internal/sandbox/macos.go @@ -451,7 +451,13 @@ func GenerateSandboxProfile(params MacOSSandboxParams) string { (global-name "com.apple.system.logger") (global-name "com.apple.system.notification_center") (global-name "com.apple.trustd.agent") - (global-name "com.apple.system.opendirectoryd.libinfo") +`) + // macOS DNS resolution goes through mDNSResponder via Mach IPC — blocking + // opendirectoryd.libinfo or configd does NOT cause a fallback to direct UDP + // DNS. getaddrinfo() simply fails with EAI_NONAME. So we must allow these + // services in all modes. In daemon mode, DNS for proxy-aware apps (curl, git) + // is handled via ALL_PROXY=socks5h:// env var instead. + profile.WriteString(` (global-name "com.apple.system.opendirectoryd.libinfo") (global-name "com.apple.system.opendirectoryd.membership") (global-name "com.apple.bsd.dirhelper") (global-name "com.apple.securityd.xpc") @@ -733,19 +739,27 @@ func WrapCommandMacOS(cfg *config.Config, command string, exposedPorts []int, da if daemonMode { // In daemon mode: run as the real user but with EGID=_greywall via sudo. - // pf routes all traffic from group _greywall through utun → tun2socks → proxy. + // pf routes all TCP from group _greywall through utun → tun2socks → proxy. // Using -u # preserves the user's identity (home dir, SSH keys, etc.) // while -g _greywall sets the effective GID for pf matching. // - // Do NOT inject HTTP_PROXY/HTTPS_PROXY env vars in daemon mode: tun2socks - // provides transparent proxying at the IP level, so apps don't need proxy - // env vars. Setting them to socks5h:// breaks apps (like Bun/Node.js) that - // read HTTP_PROXY but don't support SOCKS5 protocol. + // DNS on macOS goes through mDNSResponder (Mach IPC), which runs outside + // the _greywall group, so pf can't intercept DNS. Instead, we set + // ALL_PROXY=socks5h:// so proxy-aware apps (curl, git, etc.) resolve DNS + // through the SOCKS5 proxy. The "h" suffix means "resolve hostname at proxy". + // + // We only set ALL_PROXY (not HTTP_PROXY/HTTPS_PROXY) because apps like + // Bun/Node.js read HTTP_PROXY but don't support SOCKS5 protocol. // // sudo resets the environment, so we use `env` after sudo to re-inject // terminal vars (TERM, COLORTERM, etc.) needed for TUI apps. uid := fmt.Sprintf("#%d", os.Getuid()) sandboxEnvs := GenerateProxyEnvVars("") + // Convert socks5:// → socks5h:// for hostname resolution through proxy. + socks5hURL := strings.Replace(cfg.Network.ProxyURL, "socks5://", "socks5h://", 1) + if socks5hURL != "" { + sandboxEnvs = append(sandboxEnvs, "ALL_PROXY="+socks5hURL, "all_proxy="+socks5hURL) + } termEnvs := getTerminalEnvVars() parts = append(parts, "sudo", "-u", uid, "-g", daemonSession.SandboxGroup, "env") parts = append(parts, sandboxEnvs...)