8 Commits

Author SHA1 Message Date
473f1620d5 feat: adopt kardianos/service for daemon lifecycle management
Replace manual signal handling in runDaemon() with kardianos/service
for cross-platform service lifecycle (Start/Stop/Run). Add daemon
start/stop/restart subcommands using service.Control(), and improve
status detection with s.Status() plus socket-check fallback.

Custom macOS install logic (dscl, sudoers, pf, plist generation)
is unchanged — only the runtime lifecycle is delegated to the library.
2026-03-04 14:48:01 -06:00
58626c64e5 feat: add --http-proxy flag for configurable HTTP CONNECT proxy
Add network.httpProxyUrl config field and --http-proxy CLI flag
(default: http://localhost:42051) for apps that only understand
HTTP proxies (opencode, Node.js tools, etc.).

macOS daemon mode now sets:
- ALL_PROXY=socks5h:// for SOCKS5-aware apps (curl, git)
- HTTP_PROXY/HTTPS_PROXY=http:// for HTTP-proxy-aware apps

Credentials from the SOCKS5 proxy URL are automatically injected
into the HTTP proxy URL when not explicitly configured.
2026-03-04 12:47:57 -06:00
f05b4a6b4c fix: include user/password in HTTP_PROXY URL for macOS daemon mode
The HTTP CONNECT proxy URL was missing credentials from the SOCKS5
proxy URL. Now extracts userinfo from the configured proxy URL so
apps authenticating via HTTP_PROXY get the same credentials.
2026-03-04 12:43:10 -06:00
0e3dc23639 fix: set HTTP_PROXY for macOS daemon mode alongside ALL_PROXY
ALL_PROXY=socks5h:// only works for SOCKS5-aware apps (curl, git).
Apps like opencode that only check HTTP_PROXY/HTTPS_PROXY were not
using the proxy at all, causing DNS resolution failures.

Now sets both:
- ALL_PROXY=socks5h://host:42052 (SOCKS5 with proxy-side DNS)
- HTTP_PROXY=http://host:42051 (HTTP CONNECT proxy)

The HTTP CONNECT proxy on port 42051 resolves DNS server-side,
so apps that don't speak SOCKS5 still get proper DNS resolution
through the proxy.
2026-03-04 12:40:27 -06:00
20ee23c1c3 fix: use socks5h:// for macOS daemon DNS resolution through proxy
macOS getaddrinfo() uses mDNSResponder via Mach IPC and does NOT fall
back to direct UDP DNS when those services are blocked — it simply
fails with EAI_NONAME. This made DNS resolution fail for all sandboxed
processes in daemon mode.

Switch to setting ALL_PROXY=socks5h:// env var so proxy-aware apps
(curl, git, etc.) resolve hostnames through the SOCKS5 proxy. The "h"
suffix means "resolve hostname at proxy side". Only ALL_PROXY is set
(not HTTP_PROXY) to avoid breaking apps like Bun/Node.js.

Other changes:
- Revert opendirectoryd.libinfo and configd mach service blocks
- Exclude loopback (127.0.0.0/8) from pf TCP route-to to prevent
  double-proxying when ALL_PROXY connects directly to local proxy
- Always create DNS relay with default upstream (127.0.0.1:42053)
- Use always-on logging in DNS relay (not debug-only)
- Force IPv4 (udp4) for DNS relay upstream connections
- Log tunnel cleanup errors instead of silently discarding them
2026-03-02 12:04:36 -06:00
796c22f736 fix: don't inject SOCKS5 proxy env vars in macOS daemon mode
In daemon mode, tun2socks provides transparent proxying at the IP level
via pf + utun, so apps don't need proxy env vars. Setting HTTP_PROXY and
HTTPS_PROXY to socks5h:// breaks apps like Bun/Node.js that read these
vars but don't support the SOCKS5 protocol (UnsupportedProxyProtocol).
2026-02-26 17:46:21 -06:00
562f9bb65e fix: preserve terminal env vars through sudo in macOS daemon mode
sudo resets the environment, stripping TERM, COLORTERM, COLUMNS, LINES,
and other terminal-related variables that TUI apps need to render. This
caused TUI apps like opencode to show a blank screen in daemon mode.

Fix by injecting terminal and proxy env vars via `env` after `sudo` in
the daemon mode command pipeline. Also move PTY device ioctl/read/write
rules into the base sandbox profile so inherited terminals work without
requiring AllowPty.
2026-02-26 17:39:33 -06:00
9d5d852860 feat: switch macOS learning mode from fs_usage to eslogger
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
2026-02-26 17:23:43 -06:00
25 changed files with 1813 additions and 185 deletions

View File

@@ -4,12 +4,10 @@ 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"
@@ -22,6 +20,9 @@ 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{
@@ -35,6 +36,9 @@ 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)`,
}
@@ -43,6 +47,9 @@ Commands:
newDaemonInstallCmd(),
newDaemonUninstallCmd(),
newDaemonRunCmd(),
newDaemonStartCmd(),
newDaemonStopCmd(),
newDaemonRestartCmd(),
newDaemonStatusCmd(),
)
@@ -151,6 +158,57 @@ 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{
@@ -159,11 +217,16 @@ 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()
running := daemon.IsRunning()
// Try kardianos/service status first for reliable state detection.
serviceState := daemonServiceState()
running := serviceState == "running"
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)
@@ -178,7 +241,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 launchctl load %s\n", daemon.LaunchDaemonPlistPath)
fmt.Printf("Start it: sudo greywall daemon start\n")
}
return nil
@@ -186,38 +249,47 @@ func newDaemonStatusCmd() *cobra.Command {
}
}
// 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).
// 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.
func runDaemon(cmd *cobra.Command, args []string) error {
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)
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"
}
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)
status, err := s.Status()
if err != nil {
// Fall back to socket check.
if daemon.IsRunning() {
return "running"
}
return "stopped"
}
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
switch status {
case service.StatusRunning:
return "running"
case service.StatusStopped:
return "stopped"
default:
return "unknown"
}
daemon.Logf("Daemon stopped")
return nil
}
// boolStatus returns a human-readable string for a boolean status value.

View File

@@ -31,6 +31,7 @@ var (
monitor bool
settingsPath string
proxyURL string
httpProxyURL string
dnsAddr string
cmdString string
exposePorts []string
@@ -99,6 +100,7 @@ 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)")
@@ -228,6 +230,9 @@ 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
}
@@ -240,6 +245,12 @@ 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 {
@@ -267,7 +278,7 @@ func runCommand(cmd *cobra.Command, args []string) error {
// Learning mode setup
if learning {
if err := sandbox.CheckStraceAvailable(); err != nil {
if err := sandbox.CheckLearningAvailable(); err != nil {
return err
}
fmt.Fprintf(os.Stderr, "[greywall] Learning mode: tracing filesystem access for %q\n", cmdName)
@@ -305,6 +316,7 @@ 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()
@@ -328,6 +340,11 @@ 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,6 +4,7 @@ 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,6 +3,8 @@ 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,8 +26,9 @@ type Config struct {
// NetworkConfig defines network restrictions.
type NetworkConfig struct {
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)
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)
AllowUnixSockets []string `json:"allowUnixSockets,omitempty"`
AllowAllUnixSockets bool `json:"allowAllUnixSockets,omitempty"`
AllowLocalBinding bool `json:"allowLocalBinding,omitempty"`
@@ -203,6 +204,11 @@ 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)
@@ -273,6 +279,24 @@ 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
@@ -407,9 +431,10 @@ func Merge(base, override *Config) *Config {
AllowPty: base.AllowPty || override.AllowPty,
Network: NetworkConfig{
// ProxyURL/DnsAddr: override wins if non-empty
ProxyURL: mergeString(base.Network.ProxyURL, override.Network.ProxyURL),
DnsAddr: mergeString(base.Network.DnsAddr, override.Network.DnsAddr),
// 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),
// Append slices (base first, then override additions)
AllowUnixSockets: mergeStrings(base.Network.AllowUnixSockets, override.Network.AllowUnixSockets),

View File

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

View File

@@ -546,9 +546,3 @@ 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...)
}

13
internal/daemon/log.go Normal file
View File

@@ -0,0 +1,13 @@
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

@@ -0,0 +1,81 @@
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,8 +7,11 @@ import (
"fmt"
"net"
"os"
"os/exec"
"os/user"
"strings"
"sync"
"syscall"
"time"
)
@@ -16,10 +19,11 @@ import (
// Request from CLI to daemon.
type Request struct {
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
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
}
// Response from daemon to CLI.
@@ -33,6 +37,9 @@ 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.
@@ -57,6 +64,11 @@ 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.
@@ -133,9 +145,26 @@ 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.
// Tear down all active sessions and learning.
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)
}
@@ -227,6 +256,10 @@ 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:
@@ -262,19 +295,27 @@ 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 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)}
// 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)
}
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 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)
}
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
@@ -285,9 +326,7 @@ func (s *Server) handleCreateSession(req Request) Response {
grp, err := user.LookupGroup(SandboxGroupName)
if err != nil {
_ = tm.Stop()
if dr != nil {
dr.Stop()
}
dr.Stop()
return Response{OK: false, Error: fmt.Sprintf("failed to resolve group %s: %v", SandboxGroupName, err)}
}
sandboxGID = grp.Gid
@@ -295,9 +334,7 @@ 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 {
if dr != nil {
dr.Stop()
}
dr.Stop()
_ = tm.Stop() // best-effort cleanup
return Response{OK: false, Error: fmt.Sprintf("failed to load pf rules: %v", err)}
}
@@ -305,9 +342,7 @@ func (s *Server) handleCreateSession(req Request) Response {
// Step 4: Generate session ID and store.
sessionID, err := generateSessionID()
if err != nil {
if dr != nil {
dr.Stop()
}
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)}
@@ -316,7 +351,7 @@ func (s *Server) handleCreateSession(req Request) Response {
session := &Session{
ID: sessionID,
ProxyURL: req.ProxyURL,
DNSAddr: req.DNSAddr,
DNSAddr: dnsTarget,
CreatedAt: time.Now(),
}
s.sessions[sessionID] = session
@@ -387,6 +422,156 @@ 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,10 +15,11 @@ import (
)
const (
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"
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"
// tun2socksStopGracePeriod is the time to wait for tun2socks to exit
// after SIGTERM before sending SIGKILL.
@@ -158,19 +159,15 @@ func (t *TunManager) LoadPFRules(sandboxGroup string) error {
return fmt.Errorf("failed to ensure pf anchor: %w", err)
}
// 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
// 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.)
rules := fmt.Sprintf(
"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,
"pass out route-to (%s %s) proto tcp from any to !127.0.0.0/8 group %s\n",
t.tunDevice, tunIP, sandboxGroup,
)

View File

@@ -10,6 +10,13 @@ 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{
@@ -52,14 +59,9 @@ func SanitizeTemplateName(name string) string {
return sanitized
}
// GenerateLearnedTemplate parses an strace log, collapses paths, and saves a template.
// GenerateLearnedTemplate takes a parsed trace result, collapses paths, and saves a template.
// Returns the path where the template was saved.
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)
}
func GenerateLearnedTemplate(result *TraceResult, cmdName string, debug bool) (string, error) {
home, _ := os.UserHomeDir()
// Filter write paths: remove default writable and sensitive paths
@@ -231,8 +233,9 @@ func CollapsePaths(paths []string) []string {
}
}
// Sort and deduplicate (remove sub-paths of other paths)
// Sort, remove exact duplicates, then remove sub-paths of other paths
sort.Strings(result)
result = removeDuplicates(result)
result = deduplicateSubPaths(result)
return result
@@ -364,6 +367,20 @@ 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

@@ -0,0 +1,459 @@
//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

@@ -0,0 +1,557 @@
//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,14 +20,8 @@ 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)`)
// 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 {
// CheckLearningAvailable verifies that strace is installed and accessible.
func CheckLearningAvailable() 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)
@@ -36,7 +30,7 @@ func CheckStraceAvailable() error {
}
// ParseStraceLog reads an strace output file and extracts unique read and write paths.
func ParseStraceLog(logPath string, debug bool) (*StraceResult, error) {
func ParseStraceLog(logPath string, debug bool) (*TraceResult, 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)
@@ -46,7 +40,7 @@ func ParseStraceLog(logPath string, debug bool) (*StraceResult, error) {
home, _ := os.UserHomeDir()
seenWrite := make(map[string]bool)
seenRead := make(map[string]bool)
result := &StraceResult{}
result := &TraceResult{}
scanner := bufio.NewScanner(f)
// Increase buffer for long strace lines

View File

@@ -233,10 +233,10 @@ func TestExtractReadPath(t *testing.T) {
}
}
func TestCheckStraceAvailable(t *testing.T) {
func TestCheckLearningAvailable(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 := CheckStraceAvailable()
err := CheckLearningAvailable()
if err != nil {
t.Logf("strace not available (expected in some CI environments): %v", err)
}

View File

@@ -1,21 +1,10 @@
//go:build !linux
//go:build !linux && !darwin
package sandbox
import "fmt"
// 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")
// 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)")
}

View File

@@ -421,22 +421,21 @@ 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")
logFile := filepath.Join(tmpDir, "strace.log")
if err := os.WriteFile(logFile, []byte(logContent), 0o600); err != nil {
t.Fatal(err)
// 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"),
},
}
templatePath, err := GenerateLearnedTemplate(logFile, "testapp", false)
templatePath, err := GenerateLearnedTemplate(result, "testapp", false)
if err != nil {
t.Fatalf("GenerateLearnedTemplate() error: %v", err)
}

View File

@@ -451,7 +451,13 @@ func GenerateSandboxProfile(params MacOSSandboxParams) string {
(global-name "com.apple.system.logger")
(global-name "com.apple.system.notification_center")
(global-name "com.apple.trustd.agent")
(global-name "com.apple.system.opendirectoryd.libinfo")
`)
// 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.membership")
(global-name "com.apple.bsd.dirhelper")
(global-name "com.apple.securityd.xpc")
@@ -556,6 +562,7 @@ 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
@@ -564,6 +571,9 @@ 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
@@ -630,19 +640,13 @@ func GenerateSandboxProfile(params MacOSSandboxParams) string {
profile.WriteString(rule + "\n")
}
// PTY support
// PTY allocation support (creating new pseudo-terminals)
if params.AllowPty {
profile.WriteString(`
; Pseudo-terminal (pty) support
; Pseudo-terminal allocation (pty) support
(allow pseudo-tty)
(allow file-ioctl
(literal "/dev/ptmx")
(regex #"^/dev/ttys")
)
(allow file-read* file-write*
(literal "/dev/ptmx")
(regex #"^/dev/ttys")
)
(allow file-ioctl (literal "/dev/ptmx"))
(allow file-read* file-write* (literal "/dev/ptmx"))
`)
}
@@ -735,12 +739,59 @@ 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 traffic from group _greywall through utun → tun2socks → proxy.
// pf routes all TCP 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())
parts = append(parts, "sudo", "-u", uid, "-g", daemonSession.SandboxGroup,
"sandbox-exec", "-p", profile, shellPath, "-c", command)
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)
} else {
// Non-daemon mode: use proxy env vars for best-effort proxying.
proxyEnvs := GenerateProxyEnvVars(cfg.Network.ProxyURL)

View File

@@ -30,12 +30,16 @@ type Manager struct {
debug bool
monitor bool
initialized bool
learning bool // learning mode: permissive sandbox with strace
straceLogPath string // host-side temp file for strace output
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.
@@ -77,6 +81,28 @@ 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.
@@ -187,6 +213,10 @@ 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 {
@@ -220,26 +250,30 @@ func (m *Manager) wrapCommandLearning(command string) (string, error) {
})
}
// GenerateLearnedTemplate generates a config template from the strace log collected during learning.
// 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) {
if m.straceLogPath == "" {
return "", fmt.Errorf("no strace log available (was learning mode enabled?)")
}
return m.generateLearnedTemplatePlatform(cmdName)
}
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
// 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)
@@ -247,9 +281,11 @@ 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()
}
@@ -266,6 +302,10 @@ 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

@@ -0,0 +1,42 @@
//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

@@ -0,0 +1,31 @@
//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

@@ -0,0 +1,10 @@
//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,6 +86,31 @@ 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 {