feat: add container network filtering, enhance IP address display, and improve workload insights UI
Add is_docker_or_container_network helper to detect Docker bridge, Kubernetes CNI, and loopback networks, implement cleanup_discovered_container_networks to remove container bridge IPs from discovered networks during IPAM discovery, add ip_address_payload helper to enrich IP addresses with subnet CIDR and workload details, update ProxmoxProvider to ignore guest interfaces matching common container pref
This commit is contained in:
@@ -109,6 +109,51 @@ def ensure_discovered_network(db: Session, cluster_id: str) -> Network:
|
|||||||
return 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:
|
def import_discovered_ips(db: Session, cluster_id: str, workload: Workload, addresses: list[str]) -> int:
|
||||||
imported = 0
|
imported = 0
|
||||||
for value in addresses:
|
for value in addresses:
|
||||||
@@ -116,7 +161,7 @@ def import_discovered_ips(db: Session, cluster_id: str, workload: Workload, addr
|
|||||||
interface = ip_interface(value)
|
interface = ip_interface(value)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
continue
|
continue
|
||||||
if interface.ip.is_loopback or interface.ip.is_link_local:
|
if is_docker_or_container_network(value):
|
||||||
continue
|
continue
|
||||||
network = ensure_discovered_network(db, cluster_id)
|
network = ensure_discovered_network(db, cluster_id)
|
||||||
cidr = str(interface.network)
|
cidr = str(interface.network)
|
||||||
@@ -408,7 +453,7 @@ def workload_insights(workload_id: str, _: CurrentUser, db: Session = Depends(ge
|
|||||||
policies = db.scalars(
|
policies = db.scalars(
|
||||||
select(Policy).where((Policy.project_id == workload.project_id) | (Policy.project_id.is_(None))).order_by(Policy.name)
|
select(Policy).where((Policy.project_id == workload.project_id) | (Policy.project_id.is_(None))).order_by(Policy.name)
|
||||||
).all()
|
).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 = []
|
traffic = []
|
||||||
audit_mode_notes = [
|
audit_mode_notes = [
|
||||||
f"{policy.name} is in audit mode; matching traffic is logged without enforcement."
|
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"
|
decision = "audit" if audit_mode_notes else "unknown"
|
||||||
return WorkloadInsight(
|
return WorkloadInsight(
|
||||||
workload=workload,
|
workload=workload,
|
||||||
traffic=[
|
assigned_ips=[ip_address_payload(db, address) for address in assigned_ips],
|
||||||
{
|
traffic=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,
|
|
||||||
matching_policies=policies,
|
matching_policies=policies,
|
||||||
effective_decision=decision,
|
effective_decision=decision,
|
||||||
audit_mode_notes=audit_mode_notes,
|
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])
|
@api_router.get("/ipam/addresses", response_model=list[IpAddressRead])
|
||||||
def ipam_addresses(_: CurrentUser, db: Session = Depends(get_db)) -> list[IpAddress]:
|
def ipam_addresses(_: CurrentUser, db: Session = Depends(get_db)) -> list[dict]:
|
||||||
return db.scalars(select(IpAddress).order_by(IpAddress.address)).all()
|
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")
|
@api_router.post("/ipam/discover")
|
||||||
async def discover_ipam(user: CurrentUser, db: Session = Depends(get_db)) -> dict:
|
async def discover_ipam(user: CurrentUser, db: Session = Depends(get_db)) -> dict:
|
||||||
imported = 0
|
imported = 0
|
||||||
|
removed = cleanup_discovered_container_networks(db)
|
||||||
errors = []
|
errors = []
|
||||||
clusters = db.scalars(select(Cluster).order_by(Cluster.name)).all()
|
clusters = db.scalars(select(Cluster).order_by(Cluster.name)).all()
|
||||||
for cluster in clusters:
|
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", []))
|
imported += import_discovered_ips(db, cluster.id, workload, raw_workload.get("ip_addresses", []))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
errors.append({"cluster": cluster.name, "error": str(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)
|
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")
|
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, "errors": errors}
|
return {"imported": imported, "removed": removed, "errors": errors}
|
||||||
|
|
||||||
|
|
||||||
@api_router.post("/ipam/addresses", response_model=IpAddressRead)
|
@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)
|
commit_or_400(db)
|
||||||
db.refresh(address)
|
db.refresh(address)
|
||||||
write_audit(db, action="ipam.address.created", object_type="ip_address", object_id=address.id, user_id=user.id)
|
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)
|
@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,
|
old_values=old_values,
|
||||||
new_values={"address": address.address, "status": address.status, "note": address.note},
|
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}")
|
@api_router.delete("/ipam/addresses/{address_id}")
|
||||||
|
|||||||
@@ -210,9 +210,12 @@ class SubnetRead(OrmModel):
|
|||||||
class IpAddressRead(OrmModel):
|
class IpAddressRead(OrmModel):
|
||||||
id: str
|
id: str
|
||||||
subnet_id: str
|
subnet_id: str
|
||||||
|
subnet_cidr: str | None = None
|
||||||
address: str
|
address: str
|
||||||
status: str
|
status: str
|
||||||
workload_id: str | None
|
workload_id: str | None
|
||||||
|
workload_name: str | None = None
|
||||||
|
workload_external_id: str | None = None
|
||||||
note: str | None
|
note: str | None
|
||||||
|
|
||||||
|
|
||||||
@@ -263,6 +266,7 @@ class PolicyRead(OrmModel):
|
|||||||
|
|
||||||
class WorkloadInsight(BaseModel):
|
class WorkloadInsight(BaseModel):
|
||||||
workload: WorkloadRead
|
workload: WorkloadRead
|
||||||
|
assigned_ips: list[IpAddressRead]
|
||||||
traffic: list[dict[str, Any]]
|
traffic: list[dict[str, Any]]
|
||||||
matching_policies: list[PolicyRead]
|
matching_policies: list[PolicyRead]
|
||||||
effective_decision: str
|
effective_decision: str
|
||||||
|
|||||||
@@ -7,6 +7,17 @@ from app.services.providers.base import Provider, ProviderConnection
|
|||||||
|
|
||||||
class ProxmoxProvider(Provider):
|
class ProxmoxProvider(Provider):
|
||||||
name = "proxmox"
|
name = "proxmox"
|
||||||
|
ignored_guest_interface_prefixes = (
|
||||||
|
"br-",
|
||||||
|
"cali",
|
||||||
|
"cni",
|
||||||
|
"docker",
|
||||||
|
"flannel",
|
||||||
|
"kube",
|
||||||
|
"lo",
|
||||||
|
"veth",
|
||||||
|
"virbr",
|
||||||
|
)
|
||||||
|
|
||||||
def auth_header(self, token: str) -> str:
|
def auth_header(self, token: str) -> str:
|
||||||
token = token.strip()
|
token = token.strip()
|
||||||
@@ -67,6 +78,9 @@ class ProxmoxProvider(Provider):
|
|||||||
return
|
return
|
||||||
interfaces = response.json().get("data", {}).get("result", [])
|
interfaces = response.json().get("data", {}).get("result", [])
|
||||||
for interface in interfaces:
|
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", []):
|
for address in interface.get("ip-addresses", []):
|
||||||
ip_address = address.get("ip-address")
|
ip_address = address.get("ip-address")
|
||||||
prefix = address.get("prefix")
|
prefix = address.get("prefix")
|
||||||
|
|||||||
@@ -62,8 +62,12 @@ export type Subnet = {
|
|||||||
export type IpAddress = {
|
export type IpAddress = {
|
||||||
id: string;
|
id: string;
|
||||||
subnet_id: string;
|
subnet_id: string;
|
||||||
|
subnet_cidr: string | null;
|
||||||
address: string;
|
address: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
workload_id: string | null;
|
||||||
|
workload_name: string | null;
|
||||||
|
workload_external_id: string | null;
|
||||||
note: string | null;
|
note: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -122,6 +126,7 @@ export type Workload = {
|
|||||||
|
|
||||||
export type WorkloadInsight = {
|
export type WorkloadInsight = {
|
||||||
workload: Workload;
|
workload: Workload;
|
||||||
|
assigned_ips: IpAddress[];
|
||||||
traffic: Array<Record<string, unknown>>;
|
traffic: Array<Record<string, unknown>>;
|
||||||
matching_policies: Policy[];
|
matching_policies: Policy[];
|
||||||
effective_decision: string;
|
effective_decision: string;
|
||||||
|
|||||||
@@ -63,8 +63,8 @@ export function Ipam() {
|
|||||||
|
|
||||||
async function discoverIpam() {
|
async function discoverIpam() {
|
||||||
try {
|
try {
|
||||||
const result = await api<{ imported: number; errors: Array<Record<string, unknown>> }>("/ipam/discover", { method: "POST" });
|
const result = await api<{ imported: number; removed: number; errors: Array<Record<string, unknown>> }>("/ipam/discover", { method: "POST" });
|
||||||
setMessage(`Discovery imported ${result.imported} IP addresses${result.errors.length ? " with errors" : ""}.`);
|
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: ["subnets"] });
|
||||||
await queryClient.invalidateQueries({ queryKey: ["addresses"] });
|
await queryClient.invalidateQueries({ queryKey: ["addresses"] });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -122,7 +122,17 @@ export function Ipam() {
|
|||||||
</Modal>
|
</Modal>
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<DataTable rows={(subnets.data ?? []) as unknown as Record<string, unknown>[]} columns={[{ key: "cidr", label: "Subnet" }, { key: "gateway", label: "Gateway" }, { key: "dhcp_enabled", label: "DHCP" }]} />
|
<DataTable rows={(subnets.data ?? []) as unknown as Record<string, unknown>[]} columns={[{ key: "cidr", label: "Subnet" }, { key: "gateway", label: "Gateway" }, { key: "dhcp_enabled", label: "DHCP" }]} />
|
||||||
<DataTable rows={(addresses.data ?? []) as unknown as Record<string, unknown>[]} columns={[{ key: "address", label: "Address" }, { key: "status", label: "Status" }, { key: "note", label: "Note" }]} />
|
<DataTable
|
||||||
|
rows={(addresses.data ?? []) as unknown as Record<string, unknown>[]}
|
||||||
|
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" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
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 { api, Workload, WorkloadInsight } from "../api/client";
|
||||||
import { DataTable } from "../components/DataTable";
|
import { DataTable } from "../components/DataTable";
|
||||||
@@ -32,10 +32,30 @@ export function Workloads() {
|
|||||||
<div className="mb-4 flex items-center gap-2 font-medium"><Activity size={18} /> Workload Detail</div>
|
<div className="mb-4 flex items-center gap-2 font-medium"><Activity size={18} /> Workload Detail</div>
|
||||||
{insight.data ? (
|
{insight.data ? (
|
||||||
<div className="space-y-4 text-sm">
|
<div className="space-y-4 text-sm">
|
||||||
<div>
|
<header className="rounded-md border border-border bg-canvas p-4">
|
||||||
<div className="text-lg font-semibold">{insight.data.workload.name}</div>
|
<div className="mb-3 text-lg font-semibold">{insight.data.workload.name}</div>
|
||||||
<div className="text-slate-500">{insight.data.workload.kind} · {insight.data.workload.status} · decision {insight.data.effective_decision}</div>
|
<div className="grid gap-2 text-xs text-slate-500 sm:grid-cols-2">
|
||||||
|
<div className="flex items-center gap-2"><CircuitBoard size={14} /> Type: {insight.data.workload.kind}</div>
|
||||||
|
<div className="flex items-center gap-2"><Activity size={14} /> Status: {insight.data.workload.status}</div>
|
||||||
|
<div className="flex items-center gap-2"><Hash size={14} /> VMID: {insight.data.workload.external_id}</div>
|
||||||
|
<div className="flex items-center gap-2"><ShieldCheck size={14} /> Decision: {insight.data.effective_decision}</div>
|
||||||
</div>
|
</div>
|
||||||
|
</header>
|
||||||
|
<section>
|
||||||
|
<div className="mb-2 flex items-center gap-2 font-medium"><Network size={16} /> Assigned IPs</div>
|
||||||
|
{insight.data.assigned_ips.length ? (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{insight.data.assigned_ips.map((ip) => (
|
||||||
|
<div key={ip.id} className="rounded-md border border-border px-3 py-2 text-xs">
|
||||||
|
<div className="font-medium">{ip.address}</div>
|
||||||
|
<div className="text-slate-500">{ip.subnet_cidr ?? "unknown subnet"}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md border border-border p-3 text-xs text-slate-500">No assigned IP address was discovered for this workload yet.</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<div className="mb-2 font-medium">Traffic</div>
|
<div className="mb-2 font-medium">Traffic</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -51,12 +71,12 @@ export function Workloads() {
|
|||||||
<section>
|
<section>
|
||||||
<div className="mb-2 font-medium">Matching Policies</div>
|
<div className="mb-2 font-medium">Matching Policies</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{insight.data.matching_policies.map((policy) => (
|
{insight.data.matching_policies.length ? insight.data.matching_policies.map((policy) => (
|
||||||
<div key={policy.id} className="rounded-md border border-border p-3">
|
<div key={policy.id} className="rounded-md border border-border p-3">
|
||||||
<div>{policy.name}</div>
|
<div>{policy.name}</div>
|
||||||
<div className="text-xs text-slate-500">v{policy.version} · {policy.enforcement_mode}</div>
|
<div className="text-xs text-slate-500">v{policy.version} · {policy.enforcement_mode}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)) : <div className="rounded-md border border-border p-3 text-xs text-slate-500">No matching policy for this workload yet.</div>}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{insight.data.audit_mode_notes.length ? (
|
{insight.data.audit_mode_notes.length ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user