This lab walks the most common NAT configuration on an OPNsense
firewall: outbound NAT for the LAN and a port forward for an
inbound service. You will configure both, verify both with
pfctl, and prove end-to-end that an external client can reach
the internal server through the port forward. The discipline on
display is the one a production change request requires: snapshot
before, change deliberately, verify after, document the result.
By the end you will have used the GUI to set up NAT that the firewall can actually pass traffic through, and you will have read the compiled PF ruleset to confirm the GUI’s output is what the kernel sees.
Objective
By the end of this lab, you can:
- Configure hybrid outbound NAT mode for the LAN subnet.
- Verify the outbound NAT translation in the state table.
- Configure a port forward that maps a WAN port to an internal server.
- Read the auto-generated rdr and pass rules in
pfctl -s rules. - Test the port forward from an external client and confirm the traffic flows.
Requirements
- An OPNsense instance with at least two interfaces configured (LAN and WAN).
- An internal server on the LAN with a service listening on the port you will forward (HTTP on 8080, SSH on 2222, anything).
- An external client on the WAN side (or a second nested VM that can reach the WAN IP).
- A snapshot of the current configuration that you can revert to if the change does not go as planned.
Tasks
Task 1: Snapshot the current state
Capture the ruleset, NAT rules, and state-table statistics before the change. The diff between before and after is the evidence that the change did what you intended.
pfctl -s rules > /tmp/rules-before.txt
pfctl -s nat > /tmp/nat-before.txt
pfctl -s state | wc -l > /tmp/state-count-before.txt
cp /conf/config.xml /tmp/config-before.xml
Verify the snapshots are non-empty:
ls -lh /tmp/rules-before.txt /tmp/nat-before.txt /tmp/config-before.xml
cat /tmp/state-count-before.txt
Task 2: Configure hybrid outbound NAT
In the OPNsense GUI, navigate to Firewall → NAT → Outbound. The mode selector shows three options: Automatic, Hybrid, and Manual. Switch to Hybrid.
In hybrid mode, automatic rules continue to work for any interface without an explicit manual rule, and manual rules override the automatic ones for the interfaces they cover. The common pattern is to leave the LAN on automatic (the default rule suffices) and add explicit rules for special cases (a guest VLAN, a DMZ, a server subnet that needs to egress through a specific WAN).
For this lab, add a manual rule:
- Interface: WAN
- Source: 192.0.2.0/24 (your LAN subnet)
- Destination: any
- Translation: Interface address
- Description: LAN outbound — manual hybrid rule
Apply the change.
Task 3: Verify the outbound NAT in the state table
From a LAN client, generate outbound traffic:
curl -v --max-time 5 https://example.com/ > /dev/null 2>&1
On the firewall, inspect the state table for this flow:
# Substitute your own values before running:
LAN_CLIENT_IP=192.0.2.50 # the LAN host that made the request
pfctl -s state | grep "$LAN_CLIENT_IP"
You should see the flow with the post-NAT source address (the firewall’s WAN IP), not the LAN client’s private IP. The NAT translation is active.
Get the verbose view to confirm:
pfctl -ss | grep -A 1 'tcp 192.0.2' | head -8
The metadata line shows the post-NAT translation:
all tcp <lan-client>:random <- <remote>:443 ESTABLISHED:ESTABLISHED
[ <epoch> + <timeout> ] age <elapsed>, <firewall-wan-ip>:random -> <remote>:443
The second line is the post-NAT translation: the source has been rewritten from the LAN client’s IP to the firewall’s WAN IP. This is the outbound NAT doing its work.
Task 4: Add an internal server alias
Navigate to Firewall → Aliases and add a host alias:
- Name: lab_server
- Type: Host(s)
- Value:
INTERNAL_SERVER_IP - Description: Lab port-forward target
Apply the change. Confirm the alias is loaded:
pfctl -t lab_server -T show
The output should show the internal server’s IP.
Task 5: Configure the port forward
Navigate to Firewall → NAT → Port Forward → Add.
- Interface: WAN
- Protocol: TCP
- Destination: WAN address
- Destination port: 8443 (the external port)
- Redirect target: lab_server (the alias from Task 4)
- Redirect port: 443 (the internal port)
- Description: Lab port forward — WAN:8443 to lab_server:443
- Filter rule association: Add associated filter rule (checked)
Apply the change.
Task 6: Read the generated NAT and firewall rules
The GUI wrote two rules in the compiled ruleset: an rdr rule
under NAT and a pass rule under the firewall. Inspect both:
pfctl -s nat | grep -A 1 lab
You should see the rdr on igb1 inet proto tcp from any to any port = 8443 -> <internal-server-ip> port 443 rule.
pfctl -s rules | grep -A 1 lab_server
You should see the auto-generated pass rule:
pass in quick on igb1 inet proto tcp from any to <internal-server-ip> port = 443
Both rules are present. The two are linked by the post-DNAT
destination: the rdr rule rewrites the destination, and the
pass rule permits the rewritten packet.
Task 7: Test the port forward from an external client
You need a client that can reach the firewall’s WAN IP. In a nested setup, this is your “external” VM. From that VM:
# Substitute your own values before running:
FW_WAN_IP=203.0.113.10 # the firewall's WAN address
curl -v --max-time 10 -k "https://$FW_WAN_IP:8443/"
If the internal server is listening on 443 with a self-signed
cert, the -k flag disables cert verification so the test is
about reachability, not TLS.
On the firewall, confirm the state was created:
pfctl -s state | grep ':8443'
You should see one entry for the flow — a single state entry matches traffic in both directions. The state confirms the connection reached the firewall, was DNAT’d to the internal server, and the response was SNAT’d back.
Read the verbose view to see the translation:
pfctl -ss | grep -A 1 ':8443' | head -4
The metadata line shows the post-NAT destination:
all tcp <external-client>:<port> <- <firewall-wan-ip>:8443 ESTABLISHED:ESTABLISHED
[ <epoch> + <timeout> ] age 00:00:01, <external-client>:<port> -> <internal-server-ip>:443
The second line shows the destination has been rewritten to the internal server’s IP — the post-DNAT address.
Task 8: Capture the packet flow
A packet capture makes the NAT visible:
tcpdump -ni igb1 'port 8443' -c 10
In another session, repeat the curl from Task 7. The capture
shows the inbound packet on WAN with destination <firewall-wan-ip>:8443
and the outbound response from the internal server with source
<internal-server-ip>:443.
# Substitute your own values before running:
INTERNAL_SERVER_IP=192.0.2.10 # the LAN host behind the port forward
tcpdump -ni igb0 "host $INTERNAL_SERVER_IP and port 443" -c 10
The LAN-side capture shows the translated packet: the source is the external client, the destination is the internal server. The LAN never sees the firewall’s WAN IP for the rewritten packet.
Task 9: Test the firewall rule rejects unauthenticated traffic
What if you try to reach the internal server on port 443 without the port forward? On the LAN side, you should be able to reach the server directly (depending on your LAN rules). On the WAN side, you cannot — the firewall’s default deny on WAN rejects direct connections to internal IPs.
From the external client:
# Substitute your own values before running:
INTERNAL_SERVER_IP=192.0.2.10 # the LAN host behind the port forward
curl -v --max-time 5 -k "https://$INTERNAL_SERVER_IP:443/" 2>&1 | head -5
The connection should fail. The firewall’s WAN rules do not permit traffic from the WAN to the internal server’s IP directly; only the port forward’s rdr rule, in combination with the associated pass rule, allows the rewritten traffic.
Task 10: Document the lab
Write a short post-lab report:
NAT AND PORT FORWARD LAB REPORT
==============================
Outbound NAT (hybrid mode)
- Manual rule: WAN, source 192.0.2.0/24, dest any,
translation Interface address
- Verified: post-NAT source in pfctl -ss shows firewall WAN IP
Port forward
- External: WAN:8443
- Internal: lab_server:443 (alias resolves to <internal-server-ip>)
- Filter rule association: enabled (auto-generated pass rule)
- Verified: rdr and pass rules present in pfctl -s nat / -s rules
End-to-end test
- External client reached internal server via WAN:8443
- State created, post-NAT destination in pfctl -ss confirmed
- Direct WAN-to-internal-server attempt blocked by default deny
Attach the /tmp/rules-before.txt, /tmp/nat-before.txt,
/tmp/rules-after.txt (capture from Task 6), and the
post-lab report.
Validation
- Hybrid outbound NAT is configured and a manual rule for the LAN subnet is in place.
pfctl -s statefor an outbound flow from the LAN client shows the post-NAT source as the firewall’s WAN IP.- A port forward is configured: WAN:8443 to lab_server:443.
- The auto-generated
rdrrule is inpfctl -s nat; the auto-generatedpassrule is inpfctl -s rules. - An external client can reach the internal server via
https://<firewall-wan-ip>:8443/. - A direct attempt from the external client to the internal server’s IP on port 443 is blocked.
- The post-lab report and snapshot files are in
/tmp/.
Cleanup
You have two changes to revert: the manual outbound NAT rule and the port forward. Revert in reverse order — remove the port forward first, then the manual NAT rule, then restore the outbound mode to Automatic.
# 1. Delete the port forward from the GUI
# Firewall → NAT → Port Forward → delete the lab entry
# Apply the change
# 2. Delete the manual outbound NAT rule from the GUI
# Firewall → NAT → Outbound → delete the manual rule
# Switch back to Automatic outbound NAT mode
# Apply the change
# 3. Delete the alias
# Firewall → Aliases → delete lab_server
# Apply the change
# 4. Confirm the firewall is back to its pre-lab state
pfctl -s nat > /tmp/nat-after.txt
pfctl -s rules > /tmp/rules-after.txt
diff /tmp/nat-before.txt /tmp/nat-after.txt
diff /tmp/rules-before.txt /tmp/rules-after.txt
The diffs should be empty (or show only the order-change artefacts of the GUI’s regeneration). If either diff shows unexpected entries, restore the configuration backup.
cp /tmp/config-before.xml /conf/config.xml
configctl filter reload
# or, if needed:
/usr/local/sbin/pfctl -f /conf/pf.conf
Verify with a final test:
# Substitute your own values before running:
LAN_CLIENT_IP=192.0.2.50 # the LAN host that made the request
curl -v --max-time 5 https://example.com/ > /dev/null 2>&1
pfctl -s state | grep "$LAN_CLIENT_IP"
echo "expected: outbound NAT still working"
What you learned
- Hybrid outbound NAT lets the default automatic rules coexist with explicit manual rules. The cleanup story is easier than “Manual” mode while still letting you add exceptions.
- The port forward has two parts: an
rdrrule that rewrites the destination, and apassrule that permits the rewritten packet. The associated filter rule checkbox generates both from one GUI entry. - Verify the ruleset after every change. The GUI accepts the change but the kernel applies the compiled ruleset; the two can disagree on edge cases.
- The state table is the proof that the NAT and firewall rules
did what they were supposed to do. The
pfctl -ssview shows the post-NAT addresses that the kernel actually used. - End-to-end testing matters. A successful NAT inspection without a successful end-to-end test is a partial verification.