Add DHCP relay config — all VLANs except 99 relay to OPNsense
- Backend: _get_relay_status() reads current ip helper-address per VLAN - Backend: _build_relay_cmds() generates ERS 5952 relay CLI commands - Backend: /api/dhcp/relay/status and /api/dhcp/relay/configure endpoints - Backend: dhcp_overview now includes relay status in response - Frontend: VLAN_MAP + VlanBadge + vlanFromIp() helpers for consistent labelling - Frontend: RelayPanel shows per-VLAN relay status grid with push button; VLAN 99 always shown as locked/local, VLANs 10/20/30/40/50 show live relay target and purpose note - Frontend: Reservations table gains VLAN column and inline purpose note (from descr/notes or VLAN_MAP fallback) VLAN 99 is excluded from relay at both backend and UI level — it is the switch management / OPNsense recovery path. https://claude.ai/code/session_01JR2EMK7rwrZJowpstcaxQ6
This commit is contained in:
+2
-2
File diff suppressed because one or more lines are too long
+144
-3
@@ -1906,6 +1906,131 @@ function WireGuardTab({ session, onNeedAuth, backendOk }) {
|
||||
// DHCP MANAGEMENT TAB
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// VLAN definitions — matches the network plan
|
||||
const VLAN_MAP = {
|
||||
99: { name:"Management", color:"var(--dm)", note:"Switch recovery — always local" },
|
||||
10: { name:"House", color:"var(--ok)", note:"Devices get hostname.lan via OPNsense DNS" },
|
||||
20: { name:"Servers", color:"var(--ac)", note:"Server names resolve via OPNsense Unbound" },
|
||||
30: { name:"IoT", color:"var(--warn)",note:"Isolated devices, predictable IPs for firewall rules" },
|
||||
40: { name:"Guest", color:"#b388ff", note:"Internet only — printer access via firewall rule" },
|
||||
50: { name:"Cameras", color:"#ff80ab", note:"NVR-only access, no internet" },
|
||||
};
|
||||
|
||||
// Infer VLAN from IP address third octet
|
||||
function vlanFromIp(ip) {
|
||||
if (!ip) return null;
|
||||
const third = parseInt(ip.split('.')[2], 10);
|
||||
return isNaN(third) ? null : third;
|
||||
}
|
||||
|
||||
function VlanBadge({ vlan }) {
|
||||
const info = VLAN_MAP[vlan];
|
||||
const color = info?.color || "var(--dm)";
|
||||
const label = info ? `VLAN ${vlan} · ${info.name}` : (vlan ? `VLAN ${vlan}` : "—");
|
||||
return (
|
||||
<span style={{
|
||||
background: `${color}18`, color, borderRadius:10,
|
||||
padding:"1px 7px", fontSize:10, fontFamily:"var(--mono)", fontWeight:700,
|
||||
whiteSpace:"nowrap",
|
||||
}}>{label}</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Relay panel — shows per-VLAN relay status and lets you push config
|
||||
function RelayPanel({ overview, opnsenseHost, session, onNeedAuth, onRefresh }) {
|
||||
const [pushing, setPushing] = useState(false);
|
||||
const [result, setResult] = useState(null);
|
||||
const [opIp, setOpIp] = useState(opnsenseHost || "");
|
||||
|
||||
useEffect(() => { if (opnsenseHost && !opIp) setOpIp(opnsenseHost); }, [opnsenseHost]);
|
||||
|
||||
const relay = overview?.relay || {};
|
||||
const relayMap = relay.vlans || {}; // { "10": "192.168.99.1", ... }
|
||||
|
||||
const relayVlans = [10, 20, 30, 40, 50];
|
||||
|
||||
const push = async () => {
|
||||
if (!session) { onNeedAuth(); return; }
|
||||
if (!opIp) return;
|
||||
setPushing(true); setResult(null);
|
||||
try {
|
||||
const r = await API("/dhcp/relay/configure", {
|
||||
method:"POST", body:{ token:session.token, opnsense_ip:opIp, vlans:relayVlans }
|
||||
});
|
||||
setResult(r);
|
||||
onRefresh();
|
||||
} catch(e) { setResult({ success:false, error:e.message }); }
|
||||
setPushing(false);
|
||||
};
|
||||
|
||||
const allConfigured = relayVlans.every(v => relayMap[v] === opIp && opIp);
|
||||
|
||||
return (
|
||||
<div className="panel">
|
||||
<div className="ph">◈ DHCP Relay — Switch → OPNsense</div>
|
||||
<div className="pb">
|
||||
<div style={{fontSize:12,color:"var(--dm)",lineHeight:1.7,marginBottom:14}}>
|
||||
The switch relays DHCP requests from each VLAN to OPNsense, which assigns
|
||||
IPs, records leases, and registers hostnames in Unbound DNS automatically.
|
||||
VLAN 99 always stays local — it's the recovery path if OPNsense is unreachable.
|
||||
</div>
|
||||
|
||||
{/* Per-VLAN status grid */}
|
||||
<div style={{display:"grid",gridTemplateColumns:"repeat(auto-fill,minmax(200px,1fr))",gap:8,marginBottom:14}}>
|
||||
|
||||
{/* VLAN 99 — always local */}
|
||||
<div style={{background:"var(--bg)",border:"1px solid var(--b2)",borderRadius:4,padding:"10px 12px"}}>
|
||||
<div style={{display:"flex",alignItems:"center",gap:6,marginBottom:4}}>
|
||||
<VlanBadge vlan={99}/>
|
||||
<span style={{fontSize:10,marginLeft:"auto",color:"var(--dm)"}}>🔒 local</span>
|
||||
</div>
|
||||
<div style={{fontSize:10,color:"var(--dm)",lineHeight:1.5}}>{VLAN_MAP[99].note}</div>
|
||||
</div>
|
||||
|
||||
{relayVlans.map(vid => {
|
||||
const configured = relayMap[vid];
|
||||
const isOk = configured && configured === opIp;
|
||||
const stale = configured && configured !== opIp;
|
||||
return (
|
||||
<div key={vid} style={{background:"var(--bg)",border:`1px solid ${isOk?"rgba(0,230,118,.3)":stale?"rgba(255,234,0,.3)":"var(--b2)"}`,borderRadius:4,padding:"10px 12px"}}>
|
||||
<div style={{display:"flex",alignItems:"center",gap:6,marginBottom:4}}>
|
||||
<VlanBadge vlan={vid}/>
|
||||
<span style={{fontSize:10,marginLeft:"auto",
|
||||
color: isOk?"var(--ok)":stale?"var(--warn)":"var(--dm)"}}>
|
||||
{isOk ? `→ ${configured}` : stale ? `→ ${configured} ⚠` : "not set"}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{fontSize:10,color:"var(--dm)",lineHeight:1.5}}>{VLAN_MAP[vid]?.note}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Push controls */}
|
||||
<div style={{display:"flex",gap:8,alignItems:"flex-end",flexWrap:"wrap"}}>
|
||||
<div className="field" style={{margin:0}}>
|
||||
<label>OPNsense IP</label>
|
||||
<input value={opIp} onChange={e=>setOpIp(e.target.value)}
|
||||
placeholder="192.168.99.1"
|
||||
style={{fontFamily:"var(--mono)",maxWidth:160}}/>
|
||||
</div>
|
||||
<button className="btn bp" onClick={push}
|
||||
disabled={pushing||!opIp||!session}
|
||||
style={{fontSize:11}}>
|
||||
{pushing ? "Pushing…" : allConfigured ? "✓ Re-apply Relay Config" : "Push Relay Config to Switch"}
|
||||
</button>
|
||||
</div>
|
||||
{result && (
|
||||
<div style={{marginTop:8,fontSize:11,
|
||||
color:result.success?"var(--ok)":"var(--err)"}}>
|
||||
{result.success ? "✓ Relay config pushed — save running-config to make permanent" : `✗ ${result.error||"Push failed"}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConflictBadge({ conflict }) {
|
||||
return (
|
||||
<div style={{
|
||||
@@ -2035,6 +2160,10 @@ function DHCPRow({ res, source, onSync, session, onNeedAuth }) {
|
||||
const sourceColor = source === "switch" ? "var(--ac)" : "var(--warn)";
|
||||
const sourceName = source === "switch" ? "Switch" : "OPNsense";
|
||||
|
||||
// Derive VLAN: prefer explicit vlan field, fall back to IP third octet
|
||||
const vid = res.vlan || vlanFromIp(res.ip);
|
||||
const purpose = res.descr || res.notes || VLAN_MAP[vid]?.note || "";
|
||||
|
||||
return (
|
||||
<tr style={{borderBottom:"1px solid var(--b1)"}}>
|
||||
<td style={{padding:"7px 10px",fontFamily:"var(--mono)",fontSize:11,color:"var(--dm)"}}>
|
||||
@@ -2044,7 +2173,11 @@ function DHCPRow({ res, source, onSync, session, onNeedAuth }) {
|
||||
{res.ip}
|
||||
</td>
|
||||
<td style={{padding:"7px 10px",fontSize:12}}>
|
||||
{res.hostname || res.descr || <span style={{color:"var(--dm)"}}>—</span>}
|
||||
{res.hostname || res.name || <span style={{color:"var(--dm)"}}>—</span>}
|
||||
{purpose && <div style={{fontSize:10,color:"var(--dm)",marginTop:2}}>{purpose}</div>}
|
||||
</td>
|
||||
<td style={{padding:"7px 10px"}}>
|
||||
{vid ? <VlanBadge vlan={vid}/> : <span style={{color:"var(--dm)",fontSize:10}}>—</span>}
|
||||
</td>
|
||||
<td style={{padding:"7px 10px"}}>
|
||||
<span style={{
|
||||
@@ -2052,7 +2185,6 @@ function DHCPRow({ res, source, onSync, session, onNeedAuth }) {
|
||||
borderRadius: 10, padding: "1px 7px", fontSize: 10,
|
||||
fontFamily: "var(--mono)", fontWeight: 700
|
||||
}}>{sourceName}</span>
|
||||
{res.if && <span style={{fontSize:10,color:"var(--dm)",marginLeft:6}}>{res.if}</span>}
|
||||
</td>
|
||||
<td style={{padding:"7px 10px"}}>
|
||||
<div style={{display:"flex",gap:4}}>
|
||||
@@ -2197,6 +2329,15 @@ function DHCPTab({ session, onNeedAuth, backendOk }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Relay configuration */}
|
||||
<RelayPanel
|
||||
overview={overview}
|
||||
opnsenseHost={ops?.host||""}
|
||||
session={session}
|
||||
onNeedAuth={onNeedAuth}
|
||||
onRefresh={load}
|
||||
/>
|
||||
|
||||
{/* Conflicts */}
|
||||
{conflicts.length > 0 && (
|
||||
<div className="panel">
|
||||
@@ -2269,7 +2410,7 @@ function DHCPTab({ session, onNeedAuth, backendOk }) {
|
||||
<table style={{width:"100%",borderCollapse:"collapse"}}>
|
||||
<thead>
|
||||
<tr style={{borderBottom:"1px solid var(--b1)"}}>
|
||||
{["MAC","IP","Name / Description","Source","Actions"].map(h=>(
|
||||
{["MAC","IP","Name / Description","VLAN","Source","Actions"].map(h=>(
|
||||
<th key={h} style={{textAlign:"left",padding:"6px 10px",fontSize:10,
|
||||
letterSpacing:2,textTransform:"uppercase",color:"var(--dm)"}}>
|
||||
{h}
|
||||
|
||||
@@ -195,6 +195,7 @@ _RE_DIR = re.compile(r'^(in|out)$')
|
||||
_RE_PROTO = re.compile(r'^(ip|tcp|udp|icmp)$')
|
||||
_RE_ACTION = re.compile(r'^(permit|deny)$')
|
||||
_RE_DESC = re.compile(r'^[a-zA-Z0-9\-_ ]{0,64}$')
|
||||
_RE_IP = re.compile(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$')
|
||||
|
||||
def _san(v: str, pat: re.Pattern, field: str) -> str:
|
||||
"""Reject shell-injection characters and check value against an allow-list regex."""
|
||||
@@ -1345,6 +1346,32 @@ def _get_switch_dhcp_status() -> dict:
|
||||
except Exception:
|
||||
return {"running": False, "vlans": []}
|
||||
|
||||
def _get_relay_status() -> dict:
|
||||
"""Read current DHCP relay (ip helper-address) config from each VLAN interface."""
|
||||
import re as _re
|
||||
try:
|
||||
raw = read_cmd("show ip helper-address")
|
||||
configured = {}
|
||||
for line in raw.splitlines():
|
||||
# Typical output: " 10 192.168.99.1"
|
||||
m = _re.match(r'\s*(\d+)\s+(\d+\.\d+\.\d+\.\d+)', line)
|
||||
if m:
|
||||
configured[int(m.group(1))] = m.group(2)
|
||||
return {"vlans": configured, "ok": True}
|
||||
except Exception as e:
|
||||
log.warning(f"Relay status fetch failed: {e}")
|
||||
return {"vlans": {}, "ok": False}
|
||||
|
||||
def _build_relay_cmds(opnsense_ip: str, vlan_ids: list) -> list:
|
||||
"""Generate ERS 5952 CLI to set ip helper-address on the specified VLANs."""
|
||||
cmds = []
|
||||
for vid in vlan_ids:
|
||||
cmds += [
|
||||
f"interface vlan {vid}",
|
||||
f" ip helper-address {opnsense_ip}",
|
||||
]
|
||||
return cmds
|
||||
|
||||
def _find_conflicts(switch_res: list, opnsense_res: list) -> list:
|
||||
"""
|
||||
Find same MAC in both switch and OPNsense.
|
||||
@@ -1386,6 +1413,14 @@ class SyncRequest(BaseModel):
|
||||
mac: str
|
||||
direction: str # "to_switch" | "to_opnsense" | "remove_switch" | "remove_opnsense"
|
||||
|
||||
class RelayConfig(BaseModel):
|
||||
token: str
|
||||
opnsense_ip: str
|
||||
vlans: list = [10, 20, 30, 40, 50] # VLANs to relay; 99 is always local
|
||||
@field_validator("opnsense_ip")
|
||||
@classmethod
|
||||
def cip(cls, v): return _san(v, _RE_IP, "opnsense_ip")
|
||||
|
||||
# ── DHCP endpoints ─────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/dhcp/overview")
|
||||
@@ -1421,6 +1456,7 @@ def dhcp_overview():
|
||||
opnsense_res = _get_opnsense_reservations(cfg) if cfg.get("key") else []
|
||||
opnsense_leases = _get_opnsense_leases(cfg) if cfg.get("key") else []
|
||||
conflicts = _find_conflicts(switch_res, opnsense_res)
|
||||
relay_status = _get_relay_status()
|
||||
|
||||
# Which VLANs have switch DHCP vs OPNsense
|
||||
# Switch DHCP VLANs from status, OPNsense VLANs inferred from interface names
|
||||
@@ -1439,6 +1475,7 @@ def dhcp_overview():
|
||||
"leases": opnsense_leases,
|
||||
"interfaces": opnsense_ifaces,
|
||||
},
|
||||
"relay": relay_status,
|
||||
"conflicts": conflicts,
|
||||
"has_conflicts": len(conflicts) > 0,
|
||||
}
|
||||
@@ -1568,6 +1605,25 @@ def sync_reservation(body: SyncRequest):
|
||||
|
||||
raise HTTPException(400, f"Unknown direction: {body.direction}")
|
||||
|
||||
@app.get("/api/dhcp/relay/status")
|
||||
def relay_status_endpoint():
|
||||
"""Return current ip helper-address config from the switch per VLAN."""
|
||||
return _get_relay_status()
|
||||
|
||||
@app.post("/api/dhcp/relay/configure")
|
||||
def configure_relay(body: RelayConfig):
|
||||
"""
|
||||
Push ip helper-address to each non-management VLAN so the switch relays
|
||||
DHCP requests to OPNsense. VLAN 99 is never relayed — it stays local
|
||||
as the management / recovery path.
|
||||
"""
|
||||
require_session(body.token)
|
||||
safe_vlans = [int(v) for v in body.vlans if int(v) != 99]
|
||||
if not safe_vlans:
|
||||
raise HTTPException(400, "No VLANs to configure (VLAN 99 is excluded)")
|
||||
cmds = _build_relay_cmds(body.opnsense_ip, safe_vlans)
|
||||
return push_one_by_one(cmds)
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
# CONTROL D / ctrld DNS MANAGEMENT
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user