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
|
||||
}
|
||||
|
||||
@@ -5,9 +5,20 @@ import (
|
||||
"os"
|
||||
|
||||
"gitea.app.monadical.io/monadical/greywall/internal/config"
|
||||
"gitea.app.monadical.io/monadical/greywall/internal/daemon"
|
||||
"gitea.app.monadical.io/monadical/greywall/internal/platform"
|
||||
)
|
||||
|
||||
// DaemonSession holds the state from an active daemon session on macOS.
|
||||
// When a daemon session is active, traffic is routed through pf + utun
|
||||
// instead of using env-var proxy settings.
|
||||
type DaemonSession struct {
|
||||
SessionID string
|
||||
TunDevice string
|
||||
SandboxUser string
|
||||
SandboxGroup string
|
||||
}
|
||||
|
||||
// Manager handles sandbox initialization and command wrapping.
|
||||
type Manager struct {
|
||||
config *config.Config
|
||||
@@ -22,6 +33,9 @@ type Manager struct {
|
||||
learning bool // learning mode: permissive sandbox with strace
|
||||
straceLogPath string // host-side temp file for strace output
|
||||
commandName string // name of the command being learned
|
||||
// macOS daemon session fields
|
||||
daemonClient *daemon.Client
|
||||
daemonSession *DaemonSession
|
||||
}
|
||||
|
||||
// NewManager creates a new sandbox manager.
|
||||
@@ -63,11 +77,36 @@ func (m *Manager) Initialize() error {
|
||||
return fmt.Errorf("sandbox is not supported on platform: %s", platform.Detect())
|
||||
}
|
||||
|
||||
// On macOS, the daemon is required for transparent proxying.
|
||||
// Without it, env-var proxying is unreliable (only works for tools that
|
||||
// honor HTTP_PROXY) and gives users a false sense of security.
|
||||
if platform.Detect() == platform.MacOS && m.config.Network.ProxyURL != "" {
|
||||
client := daemon.NewClient(daemon.DefaultSocketPath, m.debug)
|
||||
if !client.IsRunning() {
|
||||
return fmt.Errorf("greywall daemon is not running (required for macOS network sandboxing)\n\n" +
|
||||
" Install and start: sudo greywall daemon install\n" +
|
||||
" Check status: greywall daemon status")
|
||||
}
|
||||
m.logDebug("Daemon is running, requesting session")
|
||||
resp, err := client.CreateSession(m.config.Network.ProxyURL, m.config.Network.DnsAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create daemon session: %w", err)
|
||||
}
|
||||
m.daemonClient = client
|
||||
m.daemonSession = &DaemonSession{
|
||||
SessionID: resp.SessionID,
|
||||
TunDevice: resp.TunDevice,
|
||||
SandboxUser: resp.SandboxUser,
|
||||
SandboxGroup: resp.SandboxGroup,
|
||||
}
|
||||
m.logDebug("Daemon session created: id=%s device=%s user=%s group=%s", resp.SessionID, resp.TunDevice, resp.SandboxUser, resp.SandboxGroup)
|
||||
}
|
||||
|
||||
// On Linux, set up proxy bridge and tun2socks if proxy is configured
|
||||
if platform.Detect() == platform.Linux {
|
||||
if m.config.Network.ProxyURL != "" {
|
||||
// Extract embedded tun2socks binary
|
||||
tun2socksPath, err := extractTun2Socks()
|
||||
tun2socksPath, err := ExtractTun2Socks()
|
||||
if err != nil {
|
||||
m.logDebug("Failed to extract tun2socks: %v (will fall back to env-var proxying)", err)
|
||||
} else {
|
||||
@@ -148,7 +187,7 @@ func (m *Manager) WrapCommand(command string) (string, error) {
|
||||
plat := platform.Detect()
|
||||
switch plat {
|
||||
case platform.MacOS:
|
||||
return WrapCommandMacOS(m.config, command, m.exposedPorts, m.debug)
|
||||
return WrapCommandMacOS(m.config, command, m.exposedPorts, m.daemonSession, m.debug)
|
||||
case platform.Linux:
|
||||
if m.learning {
|
||||
return m.wrapCommandLearning(command)
|
||||
@@ -201,6 +240,16 @@ func (m *Manager) GenerateLearnedTemplate(cmdName string) (string, error) {
|
||||
|
||||
// Cleanup stops the proxies and cleans up resources.
|
||||
func (m *Manager) Cleanup() {
|
||||
// Destroy macOS daemon session if active.
|
||||
if m.daemonClient != nil && m.daemonSession != nil {
|
||||
m.logDebug("Destroying daemon session %s", m.daemonSession.SessionID)
|
||||
if err := m.daemonClient.DestroySession(m.daemonSession.SessionID); err != nil {
|
||||
m.logDebug("Warning: failed to destroy daemon session: %v", err)
|
||||
}
|
||||
m.daemonSession = nil
|
||||
m.daemonClient = nil
|
||||
}
|
||||
|
||||
if m.reverseBridge != nil {
|
||||
m.reverseBridge.Cleanup()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user