Portunus

Forwarding rules

Create TCP, UDP, port-range, DNS-target, failover, and PROXY-protocol rules from the operator CLI.

TCP forwarding

How it works: TCP forwarding.

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=1

Within ~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

ConditionOutcome
Listen port already in use on clientRule state Failed(port_in_use); remove + retry on a free port
Target connect refused / timed outConnection-level error; rule stays Active, no metric scrubbing
Client disconnected when rule pushedHTTP 4xx with client_not_connected
Client reconnectsRules 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_total
  • portunus_rule_bytes_out_total
  • portunus_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.

UDP forwarding

How it works: UDP forwarding.

Push a UDP rule

portunus-server push-rule edge-01 6000 upstream.local:9999 --protocol udp

UDP and TCP rules can share the same listen port:

portunus-server push-rule edge-01 6000 upstream.local:9999                # TCP
portunus-server push-rule edge-01 6000 upstream.local:9999 --protocol udp # UDP

Tunables

Both knobs live in server.toml and propagate to the client via the Welcome message:

# Idle window before a per-flow upstream socket is reaped.
# Range: 30..=300. Default: 60.
udp_flow_idle_secs = 60

# Per-rule cap on simultaneous live UDP flows.
# Range: 1..=65535. Default: 1024.
udp_max_flows_per_rule = 1024

The cap is per rule, not per port: a port-range rule with udp_max_flows_per_rule = N admits at most N concurrent flows across all its listen ports combined — a single registry-wide counter, not N × range_size.

When sustained churn from many distinct sources exceeds the cap, overflow drops increment portunus_rule_flows_dropped_overflow_total. Existing flows are never evicted to make room — idle eviction handles steady-state shrinkage.

Raise udp_max_flows_per_rule alongside LimitNOFILE in the systemd unit if your traffic legitimately needs more concurrent end-user sources.

Range + DNS support

UDP works with port-range rules (one listener task per port, all sharing the rule's single flow registry and per-rule cap) and with DNS-name targets (resolver cache and single-flight semantics carry over verbatim).

Metrics

portunus_rule_udp_datagrams_in_total{client,rule,owner}
portunus_rule_udp_datagrams_out_total{client,rule,owner}
portunus_rule_active_flows{client,rule,owner}
portunus_rule_flows_dropped_overflow_total{client,rule,owner}

rule-stats adds a protocol field plus the UDP-specific counters.

Capability gate

Pushing a UDP rule against a v0.3.0-or-earlier client returns unsupported_protocol (HTTP 422 / exit 3). Upgrade the client first.

Verified performance

  • ~78,000 datagrams/s through the proxy on a developer-class macOS host (criterion bench udp_data_plane.single_flow_throughput).
  • Per-flow isolation under 1000 concurrent source ports — zero misroutes in stress test test_udp_us1_thousand_source_isolation.

Port range

How it works: Port range.

Push a range rule

portunus-server push-rule edge-01 30000-30050 upstream.local:30000-30050
# binds 51 ports atomically

UDP works the same way:

portunus-server push-rule edge-01 30000-30050 upstream.local:30000-30050 \
  --protocol udp

Limits

The default cap is 1024 ports per range, configurable in server.toml:

# Maximum ports a single port-range rule may span.
# Each port consumes one socket (one fd) on the client.
range_rule_max_ports = 1024

Raising this above the default also requires raising LimitNOFILE on the client's systemd unit. The cap exists so an operator typo can't burst the client's RLIMIT_NOFILE.

Per-port stats

# CLI
portunus-server rule-stats <rule_id> --per-port

# HTTP
curl -H "Authorization: Bearer $PORTUNUS_OPERATOR_TOKEN" \
     'http://127.0.0.1:7080/v1/rules/<id>/stats?per_port=true'

Per-port byte counters surface only through the API/CLI on demand; they are not exported as separate Prometheus series. The Prometheus cardinality budget stays one row per rule no matter how wide the range.

Conflict handling

Range conflicts reuse the v1 port_in_use error code with the offending port named in the message:

error: activation_failed: port_in_use (port 30005 already bound)

The whole range is atomic — if any port in the requested range is unavailable, the entire rule transitions to Failed and binds nothing.

Verified

  • 100-port range push wall-clock: ~18 ms on a Linux x86_64 host.
  • Total fresh-deploy → traffic round-trip on 3 sample ports: ~0.93 s.

DNS targets

How it works: DNS targets.

Push a DNS-target rule

# IPv4-first (default)
portunus-server push-rule edge-01 8443 api.example.com:443

# AAAA-first; falls back to A if no AAAA
portunus-server push-rule edge-01 8444 api.example.com:443 --prefer-ipv6

Failure visibility

DNS failures are exposed two ways:

# Per-rule field
portunus-server rule-stats <id>
# rule_id=… dns_failures=3 …

# Prometheus
curl -s 127.0.0.1:7081/metrics | grep dns_failures
# portunus_rule_dns_failures_total{client="edge-01",owner="alice",rule="2"} 3

The collector is one row per rule (not one per failure), preserving the cardinality budget. The row is removed alongside rule_active_connections on remove-rule.

Resolver tunables

Currently spec-fixed (no operator overrides yet). Listed here for visibility; a future release may surface them under [resolver] in server.toml:

KnobValue
cache_floor5 s
cache_ceiling5 min
stale_while_error_grace30 s
attempt_timeout3 s
negative_cache_retry3 s
max_concurrent_resolves64
max_cache_entries8192

Multi-target failover

How it works: Multi-target failover.

Push a multi-target rule

# Repeat --target; lower priority wins
portunus-server push-rule edge-01 8443 \
  --target primary.example.com:443@1 \
  --target backup.example.com:443@2 \
  --target standby.example.com:443@3

# Or pass a JSON literal
portunus-server push-rule edge-01 8443 --targets-json '[
  {"host":"primary.example.com","port":443,"priority":1},
  {"host":"backup.example.com","port":443,"priority":2}
]'

