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

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