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:
@@ -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