Last active
January 17, 2026 17:25
-
-
Save jmvermeulen/169971766991f8b8cc0c9c1763d1c052 to your computer and use it in GitHub Desktop.
OPNsense VLAN via (Proton)VPN.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Policy-Based VPN Routing on OPNsense with WireGuard: A Deep Dive into the Pitfalls | |
| Setting up a dedicated VLAN that forces all traffic through a VPN sounds straightforward. On OPNsense with WireGuard, it's anything but. This post documents the setup and the non-obvious issues that can leave you debugging for hours. | |
| ## The Goal | |
| - Separate WiFi SSID where **all traffic** routes through a WireGuard VPN (ProtonVPN in this case) | |
| - Kill switch: if VPN is down, traffic is blocked (no fallback to WAN) | |
| - Failover: secondary VPN tunnel activates if primary fails | |
| ## Architecture Overview | |
| ``` | |
| [VPN VLAN Clients] --> [OPNsense] --> [WireGuard Tunnel] --> [VPN Provider] --> Internet | |
| 192.168.13.0/24 | wg0/wg1 | |
| | | |
| +--> [WAN] (blocked for VPN VLAN) | |
| ``` | |
| ### Interface Layout | |
| | Interface | Purpose | Subnet | | |
| |-----------|---------|--------| | |
| | opt3 (vlan) | VPN-routed clients | 192.168.13.0/24 | | |
| | opt4 (wg0) | Primary VPN tunnel | 10.2.0.2/32 | | |
| | opt6 (wg1) | Failover VPN tunnel | 10.2.0.3/32 | | |
| ## WireGuard Configuration | |
| ### Instance Setup | |
| For ProtonVPN (or similar), use the **Router** profile, not Linux. The Router profile provides: | |
| - A /32 tunnel address | |
| - DNS server IP (typically the tunnel gateway) | |
| - Peer public key and endpoint | |
| Create two instances for failover: | |
| ``` | |
| # wg0 - Primary | |
| [Interface] | |
| PrivateKey = <your-private-key> | |
| ListenPort = 51820 | |
| [Peer] | |
| PublicKey = <provider-public-key> | |
| Endpoint = <provider-endpoint>:51820 | |
| AllowedIPs = 0.0.0.0/0 | |
| PersistentKeepalive = 25 | |
| ``` | |
| ``` | |
| # wg1 - Failover (different server) | |
| [Interface] | |
| PrivateKey = <your-private-key-2> | |
| ListenPort = 51821 | |
| [Peer] | |
| PublicKey = <provider-public-key-2> | |
| Endpoint = <different-endpoint>:51820 | |
| AllowedIPs = 0.0.0.0/0 | |
| PersistentKeepalive = 25 | |
| ``` | |
| ### Gateway Group for Failover | |
| Create a gateway group with tiered priorities: | |
| | Gateway | Tier | Purpose | | |
| |---------|------|---------| | |
| | VPN_Primary_GW | 1 | Primary tunnel | | |
| | VPN_Failover_GW | 2 | Backup tunnel | | |
| Set trigger to "Member Down" - failover activates when primary gateway health check fails. | |
| ## Firewall Rules for Policy Routing | |
| This is where OPNsense uses pf's `route-to` directive to force traffic through a specific gateway. | |
| Create rules on the VPN VLAN interface (opt3): | |
| - **Action**: Pass | |
| - **Source**: VPN VLAN net | |
| - **Destination**: Any | |
| - **Gateway**: VPN_Gateway_Group (your failover group) | |
| This generates pf rules like: | |
| ``` | |
| pass in quick on vlan0.10 route-to (wg0 10.2.0.1) inet all ... | |
| ``` | |
| ## Outbound NAT | |
| Configure hybrid outbound NAT with explicit rules: | |
| ``` | |
| Source: 192.168.13.0/24 | |
| Destination: Any | |
| Interface: wg0 (and wg1) | |
| Translation: Interface Address | |
| ``` | |
| This ensures VPN VLAN traffic is NAT'd to the tunnel's IP before exiting. | |
| --- | |
| ## The Pitfalls | |
| ### Pitfall 1: `flags S/SA` + `keep state` Creates No States for UDP | |
| **Symptom**: Tunnel has handshake, but clients have no internet. DNS fails. State table shows `NO_TRAFFIC:SINGLE` for UDP connections. | |
| **Root Cause**: OPNsense's default state type is `keep state`, which generates pf rules with `flags S/SA`. While UDP packets technically pass the rule, **no state is created** because state creation is tied to TCP flag matching. | |
| ``` | |
| # UDP passes but creates no state: | |
| pass in quick on vlan0.10 route-to (wg0 10.2.0.1) inet all flags S/SA keep state | |
| ``` | |
| **Verified behavior**: | |
| | Metric | keep state | sloppy state | | |
| |--------|------------|--------------| | |
| | Packets passed | 326 | Yes | | |
| | States created | **0** | Yes | | |
| | Return traffic matches | No | Yes | | |
| Without a state, return traffic can't be associated with the original connection. | |
| **Solution**: Change state type to `sloppy state` in the firewall rule advanced options. | |
| In `config.xml`: | |
| ```xml | |
| <statetype>sloppy state</statetype> | |
| ``` | |
| The resulting pf rule: | |
| ``` | |
| pass in quick on vlan0.10 route-to (wg0 10.2.0.1) inet all flags S/SA keep state (sloppy) | |
| ``` | |
| The `(sloppy)` modifier enables state creation for UDP and other non-TCP traffic. | |
| **Security consideration**: Only apply `sloppy state` to interfaces that need policy-based VPN routing. Keep all other interfaces on strict `keep state`: | |
| ```php | |
| <?php | |
| // fix-sloppy-state.php - Selective sloppy state | |
| $dom = new DOMDocument(); | |
| $dom->load("/conf/config.xml"); | |
| $xpath = new DOMXPath($dom); | |
| foreach ($xpath->query("//filter/rule") as $rule) { | |
| $iface = $xpath->query("interface", $rule)->item(0)->nodeValue ?? ""; | |
| $state = $xpath->query("statetype", $rule)->item(0); | |
| if ($state && $state->nodeValue === "sloppy state") { | |
| // Only VPN interfaces need sloppy | |
| if ($iface !== "opt3" && $iface !== "wireguard") { | |
| $state->nodeValue = "keep state"; | |
| } | |
| } | |
| } | |
| $dom->save("/conf/config.xml"); | |
| ``` | |
| ### Pitfall 2: Gateway Address Must Be the Peer, Not Local | |
| **Symptom**: Rules show correct `route-to`, states are created, but no traffic appears on the WireGuard interface (`tcpdump -i wg0` shows nothing). | |
| **Root Cause**: The gateway was configured with the **local** tunnel address instead of the **peer** address. | |
| Wrong: | |
| ``` | |
| Gateway Address: 10.2.0.2 (this is YOUR tunnel IP) | |
| ``` | |
| Correct: | |
| ``` | |
| Gateway Address: 10.2.0.1 (this is the PEER's IP / tunnel gateway) | |
| ``` | |
| The `route-to` directive needs a valid next-hop. If you point it to your own address, packets go nowhere. | |
| **Verification**: | |
| ```bash | |
| # Check what route-to is using: | |
| pfctl -sr | grep route-to | |
| # Should show peer IP: | |
| # route-to (wg0 10.2.0.1) <-- correct | |
| # route-to (wg0 10.2.0.2) <-- wrong (local address) | |
| ``` | |
| ### Pitfall 3: WireGuard Config Not Regenerating After Upgrade | |
| **Symptom**: After OPNsense upgrade, WireGuard tunnels don't come up. Config files in `/usr/local/etc/wireguard/` are missing or stale. | |
| **Root Cause**: OPNsense sometimes fails to regenerate wg.conf files from config.xml. | |
| **Workaround**: | |
| ```bash | |
| # Manually create the config file | |
| cat > /usr/local/etc/wireguard/wg0.conf << 'EOF' | |
| [Interface] | |
| PrivateKey = <key> | |
| ListenPort = 51820 | |
| [Peer] | |
| PublicKey = <peer-key> | |
| Endpoint = <endpoint>:51820 | |
| AllowedIPs = 0.0.0.0/0 | |
| PersistentKeepalive = 25 | |
| EOF | |
| chmod 600 /usr/local/etc/wireguard/wg0.conf | |
| # Apply without restarting interface | |
| wg syncconf wg0 /usr/local/etc/wireguard/wg0.conf | |
| ``` | |
| ### Pitfall 4: VLAN ID vs Interface Name Confusion | |
| **Symptom**: Created WiFi SSID with VLAN 10, but no DHCP. Used VLAN 2 and it works. | |
| **Root Cause**: OPNsense interface names like `vlan0.10` don't indicate the actual VLAN tag. The `.10` is just an index. | |
| **Solution**: Always check the actual VLAN configuration in Interfaces > Other Types > VLAN to find the real VLAN tag. | |
| ### Pitfall 5: DNS Resolution Chicken-and-Egg | |
| **Symptom**: DHCP gives clients the VPN provider's DNS (e.g., 10.2.0.1), but DNS queries fail because the tunnel isn't up yet for new clients. | |
| **Root Cause**: The provider's DNS server (10.2.0.1) is only reachable through the tunnel. If a client's first packets are DNS queries and the state isn't established yet, they fail. | |
| **Solution**: Use a public DNS (1.1.1.1, 8.8.8.8) that routes through the VPN tunnel instead of the provider's internal DNS. The traffic still goes through the VPN, but the DNS server is reachable even during tunnel establishment edge cases. | |
| --- | |
| ## Diagnostic Commands | |
| ```bash | |
| # Check WireGuard tunnel status and traffic | |
| wg show | |
| # Check if traffic is hitting the tunnel | |
| tcpdump -i wg0 -n | |
| # View state table for specific subnet | |
| pfctl -ss | grep 192.168.13 | |
| # Check pf rules with packet counters | |
| pfctl -vsr | grep route-to | |
| # Flush all states (forces reconnection) | |
| pfctl -Fs | |
| # Reload firewall rules from config.xml | |
| configctl filter reload | |
| ``` | |
| ## State Table Interpretation | |
| | State | Meaning | | |
| |-------|---------| | |
| | `ESTABLISHED:ESTABLISHED` | TCP connection working both ways | | |
| | `MULTIPLE:MULTIPLE` | UDP with bidirectional traffic | | |
| | `NO_TRAFFIC:SINGLE` | Packets sent, no response (tunnel not working) | | |
| | `SINGLE:NO_TRAFFIC` | Only received, not sent | | |
| If you see `NO_TRAFFIC:SINGLE` for all UDP, you likely have the `flags S/SA` problem. | |
| --- | |
| ## Kill Switch Implementation | |
| The kill switch is implicit when you: | |
| 1. Set all VPN VLAN rules to use the VPN gateway group | |
| 2. Don't create any fallback rules with WAN gateway | |
| 3. Have no default "allow all" rule | |
| If both VPN gateways are down, no rule matches, and traffic is blocked. Test by bringing down both tunnels: | |
| ```bash | |
| wg set wg0 peer <pubkey> remove | |
| wg set wg1 peer <pubkey> remove | |
| # VPN VLAN clients should now have no connectivity | |
| ``` | |
| --- | |
| ## Security Hardening | |
| After getting VPN routing working, lock down the configuration: | |
| ### 1. Selective Sloppy State | |
| Don't apply `sloppy state` globally. Only VPN-routed interfaces need it: | |
| | Interface | State Type | Reason | | |
| |-----------|------------|--------| | |
| | WAN | `keep state` | Strict inbound filtering | | |
| | LAN | `keep state` | Normal traffic, strict state tracking | | |
| | VPN VLAN | `sloppy state` | Required for UDP routing | | |
| | WireGuard | `sloppy state` | VPN traffic | | |
| ### 2. Verify No Unintended Exposure | |
| ```bash | |
| # Check inbound WAN rules | |
| pfctl -sr | grep -E '^pass.*pppoe0.*in' | |
| # Verify management interfaces | |
| grep -A10 '<ssh>' /conf/config.xml | grep interfaces | |
| grep -A10 '<webgui>' /conf/config.xml | grep interfaces | |
| ``` | |
| ### 3. Test Kill Switch | |
| Verify VPN VLAN traffic is blocked when tunnel is down: | |
| ```bash | |
| # Remove WireGuard peer temporarily | |
| wg set wg0 peer <pubkey> remove | |
| # VPN VLAN clients should have NO connectivity | |
| # (not fall back to WAN) | |
| # Restore peer | |
| wg syncconf wg0 /usr/local/etc/wireguard/wg0.conf | |
| ``` | |
| --- | |
| ## Summary | |
| The two critical issues that will waste hours of your time: | |
| 1. **Use `sloppy state`** - Default `keep state` creates no states for UDP, breaking DNS and other non-TCP traffic. Verified: 326 packets passed, 0 states created. | |
| 2. **Gateway = peer IP** - The route-to next-hop must be the remote end, not your local tunnel address | |
| Everything else is standard OPNsense configuration. But miss either of these, and you'll have a tunnel with handshakes, correct-looking rules, and zero working traffic. | |
| **Security note**: Apply `sloppy state` only to VPN interfaces. All other interfaces should use strict `keep state`. | |
| --- | |
| ## References | |
| - [OPNsense WireGuard Documentation](https://docs.opnsense.org/manual/vpnet.html) | |
| - [FreeBSD pf.conf(5) - flags and state options](https://man.freebsd.org/cgi/man.cgi?query=pf.conf) | |
| - [WireGuard Protocol](https://www.wireguard.com/protocol/) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment