3 Commits

Author SHA1 Message Date
tito
3d2374cc98 chore: ignore tun2socks source directory in gitignore
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 09:49:20 -06:00
tito
09120917e4 feat: add macOS daemon support with group-based pf routing
- Add daemon CLI subcommand (install/uninstall/status/run)
- Download tun2socks for darwin platforms in Makefile
- Export ExtractTun2Socks and add darwin embed support
- Use group-based pf filtering instead of user-based for transparent proxying
- Install sudoers rule for passwordless sandbox-exec with _greywall group
- Add nolint directives for gosec false positives on sudoers 0440 perms
- Fix lint issues: lowercase errors, fmt.Fprintf, nolint comments

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 09:46:33 -06:00
tito
4d9a13c1b4 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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 19:20:01 -06:00
25 changed files with 185 additions and 1813 deletions

View File

@@ -4,10 +4,12 @@ import (
"bufio"
"fmt"
"os"
"os/signal"
"path/filepath"
"runtime"
"strings"
"syscall"
"github.com/kardianos/service"
"github.com/spf13/cobra"
"gitea.app.monadical.io/monadical/greywall/internal/daemon"
@@ -20,9 +22,6 @@ import (
// install - Install the LaunchDaemon (requires root)
// uninstall - Uninstall the LaunchDaemon (requires root)
// run - Run the daemon (called by LaunchDaemon plist)
// start - Start the daemon service
// stop - Stop the daemon service
// restart - Restart the daemon service
// status - Show daemon status
func newDaemonCmd() *cobra.Command {
cmd := &cobra.Command{
@@ -36,9 +35,6 @@ and pf rules that enable transparent proxy routing for sandboxed processes.
Commands:
sudo greywall daemon install Install and start the daemon
sudo greywall daemon uninstall Stop and remove the daemon
sudo greywall daemon start Start the daemon service
sudo greywall daemon stop Stop the daemon service
sudo greywall daemon restart Restart the daemon service
greywall daemon status Check daemon status
greywall daemon run Run the daemon (used by LaunchDaemon)`,
}
@@ -47,9 +43,6 @@ Commands:
newDaemonInstallCmd(),
newDaemonUninstallCmd(),
newDaemonRunCmd(),
newDaemonStartCmd(),
newDaemonStopCmd(),
newDaemonRestartCmd(),
newDaemonStatusCmd(),
)
@@ -158,57 +151,6 @@ func newDaemonRunCmd() *cobra.Command {
}
}
// newDaemonStartCmd creates the "daemon start" subcommand.
func newDaemonStartCmd() *cobra.Command {
return &cobra.Command{
Use: "start",
Short: "Start the daemon service",
Long: `Start the greywall daemon service. Requires root privileges.`,
RunE: func(cmd *cobra.Command, args []string) error {
return daemonControl("start")
},
}
}
// newDaemonStopCmd creates the "daemon stop" subcommand.
func newDaemonStopCmd() *cobra.Command {
return &cobra.Command{
Use: "stop",
Short: "Stop the daemon service",
Long: `Stop the greywall daemon service. Requires root privileges.`,
RunE: func(cmd *cobra.Command, args []string) error {
return daemonControl("stop")
},
}
}
// newDaemonRestartCmd creates the "daemon restart" subcommand.
func newDaemonRestartCmd() *cobra.Command {
return &cobra.Command{
Use: "restart",
Short: "Restart the daemon service",
Long: `Restart the greywall daemon service. Requires root privileges.`,
RunE: func(cmd *cobra.Command, args []string) error {
return daemonControl("restart")
},
}
}
// daemonControl sends a control action (start/stop/restart) to the daemon
// service via kardianos/service.
func daemonControl(action string) error {
p := daemon.NewProgram(daemon.DefaultSocketPath, daemon.DefaultTun2socksPath(), debug)
s, err := service.New(p, daemon.NewServiceConfig())
if err != nil {
return fmt.Errorf("failed to create service: %w", err)
}
if err := service.Control(s, action); err != nil {
return fmt.Errorf("failed to %s daemon: %w", action, err)
}
fmt.Printf("Daemon %sed successfully.\n", action)
return nil
}
// newDaemonStatusCmd creates the "daemon status" subcommand.
func newDaemonStatusCmd() *cobra.Command {
return &cobra.Command{
@@ -217,16 +159,11 @@ func newDaemonStatusCmd() *cobra.Command {
Long: `Check whether the greywall daemon is installed and running. Does not require root.`,
RunE: func(cmd *cobra.Command, args []string) error {
installed := daemon.IsInstalled()
// Try kardianos/service status first for reliable state detection.
serviceState := daemonServiceState()
running := serviceState == "running"
running := daemon.IsRunning()
fmt.Printf("Greywall daemon status:\n")
fmt.Printf(" Installed: %s\n", boolStatus(installed))
fmt.Printf(" Running: %s\n", boolStatus(running))
fmt.Printf(" Service: %s\n", serviceState)
fmt.Printf(" Plist: %s\n", daemon.LaunchDaemonPlistPath)
fmt.Printf(" Binary: %s\n", daemon.InstallBinaryPath)
fmt.Printf(" User: %s\n", daemon.SandboxUserName)
@@ -241,7 +178,7 @@ func newDaemonStatusCmd() *cobra.Command {
fmt.Println()
fmt.Println("The daemon is installed but not running.")
fmt.Printf("Check logs: cat /var/log/greywall.log\n")
fmt.Printf("Start it: sudo greywall daemon start\n")
fmt.Printf("Start it: sudo launchctl load %s\n", daemon.LaunchDaemonPlistPath)
}
return nil
@@ -249,47 +186,38 @@ func newDaemonStatusCmd() *cobra.Command {
}
}
// runDaemon is the main entry point for the daemon process. It uses
// kardianos/service to manage the lifecycle, handling signals and
// calling Start/Stop on the program.
// runDaemon is the main entry point for the daemon process. It starts the
// Unix socket server and blocks until a termination signal is received.
// CLI clients connect to the server to request sessions (which create
// utun tunnels, DNS relays, and pf rules on demand).
func runDaemon(cmd *cobra.Command, args []string) error {
p := daemon.NewProgram(daemon.DefaultSocketPath, daemon.DefaultTun2socksPath(), debug)
s, err := service.New(p, daemon.NewServiceConfig())
if err != nil {
return fmt.Errorf("failed to create service: %w", err)
}
return s.Run()
}
// daemonServiceState returns the daemon's service state as a string.
// It tries kardianos/service status first, then falls back to socket check.
func daemonServiceState() string {
p := daemon.NewProgram(daemon.DefaultSocketPath, daemon.DefaultTun2socksPath(), debug)
s, err := service.New(p, daemon.NewServiceConfig())
if err != nil {
if daemon.IsRunning() {
return "running"
}
return "stopped"
tun2socksPath := filepath.Join(daemon.InstallLibDir, "tun2socks-darwin-"+runtime.GOARCH)
if _, err := os.Stat(tun2socksPath); err != nil {
return fmt.Errorf("tun2socks binary not found at %s (run 'sudo greywall daemon install' first)", tun2socksPath)
}
status, err := s.Status()
if err != nil {
// Fall back to socket check.
if daemon.IsRunning() {
return "running"
}
return "stopped"
daemon.Logf("Starting daemon (tun2socks=%s, socket=%s)", tun2socksPath, daemon.DefaultSocketPath)
srv := daemon.NewServer(daemon.DefaultSocketPath, tun2socksPath, debug)
if err := srv.Start(); err != nil {
return fmt.Errorf("failed to start daemon server: %w", err)
}
switch status {
case service.StatusRunning:
return "running"
case service.StatusStopped:
return "stopped"
default:
return "unknown"
daemon.Logf("Daemon started, listening on %s", daemon.DefaultSocketPath)
// Wait for termination signal.
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
sig := <-sigCh
daemon.Logf("Received signal %s, shutting down", sig)
if err := srv.Stop(); err != nil {
daemon.Logf("Shutdown error: %v", err)
return err
}
daemon.Logf("Daemon stopped")
return nil
}
// boolStatus returns a human-readable string for a boolean status value.

View File

@@ -31,7 +31,6 @@ var (
monitor bool
settingsPath string
proxyURL string
httpProxyURL string
dnsAddr string
cmdString string
exposePorts []string
@@ -100,7 +99,6 @@ Configuration file format:
rootCmd.Flags().BoolVarP(&monitor, "monitor", "m", false, "Monitor and log sandbox violations")
rootCmd.Flags().StringVarP(&settingsPath, "settings", "s", "", "Path to settings file (default: OS config directory)")
rootCmd.Flags().StringVar(&proxyURL, "proxy", "", "External SOCKS5 proxy URL (default: socks5://localhost:42052)")
rootCmd.Flags().StringVar(&httpProxyURL, "http-proxy", "", "HTTP CONNECT proxy URL (default: http://localhost:42051)")
rootCmd.Flags().StringVar(&dnsAddr, "dns", "", "DNS server address on host (default: localhost:42053)")
rootCmd.Flags().StringVarP(&cmdString, "c", "c", "", "Run command string directly (like sh -c)")
rootCmd.Flags().StringArrayVarP(&exposePorts, "port", "p", nil, "Expose port for inbound connections (can be used multiple times)")
@@ -230,9 +228,6 @@ func runCommand(cmd *cobra.Command, args []string) error {
if proxyURL != "" {
cfg.Network.ProxyURL = proxyURL
}
if httpProxyURL != "" {
cfg.Network.HTTPProxyURL = httpProxyURL
}
if dnsAddr != "" {
cfg.Network.DnsAddr = dnsAddr
}
@@ -245,12 +240,6 @@ func runCommand(cmd *cobra.Command, args []string) error {
fmt.Fprintf(os.Stderr, "[greywall] Defaulting proxy to socks5://localhost:42052\n")
}
}
if cfg.Network.HTTPProxyURL == "" {
cfg.Network.HTTPProxyURL = "http://localhost:42051"
if debug {
fmt.Fprintf(os.Stderr, "[greywall] Defaulting HTTP proxy to http://localhost:42051\n")
}
}
if cfg.Network.DnsAddr == "" {
cfg.Network.DnsAddr = "localhost:42053"
if debug {
@@ -278,7 +267,7 @@ func runCommand(cmd *cobra.Command, args []string) error {
// Learning mode setup
if learning {
if err := sandbox.CheckLearningAvailable(); err != nil {
if err := sandbox.CheckStraceAvailable(); err != nil {
return err
}
fmt.Fprintf(os.Stderr, "[greywall] Learning mode: tracing filesystem access for %q\n", cmdName)
@@ -316,7 +305,6 @@ func runCommand(cmd *cobra.Command, args []string) error {
if debug {
fmt.Fprintf(os.Stderr, "[greywall] Sandboxed command: %s\n", sandboxedCommand)
fmt.Fprintf(os.Stderr, "[greywall] Executing: sh -c %q\n", sandboxedCommand)
}
hardenedEnv := sandbox.GetHardenedEnv()
@@ -340,11 +328,6 @@ func runCommand(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to start command: %w", err)
}
// Record root PID for macOS learning mode (eslogger uses this for process tree tracking)
if learning && platform.Detect() == platform.MacOS && execCmd.Process != nil {
manager.SetLearningRootPID(execCmd.Process.Pid)
}
// Start Linux monitors (eBPF tracing for filesystem violations)
var linuxMonitors *sandbox.LinuxMonitors
if monitor && execCmd.Process != nil {

1
go.mod
View File

@@ -4,7 +4,6 @@ go 1.25
require (
github.com/bmatcuk/doublestar/v4 v4.9.1
github.com/kardianos/service v1.2.4
github.com/spf13/cobra v1.8.1
github.com/tidwall/jsonc v0.3.2
golang.org/x/sys v0.39.0

2
go.sum
View File

@@ -3,8 +3,6 @@ github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTS
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kardianos/service v1.2.4 h1:XNlGtZOYNx2u91urOdg/Kfmc+gfmuIo1Dd3rEi2OgBk=
github.com/kardianos/service v1.2.4/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=

View File

@@ -26,9 +26,8 @@ type Config struct {
// NetworkConfig defines network restrictions.
type NetworkConfig struct {
ProxyURL string `json:"proxyUrl,omitempty"` // External SOCKS5 proxy (e.g. socks5://host:1080)
HTTPProxyURL string `json:"httpProxyUrl,omitempty"` // HTTP CONNECT proxy (e.g. http://host:42051)
DnsAddr string `json:"dnsAddr,omitempty"` // DNS server address on host (e.g. localhost:3153)
ProxyURL string `json:"proxyUrl,omitempty"` // External SOCKS5 proxy (e.g. socks5://host:1080)
DnsAddr string `json:"dnsAddr,omitempty"` // DNS server address on host (e.g. localhost:3153)
AllowUnixSockets []string `json:"allowUnixSockets,omitempty"`
AllowAllUnixSockets bool `json:"allowAllUnixSockets,omitempty"`
AllowLocalBinding bool `json:"allowLocalBinding,omitempty"`
@@ -204,11 +203,6 @@ func (c *Config) Validate() error {
return fmt.Errorf("invalid network.proxyUrl %q: %w", c.Network.ProxyURL, err)
}
}
if c.Network.HTTPProxyURL != "" {
if err := validateHTTPProxyURL(c.Network.HTTPProxyURL); err != nil {
return fmt.Errorf("invalid network.httpProxyUrl %q: %w", c.Network.HTTPProxyURL, err)
}
}
if c.Network.DnsAddr != "" {
if err := validateHostPort(c.Network.DnsAddr); err != nil {
return fmt.Errorf("invalid network.dnsAddr %q: %w", c.Network.DnsAddr, err)
@@ -279,24 +273,6 @@ func validateProxyURL(proxyURL string) error {
return nil
}
// validateHTTPProxyURL validates an HTTP CONNECT proxy URL.
func validateHTTPProxyURL(proxyURL string) error {
u, err := url.Parse(proxyURL)
if err != nil {
return fmt.Errorf("invalid URL: %w", err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return errors.New("HTTP proxy URL must use http:// or https:// scheme")
}
if u.Hostname() == "" {
return errors.New("HTTP proxy URL must include a hostname")
}
if u.Port() == "" {
return errors.New("HTTP proxy URL must include a port")
}
return nil
}
// validateHostPort validates a host:port address.
func validateHostPort(addr string) error {
// Must contain a colon separating host and port
@@ -431,10 +407,9 @@ func Merge(base, override *Config) *Config {
AllowPty: base.AllowPty || override.AllowPty,
Network: NetworkConfig{
// ProxyURL/HTTPProxyURL/DnsAddr: override wins if non-empty
ProxyURL: mergeString(base.Network.ProxyURL, override.Network.ProxyURL),
HTTPProxyURL: mergeString(base.Network.HTTPProxyURL, override.Network.HTTPProxyURL),
DnsAddr: mergeString(base.Network.DnsAddr, override.Network.DnsAddr),
// ProxyURL/DnsAddr: override wins if non-empty
ProxyURL: mergeString(base.Network.ProxyURL, override.Network.ProxyURL),
DnsAddr: mergeString(base.Network.DnsAddr, override.Network.DnsAddr),
// Append slices (base first, then override additions)
AllowUnixSockets: mergeStrings(base.Network.AllowUnixSockets, override.Network.AllowUnixSockets),

View File

@@ -71,43 +71,6 @@ func (c *Client) DestroySession(sessionID string) error {
return nil
}
// StartLearning asks the daemon to start an fs_usage trace for learning mode.
func (c *Client) StartLearning() (*Response, error) {
req := Request{
Action: "start_learning",
}
resp, err := c.sendRequest(req)
if err != nil {
return nil, fmt.Errorf("start learning request failed: %w", err)
}
if !resp.OK {
return resp, fmt.Errorf("start learning failed: %s", resp.Error)
}
return resp, nil
}
// StopLearning asks the daemon to stop the fs_usage trace for the given learning session.
func (c *Client) StopLearning(learningID string) error {
req := Request{
Action: "stop_learning",
LearningID: learningID,
}
resp, err := c.sendRequest(req)
if err != nil {
return fmt.Errorf("stop learning request failed: %w", err)
}
if !resp.OK {
return fmt.Errorf("stop learning failed: %s", resp.Error)
}
return nil
}
// Status queries the daemon for its current status.
func (c *Client) Status() (*Response, error) {
req := Request{

View File

@@ -5,6 +5,7 @@ package daemon
import (
"fmt"
"net"
"os"
"sync"
"time"
)
@@ -76,7 +77,9 @@ func (d *DNSRelay) ListenAddr() string {
// listening socket and spawns a goroutine per query to forward it to the
// upstream DNS server and relay the response back.
func (d *DNSRelay) Start() error {
Logf("DNS relay listening on %s, forwarding to %s", d.listenAddr, d.targetAddr)
if d.debug {
fmt.Fprintf(os.Stderr, "[greywall:dns-relay] Listening on %s, forwarding to %s\n", d.listenAddr, d.targetAddr)
}
d.wg.Add(1)
go d.readLoop()
@@ -91,7 +94,9 @@ func (d *DNSRelay) Stop() {
_ = d.udpConn.Close()
d.wg.Wait()
Logf("DNS relay stopped")
if d.debug {
fmt.Fprintf(os.Stderr, "[greywall:dns-relay] Stopped\n")
}
}
// readLoop is the main loop that reads incoming DNS queries from the listening socket.
@@ -107,7 +112,7 @@ func (d *DNSRelay) readLoop() {
// Shutting down, expected error from closed socket.
return
default:
Logf("DNS relay: read error: %v", err)
fmt.Fprintf(os.Stderr, "[greywall:dns-relay] Read error: %v\n", err)
continue
}
}
@@ -131,33 +136,36 @@ func (d *DNSRelay) readLoop() {
func (d *DNSRelay) handleQuery(query []byte, clientAddr *net.UDPAddr) {
defer d.wg.Done()
Logf("DNS relay: query from %s (%d bytes)", clientAddr, len(query))
if d.debug {
fmt.Fprintf(os.Stderr, "[greywall:dns-relay] Query from %s (%d bytes)\n", clientAddr, len(query))
}
// Create a dedicated UDP connection to the upstream DNS server.
// Use "udp4" to force IPv4, since the upstream may only listen on 127.0.0.1.
upstreamConn, err := net.Dial("udp4", d.targetAddr)
upstreamConn, err := net.Dial("udp", d.targetAddr)
if err != nil {
Logf("DNS relay: failed to connect to upstream %s: %v", d.targetAddr, err)
fmt.Fprintf(os.Stderr, "[greywall:dns-relay] Failed to connect to upstream %s: %v\n", d.targetAddr, err)
return
}
defer upstreamConn.Close() //nolint:errcheck // best-effort cleanup of per-query UDP connection
// Send the query to the upstream server.
if _, err := upstreamConn.Write(query); err != nil {
Logf("DNS relay: failed to send query to upstream: %v", err)
fmt.Fprintf(os.Stderr, "[greywall:dns-relay] Failed to send query to upstream: %v\n", err)
return
}
// Wait for the response with a timeout.
if err := upstreamConn.SetReadDeadline(time.Now().Add(upstreamTimeout)); err != nil {
Logf("DNS relay: failed to set read deadline: %v", err)
fmt.Fprintf(os.Stderr, "[greywall:dns-relay] Failed to set read deadline: %v\n", err)
return
}
resp := make([]byte, maxDNSPacketSize)
n, err := upstreamConn.Read(resp)
if err != nil {
Logf("DNS relay: upstream response error from %s: %v", d.targetAddr, err)
if d.debug {
fmt.Fprintf(os.Stderr, "[greywall:dns-relay] Upstream response error: %v\n", err)
}
return
}
@@ -168,9 +176,11 @@ func (d *DNSRelay) handleQuery(query []byte, clientAddr *net.UDPAddr) {
case <-d.done:
return
default:
Logf("DNS relay: failed to send response to %s: %v", clientAddr, err)
fmt.Fprintf(os.Stderr, "[greywall:dns-relay] Failed to send response to %s: %v\n", clientAddr, err)
}
}
Logf("DNS relay: response to %s (%d bytes)", clientAddr, n)
if d.debug {
fmt.Fprintf(os.Stderr, "[greywall:dns-relay] Response to %s (%d bytes)\n", clientAddr, n)
}
}

View File

@@ -546,3 +546,9 @@ func logDebug(debug bool, format string, args ...interface{}) {
Logf(format, args...)
}
}
// Logf writes a timestamped message to stderr with the [greywall:daemon] prefix.
func Logf(format string, args ...interface{}) {
ts := time.Now().Format("2006-01-02 15:04:05")
fmt.Fprintf(os.Stderr, ts+" [greywall:daemon] "+format+"\n", args...)
}

View File

@@ -1,13 +0,0 @@
package daemon
import (
"fmt"
"os"
"time"
)
// Logf writes a timestamped message to stderr with the [greywall:daemon] prefix.
func Logf(format string, args ...interface{}) {
ts := time.Now().Format("2006-01-02 15:04:05")
fmt.Fprintf(os.Stderr, ts+" [greywall:daemon] "+format+"\n", args...) //nolint:gosec // logging to stderr, not user-facing HTML
}

View File

@@ -1,81 +0,0 @@
package daemon
import (
"fmt"
"os"
"path/filepath"
"runtime"
"github.com/kardianos/service"
)
// program implements the kardianos/service.Interface for greywall daemon
// lifecycle management. It delegates actual work to the Server type.
type program struct {
server *Server
socketPath string
tun2socksPath string
debug bool
}
// NewProgram creates a new program instance for use with kardianos/service.
func NewProgram(socketPath, tun2socksPath string, debug bool) *program {
return &program{
socketPath: socketPath,
tun2socksPath: tun2socksPath,
debug: debug,
}
}
// Start is called by kardianos/service when the service starts. It verifies
// the tun2socks binary exists, creates and starts the Server. The accept loop
// already runs in a goroutine, so this returns immediately.
func (p *program) Start(_ service.Service) error {
if _, err := os.Stat(p.tun2socksPath); err != nil {
return fmt.Errorf("tun2socks binary not found at %s (run 'sudo greywall daemon install' first)", p.tun2socksPath)
}
Logf("Starting daemon (tun2socks=%s, socket=%s)", p.tun2socksPath, p.socketPath)
p.server = NewServer(p.socketPath, p.tun2socksPath, p.debug)
if err := p.server.Start(); err != nil {
return fmt.Errorf("failed to start daemon server: %w", err)
}
Logf("Daemon started, listening on %s", p.socketPath)
return nil
}
// Stop is called by kardianos/service when the service stops.
func (p *program) Stop(_ service.Service) error {
if p.server == nil {
return nil
}
Logf("Stopping daemon")
if err := p.server.Stop(); err != nil {
Logf("Shutdown error: %v", err)
return err
}
Logf("Daemon stopped")
return nil
}
// NewServiceConfig returns a kardianos/service config matching the existing
// LaunchDaemon setup. The Name matches LaunchDaemonLabel so service.Control()
// can find and manage the already-installed service.
func NewServiceConfig() *service.Config {
return &service.Config{
Name: LaunchDaemonLabel,
DisplayName: "Greywall Daemon",
Description: "Greywall transparent network sandboxing daemon",
Arguments: []string{"daemon", "run"},
}
}
// DefaultTun2socksPath returns the expected tun2socks binary path based on
// the install directory and current architecture.
func DefaultTun2socksPath() string {
return filepath.Join(InstallLibDir, "tun2socks-darwin-"+runtime.GOARCH)
}

View File

@@ -7,11 +7,8 @@ import (
"fmt"
"net"
"os"
"os/exec"
"os/user"
"strings"
"sync"
"syscall"
"time"
)
@@ -19,11 +16,10 @@ import (
// Request from CLI to daemon.
type Request struct {
Action string `json:"action"` // "create_session", "destroy_session", "status", "start_learning", "stop_learning"
ProxyURL string `json:"proxy_url,omitempty"` // for create_session
DNSAddr string `json:"dns_addr,omitempty"` // for create_session
SessionID string `json:"session_id,omitempty"` // for destroy_session
LearningID string `json:"learning_id,omitempty"` // for stop_learning
Action string `json:"action"` // "create_session", "destroy_session", "status"
ProxyURL string `json:"proxy_url,omitempty"` // for create_session
DNSAddr string `json:"dns_addr,omitempty"` // for create_session
SessionID string `json:"session_id,omitempty"` // for destroy_session
}
// Response from daemon to CLI.
@@ -37,9 +33,6 @@ type Response struct {
// Status response fields.
Running bool `json:"running,omitempty"`
ActiveSessions int `json:"active_sessions,omitempty"`
// Learning response fields.
LearningID string `json:"learning_id,omitempty"`
LearningLog string `json:"learning_log,omitempty"`
}
// Session tracks an active sandbox session.
@@ -64,11 +57,6 @@ type Server struct {
debug bool
tun2socksPath string
sandboxGID string // cached numeric GID for the sandbox group
// Learning mode state
esloggerCmd *exec.Cmd // running eslogger process
esloggerLogPath string // temp file path for eslogger output
esloggerDone chan error // receives result of cmd.Wait() (set once, reused for stop)
learningID string // current learning session ID
}
// NewServer creates a new daemon server that will listen on the given Unix socket path.
@@ -145,26 +133,9 @@ func (s *Server) Stop() error {
// Wait for the accept loop and any in-flight handlers to finish.
s.wg.Wait()
// Tear down all active sessions and learning.
// Tear down all active sessions.
s.mu.Lock()
var errs []string
// Stop learning session if active
if s.esloggerCmd != nil && s.esloggerCmd.Process != nil {
s.logDebug("Stopping eslogger during shutdown")
_ = s.esloggerCmd.Process.Kill()
if s.esloggerDone != nil {
<-s.esloggerDone
}
s.esloggerCmd = nil
s.esloggerDone = nil
s.learningID = ""
}
if s.esloggerLogPath != "" {
_ = os.Remove(s.esloggerLogPath)
s.esloggerLogPath = ""
}
for id := range s.sessions {
s.logDebug("Stopping session %s during shutdown", id)
}
@@ -256,10 +227,6 @@ func (s *Server) handleConnection(conn net.Conn) {
resp = s.handleCreateSession(req)
case "destroy_session":
resp = s.handleDestroySession(req)
case "start_learning":
resp = s.handleStartLearning()
case "stop_learning":
resp = s.handleStopLearning(req)
case "status":
resp = s.handleStatus()
default:
@@ -295,27 +262,19 @@ func (s *Server) handleCreateSession(req Request) Response {
return Response{OK: false, Error: fmt.Sprintf("failed to start tunnel: %v", err)}
}
// Step 2: Create DNS relay. pf rules always redirect DNS (UDP:53) from
// the sandbox group to the relay address, so we must always start the
// relay when a proxy session is active. If no explicit DNS address was
// provided, default to the proxy's DNS resolver.
dnsTarget := req.DNSAddr
if dnsTarget == "" {
dnsTarget = defaultDNSTarget
Logf("No dns_addr provided, defaulting DNS relay upstream to %s", dnsTarget)
}
dr, err := NewDNSRelay(dnsRelayIP+":"+dnsRelayPort, dnsTarget, s.debug)
if err != nil {
if stopErr := tm.Stop(); stopErr != nil {
Logf("Warning: failed to stop tunnel during cleanup: %v", stopErr)
// Step 2: Create DNS relay if dns_addr is provided.
var dr *DNSRelay
if req.DNSAddr != "" {
var err error
dr, err = NewDNSRelay(dnsRelayIP+":"+dnsRelayPort, req.DNSAddr, s.debug)
if err != nil {
_ = tm.Stop() // best-effort cleanup
return Response{OK: false, Error: fmt.Sprintf("failed to create DNS relay: %v", err)}
}
return Response{OK: false, Error: fmt.Sprintf("failed to create DNS relay: %v", err)}
}
if err := dr.Start(); err != nil {
if stopErr := tm.Stop(); stopErr != nil {
Logf("Warning: failed to stop tunnel during cleanup: %v", stopErr)
if err := dr.Start(); err != nil {
_ = tm.Stop() // best-effort cleanup
return Response{OK: false, Error: fmt.Sprintf("failed to start DNS relay: %v", err)}
}
return Response{OK: false, Error: fmt.Sprintf("failed to start DNS relay: %v", err)}
}
// Step 3: Resolve the sandbox group GID. pfctl in the LaunchDaemon
@@ -326,7 +285,9 @@ func (s *Server) handleCreateSession(req Request) Response {
grp, err := user.LookupGroup(SandboxGroupName)
if err != nil {
_ = tm.Stop()
dr.Stop()
if dr != nil {
dr.Stop()
}
return Response{OK: false, Error: fmt.Sprintf("failed to resolve group %s: %v", SandboxGroupName, err)}
}
sandboxGID = grp.Gid
@@ -334,7 +295,9 @@ func (s *Server) handleCreateSession(req Request) Response {
}
Logf("Loading pf rules for group %s (GID %s)", SandboxGroupName, sandboxGID)
if err := tm.LoadPFRules(sandboxGID); err != nil {
dr.Stop()
if dr != nil {
dr.Stop()
}
_ = tm.Stop() // best-effort cleanup
return Response{OK: false, Error: fmt.Sprintf("failed to load pf rules: %v", err)}
}
@@ -342,7 +305,9 @@ func (s *Server) handleCreateSession(req Request) Response {
// Step 4: Generate session ID and store.
sessionID, err := generateSessionID()
if err != nil {
dr.Stop()
if dr != nil {
dr.Stop()
}
_ = tm.UnloadPFRules() // best-effort cleanup
_ = tm.Stop() // best-effort cleanup
return Response{OK: false, Error: fmt.Sprintf("failed to generate session ID: %v", err)}
@@ -351,7 +316,7 @@ func (s *Server) handleCreateSession(req Request) Response {
session := &Session{
ID: sessionID,
ProxyURL: req.ProxyURL,
DNSAddr: dnsTarget,
DNSAddr: req.DNSAddr,
CreatedAt: time.Now(),
}
s.sessions[sessionID] = session
@@ -422,156 +387,6 @@ func (s *Server) handleDestroySession(req Request) Response {
return Response{OK: true}
}
// handleStartLearning starts an eslogger trace for learning mode.
// eslogger uses the Endpoint Security framework and reports real Unix PIDs
// via audit_token.pid, plus fork events for process tree tracking.
func (s *Server) handleStartLearning() Response {
s.mu.Lock()
defer s.mu.Unlock()
// Only one learning session at a time
if s.learningID != "" {
return Response{OK: false, Error: "a learning session is already active"}
}
// Create temp file for eslogger output.
// The daemon runs as root but the CLI reads this file as a normal user,
// so we must make it world-readable.
logFile, err := os.CreateTemp("", "greywall-eslogger-*.log")
if err != nil {
return Response{OK: false, Error: fmt.Sprintf("failed to create temp file: %v", err)}
}
logPath := logFile.Name()
if err := os.Chmod(logPath, 0o644); err != nil { //nolint:gosec // intentionally world-readable so non-root CLI can parse the log
_ = logFile.Close()
_ = os.Remove(logPath) //nolint:gosec // logPath from os.CreateTemp, not user input
return Response{OK: false, Error: fmt.Sprintf("failed to set log file permissions: %v", err)}
}
// Create a separate file for eslogger stderr so we can diagnose failures.
stderrFile, err := os.CreateTemp("", "greywall-eslogger-stderr-*.log")
if err != nil {
_ = logFile.Close()
_ = os.Remove(logPath) //nolint:gosec // logPath from os.CreateTemp, not user input
return Response{OK: false, Error: fmt.Sprintf("failed to create stderr file: %v", err)}
}
stderrPath := stderrFile.Name()
// Start eslogger with filesystem events + fork for process tree tracking.
// eslogger outputs one JSON object per line to stdout.
cmd := exec.Command("eslogger", "open", "create", "write", "unlink", "rename", "link", "truncate", "fork") //nolint:gosec // daemon-controlled command
cmd.Stdout = logFile
cmd.Stderr = stderrFile
if err := cmd.Start(); err != nil {
_ = logFile.Close()
_ = stderrFile.Close()
_ = os.Remove(logPath) //nolint:gosec // logPath from os.CreateTemp, not user input
_ = os.Remove(stderrPath) //nolint:gosec // stderrPath from os.CreateTemp, not user input
return Response{OK: false, Error: fmt.Sprintf("failed to start eslogger: %v", err)}
}
// Generate learning ID
learningID, err := generateSessionID()
if err != nil {
_ = cmd.Process.Kill()
_ = logFile.Close()
_ = stderrFile.Close()
_ = os.Remove(logPath) //nolint:gosec // logPath from os.CreateTemp, not user input
_ = os.Remove(stderrPath) //nolint:gosec // stderrPath from os.CreateTemp, not user input
return Response{OK: false, Error: fmt.Sprintf("failed to generate learning ID: %v", err)}
}
// Wait briefly for eslogger to initialize, then check if it exited early
// (e.g., missing Full Disk Access permission).
exitCh := make(chan error, 1)
go func() {
exitCh <- cmd.Wait()
}()
select {
case waitErr := <-exitCh:
// eslogger exited during startup — read stderr for the error message
_ = stderrFile.Close()
stderrContent, _ := os.ReadFile(stderrPath) //nolint:gosec // stderrPath from os.CreateTemp
_ = os.Remove(stderrPath) //nolint:gosec
_ = logFile.Close()
_ = os.Remove(logPath) //nolint:gosec
errMsg := strings.TrimSpace(string(stderrContent))
if errMsg == "" {
errMsg = fmt.Sprintf("eslogger exited: %v", waitErr)
}
if strings.Contains(errMsg, "Full Disk Access") {
errMsg += "\n\nGrant Full Disk Access to /usr/local/bin/greywall:\n" +
" System Settings → Privacy & Security → Full Disk Access → add /usr/local/bin/greywall\n" +
"Then reinstall the daemon: sudo greywall daemon uninstall -f && sudo greywall daemon install"
}
return Response{OK: false, Error: fmt.Sprintf("eslogger failed to start: %s", errMsg)}
case <-time.After(500 * time.Millisecond):
// eslogger is still running after 500ms — good, it initialized successfully
}
s.esloggerCmd = cmd
s.esloggerLogPath = logPath
s.esloggerDone = exitCh
s.learningID = learningID
// Clean up stderr file now that eslogger is running
_ = stderrFile.Close()
_ = os.Remove(stderrPath) //nolint:gosec
Logf("Learning session started: id=%s log=%s pid=%d", learningID, logPath, cmd.Process.Pid)
return Response{
OK: true,
LearningID: learningID,
LearningLog: logPath,
}
}
// handleStopLearning stops the eslogger trace for a learning session.
func (s *Server) handleStopLearning(req Request) Response {
s.mu.Lock()
defer s.mu.Unlock()
if req.LearningID == "" {
return Response{OK: false, Error: "learning_id is required"}
}
if s.learningID == "" || s.learningID != req.LearningID {
return Response{OK: false, Error: fmt.Sprintf("learning session %q not found", req.LearningID)}
}
if s.esloggerCmd != nil && s.esloggerCmd.Process != nil {
// Send SIGINT to eslogger for graceful shutdown (flushes buffers)
_ = s.esloggerCmd.Process.Signal(syscall.SIGINT)
// Reuse the wait channel from startup (cmd.Wait already called there)
if s.esloggerDone != nil {
select {
case <-s.esloggerDone:
// Exited cleanly
case <-time.After(5 * time.Second):
// Force kill after timeout
_ = s.esloggerCmd.Process.Kill()
<-s.esloggerDone
}
}
}
Logf("Learning session stopped: id=%s", s.learningID)
s.esloggerCmd = nil
s.esloggerDone = nil
s.learningID = ""
// Don't remove the log file — the CLI needs to read it
s.esloggerLogPath = ""
return Response{OK: true}
}
// handleStatus returns the current daemon status including whether it is running
// and how many sessions are active.
func (s *Server) handleStatus() Response {

View File

@@ -15,11 +15,10 @@ import (
)
const (
tunIP = "198.18.0.1"
dnsRelayIP = "127.0.0.2"
dnsRelayPort = "15353" // high port; pf rdr rewrites port 53 → this port
defaultDNSTarget = "127.0.0.1:42053" // proxy's DNS resolver (UDP), used when dnsAddr is not configured
pfAnchorName = "co.greyhaven.greywall"
tunIP = "198.18.0.1"
dnsRelayIP = "127.0.0.2"
dnsRelayPort = "15353" // high port to avoid conflicts with system DNS (mDNSResponder, Docker/Lima)
pfAnchorName = "co.greyhaven.greywall"
// tun2socksStopGracePeriod is the time to wait for tun2socks to exit
// after SIGTERM before sending SIGKILL.
@@ -159,15 +158,19 @@ func (t *TunManager) LoadPFRules(sandboxGroup string) error {
return fmt.Errorf("failed to ensure pf anchor: %w", err)
}
// Build pf anchor rules for the sandbox group:
// 1. Route all non-loopback TCP through the utun → tun2socks → SOCKS proxy.
// Loopback (127.0.0.0/8) is excluded so that ALL_PROXY=socks5h://
// connections to the local proxy don't get double-proxied.
// 2. (DNS is handled via ALL_PROXY=socks5h:// env var, not via pf,
// because macOS getaddrinfo uses mDNSResponder via Mach IPC and
// blocking those services doesn't cause a UDP DNS fallback.)
// Build the anchor rules. pf requires strict ordering:
// translation (rdr) before filtering (pass).
// Note: macOS pf does not support "group" in rdr rules, so DNS
// redirection uses a two-step approach:
// 1. rdr on lo0 — redirects DNS arriving on loopback to our relay
// 2. pass out route-to lo0 — sends sandbox group's DNS to loopback
// 3. pass out route-to utun — sends sandbox group's TCP through tunnel
rules := fmt.Sprintf(
"pass out route-to (%s %s) proto tcp from any to !127.0.0.0/8 group %s\n",
"rdr on lo0 proto udp from any to any port 53 -> %s port %s\n"+
"pass out on !lo0 route-to (lo0 127.0.0.1) proto udp from any to any port 53 group %s\n"+
"pass out route-to (%s %s) proto tcp from any to any group %s\n",
dnsRelayIP, dnsRelayPort,
sandboxGroup,
t.tunDevice, tunIP, sandboxGroup,
)

View File

@@ -10,13 +10,6 @@ import (
"strings"
)
// TraceResult holds parsed read and write paths from a system trace log
// (strace on Linux, eslogger on macOS).
type TraceResult struct {
WritePaths []string
ReadPaths []string
}
// wellKnownParents are directories under $HOME where applications typically
// create their own subdirectory (e.g., ~/.cache/opencode, ~/.config/opencode).
var wellKnownParents = []string{
@@ -59,9 +52,14 @@ func SanitizeTemplateName(name string) string {
return sanitized
}
// GenerateLearnedTemplate takes a parsed trace result, collapses paths, and saves a template.
// GenerateLearnedTemplate parses an strace log, collapses paths, and saves a template.
// Returns the path where the template was saved.
func GenerateLearnedTemplate(result *TraceResult, cmdName string, debug bool) (string, error) {
func GenerateLearnedTemplate(straceLogPath, cmdName string, debug bool) (string, error) {
result, err := ParseStraceLog(straceLogPath, debug)
if err != nil {
return "", fmt.Errorf("failed to parse strace log: %w", err)
}
home, _ := os.UserHomeDir()
// Filter write paths: remove default writable and sensitive paths
@@ -233,9 +231,8 @@ func CollapsePaths(paths []string) []string {
}
}
// Sort, remove exact duplicates, then remove sub-paths of other paths
// Sort and deduplicate (remove sub-paths of other paths)
sort.Strings(result)
result = removeDuplicates(result)
result = deduplicateSubPaths(result)
return result
@@ -367,20 +364,6 @@ func ListLearnedTemplates() ([]LearnedTemplateInfo, error) {
return templates, nil
}
// removeDuplicates removes exact duplicate strings from a sorted slice.
func removeDuplicates(paths []string) []string {
if len(paths) <= 1 {
return paths
}
result := []string{paths[0]}
for i := 1; i < len(paths); i++ {
if paths[i] != paths[i-1] {
result = append(result, paths[i])
}
}
return result
}
// deduplicateSubPaths removes paths that are sub-paths of other paths in the list.
// Assumes the input is sorted.
func deduplicateSubPaths(paths []string) []string {

View File

@@ -1,459 +0,0 @@
//go:build darwin
package sandbox
import (
"bufio"
"encoding/json"
"fmt"
"os"
"strings"
"gitea.app.monadical.io/monadical/greywall/internal/daemon"
)
// opClass classifies a filesystem operation.
type opClass int
const (
opSkip opClass = iota
opRead
opWrite
)
// fwriteFlag is the macOS FWRITE flag value (O_WRONLY or O_RDWR includes this).
const fwriteFlag = 0x0002
// eslogger JSON types — mirrors the real Endpoint Security framework output.
// eslogger emits one JSON object per line to stdout.
//
// Key structural details from real eslogger output:
// - event_type is an integer (e.g., 10=open, 11=fork, 13=create, 32=unlink, 33=write, 41=truncate)
// - Event data is nested under event.{event_name} (e.g., event.open, event.fork)
// - write/unlink/truncate use "target" not "file"
// - create uses destination.existing_file
// - fork child has full process info including audit_token
// esloggerEvent is the top-level event from eslogger.
type esloggerEvent struct {
EventType int `json:"event_type"`
Process esloggerProcess `json:"process"`
Event map[string]json.RawMessage `json:"event"`
}
type esloggerProcess struct {
AuditToken esloggerAuditToken `json:"audit_token"`
Executable esloggerExec `json:"executable"`
PPID int `json:"ppid"`
}
type esloggerAuditToken struct {
PID int `json:"pid"`
}
type esloggerExec struct {
Path string `json:"path"`
PathTruncated bool `json:"path_truncated"`
}
// Event-specific types.
type esloggerOpenEvent struct {
File esloggerFile `json:"file"`
Fflag int `json:"fflag"`
}
type esloggerTargetEvent struct {
Target esloggerFile `json:"target"`
}
type esloggerCreateEvent struct {
DestinationType int `json:"destination_type"`
Destination esloggerCreateDest `json:"destination"`
}
type esloggerCreateDest struct {
ExistingFile *esloggerFile `json:"existing_file,omitempty"`
NewPath *esloggerNewPath `json:"new_path,omitempty"`
}
type esloggerNewPath struct {
Dir esloggerFile `json:"dir"`
Filename string `json:"filename"`
}
type esloggerRenameEvent struct {
Source esloggerFile `json:"source"`
Destination esloggerFile `json:"destination_new_path"` // TODO: verify actual field name
}
type esloggerForkEvent struct {
Child esloggerForkChild `json:"child"`
}
type esloggerForkChild struct {
AuditToken esloggerAuditToken `json:"audit_token"`
Executable esloggerExec `json:"executable"`
PPID int `json:"ppid"`
}
type esloggerLinkEvent struct {
Source esloggerFile `json:"source"`
TargetDir esloggerFile `json:"target_dir"`
}
type esloggerFile struct {
Path string `json:"path"`
PathTruncated bool `json:"path_truncated"`
}
// CheckLearningAvailable verifies that eslogger exists and the daemon is running.
func CheckLearningAvailable() error {
if _, err := os.Stat("/usr/bin/eslogger"); err != nil {
return fmt.Errorf("eslogger not found at /usr/bin/eslogger (requires macOS 13+): %w", err)
}
client := daemon.NewClient(daemon.DefaultSocketPath, false)
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")
}
return nil
}
// eventName extracts the event name string from the event map.
// eslogger nests event data under event.{name}, e.g., event.open, event.fork.
func eventName(ev *esloggerEvent) string {
for key := range ev.Event {
return key
}
return ""
}
// ParseEsloggerLog reads an eslogger JSON log, builds the process tree from
// fork events starting at rootPID, then filters filesystem events by the PID set.
// Uses a two-pass approach: pass 1 scans fork events to build the PID tree,
// pass 2 filters filesystem events by the PID set.
func ParseEsloggerLog(logPath string, rootPID int, debug bool) (*TraceResult, error) {
home, _ := os.UserHomeDir()
seenWrite := make(map[string]bool)
seenRead := make(map[string]bool)
result := &TraceResult{}
// Pass 1: Build the PID set from fork events.
pidSet := map[int]bool{rootPID: true}
forkEvents, err := scanForkEvents(logPath)
if err != nil {
return nil, err
}
// BFS: expand PID set using fork parent→child relationships.
// We may need multiple rounds since a child can itself fork.
changed := true
for changed {
changed = false
for _, fe := range forkEvents {
if pidSet[fe.parentPID] && !pidSet[fe.childPID] {
pidSet[fe.childPID] = true
changed = true
}
}
}
if debug {
fmt.Fprintf(os.Stderr, "[greywall] eslogger PID tree from root %d: %d PIDs\n", rootPID, len(pidSet))
}
// Pass 2: Scan filesystem events, filter by PID set.
f, err := os.Open(logPath) //nolint:gosec // daemon-controlled temp file path
if err != nil {
return nil, fmt.Errorf("failed to open eslogger log: %w", err)
}
defer func() { _ = f.Close() }()
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 0, 256*1024), 4*1024*1024)
lineCount := 0
matchedLines := 0
writeCount := 0
readCount := 0
for scanner.Scan() {
line := scanner.Bytes()
lineCount++
var ev esloggerEvent
if err := json.Unmarshal(line, &ev); err != nil {
continue
}
name := eventName(&ev)
// Skip fork events (already processed in pass 1)
if name == "fork" {
continue
}
// Filter by PID set
pid := ev.Process.AuditToken.PID
if !pidSet[pid] {
continue
}
matchedLines++
// Extract path and classify operation
paths, class := classifyEsloggerEvent(&ev, name)
if class == opSkip || len(paths) == 0 {
continue
}
for _, path := range paths {
if shouldFilterPathMacOS(path, home) {
continue
}
switch class {
case opWrite:
writeCount++
if !seenWrite[path] {
seenWrite[path] = true
result.WritePaths = append(result.WritePaths, path)
}
case opRead:
readCount++
if !seenRead[path] {
seenRead[path] = true
result.ReadPaths = append(result.ReadPaths, path)
}
}
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("error reading eslogger log: %w", err)
}
if debug {
fmt.Fprintf(os.Stderr, "[greywall] Parsed eslogger log: %d lines, %d matched PIDs, %d writes, %d reads, %d unique write paths, %d unique read paths\n",
lineCount, matchedLines, writeCount, readCount, len(result.WritePaths), len(result.ReadPaths))
}
return result, nil
}
// forkRecord stores a parent→child PID relationship from a fork event.
type forkRecord struct {
parentPID int
childPID int
}
// scanForkEvents reads the log and extracts all fork parent→child PID pairs.
func scanForkEvents(logPath string) ([]forkRecord, error) {
f, err := os.Open(logPath) //nolint:gosec // daemon-controlled temp file path
if err != nil {
return nil, fmt.Errorf("failed to open eslogger log: %w", err)
}
defer func() { _ = f.Close() }()
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 0, 256*1024), 4*1024*1024)
var forks []forkRecord
for scanner.Scan() {
line := scanner.Bytes()
// Quick pre-check to avoid parsing non-fork lines.
// Fork events have "fork" as a key in the event object.
if !strings.Contains(string(line), `"fork"`) {
continue
}
var ev esloggerEvent
if err := json.Unmarshal(line, &ev); err != nil {
continue
}
forkRaw, ok := ev.Event["fork"]
if !ok {
continue
}
var fe esloggerForkEvent
if err := json.Unmarshal(forkRaw, &fe); err != nil {
continue
}
forks = append(forks, forkRecord{
parentPID: ev.Process.AuditToken.PID,
childPID: fe.Child.AuditToken.PID,
})
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("error reading eslogger log for fork events: %w", err)
}
return forks, nil
}
// classifyEsloggerEvent extracts paths and classifies the operation from an eslogger event.
// The event name is the key inside the event map (e.g., "open", "fork", "write").
func classifyEsloggerEvent(ev *esloggerEvent, name string) ([]string, opClass) {
eventRaw, ok := ev.Event[name]
if !ok {
return nil, opSkip
}
switch name {
case "open":
var oe esloggerOpenEvent
if err := json.Unmarshal(eventRaw, &oe); err != nil {
return nil, opSkip
}
path := oe.File.Path
if path == "" || oe.File.PathTruncated {
return nil, opSkip
}
if oe.Fflag&fwriteFlag != 0 {
return []string{path}, opWrite
}
return []string{path}, opRead
case "create":
var ce esloggerCreateEvent
if err := json.Unmarshal(eventRaw, &ce); err != nil {
return nil, opSkip
}
// create events use destination.existing_file or destination.new_path
if ce.Destination.ExistingFile != nil {
path := ce.Destination.ExistingFile.Path
if path != "" && !ce.Destination.ExistingFile.PathTruncated {
return []string{path}, opWrite
}
}
if ce.Destination.NewPath != nil {
dir := ce.Destination.NewPath.Dir.Path
filename := ce.Destination.NewPath.Filename
if dir != "" && filename != "" {
return []string{dir + "/" + filename}, opWrite
}
}
return nil, opSkip
case "write", "unlink", "truncate":
// These events use "target" not "file"
var te esloggerTargetEvent
if err := json.Unmarshal(eventRaw, &te); err != nil {
return nil, opSkip
}
path := te.Target.Path
if path == "" || te.Target.PathTruncated {
return nil, opSkip
}
return []string{path}, opWrite
case "rename":
var re esloggerRenameEvent
if err := json.Unmarshal(eventRaw, &re); err != nil {
return nil, opSkip
}
var paths []string
if re.Source.Path != "" && !re.Source.PathTruncated {
paths = append(paths, re.Source.Path)
}
if re.Destination.Path != "" && !re.Destination.PathTruncated {
paths = append(paths, re.Destination.Path)
}
if len(paths) == 0 {
return nil, opSkip
}
return paths, opWrite
case "link":
var le esloggerLinkEvent
if err := json.Unmarshal(eventRaw, &le); err != nil {
return nil, opSkip
}
var paths []string
if le.Source.Path != "" && !le.Source.PathTruncated {
paths = append(paths, le.Source.Path)
}
if le.TargetDir.Path != "" && !le.TargetDir.PathTruncated {
paths = append(paths, le.TargetDir.Path)
}
if len(paths) == 0 {
return nil, opSkip
}
return paths, opWrite
default:
return nil, opSkip
}
}
// shouldFilterPathMacOS returns true if a path should be excluded from macOS learning results.
func shouldFilterPathMacOS(path, home string) bool {
if path == "" || !strings.HasPrefix(path, "/") {
return true
}
// macOS system path prefixes to filter
systemPrefixes := []string{
"/dev/",
"/private/var/run/",
"/private/var/db/",
"/private/var/folders/",
"/System/",
"/Library/",
"/usr/lib/",
"/usr/share/",
"/private/etc/",
"/tmp/",
"/private/tmp/",
}
for _, prefix := range systemPrefixes {
if strings.HasPrefix(path, prefix) {
return true
}
}
// Filter .dylib files (macOS shared libraries)
if strings.HasSuffix(path, ".dylib") {
return true
}
// Filter greywall infrastructure files
if strings.Contains(path, "greywall-") {
return true
}
// Filter paths outside home directory
if home != "" && !strings.HasPrefix(path, home+"/") {
return true
}
// Filter exact home directory match
if path == home {
return true
}
// Filter shell infrastructure directories (PATH lookups, plugin dirs)
if home != "" {
shellInfraPrefixes := []string{
home + "/.antigen/",
home + "/.oh-my-zsh/",
home + "/.pyenv/shims/",
home + "/.bun/bin/",
home + "/.local/bin/",
}
for _, prefix := range shellInfraPrefixes {
if strings.HasPrefix(path, prefix) {
return true
}
}
}
return false
}

View File

@@ -1,557 +0,0 @@
//go:build darwin
package sandbox
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
// makeEsloggerLine builds a single JSON line matching real eslogger output format.
// event_type is an int, and event data is nested under event.{eventName}.
func makeEsloggerLine(eventName string, eventTypeInt int, pid int, eventData interface{}) string {
eventJSON, _ := json.Marshal(eventData)
ev := map[string]interface{}{
"event_type": eventTypeInt,
"process": map[string]interface{}{
"audit_token": map[string]interface{}{
"pid": pid,
},
"executable": map[string]interface{}{
"path": "/usr/bin/test",
"path_truncated": false,
},
"ppid": 1,
},
"event": map[string]json.RawMessage{
eventName: json.RawMessage(eventJSON),
},
}
data, _ := json.Marshal(ev)
return string(data)
}
func TestClassifyEsloggerEvent(t *testing.T) {
tests := []struct {
name string
eventName string
eventData interface{}
expectPaths []string
expectClass opClass
}{
{
name: "open read-only",
eventName: "open",
eventData: map[string]interface{}{
"file": map[string]interface{}{"path": "/Users/test/file.txt", "path_truncated": false},
"fflag": 0x0001, // FREAD only
},
expectPaths: []string{"/Users/test/file.txt"},
expectClass: opRead,
},
{
name: "open with write flag",
eventName: "open",
eventData: map[string]interface{}{
"file": map[string]interface{}{"path": "/Users/test/file.txt", "path_truncated": false},
"fflag": 0x0003, // FREAD | FWRITE
},
expectPaths: []string{"/Users/test/file.txt"},
expectClass: opWrite,
},
{
name: "create event with existing_file",
eventName: "create",
eventData: map[string]interface{}{
"destination_type": 0,
"destination": map[string]interface{}{
"existing_file": map[string]interface{}{"path": "/Users/test/new.txt", "path_truncated": false},
},
},
expectPaths: []string{"/Users/test/new.txt"},
expectClass: opWrite,
},
{
name: "write event uses target",
eventName: "write",
eventData: map[string]interface{}{
"target": map[string]interface{}{"path": "/Users/test/data.db", "path_truncated": false},
},
expectPaths: []string{"/Users/test/data.db"},
expectClass: opWrite,
},
{
name: "unlink event uses target",
eventName: "unlink",
eventData: map[string]interface{}{
"target": map[string]interface{}{"path": "/Users/test/old.txt", "path_truncated": false},
},
expectPaths: []string{"/Users/test/old.txt"},
expectClass: opWrite,
},
{
name: "truncate event uses target",
eventName: "truncate",
eventData: map[string]interface{}{
"target": map[string]interface{}{"path": "/Users/test/trunc.log", "path_truncated": false},
},
expectPaths: []string{"/Users/test/trunc.log"},
expectClass: opWrite,
},
{
name: "rename event with source and destination",
eventName: "rename",
eventData: map[string]interface{}{
"source": map[string]interface{}{"path": "/Users/test/old.txt", "path_truncated": false},
"destination_new_path": map[string]interface{}{"path": "/Users/test/new.txt", "path_truncated": false},
},
expectPaths: []string{"/Users/test/old.txt", "/Users/test/new.txt"},
expectClass: opWrite,
},
{
name: "truncated path is skipped",
eventName: "open",
eventData: map[string]interface{}{
"file": map[string]interface{}{"path": "/Users/test/very/long/path", "path_truncated": true},
"fflag": 0x0001,
},
expectPaths: nil,
expectClass: opSkip,
},
{
name: "empty path is skipped",
eventName: "write",
eventData: map[string]interface{}{
"target": map[string]interface{}{"path": "", "path_truncated": false},
},
expectPaths: nil,
expectClass: opSkip,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
eventJSON, _ := json.Marshal(tt.eventData)
ev := &esloggerEvent{
EventType: 0,
Event: map[string]json.RawMessage{
tt.eventName: json.RawMessage(eventJSON),
},
}
paths, class := classifyEsloggerEvent(ev, tt.eventName)
if class != tt.expectClass {
t.Errorf("class = %d, want %d", class, tt.expectClass)
}
if tt.expectPaths == nil {
if len(paths) != 0 {
t.Errorf("paths = %v, want nil", paths)
}
} else {
if len(paths) != len(tt.expectPaths) {
t.Errorf("paths = %v, want %v", paths, tt.expectPaths)
} else {
for i, p := range paths {
if p != tt.expectPaths[i] {
t.Errorf("paths[%d] = %q, want %q", i, p, tt.expectPaths[i])
}
}
}
}
})
}
}
func TestParseEsloggerLog(t *testing.T) {
home, _ := os.UserHomeDir()
// Root PID is 100; it forks child PID 101, which forks grandchild 102.
// PID 200 is an unrelated process.
lines := []string{
// Fork: root (100) -> child (101)
makeEsloggerLine("fork", 11, 100, map[string]interface{}{
"child": map[string]interface{}{
"audit_token": map[string]interface{}{"pid": 101},
"executable": map[string]interface{}{"path": "/usr/bin/child", "path_truncated": false},
"ppid": 100,
},
}),
// Fork: child (101) -> grandchild (102)
makeEsloggerLine("fork", 11, 101, map[string]interface{}{
"child": map[string]interface{}{
"audit_token": map[string]interface{}{"pid": 102},
"executable": map[string]interface{}{"path": "/usr/bin/grandchild", "path_truncated": false},
"ppid": 101,
},
}),
// Write by root process (should be included) — write uses "target"
makeEsloggerLine("write", 33, 100, map[string]interface{}{
"target": map[string]interface{}{"path": filepath.Join(home, ".cache/testapp/db.sqlite"), "path_truncated": false},
}),
// Create by child (should be included) — create uses destination.existing_file
makeEsloggerLine("create", 13, 101, map[string]interface{}{
"destination_type": 0,
"destination": map[string]interface{}{
"existing_file": map[string]interface{}{"path": filepath.Join(home, ".config/testapp/conf.json"), "path_truncated": false},
},
}),
// Open (read-only) by grandchild (should be included as read)
makeEsloggerLine("open", 10, 102, map[string]interface{}{
"file": map[string]interface{}{"path": filepath.Join(home, ".config/testapp/extra.json"), "path_truncated": false},
"fflag": 0x0001,
}),
// Open (write) by grandchild (should be included as write)
makeEsloggerLine("open", 10, 102, map[string]interface{}{
"file": map[string]interface{}{"path": filepath.Join(home, ".cache/testapp/version"), "path_truncated": false},
"fflag": 0x0003,
}),
// Write by unrelated PID 200 (should NOT be included)
makeEsloggerLine("write", 33, 200, map[string]interface{}{
"target": map[string]interface{}{"path": filepath.Join(home, ".cache/otherapp/data"), "path_truncated": false},
}),
// System path write by root PID (should be filtered)
makeEsloggerLine("write", 33, 100, map[string]interface{}{
"target": map[string]interface{}{"path": "/dev/null", "path_truncated": false},
}),
// Unlink by child (should be included) — unlink uses "target"
makeEsloggerLine("unlink", 32, 101, map[string]interface{}{
"target": map[string]interface{}{"path": filepath.Join(home, ".cache/testapp/old.tmp"), "path_truncated": false},
}),
}
logContent := strings.Join(lines, "\n")
logFile := filepath.Join(t.TempDir(), "eslogger.log")
if err := os.WriteFile(logFile, []byte(logContent), 0o600); err != nil {
t.Fatal(err)
}
result, err := ParseEsloggerLog(logFile, 100, false)
if err != nil {
t.Fatalf("ParseEsloggerLog() error: %v", err)
}
// Check write paths
expectedWrites := map[string]bool{
filepath.Join(home, ".cache/testapp/db.sqlite"): false,
filepath.Join(home, ".config/testapp/conf.json"): false,
filepath.Join(home, ".cache/testapp/version"): false,
filepath.Join(home, ".cache/testapp/old.tmp"): false,
}
for _, p := range result.WritePaths {
if _, ok := expectedWrites[p]; ok {
expectedWrites[p] = true
}
}
for p, found := range expectedWrites {
if !found {
t.Errorf("WritePaths missing expected: %q, got: %v", p, result.WritePaths)
}
}
// Check that unrelated PID 200 paths were not included
for _, p := range result.WritePaths {
if strings.Contains(p, "otherapp") {
t.Errorf("WritePaths should not contain otherapp path: %q", p)
}
}
// Check read paths
expectedReads := map[string]bool{
filepath.Join(home, ".config/testapp/extra.json"): false,
}
for _, p := range result.ReadPaths {
if _, ok := expectedReads[p]; ok {
expectedReads[p] = true
}
}
for p, found := range expectedReads {
if !found {
t.Errorf("ReadPaths missing expected: %q, got: %v", p, result.ReadPaths)
}
}
}
func TestParseEsloggerLogForkChaining(t *testing.T) {
home, _ := os.UserHomeDir()
// Test deep fork chains: 100 -> 101 -> 102 -> 103
lines := []string{
makeEsloggerLine("fork", 11, 100, map[string]interface{}{
"child": map[string]interface{}{
"audit_token": map[string]interface{}{"pid": 101},
"executable": map[string]interface{}{"path": "/bin/sh", "path_truncated": false},
"ppid": 100,
},
}),
makeEsloggerLine("fork", 11, 101, map[string]interface{}{
"child": map[string]interface{}{
"audit_token": map[string]interface{}{"pid": 102},
"executable": map[string]interface{}{"path": "/usr/bin/node", "path_truncated": false},
"ppid": 101,
},
}),
makeEsloggerLine("fork", 11, 102, map[string]interface{}{
"child": map[string]interface{}{
"audit_token": map[string]interface{}{"pid": 103},
"executable": map[string]interface{}{"path": "/usr/bin/ruby", "path_truncated": false},
"ppid": 102,
},
}),
// Write from the deepest child
makeEsloggerLine("write", 33, 103, map[string]interface{}{
"target": map[string]interface{}{"path": filepath.Join(home, ".cache/app/deep.log"), "path_truncated": false},
}),
}
logContent := strings.Join(lines, "\n")
logFile := filepath.Join(t.TempDir(), "eslogger.log")
if err := os.WriteFile(logFile, []byte(logContent), 0o600); err != nil {
t.Fatal(err)
}
result, err := ParseEsloggerLog(logFile, 100, false)
if err != nil {
t.Fatalf("ParseEsloggerLog() error: %v", err)
}
// The deep child's write should be included
found := false
for _, p := range result.WritePaths {
if strings.Contains(p, "deep.log") {
found = true
break
}
}
if !found {
t.Errorf("WritePaths should include deep child write, got: %v", result.WritePaths)
}
}
func TestShouldFilterPathMacOS(t *testing.T) {
home := "/Users/testuser"
tests := []struct {
path string
expected bool
}{
{"/dev/null", true},
{"/private/var/run/syslog", true},
{"/private/var/db/something", true},
{"/private/var/folders/xx/yy", true},
{"/System/Library/Frameworks/foo", true},
{"/Library/Preferences/com.apple.foo", true},
{"/usr/lib/libSystem.B.dylib", true},
{"/usr/share/zoneinfo/UTC", true},
{"/private/etc/hosts", true},
{"/tmp/somefile", true},
{"/private/tmp/somefile", true},
{"/usr/local/lib/libfoo.dylib", true}, // .dylib
{"/other/user/file", true}, // outside home
{"/Users/testuser", true}, // exact home match
{"", true}, // empty
{"relative/path", true}, // relative
{"/Users/testuser/.cache/app/db", false},
{"/Users/testuser/project/main.go", false},
{"/Users/testuser/.config/app/conf.json", false},
{"/tmp/greywall-eslogger-abc.log", true}, // greywall infrastructure
{"/Users/testuser/.antigen/bundles/rupa/z/zig", true}, // shell infra
{"/Users/testuser/.oh-my-zsh/plugins/git/git.plugin.zsh", true}, // shell infra
{"/Users/testuser/.pyenv/shims/ruby", true}, // shell infra
{"/Users/testuser/.bun/bin/node", true}, // shell infra
{"/Users/testuser/.local/bin/rg", true}, // shell infra
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
got := shouldFilterPathMacOS(tt.path, home)
if got != tt.expected {
t.Errorf("shouldFilterPathMacOS(%q, %q) = %v, want %v", tt.path, home, got, tt.expected)
}
})
}
}
func TestCheckLearningAvailable(t *testing.T) {
err := CheckLearningAvailable()
if err != nil {
t.Logf("learning not available (expected when daemon not running): %v", err)
}
}
func TestParseEsloggerLogEmpty(t *testing.T) {
logFile := filepath.Join(t.TempDir(), "empty.log")
if err := os.WriteFile(logFile, []byte(""), 0o600); err != nil {
t.Fatal(err)
}
result, err := ParseEsloggerLog(logFile, 100, false)
if err != nil {
t.Fatalf("ParseEsloggerLog() error: %v", err)
}
if len(result.WritePaths) != 0 {
t.Errorf("expected 0 write paths, got %d", len(result.WritePaths))
}
if len(result.ReadPaths) != 0 {
t.Errorf("expected 0 read paths, got %d", len(result.ReadPaths))
}
}
func TestParseEsloggerLogMalformedJSON(t *testing.T) {
lines := []string{
"not valid json at all",
"{partial json",
makeEsloggerLine("write", 33, 100, map[string]interface{}{
"target": map[string]interface{}{"path": "/Users/test/.cache/app/good.txt", "path_truncated": false},
}),
}
logContent := strings.Join(lines, "\n")
logFile := filepath.Join(t.TempDir(), "malformed.log")
if err := os.WriteFile(logFile, []byte(logContent), 0o600); err != nil {
t.Fatal(err)
}
// Should not error — malformed lines are skipped
result, err := ParseEsloggerLog(logFile, 100, false)
if err != nil {
t.Fatalf("ParseEsloggerLog() error: %v", err)
}
_ = result
}
func TestScanForkEvents(t *testing.T) {
lines := []string{
makeEsloggerLine("fork", 11, 100, map[string]interface{}{
"child": map[string]interface{}{
"audit_token": map[string]interface{}{"pid": 101},
"executable": map[string]interface{}{"path": "/bin/sh", "path_truncated": false},
"ppid": 100,
},
}),
makeEsloggerLine("write", 33, 100, map[string]interface{}{
"target": map[string]interface{}{"path": "/Users/test/file.txt", "path_truncated": false},
}),
makeEsloggerLine("fork", 11, 101, map[string]interface{}{
"child": map[string]interface{}{
"audit_token": map[string]interface{}{"pid": 102},
"executable": map[string]interface{}{"path": "/usr/bin/node", "path_truncated": false},
"ppid": 101,
},
}),
}
logContent := strings.Join(lines, "\n")
logFile := filepath.Join(t.TempDir(), "forks.log")
if err := os.WriteFile(logFile, []byte(logContent), 0o600); err != nil {
t.Fatal(err)
}
forks, err := scanForkEvents(logFile)
if err != nil {
t.Fatalf("scanForkEvents() error: %v", err)
}
if len(forks) != 2 {
t.Fatalf("expected 2 fork records, got %d", len(forks))
}
expected := []forkRecord{
{parentPID: 100, childPID: 101},
{parentPID: 101, childPID: 102},
}
for i, f := range forks {
if f.parentPID != expected[i].parentPID || f.childPID != expected[i].childPID {
t.Errorf("fork[%d] = {parent:%d, child:%d}, want {parent:%d, child:%d}",
i, f.parentPID, f.childPID, expected[i].parentPID, expected[i].childPID)
}
}
}
func TestFwriteFlag(t *testing.T) {
if fwriteFlag != 0x0002 {
t.Errorf("fwriteFlag = 0x%04x, want 0x0002", fwriteFlag)
}
tests := []struct {
name string
fflag int
isWrite bool
}{
{"FREAD only", 0x0001, false},
{"FWRITE only", 0x0002, true},
{"FREAD|FWRITE", 0x0003, true},
{"FREAD|FWRITE|O_CREAT", 0x0203, true},
{"zero", 0x0000, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.fflag&fwriteFlag != 0
if got != tt.isWrite {
t.Errorf("fflag 0x%04x & FWRITE = %v, want %v", tt.fflag, got, tt.isWrite)
}
})
}
}
func TestParseEsloggerLogLink(t *testing.T) {
home, _ := os.UserHomeDir()
lines := []string{
makeEsloggerLine("link", 42, 100, map[string]interface{}{
"source": map[string]interface{}{"path": filepath.Join(home, ".cache/app/source.txt"), "path_truncated": false},
"target_dir": map[string]interface{}{"path": filepath.Join(home, ".cache/app/links"), "path_truncated": false},
}),
}
logContent := strings.Join(lines, "\n")
logFile := filepath.Join(t.TempDir(), "link.log")
if err := os.WriteFile(logFile, []byte(logContent), 0o600); err != nil {
t.Fatal(err)
}
result, err := ParseEsloggerLog(logFile, 100, false)
if err != nil {
t.Fatalf("ParseEsloggerLog() error: %v", err)
}
expectedWrites := map[string]bool{
filepath.Join(home, ".cache/app/source.txt"): false,
filepath.Join(home, ".cache/app/links"): false,
}
for _, p := range result.WritePaths {
if _, ok := expectedWrites[p]; ok {
expectedWrites[p] = true
}
}
for p, found := range expectedWrites {
if !found {
t.Errorf("WritePaths missing expected: %q, got: %v", p, result.WritePaths)
}
}
}
func TestParseEsloggerLogDebugOutput(t *testing.T) {
home, _ := os.UserHomeDir()
lines := []string{
makeEsloggerLine("write", 33, 100, map[string]interface{}{
"target": map[string]interface{}{"path": filepath.Join(home, ".cache/app/test.txt"), "path_truncated": false},
}),
}
logContent := strings.Join(lines, "\n")
logFile := filepath.Join(t.TempDir(), "debug.log")
if err := os.WriteFile(logFile, []byte(logContent), 0o600); err != nil {
t.Fatal(err)
}
// Just verify debug=true doesn't panic
_, err := ParseEsloggerLog(logFile, 100, true)
if err != nil {
t.Fatalf("ParseEsloggerLog() with debug=true error: %v", err)
}
}

View File

@@ -20,8 +20,14 @@ var straceSyscallRegex = regexp.MustCompile(
// openatWriteFlags matches O_WRONLY, O_RDWR, O_CREAT, O_TRUNC, O_APPEND flags in strace output.
var openatWriteFlags = regexp.MustCompile(`O_(?:WRONLY|RDWR|CREAT|TRUNC|APPEND)`)
// CheckLearningAvailable verifies that strace is installed and accessible.
func CheckLearningAvailable() error {
// StraceResult holds parsed read and write paths from an strace log.
type StraceResult struct {
WritePaths []string
ReadPaths []string
}
// CheckStraceAvailable verifies that strace is installed and accessible.
func CheckStraceAvailable() error {
_, err := exec.LookPath("strace")
if err != nil {
return fmt.Errorf("strace is required for learning mode but not found: %w\n\nInstall it with: sudo apt install strace (Debian/Ubuntu) or sudo pacman -S strace (Arch)", err)
@@ -30,7 +36,7 @@ func CheckLearningAvailable() error {
}
// ParseStraceLog reads an strace output file and extracts unique read and write paths.
func ParseStraceLog(logPath string, debug bool) (*TraceResult, error) {
func ParseStraceLog(logPath string, debug bool) (*StraceResult, error) {
f, err := os.Open(logPath) //nolint:gosec // user-controlled path from temp file - intentional
if err != nil {
return nil, fmt.Errorf("failed to open strace log: %w", err)
@@ -40,7 +46,7 @@ func ParseStraceLog(logPath string, debug bool) (*TraceResult, error) {
home, _ := os.UserHomeDir()
seenWrite := make(map[string]bool)
seenRead := make(map[string]bool)
result := &TraceResult{}
result := &StraceResult{}
scanner := bufio.NewScanner(f)
// Increase buffer for long strace lines

View File

@@ -233,10 +233,10 @@ func TestExtractReadPath(t *testing.T) {
}
}
func TestCheckLearningAvailable(t *testing.T) {
func TestCheckStraceAvailable(t *testing.T) {
// This test just verifies the function doesn't panic.
// The result depends on whether strace is installed on the test system.
err := CheckLearningAvailable()
err := CheckStraceAvailable()
if err != nil {
t.Logf("strace not available (expected in some CI environments): %v", err)
}

View File

@@ -1,10 +1,21 @@
//go:build !linux && !darwin
//go:build !linux
package sandbox
import "fmt"
// CheckLearningAvailable returns an error on unsupported platforms.
func CheckLearningAvailable() error {
return fmt.Errorf("learning mode is only available on Linux (requires strace) and macOS (requires eslogger + daemon)")
// StraceResult holds parsed read and write paths from an strace log.
type StraceResult struct {
WritePaths []string
ReadPaths []string
}
// CheckStraceAvailable returns an error on non-Linux platforms.
func CheckStraceAvailable() error {
return fmt.Errorf("learning mode is only available on Linux (requires strace and bubblewrap)")
}
// ParseStraceLog returns an error on non-Linux platforms.
func ParseStraceLog(logPath string, debug bool) (*StraceResult, error) {
return nil, fmt.Errorf("strace log parsing is only available on Linux")
}

View File

@@ -421,21 +421,22 @@ func TestGenerateLearnedTemplate(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", tmpDir)
// Create a fake strace log
home, _ := os.UserHomeDir()
logContent := strings.Join([]string{
`12345 openat(AT_FDCWD, "` + filepath.Join(home, ".cache/testapp/db.sqlite") + `", O_WRONLY|O_CREAT, 0644) = 3`,
`12345 openat(AT_FDCWD, "` + filepath.Join(home, ".cache/testapp/version") + `", O_WRONLY|O_CREAT, 0644) = 3`,
`12345 mkdirat(AT_FDCWD, "` + filepath.Join(home, ".config/testapp") + `", 0755) = 0`,
`12345 openat(AT_FDCWD, "/tmp/somefile", O_WRONLY|O_CREAT, 0644) = 3`,
`12345 openat(AT_FDCWD, "/proc/self/maps", O_RDONLY) = 3`,
}, "\n")
// Build a TraceResult directly (platform-independent test)
result := &TraceResult{
WritePaths: []string{
filepath.Join(home, ".cache/testapp/db.sqlite"),
filepath.Join(home, ".cache/testapp/version"),
filepath.Join(home, ".config/testapp"),
},
ReadPaths: []string{
filepath.Join(home, ".config/testapp/conf.json"),
},
logFile := filepath.Join(tmpDir, "strace.log")
if err := os.WriteFile(logFile, []byte(logContent), 0o600); err != nil {
t.Fatal(err)
}
templatePath, err := GenerateLearnedTemplate(result, "testapp", false)
templatePath, err := GenerateLearnedTemplate(logFile, "testapp", false)
if err != nil {
t.Fatalf("GenerateLearnedTemplate() error: %v", err)
}

View File

@@ -451,13 +451,7 @@ func GenerateSandboxProfile(params MacOSSandboxParams) string {
(global-name "com.apple.system.logger")
(global-name "com.apple.system.notification_center")
(global-name "com.apple.trustd.agent")
`)
// macOS DNS resolution goes through mDNSResponder via Mach IPC — blocking
// opendirectoryd.libinfo or configd does NOT cause a fallback to direct UDP
// DNS. getaddrinfo() simply fails with EAI_NONAME. So we must allow these
// services in all modes. In daemon mode, DNS for proxy-aware apps (curl, git)
// is handled via ALL_PROXY=socks5h:// env var instead.
profile.WriteString(` (global-name "com.apple.system.opendirectoryd.libinfo")
(global-name "com.apple.system.opendirectoryd.libinfo")
(global-name "com.apple.system.opendirectoryd.membership")
(global-name "com.apple.bsd.dirhelper")
(global-name "com.apple.securityd.xpc")
@@ -562,7 +556,6 @@ func GenerateSandboxProfile(params MacOSSandboxParams) string {
(allow file-ioctl (literal "/dev/urandom"))
(allow file-ioctl (literal "/dev/dtracehelper"))
(allow file-ioctl (literal "/dev/tty"))
(allow file-ioctl (regex #"^/dev/ttys"))
(allow file-ioctl file-read-data file-write-data
(require-all
@@ -571,9 +564,6 @@ func GenerateSandboxProfile(params MacOSSandboxParams) string {
)
)
; Inherited terminal access (TUI apps need read/write on the actual PTY device)
(allow file-read-data file-write-data (regex #"^/dev/ttys"))
`)
// Network rules
@@ -640,13 +630,19 @@ func GenerateSandboxProfile(params MacOSSandboxParams) string {
profile.WriteString(rule + "\n")
}
// PTY allocation support (creating new pseudo-terminals)
// PTY support
if params.AllowPty {
profile.WriteString(`
; Pseudo-terminal allocation (pty) support
; Pseudo-terminal (pty) support
(allow pseudo-tty)
(allow file-ioctl (literal "/dev/ptmx"))
(allow file-read* file-write* (literal "/dev/ptmx"))
(allow file-ioctl
(literal "/dev/ptmx")
(regex #"^/dev/ttys")
)
(allow file-read* file-write*
(literal "/dev/ptmx")
(regex #"^/dev/ttys")
)
`)
}
@@ -739,59 +735,12 @@ func WrapCommandMacOS(cfg *config.Config, command string, exposedPorts []int, da
if daemonMode {
// In daemon mode: run as the real user but with EGID=_greywall via sudo.
// pf routes all TCP from group _greywall through utun → tun2socks → proxy.
// 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.
//
// DNS on macOS goes through mDNSResponder (Mach IPC), which runs outside
// the _greywall group, so pf can't intercept DNS. Instead, we set
// ALL_PROXY=socks5h:// so proxy-aware apps (curl, git, etc.) resolve DNS
// through the SOCKS5 proxy. The "h" suffix means "resolve hostname at proxy".
//
// Set ALL_PROXY and HTTP_PROXY/HTTPS_PROXY with socks5h:// so both
// SOCKS5-aware apps (curl, git) and HTTP-proxy-aware apps (opencode,
// Node.js tools) resolve DNS through the proxy. The "h" suffix means
// "resolve hostname at proxy side". Note: apps that read HTTP_PROXY
// but don't support SOCKS5 protocol (e.g., Bun) may fail to connect.
//
// sudo resets the environment, so we use `env` after sudo to re-inject
// terminal vars (TERM, COLORTERM, etc.) needed for TUI apps.
uid := fmt.Sprintf("#%d", os.Getuid())
sandboxEnvs := GenerateProxyEnvVars("")
// Convert socks5:// → socks5h:// for hostname resolution through proxy.
socks5hURL := strings.Replace(cfg.Network.ProxyURL, "socks5://", "socks5h://", 1)
if socks5hURL != "" {
// ALL_PROXY uses socks5h:// (DNS resolved at proxy side) for
// SOCKS5-aware apps (curl, git).
// HTTP_PROXY/HTTPS_PROXY use the configured HTTP CONNECT proxy
// for apps that only understand HTTP proxies (opencode, Node.js
// tools, etc.). The CONNECT proxy resolves DNS server-side.
httpProxyURL := cfg.Network.HTTPProxyURL
// Inject credentials from the SOCKS5 proxy URL into the HTTP proxy
// URL if the HTTP proxy URL doesn't already have credentials.
if httpProxyURL != "" {
if hu, err := url.Parse(httpProxyURL); err == nil && hu.User == nil {
if su, err := url.Parse(socks5hURL); err == nil && su.User != nil {
hu.User = su.User
httpProxyURL = hu.String()
}
}
}
sandboxEnvs = append(sandboxEnvs,
"ALL_PROXY="+socks5hURL, "all_proxy="+socks5hURL,
)
if httpProxyURL != "" {
sandboxEnvs = append(sandboxEnvs,
"HTTP_PROXY="+httpProxyURL, "http_proxy="+httpProxyURL,
"HTTPS_PROXY="+httpProxyURL, "https_proxy="+httpProxyURL,
)
}
}
termEnvs := getTerminalEnvVars()
parts = append(parts, "sudo", "-u", uid, "-g", daemonSession.SandboxGroup, "env")
parts = append(parts, sandboxEnvs...)
parts = append(parts, termEnvs...)
parts = append(parts, "sandbox-exec", "-p", profile, shellPath, "-c", command)
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)

View File

@@ -30,16 +30,12 @@ type Manager struct {
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)
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
// 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.
@@ -81,28 +77,6 @@ func (m *Manager) Initialize() error {
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.
@@ -213,10 +187,6 @@ func (m *Manager) WrapCommand(command string) (string, error) {
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 {
@@ -250,30 +220,26 @@ func (m *Manager) wrapCommandLearning(command string) (string, error) {
})
}
// GenerateLearnedTemplate generates a config template from the trace log collected during learning.
// Platform-specific implementation in manager_linux.go / manager_darwin.go.
// GenerateLearnedTemplate generates a config template from the strace log collected during learning.
func (m *Manager) GenerateLearnedTemplate(cmdName string) (string, error) {
return m.generateLearnedTemplatePlatform(cmdName)
}
if m.straceLogPath == "" {
return "", fmt.Errorf("no strace log available (was learning mode enabled?)")
}
// 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)
templatePath, err := GenerateLearnedTemplate(m.straceLogPath, cmdName, m.debug)
if err != nil {
return "", err
}
// Clean up strace log since we've processed it
_ = os.Remove(m.straceLogPath)
m.straceLogPath = ""
return templatePath, nil
}
// 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)
@@ -281,11 +247,9 @@ func (m *Manager) Cleanup() {
m.logDebug("Warning: failed to destroy daemon session: %v", err)
}
m.daemonSession = nil
m.daemonClient = nil
}
// Clear daemon client after all daemon interactions
m.daemonClient = nil
if m.reverseBridge != nil {
m.reverseBridge.Cleanup()
}
@@ -302,10 +266,6 @@ func (m *Manager) Cleanup() {
_ = os.Remove(m.straceLogPath)
m.straceLogPath = ""
}
if m.learningLog != "" {
_ = os.Remove(m.learningLog)
m.learningLog = ""
}
m.logDebug("Sandbox manager cleaned up")
}

View File

@@ -1,42 +0,0 @@
//go:build darwin
package sandbox
import (
"fmt"
"os"
)
// generateLearnedTemplatePlatform stops the daemon eslogger session,
// parses the eslogger log with PID-based process tree filtering,
// and generates a template (macOS).
func (m *Manager) generateLearnedTemplatePlatform(cmdName string) (string, error) {
if m.learningLog == "" {
return "", fmt.Errorf("no eslogger log available (was learning mode enabled?)")
}
// Stop daemon learning session
if m.daemonClient != nil && m.learningID != "" {
if err := m.daemonClient.StopLearning(m.learningID); err != nil {
m.logDebug("Warning: failed to stop learning session: %v", err)
}
}
// Parse eslogger log with root PID for process tree tracking
result, err := ParseEsloggerLog(m.learningLog, m.learningRootPID, m.debug)
if err != nil {
return "", fmt.Errorf("failed to parse eslogger log: %w", err)
}
templatePath, err := GenerateLearnedTemplate(result, cmdName, m.debug)
if err != nil {
return "", err
}
// Clean up eslogger log
_ = os.Remove(m.learningLog)
m.learningLog = ""
m.learningID = ""
return templatePath, nil
}

View File

@@ -1,31 +0,0 @@
//go:build linux
package sandbox
import (
"fmt"
"os"
)
// generateLearnedTemplatePlatform parses the strace log and generates a template (Linux).
func (m *Manager) generateLearnedTemplatePlatform(cmdName string) (string, error) {
if m.straceLogPath == "" {
return "", fmt.Errorf("no strace log available (was learning mode enabled?)")
}
result, err := ParseStraceLog(m.straceLogPath, m.debug)
if err != nil {
return "", fmt.Errorf("failed to parse strace log: %w", err)
}
templatePath, err := GenerateLearnedTemplate(result, cmdName, m.debug)
if err != nil {
return "", err
}
// Clean up strace log since we've processed it
_ = os.Remove(m.straceLogPath)
m.straceLogPath = ""
return templatePath, nil
}

View File

@@ -1,10 +0,0 @@
//go:build !linux && !darwin
package sandbox
import "fmt"
// generateLearnedTemplatePlatform returns an error on unsupported platforms.
func (m *Manager) generateLearnedTemplatePlatform(cmdName string) (string, error) {
return "", fmt.Errorf("learning mode is not supported on this platform")
}

View File

@@ -86,31 +86,6 @@ func GenerateProxyEnvVars(proxyURL string) []string {
return envVars
}
// getTerminalEnvVars returns KEY=VALUE entries for terminal-related environment
// variables that are set in the current process. These must be re-injected after
// sudo (which resets the environment) so that TUI apps can detect terminal
// capabilities, size, and color support.
func getTerminalEnvVars() []string {
termVars := []string{
"TERM",
"COLORTERM",
"COLUMNS",
"LINES",
"TERMINFO",
"TERMINFO_DIRS",
"LANG",
"LC_ALL",
"LC_CTYPE",
}
var envs []string
for _, key := range termVars {
if val := os.Getenv(key); val != "" {
envs = append(envs, key+"="+val)
}
}
return envs
}
// EncodeSandboxedCommand encodes a command for sandbox monitoring.
func EncodeSandboxedCommand(command string) string {
if len(command) > 100 {