diff --git a/CHANGELOG.md b/CHANGELOG.md index d8b7aac..4db2ce4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed + +- macOS launchd service now uses socket activation for ports 80/443 + - Eliminates port conflicts and "address already in use" errors at boot + - launchd owns the sockets, passing them to devproxy on startup + - Service restarts on-demand when connections arrive (removed KeepAlive) + - **Upgrade note:** Existing installations must reinstall the service: + ```bash + sudo devproxy setup --service + ``` + ## 0.2.1 2026-01-19 ### Changed diff --git a/cmd/devproxy/cmd/run.go b/cmd/devproxy/cmd/run.go index e8a864c..218dead 100644 --- a/cmd/devproxy/cmd/run.go +++ b/cmd/devproxy/cmd/run.go @@ -21,6 +21,7 @@ import ( "github.com/munichmade/devproxy/internal/paths" "github.com/munichmade/devproxy/internal/privilege" "github.com/munichmade/devproxy/internal/proxy" + "github.com/munichmade/devproxy/internal/service" ) // chownRecursive changes ownership of a directory and all its contents @@ -72,22 +73,43 @@ func runDaemon() error { } // ========================================================================= - // PRIVILEGED SECTION - Bind to privileged ports while still root + // PRIVILEGED SECTION - Try socket activation, fall back to direct binding // ========================================================================= httpCfg, _ := cfg.GetEntrypoint("http") httpsCfg, _ := cfg.GetEntrypoint("https") - // Bind HTTP port (requires root for port 80) - httpListener, err := net.Listen("tcp", httpCfg.Listen) + // Try launchd socket activation first (macOS service) + // When running under launchd, sockets are pre-bound by the system + httpListener, err := service.ActivatedListener("HTTPListener") if err != nil { - return fmt.Errorf("failed to bind HTTP port %s: %w", httpCfg.Listen, err) + logging.Debug("launchd HTTP socket activation failed", "error", err) } - - // Bind HTTPS port (requires root for port 443) - httpsListener, err := net.Listen("tcp", httpsCfg.Listen) + httpsListener, err := service.ActivatedListener("HTTPSListener") if err != nil { - httpListener.Close() - return fmt.Errorf("failed to bind HTTPS port %s: %w", httpsCfg.Listen, err) + logging.Debug("launchd HTTPS socket activation failed", "error", err) + } + + // Track if we successfully used socket activation + usedSocketActivation := httpListener != nil && httpsListener != nil + + if usedSocketActivation { + fmt.Fprintf(os.Stderr, "using launchd socket activation for HTTP/HTTPS\n") + } + + // Fall back to direct binding if socket activation not available + if httpListener == nil { + httpListener, err = net.Listen("tcp", httpCfg.Listen) + if err != nil { + return fmt.Errorf("failed to bind HTTP port %s: %w", httpCfg.Listen, err) + } + } + + if httpsListener == nil { + httpsListener, err = net.Listen("tcp", httpsCfg.Listen) + if err != nil { + httpListener.Close() + return fmt.Errorf("failed to bind HTTPS port %s: %w", httpsCfg.Listen, err) + } } // Bind DNS port if enabled @@ -124,9 +146,10 @@ func runDaemon() error { } // ========================================================================= - // DROP PRIVILEGES - No longer need root after binding ports + // DROP PRIVILEGES - Only needed when NOT using socket activation + // When using launchd socket activation, we're already running unprivileged // ========================================================================= - if originalUser != nil { + if !usedSocketActivation && originalUser != nil { // Before dropping privileges, ensure data directories are owned by the original user // This is needed because directories may have been created by root dataDir := paths.DataDir() diff --git a/go.mod b/go.mod index 3a46b70..21c5c26 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( require ( github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/bored-engineer/go-launchd v0.0.0-20241220214420-b514778f4f80 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect diff --git a/go.sum b/go.sum index b2689ef..b3743c0 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEK github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/bored-engineer/go-launchd v0.0.0-20241220214420-b514778f4f80 h1:O7c0CDkfGaIa7eNktOIBoPpuaq0zazOqB3iTghXIySw= +github.com/bored-engineer/go-launchd v0.0.0-20241220214420-b514778f4f80/go.mod h1:fuBezjGxBjQFkJeCIN1eTo9c27q2WxjHK7tRlfBkyQk= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= diff --git a/internal/service/activation_darwin.go b/internal/service/activation_darwin.go new file mode 100644 index 0000000..0046b3b --- /dev/null +++ b/internal/service/activation_darwin.go @@ -0,0 +1,64 @@ +//go:build darwin + +package service + +import ( + "net" + "sync" + "syscall" + + launchd "github.com/bored-engineer/go-launchd" +) + +var ( + activatedListeners = make(map[string]net.Listener) + activationMu sync.Mutex + activationChecked = make(map[string]bool) +) + +// ActivatedListener returns a listener from launchd socket activation. +// Returns nil, nil if not running under launchd or socket not available. +// Results are cached since launchd only allows one activation per socket name. +func ActivatedListener(name string) (net.Listener, error) { + activationMu.Lock() + defer activationMu.Unlock() + + // Return cached listener if already activated + if l, ok := activatedListeners[name]; ok { + return l, nil + } + + // If we already checked this socket and it wasn't available, don't retry + if activationChecked[name] { + return nil, nil + } + activationChecked[name] = true + + // Try to activate from launchd + listener, err := launchd.Activate(name) + if err != nil { + // ESRCH = not running under launchd + // ENOENT = socket name not found in plist + // These are expected when not running as a launchd service + if err == syscall.ESRCH || err == syscall.ENOENT { + return nil, nil + } + // EALREADY = already activated (shouldn't happen due to caching, but handle it) + if err == syscall.EALREADY { + return nil, nil + } + // Unexpected error + return nil, err + } + + activatedListeners[name] = listener + return listener, nil +} + +// IsSocketActivated returns true if the process was started via launchd +// socket activation and has activated listeners available. +func IsSocketActivated() bool { + activationMu.Lock() + defer activationMu.Unlock() + return len(activatedListeners) > 0 +} diff --git a/internal/service/activation_other.go b/internal/service/activation_other.go new file mode 100644 index 0000000..6ff01e2 --- /dev/null +++ b/internal/service/activation_other.go @@ -0,0 +1,17 @@ +//go:build !darwin + +package service + +import "net" + +// ActivatedListener returns nil on non-Darwin platforms. +// Socket activation via launchd is only supported on macOS. +// Linux systemd socket activation can be added in the future. +func ActivatedListener(name string) (net.Listener, error) { + return nil, nil +} + +// IsSocketActivated returns false on non-Darwin platforms. +func IsSocketActivated() bool { + return false +} diff --git a/internal/service/activation_other_test.go b/internal/service/activation_other_test.go new file mode 100644 index 0000000..faac48f --- /dev/null +++ b/internal/service/activation_other_test.go @@ -0,0 +1,25 @@ +//go:build !darwin + +package service + +import ( + "testing" +) + +func TestActivatedListener_NonDarwin(t *testing.T) { + // On non-Darwin platforms, ActivatedListener should always return nil + listener, err := ActivatedListener("HTTPListener") + if err != nil { + t.Errorf("ActivatedListener should not return error on non-Darwin: %v", err) + } + if listener != nil { + listener.Close() + t.Error("ActivatedListener should return nil on non-Darwin platforms") + } +} + +func TestIsSocketActivated_NonDarwin(t *testing.T) { + if IsSocketActivated() { + t.Error("IsSocketActivated should always return false on non-Darwin platforms") + } +} diff --git a/internal/service/service_darwin.go b/internal/service/service_darwin.go index 48b8c49..ab2f3c0 100644 --- a/internal/service/service_darwin.go +++ b/internal/service/service_darwin.go @@ -86,8 +86,23 @@ func generatePlist(binaryPath string) string { RunAtLoad - KeepAlive - + Sockets + + HTTPListener + + SockServiceName + 80 + SockType + stream + + HTTPSListener + + SockServiceName + 443 + SockType + stream + + StandardOutPath /var/log/devproxy.log StandardErrorPath diff --git a/internal/service/service_darwin_test.go b/internal/service/service_darwin_test.go new file mode 100644 index 0000000..2061c5e --- /dev/null +++ b/internal/service/service_darwin_test.go @@ -0,0 +1,126 @@ +//go:build darwin + +package service + +import ( + "net" + "strings" + "testing" +) + +func TestGeneratePlist(t *testing.T) { + binaryPath := "/usr/local/bin/devproxy" + plist := generatePlist(binaryPath) + + t.Run("contains binary path", func(t *testing.T) { + if !strings.Contains(plist, binaryPath) { + t.Errorf("plist should contain binary path %s", binaryPath) + } + }) + + t.Run("contains service label", func(t *testing.T) { + if !strings.Contains(plist, "com.devproxy.daemon") { + t.Error("plist should contain service label com.devproxy.daemon") + } + }) + + t.Run("contains run argument", func(t *testing.T) { + if !strings.Contains(plist, "run") { + t.Error("plist should contain 'run' argument") + } + }) + + t.Run("contains Sockets dictionary", func(t *testing.T) { + if !strings.Contains(plist, "Sockets") { + t.Error("plist should contain Sockets key for socket activation") + } + }) + + t.Run("contains HTTPListener socket", func(t *testing.T) { + if !strings.Contains(plist, "HTTPListener") { + t.Error("plist should contain HTTPListener socket") + } + if !strings.Contains(plist, "80") { + t.Error("plist should configure port 80 for HTTPListener") + } + }) + + t.Run("contains HTTPSListener socket", func(t *testing.T) { + if !strings.Contains(plist, "HTTPSListener") { + t.Error("plist should contain HTTPSListener socket") + } + if !strings.Contains(plist, "443") { + t.Error("plist should configure port 443 for HTTPSListener") + } + }) + + t.Run("does not contain KeepAlive", func(t *testing.T) { + if strings.Contains(plist, "KeepAlive") { + t.Error("plist should not contain KeepAlive (using socket activation instead)") + } + }) + + t.Run("contains RunAtLoad", func(t *testing.T) { + if !strings.Contains(plist, "RunAtLoad") { + t.Error("plist should contain RunAtLoad") + } + }) + + t.Run("contains log paths", func(t *testing.T) { + if !strings.Contains(plist, "/var/log/devproxy.log") { + t.Error("plist should configure log path") + } + }) +} + +func TestActivatedListener_NotUnderLaunchd(t *testing.T) { + // When not running under launchd, ActivatedListener should return nil + // without error (graceful fallback) + listener, err := ActivatedListener("HTTPListener") + if err != nil { + t.Errorf("ActivatedListener should not return error when not under launchd: %v", err) + } + if listener != nil { + listener.Close() + t.Error("ActivatedListener should return nil when not running under launchd") + } +} + +func TestActivatedListener_Caching(t *testing.T) { + // Reset state for test + activationMu.Lock() + activatedListeners = make(map[string]net.Listener) + activationChecked = make(map[string]bool) + activationMu.Unlock() + + // First call should check launchd + listener1, _ := ActivatedListener("TestSocket") + + // Second call should use cached result (nil in this case since not under launchd) + listener2, _ := ActivatedListener("TestSocket") + + // Both should be nil (not under launchd) + if listener1 != nil || listener2 != nil { + t.Error("expected nil listeners when not under launchd") + } + + // Verify cache was used + activationMu.Lock() + checked := activationChecked["TestSocket"] + activationMu.Unlock() + + if !checked { + t.Error("expected socket to be marked as checked after first call") + } +} + +func TestIsSocketActivated(t *testing.T) { + // Reset state + activationMu.Lock() + activatedListeners = make(map[string]net.Listener) + activationMu.Unlock() + + if IsSocketActivated() { + t.Error("IsSocketActivated should return false when no listeners are activated") + } +}