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
|
// 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}
|
||||||
|
|||||||
@@ -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
|
||||||
# ══════════════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
Reference in New Issue
Block a user