From 940c47030f9bb1fadbb28ec5d93a8162744b0049 Mon Sep 17 00:00:00 2001 From: Amir Husayn Panahifar Date: Thu, 15 Oct 2026 14:29:19 +0330 Subject: [PATCH] feat(cli): add the prx command-line interface --- internal/cli/bypass.go | 137 +++++++++++++++++++++++ internal/cli/cli_test.go | 156 ++++++++++++++++++++++++++ internal/cli/doctor.go | 230 ++++++++++++++++++++++++++++++++++++++ internal/cli/lifecycle.go | 81 ++++++++++++++ internal/cli/misc.go | 111 ++++++++++++++++++ internal/cli/root.go | 163 +++++++++++++++++++++++++++ internal/cli/status.go | 195 ++++++++++++++++++++++++++++++++ 7 files changed, 1073 insertions(+) create mode 100644 internal/cli/bypass.go create mode 100644 internal/cli/cli_test.go create mode 100644 internal/cli/doctor.go create mode 100644 internal/cli/lifecycle.go create mode 100644 internal/cli/misc.go create mode 100644 internal/cli/root.go create mode 100644 internal/cli/status.go diff --git a/internal/cli/bypass.go b/internal/cli/bypass.go new file mode 100644 index 0000000..ae9ac20 --- /dev/null +++ b/internal/cli/bypass.go @@ -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 ", + 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 ", + 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 +} diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go new file mode 100644 index 0000000..680ddaf --- /dev/null +++ b/internal/cli/cli_test.go @@ -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") + } +} diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go new file mode 100644 index 0000000..f96cd19 --- /dev/null +++ b/internal/cli/doctor.go @@ -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 +} diff --git a/internal/cli/lifecycle.go b/internal/cli/lifecycle.go new file mode 100644 index 0000000..be0a1c3 --- /dev/null +++ b/internal/cli/lifecycle.go @@ -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 +} diff --git a/internal/cli/misc.go b/internal/cli/misc.go new file mode 100644 index 0000000..1e63e79 --- /dev/null +++ b/internal/cli/misc.go @@ -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 +} diff --git a/internal/cli/root.go b/internal/cli/root.go new file mode 100644 index 0000000..31135e6 --- /dev/null +++ b/internal/cli/root.go @@ -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) +} diff --git a/internal/cli/status.go b/internal/cli/status.go new file mode 100644 index 0000000..9be6972 --- /dev/null +++ b/internal/cli/status.go @@ -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 "" +}