Merge pull request #2 from outis1one/claude/avaya-switch-manager-DxZs5

Claude/avaya switch manager dx zs5
This commit is contained in:
Outis
2026-03-23 15:06:37 -04:00
committed by GitHub
3 changed files with 1784 additions and 98 deletions
+248 -67
View File
File diff suppressed because one or more lines are too long
+1063 -30
View File
File diff suppressed because it is too large Load Diff
+473 -1
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."""
@@ -549,6 +550,7 @@ class AclRule(BaseModel):
dst_mask: Optional[str] = "0.0.0.255"
dst_any: Optional[bool] = True
port: Optional[str] = ""
port_end: Optional[str] = "" # when set, generates "range port port_end"
@field_validator("action")
@classmethod
def ca(cls, v): return _san(v, _RE_ACTION, "action")
@@ -639,7 +641,12 @@ def build_acl(acl: AclCreate) -> list[str]:
for i, r in enumerate(acl.rules):
src = "any" if r.src_any else f"{r.src} {r.src_mask}"
dst = "any" if r.dst_any else f"{r.dst} {r.dst_mask}"
port_str = f" eq {r.port}" if r.port else ""
if r.port and r.port_end:
port_str = f" range {r.port} {r.port_end}"
elif r.port:
port_str = f" eq {r.port}"
else:
port_str = ""
cmds.append(f" {i+1} {r.action} {r.proto} {src} {dst}{port_str}")
vid = san_vid(acl.apply_vlan, "apply_vlan")
cmds += [f"interface vlan {vid}",
@@ -1345,6 +1352,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 +1419,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 +1462,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 +1481,7 @@ def dhcp_overview():
"leases": opnsense_leases,
"interfaces": opnsense_ifaces,
},
"relay": relay_status,
"conflicts": conflicts,
"has_conflicts": len(conflicts) > 0,
}
@@ -1568,6 +1611,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
# ══════════════════════════════════════════════════════════════════════
@@ -2313,3 +2375,413 @@ def save_local_hostnames(body: LocalHostnamesUpdate):
"then run: docker compose up -d dnsmasq"
),
}
# ══════════════════════════════════════════════════════════════════════
# OPNSENSE WIREGUARD — ROUTER-LEVEL VPN WITH PER-VLAN ACCESS CONTROL
# ══════════════════════════════════════════════════════════════════════
#
# Moves WireGuard from the management computer onto OPNsense so any
# device on any VLAN can VPN home without touching the management PC.
# Each peer is granted access only to the VLANs you choose.
#
# Architecture:
# OPNsense wg1 interface (10.99.2.0/24 — separate from local wg0)
# Peer Alice → tunnel IP 10.99.2.2 → allowed VLAN 10 + VLAN 20
# Peer Bob → tunnel IP 10.99.2.3 → allowed VLAN 10 only
# Private keys are generated here and stored only on the mgmt PC.
# OPNsense receives only the public key (standard WireGuard practice).
OPN_WG_FILE = _Path("/etc/switch-manager/opnsense_wg.json")
def _load_opnsense_wg() -> dict:
if OPN_WG_FILE.exists():
try:
return _json.loads(OPN_WG_FILE.read_text())
except Exception:
pass
return {}
def _save_opnsense_wg(cfg: dict):
OPN_WG_FILE.parent.mkdir(parents=True, exist_ok=True)
OPN_WG_FILE.write_text(_json.dumps(cfg, indent=2))
OPN_WG_FILE.chmod(0o600)
class OPNWGServerSetup(BaseModel):
token: str
server_name: str = "switch-mgmt-vpn"
listen_port: int = 51820
tunnel_subnet: str = "10.99.2.0/24"
public_endpoint: str = "" # public IP or DDNS hostname for client configs
class OPNWGAddPeer(BaseModel):
token: str
name: str
allowed_vlans: list # list of VLAN IDs: [10, 20, 30]
vlan_subnets: dict # {10: "192.168.10.0/24", 20: "192.168.20.0/24", ...}
@app.get("/api/opnsense/wireguard/status")
def opnsense_wg_status():
"""Check OPNsense WireGuard plugin, server, and peer state."""
opn_cfg = _load_opnsense_cfg()
if not opn_cfg:
return {"opnsense_configured": False}
wg = _load_opnsense_wg()
# Probe for the WireGuard plugin — a 404 means the plugin isn't installed
try:
_opnsense_request(opn_cfg, "wireguard/server/searchServer")
plugin_ok = True
except ValueError as e:
msg = str(e)
# 404 → plugin absent; other errors → reachability / auth issue
plugin_ok = False
return {
"opnsense_configured": True,
"plugin_installed": False,
"error": msg,
"server": None,
"peers": [],
}
# If we have a saved server UUID, fetch live info
server_info = None
if wg.get("server_uuid"):
try:
s = _opnsense_request(opn_cfg, f"wireguard/server/getServer/{wg['server_uuid']}")
srv = s.get("server", {})
server_info = {
"uuid": wg["server_uuid"],
"name": srv.get("name", wg.get("server_name","")),
"pubkey": srv.get("pubkey", wg.get("server_pubkey","")),
"tunnel_ip": wg.get("server_tunnel_ip",""),
"listen_port": wg.get("listen_port", 51820),
"public_endpoint": wg.get("public_endpoint",""),
}
except Exception:
# Server UUID no longer valid (e.g. OPNsense was reset)
server_info = None
# Merge OPNsense peer list with local metadata (which holds allowed_vlans)
local_peers = {p["name"]: p for p in wg.get("peers", [])}
opn_peers = []
try:
resp = _opnsense_request(opn_cfg, "wireguard/client/searchClient")
opn_peers = resp.get("rows", [])
except Exception:
pass
merged = []
for p in opn_peers:
name = p.get("name", "")
loc = local_peers.get(name, {})
merged.append({
"uuid": p.get("uuid", ""),
"name": name,
"enabled": p.get("enabled", "0") == "1",
"tunnel_ip": p.get("tunneladdress", ""),
"allowed_vlans": loc.get("allowed_vlans", []),
})
return {
"opnsense_configured": True,
"plugin_installed": plugin_ok,
"server": server_info,
"peers": merged,
}
@app.post("/api/opnsense/wireguard/setup-server")
def opnsense_wg_setup_server(body: OPNWGServerSetup):
"""Create (or replace) a WireGuard server on OPNsense via its API."""
require_session(body.token)
opn_cfg = _load_opnsense_cfg()
if not opn_cfg:
raise HTTPException(400, "OPNsense not configured — connect it in the DHCP tab first")
import ipaddress as _ip, time as _time
try:
net = _ip.ip_network(body.tunnel_subnet, strict=False)
except Exception:
raise HTTPException(400, "Invalid tunnel_subnet — use CIDR notation e.g. 10.99.2.0/24")
server_tunnel_ip = f"{list(net.hosts())[0]}/{net.prefixlen}"
wg = _load_opnsense_wg()
# Tear down any pre-existing server so we start clean
if wg.get("server_uuid"):
try:
_opnsense_request(opn_cfg,
f"wireguard/server/delServer/{wg['server_uuid']}", method="POST")
except Exception:
pass
payload = {
"server": {
"enabled": "1",
"name": body.server_name,
"instance": "1", # creates wg1 — leaves wg0 (local) untouched
"port": str(body.listen_port),
"tunneladdress": server_tunnel_ip,
"dns": "",
"peers": "",
}
}
try:
result = _opnsense_request(opn_cfg, "wireguard/server/addServer",
method="POST", body=payload)
except Exception as e:
raise HTTPException(500, f"OPNsense rejected server creation: {e}")
server_uuid = result.get("uuid","")
if not server_uuid:
raise HTTPException(500, "OPNsense did not return a server UUID")
# Apply so OPNsense generates the keypair, then read it back
try:
_opnsense_request(opn_cfg, "wireguard/service/reconfigure", method="POST")
except Exception:
pass
_time.sleep(1.5) # give the daemon a moment to generate keys
server_pubkey = ""
try:
s = _opnsense_request(opn_cfg, f"wireguard/server/getServer/{server_uuid}")
server_pubkey = s.get("server", {}).get("pubkey", "")
except Exception:
pass
wg = {
"server_uuid": server_uuid,
"server_name": body.server_name,
"listen_port": body.listen_port,
"tunnel_subnet": body.tunnel_subnet,
"server_tunnel_ip": server_tunnel_ip,
"server_pubkey": server_pubkey,
"public_endpoint": body.public_endpoint,
"peers": [],
}
_save_opnsense_wg(wg)
log.info(f"OPNsense WG server created: {body.server_name} uuid={server_uuid}")
return {
"success": True,
"server_uuid": server_uuid,
"server_pubkey": server_pubkey,
"server_tunnel_ip": server_tunnel_ip,
"listen_port": body.listen_port,
}
@app.delete("/api/opnsense/wireguard/server")
def opnsense_wg_delete_server(token: str):
"""Remove the WireGuard server from OPNsense and clear local state."""
require_session(token)
opn_cfg = _load_opnsense_cfg()
if not opn_cfg:
raise HTTPException(400, "OPNsense not configured")
wg = _load_opnsense_wg()
if not wg.get("server_uuid"):
raise HTTPException(404, "No server is configured")
try:
_opnsense_request(opn_cfg,
f"wireguard/server/delServer/{wg['server_uuid']}", method="POST")
_opnsense_request(opn_cfg, "wireguard/service/reconfigure", method="POST")
except Exception as e:
raise HTTPException(500, f"Failed to delete server: {e}")
_save_opnsense_wg({})
return {"success": True}
@app.post("/api/opnsense/wireguard/add-peer")
def opnsense_wg_add_peer(body: OPNWGAddPeer):
"""
Generate a WireGuard keypair, register the peer on OPNsense,
link it to the server, and return a ready-to-use .conf for the client.
The private key is stored only on the management PC (never sent to OPNsense).
"""
require_session(body.token)
opn_cfg = _load_opnsense_cfg()
if not opn_cfg:
raise HTTPException(400, "OPNsense not configured")
wg = _load_opnsense_wg()
if not wg.get("server_uuid"):
raise HTTPException(400, "Set up the WireGuard server on OPNsense first")
import ipaddress as _ip, re as _re
# ── Allocate next free IP in the tunnel subnet ────────────────────
net = _ip.ip_network(wg["tunnel_subnet"], strict=False)
hosts = list(net.hosts())
used = set()
# Reserve the server's own tunnel IP
m = _re.match(r'(\S+)/\d+', wg.get("server_tunnel_ip", ""))
if m:
used.add(m.group(1))
for p in wg.get("peers", []):
m2 = _re.match(r'(\S+)/\d+', p.get("tunnel_ip", ""))
if m2:
used.add(m2.group(1))
peer_ip_obj = next((h for h in hosts if str(h) not in used), None)
if not peer_ip_obj:
raise HTTPException(400, "Tunnel subnet is full — no IPs available for new peer")
peer_ip = f"{peer_ip_obj}/{net.prefixlen}"
# ── Build the AllowedIPs list from chosen VLANs ───────────────────
vlan_cidrs = []
for vid in body.allowed_vlans:
subnet = (body.vlan_subnets.get(str(vid))
or body.vlan_subnets.get(int(vid))
or f"192.168.{vid}.0/24")
vlan_cidrs.append(subnet)
# Always include the tunnel subnet so the client can reach the server
allowed_ips = ", ".join([str(net)] + vlan_cidrs) if vlan_cidrs else str(net)
# ── Generate keypair (private key stays on mgmt PC only) ─────────
c_priv, c_pub = _wg_genkey_api()
# ── Register peer (client) on OPNsense ───────────────────────────
peer_payload = {
"client": {
"enabled": "1",
"name": body.name,
"pubkey": c_pub,
"psk": "",
"tunneladdress": peer_ip,
"serveraddress": "",
"serverport": "",
"keepalive": "25",
}
}
try:
result = _opnsense_request(opn_cfg, "wireguard/client/addClient",
method="POST", body=peer_payload)
except Exception as e:
raise HTTPException(500, f"OPNsense rejected peer creation: {e}")
peer_uuid = result.get("uuid", "")
if not peer_uuid:
raise HTTPException(500, "OPNsense did not return a peer UUID")
# ── Link peer to server (append to server's peers list) ──────────
try:
s = _opnsense_request(opn_cfg,
f"wireguard/server/getServer/{wg['server_uuid']}")
srv = s.get("server", {})
existing = srv.get("peers", "")
new_peers = f"{existing},{peer_uuid}" if existing else peer_uuid
_opnsense_request(opn_cfg,
f"wireguard/server/setServer/{wg['server_uuid']}", method="POST",
body={"server": {**srv, "peers": new_peers}})
except Exception as e:
log.warning(f"Could not link peer to server (peer still registered): {e}")
# Apply config
try:
_opnsense_request(opn_cfg, "wireguard/service/reconfigure", method="POST")
except Exception:
pass
# ── Build client .conf ────────────────────────────────────────────
server_pubkey = wg.get("server_pubkey", "")
endpoint_host = wg.get("public_endpoint", "") or "<YOUR-OPNSENSE-PUBLIC-IP>"
endpoint_port = wg.get("listen_port", 51820)
tunnel_gw = wg.get("server_tunnel_ip", "").split("/")[0]
client_conf = (
f"[Interface]\n"
f"PrivateKey = {c_priv}\n"
f"Address = {peer_ip}\n"
f"DNS = {tunnel_gw}\n\n"
f"[Peer]\n"
f"PublicKey = {server_pubkey or '<SERVER_PUBKEY>'}\n"
f"Endpoint = {endpoint_host}:{endpoint_port}\n"
f"AllowedIPs = {allowed_ips}\n"
f"PersistentKeepalive = 25\n"
)
# ── Persist peer metadata locally ────────────────────────────────
peer_meta = {
"uuid": peer_uuid,
"name": body.name,
"pub_key": c_pub,
"priv_key": c_priv, # NEVER sent to OPNsense
"tunnel_ip": peer_ip,
"allowed_vlans": body.allowed_vlans,
"allowed_ips": allowed_ips,
"config": client_conf,
}
peers = [p for p in wg.get("peers", []) if p.get("name") != body.name]
peers.append(peer_meta)
wg["peers"] = peers
_save_opnsense_wg(wg)
log.info(f"OPNsense WG peer added: {body.name}{peer_ip} VLANs={body.allowed_vlans}")
return {
"success": True,
"uuid": peer_uuid,
"name": body.name,
"tunnel_ip": peer_ip,
"allowed_vlans": body.allowed_vlans,
"config": client_conf,
}
@app.delete("/api/opnsense/wireguard/peer/{uuid}")
def opnsense_wg_remove_peer(uuid: str, token: str):
"""Remove a peer from OPNsense and from local metadata."""
require_session(token)
opn_cfg = _load_opnsense_cfg()
if not opn_cfg:
raise HTTPException(400, "OPNsense not configured")
wg = _load_opnsense_wg()
# Remove from OPNsense
try:
_opnsense_request(opn_cfg,
f"wireguard/client/delClient/{uuid}", method="POST")
except Exception as e:
raise HTTPException(500, f"Failed to remove peer from OPNsense: {e}")
# Unlink from server peers list
if wg.get("server_uuid"):
try:
s = _opnsense_request(opn_cfg,
f"wireguard/server/getServer/{wg['server_uuid']}")
srv = s.get("server", {})
existing = srv.get("peers", "")
updated = ",".join(p for p in existing.split(",") if p and p != uuid)
_opnsense_request(opn_cfg,
f"wireguard/server/setServer/{wg['server_uuid']}", method="POST",
body={"server": {**srv, "peers": updated}})
except Exception:
pass
# Apply
try:
_opnsense_request(opn_cfg, "wireguard/service/reconfigure", method="POST")
except Exception:
pass
wg["peers"] = [p for p in wg.get("peers", []) if p.get("uuid") != uuid]
_save_opnsense_wg(wg)
return {"success": True}
@app.get("/api/opnsense/wireguard/peer-config/{name}")
def opnsense_wg_peer_config(name: str):
"""Return the saved .conf text for a named peer (includes private key)."""
wg = _load_opnsense_wg()
peer = next((p for p in wg.get("peers", []) if p["name"] == name), None)
if not peer:
raise HTTPException(404, f"Peer '{name}' not found in local store")
return {"name": name, "config": peer.get("config", "")}