diff --git a/internal/agentupdate/update.go b/internal/agentupdate/update.go index d0a040d5c..7faeff302 100644 --- a/internal/agentupdate/update.go +++ b/internal/agentupdate/update.go @@ -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 { diff --git a/internal/agentupdate/update_test.go b/internal/agentupdate/update_test.go index 279fc7a34..0d7dafe8d 100644 --- a/internal/agentupdate/update_test.go +++ b/internal/agentupdate/update_test.go @@ -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") + } + }) +}