# portunus-client (https://portunus.bybee.dev/en/docs/cli/portunus-client)



`portunus-client` is the edge-side binary. It dials a server endpoint
specified in its bundle, validates the server certificate by SHA-256
fingerprint, and accepts rule pushes over a bidirectional gRPC stream.

## Synopsis [#synopsis]

```sh
# Daemon mode (no subcommand): connect, accept rule pushes, forward.
portunus-client [--bundle <PATH>] [--reconnect-initial-delay-ms <N>]
                [--reconnect-max-delay-secs <N>] [--shutdown-drain-timeout-secs <N>]
                [--stats-report-interval-secs <N>]

# Enroll: redeem a one-time URI and write a bundle, then exit.
portunus-client enroll <URI> [--out <PATH>]
```

## Enroll [#enroll]

```sh
portunus-client enroll 'portunus://...' --out ./edge-01.bundle.json
```

Redeems a one-time enrollment URI (from `portunus-server enroll-client`),
verifies the pinned server certificate, and writes the credential bundle
with mode `0600`. `--out` defaults to the normal client bundle location.
On success the bundle path is printed to stdout; on failure the client
exits `1`.

## Bundle resolution [#bundle-resolution]

When `--bundle` is omitted (since v0.8), the client searches:

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.

## Flags [#flags]

Daemon-mode flags (no subcommand):

| Flag                                | Default            | Purpose                                                              |
| ----------------------------------- | ------------------ | -------------------------------------------------------------------- |
| `--bundle <PATH>`                   | (resolution chain) | Explicit bundle path                                                 |
| `--reconnect-initial-delay-ms <N>`  | 500                | Initial reconnect delay; base of the full-jitter exponential backoff |
| `--reconnect-max-delay-secs <N>`    | 30                 | Reconnect backoff cap                                                |
| `--shutdown-drain-timeout-secs <N>` | 30                 | Drain budget on SIGTERM/SIGINT                                       |
| `--stats-report-interval-secs <N>`  | 5                  | Stats reporting interval                                             |

Run `portunus-client --help` for the current authoritative surface.

## Environment variables [#environment-variables]

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

## Lifecycle [#lifecycle]

1. Read bundle.
2. Resolve `server_endpoint`.
3. TLS dial; pin SHA-256 against `server_cert_sha256`.
4. Send Hello with bearer; receive Welcome with server-side tunables.
5. Open bidirectional `RuleUpdate` ↔ `RuleStatus` stream.
6. For each `RuleUpdate`:
   * Allocate listener (TCP `bind` or UDP `bind`).
   * Activate the forwarder loop.
   * Reply `Active` (or `Failed(<reason>)`).
7. Push `StatsReport` every `--stats-report-interval-secs` (default 5 s).

## Drain on shutdown [#drain-on-shutdown]

`SIGTERM` / `SIGINT` triggers drain:

* Listeners stop accepting immediately.
* In-flight forwarded connections finish up to
  `--shutdown-drain-timeout-secs` (default 30 s).
* The kernel reaps remaining sockets.

Match `TimeoutStopSec=` in the systemd unit to avoid `SIGKILL` mid-drain.

## Logging [#logging]

JSON lines by default:

```json
{"event":"process.start"}
{"event":"control.connecting","endpoint":"…:7443"}
{"event":"control.tls_pinned","fingerprint_sha256":"f5e7c2a1..."}
{"event":"control.connected"}
{"event":"rule.received","rule_id":1,"listen_port":18080}
{"event":"rule.activated","rule_id":1,"listen_port":18080}
```

A pin mismatch:

```json
{"event":"control.tls_pinned_mismatch","expected":"…","got":"…"}
```

…and the client exits non-zero.
