231 lines
6.8 KiB
Go
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
|
||
|
|
}
|