Files
2026-03-23 08:52:01 -04:00

15 KiB

Avaya ERS 5952 Switch Manager — Project Handoff

What This Is

A browser-based management interface for the Avaya / Extreme Networks ERS 5952 enterprise switch. The switch has no REST API — everything works via SSH screen-scraping: the backend opens SSH sessions, sends CLI commands one at a time, checks each response for errors, and stops on first failure. Config is only saved to NVRAM if every command succeeds.

The entire project ships as a single Python file: Avaya_5952_setup.py This file contains three embedded Python string constants:

  • BACKEND_SRC — the FastAPI backend (Python, ~1800 lines)
  • JSX_SRC — the React frontend (JSX, ~2600 lines)
  • README_SRC — the README (Markdown, ~400 lines)

Running python Avaya_5952_setup.py extracts and runs 21 setup steps that install everything, configure SSH keys, set up TOTP, build the React app, install Docker + Caddy or native systemd, set up WireGuard VPN, and optionally install Control D DNS filtering via ctrld.

The attached file IS the project. Everything is in it.


Architecture

Browser (any device on VLAN 99 or WireGuard VPN)
    ↓ https://switch.mgmt.lan  (Caddy) or http://IP:8765
FastAPI backend (Python, runs on management computer)
    ↓ ed25519 SSH key (never leaves management computer)
ERS 5952 switch (192.168.99.1)

Management computer = Raspberry Pi, HP T620 thin client, or any always-on Linux box. Runs Docker (recommended) with Caddy for HTTPS, or native Python with systemd.


Current State — What Is Built and Working

Setup script (21 steps)

  1. Writes project files from embedded strings
  2. Installs system packages (picocom, qrencode, wireguard-tools)
  3. Adds user to dialout group (serial port access)
  4. Installs Python dependencies (fastapi, uvicorn, paramiko, pyotp)
  5. Creates /etc/switch-manager/ config directory
  6. Interactive static IP setter (detects interfaces, netplan or interfaces file)
  7. Docker vs native Python choice — explained before asking, Docker is default
  8. Console cable guidance (OS-specific, software download links)
  9. SSH keypair generation (ed25519)
  10. Loads public key onto switch (console step — only manual step)
  11. SSH connectivity test with retry
  12. Pins switch host key (MITM protection)
  13. TOTP setup (qrencode or Python qrcode, terminal QR display, verification)
  14. Patches backend config with switch IP/port/user
  15. Builds React frontend (Vite)
  16. Docker setup (official installer, docker group, compose plugin, Dockerfile, docker-compose.yml with Caddy, Caddyfile, CA cert installation) OR native systemd service 17-19. Start service
  17. WireGuard VPN (local server fully automated, OPNsense semi-automated, per-client QR codes and .conf files, split tunnel)
  18. Control D ctrld DNS filtering (all 3 options with explanations)

UI — 8 tabs

  • Port Map — visual 48+4 chassis, colour-coded by VLAN, live status, click-to-configure panel (mode/VLAN/PoE), connection banner
  • VLANs — create/rename/delete, port counts
  • ACL Builder — visual rule builder (action/proto/src/dst/port), assigned to VLAN interface with direction
  • Review & Push — annotated CLI table (every command with plain-English explanation), batch or step-by-step push, danger check, TOTP gate, per-command results with switch error output
  • Device Access — MAC randomization warnings (iOS/Android/macOS/Windows), DHCP reservation push, ACL pinhole push, live DHCP lease discovery
  • DHCP — unified switch + OPNsense view, conflict detection (same MAC in both places), one-click sync in 4 directions, OPNsense API integration
  • DNS Filtering — Control D ctrld setup, all 3 options with full explanations, per-VLAN Resolver IDs, ctrld.toml generation, DoH3 upstreams, source-IP-based VLAN routing
  • VPN — WireGuard peer management, add clients (QR + .conf), revoke, live peer status, SSH tunnel documentation

