This repository has been archived on 2026-03-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
greywall/cmd/greywall/daemon.go
Mathieu Virbel cfe29d2c0b feat: switch macOS daemon from user-based to group-based pf routing
Sandboxed commands previously ran as `sudo -u _greywall`, breaking user
identity (home dir, SSH keys, git config). Now uses `sudo -u #<uid> -g
_greywall` so the process keeps the real user's identity while pf
matches
on EGID for traffic routing.

Key changes:
- pf rules use `group <GID>` instead of `user _greywall`
- GID resolved dynamically at daemon startup (not hardcoded, since macOS
  system groups like com.apple.access_ssh may claim preferred IDs)
- Sudoers rule installed at /etc/sudoers.d/greywall (validated with
visudo)
- Invoking user added to _greywall group via dscl (not dseditgroup,
which
  clobbers group attributes)
- tun2socks device discovery scans both stdout and stderr (fixes 10s
  timeout caused by STACK message going to stdout)
- Always-on daemon logging for session create/destroy events
2026-02-26 09:56:15 -06:00

230 lines
7.4 KiB
Go

package main
import (
"bufio"
"fmt"
"os"
"os/signal"
"path/filepath"
"runtime"
"strings"
"syscall"
"github.com/spf13/cobra"
"gitea.app.monadical.io/monadical/greywall/internal/daemon"
"gitea.app.monadical.io/monadical/greywall/internal/sandbox"
)
// newDaemonCmd creates the daemon subcommand tree:
//
// greywall daemon
// install - Install the LaunchDaemon (requires root)
// uninstall - Uninstall the LaunchDaemon (requires root)
// run - Run the daemon (called by LaunchDaemon plist)
// status - Show daemon status
func newDaemonCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "daemon",
Short: "Manage the greywall background daemon",
Long: `Manage the greywall LaunchDaemon for transparent network sandboxing on macOS.
The daemon runs as a system service and manages the tun2socks tunnel, DNS relay,
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
greywall daemon status Check daemon status
greywall daemon run Run the daemon (used by LaunchDaemon)`,
}
cmd.AddCommand(
newDaemonInstallCmd(),
newDaemonUninstallCmd(),
newDaemonRunCmd(),
newDaemonStatusCmd(),
)
return cmd
}
// newDaemonInstallCmd creates the "daemon install" subcommand.
func newDaemonInstallCmd() *cobra.Command {
return &cobra.Command{
Use: "install",
Short: "Install the greywall LaunchDaemon (requires root)",
Long: `Install greywall as a macOS LaunchDaemon. This command:
1. Creates a system user (_greywall) for sandboxed process isolation
2. Copies the greywall binary to /usr/local/bin/greywall
3. Extracts and installs the tun2socks binary
4. Installs a LaunchDaemon plist for automatic startup
5. Loads and starts the daemon
Requires root privileges: sudo greywall daemon install`,
RunE: func(cmd *cobra.Command, args []string) error {
exePath, err := os.Executable()
if err != nil {
return fmt.Errorf("failed to determine executable path: %w", err)
}
exePath, err = filepath.EvalSymlinks(exePath)
if err != nil {
return fmt.Errorf("failed to resolve executable path: %w", err)
}
// Extract embedded tun2socks binary to a temp file.
tun2socksPath, err := sandbox.ExtractTun2Socks()
if err != nil {
return fmt.Errorf("failed to extract tun2socks: %w", err)
}
defer os.Remove(tun2socksPath) //nolint:errcheck // temp file cleanup
if err := daemon.Install(exePath, tun2socksPath, debug); err != nil {
return err
}
fmt.Println()
fmt.Println("To check status: greywall daemon status")
fmt.Println("To uninstall: sudo greywall daemon uninstall")
return nil
},
}
}
// newDaemonUninstallCmd creates the "daemon uninstall" subcommand.
func newDaemonUninstallCmd() *cobra.Command {
var force bool
cmd := &cobra.Command{
Use: "uninstall",
Short: "Uninstall the greywall LaunchDaemon (requires root)",
Long: `Uninstall the greywall LaunchDaemon. This command:
1. Stops and unloads the daemon
2. Removes the LaunchDaemon plist
3. Removes installed files
4. Removes the _greywall system user and group
Requires root privileges: sudo greywall daemon uninstall`,
RunE: func(cmd *cobra.Command, args []string) error {
if !force {
fmt.Println("The following will be removed:")
fmt.Printf(" - LaunchDaemon plist: %s\n", daemon.LaunchDaemonPlistPath)
fmt.Printf(" - Binary: %s\n", daemon.InstallBinaryPath)
fmt.Printf(" - Lib directory: %s\n", daemon.InstallLibDir)
fmt.Printf(" - Socket: %s\n", daemon.DefaultSocketPath)
fmt.Printf(" - Sudoers file: %s\n", daemon.SudoersFilePath)
fmt.Printf(" - System user/group: %s\n", daemon.SandboxUserName)
fmt.Println()
fmt.Print("Proceed with uninstall? [y/N] ")
reader := bufio.NewReader(os.Stdin)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
if answer != "y" && answer != "yes" {
fmt.Println("Uninstall cancelled.")
return nil
}
}
if err := daemon.Uninstall(debug); err != nil {
return err
}
fmt.Println()
fmt.Println("The greywall daemon has been uninstalled.")
return nil
},
}
cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation prompt")
return cmd
}
// newDaemonRunCmd creates the "daemon run" subcommand. This is invoked by
// the LaunchDaemon plist and should not normally be called manually.
func newDaemonRunCmd() *cobra.Command {
return &cobra.Command{
Use: "run",
Short: "Run the daemon process (called by LaunchDaemon)",
Hidden: true, // Not intended for direct user invocation.
RunE: runDaemon,
}
}
// newDaemonStatusCmd creates the "daemon status" subcommand.
func newDaemonStatusCmd() *cobra.Command {
return &cobra.Command{
Use: "status",
Short: "Show the daemon status",
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()
fmt.Printf("Greywall daemon status:\n")
fmt.Printf(" Installed: %s\n", boolStatus(installed))
fmt.Printf(" Running: %s\n", boolStatus(running))
fmt.Printf(" Plist: %s\n", daemon.LaunchDaemonPlistPath)
fmt.Printf(" Binary: %s\n", daemon.InstallBinaryPath)
fmt.Printf(" User: %s\n", daemon.SandboxUserName)
fmt.Printf(" Group: %s (pf routing)\n", daemon.SandboxGroupName)
fmt.Printf(" Sudoers: %s\n", daemon.SudoersFilePath)
fmt.Printf(" Socket: %s\n", daemon.DefaultSocketPath)
if !installed {
fmt.Println()
fmt.Println("The daemon is not installed. Run: sudo greywall daemon install")
} else if !running {
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)
}
return nil
},
}
}
// runDaemon is the main entry point for the daemon process. It starts the
// Unix socket server and blocks until a termination signal is received.
// CLI clients connect to the server to request sessions (which create
// utun tunnels, DNS relays, and pf rules on demand).
func runDaemon(cmd *cobra.Command, args []string) error {
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)
}
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)
}
daemon.Logf("Daemon started, listening on %s", daemon.DefaultSocketPath)
// Wait for termination signal.
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
sig := <-sigCh
daemon.Logf("Received signal %s, shutting down", sig)
if err := srv.Stop(); err != nil {
daemon.Logf("Shutdown error: %v", err)
return err
}
daemon.Logf("Daemon stopped")
return nil
}
// boolStatus returns a human-readable string for a boolean status value.
func boolStatus(b bool) string {
if b {
return "yes"
}
return "no"
}