Replace fs_usage (reports Mach thread IDs, requiring process name matching with false positives) with eslogger (Endpoint Security framework, reports real Unix PIDs via audit_token.pid plus fork events for process tree tracking). Key changes: - Daemon starts eslogger instead of fs_usage, with early-exit detection and clear Full Disk Access error messaging - New two-pass eslogger JSON parser: pass 1 builds PID tree from fork events, pass 2 filters filesystem events by PID set - Remove runtime PID polling (StartPIDTracking, pollDescendantPIDs) — process tree is now built post-hoc from the eslogger log - Platform-specific generateLearnedTemplatePlatform() for darwin/linux/stub - Refactor TraceResult and GenerateLearnedTemplate to be platform-agnostic
317 lines
10 KiB
Go
317 lines
10 KiB
Go
package sandbox
|
|
|
|
import (
|
|
"fmt"
|
|
"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
|
|
proxyBridge *ProxyBridge
|
|
dnsBridge *DnsBridge
|
|
reverseBridge *ReverseBridge
|
|
tun2socksPath string // path to extracted tun2socks binary on host
|
|
exposedPorts []int
|
|
debug bool
|
|
monitor bool
|
|
initialized bool
|
|
learning bool // learning mode: permissive sandbox with strace/eslogger
|
|
straceLogPath string // host-side temp file for strace output (Linux)
|
|
commandName string // name of the command being learned
|
|
// macOS daemon session fields
|
|
daemonClient *daemon.Client
|
|
daemonSession *DaemonSession
|
|
// macOS learning mode fields
|
|
learningID string // daemon learning session ID
|
|
learningLog string // eslogger log file path
|
|
learningRootPID int // root PID of the command being learned
|
|
}
|
|
|
|
// NewManager creates a new sandbox manager.
|
|
func NewManager(cfg *config.Config, debug, monitor bool) *Manager {
|
|
return &Manager{
|
|
config: cfg,
|
|
debug: debug,
|
|
monitor: monitor,
|
|
}
|
|
}
|
|
|
|
// SetExposedPorts sets the ports to expose for inbound connections.
|
|
func (m *Manager) SetExposedPorts(ports []int) {
|
|
m.exposedPorts = ports
|
|
}
|
|
|
|
// SetLearning enables or disables learning mode.
|
|
func (m *Manager) SetLearning(enabled bool) {
|
|
m.learning = enabled
|
|
}
|
|
|
|
// SetCommandName sets the command name for learning mode template generation.
|
|
func (m *Manager) SetCommandName(name string) {
|
|
m.commandName = name
|
|
}
|
|
|
|
// IsLearning returns whether learning mode is enabled.
|
|
func (m *Manager) IsLearning() bool {
|
|
return m.learning
|
|
}
|
|
|
|
// Initialize sets up the sandbox infrastructure.
|
|
func (m *Manager) Initialize() error {
|
|
if m.initialized {
|
|
return nil
|
|
}
|
|
|
|
if !platform.IsSupported() {
|
|
return fmt.Errorf("sandbox is not supported on platform: %s", platform.Detect())
|
|
}
|
|
|
|
// On macOS in learning mode, use the daemon for eslogger tracing only.
|
|
// No TUN/pf/DNS session needed — the command runs unsandboxed.
|
|
if platform.Detect() == platform.MacOS && m.learning {
|
|
client := daemon.NewClient(daemon.DefaultSocketPath, m.debug)
|
|
if !client.IsRunning() {
|
|
return fmt.Errorf("greywall daemon is not running (required for macOS learning mode)\n\n" +
|
|
" Install and start: sudo greywall daemon install\n" +
|
|
" Check status: greywall daemon status")
|
|
}
|
|
m.logDebug("Daemon is running, requesting learning session")
|
|
resp, err := client.StartLearning()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to start learning session: %w", err)
|
|
}
|
|
m.daemonClient = client
|
|
m.learningID = resp.LearningID
|
|
m.learningLog = resp.LearningLog
|
|
m.logDebug("Learning session started: id=%s log=%s", m.learningID, m.learningLog)
|
|
m.initialized = true
|
|
return nil
|
|
}
|
|
|
|
// 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()
|
|
if err != nil {
|
|
m.logDebug("Failed to extract tun2socks: %v (will fall back to env-var proxying)", err)
|
|
} else {
|
|
m.tun2socksPath = tun2socksPath
|
|
}
|
|
|
|
// Create proxy bridge (socat: Unix socket -> external SOCKS5 proxy)
|
|
bridge, err := NewProxyBridge(m.config.Network.ProxyURL, m.debug)
|
|
if err != nil {
|
|
if m.tun2socksPath != "" {
|
|
_ = os.Remove(m.tun2socksPath)
|
|
}
|
|
return fmt.Errorf("failed to initialize proxy bridge: %w", err)
|
|
}
|
|
m.proxyBridge = bridge
|
|
|
|
// Create DNS bridge if a DNS server is configured
|
|
if m.config.Network.DnsAddr != "" {
|
|
dnsBridge, err := NewDnsBridge(m.config.Network.DnsAddr, m.debug)
|
|
if err != nil {
|
|
m.proxyBridge.Cleanup()
|
|
if m.tun2socksPath != "" {
|
|
_ = os.Remove(m.tun2socksPath)
|
|
}
|
|
return fmt.Errorf("failed to initialize DNS bridge: %w", err)
|
|
}
|
|
m.dnsBridge = dnsBridge
|
|
}
|
|
}
|
|
|
|
// Set up reverse bridge for exposed ports (inbound connections)
|
|
// Only needed when network namespace is available - otherwise they share the network
|
|
features := DetectLinuxFeatures()
|
|
if len(m.exposedPorts) > 0 && features.CanUnshareNet {
|
|
reverseBridge, err := NewReverseBridge(m.exposedPorts, m.debug)
|
|
if err != nil {
|
|
if m.proxyBridge != nil {
|
|
m.proxyBridge.Cleanup()
|
|
}
|
|
if m.tun2socksPath != "" {
|
|
_ = os.Remove(m.tun2socksPath)
|
|
}
|
|
return fmt.Errorf("failed to initialize reverse bridge: %w", err)
|
|
}
|
|
m.reverseBridge = reverseBridge
|
|
} else if len(m.exposedPorts) > 0 && m.debug {
|
|
m.logDebug("Skipping reverse bridge (no network namespace, ports accessible directly)")
|
|
}
|
|
}
|
|
|
|
m.initialized = true
|
|
if m.config.Network.ProxyURL != "" {
|
|
dnsInfo := "none"
|
|
if m.config.Network.DnsAddr != "" {
|
|
dnsInfo = m.config.Network.DnsAddr
|
|
}
|
|
m.logDebug("Sandbox manager initialized (proxy: %s, dns: %s)", m.config.Network.ProxyURL, dnsInfo)
|
|
} else {
|
|
m.logDebug("Sandbox manager initialized (no proxy, network blocked)")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// WrapCommand wraps a command with sandbox restrictions.
|
|
// Returns an error if the command is blocked by policy.
|
|
func (m *Manager) WrapCommand(command string) (string, error) {
|
|
if !m.initialized {
|
|
if err := m.Initialize(); err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
|
|
// Check if command is blocked by policy
|
|
if err := CheckCommand(command, m.config); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
plat := platform.Detect()
|
|
switch plat {
|
|
case platform.MacOS:
|
|
if m.learning {
|
|
// In learning mode, run command directly (no sandbox-exec wrapping)
|
|
return command, nil
|
|
}
|
|
return WrapCommandMacOS(m.config, command, m.exposedPorts, m.daemonSession, m.debug)
|
|
case platform.Linux:
|
|
if m.learning {
|
|
return m.wrapCommandLearning(command)
|
|
}
|
|
return WrapCommandLinux(m.config, command, m.proxyBridge, m.dnsBridge, m.reverseBridge, m.tun2socksPath, m.debug)
|
|
default:
|
|
return "", fmt.Errorf("unsupported platform: %s", plat)
|
|
}
|
|
}
|
|
|
|
// wrapCommandLearning creates a permissive sandbox with strace for learning mode.
|
|
func (m *Manager) wrapCommandLearning(command string) (string, error) {
|
|
// Create host-side temp file for strace output
|
|
tmpFile, err := os.CreateTemp("", "greywall-strace-*.log")
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create strace log file: %w", err)
|
|
}
|
|
_ = tmpFile.Close()
|
|
m.straceLogPath = tmpFile.Name()
|
|
|
|
m.logDebug("Strace log file: %s", m.straceLogPath)
|
|
|
|
return WrapCommandLinuxWithOptions(m.config, command, m.proxyBridge, m.dnsBridge, m.reverseBridge, m.tun2socksPath, LinuxSandboxOptions{
|
|
UseLandlock: false, // Disabled: seccomp blocks ptrace which strace needs
|
|
UseSeccomp: false, // Disabled: conflicts with strace
|
|
UseEBPF: false,
|
|
Debug: m.debug,
|
|
Learning: true,
|
|
StraceLogPath: m.straceLogPath,
|
|
})
|
|
}
|
|
|
|
// GenerateLearnedTemplate generates a config template from the trace log collected during learning.
|
|
// Platform-specific implementation in manager_linux.go / manager_darwin.go.
|
|
func (m *Manager) GenerateLearnedTemplate(cmdName string) (string, error) {
|
|
return m.generateLearnedTemplatePlatform(cmdName)
|
|
}
|
|
|
|
// SetLearningRootPID records the root PID of the command being learned.
|
|
// The eslogger log parser uses this to build the process tree from fork events.
|
|
func (m *Manager) SetLearningRootPID(pid int) {
|
|
m.learningRootPID = pid
|
|
m.logDebug("Set learning root PID: %d", pid)
|
|
}
|
|
|
|
// Cleanup stops the proxies and cleans up resources.
|
|
func (m *Manager) Cleanup() {
|
|
// Stop macOS learning session if active
|
|
if m.daemonClient != nil && m.learningID != "" {
|
|
m.logDebug("Stopping learning session %s", m.learningID)
|
|
if err := m.daemonClient.StopLearning(m.learningID); err != nil {
|
|
m.logDebug("Warning: failed to stop learning session: %v", err)
|
|
}
|
|
m.learningID = ""
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// Clear daemon client after all daemon interactions
|
|
m.daemonClient = nil
|
|
|
|
if m.reverseBridge != nil {
|
|
m.reverseBridge.Cleanup()
|
|
}
|
|
if m.dnsBridge != nil {
|
|
m.dnsBridge.Cleanup()
|
|
}
|
|
if m.proxyBridge != nil {
|
|
m.proxyBridge.Cleanup()
|
|
}
|
|
if m.tun2socksPath != "" {
|
|
_ = os.Remove(m.tun2socksPath)
|
|
}
|
|
if m.straceLogPath != "" {
|
|
_ = os.Remove(m.straceLogPath)
|
|
m.straceLogPath = ""
|
|
}
|
|
if m.learningLog != "" {
|
|
_ = os.Remove(m.learningLog)
|
|
m.learningLog = ""
|
|
}
|
|
m.logDebug("Sandbox manager cleaned up")
|
|
}
|
|
|
|
func (m *Manager) logDebug(format string, args ...interface{}) {
|
|
if m.debug {
|
|
fmt.Fprintf(os.Stderr, "[greywall] "+format+"\n", args...)
|
|
}
|
|
}
|