Upgrade Guide
Wire-format invariants, capability gates, and the v0.7 → v0.8 fresh-start convention.
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
| 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
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
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)
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:
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-serverAfter 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)
The operator API gained Authorization: Bearer <token> requirement. Existing
CLI scripts need either:
export PORTUNUS_OPERATOR_TOKEN=<token>…or the bootstrap shortcut in server.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)
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
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. - 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. - v1.5 — UDP per-rule flow cap correction.
udp_max_flows_per_ruleis now enforced once across the whole rule instead of per listen port. A range UDP rule's effective capacity drops by a factor ofrange_size. If you relied on the old inflated capacity, raiseudp_max_flows_per_ruleproportionally or split the range; the field is capped at65535, so ifcap × range_sizepreviously 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_REUSEADDRon TCP listeners. Fixes spuriousport_in_useafterdocker restart/ fast process recycles whileaccept()-ed child sockets linger inTIME_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
- v0.5 → v0.4:
identity.jsonbecomes inert;operator_tokenis 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 andtokens.jsonsurvive. - 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
The query returns the single highest applied migration number (refinery
stores one integer per migration in schema_migrations):
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).