Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 34 additions & 11 deletions cmd/devproxy/cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
64 changes: 64 additions & 0 deletions internal/service/activation_darwin.go
Original file line number Diff line number Diff line change
@@ -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
}
17 changes: 17 additions & 0 deletions internal/service/activation_other.go
Original file line number Diff line number Diff line change
@@ -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
}
25 changes: 25 additions & 0 deletions internal/service/activation_other_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
19 changes: 17 additions & 2 deletions internal/service/service_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,23 @@ func generatePlist(binaryPath string) string {
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>Sockets</key>
<dict>
<key>HTTPListener</key>
<dict>
<key>SockServiceName</key>
<string>80</string>
<key>SockType</key>
<string>stream</string>
</dict>
<key>HTTPSListener</key>
<dict>
<key>SockServiceName</key>
<string>443</string>
<key>SockType</key>
<string>stream</string>
</dict>
</dict>
<key>StandardOutPath</key>
<string>/var/log/devproxy.log</string>
<key>StandardErrorPath</key>
Expand Down
126 changes: 126 additions & 0 deletions internal/service/service_darwin_test.go
Original file line number Diff line number Diff line change
@@ -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, "<string>run</string>") {
t.Error("plist should contain 'run' argument")
}
})

t.Run("contains Sockets dictionary", func(t *testing.T) {
if !strings.Contains(plist, "<key>Sockets</key>") {
t.Error("plist should contain Sockets key for socket activation")
}
})

t.Run("contains HTTPListener socket", func(t *testing.T) {
if !strings.Contains(plist, "<key>HTTPListener</key>") {
t.Error("plist should contain HTTPListener socket")
}
if !strings.Contains(plist, "<string>80</string>") {
t.Error("plist should configure port 80 for HTTPListener")
}
})

t.Run("contains HTTPSListener socket", func(t *testing.T) {
if !strings.Contains(plist, "<key>HTTPSListener</key>") {
t.Error("plist should contain HTTPSListener socket")
}
if !strings.Contains(plist, "<string>443</string>") {
t.Error("plist should configure port 443 for HTTPSListener")
}
})

t.Run("does not contain KeepAlive", func(t *testing.T) {
if strings.Contains(plist, "<key>KeepAlive</key>") {
t.Error("plist should not contain KeepAlive (using socket activation instead)")
}
})

t.Run("contains RunAtLoad", func(t *testing.T) {
if !strings.Contains(plist, "<key>RunAtLoad</key>") {
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")
}
}