Compare commits

..
17 Commits
Author SHA1 Message Date
Outis b8aefc4b7c Merge pull request #9 from outis1one/claude/read-repo-L0fnD
Overhaul README to reflect unified switch + OPNsense management
2026-03-28 12:58:47 -04:00
Claude 30d67ebd4f Overhaul README to reflect unified switch + OPNsense management
The tool now actively manages OPNsense alongside the switch. Updated:

- Title: "Switch + OPNsense Network Manager"
- Description: mentions VLANs, firewall, DHCP, DNS, VPN, backups
- Tabs: updated from 8 to 17 with descriptions for all new tabs
  (Dashboard, Network, Firewall, Services, Port Fwd, PoE, Topology,
  Backups, Alerts)
- OPNsense: changed from "optional/read-only" to core integration
- VPN: OPNsense WireGuard as recommended, ControlD DNS auto-applied
- "Does Not Do": removed "does not manage OPNsense" (it does now)
- Files table: added 15 new config files (policies, services,
  backups, schedules, NAT rules, etc.)
- Backup note: back up entire /etc/switch-manager/ directory
- Endpoint count: 34 → ~60
- Component count: 8 tabs → 17 tabs, 38 components

https://claude.ai/code/session_01Do9bsN39MTuy2GVv7yzSrE
2026-03-28 16:57:08 +00:00
Outis 8fe1dc6215 Merge pull request #8 from outis1one/claude/read-repo-L0fnD
Fix README: web UI is VLAN 99 / VPN only, not whole network
2026-03-28 12:48:37 -04:00
Claude e39d0137d1 Fix README: web UI is VLAN 99 / VPN only, not whole network
The web UI runs on the management computer on VLAN 99. It is NOT
accessible from any device on the network — only from VLAN 99 or
via WireGuard VPN with VLAN 99 access. That's the whole point of
management VLAN isolation.

https://claude.ai/code/session_01Do9bsN39MTuy2GVv7yzSrE
2026-03-28 16:39:31 +00:00
Outis 50f1c9a1a2 Merge pull request #7 from outis1one/claude/read-repo-L0fnD
Claude/read repo l0fn d
2026-03-28 10:29:49 -04:00
Claude be936b688d Merge main: resolve conflict, keep WebRTC NAT fix + all new features
Resolved conflict in switch_backend.py — kept both:
- Our branch: backup/restore, firewall matrix, services, scheduling,
  topology, PoE, port forwarding, WireGuard DNS profiles
- Main: OPNsense NAT WebRTC/Mattermost calls fix (symmetric NAT →
  hybrid outbound NAT + static port rules)

https://claude.ai/code/session_01Do9bsN39MTuy2GVv7yzSrE
2026-03-28 14:20:08 +00:00
Outis 235802dcbf Merge pull request #6 from outis1one/claude/avaya-switch-manager-DxZs5
Claude/avaya switch manager dx zs5
2026-03-28 10:17:12 -04:00
Claude 5781fdafd6 Move Caddy/services box IP to Settings panel
Services box LAN IP is now a setting (gear icon → Network section),
not a panel in the Services tab. Set it once, saved via API on blur
when authenticated. Loaded on app startup from /api/services/config.

Removed: Services Host panel, caddyIpInput state, saveCaddyIp function
Added: Network section in SettingsPanel with caddy_ip field
- Auto-saves to backend on blur (requires active TOTP session)
- Loaded from /api/services/config on mount
- Services tab checklist reads from status.caddy_ip

https://claude.ai/code/session_01Do9bsN39MTuy2GVv7yzSrE
2026-03-28 13:03:09 +00:00
Claude 8a9fa02e25 Separate Caddy/services box from VLAN 99 management computer
The management computer (VLAN 99) only runs the switch manager tool
and holds SSH keys/TOTP secrets. Caddy and services (Plex, etc.) run
on a SEPARATE computer on LAN.

Backend:
- New /api/services/config endpoint to store services box LAN IP
- services-config.json persists caddy_ip separately from mgmt_ip
- Port forward creation targets caddy_ip (LAN services box), not
  mgmt_ip (VLAN 99 management computer)
- Deploy endpoint uses caddy_ip for all Caddy/NAT references
- _get_caddy_ip() helper reads from services config

Frontend:
- New "Services Host" panel: configure Caddy box LAN IP
- Checklist shows caddy_ip status, not mgmt_ip
- Port forward and deploy pass caddy_ip to backend
- Clear labels: "Services box" vs "Management computer"

Architecture:
  VLAN 99: management computer (this tool, SSH keys, TOTP)
  LAN: services computer (Caddy, Plex, Docker containers)
  WAN port forward 443 → services computer LAN IP

https://claude.ai/code/session_01Do9bsN39MTuy2GVv7yzSrE
2026-03-28 12:45:34 +00:00
Claude 4b28f8cfe5 Auto-set WG peer DNS profile from selected VLAN
- DNS profile no longer hardcoded to "house"
- When selecting a single VLAN for a WG peer, auto-sets the DNS
  profile to that VLAN's name (e.g. select "Staff" VLAN → profile
  becomes "staff")
- Multiple VLANs: keeps current profile, user can change manually
- Input shows context: auto-set vs manual vs needs VLAN selection
- Profile name matches ctrld upstream names so the correct ControlD
  profile applies to VPN DNS queries

https://claude.ai/code/session_01Do9bsN39MTuy2GVv7yzSrE
2026-03-28 12:27:29 +00:00
Outis c446329b44 Add files via upload 2026-03-28 08:13:32 -04:00
Claude 40c765e831 Add WebRTC/Mattermost calls fix — static-port outbound NAT via OPNsense API
Adds two endpoints:

POST /api/opnsense/nat/fix-webrtc
  Fixes WebRTC ICE failures caused by symmetric NAT (the reason Mattermost
  calls fail on every VLAN but work through a commercial VPN).

  OPNsense default outbound NAT remaps UDP source ports per-destination:
  each flow to a different server gets a different external port, so STUN
  candidates reported by different servers never match and ICE fails.

  Fix: switch outbound NAT to Hybrid mode, then add a UDP static-port
  rule for each VLAN subnet. Static-port preserves the source port through
  NAT, making STUN candidates consistent regardless of which server reports
  them. ICE succeeds, calls work without VPN.

  Body: { token, vlans: [{id, name, subnet}], wan_interface: "wan" }

GET /api/opnsense/nat/webrtc-status
  Returns current outbound NAT mode and existing static-port rules so the
  UI can show whether the fix has been applied.

