feat: add active firewall rules display to workload insights with managed rule detection and enable status
Add active_firewall_rules_for_workload helper to fetch live firewall rules from provider with NexaFabric policy comment detection and enable status, implement list_firewall_rules method in ProxmoxProvider to retrieve rules via firewall API endpoint, extend WorkloadInsight schema with active_firewall_rules field, add ActiveRulesList component showing rule type/action/protocol/port with enable status and
This commit is contained in:
@@ -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'})"
|
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]]:
|
def dashboard_top_talkers(db: Session) -> list[dict[str, int | str]]:
|
||||||
totals: dict[str, int] = {}
|
totals: dict[str, int] = {}
|
||||||
workloads = db.scalars(select(Workload)).all()
|
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)
|
@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)
|
workload = db.get(Workload, workload_id)
|
||||||
if not workload:
|
if not workload:
|
||||||
raise HTTPException(status_code=404, detail="Workload not found")
|
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,
|
workload=workload,
|
||||||
assigned_ips=[ip_address_payload(db, address) for address in assigned_ips],
|
assigned_ips=[ip_address_payload(db, address) for address in assigned_ips],
|
||||||
traffic=traffic,
|
traffic=traffic,
|
||||||
|
active_firewall_rules=await active_firewall_rules_for_workload(db, workload),
|
||||||
matching_policies=policies,
|
matching_policies=policies,
|
||||||
effective_decision=decision,
|
effective_decision=decision,
|
||||||
audit_mode_notes=audit_mode_notes,
|
audit_mode_notes=audit_mode_notes,
|
||||||
|
|||||||
@@ -315,6 +315,7 @@ class WorkloadInsight(BaseModel):
|
|||||||
workload: WorkloadRead
|
workload: WorkloadRead
|
||||||
assigned_ips: list[IpAddressRead]
|
assigned_ips: list[IpAddressRead]
|
||||||
traffic: list[dict[str, Any]]
|
traffic: list[dict[str, Any]]
|
||||||
|
active_firewall_rules: list[dict[str, Any]] = []
|
||||||
matching_policies: list[PolicyRead]
|
matching_policies: list[PolicyRead]
|
||||||
effective_decision: str
|
effective_decision: str
|
||||||
audit_mode_notes: list[str]
|
audit_mode_notes: list[str]
|
||||||
|
|||||||
@@ -127,6 +127,13 @@ class ProxmoxProvider(Provider):
|
|||||||
"warnings": ["Preview only. No Proxmox firewall changes were sent."],
|
"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:
|
def firewall_rules_url(self, connection: ProviderConnection, target: dict[str, Any]) -> str:
|
||||||
kind = "lxc" if target.get("kind") == "lxc" else "qemu"
|
kind = "lxc" if target.get("kind") == "lxc" else "qemu"
|
||||||
node = target["node"]
|
node = target["node"]
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ export type WorkloadInsight = {
|
|||||||
workload: Workload;
|
workload: Workload;
|
||||||
assigned_ips: IpAddress[];
|
assigned_ips: IpAddress[];
|
||||||
traffic: Array<Record<string, unknown>>;
|
traffic: Array<Record<string, unknown>>;
|
||||||
|
active_firewall_rules: Array<Record<string, unknown>>;
|
||||||
matching_policies: Policy[];
|
matching_policies: Policy[];
|
||||||
effective_decision: string;
|
effective_decision: string;
|
||||||
audit_mode_notes: string[];
|
audit_mode_notes: string[];
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
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 { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
import { api, Workload, WorkloadInsight } from "../api/client";
|
import { api, Workload, WorkloadInsight } from "../api/client";
|
||||||
@@ -143,6 +143,41 @@ function CompactFlowList({ traffic }: { traffic: TrafficSummary[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ruleLabel(rule: Record<string, unknown>) {
|
||||||
|
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<Record<string, unknown>>; compact?: boolean }) {
|
||||||
|
const visibleRules = compact ? rules.slice(0, 3) : rules;
|
||||||
|
if (!rules.length) {
|
||||||
|
return <div className="rounded-md border border-border p-2 text-xs text-slate-500">No active firewall rules were read for this workload.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="divide-y divide-border rounded-md border border-border">
|
||||||
|
{visibleRules.map((rule, index) => (
|
||||||
|
<div key={`${String(rule.pos ?? index)}-${index}`} className="grid grid-cols-[1fr_auto] gap-3 px-3 py-2 text-xs">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate font-medium">{ruleLabel(rule)}</div>
|
||||||
|
<div className="truncate text-slate-500">{String(rule.comment ?? (rule.managed_by_nexafabric ? "NexaFabric managed" : "manual or provider rule"))}</div>
|
||||||
|
</div>
|
||||||
|
<div className={rule.enable === 0 ? "text-slate-500" : "text-accent"}>{rule.enable === 0 ? "off" : "on"}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{compact && rules.length > visibleRules.length ? (
|
||||||
|
<div className="px-3 py-2 text-xs text-slate-500">{rules.length - visibleRules.length} more rule{rules.length - visibleRules.length === 1 ? "" : "s"} on detail page.</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ProtocolChart({ traffic }: { traffic: TrafficSummary[] }) {
|
function ProtocolChart({ traffic }: { traffic: TrafficSummary[] }) {
|
||||||
const protocolTotals = Array.from(
|
const protocolTotals = Array.from(
|
||||||
traffic.reduce((map, flow) => map.set(flow.protocol, (map.get(flow.protocol) ?? 0) + flow.bytes), new Map<string, number>()),
|
traffic.reduce((map, flow) => map.set(flow.protocol, (map.get(flow.protocol) ?? 0) + flow.bytes), new Map<string, number>()),
|
||||||
@@ -308,6 +343,10 @@ export function Workloads() {
|
|||||||
<div className="mb-1.5 text-sm font-medium">Top Flows</div>
|
<div className="mb-1.5 text-sm font-medium">Top Flows</div>
|
||||||
<CompactFlowList traffic={traffic} />
|
<CompactFlowList traffic={traffic} />
|
||||||
</section>
|
</section>
|
||||||
|
<section>
|
||||||
|
<div className="mb-1.5 flex items-center gap-2 text-sm font-medium"><Shield size={15} /> Active Rules</div>
|
||||||
|
<ActiveRulesList rules={insight.data.active_firewall_rules ?? []} compact />
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-sm text-slate-500">Select a workload.</div>
|
<div className="text-sm text-slate-500">Select a workload.</div>
|
||||||
@@ -374,6 +413,10 @@ export function WorkloadDetail() {
|
|||||||
{!insight.data.assigned_ips.length ? <div className="text-xs text-slate-500">No assigned IPs discovered.</div> : null}
|
{!insight.data.assigned_ips.length ? <div className="text-xs text-slate-500">No assigned IPs discovered.</div> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="rounded-md border border-border bg-panel p-4">
|
||||||
|
<div className="mb-3 flex items-center gap-2 font-medium"><Shield size={16} /> Active Firewall Rules</div>
|
||||||
|
<ActiveRulesList rules={insight.data.active_firewall_rules ?? []} />
|
||||||
|
</div>
|
||||||
<div className="rounded-md border border-border bg-panel p-4">
|
<div className="rounded-md border border-border bg-panel p-4">
|
||||||
<div className="mb-3 font-medium">Matching Policies</div>
|
<div className="mb-3 font-medium">Matching Policies</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
Reference in New Issue
Block a user