2 Commits

Author SHA1 Message Date
0e3dc23639 fix: set HTTP_PROXY for macOS daemon mode alongside ALL_PROXY
ALL_PROXY=socks5h:// only works for SOCKS5-aware apps (curl, git).
Apps like opencode that only check HTTP_PROXY/HTTPS_PROXY were not
using the proxy at all, causing DNS resolution failures.

Now sets both:
- ALL_PROXY=socks5h://host:42052 (SOCKS5 with proxy-side DNS)
- HTTP_PROXY=http://host:42051 (HTTP CONNECT proxy)

The HTTP CONNECT proxy on port 42051 resolves DNS server-side,
so apps that don't speak SOCKS5 still get proper DNS resolution
through the proxy.
2026-03-04 12:40:27 -06:00
20ee23c1c3 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
2026-03-02 12:04:36 -06:00
4 changed files with 86 additions and 65 deletions

View File

@@ -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)
}

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)}
}
// 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

View File

@@ -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,
)

View File

@@ -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,45 @@ 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".
//
// Set ALL_PROXY and HTTP_PROXY/HTTPS_PROXY with socks5h:// so both
// SOCKS5-aware apps (curl, git) and HTTP-proxy-aware apps (opencode,
// Node.js tools) resolve DNS through the proxy. The "h" suffix means
// "resolve hostname at proxy side". Note: apps that read HTTP_PROXY
// but don't support SOCKS5 protocol (e.g., Bun) may fail to connect.
//
// 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 != "" {
// ALL_PROXY uses socks5h:// (DNS resolved at proxy side) for
// SOCKS5-aware apps (curl, git).
// HTTP_PROXY/HTTPS_PROXY use http:// pointing to the GreyHaven
// HTTP CONNECT proxy (port 42051) for apps that only understand
// HTTP proxies (opencode, Node.js tools, etc.). The CONNECT
// proxy resolves DNS server-side.
proxyHost := "localhost"
if u, err := url.Parse(socks5hURL); err == nil && u.Hostname() != "" {
proxyHost = u.Hostname()
}
httpProxyURL := "http://" + proxyHost + ":42051"
sandboxEnvs = append(sandboxEnvs,
"ALL_PROXY="+socks5hURL, "all_proxy="+socks5hURL,
"HTTP_PROXY="+httpProxyURL, "http_proxy="+httpProxyURL,
"HTTPS_PROXY="+httpProxyURL, "https_proxy="+httpProxyURL,
)
}
termEnvs := getTerminalEnvVars()
parts = append(parts, "sudo", "-u", uid, "-g", daemonSession.SandboxGroup, "env")
parts = append(parts, sandboxEnvs...)