Portunus
Features

UDP Forwarding

NAT-style UDP forwarding with kernel-allocated upstream sockets per source flow.

Available since v0.4.0. UDP rules coexist with TCP rules on the same listen port — the kernel demuxes by protocol.

How it works

Each UDP rule runs one listener task per listen port, each owning a fixed-size receive buffer. A datagram from a new end-user (source-addr, source-port) opens a flow: the proxy binds a fresh upstream UdpSocket, connect()s it to the selected target once, and registers the flow in a shared per-rule registry keyed by (listen_port, source_addr). Return datagrams are demuxed back to the originating source through this registry.

Receive-buffer memory is O(1) in flow count — each listener task owns one fixed-size receive buffer regardless of how many flows are live, not one buffer per flow. An idle reaper closes flows that go quiet (see udp_flow_idle_secs below).

On Linux, the listener uses batched syscalls (recvmmsg/sendmmsg, up to 32 datagrams per call) to cut per-packet syscall overhead. The batch path holds a fixed ~2 MiB receive arena per listener (32 slots × 64 KiB), plus a single 64 KiB single-packet fallback buffer. Neither grows with flow count. Other platforms skip the batch arena and use the single 64 KiB recv_from/send path.

Because the upstream socket is connect()-ed, Linux reflects ICMP errors (ECONNREFUSED, EHOSTUNREACH, ENETUNREACH) back to the proxy. An affected flow is evicted immediately; the next datagram from that source rebuilds the flow against a freshly selected target.

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.

On this page