Add SecurityGroupMember model with security_group_id/workload_id foreign keys and unique constraint, implement security_group_members table with timestamps, add SecurityGroupMemberCreate/SecurityGroupMemberRead schemas with workload_name/workload_external_id fields, implement workload_provider_targets helper to expand sg: prefix into multiple workload targets with
2270 lines
93 KiB
Python
2270 lines
93 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
|
|
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
|
|
|
|
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" <<EOF
|
|
[Unit]
|
|
Description=NexaFabric Node Agent
|
|
After=network-online.target
|
|
Wants=network-online.target
|
|
|
|
[Service]
|
|
Type=simple
|
|
ExecStart=/usr/bin/python3 $INSTALL_DIR/nexafabric-agent.py --config $CONFIG_DIR/config.json
|
|
Restart=always
|
|
RestartSec=10
|
|
User=root
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
EOF
|
|
|
|
if command -v systemctl >/dev/null 2>&1; then
|
|
systemctl daemon-reload
|
|
systemctl enable "$SERVICE_NAME.service"
|
|
systemctl restart "$SERVICE_NAME.service"
|
|
systemctl status "$SERVICE_NAME.service" --no-pager || true
|
|
else
|
|
echo "systemctl not found. Run manually: python3 $INSTALL_DIR/nexafabric-agent.py --config $CONFIG_DIR/config.json"
|
|
fi
|
|
"""
|
|
|
|
|
|
@api_router.get("/nodes/{node_id}/agent/install", response_class=PlainTextResponse)
|
|
def node_agent_install(node_id: str, request: Request, user: CurrentUser, db: Session = Depends(get_db)) -> str:
|
|
node = db.get(Node, node_id)
|
|
if not node:
|
|
raise HTTPException(status_code=404, detail="Node not found")
|
|
agent = db.get(NodeAgent, node.id)
|
|
if not agent:
|
|
agent = NodeAgent(node_id=node.id, status="pending_install", install_count=0)
|
|
db.add(agent)
|
|
agent.install_count = (agent.install_count or 0) + 1
|
|
token = create_token(node.id, "agent", timedelta(days=365), {"node_name": node.name})
|
|
write_audit(db, action="agent.install.generated", object_type="node", object_id=node.id, user_id=user.id)
|
|
base_url = external_base_url(request)
|
|
return agent_install_script(base_url, token, node)
|
|
|
|
|
|
@api_router.get("/nodes/{node_id}/agent/install-info")
|
|
def node_agent_install_info(node_id: str, request: Request, user: CurrentUser, db: Session = Depends(get_db)) -> dict[str, str]:
|
|
node = db.get(Node, node_id)
|
|
if not node:
|
|
raise HTTPException(status_code=404, detail="Node not found")
|
|
agent = db.get(NodeAgent, node.id)
|
|
if not agent:
|
|
agent = NodeAgent(node_id=node.id, status="pending_install", install_count=0)
|
|
db.add(agent)
|
|
agent.install_count = (agent.install_count or 0) + 1
|
|
install_token = create_token(node.id, "agent-install", timedelta(days=7), {"node_name": node.name})
|
|
write_audit(db, action="agent.install.generated", object_type="node", object_id=node.id, user_id=user.id)
|
|
base_url = external_base_url(request)
|
|
install_url = f"{base_url}/api/v1/agents/install/{node.id}?token={install_token}"
|
|
return {
|
|
"node_id": node.id,
|
|
"install_url": install_url,
|
|
"command": f"curl -fsSL '{install_url}' | sh",
|
|
}
|
|
|
|
|
|
@api_router.get("/agents/install/{node_id}", response_class=PlainTextResponse)
|
|
def public_node_agent_install(node_id: str, token: str, request: Request, db: Session = Depends(get_db)) -> str:
|
|
try:
|
|
claims = decode_token(token)
|
|
except Exception as exc:
|
|
raise HTTPException(status_code=401, detail="Invalid install token") from exc
|
|
if claims.get("typ") != "agent-install" or claims.get("sub") != node_id:
|
|
raise HTTPException(status_code=403, detail="Install token does not match this node")
|
|
node = db.get(Node, node_id)
|
|
if not node:
|
|
raise HTTPException(status_code=404, detail="Node not found")
|
|
agent_token = create_token(node.id, "agent", timedelta(days=365), {"node_name": node.name})
|
|
base_url = external_base_url(request)
|
|
return agent_install_script(base_url, agent_token, node)
|
|
|
|
|
|
@api_router.get("/agents/download/nexafabric-agent.py")
|
|
def download_node_agent() -> FileResponse:
|
|
path = Path(__file__).resolve().parents[2] / "agent_assets" / "nexafabric-agent.py"
|
|
return FileResponse(path, media_type="text/x-python", filename="nexafabric-agent.py")
|
|
|
|
|
|
@api_router.post("/agents/heartbeat")
|
|
def agent_heartbeat(payload: AgentHeartbeat, authorization: str | None = Header(default=None), db: Session = Depends(get_db)) -> dict:
|
|
if not authorization or not authorization.lower().startswith("bearer "):
|
|
raise HTTPException(status_code=401, detail="Missing agent token")
|
|
token = authorization.split(" ", 1)[1]
|
|
try:
|
|
claims = decode_token(token)
|
|
except Exception as exc:
|
|
raise HTTPException(status_code=401, detail="Invalid agent token") from exc
|
|
if claims.get("typ") != "agent":
|
|
raise HTTPException(status_code=403, detail="Token is not an agent token")
|
|
node = db.get(Node, claims.get("sub"))
|
|
if not node:
|
|
raise HTTPException(status_code=404, detail="Node not found")
|
|
agent = db.get(NodeAgent, node.id)
|
|
if not agent:
|
|
agent = NodeAgent(node_id=node.id, install_count=0)
|
|
db.add(agent)
|
|
agent.status = "online"
|
|
agent.version = payload.version
|
|
agent.last_seen_at = datetime.utcnow()
|
|
agent.last_payload = 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)
|