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
This commit is contained in:
229
cmd/greywall/daemon.go
Normal file
229
cmd/greywall/daemon.go
Normal file
@@ -0,0 +1,229 @@
|
||||
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"
|
||||
}
|
||||
Reference in New Issue
Block a user