Files
prx/internal/client/client.go
T

187 lines
6.1 KiB
Go
Raw Permalink Normal View History

// 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")
}