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 udpUDP 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 # UDPTunables
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 = 1024The 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.