6 Commits

Author SHA1 Message Date
473f1620d5 feat: adopt kardianos/service for daemon lifecycle management
Replace manual signal handling in runDaemon() with kardianos/service
for cross-platform service lifecycle (Start/Stop/Run). Add daemon
start/stop/restart subcommands using service.Control(), and improve
status detection with s.Status() plus socket-check fallback.

Custom macOS install logic (dscl, sudoers, pf, plist generation)
is unchanged — only the runtime lifecycle is delegated to the library.
2026-03-04 14:48:01 -06:00
58626c64e5 feat: add --http-proxy flag for configurable HTTP CONNECT proxy
Add network.httpProxyUrl config field and --http-proxy CLI flag
(default: http://localhost:42051) for apps that only understand
HTTP proxies (opencode, Node.js tools, etc.).

macOS daemon mode now sets:
- ALL_PROXY=socks5h:// for SOCKS5-aware apps (curl, git)
- HTTP_PROXY/HTTPS_PROXY=http:// for HTTP-proxy-aware apps

Credentials from the SOCKS5 proxy URL are automatically injected
into the HTTP proxy URL when not explicitly configured.
2026-03-04 12:47:57 -06:00
f05b4a6b4c fix: include user/password in HTTP_PROXY URL for macOS daemon mode
The HTTP CONNECT proxy URL was missing credentials from the SOCKS5
proxy URL. Now extracts userinfo from the configured proxy URL so
apps authenticating via HTTP_PROXY get the same credentials.
2026-03-04 12:43:10 -06:00
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
796c22f736 fix: don't inject SOCKS5 proxy env vars in macOS daemon mode
In daemon mode, tun2socks provides transparent proxying at the IP level
via pf + utun, so apps don't need proxy env vars. Setting HTTP_PROXY and
HTTPS_PROXY to socks5h:// breaks apps like Bun/Node.js that read these
vars but don't support the SOCKS5 protocol (UnsupportedProxyProtocol).
2026-02-26 17:46:21 -06:00
12 changed files with 340 additions and 106 deletions

View File

@@ -4,12 +4,10 @@ import (
"bufio"
"fmt"
"os"
"os/signal"
"path/filepath"
"runtime"
"strings"
"syscall"
"github.com/kardianos/service"
"github.com/spf13/cobra"
"gitea.app.monadical.io/monadical/greywall/internal/daemon"
@@ -22,6 +20,9 @@ import (
// install - Install the LaunchDaemon (requires root)
// uninstall - Uninstall the LaunchDaemon (requires root)
// run - Run the daemon (called by LaunchDaemon plist)
// start - Start the daemon service
// stop - Stop the daemon service
// restart - Restart the daemon service
// status - Show daemon status
func newDaemonCmd() *cobra.Command {
cmd := &cobra.Command{
@@ -35,6 +36,9 @@ and pf rules that enable transparent proxy routing for sandboxed processes.
Commands:
sudo greywall daemon install Install and start the daemon
sudo greywall daemon uninstall Stop and remove the daemon
sudo greywall daemon start Start the daemon service
sudo greywall daemon stop Stop the daemon service
sudo greywall daemon restart Restart the daemon service
greywall daemon status Check daemon status
greywall daemon run Run the daemon (used by LaunchDaemon)`,
}
@@ -43,6 +47,9 @@ Commands:
newDaemonInstallCmd(),
newDaemonUninstallCmd(),
newDaemonRunCmd(),
newDaemonStartCmd(),
newDaemonStopCmd(),
newDaemonRestartCmd(),
newDaemonStatusCmd(),
)
@@ -151,6 +158,57 @@ func newDaemonRunCmd() *cobra.Command {
}
}
// newDaemonStartCmd creates the "daemon start" subcommand.
func newDaemonStartCmd() *cobra.Command {
return &cobra.Command{
Use: "start",
Short: "Start the daemon service",
Long: `Start the greywall daemon service. Requires root privileges.`,
RunE: func(cmd *cobra.Command, args []string) error {
return daemonControl("start")
},
}
}
// newDaemonStopCmd creates the "daemon stop" subcommand.
func newDaemonStopCmd() *cobra.Command {
return &cobra.Command{
Use: "stop",
Short: "Stop the daemon service",
Long: `Stop the greywall daemon service. Requires root privileges.`,
RunE: func(cmd *cobra.Command, args []string) error {
return daemonControl("stop")
},
}
}
// newDaemonRestartCmd creates the "daemon restart" subcommand.
func newDaemonRestartCmd() *cobra.Command {
return &cobra.Command{
Use: "restart",
Short: "Restart the daemon service",
Long: `Restart the greywall daemon service. Requires root privileges.`,
RunE: func(cmd *cobra.Command, args []string) error {
return daemonControl("restart")
},
}
}
// daemonControl sends a control action (start/stop/restart) to the daemon
// service via kardianos/service.
func daemonControl(action string) error {
p := daemon.NewProgram(daemon.DefaultSocketPath, daemon.DefaultTun2socksPath(), debug)
s, err := service.New(p, daemon.NewServiceConfig())
if err != nil {
return fmt.Errorf("failed to create service: %w", err)
}
if err := service.Control(s, action); err != nil {
return fmt.Errorf("failed to %s daemon: %w", action, err)
}
fmt.Printf("Daemon %sed successfully.\n", action)
return nil
}
// newDaemonStatusCmd creates the "daemon status" subcommand.
func newDaemonStatusCmd() *cobra.Command {
return &cobra.Command{
@@ -159,11 +217,16 @@ func newDaemonStatusCmd() *cobra.Command {
Long: `Check whether the greywall daemon is installed and running. Does not require root.`,
RunE: func(cmd *cobra.Command, args []string) error {
installed := daemon.IsInstalled()
running := daemon.IsRunning()
// Try kardianos/service status first for reliable state detection.
serviceState := daemonServiceState()
running := serviceState == "running"
fmt.Printf("Greywall daemon status:\n")
fmt.Printf(" Installed: %s\n", boolStatus(installed))
fmt.Printf(" Running: %s\n", boolStatus(running))
fmt.Printf(" Service: %s\n", serviceState)
fmt.Printf(" Plist: %s\n", daemon.LaunchDaemonPlistPath)
fmt.Printf(" Binary: %s\n", daemon.InstallBinaryPath)
fmt.Printf(" User: %s\n", daemon.SandboxUserName)
@@ -178,7 +241,7 @@ func newDaemonStatusCmd() *cobra.Command {
fmt.Println()
fmt.Println("The daemon is installed but not running.")
fmt.Printf("Check logs: cat /var/log/greywall.log\n")
fmt.Printf("Start it: sudo launchctl load %s\n", daemon.LaunchDaemonPlistPath)
fmt.Printf("Start it: sudo greywall daemon start\n")
}
return nil
@@ -186,38 +249,47 @@ func newDaemonStatusCmd() *cobra.Command {
}
}
// runDaemon is the main entry point for the daemon process. It starts the
// Unix socket server and blocks until a termination signal is received.
// CLI clients connect to the server to request sessions (which create
// utun tunnels, DNS relays, and pf rules on demand).
// runDaemon is the main entry point for the daemon process. It uses
// kardianos/service to manage the lifecycle, handling signals and
// calling Start/Stop on the program.
func runDaemon(cmd *cobra.Command, args []string) error {
tun2socksPath := filepath.Join(daemon.InstallLibDir, "tun2socks-darwin-"+runtime.GOARCH)
if _, err := os.Stat(tun2socksPath); err != nil {
return fmt.Errorf("tun2socks binary not found at %s (run 'sudo greywall daemon install' first)", tun2socksPath)
p := daemon.NewProgram(daemon.DefaultSocketPath, daemon.DefaultTun2socksPath(), debug)
s, err := service.New(p, daemon.NewServiceConfig())
if err != nil {
return fmt.Errorf("failed to create service: %w", err)
}
return s.Run()
}
// daemonServiceState returns the daemon's service state as a string.
// It tries kardianos/service status first, then falls back to socket check.
func daemonServiceState() string {
p := daemon.NewProgram(daemon.DefaultSocketPath, daemon.DefaultTun2socksPath(), debug)
s, err := service.New(p, daemon.NewServiceConfig())
if err != nil {
if daemon.IsRunning() {
return "running"
}
return "stopped"
}
daemon.Logf("Starting daemon (tun2socks=%s, socket=%s)", tun2socksPath, daemon.DefaultSocketPath)
srv := daemon.NewServer(daemon.DefaultSocketPath, tun2socksPath, debug)
if err := srv.Start(); err != nil {
return fmt.Errorf("failed to start daemon server: %w", err)
status, err := s.Status()
if err != nil {
// Fall back to socket check.
if daemon.IsRunning() {
return "running"
}
return "stopped"
}
daemon.Logf("Daemon started, listening on %s", daemon.DefaultSocketPath)
// Wait for termination signal.
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
sig := <-sigCh
daemon.Logf("Received signal %s, shutting down", sig)
if err := srv.Stop(); err != nil {
daemon.Logf("Shutdown error: %v", err)
return err
switch status {
case service.StatusRunning:
return "running"
case service.StatusStopped:
return "stopped"
default:
return "unknown"
}
daemon.Logf("Daemon stopped")
return nil
}
// boolStatus returns a human-readable string for a boolean status value.

View File

@@ -31,6 +31,7 @@ var (
monitor bool
settingsPath string
proxyURL string
httpProxyURL string
dnsAddr string
cmdString string
exposePorts []string
@@ -99,6 +100,7 @@ Configuration file format:
rootCmd.Flags().BoolVarP(&monitor, "monitor", "m", false, "Monitor and log sandbox violations")
rootCmd.Flags().StringVarP(&settingsPath, "settings", "s", "", "Path to settings file (default: OS config directory)")
rootCmd.Flags().StringVar(&proxyURL, "proxy", "", "External SOCKS5 proxy URL (default: socks5://localhost:42052)")
rootCmd.Flags().StringVar(&httpProxyURL, "http-proxy", "", "HTTP CONNECT proxy URL (default: http://localhost:42051)")
rootCmd.Flags().StringVar(&dnsAddr, "dns", "", "DNS server address on host (default: localhost:42053)")
rootCmd.Flags().StringVarP(&cmdString, "c", "c", "", "Run command string directly (like sh -c)")
rootCmd.Flags().StringArrayVarP(&exposePorts, "port", "p", nil, "Expose port for inbound connections (can be used multiple times)")
@@ -228,6 +230,9 @@ func runCommand(cmd *cobra.Command, args []string) error {
if proxyURL != "" {
cfg.Network.ProxyURL = proxyURL
}
if httpProxyURL != "" {
cfg.Network.HTTPProxyURL = httpProxyURL
}
if dnsAddr != "" {
cfg.Network.DnsAddr = dnsAddr
}
@@ -240,6 +245,12 @@ func runCommand(cmd *cobra.Command, args []string) error {
fmt.Fprintf(os.Stderr, "[greywall] Defaulting proxy to socks5://localhost:42052\n")
}
}
if cfg.Network.HTTPProxyURL == "" {
cfg.Network.HTTPProxyURL = "http://localhost:42051"
if debug {
fmt.Fprintf(os.Stderr, "[greywall] Defaulting HTTP proxy to http://localhost:42051\n")
}
}
if cfg.Network.DnsAddr == "" {
cfg.Network.DnsAddr = "localhost:42053"
if debug {

1
go.mod
View File

@@ -4,6 +4,7 @@ go 1.25
require (
github.com/bmatcuk/doublestar/v4 v4.9.1
github.com/kardianos/service v1.2.4
github.com/spf13/cobra v1.8.1
github.com/tidwall/jsonc v0.3.2
golang.org/x/sys v0.39.0

2
go.sum
View File

@@ -3,6 +3,8 @@ github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTS
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kardianos/service v1.2.4 h1:XNlGtZOYNx2u91urOdg/Kfmc+gfmuIo1Dd3rEi2OgBk=
github.com/kardianos/service v1.2.4/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=

View File

@@ -26,8 +26,9 @@ type Config struct {
// NetworkConfig defines network restrictions.
type NetworkConfig struct {
ProxyURL string `json:"proxyUrl,omitempty"` // External SOCKS5 proxy (e.g. socks5://host:1080)
DnsAddr string `json:"dnsAddr,omitempty"` // DNS server address on host (e.g. localhost:3153)
ProxyURL string `json:"proxyUrl,omitempty"` // External SOCKS5 proxy (e.g. socks5://host:1080)
HTTPProxyURL string `json:"httpProxyUrl,omitempty"` // HTTP CONNECT proxy (e.g. http://host:42051)
DnsAddr string `json:"dnsAddr,omitempty"` // DNS server address on host (e.g. localhost:3153)
AllowUnixSockets []string `json:"allowUnixSockets,omitempty"`
AllowAllUnixSockets bool `json:"allowAllUnixSockets,omitempty"`
AllowLocalBinding bool `json:"allowLocalBinding,omitempty"`
@@ -203,6 +204,11 @@ func (c *Config) Validate() error {
return fmt.Errorf("invalid network.proxyUrl %q: %w", c.Network.ProxyURL, err)
}
}
if c.Network.HTTPProxyURL != "" {
if err := validateHTTPProxyURL(c.Network.HTTPProxyURL); err != nil {
return fmt.Errorf("invalid network.httpProxyUrl %q: %w", c.Network.HTTPProxyURL, err)
}
}
if c.Network.DnsAddr != "" {
if err := validateHostPort(c.Network.DnsAddr); err != nil {
return fmt.Errorf("invalid network.dnsAddr %q: %w", c.Network.DnsAddr, err)
@@ -273,6 +279,24 @@ func validateProxyURL(proxyURL string) error {
return nil
}
// validateHTTPProxyURL validates an HTTP CONNECT proxy URL.
func validateHTTPProxyURL(proxyURL string) error {
u, err := url.Parse(proxyURL)
if err != nil {
return fmt.Errorf("invalid URL: %w", err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return errors.New("HTTP proxy URL must use http:// or https:// scheme")
}
if u.Hostname() == "" {
return errors.New("HTTP proxy URL must include a hostname")
}
if u.Port() == "" {
return errors.New("HTTP proxy URL must include a port")
}
return nil
}
// validateHostPort validates a host:port address.
func validateHostPort(addr string) error {
// Must contain a colon separating host and port
@@ -407,9 +431,10 @@ func Merge(base, override *Config) *Config {
AllowPty: base.AllowPty || override.AllowPty,
Network: NetworkConfig{
// ProxyURL/DnsAddr: override wins if non-empty
ProxyURL: mergeString(base.Network.ProxyURL, override.Network.ProxyURL),
DnsAddr: mergeString(base.Network.DnsAddr, override.Network.DnsAddr),
// ProxyURL/HTTPProxyURL/DnsAddr: override wins if non-empty
ProxyURL: mergeString(base.Network.ProxyURL, override.Network.ProxyURL),
HTTPProxyURL: mergeString(base.Network.HTTPProxyURL, override.Network.HTTPProxyURL),
DnsAddr: mergeString(base.Network.DnsAddr, override.Network.DnsAddr),
// Append slices (base first, then override additions)
AllowUnixSockets: mergeStrings(base.Network.AllowUnixSockets, override.Network.AllowUnixSockets),

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

@@ -546,9 +546,3 @@ func logDebug(debug bool, format string, args ...interface{}) {
Logf(format, args...)
}
}
// Logf writes a timestamped message to stderr with the [greywall:daemon] prefix.
func Logf(format string, args ...interface{}) {
ts := time.Now().Format("2006-01-02 15:04:05")
fmt.Fprintf(os.Stderr, ts+" [greywall:daemon] "+format+"\n", args...)
}

13
internal/daemon/log.go Normal file
View File

@@ -0,0 +1,13 @@
package daemon
import (
"fmt"
"os"
"time"
)
// Logf writes a timestamped message to stderr with the [greywall:daemon] prefix.
func Logf(format string, args ...interface{}) {
ts := time.Now().Format("2006-01-02 15:04:05")
fmt.Fprintf(os.Stderr, ts+" [greywall:daemon] "+format+"\n", args...) //nolint:gosec // logging to stderr, not user-facing HTML
}

View File

@@ -0,0 +1,81 @@
package daemon
import (
"fmt"
"os"
"path/filepath"
"runtime"
"github.com/kardianos/service"
)
// program implements the kardianos/service.Interface for greywall daemon
// lifecycle management. It delegates actual work to the Server type.
type program struct {
server *Server
socketPath string
tun2socksPath string
debug bool
}
// NewProgram creates a new program instance for use with kardianos/service.
func NewProgram(socketPath, tun2socksPath string, debug bool) *program {
return &program{
socketPath: socketPath,
tun2socksPath: tun2socksPath,
debug: debug,
}
}
// Start is called by kardianos/service when the service starts. It verifies
// the tun2socks binary exists, creates and starts the Server. The accept loop
// already runs in a goroutine, so this returns immediately.
func (p *program) Start(_ service.Service) error {
if _, err := os.Stat(p.tun2socksPath); err != nil {
return fmt.Errorf("tun2socks binary not found at %s (run 'sudo greywall daemon install' first)", p.tun2socksPath)
}
Logf("Starting daemon (tun2socks=%s, socket=%s)", p.tun2socksPath, p.socketPath)
p.server = NewServer(p.socketPath, p.tun2socksPath, p.debug)
if err := p.server.Start(); err != nil {
return fmt.Errorf("failed to start daemon server: %w", err)
}
Logf("Daemon started, listening on %s", p.socketPath)
return nil
}
// Stop is called by kardianos/service when the service stops.
func (p *program) Stop(_ service.Service) error {
if p.server == nil {
return nil
}
Logf("Stopping daemon")
if err := p.server.Stop(); err != nil {
Logf("Shutdown error: %v", err)
return err
}
Logf("Daemon stopped")
return nil
}
// NewServiceConfig returns a kardianos/service config matching the existing
// LaunchDaemon setup. The Name matches LaunchDaemonLabel so service.Control()
// can find and manage the already-installed service.
func NewServiceConfig() *service.Config {
return &service.Config{
Name: LaunchDaemonLabel,
DisplayName: "Greywall Daemon",
Description: "Greywall transparent network sandboxing daemon",
Arguments: []string{"daemon", "run"},
}
}
// DefaultTun2socksPath returns the expected tun2socks binary path based on
// the install directory and current architecture.
func DefaultTun2socksPath() string {
return filepath.Join(InstallLibDir, "tun2socks-darwin-"+runtime.GOARCH)
}

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,17 +739,57 @@ 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.
//
// 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 and proxy vars.
// terminal vars (TERM, COLORTERM, etc.) needed for TUI apps.
uid := fmt.Sprintf("#%d", os.Getuid())
proxyEnvs := GenerateProxyEnvVars(cfg.Network.ProxyURL)
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 the configured HTTP CONNECT proxy
// for apps that only understand HTTP proxies (opencode, Node.js
// tools, etc.). The CONNECT proxy resolves DNS server-side.
httpProxyURL := cfg.Network.HTTPProxyURL
// Inject credentials from the SOCKS5 proxy URL into the HTTP proxy
// URL if the HTTP proxy URL doesn't already have credentials.
if httpProxyURL != "" {
if hu, err := url.Parse(httpProxyURL); err == nil && hu.User == nil {
if su, err := url.Parse(socks5hURL); err == nil && su.User != nil {
hu.User = su.User
httpProxyURL = hu.String()
}
}
}
sandboxEnvs = append(sandboxEnvs,
"ALL_PROXY="+socks5hURL, "all_proxy="+socks5hURL,
)
if httpProxyURL != "" {
sandboxEnvs = append(sandboxEnvs,
"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, proxyEnvs...)
parts = append(parts, sandboxEnvs...)
parts = append(parts, termEnvs...)
parts = append(parts, "sandbox-exec", "-p", profile, shellPath, "-c", command)
} else {