Compare commits

...
10 Commits
26 changed files with 3682 additions and 2 deletions
+2 -2
View File
@@ -12,8 +12,8 @@ MANDIR ?= $(PREFIX)/share/man
all: build
build:
CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/prx ./cmd/prx
CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/prx-helper ./cmd/prx-helper
CGO_ENABLED=0 go build -buildvcs=false -trimpath -ldflags "$(LDFLAGS)" -o bin/prx ./cmd/prx
CGO_ENABLED=0 go build -buildvcs=false -trimpath -ldflags "$(LDFLAGS)" -o bin/prx-helper ./cmd/prx-helper
test:
go test ./...
+356
View File
@@ -0,0 +1,356 @@
# prx
Safe proxy routing manager for Linux.
`prx` turns proxy routing on and off without breaking the connections that
reach *you*. In particular, an SSH session that is already open when you enable
the proxy keeps working — regardless of which port it uses.
```
prx on # route outbound traffic through the proxy, protect inbound replies
prx off # restore a clean state
prx status # what is currently applied
```
- Proxy engine: **sing-box** (TUN mode) by default, managed through systemd.
- Routing: **nftables** + **ip rule** policy routing.
- Privileges: an unprivileged CLI plus a small privileged helper invoked with
**polkit/pkexec** (falling back to **sudo**).
- Target: **Arch Linux**, and any other Linux with `nftables`, `iproute2` and
`systemd`. Nothing is hard-coded to a particular interface, gateway, address
or distribution layout.
---
## Why inbound connections survive
When a TUN-based proxy is enabled, the default route is usually replaced so
that traffic enters the tunnel. That is exactly what breaks inbound sessions:
the replies you send to a client that connected to you get pulled into the
tunnel and never reach the client.
`prx` prevents this at the routing layer:
1. Incoming connections on the WAN interface are given a conntrack mark
(`0x100` by default).
2. Outgoing packets belonging to a connection whose **original destination** is
one of our WAN addresses are marked too. Matching on the original
destination — instead of only `ct state new` — means connections that were
**already established before `prx on`** are protected as well.
3. An `ip rule` routes marked packets through a dedicated table whose default
route points at the physical gateway, not the tunnel.
Because the decision is based on the connection, not a port number, it works
for SSH on any port and for any other inbound service.
---
## Install
### From source
```sh
git clone https://github.com/example/prx
cd prx
make build # produces bin/prx and bin/prx-helper
sudo make install # installs binaries, man pages, polkit policy and systemd units
```
### Arch Linux (PKGBUILD)
```sh
cd packaging
makepkg -si
```
### Dependencies
Runtime: `nftables` (>= 0.9.4, for the `destroy` keyword), `iproute2` (with `-j`
JSON support), `systemd`, and a way to elevate privileges — `polkit`/`pkexec`
or `sudo`. Optional: `sing-box` (the default engine).
```sh
# Arch
sudo pacman -S nftables iproute2 polkit sing-box
# other distributions: install the equivalent packages
```
`prx` makes no assumptions about this machine: the WAN interface, gateway and
local addresses are detected at runtime from `ip route`/`ip addr`, IPv6 is used
only when the host actually has an IPv6 uplink, and the helper is found by
searching the standard install locations (see *Privileges*). Missing tools
produce an actionable message that names the package to install.
Create a starting configuration:
```sh
prx init # writes ~/.config/prx/config.json
prx init --engine-config # also writes a baseline sing-box config
```
---
## Commands
| Command | Description |
| --- | --- |
| `prx on [--skip-engine] [--dry-run]` | Enable routing; installs nftables + policy routing rules |
| `prx off` | Remove prx-managed rules and restore a clean state |
| `prx toggle` | Enable or disable based on current state |
| `prx status [--json]` | Human readable status, or machine readable JSON |
| `prx doctor` | Diagnose configuration and applied rules |
| `prx bypass add <cidr\|ip\|domain>` | Keep a destination out of the tunnel |
| `prx bypass rm <cidr\|ip\|domain>` | Remove a bypass entry |
| `prx bypass ls` | List bypass entries |
| `prx logs [--follow]` | Tail the engine journal |
| `prx init [--engine-config]` | Write a default configuration |
| `prx version` | Print version information |
Exit codes are `0` on success and non-zero with an actionable message on
failure.
### Useful flags
- `prx on --dry-run` — validate the plan without changing anything.
- `prx on --skip-engine` — apply only the routing layer (useful for testing or
when you manage the engine yourself).
### Man pages
Two manual pages are installed under `share/man/man1`:
```sh
man prx # the CLI
man prx-helper # the privileged helper
```
They are listed in `man/prx.1` and `man/prx-helper.1` and can be previewed
without installing:
```sh
man --local-file man/prx.1
```
---
## Configuration
`~/.config/prx/config.json` (override with `--config` or `$PRX_CONFIG`):
```json
{
"engine": {
"type": "sing-box",
"config_path": "/etc/sing-box/config.json",
"service_name": "sing-box",
"manage": true,
"stop_on_off": false
},
"network": {
"wan_interface": "",
"wan_gateway": "",
"wan_gateway6": "",
"dns_keepalive": true
},
"marks": { "inbound_mark": 256 },
"routing": {
"table_v4": 100,
"table_v6": 101,
"priority_v4": 100,
"priority_v6": 100,
"bypass_table_v4": 102,
"bypass_table_v6": 103,
"bypass_priority_v4": 50,
"bypass_priority_v6": 50
},
"bypass": {
"always_cidrs": ["127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "169.254.0.0/16", "::1/128", "fc00::/7", "fe80::/10"],
"extra_cidrs": [],
"domains": []
},
"process_bypass": { "names": ["ssh", "scp", "sftp"] },
"health": { "probe": "1.1.1.1:443" },
"state": { "path": "/run/prx/state.json" }
}
```
- `network.wan_interface` / `network.wan_gateway` / `network.wan_gateway6` are
optional overrides; otherwise the default route is detected from
`ip route show default` (IPv4 first, then IPv6 for an IPv6-only uplink).
- `health.probe` is a `host:port` dialed after enabling to confirm
connectivity. Set it to `""` to disable the check.
- `marks.inbound_mark` is `256` (0x100). Keep it distinct from the mark your
engine uses; `prx doctor` warns on a collision.
- `always_cidrs` are hints for the engine (they are baked into the generated
engine config). `extra_cidrs` are additionally enforced by the routing layer:
each one gets a route in the bypass table and a destination-based `ip rule`,
so that traffic to it never enters the tunnel.
Unknown keys are rejected (the config is decoded strictly), and every value is
validated before it is written.
State is stored in `/run/prx/state.json` so `off` and rollback know what was
applied. A companion `prx.lock` file serialises concurrent `on`/`off` so two
runs cannot interleave.
---
## Privileges
Privileged actions go through a small helper:
```
prx (unprivileged) --pkexec--> prx-helper (root)
--sudo----->
```
The CLI resolves and validates everything, then sends a JSON plan on stdin to
`prx-helper`. The helper re-validates every field before touching the system, so
the privileged attack surface stays small. `on`, `off`, `toggle`, `bypass`,
`init --engine-config` and `doctor` use the helper; `status`, `logs` and
`version` do not.
Elevation prefers `pkexec` (a polkit prompt) and falls back to `sudo -n` when
polkit is not installed, so prx also works on minimal systems. The helper is
located in this order:
- `$PRX_HELPER` (explicit override),
- next to the `prx` binary, then `../lib/prx` and `../libexec/prx`,
- `/usr/libexec/prx`, `/usr/lib/prx`, `/usr/local/libexec/prx`,
`/usr/local/lib/prx`,
- finally `$PATH`.
If `sudo` needs a password, run the command as `sudo prx ...` directly (the CLI
then runs the helper in-process), or install the polkit policy for a prompt.
The polkit action is defined in `polkit/org.prx.policy` and requires
authentication (`auth_admin`) for changes.
---
## How `on` works (transactionally)
1. Acquire a `prx.lock` file lock so concurrent runs never interleave.
2. Detect the WAN interface, gateway and local addresses.
3. Install the nftables table `inet prx` in a single atomic transaction
(`destroy` + full redefinition, so re-running never duplicates rules).
4. Install the policy rule and route (and bypass rules), idempotently. A
rollback handler is registered **before** each table is touched, so a failure
part-way through still cleans up.
5. Ensure the engine service is running.
6. Run health checks (rules present, engine active, connectivity probe).
7. If any blocking check fails, **roll back** everything (in reverse order) and
exit non-zero. If a rollback step itself fails, prx says so explicitly (a
`rollback warning:` line and an error stating the rollback was incomplete)
instead of pretending the system is clean.
`off` removes only objects prx created: the `inet prx` table, rules that look up
prx's tables, and prx's routes. It never flushes your whole ruleset.
Re-running `on` is idempotent; running `off` twice is a no-op. Configuration and
state are written atomically (temp file + fsync + rename) so they are never
observed half-written, and `Ctrl-C` cancels cleanly via signal handling. A
corrupt state file never crashes a command: it is reported (for example by
`prx status`) and the rest of the information is still shown.
---
## The generated sing-box config
`prx init --engine-config` writes a baseline TUN configuration:
- a `tun` inbound with `auto_route`;
- a `direct` outbound that **you should replace** with your real proxy
outbound (Shadowsocks, VLESS, …);
- a route rule that sends the bypass CIDRs, domains and process names direct.
`prx` does not rewrite an existing engine config on every `on`; it only
validates that a TUN inbound is present and warns otherwise.
---
## systemd
Two optional units are shipped:
- `prx-reapply.service` — re-applies the routing (runs `prx on`, which is
idempotent). It only runs when `/run/prx/state.json` exists, i.e. when prx was
enabled.
- `prx-reapply.timer` — triggers the service periodically so a changed gateway
(DHCP) is picked up.
Enable the timer if you want automatic re-application:
```sh
sudo systemctl enable --now prx-reapply.timer
```
---
## Troubleshooting
Start with:
```sh
prx doctor
```
Common situations:
- **`nft: ... Operation not permitted`** — run the command with `sudo` or
install the polkit policy so `pkexec` can prompt.
- **`required binary "nft" not found ... install nftables`** — install the named
package (`nftables`, `iproute2` or `systemd`).
- **`nftables may be too old ... needs >= 0.9.4`** — upgrade your nftables
package; older releases do not understand the `destroy` keyword prx uses.
- **`prx-helper not found`** — the helper is not installed or lives outside the
searched paths; install prx or set `$PRX_HELPER`.
- **`sudo needs a password`** — run `sudo prx ...` directly, or install polkit
for a graphical prompt.
- **`journalctl not found` / `prx logs` unavailable** — install `systemd`.
- **`state file unreadable`** — `/run/prx/state.json` is corrupt; remove it and
re-run `prx on`.
- **engine-config warning: no tun inbound** — enable TUN mode in your engine
config; prx relies on it.
- **mark-collision** — your engine uses the same mark as prx; change
`marks.inbound_mark` (or the engine's mark).
- **connectivity check fails** — the marking/routing is installed but the host
could not open an outbound TCP connection; check the engine and upstream (or
set `health.probe` to `""` to skip the check).
- **`pkexec` returns "authorization cancelled"** — you dismissed the polkit
prompt.
Inspect the applied objects manually:
```sh
sudo nft list table inet prx
ip rule show
ip route show table 100
```
---
## Testing
```sh
make test # unit tests (no root required)
sudo -E PRX_BIN="$PWD/bin/prx" ./scripts/integration-test.sh
```
The integration script enables the routing layer with `--skip-engine`, asserts
the rules exist, checks idempotency, and then disables it and asserts a clean
state.
---
## Developer and Contributor
- **Amir Husayn Panahifar** &lt;ahp@panahifar.ir&gt; — developer, contributor, author, and maintainer.
Contributions are welcome.
---
## License
MIT. See `LICENSE`.
+52
View File
@@ -0,0 +1,52 @@
// SPDX-License-Identifier: MIT
// Command prx-helper is the privileged core of prx.
//
// It reads a single JSON Plan from stdin, executes it and writes a JSON Result
// to stdout. It refuses to run without root privileges and is intended to be
// launched by the prx CLI through pkexec or sudo.
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"os/signal"
"syscall"
"prx/internal/execx"
"prx/internal/helper"
"prx/internal/proto"
)
func main() {
if os.Geteuid() != 0 {
fmt.Fprintln(os.Stderr, "prx-helper: must run as root (use pkexec or sudo)")
os.Exit(1)
}
// Cancelling on SIGINT/SIGTERM lets an in-flight transaction roll back
// instead of being killed part-way through.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
var plan proto.Plan
dec := json.NewDecoder(os.Stdin)
if err := dec.Decode(&plan); err != nil {
fmt.Fprintln(os.Stderr, "prx-helper: invalid plan on stdin: "+err.Error())
os.Exit(2)
}
res := helper.Execute(ctx, execx.OSRunner{}, plan)
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(res); err != nil {
fmt.Fprintln(os.Stderr, "prx-helper: encode result: "+err.Error())
os.Exit(2)
}
if !res.OK {
os.Exit(1)
}
}
+18
View File
@@ -0,0 +1,18 @@
// SPDX-License-Identifier: MIT
// Command prx is the unprivileged user-facing CLI.
package main
import (
"fmt"
"os"
"prx/internal/cli"
)
func main() {
if err := cli.Execute(); err != nil {
fmt.Fprintln(os.Stderr, "prx: "+err.Error())
os.Exit(1)
}
}
+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 ""
}
+186
View File
@@ -0,0 +1,186 @@
// SPDX-License-Identifier: MIT
// Package client sends plans from the unprivileged CLI to the privileged
// helper, either in-process (when already root) or through pkexec with a sudo
// fallback, so prx works on systems with or without polkit.
package client
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"prx/internal/execx"
"prx/internal/helper"
"prx/internal/proto"
)
// Dispatch executes plan with the appropriate privilege level.
func Dispatch(ctx context.Context, plan proto.Plan) (proto.Result, error) {
// Already root: run the privileged logic in-process with no copy overhead.
if os.Geteuid() == 0 {
return helper.Execute(ctx, execx.OSRunner{}, plan), nil
}
raw, err := json.Marshal(plan)
if err != nil {
return proto.Result{}, fmt.Errorf("encode plan: %w", err)
}
helperPath, err := FindHelper()
if err != nil {
return proto.Result{}, err
}
out, err := runPrivileged(ctx, helperPath, raw)
if err != nil {
return proto.Result{}, err
}
var res proto.Result
if err := json.Unmarshal(out, &res); err != nil {
return proto.Result{}, fmt.Errorf("decode helper response: %w", err)
}
return res, nil
}
// runPrivileged executes the helper with elevated privileges.
//
// polkit's pkexec is preferred because it can prompt graphically; sudo is the
// fallback so that minimal systems without polkit still work. sudo is invoked
// non-interactively because its password prompt would otherwise consume the
// JSON plan that is delivered on stdin.
func runPrivileged(ctx context.Context, helperPath string, stdin []byte) ([]byte, error) {
if _, err := exec.LookPath("pkexec"); err == nil {
return runElevated(ctx, "pkexec", helperPath, stdin)
}
if _, err := exec.LookPath("sudo"); err == nil {
return runElevated(ctx, "sudo", helperPath, stdin)
}
return nil, errors.New("neither pkexec nor sudo is available; install polkit or sudo, or run prx as root")
}
// runElevated runs the helper through tool and returns its stdout.
//
// The helper always writes a structured JSON Result to stdout, even when the
// operation failed (a failed operation is still a valid answer), so a
// structured answer on stdout always wins over the process exit status. The
// returned error is reserved for transport-level problems: cancellation of the
// authorization prompt, a helper that could not start, or a helper that
// produced nothing usable.
func runElevated(ctx context.Context, tool, helperPath string, stdin []byte) ([]byte, error) {
var args []string
switch tool {
case "sudo":
args = []string{"-n", "--", helperPath}
default:
args = []string{helperPath}
}
cmd := exec.CommandContext(ctx, tool, args...)
cmd.Stdin = bytes.NewReader(stdin)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
runErr := cmd.Run()
out := stdout.Bytes()
if hasStructuredResult(out) {
return out, nil
}
if runErr == nil {
return nil, fmt.Errorf("%s: helper produced no result", tool)
}
return nil, classifyFailure(tool, runErr, out, stderr.Bytes())
}
// hasStructuredResult reports whether out carries a well-formed JSON Result
// from the helper.
func hasStructuredResult(out []byte) bool {
if len(bytes.TrimSpace(out)) == 0 {
return false
}
var probe proto.Result
return json.Unmarshal(out, &probe) == nil
}
// classifyFailure turns an elevated-run failure into an actionable message.
// stdout is included because a helper or wrapper may report the reason there.
func classifyFailure(tool string, runErr error, stdout, stderr []byte) error {
msg := strings.TrimSpace(string(stderr))
out := strings.TrimSpace(string(stdout))
var ee *exec.ExitError
switch tool {
case "pkexec":
if errors.As(runErr, &ee) {
switch ee.ExitCode() {
case 126:
return errors.New("authorization was cancelled or not permitted; retry with 'sudo prx ...' if polkit is not set up")
case 127:
return fmt.Errorf("failed to start prx-helper: %s", firstNonEmptyString(msg, out, runErr.Error()))
}
}
case "sudo":
// `sudo -n` never prompts, so a missing credential surfaces as a
// message containing one of these phrases.
lower := strings.ToLower(msg)
if strings.Contains(lower, "a password is required") ||
strings.Contains(lower, "a terminal is required") ||
strings.Contains(lower, "no tty present") {
return errors.New("sudo needs a password; run 'sudo prx ...' directly, or install polkit (pkexec) for a graphical prompt")
}
}
return fmt.Errorf("%s failed: %s", tool, firstNonEmptyString(msg, out, runErr.Error()))
}
// firstNonEmptyString returns the first non-blank value.
func firstNonEmptyString(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
// helperCandidates are the locations searched for prx-helper, covering the
// common FHS layouts across distributions (Arch/Debian use /usr/lib,
// Fedora/RHEL/openSUSE use /usr/libexec) plus local prefixes.
func helperCandidates() []string {
var paths []string
if exe, err := os.Executable(); err == nil {
dir := filepath.Dir(exe)
paths = append(paths,
filepath.Join(dir, "prx-helper"),
filepath.Join(dir, "..", "libexec", "prx", "prx-helper"),
filepath.Join(dir, "..", "lib", "prx", "prx-helper"),
)
}
return append(paths,
"/usr/libexec/prx/prx-helper",
"/usr/lib/prx/prx-helper",
"/usr/local/libexec/prx/prx-helper",
"/usr/local/lib/prx/prx-helper",
)
}
// FindHelper locates the prx-helper binary, honouring $PRX_HELPER first.
func FindHelper() (string, error) {
if p := os.Getenv("PRX_HELPER"); p != "" {
if info, err := os.Stat(p); err != nil || info.IsDir() {
return "", fmt.Errorf("PRX_HELPER points at %q, which is not an executable file", p)
}
return p, nil
}
for _, candidate := range helperCandidates() {
if info, err := os.Stat(candidate); err == nil && !info.IsDir() && info.Mode()&0o111 != 0 {
return filepath.Clean(candidate), nil
}
}
if p, err := exec.LookPath("prx-helper"); err == nil {
return p, nil
}
return "", errors.New("prx-helper not found (searched $PATH, /usr/lib/prx and /usr/libexec/prx); install prx, set $PRX_HELPER, or re-run with sudo")
}
+152
View File
@@ -0,0 +1,152 @@
// SPDX-License-Identifier: MIT
package client
import (
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"prx/internal/proto"
)
// writeFakeTool installs an executable shell script as name in a temporary
// directory and prepends that directory to PATH for the duration of the test.
func writeFakeTool(t *testing.T, name, script string) {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
t.Fatalf("write fake %s: %v", name, err)
}
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
}
// The helper exits non-zero when an operation fails, but its structured
// answer on stdout must still reach the caller — otherwise the real reason
// for the failure is replaced by a bare "exit status 1".
func TestRunElevatedPreservesStructuredResult(t *testing.T) {
writeFakeTool(t, "sudo", `#!/bin/sh
echo '{"action":"apply","ok":false,"error":"engine start failed"}'
exit 1
`)
out, err := runElevated(context.Background(), "sudo", "/usr/lib/prx/prx-helper", []byte(`{"action":"apply"}`))
if err != nil {
t.Fatalf("structured result should be preferred over the exit status: %v", err)
}
var res proto.Result
if err := json.Unmarshal(out, &res); err != nil {
t.Fatalf("decode: %v", err)
}
if res.OK || res.Error != "engine start failed" {
t.Fatalf("result = %+v, want OK=false with the helper's error", res)
}
}
func TestRunElevatedSudoPasswordRequired(t *testing.T) {
writeFakeTool(t, "sudo", `#!/bin/sh
echo "sudo: a password is required" >&2
exit 1
`)
_, err := runElevated(context.Background(), "sudo", "/usr/lib/prx/prx-helper", []byte("{}"))
if err == nil || !strings.Contains(err.Error(), "password") {
t.Fatalf("err = %v, want a password hint", err)
}
if !strings.Contains(err.Error(), "sudo prx") {
t.Fatalf("error should tell the user what to do: %v", err)
}
}
func TestRunElevatedHelperProducesNothing(t *testing.T) {
writeFakeTool(t, "sudo", "#!/bin/sh\nexit 0\n")
_, err := runElevated(context.Background(), "sudo", "/usr/lib/prx/prx-helper", []byte("{}"))
if err == nil || !strings.Contains(err.Error(), "no result") {
t.Fatalf("err = %v, want 'no result'", err)
}
}
func TestRunElevatedNonJSONOutputIsAnError(t *testing.T) {
writeFakeTool(t, "sudo", "#!/bin/sh\necho 'boom: not json'\nexit 3\n")
_, err := runElevated(context.Background(), "sudo", "/usr/lib/prx/prx-helper", []byte("{}"))
if err == nil || !strings.Contains(err.Error(), "boom: not json") {
t.Fatalf("err = %v, want the tool's output", err)
}
}
func TestClassifyFailurePKexecCancellation(t *testing.T) {
cmd := exec.Command("sh", "-c", "exit 126")
err := cmd.Run()
if err == nil {
t.Fatal("expected 'sh -c exit 126' to fail")
}
got := classifyFailure("pkexec", err, nil, nil)
if !strings.Contains(got.Error(), "cancelled") {
t.Fatalf("got %q, want a cancellation message", got)
}
}
func TestClassifyFailureUnknownToolError(t *testing.T) {
got := classifyFailure("sudo", exec.Command("sh", "-c", "exit 5").Run(), nil, []byte("sudo: unknown user: ahp"))
if !strings.Contains(got.Error(), "unknown user") {
t.Fatalf("got %q, want the stderr detail", got)
}
}
func TestClassifyFailureIncludesStdout(t *testing.T) {
got := classifyFailure("pkexec", exec.Command("sh", "-c", "exit 5").Run(), []byte("helper exploded"), nil)
if !strings.Contains(got.Error(), "helper exploded") {
t.Fatalf("got %q, want the stdout detail", got)
}
}
func TestHasStructuredResult(t *testing.T) {
if !hasStructuredResult([]byte(`{"ok":true}`)) {
t.Error("valid Result should be detected")
}
for _, bad := range []string{"", " \n", "not json", "[]", "42"} {
if hasStructuredResult([]byte(bad)) {
t.Errorf("hasStructuredResult(%q) = true, want false", bad)
}
}
}
func TestFindHelperHonoursEnvOverride(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "prx-helper")
if err := os.WriteFile(p, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PRX_HELPER", p)
got, err := FindHelper()
if err != nil {
t.Fatalf("FindHelper: %v", err)
}
if got != p {
t.Fatalf("got %q, want %q", got, p)
}
t.Setenv("PRX_HELPER", filepath.Join(dir, "does-not-exist"))
if _, err := FindHelper(); err == nil {
t.Fatal("expected an error for a broken PRX_HELPER override")
}
}
// The helper must be discoverable on every common FHS layout, not just the one
// used by the machine prx was developed on.
func TestHelperCandidatesCoverCommonLayouts(t *testing.T) {
joined := strings.Join(helperCandidates(), "\n")
for _, want := range []string{
"/usr/lib/prx/prx-helper",
"/usr/libexec/prx/prx-helper",
"/usr/local/lib/prx/prx-helper",
"/usr/local/libexec/prx/prx-helper",
} {
if !strings.Contains(joined, want) {
t.Errorf("helperCandidates missing %s", want)
}
}
}
+143
View File
@@ -0,0 +1,143 @@
// SPDX-License-Identifier: MIT
// Package engine controls the proxy engine (sing-box) through systemd.
package engine
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"prx/internal/config"
"prx/internal/execx"
)
// IsActive reports whether the systemd unit is active.
func IsActive(ctx context.Context, r execx.Runner, service string) (bool, error) {
res, err := r.Run(ctx, nil, "systemctl", "is-active", service)
state := strings.TrimSpace(string(res.Stdout))
if err != nil {
var ee *execx.ExitError
if errors.As(err, &ee) {
// inactive/failed units exit non-zero; that is not an error here.
return state == "active", nil
}
return false, err
}
return state == "active", nil
}
// Start starts the unit.
func Start(ctx context.Context, r execx.Runner, service string) error {
_, err := r.Run(ctx, nil, "systemctl", "start", service)
if err != nil {
return fmt.Errorf("start %s: %w", service, err)
}
return nil
}
// Stop stops the unit.
func Stop(ctx context.Context, r execx.Runner, service string) error {
_, err := r.Run(ctx, nil, "systemctl", "stop", service)
if err != nil {
return fmt.Errorf("stop %s: %w", service, err)
}
return nil
}
// Reload asks the unit to reload its configuration.
func Reload(ctx context.Context, r execx.Runner, service string) error {
_, err := r.Run(ctx, nil, "systemctl", "reload", service)
if err != nil {
return fmt.Errorf("reload %s: %w", service, err)
}
return nil
}
// ValidateConfig inspects a sing-box configuration for the settings prx relies
// on, returning human readable warnings (never fatal).
func ValidateConfig(path string) ([]string, error) {
if path == "" {
return nil, nil
}
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var parsed struct {
Inbounds []struct {
Type string `json:"type"`
} `json:"inbounds"`
Route struct {
AutoRoute *bool `json:"auto_route"`
} `json:"route"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
return nil, fmt.Errorf("parse engine config %s: %w", path, err)
}
var warnings []string
hasTun := false
for _, in := range parsed.Inbounds {
if in.Type == "tun" {
hasTun = true
}
}
if !hasTun {
warnings = append(warnings, "engine config has no tun inbound; proxy routing expects TUN mode")
}
if parsed.Route.AutoRoute != nil && !*parsed.Route.AutoRoute {
warnings = append(warnings, "engine route.auto_route is false; the tunnel may not capture traffic")
}
return warnings, nil
}
// Template renders a baseline sing-box configuration derived from cfg.
func Template(cfg *config.Config) ([]byte, error) {
directRule := map[string]any{
"action": "route",
"outbound": "direct",
}
if len(cfg.Bypass.AlwaysCIDRs) > 0 || len(cfg.Bypass.ExtraCIDRs) > 0 {
directRule["ip_cidr"] = append(append([]string{}, cfg.Bypass.AlwaysCIDRs...), cfg.Bypass.ExtraCIDRs...)
}
if len(cfg.Bypass.Domains) > 0 {
directRule["domain"] = cfg.Bypass.Domains
}
if len(cfg.ProcessBypass.Names) > 0 {
directRule["process_name"] = cfg.ProcessBypass.Names
}
doc := map[string]any{
"log": map[string]any{"level": "info"},
"inbounds": []any{
map[string]any{
"type": "tun",
"tag": "tun-in",
"interface_name": "tun0",
"address": []string{"172.19.0.1/30", "fdfe:dcba:9876::1/126"},
"mtu": 9000,
"auto_route": true,
"strict_route": true,
"stack": "system",
"auto_redirect": false,
},
},
"outbounds": []any{
// Replace "direct" with your proxy outbound (e.g. { "type": "shadowsocks", ... }).
map[string]any{"type": "direct", "tag": "direct"},
},
"route": map[string]any{
"auto_detect_interface": true,
"rules": []any{directRule},
"final": "direct",
},
}
out, err := json.MarshalIndent(doc, "", " ")
if err != nil {
return nil, err
}
return append(out, '\n'), nil
}
+94
View File
@@ -0,0 +1,94 @@
// SPDX-License-Identifier: MIT
package engine
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"prx/internal/config"
"prx/internal/execx"
)
func TestIsActive(t *testing.T) {
active := &execx.Fake{Handler: func(_ string, _ []string, _ []byte) (execx.Result, error) {
return execx.Result{Stdout: []byte("active\n")}, nil
}}
got, err := IsActive(context.Background(), active, "sing-box")
if err != nil || !got {
t.Fatalf("IsActive = %v, %v", got, err)
}
inactive := &execx.Fake{Handler: func(_ string, _ []string, _ []byte) (execx.Result, error) {
return execx.Result{Stdout: []byte("inactive\n"), Code: 3}, &execx.ExitError{Name: "systemctl", Code: 3, Stderr: []byte("inactive")}
}}
got, err = IsActive(context.Background(), inactive, "sing-box")
if err != nil || got {
t.Fatalf("IsActive(inactive) = %v, %v", got, err)
}
}
func TestValidateConfig(t *testing.T) {
dir := t.TempDir()
withTun := filepath.Join(dir, "with-tun.json")
if err := os.WriteFile(withTun, []byte(`{"inbounds":[{"type":"tun"}]}`), 0o644); err != nil {
t.Fatal(err)
}
warnings, err := ValidateConfig(withTun)
if err != nil {
t.Fatalf("ValidateConfig: %v", err)
}
if len(warnings) != 0 {
t.Fatalf("unexpected warnings: %v", warnings)
}
noTun := filepath.Join(dir, "no-tun.json")
if err := os.WriteFile(noTun, []byte(`{"inbounds":[{"type":"mixed"}]}`), 0o644); err != nil {
t.Fatal(err)
}
warnings, err = ValidateConfig(noTun)
if err != nil {
t.Fatalf("ValidateConfig: %v", err)
}
if len(warnings) == 0 {
t.Fatal("expected a warning for missing tun inbound")
}
}
func TestValidateConfigWarnsOnAutoRouteDisabled(t *testing.T) {
path := filepath.Join(t.TempDir(), "auto-route-off.json")
body := `{"inbounds":[{"type":"tun"}],"route":{"auto_route":false}}`
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
warnings, err := ValidateConfig(path)
if err != nil {
t.Fatalf("ValidateConfig: %v", err)
}
found := false
for _, w := range warnings {
if strings.Contains(w, "auto_route") {
found = true
}
}
if !found {
t.Fatalf("expected an auto_route warning, got %v", warnings)
}
}
func TestTemplateContainsTunAndBypass(t *testing.T) {
cfg := config.Default()
body, err := Template(cfg)
if err != nil {
t.Fatalf("Template: %v", err)
}
s := string(body)
for _, needle := range []string{`"type": "tun"`, `"auto_route": true`, "10.0.0.0/8", "192.168.0.0/16"} {
if !strings.Contains(s, needle) {
t.Errorf("template missing %q", needle)
}
}
}
+708
View File
@@ -0,0 +1,708 @@
// SPDX-License-Identifier: MIT
// Package helper implements the privileged core invoked by the unprivileged
// prx CLI through pkexec.
//
// Every plan is re-validated here before any system change is made, and the
// apply operation is transactional: on any failure the changes made so far are
// rolled back. Concurrent apply/remove operations are serialised with an
// advisory file lock.
package helper
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"path/filepath"
"strconv"
"strings"
"time"
"prx/internal/config"
"prx/internal/engine"
"prx/internal/execx"
"prx/internal/fsx"
"prx/internal/health"
"prx/internal/iprule"
"prx/internal/netdetect"
"prx/internal/nft"
"prx/internal/proto"
"prx/internal/state"
)
// healthTimeout bounds the post-apply connectivity probe.
const healthTimeout = 3 * time.Second
// Execute runs a single privileged request.
func Execute(ctx context.Context, r execx.Runner, plan proto.Plan) proto.Result {
switch plan.Action {
case proto.ActionApply:
return apply(ctx, r, plan)
case proto.ActionRemove:
return remove(ctx, r, plan)
case proto.ActionInspect:
return inspect(ctx, r, plan)
case proto.ActionEngine:
return engineAction(ctx, r, plan)
case proto.ActionInitEngine:
return initEngine(plan)
default:
return proto.Result{Action: plan.Action, Error: fmt.Sprintf("unknown action %q", plan.Action)}
}
}
// rollback records undo actions in the order they must be reversed.
type rollback struct {
fns []func(context.Context) error
}
func (rb *rollback) add(fn func(context.Context) error) { rb.fns = append(rb.fns, fn) }
// run executes the undo actions in reverse order and returns every failure so
// the caller can report that rollback was incomplete.
func (rb *rollback) run(ctx context.Context) []error {
var errs []error
for i := len(rb.fns) - 1; i >= 0; i-- {
if err := rb.fns[i](ctx); err != nil {
errs = append(errs, err)
}
}
return errs
}
func apply(ctx context.Context, r execx.Runner, plan proto.Plan) (res proto.Result) {
res = proto.Result{Action: proto.ActionApply}
if err := validatePlan(plan); err != nil {
res.Error = err.Error()
return res
}
if err := preflight(r, plan.ManageEngine && !plan.SkipEngine && plan.ServiceName != ""); err != nil {
res.Error = err.Error()
return res
}
// A dry run makes no changes, so it does not need the state lock.
if !plan.DryRun {
unlock, err := lockState(plan.StatePath)
if err != nil {
res.Error = err.Error()
return res
}
defer func() {
if uerr := unlock(); uerr != nil {
res.Messages = append(res.Messages, "warning: "+uerr.Error())
}
}()
}
wan, err := netdetect.Detect(ctx, r, plan.WANInterface, plan.WANGateway4, plan.WANGateway6)
if err != nil {
res.Error = err.Error()
return res
}
if wan.Gateway4 == "" && wan.Gateway6 == "" {
res.Error = "no default gateway detected; cannot install policy routing"
return res
}
script, err := nft.Script(nft.Params{
WANInterface: wan.Interface,
Mark: plan.Mark,
Local4: wan.Local4,
Local6: wan.Local6,
})
if err != nil {
res.Error = err.Error()
return res
}
if plan.DryRun {
if err := nft.Check(ctx, r, script); err != nil {
res.Error = "dry-run validation failed: " + err.Error()
return res
}
res.OK = true
res.Messages = append(res.Messages, "would protect replies: "+describeTarget(wan, plan.Mark))
return res
}
rb := &rollback{}
changed := false
// fail aborts the transaction: it undoes everything applied so far and
// reports any rollback step that itself failed, so nothing is ever lost
// silently.
fail := func(msg string) proto.Result {
res.Error = msg
var failed int
for _, rbErr := range rb.run(ctx) {
failed++
res.Messages = append(res.Messages, "rollback warning: "+rbErr.Error())
}
if failed > 0 {
res.Error = fmt.Sprintf("%s (rollback incomplete: %d step(s) failed — inspect with 'prx doctor')", msg, failed)
}
return res
}
// 1) Protect inbound replies before the engine takes over routing.
if err := nft.Apply(ctx, r, script); err != nil {
return fail("apply nftables rules: " + err.Error())
}
rb.add(func(c context.Context) error { return nft.Remove(c, r) })
changed = true
// 2) Policy routing for marked packets. The rollback for a table is
// registered before the table is touched so that a failure part-way
// through still removes anything that was added.
for _, target := range []struct {
family int
gateway string
table int
priority int
}{
{family: 4, gateway: wan.Gateway4, table: plan.TableV4, priority: plan.PriorityV4},
{family: 6, gateway: wan.Gateway6, table: plan.TableV6, priority: plan.PriorityV6},
} {
if target.gateway == "" {
continue
}
family, table := target.family, target.table
rb.add(func(c context.Context) error { return removeTable(c, r, family, table) })
spec := iprule.Spec{
Family: family,
Mark: plan.Mark,
Table: table,
Priority: target.priority,
Gateway: target.gateway,
Dev: wan.Interface,
}
if err := ensureRouting(ctx, r, spec, &changed); err != nil {
return fail(err.Error())
}
}
// 3) Destination-based bypass (direct) routing.
if err := applyBypass(ctx, r, plan, wan, rb, &changed); err != nil {
return fail(err.Error())
}
// 4) Ensure the engine is running.
engineWasRunning := false
if plan.ManageEngine && !plan.SkipEngine && plan.ServiceName != "" {
active, err := engine.IsActive(ctx, r, plan.ServiceName)
if err != nil {
return fail(err.Error())
}
engineWasRunning = active
if !active {
if err := engine.Start(ctx, r, plan.ServiceName); err != nil {
return fail(err.Error())
}
changed = true
service := plan.ServiceName
rb.add(func(c context.Context) error { return engine.Stop(c, r, service) })
}
}
// 5) Health checks.
checks, fatalOK := runChecks(ctx, r, plan, wan)
res.Checks = checks
if !fatalOK {
return fail("post-apply health checks failed; changes rolled back")
}
// 6) Record state.
st := &state.State{
Enabled: true,
WANInterface: wan.Interface,
Gateway4: wan.Gateway4,
Gateway6: wan.Gateway6,
Mark: plan.Mark,
TableV4: plan.TableV4,
TableV6: plan.TableV6,
PriorityV4: plan.PriorityV4,
PriorityV6: plan.PriorityV6,
BypassTableV4: plan.BypassTableV4,
BypassTableV6: plan.BypassTableV6,
BypassPrioV4: plan.BypassPrioV4,
BypassPrioV6: plan.BypassPrioV6,
BypassCIDRs: plan.BypassCIDRs,
EngineManaged: plan.ManageEngine && !plan.SkipEngine,
EngineWasRunning: engineWasRunning,
EnabledAt: time.Now().UTC(),
}
if err := state.Save(plan.StatePath, st); err != nil {
return fail(err.Error())
}
res.OK = true
res.Changed = changed
res.State = &proto.StateInfo{
Enabled: true,
WANInterface: wan.Interface,
Gateway4: wan.Gateway4,
Gateway6: wan.Gateway6,
Mark: plan.Mark,
}
res.Messages = append(res.Messages, "proxy routing enabled: "+describeTarget(wan, plan.Mark))
return res
}
// describeTarget renders a human readable summary of the applied configuration.
func describeTarget(wan netdetect.WAN, mark uint32) string {
parts := []string{"interface " + wan.Interface}
if wan.Gateway4 != "" {
parts = append(parts, "gw4 "+wan.Gateway4)
}
if wan.Gateway6 != "" {
parts = append(parts, "gw6 "+wan.Gateway6)
}
parts = append(parts, fmt.Sprintf("mark 0x%x", mark))
return strings.Join(parts, ", ")
}
func ensureRouting(ctx context.Context, r execx.Runner, spec iprule.Spec, changed *bool) error {
ch, err := iprule.EnsureRule(ctx, r, spec)
if err != nil {
return err
}
*changed = *changed || ch
ch, err = iprule.EnsureRoute(ctx, r, spec.Family, spec.Table, spec.Gateway, spec.Dev)
if err != nil {
return err
}
*changed = *changed || ch
return nil
}
func applyBypass(ctx context.Context, r execx.Runner, plan proto.Plan, wan netdetect.WAN, rb *rollback, changed *bool) error {
registeredV4, registeredV6 := false, false
for _, cidr := range plan.BypassCIDRs {
ip, _, err := net.ParseCIDR(cidr)
if err != nil {
return fmt.Errorf("invalid bypass CIDR %q: %w", cidr, err)
}
family, table, prio, gw := 4, plan.BypassTableV4, plan.BypassPrioV4, wan.Gateway4
if ip.To4() == nil {
family, table, prio, gw = 6, plan.BypassTableV6, plan.BypassPrioV6, wan.Gateway6
}
if gw == "" {
// No gateway for this family on this host; skip silently.
continue
}
// Register the table cleanup before touching it.
if family == 4 && !registeredV4 {
t := table
rb.add(func(c context.Context) error { return removeTable(c, r, 4, t) })
registeredV4 = true
}
if family == 6 && !registeredV6 {
t := table
rb.add(func(c context.Context) error { return removeTable(c, r, 6, t) })
registeredV6 = true
}
if ch, err := iprule.EnsureCIDRRoute(ctx, r, family, table, cidr, gw, wan.Interface); err != nil {
return err
} else {
*changed = *changed || ch
}
spec := iprule.BypassSpec{Family: family, CIDR: cidr, Table: table, Priority: prio, Gateway: gw, Dev: wan.Interface}
if ch, err := iprule.EnsureBypassRule(ctx, r, spec); err != nil {
return err
} else {
*changed = *changed || ch
}
}
return nil
}
func removeTable(ctx context.Context, r execx.Runner, family, table int) error {
if _, err := iprule.RemoveRulesByTable(ctx, r, family, table); err != nil {
return err
}
return iprule.FlushTable(ctx, r, family, table)
}
func remove(ctx context.Context, r execx.Runner, plan proto.Plan) (res proto.Result) {
res = proto.Result{Action: proto.ActionRemove}
if err := validatePlan(plan); err != nil {
res.Error = err.Error()
return res
}
if err := preflight(r, plan.StopEngineOnOff && plan.ManageEngine && plan.ServiceName != ""); err != nil {
res.Error = err.Error()
return res
}
unlock, err := lockState(plan.StatePath)
if err != nil {
res.Error = err.Error()
return res
}
defer func() {
if uerr := unlock(); uerr != nil {
res.Messages = append(res.Messages, "warning: "+uerr.Error())
}
}()
var msgs []string
for _, family := range []int{4, 6} {
table := plan.TableV4
bp := plan.BypassTableV4
if family == 6 {
table = plan.TableV6
bp = plan.BypassTableV6
}
for _, spec := range []struct {
label string
table int
}{
{label: fmt.Sprintf("ipv%d policy", family), table: table},
{label: fmt.Sprintf("ipv%d bypass", family), table: bp},
} {
n, err := iprule.RemoveRulesByTable(ctx, r, family, spec.table)
if err != nil {
res.Error = err.Error()
return res
}
if n > 0 {
msgs = append(msgs, fmt.Sprintf("removed %d %s rule(s)", n, spec.label))
}
if err := iprule.FlushTable(ctx, r, family, spec.table); err != nil {
res.Error = err.Error()
return res
}
}
}
if err := nft.Remove(ctx, r); err != nil {
res.Error = "remove nftables table: " + err.Error()
return res
}
if plan.StopEngineOnOff && plan.ManageEngine && plan.ServiceName != "" {
if err := engine.Stop(ctx, r, plan.ServiceName); err != nil {
res.Messages = append(res.Messages, "warning: "+err.Error())
} else {
msgs = append(msgs, "stopped engine "+plan.ServiceName)
}
}
if err := state.Remove(plan.StatePath); err != nil {
res.Messages = append(res.Messages, "warning: "+err.Error())
}
res.OK = true
res.Changed = true
res.Messages = append(msgs, "proxy routing disabled")
res.State = &proto.StateInfo{Enabled: false}
return res
}
func inspect(ctx context.Context, r execx.Runner, plan proto.Plan) proto.Result {
res := proto.Result{Action: proto.ActionInspect, OK: true}
exists, err := nft.Exists(ctx, r)
if err != nil {
res.Checks = append(res.Checks, proto.Check{Name: "nft-table", OK: false, Fatal: true, Detail: err.Error()})
} else {
res.Checks = append(res.Checks, proto.Check{Name: "nft-table", OK: exists, Fatal: true, Detail: nft.Table()})
}
wan, err := netdetect.Detect(ctx, r, plan.WANInterface, plan.WANGateway4, plan.WANGateway6)
if err != nil {
res.Checks = append(res.Checks, proto.Check{Name: "wan", OK: false, Detail: err.Error(), Fatal: true})
return res
}
res.Checks = append(res.Checks, proto.Check{Name: "wan", OK: true, Detail: describeTarget(wan, plan.Mark)})
for _, target := range []struct {
family int
gateway string
table int
priority int
}{
{family: 4, gateway: wan.Gateway4, table: plan.TableV4, priority: plan.PriorityV4},
{family: 6, gateway: wan.Gateway6, table: plan.TableV6, priority: plan.PriorityV6},
} {
if target.gateway == "" {
// No gateway for this family on this host: nothing to check.
continue
}
spec := iprule.Spec{Family: target.family, Mark: plan.Mark, Table: target.table, Priority: target.priority}
ruleOK, err := iprule.RuleExists(ctx, r, spec)
res.Checks = append(res.Checks, proto.Check{
Name: fmt.Sprintf("ipv%d-rule", target.family),
OK: ruleOK && err == nil,
Fatal: true,
Detail: errDetail(err, fmt.Sprintf("fwmark 0x%x -> table %d prio %d", plan.Mark, target.table, target.priority)),
})
routeOK, err := iprule.TableHasRoutes(ctx, r, target.family, target.table)
res.Checks = append(res.Checks, proto.Check{
Name: fmt.Sprintf("ipv%d-route", target.family),
OK: routeOK && err == nil,
Fatal: true,
Detail: errDetail(err, "table "+strconv.Itoa(target.table)),
})
}
if plan.ManageEngine && plan.ServiceName != "" {
active, err := engine.IsActive(ctx, r, plan.ServiceName)
res.Checks = append(res.Checks, proto.Check{Name: "engine", OK: active && err == nil, Warn: true, Detail: errDetail(err, plan.ServiceName)})
}
tuns, tunErr := netdetect.TunInterfaces(ctx, r)
tunDetail := strings.Join(tuns, ", ")
if tunErr != nil {
tunDetail = errDetail(tunErr, tunDetail)
}
res.Checks = append(res.Checks, proto.Check{
Name: "tun",
OK: tunErr == nil && len(tuns) > 0,
Warn: true,
Detail: tunDetail,
})
// A corrupt state file must be reported rather than silently reported as
// "disabled".
st, loadErr := state.Load(plan.StatePath)
if loadErr != nil {
res.Checks = append(res.Checks, proto.Check{
Name: "state",
OK: false,
Warn: true,
Detail: loadErr.Error(),
})
}
res.State = &proto.StateInfo{
Enabled: st.Enabled,
WANInterface: st.WANInterface,
Gateway4: st.Gateway4,
Gateway6: st.Gateway6,
Mark: st.Mark,
}
return res
}
func engineAction(ctx context.Context, r execx.Runner, plan proto.Plan) proto.Result {
res := proto.Result{Action: proto.ActionEngine}
if plan.ServiceName == "" || !config.ValidServiceName(plan.ServiceName) {
res.Error = fmt.Sprintf("invalid service name %q", plan.ServiceName)
return res
}
switch plan.EngineOp {
case proto.EngineStart:
if err := engine.Start(ctx, r, plan.ServiceName); err != nil {
res.Error = err.Error()
return res
}
case proto.EngineStop:
if err := engine.Stop(ctx, r, plan.ServiceName); err != nil {
res.Error = err.Error()
return res
}
case proto.EngineReload:
if err := engine.Reload(ctx, r, plan.ServiceName); err != nil {
res.Error = err.Error()
return res
}
case proto.EngineStatus:
active, err := engine.IsActive(ctx, r, plan.ServiceName)
if err != nil {
res.Error = err.Error()
return res
}
res.OK = true
res.Messages = append(res.Messages, fmt.Sprintf("%s active=%v", plan.ServiceName, active))
return res
default:
res.Error = fmt.Sprintf("unknown engine operation %q", plan.EngineOp)
return res
}
res.OK = true
res.Changed = true
return res
}
func initEngine(plan proto.Plan) proto.Result {
res := proto.Result{Action: proto.ActionInitEngine}
if plan.EngineConfig == "" || !filepath.IsAbs(plan.EngineConfig) {
res.Error = "engine config path must be absolute"
return res
}
if len(plan.EngineConfigBody) == 0 {
res.Error = "no engine config body supplied"
return res
}
// Reject anything that is not valid JSON before writing it to disk.
var probe json.RawMessage
if err := json.Unmarshal(plan.EngineConfigBody, &probe); err != nil {
res.Error = "engine config is not valid JSON: " + err.Error()
return res
}
if err := fsx.WriteFileAtomic(plan.EngineConfig, plan.EngineConfigBody, 0o644); err != nil {
res.Error = err.Error()
return res
}
res.OK = true
res.Changed = true
res.Messages = append(res.Messages, "wrote engine config to "+plan.EngineConfig)
return res
}
func runChecks(ctx context.Context, r execx.Runner, plan proto.Plan, wan netdetect.WAN) ([]proto.Check, bool) {
var checks []proto.Check
fatalOK := true
addFatal := func(name string, ok bool, detail string) {
checks = append(checks, proto.Check{Name: name, OK: ok, Detail: detail, Fatal: true})
if !ok {
fatalOK = false
}
}
exists, err := nft.Exists(ctx, r)
if err != nil {
addFatal("nft-table", false, err.Error())
} else {
addFatal("nft-table", exists, nft.Table())
}
for _, target := range []struct {
family int
gateway string
table int
priority int
}{
{family: 4, gateway: wan.Gateway4, table: plan.TableV4, priority: plan.PriorityV4},
{family: 6, gateway: wan.Gateway6, table: plan.TableV6, priority: plan.PriorityV6},
} {
if target.gateway == "" {
continue
}
ruleOK, err := iprule.RuleExists(ctx, r, iprule.Spec{Family: target.family, Mark: plan.Mark, Table: target.table, Priority: target.priority})
addFatal(fmt.Sprintf("ipv%d-rule", target.family), ruleOK && err == nil, errDetail(err, "fwmark rule present"))
routeOK, err := iprule.RouteExists(ctx, r, target.family, target.table, target.gateway, wan.Interface)
addFatal(fmt.Sprintf("ipv%d-route", target.family), routeOK && err == nil, errDetail(err, "table "+strconv.Itoa(target.table)))
}
if plan.ManageEngine && !plan.SkipEngine && plan.ServiceName != "" {
active, err := engine.IsActive(ctx, r, plan.ServiceName)
addFatal("engine-active", active && err == nil, errDetail(err, plan.ServiceName))
tuns, tunErr := netdetect.TunInterfaces(ctx, r)
detail := strings.Join(tuns, ", ")
if tunErr != nil {
detail = errDetail(tunErr, detail)
}
checks = append(checks, proto.Check{Name: "tun", OK: tunErr == nil && len(tuns) > 0, Warn: true, Detail: detail})
}
if plan.HealthProbe != "" {
if err := health.DialTCP(ctx, plan.HealthProbe, healthTimeout); err != nil {
checks = append(checks, proto.Check{
Name: "connectivity",
OK: false,
Warn: true,
Detail: fmt.Sprintf("%v (set health.probe, or \"\" to disable)", err),
})
} else {
checks = append(checks, proto.Check{Name: "connectivity", OK: true, Detail: "tcp " + plan.HealthProbe})
}
}
return checks, fatalOK
}
func errDetail(err error, ok string) string {
if err != nil {
return err.Error()
}
return ok
}
// packageHints names the package that provides each required binary, so the
// preflight failure is actionable on any distribution.
var packageHints = map[string]string{
"nft": "install nftables",
"ip": "install iproute2",
"systemctl": "install systemd",
"journalctl": "install systemd",
}
// preflight verifies the external commands required for an operation exist.
func preflight(r execx.Runner, needSystemctl bool) error {
bins := []string{"nft", "ip"}
if needSystemctl {
bins = append(bins, "systemctl")
}
for _, bin := range bins {
if _, err := r.LookPath(bin); err != nil {
return fmt.Errorf("required binary %q not found in PATH; %s to continue", bin, packageHints[bin])
}
}
return nil
}
// validatePlan re-validates every field of an untrusted plan before use.
func validatePlan(plan proto.Plan) error {
if plan.Mark == 0 {
return errors.New("mark must be non-zero")
}
if plan.WANInterface != "" && !config.ValidInterfaceName(plan.WANInterface) {
return fmt.Errorf("invalid wan_interface %q", plan.WANInterface)
}
if plan.WANGateway4 != "" && net.ParseIP(plan.WANGateway4) == nil {
return fmt.Errorf("invalid wan_gateway_v4 %q", plan.WANGateway4)
}
if plan.WANGateway6 != "" && net.ParseIP(plan.WANGateway6) == nil {
return fmt.Errorf("invalid wan_gateway_v6 %q", plan.WANGateway6)
}
for _, check := range []struct {
field string
value int
}{
{"table_v4", plan.TableV4},
{"table_v6", plan.TableV6},
{"bypass_table_v4", plan.BypassTableV4},
{"bypass_table_v6", plan.BypassTableV6},
} {
if err := config.CheckTableID(check.field, check.value); err != nil {
return err
}
}
for _, check := range []struct {
field string
value int
}{
{"priority_v4", plan.PriorityV4},
{"priority_v6", plan.PriorityV6},
{"bypass_priority_v4", plan.BypassPrioV4},
{"bypass_priority_v6", plan.BypassPrioV6},
} {
if err := config.CheckPriority(check.field, check.value); err != nil {
return err
}
}
if plan.ManageEngine && plan.ServiceName != "" && !config.ValidServiceName(plan.ServiceName) {
return fmt.Errorf("invalid service name %q", plan.ServiceName)
}
if plan.EngineConfig != "" && !filepath.IsAbs(plan.EngineConfig) {
return fmt.Errorf("engine_config %q must be absolute", plan.EngineConfig)
}
if plan.StatePath == "" || !filepath.IsAbs(plan.StatePath) {
return fmt.Errorf("state_path %q must be an absolute path", plan.StatePath)
}
for _, cidr := range plan.BypassCIDRs {
if _, _, err := net.ParseCIDR(cidr); err != nil {
return fmt.Errorf("invalid bypass CIDR %q", cidr)
}
}
return nil
}
+298
View File
@@ -0,0 +1,298 @@
// SPDX-License-Identifier: MIT
package helper
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"prx/internal/execx"
"prx/internal/proto"
)
func basePlan(t *testing.T) proto.Plan {
t.Helper()
return proto.Plan{
Action: proto.ActionApply,
Mark: 0x100,
TableV4: 100,
TableV6: 101,
PriorityV4: 100,
PriorityV6: 100,
BypassTableV4: 102,
BypassTableV6: 103,
BypassPrioV4: 50,
BypassPrioV6: 50,
ManageEngine: false,
StatePath: filepath.Join(t.TempDir(), "state.json"),
}
}
// systemFake emulates a host with a working WAN and optionally active engine.
func systemFake(active bool) *execx.Fake {
return &execx.Fake{Handler: func(name string, args []string, _ []byte) (execx.Result, error) {
joined := strings.TrimSpace(name + " " + strings.Join(args, " "))
switch {
case joined == "ip -j route show default":
return execx.Result{Stdout: []byte(`[{"dst":"default","gateway":"192.168.1.1","dev":"wlan0","metric":600}]`)}, nil
case joined == "ip -6 -j route show default":
return execx.Result{Stdout: []byte(`[]`)}, nil
case joined == "ip -j addr show dev wlan0":
return execx.Result{Stdout: []byte(`[{"ifname":"wlan0","addr_info":[{"family":"inet","local":"192.168.1.100","prefixlen":24,"scope":"global"}]}]`)}, nil
case joined == "ip rule show" || joined == "ip -6 rule show":
return execx.Result{Stdout: []byte("100:\tfrom all fwmark 0x100 lookup 100\n")}, nil
case strings.HasPrefix(joined, "ip route show table"):
return execx.Result{Stdout: []byte("default via 192.168.1.1 dev wlan0\n")}, nil
case joined == "ip -j link show type tun":
return execx.Result{Stdout: []byte(`[{"ifname":"tun0"}]`)}, nil
case strings.HasPrefix(joined, "nft"):
return execx.Result{}, nil
case strings.HasPrefix(joined, "systemctl is-active"):
if active {
return execx.Result{Stdout: []byte("active\n")}, nil
}
return execx.Result{Stdout: []byte("inactive\n"), Code: 3}, &execx.ExitError{Name: "systemctl", Code: 3, Stderr: []byte("inactive")}
case strings.HasPrefix(joined, "systemctl start"):
return execx.Result{}, nil
case strings.HasPrefix(joined, "systemctl stop"):
return execx.Result{}, nil
}
return execx.Result{}, nil
}}
}
func TestValidatePlan(t *testing.T) {
cases := map[string]func(*proto.Plan){
"zero mark": func(p *proto.Plan) { p.Mark = 0 },
"reserved table": func(p *proto.Plan) { p.TableV4 = 254 },
"bad priority": func(p *proto.Plan) { p.PriorityV4 = 99999 },
"bad cidr": func(p *proto.Plan) { p.BypassCIDRs = []string{"nope"} },
"relative state": func(p *proto.Plan) { p.StatePath = "state.json" },
"bad iface": func(p *proto.Plan) { p.WANInterface = "bad iface!" },
"bad gateway": func(p *proto.Plan) { p.WANGateway4 = "not-ip" },
}
for name, mutate := range cases {
p := basePlan(t)
mutate(&p)
if err := validatePlan(p); err == nil {
t.Errorf("case %q: expected error", name)
}
}
if err := validatePlan(basePlan(t)); err != nil {
t.Fatalf("valid plan rejected: %v", err)
}
}
func TestApplyDryRunChangesNothing(t *testing.T) {
p := basePlan(t)
p.DryRun = true
res := Execute(context.Background(), systemFake(false), p)
if !res.OK {
t.Fatalf("dry run failed: %+v", res)
}
if res.Changed {
t.Fatal("dry run must not report changes")
}
}
func TestApplySkipEngineSucceeds(t *testing.T) {
p := basePlan(t)
p.SkipEngine = true
f := systemFake(false)
res := Execute(context.Background(), f, p)
if !res.OK {
t.Fatalf("apply failed: %+v", res)
}
if _, err := filepath.Glob(p.StatePath); err != nil {
t.Fatal(err)
}
if !hasCall(f.Calls, "nft", "-f", "-") {
t.Fatal("expected nft apply call")
}
}
func TestApplyRollsBackOnHealthFailure(t *testing.T) {
p := basePlan(t)
p.ManageEngine = true
p.ServiceName = "sing-box"
p.SkipEngine = false
f := systemFake(false) // engine never becomes active
res := Execute(context.Background(), f, p)
if res.OK {
t.Fatal("expected apply to fail")
}
if !strings.Contains(res.Error, "rolled back") {
t.Fatalf("expected rollback message, got %q", res.Error)
}
if !hasCall(f.Calls, "nft", "destroy", "table", "inet", "prx") {
t.Fatal("expected rollback to destroy the nft table")
}
}
func TestRemoveIsIdempotent(t *testing.T) {
p := basePlan(t)
p.Action = proto.ActionRemove
p.StatePath = filepath.Join(t.TempDir(), "state.json")
f := &execx.Fake{Handler: func(name string, args []string, _ []byte) (execx.Result, error) {
if strings.HasPrefix(strings.TrimSpace(name+" "+strings.Join(args, " ")), "nft") {
return execx.Result{}, nil
}
return execx.Result{Stdout: []byte("")}, nil
}}
for i := 0; i < 2; i++ {
res := Execute(context.Background(), f, p)
if !res.OK {
t.Fatalf("remove iteration %d failed: %+v", i, res)
}
}
}
func TestInspectReturnsChecks(t *testing.T) {
p := basePlan(t)
p.Action = proto.ActionInspect
res := Execute(context.Background(), systemFake(true), p)
if !res.OK {
t.Fatalf("inspect failed: %+v", res)
}
if len(res.Checks) == 0 {
t.Fatal("expected checks")
}
}
func TestInitEngineRejectsBadJSON(t *testing.T) {
p := proto.Plan{
Action: proto.ActionInitEngine,
EngineConfig: filepath.Join(t.TempDir(), "config.json"),
EngineConfigBody: []byte("not json"),
}
res := Execute(context.Background(), systemFake(false), p)
if res.OK {
t.Fatal("expected rejection of invalid JSON")
}
}
func hasCall(calls []execx.Call, name string, args ...string) bool {
for _, c := range calls {
if c.Name != name || len(c.Args) != len(args) {
continue
}
match := true
for i := range args {
if c.Args[i] != args[i] {
match = false
break
}
}
if match {
return true
}
}
return false
}
// TestApplyRollsBackPartialRoutingFailure verifies that a failure after the
// policy rule was added still cleans up the routing table. The rollback for a
// table must be registered before the table is touched.
func TestApplyRollsBackPartialRoutingFailure(t *testing.T) {
p := basePlan(t)
p.SkipEngine = true
f := &execx.Fake{Handler: func(name string, args []string, _ []byte) (execx.Result, error) {
joined := strings.TrimSpace(name + " " + strings.Join(args, " "))
switch {
case joined == "ip -j route show default":
return execx.Result{Stdout: []byte(`[{"dst":"default","gateway":"192.168.1.1","dev":"wlan0","metric":600}]`)}, nil
case joined == "ip -6 -j route show default":
return execx.Result{Stdout: []byte(`[]`)}, nil
case joined == "ip -j addr show dev wlan0":
return execx.Result{Stdout: []byte(`[{"ifname":"wlan0","addr_info":[{"family":"inet","local":"192.168.1.100","prefixlen":24,"scope":"global"}]}]`)}, nil
case joined == "ip rule show":
// No rule present yet, so EnsureRule will add one.
return execx.Result{Stdout: []byte("")}, nil
case joined == "ip route show table 100":
// RouteExists cannot read the table: force EnsureRoute to fail.
return execx.Result{Code: 2, Stderr: []byte("RTNETLINK answers: Operation not permitted")}, &execx.ExitError{Name: "ip", Code: 2, Stderr: []byte("RTNETLINK answers: Operation not permitted")}
case strings.HasPrefix(joined, "nft"):
return execx.Result{}, nil
}
return execx.Result{}, nil
}}
res := Execute(context.Background(), f, p)
if res.OK {
t.Fatalf("expected apply to fail: %+v", res)
}
if !hasCall(f.Calls, "ip", "rule", "add", "fwmark", "0x100", "lookup", "100", "priority", "100") {
t.Fatal("expected the policy rule to have been added")
}
if !hasCall(f.Calls, "ip", "route", "flush", "table", "100") {
t.Fatal("partial failure leaked routing table 100 (rollback not registered early enough)")
}
if !hasCall(f.Calls, "nft", "destroy", "table", "inet", "prx") {
t.Fatal("expected rollback to destroy the nft table")
}
if _, err := os.Stat(p.StatePath); !os.IsNotExist(err) {
t.Fatalf("state file should not exist after rollback, stat err = %v", err)
}
}
func TestApplyAcquiresStateLock(t *testing.T) {
p := basePlan(t)
p.SkipEngine = true
if res := Execute(context.Background(), systemFake(false), p); !res.OK {
t.Fatalf("apply failed: %+v", res)
}
lock := filepath.Join(filepath.Dir(p.StatePath), "prx.lock")
if _, err := os.Stat(lock); err != nil {
t.Fatalf("expected a lock file next to the state: %v", err)
}
}
// TestApplyReportsIncompleteRollback verifies that a rollback step which fails
// is surfaced to the caller instead of being silently discarded.
func TestApplyReportsIncompleteRollback(t *testing.T) {
p := basePlan(t)
p.SkipEngine = true
f := &execx.Fake{Handler: func(name string, args []string, _ []byte) (execx.Result, error) {
joined := strings.TrimSpace(name + " " + strings.Join(args, " "))
switch {
case joined == "ip -j route show default":
return execx.Result{Stdout: []byte(`[{"dst":"default","gateway":"192.168.1.1","dev":"wlan0","metric":600}]`)}, nil
case joined == "ip -6 -j route show default":
return execx.Result{Stdout: []byte(`[]`)}, nil
case joined == "ip -j addr show dev wlan0":
return execx.Result{Stdout: []byte(`[{"ifname":"wlan0","addr_info":[{"family":"inet","local":"192.168.1.100","prefixlen":24,"scope":"global"}]}]`)}, nil
case joined == "ip rule show":
return execx.Result{Stdout: []byte("")}, nil
case joined == "ip route show table 100":
return execx.Result{Code: 2, Stderr: []byte("RTNETLINK answers: Operation not permitted")}, &execx.ExitError{Name: "ip", Code: 2, Stderr: []byte("RTNETLINK answers: Operation not permitted")}
case joined == "ip route flush table 100":
// The rollback itself fails.
return execx.Result{Code: 2, Stderr: []byte("flush refused")}, &execx.ExitError{Name: "ip", Code: 2, Stderr: []byte("flush refused")}
case strings.HasPrefix(joined, "nft"):
return execx.Result{}, nil
}
return execx.Result{}, nil
}}
res := Execute(context.Background(), f, p)
if res.OK {
t.Fatalf("expected apply to fail: %+v", res)
}
if !strings.Contains(res.Error, "rollback incomplete") {
t.Fatalf("res.Error = %q, want it to flag an incomplete rollback", res.Error)
}
found := false
for _, m := range res.Messages {
if strings.Contains(m, "rollback warning") {
found = true
}
}
if !found {
t.Fatalf("expected a rollback warning message, got %v", res.Messages)
}
}
+11
View File
@@ -0,0 +1,11 @@
// SPDX-License-Identifier: MIT
//go:build !unix
package helper
// lockState is a no-op on platforms without advisory file locking. prx only
// supports Unix-like systems; this exists so the package still builds elsewhere.
func lockState(_ string) (func() error, error) {
return func() error { return nil }, nil
}
+43
View File
@@ -0,0 +1,43 @@
// SPDX-License-Identifier: MIT
//go:build unix
package helper
import (
"errors"
"fmt"
"os"
"path/filepath"
"syscall"
)
// lockState serialises concurrent apply/remove operations using an advisory
// file lock stored next to the state file. It returns a release function that
// must always be called (typically deferred); its error reports a release that
// failed, which the caller should surface.
func lockState(statePath string) (func() error, error) {
dir := filepath.Dir(statePath)
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("create %s: %w", dir, err)
}
path := filepath.Join(dir, "prx.lock")
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return nil, fmt.Errorf("open lock %s: %w", path, err)
}
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil {
_ = f.Close()
return nil, fmt.Errorf("lock %s: %w", path, err)
}
return func() error {
var errs []error
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_UN); err != nil {
errs = append(errs, fmt.Errorf("unlock %s: %w", path, err))
}
if err := f.Close(); err != nil {
errs = append(errs, fmt.Errorf("close %s: %w", path, err))
}
return errors.Join(errs...)
}, nil
}
+81
View File
@@ -0,0 +1,81 @@
.\" Manual page for prx-helper
.\" Copyright (C) 2026 Amir Husayn Panahifar <ahp@panahifar.ir>
.TH PRX-HELPER 1 "2026-10-15" "prx 0.1.0" "User Commands"
.SH NAME
prx-helper \- privileged core of prx
.SH SYNOPSIS
.B prx-helper
.SH DESCRIPTION
.B prx-helper
is the privileged half of
.BR prx (1).
It reads a single JSON
.I plan
from standard input, validates it, executes it, and writes a JSON result to
standard output.
.PP
It is not meant to be run by hand; the unprivileged
.B prx
CLI launches it through
.BR pkexec (1)
or
.BR sudo (8),
and communicates over standard input and standard output.
It refuses to run without root privileges.
.PP
Every field of the incoming plan is re\-validated before any system change is
made, so the privileged attack surface stays small.
The
.B apply
operation is transactional: if a post\-apply health check fails, the changes
made so far are rolled back.
If a rollback step itself fails, that is reported in the result rather than
being ignored.
.SH ACTIONS
The plan's
.B action
field selects one of:
.TP
.B apply
Detect the WAN interface and gateway, install the nftables table, the policy
rule and route, any bypass rules and routes, ensure the engine is running, run
health checks, and record the state snapshot.
A health\-check failure triggers a rollback.
.TP
.B remove
Remove the prx\-managed rules, tables and routes, optionally stop the engine,
and delete the state snapshot.
.TP
.B inspect
Report the state of the rules, tables, engine and TUN interfaces without
changing anything.
.TP
.B engine
Start, stop, reload or query the engine service.
.TP
.B init-engine
Write a generated engine configuration to disk after validating it as JSON.
.SH SECURITY
Input is treated as untrusted.
Interface names, IP addresses, CIDR blocks, route table identifiers, rule
priorities, marks and service names are validated against strict patterns and
ranges before being used to build nftables scripts or
.B ip
commands.
.SH "EXIT STATUS"
.TP
.B 0
The action succeeded.
.TP
.B 1
The action failed, or the process was not run as root.
.TP
.B 2
The plan on standard input was malformed, or the result could not be encoded.
.SH "SEE ALSO"
.BR prx (1),
.BR polkit (8),
.BR pkexec (1),
.BR sudo (8)
.SH AUTHOR
Amir Husayn Panahifar <ahp@panahifar.ir>.
+279
View File
@@ -0,0 +1,279 @@
.\" Manual page for prx
.\" Copyright (C) 2026 Amir Husayn Panahifar <ahp@panahifar.ir>
.TH PRX 1 "2026-10-15" "prx 0.1.0" "User Commands"
.SH NAME
prx \- safe proxy routing manager
.SH SYNOPSIS
.B prx
.RI [ options ]
.I command
.RI [ arguments ]
.SH DESCRIPTION
.B prx
turns proxy routing on and off without breaking the connections that reach
your host.
In particular, an inbound session that is already open when the proxy is
enabled (for example an SSH login) keeps working, regardless of the port it
uses.
.PP
The tool orchestrates an external proxy engine (by default
.BR sing-box (1)
in TUN mode, managed through systemd) and installs the network configuration
needed to keep reply traffic on the physical uplink:
.IP "1." 4
Incoming connections on the WAN interface are tagged with a conntrack mark
(\fB0x100\fR by default).
.IP "2." 4
Outgoing packets belonging to a connection whose
.I original
destination is one of the host's WAN addresses are tagged as well.
Because the original destination is matched, connections that were already
established before
.B prx on
are protected too.
.IP "3." 4
An
.BR ip-rule (8)
entry routes marked packets through a dedicated routing table whose default
route points at the physical gateway rather than the tunnel.
.PP
Because the decision is based on the connection and not on a port number, the
protection is port\-independent.
.PP
All privileged work is performed by a small helper,
.BR prx-helper (1),
which the unprivileged
.B prx
CLI invokes through
.BR pkexec (1)
(polkit), falling back to
.BR sudo (8)
when polkit is not installed.
.SH OPTIONS
.SS Global options
.TP
.BR \-\-config " " \fIFILE\fR
Read configuration from
.IR FILE .
Defaults to
.IR $XDG_CONFIG_HOME/prx/config.json
(or
.IR ~/.config/prx/config.json ).
The environment variable
.B PRX_CONFIG
overrides this default.
.TP
.BR \-v ", " \-\-verbose
Enable verbose logging on standard error.
.TP
.BR \-h ", " \-\-help
Show help for
.B prx
or for a subcommand.
.SH COMMANDS
.TP
.BR on " [" \-\-skip\-engine "] [" \-\-dry\-run ]
Enable proxy routing.
Installs the nftables table
.BR "inet prx" ,
the policy rule and route, and (for extra bypass CIDRs) the bypass rules and
routes, then ensures the engine service is running and runs health checks.
If a blocking health check fails, every change made so far is rolled back and
the command exits non\-zero.
Re\-running
.B on
is idempotent.
.TP
.B off
Disable proxy routing.
Removes only the objects
.B prx
created: the
.B "inet prx"
table, any rules that look up
.BR prx 's
routing tables, and
.BR prx 's
routes.
It never flushes the whole ruleset.
Running
.B off
twice is a no\-op.
.TP
.B toggle
Run
.B on
or
.B off
depending on the current state.
.TP
.BR status " [" \-\-json ]
Print the current status.
Reads the state snapshot and inspects the policy rules without requiring root.
With
.B \-\-json
the result is emitted as JSON.
.TP
.B doctor
Run diagnostics.
Checks required binaries, the configuration, the engine configuration, mark
collisions, the applied rules and the engine status.
Exits non\-zero when a critical (local) problem is found, or when
.B prx
is enabled but the applied rules are inconsistent.
.TP
.BR bypass " add " \fICIDR\fR | \fIIP\fR | \fIDOMAIN\fR
Add a destination to the bypass list.
A CIDR or bare IP is additionally enforced at the routing layer: a route is
installed in the bypass table and a destination\-based
.B ip-rule
is added, so traffic to it never enters the tunnel.
A domain is recorded as an engine hint only.
.TP
.BR bypass " rm " \fICIDR\fR | \fIIP\fR | \fIDOMAIN\fR
Remove an entry from the bypass list.
.TP
.B bypass ls
List the bypass entries.
.TP
.BR logs " [" \-f | \-\-follow ]
Show the proxy engine's journal output (via
.BR journalctl (1)).
.TP
.BR init " [" \-\-force "] [" \-\-engine\-config "]"
Write a default configuration file to the configured path.
With
.B \-\-engine\-config
also generate a baseline engine (sing\-box) configuration.
.TP
.B version
Print version information.
.SH "ON OPTIONS"
.TP
.B \-\-skip\-engine
Apply only the routing layer without starting or stopping the engine.
Useful for testing and when the engine is managed separately.
.TP
.B \-\-dry\-run
Validate the plan with
.BR "nft \-c"
without changing anything.
.SH "INIT OPTIONS"
.TP
.B \-\-force
Overwrite an existing configuration file.
.TP
.B \-\-engine\-config
Also write a baseline engine configuration containing a TUN inbound and a
placeholder outbound.
.TP
.BR \-\-engine\-config\-path " " \fIPATH\fR
Path for the generated engine configuration.
Defaults to
.B engine.config_path
from the prx configuration.
.SH FILES
.TP
.I ~/.config/prx/config.json
User configuration.
.TP
.I /run/prx/state.json
Runtime snapshot written when
.B prx
is enabled; used by
.B off
and for rollback.
.TP
.I /run/prx/prx.lock
Advisory lock held during
.B on
and
.B off
so concurrent runs do not interleave.
.TP
.I /etc/sing-box/config.json
Default engine configuration.
.TP
.I /usr/share/polkit-1/actions/org.prx.policy
Polkit action definition mapping
.B prx-helper
to
.BR org.prx.manage .
.TP
.I /usr/lib/prx/prx-helper
Installed location of the privileged helper.
.B prx
also searches
.IR /usr/libexec/prx ,
.IR /usr/local/lib/prx
and
.IR /usr/local/libexec/prx .
.SH ENVIRONMENT
.TP
.B PRX_CONFIG
Default configuration file path, used when
.B \-\-config
is not given.
.TP
.B PRX_HELPER
Explicit path to the privileged helper, overriding the search locations.
.SH "EXIT STATUS"
.TP
.B 0
Success.
.TP
.B 1
An error occurred; a message is printed to standard error.
.TP
.B 126
Authorization was cancelled or is not permitted (from
.BR pkexec ).
.TP
.B 127
The privileged helper could not be started.
.SH EXAMPLES
Enable the proxy:
.PP
.RS 4
.B prx on
.RE
.PP
Enable only the routing layer, without touching the engine:
.PP
.RS 4
.B prx on \-\-skip\-engine
.RE
.PP
Validate without making changes:
.PP
.RS 4
.B prx on \-\-dry\-run
.RE
.PP
Keep a subnet out of the tunnel and refresh the rules:
.PP
.RS 4
.B prx bypass add 203.0.113.0/24
.RE
.PP
Check status as JSON:
.PP
.RS 4
.B prx status \-\-json
.RE
.PP
Disable the proxy:
.PP
.RS 4
.B prx off
.RE
.SH "SEE ALSO"
.BR prx-helper (1),
.BR nft (8),
.BR ip-rule (8),
.BR ip-route (8),
.BR pkexec (1),
.BR sudo (8),
.BR systemctl (1)
.SH AUTHOR
Amir Husayn Panahifar <ahp@panahifar.ir>.
+40
View File
@@ -0,0 +1,40 @@
# Maintainer: Amir Husayn Panahifar <ahp@panahifar.ir>
pkgname=prx
pkgver=0.1.0
pkgrel=1
pkgdesc="Safe proxy routing manager that keeps inbound connections (e.g. SSH) alive"
arch=('x86_64' 'aarch64')
url="https://github.com/example/prx"
license=('MIT')
depends=('glibc' 'nftables' 'iproute2' 'systemd')
optdepends=(
'sing-box: default proxy engine (TUN mode)'
'polkit: run privileged actions through pkexec without sudo'
)
makedepends=('go')
provides=('prx')
# Adjust `source` to a tagged tarball or a git source before publishing.
source=("$pkgname-$pkgver.tar.gz")
sha256sums=('SKIP')
build() {
cd "$srcdir/$pkgname-$pkgver"
export CGO_ENABLED=0
export GOFLAGS="-trimpath -mod=mod"
go build -ldflags "-s -w -X prx/internal/version.Version=$pkgver" -o prx ./cmd/prx
go build -ldflags "-s -w" -o prx-helper ./cmd/prx-helper
}
package() {
cd "$srcdir/$pkgname-$pkgver"
install -Dm755 prx "$pkgdir/usr/bin/prx"
install -Dm755 prx-helper "$pkgdir/usr/lib/prx/prx-helper"
install -Dm644 polkit/org.prx.policy "$pkgdir/usr/share/polkit-1/actions/org.prx.policy"
install -Dm644 systemd/prx-reapply.service "$pkgdir/usr/lib/systemd/system/prx-reapply.service"
install -Dm644 systemd/prx-reapply.timer "$pkgdir/usr/lib/systemd/system/prx-reapply.timer"
install -Dm644 man/prx.1 "$pkgdir/usr/share/man/man1/prx.1"
install -Dm644 man/prx-helper.1 "$pkgdir/usr/share/man/man1/prx-helper.1"
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
}
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE policyconfig PUBLIC
"-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
"http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
<policyconfig>
<vendor>prx</vendor>
<vendor_url>https://github.com/example/prx</vendor_url>
<!--
Maps the prx-helper executable to a dedicated polkit action. When the
unprivileged CLI runs `pkexec /usr/lib/prx/prx-helper`, polkit evaluates
org.prx.manage instead of the generic org.freedesktop.policykit.exec action.
-->
<action id="org.prx.manage">
<description>Manage prx proxy routing</description>
<message>Authentication is required to change proxy routing</message>
<defaults>
<allow_any>auth_admin</allow_any>
<allow_inactive>auth_admin</allow_inactive>
<allow_active>auth_admin</allow_active>
</defaults>
<annotate key="org.freedesktop.policykit.exec.path">/usr/lib/prx/prx-helper</annotate>
</action>
</policyconfig>
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
#
# prx integration test.
#
# Exercises the real nftables and policy-routing layer on the local host:
# on -> assert rules exist -> on again (idempotent) -> off -> assert clean.
#
# The proxy engine is skipped (--skip-engine) so this does not require sing-box.
# Run it with root, e.g.:
#
# sudo -E PRX_BIN="$PWD/bin/prx" ./scripts/integration-test.sh
#
set -euo pipefail
BIN="${PRX_BIN:-$(pwd)/bin/prx}"
if [[ ! -x "$BIN" ]]; then
echo "prx binary not found at $BIN (build it first: make build)" >&2
exit 1
fi
if [[ "${EUID}" -ne 0 ]]; then
echo "this test needs root; re-run with sudo -E $0" >&2
exit 1
fi
TMP="$(mktemp -d)"
CONFIG="$TMP/config.json"
STATE="$TMP/state.json"
cat >"$CONFIG" <<EOF
{
"state": { "path": "$STATE" }
}
EOF
PRX=("$BIN" --config "$CONFIG")
cleanup() {
echo "--- cleanup ---"
"${PRX[@]}" off >/dev/null 2>&1 || true
rm -rf "$TMP"
}
trap cleanup EXIT
pass() { echo " ok: $1"; }
fail() { echo " FAIL: $1" >&2; exit 1; }
rule_count() {
ip rule show | grep -c "fwmark 0x100" || true
}
echo "--- enabling (skip engine) ---"
"${PRX[@]}" on --skip-engine
echo "--- assertions after on ---"
nft list table inet prx >/dev/null 2>&1 || fail "nftables table inet prx missing"
pass "nftables table inet prx present"
ip rule show | grep -q "fwmark 0x100 lookup 100" || fail "policy rule missing"
pass "policy rule present"
ip route show table 100 | grep -q "^default via" || fail "table 100 has no default route"
pass "table 100 default route present"
[[ "$(rule_count)" -eq 1 ]] || fail "expected exactly one mark rule, got $(rule_count)"
pass "exactly one mark rule"
echo "--- enabling again (idempotency) ---"
"${PRX[@]}" on --skip-engine
[[ "$(rule_count)" -eq 1 ]] || fail "duplicate rule after second on ($(rule_count))"
pass "no duplicate rule after second on"
nft list table inet prx | grep -c "meta mark set" >/dev/null || fail "nft rules missing"
pass "nft rules intact after re-apply"
echo "--- status ---"
"${PRX[@]}" status
echo "--- disabling ---"
"${PRX[@]}" off
echo "--- assertions after off ---"
if nft list table inet prx >/dev/null 2>&1; then
fail "nftables table inet prx still present after off"
fi
pass "nftables table removed"
[[ "$(rule_count)" -eq 0 ]] || fail "policy rule still present after off"
pass "policy rule removed"
echo "--- disabling again (idempotency) ---"
"${PRX[@]}" off
pass "second off succeeded"
echo
echo "ALL INTEGRATION CHECKS PASSED"
+13
View File
@@ -0,0 +1,13 @@
[Unit]
Description=Re-apply prx proxy routing
Documentation=man:prx(1)
After=network-online.target
Wants=network-online.target
# Only run when prx was enabled (state snapshot exists).
ConditionPathExists=/run/prx/state.json
[Service]
Type=oneshot
# `prx on` is idempotent: it re-detects the gateway and refreshes the rules.
ExecStart=/usr/bin/prx on
RemainAfterExit=no
+11
View File
@@ -0,0 +1,11 @@
[Unit]
Description=Periodically re-apply prx proxy routing (handles DHCP/gateway changes)
[Timer]
Unit=prx-reapply.service
OnBootSec=1min
OnUnitActiveSec=5min
Persistent=false
[Install]
WantedBy=timers.target