pfnatd

Crates.iopfnatd
lib.rspfnatd
version1.0.0
created_at2025-11-07 21:06:24.512095+00
updated_at2025-11-17 04:13:31.142699+00
descriptionEasy NAT mode for OpenBSD packet filter (pf)
homepage
repositoryhttps://github.com/mxk/pfnatd
max_upload_size
id1922168
size127,797
Maxim Khitrov (mxk)

documentation

README

pfnatd

Crates.io Version Crates.io License

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.

Installation on OpenBSD 7.8+

  1. 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
    
  2. 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.

Testing

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).

Technical Details

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:

  1. It is transparent and passive. There is no server listening for incoming connections on the local network.
  2. It is more secure. Rules are added only to translate outbound traffic rather than to open any inbound ports. Inbound traffic is only allowed implicitly by matching state established by outbound traffic.
  3. Malicious clients cannot control external port assignment or open any services to the entire internet.

Rule overview

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

Known Issues

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.

Commit count: 0

cargo fmt