diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 445e2ea..bfce30f 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -109,6 +109,51 @@ def ensure_discovered_network(db: Session, cluster_id: str) -> Network: return network +def is_docker_or_container_network(value: str) -> bool: + try: + interface = ip_interface(value) + except ValueError: + return False + ip = interface.ip + network = str(interface.network) + if ip.is_loopback or ip.is_link_local: + return True + if ip.version == 4 and ip.packed[0] == 172 and 17 <= ip.packed[1] <= 31: + return True + return network.startswith(("10.42.", "10.43.", "10.244.", "10.245.")) + + +def ip_address_payload(db: Session, address: IpAddress) -> dict: + subnet = db.get(Subnet, address.subnet_id) + workload = db.get(Workload, address.workload_id) if address.workload_id else None + return { + "id": address.id, + "subnet_id": address.subnet_id, + "subnet_cidr": subnet.cidr if subnet else None, + "address": address.address, + "status": address.status, + "workload_id": address.workload_id, + "workload_name": workload.name if workload else None, + "workload_external_id": workload.external_id if workload else None, + "note": address.note, + } + + +def cleanup_discovered_container_networks(db: Session) -> int: + removed = 0 + discovered_networks = db.scalars(select(Network).where(Network.name == "discovered-ipam")).all() + for network in discovered_networks: + subnets = db.scalars(select(Subnet).where(Subnet.network_id == network.id)).all() + for subnet in subnets: + if is_docker_or_container_network(subnet.cidr): + addresses = db.scalars(select(IpAddress).where(IpAddress.subnet_id == subnet.id)).all() + for address in addresses: + db.delete(address) + removed += 1 + db.delete(subnet) + return removed + + def import_discovered_ips(db: Session, cluster_id: str, workload: Workload, addresses: list[str]) -> int: imported = 0 for value in addresses: @@ -116,7 +161,7 @@ def import_discovered_ips(db: Session, cluster_id: str, workload: Workload, addr interface = ip_interface(value) except ValueError: continue - if interface.ip.is_loopback or interface.ip.is_link_local: + if is_docker_or_container_network(value): continue network = ensure_discovered_network(db, cluster_id) cidr = str(interface.network) @@ -408,7 +453,7 @@ def workload_insights(workload_id: str, _: CurrentUser, db: Session = Depends(ge policies = db.scalars( select(Policy).where((Policy.project_id == workload.project_id) | (Policy.project_id.is_(None))).order_by(Policy.name) ).all() - assigned_ips = db.scalars(select(IpAddress).where(IpAddress.workload_id == workload.id)).all() + assigned_ips = db.scalars(select(IpAddress).where(IpAddress.workload_id == workload.id).order_by(IpAddress.address)).all() traffic = [] audit_mode_notes = [ f"{policy.name} is in audit mode; matching traffic is logged without enforcement." @@ -418,19 +463,8 @@ def workload_insights(workload_id: str, _: CurrentUser, db: Session = Depends(ge decision = "audit" if audit_mode_notes else "unknown" return WorkloadInsight( workload=workload, - traffic=[ - { - "source": workload.name, - "destination": "unknown", - "protocol": "unknown", - "port": "unknown", - "bytes": 0, - "decision": "no_flow_telemetry", - "ip_addresses": [address.address for address in assigned_ips], - } - ] - if assigned_ips - else traffic, + assigned_ips=[ip_address_payload(db, address) for address in assigned_ips], + traffic=traffic, matching_policies=policies, effective_decision=decision, audit_mode_notes=audit_mode_notes, @@ -472,13 +506,15 @@ def create_subnet(payload: SubnetCreate, user: CurrentUser, db: Session = Depend @api_router.get("/ipam/addresses", response_model=list[IpAddressRead]) -def ipam_addresses(_: CurrentUser, db: Session = Depends(get_db)) -> list[IpAddress]: - return db.scalars(select(IpAddress).order_by(IpAddress.address)).all() +def ipam_addresses(_: CurrentUser, db: Session = Depends(get_db)) -> list[dict]: + addresses = db.scalars(select(IpAddress).order_by(IpAddress.address)).all() + return [ip_address_payload(db, address) for address in addresses] @api_router.post("/ipam/discover") async def discover_ipam(user: CurrentUser, db: Session = Depends(get_db)) -> dict: imported = 0 + removed = cleanup_discovered_container_networks(db) errors = [] clusters = db.scalars(select(Cluster).order_by(Cluster.name)).all() for cluster in clusters: @@ -502,10 +538,18 @@ async def discover_ipam(user: CurrentUser, db: Session = Depends(get_db)) -> dic imported += import_discovered_ips(db, cluster.id, workload, raw_workload.get("ip_addresses", [])) except Exception as exc: errors.append({"cluster": cluster.name, "error": str(exc)}) - db.add(Job(kind="ipam.discover", status="success" if not errors else "failed", progress=100, logs=[f"Imported {imported} IP addresses"], error=str(errors) if errors else None)) + db.add( + Job( + kind="ipam.discover", + status="success" if not errors else "failed", + progress=100, + logs=[f"Imported {imported} IP addresses", f"Removed {removed} container bridge IPs"], + error=str(errors) if errors else None, + ) + ) commit_or_400(db) - write_audit(db, action="ipam.discover", object_type="ipam", user_id=user.id, new_values={"imported": imported, "errors": errors}, result="success" if not errors else "failed") - return {"imported": imported, "errors": errors} + write_audit(db, action="ipam.discover", object_type="ipam", user_id=user.id, new_values={"imported": imported, "removed": removed, "errors": errors}, result="success" if not errors else "failed") + return {"imported": imported, "removed": removed, "errors": errors} @api_router.post("/ipam/addresses", response_model=IpAddressRead) @@ -517,7 +561,7 @@ def reserve_ip(payload: IpReservationCreate, user: CurrentUser, db: Session = De commit_or_400(db) db.refresh(address) write_audit(db, action="ipam.address.created", object_type="ip_address", object_id=address.id, user_id=user.id) - return address + return ip_address_payload(db, address) @api_router.patch("/ipam/addresses/{address_id}", response_model=IpAddressRead) @@ -541,7 +585,7 @@ def update_ip(address_id: str, payload: IpReservationCreate, user: CurrentUser, old_values=old_values, new_values={"address": address.address, "status": address.status, "note": address.note}, ) - return address + return ip_address_payload(db, address) @api_router.delete("/ipam/addresses/{address_id}") diff --git a/backend/app/schemas/domain.py b/backend/app/schemas/domain.py index 7149b5f..ebe5510 100644 --- a/backend/app/schemas/domain.py +++ b/backend/app/schemas/domain.py @@ -210,9 +210,12 @@ class SubnetRead(OrmModel): class IpAddressRead(OrmModel): id: str subnet_id: str + subnet_cidr: str | None = None address: str status: str workload_id: str | None + workload_name: str | None = None + workload_external_id: str | None = None note: str | None @@ -263,6 +266,7 @@ class PolicyRead(OrmModel): class WorkloadInsight(BaseModel): workload: WorkloadRead + assigned_ips: list[IpAddressRead] traffic: list[dict[str, Any]] matching_policies: list[PolicyRead] effective_decision: str diff --git a/backend/app/services/providers/proxmox.py b/backend/app/services/providers/proxmox.py index db56bae..e59b16c 100644 --- a/backend/app/services/providers/proxmox.py +++ b/backend/app/services/providers/proxmox.py @@ -7,6 +7,17 @@ from app.services.providers.base import Provider, ProviderConnection class ProxmoxProvider(Provider): name = "proxmox" + ignored_guest_interface_prefixes = ( + "br-", + "cali", + "cni", + "docker", + "flannel", + "kube", + "lo", + "veth", + "virbr", + ) def auth_header(self, token: str) -> str: token = token.strip() @@ -67,6 +78,9 @@ class ProxmoxProvider(Provider): return interfaces = response.json().get("data", {}).get("result", []) for interface in interfaces: + interface_name = str(interface.get("name") or "").lower() + if interface_name.startswith(self.ignored_guest_interface_prefixes): + continue for address in interface.get("ip-addresses", []): ip_address = address.get("ip-address") prefix = address.get("prefix") diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index de02c4b..5b2edfa 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -62,8 +62,12 @@ export type Subnet = { export type IpAddress = { id: string; subnet_id: string; + subnet_cidr: string | null; address: string; status: string; + workload_id: string | null; + workload_name: string | null; + workload_external_id: string | null; note: string | null; }; @@ -122,6 +126,7 @@ export type Workload = { export type WorkloadInsight = { workload: Workload; + assigned_ips: IpAddress[]; traffic: Array>; matching_policies: Policy[]; effective_decision: string; diff --git a/frontend/src/pages/Ipam.tsx b/frontend/src/pages/Ipam.tsx index 5f0be90..45c7cd2 100644 --- a/frontend/src/pages/Ipam.tsx +++ b/frontend/src/pages/Ipam.tsx @@ -63,8 +63,8 @@ export function Ipam() { async function discoverIpam() { try { - const result = await api<{ imported: number; errors: Array> }>("/ipam/discover", { method: "POST" }); - setMessage(`Discovery imported ${result.imported} IP addresses${result.errors.length ? " with errors" : ""}.`); + const result = await api<{ imported: number; removed: number; errors: Array> }>("/ipam/discover", { method: "POST" }); + setMessage(`Discovery imported ${result.imported} IP addresses and removed ${result.removed} container bridge entries${result.errors.length ? " with errors" : ""}.`); await queryClient.invalidateQueries({ queryKey: ["subnets"] }); await queryClient.invalidateQueries({ queryKey: ["addresses"] }); } catch (error) { @@ -122,7 +122,17 @@ export function Ipam() {
[]} columns={[{ key: "cidr", label: "Subnet" }, { key: "gateway", label: "Gateway" }, { key: "dhcp_enabled", label: "DHCP" }]} /> - []} columns={[{ key: "address", label: "Address" }, { key: "status", label: "Status" }, { key: "note", label: "Note" }]} /> + []} + columns={[ + { key: "address", label: "Address" }, + { key: "subnet_cidr", label: "Subnet" }, + { key: "status", label: "Status" }, + { key: "workload_name", label: "VM/LXC" }, + { key: "workload_external_id", label: "VMID" }, + { key: "note", label: "Note" }, + ]} + />
diff --git a/frontend/src/pages/Workloads.tsx b/frontend/src/pages/Workloads.tsx index d24eb1c..6f4bf9f 100644 --- a/frontend/src/pages/Workloads.tsx +++ b/frontend/src/pages/Workloads.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { Activity } from "lucide-react"; +import { Activity, CircuitBoard, Hash, Network, ShieldCheck } from "lucide-react"; import { api, Workload, WorkloadInsight } from "../api/client"; import { DataTable } from "../components/DataTable"; @@ -32,10 +32,30 @@ export function Workloads() {
Workload Detail
{insight.data ? (
-
-
{insight.data.workload.name}
-
{insight.data.workload.kind} · {insight.data.workload.status} · decision {insight.data.effective_decision}
-
+
+
{insight.data.workload.name}
+
+
Type: {insight.data.workload.kind}
+
Status: {insight.data.workload.status}
+
VMID: {insight.data.workload.external_id}
+
Decision: {insight.data.effective_decision}
+
+
+
+
Assigned IPs
+ {insight.data.assigned_ips.length ? ( +
+ {insight.data.assigned_ips.map((ip) => ( +
+
{ip.address}
+
{ip.subnet_cidr ?? "unknown subnet"}
+
+ ))} +
+ ) : ( +
No assigned IP address was discovered for this workload yet.
+ )} +
Traffic
@@ -51,12 +71,12 @@ export function Workloads() {
Matching Policies
- {insight.data.matching_policies.map((policy) => ( + {insight.data.matching_policies.length ? insight.data.matching_policies.map((policy) => (
{policy.name}
v{policy.version} · {policy.enforcement_mode}
- ))} + )) :
No matching policy for this workload yet.
}
{insight.data.audit_mode_notes.length ? (