fix: use socks5h:// for macOS daemon DNS resolution through proxy

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
This commit is contained in:
2026-03-02 12:04:36 -06:00
parent 796c22f736
commit 20ee23c1c3
4 changed files with 68 additions and 65 deletions

View File

@@ -5,7 +5,6 @@ package daemon
import ( import (
"fmt" "fmt"
"net" "net"
"os"
"sync" "sync"
"time" "time"
) )
@@ -77,9 +76,7 @@ func (d *DNSRelay) ListenAddr() string {
// listening socket and spawns a goroutine per query to forward it to the // listening socket and spawns a goroutine per query to forward it to the
// upstream DNS server and relay the response back. // upstream DNS server and relay the response back.
func (d *DNSRelay) Start() error { func (d *DNSRelay) Start() error {
if d.debug { Logf("DNS relay listening on %s, forwarding to %s", d.listenAddr, d.targetAddr)
fmt.Fprintf(os.Stderr, "[greywall:dns-relay] Listening on %s, forwarding to %s\n", d.listenAddr, d.targetAddr)
}
d.wg.Add(1) d.wg.Add(1)
go d.readLoop() go d.readLoop()
@@ -94,9 +91,7 @@ func (d *DNSRelay) Stop() {
_ = d.udpConn.Close() _ = d.udpConn.Close()
d.wg.Wait() d.wg.Wait()
if d.debug { Logf("DNS relay stopped")
fmt.Fprintf(os.Stderr, "[greywall:dns-relay] Stopped\n")
}
} }
// readLoop is the main loop that reads incoming DNS queries from the listening socket. // 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. // Shutting down, expected error from closed socket.
return return
default: default:
fmt.Fprintf(os.Stderr, "[greywall:dns-relay] Read error: %v\n", err) Logf("DNS relay: read error: %v", err)
continue continue
} }
} }
@@ -136,36 +131,33 @@ func (d *DNSRelay) readLoop() {
func (d *DNSRelay) handleQuery(query []byte, clientAddr *net.UDPAddr) { func (d *DNSRelay) handleQuery(query []byte, clientAddr *net.UDPAddr) {
defer d.wg.Done() defer d.wg.Done()
if d.debug { Logf("DNS relay: query from %s (%d bytes)", clientAddr, len(query))
fmt.Fprintf(os.Stderr, "[greywall:dns-relay] Query from %s (%d bytes)\n", clientAddr, len(query))
}
// Create a dedicated UDP connection to the upstream DNS server. // 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 { 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 return
} }
defer upstreamConn.Close() //nolint:errcheck // best-effort cleanup of per-query UDP connection defer upstreamConn.Close() //nolint:errcheck // best-effort cleanup of per-query UDP connection
// Send the query to the upstream server. // Send the query to the upstream server.
if _, err := upstreamConn.Write(query); err != nil { 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 return
} }
// Wait for the response with a timeout. // Wait for the response with a timeout.
if err := upstreamConn.SetReadDeadline(time.Now().Add(upstreamTimeout)); err != nil { 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 return
} }
resp := make([]byte, maxDNSPacketSize) resp := make([]byte, maxDNSPacketSize)
n, err := upstreamConn.Read(resp) n, err := upstreamConn.Read(resp)
if err != nil { if err != nil {
if d.debug { Logf("DNS relay: upstream response error from %s: %v", d.targetAddr, err)
fmt.Fprintf(os.Stderr, "[greywall:dns-relay] Upstream response error: %v\n", err)
}
return return
} }
@@ -176,11 +168,9 @@ func (d *DNSRelay) handleQuery(query []byte, clientAddr *net.UDPAddr) {
case <-d.done: case <-d.done:
return return
default: 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 { Logf("DNS relay: response to %s (%d bytes)", clientAddr, n)
fmt.Fprintf(os.Stderr, "[greywall:dns-relay] Response to %s (%d bytes)\n", clientAddr, n)
}
} }

View File

