feat: switch macOS daemon from user-based to group-based pf routing

Sandboxed commands previously ran as `sudo -u _greywall`, breaking user
identity (home dir, SSH keys, git config). Now uses `sudo -u #<uid> -g
_greywall` so the process keeps the real user's identity while pf
matches
on EGID for traffic routing.

Key changes:
- pf rules use `group <GID>` instead of `user _greywall`
- GID resolved dynamically at daemon startup (not hardcoded, since macOS
  system groups like com.apple.access_ssh may claim preferred IDs)
- Sudoers rule installed at /etc/sudoers.d/greywall (validated with
visudo)
- Invoking user added to _greywall group via dscl (not dseditgroup,
which
  clobbers group attributes)
- tun2socks device discovery scans both stdout and stderr (fixes 10s
  timeout caused by STACK message going to stdout)
- Always-on daemon logging for session create/destroy events
This commit is contained in:
2026-02-25 19:20:01 -06:00
parent 4ea4592d75
commit cfe29d2c0b
15 changed files with 3866 additions and 18 deletions

View File

@@ -45,6 +45,8 @@ type MacOSSandboxParams struct {
AllowPty bool
AllowGitConfig bool
Shell string
DaemonMode bool // When true, pf handles network routing; Seatbelt allows network-outbound
DaemonSocketPath string // Daemon socket to deny access to from sandboxed process
}
// GlobToRegex converts a glob pattern to a regex for macOS sandbox profiles.
@@ -422,8 +424,8 @@ func GenerateSandboxProfile(params MacOSSandboxParams) string {
// Header
profile.WriteString("(version 1)\n")
profile.WriteString(fmt.Sprintf("(deny default (with message %q))\n\n", logTag))
profile.WriteString(fmt.Sprintf("; LogTag: %s\n\n", logTag))
fmt.Fprintf(&profile, "(deny default (with message %q))\n\n", logTag)
fmt.Fprintf(&profile, "; LogTag: %s\n\n", logTag)
// Essential permissions - based on Chrome sandbox policy
profile.WriteString(`; Essential permissions - based on Chrome sandbox policy
@@ -566,9 +568,27 @@ func GenerateSandboxProfile(params MacOSSandboxParams) string {
// Network rules
profile.WriteString("; Network\n")
if !params.NeedsNetworkRestriction {
switch {
case params.DaemonMode:
// In daemon mode, pf handles network routing: all traffic from the
// _greywall user is routed through utun → tun2socks → proxy.
// Seatbelt must allow network-outbound so packets reach pf.
// The proxy allowlist is enforced by the external SOCKS5 proxy.
profile.WriteString("(allow network-outbound)\n")
// Allow local binding for servers if configured.
if params.AllowLocalBinding {
profile.WriteString(`(allow network-bind (local ip "localhost:*"))
(allow network-inbound (local ip "localhost:*"))
`)
}
// Explicitly deny access to the daemon socket to prevent the
// sandboxed process from manipulating daemon sessions.
if params.DaemonSocketPath != "" {
fmt.Fprintf(&profile, "(deny network-outbound (remote unix-socket (path-literal %s)))\n", escapePath(params.DaemonSocketPath))
}
case !params.NeedsNetworkRestriction:
profile.WriteString("(allow network*)\n")
} else {
default:
if params.AllowLocalBinding {
// Allow binding and inbound connections on localhost (for servers)
profile.WriteString(`(allow network-bind (local ip "localhost:*"))
@@ -586,14 +606,13 @@ func GenerateSandboxProfile(params MacOSSandboxParams) string {
} else if len(params.AllowUnixSockets) > 0 {
for _, socketPath := range params.AllowUnixSockets {
normalized := NormalizePath(socketPath)
profile.WriteString(fmt.Sprintf("(allow network* (subpath %s))\n", escapePath(normalized)))
fmt.Fprintf(&profile, "(allow network* (subpath %s))\n", escapePath(normalized))
}
}
// Allow outbound to the external proxy host:port
if params.ProxyHost != "" && params.ProxyPort != "" {
profile.WriteString(fmt.Sprintf(`(allow network-outbound (remote ip "%s:%s"))
`, params.ProxyHost, params.ProxyPort))
fmt.Fprintf(&profile, "(allow network-outbound (remote ip \"%s:%s\"))\n", params.ProxyHost, params.ProxyPort)
}
}
profile.WriteString("\n")
@@ -631,7 +650,9 @@ func GenerateSandboxProfile(params MacOSSandboxParams) string {
}
// WrapCommandMacOS wraps a command with macOS sandbox restrictions.
func WrapCommandMacOS(cfg *config.Config, command string, exposedPorts []int, debug bool) (string, error) {
// When daemonSession is non-nil, the command runs as the _greywall user
// with network-outbound allowed (pf routes traffic through utun → proxy).
func WrapCommandMacOS(cfg *config.Config, command string, exposedPorts []int, daemonSession *DaemonSession, debug bool) (string, error) {
cwd, _ := os.Getwd()
// Build allow paths: default + configured
@@ -657,9 +678,13 @@ func WrapCommandMacOS(cfg *config.Config, command string, exposedPorts []int, de
}
}
// Determine if we're using daemon-mode (transparent proxying via pf + utun)
daemonMode := daemonSession != nil
// Restrict network unless proxy is configured to an external host
// If no proxy: block all outbound. If proxy: allow outbound only to proxy.
needsNetworkRestriction := true
// In daemon mode, network restriction is handled by pf, not Seatbelt.
needsNetworkRestriction := !daemonMode
params := MacOSSandboxParams{
Command: command,
@@ -679,6 +704,8 @@ func WrapCommandMacOS(cfg *config.Config, command string, exposedPorts []int, de
WriteDenyPaths: cfg.Filesystem.DenyWrite,
AllowPty: cfg.AllowPty,
AllowGitConfig: cfg.Filesystem.AllowGitConfig,
DaemonMode: daemonMode,
DaemonSocketPath: "/var/run/greywall.sock",
}
if debug && len(exposedPorts) > 0 {
@@ -687,6 +714,10 @@ func WrapCommandMacOS(cfg *config.Config, command string, exposedPorts []int, de
if debug && allowLocalBinding && !allowLocalOutbound {
fmt.Fprintf(os.Stderr, "[greywall:macos] Blocking localhost outbound (AllowLocalOutbound=false)\n")
}
if debug && daemonMode {
fmt.Fprintf(os.Stderr, "[greywall:macos] Daemon mode: transparent proxying via pf + utun (group=%s, device=%s)\n",
daemonSession.SandboxGroup, daemonSession.TunDevice)
}
profile := GenerateSandboxProfile(params)
@@ -700,14 +731,23 @@ func WrapCommandMacOS(cfg *config.Config, command string, exposedPorts []int, de
return "", fmt.Errorf("shell %q not found: %w", shell, err)
}
proxyEnvs := GenerateProxyEnvVars(cfg.Network.ProxyURL)
// Build the command
// env VAR1=val1 VAR2=val2 sandbox-exec -p 'profile' shell -c 'command'
var parts []string
parts = append(parts, "env")
parts = append(parts, proxyEnvs...)
parts = append(parts, "sandbox-exec", "-p", profile, shellPath, "-c", command)
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.
// Using -u #<uid> preserves the user's identity (home dir, SSH keys, etc.)
// while -g _greywall sets the effective GID for pf matching.
uid := fmt.Sprintf("#%d", os.Getuid())
parts = append(parts, "sudo", "-u", uid, "-g", daemonSession.SandboxGroup,
"sandbox-exec", "-p", profile, shellPath, "-c", command)
} else {
// Non-daemon mode: use proxy env vars for best-effort proxying.
proxyEnvs := GenerateProxyEnvVars(cfg.Network.ProxyURL)
parts = append(parts, "env")
parts = append(parts, proxyEnvs...)
parts = append(parts, "sandbox-exec", "-p", profile, shellPath, "-c", command)
}
return ShellQuote(parts), nil
}