| Crates.io | pfnatd |
| lib.rs | pfnatd |
| version | 1.0.0 |
| created_at | 2025-11-07 21:06:24.512095+00 |
| updated_at | 2025-11-17 04:13:31.142699+00 |
| description | Easy NAT mode for OpenBSD packet filter (pf) |
| homepage | |
| repository | https://github.com/mxk/pfnatd |
| max_upload_size | |
| id | 1922168 |
| size | 127,797 |
Easy NAT mode (aka Endpoint-Independent Mapping) for OpenBSD packet filter (pf). The pfnatd daemon monitors outbound STUN (RFC 8489) traffic via pflog(4) and adds nat-to rules to ensure that subsequent packets from the same source are translated to the same external address/port regardless of the destination. This allows UDP-based services to establish direct connections to peers without relays.
Add anchor "pfnatd" to your pf.conf before any other nat-to rules and reload pf. Rules within the anchor use match ... tag PFNATD, which allows additional processing by the main ruleset and requires an explicit pass rule to apply the translation. For example:
anchor "pfnatd" out on egress
pass out quick tagged PFNATD
Build, install, and start pfnatd daemon:
doas pkg_add git llvm rust
cargo install pfnatd
doas ~/.cargo/bin/pfnatd install
doas rcctl start pfnatd
When started via the rc.d(8) script, pfnatd logs messages to syslogd(8) using LOG_DAEMON facility. Use --log-level option to control verbosity:
doas rcctl set pfnatd flags --log-level=trace
Run pfnatd help to see additional commands and options.
pfnatd has a built-in STUN client for testing. Below are example outputs for a client behind an OpenBSD firewall.
Without pfnatd daemon running (two different random ports):
$ pfnatd stun stun.cloudflare.com
192.0.2.1:60389
$ pfnatd stun stun.l.google.com
192.0.2.1:54698
With pfnatd daemon running (same random port):
$ pfnatd stun stun.cloudflare.com
192.0.2.1:53203
$ pfnatd stun stun.l.google.com
192.0.2.1:53203
It is recommended to run doas pfnatd --log-level=trace while testing to see which packets are being logged to pflog(4).
By default, nat-to rules allocate a random outbound port for each distinct source/destination pair. This is hard NAT, which breaks STUN because the STUN server sees a source port that (most likely) won't match the port used for any other destination(s).
One workaround is to use the static-port option, but this prevents multiple LAN hosts from establishing a connection to the same destination using the same local source port. The post-NAT src:port and dst:port would be the identical, so both connections would match the same pf state entry.
By dynamically adding nat-to rules in response to STUN traffic, pfnatd allows the initial random port assigned by pf to be used for all other destinations from the same LAN src:port client. This effectively makes pf behave as an easy NAT device, while still allowing any number of LAN clients to connect to the same destination.
Other solutions to the hard NAT problem, not counting manual rule management, are UPnP, NAT-PMP, and PCP protocols. There are a few reasons why pfnatd uses the STUN approach:
The following rules are added to the pfnatd anchor:
match out log (matches, to pflog1) proto udp
This static rule allows pfnatd to identify STUN requests. By default, pfnatd monitors pflog1 interface, which is created automatically, to avoid interfering with pflogd(8) operation. The main ruleset is assumed to contain a default pass ... nat-to ... rule that, when matched and logged, triggers the creation of the following dynamic rule:
match out on <iface> proto udp from <src-ip> port <src-port> nat-to <nat-ip> port <nat-port> tag PFNATD
This rule is added for each unique STUN request and persists as long as there is at least one matching state. Source and NAT addresses are obtained from the packets logged by the first rule. The client application must implement a keep-alive mechanism either by repeating STUN requests or by exchanging packets with another endpoint in order to keep the state and this rule active.
While many STUN servers use the default UDP port 3478, some do not. For example, stun.cloudflare.com allows requests on port 53 and stun.l.google.com on port 19302. For this reason, the first match rule does not restrict the port. Instead, pfnatd inspects UDP data to only match STUN binding requests that contain RFC 5389 magic cookie. If stricter filtering is required, restrictions can be added to the anchor:
anchor "pfnatd" out on egress to port 3478
A race condition exists if the client sends multiple concurrent STUN requests to different servers. By the time pfnatd has added a nat-to rule for the first requests, other requests may have created additional states using different outbound translations. When pfnatd detects different translations for the same source, it kills any states that do not match the existing rule, causing those responses to be blocked. This is safe to do because STUN operates on a best-effort basis and must tolerate lost response packets.