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:
@@ -4,12 +4,10 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
|
||||||
|
|
||||||
|
"github.com/kardianos/service"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"gitea.app.monadical.io/monadical/greywall/internal/daemon"
|
"gitea.app.monadical.io/monadical/greywall/internal/daemon"
|
||||||
@@ -22,6 +20,9 @@ import (
|
|||||||
// install - Install the LaunchDaemon (requires root)
|
// install - Install the LaunchDaemon (requires root)
|
||||||
// uninstall - Uninstall the LaunchDaemon (requires root)
|
// uninstall - Uninstall the LaunchDaemon (requires root)
|
||||||
// run - Run the daemon (called by LaunchDaemon plist)
|
// 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
|
// status - Show daemon status
|
||||||
func newDaemonCmd() *cobra.Command {
|
func newDaemonCmd() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
@@ -35,6 +36,9 @@ and pf rules that enable transparent proxy routing for sandboxed processes.
|
|||||||
Commands:
|
Commands:
|
||||||
sudo greywall daemon install Install and start the daemon
|
sudo greywall daemon install Install and start the daemon
|
||||||
sudo greywall daemon uninstall Stop and remove 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 status Check daemon status
|
||||||
greywall daemon run Run the daemon (used by LaunchDaemon)`,
|
greywall daemon run Run the daemon (used by LaunchDaemon)`,
|
||||||
}
|
}
|
||||||
@@ -43,6 +47,9 @@ Commands:
|
|||||||
newDaemonInstallCmd(),
|
newDaemonInstallCmd(),
|
||||||
newDaemonUninstallCmd(),
|
newDaemonUninstallCmd(),
|
||||||
newDaemonRunCmd(),
|
newDaemonRunCmd(),
|
||||||
|
newDaemonStartCmd(),
|
||||||
|
newDaemonStopCmd(),
|
||||||
|
newDaemonRestartCmd(),
|
||||||
newDaemonStatusCmd(),
|
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.
|
// newDaemonStatusCmd creates the "daemon status" subcommand.
|
||||||
func newDaemonStatusCmd() *cobra.Command {
|
func newDaemonStatusCmd() *cobra.Command {
|
||||||
return &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.`,
|
Long: `Check whether the greywall daemon is installed and running. Does not require root.`,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
installed := daemon.IsInstalled()
|
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("Greywall daemon status:\n")
|
||||||
fmt.Printf(" Installed: %s\n", boolStatus(installed))
|
fmt.Printf(" Installed: %s\n", boolStatus(installed))
|
||||||
fmt.Printf(" Running: %s\n", boolStatus(running))
|
fmt.Printf(" Running: %s\n", boolStatus(running))
|
||||||
|
fmt.Printf(" Service: %s\n", serviceState)
|
||||||
fmt.Printf(" Plist: %s\n", daemon.LaunchDaemonPlistPath)
|
fmt.Printf(" Plist: %s\n", daemon.LaunchDaemonPlistPath)
|
||||||
fmt.Printf(" Binary: %s\n", daemon.InstallBinaryPath)
|
fmt.Printf(" Binary: %s\n", daemon.InstallBinaryPath)
|
||||||
fmt.Printf(" User: %s\n", daemon.SandboxUserName)
|
fmt.Printf(" User: %s\n", daemon.SandboxUserName)
|
||||||
@@ -178,7 +241,7 @@ func newDaemonStatusCmd() *cobra.Command {
|
|||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Println("The daemon is installed but not running.")
|
fmt.Println("The daemon is installed but not running.")
|
||||||
fmt.Printf("Check logs: cat /var/log/greywall.log\n")
|
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
|
return nil
|
||||||
@@ -186,38 +249,47 @@ func newDaemonStatusCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// runDaemon is the main entry point for the daemon process. It starts the
|
// runDaemon is the main entry point for the daemon process. It uses
|
||||||
// Unix socket server and blocks until a termination signal is received.
|
// kardianos/service to manage the lifecycle, handling signals and
|
||||||
// CLI clients connect to the server to request sessions (which create
|
// calling Start/Stop on the program.
|
||||||
// utun tunnels, DNS relays, and pf rules on demand).
|
|
||||||
func runDaemon(cmd *cobra.Command, args []string) error {
|
func runDaemon(cmd *cobra.Command, args []string) error {
|
||||||
tun2socksPath := filepath.Join(daemon.InstallLibDir, "tun2socks-darwin-"+runtime.GOARCH)
|
p := daemon.NewProgram(daemon.DefaultSocketPath, daemon.DefaultTun2socksPath(), debug)
|
||||||
if _, err := os.Stat(tun2socksPath); err != nil {
|
s, err := service.New(p, daemon.NewServiceConfig())
|
||||||
return fmt.Errorf("tun2socks binary not found at %s (run 'sudo greywall daemon install' first)", tun2socksPath)
|
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)
|
status, err := s.Status()
|
||||||
|
if err != nil {
|
||||||
srv := daemon.NewServer(daemon.DefaultSocketPath, tun2socksPath, debug)
|
// Fall back to socket check.
|
||||||
if err := srv.Start(); err != nil {
|
if daemon.IsRunning() {
|
||||||
return fmt.Errorf("failed to start daemon server: %w", err)
|
return "running"
|
||||||
|
}
|
||||||
|
return "stopped"
|
||||||
}
|
}
|
||||||
|
|
||||||
daemon.Logf("Daemon started, listening on %s", daemon.DefaultSocketPath)
|
switch status {
|
||||||
|
case service.StatusRunning:
|
||||||
// Wait for termination signal.
|
return "running"
|
||||||
sigCh := make(chan os.Signal, 1)
|
case service.StatusStopped:
|
||||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
return "stopped"
|
||||||
sig := <-sigCh
|
default:
|
||||||
daemon.Logf("Received signal %s, shutting down", sig)
|
return "unknown"
|
||||||
|
|
||||||
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.
|
// boolStatus returns a human-readable string for a boolean status value.
|
||||||
|
|||||||
1
go.mod
1
go.mod
@@ -4,6 +4,7 @@ go 1.25
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1
|
github.com/bmatcuk/doublestar/v4 v4.9.1
|
||||||
|
github.com/kardianos/service v1.2.4
|
||||||
github.com/spf13/cobra v1.8.1
|
github.com/spf13/cobra v1.8.1
|
||||||
github.com/tidwall/jsonc v0.3.2
|
github.com/tidwall/jsonc v0.3.2
|
||||||
golang.org/x/sys v0.39.0
|
golang.org/x/sys v0.39.0
|
||||||
|
|||||||
2
go.sum
2
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/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 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
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/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 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
||||||
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
||||||
|
|||||||
@@ -546,9 +546,3 @@ func logDebug(debug bool, format string, args ...interface{}) {
|
|||||||
Logf(format, args...)
|
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
13
internal/daemon/log.go
Normal 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
|
||||||
|
}
|
||||||
81
internal/daemon/program.go
Normal file
81
internal/daemon/program.go
Normal 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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user