@@ -295,19 +295,27 @@ func (s *Server) handleCreateSession(req Request) Response {
return Response{OK: false, Error: fmt.Sprintf("failed to start tunnel: %v", err)} return Response{OK: false, Error: fmt.Sprintf("failed to start tunnel: %v", err)}
} }
// Step 2: Create DNS relay if dns_addr is provided. // Step 2: Create DNS relay. pf rules always redirect DNS (UDP:53) from
var dr *DNSRelay // the sandbox group to the relay address, so we must always start the
if req.DNSAddr != "" { // relay when a proxy session is active. If no explicit DNS address was
var err error // provided, default to the proxy's DNS resolver.
dr, err = NewDNSRelay(dnsRelayIP+":"+dnsRelayPort, req.DNSAddr, s.debug) dnsTarget := req.DNSAddr
if err != nil { if dnsTarget == "" {
_ = tm.Stop() // best-effort cleanup dnsTarget = defaultDNSTarget
return Response{OK: false, Error: fmt.Sprintf("failed to create DNS relay: %v", err)} 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 { return Response{OK: false, Error: fmt.Sprintf("failed to create DNS relay: %v", err)}
_ = tm.Stop() // best-effort cleanup }
return Response{OK: false, Error: fmt.Sprintf("failed to start 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 // 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) grp, err := user.LookupGroup(SandboxGroupName)
if err != nil { if err != nil {
_ = tm.Stop() _ = tm.Stop()
if dr != nil { dr.Stop()
dr.Stop()
}
return Response{OK: false, Error: fmt.Sprintf("failed to resolve group %s: %v", SandboxGroupName, err)} return Response{OK: false, Error: fmt.Sprintf("failed to resolve group %s: %v", SandboxGroupName, err)}
} }
sandboxGID = grp.Gid 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) Logf("Loading pf rules for group %s (GID %s)", SandboxGroupName, sandboxGID)
if err := tm.LoadPFRules(sandboxGID); err != nil { if err := tm.LoadPFRules(sandboxGID); err != nil {
if dr != nil { dr.Stop()
dr.Stop()
}
_ = tm.Stop() // best-effort cleanup _ = tm.Stop() // best-effort cleanup
return Response{OK: false, Error: fmt.Sprintf("failed to load pf rules: %v", err)} 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. // Step 4: Generate session ID and store.
sessionID, err := generateSessionID() sessionID, err := generateSessionID()
if err != nil { if err != nil {
if dr != nil { dr.Stop()
dr.Stop()
}
_ = tm.UnloadPFRules() // best-effort cleanup _ = tm.UnloadPFRules() // best-effort cleanup
_ = tm.Stop() // best-effort cleanup _ = tm.Stop() // best-effort cleanup
return Response{OK: false, Error: fmt.Sprintf("failed to generate session ID: %v", err)} 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{ session := &Session{
ID: sessionID, ID: sessionID,
ProxyURL: req.ProxyURL, ProxyURL: req.ProxyURL,
DNSAddr: req.DNSAddr, DNSAddr: dnsTarget,
CreatedAt: time.Now(), CreatedAt: time.Now(),
} }
s.sessions[sessionID] = session s.sessions[sessionID] = session

View File

@@ -15,10 +15,11 @@ import (
) )
const ( const (
tunIP = "198.18.0.1" tunIP = "198.18.0.1"
dnsRelayIP = "127.0.0.2" dnsRelayIP = "127.0.0.2"
dnsRelayPort = "15353" // high port to avoid conflicts with system DNS (mDNSResponder, Docker/Lima) dnsRelayPort = "15353" // high port; pf rdr rewrites port 53 → this port
pfAnchorName = "co.greyhaven.greywall" 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 // tun2socksStopGracePeriod is the time to wait for tun2socks to exit
// after SIGTERM before sending SIGKILL. // 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) return fmt.Errorf("failed to ensure pf anchor: %w", err)
} }
// Build the anchor rules. pf requires strict ordering: // Build pf anchor rules for the sandbox group:
// translation (rdr) before filtering (pass). // 1. Route all non-loopback TCP through the utun → tun2socks → SOCKS proxy.
// Note: macOS pf does not support "group" in rdr rules, so DNS // Loopback (127.0.0.0/8) is excluded so that ALL_PROXY=socks5h://
// redirection uses a two-step approach: // connections to the local proxy don't get double-proxied.
// 1. rdr on lo0 — redirects DNS arriving on loopback to our relay // 2. (DNS is handled via ALL_PROXY=socks5h:// env var, not via pf,
// 2. pass out route-to lo0 — sends sandbox group's DNS to loopback // because macOS getaddrinfo uses mDNSResponder via Mach IPC and
// 3. pass out route-to utun — sends sandbox group's TCP through tunnel // blocking those services doesn't cause a UDP DNS fallback.)
rules := fmt.Sprintf( rules := fmt.Sprintf(
"rdr on lo0 proto udp from any to any port 53 -> %s port %s\n"+ "pass out route-to (%s %s) proto tcp from any to !127.0.0.0/8 group %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,
t.tunDevice, tunIP, sandboxGroup, t.tunDevice, tunIP, sandboxGroup,
) )

View File

@@ -451,7 +451,13 @@ func GenerateSandboxProfile(params MacOSSandboxParams) string {
(global-name "com.apple.system.logger") (global-name "com.apple.system.logger")
(global-name "com.apple.system.notification_center") (global-name "com.apple.system.notification_center")
(global-name "com.apple.trustd.agent") (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.system.opendirectoryd.membership")
(global-name "com.apple.bsd.dirhelper") (global-name "com.apple.bsd.dirhelper")
(global-name "com.apple.securityd.xpc") (global-name "com.apple.securityd.xpc")
@@ -733,19 +739,27 @@ func WrapCommandMacOS(cfg *config.Config, command string, exposedPorts []int, da
if daemonMode { if daemonMode {
// In daemon mode: run as the real user but with EGID=_greywall via sudo. // 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 #<uid> preserves the user's identity (home dir, SSH keys, etc.) // Using -u #<uid> preserves the user's identity (home dir, SSH keys, etc.)
// while -g _greywall sets the effective GID for pf matching. // while -g _greywall sets the effective GID for pf matching.
// //
// Do NOT inject HTTP_PROXY/HTTPS_PROXY env vars in daemon mode: tun2socks // DNS on macOS goes through mDNSResponder (Mach IPC), which runs outside
// provides transparent proxying at the IP level, so apps don't need proxy // the _greywall group, so pf can't intercept DNS. Instead, we set
// env vars. Setting them to socks5h:// breaks apps (like Bun/Node.js) that // ALL_PROXY=socks5h:// so proxy-aware apps (curl, git, etc.) resolve DNS
// read HTTP_PROXY but don't support SOCKS5 protocol. // 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 // sudo resets the environment, so we use `env` after sudo to re-inject
// terminal vars (TERM, COLORTERM, etc.) needed for TUI apps. // terminal vars (TERM, COLORTERM, etc.) needed for TUI apps.
uid := fmt.Sprintf("#%d", os.Getuid()) uid := fmt.Sprintf("#%d", os.Getuid())
sandboxEnvs := GenerateProxyEnvVars("") 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() termEnvs := getTerminalEnvVars()
parts = append(parts, "sudo", "-u", uid, "-g", daemonSession.SandboxGroup, "env") parts = append(parts, "sudo", "-u", uid, "-g", daemonSession.SandboxGroup, "env")
parts = append(parts, sandboxEnvs...) parts = append(parts, sandboxEnvs...)