Synopsis #
Network Address Translation (NAT) changes packet addresses as traffic crosses an OpenBSD router. Outbound NAT commonly allows an IPv4 network using private addresses to share one public address. Redirection, expressed with rdr-to in pf.conf(5)
, forwards inbound connections to another address or port.
Translation and filtering are separate decisions. A packet must match the intended translation and a pass rule. NAT is not a security boundary, and ordinary routed IPv6 should not be replaced by IPv4-style NAT without a specific design requirement.
Example Topology #
The examples use:
| Role | Interface or address |
|---|---|
| External interface | vio0 |
| Internal interface | vio1 |
| LAN | 192.168.10.0/24 |
| Router LAN address | 192.168.10.1 |
| Internal HTTPS server | 192.168.10.20 |
| Published external port | TCP 443 |
Replace every interface and address with values from the actual system. Confirm them with ifconfig(8) and route(8) .
Enable IPv4 Forwarding #
The kernel must forward IPv4 packets between interfaces. Set the runtime value with sysctl(8)
and add the persistent setting to /etc/sysctl.conf:
# sysctl net.inet.ip.forwarding=1
# Enable IPv4 forwarding now
# echo 'net.inet.ip.forwarding=1' >> /etc/sysctl.conf
# Enable it at subsequent boots
Do not enable IPv6 forwarding unless the host also has an intentional IPv6 routing and PF policy.
Configure Outbound NAT #
A concise default-block ruleset can use macros for the topology:
ext_if = "vio0"
int_if = "vio1"
lan_net = "192.168.10.0/24"
set skip on lo
block return
match out on $ext_if inet from $lan_net to any nat-to ($ext_if)
pass in on $int_if inet from $lan_net to any
pass out on $ext_if inet received-on $int_if
The parentheses around ($ext_if) cause PF to track address changes on a dynamically configured external interface. The match rule makes nat-to sticky. Translation changes the source address before the outbound filter decision, so the outbound rule identifies forwarded traffic by its ingress interface with received-on rather than matching the original private source address.
The internal clients must use 192.168.10.1 as their default gateway.
Add an Inbound Port Forward #
Add the internal server macro and a combined redirection and pass rule:
web_server = "192.168.10.20"
pass in on $ext_if inet proto tcp to ($ext_if) port 443 \
rdr-to $web_server port 443
The rule changes the destination to 192.168.10.20 and permits the connection. The resulting state handles return traffic.
Confirm that the server:
- listens on
192.168.10.20or an appropriate wildcard address; - uses
192.168.10.1as its route back to the external client, or otherwise returns through the same PF router; - permits the connection in any host-local policy;
- is not already reached through a conflicting rule earlier in the ruleset.
Complete Example #
The combined example provides outbound IPv4 NAT and one inbound HTTPS forward:
ext_if = "vio0"
int_if = "vio1"
lan_net = "192.168.10.0/24"
web_server = "192.168.10.20"
set skip on lo
block return
match out on $ext_if inet from $lan_net to any nat-to ($ext_if)
pass in on $int_if inet from $lan_net to any
pass out on $ext_if inet received-on $int_if
pass in on $ext_if inet proto tcp to ($ext_if) port 443 \
rdr-to $web_server port 443
A production ruleset normally also contains explicit ICMP policy, management access, logging, antispoofing decisions, and service-specific restrictions. Add those controls according to the router’s role rather than treating this example as a universal firewall policy.
Validate and Load the Rules #
Use pfctl(8) to parse before loading:
# pfctl -nf /etc/pf.conf
# Check syntax without changing the active ruleset
# pfctl -f /etc/pf.conf
# Load the complete validated ruleset
# pfctl -vvsr
# Display active filter rules and counters
# pfctl -sn
# Display active translation rules
Keep console access while changing a remote firewall.
Test Outbound NAT #
From a LAN client, test the router, a public IP address, and DNS separately:
$ ping -c 3 192.168.10.1
# Confirm LAN reachability to the router
$ ping -c 3 <known-reachable-ip>
# Confirm routing without relying on DNS
$ host openbsd.org
# Confirm resolver operation
On the router, inspect states and capture external traffic:
# pfctl -ss
# Confirm that the client connection created state
# tcpdump -n -e -ttt -i vio0 'host <known-reachable-ip>'
# Confirm that translated traffic leaves the external interface
Replace <known-reachable-ip> with an address that is expected to answer in the deployment. Documentation prefixes such as 198.51.100.0/24 are not reachability targets. The capture should show the router’s external address as the source, not the client’s private address.
Test the Port Forward Externally #
Use a client on a genuinely external network. A test from the LAN to the router’s external address does not cross the external interface and therefore does not exercise the same rule.
Capture both sides of the router with tcpdump(8) :
# tcpdump -n -e -ttt -i vio0 'tcp port 443'
# Observe the original external connection
# tcpdump -n -e -ttt -i vio1 'host 192.168.10.20 and tcp port 443'
# Observe the redirected connection toward the server
If the request reaches the server but no reply returns, inspect the server service and its route back to the client.
Internal Access to the Published Name #
The preferred design is split-horizon DNS: internal clients resolve the public service name directly to 192.168.10.20, while external clients resolve it to the public address. This avoids unnecessary translation and keeps the internal packet path explicit.
When split DNS is not possible, reflection requires both redirection and source NAT because the client and server share the same interface:
pass in on $int_if inet proto tcp from $lan_net \
to ($ext_if) port 443 rdr-to $web_server port 443
pass out on $int_if inet proto tcp to $web_server port 443 \
received-on $int_if nat-to ($int_if)
The second rule forces replies back through the PF router. The internal server therefore sees the router’s LAN address, not the original client address. Use this only when that loss of source identity is acceptable.
One-to-One Translation #
The binat-to option creates a bidirectional mapping between an internal and external address:
pass on $ext_if from 192.168.10.30 to any binat-to 198.51.100.30
The external address must be routed to the OpenBSD system and included in the network design. One-to-one translation does not replace a filter policy.
Troubleshooting #
Inspect the packet path in this order:
- interface link and addresses;
- client and router routes;
net.inet.ip.forwarding;pfctl -nf /etc/pf.conf;- active filter and translation rules;
- state-table entries;
- captures on ingress and egress;
- destination service and reverse route;
- resolver behavior.
Do not disable PF or flush all states as the first diagnostic step. Troubleshoot PF, NAT, and Routing provides a complete observation-driven workflow.
IPv6 #
Globally routable IPv6 networks normally use routing and filtering without address translation. Configure IPv6 forwarding, router advertisements or static routes, and an explicit PF policy only when the complete IPv6 path is understood. Do not copy the IPv4 NAT rules and substitute inet6.