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, 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, 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 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 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 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")) 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, ) 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 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": 1500, "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" </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 = 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[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) 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)