Fall back to argv0 for agent self-update path on FreeBSD (#1457)
Some checks are pending
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Frontend & Backend (push) Waiting to run

Back-port the agentupdate half of v5 fix 8600706da to v6 (the install.sh
FreeBSD rc.d supervisor-pidfile change is already present in substance).
performUpdate now resolves the executable via resolveExecutablePath, which
falls back to an absolute, existing os.Args[0] when os.Executable() fails
or returns empty — the condition that breaks agent self-update on
FreeBSD/OPNsense. Adds an injectable osArgsFn and a unit test.
This commit is contained in:
rcourtman 2026-06-04 10:19:45 +01:00
parent 67a8c4188b
commit b8a60db6fa
2 changed files with 76 additions and 2 deletions

View file

@ -80,6 +80,7 @@ var (
restartProcessFn = restartProcess
execCommandContextFn = exec.CommandContext
osExecutableFn = os.Executable
osArgsFn = func() []string { return os.Args }
evalSymlinksFn = filepath.EvalSymlinks
createTempFn = os.CreateTemp
chmodFn = os.Chmod
@ -628,13 +629,39 @@ func (u *Updater) validatePulseURL() error {
// performUpdate downloads and installs the new agent binary.
func (u *Updater) performUpdate(ctx context.Context) error {
execPath, err := osExecutableFn()
execPath, err := resolveExecutablePath()
if err != nil {
return fmt.Errorf("failed to get executable path: %w", err)
return err
}
return u.performUpdateWithExecPath(ctx, execPath)
}
// resolveExecutablePath returns the running binary's path, falling back to an
// absolute os.Args[0] when os.Executable() fails or returns empty. This happens
// on FreeBSD/OPNsense, where without the fallback the agent cannot self-update
// (#1457).
func resolveExecutablePath() (string, error) {
execPath, err := osExecutableFn()
if err == nil && strings.TrimSpace(execPath) != "" {
return execPath, nil
}
args := osArgsFn()
if len(args) > 0 {
candidate := strings.TrimSpace(args[0])
if filepath.IsAbs(candidate) {
if info, statErr := os.Stat(candidate); statErr == nil && !info.IsDir() {
return candidate, nil
}
}
}
if err == nil {
err = errors.New("empty executable path")
}
return "", fmt.Errorf("failed to get executable path: %w", err)
}
func (u *Updater) performUpdateWithExecPath(ctx context.Context, execPath string) error {
agentName, err := normalizeAgentName(u.cfg.AgentName)
if err != nil {

View file

@ -1,6 +1,7 @@
package agentupdate
import (
"errors"
"os"
"path/filepath"
"runtime"
@ -519,3 +520,49 @@ func TestConstants(t *testing.T) {
t.Errorf("downloadTimeout = %v, want 5m", downloadTimeout)
}
}
func TestResolveExecutablePath(t *testing.T) {
origExec := osExecutableFn
origArgs := osArgsFn
t.Cleanup(func() {
osExecutableFn = origExec
osArgsFn = origArgs
})
t.Run("returns os.Executable when available", func(t *testing.T) {
osExecutableFn = func() (string, error) { return "/usr/local/bin/pulse-agent", nil }
osArgsFn = func() []string { return []string{"argv0"} }
got, err := resolveExecutablePath()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "/usr/local/bin/pulse-agent" {
t.Fatalf("got %q", got)
}
})
t.Run("falls back to absolute argv0 when os.Executable is empty", func(t *testing.T) {
dir := t.TempDir()
bin := filepath.Join(dir, "pulse-agent")
if err := os.WriteFile(bin, []byte("x"), 0o755); err != nil {
t.Fatal(err)
}
osExecutableFn = func() (string, error) { return "", nil }
osArgsFn = func() []string { return []string{bin} }
got, err := resolveExecutablePath()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != bin {
t.Fatalf("got %q, want %q", got, bin)
}
})
t.Run("errors when neither source is usable", func(t *testing.T) {
osExecutableFn = func() (string, error) { return "", errors.New("boom") }
osArgsFn = func() []string { return []string{"relative-arg"} }
if _, err := resolveExecutablePath(); err == nil {
t.Fatal("expected error")
}
})
}