TCP Forwarding
The core forwarding primitive — bind a TCP listener on the edge client and forward every accepted connection to a configured upstream target.
TCP forwarding is the core primitive. Every other feature (UDP, port ranges, SNI routing, rate limiting) layers on top of it.
How it works
Each TCP rule runs a single accept loop on the client. For every
accepted connection the client opens one upstream TcpStream::connect
to the configured target and runs a bidirectional byte copy until either
side closes.
There is no userspace buffering beyond the kernel's send/recv windows. The forwarder is a pure L4 byte passthrough — TLS sessions, HTTP requests, and arbitrary protocols pass through unmodified.
On Linux, connections without a bandwidth cap take the splice(2)
fast path (zero-copy through the kernel). Rate-limited connections fall
back to a userspace copy loop. The listener binds with SO_REUSEADDR,
so a fast process restart (e.g. docker restart) rebinds immediately
instead of failing with port_in_use while a prior socket sits in
TIME_WAIT.
Push a rule
portunus-server push-rule <client_name> <listen_port> <target_host>:<target_port>Example:
portunus-server push-rule edge-01 8080 example.com:80
# → rule_id=1Within ~1 second (the ack budget) the rule transitions
Pending → Active and the client starts accepting on port 8080.
Inspect
# Operator CLI
portunus-server list-rules --client edge-01
portunus-server rule-stats 1
# Operator HTTP API
curl -H "Authorization: Bearer $PORTUNUS_OPERATOR_TOKEN" \
http://127.0.0.1:7080/v1/rules
# Prometheus
curl -s http://127.0.0.1:7081/metrics | grep '^portunus_rule'Remove
portunus-server remove-rule <rule_id>In-flight connections drain up to shutdown_drain_timeout_secs
(default 30 s). The listener stops accepting immediately.
Failure modes
| Condition | Outcome |
|---|---|
| Listen port already in use on client | Rule state Failed(port_in_use); remove + retry on a free port |
| Target connect refused / timed out | Connection-level error; rule stays Active, no metric scrubbing |
| Client disconnected when rule pushed | HTTP 4xx with client_not_connected |
| Client reconnects | Rules persist server-side and re-push to the same client |
Metrics
Per-rule collectors (one row per live rule, labels {client, rule, owner}):
portunus_rule_bytes_in_totalportunus_rule_bytes_out_totalportunus_rule_active_connections
Byte counters update incrementally (every 64 KiB) even on the splice
fast path, so long-lived connections (SSH, gRPC streams, WebSockets)
report live throughput instead of staying frozen until the connection
closes.
See Metrics for the full collector list.
See also
- UDP Forwarding — same model for UDP
- Multi-target Failover — push 1..=8 targets per rule
- DNS-name Targets — use a hostname instead of an IP