feat(cli): add the prx command-line interface
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"prx/internal/client"
|
||||
"prx/internal/engine"
|
||||
"prx/internal/execx"
|
||||
"prx/internal/proto"
|
||||
"prx/internal/state"
|
||||
)
|
||||
|
||||
var markRE = regexp.MustCompile(`"(?:fwmark|default_mark|mark)"\s*:\s*(0x[0-9a-fA-F]+|\d+)`)
|
||||
|
||||
// nftVersionRE extracts the version from output such as
|
||||
// "nftables v1.0.9 (Sunday, 1 September 2024)".
|
||||
var nftVersionRE = regexp.MustCompile(`v(\d+)\.(\d+)(?:\.(\d+))?`)
|
||||
|
||||
// nftVersionWarning returns a warning when the installed nftables predates the
|
||||
// `destroy` keyword (added in 0.9.4), or an empty string when it is new enough
|
||||
// or the version cannot be determined.
|
||||
func nftVersionWarning(output string) string {
|
||||
m := nftVersionRE.FindStringSubmatch(output)
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
major, _ := strconv.Atoi(m[1])
|
||||
minor, _ := strconv.Atoi(m[2])
|
||||
patch := 0
|
||||
if m[3] != "" {
|
||||
patch, _ = strconv.Atoi(m[3])
|
||||
}
|
||||
if major == 0 && (minor < 9 || (minor == 9 && patch < 4)) {
|
||||
return fmt.Sprintf("nftables %d.%d.%d is too old; prx needs >= 0.9.4 (for the 'destroy' keyword)", major, minor, patch)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// diagnostic pairs a check with whether it was produced locally (and is
|
||||
// therefore authoritative even when prx is disabled).
|
||||
type diagnostic struct {
|
||||
check proto.Check
|
||||
local bool
|
||||
}
|
||||
|
||||
func (a *app) newDoctorCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "doctor",
|
||||
Short: "Diagnose configuration and applied rules",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
cfg, err := a.loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx := cmd.Context()
|
||||
r := execx.OSRunner{}
|
||||
|
||||
// Local checks are always authoritative, even when prx is off.
|
||||
var diags []diagnostic
|
||||
addLocal := func(c proto.Check) { diags = append(diags, diagnostic{check: c, local: true}) }
|
||||
|
||||
for _, bin := range []string{"nft", "ip", "systemctl"} {
|
||||
_, err := r.LookPath(bin)
|
||||
addLocal(proto.Check{Name: "binary:" + bin, OK: err == nil, Fatal: true, Detail: ternary(err == nil, "found", "missing — "+packageHint(bin))})
|
||||
}
|
||||
if _, err := r.LookPath("journalctl"); err != nil {
|
||||
// Not fatal: only 'prx logs' needs it.
|
||||
addLocal(proto.Check{Name: "binary:journalctl", OK: false, Warn: true, Detail: "missing — 'prx logs' unavailable"})
|
||||
}
|
||||
if _, err := r.LookPath("nft"); err == nil {
|
||||
res, verr := r.Run(ctx, nil, "nft", "--version")
|
||||
switch {
|
||||
case verr != nil:
|
||||
addLocal(proto.Check{Name: "nft-version", OK: false, Warn: true, Detail: verr.Error()})
|
||||
default:
|
||||
if w := nftVersionWarning(string(res.Stdout)); w != "" {
|
||||
addLocal(proto.Check{Name: "nft-version", OK: false, Warn: true, Detail: w})
|
||||
}
|
||||
}
|
||||
}
|
||||
addLocal(a.privilegeCheck(ctx, r))
|
||||
addLocal(proto.Check{Name: "config", OK: true, Detail: a.configPath})
|
||||
|
||||
if cfg.Engine.Manage && cfg.Engine.ConfigPath != "" {
|
||||
warnings, err := engine.ValidateConfig(cfg.Engine.ConfigPath)
|
||||
switch {
|
||||
case err != nil:
|
||||
addLocal(proto.Check{Name: "engine-config", OK: false, Warn: true, Detail: err.Error()})
|
||||
default:
|
||||
if len(warnings) == 0 {
|
||||
addLocal(proto.Check{Name: "engine-config", OK: true, Detail: cfg.Engine.ConfigPath})
|
||||
}
|
||||
for _, w := range warnings {
|
||||
addLocal(proto.Check{Name: "engine-config", OK: false, Warn: true, Detail: w})
|
||||
}
|
||||
}
|
||||
if collisions := markCollisions(cfg.Engine.ConfigPath, cfg.Marks.InboundMark); len(collisions) > 0 {
|
||||
addLocal(proto.Check{Name: "mark-collision", OK: false, Fatal: true,
|
||||
Detail: fmt.Sprintf("engine config reuses mark 0x%x, same as prx (%s)", cfg.Marks.InboundMark, collisions[0])})
|
||||
}
|
||||
}
|
||||
|
||||
st, stateErr := state.Load(cfg.State.Path)
|
||||
if stateErr != nil {
|
||||
addLocal(proto.Check{Name: "state", OK: false, Warn: true, Detail: stateErr.Error()})
|
||||
} else {
|
||||
addLocal(proto.Check{Name: "state", OK: true, Detail: fmt.Sprintf("enabled=%v", st.Enabled)})
|
||||
}
|
||||
|
||||
// Deep inspection needs root; degrade gracefully.
|
||||
res, err := client.Dispatch(ctx, a.buildPlan(cfg, proto.ActionInspect))
|
||||
if err != nil {
|
||||
diags = append(diags, diagnostic{check: proto.Check{Name: "inspect", OK: false, Warn: true, Detail: err.Error()}})
|
||||
} else {
|
||||
// A remote check must not repeat a local one (for example
|
||||
// "state"): the local result is authoritative.
|
||||
have := make(map[string]bool, len(diags))
|
||||
for _, d := range diags {
|
||||
have[d.check.Name] = true
|
||||
}
|
||||
for _, c := range res.Checks {
|
||||
if have[c.Name] {
|
||||
continue
|
||||
}
|
||||
have[c.Name] = true
|
||||
diags = append(diags, diagnostic{check: c})
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintln(a.out, "prx doctor")
|
||||
failed := false
|
||||
for _, d := range diags {
|
||||
c := d.check
|
||||
mark := checkMark(c)
|
||||
if c.Fatal && !c.OK && !c.Warn && !d.local && !st.Enabled {
|
||||
// Expected when prx is disabled.
|
||||
mark = "off"
|
||||
}
|
||||
if c.Fatal && !c.OK && !c.Warn && (d.local || st.Enabled) {
|
||||
failed = true
|
||||
}
|
||||
line := fmt.Sprintf(" [%s] %s", mark, c.Name)
|
||||
if c.Detail != "" {
|
||||
line += ": " + c.Detail
|
||||
}
|
||||
fmt.Fprintln(a.out, line)
|
||||
}
|
||||
if failed {
|
||||
return fmt.Errorf("doctor found critical problems")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
// packageHint names the package that provides a required binary, so a missing
|
||||
// dependency is actionable on any distribution.
|
||||
func packageHint(bin string) string {
|
||||
switch bin {
|
||||
case "nft":
|
||||
return "install nftables"
|
||||
case "ip":
|
||||
return "install iproute2"
|
||||
case "systemctl", "journalctl":
|
||||
return "install systemd"
|
||||
default:
|
||||
return "install it"
|
||||
}
|
||||
}
|
||||
|
||||
// privilegeCheck reports how prx will reach root, or what prevents it from
|
||||
// doing so.
|
||||
func (a *app) privilegeCheck(_ context.Context, r execx.Runner) proto.Check {
|
||||
const name = "privilege"
|
||||
if os.Geteuid() == 0 {
|
||||
return proto.Check{Name: name, OK: true, Detail: "running as root (helper runs in-process)"}
|
||||
}
|
||||
helperPath, err := client.FindHelper()
|
||||
if err != nil {
|
||||
return proto.Check{Name: name, OK: false, Fatal: true, Detail: err.Error()}
|
||||
}
|
||||
detail := "helper " + helperPath + ", via "
|
||||
if _, err := r.LookPath("pkexec"); err == nil {
|
||||
return proto.Check{Name: name, OK: true, Detail: detail + "pkexec (graphical prompt)"}
|
||||
}
|
||||
if _, err := r.LookPath("sudo"); err == nil {
|
||||
return proto.Check{Name: name, OK: true, Detail: detail + "sudo (passwordless required; or run 'sudo prx ...')"}
|
||||
}
|
||||
return proto.Check{
|
||||
Name: name,
|
||||
OK: false,
|
||||
Fatal: true,
|
||||
Detail: "neither pkexec nor sudo found; install polkit or sudo, or run prx as root",
|
||||
}
|
||||
}
|
||||
|
||||
func markCollisions(path string, mark uint32) []string {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, m := range markRE.FindAllStringSubmatch(string(raw), -1) {
|
||||
v, err := strconv.ParseUint(m[1], 0, 32)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if uint32(v) == mark {
|
||||
out = append(out, m[0])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ternary(cond bool, a, b string) string {
|
||||
if cond {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
Reference in New Issue
Block a user