Add OPNsense WireGuard — router-level VPN with per-VLAN access control

Moves WireGuard off the management computer and onto OPNsense so any
device can VPN home without touching the management PC. Each peer is
restricted to only the VLANs you select (e.g. phone gets VLAN 10 only,
laptop gets VLAN 10 + 20). Private keys are generated on the mgmt PC
and never sent to OPNsense — only the public key is registered.

Backend (switch_backend.py):
- /api/opnsense/wireguard/status      — check plugin, server, peers
- /api/opnsense/wireguard/setup-server — create wg1 on OPNsense via API
- DELETE /api/opnsense/wireguard/server — tear down server
- /api/opnsense/wireguard/add-peer    — generate keypair, register peer,
                                        link to server, return .conf
- DELETE /api/opnsense/wireguard/peer/{uuid} — revoke peer
- /api/opnsense/wireguard/peer-config/{name} — fetch saved .conf

Frontend (ers5952-manager.jsx):
- New OPNsenseWGSection component added to VPN tab below local WireGuard
- Progressive UI: not configured → plugin missing → server setup →
  peer management (VLAN checkboxes) → QR/.conf download
- Firewall rules guidance panel auto-generated from active peers showing
  exactly which OPNsense rules to add per VLAN
- vlans prop threaded through to WireGuardTab so VLAN names/colors
  appear on peer badges and in the VLAN selector

