Compare commits
10
Commits
3e7ec7f44f
...
06a224f606
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06a224f606
|
||
|
|
77ef81e688
|
||
|
|
20459525f3
|
||
|
|
5cb1c282c1
|
||
|
|
7f11dd2c01
|
||
|
|
d81477c5f9
|
||
|
|
940c47030f
|
||
|
|
1e9b0e9aac
|
||
|
|
66417ab679
|
||
|
|
c3046d51c0
|
@@ -12,8 +12,8 @@ MANDIR ?= $(PREFIX)/share/man
|
||||
all: build
|
||||
|
||||
build:
|
||||
CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/prx ./cmd/prx
|
||||
CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/prx-helper ./cmd/prx-helper
|
||||
CGO_ENABLED=0 go build -buildvcs=false -trimpath -ldflags "$(LDFLAGS)" -o bin/prx ./cmd/prx
|
||||
CGO_ENABLED=0 go build -buildvcs=false -trimpath -ldflags "$(LDFLAGS)" -o bin/prx-helper ./cmd/prx-helper
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
@@ -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