The HTTP API accepts a targets[] array on POST /v1/rules. The Web UI exposes an "Add another target" button on the rule push form.

Single-target rules are unchanged

Single-target rules stay byte-identical to v0.6.0 — multi-target lives in a separate code path entered via match targets.len() at rule activation, so existing throughput is unaffected.

Per-target stats

Per-target byte counters surface only on demand to keep the Prometheus cardinality bounded:

# CLI (single-target rules print a no-op note and exit 0)
portunus-server rule-stats <id> --per-target

# HTTP
curl -H "Authorization: Bearer $PORTUNUS_OPERATOR_TOKEN" \
     'http://127.0.0.1:7080/v1/rules/<id>/stats?per_target=true'

# SSE (live stream)
curl -N -H "Authorization: Bearer $PORTUNUS_OPERATOR_TOKEN" \
     'http://127.0.0.1:7080/v1/rules/<id>/stats/stream?per_target=true'

The Web UI rule detail page shows health badges (Healthy / Failed), last-failure / last-success timestamps, and per-target byte counters that update on the existing 5-second SSE cadence.

Default /metrics

One always-on counter: portunus_rule_target_failovers_total{client,rule,owner} captures Healthy↔Failed transitions per rule. Per-target byte counters are never in the default /metrics series.

Capability gate

Multi-target push to a v0.6.0-or-earlier client is rejected at the HTTP layer with 422 multi_target_unsupported_by_client.

Performance

Since v1.7.0 the live byte counters and the Linux splice(2) fast path also apply to the multi-target failover path; per-target accounting is reconciled centrally and closed correctly on error.

PROXY protocol

How it works: PROXY protocol.

Per-target opt-in means a single rule can mix PROXY-aware and PROXY-unaware backends. proxy_protocol is set per target in the targets[] array — via --targets-json (or POST /v1/rules), or in a standalone TOML target. The repeatable --target host:port[@priority] shorthand cannot carry it.

# backend-a gets the v2 binary header; backend-b gets the legacy byte stream
portunus-server push-rule edge-01 443 --targets-json '[
  {"host":"backend-a.local","port":8080,"priority":1,"proxy_protocol":"v2"},
  {"host":"backend-b.local","port":8080,"priority":2}
]'

Running the standalone forwarder instead? Set proxy_protocol per target in TOML — see Standalone → Forwarding rules.

UDP rules carrying proxy_protocol are rejected with validation.proxy_protocol_on_unsupported_rule. The accepted values are "v1" and "v2"; anything else fails validation.proxy_protocol_invalid.

On this page