Add port forwarding, PoE dashboard, topology tabs + deduplicate firewall
Removed duplicate firewall policy endpoints (kept existing ones at /api/firewall/* which match the frontend). Port Forwarding tab: - Create/delete OPNsense NAT port forwards via API - Track rule UUIDs for clean removal - Form: protocol, WAN port, target IP:port, description - Table: active forwards with one-click remove - Note: for HTTP services, use Services tab (Caddy) instead PoE Budget tab: - Visual power bar: used/total/remaining watts with percentage - Color-coded thresholds: green (<75%), orange (75-90%), red (>90%) - Warning banner when budget exceeds 85% - Per-port power draw grid with status indicators - Auto-parsed from cached switch PoE status Network Topology tab: - Auto-generated from live switch + OPNsense data - Router node: IP, version, online/offline status - Switch node: hostname, IP, port up/down counts - Trunk link visualization between router and switch - VLAN fan-out cards: port counts, device counts, subnets - One-click refresh https://claude.ai/code/session_01Do9bsN39MTuy2GVv7yzSrE
This commit is contained in:
@@ -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" && <PortForwardTab
|
||||
session={session}
|
||||
onNeedAuth={() => setShowTotp(true)}
|
||||
backendOk={pollStatus!=="err"}
|
||||
/>}
|
||||
{tab==="poe" && <PoETab backendOk={pollStatus!=="err"} />}
|
||||
{tab==="topology" && <TopologyTab
|
||||
vlans={vlans} ports={ports}
|
||||
backendOk={pollStatus!=="err"}
|
||||
/>}
|
||||
|
||||
{showTotp && <TotpModal
|
||||
onSuccess={handleTotpSuccess}
|
||||
@@ -5608,3 +5621,311 @@ function VlanScheduleWizard({ vlans, session, onNeedAuth, onSaved }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// 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 (
|
||||
<div className="main">
|
||||
<div style={{flex:1}}>
|
||||
<div className="panel">
|
||||
<div className="ph">Port Forwarding — OPNsense NAT</div>
|
||||
<div className="pb" style={{fontSize:12,color:"var(--dm)",lineHeight:1.6,marginBottom:12}}>
|
||||
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.).
|
||||
</div>
|
||||
|
||||
<div style={{display:"grid",gridTemplateColumns:"80px 100px 1fr 100px 1fr",gap:12,alignItems:"flex-end"}}>
|
||||
<div className="field"><label>Protocol</label>
|
||||
<select value={form.proto} onChange={e => setForm(f => ({...f, proto: e.target.value}))}>
|
||||
<option value="tcp">TCP</option>
|
||||
<option value="udp">UDP</option>
|
||||
<option value="tcp/udp">Both</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="field"><label>WAN Port</label>
|
||||
<input value={form.wan_port} onChange={e => setForm(f => ({...f, wan_port: e.target.value}))}
|
||||
placeholder="25565" type="number"/>
|
||||
</div>
|
||||
<div className="field"><label>Target IP (LAN server)</label>
|
||||
<input value={form.target_ip} onChange={e => setForm(f => ({...f, target_ip: e.target.value}))}
|
||||
placeholder="192.168.1.100"/>
|
||||
</div>
|
||||
<div className="field"><label>Target Port</label>
|
||||
<input value={form.target_port} onChange={e => setForm(f => ({...f, target_port: e.target.value}))}
|
||||
placeholder="same" type="number"/>
|
||||
</div>
|
||||
<div className="field"><label>Description</label>
|
||||
<input value={form.description} onChange={e => setForm(f => ({...f, description: e.target.value}))}
|
||||
placeholder="Minecraft server"/>
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn bp" onClick={create} disabled={creating || !form.wan_port || !form.target_ip}
|
||||
style={{marginTop:12}}>
|
||||
{creating ? "Creating..." : "Create Port Forward"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{forwards.length > 0 && (
|
||||
<div className="panel">
|
||||
<div className="ph">Active Port Forwards ({forwards.length})</div>
|
||||
<div className="pb">
|
||||
<table className="vtbl">
|
||||
<thead><tr><th>Proto</th><th>WAN Port</th><th>Target</th><th>Description</th><th>Created</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{forwards.map((f,i) => (
|
||||
<tr key={i}>
|
||||
<td style={{fontWeight:600,textTransform:"uppercase"}}>{f.proto}</td>
|
||||
<td style={{fontFamily:"monospace",color:"var(--ac)"}}>{f.wan_port}</td>
|
||||
<td style={{fontFamily:"monospace"}}>{f.target_ip}:{f.target_port}</td>
|
||||
<td>{f.description || "—"}</td>
|
||||
<td style={{fontSize:11,color:"var(--dm)"}}>{f.created_at || "—"}</td>
|
||||
<td><button className="btn bd" style={{fontSize:10,padding:"2px 8px"}}
|
||||
onClick={() => remove(f.uuid)}>Remove</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// 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 (
|
||||
<div className="main"><div style={{flex:1}}>
|
||||
<div className="panel"><div className="ph">PoE Budget</div>
|
||||
<div className="pb" style={{color:"var(--dm)",fontSize:12,textAlign:"center",padding:24}}>
|
||||
{loading ? "Loading..." : "No PoE data available — switch may be offline"}
|
||||
</div>
|
||||
</div>
|
||||
</div></div>
|
||||
);
|
||||
|
||||
const pct = poe.percent_used || 0;
|
||||
const barColor = pct > 90 ? "#ff1744" : pct > 75 ? "#ff6d00" : "#00e676";
|
||||
|
||||
return (
|
||||
<div className="main">
|
||||
<div style={{flex:1}}>
|
||||
<div className="panel">
|
||||
<div className="ph">PoE Power Budget</div>
|
||||
<div className="pb">
|
||||
{/* Budget bar */}
|
||||
<div style={{marginBottom:20}}>
|
||||
<div style={{display:"flex",justifyContent:"space-between",fontSize:12,marginBottom:6}}>
|
||||
<span>Used: <b style={{color:barColor}}>{poe.used_watts || "?"}W</b></span>
|
||||
<span>Available: <b>{poe.total_watts || "?"}W</b></span>
|
||||
<span>Remaining: <b style={{color:"#00e676"}}>{poe.remaining_watts || "?"}W</b></span>
|
||||
</div>
|
||||
<div style={{height:24,background:"var(--b1)",borderRadius:12,overflow:"hidden",position:"relative"}}>
|
||||
<div style={{
|
||||
height:"100%",width:`${Math.min(pct,100)}%`,background:barColor,
|
||||
borderRadius:12,transition:"width 0.5s",
|
||||
}}/>
|
||||
<div style={{
|
||||
position:"absolute",top:0,left:0,right:0,bottom:0,
|
||||
display:"flex",alignItems:"center",justifyContent:"center",
|
||||
fontSize:12,fontWeight:700,color:"var(--tx)",
|
||||
}}>
|
||||
{pct.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
{pct > 85 && (
|
||||
<div style={{marginTop:8,fontSize:12,color:"#ff6d00",fontWeight:600}}>
|
||||
Warning: PoE budget above 85%. New PoE devices may not power up.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Per-port table */}
|
||||
{poe.ports?.length > 0 && (
|
||||
<>
|
||||
<div className="sect">Per-Port Power Draw</div>
|
||||
<div style={{display:"flex",flexWrap:"wrap",gap:6}}>
|
||||
{poe.ports.map(p => (
|
||||
<div key={p.port} style={{
|
||||
padding:"6px 10px",borderRadius:4,fontSize:11,minWidth:70,textAlign:"center",
|
||||
background: p.watts > 0 ? barColor + "15" : "var(--bg)",
|
||||
border: `1px solid ${p.watts > 0 ? barColor + "30" : "var(--b2)"}`,
|
||||
}}>
|
||||
<div style={{fontWeight:700,color:"var(--ac)"}}>Port {p.port}</div>
|
||||
<div style={{color: p.watts > 0 ? barColor : "var(--dm)"}}>{p.watts}W</div>
|
||||
<div style={{fontSize:9,color:"var(--dm)"}}>{p.status}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button className="btn bd" onClick={load} disabled={loading}
|
||||
style={{marginTop:16,fontSize:11,padding:"4px 12px"}}>
|
||||
{loading ? "Loading..." : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// 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 (
|
||||
<div className="main">
|
||||
<div style={{flex:1}}>
|
||||
<div className="panel">
|
||||
<div className="ph">Network Topology</div>
|
||||
<div className="pb">
|
||||
{/* Router */}
|
||||
<div style={{textAlign:"center",marginBottom:20}}>
|
||||
<div style={{
|
||||
display:"inline-block",padding:"16px 32px",borderRadius:8,
|
||||
background: topo?.router?.connected ? "rgba(0,230,118,0.1)" : "rgba(255,23,68,0.1)",
|
||||
border: `2px solid ${topo?.router?.connected ? "#00e676" : "#ff1744"}`,
|
||||
}}>
|
||||
<div style={{fontSize:16,fontWeight:700,color:"var(--ac)"}}>OPNsense</div>
|
||||
<div style={{fontSize:12,color:"var(--dm)"}}>{topo?.router?.ip || "not configured"}</div>
|
||||
{topo?.router?.version && <div style={{fontSize:10,color:"var(--dm)"}}>v{topo.router.version}</div>}
|
||||
<div style={{fontSize:11,marginTop:4,color:topo?.router?.connected?"#00e676":"#ff1744"}}>
|
||||
{topo?.router?.connected ? "Online" : "Offline"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trunk link */}
|
||||
<div style={{textAlign:"center",marginBottom:20}}>
|
||||
<div style={{width:2,height:30,background:"var(--ac)",margin:"0 auto"}}/>
|
||||
<div style={{fontSize:10,color:"var(--dm)"}}>Trunk (all VLANs tagged)</div>
|
||||
<div style={{width:2,height:30,background:"var(--ac)",margin:"0 auto"}}/>
|
||||
</div>
|
||||
|
||||
{/* Switch */}
|
||||
<div style={{textAlign:"center",marginBottom:20}}>
|
||||
<div style={{
|
||||
display:"inline-block",padding:"16px 32px",borderRadius:8,
|
||||
background: topo?.switch?.connected ? "rgba(0,230,118,0.1)" : "rgba(255,23,68,0.1)",
|
||||
border: `2px solid ${topo?.switch?.connected ? "#00e676" : "#ff1744"}`,
|
||||
}}>
|
||||
<div style={{fontSize:16,fontWeight:700,color:"var(--ac)"}}>{topo?.switch?.hostname || "ERS-5952"}</div>
|
||||
<div style={{fontSize:12,color:"var(--dm)"}}>{topo?.switch?.ip}</div>
|
||||
<div style={{fontSize:11,marginTop:4,color:topo?.switch?.connected?"#00e676":"#ff1744"}}>
|
||||
{topo?.switch?.connected ? "Online" : "Offline"}
|
||||
</div>
|
||||
<div style={{fontSize:11,color:"var(--dm)",marginTop:4}}>
|
||||
{upPorts.length} ports up, {downPorts.length} down
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* VLANs fan out */}
|
||||
<div style={{display:"flex",flexWrap:"wrap",gap:12,justifyContent:"center",marginTop:20}}>
|
||||
{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 (
|
||||
<div key={v.id} style={{
|
||||
padding:"12px 16px",borderRadius:8,minWidth:150,textAlign:"center",
|
||||
background: v.color + "10",border:`1px solid ${v.color}40`,
|
||||
}}>
|
||||
<div style={{fontWeight:700,color:v.color,fontSize:14}}>VLAN {v.id}</div>
|
||||
<div style={{fontSize:12,color:"var(--tx)"}}>{v.name}</div>
|
||||
<div style={{fontSize:11,color:"var(--dm)",marginTop:6}}>
|
||||
{vlanPorts.length} ports ({upCount} up)
|
||||
</div>
|
||||
<div style={{fontSize:11,color:"var(--dm)"}}>
|
||||
{devices.length} registered device{devices.length !== 1 ? "s" : ""}
|
||||
</div>
|
||||
<div style={{fontSize:10,color:"var(--dm)",marginTop:2}}>
|
||||
192.168.{v.id}.0/24
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button className="btn bd" onClick={load} disabled={loading}
|
||||
style={{marginTop:20,fontSize:11,padding:"4px 12px"}}>
|
||||
{loading ? "Loading..." : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user