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
+2 -2
View File
File diff suppressed because one or more lines are too long
+427 -1
View File
@@ -1435,6 +1435,7 @@ export default function App() {
session={session} session={session}
onNeedAuth={() => setShowTotp(true)} onNeedAuth={() => setShowTotp(true)}
backendOk={pollStatus!=="err"} backendOk={pollStatus!=="err"}
vlans={vlans}
/>} />}
{showTotp && <TotpModal {showTotp && <TotpModal
@@ -1735,7 +1736,7 @@ function QRModal({ config, name, onClose }) {
); );
} }
function WireGuardTab({ session, onNeedAuth, backendOk }) { function WireGuardTab({ session, onNeedAuth, backendOk, vlans = [] }) {
const [status, setStatus] = useState(null); const [status, setStatus] = useState(null);
const [clients, setClients] = useState([]); const [clients, setClients] = useState([]);
const [newName, setNewName] = useState(''); const [newName, setNewName] = useState('');
@@ -1743,6 +1744,19 @@ function WireGuardTab({ session, onNeedAuth, backendOk }) {
const [qrModal, setQrModal] = useState(null); // { config, name } const [qrModal, setQrModal] = useState(null); // { config, name }
const [error, setError] = useState(''); const [error, setError] = useState('');
// ── OPNsense WireGuard state ────────────────────────────────────
const [opnWg, setOpnWg] = useState(null); // status response
const [opnLoading, setOpnLoading] = useState(false);
const [opnError, setOpnError] = useState('');
const [opnSetup, setOpnSetup] = useState({
server_name: 'switch-mgmt-vpn', listen_port: 51820,
tunnel_subnet: '10.99.2.0/24', public_endpoint: '',
});
const [opnPeerName, setOpnPeerName] = useState('');
const [opnVlans, setOpnVlans] = useState([]); // checked VLAN IDs
const [opnAdding, setOpnAdding] = useState(false);
const [opnQr, setOpnQr] = useState(null); // { config, name }
const load = async () => { const load = async () => {
try { try {
const [s, c] = await Promise.all([API("/wireguard/status"), API("/wireguard/clients")]); const [s, c] = await Promise.all([API("/wireguard/status"), API("/wireguard/clients")]);
@@ -1785,6 +1799,81 @@ function WireGuardTab({ session, onNeedAuth, backendOk }) {
const isRunning = status?.running; const isRunning = status?.running;
// ── OPNsense WireGuard handlers ──────────────────────────────────
const loadOpnWg = async () => {
setOpnLoading(true); setOpnError('');
try { setOpnWg(await API("/opnsense/wireguard/status")); }
catch(e) { setOpnError(e.message); }
setOpnLoading(false);
};
useEffect(() => { if (backendOk) loadOpnWg(); }, [backendOk]);
const opnSetupServer = async () => {
if (!session) { onNeedAuth(); return; }
setOpnLoading(true); setOpnError('');
try {
await API("/opnsense/wireguard/setup-server", {
method:"POST", body:{ token: session.token, ...opnSetup }
});
await loadOpnWg();
} catch(e) { setOpnError(e.message); }
setOpnLoading(false);
};
const opnDeleteServer = async () => {
if (!session) { onNeedAuth(); return; }
if (!confirm("Remove the WireGuard server from OPNsense? All peers will be disconnected.")) return;
setOpnLoading(true); setOpnError('');
try {
await API(`/opnsense/wireguard/server?token=${session.token}`, { method:"DELETE" });
await loadOpnWg();
} catch(e) { setOpnError(e.message); }
setOpnLoading(false);
};
const opnToggleVlan = (vid) => {
setOpnVlans(prev => prev.includes(vid) ? prev.filter(v=>v!==vid) : [...prev, vid]);
};
const opnAddPeer = async () => {
if (!opnPeerName.trim()) return;
if (opnVlans.length === 0) { setOpnError("Select at least one VLAN for this peer."); return; }
if (!session) { onNeedAuth(); return; }
setOpnAdding(true); setOpnError('');
// Build vlan_subnets map from the vlans prop
const vlan_subnets = {};
vlans.forEach(v => { vlan_subnets[v.id] = `192.168.${v.id}.0/24`; });
try {
const r = await API("/opnsense/wireguard/add-peer", {
method:"POST",
body:{ token: session.token, name: opnPeerName.trim(),
allowed_vlans: opnVlans, vlan_subnets }
});
setOpnQr({ config: r.config, name: r.name });
setOpnPeerName(''); setOpnVlans([]);
await loadOpnWg();
} catch(e) { setOpnError(e.message); }
setOpnAdding(false);
};
const opnRevokePeer = async (uuid, name) => {
if (!session) { onNeedAuth(); return; }
if (!confirm(`Revoke VPN access for "${name}"?`)) return;
setOpnError('');
try {
await API(`/opnsense/wireguard/peer/${uuid}?token=${session.token}`, { method:"DELETE" });
await loadOpnWg();
} catch(e) { setOpnError(e.message); }
};
const opnShowConf = async (name) => {
try {
const r = await API(`/opnsense/wireguard/peer-config/${name}`);
setOpnQr({ config: r.config, name: r.name });
} catch(e) { setOpnError(e.message); }
};
return ( return (
<div className="main" style={{flexDirection:"column",gap:12}}> <div className="main" style={{flexDirection:"column",gap:12}}>
<div className="panel"> <div className="panel">
@@ -1898,6 +1987,343 @@ function WireGuardTab({ session, onNeedAuth, backendOk }) {
</div> </div>
{qrModal && <QRModal config={qrModal.config} name={qrModal.name} onClose={()=>setQrModal(null)}/>} {qrModal && <QRModal config={qrModal.config} name={qrModal.name} onClose={()=>setQrModal(null)}/>}
{/* ── OPNsense WireGuard ─────────────────────────────────────── */}
<OPNsenseWGSection
opnWg={opnWg}
opnLoading={opnLoading}
opnError={opnError}
opnSetup={opnSetup}
setOpnSetup={setOpnSetup}
opnPeerName={opnPeerName}
setOpnPeerName={setOpnPeerName}
opnVlans={opnVlans}
opnAdding={opnAdding}
session={session}
onNeedAuth={onNeedAuth}
vlans={vlans}
onToggleVlan={opnToggleVlan}
onSetupServer={opnSetupServer}
onDeleteServer={opnDeleteServer}
onAddPeer={opnAddPeer}
onRevokePeer={opnRevokePeer}
onShowConf={opnShowConf}
onRefresh={loadOpnWg}
/>
{opnQr && <QRModal config={opnQr.config} name={opnQr.name} onClose={()=>setOpnQr(null)}/>}
</div>
);
}
// ── OPNsense WireGuard section (separate component for readability) ───────────
function OPNsenseWGSection({
opnWg, opnLoading, opnError, opnSetup, setOpnSetup,
opnPeerName, setOpnPeerName, opnVlans, opnAdding,
session, onNeedAuth, vlans,
onToggleVlan, onSetupServer, onDeleteServer,
onAddPeer, onRevokePeer, onShowConf, onRefresh,
}) {
const panelHeader = (
<div className="ph" style={{display:"flex",alignItems:"center",gap:8}}>
OPNsense WireGuard Router-Level VPN
<span style={{marginLeft:"auto",fontSize:10,fontFamily:"var(--mono)",
color:"var(--dm)",cursor:"pointer"}} onClick={onRefresh}>
{opnLoading ? "loading…" : "↻ refresh"}
</span>
</div>
);
// ── OPNsense not connected ──────────────────────────────────────
if (!opnWg || !opnWg.opnsense_configured) {
return (
<div className="panel">
{panelHeader}
<div className="pb" style={{color:"var(--dm)",fontSize:12,lineHeight:1.8}}>
<div style={{marginBottom:8}}>
Move WireGuard off your management computer and onto OPNsense.
Each peer can be restricted to specific VLANs e.g. a phone
gets access to VLAN 10 only, while a laptop gets VLAN 10 + 20.
</div>
<div style={{background:"rgba(255,234,0,.07)",border:"1px solid rgba(255,234,0,.2)",
borderRadius:4,padding:"10px 14px",fontSize:11,color:"var(--warn)"}}>
OPNsense is not connected. Go to the <strong>DHCP tab</strong> and
add your OPNsense API credentials first.
</div>
</div>
</div>
);
}
// ── Plugin not installed ────────────────────────────────────────
if (!opnWg.plugin_installed) {
return (
<div className="panel">
{panelHeader}
<div className="pb">
<div style={{background:"rgba(255,23,68,.07)",border:"1px solid rgba(255,23,68,.2)",
borderRadius:4,padding:"10px 14px",fontSize:11,color:"var(--err)",marginBottom:12}}>
<strong>WireGuard plugin not installed on OPNsense.</strong>
{opnWg.error && <div style={{marginTop:4,opacity:.7}}>{opnWg.error}</div>}
</div>
<div style={{fontSize:12,color:"var(--dm)",lineHeight:1.9}}>
Install it in OPNsense:
<ol style={{margin:"6px 0 0 16px",padding:0}}>
<li>System Firmware Plugins</li>
<li>Search <span style={{fontFamily:"var(--mono)",color:"var(--ac)"}}>wireguard</span></li>
<li>Install <strong>os-wireguard</strong></li>
<li>Reload this page or click refresh above</li>
</ol>
</div>
</div>
</div>
);
}
const server = opnWg.server;
const peers = opnWg.peers || [];
// ── Server not yet created ──────────────────────────────────────
if (!server) {
return (
<div className="panel">
{panelHeader}
<div className="pb">
<div style={{fontSize:12,color:"var(--dm)",lineHeight:1.7,marginBottom:14}}>
Create a WireGuard server on OPNsense. It runs as <code>wg1</code> so it
does not conflict with the local <code>wg0</code> on this machine.
Peer private keys are generated here and stored only on this management PC
OPNsense only ever receives the public key.
</div>
{opnError && (
<div style={{color:"var(--err)",fontSize:11,marginBottom:10}}>{opnError}</div>
)}
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:10,marginBottom:14}}>
<div className="field" style={{margin:0}}>
<label>Server Name</label>
<input value={opnSetup.server_name}
onChange={e=>setOpnSetup(s=>({...s,server_name:e.target.value}))}
placeholder="switch-mgmt-vpn"/>
</div>
<div className="field" style={{margin:0}}>
<label>Listen Port</label>
<input type="number" value={opnSetup.listen_port}
onChange={e=>setOpnSetup(s=>({...s,listen_port:parseInt(e.target.value)||51820}))}/>
</div>
<div className="field" style={{margin:0}}>
<label>Tunnel Subnet</label>
<input value={opnSetup.tunnel_subnet}
onChange={e=>setOpnSetup(s=>({...s,tunnel_subnet:e.target.value}))}
placeholder="10.99.2.0/24"/>
</div>
<div className="field" style={{margin:0}}>
<label>Your Public IP / DDNS</label>
<input value={opnSetup.public_endpoint}
onChange={e=>setOpnSetup(s=>({...s,public_endpoint:e.target.value}))}
placeholder="home.example.com or 1.2.3.4"/>
</div>
</div>
<div style={{fontSize:11,color:"var(--dm)",marginBottom:14,lineHeight:1.7}}>
The tunnel subnet (default <span style={{fontFamily:"var(--mono)",color:"var(--ac)"}}>10.99.2.0/24</span>)
is separate from your LAN VLANs. VPN clients get IPs from this range.
Your public IP / DDNS is what clients connect to from the internet.
</div>
<div style={{display:"flex",gap:8,alignItems:"center"}}>
{!session && (
<button className="btn bg" style={{fontSize:11,padding:"4px 10px"}} onClick={onNeedAuth}>
Authenticate
</button>
)}
<button className="btn bp" onClick={onSetupServer}
disabled={opnLoading || !session || !opnSetup.server_name}>
{opnLoading ? "Creating…" : "Create Server on OPNsense"}
</button>
</div>
</div>
</div>
);
}
// ── Server active — show peers and add-peer form ────────────────
const tunnelNet = opnSetup.tunnel_subnet || "10.99.2.0/24";
// Determine which VLANs have firewall rules to show
const allUsedVlans = [...new Set(peers.flatMap(p => p.allowed_vlans || []))];
return (
<div style={{display:"flex",flexDirection:"column",gap:12}}>
{/* Server status card */}
<div className="panel">
{panelHeader}
<div className="pb">
<div style={{display:"flex",alignItems:"flex-start",gap:16,flexWrap:"wrap"}}>
<div style={{flex:1,minWidth:200}}>
<div style={{fontWeight:700,marginBottom:6,color:"var(--ok)"}}>
{server.name}
</div>
<div style={{fontFamily:"var(--mono)",fontSize:11,color:"var(--dm)",lineHeight:2}}>
<span style={{color:"var(--tx)"}}>Tunnel:</span> {server.tunnel_ip}<br/>
<span style={{color:"var(--tx)"}}>Port:</span> {server.listen_port}<br/>
<span style={{color:"var(--tx)"}}>Endpoint:</span> {server.public_endpoint||<em>not set</em>}
</div>
</div>
<div style={{flex:2,minWidth:240}}>
<div style={{fontSize:10,color:"var(--dm)",marginBottom:4}}>Server Public Key</div>
<div style={{fontFamily:"var(--mono)",fontSize:10,background:"var(--bg)",
borderRadius:4,padding:"6px 10px",wordBreak:"break-all",
border:"1px solid var(--b1)",color:"var(--ac)"}}>
{server.pubkey || "— generating —"}
</div>
</div>
<button className="btn bd" style={{fontSize:10,padding:"3px 10px",alignSelf:"flex-start"}}
onClick={onDeleteServer}>
Remove Server
</button>
</div>
{opnError && (
<div style={{color:"var(--err)",fontSize:11,marginTop:10}}>{opnError}</div>
)}
</div>
</div>
{/* Peer list */}
<div className="panel">
<div className="ph"> VPN Peers ({peers.length})</div>
<div className="pb">
{peers.length === 0 && (
<div className="empty" style={{padding:"12px 0"}}>No peers yet. Add one below.</div>
)}
{peers.map(p => (
<div key={p.uuid} style={{display:"flex",alignItems:"center",gap:10,
padding:"8px 0",borderBottom:"1px solid var(--b1)"}}>
<div style={{width:8,height:8,borderRadius:"50%",flexShrink:0,
background:p.enabled?"var(--ok)":"var(--dm)"}}/>
<div style={{flex:1}}>
<div style={{fontWeight:700,marginBottom:3}}>{p.name}</div>
<div style={{fontFamily:"var(--mono)",fontSize:10,color:"var(--dm)"}}>
{p.tunnel_ip}
</div>
<div style={{display:"flex",flexWrap:"wrap",gap:4,marginTop:4}}>
{(p.allowed_vlans||[]).map(vid => {
const v = vlans.find(x=>x.id===vid)||{name:`VLAN ${vid}`,color:"var(--dm)"};
return (
<span key={vid} style={{
background:`${v.color}22`,color:v.color,
borderRadius:10,padding:"1px 7px",fontSize:10,
fontFamily:"var(--mono)",fontWeight:700,
}}>VLAN {vid} · {v.name}</span>
);
})}
</div>
</div>
<button className="btn bg" style={{fontSize:10,padding:"3px 8px"}}
onClick={()=>onShowConf(p.name)}>
.conf / QR
</button>
<button className="btn bd" style={{fontSize:10,padding:"3px 8px"}}
onClick={()=>onRevokePeer(p.uuid, p.name)}>
Revoke
</button>
</div>
))}
{/* Add peer form */}
<div style={{marginTop:16,paddingTop:14,borderTop:"1px solid var(--b1)"}}>
<div style={{fontWeight:700,marginBottom:10,fontSize:12}}>Add Peer</div>
<div style={{display:"flex",gap:10,alignItems:"flex-start",flexWrap:"wrap"}}>
<div className="field" style={{margin:0,flex:"0 0 180px"}}>
<label>Peer Name</label>
<input value={opnPeerName}
onChange={e=>setOpnPeerName(e.target.value)}
onKeyDown={e=>e.key==="Enter"&&onAddPeer()}
placeholder="e.g. phone, laptop"/>
</div>
<div style={{flex:1,minWidth:220}}>
<label style={{display:"block",fontSize:11,color:"var(--dm)",
marginBottom:6,fontFamily:"var(--mono)"}}>VLAN Access</label>
<div style={{display:"flex",flexWrap:"wrap",gap:6}}>
{vlans.filter(v=>v.id!==99).map(v => {
const on = opnVlans.includes(v.id);
return (
<div key={v.id} onClick={()=>onToggleVlan(v.id)}
style={{
cursor:"pointer",userSelect:"none",borderRadius:5,
padding:"5px 10px",fontSize:11,fontFamily:"var(--mono)",
border:`1px solid ${on?v.color:"var(--b2)"}`,
background:on?`${v.color}22`:"transparent",
color:on?v.color:"var(--dm)",fontWeight:on?700:400,
transition:"all .15s",
}}>
{on?"✓ ":""}{v.id} · {v.name}
</div>
);
})}
</div>
</div>
</div>
<div style={{marginTop:12,display:"flex",gap:8,alignItems:"center"}}>
{!session && (
<button className="btn bg" style={{fontSize:11,padding:"4px 10px"}}
onClick={onNeedAuth}>Authenticate</button>
)}
<button className="btn bp" onClick={onAddPeer}
disabled={opnAdding||!opnPeerName.trim()||opnVlans.length===0||!session}>
{opnAdding?"Adding…":"Add Peer"}
</button>
<span style={{fontSize:11,color:"var(--dm)"}}>
Generates keypair here · sends only pubkey to OPNsense
</span>
</div>
</div>
</div>
</div>
{/* Firewall rules guidance */}
{peers.length > 0 && (
<div className="panel">
<div className="ph"> Required OPNsense Firewall Rules</div>
<div className="pb">
<div style={{fontSize:12,color:"var(--dm)",lineHeight:1.7,marginBottom:10}}>
WireGuard handles the tunnel, but OPNsense still enforces firewall rules
between the tunnel and your VLANs. Add these rules under
<span style={{fontFamily:"var(--mono)",color:"var(--ac)",margin:"0 4px"}}>
Firewall Rules WireGuard
</span>
(the interface is <span style={{fontFamily:"var(--mono)",color:"var(--ac)"}}>wg1</span>
if this is instance 1):
</div>
<div style={{background:"#060809",borderRadius:4,padding:"10px 14px",
fontFamily:"var(--mono)",fontSize:11,color:"#a0b0c0",lineHeight:2}}>
{/* One rule per unique VLAN across all peers */}
{[...new Set(peers.flatMap(p=>p.allowed_vlans||[]))].sort((a,b)=>a-b).map(vid => {
const v = vlans.find(x=>x.id===vid)||{name:`VLAN ${vid}`};
return (
<div key={vid}>
<span style={{color:"#4fc"}}># Allow WireGuard VLAN {vid} ({v.name})</span><br/>
<span style={{color:"#ffa"}}>pass </span>
<span>in interface wg1</span><br/>
<span style={{paddingLeft:16}}>src: {tunnelNet}</span><br/>
<span style={{paddingLeft:16}}>dst: 192.168.{vid}.0/24</span><br/>
<br/>
</div>
);
})}
<span style={{color:"#4fc"}}># Block everything else from the tunnel</span><br/>
<span style={{color:"#f88"}}>block </span>
<span>in interface wg1 src: {tunnelNet} dst: any</span>
</div>
<div style={{fontSize:11,color:"var(--dm)",marginTop:8,lineHeight:1.7}}>
Also open UDP {server?.listen_port||51820} inbound on your WAN interface
so peers can reach OPNsense from the internet.
</div>
</div>
</div>
)}
</div> </div>
); );
} }
+410
View File
@@ -2369,3 +2369,413 @@ def save_local_hostnames(body: LocalHostnamesUpdate):
"then run: docker compose up -d dnsmasq" "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", "")}