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:
Claude
2026-03-23 15:04:51 +00:00
parent b1b2b91905
commit 8b4ed7f331
3 changed files with 202 additions and 5 deletions
+2 -2
View File
File diff suppressed because one or more lines are too long
+144 -3
View File
@@ -1906,6 +1906,131 @@ function WireGuardTab({ session, onNeedAuth, backendOk }) {
// DHCP MANAGEMENT TAB // 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 }) { function ConflictBadge({ conflict }) {
return ( return (
<div style={{ <div style={{
@@ -2035,6 +2160,10 @@ function DHCPRow({ res, source, onSync, session, onNeedAuth }) {
const sourceColor = source === "switch" ? "var(--ac)" : "var(--warn)"; const sourceColor = source === "switch" ? "var(--ac)" : "var(--warn)";
const sourceName = source === "switch" ? "Switch" : "OPNsense"; 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 ( return (
<tr style={{borderBottom:"1px solid var(--b1)"}}> <tr style={{borderBottom:"1px solid var(--b1)"}}>
<td style={{padding:"7px 10px",fontFamily:"var(--mono)",fontSize:11,color:"var(--dm)"}}> <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} {res.ip}
</td> </td>
<td style={{padding:"7px 10px",fontSize:12}}> <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>
<td style={{padding:"7px 10px"}}> <td style={{padding:"7px 10px"}}>
<span style={{ <span style={{
@@ -2052,7 +2185,6 @@ function DHCPRow({ res, source, onSync, session, onNeedAuth }) {
borderRadius: 10, padding: "1px 7px", fontSize: 10, borderRadius: 10, padding: "1px 7px", fontSize: 10,
fontFamily: "var(--mono)", fontWeight: 700 fontFamily: "var(--mono)", fontWeight: 700
}}>{sourceName}</span> }}>{sourceName}</span>
{res.if && <span style={{fontSize:10,color:"var(--dm)",marginLeft:6}}>{res.if}</span>}
</td> </td>
<td style={{padding:"7px 10px"}}> <td style={{padding:"7px 10px"}}>
<div style={{display:"flex",gap:4}}> <div style={{display:"flex",gap:4}}>
@@ -2197,6 +2329,15 @@ function DHCPTab({ session, onNeedAuth, backendOk }) {
</div> </div>
</div> </div>
{/* Relay configuration */}
<RelayPanel
overview={overview}
opnsenseHost={ops?.host||""}
session={session}
onNeedAuth={onNeedAuth}
onRefresh={load}
/>
{/* Conflicts */} {/* Conflicts */}
{conflicts.length > 0 && ( {conflicts.length > 0 && (
<div className="panel"> <div className="panel">
@@ -2269,7 +2410,7 @@ function DHCPTab({ session, onNeedAuth, backendOk }) {
<table style={{width:"100%",borderCollapse:"collapse"}}> <table style={{width:"100%",borderCollapse:"collapse"}}>
<thead> <thead>
<tr style={{borderBottom:"1px solid var(--b1)"}}> <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, <th key={h} style={{textAlign:"left",padding:"6px 10px",fontSize:10,
letterSpacing:2,textTransform:"uppercase",color:"var(--dm)"}}> letterSpacing:2,textTransform:"uppercase",color:"var(--dm)"}}>
{h} {h}
+56
View File
@@ -195,6 +195,7 @@ _RE_DIR = re.compile(r'^(in|out)$')
_RE_PROTO = re.compile(r'^(ip|tcp|udp|icmp)$') _RE_PROTO = re.compile(r'^(ip|tcp|udp|icmp)$')
_RE_ACTION = re.compile(r'^(permit|deny)$') _RE_ACTION = re.compile(r'^(permit|deny)$')
_RE_DESC = re.compile(r'^[a-zA-Z0-9\-_ ]{0,64}$') _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: def _san(v: str, pat: re.Pattern, field: str) -> str:
"""Reject shell-injection characters and check value against an allow-list regex.""" """Reject shell-injection characters and check value against an allow-list regex."""
@@ -1345,6 +1346,32 @@ def _get_switch_dhcp_status() -> dict:
except Exception: except Exception:
return {"running": False, "vlans": []} 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: def _find_conflicts(switch_res: list, opnsense_res: list) -> list:
""" """
Find same MAC in both switch and OPNsense. Find same MAC in both switch and OPNsense.
@@ -1386,6 +1413,14 @@ class SyncRequest(BaseModel):
mac: str mac: str
direction: str # "to_switch" | "to_opnsense" | "remove_switch" | "remove_opnsense" 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 ───────────────────────────────────────────────────────── # ── DHCP endpoints ─────────────────────────────────────────────────────────
@app.get("/api/dhcp/overview") @app.get("/api/dhcp/overview")
@@ -1421,6 +1456,7 @@ def dhcp_overview():
opnsense_res = _get_opnsense_reservations(cfg) if cfg.get("key") else [] opnsense_res = _get_opnsense_reservations(cfg) if cfg.get("key") else []
opnsense_leases = _get_opnsense_leases(cfg) if cfg.get("key") else [] opnsense_leases = _get_opnsense_leases(cfg) if cfg.get("key") else []
conflicts = _find_conflicts(switch_res, opnsense_res) conflicts = _find_conflicts(switch_res, opnsense_res)
relay_status = _get_relay_status()
# Which VLANs have switch DHCP vs OPNsense # Which VLANs have switch DHCP vs OPNsense
# Switch DHCP VLANs from status, OPNsense VLANs inferred from interface names # Switch DHCP VLANs from status, OPNsense VLANs inferred from interface names
@@ -1439,6 +1475,7 @@ def dhcp_overview():
"leases": opnsense_leases, "leases": opnsense_leases,
"interfaces": opnsense_ifaces, "interfaces": opnsense_ifaces,
}, },
"relay": relay_status,
"conflicts": conflicts, "conflicts": conflicts,
"has_conflicts": len(conflicts) > 0, "has_conflicts": len(conflicts) > 0,
} }
@@ -1568,6 +1605,25 @@ def sync_reservation(body: SyncRequest):
raise HTTPException(400, f"Unknown direction: {body.direction}") 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 # CONTROL D / ctrld DNS MANAGEMENT
# ══════════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════════