Security model

  • VLAN 99 isolation — management network unreachable from other VLANs
  • ed25519 SSH key on management computer only — never transmitted
  • TOTP gates every push session — one code, one push batch, auto-locks
  • Hard-blocked commands (removing VLAN 99, disabling SSH) refused entirely
  • Warning-level commands shown for review before push
  • Per-command execution — stops on first switch error
  • Config saved to NVRAM only on full success
  • Connection pool (25s lifetime) — respects switch idle timeout

API — 34 endpoints

Auth: /api/auth/verify, /api/auth/check, /api/auth/revoke Switch: /api/switch/push, /api/switch/port, /api/switch/vlan (GET/POST/DELETE), /api/switch/acl, /api/switch/config Live: /api/status, /api/live, /api/heartbeat, /api/check/danger Devices: /api/devices, /api/devices/save, /api/devices/delete, /api/devices/push-reservation, /api/devices/push-pinhole DHCP: /api/dhcp/overview, /api/dhcp/detect-opnsense, /api/dhcp/configure-opnsense (POST/DELETE), /api/dhcp/push-to-opnsense, /api/dhcp/sync WireGuard: /api/wireguard/status, /api/wireguard/clients, /api/wireguard/add-client, /api/wireguard/revoke-client, /api/wireguard/client-qr/{name} ctrld: /api/ctrld/status, /api/ctrld/toml-preview, /api/ctrld/save-config, /api/ctrld/update-profiles, /api/ctrld/uninstall


What Still Needs to Be Built

1. Port 53 conflict resolution (IMPORTANT — blocks ctrld from working)

On Linux (Ubuntu/Debian), systemd-resolved holds port 53. Fix: disable the stub listener, not the service itself.

# Add to /etc/systemd/resolved.conf
[Resolve]
DNSStubListener=no
# Then: sudo systemctl restart systemd-resolved

The setup script should detect this and fix it automatically before starting ctrld (Option A — local install). Add to _ctrld_install_local_setup() in the logic section.

On OPNsense (FreeBSD), Unbound holds port 53. The RIGHT approach (Option C in ctrld terms, not our Option C):

  • Keep Unbound running — it handles .lan hostnames and local DNS
  • Move Unbound to listen on 127.0.0.1:5353 instead of :53
  • Run ctrld on :53
  • Configure ctrld to forward *.lan, *.local queries to 127.0.0.1:5353
  • ctrld handles all other queries via DoH3 to Control D

The ctrld.toml for this split-horizon setup:

[service]
name = "ctrld"

[[listener]]
ip = "0.0.0.0"
port = 53
tag = "all-vlans"

# Forward internal names to local Unbound
[[upstream]]
id = "local"
type = "doh"  # actually plain DNS to local resolver
endpoint = "sdns://AAAA..."  # or just use type="legacy" with bootstrap
tag = "local"

# Per-VLAN upstreams
[[upstream]]
id = "vlan10"
type = "doh3"
endpoint = "https://dns.controld.com/RESOLVER_ID_VLAN10"
tag = "vlan10"

# ... etc per VLAN ...

# Route internal domains to local resolver
[[rule]]
listener = "all-vlans"
match_domains = ["lan", "local", "mgmt.lan"]
upstream = "local"

# Route each VLAN to its profile
[[rule]]
listener = "all-vlans"
source_ip = "192.168.10.0/24"
upstream = "vlan10"

The DNS tab and setup script need to: a) Detect systemd-resolved conflict and fix it (Linux) b) Show OPNsense-specific instructions for moving Unbound to port 5353 c) Offer to add local hostname forwarding rules to ctrld.toml d) Ask for the local domain (default: lan) to forward internally

2. DNS enforcement ACLs (IMPORTANT — without this ctrld is bypassable)

Devices can ignore DHCP-assigned DNS and use 8.8.8.8 directly. The 5952 must block this via ACLs on each VLAN interface.

