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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
// 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 {
|
||||
_ = tm.Stop() // best-effort cleanup
|
||||
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 create DNS relay: %v", err)}
|
||||
}
|
||||
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)}
|
||||
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()
|
||||
}
|
||||
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()
|
||||
}
|
||||
_ = 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()
|
||||
}
|
||||
_ = 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
|
||||
|
||||
@@ -17,7 +17,8 @@ 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)
|
||||
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
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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 #<uid> 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...)
|
||||
|
||||
Reference in New Issue
Block a user