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.
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
| Task | By hand on the core pages | With the plugin |
|---|---|---|
| Create a tunnel | Peer, 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 order | Create → load the config → name it, pick the WAN and a monitor IP → Save → confirm |
| Move to a new server | About 15 steps on 5 pages, with 3 ordering traps | Edit → Load file → Measure → Save → confirm |
| Move to another WAN | Edit the endpoint route, apply, restart the tunnel, check the pf state really moved | Edit → pick the WAN → Save |
| Change NAT sources | Add or delete one outbound NAT rule per source and family | Edit → select or deselect sources → Save |
| Change the MTU | Measure by hand, edit the instance MTU, recompute and set MSS | Edit → Measure → Save; the clamp follows by itself |
| Remove a tunnel | Find and delete every object it owns, after checking nothing references them | Remove → 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:
- the WireGuard peer and instance;
- the interface assignment, enabled and locked;
- an IPv4 gateway with the monitor, and an IPv6 gateway with its own monitor that starts forced down until the health mirror releases it;
- a
/32(IPv4) or/128(IPv6) route to the endpoint via the chosen WAN gateway; - outbound NAT, per source and family.
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:
- Outbound NAT sources, as a multi-select per family. A newly selected source adds one plain rule (destination any, translated to the tunnel's address); a deselected one deletes that source's rules. Every other rule, and every other field of a kept rule, is left exactly as it is and listed.
- MTU, with Measure. The MSS clamp follows automatically.
- Bound WAN: the endpoint route changes gateway and the tunnel restarts so it handshakes via the new WAN.
- Monitor IPs, IPv4 and IPv6, including removal of the old monitor's host route.
- IPv6 on or off. Off removes the IPv6 gateway (refused while a group or rule uses it), address, allowed IPs and NAT; on needs a replacement config that assigns an IPv6 address.
- Server swap: load a new config, and Measure if the new server may sit on a different path. Keys, endpoint, route and addresses move together, and the tunnel restarts on the new path; new keys for the same server change no route at all.
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:
- Sentinel gateways (prevention).
NO_DEFAULT4andNO_DEFAULT6are address-less gateways at priority 254 on a loopback. The native WANs still sort first and win whenever they are up; otherwise a sentinel outranks every tunnel, and because it has no address, core installs no default at all. The button Check default-route exclusion creates or repairs them, showing what it would change first. - Default-route guard (cleanup). At the end of every routing reconfigure and once a minute, it removes a default route that points through a tunnel or any other gateway that is not a native default. It only deletes a route whose gateway it positively identifies, so it can never remove a native default it failed to recognise.
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:
- 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.
- 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
| Switch | What it does | Normally |
|---|---|---|
| Enable | Master 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 mirror | Holds 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 routes | Keeps 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 guard | Removes 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 pins | Blocks each tunnel's handshakes from leaving by any WAN but its bound one. | On |
| Only NATed traffic into tunnels | Blocks packets entering a tunnel from any address but the firewall's own. | On |
| MSS clamp | Clamps 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.
| Finding | Meaning and fix |
|---|---|
instance-missing blocking | The managed WireGuard instance was deleted. Remove drops it from the managed list. |
not-assigned blocking | The wgN device has no interface assignment. Interfaces > Assignments. |
interface-disabled blocking | The tunnel's interface is disabled; core renders no rules on it. Enable it. |
not-single-peer blocking | The instance needs exactly one peer. |
endpoint-unsupported blocking | The endpoint is a hostname or not a global IP address; a swap to an IPv4 or global IPv6 endpoint resolves it. |
ambiguous-gateway blocking | More than one gateway per family on the tunnel interface. |
unbound | No /32 or /128 route to the endpoint, so no WAN to pin it to. Rebind. |
stale-route | A route still points at a previous endpoint. Rebind offers to delete it with its kernel route. |
wan-unavailable | The bound WAN is disabled or gone; the tunnel is blocked on every interface until it returns. |
ipv6-incomplete | An IPv6 tunnel address without an IPv6 gateway, or the reverse. |
mtu-override | The interface sets its own MTU, which wins over the instance MTU. |
mtu-too-small | The MTU is too small to clamp TCP MSS; at least 1280. |
legacy-mss | An MSS is set by hand on the tunnel interface; the plugin's clamp replaces it. |
nat-missing | No outbound NAT on the tunnel interface for a family, so the inner-source block drops everything a LAN sends into it. |
monitor-shared | The monitor IP is also used by something else. |
ipv6-unmonitored | The 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-config | The 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-missing | The default-route exclusion's sentinel gateways are missing. Check default-route exclusion creates them. |
render-failed | The 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-pending | A Create or Edit saved but its apply did not complete. The row's Apply runs what is still needed. |
Design principles
- Core is the source of truth. The plugin stores only which instances it manages, which gateways its health mirror holds down, and its switches. Endpoint, WAN binding, gateways, routes, NAT and findings are derived from core configuration on every use, so editing a tunnel on the core pages is always allowed, and the plugin simply follows.
- One save per action. Every action re-reads configuration under the config lock, writes all its objects, validates each one and saves once. With a dry run, or on any error, it saves nothing.
- Lock order. Actions that change routing take the gateway lock first (the one core's gateway alarm and the health mirror use), then the config lock, save, release, apply, and replay any alarm raised meanwhile.
- Preview first. Every write shows exactly what it will change, and which apply will follow, before it changes anything.
- Secrets stay put. A private key is only ever in the request body and the saved config: never on a command line, in configd, in a log, in a response or in an exception trace.
- Refuse rather than break. A change that would strand something (a shared endpoint, a referenced gateway, a monitor IP in use) is refused with nothing written, naming what is in the way.
Core traps it works around
Each of these was found the hard way on a live firewall.
- WireGuard remembers its first path. A handshake sent before the endpoint route exists leaves by the default WAN, and core's force-gateway rules keep that state alive. Routes are applied before
wireguard configure, and every path-changing edit ends with a restart. wireguard configurenever writeswgN.conf. The GUI runstemplate reload OPNsense/Wireguardfirst; without it the device comes up with no peer on a random port.- A non-uuid argument restarts every instance. Core's WireGuard service control reads anything but a lower-case v4 uuid as a CARP vhid and acts on all instances, so every instance action is checked before it is issued.
- Relation fields validate against a per-process snapshot. Building the peer model builds the instance model, which snapshots the peer list before the new peer exists. The plugin empties the snapshot after each serialize.
- The MVC interface assignment only sees saved devices and has no enable field, so Create writes the assignment into the config tree within the same save.
Config::xpath()queries a detached copy. Reading through it is fine; removing its nodes changes nothing.- Locks leak through
exec(). PHP opens files without close-on-exec, so a daemon a reconfigure restarts inherits the script's lock files for life. Every plugin lock is opened close-on-exec, and reconfigures run through configd. - Core's gateway alarm is dropped while the gateway lock is held. Hence the replay after every routing change.
- dpinger adds a host route to a monitor IP and never removes the old one. Edit hands the old one to core's route-deletion mechanism.
- Core starts an IPv6 monitor before a tunnel's IPv6 routes exist, and skips it while the tunnel address is still tentative. Hence the plugin's own monitor route and its once-a-minute repair.
- The default-gateway election ignores the
defaultgwflag and never deletes a stale default. Hence the sentinels and the guard. - Core's backend returns an empty string on a timeout. A step counts as done only on an explicit OK.
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
- Endpoints are IPv4 addresses, or global IPv6 addresses bound to a WAN's IPv6 gateway by a /128 route. A hostname endpoint could rotate addresses behind the pin, so it is refused (
endpoint-unsupported); IPv6 over 6rd/6to4 WANs is not supported. - One peer per instance, and one IPv4 tunnel address per config.
- Gateway groups stay on core's page: add a new tunnel's gateways to your groups there. The Tunnel References table links to it.
- Providers that issue keys only through an API (no downloadable config) need a config file generated first; the wg-quick file is the input.