diff --git a/ers5952-manager.jsx b/ers5952-manager.jsx index d8efc5e..e550435 100644 --- a/ers5952-manager.jsx +++ b/ers5952-manager.jsx @@ -1528,6 +1528,7 @@ export default function App() { { id:"network", label:"Network" }, { id:"firewall", label:"Firewall" }, { id:"services", label:"Services" }, + { id:"portfwd", label:"Port Fwd" }, { id:"ports", label:"Port Map" }, { id:"vlans", label:"VLANs" }, { id:"acls", label:"ACL Builder" }, @@ -1536,6 +1537,8 @@ export default function App() { { id:"dhcp", label:"DHCP" }, { id:"dns", label:"DNS Filtering" }, { id:"vpn", label:"VPN" }, + { id:"poe", label:"PoE" }, + { id:"topology", label:"Topology" }, { id:"backups", label:"Backups" }, { id:"alerts", label:"Alerts" }, ]; @@ -1638,6 +1641,16 @@ export default function App() { onNeedAuth={() => setShowTotp(true)} backendOk={pollStatus!=="err"} />} + {tab==="portfwd" && setShowTotp(true)} + backendOk={pollStatus!=="err"} + />} + {tab==="poe" && } + {tab==="topology" && } {showTotp && ); } + + +// ══════════════════════════════════════════════════════════════════════════════ +// PORT FORWARD TAB +// ══════════════════════════════════════════════════════════════════════════════ + +function PortForwardTab({ session, onNeedAuth, backendOk }) { + const [forwards, setForwards] = useState([]); + const [form, setForm] = useState({ proto:"tcp", wan_port:"", target_ip:"", target_port:"", description:"" }); + const [creating, setCreating] = useState(false); + + const load = async () => { + try { setForwards((await API("/port-forwards")).forwards || []); } catch(e) { console.error(e); } + }; + useEffect(() => { if (backendOk) load(); }, [backendOk]); + + const create = async () => { + if (!session) { onNeedAuth(); return; } + setCreating(true); + try { + const r = await API("/port-forwards", { method:"POST", body:{ + token: session.token, forward: {...form, target_port: form.target_port || form.wan_port } + }}); + if (r.note) alert(r.note); + setForm({ proto:"tcp", wan_port:"", target_ip:"", target_port:"", description:"" }); + await load(); + } catch(e) { alert("Failed: " + e.message); } + setCreating(false); + }; + + const remove = async (uuid) => { + if (!session) { onNeedAuth(); return; } + await API("/port-forwards", { method:"DELETE", body:{ token:session.token, uuid }}); + await load(); + }; + + return ( +
+
+
+
Port Forwarding — OPNsense NAT
+
+ Forward WAN ports to internal servers. For services behind Caddy (reverse proxy), + you only need port 443 forwarded — Caddy handles routing by hostname. + Use this for non-HTTP services (game servers, SSH, mail, etc.). +
+ +
+
+ +
+
+ setForm(f => ({...f, wan_port: e.target.value}))} + placeholder="25565" type="number"/> +
+
+ setForm(f => ({...f, target_ip: e.target.value}))} + placeholder="192.168.1.100"/> +
+
+ setForm(f => ({...f, target_port: e.target.value}))} + placeholder="same" type="number"/> +
+
+ setForm(f => ({...f, description: e.target.value}))} + placeholder="Minecraft server"/> +
+
+ +
+ + {forwards.length > 0 && ( +
+
Active Port Forwards ({forwards.length})
+
+ + + + {forwards.map((f,i) => ( + + + + + + + + + ))} + +
ProtoWAN PortTargetDescriptionCreated
{f.proto}{f.wan_port}{f.target_ip}:{f.target_port}{f.description || "—"}{f.created_at || "—"}
+
+
+ )} +
+
+ ); +} + + +// ══════════════════════════════════════════════════════════════════════════════ +// POE BUDGET TAB +// ══════════════════════════════════════════════════════════════════════════════ + +function PoETab({ backendOk }) { + const [poe, setPoe] = useState(null); + const [loading, setLoading] = useState(false); + + const load = async () => { + setLoading(true); + try { setPoe(await API("/poe/budget")); } catch(e) { console.error(e); } + setLoading(false); + }; + useEffect(() => { if (backendOk) load(); }, [backendOk]); + + if (!poe || !poe.available) return ( +
+
PoE Budget
+
+ {loading ? "Loading..." : "No PoE data available — switch may be offline"} +
+
+
+ ); + + const pct = poe.percent_used || 0; + const barColor = pct > 90 ? "#ff1744" : pct > 75 ? "#ff6d00" : "#00e676"; + + return ( +
+
+
+
PoE Power Budget
+
+ {/* Budget bar */} +
+
+ Used: {poe.used_watts || "?"}W + Available: {poe.total_watts || "?"}W + Remaining: {poe.remaining_watts || "?"}W +
+
+
+
+ {pct.toFixed(1)}% +
+
+ {pct > 85 && ( +
+ Warning: PoE budget above 85%. New PoE devices may not power up. +
+ )} +
+ + {/* Per-port table */} + {poe.ports?.length > 0 && ( + <> +
Per-Port Power Draw
+
+ {poe.ports.map(p => ( +
0 ? barColor + "15" : "var(--bg)", + border: `1px solid ${p.watts > 0 ? barColor + "30" : "var(--b2)"}`, + }}> +
Port {p.port}
+
0 ? barColor : "var(--dm)"}}>{p.watts}W
+
{p.status}
+
+ ))} +
+ + )} + + +
+
+
+
+ ); +} + + +// ══════════════════════════════════════════════════════════════════════════════ +// TOPOLOGY TAB — network diagram +// ══════════════════════════════════════════════════════════════════════════════ + +function TopologyTab({ vlans, ports, backendOk }) { + const [topo, setTopo] = useState(null); + const [loading, setLoading] = useState(false); + + const load = async () => { + setLoading(true); + try { setTopo(await API("/topology")); } catch(e) { console.error(e); } + setLoading(false); + }; + useEffect(() => { if (backendOk) load(); }, [backendOk]); + + const upPorts = (topo?.ports || []).filter(p => p.link === "up"); + const downPorts = (topo?.ports || []).filter(p => p.link === "down"); + + return ( +
+
+
+
Network Topology
+
+ {/* Router */} +
+
+
OPNsense
+
{topo?.router?.ip || "not configured"}
+ {topo?.router?.version &&
v{topo.router.version}
} +
+ {topo?.router?.connected ? "Online" : "Offline"} +
+
+
+ + {/* Trunk link */} +
+
+
Trunk (all VLANs tagged)
+
+
+ + {/* Switch */} +
+
+
{topo?.switch?.hostname || "ERS-5952"}
+
{topo?.switch?.ip}
+
+ {topo?.switch?.connected ? "Online" : "Offline"} +
+
+ {upPorts.length} ports up, {downPorts.length} down +
+
+
+ + {/* VLANs fan out */} +
+ {vlans.map(v => { + const vlanPorts = ports.filter(p => + (p.mode === "access" && p.accessVlan === v.id) || + (p.mode === "trunk" && p.taggedVlans?.includes(v.id)) + ); + const upCount = vlanPorts.filter(p => { + const tp = (topo?.ports || []).find(tp => tp.id === p.id); + return tp?.link === "up"; + }).length; + const devices = (topo?.devices || []).filter(d => d.vlan === v.id); + return ( +
+
VLAN {v.id}
+
{v.name}
+
+ {vlanPorts.length} ports ({upCount} up) +
+
+ {devices.length} registered device{devices.length !== 1 ? "s" : ""} +
+
+ 192.168.{v.id}.0/24 +
+
+ ); + })} +
+ + +
+
+
+
+ ); +} diff --git a/switch_backend.py b/switch_backend.py index c9aa1be..0484c34 100644 --- a/switch_backend.py +++ b/switch_backend.py @@ -5238,369 +5238,6 @@ def run_schedule_now(body: dict): return {"success": True, "ran": name} -# ══════════════════════════════════════════════════════════════════════ -# FIREWALL POLICY MATRIX — inter-VLAN access control -# ══════════════════════════════════════════════════════════════════════ -# -# Manages both switch ACLs AND OPNsense firewall rules together. -# Policies define what each VLAN pair can do: -# - full: all traffic allowed between VLANs -# - internet: VLAN gets internet only, no RFC1918 access -# - blocked: no traffic between these VLANs -# - service: one-way access (A can reach B, but B cannot reach A) -# - custom: user-defined rules -# -# "service" is the printer pattern: Staff can print, but printers -# can't initiate connections to Staff. - -POLICIES_FILE = _Path("/etc/switch-manager/vlan-policies.json") - -_POLICY_TYPES = {"full", "internet", "blocked", "service", "custom"} - -def _load_policies() -> list: - if POLICIES_FILE.exists(): - try: return _json.loads(POLICIES_FILE.read_text()) - except: pass - return [] - -def _save_policies(policies: list): - POLICIES_FILE.write_text(_json.dumps(policies, indent=2)) - POLICIES_FILE.chmod(0o600) - - -def _policy_to_switch_acl(policy: dict) -> list: - """Generate ERS switch ACL commands for a VLAN policy.""" - ptype = policy.get("type", "blocked") - src_vid = policy.get("src_vlan") - dst_vid = policy.get("dst_vlan") - src_sub = policy.get("src_subnet", f"192.168.{src_vid}.0") - dst_sub = policy.get("dst_subnet", f"192.168.{dst_vid}.0") - acl_name = f"POLICY-V{src_vid}-V{dst_vid}" - cmds = [] - - if ptype == "blocked": - cmds = [ - f"ip access-list extended {acl_name}", - f" 1 deny ip {src_sub} 0.0.0.255 {dst_sub} 0.0.0.255", - f" 2 permit ip any any", - f"interface vlan {src_vid}", - f" ip access-group {acl_name} in", - ] - elif ptype == "full": - cmds = [ - f"ip access-list extended {acl_name}", - f" 1 permit ip {src_sub} 0.0.0.255 {dst_sub} 0.0.0.255", - f" 2 permit ip any any", - f"interface vlan {src_vid}", - f" ip access-group {acl_name} in", - ] - elif ptype == "internet": - cmds = [ - f"ip access-list extended {acl_name}", - f" 1 deny ip {src_sub} 0.0.0.255 10.0.0.0 0.255.255.255", - f" 2 deny ip {src_sub} 0.0.0.255 172.16.0.0 0.15.255.255", - f" 3 deny ip {src_sub} 0.0.0.255 192.168.0.0 0.0.255.255", - f" 4 permit ip any any", - f"interface vlan {src_vid}", - f" ip access-group {acl_name} in", - ] - elif ptype == "service": - # One-way: src can reach dst on specified ports, dst cannot initiate to src - ports = policy.get("ports", []) - cmds = [f"ip access-list extended {acl_name}"] - rule_num = 1 - for p in ports: - proto = p.get("proto", "tcp") - port = p.get("port", "") - if port: - cmds.append( - f" {rule_num} permit {proto} {src_sub} 0.0.0.255 " - f"{dst_sub} 0.0.0.255 eq {port}") - else: - cmds.append( - f" {rule_num} permit {proto} {src_sub} 0.0.0.255 " - f"{dst_sub} 0.0.0.255") - rule_num += 1 - # Deny all other traffic to that VLAN - cmds.append(f" {rule_num} deny ip {src_sub} 0.0.0.255 {dst_sub} 0.0.0.255") - rule_num += 1 - cmds.append(f" {rule_num} permit ip any any") - cmds += [ - f"interface vlan {src_vid}", - f" ip access-group {acl_name} in", - ] - - return cmds - - -def _policy_to_opnsense_rules(policy: dict, cfg: dict) -> list: - """Generate OPNsense firewall API calls for a VLAN policy. - Returns list of {method, path, body} dicts.""" - ptype = policy.get("type", "blocked") - src_vid = policy.get("src_vlan") - dst_vid = policy.get("dst_vlan") - vmap = _load_vlan_if_map() - src_if = vmap.get(str(src_vid), "") - dst_if = vmap.get(str(dst_vid), "") - src_sub = policy.get("src_subnet", f"192.168.{src_vid}.0/24") - dst_sub = policy.get("dst_subnet", f"192.168.{dst_vid}.0/24") - rules = [] - - if not src_if: - return rules # Can't create OPNsense rules without interface mapping - - if ptype == "blocked": - rules.append({ - "rule": { - "enabled": "1", "action": "block", - "interface": src_if, "direction": "in", - "ipprotocol": "inet", "protocol": "any", - "source": {"network": f"{src_if}net"}, - "destination": {"address": dst_sub}, - "descr": f"Policy: block V{src_vid} → V{dst_vid}", - } - }) - elif ptype == "full": - rules.append({ - "rule": { - "enabled": "1", "action": "pass", - "interface": src_if, "direction": "in", - "ipprotocol": "inet", "protocol": "any", - "source": {"network": f"{src_if}net"}, - "destination": {"address": dst_sub}, - "descr": f"Policy: allow V{src_vid} → V{dst_vid}", - } - }) - elif ptype == "internet": - # Block all RFC1918, permit everything else - for net in ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]: - rules.append({ - "rule": { - "enabled": "1", "action": "block", - "interface": src_if, "direction": "in", - "ipprotocol": "inet", "protocol": "any", - "source": {"network": f"{src_if}net"}, - "destination": {"address": net}, - "descr": f"Policy: V{src_vid} internet-only (block {net})", - } - }) - rules.append({ - "rule": { - "enabled": "1", "action": "pass", - "interface": src_if, "direction": "in", - "ipprotocol": "inet", "protocol": "any", - "source": {"network": f"{src_if}net"}, - "destination": {"any": "1"}, - "descr": f"Policy: V{src_vid} internet-only (allow out)", - } - }) - elif ptype == "service": - ports = policy.get("ports", []) - for p in ports: - proto = p.get("proto", "tcp") - port = p.get("port", "") - rule = { - "enabled": "1", "action": "pass", - "interface": src_if, "direction": "in", - "ipprotocol": "inet", "protocol": proto, - "source": {"network": f"{src_if}net"}, - "destination": {"address": dst_sub}, - "descr": f"Policy: V{src_vid} → V{dst_vid} service {proto}/{port}", - } - if port: - rule["destination"]["port"] = str(port) - rules.append({"rule": rule}) - # Block everything else to that VLAN - rules.append({ - "rule": { - "enabled": "1", "action": "block", - "interface": src_if, "direction": "in", - "ipprotocol": "inet", "protocol": "any", - "source": {"network": f"{src_if}net"}, - "destination": {"address": dst_sub}, - "descr": f"Policy: block V{src_vid} → V{dst_vid} (except services above)", - } - }) - - return rules - - -# ── Policy presets ────────────────────────────────────────────────── - -POLICY_PRESETS = [ - { - "id": "printer", - "label": "Printer VLAN — other VLANs can print, printers can't reach out", - "description": "Allows printing (TCP 9100 RAW, TCP 631 IPP, UDP 631 IPP) " - "from source VLAN to printer VLAN. Printers cannot initiate " - "connections back. Printers get internet for firmware updates.", - "type": "service", - "ports": [ - {"proto": "tcp", "port": "9100"}, # RAW printing - {"proto": "tcp", "port": "631"}, # IPP - {"proto": "udp", "port": "631"}, # IPP discovery - {"proto": "tcp", "port": "443"}, # HTTPS (web UI, cloud print) - {"proto": "tcp", "port": "80"}, # HTTP (web UI) - ], - "bidirectional": False, - }, - { - "id": "lan_access_all", - "label": "LAN can reach all VLANs", - "description": "LAN (trusted) has full access to all other VLANs. " - "Other VLANs cannot reach LAN.", - "type": "full", - "bidirectional": False, - }, - { - "id": "iot_isolated", - "label": "IoT — internet only, full isolation", - "description": "Blocks ALL private IP ranges. Devices get internet only. " - "Cannot reach any VLAN, server, NAS, or management network.", - "type": "internet", - "bidirectional": False, - }, - { - "id": "guest_isolated", - "label": "Guest — internet only, strict", - "description": "Same as IoT isolation. Guest devices get internet only.", - "type": "internet", - "bidirectional": False, - }, - { - "id": "camera_nvr", - "label": "Camera VLAN — NVR access only", - "description": "Cameras can only reach the NVR IP. No internet, no other VLANs.", - "type": "service", - "ports": [{"proto": "tcp", "port": ""}], # All TCP to NVR - "bidirectional": False, - "needs_target_ip": True, - }, -] - - -@app.get("/api/policies") -def get_policies(): - """List all VLAN policies and available presets.""" - return { - "policies": _load_policies(), - "presets": POLICY_PRESETS, - } - - -@app.post("/api/policies") -def save_policy(body: dict): - """Add or update a VLAN-to-VLAN policy.""" - require_session(body.get("token", "")) - policy = body.get("policy", {}) - if not policy.get("src_vlan") or not policy.get("type"): - raise HTTPException(400, "src_vlan and type required") - if policy["type"] not in _POLICY_TYPES: - raise HTTPException(400, f"Invalid type: {policy['type']}") - - policies = _load_policies() - # Replace existing policy for same src→dst pair - key = (policy["src_vlan"], policy.get("dst_vlan")) - policies = [p for p in policies if (p["src_vlan"], p.get("dst_vlan")) != key] - policies.append(policy) - _save_policies(policies) - return {"success": True, "policies": policies} - - -@app.delete("/api/policies") -def delete_policy(body: dict): - """Remove a VLAN policy.""" - require_session(body.get("token", "")) - src = body.get("src_vlan") - dst = body.get("dst_vlan") - policies = _load_policies() - policies = [p for p in policies if not (p["src_vlan"] == src and p.get("dst_vlan") == dst)] - _save_policies(policies) - return {"success": True, "policies": policies} - - -@app.post("/api/policies/preview") -def preview_policy(body: dict): - """Preview the switch ACL + OPNsense rules that a policy would generate.""" - policy = body.get("policy", {}) - if not policy.get("src_vlan") or not policy.get("type"): - raise HTTPException(400, "src_vlan and type required") - - switch_cmds = _policy_to_switch_acl(policy) - cfg = _load_opnsense_cfg() - opnsense_rules = _policy_to_opnsense_rules(policy, cfg) - - return { - "switch_commands": switch_cmds, - "opnsense_rules": [r["rule"]["descr"] for r in opnsense_rules], - "opnsense_rule_count": len(opnsense_rules), - } - - -@app.post("/api/policies/push") -def push_policy(body: dict): - """Push a policy to both switch and OPNsense.""" - require_session(body.get("token", "")) - policy = body.get("policy", {}) - if not policy.get("src_vlan") or not policy.get("type"): - raise HTTPException(400, "src_vlan and type required") - - steps_done = [] - errors = [] - - backup = _pre_change_backup(reason=f"pre-policy V{policy['src_vlan']}→V{policy.get('dst_vlan','*')}") - - # Push switch ACLs - switch_cmds = _policy_to_switch_acl(policy) - if switch_cmds: - danger = check_danger(switch_cmds) - if danger["has_hard_block"]: - raise HTTPException(400, {"message": "Hard-blocked commands", "blocked": danger["hard_blocked"]}) - result = push_one_by_one(switch_cmds) - if result.get("success"): - steps_done.append(f"Switch: {len(switch_cmds)} ACL commands pushed") - else: - errors.append(f"Switch push failed: {result.get('error', 'unknown')}") - - # Push OPNsense rules - cfg = _load_opnsense_cfg() - if cfg.get("key"): - opnsense_rules = _policy_to_opnsense_rules(policy, cfg) - for rule_body in opnsense_rules: - try: - _opnsense_request(cfg, "firewall/filter/addRule", "POST", rule_body) - except ValueError as e: - errors.append(f"OPNsense rule: {e}") - if opnsense_rules: - try: - _opnsense_request(cfg, "firewall/filter/apply", "POST") - steps_done.append(f"OPNsense: {len(opnsense_rules)} firewall rules applied") - except ValueError as e: - errors.append(f"OPNsense apply: {e}") - - # Save policy - policies = _load_policies() - key = (policy["src_vlan"], policy.get("dst_vlan")) - policies = [p for p in policies if (p["src_vlan"], p.get("dst_vlan")) != key] - policy["pushed"] = True - policy["pushed_at"] = _ts() - policies.append(policy) - _save_policies(policies) - - # Post-connectivity check - post_conn = _check_connectivity() - if not post_conn["switch"]["ok"]: - errors.append(f"WARNING: Switch connectivity lost after push! Backup: {backup['switch'].get('file','N/A')}") - - return { - "success": len(errors) == 0, - "steps_done": steps_done, - "errors": errors, - "backup": backup, - "post_connectivity": post_conn, - } - - # ══════════════════════════════════════════════════════════════════════ # PORT FORWARDING — manage OPNsense NAT port forwards # ══════════════════════════════════════════════════════════════════════