From 5cb1c282c12fe3fc3f04082ac6f165427ece92e6 Mon Sep 17 00:00:00 2001 From: Amir Husayn Panahifar Date: Thu, 15 Oct 2026 14:29:19 +0330 Subject: [PATCH] docs: add README and manual pages --- README.md | 356 +++++++++++++++++++++++++++++++++++++++++++++++ man/prx-helper.1 | 81 +++++++++++ man/prx.1 | 279 +++++++++++++++++++++++++++++++++++++ 3 files changed, 716 insertions(+) create mode 100644 README.md create mode 100644 man/prx-helper.1 create mode 100644 man/prx.1 diff --git a/README.md b/README.md new file mode 100644 index 0000000..6c49511 --- /dev/null +++ b/README.md @@ -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 ` | Keep a destination out of the tunnel | +| `prx bypass rm ` | 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. + +--- + +## Author + +- **Amir Husayn Panahifar** <ahp@panahifar.ir> — author and maintainer. + +Contributions are welcome. + +--- + +## License + +MIT. See `LICENSE`. diff --git a/man/prx-helper.1 b/man/prx-helper.1 new file mode 100644 index 0000000..7896fc9 --- /dev/null +++ b/man/prx-helper.1 @@ -0,0 +1,81 @@ +.\" Manual page for prx-helper +.\" Copyright (C) 2026 Amir Husayn Panahifar +.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 . diff --git a/man/prx.1 b/man/prx.1 new file mode 100644 index 0000000..a15c0d2 --- /dev/null +++ b/man/prx.1 @@ -0,0 +1,279 @@ +.\" Manual page for prx +.\" Copyright (C) 2026 Amir Husayn Panahifar +.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 .