feat(cli): add the prx command-line interface

This commit is contained in:
ahp
2026-10-15 14:29:19 +03:30
parent 1e9b0e9aac
commit 940c47030f
7 changed files with 1073 additions and 0 deletions
+137
View File
@@ -0,0 +1,137 @@
// SPDX-License-Identifier: MIT
package cli
import (
"context"
"fmt"
"io"
"github.com/spf13/cobra"
"prx/internal/config"
"prx/internal/proto"
"prx/internal/state"
)
func (a *app) newBypassCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "bypass",
Short: "Manage destinations that must bypass the proxy",
}
cmd.AddCommand(a.newBypassAddCmd(), a.newBypassRmCmd(), a.newBypassLsCmd())
return cmd
}
func (a *app) newBypassAddCmd() *cobra.Command {
return &cobra.Command{
Use: "add <cidr|ip|domain>",
Short: "Add a CIDR/IP (routed direct) or domain (engine hint) to the bypass list",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := a.loadConfig()
if err != nil {
return err
}
arg := args[0]
var changed bool
if config.IsCIDR(arg) {
changed, err = cfg.AddExtraCIDR(arg)
} else {
changed, err = cfg.AddDomain(arg)
}
if err != nil {
return err
}
if !changed {
fmt.Fprintln(a.out, "already present")
return nil
}
if err := config.Save(a.configPath, cfg); err != nil {
return err
}
fmt.Fprintf(a.out, "added %s to bypass list\n", arg)
return a.reapplyIfEnabled(cmd.Context(), cfg)
},
}
}
func (a *app) newBypassRmCmd() *cobra.Command {
return &cobra.Command{
Use: "rm <cidr|ip|domain>",
Short: "Remove an entry from the bypass list",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := a.loadConfig()
if err != nil {
return err
}
arg := args[0]
var changed bool
if config.IsCIDR(arg) {
changed, err = cfg.RemoveExtraCIDR(arg)
} else {
changed = cfg.RemoveDomain(arg)
}
if err != nil {
return err
}
if !changed {
fmt.Fprintln(a.out, "not present")
return nil
}
if err := config.Save(a.configPath, cfg); err != nil {
return err
}
fmt.Fprintf(a.out, "removed %s from bypass list\n", arg)
return a.reapplyIfEnabled(cmd.Context(), cfg)
},
}
}
func (a *app) newBypassLsCmd() *cobra.Command {
return &cobra.Command{
Use: "ls",
Short: "List bypass entries",
Args: cobra.NoArgs,
RunE: func(_ *cobra.Command, _ []string) error {
cfg, err := a.loadConfig()
if err != nil {
return err
}
writeList(a.out, "always (engine hints):", cfg.Bypass.AlwaysCIDRs, false)
writeList(a.out, "extra CIDRs (routed direct):", cfg.Bypass.ExtraCIDRs, true)
writeList(a.out, "domains (engine hints):", cfg.Bypass.Domains, true)
return nil
},
}
}
// writeList prints a labelled list, showing "(none)" when empty and showEmpty.
func writeList(w io.Writer, label string, items []string, showEmpty bool) {
fmt.Fprintln(w, label)
if len(items) == 0 && showEmpty {
fmt.Fprintln(w, " (none)")
}
for _, item := range items {
fmt.Fprintln(w, " "+item)
}
}
// reapplyIfEnabled refreshes the routing rules when prx is currently enabled so
// bypass changes take effect immediately.
func (a *app) reapplyIfEnabled(ctx context.Context, cfg *config.Config) error {
st, err := state.Load(cfg.State.Path)
if err != nil {
return err
}
if !st.Enabled {
fmt.Fprintln(a.out, "takes effect on the next 'prx on'")
return nil
}
if err := a.dispatch(ctx, cfg, proto.ActionApply, nil); err != nil {
return err
}
fmt.Fprintln(a.out, "routing rules refreshed")
return nil
}
+156
View File
@@ -0,0 +1,156 @@
// SPDX-License-Identifier: MIT
package cli
import (
"bytes"
"context"
"errors"
"os"
"strings"
"testing"
"prx/internal/config"
"prx/internal/execx"
"prx/internal/proto"
)
func TestCheckMark(t *testing.T) {
cases := []struct {
check proto.Check
want string
}{
{proto.Check{OK: true}, "ok"},
{proto.Check{OK: false, Warn: true}, "warn"},
{proto.Check{OK: false}, "FAIL"},
{proto.Check{OK: false, Fatal: true}, "FAIL"},
}
for _, tc := range cases {
if got := checkMark(tc.check); got != tc.want {
t.Errorf("checkMark(%+v) = %q, want %q", tc.check, got, tc.want)
}
}
}
func TestBuildPlanMapsConfig(t *testing.T) {
cfg := config.Default()
cfg.Network.WANInterface = "wlan0"
cfg.Network.WANGateway = "192.168.1.1"
cfg.Network.WANGateway6 = "2001:db8::1"
cfg.Health.Probe = "9.9.9.9:443"
cfg.Bypass.ExtraCIDRs = []string{"203.0.113.0/24"}
a := &app{}
plan := a.buildPlan(cfg, proto.ActionApply)
if plan.Action != proto.ActionApply {
t.Errorf("action = %q", plan.Action)
}
if plan.WANInterface != "wlan0" || plan.WANGateway4 != "192.168.1.1" || plan.WANGateway6 != "2001:db8::1" {
t.Errorf("network fields not mapped: %+v", plan)
}
if plan.HealthProbe != "9.9.9.9:443" {
t.Errorf("health probe not mapped: %q", plan.HealthProbe)
}
if len(plan.BypassCIDRs) != 1 || plan.BypassCIDRs[0] != "203.0.113.0/24" {
t.Errorf("bypass CIDRs not mapped: %v", plan.BypassCIDRs)
}
if plan.StatePath != config.DefaultStatePath {
t.Errorf("state path not mapped: %q", plan.StatePath)
}
}
func TestPrintResultFormatsChecks(t *testing.T) {
buf := &bytes.Buffer{}
a := &app{out: buf}
a.printResult(proto.Result{
Messages: []string{"done"},
Checks: []proto.Check{
{Name: "nft", OK: true, Detail: "present"},
{Name: "tun", OK: false, Warn: true},
},
})
out := buf.String()
for _, needle := range []string{" done", "[ok] nft: present", "[warn] tun"} {
if !strings.Contains(out, needle) {
t.Errorf("output missing %q:\n%s", needle, out)
}
}
}
func TestFormatResultError(t *testing.T) {
if err := formatResultError(proto.Result{Error: "boom"}); err == nil || err.Error() != "boom" {
t.Fatalf("formatResultError = %v", err)
}
if err := formatResultError(proto.Result{}); err == nil {
t.Fatal("expected a generic error when Result.Error is empty")
}
}
func TestNftVersionWarning(t *testing.T) {
cases := []struct {
in string
warn bool
}{
{"nftables v0.9.3 (Lester Gomes)\n", true},
{"nftables v0.9.4 (Fearless Fred)\n", false},
{"nftables v0.9.0 (Topsy)\n", true},
{"nftables v1.0.9 (Sunday, 1 September 2024)\n", false},
{"nftables v2.0.1\n", false},
{"unparseable output", false},
}
for _, tc := range cases {
got := nftVersionWarning(tc.in)
if tc.warn && got == "" {
t.Errorf("nftVersionWarning(%q) = \"\", want a warning", tc.in)
}
if !tc.warn && got != "" {
t.Errorf("nftVersionWarning(%q) = %q, want no warning", tc.in, got)
}
}
}
func TestIsPermissionError(t *testing.T) {
if !isPermissionError(errors.New("nft list: exit status 1: Error: Operation not permitted")) {
t.Error("expected a permission error to be recognised")
}
if isPermissionError(errors.New("exec: nft: executable file not found in $PATH")) {
t.Error("a missing binary must not be reported as a permission error")
}
}
// privilegeCheck must always return a named check and, for a non-root caller,
// never panic even when nothing is installed.
func TestPrivilegeCheckAlwaysNamed(t *testing.T) {
a := &app{}
c := a.privilegeCheck(context.Background(), &execx.Fake{})
if c.Name != "privilege" {
t.Fatalf("check name = %q, want %q", c.Name, "privilege")
}
if os.Geteuid() == 0 && !c.OK {
t.Fatalf("root should pass the privilege check: %+v", c)
}
}
func TestPackageHint(t *testing.T) {
for bin, want := range map[string]string{
"nft": "install nftables",
"ip": "install iproute2",
"systemctl": "install systemd",
"journalctl": "install systemd",
} {
if got := packageHint(bin); got != want {
t.Errorf("packageHint(%q) = %q, want %q", bin, got, want)
}
}
}
// errorText(nil) must be empty so a valid state is not reported as unreadable.
func TestErrorText(t *testing.T) {
if got := errorText(nil); got != "" {
t.Fatalf("errorText(nil) = %q, want \"\"", got)
}
if got := errorText(errors.New("boom")); got != "boom" {
t.Fatalf("errorText(err) = %q, want %q", got, "boom")
}
}
+230
View File
@@ -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
}
+81
View File
@@ -0,0 +1,81 @@
// SPDX-License-Identifier: MIT
package cli
import (
"fmt"
"github.com/spf13/cobra"
"prx/internal/proto"
"prx/internal/state"
)
func (a *app) newOnCmd() *cobra.Command {
var skipEngine, dryRun bool
cmd := &cobra.Command{
Use: "on",
Short: "Enable proxy routing with inbound protection",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := a.loadConfig()
if err != nil {
return err
}
err = a.dispatch(cmd.Context(), cfg, proto.ActionApply, func(p *proto.Plan) {
p.SkipEngine = skipEngine
p.DryRun = dryRun
})
if err != nil {
return err
}
if dryRun {
fmt.Fprintln(a.out, "dry-run complete; nothing changed")
}
return nil
},
}
cmd.Flags().BoolVar(&skipEngine, "skip-engine", false, "apply routing rules without touching the proxy engine")
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "validate the plan without changing anything")
return cmd
}
func (a *app) newOffCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "off",
Short: "Disable proxy routing and remove prx-managed rules",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := a.loadConfig()
if err != nil {
return err
}
return a.dispatch(cmd.Context(), cfg, proto.ActionRemove, nil)
},
}
return cmd
}
func (a *app) newToggleCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "toggle",
Short: "Toggle proxy routing based on current state",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := a.loadConfig()
if err != nil {
return err
}
st, err := state.Load(cfg.State.Path)
if err != nil {
return err
}
action := proto.ActionApply
if st.Enabled {
action = proto.ActionRemove
}
return a.dispatch(cmd.Context(), cfg, action, nil)
},
}
return cmd
}
+111
View File
@@ -0,0 +1,111 @@
// SPDX-License-Identifier: MIT
package cli
import (
"errors"
"fmt"
"os/exec"
"github.com/spf13/cobra"
"prx/internal/config"
"prx/internal/engine"
"prx/internal/proto"
"prx/internal/version"
)
func (a *app) newLogsCmd() *cobra.Command {
var follow bool
cmd := &cobra.Command{
Use: "logs",
Short: "Show proxy engine journal logs",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
if _, err := exec.LookPath("journalctl"); err != nil {
return errors.New("journalctl not found; install systemd to use 'prx logs'")
}
cfg, err := a.loadConfig()
if err != nil {
return err
}
if cfg.Engine.ServiceName == "" {
return errors.New("engine.service_name is not configured; cannot read logs")
}
args := []string{"-u", cfg.Engine.ServiceName, "--no-pager"}
if follow {
args = append(args, "-f")
} else {
args = append(args, "-n", "100")
}
c := exec.CommandContext(cmd.Context(), "journalctl", args...)
c.Stdout = a.out
c.Stderr = a.errOut
if err := c.Run(); err != nil {
return fmt.Errorf("journalctl: %w (you may need to be in the systemd-journal group)", err)
}
return nil
},
}
cmd.Flags().BoolVarP(&follow, "follow", "f", false, "follow log output")
return cmd
}
func (a *app) newVersionCmd() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Print version information",
Args: cobra.NoArgs,
RunE: func(_ *cobra.Command, _ []string) error {
fmt.Fprintf(a.out, "prx %s (commit %s, built %s)\n", version.Version, version.Commit, version.Date)
return nil
},
}
}
func (a *app) newInitCmd() *cobra.Command {
var (
force bool
withEngine bool
engineConfig string
)
cmd := &cobra.Command{
Use: "init",
Short: "Write a default configuration file",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
if config.Exists(a.configPath) && !force {
fmt.Fprintf(a.out, "config already exists at %s (use --force to overwrite)\n", a.configPath)
} else {
if err := config.Save(a.configPath, config.Default()); err != nil {
return err
}
fmt.Fprintf(a.out, "wrote default config to %s\n", a.configPath)
}
if !withEngine {
return nil
}
cfg, err := a.loadConfig()
if err != nil {
return err
}
path := engineConfig
if path == "" {
path = cfg.Engine.ConfigPath
}
body, err := engine.Template(cfg)
if err != nil {
return err
}
return a.dispatch(cmd.Context(), cfg, proto.ActionInitEngine, func(p *proto.Plan) {
p.EngineConfig = path
p.EngineConfigBody = body
})
},
}
cmd.Flags().BoolVar(&force, "force", false, "overwrite an existing config file")
cmd.Flags().BoolVar(&withEngine, "engine-config", false, "also generate a baseline engine configuration")
cmd.Flags().StringVar(&engineConfig, "engine-config-path", "", "engine config path (defaults to engine.config_path)")
return cmd
}
+163
View File
@@ -0,0 +1,163 @@
// SPDX-License-Identifier: MIT
// Package cli implements the prx command line interface.
package cli
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"os"
"os/signal"
"syscall"
"github.com/spf13/cobra"
"prx/internal/client"
"prx/internal/config"
"prx/internal/proto"
)
// app carries the state shared by every command.
type app struct {
configPath string
verbose bool
out io.Writer
errOut io.Writer
logger *slog.Logger
}
// Execute builds and runs the root command, cancelling in-flight work when the
// process receives SIGINT or SIGTERM.
func Execute() error {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
a := &app{out: os.Stdout, errOut: os.Stderr}
root := a.newRootCmd()
return root.ExecuteContext(ctx)
}
func (a *app) newRootCmd() *cobra.Command {
root := &cobra.Command{
Use: "prx",
Short: "Safe proxy routing manager",
Long: "prx enables and disables proxy routing while protecting inbound\n" +
"connections (such as SSH) from being pulled into the tunnel.",
SilenceUsage: true,
SilenceErrors: true,
PersistentPreRunE: func(_ *cobra.Command, _ []string) error {
level := slog.LevelInfo
if a.verbose {
level = slog.LevelDebug
}
a.logger = slog.New(slog.NewTextHandler(a.errOut, &slog.HandlerOptions{Level: level}))
return nil
},
}
root.PersistentFlags().StringVar(&a.configPath, "config", config.DefaultPath(), "path to config file")
root.PersistentFlags().BoolVarP(&a.verbose, "verbose", "v", false, "verbose logging")
root.AddCommand(
a.newOnCmd(),
a.newOffCmd(),
a.newToggleCmd(),
a.newStatusCmd(),
a.newDoctorCmd(),
a.newBypassCmd(),
a.newLogsCmd(),
a.newInitCmd(),
a.newVersionCmd(),
)
return root
}
func (a *app) loadConfig() (*config.Config, error) {
return config.Load(a.configPath)
}
func (a *app) buildPlan(cfg *config.Config, action proto.Action) proto.Plan {
return proto.Plan{
Action: action,
WANInterface: cfg.Network.WANInterface,
WANGateway4: cfg.Network.WANGateway,
WANGateway6: cfg.Network.WANGateway6,
Mark: cfg.Marks.InboundMark,
TableV4: cfg.Routing.TableV4,
TableV6: cfg.Routing.TableV6,
PriorityV4: cfg.Routing.PriorityV4,
PriorityV6: cfg.Routing.PriorityV6,
BypassTableV4: cfg.Routing.BypassTableV4,
BypassTableV6: cfg.Routing.BypassTableV6,
BypassPrioV4: cfg.Routing.BypassPrioV4,
BypassPrioV6: cfg.Routing.BypassPrioV6,
BypassCIDRs: cfg.Bypass.ExtraCIDRs,
ManageEngine: cfg.Engine.Manage,
ServiceName: cfg.Engine.ServiceName,
EngineConfig: cfg.Engine.ConfigPath,
StopEngineOnOff: cfg.Engine.StopOnOff,
HealthProbe: cfg.Health.Probe,
StatePath: cfg.State.Path,
}
}
// dispatch sends a plan to the helper, prints the result and turns a failed
// result into an error. mutate, when non-nil, adjusts the plan before it is
// sent.
func (a *app) dispatch(ctx context.Context, cfg *config.Config, action proto.Action, mutate func(*proto.Plan)) error {
plan := a.buildPlan(cfg, action)
if mutate != nil {
mutate(&plan)
}
if a.logger != nil {
a.logger.Debug("dispatching plan", "action", string(action), "state", plan.StatePath)
}
res, err := client.Dispatch(ctx, plan)
if err != nil {
return err
}
a.printResult(res)
if !res.OK {
return formatResultError(res)
}
return nil
}
func (a *app) printResult(res proto.Result) {
for _, m := range res.Messages {
fmt.Fprintln(a.out, " "+m)
}
if len(res.Checks) == 0 {
return
}
fmt.Fprintln(a.out, " checks:")
for _, c := range res.Checks {
line := fmt.Sprintf(" [%s] %s", checkMark(c), c.Name)
if c.Detail != "" {
line += ": " + c.Detail
}
fmt.Fprintln(a.out, line)
}
}
// checkMark renders a diagnostic check as a short status marker.
func checkMark(c proto.Check) string {
switch {
case c.OK:
return "ok"
case c.Warn:
return "warn"
default:
return "FAIL"
}
}
func formatResultError(res proto.Result) error {
if res.Error == "" {
return errors.New("operation failed")
}
return errors.New(res.Error)
}
+195
View File
@@ -0,0 +1,195 @@
// SPDX-License-Identifier: MIT
package cli
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/spf13/cobra"
"prx/internal/config"
"prx/internal/engine"
"prx/internal/execx"
"prx/internal/iprule"
"prx/internal/nft"
"prx/internal/state"
)
type statusReport struct {
Enabled bool `json:"enabled"`
WANInterface string `json:"wan_interface,omitempty"`
Gateway4 string `json:"gateway_v4,omitempty"`
Gateway6 string `json:"gateway_v6,omitempty"`
Mark string `json:"mark"`
RuleV4 string `json:"ipv4_rule"`
TableV4 string `json:"ipv4_table"`
RuleV6 string `json:"ipv6_rule"`
TableV6 string `json:"ipv6_table"`
Engine string `json:"engine"`
NftTable string `json:"nft_table"`
EnabledAt string `json:"enabled_at,omitempty"`
// StateError reports a state file that could not be read, if any.
StateError string `json:"state_error,omitempty"`
}
func (a *app) newStatusCmd() *cobra.Command {
var asJSON bool
cmd := &cobra.Command{
Use: "status",
Short: "Show current proxy routing status",
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := a.loadConfig()
if err != nil {
return err
}
r := execx.OSRunner{}
ctx := cmd.Context()
// A corrupt or unreadable state file must not hide the rest of
// the report: show what we know and flag the problem.
st, stateErr := state.Load(cfg.State.Path)
rep := statusReport{
Enabled: st.Enabled,
WANInterface: firstNonEmptyStr(st.WANInterface, cfg.Network.WANInterface),
Gateway4: st.Gateway4,
Gateway6: st.Gateway6,
Mark: fmt.Sprintf("0x%x", cfg.Marks.InboundMark),
RuleV4: ruleStatus(ctx, r, 4, cfg.Marks.InboundMark, cfg.Routing.TableV4, cfg.Routing.PriorityV4),
TableV4: tableStatus(ctx, r, 4, cfg.Routing.TableV4),
RuleV6: ruleStatus(ctx, r, 6, cfg.Marks.InboundMark, cfg.Routing.TableV6, cfg.Routing.PriorityV6),
TableV6: tableStatus(ctx, r, 6, cfg.Routing.TableV6),
Engine: engineStatus(ctx, r, cfg),
NftTable: nftStatus(ctx, r),
StateError: errorText(stateErr),
}
if !st.EnabledAt.IsZero() {
rep.EnabledAt = st.EnabledAt.Format("2006-01-02 15:04:05 MST")
}
if asJSON {
enc := json.NewEncoder(a.out)
enc.SetIndent("", " ")
return enc.Encode(rep)
}
stateStr := "disabled"
if rep.Enabled {
stateStr = "enabled"
}
fmt.Fprintf(a.out, "prx: %s\n", stateStr)
fmt.Fprintf(a.out, " mark: %s\n", rep.Mark)
if rep.WANInterface != "" {
fmt.Fprintf(a.out, " interface: %s (gw4=%s gw6=%s)\n", rep.WANInterface, orDash(rep.Gateway4), orDash(rep.Gateway6))
}
fmt.Fprintf(a.out, " ipv4 rule: %s\n", rep.RuleV4)
fmt.Fprintf(a.out, " ipv4 table: %s\n", rep.TableV4)
fmt.Fprintf(a.out, " ipv6 rule: %s\n", rep.RuleV6)
fmt.Fprintf(a.out, " ipv6 table: %s\n", rep.TableV6)
fmt.Fprintf(a.out, " nft table: %s\n", rep.NftTable)
fmt.Fprintf(a.out, " engine: %s\n", rep.Engine)
if rep.EnabledAt != "" {
fmt.Fprintf(a.out, " since: %s\n", rep.EnabledAt)
}
if rep.StateError != "" {
fmt.Fprintf(a.out, " warning: state file unreadable: %s\n", rep.StateError)
}
return nil
},
}
cmd.Flags().BoolVar(&asJSON, "json", false, "output JSON")
return cmd
}
// errorText returns the error message, or an empty string when err is nil.
func errorText(err error) string {
if err == nil {
return ""
}
return err.Error()
}
func ruleStatus(ctx context.Context, r execx.Runner, family int, mark uint32, table, prio int) string {
spec := iprule.Spec{Family: family, Mark: mark, Table: table, Priority: prio}
ok, err := iprule.RuleExists(ctx, r, spec)
switch {
case err != nil:
return "unknown (" + err.Error() + ")"
case ok:
return fmt.Sprintf("present (fwmark 0x%x -> table %d prio %d)", mark, table, prio)
default:
return "absent"
}
}
func tableStatus(ctx context.Context, r execx.Runner, family, table int) string {
ok, err := iprule.TableHasRoutes(ctx, r, family, table)
switch {
case err != nil:
return "unknown (" + err.Error() + ")"
case ok:
return fmt.Sprintf("table %d populated", table)
default:
return fmt.Sprintf("table %d empty", table)
}
}
func engineStatus(ctx context.Context, r execx.Runner, cfg *config.Config) string {
if !cfg.Engine.Manage || cfg.Engine.ServiceName == "" {
return "not managed"
}
active, err := engine.IsActive(ctx, r, cfg.Engine.ServiceName)
if err != nil {
return "unknown (" + err.Error() + ")"
}
if active {
return cfg.Engine.ServiceName + " active"
}
return cfg.Engine.ServiceName + " inactive"
}
func nftStatus(ctx context.Context, r execx.Runner) string {
ok, err := nft.Exists(ctx, r)
switch {
case err != nil:
if isPermissionError(err) {
return nft.Table() + " unknown (needs root)"
}
return nft.Table() + " unknown (" + err.Error() + ")"
case ok:
return nft.Table() + " present"
default:
return nft.Table() + " absent"
}
}
// isPermissionError reports whether err was caused by missing privileges,
// as opposed to a missing binary or a malformed invocation.
func isPermissionError(err error) bool {
msg := err.Error()
for _, marker := range []string{"Operation not permitted", "Permission denied", "not permitted"} {
if strings.Contains(msg, marker) {
return true
}
}
return false
}
func orDash(s string) string {
if s == "" {
return "-"
}
return s
}
func firstNonEmptyStr(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}