# Client Configuration (https://portunus.bybee.dev/en/docs/configuration/client)



`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 [#install-the-binary]

```sh
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).

| 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 [#enroll]

```sh
# 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 [#the-bundle]

```json
{
  "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.

<Callout type="warn">
  Edit `server_endpoint` if the address the edge host reaches the
  server on differs from what the server advertised by default.
</Callout>

## Run the client [#run-the-client]

### Explicit `--bundle` [#explicit---bundle]

```sh
portunus-client --bundle ./edge-01.bundle.json
```

### Bundle resolution (since v0.8) [#bundle-resolution-since-v08]

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.

```sh
mkdir -p ~/.config/portunus
mv edge-01.bundle.json ~/.config/portunus/client.bundle.json
portunus-client
```

## systemd [#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:

```sh
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](/en/docs/deployment/systemd).

## Docker [#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:

```sh
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](/en/docs/deployment/docker).

## Flags [#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 [#environment-variables]

| Variable                 | Effect                                                      |
| ------------------------ | ----------------------------------------------------------- |
| `PORTUNUS_CLIENT_BUNDLE` | Bundle path lookup (highest precedence in resolution chain) |
| `RUST_LOG`               | Standard `tracing-subscriber` env-filter                    |

## TLS pinning [#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:

```json
{"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 [#resource-limits]

For UDP-heavy or wide port-range deployments, raise `LimitNOFILE`:

```ini
# /etc/systemd/system/portunus-client.service
[Service]
LimitNOFILE=65535
```

The default is fine for typical TCP rule fleets.

## Connection model [#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).
