From 473f1620d57bfa3c5be38b1fdb4dd3a5aa650353 Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Wed, 4 Mar 2026 14:48:01 -0600 Subject: [PATCH] feat: adopt kardianos/service for daemon lifecycle management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace manual signal handling in runDaemon() with kardianos/service for cross-platform service lifecycle (Start/Stop/Run). Add daemon start/stop/restart subcommands using service.Control(), and improve status detection with s.Status() plus socket-check fallback. Custom macOS install logic (dscl, sudoers, pf, plist generation) is unchanged — only the runtime lifecycle is delegated to the library. --- cmd/greywall/daemon.go | 134 ++++++++++++++++++++++++++++--------- go.mod | 1 + go.sum | 2 + internal/daemon/launchd.go | 6 -- internal/daemon/log.go | 13 ++++ internal/daemon/program.go | 81 ++++++++++++++++++++++ 6 files changed, 200 insertions(+), 37 deletions(-) create mode 100644 internal/daemon/log.go create mode 100644 internal/daemon/program.go diff --git a/cmd/greywall/daemon.go b/cmd/greywall/daemon.go index 043ed75..1f24a8f 100644 --- a/cmd/greywall/daemon.go +++ b/cmd/greywall/daemon.go @@ -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. diff --git a/go.mod b/go.mod index 2845944..8e4ad90 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 346b4cb..df7b3b1 100644 --- a/go.sum +++ b/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= diff --git a/internal/daemon/launchd.go b/internal/daemon/launchd.go index 0e7c8f8..316b265 100644 --- a/internal/daemon/launchd.go +++ b/internal/daemon/launchd.go @@ -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...) -} diff --git a/internal/daemon/log.go b/internal/daemon/log.go new file mode 100644 index 0000000..7fa9fdc --- /dev/null +++ b/internal/daemon/log.go @@ -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 +} diff --git a/internal/daemon/program.go b/internal/daemon/program.go new file mode 100644 index 0000000..43aeeed --- /dev/null +++ b/internal/daemon/program.go @@ -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) +}