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
10 changes: 10 additions & 0 deletions INSTALLATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,16 @@ If you installed via a package manager, use it rather than `routatic-proxy updat
| RPM (Fedora/RHEL) | `sudo dnf upgrade routatic-proxy` |
| Docker | `docker pull ghcr.io/routatic/proxy:latest` |

On macOS when `routatic-proxy autostart` is enabled, `brew upgrade` swaps the
`Cellar` binary but leaves the running daemon in RAM. `launchd` cold-starts the
new binary on next login. To run the new version now:

```bash
routatic-proxy stop
launchctl kickstart -k gui/$(id -u)/com.routatic.proxy
# or: routatic-proxy autostart disable && routatic-proxy autostart enable
```

### Verifying a download

Every release publishes `checksums.txt`. `routatic-proxy update` does not verify checksums itself, so for a manual download compare the hash yourself:
Expand Down
17 changes: 17 additions & 0 deletions internal/daemon/autostart_darwin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,14 @@ func TestEnableDisableAutostart_Darwin(t *testing.T) {
t.Errorf("Plist missing XML-escaped API key value. Content:\n%s", content)
}

// On darwin, when the binary is a Homebrew stable path, the plist must
// not contain a versioned Cellar path (launchd would break on brew upgrade).
if isHomebrewStablePath(resolveExecutablePath(mustExecutable(t))) {
if strings.Contains(content, "Cellar/") {
t.Errorf("Plist must not contain Cellar/ for Homebrew installs, got:\n%s", content)
}
}

// Disable autostart
err = DisableAutostart()
if err != nil {
Expand All @@ -75,3 +83,12 @@ func TestEnableDisableAutostart_Darwin(t *testing.T) {
t.Errorf("Expected plist file to be deleted, but it still exists")
}
}

func mustExecutable(t *testing.T) string {
t.Helper()
p, err := os.Executable()
if err != nil {
t.Fatalf("os.Executable: %v", err)
}
return p
}
73 changes: 73 additions & 0 deletions internal/daemon/daemon_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,79 @@ func TestResolveExecutablePath_CurrentBinary(t *testing.T) {
}
}

func TestResolveExecutablePath_HomebrewStable(t *testing.T) {
if runtime.GOOS != "darwin" {
t.Skip("Homebrew stable path preservation is darwin-only")
}
cases := []struct {
name string
input string
}{
{"opt Homebrew Apple Silicon", "/opt/homebrew/opt/routatic-proxy/bin/routatic-proxy"},
{"bin Homebrew Apple Silicon", "/opt/homebrew/bin/routatic-proxy"},
{"opt Homebrew Intel", "/usr/local/opt/routatic-proxy/bin/routatic-proxy"},
{"bin Homebrew Intel", "/usr/local/bin/routatic-proxy"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := resolveExecutablePath(tc.input)
if got != tc.input {
t.Errorf("resolveExecutablePath(%q) = %q, want preserved %q (darwin Homebrew stable path must not be resolved to Cellar)", tc.input, got, tc.input)
}
if got != "" && !isHomebrewStablePath(got) {
t.Errorf("resolveExecutablePath(%q) = %q no longer looks like a Homebrew stable path", tc.input, got)
}
})
}
}

func TestResolveExecutablePath_NonHomebrewSymlinkResolves(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("EvalSymlinks behavior is Windows-skipped")
}
dir := t.TempDir()
real := filepath.Join(dir, "real-bin")
link := filepath.Join(dir, "link-bin")
if err := os.WriteFile(real, []byte("x"), 0755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(real, link); err != nil {
t.Fatal(err)
}
got := resolveExecutablePath(link)
// Non-brew symlink should still be resolved via EvalSymlinks.
want, _ := filepath.EvalSymlinks(link)
if got != want {
t.Errorf("resolveExecutablePath(%q) = %q, want resolved %q (non-brew symlinks must still resolve)", link, got, want)
}
if isHomebrewStablePath(link) {
t.Errorf("test link %q must not be considered a Homebrew stable path", link)
}
}

func TestIsHomebrewStablePath(t *testing.T) {
cases := []struct {
input string
want bool
}{
{"/opt/homebrew/opt/routatic-proxy/bin/routatic-proxy", true},
{"/opt/homebrew/bin/routatic-proxy", true},
{"/usr/local/opt/routatic-proxy/bin/routatic-proxy", true},
{"/usr/local/bin/routatic-proxy", true},
{"/opt/homebrew/Cellar/routatic-proxy/0.6.4/bin/routatic-proxy", false},
{"/Users/alec/git/proxy/bin/routatic-proxy", false},
{"/tmp/link-bin", false},
{"/home/linuxbrew/.linuxbrew/bin/routatic-proxy", false},
{"", false},
}
for _, tc := range cases {
got := isHomebrewStablePath(tc.input)
if got != tc.want {
t.Errorf("isHomebrewStablePath(%q) = %v, want %v", tc.input, got, tc.want)
}
}
}

func TestIsProcessRunning_CurrentProcess(t *testing.T) {
if !IsProcessRunning(os.Getpid()) {
t.Error("current process should be reported as running")
Expand Down
20 changes: 20 additions & 0 deletions internal/daemon/paths.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"os/exec"
"path/filepath"
"runtime"
"strings"
)

const (
Expand Down Expand Up @@ -99,10 +100,29 @@ func resolveExecutablePath(execPath string) string {
return execPath
}

// Homebrew on macOS: stable symlinks (/opt/homebrew/opt/* and
// /opt/homebrew/bin/*, plus /usr/local equivalents on Intel) must not
// be resolved to Cellar/X.Y.Z, which is deleted on `brew upgrade` and
// would break the launchd plist. Only applies to darwin + brew prefixes;
// all other installs (go install, direct download, Linux) keep EvalSymlinks.
if runtime.GOOS == "darwin" && isHomebrewStablePath(execPath) {
if abs, err := filepath.Abs(execPath); err == nil {
return abs
}
return execPath
}

resolved, err := filepath.EvalSymlinks(execPath)
if err != nil {
slog.Warn("symlink resolution failed, using raw path", "path", execPath, "err", err)
return execPath
}
return resolved
}

func isHomebrewStablePath(p string) bool {
return strings.HasPrefix(p, "/opt/homebrew/opt/") ||
strings.HasPrefix(p, "/opt/homebrew/bin/") ||
strings.HasPrefix(p, "/usr/local/opt/") ||
strings.HasPrefix(p, "/usr/local/bin/")
}
Loading