Compare commits
8 Commits
e05b54ec1b
...
mathieu/ma
| Author | SHA1 | Date | |
|---|---|---|---|
| 473f1620d5 | |||
| 58626c64e5 | |||
| f05b4a6b4c | |||
| 0e3dc23639 | |||
| 20ee23c1c3 | |||
| 796c22f736 | |||
| 562f9bb65e | |||
| 9d5d852860 |
@@ -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.
|
||||
|
||||
@@ -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
1
go.mod
@@ -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
2
go.sum
@@ -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=
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
13
internal/daemon/log.go
Normal 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
|
||||
}
|
||||
81
internal/daemon/program.go
Normal file
81
internal/daemon/program.go
Normal 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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
459
internal/sandbox/learning_darwin.go
Normal file
459
internal/sandbox/learning_darwin.go
Normal 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
|
||||
}
|
||||
557
internal/sandbox/learning_darwin_test.go
Normal file
557
internal/sandbox/learning_darwin_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
42
internal/sandbox/manager_darwin.go
Normal file
42
internal/sandbox/manager_darwin.go
Normal 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
|
||||
}
|
||||
31
internal/sandbox/manager_linux.go
Normal file
31
internal/sandbox/manager_linux.go
Normal 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
|
||||
}
|
||||
10
internal/sandbox/manager_stub.go
Normal file
10
internal/sandbox/manager_stub.go
Normal 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")
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user