Compare commits
17
Commits
31f2008153
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8aefc4b7c | ||
|
|
30d67ebd4f | ||
|
|
8fe1dc6215 | ||
|
|
e39d0137d1 | ||
|
|
50f1c9a1a2 | ||
|
|
be936b688d | ||
|
|
235802dcbf | ||
|
|
5781fdafd6 | ||
|
|
8a9fa02e25 | ||
|
|
4b28f8cfe5 | ||
|
|
c446329b44 | ||
|
|
40c765e831 | ||
|
|
35f6f8c94d | ||
|
|
4cf74335ad | ||
|
|
2db5c3babc | ||
|
|
91eaeffd9c | ||
|
|
da1d629e18 |
@@ -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 midnight–6am, 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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user