feat: adopt kardianos/service for daemon lifecycle management

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.
This commit is contained in:
2026-03-04 14:48:01 -06:00
parent 58626c64e5
commit 473f1620d5
6 changed files with 200 additions and 37 deletions

View File

@@ -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.

1
go.mod
View File

@@ -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
View File

@@ -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=

View File

@@ -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
View 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
}

View 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)
}