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
+56
View File
@@ -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
# ══════════════════════════════════════════════════════════════════════