Client Configuration
How portunus-client picks up its bundle, and what env vars influence behaviour.
portunus-client is configured by redeeming a short-lived enrollment
command. There is no client.toml. An operator issues a one-time
command (Web UI Clients page, or portunus-server enroll-client),
the edge host redeems it, and the client writes a local bundle that
holds the bearer token.
This page is the canonical reference for installing and enrolling a client. Deployment guides (systemd, Docker, Railway) link here.
Install the binary
curl -fsSL https://raw.githubusercontent.com/ZingerLittleBee/Portunus/main/scripts/install.sh | sh -s -- clientinstall.sh <client|server> detects your OS/arch, downloads the
matching release binary, verifies its SHA-256 against the published
checksums, and installs it to /usr/local/bin (--bin-dir to change;
it uses sudo only for the privileged steps).
| Flag | Effect |
|---|---|
--version <X.Y.Z|vX.Y.Z> | Pin a release (default: latest). Either form works. |
--bin-dir DIR | Install location (default /usr/local/bin). |
--systemd | Also install + enable the hardened unit (Linux only; see systemd guide). |
--yes | Non-interactive; assume yes. Forward-compat (no prompts exist yet). |
--dry-run | Print the resolved target/URL and planned actions; do nothing. |
For an already-root context: curl -fsSL … | sudo sh -s -- client --systemd.
Enroll
# Operator side (or use the Web UI Clients page):
portunus-server enroll-client edge-01 --ttl-secs 600
# Edge host: paste the printed command
portunus-client enroll 'portunus://host-A.example.com:7443/enroll?...' --out ./client.bundle.jsonportunus-server enroll-client is an offline command: it opens
state.db directly and fails with store_in_use if a server is already
running on the same --data-dir. With a running server (the normal
production case) issue the enrollment from the Web UI Clients page, or
via the operator HTTP API / Docker operator sidecar — all of which go
through the live server. Reserve the host-binary CLI for bootstrap before
the service starts.
portunus-client enroll verifies the pinned certificate and writes the
bundle with mode 0600 to the default location unless --out is
supplied.
The bundle
{
"version": 1,
"client_name": "edge-01",
"server_endpoint": "host-A.example.com:7443",
"server_cert_sha256": "f5e7c2a1...",
"server_cert_pem": "-----BEGIN CERTIFICATE-----\n...",
"token": "<bearer>"
}server_cert_pem is the pinned leaf certificate; the client checks that
its SHA-256 matches server_cert_sha256 before connecting.
Edit server_endpoint if the address the edge host reaches the
server on differs from what the server advertised by default.
Run the client
Explicit --bundle
portunus-client --bundle ./edge-01.bundle.jsonBundle resolution (since v0.8)
When --bundle is omitted, the client searches in this order:
$PORTUNUS_CLIENT_BUNDLE$XDG_CONFIG_HOME/portunus/client.bundle.json$HOME/.config/portunus/client.bundle.json./client.bundle.json
If none resolve the client exits 1 listing every attempted path.
mkdir -p ~/.config/portunus
mv edge-01.bundle.json ~/.config/portunus/client.bundle.json
portunus-clientsystemd
install.sh client --systemd installs the hardened
portunus-client.service (runs as the portunus-client user,
--bundle /etc/portunus/client.bundle.json). Provision the bundle:
portunus-client enroll 'portunus://...' --out ./client.bundle.json
sudo install -o root -g portunus-client -m 0640 \
./client.bundle.json /etc/portunus/client.bundle.json
sudo systemctl enable --now portunus-clientSee the systemd deployment guide.
Docker
The client image entrypoint is already portunus-client. Run both the
one-shot enroll and the long-lived container as your host user so the
0600 bundle is written and read by one identity:
docker run --rm --user "$(id -u):$(id -g)" -v "$PWD:/work" \
ghcr.io/zingerlittlebee/portunus-client \
enroll 'portunus://...' --out /work/client.bundle.json
docker run -d --name portunus-client --network host \
--user "$(id -u):$(id -g)" \
-v "$PWD/client.bundle.json:/etc/portunus/client.bundle.json:ro" \
ghcr.io/zingerlittlebee/portunus-clientSee the Docker deployment guide.
Flags
| Flag | Default | Purpose |
|---|---|---|
--bundle <PATH> | (resolution chain) | Explicit bundle path; overrides the resolution chain |
--reconnect-initial-delay-ms <N> | 500 | Initial reconnect delay; base for full-jitter exponential backoff |
--reconnect-max-delay-secs <N> | 30 | Reconnect backoff cap |
--shutdown-drain-timeout-secs <N> | 30 | How long in-flight forwarded connections drain on SIGTERM |
--stats-report-interval-secs <N> | 5 | Interval between per-rule stats reports sent to the server |
Run portunus-client --help for the current full surface.
Environment variables
| Variable | Effect |
|---|---|
PORTUNUS_CLIENT_BUNDLE | Bundle path lookup (highest precedence in resolution chain) |
RUST_LOG | Standard tracing-subscriber env-filter |
TLS pinning
The client validates the server certificate by SHA-256 fingerprint
(server_cert_sha256 from the bundle), not by CA chain. This is the
core security guarantee — even if the server's IP / DNS is hijacked,
the wrong cert is rejected before any token leaves the client.
A mismatch logs:
{"event":"control.tls_pinned_mismatch","expected":"…","got":"…"}…and the client exits non-zero. The server logs nothing because the TLS handshake never completed.
Resource limits
For UDP-heavy or wide port-range deployments, raise LimitNOFILE:
# /etc/systemd/system/portunus-client.service
[Service]
LimitNOFILE=65535The default is fine for typical TCP rule fleets.
Connection model
The client maintains a single bidirectional gRPC stream to the
server, with automatic reconnect on disconnect. Rules pushed to a
disconnected client are rejected at the server with
client_not_connected (HTTP 4xx). They are not queued — the operator
re-pushes after the client reconnects, or relies on the server's
re-push on reconnection (rules persist server-side).