From 40c765e831be39cc324d8d719e78bf9ce7be8d8e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Mar 2026 15:06:22 +0000 Subject: [PATCH] =?UTF-8?q?Add=20WebRTC/Mattermost=20calls=20fix=20?= =?UTF-8?q?=E2=80=94=20static-port=20outbound=20NAT=20via=20OPNsense=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two endpoints: POST /api/opnsense/nat/fix-webrtc Fixes WebRTC ICE failures caused by symmetric NAT (the reason Mattermost calls fail on every VLAN but work through a commercial VPN). OPNsense default outbound NAT remaps UDP source ports per-destination: each flow to a different server gets a different external port, so STUN candidates reported by different servers never match and ICE fails. Fix: switch outbound NAT to Hybrid mode, then add a UDP static-port rule for each VLAN subnet. Static-port preserves the source port through NAT, making STUN candidates consistent regardless of which server reports them. ICE succeeds, calls work without VPN. Body: { token, vlans: [{id, name, subnet}], wan_interface: "wan" } GET /api/opnsense/nat/webrtc-status Returns current outbound NAT mode and existing static-port rules so the UI can show whether the fix has been applied. https://claude.ai/code/session_01JR2EMK7rwrZJowpstcaxQ6 --- switch_backend.py | 135 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/switch_backend.py b/switch_backend.py index cad4b82..d1e9506 100644 --- a/switch_backend.py +++ b/switch_backend.py @@ -3613,3 +3613,138 @@ def opnsense_unbound_write_forward_ctrld(body: dict): raise HTTPException(500, f"unbound-checkconf failed after write: {err or out}") _opnsense_ssh_run(cfg, "unbound-control reload 2>&1") return {"success": True, "content": content, "enabled": enabled, "port": port} + + +# ══════════════════════════════════════════════════════════════════════ +# OPNSENSE NAT — WEBRTC / MATTERMOST CALLS FIX +# ══════════════════════════════════════════════════════════════════════ +# +# Problem: OPNsense uses symmetric NAT by default (port address translation). +# Each UDP flow to a *different* destination gets a *different* external source +# port. WebRTC ICE relies on STUN to discover the external address, but with +# symmetric NAT the STUN server sees a different port than the TURN or peer +# server will see — ICE candidate matching fails and calls drop. A commercial +# VPN "fixes" it because the VPN encapsulates UDP inside a single TCP/UDP +# tunnel that is full-cone from OPNsense's perspective. +# +# Fix: add a "static port" outbound NAT rule for each VLAN subnet. +# "Static port" (fixedport in pfSense/OPNsense) preserves the source port +# number through NAT. The external port equals the internal port, so every +# STUN server sees the same address:port — ICE succeeds. +# +# This requires switching outbound NAT from "Automatic" to "Hybrid" mode +# (hybrid = keep automatic rules, also honour manual ones). The static-port +# rules are added for UDP only; TCP and other protocols are unaffected. +# ══════════════════════════════════════════════════════════════════════ + +@app.post("/api/opnsense/nat/fix-webrtc") +def opnsense_nat_fix_webrtc(body: dict): + """ + Fix WebRTC / Mattermost Calls / STUN failures caused by symmetric NAT. + + Steps: + 1. Switch outbound NAT mode to Hybrid (preserves automatic rules). + 2. For each VLAN subnet in body.vlans, add a UDP static-port outbound + NAT rule on the WAN interface. Static port = source port preserved + through NAT so STUN candidates are consistent across servers. + 3. Apply changes. + + Body: { token, vlans: [{id, name, subnet}], wan_interface: "wan" } + Returns: { success, mode_set, rules_added: [...], rules_failed: [...] } + """ + require_session(body.get("token", "")) + cfg = _load_opnsense_cfg() + if not cfg.get("host"): + raise HTTPException(503, "OPNsense API not configured") + + vlans = body.get("vlans", []) + wan_iface = body.get("wan_interface", "wan") + rules_added = [] + rules_failed = [] + + # Step 1 — switch to Hybrid outbound NAT mode + try: + _opnsense_request(cfg, "firewall/nat/outbound/setMode", + method="POST", body={"mode": "hybrid"}) + mode_set = True + except Exception as e: + raise HTTPException(500, f"Could not set outbound NAT to hybrid: {e}") + + # Step 2 — add a static-port UDP rule for each VLAN + for vlan in vlans: + subnet = vlan.get("subnet", "").strip() + name = vlan.get("name", f"VLAN{vlan.get('id','')}") + if not subnet: + continue + rule = { + "rule": { + "enabled": "1", + "sequence": "1", + "interface": wan_iface, + "ipprotocol": "inet", + "protocol": "UDP", + "source": {"network": subnet, "port": ""}, + "sourceport": "", + "destination": {"network": "any", "port": ""}, + "destinationport": "", + "target": "", + "targetip": "", + "targetip_subnet": "32", + "nonat": "0", + "staticnatport": "1", + "descr": f"Static port UDP — {name} WebRTC/STUN fix", + } + } + try: + resp = _opnsense_request(cfg, "firewall/nat/outbound/addRule", + method="POST", body=rule) + rules_added.append({"vlan": name, "subnet": subnet, "uuid": resp.get("uuid","")}) + except Exception as e: + rules_failed.append({"vlan": name, "subnet": subnet, "error": str(e)}) + + # Step 3 — apply + try: + _opnsense_request(cfg, "firewall/nat/outbound/apply", method="POST") + except Exception as e: + rules_failed.append({"vlan": "apply", "error": str(e)}) + + return { + "success": len(rules_added) > 0 and not rules_failed, + "mode_set": mode_set, + "rules_added": rules_added, + "rules_failed": rules_failed, + "explanation": ( + "Static-port NAT preserves UDP source ports through NAT. " + "STUN now sees the same external address regardless of destination server. " + "WebRTC ICE candidates match — calls work without VPN." + ), + } + + +@app.get("/api/opnsense/nat/webrtc-status") +def opnsense_nat_webrtc_status(): + """ + Check whether static-port outbound NAT rules exist for WebRTC. + Returns current outbound NAT mode and any existing static-port rules. + """ + cfg = _load_opnsense_cfg() + if not cfg.get("host"): + raise HTTPException(503, "OPNsense API not configured") + try: + data = _opnsense_request(cfg, "firewall/nat/outbound/get") + mode = data.get("natoutbound", {}).get("mode", "unknown") + rules = data.get("natoutbound", {}).get("rule", {}) + static_rules = [ + {"uuid": uid, "descr": r.get("descr",""), "source": r.get("source",{})} + for uid, r in (rules.items() if isinstance(rules, dict) else {}.items()) + if str(r.get("staticnatport","0")) == "1" + ] + return { + "mode": mode, + "hybrid": mode == "hybrid", + "static_rules": static_rules, + "webrtc_ready": mode == "hybrid" and len(static_rules) > 0, + } + except Exception as e: + raise HTTPException(500, str(e)) +