Add NodeAgent and TrafficFlow models to track agent status and network flows, implement /agents/heartbeat endpoint to receive interface counters, conntrack flows, firewall status, and nftables ruleset hash from agents, add nexafabric-agent.py Python script to collect host telemetry including VM/LXC interface hints via tap/fwbr regex matching, conntrack flow parsing with protocol/state/byte counters, and pve-firewall status checks,
1290 lines
53 KiB
Python
1290 lines
53 KiB
Python
from datetime import datetime, timedelta
|
|
import csv
|
|
import io
|
|
from ipaddress import ip_address, ip_interface, ip_network
|
|
from pathlib import Path
|
|
|
|
from fastapi import APIRouter, Depends, Header, HTTPException, Request
|
|
from fastapi.responses import FileResponse, PlainTextResponse, StreamingResponse
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.api.deps import CurrentUser
|
|
from app.api.v1 import auth
|
|
from app.core.security import hash_password
|
|
from app.core.security import create_token, decode_token
|
|
from app.db.session import get_db
|
|
from app.models.domain import (
|
|
AuditLog,
|
|
Cluster,
|
|
IpAddress,
|
|
Job,
|
|
Network,
|
|
Node,
|
|
NodeAgent,
|
|
Policy,
|
|
Project,
|
|
Role,
|
|
SecurityGroup,
|
|
SecurityRule,
|
|
ServiceCatalogItem,
|
|
SystemSetting,
|
|
Subnet,
|
|
Tenant,
|
|
TrafficFlow,
|
|
User,
|
|
Workload,
|
|
)
|
|
from app.schemas.domain import (
|
|
AuditLogRead,
|
|
AgentHeartbeat,
|
|
ClusterCreate,
|
|
ClusterRead,
|
|
ClusterUpdate,
|
|
FirewallApplyRequest,
|
|
FirewallPreview,
|
|
IpAddressRead,
|
|
IpReservationCreate,
|
|
JobRead,
|
|
NetworkCreate,
|
|
NetworkRead,
|
|
NodeRead,
|
|
NodeWithAgentRead,
|
|
PolicyCreate,
|
|
PolicyRead,
|
|
ProjectCreate,
|
|
ProjectRead,
|
|
RoleCreate,
|
|
RoleRead,
|
|
SecurityRuleCreate,
|
|
SecurityRuleRead,
|
|
ServiceCatalogCreate,
|
|
ServiceCatalogRead,
|
|
SecurityGroupCreate,
|
|
SecurityGroupRead,
|
|
SetupCompleteRequest,
|
|
SetupStatus,
|
|
SubnetCreate,
|
|
SubnetRead,
|
|
TenantCreate,
|
|
TenantRead,
|
|
UserCreate,
|
|
UserRead,
|
|
WorkloadInsight,
|
|
WorkloadRead,
|
|
)
|
|
from app.services.audit import write_audit
|
|
from app.services.firewall_orchestrator import FirewallOrchestrator
|
|
from app.services.providers.base import ProviderConnection
|
|
from app.services.providers.registry import get_provider
|
|
|
|
api_router = APIRouter()
|
|
api_router.include_router(auth.router)
|
|
|
|
|
|
def commit_or_400(db: Session) -> None:
|
|
try:
|
|
db.commit()
|
|
except IntegrityError as exc:
|
|
db.rollback()
|
|
raise HTTPException(status_code=409, detail="Resource conflicts with an existing record") from exc
|
|
|
|
|
|
def setup_setting(db: Session) -> SystemSetting:
|
|
setting = db.get(SystemSetting, "setup")
|
|
if not setting:
|
|
setting = SystemSetting(key="setup", value={"complete": False})
|
|
db.add(setting)
|
|
db.commit()
|
|
db.refresh(setting)
|
|
return setting
|
|
|
|
|
|
def ensure_discovered_network(db: Session, cluster_id: str) -> Network:
|
|
network = db.scalar(select(Network).where(Network.cluster_id == cluster_id, Network.name == "discovered-ipam"))
|
|
if network:
|
|
return network
|
|
network = Network(
|
|
cluster_id=cluster_id,
|
|
name="discovered-ipam",
|
|
kind="discovered",
|
|
description="Automatically created for IP addresses discovered during Proxmox sync.",
|
|
)
|
|
db.add(network)
|
|
db.flush()
|
|
return network
|
|
|
|
|
|
def is_docker_or_container_network(value: str) -> bool:
|
|
try:
|
|
interface = ip_interface(value)
|
|
except ValueError:
|
|
return False
|
|
ip = interface.ip
|
|
network = str(interface.network)
|
|
if ip.is_loopback or ip.is_link_local:
|
|
return True
|
|
if ip.version == 4 and ip.packed[0] == 172 and 17 <= ip.packed[1] <= 31:
|
|
return True
|
|
return network.startswith(("10.42.", "10.43.", "10.244.", "10.245."))
|
|
|
|
|
|
def ip_address_payload(db: Session, address: IpAddress) -> dict:
|
|
subnet = db.get(Subnet, address.subnet_id)
|
|
workload = db.get(Workload, address.workload_id) if address.workload_id else None
|
|
return {
|
|
"id": address.id,
|
|
"subnet_id": address.subnet_id,
|
|
"subnet_cidr": subnet.cidr if subnet else None,
|
|
"address": address.address,
|
|
"status": address.status,
|
|
"workload_id": address.workload_id,
|
|
"workload_name": workload.name if workload else None,
|
|
"workload_external_id": workload.external_id if workload else None,
|
|
"note": address.note,
|
|
}
|
|
|
|
|
|
def is_ip_or_cidr(value: str) -> bool:
|
|
try:
|
|
ip_network(value, strict=False)
|
|
return True
|
|
except ValueError:
|
|
try:
|
|
ip_address(value)
|
|
return True
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
def cleanup_discovered_container_networks(db: Session) -> int:
|
|
removed = 0
|
|
discovered_networks = db.scalars(select(Network).where(Network.name == "discovered-ipam")).all()
|
|
for network in discovered_networks:
|
|
subnets = db.scalars(select(Subnet).where(Subnet.network_id == network.id)).all()
|
|
for subnet in subnets:
|
|
if is_docker_or_container_network(subnet.cidr):
|
|
addresses = db.scalars(select(IpAddress).where(IpAddress.subnet_id == subnet.id)).all()
|
|
for address in addresses:
|
|
db.delete(address)
|
|
removed += 1
|
|
db.delete(subnet)
|
|
return removed
|
|
|
|
|
|
def import_discovered_ips(db: Session, cluster_id: str, workload: Workload, addresses: list[str]) -> int:
|
|
imported = 0
|
|
for value in addresses:
|
|
try:
|
|
interface = ip_interface(value)
|
|
except ValueError:
|
|
continue
|
|
if is_docker_or_container_network(value):
|
|
continue
|
|
network = ensure_discovered_network(db, cluster_id)
|
|
cidr = str(interface.network)
|
|
subnet = db.scalar(select(Subnet).where(Subnet.network_id == network.id, Subnet.cidr == cidr))
|
|
if not subnet:
|
|
subnet = Subnet(network_id=network.id, cidr=cidr)
|
|
db.add(subnet)
|
|
db.flush()
|
|
address_value = str(interface.ip)
|
|
existing = db.scalar(select(IpAddress).where(IpAddress.subnet_id == subnet.id, IpAddress.address == address_value))
|
|
if existing:
|
|
existing.workload_id = workload.id
|
|
existing.status = "assigned"
|
|
else:
|
|
db.add(IpAddress(subnet_id=subnet.id, address=address_value, status="assigned", workload_id=workload.id))
|
|
imported += 1
|
|
return imported
|
|
|
|
|
|
def workload_provider_target(db: Session, cluster: Cluster, ref: str) -> tuple[dict, Workload] | None:
|
|
if not ref.startswith("workload:"):
|
|
return None
|
|
workload_id = ref.removeprefix("workload:")
|
|
workload = db.get(Workload, workload_id)
|
|
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], []
|
|
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 proxmox_action(action: str) -> str:
|
|
return {"allow": "ACCEPT", "deny": "DROP", "reject": "REJECT"}.get(action, "ACCEPT")
|
|
|
|
|
|
def resolve_firewall_preview(db: Session, cluster: Cluster, preview: FirewallPreview) -> FirewallPreview:
|
|
warnings = list(preview.warnings)
|
|
conflicts = list(preview.conflicts)
|
|
generated_rules: list[dict] = []
|
|
|
|
for rule_index, rule in enumerate(preview.generated_rules, start=1):
|
|
mapped = dict(rule)
|
|
direction = str(rule.get("direction", "ingress"))
|
|
target_ref = str(rule.get("destination") if direction == "ingress" else rule.get("source"))
|
|
target = workload_provider_target(db, cluster, target_ref)
|
|
if not target:
|
|
conflicts.append(
|
|
f"Rule {rule_index} needs a concrete {'destination' if direction == 'ingress' else 'source'} workload for Proxmox live apply."
|
|
)
|
|
generated_rules.append(mapped)
|
|
continue
|
|
|
|
provider_target, target_workload = target
|
|
remote_ref = str(rule.get("source") if direction == "ingress" else rule.get("destination"))
|
|
remote_values, endpoint_warnings = endpoint_values(db, cluster, remote_ref)
|
|
warnings.extend(endpoint_warnings)
|
|
if not remote_values:
|
|
conflicts.append(f"Rule {rule_index} cannot resolve {remote_ref} to a Proxmox firewall source/destination matcher.")
|
|
generated_rules.append({**mapped, "provider_target": provider_target})
|
|
continue
|
|
|
|
ports = str(rule.get("ports", "any"))
|
|
protocol = str(rule.get("protocol", "any"))
|
|
protocols = ["tcp", "udp"] if protocol == "tcp/udp" else [protocol]
|
|
for remote_value in remote_values:
|
|
for provider_protocol in protocols:
|
|
provider_rule = {
|
|
"type": "in" if direction == "ingress" else "out",
|
|
"action": proxmox_action(str(rule.get("action", "allow"))),
|
|
"enable": 1,
|
|
"comment": (
|
|
f"NexaFabric policy={rule.get('policy_id')} version={rule.get('policy_version')} "
|
|
f"rule={rule_index} target={target_workload.name}"
|
|
),
|
|
}
|
|
if provider_protocol != "any":
|
|
provider_rule["proto"] = provider_protocol
|
|
if ports != "any":
|
|
provider_rule["dport"] = ports
|
|
if remote_value:
|
|
provider_rule["source" if direction == "ingress" else "dest"] = remote_value
|
|
if rule.get("logging"):
|
|
provider_rule["log"] = "info"
|
|
mapped_rule = {**mapped, "provider_target": provider_target, "provider_rule": provider_rule}
|
|
generated_rules.append(mapped_rule)
|
|
|
|
return FirewallPreview(
|
|
policy_id=preview.policy_id,
|
|
dry_run=preview.dry_run,
|
|
generated_rules=generated_rules,
|
|
warnings=warnings,
|
|
conflicts=conflicts,
|
|
)
|
|
|
|
|
|
@api_router.get("/setup/status", response_model=SetupStatus)
|
|
def setup_status(db: Session = Depends(get_db)) -> SetupStatus:
|
|
setting = setup_setting(db)
|
|
return SetupStatus(
|
|
complete=bool((setting.value or {}).get("complete")),
|
|
has_users=bool(db.scalar(select(func.count()).select_from(User))),
|
|
has_clusters=bool(db.scalar(select(func.count()).select_from(Cluster))),
|
|
)
|
|
|
|
|
|
@api_router.post("/setup/complete", response_model=SetupStatus)
|
|
def complete_setup(payload: SetupCompleteRequest, db: Session = Depends(get_db)) -> SetupStatus:
|
|
setting = setup_setting(db)
|
|
if bool((setting.value or {}).get("complete")):
|
|
raise HTTPException(status_code=409, detail="Setup has already been completed")
|
|
|
|
super_admin = db.scalar(select(Role).where(Role.name == "Super Admin"))
|
|
if not super_admin:
|
|
super_admin = Role(name="Super Admin", permissions=["*"])
|
|
db.add(super_admin)
|
|
db.flush()
|
|
|
|
email = payload.admin_email.strip().lower()
|
|
admin = db.scalar(select(User).where(User.email == email))
|
|
if not admin:
|
|
admin = User(email=email, display_name=payload.admin_name, password_hash=hash_password(payload.admin_password))
|
|
db.add(admin)
|
|
admin.display_name = payload.admin_name
|
|
admin.password_hash = hash_password(payload.admin_password)
|
|
admin.is_active = True
|
|
if super_admin not in admin.roles:
|
|
admin.roles.append(super_admin)
|
|
|
|
if payload.cluster_name and payload.cluster_api_url and payload.cluster_api_token:
|
|
existing_cluster = db.scalar(select(Cluster).where(Cluster.name == payload.cluster_name))
|
|
if not existing_cluster:
|
|
db.add(
|
|
Cluster(
|
|
name=payload.cluster_name,
|
|
api_url=payload.cluster_api_url,
|
|
token_ref=payload.cluster_api_token,
|
|
provider=payload.cluster_provider,
|
|
mode=payload.cluster_mode,
|
|
verify_tls=payload.verify_tls,
|
|
)
|
|
)
|
|
|
|
setting.value = {"complete": True, "completed_at": datetime.utcnow().isoformat()}
|
|
db.add(AuditLog(user_id=admin.id, action="setup.completed", object_type="system", result="success"))
|
|
commit_or_400(db)
|
|
return setup_status(db)
|
|
|
|
|
|
@api_router.get("/dashboard")
|
|
def dashboard(_: CurrentUser, db: Session = Depends(get_db)) -> dict:
|
|
last_syncs = db.scalars(select(Cluster).order_by(Cluster.updated_at.desc()).limit(5)).all()
|
|
faulty_nodes = db.scalars(select(Node).where(Node.status != "online")).all()
|
|
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": 1,
|
|
"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": [],
|
|
}
|
|
|
|
|
|
@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,
|
|
}
|
|
|
|
|
|
@api_router.get("/nodes/agents", response_model=list[NodeWithAgentRead])
|
|
def nodes_with_agents(_: CurrentUser, db: Session = Depends(get_db)) -> list[dict]:
|
|
agents = {agent.node_id: agent for agent in db.scalars(select(NodeAgent)).all()}
|
|
return [
|
|
node_agent_payload(node, agents.get(node.id))
|
|
for node in db.scalars(select(Node).order_by(Node.name)).all()
|
|
]
|
|
|
|
|
|
def agent_install_script(base_url: str, token: str, node: Node) -> str:
|
|
return f"""#!/bin/sh
|
|
set -eu
|
|
|
|
NEXAFABRIC_URL="${{NEXAFABRIC_URL:-{base_url.rstrip("/")}}}"
|
|
INSTALL_DIR="${{INSTALL_DIR:-/opt/nexafabric-agent}}"
|
|
CONFIG_DIR="${{CONFIG_DIR:-/etc/nexafabric-agent}}"
|
|
SERVICE_NAME="${{SERVICE_NAME:-nexafabric-agent}}"
|
|
|
|
if ! command -v python3 >/dev/null 2>&1; then
|
|
echo "python3 is required. Install python3 on this Proxmox node and run this script again." >&2
|
|
exit 1
|
|
fi
|
|
|
|
if ! command -v conntrack >/dev/null 2>&1 && command -v apt-get >/dev/null 2>&1; then
|
|
apt-get update
|
|
apt-get install -y conntrack
|
|
fi
|
|
|
|
mkdir -p "$INSTALL_DIR" "$CONFIG_DIR"
|
|
curl -fsSL "$NEXAFABRIC_URL/api/v1/agents/download/nexafabric-agent.py" -o "$INSTALL_DIR/nexafabric-agent.py"
|
|
chmod 0755 "$INSTALL_DIR/nexafabric-agent.py"
|
|
|
|
cat > "$CONFIG_DIR/config.json" <<'JSON'
|
|
{{
|
|
"api_url": "{base_url.rstrip("/")}/api/v1",
|
|
"token": "{token}",
|
|
"node_id": "{node.id}",
|
|
"node_name": "{node.name}",
|
|
"interval_seconds": 30,
|
|
"flow_limit": 500,
|
|
"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 --now "$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 = str(request.base_url).rstrip("/")
|
|
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 = str(request.base_url).rstrip("/")
|
|
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 = str(request.base_url).rstrip("/")
|
|
return agent_install_script(base_url, agent_token, node)
|
|
|
|
|
|
@api_router.get("/agents/download/nexafabric-agent.py")
|
|
def download_node_agent() -> FileResponse:
|
|
path = Path(__file__).resolve().parents[2] / "agent_assets" / "nexafabric-agent.py"
|
|
return FileResponse(path, media_type="text/x-python", filename="nexafabric-agent.py")
|
|
|
|
|
|
@api_router.post("/agents/heartbeat")
|
|
def agent_heartbeat(payload: AgentHeartbeat, authorization: str | None = Header(default=None), db: Session = Depends(get_db)) -> dict:
|
|
if not authorization or not authorization.lower().startswith("bearer "):
|
|
raise HTTPException(status_code=401, detail="Missing agent token")
|
|
token = authorization.split(" ", 1)[1]
|
|
try:
|
|
claims = decode_token(token)
|
|
except Exception as exc:
|
|
raise HTTPException(status_code=401, detail="Invalid agent token") from exc
|
|
if claims.get("typ") != "agent":
|
|
raise HTTPException(status_code=403, detail="Token is not an agent token")
|
|
node = db.get(Node, claims.get("sub"))
|
|
if not node:
|
|
raise HTTPException(status_code=404, detail="Node not found")
|
|
agent = db.get(NodeAgent, node.id)
|
|
if not agent:
|
|
agent = NodeAgent(node_id=node.id, install_count=0)
|
|
db.add(agent)
|
|
agent.status = "online"
|
|
agent.version = payload.version
|
|
agent.last_seen_at = datetime.utcnow()
|
|
agent.last_payload = payload.model_dump()
|
|
for old_flow in db.scalars(select(TrafficFlow).where(TrafficFlow.node_id == node.id)).all():
|
|
db.delete(old_flow)
|
|
for raw_flow in payload.flows[:1000]:
|
|
source_ip = str(raw_flow.get("source_ip") or "")
|
|
destination_ip = str(raw_flow.get("destination_ip") or "")
|
|
if not source_ip or not destination_ip:
|
|
continue
|
|
observed_at = payload.collected_at or datetime.utcnow()
|
|
db.add(
|
|
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("state") or "") or None,
|
|
observed_at=observed_at.replace(tzinfo=None) if observed_at.tzinfo else observed_at,
|
|
raw=raw_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)
|
|
def workload_insights(workload_id: str, _: CurrentUser, db: Session = Depends(get_db)) -> WorkloadInsight:
|
|
workload = db.get(Workload, workload_id)
|
|
if not workload:
|
|
raise HTTPException(status_code=404, detail="Workload not found")
|
|
policies = db.scalars(
|
|
select(Policy).where((Policy.project_id == workload.project_id) | (Policy.project_id.is_(None))).order_by(Policy.name)
|
|
).all()
|
|
assigned_ips = db.scalars(select(IpAddress).where(IpAddress.workload_id == workload.id).order_by(IpAddress.address)).all()
|
|
workload_ips = [address.address for address in assigned_ips]
|
|
ip_owners = {
|
|
address.address: db.get(Workload, address.workload_id)
|
|
for address in db.scalars(select(IpAddress).where(IpAddress.workload_id.is_not(None))).all()
|
|
}
|
|
traffic = []
|
|
if workload_ips:
|
|
flows = db.scalars(
|
|
select(TrafficFlow)
|
|
.where((TrafficFlow.source_ip.in_(workload_ips)) | (TrafficFlow.destination_ip.in_(workload_ips)))
|
|
.order_by(TrafficFlow.updated_at.desc())
|
|
.limit(50)
|
|
).all()
|
|
for flow in flows:
|
|
source_owner = ip_owners.get(flow.source_ip)
|
|
destination_owner = ip_owners.get(flow.destination_ip)
|
|
traffic.append(
|
|
{
|
|
"source": source_owner.name if source_owner else "external",
|
|
"destination": destination_owner.name if destination_owner else "external",
|
|
"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": "observed",
|
|
"observed_at": flow.observed_at.isoformat() if flow.observed_at else None,
|
|
"ip_addresses": [flow.source_ip, flow.destination_ip],
|
|
}
|
|
)
|
|
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,
|
|
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.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])
|
|
def policies(_: CurrentUser, db: Session = Depends(get_db)) -> list[Policy]:
|
|
return 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=payload.definition)
|
|
db.add(policy)
|
|
commit_or_400(db)
|
|
db.refresh(policy)
|
|
write_audit(db, action="policy.created", object_type="policy", object_id=policy.id, user_id=user.id)
|
|
return policy
|
|
|
|
|
|
@api_router.patch("/policies/{policy_id}", response_model=PolicyRead)
|
|
def update_policy(policy_id: str, payload: PolicyCreate, user: CurrentUser, db: Session = Depends(get_db)) -> Policy:
|
|
policy = db.get(Policy, policy_id)
|
|
if not policy:
|
|
raise HTTPException(status_code=404, detail="Policy not found")
|
|
old_values = {"name": policy.name, "enabled": policy.enabled, "definition": policy.definition, "version": policy.version}
|
|
policy.project_id = payload.project_id
|
|
policy.name = payload.name
|
|
policy.enabled = payload.enabled
|
|
policy.definition = payload.definition
|
|
policy.version += 1
|
|
commit_or_400(db)
|
|
db.refresh(policy)
|
|
write_audit(db, action="policy.updated", object_type="policy", object_id=policy.id, user_id=user.id, old_values=old_values, new_values=payload.model_dump())
|
|
return policy
|
|
|
|
|
|
@api_router.delete("/policies/{policy_id}")
|
|
def delete_policy(policy_id: str, user: CurrentUser, db: Session = Depends(get_db)) -> dict[str, str]:
|
|
policy = db.get(Policy, policy_id)
|
|
if not policy:
|
|
raise HTTPException(status_code=404, detail="Policy not found")
|
|
db.delete(policy)
|
|
commit_or_400(db)
|
|
write_audit(db, action="policy.deleted", object_type="policy", object_id=policy_id, user_id=user.id)
|
|
return {"status": "deleted", "id": policy_id}
|
|
|
|
|
|
@api_router.post("/policies/{policy_id}/compile", response_model=PolicyRead)
|
|
def compile_policy(policy_id: str, user: CurrentUser, db: Session = Depends(get_db)) -> Policy:
|
|
from app.services.policy_engine import PolicyEngine
|
|
|
|
policy = db.get(Policy, policy_id)
|
|
if not policy:
|
|
raise HTTPException(status_code=404, detail="Policy not found")
|
|
policy.last_compiled = PolicyEngine().compile(policy)
|
|
db.add(Job(kind="policy.compile", status="success", progress=100, logs=[f"Compiled policy {policy.name}"]))
|
|
commit_or_400(db)
|
|
db.refresh(policy)
|
|
write_audit(db, action="policy.compiled", object_type="policy", object_id=policy.id, user_id=user.id, new_values=policy.last_compiled)
|
|
return policy
|
|
|
|
|
|
@api_router.post("/firewall/preview/{policy_id}", response_model=FirewallPreview)
|
|
async def firewall_preview(policy_id: str, user: CurrentUser, db: Session = Depends(get_db)) -> FirewallPreview:
|
|
policy = db.get(Policy, policy_id)
|
|
cluster = db.scalar(select(Cluster).order_by(Cluster.name).limit(1))
|
|
if not policy or not cluster:
|
|
raise HTTPException(status_code=404, detail="Policy or cluster not found")
|
|
preview = resolve_firewall_preview(db, cluster, await FirewallOrchestrator().preview(cluster, policy))
|
|
write_audit(db, action="firewall.preview", object_type="policy", object_id=policy.id, user_id=user.id, new_values=preview.model_dump())
|
|
return preview
|
|
|
|
|
|
@api_router.post("/firewall/apply")
|
|
async def firewall_apply(payload: FirewallApplyRequest, user: CurrentUser, db: Session = Depends(get_db)) -> dict:
|
|
if not payload.confirm:
|
|
raise HTTPException(status_code=400, detail="Firewall apply requires confirm=true after preview review")
|
|
policy = db.get(Policy, payload.policy_id)
|
|
cluster = db.get(Cluster, payload.cluster_id) if payload.cluster_id else db.scalar(select(Cluster).order_by(Cluster.name).limit(1))
|
|
if not policy or not cluster:
|
|
raise HTTPException(status_code=404, detail="Policy or cluster not found")
|
|
preview = resolve_firewall_preview(db, cluster, await FirewallOrchestrator().preview(cluster, policy))
|
|
if payload.dry_run:
|
|
result = {
|
|
"applied": False,
|
|
"dry_run": True,
|
|
"reason": "Dry run completed. No firewall rules were applied.",
|
|
"rules": preview.generated_rules,
|
|
}
|
|
elif preview.conflicts:
|
|
result = {
|
|
"applied": False,
|
|
"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,
|
|
"reason": f"Provider apply failed: {exc}",
|
|
"rules": preview.generated_rules,
|
|
}
|
|
applied = bool(result.get("applied"))
|
|
operation_success = applied or payload.dry_run
|
|
job = Job(
|
|
kind="firewall.apply",
|
|
status="success" if operation_success else "failed",
|
|
progress=100,
|
|
started_at=datetime.utcnow(),
|
|
finished_at=datetime.utcnow(),
|
|
logs=[f"Policy {policy.name}", f"Cluster mode: {cluster.mode}", f"Dry run: {payload.dry_run}", str(result)],
|
|
error=None if operation_success else result.get("reason", "Provider did not apply rules"),
|
|
)
|
|
db.add(job)
|
|
commit_or_400(db)
|
|
write_audit(
|
|
db,
|
|
action="firewall.apply",
|
|
object_type="policy",
|
|
object_id=policy.id,
|
|
user_id=user.id,
|
|
new_values={"request": payload.model_dump(), "result": result},
|
|
result="success" if operation_success else "blocked",
|
|
error_text=None if operation_success else result.get("reason"),
|
|
)
|
|
return {"job_id": job.id, "preview": preview.model_dump(), "provider_result": result}
|
|
|
|
|
|
@api_router.get("/service-catalog", response_model=list[ServiceCatalogRead])
|
|
def service_catalog(_: CurrentUser, db: Session = Depends(get_db)) -> list[ServiceCatalogItem]:
|
|
return db.scalars(select(ServiceCatalogItem).order_by(ServiceCatalogItem.name)).all()
|
|
|
|
|
|
@api_router.post("/service-catalog", response_model=ServiceCatalogRead)
|
|
def create_service(payload: ServiceCatalogCreate, user: CurrentUser, db: Session = Depends(get_db)) -> ServiceCatalogItem:
|
|
service = ServiceCatalogItem(**payload.model_dump())
|
|
db.add(service)
|
|
commit_or_400(db)
|
|
db.refresh(service)
|
|
write_audit(db, action="service.created", object_type="service_catalog", object_id=service.id, user_id=user.id)
|
|
return service
|
|
|
|
|
|
@api_router.get("/jobs", response_model=list[JobRead])
|
|
def jobs(_: CurrentUser, db: Session = Depends(get_db)) -> list[Job]:
|
|
return db.scalars(select(Job).order_by(Job.created_at.desc())).all()
|
|
|
|
|
|
@api_router.get("/audit", response_model=list[AuditLogRead])
|
|
def audit(_: CurrentUser, db: Session = Depends(get_db)) -> list[AuditLog]:
|
|
return db.scalars(select(AuditLog).order_by(AuditLog.created_at.desc()).limit(200)).all()
|
|
|
|
|
|
@api_router.get("/settings")
|
|
def settings(_: CurrentUser) -> dict:
|
|
return {"product": "NexaFabric", "firewall_apply_requires_preview": True, "agent_optional": True}
|