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
230 lines
7.4 KiB
Go
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"
|
|
}
|