# Client Configuration (https://portunus.bybee.dev/en/docs/server-client/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`).                                                |
| `--no-service`              | Install the binary only; skip the service unit.                                             |
| `--deploy docker`           | Run via Docker Compose instead of a binary + service unit.                                  |
| `--systemd`                 | Deprecated no-op (accepted for back-compat); the hardened unit is installed by default now. |
| `--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`.

## 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",
  "client_id": "01J8XABCDEF0123456789ABCDE",
  "server_endpoint": "host-A.example.com:7443",
  "server_cert_sha256": "f5e7c2a1...",
  "token": "<bearer>"
}
```

`server_cert_sha256` is a 64-hex SHA-256 of the server's leaf certificate
DER and is the bundle's **sole trust anchor**. At the TLS handshake the
client computes SHA-256 over the presented leaf certificate DER and
rejects the connection unless it equals this pin.

<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` installs the hardened `portunus-client.service` by
default (runs as the `portunus-client` user,
`--bundle /etc/portunus/client.bundle.json`). Pass `--enroll` to install,
enroll, place the bundle, and start the service in one command:

```sh
curl -fsSL https://raw.githubusercontent.com/ZingerLittleBee/Portunus/main/scripts/install.sh \
  | sh -s -- client --enroll 'portunus://HOST:7443/enroll?pin=sha256:…&code=…'
```

`--enroll` places the bundle at `/etc/portunus/client.bundle.json`
(`root:portunus-client 0640`) and calls `systemctl enable --now
portunus-client` automatically.

See the [systemd deployment guide](/en/docs/server-client/deployment/systemd).

## Docker [#docker]

The client image self-enrolls on first boot when `PORTUNUS_ENROLL_URI` is
set. Mount a volume at `/etc/portunus` so the written bundle persists
across container restarts:

```sh
docker run -d --name portunus-client --network host \
  -e PORTUNUS_ENROLL_URI='portunus://HOST:7443/enroll?pin=sha256:…&code=…' \
  -v portunus-client:/etc/portunus \
  ghcr.io/zingerlittlebee/portunus-client
```

On first boot the container writes
`/etc/portunus/client.bundle.json` into the volume and then starts
forwarding. On subsequent starts `PORTUNUS_ENROLL_URI` is ignored if a
bundle already exists.

See the [Docker deployment guide](/en/docs/server-client/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 surfaces as a TLS handshake failure: the client logs
`control.connect_failed` with the certificate/TLS error in its `error=`
field and **retries with backoff** (non-terminal — the client does not
exit). 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).