For each VLAN (except VLAN 99):

ip access-list extended DNS-ENFORCE-VLAN10
  permit udp 192.168.10.0 0.0.0.255 host [ctrld-ip] eq 53
  permit tcp 192.168.10.0 0.0.0.255 host [ctrld-ip] eq 53
  deny   udp 192.168.10.0 0.0.0.255 any eq 53
  deny   tcp 192.168.10.0 0.0.0.255 any eq 53
  deny   tcp 192.168.10.0 0.0.0.255 any eq 853
  permit ip any any
interface vlan 10
  ip access-group DNS-ENFORCE-VLAN10 in

The DNS tab should have a "Enforce DNS on Switch" button that:

  • Takes the ctrld IP (from status or manual entry)
  • Generates these ACLs for all configured VLANs
  • Shows them for review
  • Pushes via existing TOTP-gated push mechanism

3. Inter-VLAN routing ACL templates

Pre-built templates in the ACL Builder tab. Currently the ACL builder is blank — user builds rules manually. Add a "Templates" section:

Templates to build (parameterised by VLAN subnets):

Staff VLAN (full access except management):

deny ip [staff-subnet] 192.168.99.0/24  ← no management access
permit ip any any                         ← everything else allowed

IoT VLAN (internet only):

deny ip [iot-subnet] 192.168.0.0/16     ← no RFC1918
deny ip [iot-subnet] 10.0.0.0/8
deny ip [iot-subnet] 172.16.0.0/12
permit ip any any                        ← internet allowed

Guest VLAN (internet only, stricter):

permit udp [guest-subnet] host [ctrld-ip] eq 53  ← DNS must work first
deny ip [guest-subnet] 192.168.0.0/16
deny ip [guest-subnet] 10.0.0.0/8
deny ip [guest-subnet] 172.16.0.0/12
permit ip any any

Camera VLAN (NVR only):

permit ip [camera-subnet] host [nvr-ip]   ← only NVR
deny ip [camera-subnet] any               ← nothing else

DNS enforcement (per VLAN, as above)

The UI should show a "Templates" dropdown in the ACL Builder that pre-fills the rule table for common patterns, then lets the user adjust IPs before pushing.

4. Local hostname resolution (optional but useful)

Add dnsmasq as an optional Docker service for .lan resolution. Minimal config:

# docker-compose.yml addition
  dnsmasq:
    image: andyshinn/dnsmasq:latest
    ports:
      - "5353:53/udp"
      - "5353:53/tcp"
    volumes:
      - ./dnsmasq.conf:/etc/dnsmasq.conf
    restart: unless-stopped
    cap_add:
      - NET_ADMIN
# dnsmasq.conf (generated by setup)
address=/switch.mgmt.lan/192.168.99.50
address=/management.lan/192.168.99.50
# Add more as needed

ctrld.toml then forwards *.lan to 127.0.0.1:5353 (dnsmasq).

The DNS tab should have a "Local Hostnames" section where the user adds name → IP mappings. The site generates the dnsmasq.conf and updates ctrld.toml split-horizon rules.

5. Comments pass

The backend (BACKEND_SRC) needs docstrings and inline comments. Every function needs a one-line docstring. Non-obvious ACL patterns, the connection pool logic, session management, and the ctrld.toml builder all need explanatory comments. Currently comment density is inconsistent.

6. README updates needed

Add sections for:

  • Port 53 conflict resolution (systemd-resolved and OPNsense/Unbound)
  • ctrld on OPNsense vs management computer — tradeoffs
  • DNS enforcement ACLs — why they matter, what they do
  • Inter-VLAN routing — the ACL templates, what each VLAN can reach
  • Local hostname resolution — the dnsmasq option
  • ACL templates in the UI

Key Files and Paths

