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:
@@ -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", "")}
|
||||
|
||||
Reference in New Issue
Block a user