feat(client): dispatch plans via pkexec with sudo fallback
This commit is contained in:
@@ -0,0 +1,186 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
// Package client sends plans from the unprivileged CLI to the privileged
|
||||||
|
// helper, either in-process (when already root) or through pkexec with a sudo
|
||||||
|
// fallback, so prx works on systems with or without polkit.
|
||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"prx/internal/execx"
|
||||||
|
"prx/internal/helper"
|
||||||
|
"prx/internal/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Dispatch executes plan with the appropriate privilege level.
|
||||||
|
func Dispatch(ctx context.Context, plan proto.Plan) (proto.Result, error) {
|
||||||
|
// Already root: run the privileged logic in-process with no copy overhead.
|
||||||
|
if os.Geteuid() == 0 {
|
||||||
|
return helper.Execute(ctx, execx.OSRunner{}, plan), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := json.Marshal(plan)
|
||||||
|
if err != nil {
|
||||||
|
return proto.Result{}, fmt.Errorf("encode plan: %w", err)
|
||||||
|
}
|
||||||
|
helperPath, err := FindHelper()
|
||||||
|
if err != nil {
|
||||||
|
return proto.Result{}, err
|
||||||
|
}
|
||||||
|
out, err := runPrivileged(ctx, helperPath, raw)
|
||||||
|
if err != nil {
|
||||||
|
return proto.Result{}, err
|
||||||
|
}
|
||||||
|
var res proto.Result
|
||||||
|
if err := json.Unmarshal(out, &res); err != nil {
|
||||||
|
return proto.Result{}, fmt.Errorf("decode helper response: %w", err)
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runPrivileged executes the helper with elevated privileges.
|
||||||
|
//
|
||||||
|
// polkit's pkexec is preferred because it can prompt graphically; sudo is the
|
||||||
|
// fallback so that minimal systems without polkit still work. sudo is invoked
|
||||||
|
// non-interactively because its password prompt would otherwise consume the
|
||||||
|
// JSON plan that is delivered on stdin.
|
||||||
|
func runPrivileged(ctx context.Context, helperPath string, stdin []byte) ([]byte, error) {
|
||||||
|
if _, err := exec.LookPath("pkexec"); err == nil {
|
||||||
|
return runElevated(ctx, "pkexec", helperPath, stdin)
|
||||||
|
}
|
||||||
|
if _, err := exec.LookPath("sudo"); err == nil {
|
||||||
|
return runElevated(ctx, "sudo", helperPath, stdin)
|
||||||
|
}
|
||||||
|
return nil, errors.New("neither pkexec nor sudo is available; install polkit or sudo, or run prx as root")
|
||||||
|
}
|
||||||
|
|
||||||
|
// runElevated runs the helper through tool and returns its stdout.
|
||||||
|
//
|
||||||
|
// The helper always writes a structured JSON Result to stdout, even when the
|
||||||
|
// operation failed (a failed operation is still a valid answer), so a
|
||||||
|
// structured answer on stdout always wins over the process exit status. The
|
||||||
|
// returned error is reserved for transport-level problems: cancellation of the
|
||||||
|
// authorization prompt, a helper that could not start, or a helper that
|
||||||
|
// produced nothing usable.
|
||||||
|
func runElevated(ctx context.Context, tool, helperPath string, stdin []byte) ([]byte, error) {
|
||||||
|
var args []string
|
||||||
|
switch tool {
|
||||||
|
case "sudo":
|
||||||
|
args = []string{"-n", "--", helperPath}
|
||||||
|
default:
|
||||||
|
args = []string{helperPath}
|
||||||
|
}
|
||||||
|
cmd := exec.CommandContext(ctx, tool, args...)
|
||||||
|
cmd.Stdin = bytes.NewReader(stdin)
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
runErr := cmd.Run()
|
||||||
|
out := stdout.Bytes()
|
||||||
|
if hasStructuredResult(out) {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
if runErr == nil {
|
||||||
|
return nil, fmt.Errorf("%s: helper produced no result", tool)
|
||||||
|
}
|
||||||
|
return nil, classifyFailure(tool, runErr, out, stderr.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasStructuredResult reports whether out carries a well-formed JSON Result
|
||||||
|
// from the helper.
|
||||||
|
func hasStructuredResult(out []byte) bool {
|
||||||
|
if len(bytes.TrimSpace(out)) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var probe proto.Result
|
||||||
|
return json.Unmarshal(out, &probe) == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// classifyFailure turns an elevated-run failure into an actionable message.
|
||||||
|
// stdout is included because a helper or wrapper may report the reason there.
|
||||||
|
func classifyFailure(tool string, runErr error, stdout, stderr []byte) error {
|
||||||
|
msg := strings.TrimSpace(string(stderr))
|
||||||
|
out := strings.TrimSpace(string(stdout))
|
||||||
|
var ee *exec.ExitError
|
||||||
|
switch tool {
|
||||||
|
case "pkexec":
|
||||||
|
if errors.As(runErr, &ee) {
|
||||||
|
switch ee.ExitCode() {
|
||||||
|
case 126:
|
||||||
|
return errors.New("authorization was cancelled or not permitted; retry with 'sudo prx ...' if polkit is not set up")
|
||||||
|
case 127:
|
||||||
|
return fmt.Errorf("failed to start prx-helper: %s", firstNonEmptyString(msg, out, runErr.Error()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "sudo":
|
||||||
|
// `sudo -n` never prompts, so a missing credential surfaces as a
|
||||||
|
// message containing one of these phrases.
|
||||||
|
lower := strings.ToLower(msg)
|
||||||
|
if strings.Contains(lower, "a password is required") ||
|
||||||
|
strings.Contains(lower, "a terminal is required") ||
|
||||||
|
strings.Contains(lower, "no tty present") {
|
||||||
|
return errors.New("sudo needs a password; run 'sudo prx ...' directly, or install polkit (pkexec) for a graphical prompt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s failed: %s", tool, firstNonEmptyString(msg, out, runErr.Error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstNonEmptyString returns the first non-blank value.
|
||||||
|
func firstNonEmptyString(values ...string) string {
|
||||||
|
for _, v := range values {
|
||||||
|
if strings.TrimSpace(v) != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// helperCandidates are the locations searched for prx-helper, covering the
|
||||||
|
// common FHS layouts across distributions (Arch/Debian use /usr/lib,
|
||||||
|
// Fedora/RHEL/openSUSE use /usr/libexec) plus local prefixes.
|
||||||
|
func helperCandidates() []string {
|
||||||
|
var paths []string
|
||||||
|
if exe, err := os.Executable(); err == nil {
|
||||||
|
dir := filepath.Dir(exe)
|
||||||
|
paths = append(paths,
|
||||||
|
filepath.Join(dir, "prx-helper"),
|
||||||
|
filepath.Join(dir, "..", "libexec", "prx", "prx-helper"),
|
||||||
|
filepath.Join(dir, "..", "lib", "prx", "prx-helper"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return append(paths,
|
||||||
|
"/usr/libexec/prx/prx-helper",
|
||||||
|
"/usr/lib/prx/prx-helper",
|
||||||
|
"/usr/local/libexec/prx/prx-helper",
|
||||||
|
"/usr/local/lib/prx/prx-helper",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindHelper locates the prx-helper binary, honouring $PRX_HELPER first.
|
||||||
|
func FindHelper() (string, error) {
|
||||||
|
if p := os.Getenv("PRX_HELPER"); p != "" {
|
||||||
|
if info, err := os.Stat(p); err != nil || info.IsDir() {
|
||||||
|
return "", fmt.Errorf("PRX_HELPER points at %q, which is not an executable file", p)
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
for _, candidate := range helperCandidates() {
|
||||||
|
if info, err := os.Stat(candidate); err == nil && !info.IsDir() && info.Mode()&0o111 != 0 {
|
||||||
|
return filepath.Clean(candidate), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p, err := exec.LookPath("prx-helper"); err == nil {
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
return "", errors.New("prx-helper not found (searched $PATH, /usr/lib/prx and /usr/libexec/prx); install prx, set $PRX_HELPER, or re-run with sudo")
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"prx/internal/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// writeFakeTool installs an executable shell script as name in a temporary
|
||||||
|
// directory and prepends that directory to PATH for the duration of the test.
|
||||||
|
func writeFakeTool(t *testing.T, name, script string) {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, name)
|
||||||
|
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
|
||||||
|
t.Fatalf("write fake %s: %v", name, err)
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The helper exits non-zero when an operation fails, but its structured
|
||||||
|
// answer on stdout must still reach the caller — otherwise the real reason
|
||||||
|
// for the failure is replaced by a bare "exit status 1".
|
||||||
|
func TestRunElevatedPreservesStructuredResult(t *testing.T) {
|
||||||
|
writeFakeTool(t, "sudo", `#!/bin/sh
|
||||||
|
echo '{"action":"apply","ok":false,"error":"engine start failed"}'
|
||||||
|
exit 1
|
||||||
|
`)
|
||||||
|
out, err := runElevated(context.Background(), "sudo", "/usr/lib/prx/prx-helper", []byte(`{"action":"apply"}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("structured result should be preferred over the exit status: %v", err)
|
||||||
|
}
|
||||||
|
var res proto.Result
|
||||||
|
if err := json.Unmarshal(out, &res); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if res.OK || res.Error != "engine start failed" {
|
||||||
|
t.Fatalf("result = %+v, want OK=false with the helper's error", res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunElevatedSudoPasswordRequired(t *testing.T) {
|
||||||
|
writeFakeTool(t, "sudo", `#!/bin/sh
|
||||||
|
echo "sudo: a password is required" >&2
|
||||||
|
exit 1
|
||||||
|
`)
|
||||||
|
_, err := runElevated(context.Background(), "sudo", "/usr/lib/prx/prx-helper", []byte("{}"))
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "password") {
|
||||||
|
t.Fatalf("err = %v, want a password hint", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "sudo prx") {
|
||||||
|
t.Fatalf("error should tell the user what to do: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunElevatedHelperProducesNothing(t *testing.T) {
|
||||||
|
writeFakeTool(t, "sudo", "#!/bin/sh\nexit 0\n")
|
||||||
|
_, err := runElevated(context.Background(), "sudo", "/usr/lib/prx/prx-helper", []byte("{}"))
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "no result") {
|
||||||
|
t.Fatalf("err = %v, want 'no result'", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunElevatedNonJSONOutputIsAnError(t *testing.T) {
|
||||||
|
writeFakeTool(t, "sudo", "#!/bin/sh\necho 'boom: not json'\nexit 3\n")
|
||||||
|
_, err := runElevated(context.Background(), "sudo", "/usr/lib/prx/prx-helper", []byte("{}"))
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "boom: not json") {
|
||||||
|
t.Fatalf("err = %v, want the tool's output", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyFailurePKexecCancellation(t *testing.T) {
|
||||||
|
cmd := exec.Command("sh", "-c", "exit 126")
|
||||||
|
err := cmd.Run()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected 'sh -c exit 126' to fail")
|
||||||
|
}
|
||||||
|
got := classifyFailure("pkexec", err, nil, nil)
|
||||||
|
if !strings.Contains(got.Error(), "cancelled") {
|
||||||
|
t.Fatalf("got %q, want a cancellation message", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyFailureUnknownToolError(t *testing.T) {
|
||||||
|
got := classifyFailure("sudo", exec.Command("sh", "-c", "exit 5").Run(), nil, []byte("sudo: unknown user: ahp"))
|
||||||
|
if !strings.Contains(got.Error(), "unknown user") {
|
||||||
|
t.Fatalf("got %q, want the stderr detail", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyFailureIncludesStdout(t *testing.T) {
|
||||||
|
got := classifyFailure("pkexec", exec.Command("sh", "-c", "exit 5").Run(), []byte("helper exploded"), nil)
|
||||||
|
if !strings.Contains(got.Error(), "helper exploded") {
|
||||||
|
t.Fatalf("got %q, want the stdout detail", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasStructuredResult(t *testing.T) {
|
||||||
|
if !hasStructuredResult([]byte(`{"ok":true}`)) {
|
||||||
|
t.Error("valid Result should be detected")
|
||||||
|
}
|
||||||
|
for _, bad := range []string{"", " \n", "not json", "[]", "42"} {
|
||||||
|
if hasStructuredResult([]byte(bad)) {
|
||||||
|
t.Errorf("hasStructuredResult(%q) = true, want false", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindHelperHonoursEnvOverride(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
p := filepath.Join(dir, "prx-helper")
|
||||||
|
if err := os.WriteFile(p, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("PRX_HELPER", p)
|
||||||
|
got, err := FindHelper()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FindHelper: %v", err)
|
||||||
|
}
|
||||||
|
if got != p {
|
||||||
|
t.Fatalf("got %q, want %q", got, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("PRX_HELPER", filepath.Join(dir, "does-not-exist"))
|
||||||
|
if _, err := FindHelper(); err == nil {
|
||||||
|
t.Fatal("expected an error for a broken PRX_HELPER override")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The helper must be discoverable on every common FHS layout, not just the one
|
||||||
|
// used by the machine prx was developed on.
|
||||||
|
func TestHelperCandidatesCoverCommonLayouts(t *testing.T) {
|
||||||
|
joined := strings.Join(helperCandidates(), "\n")
|
||||||
|
for _, want := range []string{
|
||||||
|
"/usr/lib/prx/prx-helper",
|
||||||
|
"/usr/libexec/prx/prx-helper",
|
||||||
|
"/usr/local/lib/prx/prx-helper",
|
||||||
|
"/usr/local/libexec/prx/prx-helper",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(joined, want) {
|
||||||
|
t.Errorf("helperCandidates missing %s", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user