# Upgrade Guide (https://portunus.bybee.dev/en/docs/operations/upgrade)



Wire-format changes are **additive only** — newer fields use proto3
default-stripping so cross-version pairs degrade gracefully. The only
breaking step in the project's history is v0.7 → v0.8 (the JSON →
SQLite cutover, documented below); v0.7 never reached production.

Since v1.0 no upgrade requires a manual SQLite schema migration — the
server runs embedded migrations forward automatically at startup.

## Compatibility matrix [#compatibility-matrix]

| Combination                | Behaviour                                                                                                                  |
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| Newer server, older client | New rule fields are stripped on send; the server enforces capability gates and refuses pushes the client cannot honour.    |
| Older server, newer client | The client transparently omits new fields under proto3 default-stripping. Behaviour identical to running the older client. |
| Same version both sides    | Full feature surface available.                                                                                            |

## Capability gates [#capability-gates]

The server **refuses ahead of time** to push a feature that the client
on the other end does not understand. The rule never activates anywhere
— operators see an HTTP 422 with a specific code:

| Code                                 | Triggered by                                     |
| ------------------------------------ | ------------------------------------------------ |
| `unsupported_protocol`               | UDP rule pushed to a v0.3-or-earlier client      |
| `multi_target_unsupported_by_client` | `targets[]` pushed to a v0.6-or-earlier client   |
| `sni_unsupported_by_client`          | `sni_pattern` pushed to a v0.8-or-earlier client |
| `rate_limit_unsupported_by_client`   | `rate_limit` pushed to a v0.10-or-earlier client |

## Recommended upgrade order [#recommended-upgrade-order]

When mixing versions across server and client, **upgrade the server
first**. The server is the gate, so a newer server with older clients
is safe; an older server with a newer client cannot light up new
features but otherwise operates normally.

## v0.7 → v0.8 (clean-slate) [#v07--v08-clean-slate]

This is the only "breaking" upgrade in the project's history — and only
because v0.7 never reached production.

In v0.8 every JSON file (`tokens.json`, `identity.json`, `rules.json`)
plus the in-memory audit ring became one SQLite database at
`<data-dir>/state.db`.

There is **no upgrade path** from a v0.7 deployment that wrote JSON
files. The expectation is a fresh `--data-dir`:

```sh
sudo systemctl stop portunus-server
sudo rm /var/lib/portunus/tokens.json /var/lib/portunus/identity.json
portunus-server reset --confirm
sudo systemctl start portunus-server
```

After the server starts, complete first-run Web onboarding with the setup token
printed in the server logs. Existing client bundles (TLS-pinned) survive — only
the operator-side identity and the rule definitions need re-creating.

## v0.4 → v0.5 (RBAC) [#v04--v05-rbac]

The operator API gained `Authorization: Bearer <token>` requirement. Existing
CLI scripts need either:

```sh
export PORTUNUS_OPERATOR_TOKEN=<token>
```

…or the bootstrap shortcut in `server.toml`:

```toml
operator_token = "<43-char URL-safe-base64 token>"
```

The data plane (gRPC client tokens, TCP/UDP forwarding) is
**byte-identical** — no client-side changes required.

## v1.0 → v1.1 (local Web passwords) [#v10--v11-local-web-passwords]

The Web UI moved from browser-entered bearer tokens to local user ID/password
login with server-side `portunus_session` cookies. Existing API bearer tokens
continue to work for CLI and automation. Existing users are not assigned
passwords automatically; a `superadmin` should create or reset local passwords
from the Web UI, or use local `reset-password` while the server is stopped.

## v1.x data-plane releases [#v1x-data-plane-releases]

All v1.x upgrades are drop-in: no manual SQLite migration, no wire
break, no operator-surface removal. Upgrade the binary and restart.
Highlights operators may notice:

* **v1.3 (Linux `splice(2)` fast path)** — TCP throughput roughly
  doubles on Linux for rules with no bandwidth caps. Operator-invisible;
  see [Troubleshooting → Disabling the Linux fast path](/en/docs/operations/troubleshooting#disabling-the-linux-fast-path-for-triage).
* **v1.4 (traffic quotas)** — adds SQLite migration `V008`
  (`traffic_quotas` + history tables) applied automatically. No backfill,
  no default rows; unconfigured pairs forward exactly as before. See the
  [Traffic Quotas runbook](/en/docs/operations/runbook-traffic-quotas).
* **v1.5 — UDP per-rule flow cap correction.** `udp_max_flows_per_rule`
  is now enforced once across the whole rule instead of per listen port.
  A **range** UDP rule's effective capacity drops by a factor of
  `range_size`. If you relied on the old inflated capacity, raise
  `udp_max_flows_per_rule` proportionally or split the range; the field
  is capped at `65535`, so if `cap × range_size` previously exceeded
  that you **must** split the rule. Single-port UDP rules are unchanged.
* **v1.6 — standalone stats TUI** (`portunus-standalone stats`); no
  control-plane change.
* **v1.6.1 — `SO_REUSEADDR` on TCP listeners.** Fixes spurious
  `port_in_use` after `docker restart` / fast process recycles while
  `accept()`-ed child sockets linger in `TIME_WAIT`. Splice byte
  counters (`bytes_in` / `bytes_out`) now update incrementally on
  long-lived flows instead of only at connection close.
* **v1.7 — forwarder hardening.** UDP head-of-line-blocking fix,
  accept-loop backoff, time-boxed PROXY-protocol prelude and IP-target
  dials, bounded DNS cache, splice + byte counters on the multi-target
  failover path, and a fix for the per-rule rate limiter leaking on rule
  removal. All internal; no operator action.

## Downgrade caveats [#downgrade-caveats]

* **v0.5 → v0.4**: `identity.json` becomes inert; `operator_token` is
  silently ignored; auth-layer is absent so every operator request is
  unauthenticated again. RBAC state is lost (re-bootstrap required on
  a future v0.5+ upgrade). Forwarding rules and `tokens.json` survive.
* **v0.8 → v0.7**: not supported. v0.7 wrote JSON files; v0.8 wrote
  SQLite. There is no automated downgrade path.

## Checking the schema version [#checking-the-schema-version]

The query returns the single highest applied migration number (refinery
stores one integer per migration in `schema_migrations`):

```sh
sqlite3 /var/lib/portunus/state.db \
    'SELECT MAX(version) FROM schema_migrations;'
```

| Version | First shipped in | Migration(s)                |
| ------- | ---------------- | --------------------------- |
| `1`     | v0.8             | initial schema              |
| `2`     | v0.9             | TLS SNI routing             |
| `3`     | v0.10            | PROXY protocol (per-target) |
| `4`     | v0.10            | rule runtime columns        |
| `5`     | v0.11            | rate limiting               |
| `6`     | v1.1             | local password auth         |
| `7`     | v1.3.1           | per-client entry address    |
| `8`     | v1.4             | traffic quotas + history    |
| `9`     | v1.4.2           | client enrollments          |
| `10`    | v1.4.3           | runtime advertised endpoint |

v1.5 – v1.7 add no SQLite migrations; the head stays at `10`.

A newer binary auto-applies any missing migrations on startup. A binary
older than the on-disk schema refuses to start with exit 78
(`startup.schema_version_too_new`).
