Add WebRTC/Mattermost calls fix — static-port outbound NAT via OPNsense API
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
This commit is contained in:
@@ -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))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user