https://claude.ai/code/session_01JR2EMK7rwrZJowpstcaxQ6
This commit is contained in:
Claude
2026-03-23 15:31:53 +00:00
parent 8b4ed7f331
commit f9bb4b26cc
3 changed files with 839 additions and 3 deletions
+410
View File
@@ -2369,3 +2369,413 @@ def save_local_hostnames(body: LocalHostnamesUpdate):
"then run: docker compose up -d dnsmasq"
),
}
# ══════════════════════════════════════════════════════════════════════
# OPNSENSE WIREGUARD — ROUTER-LEVEL VPN WITH PER-VLAN ACCESS CONTROL
# ══════════════════════════════════════════════════════════════════════
#
# Moves WireGuard from the management computer onto OPNsense so any
# device on any VLAN can VPN home without touching the management PC.
# Each peer is granted access only to the VLANs you choose.
#
# Architecture:
# OPNsense wg1 interface (10.99.2.0/24 — separate from local wg0)
# Peer Alice → tunnel IP 10.99.2.2 → allowed VLAN 10 + VLAN 20
# Peer Bob → tunnel IP 10.99.2.3 → allowed VLAN 10 only
# Private keys are generated here and stored only on the mgmt PC.
# OPNsense receives only the public key (standard WireGuard practice).
OPN_WG_FILE = _Path("/etc/switch-manager/opnsense_wg.json")
def _load_opnsense_wg() -> dict:
if OPN_WG_FILE.exists():
try:
return _json.loads(OPN_WG_FILE.read_text())
except Exception:
pass
return {}
def _save_opnsense_wg(cfg: dict):
OPN_WG_FILE.parent.mkdir(parents=True, exist_ok=True)
OPN_WG_FILE.write_text(_json.dumps(cfg, indent=2))
OPN_WG_FILE.chmod(0o600)
class OPNWGServerSetup(BaseModel):
token: str
server_name: str = "switch-mgmt-vpn"
listen_port: int = 51820
tunnel_subnet: str = "10.99.2.0/24"
public_endpoint: str = "" # public IP or DDNS hostname for client configs
class OPNWGAddPeer(BaseModel):
token: str
name: str
allowed_vlans: list # list of VLAN IDs: [10, 20, 30]
vlan_subnets: dict # {10: "192.168.10.0/24", 20: "192.168.20.0/24", ...}
@app.get("/api/opnsense/wireguard/status")
def opnsense_wg_status():
"""Check OPNsense WireGuard plugin, server, and peer state."""
opn_cfg = _load_opnsense_cfg()
if not opn_cfg:
return {"opnsense_configured": False}
wg = _load_opnsense_wg()
# Probe for the WireGuard plugin — a 404 means the plugin isn't installed
try:
_opnsense_request(opn_cfg, "wireguard/server/searchServer")
plugin_ok = True
except ValueError as e:
msg = str(e)
# 404 → plugin absent; other errors → reachability / auth issue
plugin_ok = False
return {
"opnsense_configured": True,
"plugin_installed": False,
"error": msg,
"server": None,
"peers": [],
}
# If we have a saved server UUID, fetch live info
server_info = None
if wg.get("server_uuid"):
try:
s = _opnsense_request(opn_cfg, f"wireguard/server/getServer/{wg['server_uuid']}")
srv = s.get("server", {})
server_info = {
"uuid": wg["server_uuid"],
"name": srv.get("name", wg.get("server_name","")),
"pubkey": srv.get("pubkey", wg.get("server_pubkey","")),
"tunnel_ip": wg.get("server_tunnel_ip",""),
"listen_port": wg.get("listen_port", 51820),
"public_endpoint": wg.get("public_endpoint",""),
}
except Exception:
# Server UUID no longer valid (e.g. OPNsense was reset)
server_info = None
# Merge OPNsense peer list with local metadata (which holds allowed_vlans)
local_peers = {p["name"]: p for p in wg.get("peers", [])}
opn_peers = []
try:
resp = _opnsense_request(opn_cfg, "wireguard/client/searchClient")
opn_peers = resp.get("rows", [])
except Exception:
pass
merged = []
for p in opn_peers:
name = p.get("name", "")
loc = local_peers.get(name, {})
merged.append({
"uuid": p.get("uuid", ""),
"name": name,
"enabled": p.get("enabled", "0") == "1",
"tunnel_ip": p.get("tunneladdress", ""),
"allowed_vlans": loc.get("allowed_vlans", []),
})
return {
"opnsense_configured": True,
"plugin_installed": plugin_ok,
"server": server_info,
"peers": merged,
}
@app.post("/api/opnsense/wireguard/setup-server")
def opnsense_wg_setup_server(body: OPNWGServerSetup):
"""Create (or replace) a WireGuard server on OPNsense via its API."""
require_session(body.token)
opn_cfg = _load_opnsense_cfg()
if not opn_cfg:
raise HTTPException(400, "OPNsense not configured — connect it in the DHCP tab first")
import ipaddress as _ip, time as _time
try:
net = _ip.ip_network(body.tunnel_subnet, strict=False)
except Exception:
raise HTTPException(400, "Invalid tunnel_subnet — use CIDR notation e.g. 10.99.2.0/24")
server_tunnel_ip = f"{list(net.hosts())[0]}/{net.prefixlen}"
wg = _load_opnsense_wg()
# Tear down any pre-existing server so we start clean
if wg.get("server_uuid"):
try:
_opnsense_request(opn_cfg,
f"wireguard/server/delServer/{wg['server_uuid']}", method="POST")
except Exception:
pass
payload = {
"server": {
"enabled": "1",
"name": body.server_name,
"instance": "1", # creates wg1 — leaves wg0 (local) untouched
"port": str(body.listen_port),
"tunneladdress": server_tunnel_ip,
"dns": "",
"peers": "",
}
}
try:
result = _opnsense_request(opn_cfg, "wireguard/server/addServer",
method="POST", body=payload)
except Exception as e:
raise HTTPException(500, f"OPNsense rejected server creation: {e}")
server_uuid = result.get("uuid","")
if not server_uuid:
raise HTTPException(500, "OPNsense did not return a server UUID")
# Apply so OPNsense generates the keypair, then read it back
try:
_opnsense_request(opn_cfg, "wireguard/service/reconfigure", method="POST")
except Exception:
pass
_time.sleep(1.5) # give the daemon a moment to generate keys
server_pubkey = ""
try:
s = _opnsense_request(opn_cfg, f"wireguard/server/getServer/{server_uuid}")
server_pubkey = s.get("server", {}).get("pubkey", "")
except Exception:
pass
wg = {
"server_uuid": server_uuid,
"server_name": body.server_name,
"listen_port": body.listen_port,
"tunnel_subnet": body.tunnel_subnet,
"server_tunnel_ip": server_tunnel_ip,
"server_pubkey": server_pubkey,
"public_endpoint": body.public_endpoint,
"peers": [],
}
_save_opnsense_wg(wg)
log.info(f"OPNsense WG server created: {body.server_name} uuid={server_uuid}")
return {
"success": True,
"server_uuid": server_uuid,
"server_pubkey": server_pubkey,
"server_tunnel_ip": server_tunnel_ip,
"listen_port": body.listen_port,
}
@app.delete("/api/opnsense/wireguard/server")
def opnsense_wg_delete_server(token: str):
"""Remove the WireGuard server from OPNsense and clear local state."""
require_session(token)
opn_cfg = _load_opnsense_cfg()
if not opn_cfg:
raise HTTPException(400, "OPNsense not configured")
wg = _load_opnsense_wg()
if not wg.get("server_uuid"):
raise HTTPException(404, "No server is configured")
try:
_opnsense_request(opn_cfg,
f"wireguard/server/delServer/{wg['server_uuid']}", method="POST")
_opnsense_request(opn_cfg, "wireguard/service/reconfigure", method="POST")
except Exception as e:
raise HTTPException(500, f"Failed to delete server: {e}")
_save_opnsense_wg({})
return {"success": True}
@app.post("/api/opnsense/wireguard/add-peer")
def opnsense_wg_add_peer(body: OPNWGAddPeer):
"""
Generate a WireGuard keypair, register the peer on OPNsense,
link it to the server, and return a ready-to-use .conf for the client.
The private key is stored only on the management PC (never sent to OPNsense).
"""
require_session(body.token)
opn_cfg = _load_opnsense_cfg()
if not opn_cfg:
raise HTTPException(400, "OPNsense not configured")
wg = _load_opnsense_wg()
if not wg.get("server_uuid"):
raise HTTPException(400, "Set up the WireGuard server on OPNsense first")
import ipaddress as _ip, re as _re
# ── Allocate next free IP in the tunnel subnet ────────────────────
net = _ip.ip_network(wg["tunnel_subnet"], strict=False)
hosts = list(net.hosts())
used = set()
# Reserve the server's own tunnel IP
m = _re.match(r'(\S+)/\d+', wg.get("server_tunnel_ip", ""))
if m:
used.add(m.group(1))
for p in wg.get("peers", []):
m2 = _re.match(r'(\S+)/\d+', p.get("tunnel_ip", ""))
if m2:
used.add(m2.group(1))
peer_ip_obj = next((h for h in hosts if str(h) not in used), None)
if not peer_ip_obj:
raise HTTPException(400, "Tunnel subnet is full — no IPs available for new peer")
peer_ip = f"{peer_ip_obj}/{net.prefixlen}"
# ── Build the AllowedIPs list from chosen VLANs ───────────────────
vlan_cidrs = []
for vid in body.allowed_vlans:
subnet = (body.vlan_subnets.get(str(vid))
or body.vlan_subnets.get(int(vid))
or f"192.168.{vid}.0/24")
vlan_cidrs.append(subnet)
# Always include the tunnel subnet so the client can reach the server
allowed_ips = ", ".join([str(net)] + vlan_cidrs) if vlan_cidrs else str(net)
# ── Generate keypair (private key stays on mgmt PC only) ─────────
c_priv, c_pub = _wg_genkey_api()
# ── Register peer (client) on OPNsense ───────────────────────────
peer_payload = {
"client": {
"enabled": "1",
"name": body.name,
"pubkey": c_pub,
"psk": "",
"tunneladdress": peer_ip,
"serveraddress": "",
"serverport": "",
"keepalive": "25",
}
}
try:
result = _opnsense_request(opn_cfg, "wireguard/client/addClient",
method="POST", body=peer_payload)
except Exception as e:
raise HTTPException(500, f"OPNsense rejected peer creation: {e}")
peer_uuid = result.get("uuid", "")
if not peer_uuid:
raise HTTPException(500, "OPNsense did not return a peer UUID")
# ── Link peer to server (append to server's peers list) ──────────
try:
s = _opnsense_request(opn_cfg,
f"wireguard/server/getServer/{wg['server_uuid']}")
srv = s.get("server", {})
existing = srv.get("peers", "")
new_peers = f"{existing},{peer_uuid}" if existing else peer_uuid
_opnsense_request(opn_cfg,
f"wireguard/server/setServer/{wg['server_uuid']}", method="POST",
body={"server": {**srv, "peers": new_peers}})
except Exception as e:
log.warning(f"Could not link peer to server (peer still registered): {e}")
# Apply config
try:
_opnsense_request(opn_cfg, "wireguard/service/reconfigure", method="POST")
except Exception:
pass
# ── Build client .conf ────────────────────────────────────────────
server_pubkey = wg.get("server_pubkey", "")
endpoint_host = wg.get("public_endpoint", "") or "<YOUR-OPNSENSE-PUBLIC-IP>"
endpoint_port = wg.get("listen_port", 51820)
tunnel_gw = wg.get("server_tunnel_ip", "").split("/")[0]
client_conf = (
f"[Interface]\n"
f"PrivateKey = {c_priv}\n"
f"Address = {peer_ip}\n"
f"DNS = {tunnel_gw}\n\n"
f"[Peer]\n"
f"PublicKey = {server_pubkey or '<SERVER_PUBKEY>'}\n"
f"Endpoint = {endpoint_host}:{endpoint_port}\n"
f"AllowedIPs = {allowed_ips}\n"
f"PersistentKeepalive = 25\n"
)
# ── Persist peer metadata locally ────────────────────────────────
peer_meta = {
"uuid": peer_uuid,
"name": body.name,
"pub_key": c_pub,
"priv_key": c_priv, # NEVER sent to OPNsense
"tunnel_ip": peer_ip,
"allowed_vlans": body.allowed_vlans,
"allowed_ips": allowed_ips,
"config": client_conf,
}
peers = [p for p in wg.get("peers", []) if p.get("name") != body.name]
peers.append(peer_meta)
wg["peers"] = peers
_save_opnsense_wg(wg)
log.info(f"OPNsense WG peer added: {body.name}{peer_ip} VLANs={body.allowed_vlans}")
return {
"success": True,
"uuid": peer_uuid,
"name": body.name,
"tunnel_ip": peer_ip,
"allowed_vlans": body.allowed_vlans,
"config": client_conf,
}
@app.delete("/api/opnsense/wireguard/peer/{uuid}")
def opnsense_wg_remove_peer(uuid: str, token: str):
"""Remove a peer from OPNsense and from local metadata."""
require_session(token)
opn_cfg = _load_opnsense_cfg()
if not opn_cfg:
raise HTTPException(400, "OPNsense not configured")
wg = _load_opnsense_wg()
# Remove from OPNsense
try:
_opnsense_request(opn_cfg,
f"wireguard/client/delClient/{uuid}", method="POST")
except Exception as e:
raise HTTPException(500, f"Failed to remove peer from OPNsense: {e}")
# Unlink from server peers list
if wg.get("server_uuid"):
try:
s = _opnsense_request(opn_cfg,
f"wireguard/server/getServer/{wg['server_uuid']}")
srv = s.get("server", {})
existing = srv.get("peers", "")
updated = ",".join(p for p in existing.split(",") if p and p != uuid)
_opnsense_request(opn_cfg,
f"wireguard/server/setServer/{wg['server_uuid']}", method="POST",
body={"server": {**srv, "peers": updated}})
except Exception:
pass
# Apply
try:
_opnsense_request(opn_cfg, "wireguard/service/reconfigure", method="POST")
except Exception:
pass
wg["peers"] = [p for p in wg.get("peers", []) if p.get("uuid") != uuid]
_save_opnsense_wg(wg)
return {"success": True}
@app.get("/api/opnsense/wireguard/peer-config/{name}")
def opnsense_wg_peer_config(name: str):
"""Return the saved .conf text for a named peer (includes private key)."""
wg = _load_opnsense_wg()
peer = next((p for p in wg.get("peers", []) if p["name"] == name), None)
if not peer:
raise HTTPException(404, f"Peer '{name}' not found in local store")
return {"name": name, "config": peer.get("config", "")}