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
# ══════════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════════