Files
avaya_switch_management/HANDOFF.md
T
2026-03-23 08:52:01 -04:00

403 lines
15 KiB
Markdown

# 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
20. WireGuard VPN (local server fully automated, OPNsense semi-automated,
per-client QR codes and .conf files, split tunnel)
21. 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.
```bash
# 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:
```toml
[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
```python
# 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.