OPNsenseXIII · Port Forwarding and NAT ReflectionPort forwarding
Port forwarding packet path — Internet to internal server through OPNsense
What you'll learn
- Trace a packet from Internet host to internal server through OPNsense
- Identify the DNAT stage and the firewall rule stage
- Recognise the rdr-to clause in PF output
- Explain why return traffic requires state
Prerequisites
Verified against OPNsense 25.x · FreeBSD 14.x · PF (FreeBSD packet filter) FreeBSD 14.x · Unbound 1.20+ · Kea DHCP OPNsense 25.x plugin · WireGuard in-kernel + OPNsense plugin · strongSwan (IPsec plugin) OPNsense 25.x plugin · OpenVPN 2.6.x · Suricata 7.x · 2026-08-14
A port forward is the inverse of outbound NAT: an external client sends traffic to the firewall’s public IP on a specific port; the firewall rewrites the destination and forwards the packet to an internal server. To the external client, the firewall’s public IP appears to host the service. To the internal server, the original client’s IP is visible as the source.
This lesson traces the complete packet path of a port-forwarded flow, from the Internet client through every stage on the firewall to the internal server. Every other lesson in Part XIII builds on this packet path.
The end-to-end packet path
The setup: the firewall has a WAN IP 198.51.100.1. An internal server at 192.0.2.10 runs HTTPS on port 443. The operator has created a port forward from WAN:443 to 192.0.2.10:443, with the auto-generated firewall rule enabled.
An Internet client at 203.0.113.99 wants to reach the service. The client sends a SYN to 198.51.100.1:443. The packet path:
-
Packet arrives on WAN (igb1). The kernel receives the packet, hands it to PF for evaluation. The packet has source 203.0.113.99:51234 and destination 198.51.100.1:443.
-
DNAT (rdr). PF evaluates the rdr anchor. The port forward rule matches: destination 198.51.100.1:443 gets rewritten to 192.0.2.10:443. The packet now has source 203.0.113.99:51234 and destination 192.0.2.10:443.
-
Filter. PF evaluates the filter rules on the WAN interface. The auto-generated associated rule matches: pass in on WAN proto tcp from any to 192.0.2.10 port 443. PF creates state and decides to pass.
-
Routing. PF routes the (post-DNAT) packet toward 192.0.2.10 via the LAN (igb0). The kernel routing table has 192.0.2.0/24 as a directly connected network on igb0.
-
SNAT (nat) check. PF evaluates the nat anchor for outbound traffic from 192.0.2.10. No nat rule matches (port-forwarded inbound traffic is not SNAT’d), so the source remains 203.0.113.99:51234.
-
Packet leaves on LAN. The server at 192.0.2.10 receives the SYN with source 203.0.113.99:51234 and destination 192.0.2.10:443. The server sees a real client IP — not the firewall’s WAN IP.
-
Reply. The server sends a SYN-ACK from 192.0.2.10:443 to 203.0.113.99:51234. The reply goes back through the LAN to the firewall.
-
Return path. PF matches the state entry. The reply’s destination 203.0.113.99:51234 is the original client, and the source 192.0.2.10:443 is the original (post-DNAT) destination. The state entry has the reverse translation: external destination 198.51.100.1:443, internal destination 192.0.2.10:443. PF rewrites the source back to 198.51.100.1 and routes the reply to the client via the WAN.
The client sees the connection as if the firewall itself was the server. The server sees the connection as if the client was directly attached. The translation is invisible to both.
$ pfctl -s natrdr on igb1 inet proto tcp from any to any port = 443 -> 192.0.2.10 port 443Illustrative output
The associated firewall rule
The port forward entry alone does not allow traffic. It only rewrites. The firewall still has to permit the rewritten packet. By default, OPNsense generates an associated firewall rule on the WAN interface that allows traffic matching the rdr rule.
The associated rule:
pass in quick on igb1 inet proto tcp from any to 192.0.2.10 port = 443
The rule matches the post-DNAT destination (192.0.2.10:443) and passes the packet. Without this rule, the rdr rule would rewrite the packet, but PF would then evaluate the WAN rules and find no match — the implicit deny at the bottom would block the packet.
Why state is essential
A port forward without state is broken in two ways. First, the firewall cannot reverse-translate the reply: the reply arrives with source 192.0.2.10:443 and destination 203.0.113.99:51234; without state, the firewall has no record that this reply is the return traffic for the original SYN, and the firewall has no record that the source should be rewritten back to 198.51.100.1:443. The reply would be forwarded to the client with source 192.0.2.10:443 — a private IP that the client cannot route to. The reply would be dropped.
Second, even if the reply somehow reached the client, the client would see source 192.0.2.10:443 instead of 198.51.100.1:443. The TCP connection state would not match; the client would respond with a RST.
State ties the forward and reverse translations together. PF records the (external client, external server, internal server) tuple in the state entry. Return traffic matches the state entry by the (source, destination) tuple, and PF applies the reverse translation automatically.
$ pfctl -s state -v | grep -A 1 192.0.2.10 | head -2all tcp 192.0.2.10:443 (198.51.100.1:443) <- 203.0.113.99:51234 ESTABLISHED:ESTABLISHED
age 00:01:42, expires in 23:58:18, 92:184 pkts, 9218:214400 bytes, rule 21Illustrative output
The life of the single state entry
The connection passes through four phases from the firewall’s perspective, and all four are the same state entry. PF does not create a second entry for the reply direction and does not create a third or fourth for the handshake — one connection is one state:
- SYN. The client sends a SYN. PF creates the state entry on the SYN. The entry’s TCP tracking shows the handshake in progress.
- SYN-ACK. The server replies with a SYN-ACK. PF matches the same entry on the SYN-ACK and advances its TCP tracking.
- ESTABLISHED. Both sides exchange data. PF matches each packet against the same entry. No new state is created, and the packet-and-byte counters on that entry climb.
- FIN/RST. One side closes. PF matches the FIN/RST on the same entry, advances it to a closing state, and the entry is removed when the closing timeout expires.
State is created on the SYN because the rdr rule’s associated
pass rule keeps state by default. PF does not wait for the
SYN-ACK to create state; it creates state on the first matching
packet. pfctl -s state prints the entry’s TCP state pair at
the end of the summary line (ESTABLISHED:ESTABLISHED above —
the local and peer halves of the tracking), which is how the
operator sees which phase the connection is in; -v adds the
age, the counters and the rule number on a second line.
PF does not emit a log line per TCP state transition. It logs
packets matched by a rule carrying the log keyword, so what
appears in the firewall log is the SYN that created (or was
blocked before creating) the state, plus any later packet that
was matched by a logging rule — typically a block. Operators
debugging “why did the connection drop?” therefore look for the
logged SYN and for block entries; the per-packet progress of an
established flow is not in the log, and is read from the state
entry’s counters with pfctl -s state -v instead.
The rdr-to clause in detail
The full syntax of an rdr rule:
rdr on <interface> inet proto <protocol> from <source> to <destination> \
port <ext-port> -> <internal-ip> port <int-port>
The fields:
- Interface. The ingress interface. Packets arriving on this interface are evaluated against the rule.
- Protocol. TCP, UDP, or another protocol. ICMP has its own handling.
- Source. The source address. Often
any, but can be restricted to specific CIDRs. - Destination. The destination address. Often
any(any address on the firewall), but can be restricted to specific IPs — useful when the firewall has multiple WAN IPs. - External port. The port the external client sends to.
- Internal address and port. The destination after DNAT.
The destination field is often overlooked. A firewall with two public IPs (198.51.100.1 and 198.51.100.5) needs two different port forwards if the operator wants one service on each IP. The “destination” field disambiguates.
A worked example
The requirement: expose a web server at 192.0.2.10 on port 443 to the Internet via the firewall’s WAN IP 198.51.100.1.
The GUI configuration under Firewall → NAT → Port Forward → Add:
- Interface: WAN
- Protocol: TCP
- Source: any
- Destination: WAN address (198.51.100.1)
- Destination port: 443
- Redirect target IP: 192.0.2.10
- Redirect target port: 443
- Filter rule association: [x] Add associated filter rule
The generated rules:
rdr on igb1 inet proto tcp from any to 198.51.100.1 port = 443 -> 192.0.2.10 port 443
pass in quick on igb1 inet proto tcp from any to 192.0.2.10 port = 443
Verification:
pfctl -s natshows the rdr rule.pfctl -s rulesshows the pass rule.tcpdump -ni igb1 host 203.0.113.99 and port 443shows the incoming SYN.tcpdump -ni igb0 host 192.0.2.10 and port 443shows the forwarded SYN.curl https://198.51.100.1from a host on the Internet returns the web server’s response.
Common failure modes at this stage
Three failure modes occur most often:
- No associated rule. The rdr rule rewrites the destination, but no firewall rule permits the rewritten packet. PF drops the packet after rdr. The fix: enable the associated rule or write a manual rule.
- Wrong internal IP. The rdr rule points to the wrong
internal server. The packet is forwarded to the wrong host,
which may not be listening on the port. The connection times
out. The fix: verify the internal IP with
pingortcpdump -ni igb0 host \<internal\>. - The internal server’s default gateway is not the firewall. The server receives the SYN; replies with a SYN-ACK; the reply goes to its default gateway (which is some other router on the LAN); the reply never reaches the firewall. The fix: set the server’s default gateway to the firewall’s LAN IP.
Summary
- A port forward rewrites the destination on inbound traffic; the firewall rules permit the rewritten packet.
- The packet path is: WAN → rdr → filter (state created) → routing → LAN → server. The reply reverses the translation using state.
- The associated firewall rule is generated by default and uses the post-DNAT destination (the internal IP).
- State is required for reverse translation of the reply.
- The internal server’s default gateway must be the firewall, or the reply is routed to the wrong next hop and lost.
Knowledge check · 4 questions
Q1. In the port forward packet path, when is PF state created for the flow?
Q2. A port forward from WAN:443 to 192.0.2.10:443 generates an associated firewall rule with destination 198.51.100.1 (the WAN IP).
Q3. A port forward is configured and the associated rule is enabled, but `curl https://198.51.100.1` from the Internet hangs and times out. Which of the following are likely causes? Select all that apply.
Q4. In `pfctl -s nat` you see `rdr on igb1 inet proto tcp from any to any port = 443 -> 192.0.2.10 port 443`. The destination field is "any" rather than the WAN IP. What does this mean?
Passing score: 75%. Answers are checked in this browser.