From 571d1513e7653c4736f0962c6981b7a75f226c59 Mon Sep 17 00:00:00 2001 From: nessi Date: Thu, 9 Jul 2026 15:50:42 +0200 Subject: [PATCH] feat: add suspicious traffic detection dashboard widget with sensitive port monitoring and security posture indicator Add dashboard_suspicious_traffic to detect external connections to sensitive ports (SSH/RDP/SMB/VNC/PostgreSQL/MySQL/Redis) from outside IPAM subnets with severity classification, extend Dashboard type with security_posture/suspicious_traffic/last_syncs fields, implement BarList component for traffic visualization with percentage bars and byte formatting, add security posture card --- backend/app/api/v1/router.py | 46 ++++++++++- frontend/src/api/client.ts | 11 +++ frontend/src/components/Layout.tsx | 20 ++++- frontend/src/pages/Dashboard.tsx | 122 ++++++++++++++++++++++++++--- 4 files changed, 182 insertions(+), 17 deletions(-) diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 1bf31f2..a35dae8 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -334,6 +334,47 @@ def dashboard_top_talkers(db: Session) -> list[dict[str, int | str]]: ] +def dashboard_suspicious_traffic(db: Session) -> list[dict[str, int | str]]: + sensitive_ports = { + 22: "SSH exposed from outside IPAM", + 3389: "RDP exposed from outside IPAM", + 445: "SMB exposed from outside IPAM", + 5900: "VNC exposed from outside IPAM", + 5432: "PostgreSQL exposed from outside IPAM", + 3306: "MySQL exposed from outside IPAM", + 6379: "Redis exposed from outside IPAM", + } + subnets = db.scalars(select(Subnet).order_by(Subnet.cidr)).all() + workload_ips = { + address.address + for address in db.scalars(select(IpAddress).where(IpAddress.workload_id.is_not(None))).all() + } + events: dict[tuple[str, str, int], dict[str, int | str]] = {} + for flow in db.scalars(select(TrafficFlow).order_by(TrafficFlow.updated_at.desc()).limit(500)).all(): + port = flow.destination_port or 0 + if port not in sensitive_ports: + continue + source_internal = bool(subnet_label_for_ip(subnets, flow.source_ip)) + destination_internal = bool(subnet_label_for_ip(subnets, flow.destination_ip)) or flow.destination_ip in workload_ips + if source_internal or not destination_internal: + continue + key = (flow.source_ip, flow.destination_ip, port) + event = events.setdefault( + key, + { + "source": flow.source_ip, + "destination": flow.destination_ip, + "protocol": flow.protocol, + "port": port, + "bytes": 0, + "reason": sensitive_ports[port], + "severity": "high" if port in {22, 3389, 445} else "medium", + }, + ) + event["bytes"] = int(event["bytes"]) + int(flow.bytes or 0) + return sorted(events.values(), key=lambda item: int(item["bytes"]), reverse=True)[:5] + + def proxmox_action(action: str) -> str: return {"allow": "ACCEPT", "deny": "DROP", "reject": "REJECT"}.get(action, "ACCEPT") @@ -455,12 +496,15 @@ def complete_setup(payload: SetupCompleteRequest, db: Session = Depends(get_db)) def dashboard(_: CurrentUser, db: Session = Depends(get_db)) -> dict: last_syncs = db.scalars(select(Cluster).order_by(Cluster.updated_at.desc()).limit(5)).all() faulty_nodes = db.scalars(select(Node).where(Node.status != "online")).all() + suspicious = dashboard_suspicious_traffic(db) return { "clusters": db.scalar(select(func.count()).select_from(Cluster)), "nodes": db.scalar(select(func.count()).select_from(Node)), "workloads": db.scalar(select(func.count()).select_from(Workload)), "networks": db.scalar(select(func.count()).select_from(Network)), - "open_policy_violations": 1, + "open_policy_violations": len(suspicious), + "security_posture": "attention" if suspicious or faulty_nodes else "stable", + "suspicious_traffic": suspicious, "last_syncs": [ { "id": cluster.id, diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 7f9a6e0..49dfbc3 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -6,8 +6,19 @@ export type Dashboard = { workloads: number; networks: number; open_policy_violations: number; + security_posture: string; faulty_nodes: Array<{ id: string; name: string; status: string }>; + last_syncs: Array<{ id: string; name: string; provider: string; status: string | null; error: string | null; at: string | null }>; top_talkers: Array<{ name: string; bytes: number }>; + suspicious_traffic: Array<{ + source: string; + destination: string; + protocol: string; + port: number; + bytes: number; + reason: string; + severity: string; + }>; }; export type SetupStatus = { diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 98f135b..df4ec01 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -9,6 +9,7 @@ import { Flame, GitBranch, LayoutDashboard, + LogOut, LockKeyhole, Moon, Network, @@ -22,7 +23,7 @@ import { } from "lucide-react"; import { useEffect } from "react"; -import { token } from "../api/client"; +import { clearTokens, token } from "../api/client"; import { useTheme } from "../stores/theme"; const navGroups = [ @@ -92,6 +93,11 @@ export function Layout() { const navigate = useNavigate(); const { dark, toggle } = useTheme(); + function logout() { + clearTokens(); + navigate("/login"); + } + useEffect(() => { if (!token()) navigate("/login"); function handleAuthExpired() { @@ -135,9 +141,15 @@ export function Layout() {
SDN-like network and security operations
- +
+ + +
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index f805597..71347b4 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import { AlertTriangle, Boxes, Network, Server, ShieldAlert } from "lucide-react"; +import { Activity, AlertTriangle, Boxes, Network, Radar, Server, ShieldAlert, ShieldCheck, Wifi } from "lucide-react"; import { api, Dashboard as DashboardData } from "../api/client"; import { PageHeader } from "../components/PageHeader"; @@ -8,16 +8,78 @@ const cards = [ ["clusters", "Clusters", Server], ["nodes", "Nodes", Boxes], ["workloads", "VMs/LXCs", Network], - ["networks", "Networks", Network], - ["open_policy_violations", "Policy Violations", ShieldAlert], + ["networks", "Networks", Wifi], + ["open_policy_violations", "Signals", ShieldAlert], ] as const; +function formatBytes(value: number) { + if (!value) { + return "0 B"; + } + const units = ["B", "KB", "MB", "GB", "TB"]; + const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1); + return `${(value / 1024 ** index).toFixed(index === 0 ? 0 : 1)} ${units[index]}`; +} + +function BarList({ items }: { items: Array<{ name: string; bytes: number }> }) { + const max = Math.max(...items.map((item) => item.bytes), 1); + if (!items.length) { + return
No flow telemetry collected yet.
; + } + return ( +
+ {items.map((item) => ( +
+
+ {item.name} + {formatBytes(item.bytes)} +
+
+
+
+
+ ))} +
+ ); +} + export function Dashboard() { const { data } = useQuery({ queryKey: ["dashboard"], queryFn: () => api("/dashboard") }); + const suspicious = data?.suspicious_traffic ?? []; + const postureStable = data?.security_posture !== "attention"; return ( <> +
+
+
+
+ {postureStable ? : } +
+
+
{postureStable ? "Control plane stable" : "Attention required"}
+
+ {postureStable ? "No suspicious traffic signals or faulty nodes detected." : `${suspicious.length} suspicious traffic signal${suspicious.length === 1 ? "" : "s"} require review.`} +
+
+
+
+
+
Telemetry
+
{(data?.top_talkers ?? []).length ? "Active" : "Waiting"}
+
+
+
Faulty Nodes
+
{data?.faulty_nodes.length ?? 0}
+
+
+
Signals
+
{suspicious.length}
+
+
+
+
{cards.map(([key, label, Icon]) => (
@@ -29,27 +91,63 @@ export function Dashboard() {
))}
-
+
+
+
+ + Suspicious Traffic +
+ {suspicious.length ? ( +
+ {suspicious.map((item) => ( +
+
+
{item.source} -> {item.destination}
+
{item.protocol}:{item.port} · {item.reason}
+
+
+ {item.severity} + {formatBytes(item.bytes)} +
+
+ ))} +
+ ) : ( +
No suspicious traffic detected from current flow telemetry.
+ )} +
+
+
+ + Top Talkers +
+ +
+
+
Faulty Nodes
- {(data?.faulty_nodes ?? []).map((node) => ( + {(data?.faulty_nodes ?? []).length ? (data?.faulty_nodes ?? []).map((node) => (
{node.name} {node.status}
- ))} + )) :
All known nodes are online.
}
-
Top Talkers
- {(data?.top_talkers ?? []).length ? (data?.top_talkers ?? []).map((item) => ( -
- {item.name} - {Math.round(item.bytes / 1_000_000)} MB +
Recent Cluster Sync
+ {(data?.last_syncs ?? []).length ? (data?.last_syncs ?? []).map((cluster) => ( +
+
+
{cluster.name}
+
{cluster.provider} · {cluster.at ? new Date(cluster.at).toLocaleString() : "never synced"}
+
+ {cluster.status ?? "unknown"}
- )) :
No flow telemetry collected yet.
} + )) :
No cluster sync history yet.
}