Compare commits
10
Commits
3e7ec7f44f
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06a224f606
|
||
|
|
77ef81e688
|
||
|
|
20459525f3
|
||
|
|
5cb1c282c1
|
||
|
|
7f11dd2c01
|
||
|
|
d81477c5f9
|
||
|
|
940c47030f
|
||
|
|
1e9b0e9aac
|
||
|
|
66417ab679
|
||
|
|
c3046d51c0
|
@@ -12,8 +12,8 @@ MANDIR ?= $(PREFIX)/share/man
|
|||||||
all: build
|
all: build
|
||||||
|
|
||||||
build:
|
build:
|
||||||
CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/prx ./cmd/prx
|
CGO_ENABLED=0 go build -buildvcs=false -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-helper ./cmd/prx-helper
|
||||||
|
|
||||||
test:
|
test:
|
||||||
go test ./...
|
go test ./...
|
||||||
|
|||||||
@@ -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** <ahp@panahifar.ir> — developer, contributor, author, and maintainer.
|
||||||
|
|
||||||
|
Contributions are welcome.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT. See `LICENSE`.
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"prx/internal/config"
|
||||||
|
"prx/internal/proto"
|
||||||
|
"prx/internal/state"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *app) newBypassCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "bypass",
|
||||||
|
Short: "Manage destinations that must bypass the proxy",
|
||||||
|
}
|
||||||
|
cmd.AddCommand(a.newBypassAddCmd(), a.newBypassRmCmd(), a.newBypassLsCmd())
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) newBypassAddCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "add <cidr|ip|domain>",
|
||||||
|
Short: "Add a CIDR/IP (routed direct) or domain (engine hint) to the bypass list",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
cfg, err := a.loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
arg := args[0]
|
||||||
|
var changed bool
|
||||||
|
if config.IsCIDR(arg) {
|
||||||
|
changed, err = cfg.AddExtraCIDR(arg)
|
||||||
|
} else {
|
||||||
|
changed, err = cfg.AddDomain(arg)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
fmt.Fprintln(a.out, "already present")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := config.Save(a.configPath, cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(a.out, "added %s to bypass list\n", arg)
|
||||||
|
return a.reapplyIfEnabled(cmd.Context(), cfg)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) newBypassRmCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "rm <cidr|ip|domain>",
|
||||||
|
Short: "Remove an entry from the bypass list",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
cfg, err := a.loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
arg := args[0]
|
||||||
|
var changed bool
|
||||||
|
if config.IsCIDR(arg) {
|
||||||
|
changed, err = cfg.RemoveExtraCIDR(arg)
|
||||||
|
} else {
|
||||||
|
changed = cfg.RemoveDomain(arg)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
fmt.Fprintln(a.out, "not present")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := config.Save(a.configPath, cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(a.out, "removed %s from bypass list\n", arg)
|
||||||
|
return a.reapplyIfEnabled(cmd.Context(), cfg)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) newBypassLsCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "ls",
|
||||||
|
Short: "List bypass entries",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(_ *cobra.Command, _ []string) error {
|
||||||
|
cfg, err := a.loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
writeList(a.out, "always (engine hints):", cfg.Bypass.AlwaysCIDRs, false)
|
||||||
|
writeList(a.out, "extra CIDRs (routed direct):", cfg.Bypass.ExtraCIDRs, true)
|
||||||
|
writeList(a.out, "domains (engine hints):", cfg.Bypass.Domains, true)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeList prints a labelled list, showing "(none)" when empty and showEmpty.
|
||||||
|
func writeList(w io.Writer, label string, items []string, showEmpty bool) {
|
||||||
|
fmt.Fprintln(w, label)
|
||||||
|
if len(items) == 0 && showEmpty {
|
||||||
|
fmt.Fprintln(w, " (none)")
|
||||||
|
}
|
||||||
|
for _, item := range items {
|
||||||
|
fmt.Fprintln(w, " "+item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reapplyIfEnabled refreshes the routing rules when prx is currently enabled so
|
||||||
|
// bypass changes take effect immediately.
|
||||||
|
func (a *app) reapplyIfEnabled(ctx context.Context, cfg *config.Config) error {
|
||||||
|
st, err := state.Load(cfg.State.Path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !st.Enabled {
|
||||||
|
fmt.Fprintln(a.out, "takes effect on the next 'prx on'")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := a.dispatch(ctx, cfg, proto.ActionApply, nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintln(a.out, "routing rules refreshed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"prx/internal/config"
|
||||||
|
"prx/internal/execx"
|
||||||
|
"prx/internal/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCheckMark(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
check proto.Check
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{proto.Check{OK: true}, "ok"},
|
||||||
|
{proto.Check{OK: false, Warn: true}, "warn"},
|
||||||
|
{proto.Check{OK: false}, "FAIL"},
|
||||||
|
{proto.Check{OK: false, Fatal: true}, "FAIL"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := checkMark(tc.check); got != tc.want {
|
||||||
|
t.Errorf("checkMark(%+v) = %q, want %q", tc.check, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildPlanMapsConfig(t *testing.T) {
|
||||||
|
cfg := config.Default()
|
||||||
|
cfg.Network.WANInterface = "wlan0"
|
||||||
|
cfg.Network.WANGateway = "192.168.1.1"
|
||||||
|
cfg.Network.WANGateway6 = "2001:db8::1"
|
||||||
|
cfg.Health.Probe = "9.9.9.9:443"
|
||||||
|
cfg.Bypass.ExtraCIDRs = []string{"203.0.113.0/24"}
|
||||||
|
|
||||||
|
a := &app{}
|
||||||
|
plan := a.buildPlan(cfg, proto.ActionApply)
|
||||||
|
|
||||||
|
if plan.Action != proto.ActionApply {
|
||||||
|
t.Errorf("action = %q", plan.Action)
|
||||||
|
}
|
||||||
|
if plan.WANInterface != "wlan0" || plan.WANGateway4 != "192.168.1.1" || plan.WANGateway6 != "2001:db8::1" {
|
||||||
|
t.Errorf("network fields not mapped: %+v", plan)
|
||||||
|
}
|
||||||
|
if plan.HealthProbe != "9.9.9.9:443" {
|
||||||
|
t.Errorf("health probe not mapped: %q", plan.HealthProbe)
|
||||||
|
}
|
||||||
|
if len(plan.BypassCIDRs) != 1 || plan.BypassCIDRs[0] != "203.0.113.0/24" {
|
||||||
|
t.Errorf("bypass CIDRs not mapped: %v", plan.BypassCIDRs)
|
||||||
|
}
|
||||||
|
if plan.StatePath != config.DefaultStatePath {
|
||||||
|
t.Errorf("state path not mapped: %q", plan.StatePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrintResultFormatsChecks(t *testing.T) {
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
a := &app{out: buf}
|
||||||
|
a.printResult(proto.Result{
|
||||||
|
Messages: []string{"done"},
|
||||||
|
Checks: []proto.Check{
|
||||||
|
{Name: "nft", OK: true, Detail: "present"},
|
||||||
|
{Name: "tun", OK: false, Warn: true},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
out := buf.String()
|
||||||
|
for _, needle := range []string{" done", "[ok] nft: present", "[warn] tun"} {
|
||||||
|
if !strings.Contains(out, needle) {
|
||||||
|
t.Errorf("output missing %q:\n%s", needle, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatResultError(t *testing.T) {
|
||||||
|
if err := formatResultError(proto.Result{Error: "boom"}); err == nil || err.Error() != "boom" {
|
||||||
|
t.Fatalf("formatResultError = %v", err)
|
||||||
|
}
|
||||||
|
if err := formatResultError(proto.Result{}); err == nil {
|
||||||
|
t.Fatal("expected a generic error when Result.Error is empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNftVersionWarning(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
warn bool
|
||||||
|
}{
|
||||||
|
{"nftables v0.9.3 (Lester Gomes)\n", true},
|
||||||
|
{"nftables v0.9.4 (Fearless Fred)\n", false},
|
||||||
|
{"nftables v0.9.0 (Topsy)\n", true},
|
||||||
|
{"nftables v1.0.9 (Sunday, 1 September 2024)\n", false},
|
||||||
|
{"nftables v2.0.1\n", false},
|
||||||
|
{"unparseable output", false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got := nftVersionWarning(tc.in)
|
||||||
|
if tc.warn && got == "" {
|
||||||
|
t.Errorf("nftVersionWarning(%q) = \"\", want a warning", tc.in)
|
||||||
|
}
|
||||||
|
if !tc.warn && got != "" {
|
||||||
|
t.Errorf("nftVersionWarning(%q) = %q, want no warning", tc.in, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsPermissionError(t *testing.T) {
|
||||||
|
if !isPermissionError(errors.New("nft list: exit status 1: Error: Operation not permitted")) {
|
||||||
|
t.Error("expected a permission error to be recognised")
|
||||||
|
}
|
||||||
|
if isPermissionError(errors.New("exec: nft: executable file not found in $PATH")) {
|
||||||
|
t.Error("a missing binary must not be reported as a permission error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// privilegeCheck must always return a named check and, for a non-root caller,
|
||||||
|
// never panic even when nothing is installed.
|
||||||
|
func TestPrivilegeCheckAlwaysNamed(t *testing.T) {
|
||||||
|
a := &app{}
|
||||||
|
c := a.privilegeCheck(context.Background(), &execx.Fake{})
|
||||||
|
if c.Name != "privilege" {
|
||||||
|
t.Fatalf("check name = %q, want %q", c.Name, "privilege")
|
||||||
|
}
|
||||||
|
if os.Geteuid() == 0 && !c.OK {
|
||||||
|
t.Fatalf("root should pass the privilege check: %+v", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackageHint(t *testing.T) {
|
||||||
|
for bin, want := range map[string]string{
|
||||||
|
"nft": "install nftables",
|
||||||
|
"ip": "install iproute2",
|
||||||
|
"systemctl": "install systemd",
|
||||||
|
"journalctl": "install systemd",
|
||||||
|
} {
|
||||||
|
if got := packageHint(bin); got != want {
|
||||||
|
t.Errorf("packageHint(%q) = %q, want %q", bin, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// errorText(nil) must be empty so a valid state is not reported as unreadable.
|
||||||
|
func TestErrorText(t *testing.T) {
|
||||||
|
if got := errorText(nil); got != "" {
|
||||||
|
t.Fatalf("errorText(nil) = %q, want \"\"", got)
|
||||||
|
}
|
||||||
|
if got := errorText(errors.New("boom")); got != "boom" {
|
||||||
|
t.Fatalf("errorText(err) = %q, want %q", got, "boom")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"prx/internal/client"
|
||||||
|
"prx/internal/engine"
|
||||||
|
"prx/internal/execx"
|
||||||
|
"prx/internal/proto"
|
||||||
|
"prx/internal/state"
|
||||||
|
)
|
||||||
|
|
||||||
|
var markRE = regexp.MustCompile(`"(?:fwmark|default_mark|mark)"\s*:\s*(0x[0-9a-fA-F]+|\d+)`)
|
||||||
|
|
||||||
|
// nftVersionRE extracts the version from output such as
|
||||||
|
// "nftables v1.0.9 (Sunday, 1 September 2024)".
|
||||||
|
var nftVersionRE = regexp.MustCompile(`v(\d+)\.(\d+)(?:\.(\d+))?`)
|
||||||
|
|
||||||
|
// nftVersionWarning returns a warning when the installed nftables predates the
|
||||||
|
// `destroy` keyword (added in 0.9.4), or an empty string when it is new enough
|
||||||
|
// or the version cannot be determined.
|
||||||
|
func nftVersionWarning(output string) string {
|
||||||
|
m := nftVersionRE.FindStringSubmatch(output)
|
||||||
|
if m == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
major, _ := strconv.Atoi(m[1])
|
||||||
|
minor, _ := strconv.Atoi(m[2])
|
||||||
|
patch := 0
|
||||||
|
if m[3] != "" {
|
||||||
|
patch, _ = strconv.Atoi(m[3])
|
||||||
|
}
|
||||||
|
if major == 0 && (minor < 9 || (minor == 9 && patch < 4)) {
|
||||||
|
return fmt.Sprintf("nftables %d.%d.%d is too old; prx needs >= 0.9.4 (for the 'destroy' keyword)", major, minor, patch)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// diagnostic pairs a check with whether it was produced locally (and is
|
||||||
|
// therefore authoritative even when prx is disabled).
|
||||||
|
type diagnostic struct {
|
||||||
|
check proto.Check
|
||||||
|
local bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) newDoctorCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "doctor",
|
||||||
|
Short: "Diagnose configuration and applied rules",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
cfg, err := a.loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ctx := cmd.Context()
|
||||||
|
r := execx.OSRunner{}
|
||||||
|
|
||||||
|
// Local checks are always authoritative, even when prx is off.
|
||||||
|
var diags []diagnostic
|
||||||
|
addLocal := func(c proto.Check) { diags = append(diags, diagnostic{check: c, local: true}) }
|
||||||
|
|
||||||
|
for _, bin := range []string{"nft", "ip", "systemctl"} {
|
||||||
|
_, err := r.LookPath(bin)
|
||||||
|
addLocal(proto.Check{Name: "binary:" + bin, OK: err == nil, Fatal: true, Detail: ternary(err == nil, "found", "missing — "+packageHint(bin))})
|
||||||
|
}
|
||||||
|
if _, err := r.LookPath("journalctl"); err != nil {
|
||||||
|
// Not fatal: only 'prx logs' needs it.
|
||||||
|
addLocal(proto.Check{Name: "binary:journalctl", OK: false, Warn: true, Detail: "missing — 'prx logs' unavailable"})
|
||||||
|
}
|
||||||
|
if _, err := r.LookPath("nft"); err == nil {
|
||||||
|
res, verr := r.Run(ctx, nil, "nft", "--version")
|
||||||
|
switch {
|
||||||
|
case verr != nil:
|
||||||
|
addLocal(proto.Check{Name: "nft-version", OK: false, Warn: true, Detail: verr.Error()})
|
||||||
|
default:
|
||||||
|
if w := nftVersionWarning(string(res.Stdout)); w != "" {
|
||||||
|
addLocal(proto.Check{Name: "nft-version", OK: false, Warn: true, Detail: w})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addLocal(a.privilegeCheck(ctx, r))
|
||||||
|
addLocal(proto.Check{Name: "config", OK: true, Detail: a.configPath})
|
||||||
|
|
||||||
|
if cfg.Engine.Manage && cfg.Engine.ConfigPath != "" {
|
||||||
|
warnings, err := engine.ValidateConfig(cfg.Engine.ConfigPath)
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
addLocal(proto.Check{Name: "engine-config", OK: false, Warn: true, Detail: err.Error()})
|
||||||
|
default:
|
||||||
|
if len(warnings) == 0 {
|
||||||
|
addLocal(proto.Check{Name: "engine-config", OK: true, Detail: cfg.Engine.ConfigPath})
|
||||||
|
}
|
||||||
|
for _, w := range warnings {
|
||||||
|
addLocal(proto.Check{Name: "engine-config", OK: false, Warn: true, Detail: w})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if collisions := markCollisions(cfg.Engine.ConfigPath, cfg.Marks.InboundMark); len(collisions) > 0 {
|
||||||
|
addLocal(proto.Check{Name: "mark-collision", OK: false, Fatal: true,
|
||||||
|
Detail: fmt.Sprintf("engine config reuses mark 0x%x, same as prx (%s)", cfg.Marks.InboundMark, collisions[0])})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
st, stateErr := state.Load(cfg.State.Path)
|
||||||
|
if stateErr != nil {
|
||||||
|
addLocal(proto.Check{Name: "state", OK: false, Warn: true, Detail: stateErr.Error()})
|
||||||
|
} else {
|
||||||
|
addLocal(proto.Check{Name: "state", OK: true, Detail: fmt.Sprintf("enabled=%v", st.Enabled)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deep inspection needs root; degrade gracefully.
|
||||||
|
res, err := client.Dispatch(ctx, a.buildPlan(cfg, proto.ActionInspect))
|
||||||
|
if err != nil {
|
||||||
|
diags = append(diags, diagnostic{check: proto.Check{Name: "inspect", OK: false, Warn: true, Detail: err.Error()}})
|
||||||
|
} else {
|
||||||
|
// A remote check must not repeat a local one (for example
|
||||||
|
// "state"): the local result is authoritative.
|
||||||
|
have := make(map[string]bool, len(diags))
|
||||||
|
for _, d := range diags {
|
||||||
|
have[d.check.Name] = true
|
||||||
|
}
|
||||||
|
for _, c := range res.Checks {
|
||||||
|
if have[c.Name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
have[c.Name] = true
|
||||||
|
diags = append(diags, diagnostic{check: c})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintln(a.out, "prx doctor")
|
||||||
|
failed := false
|
||||||
|
for _, d := range diags {
|
||||||
|
c := d.check
|
||||||
|
mark := checkMark(c)
|
||||||
|
if c.Fatal && !c.OK && !c.Warn && !d.local && !st.Enabled {
|
||||||
|
// Expected when prx is disabled.
|
||||||
|
mark = "off"
|
||||||
|
}
|
||||||
|
if c.Fatal && !c.OK && !c.Warn && (d.local || st.Enabled) {
|
||||||
|
failed = true
|
||||||
|
}
|
||||||
|
line := fmt.Sprintf(" [%s] %s", mark, c.Name)
|
||||||
|
if c.Detail != "" {
|
||||||
|
line += ": " + c.Detail
|
||||||
|
}
|
||||||
|
fmt.Fprintln(a.out, line)
|
||||||
|
}
|
||||||
|
if failed {
|
||||||
|
return fmt.Errorf("doctor found critical problems")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// packageHint names the package that provides a required binary, so a missing
|
||||||
|
// dependency is actionable on any distribution.
|
||||||
|
func packageHint(bin string) string {
|
||||||
|
switch bin {
|
||||||
|
case "nft":
|
||||||
|
return "install nftables"
|
||||||
|
case "ip":
|
||||||
|
return "install iproute2"
|
||||||
|
case "systemctl", "journalctl":
|
||||||
|
return "install systemd"
|
||||||
|
default:
|
||||||
|
return "install it"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// privilegeCheck reports how prx will reach root, or what prevents it from
|
||||||
|
// doing so.
|
||||||
|
func (a *app) privilegeCheck(_ context.Context, r execx.Runner) proto.Check {
|
||||||
|
const name = "privilege"
|
||||||
|
if os.Geteuid() == 0 {
|
||||||
|
return proto.Check{Name: name, OK: true, Detail: "running as root (helper runs in-process)"}
|
||||||
|
}
|
||||||
|
helperPath, err := client.FindHelper()
|
||||||
|
if err != nil {
|
||||||
|
return proto.Check{Name: name, OK: false, Fatal: true, Detail: err.Error()}
|
||||||
|
}
|
||||||
|
detail := "helper " + helperPath + ", via "
|
||||||
|
if _, err := r.LookPath("pkexec"); err == nil {
|
||||||
|
return proto.Check{Name: name, OK: true, Detail: detail + "pkexec (graphical prompt)"}
|
||||||
|
}
|
||||||
|
if _, err := r.LookPath("sudo"); err == nil {
|
||||||
|
return proto.Check{Name: name, OK: true, Detail: detail + "sudo (passwordless required; or run 'sudo prx ...')"}
|
||||||
|
}
|
||||||
|
return proto.Check{
|
||||||
|
Name: name,
|
||||||
|
OK: false,
|
||||||
|
Fatal: true,
|
||||||
|
Detail: "neither pkexec nor sudo found; install polkit or sudo, or run prx as root",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func markCollisions(path string, mark uint32) []string {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, m := range markRE.FindAllStringSubmatch(string(raw), -1) {
|
||||||
|
v, err := strconv.ParseUint(m[1], 0, 32)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if uint32(v) == mark {
|
||||||
|
out = append(out, m[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func ternary(cond bool, a, b string) string {
|
||||||
|
if cond {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"prx/internal/proto"
|
||||||
|
"prx/internal/state"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *app) newOnCmd() *cobra.Command {
|
||||||
|
var skipEngine, dryRun bool
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "on",
|
||||||
|
Short: "Enable proxy routing with inbound protection",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
cfg, err := a.loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = a.dispatch(cmd.Context(), cfg, proto.ActionApply, func(p *proto.Plan) {
|
||||||
|
p.SkipEngine = skipEngine
|
||||||
|
p.DryRun = dryRun
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if dryRun {
|
||||||
|
fmt.Fprintln(a.out, "dry-run complete; nothing changed")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().BoolVar(&skipEngine, "skip-engine", false, "apply routing rules without touching the proxy engine")
|
||||||
|
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "validate the plan without changing anything")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) newOffCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "off",
|
||||||
|
Short: "Disable proxy routing and remove prx-managed rules",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
cfg, err := a.loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return a.dispatch(cmd.Context(), cfg, proto.ActionRemove, nil)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) newToggleCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "toggle",
|
||||||
|
Short: "Toggle proxy routing based on current state",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
cfg, err := a.loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st, err := state.Load(cfg.State.Path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
action := proto.ActionApply
|
||||||
|
if st.Enabled {
|
||||||
|
action = proto.ActionRemove
|
||||||
|
}
|
||||||
|
return a.dispatch(cmd.Context(), cfg, action, nil)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"prx/internal/config"
|
||||||
|
"prx/internal/engine"
|
||||||
|
"prx/internal/proto"
|
||||||
|
"prx/internal/version"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *app) newLogsCmd() *cobra.Command {
|
||||||
|
var follow bool
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "logs",
|
||||||
|
Short: "Show proxy engine journal logs",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
if _, err := exec.LookPath("journalctl"); err != nil {
|
||||||
|
return errors.New("journalctl not found; install systemd to use 'prx logs'")
|
||||||
|
}
|
||||||
|
cfg, err := a.loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cfg.Engine.ServiceName == "" {
|
||||||
|
return errors.New("engine.service_name is not configured; cannot read logs")
|
||||||
|
}
|
||||||
|
args := []string{"-u", cfg.Engine.ServiceName, "--no-pager"}
|
||||||
|
if follow {
|
||||||
|
args = append(args, "-f")
|
||||||
|
} else {
|
||||||
|
args = append(args, "-n", "100")
|
||||||
|
}
|
||||||
|
c := exec.CommandContext(cmd.Context(), "journalctl", args...)
|
||||||
|
c.Stdout = a.out
|
||||||
|
c.Stderr = a.errOut
|
||||||
|
if err := c.Run(); err != nil {
|
||||||
|
return fmt.Errorf("journalctl: %w (you may need to be in the systemd-journal group)", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().BoolVarP(&follow, "follow", "f", false, "follow log output")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) newVersionCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "version",
|
||||||
|
Short: "Print version information",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(_ *cobra.Command, _ []string) error {
|
||||||
|
fmt.Fprintf(a.out, "prx %s (commit %s, built %s)\n", version.Version, version.Commit, version.Date)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) newInitCmd() *cobra.Command {
|
||||||
|
var (
|
||||||
|
force bool
|
||||||
|
withEngine bool
|
||||||
|
engineConfig string
|
||||||
|
)
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "init",
|
||||||
|
Short: "Write a default configuration file",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
if config.Exists(a.configPath) && !force {
|
||||||
|
fmt.Fprintf(a.out, "config already exists at %s (use --force to overwrite)\n", a.configPath)
|
||||||
|
} else {
|
||||||
|
if err := config.Save(a.configPath, config.Default()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(a.out, "wrote default config to %s\n", a.configPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !withEngine {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cfg, err := a.loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path := engineConfig
|
||||||
|
if path == "" {
|
||||||
|
path = cfg.Engine.ConfigPath
|
||||||
|
}
|
||||||
|
body, err := engine.Template(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return a.dispatch(cmd.Context(), cfg, proto.ActionInitEngine, func(p *proto.Plan) {
|
||||||
|
p.EngineConfig = path
|
||||||
|
p.EngineConfigBody = body
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().BoolVar(&force, "force", false, "overwrite an existing config file")
|
||||||
|
cmd.Flags().BoolVar(&withEngine, "engine-config", false, "also generate a baseline engine configuration")
|
||||||
|
cmd.Flags().StringVar(&engineConfig, "engine-config-path", "", "engine config path (defaults to engine.config_path)")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
// Package cli implements the prx command line interface.
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"prx/internal/client"
|
||||||
|
"prx/internal/config"
|
||||||
|
"prx/internal/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// app carries the state shared by every command.
|
||||||
|
type app struct {
|
||||||
|
configPath string
|
||||||
|
verbose bool
|
||||||
|
|
||||||
|
out io.Writer
|
||||||
|
errOut io.Writer
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute builds and runs the root command, cancelling in-flight work when the
|
||||||
|
// process receives SIGINT or SIGTERM.
|
||||||
|
func Execute() error {
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
a := &app{out: os.Stdout, errOut: os.Stderr}
|
||||||
|
root := a.newRootCmd()
|
||||||
|
return root.ExecuteContext(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) newRootCmd() *cobra.Command {
|
||||||
|
root := &cobra.Command{
|
||||||
|
Use: "prx",
|
||||||
|
Short: "Safe proxy routing manager",
|
||||||
|
Long: "prx enables and disables proxy routing while protecting inbound\n" +
|
||||||
|
"connections (such as SSH) from being pulled into the tunnel.",
|
||||||
|
SilenceUsage: true,
|
||||||
|
SilenceErrors: true,
|
||||||
|
PersistentPreRunE: func(_ *cobra.Command, _ []string) error {
|
||||||
|
level := slog.LevelInfo
|
||||||
|
if a.verbose {
|
||||||
|
level = slog.LevelDebug
|
||||||
|
}
|
||||||
|
a.logger = slog.New(slog.NewTextHandler(a.errOut, &slog.HandlerOptions{Level: level}))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
root.PersistentFlags().StringVar(&a.configPath, "config", config.DefaultPath(), "path to config file")
|
||||||
|
root.PersistentFlags().BoolVarP(&a.verbose, "verbose", "v", false, "verbose logging")
|
||||||
|
|
||||||
|
root.AddCommand(
|
||||||
|
a.newOnCmd(),
|
||||||
|
a.newOffCmd(),
|
||||||
|
a.newToggleCmd(),
|
||||||
|
a.newStatusCmd(),
|
||||||
|
a.newDoctorCmd(),
|
||||||
|
a.newBypassCmd(),
|
||||||
|
a.newLogsCmd(),
|
||||||
|
a.newInitCmd(),
|
||||||
|
a.newVersionCmd(),
|
||||||
|
)
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) loadConfig() (*config.Config, error) {
|
||||||
|
return config.Load(a.configPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) buildPlan(cfg *config.Config, action proto.Action) proto.Plan {
|
||||||
|
return proto.Plan{
|
||||||
|
Action: action,
|
||||||
|
WANInterface: cfg.Network.WANInterface,
|
||||||
|
WANGateway4: cfg.Network.WANGateway,
|
||||||
|
WANGateway6: cfg.Network.WANGateway6,
|
||||||
|
Mark: cfg.Marks.InboundMark,
|
||||||
|
TableV4: cfg.Routing.TableV4,
|
||||||
|
TableV6: cfg.Routing.TableV6,
|
||||||
|
PriorityV4: cfg.Routing.PriorityV4,
|
||||||
|
PriorityV6: cfg.Routing.PriorityV6,
|
||||||
|
BypassTableV4: cfg.Routing.BypassTableV4,
|
||||||
|
BypassTableV6: cfg.Routing.BypassTableV6,
|
||||||
|
BypassPrioV4: cfg.Routing.BypassPrioV4,
|
||||||
|
BypassPrioV6: cfg.Routing.BypassPrioV6,
|
||||||
|
BypassCIDRs: cfg.Bypass.ExtraCIDRs,
|
||||||
|
ManageEngine: cfg.Engine.Manage,
|
||||||
|
ServiceName: cfg.Engine.ServiceName,
|
||||||
|
EngineConfig: cfg.Engine.ConfigPath,
|
||||||
|
StopEngineOnOff: cfg.Engine.StopOnOff,
|
||||||
|
HealthProbe: cfg.Health.Probe,
|
||||||
|
StatePath: cfg.State.Path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dispatch sends a plan to the helper, prints the result and turns a failed
|
||||||
|
// result into an error. mutate, when non-nil, adjusts the plan before it is
|
||||||
|
// sent.
|
||||||
|
func (a *app) dispatch(ctx context.Context, cfg *config.Config, action proto.Action, mutate func(*proto.Plan)) error {
|
||||||
|
plan := a.buildPlan(cfg, action)
|
||||||
|
if mutate != nil {
|
||||||
|
mutate(&plan)
|
||||||
|
}
|
||||||
|
if a.logger != nil {
|
||||||
|
a.logger.Debug("dispatching plan", "action", string(action), "state", plan.StatePath)
|
||||||
|
}
|
||||||
|
res, err := client.Dispatch(ctx, plan)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.printResult(res)
|
||||||
|
if !res.OK {
|
||||||
|
return formatResultError(res)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) printResult(res proto.Result) {
|
||||||
|
for _, m := range res.Messages {
|
||||||
|
fmt.Fprintln(a.out, " "+m)
|
||||||
|
}
|
||||||
|
if len(res.Checks) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintln(a.out, " checks:")
|
||||||
|
for _, c := range res.Checks {
|
||||||
|
line := fmt.Sprintf(" [%s] %s", checkMark(c), c.Name)
|
||||||
|
if c.Detail != "" {
|
||||||
|
line += ": " + c.Detail
|
||||||
|
}
|
||||||
|
fmt.Fprintln(a.out, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkMark renders a diagnostic check as a short status marker.
|
||||||
|
func checkMark(c proto.Check) string {
|
||||||
|
switch {
|
||||||
|
case c.OK:
|
||||||
|
return "ok"
|
||||||
|
case c.Warn:
|
||||||
|
return "warn"
|
||||||
|
default:
|
||||||
|
return "FAIL"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatResultError(res proto.Result) error {
|
||||||
|
if res.Error == "" {
|
||||||
|
return errors.New("operation failed")
|
||||||
|
}
|
||||||
|
return errors.New(res.Error)
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"prx/internal/config"
|
||||||
|
"prx/internal/engine"
|
||||||
|
"prx/internal/execx"
|
||||||
|
"prx/internal/iprule"
|
||||||
|
"prx/internal/nft"
|
||||||
|
"prx/internal/state"
|
||||||
|
)
|
||||||
|
|
||||||
|
type statusReport struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
WANInterface string `json:"wan_interface,omitempty"`
|
||||||
|
Gateway4 string `json:"gateway_v4,omitempty"`
|
||||||
|
Gateway6 string `json:"gateway_v6,omitempty"`
|
||||||
|
Mark string `json:"mark"`
|
||||||
|
RuleV4 string `json:"ipv4_rule"`
|
||||||
|
TableV4 string `json:"ipv4_table"`
|
||||||
|
RuleV6 string `json:"ipv6_rule"`
|
||||||
|
TableV6 string `json:"ipv6_table"`
|
||||||
|
Engine string `json:"engine"`
|
||||||
|
NftTable string `json:"nft_table"`
|
||||||
|
EnabledAt string `json:"enabled_at,omitempty"`
|
||||||
|
// StateError reports a state file that could not be read, if any.
|
||||||
|
StateError string `json:"state_error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) newStatusCmd() *cobra.Command {
|
||||||
|
var asJSON bool
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "status",
|
||||||
|
Short: "Show current proxy routing status",
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
cfg, err := a.loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r := execx.OSRunner{}
|
||||||
|
ctx := cmd.Context()
|
||||||
|
|
||||||
|
// A corrupt or unreadable state file must not hide the rest of
|
||||||
|
// the report: show what we know and flag the problem.
|
||||||
|
st, stateErr := state.Load(cfg.State.Path)
|
||||||
|
|
||||||
|
rep := statusReport{
|
||||||
|
Enabled: st.Enabled,
|
||||||
|
WANInterface: firstNonEmptyStr(st.WANInterface, cfg.Network.WANInterface),
|
||||||
|
Gateway4: st.Gateway4,
|
||||||
|
Gateway6: st.Gateway6,
|
||||||
|
Mark: fmt.Sprintf("0x%x", cfg.Marks.InboundMark),
|
||||||
|
RuleV4: ruleStatus(ctx, r, 4, cfg.Marks.InboundMark, cfg.Routing.TableV4, cfg.Routing.PriorityV4),
|
||||||
|
TableV4: tableStatus(ctx, r, 4, cfg.Routing.TableV4),
|
||||||
|
RuleV6: ruleStatus(ctx, r, 6, cfg.Marks.InboundMark, cfg.Routing.TableV6, cfg.Routing.PriorityV6),
|
||||||
|
TableV6: tableStatus(ctx, r, 6, cfg.Routing.TableV6),
|
||||||
|
Engine: engineStatus(ctx, r, cfg),
|
||||||
|
NftTable: nftStatus(ctx, r),
|
||||||
|
StateError: errorText(stateErr),
|
||||||
|
}
|
||||||
|
if !st.EnabledAt.IsZero() {
|
||||||
|
rep.EnabledAt = st.EnabledAt.Format("2006-01-02 15:04:05 MST")
|
||||||
|
}
|
||||||
|
|
||||||
|
if asJSON {
|
||||||
|
enc := json.NewEncoder(a.out)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
return enc.Encode(rep)
|
||||||
|
}
|
||||||
|
|
||||||
|
stateStr := "disabled"
|
||||||
|
if rep.Enabled {
|
||||||
|
stateStr = "enabled"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(a.out, "prx: %s\n", stateStr)
|
||||||
|
fmt.Fprintf(a.out, " mark: %s\n", rep.Mark)
|
||||||
|
if rep.WANInterface != "" {
|
||||||
|
fmt.Fprintf(a.out, " interface: %s (gw4=%s gw6=%s)\n", rep.WANInterface, orDash(rep.Gateway4), orDash(rep.Gateway6))
|
||||||
|
}
|
||||||
|
fmt.Fprintf(a.out, " ipv4 rule: %s\n", rep.RuleV4)
|
||||||
|
fmt.Fprintf(a.out, " ipv4 table: %s\n", rep.TableV4)
|
||||||
|
fmt.Fprintf(a.out, " ipv6 rule: %s\n", rep.RuleV6)
|
||||||
|
fmt.Fprintf(a.out, " ipv6 table: %s\n", rep.TableV6)
|
||||||
|
fmt.Fprintf(a.out, " nft table: %s\n", rep.NftTable)
|
||||||
|
fmt.Fprintf(a.out, " engine: %s\n", rep.Engine)
|
||||||
|
if rep.EnabledAt != "" {
|
||||||
|
fmt.Fprintf(a.out, " since: %s\n", rep.EnabledAt)
|
||||||
|
}
|
||||||
|
if rep.StateError != "" {
|
||||||
|
fmt.Fprintf(a.out, " warning: state file unreadable: %s\n", rep.StateError)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().BoolVar(&asJSON, "json", false, "output JSON")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// errorText returns the error message, or an empty string when err is nil.
|
||||||
|
func errorText(err error) string {
|
||||||
|
if err == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func ruleStatus(ctx context.Context, r execx.Runner, family int, mark uint32, table, prio int) string {
|
||||||
|
spec := iprule.Spec{Family: family, Mark: mark, Table: table, Priority: prio}
|
||||||
|
ok, err := iprule.RuleExists(ctx, r, spec)
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
return "unknown (" + err.Error() + ")"
|
||||||
|
case ok:
|
||||||
|
return fmt.Sprintf("present (fwmark 0x%x -> table %d prio %d)", mark, table, prio)
|
||||||
|
default:
|
||||||
|
return "absent"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableStatus(ctx context.Context, r execx.Runner, family, table int) string {
|
||||||
|
ok, err := iprule.TableHasRoutes(ctx, r, family, table)
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
return "unknown (" + err.Error() + ")"
|
||||||
|
case ok:
|
||||||
|
return fmt.Sprintf("table %d populated", table)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("table %d empty", table)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func engineStatus(ctx context.Context, r execx.Runner, cfg *config.Config) string {
|
||||||
|
if !cfg.Engine.Manage || cfg.Engine.ServiceName == "" {
|
||||||
|
return "not managed"
|
||||||
|
}
|
||||||
|
active, err := engine.IsActive(ctx, r, cfg.Engine.ServiceName)
|
||||||
|
if err != nil {
|
||||||
|
return "unknown (" + err.Error() + ")"
|
||||||
|
}
|
||||||
|
if active {
|
||||||
|
return cfg.Engine.ServiceName + " active"
|
||||||
|
}
|
||||||
|
return cfg.Engine.ServiceName + " inactive"
|
||||||
|
}
|
||||||
|
|
||||||
|
func nftStatus(ctx context.Context, r execx.Runner) string {
|
||||||
|
ok, err := nft.Exists(ctx, r)
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
if isPermissionError(err) {
|
||||||
|
return nft.Table() + " unknown (needs root)"
|
||||||
|
}
|
||||||
|
return nft.Table() + " unknown (" + err.Error() + ")"
|
||||||
|
case ok:
|
||||||
|
return nft.Table() + " present"
|
||||||
|
default:
|
||||||
|
return nft.Table() + " absent"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPermissionError reports whether err was caused by missing privileges,
|
||||||
|
// as opposed to a missing binary or a malformed invocation.
|
||||||
|
func isPermissionError(err error) bool {
|
||||||
|
msg := err.Error()
|
||||||
|
for _, marker := range []string{"Operation not permitted", "Permission denied", "not permitted"} {
|
||||||
|
if strings.Contains(msg, marker) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func orDash(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmptyStr(vals ...string) string {
|
||||||
|
for _, v := range vals {
|
||||||
|
if v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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>.
|
||||||
@@ -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>.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
Executable
+96
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user