Runbook 05 — ProtonVPN egress (multi-tunnel load-balanced group + WAN bypass)¶
Goal: all outbound traffic (clients and servers) egresses through a
load-balanced group of N ProtonVPN WireGuard tunnels. Local (RFC1918) traffic and
a curated VPN_BYPASS list (e.g. Google/YouTube) use the real WAN; inbound
reverse-proxy traffic uses WAN and its replies return via WAN automatically
(pf reply-to). If the whole Proton group is down, internet fails closed.
Prereqs: runbook 04 validated. N fresh ProtonVPN WireGuard configs
(generate new ones in the Proton portal — different servers/cities for
diversity; generate fresh keys). This build uses N = 4 (PROTON1–4).
Architecture recap¶
┌─ inbound 80/443 + replies (reply-to) ─────────────┐
Internet ──ISP── OPNsense WAN (real public IP, = system default gw) ──┤
├─ VPN_BYPASS list (YouTube/Google) → WAN ───────────┤
└─ everything else → PROTONVPN_LB (failover) ────────┘
PROTONVPN_LB = wg1 + wg2 + … (all Tier-1, load-balanced)
- System default gateway stays
WAN_GW(a default must be a single gateway). - "Everything via Proton" is done with policy routing: each zone's internet rule is tagged with the group as its gateway (runbook 06).
- The group load-balances across the N tunnels (per-connection). Auto-failover is handled by the ctrl-side endpoint-monitor in §3 (handshake-age based) — dpinger gateway monitoring is off by default because ICMP over Proton WG is unreliable.
1. Create N WireGuard client tunnels (one per Proton config)¶
Order matters: create the Peer first, then the Instance (the Instance has a Peers dropdown that selects the peer; the peer has no "instance" field).
⚠️ The
10.2.0.2problem — read this first; it's the whole reason multi-tunnel Proton is fiddly. Proton hands every WireGuard config the same client address10.2.0.2/32, and its server only accepts traffic sourced from10.2.0.2. But OPNsense can't put10.2.0.2on four interfaces. The resolution that actually works (verified end-to-end on this build, and matching the OPNsense forum's multi-tunnel guide): 1. Give each tunnel a unique OPNsense-side address in its own/28—10.2.0.2/28,10.2.1.2/28,10.2.2.2/28,10.2.3.2/28. (A/32works for a single tunnel but not several.) 2. Outbound-NAT every tunnel's source back to10.2.0.2(§5), so Proton still sees the address it requires.OPNsense stays happy (unique IPs); Proton stays happy (
10.2.0.2on all four). Skip the §5 NAT and only the one real10.2.0.2tunnel passes data — the other three handshake but silently drop everything.
A Proton config maps to the two OPNsense forms like this. This is an IPv4-only
build — IPv6 is turned off firewall-wide (runbook 02). Use only the IPv4 lines and
ignore every IPv6 line in the Proton config (the v6 Address, the ::/0 in
AllowedIPs, and the IPv6 Endpoint):
[Interface] → VPN → WireGuard → Instances (the local end)
PrivateKey = IH85…lh2Kwy9pnls= → Private key (paste it; Public key auto-fills on save)
Address = 10.2.0.2/32 → Tunnel address = 10.2.<n-1>.2/28 (UNIQUE per tunnel — see callout)
DNS = 10.2.0.1 → Instance Gateway (advanced) = 10.2.<n-1>.1 · DNS servers = blank
[Peer] → VPN → WireGuard → Peers (Proton's server)
PublicKey = DO1n…OqiGgb8ByKVc= → Public key
Endpoint = 198.51.100.58:51820 → Endpoint address = 198.51.100.58 · Endpoint port = 51820
AllowedIPs = 0.0.0.0/0 → Allowed IPs = 0.0.0.0/0
PersistentKeepalive = 25 → Keepalive (interval) = 25
So, for each endpoint n = 1..N:
a. Peer — VPN → WireGuard → Peers → + (field labels verified against
dialogEditWireguardClient.xml):
- Name proton<n>-peer · Public key (from [Peer] PublicKey)
- Endpoint address = the IP only (198.51.100.58) · Endpoint port = 51820
- Allowed IPs 0.0.0.0/0 · Keepalive interval 25 · (no pre-shared key)
b. Instance — VPN → WireGuard → Instances → + (labels verified against
dialogEditWireguardServer.xml):
- Name proton<n> · Private key (from [Interface] PrivateKey) — leave Public
key blank; OPNsense derives it on Save (or use the gear/generate button)
- Tunnel address = a unique /28 per tunnel — 10.2.0.2/28, 10.2.1.2/28,
10.2.2.2/28, 10.2.3.2/28. NOT Proton's 10.2.0.2/32 (see the callout); the
NAT in §5 restores 10.2.0.2 toward Proton.
- Peers: select proton<n>-peer from the dropdown
- Disable routes: ON (we route via policy, not WG's auto routes)
- Flip advanced mode on the form to reveal the next two (both are
<advanced> fields):
- DNS servers: leave blank (else OPNsense's resolver config gets
overwritten — we use Unbound)
- Gateway: the .1 of this tunnel's /28 — 10.2.0.1, 10.2.1.1,
10.2.2.1, 10.2.3.1 (same value as the System Gateway in §3). Its help: "Set
the gateway IP here when using Disable routes… also add it as a system gateway."
- Listen port / MTU: leave default.
Enable WireGuard (the "Enable WireGuard" master toggle at the top of VPN →
WireGuard). Interfaces → Assignments: assign each wg interface → name
PROTON1 … PROTON4, enable it, IPv4/IPv6 Config = None.
2. Keep each tunnel's transport on WAN (no routing loop)¶
Naming: these runbooks call your WAN's upstream gateway
WAN_GW, but OPNsense auto-names it —WAN_DHCPwhen WAN uses DHCP, or a name you choose on a static WAN. Substitute your actual WAN gateway name (e.g.WAN_DHCP) wherever you seeWAN_GWhere and in runbook 06.
For each Proton endpoint's public IP — System → Routes → Configuration → +:
- Network: <endpoint-n public IP>/32
- Gateway: your WAN gateway (WAN_GW / WAN_DHCP)
- Description: Proton endpoint n via WAN
This guarantees the outer WireGuard packets reach Proton over the ISP link even when the policy default for everything else is the tunnel group.
3. One gateway per tunnel — and disable monitoring on them¶
For each n = 1..N — System → Gateways → Configuration → + (MVC dialog,
Routing/dialogEditGateway.xml). The IP Address is the same value you set in
the Instance "Gateway" field (§1) — the .1 of each tunnel's /28:
| Gateway | Interface | IP Address (= Instance gateway) |
|---|---|---|
PROTON_GW1 |
PROTON1 |
10.2.0.1 |
PROTON_GW2 |
PROTON2 |
10.2.1.1 |
PROTON_GW3 |
PROTON3 |
10.2.2.1 |
PROTON_GW4 |
PROTON4 |
10.2.3.1 |
On every Proton gateway:
- Far Gateway: ON (harmless; with Disable routes only the gateway /32 is
routed onto the tunnel anyway).
- Upstream Gateway: OFF — never a system-default candidate.
- Disable Gateway Monitoring: ON — yes, turn monitoring off. (see below)
Why disable monitoring (the hard-won lesson). ICMP gateway monitoring over ProtonVPN WireGuard is erratic and effectively doesn't work — dpinger can't get replies back through the tunnel (Proton's inner gateway/exit don't answer the way dpinger needs, and constant pings can even trip Proton's IDS). Every monitor target we tried — root servers, Quad9, Mullvad, even Proton's own
10.2.0.1— showed the gateway red while traffic flowed fine. Multiple OPNsense/pfSense threads reach the same conclusion: turn monitoring off. With it off, all four gateways stay "up," the group load-balances across all tunnels, and traffic flows. (Refs: OPNsense forum "Multiple instances wireguard VPN PROTON"; Netgate "pfSense with multiple Proton WireGuard tunnels"; core #5815/#8108.)The tradeoff: no automatic failover if one tunnel dies — the LB keeps using it, so those connections fail until you disable that instance by hand. Two ways to get coarse failover back: - The GUI-only route: monitor each tunnel's Proton endpoint public IP with "Disable Host Route" checked — the ping rides the existing
/32 → WANroute (§2) and tests whether Proton's server is reachable, not the tunnel interior. - Implemented (2026-07-15, enabled 2026-07-17): the ctrl-side endpoint-monitor. Acontrol-role systemd timer (scripts/proton-endpoint-monitor.sh) reads each Proton tunnel's WireGuard handshake age from the OPNsense REST API every 5 min (GET /api/wireguard/service/show→latest-handshake-age+peer-statusper peer) — the inner-tunnel liveness signal (PersistentKeepalive=25 keeps a fresh handshake as steady state), stronger than pinging the server. (The API is used, not SSH: root-SSH-by-cert to OPNsense is denied from ctrl — verified 2026-07-17 — and the API is the same path appliance-update-check uses.) Proton peers are matched by ifname regex (^Proton-), so the roadwarrior + edge tunnels are excluded automatically. Stale >PROTON_STALE_SECS(default 300s) or peer offline, for 3 cycles → transition ntfy tolab-alerts. Withproton_monitor_autodisable: trueit also disables the dead tunnel's WireGuard instance via the API (toggleServer + service/reconfigure) so PROTONVPN_LB re-homes its share, and re-enables it on recovery — guarded byPROTON_MIN_HEALTHY(default 2): it never disables below the floor, so remediation can never trip the fail-closed kill-switch. Acting on the instance is the correct lever — "skip rules when gateway down" doesn't apply to gateway groups (#8108).Why handshake-age, not an active data-path probe (rejected). A probe through each tunnel (curl an edge health URL forced out each
PROTON_GW) is a stronger signal — it catches the rare "Proton handshakes but silently stops forwarding" that handshake-age misses. But it still needs a poll (no event source exists), plus per-gateway policy-route rules to pin each probe to a specific tunnel (the LB group otherwise sprays a probe across all four → tells you nothing per-tunnel), plus it conflates tunnel health with edge health. That's disproportionate machinery for the blast radius (one dead tunnel = ~¼ of new connections degrade until it recovers/is disabled, not an outage). Native OPNsense gateway monitoring can't help either — it's ICMP/dpinger, which is exactly what Proton WG breaks (§3). Self-probe to.69is a non-starter (it's WireGuard UDP — answers no generic probe — and would need a new inbound WAN listener). Handshake-age via the API gets ~all the signal for a fraction of the cost; the 5-min cadence matchesedge-home-watch.STAGED. The role ships disabled (
proton_monitor_enabled: false); alert-only was enabled + verified 2026-07-17. To (re-)enable / extend: 1. Alert-only just needsproton_monitor_enabled: true+ the OPNsense API creds (vault_opnsense_api_key/_secret, already used by appliance-update-check). The Proton peers self-discover viaproton_monitor_ifname_regex(^Proton-) — no interface list to maintain. Roll out thecontrolrole on ctrl. 2. Test: run the unit once (systemctl start proton-endpoint-monitor) and confirmhealthy=N/Ninjournalctl -u proton-endpoint-monitor; dropPROTON_STALE_SECSlow or disable one tunnel to watch a DOWN/recover ntfy. 3. Only then, for auto-disable: capture each tunnel's instance UUID (fromGET /api/wireguard/server/searchServer) intoproton_monitor_tunnel_uuids("<ifname>=<uuid>,..."), setproton_monitor_autodisable: true, and test with one tunnel while ≥2 stay healthy.Needs a write-only ntfy token (
vault_proton_monitor_ntfy_token) like the other ntfy tokens; the auto-disable reusesvault_opnsense_api_key/_secret.
Confirm WAN_GW keeps Upstream Gateway: ON (the single system default) with
monitor 9.9.9.9 — WAN monitoring is fine, it's not over a tunnel.
4. The load-balancing group¶
All four tunnels ride the same Comcast WAN, so they all go in Tier 1 and load-balance per-connection.
System → Gateways → Group → +:
- Name: PROTONVPN_LB
- PROTON_GW1 … PROTON_GW4 → all Tier 1 → load-balanced (per-connection)
- Trigger Level: Member down
- Description: ProtonVPN LB
If the group misbehaves — a client can't reach the internet even though the group shows online and a gateway is up — delete the group and recreate it with a different name (e.g.
PROTONVPN_LB). A corrupted gateway-group object is a known OPNsense quirk with this setup (same OPNsense-forum thread); a fresh name clears it. Remember to repoint the per-zone rules (runbook 06) at the new name.
Also enable Firewall → Settings → Advanced → "Use sticky connections"
(lb_use_sticky — same page as the Skip-rules toggle in §7, not under
System → Settings) so a client's flows pin to one Tier-1 gateway (no per-flow
exit-hopping that breaks sticky-session sites).
Tier 2 is reserved for a genuinely independent uplink. If you ever add a second WAN that survives a Comcast outage — a cellular modem, a second ISP — run a Proton tunnel over it and put that gateway in Tier 2. That is real failover (Comcast dies → the group shifts to the cellular path, still via Proton). Tiering same-WAN tunnels is not. This is separate from the disabled break-glass rule (runbook 06), which is the last resort when all of Proton is down.
Result: connections share the load across all four tunnels (per-connection LB with sticky-address). With monitoring off there's no automatic per-tunnel failover from dpinger — a dead Proton server keeps getting its share of connections until the §3 endpoint-monitor disables that instance (or you do it by hand). A WAN/Comcast outage takes the whole group down → the kill-switch (§7) blocks rather than leaking. It's per-connection LB — many parallel flows aggregate across tunnels (great for a household), but a single stream still rides one tunnel (and Proton's per-server cap).
4b. MSS clamping (required — full-tunnel egress)¶
Routing everything through a 1420-MTU WireGuard tunnel will blackhole some
sites (PMTU discovery fails → "most things work, some hang forever") unless you
clamp TCP MSS. Firewall → Settings → Normalization → +:
- Interface(s): the PROTON* tunnel interfaces (or apply broadly to the
internal zones) · Direction: any · Protocol: TCP
- Max mss: 1380 (field label is "Max mss"; WG MTU 1420 − 40 for IPv4/TCP
headers; use 1372 if you set the Proton MTU to 1412)
- Description: Clamp MSS for ProtonVPN
5. Outbound NAT — translate every tunnel's source to 10.2.0.2 (the key fix)¶
This is the second half of the 10.2.0.2 fix from §1: OPNsense uses unique tunnel
addresses, but Proton only accepts source 10.2.0.2, so NAT every tunnel's egress
back to it.
Firewall → NAT → Outbound → Hybrid. Add one rule per Proton interface:
- Interface: PROTON<n>
- Source: RFC1918 (covers clients and firewall-self traffic, e.g. §8 recursion)
- Translation / target: a Single host — 10.2.0.2 ← NOT "Interface
address". This is the piece that makes tunnels 2–4 pass data; with "Interface
address" they'd egress as 10.2.1.2/2.2/3.2 and Proton silently drops them.
- Description: NAT out PROTON<n>
(Default auto WAN outbound NAT still covers the VPN_BYPASS + inbound paths.)
6. The VPN_BYPASS alias — the safety valve (empty by default)¶
Policy is all egress via Proton. VPN_BYPASS is the escape hatch for when a
destination's Proton exit IP gets blocked: drop that destination in, and it pops
out the real WAN instead. Create it now but empty so the per-zone rules
(§7 / runbook 06) have their hook; with no entries it matches nothing, so
everything goes via Proton until you need it.
Firewall → Aliases → +:
- VPN_BYPASS — type Network(s) — leave the content empty. This is
what the rules reference.
If your OPNsense build refuses to save a truly empty alias (or a rule on it), seed it with a harmless non-routable placeholder like
192.0.2.0/24(TEST-NET-1) — it matches nothing real.⚠️ Never put an address that is a VIP on the WAN interface into
VPN_BYPASS— above all the WG road-warrior endpoint. Bypassing routes internal traffic out WAN, where outbound NAT rewrites the source to the private transit address; if the destination is a VIP on that same interface the packet loops back in and is killed by the built-in "Block private networks from WAN" rule. Egress via Proton is what makes an on-link public VIP reachable from inside: it re-enters from a public exit IP, so anti-spoofing passes.This is counter-intuitive — the bypass looks like the more direct path — and it fails only for clients at home, so it presents as "VPN works on cellular but not on home wifi" and will not reproduce from ctrl. Cost us a road-warrior outage on 2026-07-31 when the lab's whole public
/29was added at once, swallowing the WG endpoint (runbook 48). Enumerate the individual addresses that need the bypass; never the containing block.When Proton gets blocked somewhere (see §9 for the full how-to): add the destination to
VPN_BYPASS— a few CIDRs/FQDNs, or for a big provider a nested URL Table (IPs) alias (e.g. Google's published ranges for YouTube). Those destinations then see your real IP (that's why they stop blocking you); everything else stays on Proton.
7. Policy routing & kill-switch — applied in runbook 06¶
The per-zone rule order (set in runbook 06) becomes:
1. allow → firewall (DNS/NTP) gateway: default
2. allow → SERVERS / RFC1918 (local, incl. NFS) gateway: default
3. block → MGMT / DMZ-internal / other client zones
4. allow → VPN_BYPASS gateway: WAN_GW
5. allow → any (internet) gateway: PROTONVPN_LB
6. block → any (kill-switch)
- Step 4 before 5 = the YouTube/Google bypass.
- Step 5 = PROTONVPN_LB = everything else via the load-balancing group.
- Kill-switch / fail-closed — the one thing to actually set here:
- Firewall → Settings → Advanced → "Gateway Monitoring" → "Skip rules" = ON (checked). (Not System → Settings — and don't confuse it with System → Settings → General → "Gateway switching", a different feature.) Unchecked, a policy-route rule whose gateway is down silently falls back to the default (WAN) — a leak. Checked, the rule is skipped when its gateway is down, so traffic drops through to the block (step 6). Its help text: "…the rule is not created when gateway is down."*
- Caveat (OPNsense #8108): that toggle does not apply to gateway groups,
and
PROTONVPN_LBis a group. For the group rule the guarantee instead comes from the explicitblock → any(step 6): when the whole group is down, group-routed traffic does not fall back to WAN, and the block drops anything not forced through Proton. Set the toggle ON anyway — it still protects any single-gateway policy rule and is harmless to the group. - The kill-switch is therefore the explicit block rule — you MUST test it (disable all Proton instances → non-bypass internet must stop; see Validation / runbook 14). Don't trust it untested.
- Servers get the same shape (their "any → PROTONVPN_LB") so server egress is also via Proton. (Home takes no public inbound under edge/origin — the only WAN inbound is the WG admin endpoint, runbook 06/07.)
8. Stop the firewall's own DNS recursion leaking to the ISP¶
Firewall-originated traffic (Unbound recursion) uses the system default = WAN, so by default DNS recursion exits your real Comcast IP — the ISP sees every domain your network resolves. Since Comcast is the threat model, close it.
Do NOT use Unbound's "Outgoing Network Interfaces" for this. (Source-checked: the field exists on Services → Unbound DNS → General as
outgoing_interface, but it's an<advanced>true</advanced>field — invisible until you flip the page's advanced-mode toggle. That's why it looked missing.) It's the wrong tool anyway: its own help says it "only works when [interfaces] are statically configured" (our WG interfaces are Config=None → dynamic), it's a source-IP bind not routing, and per OPNsense #5329 excluding WAN from it just stops Unbound resolving entirely. Leave it at default.
The correct mechanism is to policy-route the firewall's own port-53 egress onto
the Proton group with a floating rule (route-to applies to locally-generated
traffic on the out direction — the same trick as a "force gateway" rule):
Firewall → Rules → Floating → +:
- Action Pass · Interface WAN · Direction: out
- Protocol UDP (add a twin TCP rule, or use TCP/UDP — large answers use TCP)
- Source: This Firewall (self)
- Destination any · Destination port: 53 (DNS)
- Gateway: PROTONVPN_LB
- Description: Firewall DNS recursion via Proton (no ISP leak)
Unbound's recursion now rides the Proton tunnels; if the whole group is down the rule drops (fail-closed) — DNS fails rather than leaking, which is correct.
Verify — this is the acceptance test, not optional. Run Interfaces → Diagnostics → Packet Capture on WAN, filter port 53, and force a fresh lookup. You should see no plaintext :53 leaving WAN (it's encrypted inside the WireGuard tunnels to Proton instead). A public DNS-leak test from a client should show Proton, not Comcast. If
route-toon firewall-self traffic behaves oddly in your 26.1.9 build, tell me exactly what the floating-rule dialog shows and we'll adjust — this is one to confirm against the live GUI, not the prose docs.
9. Maintaining the VPN_BYPASS list¶
This list is the one thing you'll tweak over time, as services flag Proton's IPs.
How to spot a service that needs bypassing:
- A site loads fine on a non-VPN device but errors/captchas/"unusual traffic" or
"this video is not available" on the LAN.
- Streaming says you're in the wrong region, or logins demand repeated 2FA.
- Quick confirm: temporarily move the test client to the VPN_BYPASS path (or
just disable the Proton instance) — if the problem vanishes, it's IP-reputation.
- Cross-check the destination IPs in Firewall → Log Files → Live View (filter
on the client) to see what it's actually talking to.
How to add it (pick the right alias type):
- Big provider with published ranges (Google/YouTube already covered) → add a
URL Table (IPs) alias pointing at their official IP-range list and add it to
the VPN_BYPASS group. Auto-refreshes; lowest maintenance.
- A specific site/app → add its domain to the VPN_BYPASS_HOSTS FQDN
alias. OPNsense resolves the name to IPs and keeps them updated. Caveat: many
sites sit behind CDNs (Cloudflare/Akamai/Fastly) on shared IPs — an FQDN alias
only captures the IPs that name currently resolves to, so CDN-heavy sites can be
hit-or-miss. If one domain isn't enough, watch the live log and add the extra
domains it loads from.
- Known fixed IPs → add them directly to a Host(s)/Network(s) alias.
After editing an alias, Firewall → Aliases → Apply; no rule changes needed
(the rules already reference VPN_BYPASS). Confirm with Diagnostics → Aliases
that the entries resolved to addresses.
Keep it tight. Every entry is a destination that sees your real IP instead of Proton's — that's the point, but only add what actually needs it. Review the list occasionally and remove sites that no longer block Proton. If you find yourself bypassing huge swaths of the internet, that's a signal your Proton servers are heavily flagged — rotate to fresher Proton endpoints (runbook updates the group members) rather than ballooning the bypass.
Validation¶
- [ ] All N tunnels show a handshake (VPN → WireGuard → Status).
- [ ] System → Gateways: each
PROTON_GW<n>shows up (monitoring is disabled, so they read "up" by definition);PROTONVPN_LBshows online;WAN_GW= default. - [ ]
/32route to each Proton endpoint via WAN present. - [ ] (After runbook 06) a client AND a server both show a Proton egress IP at
ipinfo.io — and it works on every connection, not 1-in-4 (if only ~1 in 4
works, the §5 NAT-to-
10.2.0.2isn't applied on all tunnels). AVPN_BYPASSdestination shows your real IP. - [ ] Load-balancing: several parallel connections land on different tunnels (egress IP varies); a single flow stays put (sticky-address).
- [ ] (No auto-failover with monitoring off) — to confirm a tunnel is healthy, disable the other three instances and verify clients still egress via the remaining one.
- [ ] Kill-switch: disable all Proton instances → non-bypass internet stops (no leak); bypass destinations + inbound reverse proxy still work.
- [ ] RFC1918 bypass intact: client→fileserver NFS stays local/fast.
Then → runbook 06 (firewall rules).