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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user