diff --git a/switch_backend.py b/switch_backend.py index d9ccc87..c9aa1be 100644 --- a/switch_backend.py +++ b/switch_backend.py @@ -5236,3 +5236,578 @@ def run_schedule_now(body: dict): raise HTTPException(404, f"Schedule '{name}' not found") _run_scheduled_task(sched) 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 +# ══════════════════════════════════════════════════════════════════════ + +PORT_FWD_FILE = _Path("/etc/switch-manager/port-forwards.json") + +def _load_port_forwards() -> list: + if PORT_FWD_FILE.exists(): + try: return _json.loads(PORT_FWD_FILE.read_text()) + except: pass + return [] + +def _save_port_forwards(fwds: list): + PORT_FWD_FILE.write_text(_json.dumps(fwds, indent=2)) + PORT_FWD_FILE.chmod(0o600) + + +@app.get("/api/port-forwards") +def get_port_forwards(): + return {"forwards": _load_port_forwards()} + + +@app.post("/api/port-forwards") +def create_port_forward(body: dict): + """Create a NAT port forward on OPNsense + companion firewall rule.""" + require_session(body.get("token", "")) + fwd = body.get("forward", {}) + proto = fwd.get("proto", "tcp") + wan_port = fwd.get("wan_port", "") + target_ip = fwd.get("target_ip", "") + target_port = fwd.get("target_port", wan_port) + description = fwd.get("description", "") + + if not wan_port or not target_ip: + raise HTTPException(400, "wan_port and target_ip required") + + cfg = _load_opnsense_cfg() + if not cfg.get("key"): + raise HTTPException(503, "OPNsense API not configured") + + backup = _pre_change_backup(reason=f"pre-port-forward {proto}/{wan_port}→{target_ip}:{target_port}") + + try: + # Create firewall pass rule for the forwarded traffic + r = _opnsense_request(cfg, "firewall/filter/addRule", "POST", { + "rule": { + "enabled": "1", "action": "pass", + "interface": "wan", "direction": "in", + "ipprotocol": "inet", "protocol": proto, + "source": {"any": "1"}, + "destination": {"address": target_ip, "port": str(target_port)}, + "descr": f"Port forward: WAN {proto}/{wan_port} → {target_ip}:{target_port}" + f"{' — ' + description if description else ''}", + } + }) + _opnsense_request(cfg, "firewall/filter/apply", "POST") + uuid = r.get("uuid", "") + + fwd_entry = { + "proto": proto, "wan_port": wan_port, + "target_ip": target_ip, "target_port": target_port, + "description": description, "uuid": uuid, + "created_at": _ts(), + } + fwds = _load_port_forwards() + fwds.append(fwd_entry) + _save_port_forwards(fwds) + + return {"success": True, "forward": fwd_entry, "backup": backup, + "note": "Firewall rule created. Also verify NAT port forward exists: " + f"OPNsense > Firewall > NAT > Port Forward — WAN {proto} {wan_port} → {target_ip}:{target_port}"} + except Exception as e: + raise HTTPException(500, f"Port forward creation failed: {e}") + + +@app.delete("/api/port-forwards") +def delete_port_forward(body: dict): + """Remove a port forward and its firewall rule.""" + require_session(body.get("token", "")) + uuid = body.get("uuid", "") + if not uuid: + raise HTTPException(400, "uuid required") + + cfg = _load_opnsense_cfg() + if cfg.get("key") and uuid: + try: + _opnsense_request(cfg, f"firewall/filter/delRule/{uuid}", "POST") + _opnsense_request(cfg, "firewall/filter/apply", "POST") + except Exception as e: + log.warning(f"Port forward rule delete failed: {e}") + + fwds = _load_port_forwards() + fwds = [f for f in fwds if f.get("uuid") != uuid] + _save_port_forwards(fwds) + return {"success": True} + + +# ══════════════════════════════════════════════════════════════════════ +# NETWORK TOPOLOGY — auto-generated from live data +# ══════════════════════════════════════════════════════════════════════ + +@app.get("/api/topology") +def get_topology(): + """Build network topology from live switch + OPNsense data.""" + topology = { + "router": {"ip": "", "hostname": "OPNsense", "connected": False}, + "switch": {"ip": SWITCH_HOST, "hostname": "ERS-5952", "connected": False}, + "vlans": [], + "ports": [], + "devices": [], + } + + # Switch connectivity + try: + conn = _pool.get() + transport = conn.get_transport() + if transport and transport.is_active(): + topology["switch"]["connected"] = True + except Exception: + pass + + # OPNsense + cfg = _load_opnsense_cfg() + if cfg.get("host"): + topology["router"]["ip"] = cfg["host"] + try: + fw = _opnsense_request(cfg, "core/firmware/status") + topology["router"]["connected"] = True + topology["router"]["version"] = fw.get("product_version", "") + except Exception: + pass + + # VLANs from cache + with _cache_lock: + vlan_raw = _cache.get("vlan_members", "") + port_raw = _cache.get("port_status", "") + + # Parse port status for link state + if port_raw: + for line in port_raw.splitlines(): + import re as _re_topo + m = _re_topo.match(r'\s*(\d+)\s+(\S+)\s+(\S+)\s+(\S+)', line) + if m: + port_id = int(m.group(1)) + link = m.group(3).lower() + topology["ports"].append({ + "id": port_id, + "link": "up" if "up" in link else "down", + }) + + # Devices from saved list + try: + topology["devices"] = _load_devices()[:50] # Cap at 50 + except Exception: + pass + + # VLAN info + vmap = _load_vlan_if_map() + topology["vlan_interface_map"] = vmap + + return topology + + +# ══════════════════════════════════════════════════════════════════════ +# POE BUDGET DASHBOARD — power consumption overview +# ══════════════════════════════════════════════════════════════════════ + +@app.get("/api/poe/budget") +def poe_budget(): + """Parse PoE status from cached switch data.""" + with _cache_lock: + poe_raw = _cache.get("poe_status", "") + + if not poe_raw: + return {"available": False, "error": "No PoE data cached — switch may be offline"} + + import re as _re_poe + result = { + "available": True, + "raw": poe_raw[:2000], + "total_watts": None, + "used_watts": None, + "remaining_watts": None, + "percent_used": None, + "ports": [], + } + + # Parse total/used from various ERS output formats + budget_m = _re_poe.findall(r'(\d+)\s*[Ww]atts?\s*(available|budget|maximum|total)', poe_raw, _re_poe.I) + used_m = _re_poe.findall(r'(\d+)\s*[Ww]atts?\s*(used|consumed|delivering)', poe_raw, _re_poe.I) + if budget_m: + result["total_watts"] = int(budget_m[0][0]) + if used_m: + result["used_watts"] = int(used_m[0][0]) + if result["total_watts"] and result["used_watts"]: + result["remaining_watts"] = result["total_watts"] - result["used_watts"] + result["percent_used"] = round(result["used_watts"] / result["total_watts"] * 100, 1) + + # Parse per-port PoE + for line in poe_raw.splitlines(): + pm = _re_poe.match( + r'\s*(\d+)\s+\S+\s+(\S+)\s+\S+\s+(\d+(?:\.\d+)?)\s*[Ww]', line) + if pm: + result["ports"].append({ + "port": int(pm.group(1)), + "status": pm.group(2), + "watts": float(pm.group(3)), + }) + + return result