Skip to main content
RunBook Academy

OPNsenseXIII · Port Forwarding and NAT ReflectionPort forwarding

Port forwarding packet path — Internet to internal server through OPNsense

Foundation⏱ ~13 minpfctltcpdump

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

Not yet marked complete on this device.

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:

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

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

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

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

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

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

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

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

Read-only / Saferdr-to rule
$ pfctl -s nat
rdr on igb1 inet proto tcp from any to any port = 443 -> 192.0.2.10 port 443

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

Read-only / Safeport-forward state entry
$ pfctl -s state -v | grep -A 1 192.0.2.10 | head -2
all 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 21

Illustrative 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:

  1. SYN. The client sends a SYN. PF creates the state entry on the SYN. The entry’s TCP tracking shows the handshake in progress.
  2. SYN-ACK. The server replies with a SYN-ACK. PF matches the same entry on the SYN-ACK and advances its TCP tracking.
  3. 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.
  4. 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 nat shows the rdr rule.
  • pfctl -s rules shows the pass rule.
  • tcpdump -ni igb1 host 203.0.113.99 and port 443 shows the incoming SYN.
  • tcpdump -ni igb0 host 192.0.2.10 and port 443 shows the forwarded SYN.
  • curl https://198.51.100.1 from 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 ping or tcpdump -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

  1. Q1. In the port forward packet path, when is PF state created for the flow?

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

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

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