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")
+ }
+}