feat(cli): add the prx command-line interface
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 ""
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user