Files
NexaFabric/backend/app/api/v1/router.py
T
nessi 0d07349de0 feat: add traffic flow deduplication with 24h retention and update-in-place for existing flows
Add traffic_flow_key helper to generate unique flow identifier from node/IPs/protocol/ports/decision, implement 24-hour retention cutoff to delete old flows instead of all flows on heartbeat, build existing_flows lookup map from database with composite key matching, update agent_heartbeat to check for existing flows and update bytes/packets/state/observed_at/raw in-place instead of creating duplicates, extend
2026-07-09 21:19:26 +02:00

1985 lines
80 KiB
Python

from datetime import datetime, timedelta
import csv
import io
from ipaddress import ip_address, ip_interface, ip_network
from pathlib import Path
from fastapi import APIRouter, Depends, Header, HTTPException, Request
from fastapi.responses import FileResponse, PlainTextResponse, StreamingResponse
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.api.deps import CurrentUser
from app.api.v1 import auth
from app.core.security import hash_password
from app.core.security import create_token, decode_token
from app.db.session import get_db
from app.models.domain import (
AuditLog,
Cluster,
IpAddress,
Job,
Network,
Node,
NodeAgent,
Policy,
Project,
Role,
SecurityGroup,
SecurityRule,
ServiceCatalogItem,
SystemSetting,
Subnet,
Tenant,
TrafficFlow,
User,
Workload,
)
from app.schemas.domain import (
AuditLogRead,
AgentHeartbeat,
ClusterCreate,
ClusterRead,
ClusterUpdate,
FirewallApplyRequest,
FirewallPreview,
IpAddressRead,
IpReservationCreate,
JobRead,
NetworkCreate,
NetworkRead,
NodeRead,
NodeWithAgentRead,
PolicyCreate,
PolicyRead,
ProjectCreate,
ProjectRead,
RoleCreate,
RoleRead,
SecurityRuleCreate,
SecurityRuleRead,
ServiceCatalogCreate,
ServiceCatalogRead,
SecurityGroupCreate,
SecurityGroupRead,
SetupCompleteRequest,
SetupStatus,
SubnetCreate,
SubnetRead,
SubnetUpdate,
TenantCreate,
TenantRead,
UserCreate,
UserRead,
WorkloadInsight,
WorkloadRead,
)
from app.services.audit import write_audit
from app.services.firewall_orchestrator import FirewallOrchestrator
from app.services.providers.base import ProviderConnection
from app.services.providers.registry import get_provider
api_router = APIRouter()
api_router.include_router(auth.router)
def commit_or_400(db: Session) -> None:
try:
db.commit()
except IntegrityError as exc:
db.rollback()
raise HTTPException(status_code=409, detail="Resource conflicts with an existing record") from exc
def setup_setting(db: Session) -> SystemSetting:
setting = db.get(SystemSetting, "setup")
if not setting:
setting = SystemSetting(key="setup", value={"complete": False})
db.add(setting)
db.commit()
db.refresh(setting)
return setting
def ensure_discovered_network(db: Session, cluster_id: str) -> Network:
network = db.scalar(select(Network).where(Network.cluster_id == cluster_id, Network.name == "discovered-ipam"))
if network:
return network
network = Network(
cluster_id=cluster_id,
name="discovered-ipam",
kind="discovered",
description="Automatically created for IP addresses discovered during Proxmox sync.",
)
db.add(network)
db.flush()
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 is_ip_or_cidr(value: str) -> bool:
try:
ip_network(value, strict=False)
return True
except ValueError:
try:
ip_address(value)
return True
except ValueError:
return False
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:
try:
interface = ip_interface(value)
except ValueError:
continue
if is_docker_or_container_network(value):
continue
network = ensure_discovered_network(db, cluster_id)
cidr = str(interface.network)
subnet = db.scalar(select(Subnet).where(Subnet.network_id == network.id, Subnet.cidr == cidr))
if not subnet:
subnet = Subnet(network_id=network.id, cidr=cidr)
db.add(subnet)
db.flush()
address_value = str(interface.ip)
existing = db.scalar(select(IpAddress).where(IpAddress.subnet_id == subnet.id, IpAddress.address == address_value))
if existing:
existing.workload_id = workload.id
existing.status = "assigned"
else:
db.add(IpAddress(subnet_id=subnet.id, address=address_value, status="assigned", workload_id=workload.id))
imported += 1
return imported
def workload_provider_target(db: Session, cluster: Cluster, ref: str) -> tuple[dict, Workload] | None:
workload: Workload | None = None
if ref.startswith("workload:"):
workload_ref = ref.removeprefix("workload:")
workload = db.get(Workload, workload_ref)
elif ref.startswith("vmid:"):
workload_ref = ref.removeprefix("vmid:")
workload = db.scalar(select(Workload).where(Workload.cluster_id == cluster.id, Workload.external_id == workload_ref))
else:
workload = db.scalar(select(Workload).where(Workload.cluster_id == cluster.id, Workload.name == ref))
if not workload or workload.cluster_id != cluster.id:
return None
node = db.get(Node, workload.node_id)
if not node:
return None
kind = "lxc" if workload.kind == "lxc" else "qemu"
return (
{
"node": node.name,
"kind": kind,
"vmid": workload.external_id,
"workload_id": workload.id,
"workload_name": workload.name,
},
workload,
)
def endpoint_values(db: Session, cluster: Cluster, ref: str) -> tuple[list[str | None], list[str]]:
if ref == "any":
return [None], []
resolved = workload_provider_target(db, cluster, ref)
if resolved:
_, workload = resolved
addresses = db.scalars(select(IpAddress).where(IpAddress.workload_id == workload.id).order_by(IpAddress.address)).all()
values = [address.address for address in addresses if address.address]
if values:
return values, []
return [], [f"Workload {workload.name} has no assigned IP address for provider-side source/destination matching."]
if is_ip_or_cidr(ref):
return [ref], []
if ref.startswith("network:"):
network_name = ref.removeprefix("network:")
network = db.scalar(select(Network).where(Network.cluster_id == cluster.id, Network.name == network_name))
if not network:
return [], [f"Network {network_name} was not found in this cluster."]
values = [subnet.cidr for subnet in db.scalars(select(Subnet).where(Subnet.network_id == network.id).order_by(Subnet.cidr)).all()]
if values:
return values, []
return [], [f"Network {network_name} has no IPAM subnets to use as provider-side matcher."]
return [], [f"Endpoint {ref} is not yet resolvable to a Proxmox firewall matcher."]
def flow_int(value: object, default: int = 0) -> int:
try:
return int(value) if value not in (None, "") else default
except (TypeError, ValueError):
return default
def normalized_policy_definition(definition: dict) -> dict:
normalized = dict(definition or {})
mode = str(normalized.get("enforcement_mode") or "enforced").lower()
normalized["enforcement_mode"] = mode if mode in {"enforced", "audit"} else "enforced"
return normalized
def subnet_label_for_ip(subnets: list[Subnet], value: str) -> str | None:
try:
address = ip_address(value)
except ValueError:
return None
matches: list[tuple[int, Subnet]] = []
for subnet in subnets:
try:
network = ip_network(subnet.cidr, strict=False)
except ValueError:
continue
if address in network:
matches.append((network.prefixlen, subnet))
if not matches:
return None
_, subnet = sorted(matches, key=lambda item: item[0], reverse=True)[0]
return f"internal ({subnet.cidr})"
def flow_endpoint_label(owner: Workload | None, subnets: list[Subnet], value: str) -> str:
if owner:
return owner.name
return subnet_label_for_ip(subnets, value) or "external"
def flow_ip_label(owner: Workload | None, subnets: list[Subnet], value: str) -> str:
if owner:
return f"{value} (internal)"
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 firewall_rule_decision(action: object) -> str:
normalized = str(action or "").lower()
if normalized in {"accept", "allow"}:
return "allowed"
if normalized in {"drop", "reject", "deny"}:
return "blocked"
return "observed"
def port_matches(rule_value: object, flow_port: int | None) -> bool:
if rule_value in (None, "", "any"):
return True
if flow_port is None:
return False
for raw_part in str(rule_value).split(","):
part = raw_part.strip()
if not part:
continue
separator = ":" if ":" in part else "-" if "-" in part else ""
if separator:
start, end = part.split(separator, 1)
try:
if int(start) <= flow_port <= int(end):
return True
except ValueError:
continue
continue
try:
if int(part) == flow_port:
return True
except ValueError:
continue
return False
def ip_value_matches(rule_value: object, flow_ip: str) -> bool:
if rule_value in (None, "", "any"):
return True
try:
address = ip_address(flow_ip)
except ValueError:
return False
for raw_part in str(rule_value).split(","):
part = raw_part.strip()
if not part:
continue
try:
if "/" in part:
if address in ip_network(part, strict=False):
return True
elif address == ip_address(part):
return True
except ValueError:
if part == flow_ip:
return True
return False
def firewall_rule_matches_flow(rule: dict[str, object], flow: TrafficFlow, workload_ips: set[str]) -> bool:
enabled = str(rule.get("enable", "1")).lower()
if enabled in {"0", "false", "no"}:
return False
rule_type = str(rule.get("type") or "").lower()
if rule_type == "in" and flow.destination_ip not in workload_ips:
return False
if rule_type == "out" and flow.source_ip not in workload_ips:
return False
proto = str(rule.get("proto") or "any").lower()
if proto not in {"", "any"} and proto != str(flow.protocol or "").lower():
return False
if not ip_value_matches(rule.get("source"), flow.source_ip):
return False
if not ip_value_matches(rule.get("dest"), flow.destination_ip):
return False
if not port_matches(rule.get("sport"), flow.source_port):
return False
if not port_matches(rule.get("dport"), flow.destination_port):
return False
return True
def firewall_rule_flow_payload(rule: dict[str, object]) -> dict[str, object]:
return {
"pos": rule.get("pos"),
"type": rule.get("type"),
"action": rule.get("action"),
"proto": rule.get("proto"),
"source": rule.get("source"),
"dest": rule.get("dest"),
"sport": rule.get("sport"),
"dport": rule.get("dport"),
"comment": rule.get("comment"),
"decision": firewall_rule_decision(rule.get("action")),
"managed_by_nexafabric": bool(rule.get("managed_by_nexafabric")),
}
def endpoint_ref_matches_flow_side(
db: Session,
ref: object,
flow_ip: str,
side_workload: Workload | None,
side_workload_ips: set[str],
cluster_id: str,
) -> bool:
value = str(ref or "any")
if value == "any":
return True
if value.startswith("workload:"):
workload_id = value.removeprefix("workload:")
return bool(side_workload and side_workload.id == workload_id and flow_ip in side_workload_ips)
if value.startswith("network:"):
network_name = value.removeprefix("network:")
network = db.scalar(select(Network).where(Network.cluster_id == cluster_id, Network.name == network_name))
if not network:
return False
subnets = db.scalars(select(Subnet).where(Subnet.network_id == network.id)).all()
return any(ip_value_matches(subnet.cidr, flow_ip) for subnet in subnets)
if is_ip_or_cidr(value):
return ip_value_matches(value, flow_ip)
return False
def policy_matches_flow(
db: Session,
policy: Policy,
flow: TrafficFlow,
ip_owners: dict[str, Workload | None],
workload_ips_by_id: dict[str, set[str]],
cluster_id: str,
) -> bool:
if not policy.enabled:
return False
definition = normalized_policy_definition(policy.definition or {})
policy_protocol = str(definition.get("protocol") or "any").lower()
flow_protocol = str(flow.protocol or "").lower()
if policy_protocol not in {"any", flow_protocol} and not (
policy_protocol in {"tcp/udp", "tcp & udp", "tcp_udp"} and flow_protocol in {"tcp", "udp"}
):
return False
if not port_matches(definition.get("ports") or definition.get("port"), flow.destination_port):
return False
source_workload = ip_owners.get(flow.source_ip)
destination_workload = ip_owners.get(flow.destination_ip)
if not endpoint_ref_matches_flow_side(
db,
definition.get("source"),
flow.source_ip,
source_workload,
workload_ips_by_id.get(source_workload.id, set()) if source_workload else set(),
cluster_id,
):
return False
return endpoint_ref_matches_flow_side(
db,
definition.get("destination"),
flow.destination_ip,
destination_workload,
workload_ips_by_id.get(destination_workload.id, set()) if destination_workload else set(),
cluster_id,
)
def policy_flow_payload(policy: Policy) -> dict[str, object]:
definition = normalized_policy_definition(policy.definition or {})
action = str(definition.get("action") or "allow").lower()
mode = str(definition.get("enforcement_mode") or policy.enforcement_mode or "enforced").lower()
block = action in {"deny", "drop", "reject", "block"}
return {
"id": policy.id,
"name": policy.name,
"version": policy.version,
"enforcement_mode": mode,
"action": action,
"protocol": definition.get("protocol") or "any",
"ports": definition.get("ports") or definition.get("port"),
"description": definition.get("description"),
"decision": ("would_block" if block else "would_allow") if mode == "audit" else ("blocked" if block else "allowed"),
}
def flow_policy_decision(active_matches: list[dict[str, object]], policy_matches: list[dict[str, object]]) -> str:
for match in active_matches:
decision = str(match.get("decision") or "")
if decision in {"blocked", "allowed"}:
return decision
for match in policy_matches:
decision = str(match.get("decision") or "")
if decision in {"blocked", "allowed", "would_block", "would_allow"}:
return decision
return "observed"
def raw_flow_decision(flow: TrafficFlow) -> str | None:
raw = flow.raw if isinstance(flow.raw, dict) else {}
decision = str(raw.get("decision") or flow.state or "").lower()
if decision in {"blocked", "drop", "dropped", "reject", "rejected", "deny", "denied"}:
return "blocked"
if decision in {"allowed", "accept", "accepted", "allow"}:
return "allowed"
return None
def traffic_flow_key(node_id: str, raw_flow: dict[str, object]) -> tuple[object, ...]:
decision = str(raw_flow.get("decision") or raw_flow.get("state") or "observed").lower()
return (
node_id,
str(raw_flow.get("source_ip") or ""),
str(raw_flow.get("destination_ip") or ""),
str(raw_flow.get("protocol") or "unknown"),
flow_int(raw_flow.get("source_port"), 0) or None,
flow_int(raw_flow.get("destination_port"), 0) or None,
decision,
)
def policy_read_payload(policy: Policy, deployment_status: dict[str, object] | None = None) -> dict[str, object]:
return {
"id": policy.id,
"project_id": policy.project_id,
"name": policy.name,
"version": policy.version,
"enabled": policy.enabled,
"enforcement_mode": policy.enforcement_mode,
"definition": policy.definition,
"last_compiled": policy.last_compiled,
"deployment_status": deployment_status,
}
def nexafabric_rule_version(rule: dict[str, object], policy_id: str) -> int | None:
comment = str(rule.get("comment") or "")
marker = f"NexaFabric policy={policy_id} version="
if marker not in comment:
return None
try:
return int(comment.split(marker, 1)[1].split(" ", 1)[0])
except (IndexError, ValueError):
return None
async def policy_deployment_status(db: Session, policy: Policy) -> dict[str, object]:
if not policy.enabled:
return {"state": "disabled", "label": "Disabled", "expected_rules": 0, "active_rules": 0, "stale_rules": 0, "clusters": []}
if policy.enforcement_mode == "audit":
return {"state": "audit", "label": "Audit mode", "expected_rules": 0, "active_rules": 0, "stale_rules": 0, "clusters": []}
clusters = db.scalars(select(Cluster).order_by(Cluster.name)).all()
if not clusters:
return {"state": "unknown", "label": "No cluster", "expected_rules": 0, "active_rules": 0, "stale_rules": 0, "clusters": []}
expected_rules = 0
active_rules = 0
stale_rules = 0
unresolved = 0
cluster_results: list[dict[str, object]] = []
for cluster in clusters:
try:
preview = resolve_firewall_preview(db, cluster, await FirewallOrchestrator().preview(cluster, policy))
except Exception as exc:
cluster_results.append({"cluster_id": cluster.id, "cluster_name": cluster.name, "state": "error", "error": str(exc)})
continue
writable_rules = [
rule for rule in preview.generated_rules if rule.get("provider_target") and rule.get("provider_rule") and not rule.get("audit_only")
]
expected_rules += len(writable_rules)
unresolved += len(preview.conflicts)
provider = get_provider(cluster.provider)
list_rules = getattr(provider, "list_firewall_rules", None)
if not list_rules:
cluster_results.append(
{
"cluster_id": cluster.id,
"cluster_name": cluster.name,
"state": "unknown",
"expected_rules": len(writable_rules),
"active_rules": 0,
"stale_rules": 0,
"reason": "Provider cannot list active firewall rules.",
}
)
continue
cluster_active = 0
cluster_stale = 0
seen_targets: dict[str, dict[str, object]] = {}
for rule in writable_rules:
target = rule.get("provider_target")
if isinstance(target, dict):
seen_targets[str(target)] = target
for target in seen_targets.values():
try:
rules = await list_rules(
ProviderConnection(
api_url=cluster.api_url,
token=cluster.token_ref or "",
verify_tls=cluster.verify_tls,
read_only=True,
),
target,
)
except Exception as exc:
cluster_results.append({"cluster_id": cluster.id, "cluster_name": cluster.name, "state": "error", "error": str(exc)})
continue
for active_rule in rules:
version = nexafabric_rule_version(active_rule, policy.id)
if version is None:
continue
if version == policy.version:
cluster_active += 1
else:
cluster_stale += 1
active_rules += cluster_active
stale_rules += cluster_stale
if preview.conflicts:
cluster_state = "unresolved"
elif cluster_active >= len(writable_rules) and writable_rules:
cluster_state = "active"
elif cluster_active:
cluster_state = "partial"
elif cluster_stale:
cluster_state = "stale"
else:
cluster_state = "not_applied"
cluster_results.append(
{
"cluster_id": cluster.id,
"cluster_name": cluster.name,
"state": cluster_state,
"expected_rules": len(writable_rules),
"active_rules": cluster_active,
"stale_rules": cluster_stale,
"conflicts": preview.conflicts,
}
)
if unresolved:
state = "unresolved"
label = "Needs attention"
elif expected_rules and active_rules >= expected_rules:
state = "active"
label = "Active"
elif active_rules:
state = "partial"
label = "Partially active"
elif stale_rules:
state = "stale"
label = "Outdated"
elif expected_rules:
state = "not_applied"
label = "Not applied"
else:
state = "unknown"
label = "No resolved rules"
return {
"state": state,
"label": label,
"expected_rules": expected_rules,
"active_rules": active_rules,
"stale_rules": stale_rules,
"clusters": cluster_results,
}
def dashboard_top_talkers(db: Session) -> list[dict[str, int | str]]:
totals: dict[str, int] = {}
workloads = db.scalars(select(Workload)).all()
workloads_by_node_vmid = {(workload.node_id, workload.external_id): workload for workload in workloads}
ip_owners = {
address.address: db.get(Workload, address.workload_id)
for address in db.scalars(select(IpAddress).where(IpAddress.workload_id.is_not(None))).all()
}
flows = db.scalars(select(TrafficFlow)).all()
for flow in flows:
raw = flow.raw if isinstance(flow.raw, dict) else {}
candidates: list[Workload] = []
raw_vmid = str(raw.get("vmid") or "")
if raw_vmid:
workload = workloads_by_node_vmid.get((flow.node_id, raw_vmid))
if workload:
candidates.append(workload)
for owner in (ip_owners.get(flow.source_ip), ip_owners.get(flow.destination_ip)):
if owner and owner not in candidates:
candidates.append(owner)
for workload in candidates:
totals[workload.name] = totals.get(workload.name, 0) + int(flow.bytes or 0)
if not totals:
agents = db.scalars(select(NodeAgent)).all()
for agent in agents:
payload = agent.last_payload if isinstance(agent.last_payload, dict) else {}
for item in payload.get("interface_traffic", []):
if not isinstance(item, dict):
continue
workload = workloads_by_node_vmid.get((agent.node_id, str(item.get("vmid") or "")))
if not workload:
continue
totals[workload.name] = totals.get(workload.name, 0) + int(item.get("bytes") or 0)
return [
{"name": name, "bytes": bytes_value}
for name, bytes_value in sorted(totals.items(), key=lambda item: item[1], reverse=True)[:5]
]
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
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 not destination_internal:
continue
decision = raw_flow_decision(flow)
if source_internal and decision != "blocked":
continue
if port not in sensitive_ports and decision != "blocked":
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": "Blocked by firewall" if decision == "blocked" else sensitive_ports[port],
"severity": "high" if port in {22, 3389, 445} or decision == "blocked" else "medium",
"decision": decision or "observed",
},
)
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")
def resolve_firewall_preview(db: Session, cluster: Cluster, preview: FirewallPreview) -> FirewallPreview:
warnings = list(preview.warnings)
conflicts = list(preview.conflicts)
generated_rules: list[dict] = []
for rule_index, rule in enumerate(preview.generated_rules, start=1):
mapped = dict(rule)
direction = str(rule.get("direction", "ingress"))
target_ref = str(rule.get("destination") if direction == "ingress" else rule.get("source"))
target = workload_provider_target(db, cluster, target_ref)
if not target:
conflicts.append(
f"Rule {rule_index} needs a concrete {'destination' if direction == 'ingress' else 'source'} workload for Proxmox live apply."
)
generated_rules.append(mapped)
continue
provider_target, target_workload = target
remote_ref = str(rule.get("source") if direction == "ingress" else rule.get("destination"))
remote_values, endpoint_warnings = endpoint_values(db, cluster, remote_ref)
warnings.extend(endpoint_warnings)
if not remote_values:
conflicts.append(f"Rule {rule_index} cannot resolve {remote_ref} to a Proxmox firewall source/destination matcher.")
generated_rules.append({**mapped, "provider_target": provider_target})
continue
ports = str(rule.get("ports", "any"))
protocol = str(rule.get("protocol", "any"))
protocols = ["tcp", "udp"] if protocol == "tcp/udp" else [protocol]
for remote_value in remote_values:
for provider_protocol in protocols:
provider_rule = {
"type": "in" if direction == "ingress" else "out",
"action": proxmox_action(str(rule.get("action", "allow"))),
"enable": 1,
"comment": (
f"NexaFabric policy={rule.get('policy_id')} version={rule.get('policy_version')} "
f"rule={rule_index} target={target_workload.name}"
),
}
if provider_protocol != "any":
provider_rule["proto"] = provider_protocol
if ports != "any":
provider_rule["dport"] = ports
if remote_value:
provider_rule["source" if direction == "ingress" else "dest"] = remote_value
if rule.get("logging"):
provider_rule["log"] = "info"
mapped_rule = {**mapped, "provider_target": provider_target, "provider_rule": provider_rule}
generated_rules.append(mapped_rule)
return FirewallPreview(
policy_id=preview.policy_id,
dry_run=preview.dry_run,
generated_rules=generated_rules,
warnings=warnings,
conflicts=conflicts,
)
@api_router.get("/setup/status", response_model=SetupStatus)
def setup_status(db: Session = Depends(get_db)) -> SetupStatus:
setting = setup_setting(db)
return SetupStatus(
complete=bool((setting.value or {}).get("complete")),
has_users=bool(db.scalar(select(func.count()).select_from(User))),
has_clusters=bool(db.scalar(select(func.count()).select_from(Cluster))),
)
@api_router.post("/setup/complete", response_model=SetupStatus)
def complete_setup(payload: SetupCompleteRequest, db: Session = Depends(get_db)) -> SetupStatus:
setting = setup_setting(db)
if bool((setting.value or {}).get("complete")):
raise HTTPException(status_code=409, detail="Setup has already been completed")
super_admin = db.scalar(select(Role).where(Role.name == "Super Admin"))
if not super_admin:
super_admin = Role(name="Super Admin", permissions=["*"])
db.add(super_admin)
db.flush()
email = payload.admin_email.strip().lower()
admin = db.scalar(select(User).where(User.email == email))
if not admin:
admin = User(email=email, display_name=payload.admin_name, password_hash=hash_password(payload.admin_password))
db.add(admin)
admin.display_name = payload.admin_name
admin.password_hash = hash_password(payload.admin_password)
admin.is_active = True
if super_admin not in admin.roles:
admin.roles.append(super_admin)
if payload.cluster_name and payload.cluster_api_url and payload.cluster_api_token:
existing_cluster = db.scalar(select(Cluster).where(Cluster.name == payload.cluster_name))
if not existing_cluster:
db.add(
Cluster(
name=payload.cluster_name,
api_url=payload.cluster_api_url,
token_ref=payload.cluster_api_token,
provider=payload.cluster_provider,
mode=payload.cluster_mode,
verify_tls=payload.verify_tls,
)
)
setting.value = {"complete": True, "completed_at": datetime.utcnow().isoformat()}
db.add(AuditLog(user_id=admin.id, action="setup.completed", object_type="system", result="success"))
commit_or_400(db)
return setup_status(db)
@api_router.get("/dashboard")
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": len(suspicious),
"security_posture": "attention" if suspicious or faulty_nodes else "stable",
"suspicious_traffic": suspicious,
"last_syncs": [
{
"id": cluster.id,
"name": cluster.name,
"provider": cluster.provider,
"status": cluster.last_sync_status,
"error": cluster.last_sync_error,
"at": cluster.last_sync_at.isoformat() if cluster.last_sync_at else None,
}
for cluster in last_syncs
],
"faulty_nodes": [
{"id": node.id, "name": node.name, "status": node.status, "cluster_id": node.cluster_id}
for node in faulty_nodes
],
"top_talkers": dashboard_top_talkers(db),
}
@api_router.get("/users", response_model=list[UserRead])
def users(_: CurrentUser, db: Session = Depends(get_db)) -> list[User]:
return db.scalars(select(User).order_by(User.email)).all()
@api_router.post("/users", response_model=UserRead)
def create_user(payload: UserCreate, user: CurrentUser, db: Session = Depends(get_db)) -> User:
roles = db.scalars(select(Role).where(Role.id.in_(payload.role_ids))).all() if payload.role_ids else []
new_user = User(
email=payload.email.strip().lower(),
display_name=payload.display_name,
password_hash=hash_password(payload.password),
roles=roles,
)
db.add(new_user)
commit_or_400(db)
db.refresh(new_user)
write_audit(db, action="user.created", object_type="user", object_id=new_user.id, user_id=user.id)
return new_user
@api_router.get("/roles", response_model=list[RoleRead])
def roles(_: CurrentUser, db: Session = Depends(get_db)) -> list[Role]:
return db.scalars(select(Role).order_by(Role.name)).all()
@api_router.post("/roles", response_model=RoleRead)
def create_role(payload: RoleCreate, user: CurrentUser, db: Session = Depends(get_db)) -> Role:
role = Role(name=payload.name, permissions=payload.permissions)
db.add(role)
commit_or_400(db)
db.refresh(role)
write_audit(db, action="role.created", object_type="role", object_id=role.id, user_id=user.id)
return role
@api_router.get("/clusters", response_model=list[ClusterRead])
def clusters(_: CurrentUser, db: Session = Depends(get_db)) -> list[Cluster]:
return db.scalars(select(Cluster).order_by(Cluster.name)).all()
@api_router.post("/clusters", response_model=ClusterRead)
def create_cluster(payload: ClusterCreate, user: CurrentUser, db: Session = Depends(get_db)) -> Cluster:
cluster = Cluster(
name=payload.name,
api_url=payload.api_url,
provider=payload.provider,
token_ref=payload.api_token,
mode=payload.mode,
verify_tls=payload.verify_tls,
)
db.add(cluster)
commit_or_400(db)
db.refresh(cluster)
write_audit(db, action="cluster.created", object_type="cluster", object_id=cluster.id, user_id=user.id)
return cluster
@api_router.patch("/clusters/{cluster_id}", response_model=ClusterRead)
def update_cluster(cluster_id: str, payload: ClusterUpdate, user: CurrentUser, db: Session = Depends(get_db)) -> Cluster:
cluster = db.get(Cluster, cluster_id)
if not cluster:
raise HTTPException(status_code=404, detail="Cluster not found")
old_values = {
"name": cluster.name,
"api_url": cluster.api_url,
"provider": cluster.provider,
"mode": cluster.mode,
"verify_tls": cluster.verify_tls,
}
cluster.name = payload.name
cluster.api_url = payload.api_url
cluster.provider = payload.provider
cluster.mode = payload.mode
cluster.verify_tls = payload.verify_tls
if payload.api_token:
cluster.token_ref = payload.api_token
commit_or_400(db)
db.refresh(cluster)
write_audit(
db,
action="cluster.updated",
object_type="cluster",
object_id=cluster.id,
user_id=user.id,
old_values=old_values,
new_values={**payload.model_dump(exclude={"api_token"}), "api_token_changed": bool(payload.api_token)},
)
return cluster
@api_router.delete("/clusters/{cluster_id}")
def delete_cluster(cluster_id: str, user: CurrentUser, db: Session = Depends(get_db)) -> dict[str, str]:
cluster = db.get(Cluster, cluster_id)
if not cluster:
raise HTTPException(status_code=404, detail="Cluster not found")
workload_ids = [row[0] for row in db.execute(select(Workload.id).where(Workload.cluster_id == cluster.id)).all()]
if workload_ids:
for address in db.scalars(select(IpAddress).where(IpAddress.workload_id.in_(workload_ids))).all():
db.delete(address)
network_ids = [row[0] for row in db.execute(select(Network.id).where(Network.cluster_id == cluster.id)).all()]
if network_ids:
subnet_ids = [row[0] for row in db.execute(select(Subnet.id).where(Subnet.network_id.in_(network_ids))).all()]
if subnet_ids:
for address in db.scalars(select(IpAddress).where(IpAddress.subnet_id.in_(subnet_ids))).all():
db.delete(address)
for subnet in db.scalars(select(Subnet).where(Subnet.id.in_(subnet_ids))).all():
db.delete(subnet)
for network in db.scalars(select(Network).where(Network.id.in_(network_ids))).all():
db.delete(network)
node_ids = [row[0] for row in db.execute(select(Node.id).where(Node.cluster_id == cluster.id)).all()]
if node_ids:
for agent in db.scalars(select(NodeAgent).where(NodeAgent.node_id.in_(node_ids))).all():
db.delete(agent)
for flow in db.scalars(select(TrafficFlow).where(TrafficFlow.node_id.in_(node_ids))).all():
db.delete(flow)
for workload in db.scalars(select(Workload).where(Workload.cluster_id == cluster.id)).all():
db.delete(workload)
for node in db.scalars(select(Node).where(Node.cluster_id == cluster.id)).all():
db.delete(node)
db.delete(cluster)
commit_or_400(db)
write_audit(db, action="cluster.deleted", object_type="cluster", object_id=cluster_id, user_id=user.id)
return {"status": "deleted", "id": cluster_id}
@api_router.post("/clusters/{cluster_id}/test")
async def test_cluster(cluster_id: str, _: CurrentUser, db: Session = Depends(get_db)) -> dict:
cluster = db.get(Cluster, cluster_id)
if not cluster:
raise HTTPException(status_code=404, detail="Cluster not found")
try:
result = await get_provider(cluster.provider).test_connection(
ProviderConnection(
api_url=cluster.api_url,
token=cluster.token_ref or "",
verify_tls=cluster.verify_tls,
read_only=cluster.mode == "read_only",
)
)
except Exception as exc:
raise HTTPException(status_code=502, detail=f"Provider connection failed: {exc}") from exc
return {"cluster_id": cluster.id, "status": "ok", "provider": cluster.provider, "result": result}
@api_router.post("/clusters/{cluster_id}/sync")
async def sync_cluster(cluster_id: str, user: CurrentUser, db: Session = Depends(get_db)) -> dict:
cluster = db.get(Cluster, cluster_id)
if not cluster:
raise HTTPException(status_code=404, detail="Cluster not found")
provider = get_provider(cluster.provider)
try:
inventory = await provider.sync_inventory(
ProviderConnection(
api_url=cluster.api_url,
token=cluster.token_ref or "",
verify_tls=cluster.verify_tls,
read_only=cluster.mode == "read_only",
)
)
except Exception as exc:
cluster.last_sync_at = datetime.utcnow()
cluster.last_sync_status = "failed"
cluster.last_sync_error = str(exc)
db.add(Job(kind="proxmox.sync", status="failed", progress=100, logs=[f"Sync failed for {cluster.name}"], error=str(exc)))
db.commit()
write_audit(
db,
action="cluster.sync",
object_type="cluster",
object_id=cluster.id,
user_id=user.id,
result="failed",
error_text=str(exc),
)
raise HTTPException(status_code=502, detail=f"Provider sync failed: {exc}") from exc
cluster.last_sync_at = datetime.utcnow()
cluster.last_sync_status = "success"
cluster.last_sync_error = None
node_by_name = {node.name: node for node in db.scalars(select(Node).where(Node.cluster_id == cluster.id)).all()}
for raw_node in inventory.get("nodes", []):
name = raw_node.get("node") or raw_node.get("name")
if not name:
continue
node = node_by_name.get(name)
if not node:
node = Node(cluster_id=cluster.id, name=name)
db.add(node)
node_by_name[name] = node
node.status = raw_node.get("status", node.status)
node.cpu_count = int(raw_node.get("maxcpu") or raw_node.get("cpu_count") or node.cpu_count or 0)
maxmem = raw_node.get("maxmem")
node.memory_mb = int(maxmem / 1024 / 1024) if isinstance(maxmem, int | float) else int(raw_node.get("memory_mb") or node.memory_mb or 0)
db.flush()
workload_by_external_id = {
workload.external_id: workload
for workload in db.scalars(select(Workload).where(Workload.cluster_id == cluster.id)).all()
}
for raw_workload in inventory.get("workloads", []):
external_id = str(raw_workload.get("vmid") or raw_workload.get("id") or "")
if not external_id:
continue
node_name = raw_workload.get("node")
node = node_by_name.get(node_name) or next(iter(node_by_name.values()), None)
if not node:
continue
workload = workload_by_external_id.get(external_id)
if not workload:
workload = Workload(cluster_id=cluster.id, node_id=node.id, external_id=external_id, name=external_id, kind="qemu")
db.add(workload)
workload_by_external_id[external_id] = workload
workload.node_id = node.id
workload.name = raw_workload.get("name") or workload.name
workload.kind = raw_workload.get("type") or raw_workload.get("kind") or workload.kind
workload.status = raw_workload.get("status") or workload.status
import_discovered_ips(db, cluster.id, workload, raw_workload.get("ip_addresses", []))
network_by_name = {
network.name: network
for network in db.scalars(select(Network).where(Network.cluster_id == cluster.id)).all()
}
for raw_network in inventory.get("networks", []):
name = raw_network.get("name") or raw_network.get("iface") or raw_network.get("id")
if not name:
continue
network = network_by_name.get(name)
if not network:
network = Network(cluster_id=cluster.id, name=name, kind=raw_network.get("type") or "network")
db.add(network)
network_by_name[name] = network
network.kind = raw_network.get("type") or raw_network.get("kind") or network.kind
vlan = raw_network.get("vlan") or raw_network.get("vlan_id")
network.vlan_id = int(vlan) if vlan not in (None, "") else network.vlan_id
db.add(Job(kind="proxmox.sync", status="success", progress=100, logs=[f"Synced {cluster.name}"]))
commit_or_400(db)
write_audit(db, action="cluster.sync", object_type="cluster", object_id=cluster.id, user_id=user.id)
return {"cluster_id": cluster.id, "status": "success", "inventory_counts": {key: len(value) for key, value in inventory.items()}}
@api_router.get("/nodes", response_model=list[NodeRead])
def nodes(_: CurrentUser, db: Session = Depends(get_db)) -> list[Node]:
return db.scalars(select(Node).order_by(Node.name)).all()
def node_agent_payload(node: Node, agent: NodeAgent | None) -> dict:
return {
"id": node.id,
"cluster_id": node.cluster_id,
"name": node.name,
"status": node.status,
"cpu_count": node.cpu_count,
"memory_mb": node.memory_mb,
"agent": {
"node_id": agent.node_id,
"status": agent.status,
"version": agent.version,
"last_seen_at": agent.last_seen_at,
"install_count": agent.install_count,
"last_payload": agent.last_payload,
}
if agent
else None,
}
def external_base_url(request: Request) -> str:
forwarded_host = request.headers.get("x-forwarded-host") or request.headers.get("host")
forwarded_proto = request.headers.get("x-forwarded-proto") or request.url.scheme
if forwarded_host:
return f"{forwarded_proto}://{forwarded_host}".rstrip("/")
return str(request.base_url).rstrip("/")
@api_router.get("/nodes/agents", response_model=list[NodeWithAgentRead])
def nodes_with_agents(_: CurrentUser, db: Session = Depends(get_db)) -> list[dict]:
agents = {agent.node_id: agent for agent in db.scalars(select(NodeAgent)).all()}
return [
node_agent_payload(node, agents.get(node.id))
for node in db.scalars(select(Node).order_by(Node.name)).all()
]
def agent_install_script(base_url: str, token: str, node: Node) -> str:
return f"""#!/bin/sh
set -eu
NEXAFABRIC_URL="${{NEXAFABRIC_URL:-{base_url.rstrip("/")}}}"
INSTALL_DIR="${{INSTALL_DIR:-/opt/nexafabric-agent}}"
CONFIG_DIR="${{CONFIG_DIR:-/etc/nexafabric-agent}}"
SERVICE_NAME="${{SERVICE_NAME:-nexafabric-agent}}"
if ! command -v python3 >/dev/null 2>&1; then
echo "python3 is required. Install python3 on this Proxmox node and run this script again." >&2
exit 1
fi
if ! command -v conntrack >/dev/null 2>&1 && command -v apt-get >/dev/null 2>&1; then
apt-get update
apt-get install -y conntrack
fi
mkdir -p "$INSTALL_DIR" "$CONFIG_DIR"
curl -fsSL "$NEXAFABRIC_URL/api/v1/agents/download/nexafabric-agent.py" -o "$INSTALL_DIR/nexafabric-agent.py"
chmod 0755 "$INSTALL_DIR/nexafabric-agent.py"
cat > "$CONFIG_DIR/config.json" <<'JSON'
{{
"api_url": "{base_url.rstrip("/")}/api/v1",
"token": "{token}",
"node_id": "{node.id}",
"node_name": "{node.name}",
"interval_seconds": 30,
"flow_limit": 500,
"packet_flow_collector": true,
"packet_flow_window_seconds": 10,
"firewall_log_collector": true,
"firewall_log_window_minutes": 5,
"verify_tls": true
}}
JSON
chmod 0600 "$CONFIG_DIR/config.json"
cat > "/etc/systemd/system/$SERVICE_NAME.service" <<EOF
[Unit]
Description=NexaFabric Node Agent
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/python3 $INSTALL_DIR/nexafabric-agent.py --config $CONFIG_DIR/config.json
Restart=always
RestartSec=10
User=root
[Install]
WantedBy=multi-user.target
EOF
if command -v systemctl >/dev/null 2>&1; then
systemctl daemon-reload
systemctl enable "$SERVICE_NAME.service"
systemctl restart "$SERVICE_NAME.service"
systemctl status "$SERVICE_NAME.service" --no-pager || true
else
echo "systemctl not found. Run manually: python3 $INSTALL_DIR/nexafabric-agent.py --config $CONFIG_DIR/config.json"
fi
"""
@api_router.get("/nodes/{node_id}/agent/install", response_class=PlainTextResponse)
def node_agent_install(node_id: str, request: Request, user: CurrentUser, db: Session = Depends(get_db)) -> str:
node = db.get(Node, node_id)
if not node:
raise HTTPException(status_code=404, detail="Node not found")
agent = db.get(NodeAgent, node.id)
if not agent:
agent = NodeAgent(node_id=node.id, status="pending_install", install_count=0)
db.add(agent)
agent.install_count = (agent.install_count or 0) + 1
token = create_token(node.id, "agent", timedelta(days=365), {"node_name": node.name})
write_audit(db, action="agent.install.generated", object_type="node", object_id=node.id, user_id=user.id)
base_url = external_base_url(request)
return agent_install_script(base_url, token, node)
@api_router.get("/nodes/{node_id}/agent/install-info")
def node_agent_install_info(node_id: str, request: Request, user: CurrentUser, db: Session = Depends(get_db)) -> dict[str, str]:
node = db.get(Node, node_id)
if not node:
raise HTTPException(status_code=404, detail="Node not found")
agent = db.get(NodeAgent, node.id)
if not agent:
agent = NodeAgent(node_id=node.id, status="pending_install", install_count=0)
db.add(agent)
agent.install_count = (agent.install_count or 0) + 1
install_token = create_token(node.id, "agent-install", timedelta(days=7), {"node_name": node.name})
write_audit(db, action="agent.install.generated", object_type="node", object_id=node.id, user_id=user.id)
base_url = external_base_url(request)
install_url = f"{base_url}/api/v1/agents/install/{node.id}?token={install_token}"
return {
"node_id": node.id,
"install_url": install_url,
"command": f"curl -fsSL '{install_url}' | sh",
}
@api_router.get("/agents/install/{node_id}", response_class=PlainTextResponse)
def public_node_agent_install(node_id: str, token: str, request: Request, db: Session = Depends(get_db)) -> str:
try:
claims = decode_token(token)
except Exception as exc:
raise HTTPException(status_code=401, detail="Invalid install token") from exc
if claims.get("typ") != "agent-install" or claims.get("sub") != node_id:
raise HTTPException(status_code=403, detail="Install token does not match this node")
node = db.get(Node, node_id)
if not node:
raise HTTPException(status_code=404, detail="Node not found")
agent_token = create_token(node.id, "agent", timedelta(days=365), {"node_name": node.name})
base_url = external_base_url(request)
return agent_install_script(base_url, agent_token, node)
@api_router.get("/agents/download/nexafabric-agent.py")
def download_node_agent() -> FileResponse:
path = Path(__file__).resolve().parents[2] / "agent_assets" / "nexafabric-agent.py"
return FileResponse(path, media_type="text/x-python", filename="nexafabric-agent.py")
@api_router.post("/agents/heartbeat")
def agent_heartbeat(payload: AgentHeartbeat, authorization: str | None = Header(default=None), db: Session = Depends(get_db)) -> dict:
if not authorization or not authorization.lower().startswith("bearer "):
raise HTTPException(status_code=401, detail="Missing agent token")
token = authorization.split(" ", 1)[1]
try:
claims = decode_token(token)
except Exception as exc:
raise HTTPException(status_code=401, detail="Invalid agent token") from exc
if claims.get("typ") != "agent":
raise HTTPException(status_code=403, detail="Token is not an agent token")
node = db.get(Node, claims.get("sub"))
if not node:
raise HTTPException(status_code=404, detail="Node not found")
agent = db.get(NodeAgent, node.id)
if not agent:
agent = NodeAgent(node_id=node.id, install_count=0)
db.add(agent)
agent.status = "online"
agent.version = payload.version
agent.last_seen_at = datetime.utcnow()
agent.last_payload = payload.model_dump(mode="json")
retention_cutoff = datetime.utcnow() - timedelta(hours=24)
for old_flow in db.scalars(select(TrafficFlow).where(TrafficFlow.node_id == node.id, TrafficFlow.updated_at < retention_cutoff)).all():
db.delete(old_flow)
existing_flows = {
(
flow.node_id,
flow.source_ip,
flow.destination_ip,
flow.protocol,
flow.source_port,
flow.destination_port,
str(flow.state or "observed").lower(),
): flow
for flow in db.scalars(select(TrafficFlow).where(TrafficFlow.node_id == node.id)).all()
}
for raw_flow in payload.flows[:1000]:
source_ip = str(raw_flow.get("source_ip") or "")
destination_ip = str(raw_flow.get("destination_ip") or "")
if not source_ip or not destination_ip:
continue
observed_at = payload.collected_at or datetime.utcnow()
observed_at = observed_at.replace(tzinfo=None) if observed_at.tzinfo else observed_at
key = traffic_flow_key(node.id, raw_flow)
existing = existing_flows.get(key)
if existing:
existing.bytes = flow_int(raw_flow.get("bytes"))
existing.packets = flow_int(raw_flow.get("packets"))
existing.state = str(raw_flow.get("decision") or raw_flow.get("state") or "") or None
existing.observed_at = observed_at
existing.raw = raw_flow
existing.updated_at = datetime.utcnow()
continue
flow = TrafficFlow(
node_id=node.id,
source_ip=source_ip,
destination_ip=destination_ip,
protocol=str(raw_flow.get("protocol") or "unknown"),
source_port=flow_int(raw_flow.get("source_port"), 0) or None,
destination_port=flow_int(raw_flow.get("destination_port"), 0) or None,
bytes=flow_int(raw_flow.get("bytes")),
packets=flow_int(raw_flow.get("packets")),
state=str(raw_flow.get("decision") or raw_flow.get("state") or "") or None,
observed_at=observed_at,
raw=raw_flow,
)
db.add(flow)
existing_flows[key] = flow
commit_or_400(db)
return {"status": "ok", "node_id": node.id}
@api_router.get("/vms", response_model=list[WorkloadRead])
def workloads(_: CurrentUser, db: Session = Depends(get_db)) -> list[Workload]:
return db.scalars(select(Workload).order_by(Workload.name)).all()
@api_router.get("/vms/{workload_id}/insights", response_model=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")
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).order_by(IpAddress.address)).all()
workload_ips = [address.address for address in assigned_ips]
all_assigned_ips = db.scalars(select(IpAddress).where(IpAddress.workload_id.is_not(None))).all()
ip_owners = {
address.address: db.get(Workload, address.workload_id)
for address in all_assigned_ips
}
workload_ips_by_id: dict[str, set[str]] = {}
for address in all_assigned_ips:
if address.workload_id:
workload_ips_by_id.setdefault(address.workload_id, set()).add(address.address)
known_subnets = db.scalars(select(Subnet).order_by(Subnet.cidr)).all()
active_firewall_rules = await active_firewall_rules_for_workload(db, workload)
traffic = []
if workload_ips:
workload_ip_set = set(workload_ips)
flows = db.scalars(
select(TrafficFlow)
.where((TrafficFlow.source_ip.in_(workload_ips)) | (TrafficFlow.destination_ip.in_(workload_ips)))
.order_by((TrafficFlow.state == "blocked").desc(), TrafficFlow.updated_at.desc())
.limit(50)
).all()
for flow in flows:
source_owner = ip_owners.get(flow.source_ip)
destination_owner = ip_owners.get(flow.destination_ip)
matching_firewall_rules = [
firewall_rule_flow_payload(rule)
for rule in active_firewall_rules
if "error" not in rule and firewall_rule_matches_flow(rule, flow, workload_ip_set)
]
matching_policies = [
policy_flow_payload(policy)
for policy in policies
if policy_matches_flow(db, policy, flow, ip_owners, workload_ips_by_id, workload.cluster_id)
]
raw_decision = raw_flow_decision(flow)
raw_payload = flow.raw if isinstance(flow.raw, dict) else {}
traffic.append(
{
"source": flow_endpoint_label(source_owner, known_subnets, flow.source_ip),
"destination": flow_endpoint_label(destination_owner, known_subnets, flow.destination_ip),
"source_label": flow_ip_label(source_owner, known_subnets, flow.source_ip),
"destination_label": flow_ip_label(destination_owner, known_subnets, flow.destination_ip),
"source_ip": flow.source_ip,
"destination_ip": flow.destination_ip,
"protocol": flow.protocol,
"port": flow.destination_port,
"source_port": flow.source_port,
"bytes": flow.bytes,
"packets": flow.packets,
"state": flow.state,
"decision": raw_decision or flow_policy_decision(matching_firewall_rules, matching_policies),
"collector": raw_payload.get("collector"),
"matching_firewall_rules": matching_firewall_rules,
"matching_audit_policies": [
policy for policy in matching_policies if str(policy.get("enforcement_mode")) == "audit"
],
"matching_policies": matching_policies,
"observed_at": flow.observed_at.isoformat() if flow.observed_at else None,
"ip_addresses": [flow.source_ip, flow.destination_ip],
}
)
if not traffic:
agent = db.get(NodeAgent, workload.node_id)
payload = agent.last_payload if agent and isinstance(agent.last_payload, dict) else {}
for item in payload.get("interface_traffic", []):
if not isinstance(item, dict) or str(item.get("vmid")) != str(workload.external_id):
continue
traffic.append(
{
"source": workload.name,
"destination": "network",
"source_label": f"{workload_ips[0]} (internal)" if workload_ips else workload.name,
"destination_label": "network",
"interface": item.get("interface"),
"protocol": item.get("protocol") or "interface-counter",
"port": None,
"bytes": item.get("bytes") or 0,
"packets": item.get("packets") or 0,
"rx_bytes": item.get("rx_bytes") or 0,
"tx_bytes": item.get("tx_bytes") or 0,
"rx_packets": item.get("rx_packets") or 0,
"tx_packets": item.get("tx_packets") or 0,
"state": item.get("state") or "unknown",
"decision": "observed",
"matching_firewall_rules": [],
"matching_audit_policies": [],
"matching_policies": [],
"observed_at": payload.get("collected_at"),
"ip_addresses": workload_ips,
"note": "Interface counter fallback. No host conntrack flows were available.",
}
)
audit_mode_notes = [
f"{policy.name} is in audit mode; matching traffic is logged without enforcement."
for policy in policies
if policy.enforcement_mode == "audit"
]
decision = "audit" if audit_mode_notes else "unknown"
return WorkloadInsight(
workload=workload,
assigned_ips=[ip_address_payload(db, address) for address in assigned_ips],
traffic=traffic,
active_firewall_rules=active_firewall_rules,
matching_policies=policies,
effective_decision=decision,
audit_mode_notes=audit_mode_notes,
)
@api_router.get("/networks", response_model=list[NetworkRead])
def networks(_: CurrentUser, db: Session = Depends(get_db)) -> list[Network]:
return db.scalars(select(Network).order_by(Network.name)).all()
@api_router.post("/networks", response_model=NetworkRead)
def create_network(payload: NetworkCreate, user: CurrentUser, db: Session = Depends(get_db)) -> Network:
if not db.get(Cluster, payload.cluster_id):
raise HTTPException(status_code=404, detail="Cluster not found")
network = Network(**payload.model_dump())
db.add(network)
commit_or_400(db)
db.refresh(network)
write_audit(db, action="network.created", object_type="network", object_id=network.id, user_id=user.id)
return network
@api_router.get("/ipam/subnets", response_model=list[SubnetRead])
def subnets(_: CurrentUser, db: Session = Depends(get_db)) -> list[Subnet]:
return db.scalars(select(Subnet).order_by(Subnet.cidr)).all()
@api_router.post("/ipam/subnets", response_model=SubnetRead)
def create_subnet(payload: SubnetCreate, user: CurrentUser, db: Session = Depends(get_db)) -> Subnet:
if not db.get(Network, payload.network_id):
raise HTTPException(status_code=404, detail="Network not found")
subnet = Subnet(**payload.model_dump())
db.add(subnet)
commit_or_400(db)
db.refresh(subnet)
write_audit(db, action="ipam.subnet.created", object_type="subnet", object_id=subnet.id, user_id=user.id)
return subnet
@api_router.patch("/ipam/subnets/{subnet_id}", response_model=SubnetRead)
def update_subnet(subnet_id: str, payload: SubnetUpdate, user: CurrentUser, db: Session = Depends(get_db)) -> Subnet:
subnet = db.get(Subnet, subnet_id)
if not subnet:
raise HTTPException(status_code=404, detail="Subnet not found")
changes = payload.model_dump(exclude_unset=True)
if "cidr" in changes and not changes["cidr"]:
raise HTTPException(status_code=400, detail="CIDR is required")
if "network_id" in changes and not changes["network_id"]:
raise HTTPException(status_code=400, detail="Network is required")
if "network_id" in changes and changes["network_id"] and not db.get(Network, changes["network_id"]):
raise HTTPException(status_code=404, detail="Network not found")
old_values = {
"network_id": subnet.network_id,
"cidr": subnet.cidr,
"gateway": subnet.gateway,
"dns": subnet.dns,
"dhcp_enabled": subnet.dhcp_enabled,
}
for key, value in changes.items():
setattr(subnet, key, value)
commit_or_400(db)
db.refresh(subnet)
write_audit(
db,
action="ipam.subnet.updated",
object_type="subnet",
object_id=subnet.id,
user_id=user.id,
old_values=old_values,
new_values=changes,
)
return subnet
@api_router.get("/ipam/addresses", response_model=list[IpAddressRead])
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:
try:
inventory = await get_provider(cluster.provider).sync_inventory(
ProviderConnection(
api_url=cluster.api_url,
token=cluster.token_ref or "",
verify_tls=cluster.verify_tls,
read_only=True,
)
)
workload_by_external_id = {
workload.external_id: workload
for workload in db.scalars(select(Workload).where(Workload.cluster_id == cluster.id)).all()
}
for raw_workload in inventory.get("workloads", []):
external_id = str(raw_workload.get("vmid") or raw_workload.get("id") or "")
workload = workload_by_external_id.get(external_id)
if workload:
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", 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, "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)
def reserve_ip(payload: IpReservationCreate, user: CurrentUser, db: Session = Depends(get_db)) -> IpAddress:
if not db.get(Subnet, payload.subnet_id):
raise HTTPException(status_code=404, detail="Subnet not found")
address = IpAddress(subnet_id=payload.subnet_id, address=payload.address, status=payload.status, note=payload.note)
db.add(address)
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 ip_address_payload(db, address)
@api_router.patch("/ipam/addresses/{address_id}", response_model=IpAddressRead)
def update_ip(address_id: str, payload: IpReservationCreate, user: CurrentUser, db: Session = Depends(get_db)) -> IpAddress:
address = db.get(IpAddress, address_id)
if not address:
raise HTTPException(status_code=404, detail="IP address not found")
old_values = {"address": address.address, "status": address.status, "note": address.note}
address.subnet_id = payload.subnet_id
address.address = payload.address
address.status = payload.status
address.note = payload.note
commit_or_400(db)
db.refresh(address)
write_audit(
db,
action="ipam.address.updated",
object_type="ip_address",
object_id=address.id,
user_id=user.id,
old_values=old_values,
new_values={"address": address.address, "status": address.status, "note": address.note},
)
return ip_address_payload(db, address)
@api_router.delete("/ipam/addresses/{address_id}")
def delete_ip(address_id: str, user: CurrentUser, db: Session = Depends(get_db)) -> dict[str, str]:
address = db.get(IpAddress, address_id)
if not address:
raise HTTPException(status_code=404, detail="IP address not found")
db.delete(address)
commit_or_400(db)
write_audit(db, action="ipam.address.deleted", object_type="ip_address", object_id=address_id, user_id=user.id)
return {"status": "deleted", "id": address_id}
@api_router.get("/ipam/export.csv")
def export_ipam(_: CurrentUser, db: Session = Depends(get_db)) -> StreamingResponse:
buffer = io.StringIO()
writer = csv.writer(buffer)
writer.writerow(["subnet_id", "address", "status", "workload_id", "note"])
for address in db.scalars(select(IpAddress).order_by(IpAddress.address)):
writer.writerow([address.subnet_id, address.address, address.status, address.workload_id or "", address.note or ""])
buffer.seek(0)
return StreamingResponse(
iter([buffer.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=nexafabric-ipam.csv"},
)
@api_router.get("/tenants", response_model=list[TenantRead])
def tenants(_: CurrentUser, db: Session = Depends(get_db)) -> list[Tenant]:
return db.scalars(select(Tenant).order_by(Tenant.name)).all()
@api_router.post("/tenants", response_model=TenantRead)
def create_tenant(payload: TenantCreate, user: CurrentUser, db: Session = Depends(get_db)) -> Tenant:
tenant = Tenant(**payload.model_dump())
db.add(tenant)
commit_or_400(db)
db.refresh(tenant)
write_audit(db, action="tenant.created", object_type="tenant", object_id=tenant.id, user_id=user.id)
return tenant
@api_router.get("/projects", response_model=list[ProjectRead])
def projects(_: CurrentUser, db: Session = Depends(get_db)) -> list[Project]:
return db.scalars(select(Project).order_by(Project.name)).all()
@api_router.post("/projects", response_model=ProjectRead)
def create_project(payload: ProjectCreate, user: CurrentUser, db: Session = Depends(get_db)) -> Project:
if not db.get(Tenant, payload.tenant_id):
raise HTTPException(status_code=404, detail="Tenant not found")
project = Project(**payload.model_dump())
db.add(project)
commit_or_400(db)
db.refresh(project)
write_audit(db, action="project.created", object_type="project", object_id=project.id, user_id=user.id)
return project
@api_router.get("/security-groups", response_model=list[SecurityGroupRead])
def security_groups(_: CurrentUser, db: Session = Depends(get_db)) -> list[SecurityGroup]:
return db.scalars(select(SecurityGroup).order_by(SecurityGroup.name)).all()
@api_router.post("/security-groups", response_model=SecurityGroupRead)
def create_security_group(payload: SecurityGroupCreate, user: CurrentUser, db: Session = Depends(get_db)) -> SecurityGroup:
group = SecurityGroup(project_id=payload.project_id, name=payload.name, description=payload.description)
db.add(group)
commit_or_400(db)
db.refresh(group)
write_audit(db, action="security_group.created", object_type="security_group", object_id=group.id, user_id=user.id)
return group
@api_router.get("/security-groups/{group_id}/rules", response_model=list[SecurityRuleRead])
def security_group_rules(group_id: str, _: CurrentUser, db: Session = Depends(get_db)) -> list[SecurityRule]:
return db.scalars(
select(SecurityRule)
.where(SecurityRule.security_group_id == group_id)
.order_by(SecurityRule.priority, SecurityRule.created_at)
).all()
@api_router.post("/security-rules", response_model=SecurityRuleRead)
def create_security_rule(payload: SecurityRuleCreate, user: CurrentUser, db: Session = Depends(get_db)) -> SecurityRule:
if not db.get(SecurityGroup, payload.security_group_id):
raise HTTPException(status_code=404, detail="Security group not found")
rule = SecurityRule(**payload.model_dump())
db.add(rule)
commit_or_400(db)
db.refresh(rule)
write_audit(db, action="security_rule.created", object_type="security_rule", object_id=rule.id, user_id=user.id)
return rule
@api_router.delete("/security-rules/{rule_id}")
def delete_security_rule(rule_id: str, user: CurrentUser, db: Session = Depends(get_db)) -> dict[str, str]:
rule = db.get(SecurityRule, rule_id)
if not rule:
raise HTTPException(status_code=404, detail="Security rule not found")
db.delete(rule)
commit_or_400(db)
write_audit(db, action="security_rule.deleted", object_type="security_rule", object_id=rule_id, user_id=user.id)
return {"status": "deleted", "id": rule_id}
@api_router.get("/policies", response_model=list[PolicyRead])
async def policies(_: CurrentUser, db: Session = Depends(get_db)) -> list[dict[str, object]]:
return [
policy_read_payload(policy, await policy_deployment_status(db, policy))
for policy in db.scalars(select(Policy).order_by(Policy.name)).all()
]
@api_router.post("/policies", response_model=PolicyRead)
def create_policy(payload: PolicyCreate, user: CurrentUser, db: Session = Depends(get_db)) -> Policy:
policy = Policy(project_id=payload.project_id, name=payload.name, enabled=payload.enabled, definition=normalized_policy_definition(payload.definition))
db.add(policy)
commit_or_400(db)
db.refresh(policy)
write_audit(db, action="policy.created", object_type="policy", object_id=policy.id, user_id=user.id)
return policy
@api_router.patch("/policies/{policy_id}", response_model=PolicyRead)
def update_policy(policy_id: str, payload: PolicyCreate, user: CurrentUser, db: Session = Depends(get_db)) -> Policy:
policy = db.get(Policy, policy_id)
if not policy:
raise HTTPException(status_code=404, detail="Policy not found")
old_values = {"name": policy.name, "enabled": policy.enabled, "definition": policy.definition, "version": policy.version}
policy.project_id = payload.project_id
policy.name = payload.name
policy.enabled = payload.enabled
policy.definition = normalized_policy_definition(payload.definition)
policy.version += 1
commit_or_400(db)
db.refresh(policy)
write_audit(db, action="policy.updated", object_type="policy", object_id=policy.id, user_id=user.id, old_values=old_values, new_values=payload.model_dump())
return policy
@api_router.delete("/policies/{policy_id}")
def delete_policy(policy_id: str, user: CurrentUser, db: Session = Depends(get_db)) -> dict[str, str]:
policy = db.get(Policy, policy_id)
if not policy:
raise HTTPException(status_code=404, detail="Policy not found")
db.delete(policy)
commit_or_400(db)
write_audit(db, action="policy.deleted", object_type="policy", object_id=policy_id, user_id=user.id)
return {"status": "deleted", "id": policy_id}
@api_router.post("/policies/{policy_id}/compile", response_model=PolicyRead)
def compile_policy(policy_id: str, user: CurrentUser, db: Session = Depends(get_db)) -> Policy:
from app.services.policy_engine import PolicyEngine
policy = db.get(Policy, policy_id)
if not policy:
raise HTTPException(status_code=404, detail="Policy not found")
policy.last_compiled = PolicyEngine().compile(policy)
db.add(Job(kind="policy.compile", status="success", progress=100, logs=[f"Compiled policy {policy.name}"]))
commit_or_400(db)
db.refresh(policy)
write_audit(db, action="policy.compiled", object_type="policy", object_id=policy.id, user_id=user.id, new_values=policy.last_compiled)
return policy
@api_router.post("/firewall/preview/{policy_id}", response_model=FirewallPreview)
async def firewall_preview(policy_id: str, user: CurrentUser, db: Session = Depends(get_db)) -> FirewallPreview:
policy = db.get(Policy, policy_id)
cluster = db.scalar(select(Cluster).order_by(Cluster.name).limit(1))
if not policy or not cluster:
raise HTTPException(status_code=404, detail="Policy or cluster not found")
preview = resolve_firewall_preview(db, cluster, await FirewallOrchestrator().preview(cluster, policy))
write_audit(db, action="firewall.preview", object_type="policy", object_id=policy.id, user_id=user.id, new_values=preview.model_dump())
return preview
@api_router.post("/firewall/apply")
async def firewall_apply(payload: FirewallApplyRequest, user: CurrentUser, db: Session = Depends(get_db)) -> dict:
if not payload.confirm:
raise HTTPException(status_code=400, detail="Firewall apply requires confirm=true after preview review")
policy = db.get(Policy, payload.policy_id)
cluster = db.get(Cluster, payload.cluster_id) if payload.cluster_id else db.scalar(select(Cluster).order_by(Cluster.name).limit(1))
if not policy or not cluster:
raise HTTPException(status_code=404, detail="Policy or cluster not found")
preview = resolve_firewall_preview(db, cluster, await FirewallOrchestrator().preview(cluster, policy))
writable_rules = [rule for rule in preview.generated_rules if not rule.get("audit_only")]
policy_mode = policy.enforcement_mode
if payload.dry_run:
result = {
"applied": False,
"dry_run": True,
"policy_mode": policy_mode,
"reason": "Dry run completed. No firewall rules were applied.",
"rules": preview.generated_rules,
}
elif preview.generated_rules and not writable_rules:
result = {
"applied": False,
"policy_mode": policy_mode,
"reason": "Policy is in audit mode. Audit policies do not write Proxmox firewall rules. Change the policy mode to enforced before live apply.",
"rules": preview.generated_rules,
}
elif preview.conflicts:
result = {
"applied": False,
"policy_mode": policy_mode,
"reason": "Live apply stopped because the preview has unresolved conflicts.",
"conflicts": preview.conflicts,
"rules": preview.generated_rules,
}
else:
provider = get_provider(cluster.provider)
try:
result = await provider.apply_rules(
ProviderConnection(
api_url=cluster.api_url,
token=cluster.token_ref or "",
verify_tls=cluster.verify_tls,
read_only=cluster.mode == "read_only",
),
preview.generated_rules,
)
except Exception as exc:
result = {
"applied": False,
"policy_mode": policy_mode,
"reason": f"Provider apply failed: {exc}",
"rules": preview.generated_rules,
}
else:
result["policy_mode"] = policy_mode
applied = bool(result.get("applied"))
operation_success = applied or payload.dry_run
job = Job(
kind="firewall.apply",
status="success" if operation_success else "failed",
progress=100,
started_at=datetime.utcnow(),
finished_at=datetime.utcnow(),
logs=[f"Policy {policy.name}", f"Cluster mode: {cluster.mode}", f"Dry run: {payload.dry_run}", str(result)],
error=None if operation_success else result.get("reason", "Provider did not apply rules"),
)
db.add(job)
commit_or_400(db)
write_audit(
db,
action="firewall.apply",
object_type="policy",
object_id=policy.id,
user_id=user.id,
new_values={"request": payload.model_dump(), "result": result},
result="success" if operation_success else "blocked",
error_text=None if operation_success else result.get("reason"),
)
return {"job_id": job.id, "preview": preview.model_dump(), "provider_result": result}
@api_router.get("/service-catalog", response_model=list[ServiceCatalogRead])
def service_catalog(_: CurrentUser, db: Session = Depends(get_db)) -> list[ServiceCatalogItem]:
return db.scalars(select(ServiceCatalogItem).order_by(ServiceCatalogItem.name)).all()
@api_router.post("/service-catalog", response_model=ServiceCatalogRead)
def create_service(payload: ServiceCatalogCreate, user: CurrentUser, db: Session = Depends(get_db)) -> ServiceCatalogItem:
service = ServiceCatalogItem(**payload.model_dump())
db.add(service)
commit_or_400(db)
db.refresh(service)
write_audit(db, action="service.created", object_type="service_catalog", object_id=service.id, user_id=user.id)
return service
@api_router.get("/jobs", response_model=list[JobRead])
def jobs(_: CurrentUser, db: Session = Depends(get_db)) -> list[Job]:
return db.scalars(select(Job).order_by(Job.created_at.desc())).all()
@api_router.get("/audit", response_model=list[AuditLogRead])
def audit(_: CurrentUser, db: Session = Depends(get_db)) -> list[AuditLog]:
return db.scalars(select(AuditLog).order_by(AuditLog.created_at.desc()).limit(200)).all()
@api_router.get("/settings")
def settings(_: CurrentUser) -> dict:
return {"product": "NexaFabric", "firewall_apply_requires_preview": True, "agent_optional": True}