Files
prx/internal/cli/doctor.go
T

231 lines
6.8 KiB
Go

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