Architecture
How the control-plane server, edge clients, operator surface, and data-plane fit together.
Portunus separates the control plane (rule push, auth, metrics rollup)
from the data plane (byte forwarding). The two never share code paths,
which is what lets the project guarantee data-plane stability across releases.
The full control-plane deployment is a two-process system: portunus-server
manages state and pushes rules to one or more portunus-client edge processes.
The data plane itself lives in the standalone portunus-forwarder library,
which both the client and the single-binary
portunus-standalone forwarder consume —
so a TOML-driven standalone host runs the same forwarding code without any
control plane.
Components
┌──────────────────────────────────────┐
│ portunus-server │
│ │
Operator ──┤ • CLI subcommands │
(CLI/HTTP) │ • Operator HTTP API + Web sessions │
│ • Web UI (loopback) │
│ • RBAC: users, credentials, grants │
│ • SQLite state store │
│ • Prometheus /metrics │
└──────────────┬───────────────────────┘
│ gRPC / TLS-pinned
│ (bidirectional stream)
┌──────────────▼───────────────────────┐
│ portunus-client │
│ │
End-user ──▶ TCP accept loop / UDP recv_from loop │
traffic │ │
│ • Per-rule listeners │
│ • Per-target health tracking │
│ • DNS resolver with caching │
│ • Bidirectional copy / NAT-style │
│ UDP flow demux │
└──────────────┬───────────────────────┘
│ TCP / UDP
▼
Configured upstream targetsStandalone mode
For a single host that needs the same forwarding without a control plane,
portunus-standalone reads a TOML file
and runs the portunus-forwarder data plane directly:
portunus.toml
│ rules, defaults, [stats]
▼
┌──────────────────────────────────────┐
│ portunus-standalone │
│ │
End-user ──▶ portunus-forwarder data plane │
traffic │ • Per-rule TCP / UDP listeners │
│ • Port ranges, DNS targets │
│ • Multi-target failover │
│ • PROXY protocol │
│ • Optional stats over a Unix socket │
│ → `portunus-standalone stats` TUI │
└──────────────┬───────────────────────┘
│ TCP / UDP
▼
Configured upstream targetsIt runs the same data plane as portunus-client, so the
Data plane section below applies unchanged. Everything else
is gone: no gRPC control stream, operator HTTP API, Web UI, RBAC, audit log,
or Prometheus /metrics. Configuration is the TOML file, reloaded on
restart, and its schema does not expose TLS SNI routing or rate limiting —
both are configured only through the server. Live throughput is read from
the optional stats socket and the built-in stats TUI. See the
standalone guide for the full schema.
Control plane
- TLS-pinned client authentication. The server generates a self-signed leaf cert on first run; every client bundle includes the SHA-256 fingerprint, which the client pins on every connect.
- Operator HTTP API on
operator_http_listen(loopback by default). Protected routes accept either Web session cookies orAuthorization: Bearer <token>API credentials. - gRPC control stream on
control_listen. Rule pushes, stats reports, health updates, hot-reload events all flow over a single bidirectional stream per client. - SQLite state at
<data-dir>/state.db(WAL mode, refinery migrations). Holds users, credentials, grants, rule targets, and the audit log.
Data plane
- TCP: each rule binds an
acceptloop; per-connection bidirectional copy with no userspace buffering beyond the kernel send/recv windows. On Linux, an uncapped rule moves bytes through a kernelsplice + pipefast path so the payload never enters userspace; any bandwidth cap, UDP, or a non-Linux build falls back to the userspace copy. See the performance report. - UDP: each rule runs a single centralized runtime — one listener task
per listen port, a shared flow registry keyed by
(listen_port, source-addr), a reply-demux task, and an idle-flow reaper. Each flow gets oneconnect()-ed upstream socket (multi-target selection happens once, at flow creation), giving NAT-style return-path isolation. The flow cap (udp_max_flows_per_rule) is enforced rule-wide. On Linux, ICMP errors (port/host/network unreachable) evict the affected flow; the next datagram rebuilds it against a freshly selected target. - DNS targets: resolved on first connect, cached per the resolver's
TTL clamped to
[5 s, 5 min], with 30 s stale-while-error grace. - Multi-target failover: 1..=8 targets per rule, priority-ordered.
Passive health detection (3 consecutive failures →
Failed, 2 consecutive successes →Healthy) is always on and is the only health mechanism in standalone mode. An optional active TCP-connect probe is a control-plane-only feature: it runs only when the server pushes a rule with--health-check-interval-secs, so the TOML-drivenportunus-standaloneforwarder has passive failover only — no active probing. - PROXY protocol: an optional v1/v2 header prepended to the upstream connection so the target sees the original client address (TCP only).
- TLS SNI routing: peeks the ClientHello, parses the SNI extension, routes by exact host or single-label wildcard. Never decrypts.
- Rate limiting: token-bucket throttle for bandwidth caps; accept-then-RST for connection-rate / concurrent caps.
Constitution invariants
The project enforces a small set of invariants across every release:
- Single-seam auth — every operator request flows through one
auth_layer. There is no second code path. - Data-plane stability — TCP / UDP forwarding hot paths are byte-identical across versions where the spec doesn't explicitly call for a change. Criterion benches gate regressions at ±25%.
- Contracts before implementation — every wire-format and HTTP-API change ships with a contract test before the feature lands.
- No raw secrets in logs — a redaction layer scrubs known-sensitive field names; the audit emitter only sees post-verify identities.
- Server-side enforcement — the SPA renders whatever the server returns; RBAC isolation is never client-side.
Versioning
Wire-format changes are additive only. New fields use proto3 default-stripping so a newer client talking to an older server simply omits the new fields, and an older client talking to a newer server sees no behavioural change because the server gates pushes via a capability check.
See CHANGELOG.md
for the full release history.