All sensitive files in /etc/switch-manager/ (chmod 600 where appropriate):

  • ers5952_key — SSH private key
  • ers5952_key.pub — SSH public key (loaded on switch)
  • known_hosts — pinned switch host key
  • totp_secret — TOTP seed (BACK THIS UP)
  • devices.json — registered devices
  • opnsense.json — OPNsense API credentials
  • ctrld.json — Control D / ctrld config
  • wg_server_private — WireGuard server private key
  • wg_server_public — WireGuard server public key
  • clients/ — WireGuard client .conf files
  • fqdn — Caddy hostname (e.g. switch.mgmt.lan)

Project folder (same dir as Avaya_5952_setup.py):

  • switch_backend.py — FastAPI backend (written by setup)
  • ers5952-manager.jsx — React source (written by setup)
  • README.md — this README (written by setup)
  • Dockerfile — Docker image
  • docker-compose.yml — Caddy + switch-manager services
  • Caddyfile — HTTPS reverse proxy config
  • ctrld.toml — ctrld per-VLAN config (if configured)
  • frontend/dist/ — built React app

How to Extract/Rebuild the Embedded Files

# Extract any embedded constant from Avaya_5952_setup.py
def extract(src, name):
    idx = src.find(f'\n{name} = ')
    start = idx + len(f'\n{name} = ')
    qc = src[start]
    end = start + 1
    while end < len(src):
        if src[end] == qc and src[end-1] != '\\': end += 1; break
        end += 1
    return eval(src[start:end])

setup = open('Avaya_5952_setup.py').read()
backend = extract(setup, 'BACKEND_SRC')
jsx     = extract(setup, 'JSX_SRC')
readme  = extract(setup, 'README_SRC')

# Rebuild after modifying
header_end  = setup.find('\nBACKEND_SRC = ')
logic_start = setup.find('\n# ═══\n# SETUP STEPS')
header = setup[:header_end]
logic  = setup[logic_start:]

new_file = (
    header
    + '\nBACKEND_SRC = ' + repr(backend) + '\n\n'
    + 'JSX_SRC = '       + repr(jsx)     + '\n\n'
    + 'README_SRC = '    + repr(readme)  + '\n'
    + logic
)

Design Principles (carry these forward)

  1. Never write CLI — user clicks, tool generates, user inspects, user pushes
  2. Explain before asking — every choice is explained before the prompt
  3. One file — Avaya_5952_setup.py contains everything, no other files to copy
  4. Stop on first error — per-command execution, never push blindly
  5. Hard block lethal commands — removing VLAN 99, disabling SSH = refused, run at console
  6. Session-scoped auth — TOTP unlocks one push batch, auto-locks after
  7. Self-healing — per-poll SSH connections, connection pool with liveness check
  8. Docker first — explained as recommended, bare metal available with reasons
  9. No OPNsense creep — OPNsense integration is optional and additive, never required
  10. Pi references — always "management computer (Raspberry Pi, HP T620 thin client, old computer or laptop)" on first mention, then just "management computer"

Known Issues / Gotchas

  • The ctrld.toml "local" upstream type needs verification against actual ctrld docs for plain DNS forwarding syntax. Use type = "legacy" with bootstrap_ip for forwarding to a plain DNS server (Unbound/dnsmasq on localhost).

  • WireGuard wg syncconf command in the add-client endpoint may not work on all systems. Fallback: systemctl reload wg-quick@wg0. Both should be tried.

  • The OPNsense DHCP API endpoint changed between OPNsense versions. The current code uses dhcpv4/leases/searchReservation which is correct for OPNsense 23+. Older versions use a different path.

  • Docker compose file uses named volumes for Caddy data. The Caddy CA cert extraction in _install_caddy_ca() uses docker exec with a glob for the container name which may fail if the compose project name differs. Should use docker compose exec caddy instead.

  • The ctrld binary path /usr/local/bin/ctrld is the Linux default. On OPNsense (FreeBSD) it installs to a different path. The backend CTRLD_BIN constant should be detected at runtime, not hardcoded.