357 lines
12 KiB
Markdown
357 lines
12 KiB
Markdown
# 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.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Author
|
||
|
|
|
||
|
|
- **Amir Husayn Panahifar** <ahp@panahifar.ir> — author and maintainer.
|
||
|
|
|
||
|
|
Contributions are welcome.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## License
|
||
|
|
|
||
|
|
MIT. See `LICENSE`.
|