Files
NexaFabric/backend/app/api/v1/router.py
T
nessi 3aa7ae0c65 feat: add Go-based eBPF helper using AF_PACKET raw sockets with automatic build in agent installer
Add nexafabric-ebpf.go implementing flow collection via Linux raw packet sockets (AF_PACKET) instead of tc/eBPF to enable immediate Proxmox deployment without kernel dependencies, implement packet parsing with VLAN/IP/TCP/UDP/ICMP support and flow aggregation by 5-tuple with vmid/nic/interface/direction metadata extraction from tap/fwbr interface names, add /agents/download/nexafabric-ebpf.go endpoint
2026-07-10 14:19:28 +02:00

2295 lines
94 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, Query, Request
from fastapi.responses import FileResponse, PlainTextResponse, StreamingResponse
from sqlalchemy import func, or_, 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,
SecurityGroupMember,
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,
RuntimeSettingsRead,
RuntimeSettingsUpdate,
SecurityRuleCreate,
SecurityRuleRead,
ServiceCatalogCreate,
ServiceCatalogRead,
SecurityGroupCreate,
SecurityGroupMemberCreate,
SecurityGroupMemberRead,
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 runtime_setting(db: Session) -> SystemSetting:
setting = db.get(SystemSetting, "runtime")
if not setting:
setting = SystemSetting(
key="runtime",
value={
"flow_retention_hours": 24,
"auto_node_sync_enabled": False,
"auto_node_sync_interval_minutes": 60,
"auto_ipam_sync_enabled": False,
"auto_ipam_sync_interval_minutes": 60,
},
)
db.add(setting)
db.commit()
db.refresh(setting)
return setting
def runtime_settings_payload(db: Session) -> RuntimeSettingsRead:
value = runtime_setting(db).value or {}
return RuntimeSettingsRead(
flow_retention_hours=int(value.get("flow_retention_hours") or 24),
auto_node_sync_enabled=bool(value.get("auto_node_sync_enabled", False)),
auto_node_sync_interval_minutes=int(value.get("auto_node_sync_interval_minutes") or 60),
auto_ipam_sync_enabled=bool(value.get("auto_ipam_sync_enabled", False)),
auto_ipam_sync_interval_minutes=int(value.get("auto_ipam_sync_interval_minutes") or 60),
last_node_auto_sync_at=datetime.fromisoformat(value["last_node_auto_sync_at"]) if value.get("last_node_auto_sync_at") else None,
last_ipam_auto_sync_at=datetime.fromisoformat(value["last_ipam_auto_sync_at"]) if value.get("last_ipam_auto_sync_at") else None,
)
def require_super_admin(user: User) -> None:
permissions = {permission for role in user.roles for permission in role.permissions}
if "*" not in permissions:
raise HTTPException(status_code=403, detail="Super Admin permission required")
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 workload_provider_targets(db: Session, cluster: Cluster, ref: str) -> list[tuple[dict, Workload]]:
resolved = workload_provider_target(db, cluster, ref)
if resolved:
return [resolved]
if not ref.startswith("sg:"):
return []
group_ref = ref.removeprefix("sg:")
group = db.get(SecurityGroup, group_ref) or db.scalar(select(SecurityGroup).where(SecurityGroup.name == group_ref))
if not group:
return []
workloads = db.scalars(
select(Workload)
.join(SecurityGroupMember, SecurityGroupMember.workload_id == Workload.id)
.where(SecurityGroupMember.security_group_id == group.id, Workload.cluster_id == cluster.id)
.order_by(Workload.name)
).all()
targets = []
for workload in workloads:
target = workload_provider_target(db, cluster, f"workload:{workload.id}")
if target:
targets.append(target)
return targets
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."]
if ref.startswith("sg:"):
group_ref = ref.removeprefix("sg:")
group = db.get(SecurityGroup, group_ref) or db.scalar(select(SecurityGroup).where(SecurityGroup.name == group_ref))
if not group:
return [], [f"Security group {group_ref} was not found."]
workload_ids = [
row[0]
for row in db.execute(
select(SecurityGroupMember.workload_id)
.join(Workload, Workload.id == SecurityGroupMember.workload_id)
.where(SecurityGroupMember.security_group_id == group.id, Workload.cluster_id == cluster.id)
).all()
]
if not workload_ids:
return [], [f"Security group {group.name} has no workloads in this cluster."]
values = [
address.address
for address in db.scalars(select(IpAddress).where(IpAddress.workload_id.in_(workload_ids)).order_by(IpAddress.address)).all()
if address.address
]
if values:
return values, []
return [], [f"Security group {group.name} has no assigned member IPs."]
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 value.startswith("sg:"):
group_ref = value.removeprefix("sg:")
group = db.get(SecurityGroup, group_ref) or db.scalar(select(SecurityGroup).where(SecurityGroup.name == group_ref))
if not group:
return False
if side_workload:
return db.scalar(
select(func.count())
.select_from(SecurityGroupMember)
.where(SecurityGroupMember.security_group_id == group.id, SecurityGroupMember.workload_id == side_workload.id)
) > 0
member_ips = db.scalars(
select(IpAddress.address)
.join(SecurityGroupMember, SecurityGroupMember.workload_id == IpAddress.workload_id)
.where(SecurityGroupMember.security_group_id == group.id)
).all()
return flow_ip in set(member_ips)
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 compact_agent_payload(payload: AgentHeartbeat) -> dict:
value = payload.model_dump(mode="json")
flows = value.get("flows")
if isinstance(flows, list):
value["flow_count"] = len(flows)
value["flows"] = flows[:50]
value["flows_truncated"] = len(flows) > 50
ebpf_flows = value.get("ebpf_flows")
if isinstance(ebpf_flows, list):
value["ebpf_flow_count"] = len(ebpf_flows)
value["ebpf_flows"] = ebpf_flows[:50]
value["ebpf_flows_truncated"] = len(ebpf_flows) > 50
return value
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] = {}
source_rows = db.execute(
select(Workload.name, func.coalesce(func.sum(TrafficFlow.bytes), 0))
.join(IpAddress, IpAddress.workload_id == Workload.id)
.join(TrafficFlow, TrafficFlow.source_ip == IpAddress.address)
.group_by(Workload.name)
).all()
destination_rows = db.execute(
select(Workload.name, func.coalesce(func.sum(TrafficFlow.bytes), 0))
.join(IpAddress, IpAddress.workload_id == Workload.id)
.join(TrafficFlow, TrafficFlow.destination_ip == IpAddress.address)
.group_by(Workload.name)
).all()
for name, bytes_value in [*source_rows, *destination_rows]:
totals[str(name)] = totals.get(str(name), 0) + int(bytes_value or 0)
if not totals:
workloads = db.scalars(select(Workload)).all()
workloads_by_node_vmid = {(workload.node_id, workload.external_id): workload for workload in workloads}
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()
}
blocked_states = ["blocked", "drop", "dropped", "reject", "rejected", "deny", "denied"]
candidate_flows = db.scalars(
select(TrafficFlow)
.where(
or_(
TrafficFlow.destination_port.in_(list(sensitive_ports)),
func.lower(func.coalesce(TrafficFlow.state, "")).in_(blocked_states),
)
)
.order_by(TrafficFlow.updated_at.desc())
).all()
events: dict[tuple[str, str, int], dict[str, int | str]] = {}
for flow in candidate_flows:
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"))
targets = workload_provider_targets(db, cluster, target_ref)
if not targets:
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
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.")
for provider_target, _target_workload in targets:
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 provider_target, target_workload in targets:
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,
)
def cluster_provider_targets(db: Session, cluster: Cluster) -> list[dict]:
targets = []
workloads = db.scalars(select(Workload).where(Workload.cluster_id == cluster.id).order_by(Workload.name)).all()
for workload in workloads:
resolved = workload_provider_target(db, cluster, f"workload:{workload.id}")
if resolved:
target, _ = resolved
targets.append(target)
return targets
async def cleanup_policy_provider_rules(db: Session, policy_id: str) -> dict[str, object]:
results = []
total_deleted = 0
for cluster in db.scalars(select(Cluster).order_by(Cluster.name)).all():
targets = cluster_provider_targets(db, cluster)
if not targets:
results.append({"cluster_id": cluster.id, "cluster_name": cluster.name, "rules_deleted": 0, "reason": "No targets"})
continue
provider = get_provider(cluster.provider)
delete_rules = getattr(provider, "delete_policy_rules", None)
if not delete_rules:
results.append({"cluster_id": cluster.id, "cluster_name": cluster.name, "rules_deleted": 0, "reason": "Provider cannot delete policy rules"})
continue
result = await delete_rules(
ProviderConnection(
api_url=cluster.api_url,
token=cluster.token_ref or "",
verify_tls=cluster.verify_tls,
read_only=cluster.mode == "read_only",
),
targets,
policy_id,
)
total_deleted += int(result.get("rules_deleted") or 0)
results.append({"cluster_id": cluster.id, "cluster_name": cluster.name, **result})
return {"policy_id": policy_id, "rules_deleted": total_deleted, "clusters": results}
@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 member in db.scalars(select(SecurityGroupMember).where(SecurityGroupMember.workload_id.in_(workload_ids))).all():
db.delete(member)
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
if ! command -v go >/dev/null 2>&1 && command -v apt-get >/dev/null 2>&1; then
apt-get update
apt-get install -y golang-go || true
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"
curl -fsSL "$NEXAFABRIC_URL/api/v1/agents/download/nexafabric-ebpf.go" -o "$INSTALL_DIR/nexafabric-ebpf.go" || true
if command -v go >/dev/null 2>&1 && [ -f "$INSTALL_DIR/nexafabric-ebpf.go" ]; then
(cd "$INSTALL_DIR" && go build -o "$INSTALL_DIR/nexafabric-ebpf" "$INSTALL_DIR/nexafabric-ebpf.go") || true
[ -f "$INSTALL_DIR/nexafabric-ebpf" ] && chmod 0755 "$INSTALL_DIR/nexafabric-ebpf"
fi
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": 1500,
"ebpf_collector": true,
"ebpf_binary": "/opt/nexafabric-agent/nexafabric-ebpf",
"ebpf_window_seconds": 10,
"ebpf_timeout_seconds": 15,
"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.get("/agents/download/nexafabric-ebpf.go")
def download_node_agent_ebpf_helper() -> FileResponse:
path = Path(__file__).resolve().parents[2] / "agent_assets" / "nexafabric-ebpf.go"
return FileResponse(path, media_type="text/x-go", filename="nexafabric-ebpf.go")
@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 = compact_agent_payload(payload)
retention_hours = runtime_settings_payload(db).flow_retention_hours
retention_cutoff = datetime.utcnow() - timedelta(hours=retention_hours)
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[:5000]:
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),
traffic_mode: str = Query(default="summary", alias="traffic", pattern="^(summary|full)$"),
include_rules: bool = False,
include_flow_context: bool = False,
) -> 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()
owner_ids = {address.workload_id for address in all_assigned_ips if address.workload_id}
owners = {workload.id: workload for workload in db.scalars(select(Workload).where(Workload.id.in_(owner_ids))).all()} if owner_ids else {}
ip_owners = {address.address: owners.get(address.workload_id) for address in all_assigned_ips if address.workload_id}
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) if include_rules else []
traffic_rows = []
if workload_ips:
workload_ip_set = set(workload_ips)
flow_query = (
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())
)
if traffic_mode == "summary":
flow_query = flow_query.limit(250)
flows = db.scalars(flow_query).all()
for flow in flows:
source_owner = ip_owners.get(flow.source_ip)
destination_owner = ip_owners.get(flow.destination_ip)
matching_firewall_rules = []
matching_policies = []
if include_flow_context:
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_rows.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) if include_flow_context else "observed"),
"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_rows:
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_rows.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_rows,
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[dict[str, object]]:
groups = db.scalars(select(SecurityGroup).order_by(SecurityGroup.name)).all()
members_by_group: dict[str, list[dict[str, object]]] = {group.id: [] for group in groups}
if groups:
rows = db.execute(
select(SecurityGroupMember, Workload)
.join(Workload, Workload.id == SecurityGroupMember.workload_id)
.where(SecurityGroupMember.security_group_id.in_([group.id for group in groups]))
.order_by(Workload.name)
).all()
for member, workload in rows:
members_by_group.setdefault(member.security_group_id, []).append(
{
"id": member.id,
"security_group_id": member.security_group_id,
"workload_id": member.workload_id,
"workload_name": workload.name,
"workload_external_id": workload.external_id,
}
)
return [
{
"id": group.id,
"project_id": group.project_id,
"name": group.name,
"description": group.description,
"members": members_by_group.get(group.id, []),
}
for group in groups
]
@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
def security_group_member_payload(db: Session, member: SecurityGroupMember) -> dict[str, object]:
workload = db.get(Workload, member.workload_id)
return {
"id": member.id,
"security_group_id": member.security_group_id,
"workload_id": member.workload_id,
"workload_name": workload.name if workload else None,
"workload_external_id": workload.external_id if workload else None,
}
@api_router.post("/security-groups/{group_id}/members", response_model=SecurityGroupMemberRead)
def add_security_group_member(group_id: str, payload: SecurityGroupMemberCreate, user: CurrentUser, db: Session = Depends(get_db)) -> dict[str, object]:
if not db.get(SecurityGroup, group_id):
raise HTTPException(status_code=404, detail="Security group not found")
if not db.get(Workload, payload.workload_id):
raise HTTPException(status_code=404, detail="Workload not found")
existing = db.scalar(
select(SecurityGroupMember).where(
SecurityGroupMember.security_group_id == group_id,
SecurityGroupMember.workload_id == payload.workload_id,
)
)
if existing:
return security_group_member_payload(db, existing)
member = SecurityGroupMember(security_group_id=group_id, workload_id=payload.workload_id)
db.add(member)
commit_or_400(db)
db.refresh(member)
write_audit(db, action="security_group.member_added", object_type="security_group", object_id=group_id, user_id=user.id, new_values=payload.model_dump())
return security_group_member_payload(db, member)
@api_router.delete("/security-groups/{group_id}/members/{member_id}")
def delete_security_group_member(group_id: str, member_id: str, user: CurrentUser, db: Session = Depends(get_db)) -> dict[str, str]:
member = db.get(SecurityGroupMember, member_id)
if not member or member.security_group_id != group_id:
raise HTTPException(status_code=404, detail="Security group member not found")
db.delete(member)
commit_or_400(db)
write_audit(db, action="security_group.member_removed", object_type="security_group", object_id=group_id, user_id=user.id, old_values={"member_id": member_id})
return {"status": "deleted", "id": member_id}
@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)
async 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}
old_mode = policy.enforcement_mode
old_enabled = policy.enabled
new_definition = normalized_policy_definition(payload.definition)
new_mode = str(new_definition.get("enforcement_mode") or "enforced")
policy.project_id = payload.project_id
policy.name = payload.name
policy.enabled = payload.enabled
policy.definition = new_definition
policy.version += 1
commit_or_400(db)
db.refresh(policy)
cleanup_result = None
if old_enabled and old_mode == "enforced" and (not payload.enabled or new_mode == "audit"):
cleanup_result = await cleanup_policy_provider_rules(db, policy.id)
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(), "provider_cleanup": cleanup_result},
)
return policy
@api_router.delete("/policies/{policy_id}")
async def delete_policy(policy_id: str, user: CurrentUser, db: Session = Depends(get_db)) -> dict[str, object]:
policy = db.get(Policy, policy_id)
if not policy:
raise HTTPException(status_code=404, detail="Policy not found")
cleanup_result = await cleanup_policy_provider_rules(db, policy.id)
db.delete(policy)
commit_or_400(db)
write_audit(db, action="policy.deleted", object_type="policy", object_id=policy_id, user_id=user.id, new_values={"provider_cleanup": cleanup_result})
return {"status": "deleted", "id": policy_id, "provider_cleanup": cleanup_result}
@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", response_model=RuntimeSettingsRead)
def settings(_: CurrentUser, db: Session = Depends(get_db)) -> RuntimeSettingsRead:
return runtime_settings_payload(db)
@api_router.patch("/settings", response_model=RuntimeSettingsRead)
def update_settings(payload: RuntimeSettingsUpdate, user: CurrentUser, db: Session = Depends(get_db)) -> RuntimeSettingsRead:
require_super_admin(user)
setting = runtime_setting(db)
old_values = dict(setting.value or {})
changes = payload.model_dump(exclude_unset=True, exclude_none=True)
setting.value = {**old_values, **changes}
commit_or_400(db)
write_audit(
db,
action="settings.updated",
object_type="system",
object_id="runtime",
user_id=user.id,
old_values=old_values,
new_values=setting.value,
)
return runtime_settings_payload(db)