https://claude.ai/code/session_01JR2EMK7rwrZJowpstcaxQ6
2026-03-27 15:06:22 +00:00
Claude 35f6f8c94d Revert DNS/ctrld changes from this session — restore to working state
Hard-restores switch_backend.py to d9b6d05 (before this session's changes).

Reverted commits:
  4cf7433 Fix per-VLAN DNS: switch to proxy mode, Unbound on :5353
  2db5c3b Fix per-VLAN ControlD profiles — use gateway-listener mode
  91eaeff Fix local-lan-zone.conf: add server: wrapper + correct local-data syntax
  da1d629 Fix .lan NXDOMAIN for custom hostnames (pbx.lan, nas.lan, etc.)

These changes broke a working (mostly) system. Before touching the DNS
and ctrld architecture again, the actual deployed state needs to be
understood first.

https://claude.ai/code/session_01JR2EMK7rwrZJowpstcaxQ6
2026-03-27 14:48:28 +00:00
Claude 4cf74335ad Fix per-VLAN DNS: switch to proxy mode, Unbound on :5353
The gateway-listener approach (ctrld on each VLAN gateway IP:53) requires
Unbound to stop listening on those IPs, but OPNsense has no loopback option
in the Network Interfaces list — only named interfaces (LAN, vlan20, etc.).
Restricting Unbound that way is impractical.

Correct approach: proxy mode — ctrld owns port 53, Unbound moves to a
different port (5353). No interface restrictions needed, no port conflict
regardless of start order, and ctrld sees real client source IPs so
per-VLAN CIDR routing works correctly.

Architecture:
  Clients → ctrld (0.0.0.0:53) → ControlD per-VLAN profile
  ctrld → Unbound (127.0.0.1:5353) for *.lan / *.local (split-horizon)
  Unbound has local-data records for all custom .lan hostnames

Changes:
  _build_ctrld_toml: new unbound_port param (default 5353); proxy mode now
    adds upstream.local → 127.0.0.1:unbound_port and split-horizon rules
    for *.lan / *.local in the listener policy; defaults changed from
    deploy_mode="router"/ctrld_port=5354 to deploy_mode="proxy"/ctrld_port=53
  CtrldConfig: default deploy_mode="proxy", ctrld_port=53; added unbound_port=5353
  _ctrld_generate_opnsense_cmd: proxy mode instructions now say to change
    Unbound Listen Port to 5353 in OPNsense GUI (one field change, visible
    in Services → Unbound DNS → General) and disable Query Forwarding
  All call sites updated to pass unbound_port and use new defaults

OPNsense steps to activate:
  1. Services → Unbound DNS → General → Listen Port: 5353 → Apply
  2. Services → Unbound DNS → Query Forwarding → disable/remove forward zone
  3. Regenerate and push ctrld.toml from DNS Filtering tab

https://claude.ai/code/session_01JR2EMK7rwrZJowpstcaxQ6
2026-03-27 12:33:33 +00:00
Claude 2db5c3babc Fix per-VLAN ControlD profiles — use gateway-listener mode
The single-localhost-listener architecture (Unbound:53 → ctrld:5354)
fundamentally cannot support per-VLAN ControlD profiles: all queries
arrive at ctrld from Unbound as 127.0.0.1, so ctrld has no way to
distinguish VLANs and routes everything to a single upstream.  This
broke Asterisk and IoT isolation — all traffic was hitting the same
ControlD profile regardless of which VLAN it came from.

New architecture when all VLAN profiles have a gateway IP set:

  Clients → ctrld on VLAN-gateway-IP:53 → per-VLAN ControlD profile
  Unbound stays on 127.0.0.1:53 (loopback only — no port conflict)
  ctrld sees real client source IPs → routes correctly per VLAN
  ctrld forwards *.lan / *.local → Unbound loopback (local-data)

_build_ctrld_toml changes:
  - Detects when all active profiles have a gateway IP
  - Generates one [listener.N] per VLAN on its gateway IP:53 instead
    of a single [listener.0] on 127.0.0.1:ctrld_port
  - Each listener has its own [listener.N.policy] with the correct
    upstream.N (that VLAN's ControlD profile)
  - Adds upstream.local → 127.0.0.1:53 for .lan/.local split-horizon
  - Falls back to single-listener with a clear WARNING comment when
    gateways are missing

_ctrld_generate_opnsense_cmd changes:
  - Detects which mode was generated and produces matching instructions
  - Gateway mode: tells user to restrict Unbound to loopback and
    disable Query Forwarding (ctrld is no longer downstream of Unbound)
  - Fallback mode: warns that per-VLAN profiles are not working

Required OPNsense change to activate gateway mode:
  Services → Unbound DNS → General → Network Interfaces → Loopback only
  Services → Unbound DNS → Query Forwarding → disable/remove forward to ctrld

https://claude.ai/code/session_01JR2EMK7rwrZJowpstcaxQ6
2026-03-27 12:02:33 +00:00
Claude 91eaeffd9c Fix local-lan-zone.conf: add server: wrapper + correct local-data syntax
Two bugs in the Unbound config file generation:

1. Missing server: wrapper
   OPNsense includes /var/unbound/etc/*.conf at the top level of
   unbound.conf (via include: or include-toplevel:).  Server-level
   directives like local-zone: and local-data: must sit inside a
   server: {} block — without it they are outside any section and
   either silently ignored or rejected by unbound-checkconf.
   forward-zone: is a top-level section so forward_to_ctrld.conf
   correctly has no wrapper.

   Consequence: the original 'local-zone: "lan." static' without a
   server: wrapper was never actually applied, meaning the .lan leak
   prevention was not working.

2. No local-data records
   Even with a correct zone declaration, every .lan name not listed
   as local-data gets NXDOMAIN from the static zone.  The previous
   commit added the local-data records; this commit gives them valid
   syntax inside the server: block.

Generated file now looks like:
  server:
      local-zone: "lan." static

      local-data: "switch.mgmt.lan. A <mgmt_ip>"
      local-data: "management.lan. A <mgmt_ip>"
      local-data: "pbx.lan. A 192.168.50.10"
      ...

https://claude.ai/code/session_01JR2EMK7rwrZJowpstcaxQ6
2026-03-27 11:06:33 +00:00
Claude da1d629e18 Fix .lan NXDOMAIN for custom hostnames (pbx.lan, nas.lan, etc.)
After the Unbound/:53 + ctrld/:5354 architecture change, fix-lan-zone
wrote local-lan-zone.conf with only 'local-zone: "lan." static' and no
local-data records.  Unbound then returned NXDOMAIN for every .lan name
not explicitly listed — including pbx.lan and any hostname in
local-hostnames.json — because the static zone intercepts all .lan
queries before they can reach dnsmasq.

Fix:
- Add _build_unbound_lan_zone_conf(entries, mgmt_ip) which builds a
  complete local-lan-zone.conf: the static zone declaration plus
  local-data A records for every entry in local-hostnames.json and the
  two built-in management aliases (switch.mgmt.lan, management.lan).
- Update fix-lan-zone to use this helper instead of the bare zone-only
  string.  Running fix-lan-zone now also pushes all saved hostnames.
- Update save_local_hostnames to push the updated local-lan-zone.conf
  to Unbound via SSH and reload if OPNsense SSH is configured, so
  adding/editing hostnames in the DNS tab takes effect immediately
  without a separate fix-lan-zone call.

https://claude.ai/code/session_01JR2EMK7rwrZJowpstcaxQ6
2026-03-25 14:29:13 +00:00
4 changed files with 819 additions and 51 deletions
+67 -16
View File
@@ -1,6 +1,6 @@
# Avaya / Extreme ERS Switch Manager # Avaya / Extreme ERS Switch + OPNsense Network Manager
A browser-based management interface for Avaya / Extreme Networks ERS switches. You click buttons. The software figures out the CLI. You never type a switch command. A browser-based unified management interface for Avaya / Extreme Networks ERS switches and OPNsense routers. Manages VLANs, firewall policies, DHCP, DNS filtering, WireGuard VPN, port forwarding, service proxying, backups, and scheduling — across both devices from one place. You click buttons. The software figures out the CLI and API calls. You never type a switch command or log into the OPNsense GUI separately.
Compatible with: ERS 5928, ERS 5948, ERS 5952, ERS 5952-PWR+, ERS 59100GTS-PWR+ Compatible with: ERS 5928, ERS 5948, ERS 5952, ERS 5952-PWR+, ERS 59100GTS-PWR+
@@ -10,9 +10,9 @@ Compatible with: ERS 5928, ERS 5948, ERS 5952, ERS 5952-PWR+, ERS 59100GTS-PWR+
**Management computer OS: Linux** (Ubuntu / Debian recommended — Raspberry Pi OS works perfectly) **Management computer OS: Linux** (Ubuntu / Debian recommended — Raspberry Pi OS works perfectly)
The setup script uses `apt`, `systemd`, `picocom`, and Docker. It will not run on Mac or Windows. The management computer does not need a monitor — a headless Raspberry Pi or thin client is ideal. Once running, the web UI is accessible from any browser on any device on your network. The setup script uses `apt`, `systemd`, `picocom`, and Docker. It will not run on Mac or Windows. The management computer does not need a monitor — a headless Raspberry Pi or thin client is ideal. Once running, the web UI is accessible only from devices on VLAN 99 (management network) or via WireGuard VPN with VLAN 99 access.
The switch UI itself (the browser interface) works from any OS — phone, tablet, Mac, Windows, Linux. The switch UI itself (the browser interface) works from any OS — phone, tablet, Mac, Windows, Linux — as long as you're on VLAN 99 or connected via VPN.
**Switch OS: BOSS (Baystack Operating System Software) v7.9.6** **Switch OS: BOSS (Baystack Operating System Software) v7.9.6**
@@ -261,7 +261,27 @@ sudo ip link set eth0.99 up
--- ---
## The Interface — Eight Tabs ## The Interface — 17 Tabs
### Dashboard
Health overview showing switch and OPNsense connectivity, VLAN summary, and system status at a glance.
### Network
Unified VLAN provisioning — create a VLAN across both the switch and OPNsense in one operation. Select ports, set PoE per-port, auto-configure DHCP scope and firewall rules on OPNsense. Auto-backup before every change.
### Firewall
Inter-VLAN policy matrix — visual grid showing what each VLAN can reach. Click a cell to set the policy between two VLANs. Generates both switch ACLs and OPNsense firewall rules. Presets: Printer VLAN (one-way, other VLANs can print but printers can't reach out), IoT isolated (internet only), Guest isolated, Camera NVR-only, LAN-access-all.
### Services
Reverse proxy management for Caddy (running on a separate LAN services computer). Add services by FQDN and backend URL. Deploy writes the Caddyfile, verifies NAT reflection and WAN port forward on OPNsense. Isolated VLANs reach services via public FQDNs — NAT reflection handles it internally, no VLAN isolation broken.
### Port Fwd
OPNsense NAT port forwards for non-HTTP services (game servers, SSH, mail). For HTTP services, use the Services tab (Caddy handles routing by hostname on port 443).
### Port Map ### Port Map
@@ -319,8 +339,8 @@ Unified view of all DHCP reservations across switch and OPNsense (if configured)
- Use OPNsense for all other VLANs — integrates with DNS, firewall rules, lease history - Use OPNsense for all other VLANs — integrates with DNS, firewall rules, lease history
- Never run both for the same VLAN - Never run both for the same VLAN
**OPNsense integration (optional):** **OPNsense integration:**
Auto-detects OPNsense at your gateway IP. If found, prompts for API key. Once connected, shows reservations from both switch and OPNsense in one table, colour-coded by source. Auto-detects OPNsense at your gateway IP. Prompts for API key and SSH key. Once connected, the tool manages both devices together — VLANs, DHCP, firewall rules, port forwards, DNS, WireGuard, and backups. DHCP tab shows reservations from both switch and OPNsense in one table, colour-coded by source.
**Conflict detection:** If the same MAC has reservations in both places, a badge appears — red for IP conflicts (same MAC, different IP), yellow for duplicates (same MAC, same IP). Each conflict has a Resolve button with four options: Switch wins, OPNsense wins, Remove from switch, Remove from OPNsense. **Conflict detection:** If the same MAC has reservations in both places, a badge appears — red for IP conflicts (same MAC, different IP), yellow for duplicates (same MAC, same IP). Each conflict has a Resolve button with four options: Switch wins, OPNsense wins, Remove from switch, Remove from OPNsense.
@@ -353,19 +373,35 @@ Generates the `ctrld.toml` config and install command. You install wherever you
### VPN ### VPN
Manages WireGuard VPN for remote access to the management interface from outside the management VLAN. Manages WireGuard VPN on OPNsense (recommended) or locally on the management computer (fallback).
**Add clients:** Enter a name (laptop, phone, tablet) and the tool generates a keypair, adds the peer to the server config, reloads WireGuard live, and displays a QR code to scan with the WireGuard app. Also saves a `.conf` file for desktop import. **OPNsense WireGuard (recommended):** Creates a WireGuard server on OPNsense via plugin API. Peers select which VLANs they can access — OPNsense routes between the tunnel and VLANs per firewall rules. VLAN isolation preserved. ControlD DNS profiles auto-applied via ctrld — when you select a VLAN for a peer, the DNS profile is auto-set to match.
**Revoke clients:** Disconnects the peer immediately and removes it from the server config. **Add clients:** Enter a name, select VLAN access, and the tool generates a keypair, registers the peer on OPNsense, and displays a QR code. Private key never leaves the management computer. DNS is set to OPNsense so Unbound → ctrld → ControlD filtering applies. This also solves the Android Private DNS conflict — WireGuard's DNS setting overrides it.
**Connected peers:** Shows last handshake time and transfer stats for each peer. **Local WireGuard (fallback):** Runs on the management computer. Peers can only reach VLAN 99. Use when OPNsense is being reconfigured.
**SSH tunnel alternative:** For power users — one command gives secure access without WireGuard installed: **SSH tunnel alternative:** For power users — one command gives secure access without WireGuard installed:
```bash ```bash
ssh -L 8765:localhost:8765 user@management-computer-ip ssh -L 8765:localhost:8765 user@management-computer-ip
``` ```
### PoE
Power budget dashboard. Visual bar showing total/used/remaining watts with colour-coded thresholds. Per-port power draw grid. Warning when budget exceeds 85%.
### Topology
Auto-generated network diagram: OPNsense router → trunk link → switch → VLAN fan-out with port counts, device counts, and live status for each node.
### Backups
Automatic backup before every change (VLAN provisioning, policy push, service deploy). OPNsense full XML config export via API. Switch running-config capture via SSH. Manual backup/restore with download. Restore creates a safety backup first. Keeps last 50 per device.
### Alerts
ntfy push notifications for network events (connectivity loss, PoE budget warning). VLAN time-based schedules — disable/enable internet access per VLAN on a schedule (Guest WiFi off midnight6am, Business off after hours). Scheduled backups and connectivity checks.
--- ---
## Making Changes — Step by Step ## Making Changes — Step by Step
@@ -466,8 +502,8 @@ The switch is never being polled when nobody is looking at the dashboard.
| File | Location | Purpose | | File | Location | Purpose |
|---|---|---| |---|---|---|
| `switch_backend.py` | Project folder | Python API server (34 endpoints) | | `switch_backend.py` | Project folder | Python API server (~60 endpoints) |
| `ers5952-manager.jsx` | Project folder | React app source (8 tabs) | | `ers5952-manager.jsx` | Project folder | React app source (17 tabs, 38 components) |
| `README.md` | Project folder | This file | | `README.md` | Project folder | This file |
| `Dockerfile` | Project folder | Docker image definition | | `Dockerfile` | Project folder | Docker image definition |
| `docker-compose.yml` | Project folder | Caddy + switch manager services | | `docker-compose.yml` | Project folder | Caddy + switch manager services |
@@ -485,10 +521,25 @@ The switch is never being polled when nobody is looking at the dashboard.
| `wg_server_public` | `/etc/switch-manager/` | WireGuard server public key | | `wg_server_public` | `/etc/switch-manager/` | WireGuard server public key |
| `clients/` | `/etc/switch-manager/` | WireGuard client .conf files | | `clients/` | `/etc/switch-manager/` | WireGuard client .conf files |
| `switch-manager.service` | `/etc/systemd/system/` | Systemd service (native mode) | | `switch-manager.service` | `/etc/systemd/system/` | Systemd service (native mode) |
| `opnsense_key` | `/etc/switch-manager/` | OPNsense SSH private key (chmod 600) |
| `opnsense_known_hosts` | `/etc/switch-manager/` | Pinned OPNsense host key |
| `opnsense_wg.json` | `/etc/switch-manager/` | WireGuard server/peer state (chmod 600) |
| `vlan-if-map.json` | `/etc/switch-manager/` | VLAN ID → OPNsense interface mapping |
| `vlan-policies.json` | `/etc/switch-manager/` | Inter-VLAN firewall policies |
| `service-proxies.json` | `/etc/switch-manager/` | Caddy reverse proxy service entries |
| `services-config.json` | `/etc/switch-manager/` | Services box LAN IP config |
| `Caddyfile.services` | `/etc/switch-manager/` | Auto-generated Caddy service proxy entries |
| `service-nat-rules.json` | `/etc/switch-manager/` | Tracked OPNsense NAT rule UUIDs |
| `port-forwards.json` | `/etc/switch-manager/` | Managed port forward rules |
| `pinholes.json` | `/etc/switch-manager/` | Firewall pinhole rule UUIDs |
| `schedules.json` | `/etc/switch-manager/` | Scheduled operations (backups, VLAN toggles) |
| `ntfy.json` | `/etc/switch-manager/` | ntfy alert configuration |
| `backups/switch/` | `/etc/switch-manager/` | Switch running-config backups |
| `backups/opnsense/` | `/etc/switch-manager/` | OPNsense XML config backups |
| `local-hostnames.json` | `/etc/switch-manager/` | User-defined hostname→IP mappings (optional) | | `local-hostnames.json` | `/etc/switch-manager/` | User-defined hostname→IP mappings (optional) |
| `dnsmasq.conf` | `/etc/switch-manager/` | Generated dnsmasq config (optional) | | `dnsmasq.conf` | `/etc/switch-manager/` | Generated dnsmasq config (optional) |
**Back up `/etc/switch-manager/totp_secret`** — if the management computer fails and you have not backed this up you will need to regenerate the TOTP secret and re-scan it into your authenticator app. **Back up the entire `/etc/switch-manager/` directory** — it contains SSH keys, TOTP secrets, OPNsense credentials, WireGuard keys, and all configuration state. The Backups tab automatically saves switch and OPNsense configs before every change, but the management computer's own config directory should also be backed up externally.
--- ---
@@ -743,10 +794,10 @@ Useful when reviewing long `show config` or `show interfaces` output during cons
## What This Tool Does Not Do ## What This Tool Does Not Do
- Does not manage OPNsense, pfSense, or any other device directly (OPNsense integration is read/sync only) - Does not manage UniFi, pfSense, or non-OPNsense routers (OPNsense is the supported router platform)
- Does not provide a terminal or shell — there is no way to type arbitrary commands through the main interface (CLI mode in settings is available for advanced users but still runs through the safety pipeline) - Does not provide a terminal or shell — there is no way to type arbitrary commands through the main interface (CLI mode in settings is available for advanced users but still runs through the safety pipeline)
- Does not support multiple switches simultaneously - Does not support multiple switches simultaneously
- Does not provide traffic analytics or bandwidth graphs - Does not provide deep traffic analytics or bandwidth graphs (PoE budget and port status are available)
- Does not automatically discover or adopt new network devices - Does not automatically discover or adopt new network devices
--- ---
+501
View File
@@ -0,0 +1,501 @@
#!/bin/bash
# ─── Config ───────────────────────────────────────────────────────────────────
CONFIG_DIR="$HOME/.config/backup_project"
INSTALL_PATH="/usr/local/bin/backup_project"
# ─── Help ─────────────────────────────────────────────────────────────────────
if [[ "$1" == "--help" || "$1" == "-h" ]]; then
cat <<EOF
backup_project — Incremental folder backup with versioned naming
USAGE:
backup_project [folder] Back up a folder (or current dir)
backup_project --set-version X.Y.Z [folder] Set the next version number (respects existing folders)
backup_project --force X.Y.Z [folder] Force exact version number (ignores existing folders, one-time)
backup_project --install Install script system-wide to /usr/local/bin
backup_project --help Show this help message
EXAMPLES:
backup_project # backs up current directory
backup_project ~/Documents/this-project # backs up specified folder
backup_project --set-version 2.0.15 # next backup will be 2.0.15 (or higher if folders exist)
backup_project --force 2.0.15 # next backup will be exactly 2.0.15
backup_project --force 2.0.15 ~/Documents/this-project
NOTES:
- On first run for any folder, you will be prompted for a starting version (e.g. 2.0.12)
- Each folder has its own config stored in ~/.config/backup_project/
- Backups are created in the same parent directory as the source folder
- To reset a folder's config: rm ~/.config/backup_project/<config>.cfg
- To view all configs: ls ~/.config/backup_project/
- To uninstall: sudo rm /usr/local/bin/backup_project
EOF
exit 0
fi
# ─── Self-install ─────────────────────────────────────────────────────────────
if [[ "$1" == "--install" ]]; then
echo "Installing backup_project to $INSTALL_PATH ..."
sudo cp "$0" "$INSTALL_PATH"
sudo chmod +x "$INSTALL_PATH"
echo "Done! You can now run 'backup_project [folder]' from anywhere."
exit 0
fi
# ─── Set version override ─────────────────────────────────────────────────────
# Usage: backup_project --set-version 2.0.15 [folder]
if [[ "$1" == "--set-version" ]]; then
NEW_VERSION="$2"
SOURCE="$(realpath "${3:-$(pwd)}")"
if ! echo "$NEW_VERSION" | grep -qP '^\d+(\.\d+)*\.\d+
# If no argument given, use current directory
if [ -z "$1" ]; then
SOURCE="$(pwd)"
else
SOURCE="$1"
fi
# Strip trailing slash, resolve to absolute path
SOURCE="$(realpath "${SOURCE%/}")"
if [ ! -d "$SOURCE" ]; then
echo "Error: '$SOURCE' is not a directory."
exit 1
fi
# ─── Load or create config for this source folder ────────────────────────────
mkdir -p "$CONFIG_DIR"
CONFIG_KEY=$(echo "$SOURCE" | tr '/' '_' | tr ' ' '_')
FOLDER_CONFIG="$CONFIG_DIR/${CONFIG_KEY}.cfg"
if [ ! -f "$FOLDER_CONFIG" ]; then
echo "First time backing up '$(basename "$SOURCE")'."
read -rp "Enter starting version (e.g. 2.0.12): " FULL_VERSION
# Validate format: must be X.Y.Z (digits and dots, at least one dot, ends in digits)
if ! echo "$FULL_VERSION" | grep -qP '^\d+(\.\d+)*\.\d+$'; then
echo "Error: version must be in format like 2.0.12 or 1.0.0"
exit 1
fi
# Split into prefix (everything before last dot) and starting minor (last number)
BASE_VERSION="${FULL_VERSION%.*}" # e.g. 2.0
START_MINOR="${FULL_VERSION##*.}" # e.g. 12
echo "BASE_VERSION=$BASE_VERSION" > "$FOLDER_CONFIG"
echo "START_MINOR=$START_MINOR" >> "$FOLDER_CONFIG"
echo "Saved: prefix='$BASE_VERSION', starting minor='$START_MINOR' for '$(basename "$SOURCE")'."
else
source "$FOLDER_CONFIG"
fi
# ─── Find next version number ─────────────────────────────────────────────────
BASE_NAME=$(basename "$SOURCE")
PARENT_DIR=$(dirname "$SOURCE")
if [ -n "$FORCE_MINOR" ]; then
# --force: use exactly this number, ignore existing folders, then clear the force
NEXT="$FORCE_MINOR"
echo "BASE_VERSION=$BASE_VERSION" > "$FOLDER_CONFIG"
echo "START_MINOR=$FORCE_MINOR" >> "$FOLDER_CONFIG"
echo "FORCE_MINOR=" >> "$FOLDER_CONFIG"
else
# Find highest existing minor version >= START_MINOR
LAST=$(ls -d "${PARENT_DIR}/${BASE_NAME}-${BASE_VERSION}".* 2>/dev/null \
| grep -oP '\d+
DEST="${PARENT_DIR}/${BASE_NAME}-${BASE_VERSION}.${NEXT}"
# ─── Copy ─────────────────────────────────────────────────────────────────────
cp -r "$SOURCE" "$DEST"
echo "✓ Backed up '$(basename "$SOURCE")' → '$DEST'"
; then
echo "Error: version must be in format like 2.0.15"
exit 1
fi
CONFIG_KEY=$(echo "$SOURCE" | tr '/' '_' | tr ' ' '_')
FOLDER_CONFIG="$CONFIG_DIR/${CONFIG_KEY}.cfg"
if [ ! -f "$FOLDER_CONFIG" ]; then
echo "Error: no config found for '$SOURCE'. Run a backup first."
exit 1
fi
BASE_VERSION="${NEW_VERSION%.*}"
START_MINOR="${NEW_VERSION##*.}"
echo "BASE_VERSION=$BASE_VERSION" > "$FOLDER_CONFIG"
echo "START_MINOR=$START_MINOR" >> "$FOLDER_CONFIG"
echo "FORCE_MINOR=" >> "$FOLDER_CONFIG"
echo "✓ Next backup of '$(basename "$SOURCE")' will be '$NEW_VERSION'"
exit 0
fi
# ─── Force version override ───────────────────────────────────────────────────
# Usage: backup_project --force 2.0.15 [folder]
if [[ "$1" == "--force" ]]; then
NEW_VERSION="$2"
SOURCE="$(realpath "${3:-$(pwd)}")"
if ! echo "$NEW_VERSION" | grep -qP '^\d+(\.\d+)*\.\d+
# ─── Resolve source folder ────────────────────────────────────────────────────
# If no argument given, use current directory
if [ -z "$1" ]; then
SOURCE="$(pwd)"
else
SOURCE="$1"
fi
# Strip trailing slash, resolve to absolute path
SOURCE="$(realpath "${SOURCE%/}")"
if [ ! -d "$SOURCE" ]; then
echo "Error: '$SOURCE' is not a directory."
exit 1
fi
# ─── Load or create config for this source folder ────────────────────────────
mkdir -p "$CONFIG_DIR"
CONFIG_KEY=$(echo "$SOURCE" | tr '/' '_' | tr ' ' '_')
FOLDER_CONFIG="$CONFIG_DIR/${CONFIG_KEY}.cfg"
if [ ! -f "$FOLDER_CONFIG" ]; then
echo "First time backing up '$(basename "$SOURCE")'."
read -rp "Enter starting version (e.g. 2.0.12): " FULL_VERSION
# Validate format: must be X.Y.Z (digits and dots, at least one dot, ends in digits)
if ! echo "$FULL_VERSION" | grep -qP '^\d+(\.\d+)*\.\d+$'; then
echo "Error: version must be in format like 2.0.12 or 1.0.0"
exit 1
fi
# Split into prefix (everything before last dot) and starting minor (last number)
BASE_VERSION="${FULL_VERSION%.*}" # e.g. 2.0
START_MINOR="${FULL_VERSION##*.}" # e.g. 12
echo "BASE_VERSION=$BASE_VERSION" > "$FOLDER_CONFIG"
echo "START_MINOR=$START_MINOR" >> "$FOLDER_CONFIG"
echo "Saved: prefix='$BASE_VERSION', starting minor='$START_MINOR' for '$(basename "$SOURCE")'."
else
source "$FOLDER_CONFIG"
fi
# ─── Find next version number ─────────────────────────────────────────────────
BASE_NAME=$(basename "$SOURCE")
PARENT_DIR=$(dirname "$SOURCE")
# Find highest existing minor version, but only consider numbers >= START_MINOR
LAST=$(ls -d "${PARENT_DIR}/${BASE_NAME}-${BASE_VERSION}".* 2>/dev/null \
| grep -oP '\d+$' \
| awk -v start="$START_MINOR" '$1 >= start' \
| sort -n \
| tail -1)
if [ -z "$LAST" ]; then
NEXT="$START_MINOR"
else
NEXT=$((LAST + 1))
fi
DEST="${PARENT_DIR}/${BASE_NAME}-${BASE_VERSION}.${NEXT}"
# ─── Copy ─────────────────────────────────────────────────────────────────────
cp -r "$SOURCE" "$DEST"
echo "✓ Backed up '$(basename "$SOURCE")' → '$DEST'"
; then
echo "Error: version must be in format like 2.0.15"
exit 1
fi
CONFIG_KEY=$(echo "$SOURCE" | tr '/' '_' | tr ' ' '_')
FOLDER_CONFIG="$CONFIG_DIR/${CONFIG_KEY}.cfg"
if [ ! -f "$FOLDER_CONFIG" ]; then
echo "Error: no config found for '$SOURCE'. Run a backup first."
exit 1
fi
BASE_VERSION="${NEW_VERSION%.*}"
FORCE_MINOR="${NEW_VERSION##*.}"
echo "BASE_VERSION=$BASE_VERSION" > "$FOLDER_CONFIG"
echo "START_MINOR=$FORCE_MINOR" >> "$FOLDER_CONFIG"
echo "FORCE_MINOR=$FORCE_MINOR" >> "$FOLDER_CONFIG"
echo "✓ Next backup of '$(basename "$SOURCE")' will be forced to '$NEW_VERSION' (ignoring existing folders)"
exit 0
fi
# ─── Resolve source folder ────────────────────────────────────────────────────
# If no argument given, use current directory
if [ -z "$1" ]; then
SOURCE="$(pwd)"
else
SOURCE="$1"
fi
# Strip trailing slash, resolve to absolute path
SOURCE="$(realpath "${SOURCE%/}")"
if [ ! -d "$SOURCE" ]; then
echo "Error: '$SOURCE' is not a directory."
exit 1
fi
# ─── Load or create config for this source folder ────────────────────────────
mkdir -p "$CONFIG_DIR"
CONFIG_KEY=$(echo "$SOURCE" | tr '/' '_' | tr ' ' '_')
FOLDER_CONFIG="$CONFIG_DIR/${CONFIG_KEY}.cfg"
if [ ! -f "$FOLDER_CONFIG" ]; then
echo "First time backing up '$(basename "$SOURCE")'."
read -rp "Enter starting version (e.g. 2.0.12): " FULL_VERSION
# Validate format: must be X.Y.Z (digits and dots, at least one dot, ends in digits)
if ! echo "$FULL_VERSION" | grep -qP '^\d+(\.\d+)*\.\d+$'; then
echo "Error: version must be in format like 2.0.12 or 1.0.0"
exit 1
fi
# Split into prefix (everything before last dot) and starting minor (last number)
BASE_VERSION="${FULL_VERSION%.*}" # e.g. 2.0
START_MINOR="${FULL_VERSION##*.}" # e.g. 12
echo "BASE_VERSION=$BASE_VERSION" > "$FOLDER_CONFIG"
echo "START_MINOR=$START_MINOR" >> "$FOLDER_CONFIG"
echo "Saved: prefix='$BASE_VERSION', starting minor='$START_MINOR' for '$(basename "$SOURCE")'."
else
source "$FOLDER_CONFIG"
fi
# ─── Find next version number ─────────────────────────────────────────────────
BASE_NAME=$(basename "$SOURCE")
PARENT_DIR=$(dirname "$SOURCE")
# Find highest existing minor version, but only consider numbers >= START_MINOR
LAST=$(ls -d "${PARENT_DIR}/${BASE_NAME}-${BASE_VERSION}".* 2>/dev/null \
| grep -oP '\d+$' \
| awk -v start="$START_MINOR" '$1 >= start' \
| sort -n \
| tail -1)
if [ -z "$LAST" ]; then
NEXT="$START_MINOR"
else
NEXT=$((LAST + 1))
fi
DEST="${PARENT_DIR}/${BASE_NAME}-${BASE_VERSION}.${NEXT}"
# ─── Copy ─────────────────────────────────────────────────────────────────────
cp -r "$SOURCE" "$DEST"
echo "✓ Backed up '$(basename "$SOURCE")' → '$DEST'"
\
| awk -v start="$START_MINOR" '$1 >= start' \
| sort -n \
| tail -1)
if [ -z "$LAST" ]; then
NEXT="$START_MINOR"
else
NEXT=$((LAST + 1))
fi
fi
DEST="${PARENT_DIR}/${BASE_NAME}-${BASE_VERSION}.${NEXT}"
# ─── Copy ─────────────────────────────────────────────────────────────────────
cp -r "$SOURCE" "$DEST"
echo "✓ Backed up '$(basename "$SOURCE")' → '$DEST'"
; then
echo "Error: version must be in format like 2.0.15"
exit 1
fi
CONFIG_KEY=$(echo "$SOURCE" | tr '/' '_' | tr ' ' '_')
FOLDER_CONFIG="$CONFIG_DIR/${CONFIG_KEY}.cfg"
if [ ! -f "$FOLDER_CONFIG" ]; then
echo "Error: no config found for '$SOURCE'. Run a backup first."
exit 1
fi
BASE_VERSION="${NEW_VERSION%.*}"
START_MINOR="${NEW_VERSION##*.}"
echo "BASE_VERSION=$BASE_VERSION" > "$FOLDER_CONFIG"
echo "START_MINOR=$START_MINOR" >> "$FOLDER_CONFIG"
echo "FORCE_MINOR=" >> "$FOLDER_CONFIG"
echo "✓ Next backup of '$(basename "$SOURCE")' will be '$NEW_VERSION'"
exit 0
fi
# ─── Force version override ───────────────────────────────────────────────────
# Usage: backup_project --force 2.0.15 [folder]
if [[ "$1" == "--force" ]]; then
NEW_VERSION="$2"
SOURCE="$(realpath "${3:-$(pwd)}")"
if ! echo "$NEW_VERSION" | grep -qP '^\d+(\.\d+)*\.\d+
# ─── Resolve source folder ────────────────────────────────────────────────────
# If no argument given, use current directory
if [ -z "$1" ]; then
SOURCE="$(pwd)"
else
SOURCE="$1"
fi
# Strip trailing slash, resolve to absolute path
SOURCE="$(realpath "${SOURCE%/}")"
if [ ! -d "$SOURCE" ]; then
echo "Error: '$SOURCE' is not a directory."
exit 1
fi
# ─── Load or create config for this source folder ────────────────────────────
mkdir -p "$CONFIG_DIR"
CONFIG_KEY=$(echo "$SOURCE" | tr '/' '_' | tr ' ' '_')
FOLDER_CONFIG="$CONFIG_DIR/${CONFIG_KEY}.cfg"
if [ ! -f "$FOLDER_CONFIG" ]; then
echo "First time backing up '$(basename "$SOURCE")'."
read -rp "Enter starting version (e.g. 2.0.12): " FULL_VERSION
# Validate format: must be X.Y.Z (digits and dots, at least one dot, ends in digits)
if ! echo "$FULL_VERSION" | grep -qP '^\d+(\.\d+)*\.\d+$'; then
echo "Error: version must be in format like 2.0.12 or 1.0.0"
exit 1
fi
# Split into prefix (everything before last dot) and starting minor (last number)
BASE_VERSION="${FULL_VERSION%.*}" # e.g. 2.0
START_MINOR="${FULL_VERSION##*.}" # e.g. 12
echo "BASE_VERSION=$BASE_VERSION" > "$FOLDER_CONFIG"
echo "START_MINOR=$START_MINOR" >> "$FOLDER_CONFIG"
echo "Saved: prefix='$BASE_VERSION', starting minor='$START_MINOR' for '$(basename "$SOURCE")'."
else
source "$FOLDER_CONFIG"
fi
# ─── Find next version number ─────────────────────────────────────────────────
BASE_NAME=$(basename "$SOURCE")
PARENT_DIR=$(dirname "$SOURCE")
# Find highest existing minor version, but only consider numbers >= START_MINOR
LAST=$(ls -d "${PARENT_DIR}/${BASE_NAME}-${BASE_VERSION}".* 2>/dev/null \
| grep -oP '\d+$' \
| awk -v start="$START_MINOR" '$1 >= start' \
| sort -n \
| tail -1)
if [ -z "$LAST" ]; then
NEXT="$START_MINOR"
else
NEXT=$((LAST + 1))
fi
DEST="${PARENT_DIR}/${BASE_NAME}-${BASE_VERSION}.${NEXT}"
# ─── Copy ─────────────────────────────────────────────────────────────────────
cp -r "$SOURCE" "$DEST"
echo "✓ Backed up '$(basename "$SOURCE")' → '$DEST'"
; then
echo "Error: version must be in format like 2.0.15"
exit 1
fi
CONFIG_KEY=$(echo "$SOURCE" | tr '/' '_' | tr ' ' '_')
FOLDER_CONFIG="$CONFIG_DIR/${CONFIG_KEY}.cfg"
if [ ! -f "$FOLDER_CONFIG" ]; then
echo "Error: no config found for '$SOURCE'. Run a backup first."
exit 1
fi
BASE_VERSION="${NEW_VERSION%.*}"
FORCE_MINOR="${NEW_VERSION##*.}"
echo "BASE_VERSION=$BASE_VERSION" > "$FOLDER_CONFIG"
echo "START_MINOR=$FORCE_MINOR" >> "$FOLDER_CONFIG"
echo "FORCE_MINOR=$FORCE_MINOR" >> "$FOLDER_CONFIG"
echo "✓ Next backup of '$(basename "$SOURCE")' will be forced to '$NEW_VERSION' (ignoring existing folders)"
exit 0
fi
# ─── Resolve source folder ────────────────────────────────────────────────────
# If no argument given, use current directory
if [ -z "$1" ]; then
SOURCE="$(pwd)"
else
SOURCE="$1"
fi
# Strip trailing slash, resolve to absolute path
SOURCE="$(realpath "${SOURCE%/}")"
if [ ! -d "$SOURCE" ]; then
echo "Error: '$SOURCE' is not a directory."
exit 1
fi
# ─── Load or create config for this source folder ────────────────────────────
mkdir -p "$CONFIG_DIR"
CONFIG_KEY=$(echo "$SOURCE" | tr '/' '_' | tr ' ' '_')
FOLDER_CONFIG="$CONFIG_DIR/${CONFIG_KEY}.cfg"
if [ ! -f "$FOLDER_CONFIG" ]; then
echo "First time backing up '$(basename "$SOURCE")'."
read -rp "Enter starting version (e.g. 2.0.12): " FULL_VERSION
# Validate format: must be X.Y.Z (digits and dots, at least one dot, ends in digits)
if ! echo "$FULL_VERSION" | grep -qP '^\d+(\.\d+)*\.\d+$'; then
echo "Error: version must be in format like 2.0.12 or 1.0.0"
exit 1
fi
# Split into prefix (everything before last dot) and starting minor (last number)
BASE_VERSION="${FULL_VERSION%.*}" # e.g. 2.0
START_MINOR="${FULL_VERSION##*.}" # e.g. 12
echo "BASE_VERSION=$BASE_VERSION" > "$FOLDER_CONFIG"
echo "START_MINOR=$START_MINOR" >> "$FOLDER_CONFIG"
echo "Saved: prefix='$BASE_VERSION', starting minor='$START_MINOR' for '$(basename "$SOURCE")'."
else
source "$FOLDER_CONFIG"
fi
# ─── Find next version number ─────────────────────────────────────────────────
BASE_NAME=$(basename "$SOURCE")
PARENT_DIR=$(dirname "$SOURCE")
# Find highest existing minor version, but only consider numbers >= START_MINOR
LAST=$(ls -d "${PARENT_DIR}/${BASE_NAME}-${BASE_VERSION}".* 2>/dev/null \
| grep -oP '\d+$' \
| awk -v start="$START_MINOR" '$1 >= start' \
| sort -n \
| tail -1)
if [ -z "$LAST" ]; then
NEXT="$START_MINOR"
else
NEXT=$((LAST + 1))
fi
DEST="${PARENT_DIR}/${BASE_NAME}-${BASE_VERSION}.${NEXT}"
# ─── Copy ─────────────────────────────────────────────────────────────────────
cp -r "$SOURCE" "$DEST"
echo "✓ Backed up '$(basename "$SOURCE")' → '$DEST'"
+58 -17
View File
@@ -417,7 +417,7 @@ function SessionBtn({ session, onUnlock }) {
// //
// SETTINGS PANEL // SETTINGS PANEL
// //
function SettingsPanel({ settings, setSettings, onClose }) { function SettingsPanel({ settings, setSettings, session, onClose }) {
return ( return (
<div className="settings-overlay" onClick={onClose}> <div className="settings-overlay" onClick={onClose}>
<div className="settings-panel" onClick={e => e.stopPropagation()}> <div className="settings-panel" onClick={e => e.stopPropagation()}>
@@ -454,6 +454,29 @@ function SettingsPanel({ settings, setSettings, onClose }) {
</div> </div>
</div> </div>
<div className="settings-section">
<h3>Network</h3>
<div className="setting-row">
<div>
<div className="setting-label">Services / Caddy box LAN IP</div>
<div className="setting-sub">The computer running Caddy and services (Plex, etc.) separate from this management computer</div>
</div>
<input value={settings.caddyIp || ""} onChange={e => setSettings(s => ({...s, caddyIp: e.target.value}))}
placeholder="192.168.1.50"
onBlur={() => {
if (settings.caddyIp && session?.token) {
fetch("/api/services/config", {
method:"POST",
headers:{"Content-Type":"application/json"},
body: JSON.stringify({ token: session.token, config: { caddy_ip: settings.caddyIp } })
}).catch(() => {});
}
}}
style={{background:"var(--bg)",border:"1px solid var(--b2)",color:"var(--tx)",
padding:"4px 8px",fontFamily:"var(--mono)",fontSize:11,borderRadius:3,width:140}}/>
</div>
</div>
<div className="settings-section"> <div className="settings-section">
<h3>About</h3> <h3>About</h3>
<div style={{fontSize:11,color:"var(--dm)",lineHeight:1.7}}> <div style={{fontSize:11,color:"var(--dm)",lineHeight:1.7}}>
@@ -1473,7 +1496,7 @@ export default function App() {
const [selected, setSelected] = useState(null); const [selected, setSelected] = useState(null);
const [hostname, setHostname] = useState("ERS-5952"); const [hostname, setHostname] = useState("ERS-5952");
const [switchIP, setSwitchIP] = useState("192.168.99.1"); const [switchIP, setSwitchIP] = useState("192.168.99.1");
const [settings, setSettings] = useState({ cliMode: false, defaultPushMode: "batch" }); const [settings, setSettings] = useState({ cliMode: false, defaultPushMode: "batch", caddyIp: "" });
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
const [connState, setConnState] = useState("connecting"); const [connState, setConnState] = useState("connecting");
@@ -1487,6 +1510,13 @@ export default function App() {
const updatePort = useCallback(p => setPorts(prev => prev.map(x => x.id===p.id?p:x)), []); const updatePort = useCallback(p => setPorts(prev => prev.map(x => x.id===p.id?p:x)), []);
// Heartbeat // Heartbeat
// Load services config (caddy IP) once on mount
useEffect(() => {
API("/services/config").then(cfg => {
if (cfg.caddy_ip) setSettings(s => ({...s, caddyIp: cfg.caddy_ip}));
}).catch(() => {});
}, []);
useEffect(() => { useEffect(() => {
let firstPoll = true; let firstPoll = true;
const beat = async () => { const beat = async () => {
@@ -1660,6 +1690,7 @@ export default function App() {
{showSettings && <SettingsPanel {showSettings && <SettingsPanel
settings={settings} setSettings={setSettings} settings={settings} setSettings={setSettings}
session={session}
onClose={() => setShowSettings(false)} onClose={() => setShowSettings(false)}
/>} />}
</div> </div>
@@ -2208,7 +2239,7 @@ function WireGuardTab({ session, onNeedAuth, backendOk, vlans = [] }) {
}); });
const [opnPeerName, setOpnPeerName] = useState(''); const [opnPeerName, setOpnPeerName] = useState('');
const [opnVlans, setOpnVlans] = useState([]); // checked VLAN IDs const [opnVlans, setOpnVlans] = useState([]); // checked VLAN IDs
const [opnDnsProfile, setOpnDnsProfile] = useState('house'); // ControlD profile for VPN clients const [opnDnsProfile, setOpnDnsProfile] = useState(''); // ControlD profile for VPN clients auto-set from VLAN
const [opnAdding, setOpnAdding] = useState(false); const [opnAdding, setOpnAdding] = useState(false);
const [opnQr, setOpnQr] = useState(null); // { config, name } const [opnQr, setOpnQr] = useState(null); // { config, name }
@@ -2288,7 +2319,16 @@ function WireGuardTab({ session, onNeedAuth, backendOk, vlans = [] }) {
}; };
const opnToggleVlan = (vid) => { const opnToggleVlan = (vid) => {
setOpnVlans(prev => prev.includes(vid) ? prev.filter(v=>v!==vid) : [...prev, vid]); const next = opnVlans.includes(vid) ? opnVlans.filter(v=>v!==vid) : [...opnVlans, vid];
setOpnVlans(next);
// Auto-set DNS profile from the VLAN (when single VLAN selected)
if (next.length === 1) {
const v = vlans.find(x => x.id === next[0]);
if (v) setOpnDnsProfile(v.name.toLowerCase());
} else if (next.length === 0) {
setOpnDnsProfile('');
}
// When multiple VLANs selected, keep whatever profile is set
}; };
const opnAddPeer = async () => { const opnAddPeer = async () => {
@@ -2795,15 +2835,19 @@ function OPNsenseWGSection({
</div> </div>
<div style={{marginTop:10}}> <div style={{marginTop:10}}>
<div className="field" style={{margin:0,maxWidth:260}}> <div className="field" style={{margin:0,maxWidth:300}}>
<label>ControlD DNS Profile (applied via ctrld)</label> <label>ControlD DNS Profile</label>
<input value={opnDnsProfile} <input value={opnDnsProfile}
onChange={e=>setOpnDnsProfile(e.target.value)} onChange={e=>setOpnDnsProfile(e.target.value)}
placeholder="house"/> placeholder="auto-set from VLAN, or type a profile name"/>
</div> </div>
<div style={{fontSize:11,color:"var(--dm)",marginTop:4}}> <div style={{fontSize:11,color:"var(--dm)",marginTop:4}}>
VPN clients use OPNsense DNS Unbound ctrld ControlD. {opnVlans.length === 1
This profile applies to the WireGuard tunnel subnet. ? `Auto-set to "${opnDnsProfile}" from selected VLAN. Change if needed.`
: opnVlans.length > 1
? "Multiple VLANs selected — set the profile manually."
: "Select a VLAN above to auto-fill, or type a ControlD profile name."}
{" "}VPN clients use OPNsense DNS Unbound ctrld ControlD.
</div> </div>
</div> </div>
@@ -5128,7 +5172,7 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
setActionLoading("pf"); setActionLoading("pf");
try { try {
const r = await API("/services/create-port-forward", { method:"POST", const r = await API("/services/create-port-forward", { method:"POST",
body:{ token: session.token, mgmt_ip: status?.mgmt_ip } }); body:{ token: session.token, caddy_ip: status?.caddy_ip } });
if (r.note) alert(r.note); if (r.note) alert(r.note);
await load(); await load();
} catch(e) { alert("Failed: " + e.message); } } catch(e) { alert("Failed: " + e.message); }
@@ -5140,7 +5184,7 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
setDeploying(true); setDeployResult(null); setDeploying(true); setDeployResult(null);
try { try {
const r = await API("/services/deploy", { method:"POST", const r = await API("/services/deploy", { method:"POST",
body:{ token: session.token, mgmt_ip: status?.mgmt_ip } }); body:{ token: session.token, caddy_ip: status?.caddy_ip } });
setDeployResult(r); setDeployResult(r);
await load(); await load();
} catch(e) { setDeployResult({ success: false, errors: [e.message] }); } } catch(e) { setDeployResult({ success: false, errors: [e.message] }); }
@@ -5194,6 +5238,8 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
<div className="panel"> <div className="panel">
<div className="ph">Setup Checklist</div> <div className="ph">Setup Checklist</div>
<div className="pb"> <div className="pb">
<Check ok={status?.caddy_configured}
label={status?.caddy_ip ? `Services box: ${status.caddy_ip}` : "Services box IP not set — configure above"} />
<Check ok={status?.opnsense_configured} label="OPNsense API connected" /> <Check ok={status?.opnsense_configured} label="OPNsense API connected" />
<Check ok={status?.opnsense_ssh} label="OPNsense SSH connected" /> <Check ok={status?.opnsense_ssh} label="OPNsense SSH connected" />
<Check ok={status?.nat_reflection} <Check ok={status?.nat_reflection}
@@ -5201,18 +5247,13 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
action={enableNatReflection} actionLabel="Enable NAT Reflection" action={enableNatReflection} actionLabel="Enable NAT Reflection"
loading={actionLoading === "nat"} /> loading={actionLoading === "nat"} />
<Check ok={status?.port_forward_443} <Check ok={status?.port_forward_443}
label={`WAN port forward 443 → ${status?.mgmt_ip || "?"}:443 (Caddy)`} label={`WAN port forward 443 → ${status?.caddy_ip || "?"}:443 (Caddy)`}
action={createPortForward} actionLabel="Create Port Forward" action={createPortForward} actionLabel="Create Port Forward"
loading={actionLoading === "pf"} /> loading={actionLoading === "pf"} />
<Check ok={status?.caddy_file_exists} <Check ok={status?.caddy_file_exists}
label="Caddyfile.services exists" /> label="Caddyfile.services exists" />
<Check ok={services.length > 0} <Check ok={services.length > 0}
label={`${services.length} service${services.length !== 1 ? "s" : ""} configured`} /> label={`${services.length} service${services.length !== 1 ? "s" : ""} configured`} />
{status?.mgmt_ip && (
<div style={{fontSize:11,color:"var(--dm)",marginTop:4}}>
Management computer IP: <span style={{fontFamily:"monospace",color:"var(--ac)"}}>{status.mgmt_ip}</span>
</div>
)}
</div> </div>
</div> </div>
+193 -18
View File
@@ -4515,8 +4515,12 @@ def push_policy(body: dict):
# ══════════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════════
# #
# Architecture: # Architecture:
# Caddy runs on the LAN management computer. It is the reverse proxy for # Caddy runs on a SEPARATE LAN services computer, NOT the VLAN 99
# all services — only port 443 is forwarded from WAN, and Caddy routes # management computer. Management box only runs this tool + SSH keys.
# Services box (LAN) runs Caddy, Plex, Docker, etc.
#
# WAN port forward 443 → services box LAN IP (Caddy).
# Caddy routes
# by hostname (SNI) to the correct backend. Service ports (32400, 8123, # by hostname (SNI) to the correct backend. Service ports (32400, 8123,
# etc.) are NEVER exposed on WAN. # etc.) are NEVER exposed on WAN.
# #
@@ -4530,8 +4534,25 @@ def push_policy(body: dict):
# IoT = untrusted = same access as someone on the internet. # IoT = untrusted = same access as someone on the internet.
SERVICES_FILE = _Path("/etc/switch-manager/service-proxies.json") SERVICES_FILE = _Path("/etc/switch-manager/service-proxies.json")
SERVICES_CONFIG_FILE = _Path("/etc/switch-manager/services-config.json")
CADDYFILE_EXTRA = _Path("/etc/switch-manager/Caddyfile.services") CADDYFILE_EXTRA = _Path("/etc/switch-manager/Caddyfile.services")
def _load_services_config() -> dict:
"""Load services/Caddy host config: caddy_ip, etc.
This is the LAN services box, NOT the VLAN 99 management computer."""
if SERVICES_CONFIG_FILE.exists():
try: return _json.loads(SERVICES_CONFIG_FILE.read_text())
except: pass
return {}
def _save_services_config(cfg: dict):
SERVICES_CONFIG_FILE.write_text(_json.dumps(cfg, indent=2))
SERVICES_CONFIG_FILE.chmod(0o600)
def _get_caddy_ip() -> str:
"""Return the Caddy/services box LAN IP."""
return _load_services_config().get("caddy_ip", "")
def _load_services() -> list: def _load_services() -> list:
if SERVICES_FILE.exists(): if SERVICES_FILE.exists():
try: return _json.loads(SERVICES_FILE.read_text()) try: return _json.loads(SERVICES_FILE.read_text())
@@ -4622,13 +4643,32 @@ def _get_mgmt_ip() -> str:
return "" return ""
@app.get("/api/services/config")
def get_services_config():
"""Return services host configuration."""
return _load_services_config()
@app.post("/api/services/config")
def save_services_config_endpoint(body: dict):
"""Save services host configuration (Caddy box LAN IP)."""
require_session(body.get("token", ""))
cfg = body.get("config", {})
if not cfg.get("caddy_ip"):
raise HTTPException(400, "caddy_ip required — the LAN IP of your services/Caddy computer")
_save_services_config(cfg)
return {"success": True, "config": cfg}
@app.get("/api/services/status") @app.get("/api/services/status")
def services_status(): def services_status():
"""Full status check: Caddy import, NAT reflection, port forward, services.""" """Full status check: Caddy host, NAT reflection, port forward, services."""
services = _load_services() services = _load_services()
svc_cfg = _load_services_config()
cfg = _load_opnsense_cfg() cfg = _load_opnsense_cfg()
caddy_ip = svc_cfg.get("caddy_ip", "")
result = { result = {
"services": services, "services": services,
"caddy_ip": caddy_ip,
"caddy_configured": bool(caddy_ip),
"mgmt_ip": _get_mgmt_ip(), "mgmt_ip": _get_mgmt_ip(),
"caddy_file_exists": CADDYFILE_EXTRA.exists(), "caddy_file_exists": CADDYFILE_EXTRA.exists(),
"nat_reflection": None, "nat_reflection": None,
@@ -4731,19 +4771,19 @@ def create_wan_port_forward(body: dict):
if not cfg.get("key"): if not cfg.get("key"):
raise HTTPException(503, "OPNsense API not configured") raise HTTPException(503, "OPNsense API not configured")
mgmt_ip = body.get("mgmt_ip", _get_mgmt_ip()) caddy_ip = body.get("caddy_ip", _get_caddy_ip())
if not mgmt_ip: if not caddy_ip:
raise HTTPException(400, "Cannot determine management computer IP — provide mgmt_ip") raise HTTPException(400, "Caddy/services box IP not configured — set it in the Services tab")
tracked = _load_service_rules() tracked = _load_service_rules()
if tracked.get("wan_443_uuid"): if tracked.get("wan_443_uuid"):
return {"success": True, "already_exists": True, "uuid": tracked["wan_443_uuid"], return {"success": True, "already_exists": True, "uuid": tracked["wan_443_uuid"],
"mgmt_ip": mgmt_ip} "caddy_ip": caddy_ip}
backup = _pre_change_backup(reason="pre-WAN-port-forward-443") backup = _pre_change_backup(reason="pre-WAN-port-forward-443")
try: try:
# Create NAT port forward rule: WAN TCP 443 → mgmt_ip:443 # Create NAT port forward rule: WAN TCP 443 → caddy_ip:443 (services box on LAN)
r = _opnsense_request(cfg, "firewall/source_nat/addRule", "POST", { r = _opnsense_request(cfg, "firewall/source_nat/addRule", "POST", {
"rule": { "rule": {
"enabled": "1", "enabled": "1",
@@ -4751,8 +4791,8 @@ def create_wan_port_forward(body: dict):
"protocol": "tcp", "protocol": "tcp",
"source": {"any": "1"}, "source": {"any": "1"},
"destination": {"any": "1", "port": "443"}, "destination": {"any": "1", "port": "443"},
"target": {"address": mgmt_ip, "port": "443"}, "target": {"address": caddy_ip, "port": "443"},
"descr": "switch-manager: WAN 443 → Caddy reverse proxy", "descr": f"switch-manager: WAN 443 → Caddy ({caddy_ip})",
"nordr": "0", "nordr": "0",
} }
}) })
@@ -4770,8 +4810,8 @@ def create_wan_port_forward(body: dict):
"ipprotocol": "inet", "ipprotocol": "inet",
"protocol": "tcp", "protocol": "tcp",
"source": {"any": "1"}, "source": {"any": "1"},
"destination": {"address": mgmt_ip, "port": "443"}, "destination": {"address": caddy_ip, "port": "443"},
"descr": "switch-manager: allow WAN → Caddy:443 (pair with NAT rule)", "descr": f"switch-manager: allow WAN → Caddy ({caddy_ip}:443)",
} }
}) })
uuid = r.get("uuid", "") uuid = r.get("uuid", "")
@@ -4780,10 +4820,10 @@ def create_wan_port_forward(body: dict):
_opnsense_request(cfg, "firewall/filter/apply", "POST") _opnsense_request(cfg, "firewall/filter/apply", "POST")
tracked["wan_443_uuid"] = uuid tracked["wan_443_uuid"] = uuid
tracked["mgmt_ip"] = mgmt_ip tracked["caddy_ip"] = caddy_ip
_save_service_rules(tracked) _save_service_rules(tracked)
return {"success": True, "uuid": uuid, "mgmt_ip": mgmt_ip, "backup": backup, return {"success": True, "uuid": uuid, "caddy_ip": caddy_ip, "backup": backup,
"note": "If this is the first time, also verify in OPNsense UI: " "note": "If this is the first time, also verify in OPNsense UI: "
"Firewall > NAT > Port Forward that the rule looks correct. " "Firewall > NAT > Port Forward that the rule looks correct. "
"The OPNsense NAT API varies between versions."} "The OPNsense NAT API varies between versions."}
@@ -4816,7 +4856,7 @@ def deploy_services(body: dict):
backup = _pre_change_backup(reason="pre-service-deploy") backup = _pre_change_backup(reason="pre-service-deploy")
mgmt_ip = body.get("mgmt_ip", _get_mgmt_ip()) caddy_ip = body.get("caddy_ip", _get_caddy_ip()) or _get_mgmt_ip()
# ── Step 1: Write Caddyfile.services ───────────────────────────── # ── Step 1: Write Caddyfile.services ─────────────────────────────
caddy_content = _generate_caddyfile_services(services) caddy_content = _generate_caddyfile_services(services)
@@ -4891,11 +4931,11 @@ def deploy_services(body: dict):
# ── Step 4: WAN port forward ───────────────────────────────────── # ── Step 4: WAN port forward ─────────────────────────────────────
tracked = _load_service_rules() tracked = _load_service_rules()
if tracked.get("wan_443_uuid"): if tracked.get("wan_443_uuid"):
steps_done.append(f"WAN port forward 443 → {tracked.get('mgmt_ip', mgmt_ip)}:443 (tracked)") steps_done.append(f"WAN port forward 443 → {tracked.get('caddy_ip', caddy_ip)}:443 (tracked)")
else: else:
pending_steps.append( pending_steps.append(
f"Create WAN port forward: OPNsense > Firewall > NAT > Port Forward — " f"Create WAN port forward: OPNsense > Firewall > NAT > Port Forward — "
f"WAN TCP 443 → {mgmt_ip}:443 (Caddy). " f"WAN TCP 443 → {caddy_ip}:443 (services box). "
f"Or use the 'Create Port Forward' button above.") f"Or use the 'Create Port Forward' button above.")
return { return {
@@ -4905,7 +4945,7 @@ def deploy_services(body: dict):
"errors": errors, "errors": errors,
"backup": backup, "backup": backup,
"caddy_content": caddy_content, "caddy_content": caddy_content,
"mgmt_ip": mgmt_ip, "caddy_ip": caddy_ip,
"nat_reflection_enabled": nat_status, "nat_reflection_enabled": nat_status,
} }
@@ -5485,3 +5525,138 @@ def poe_budget():
}) })
return result return result
# ══════════════════════════════════════════════════════════════════════
# OPNSENSE NAT — WEBRTC / MATTERMOST CALLS FIX
# ══════════════════════════════════════════════════════════════════════
#
# Problem: OPNsense uses symmetric NAT by default (port address translation).
# Each UDP flow to a *different* destination gets a *different* external source
# port. WebRTC ICE relies on STUN to discover the external address, but with
# symmetric NAT the STUN server sees a different port than the TURN or peer
# server will see — ICE candidate matching fails and calls drop. A commercial
# VPN "fixes" it because the VPN encapsulates UDP inside a single TCP/UDP
# tunnel that is full-cone from OPNsense's perspective.
#
# Fix: add a "static port" outbound NAT rule for each VLAN subnet.
# "Static port" (fixedport in pfSense/OPNsense) preserves the source port
# number through NAT. The external port equals the internal port, so every
# STUN server sees the same address:port — ICE succeeds.
#
# This requires switching outbound NAT from "Automatic" to "Hybrid" mode
# (hybrid = keep automatic rules, also honour manual ones). The static-port
# rules are added for UDP only; TCP and other protocols are unaffected.
# ══════════════════════════════════════════════════════════════════════
@app.post("/api/opnsense/nat/fix-webrtc")
def opnsense_nat_fix_webrtc(body: dict):
"""
Fix WebRTC / Mattermost Calls / STUN failures caused by symmetric NAT.
Steps:
1. Switch outbound NAT mode to Hybrid (preserves automatic rules).
2. For each VLAN subnet in body.vlans, add a UDP static-port outbound
NAT rule on the WAN interface. Static port = source port preserved
through NAT so STUN candidates are consistent across servers.
3. Apply changes.
Body: { token, vlans: [{id, name, subnet}], wan_interface: "wan" }
Returns: { success, mode_set, rules_added: [...], rules_failed: [...] }
"""
require_session(body.get("token", ""))
cfg = _load_opnsense_cfg()
if not cfg.get("host"):
raise HTTPException(503, "OPNsense API not configured")
vlans = body.get("vlans", [])
wan_iface = body.get("wan_interface", "wan")
rules_added = []
rules_failed = []
# Step 1 — switch to Hybrid outbound NAT mode
try:
_opnsense_request(cfg, "firewall/nat/outbound/setMode",
method="POST", body={"mode": "hybrid"})
mode_set = True
except Exception as e:
raise HTTPException(500, f"Could not set outbound NAT to hybrid: {e}")
# Step 2 — add a static-port UDP rule for each VLAN
for vlan in vlans:
subnet = vlan.get("subnet", "").strip()
name = vlan.get("name", f"VLAN{vlan.get('id','')}")
if not subnet:
continue
rule = {
"rule": {
"enabled": "1",
"sequence": "1",
"interface": wan_iface,
"ipprotocol": "inet",
"protocol": "UDP",
"source": {"network": subnet, "port": ""},
"sourceport": "",
"destination": {"network": "any", "port": ""},
"destinationport": "",
"target": "",
"targetip": "",
"targetip_subnet": "32",
"nonat": "0",
"staticnatport": "1",
"descr": f"Static port UDP — {name} WebRTC/STUN fix",
}
}
try:
resp = _opnsense_request(cfg, "firewall/nat/outbound/addRule",
method="POST", body=rule)
rules_added.append({"vlan": name, "subnet": subnet, "uuid": resp.get("uuid","")})
except Exception as e:
rules_failed.append({"vlan": name, "subnet": subnet, "error": str(e)})
# Step 3 — apply
try:
_opnsense_request(cfg, "firewall/nat/outbound/apply", method="POST")
except Exception as e:
rules_failed.append({"vlan": "apply", "error": str(e)})
return {
"success": len(rules_added) > 0 and not rules_failed,
"mode_set": mode_set,
"rules_added": rules_added,
"rules_failed": rules_failed,
"explanation": (
"Static-port NAT preserves UDP source ports through NAT. "
"STUN now sees the same external address regardless of destination server. "
"WebRTC ICE candidates match — calls work without VPN."
),
}
@app.get("/api/opnsense/nat/webrtc-status")
def opnsense_nat_webrtc_status():
"""
Check whether static-port outbound NAT rules exist for WebRTC.
Returns current outbound NAT mode and any existing static-port rules.
"""
cfg = _load_opnsense_cfg()
if not cfg.get("host"):
raise HTTPException(503, "OPNsense API not configured")
try:
data = _opnsense_request(cfg, "firewall/nat/outbound/get")
mode = data.get("natoutbound", {}).get("mode", "unknown")
rules = data.get("natoutbound", {}).get("rule", {})
static_rules = [
{"uuid": uid, "descr": r.get("descr",""), "source": r.get("source",{})}
for uid, r in (rules.items() if isinstance(rules, dict) else {}.items())
if str(r.get("staticnatport","0")) == "1"
]
return {
"mode": mode,
"hybrid": mode == "hybrid",
"static_rules": static_rules,
"webrtc_ready": mode == "hybrid" and len(static_rules) > 0,
}
except Exception as e:
raise HTTPException(500, str(e))