Portunus
Configuration

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 -- client

install.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).

FlagEffect
--version <X.Y.Z|vX.Y.Z>Pin a release (default: latest). Either form works.
--bin-dir DIRInstall location (default /usr/local/bin).
--systemdAlso install + enable the hardened unit (Linux only; see systemd guide).
--yesNon-interactive; assume yes. Forward-compat (no prompts exist yet).
--dry-runPrint 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.json

portunus-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.json

Bundle resolution (since v0.8)

When --bundle is omitted, the client searches in this order:

  1. $PORTUNUS_CLIENT_BUNDLE
  2. $XDG_CONFIG_HOME/portunus/client.bundle.json
  3. $HOME/.config/portunus/client.bundle.json
  4. ./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-client

systemd

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-client

See 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-client

See the Docker deployment guide.

Flags

FlagDefaultPurpose
--bundle <PATH>(resolution chain)Explicit bundle path; overrides the resolution chain
--reconnect-initial-delay-ms <N>500Initial reconnect delay; base for full-jitter exponential backoff
--reconnect-max-delay-secs <N>30Reconnect backoff cap
--shutdown-drain-timeout-secs <N>30How long in-flight forwarded connections drain on SIGTERM
--stats-report-interval-secs <N>5Interval between per-rule stats reports sent to the server

Run portunus-client --help for the current full surface.

Environment variables

VariableEffect
PORTUNUS_CLIENT_BUNDLEBundle path lookup (highest precedence in resolution chain)
RUST_LOGStandard 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=65535

The 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).

On this page