This lab builds a WireGuard remote-access tunnel on OPNsense: one peer (a road-warrior laptop) connecting into the firewall, with firewall rules and outbound NAT that put the peer’s traffic on the LAN. By the end, the peer can ping a host on the LAN and the LAN sees traffic coming from the tunnel subnet.
The lab uses a single peer because the production discipline does not change with N. When you have a hundred peers, you have a hundred rows in the GUI and one firewall rule on the WireGuard group interface.
Objective
By the end of this lab, you can:
- Install the os-wireguard plugin and confirm the kernel module is loaded.
- Generate Curve25519 key pairs on both ends without leaking the private keys to a screenshot or shell history.
- Configure a tunnel with
Listen Port = 51820and a tunnel address of10.10.99.1/24. - Configure a peer with
AllowedIPs = 0.0.0.0/0(full tunnel) and aPersistent Keepalive = 25. - Add the firewall rules and NAT entries that make the tunnel actually pass traffic.
- Validate a current handshake from both ends and prove a ping traverses the tunnel.
Requirements
You need:
- An OPNsense instance with WAN reachable from the WireGuard client. In nested mode, that means host-only NAT on the WAN interface and a client on the host.
- A second VM, container, or physical host that will act as the WireGuard peer. Linux, macOS, Windows, iOS, or Android — any current client works.
- A host on the LAN that the peer can ping through the tunnel.
The DHCP-scope lab’s
10.10.10.100 - 10.10.10.200pool is fine; pick any leased address. - Outbound UDP/51820 from the peer to OPNsense’s WAN. Confirm upstream firewalls permit this before troubleshooting OPNsense.
Tasks
Task 1: Install the plugin
From the OPNsense GUI: System → Firmware → Plugins. Search
for os-wireguard, click Install, accept the prompt. The
plugin pulls the kernel module and the management GUI; it does
not require a reboot on most builds.
After install:
# On OPNsense shell
kldstat | grep -i wireguard
wg --version
If kldstat is empty, the module is not loaded. Try:
kldload wireguard
If kldload errors with module already loaded you are done; if
it errors with cannot find file, the plugin install did not
finish — re-install from the GUI and check System → Log →
General for pkg errors.
Task 2: Generate the key pairs
The WireGuard keys are Curve25519. Generate them on the host that will hold them, never copy a private key to a chat window:
# On OPNsense (the server's keys)
umask 077
wg genkey | tee /etc/wireguard/server_private.key | \
wg pubkey > /etc/wireguard/server_public.key
cat /etc/wireguard/server_public.key
# On the peer (the laptop's keys)
umask 077
wg genkey | tee ~/wg_private.key | \
wg pubkey > ~/wg_public.key
cat ~/wg_public.key
Copy the public keys somewhere safe. You will paste the client’s public key into OPNsense, and the server’s public key into the client. The private keys stay on their respective hosts.
Task 3: Configure the server tunnel
VPN → WireGuard → Tunnels → + Add:
| Field | Value |
|---|---|
| Enabled | on |
| Name | wg0 |
| Listen Port | 51820 |
| Tunnel | 10.10.99.1/24 |
| Peers | (add in Task 4) |
| Gateway | leave blank |
Save and apply.
The GUI writes /etc/wireguard/wg0.conf and brings up the
interface. Confirm:
wg show wg0
ifconfig wg0
The wg show output should show the interface as peer: (none)
until you add a peer, and the listen port as 51820. The
ifconfig output should show inet 10.10.99.1 netmask 255.255.255.0.
Task 4: Configure the peer
VPN → WireGuard → Peers → + Add:
| Field | Value |
|---|---|
| Enabled | on |
| Name | roadwarrior-laptop |
| Public Key | (paste the client’s public key) |
| AllowedIPs | 10.10.99.2/32 |
| Endpoint Address | (empty for now; filled by the client) |
| Endpoint Port | (empty) |
| Persistent Keepalive | 25 |
| Pre-shared Key | optional; add one if your policy requires it |
Save and apply. The peer row now appears under the tunnel. The
AllowedIPs for the server’s view of this peer is
10.10.99.2/32 — that is the address the server will hand the
peer when the handshake completes. It is not the same as the
peer’s view of AllowedIPs (which we set in Task 5 to 0.0.0.0/0
for full-tunnel).
Task 5: Configure the peer on the client
On the road-warrior laptop, create /etc/wireguard/wg0.conf:
[Interface]
PrivateKey = <paste client private key>
Address = 10.10.99.2/32
DNS = 10.10.10.1
[Peer]
PublicKey = <paste server public key>
Endpoint = <OPNsense WAN IP>:51820
AllowedIPs = 0.0.0.0/0, ::/0
PersistentKeepalive = 25
The AllowedIPs = 0.0.0.0/0, ::/0 is the full-tunnel form.
Replace with 10.10.10.0/24, 10.10.20.0/24 (the LAN ranges)
for split-tunnel.
Bring up the tunnel:
# Linux (root required for wg-quick)
sudo wg-quick up wg0
# macOS / Windows / iOS / Android: use the GUI client and import
# the conf or scan the QR code.
Confirm:
sudo wg show
The output must show a peer with the server’s public key and
latest handshake: 5 seconds ago. If latest handshake is
never, the client has sent packets but the server has not
replied — go to Troubleshooting.
Task 6: Firewall rules on the WireGuard group
A WireGuard tunnel on OPNsense is a group interface. By default it has no rules, which means the firewall drops everything that arrives from the tunnel. Add rules under Firewall → Rules → WireGuard (Group):
| Action | Source | Destination | Protocol/Port |
|---|---|---|---|
| pass | 10.10.99.0/24 | 10.10.10.0/24 | any |
| pass | 10.10.10.0/24 | 10.10.99.0/24 | any (icmp only for the lab; relax as needed) |
The first rule lets tunnel clients reach the LAN. The second rule lets LAN hosts reply to tunnel clients. In production, tighten both: scope by port, scope by host, scope by group.
Confirm the rules:
pfctl -sr | grep -A1 'wg '
Task 7: Outbound NAT for the tunnel subnet
For the peer to reach the LAN, traffic from 10.10.99.0/24
must be translated to the OPNsense WAN or LAN source address when
it leaves the LAN interface. Firewall → NAT → Outbound:
If your outbound NAT is in Automatic mode, the rule is added for you. Switch to Hybrid mode to see what was generated:
pfctl -sn | grep -A1 '10.10.99'
You should see:
nat on em1 inet from 10.10.99.0/24 to any -> (em0)
If outbound NAT is in Manual mode, add the rule yourself:
| Field | Value |
|---|---|
| Interface | WAN |
| Source | 10.10.99.0/24 |
| Destination | any |
| Translation | Interface address |
| Description | WireGuard outbound |
Apply.
Task 8: Validate the handshake
The two wg show commands are the canonical evidence. From
both ends:
# On OPNsense
wg show wg0 latest-handshakes
# On the peer
sudo wg show
latest handshake must be within the last 2-3 minutes on both
sides. If only the client side shows a recent handshake, the
server is dropping the reply — usually a firewall rule problem
on the WireGuard group.
If the handshake is stale (older than 2 minutes) and the peer is
idle, that is fine; PersistentKeepalive = 25 refreshes the
handshake every 25 seconds only when traffic flows.
Task 9: Validate end-to-end with a ping
From the peer:
ping -c 4 10.10.10.50 # the reserved printer address
If the ping succeeds, the tunnel is up, the firewall rule is allowing traffic, and outbound NAT is working.
From a LAN host:
ping -c 4 10.10.99.2
This must also succeed. If the first ping works but the second
does not, the LAN host does not have a route to 10.10.99.0/24
through OPNsense. Firewall → Rules → LAN → + Add an allow
rule for the LAN subnet to the tunnel subnet.
Task 10: Watch the handshake on the wire
The signature of a working WireGuard tunnel on the WAN side is short UDP/51820 packets at regular intervals. Capture:
# On OPNsense WAN
sudo tcpdump -i em0 -nn -c 30 -s 0 udp port 51820
While the capture runs, from the peer, ping a LAN host. The capture shows UDP/51820 packets going both ways with payload lengths of 64-148 bytes — that is WireGuard’s encrypted transport. You will not see the inner ICMP echo in the capture because the payload is encrypted; the proof is the bidirectional UDP/51820 flow during a ping that the LAN can answer.
If the capture shows only outbound packets with no reply, the firewall is blocking the reply. The most common cause is the WireGuard group interface having no inbound allow rule.
Validation
wg show wg0on OPNsense shows a peer with alatest handshakewithin the last 2 minutes.wg showon the peer shows the same peer with a recent handshake and atransfer: X received, Y sentrow with non-zero bytes.ping 10.10.10.50from the peer succeeds.tcpdump -i em0 port 51820shows bidirectional UDP traffic during the ping.pfctl -sr | grep wgshows the firewall rules on the WireGuard group.pfctl -sn | grep 10.10.99shows the outbound NAT rule.
Expected Result
A WireGuard tunnel between the OPNsense firewall and one remote client, carrying the client’s traffic into the LAN. Both ends report a recent handshake; a ping from the client to a LAN host succeeds; the LAN host can reply. The WireGuard group interface has firewall rules that allow tunnel-internal traffic; outbound NAT translates the tunnel subnet to OPNsense’s WAN address on the way out.
Troubleshooting
Handshake never completes.
UDP/51820 is being blocked somewhere between the peer and
OPNsense. Check: (1) OPNsense WAN IP is reachable from the peer
(ping <opnsense-wan>), (2) no upstream firewall (ISP CPE, cloud
SG) blocks UDP/51820, (3) Firewall → Rules → WAN has an
allow rule for UDP/51820 inbound to OPNsense’s WAN IP. The
WireGuard plugin installs a default rule for this, but a recent
change may have removed it.
Handshake completes but ping fails.
The WireGuard interface has no firewall rule, or outbound NAT is
missing. Confirm pfctl -sr | grep wg returns non-empty and
pfctl -sn | grep 10.10.99 returns non-empty.
Handshake completes but only one direction.
Split-tunnel configuration mismatch. The peer’s AllowedIPs
must include the destination subnet, and the server’s view of the
peer’s AllowedIPs must include the peer’s tunnel address.
Tunnel is up, but DNS leaks the client’s ISP.
The client’s DNS is not the LAN’s DNS. Confirm wg0.conf on the
client sets DNS = 10.10.10.1 and that the WireGuard client
honours it (Linux’s wg-quick does; some mobile clients ignore
the field). For full-tunnel, check https://dnsleaktest.com
from inside the tunnel.
Tunnel drops after 2-3 minutes of idle.
NAT timeout on the upstream firewall is killing the UDP flow.
PersistentKeepalive = 25 keeps the UDP/51820 packets flowing
every 25 seconds, which prevents most NATs from expiring the
mapping. If even 25 seconds is too long, drop it to 15.
Cleanup
Tear the tunnel down and confirm no leftover state.
# On the peer
sudo wg-quick down wg0
# On OPNsense (GUI)
# VPN → WireGuard → Peers → delete the roadwarrior peer
# VPN → WireGuard → Tunnels → delete wg0 (or just uncheck Enable)
# Confirm no stale keys remain
ls -l /etc/wireguard/
shred -u /etc/wireguard/server_private.key 2>/dev/null || \
rm -f /etc/wireguard/server_private.key
# On the peer
shred -u ~/wg_private.key 2>/dev/null || rm -f ~/wg_private.key
# Confirm the WireGuard group has no rules left
pfctl -sr | grep -E 'wg0|10.10.99' || echo "no WireGuard rules"
If you created the LAN-to-tunnel rule in Task 9 to allow replies, remove it. The lab’s setup is meant to be reverted, not left as a permanent policy.
What you learned
- WireGuard key pairs are per-host. Each side holds its private key; both sides know the other side’s public key. That is the trust model.
AllowedIPsmeans different things on each side. Server-side it is “addresses I will accept from this peer.” Client-side it is “addresses I will route into the tunnel.”- A WireGuard tunnel on OPNsense is a group interface with no default rules. Without firewall rules and outbound NAT, the tunnel completes the handshake but cannot carry traffic.
PersistentKeepaliveis the answer to NAT timeouts on upstream firewalls. Without it, idle tunnels drop after the NAT mapping expires.