OPNsense plugin · os-wg-client-tunnels 3.3

WireGuard Upstream Tunnels

Turns a VPN provider's WireGuard config file into a policy-routed upstream on OPNsense in one dialog, keeps it enforced, and tells you when someone changes a piece of it by hand.

8 pages → 1 dialog
to create a tunnel
~15 steps → 5 clicks
to move a tunnel to a new server
6 safeguards
core has no setting for, kept in force continuously

Why this plugin

Running a VPN provider as an upstream, a gateway that selected LANs are policy-routed through while the firewall itself keeps using its native WANs, is a well-trodden OPNsense setup. It is also one of the most fiddly: each tunnel is eight separate objects on eight core pages, applied in an order core does not enforce. Most of the ways it goes wrong are silent. Traffic keeps flowing, every status page reads green, and it is flowing somewhere you did not intend.

Wrong WAN, silently

A handshake sent before the endpoint route exists leaves by the default WAN, and WireGuard keeps that source address for good.

A tunnel as the default route

When a WAN's gateway goes down, core's election can pick a tunnel as the default, sending the firewall's own traffic into the VPN.

MTU blackholes

On a WAN with a smaller path MTU, large TCP packets vanish and TLS handshakes hang, because the packet-too-big notice goes to the NATed tunnel address.

Un-NATed leaks

A LAN or delegated IPv6 address that reaches a tunnel untranslated is handed to the provider as is.

IPv6 that never comes up

Core adds far-gateway host routes for IPv4 only, so a tunnel's IPv6 gateway has no route to its next hop.

Drift

Someone edits a route, a gateway or an interface MTU by hand, and the tunnel quietly stops matching its design.

The plugin builds every piece in one save, applies it in the order that works, renders the safeguards core lacks at every filter reload, and flags drift as a finding that names the core page that fixes it.

By the numbers

TaskBy hand on the core pagesWith the plugin
Create a tunnelPeer, instance, interface assignment, IPv4 and IPv6 gateways, endpoint route, outbound NAT per LAN and family, measured MTU and matching MSS; about 8 pages and a chain of applies in a specific orderCreate → load the config → name it, pick the WAN and a monitor IP → Save → confirm
Move to a new serverAbout 15 steps on 5 pages, with 3 ordering trapsEdit → Load file → Measure → Save → confirm
Move to another WANEdit the endpoint route, apply, restart the tunnel, check the pf state really movedEdit → pick the WAN → Save
Change NAT sourcesAdd or delete one outbound NAT rule per source and familyEdit → select or deselect sources → Save
Change the MTUMeasure by hand, edit the instance MTU, recompute and set MSSEdit → Measure → Save; the clamp follows by itself
Remove a tunnelFind and delete every object it owns, after checking nothing references themRemove → confirm; refused, changing nothing, while anything still references it

Source and install

Install it from the signed LegoTypes repository: see Install. The plugin is not in the official OPNsense repository. Source: LegoTypes/plugins, branch add-wg-ipv6-gateway, net/wg-client-tunnels. The package is os-wg-client-tunnels; its page URL, configd actions and namespace keep the wgclienttunnels name. It targets OPNsense 26.7 with core's built-in WireGuard, and its changelog is in pkg-descr.

Configuration

