# UDP Forwarding (https://portunus.bybee.dev/en/docs/features/udp-forwarding)



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

## How it works [#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 [#push-a-udp-rule]

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

UDP and TCP rules can share the same listen port:

```sh
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 [#tunables]

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

```toml
# 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.

<Callout type="info">
  Raise `udp_max_flows_per_rule` alongside `LimitNOFILE` in the systemd
  unit if your traffic legitimately needs more concurrent end-user sources.
</Callout>

## Range + DNS support [#range--dns-support]

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

## Metrics [#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 [#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 [#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`.
