diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index a14e066..9cd3ff4 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -299,6 +299,40 @@ def flow_ip_label(owner: Workload | None, subnets: list[Subnet], value: str) -> return f"{value} ({'internal' if subnet_label_for_ip(subnets, value) else 'external'})" +async def active_firewall_rules_for_workload(db: Session, workload: Workload) -> list[dict[str, object]]: + cluster = db.get(Cluster, workload.cluster_id) + if not cluster: + return [] + resolved = workload_provider_target(db, cluster, f"workload:{workload.id}") + if not resolved: + return [] + provider_target, _ = resolved + provider = get_provider(cluster.provider) + list_rules = getattr(provider, "list_firewall_rules", None) + if not list_rules: + return [] + try: + rules = await list_rules( + ProviderConnection( + api_url=cluster.api_url, + token=cluster.token_ref or "", + verify_tls=cluster.verify_tls, + read_only=True, + ), + provider_target, + ) + except Exception as exc: + return [{"error": f"Unable to read active firewall rules: {exc}", "target": provider_target}] + return [ + { + **rule, + "target": provider_target, + "managed_by_nexafabric": "NexaFabric policy=" in str(rule.get("comment") or ""), + } + for rule in rules + ] + + def dashboard_top_talkers(db: Session) -> list[dict[str, int | str]]: totals: dict[str, int] = {} workloads = db.scalars(select(Workload)).all() @@ -994,7 +1028,7 @@ def workloads(_: CurrentUser, db: Session = Depends(get_db)) -> list[Workload]: @api_router.get("/vms/{workload_id}/insights", response_model=WorkloadInsight) -def workload_insights(workload_id: str, _: CurrentUser, db: Session = Depends(get_db)) -> WorkloadInsight: +async def workload_insights(workload_id: str, _: CurrentUser, db: Session = Depends(get_db)) -> WorkloadInsight: workload = db.get(Workload, workload_id) if not workload: raise HTTPException(status_code=404, detail="Workload not found") @@ -1076,6 +1110,7 @@ def workload_insights(workload_id: str, _: CurrentUser, db: Session = Depends(ge workload=workload, assigned_ips=[ip_address_payload(db, address) for address in assigned_ips], traffic=traffic, + active_firewall_rules=await active_firewall_rules_for_workload(db, workload), matching_policies=policies, effective_decision=decision, audit_mode_notes=audit_mode_notes, diff --git a/backend/app/schemas/domain.py b/backend/app/schemas/domain.py index bf5c389..f53d513 100644 --- a/backend/app/schemas/domain.py +++ b/backend/app/schemas/domain.py @@ -315,6 +315,7 @@ class WorkloadInsight(BaseModel): workload: WorkloadRead assigned_ips: list[IpAddressRead] traffic: list[dict[str, Any]] + active_firewall_rules: list[dict[str, Any]] = [] matching_policies: list[PolicyRead] effective_decision: str audit_mode_notes: list[str] diff --git a/backend/app/services/providers/proxmox.py b/backend/app/services/providers/proxmox.py index 895826b..00158bc 100644 --- a/backend/app/services/providers/proxmox.py +++ b/backend/app/services/providers/proxmox.py @@ -127,6 +127,13 @@ class ProxmoxProvider(Provider): "warnings": ["Preview only. No Proxmox firewall changes were sent."], } + async def list_firewall_rules(self, connection: ProviderConnection, target: dict[str, Any]) -> list[dict[str, Any]]: + headers = {"Authorization": self.auth_header(connection.token)} + async with httpx.AsyncClient(verify=connection.verify_tls, timeout=10) as client: + response = await client.get(self.firewall_rules_url(connection, target), headers=headers) + response.raise_for_status() + return response.json().get("data", []) + def firewall_rules_url(self, connection: ProviderConnection, target: dict[str, Any]) -> str: kind = "lxc" if target.get("kind") == "lxc" else "qemu" node = target["node"] diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 49dfbc3..78e18be 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -167,6 +167,7 @@ export type WorkloadInsight = { workload: Workload; assigned_ips: IpAddress[]; traffic: Array>; + active_firewall_rules: Array>; matching_policies: Policy[]; effective_decision: string; audit_mode_notes: string[]; diff --git a/frontend/src/pages/Workloads.tsx b/frontend/src/pages/Workloads.tsx index d644dd2..c0baaa0 100644 --- a/frontend/src/pages/Workloads.tsx +++ b/frontend/src/pages/Workloads.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { Activity, ArrowRight, CircuitBoard, Hash, Network, ShieldCheck } from "lucide-react"; +import { Activity, ArrowRight, CircuitBoard, Hash, Network, Shield, ShieldCheck } from "lucide-react"; import { Link, useNavigate, useParams } from "react-router-dom"; import { api, Workload, WorkloadInsight } from "../api/client"; @@ -143,6 +143,41 @@ function CompactFlowList({ traffic }: { traffic: TrafficSummary[] }) { ); } +function ruleLabel(rule: Record) { + if (rule.error) { + return String(rule.error); + } + const type = String(rule.type ?? "rule"); + const action = String(rule.action ?? "unknown"); + const proto = rule.proto ? String(rule.proto) : "any"; + const port = rule.dport || rule.sport ? `:${String(rule.dport ?? rule.sport)}` : ""; + return `${type} ${action} ${proto}${port}`; +} + +function ActiveRulesList({ rules, compact = false }: { rules: Array>; compact?: boolean }) { + const visibleRules = compact ? rules.slice(0, 3) : rules; + if (!rules.length) { + return
No active firewall rules were read for this workload.
; + } + + return ( +
+ {visibleRules.map((rule, index) => ( +
+
+
{ruleLabel(rule)}
+
{String(rule.comment ?? (rule.managed_by_nexafabric ? "NexaFabric managed" : "manual or provider rule"))}
+
+
{rule.enable === 0 ? "off" : "on"}
+
+ ))} + {compact && rules.length > visibleRules.length ? ( +
{rules.length - visibleRules.length} more rule{rules.length - visibleRules.length === 1 ? "" : "s"} on detail page.
+ ) : null} +
+ ); +} + function ProtocolChart({ traffic }: { traffic: TrafficSummary[] }) { const protocolTotals = Array.from( traffic.reduce((map, flow) => map.set(flow.protocol, (map.get(flow.protocol) ?? 0) + flow.bytes), new Map()), @@ -308,6 +343,10 @@ export function Workloads() {
Top Flows
+
+
Active Rules
+ +
) : (
Select a workload.
@@ -374,6 +413,10 @@ export function WorkloadDetail() { {!insight.data.assigned_ips.length ?
No assigned IPs discovered.
: null} +
+
Active Firewall Rules
+ +
Matching Policies