Everything is on one page under VPN > WireGuard > Upstream tunnels: a settings pane with a help column, then the tunnel list. The list is a standard OPNsense grid with one row per managed tunnel: its device and interface, endpoint, bound WAN, MTU and MSS clamp, IPv4 and IPv6 gateways with live status (drawn as on core's gateway page), outbound NAT sources, gateway groups and findings. Every name links to the core page that owns it, and a gateway link opens that gateway's own edit dialog.

Create

Paste or load the provider's wg-quick file, the .conf every provider offers for download. Only five values are read from it: PrivateKey and Address from [Interface], PublicKey, PresharedKey and Endpoint from its single [Peer]. DNS, MTU, AllowedIPs, keepalive and hook lines are ignored. The file is read in your browser and sent in the request itself; the private key never reaches a command line, configd or a log.

Give it a name, the WAN it should leave by, and a monitor IP (an address nothing else uses; dpinger routes it through the tunnel) and, with IPv6, an IPv6 monitor IP (also an address nothing else uses; the plugin routes it through the tunnel). Pick an IPv6 monitor that answers ping through that tunnel: an address can answer from one exit server and not another, and one that never answers keeps the IPv6 gateway down. Optionally choose a template tunnel, whose gateway thresholds and outbound NAT rules are copied, or pick NAT sources. Create then builds, in one save:

Its apply runs in the order that works: template reload (writes wgN.conf), routes (the endpoint route exists before the first handshake), WireGuard, interface registration, routes again (gateways and dpinger on the new device), filter reload. The MTU is measured unless given: a don't-fragment ping bisection from the bound WAN's own address to the endpoint, minus 60 bytes of WireGuard overhead, kept within 1280–1420.

Conventions, by instance number N (device wgN): listen port 51820+N, far gateway 10.2.0.(3+N), IPv6 next hop fd00::N:2, and with unique addressing the tunnel address fd00::N:1/128. Unique addressing exists because providers often hand every key the same IPv6 address and FreeBSD refuses a duplicate address on two interfaces.

A new tunnel carries nothing until a rule sends traffic to it: see After tunnel setup for groups, policy rules, failing closed, kill states and DNS.

Edit and server swap

Edit opens the same dialog, prefilled. The tunnel's identity (name, instance number, device, interface, gateway names) never changes, so gateway groups and rules that name it keep working. What it edits:

Only the differences are written, and only changed values are judged, so an odd but unchanged value never blocks another edit. The apply follows the change: NAT alone reloads the filter without touching routing; a monitor change reconfigures routes; an MTU, WAN, IPv6 or server change runs Create's apply and then restarts the tunnel.

If an apply does not complete, the tunnel shows apply-pending and the record remembers which apply it still needs. The row's Apply button runs exactly that; only a complete apply of at least that kind, started against that record, clears it.

Rebind, Adopt, Remove

Rebind repairs a tunnel whose endpoint changed on the core pages so its route no longer matches (unbound): it writes a /32 to the current endpoint via the chosen WAN, optionally deletes the stale route with its kernel route, and restarts the tunnel.

Adopt brings an existing WireGuard instance under management without changing it. Findings then show what, if anything, differs from what Create would have built.

Remove deletes everything a tunnel owns, but refuses, changing nothing, while any gateway group, rule, port forward, NAT, static route, interface, interface group, bridge, GRE/GIF tunnel or virtual IP still references its gateways or interface.

What it enforces

These are rendered by the plugin at every filter reload, derived from the managed tunnels; none is stored as a rule in config, so a restored backup or a changed tunnel never leaves a stale copy behind.

WAN pins

Per WAN, a block out quick on every interface but the bound one, for UDP to that WAN's tunnel endpoints. The endpoint route alone is not enough: a route to a disabled gateway is skipped, and when a WAN loses its address the kernel drops the route, so the handshake follows the default out of the other WAN. The reply received there sets WireGuard's source address, and core's generated route-to rules then hold the tunnel on the wrong WAN indefinitely. With the wrong WAN blocked no reply can arrive, and the tunnel returns by itself when its WAN does. A tunnel whose bound WAN cannot be resolved is blocked on every interface rather than left unpinned: it fails closed.

Only NATed traffic into tunnels

block out quick on <tunnel> from ! (self), both families. Outbound NAT runs first, so only traffic translated to the tunnel's own address passes; a LAN or delegated IPv6 source that reaches a tunnel untranslated is dropped instead of being handed to the provider.

Both kinds of rule are logged, described WireGuard Upstream Tunnels: …, and labelled with a stable 32-hex hash so the live firewall log can name them. On Firewall > Rules they appear in the Automatically generated rules group, which the page keeps collapsed until you open it.

Default-route exclusion

Core's default-gateway election returns the first gateway that is not disabled, defunct or forced down, in priority order. Priority 255 is the floor, so tunnels can be ordered last but never excluded, and ties break on position in config.xml. When a WAN's gateway is down, a tunnel wins, and the whole network's default route quietly becomes the VPN. It does not blackhole, the provider NATs it, so everything keeps working and every status page reads up, which is exactly what makes it easy to miss. A default installed that way also outlives the tunnel being forced down: the reconfigure never deletes a stale default.

The rule, for both families: a tunnel is never the default. Enforced in two halves:

MTU and the MSS clamp

Providers announce an MSS that assumes a 1420-byte tunnel MTU. On a WAN with a smaller path MTU (mobile links are the usual case) a tunnel sized correctly at, say, 1376 drops the 1420-byte packets hosts send, and the packet-too-big notice goes to the NATed tunnel address, never to the LAN host. TLS handshakes hang.

The plugin clamps TCP MSS on every tunnel to its effective MTU, MTU−40 for IPv4 and MTU−60 for IPv6, in both directions, with one match on wgN … scrub (max-mss …) rule per family in a dedicated head anchor. The effective MTU is the instance MTU, or the interface MTU when one is set there (core applies it last, and the tunnel shows mtu-override). A change made through Edit applies at once; a change made on core's pages is picked up by the config-save reconcile, which reloads just the anchor. Setting an MSS by hand on the tunnel interface is unnecessary and shows as legacy-mss.

Health mirror

Four times a minute, in two passes and one save:

  1. Underlay. Each tunnel's IPv4 gateway is forced down while its bound WAN is disabled, forced down, absent, or reading down, loss or delay+loss: exactly what a gateway group treats as down. The mirror records which gateways it holds and only ever releases those, so a force-down you set by hand is left alone.
  2. IPv6. Each tunnel's IPv6 gateway follows its IPv4 tunnel: the mirror forces it down when the tunnel's loss crosses its high threshold (back at the low one), when its monitor is missing, or when the IPv4 gateway is forced down. IPv6 rides inside the IPv4 tunnel, so the native WAN's IPv6 being down does not take tunnel IPv6 down. Since 3.3 each IPv6 gateway also has its own monitor, pinged through the tunnel and judged by core, not by the mirror, so an IPv6 failure inside a tunnel whose IPv4 still works takes only the IPv6 gateway down; the IPv4 and IPv6 groups may then leave by different servers until it recovers.

A settle guard prevents false recovery: every routing reconfigure restarts all monitors, and a monitor that has just started reports 0% loss because it has no samples yet. While a monitor is younger than its gateway's time period, a healthy reading can trip a gateway down but never bring one up.

The mirror also replays the gateway alarm it causes. Core's alarm action takes the gateway lock non-blockingly, so an alarm raised while the mirror held it would be dropped and the firewall would keep routing into the tunnel it just forced down. After releasing the lock the mirror waits for gateway status to show what it wrote, then runs the alarm itself.

Core adds no IPv6 far-gateway host routes, so the plugin also keeps each tunnel's IPv6 address and the routes to its IPv6 next hop and its IPv6 monitor in place: at boot, after every routing reconfigure, on a new WAN address, and once a minute. The once-a-minute pass is also what restarts a missing IPv6 monitor, so that takes up to a minute.

Settings

SwitchWhat it doesNormally
EnableMaster switch. Off stops everything on the page and removes the tunnels' firewall pins and MSS clamp; the tunnels themselves keep running, since they are core configuration.On
Health mirrorHolds tunnels down with their WAN, and IPv6 gateways with their IPv4 health. Off freezes whatever is forced down at that moment.On
IPv6 addresses and routesKeeps each tunnel's IPv6 address, next-hop route and monitor route. Off: IPv6 through the tunnels breaks at the next tunnel restart or reboot.On when any tunnel has IPv6
Default-route guardRemoves a default route through a tunnel or other non-default gateway, both families.On if the router uses one or more WANs as route defaults
WAN pinsBlocks each tunnel's handshakes from leaving by any WAN but its bound one.On
Only NATed traffic into tunnelsBlocks packets entering a tunnel from any address but the firewall's own.On
MSS clampClamps TCP MSS to each tunnel's MTU, both directions.On

Findings

Each tunnel is re-derived from core configuration on every view, and anything that does not match the design becomes a finding in its row, with a tooltip naming the page that fixes it. A blocking finding stops every plugin behaviour for that tunnel (no pins, no clamp, no edits) until it is resolved there.

FindingMeaning and fix
instance-missing blockingThe managed WireGuard instance was deleted. Remove drops it from the managed list.
not-assigned blockingThe wgN device has no interface assignment. Interfaces > Assignments.
interface-disabled blockingThe tunnel's interface is disabled; core renders no rules on it. Enable it.
not-single-peer blockingThe instance needs exactly one peer.
endpoint-unsupported blockingThe endpoint is a hostname or not a global IP address; a swap to an IPv4 or global IPv6 endpoint resolves it.
ambiguous-gateway blockingMore than one gateway per family on the tunnel interface.
unboundNo /32 or /128 route to the endpoint, so no WAN to pin it to. Rebind.
stale-routeA route still points at a previous endpoint. Rebind offers to delete it with its kernel route.
wan-unavailableThe bound WAN is disabled or gone; the tunnel is blocked on every interface until it returns.
ipv6-incompleteAn IPv6 tunnel address without an IPv6 gateway, or the reverse.
mtu-overrideThe interface sets its own MTU, which wins over the instance MTU.
mtu-too-smallThe MTU is too small to clamp TCP MSS; at least 1280.
legacy-mssAn MSS is set by hand on the tunnel interface; the plugin's clamp replaces it.
nat-missingNo outbound NAT on the tunnel interface for a family, so the inner-source block drops everything a LAN sends into it.
monitor-sharedThe monitor IP is also used by something else.
ipv6-unmonitoredThe IPv6 gateway has no monitor of its own, so an IPv6-only failure inside the tunnel goes unseen. Edit: set an IPv6 monitor.
ipv6-monitor-configThe IPv6 monitor is set up in a way that can misread: core may route it, its thresholds are tighter than the IPv4 gateway's, or the plugin's IPv6 routes are switched off. Saving the monitor again in Edit repairs the first two.
sentinel-missingThe default-route exclusion's sentinel gateways are missing. Check default-route exclusion creates them.
render-failedThe last filter reload could not build the plugin's rules; they are missing until the next reload succeeds. The reason is in the firewall log.
apply-pendingA Create or Edit saved but its apply did not complete. The row's Apply runs what is still needed.

Design principles

Core traps it works around

Each of these was found the hard way on a live firewall.

Command line

Everything the page does is also available on the firewall as tunnel.php; write actions take --dry, and requests that carry a key are JSON on stdin so the key never reaches the process list.

T=/usr/local/opnsense/scripts/OPNsense/WGClientTunnels/tunnel.php
php $T list                                   # managed tunnels, then unmanaged instances
php $T status                                 # findings only
php $T measure-mtu WAN_B 198.51.100.10         # path MTU for a WAN and endpoint

# create: JSON on stdin (the config text carries the private key)
jq -n --rawfile c provider.conf \
  '{config: $c, name: "vpn-a", wan: "WAN_B", monitor: "192.0.2.53", template: "vpn-b"}' \
  | php $T --dry create                       # then without --dry

# edit: the uuid and only what changes
echo '{"uuid": "<uuid>", "mtu": 1376}' | php $T --dry edit
jq -n --rawfile c new-server.conf '{uuid: "<uuid>", config: $c}' | php $T edit

php $T --dry rebind <uuid> WAN_B [<stale route uuid>]
php $T --dry adopt <uuid>
php $T --dry remove <uuid>
php $T apply <uuid>                            # the apply a pending change still needs
php $T --dry ensure-sentinel                  # check the default-route exclusion
php $T --selftest                             # decision-logic tests, no config access

Limits