chore: initial project setup with backend, frontend, CI/CD, and documentation
CI / backend (push) Failing after 15s
CI / frontend (push) Failing after 39s

Add complete NexaFabric project structure including:
- FastAPI backend with SQLAlchemy models, JWT auth, RBAC, audit logging, and provider interfaces
- React + TypeScript frontend with Vite, Tailwind CSS, TanStack Query, and Zustand
- Docker Compose configuration for PostgreSQL, Redis, API, worker, frontend, and nginx
- GitHub Actions and GitLab CI workflows for testing, linting, building, and security scanning
- Environment
This commit is contained in:
2026-07-09 12:10:35 +02:00
commit 14e7710120
83 changed files with 2887 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
"""NexaFabric backend package."""
+2
View File
@@ -0,0 +1,2 @@
"""API package."""
+42
View File
@@ -0,0 +1,42 @@
from typing import Annotated
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.security import decode_token
from app.db.session import get_db
from app.models.domain import User
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
DbSession = Annotated[Session, Depends(get_db)]
def current_user(db: DbSession, token: Annotated[str, Depends(oauth2_scheme)]) -> User:
try:
payload = decode_token(token)
except JWTError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") from exc
if payload.get("typ") != "access":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type")
user = db.scalar(select(User).where(User.id == payload["sub"], User.is_active.is_(True)))
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or missing user")
return user
CurrentUser = Annotated[User, Depends(current_user)]
def require_permission(permission: str):
def dependency(user: CurrentUser) -> User:
permissions = {permission for role in user.roles for permission in role.permissions}
if "*" not in permissions and permission not in permissions:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing permission")
return user
return dependency
+2
View File
@@ -0,0 +1,2 @@
"""API v1 package."""
+44
View File
@@ -0,0 +1,44 @@
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import CurrentUser
from app.core.security import create_access_token, create_refresh_token, verify_password
from app.db.session import get_db
from app.models.domain import User
from app.schemas.domain import LoginRequest, TokenPair, UserRead
from app.services.audit import write_audit
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/login", response_model=TokenPair)
def login(payload: LoginRequest, db: Session = Depends(get_db)) -> TokenPair:
user = db.scalar(select(User).where(User.email == payload.email))
if not user or not verify_password(payload.password, user.password_hash):
if user:
user.failed_login_attempts += 1
db.commit()
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
permissions = sorted({permission for role in user.roles for permission in role.permissions})
user.failed_login_attempts = 0
db.commit()
write_audit(db, action="login", object_type="user", object_id=user.id, user_id=user.id)
return TokenPair(
access_token=create_access_token(user.id, permissions),
refresh_token=create_refresh_token(user.id),
)
@router.get("/me", response_model=UserRead)
def me(user: CurrentUser) -> User:
return user
@router.post("/logout")
def logout(user: CurrentUser, db: Session = Depends(get_db)) -> dict[str, str]:
write_audit(db, action="logout", object_type="user", object_id=user.id, user_id=user.id)
return {"status": "ok", "at": datetime.utcnow().isoformat()}
+226
View File
@@ -0,0 +1,226 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.api.deps import CurrentUser
from app.api.v1 import auth
from app.db.session import get_db
from app.models.domain import (
AuditLog,
Cluster,
Job,
Network,
Node,
Policy,
Project,
SecurityGroup,
Subnet,
Tenant,
User,
Workload,
)
from app.schemas.domain import (
AuditLogRead,
ClusterCreate,
ClusterRead,
FirewallPreview,
IpAddressRead,
IpReservationCreate,
JobRead,
NetworkRead,
NodeRead,
PolicyCreate,
PolicyRead,
ProjectRead,
SecurityGroupCreate,
SecurityGroupRead,
SubnetRead,
TenantRead,
UserRead,
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)
@api_router.get("/dashboard")
def dashboard(_: CurrentUser, db: Session = Depends(get_db)) -> dict:
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": db.scalars(select(Cluster).order_by(Cluster.updated_at.desc()).limit(5)).all(),
"faulty_nodes": db.scalars(select(Node).where(Node.status != "online")).all(),
"top_talkers": [
{"name": "finance-app-2", "bytes": 942000000},
{"name": "core-services-1", "bytes": 512000000},
],
}
@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.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,
token_ref=payload.api_token,
mode=payload.mode,
verify_tls=payload.verify_tls,
)
db.add(cluster)
db.commit()
db.refresh(cluster)
write_audit(db, action="cluster.created", object_type="cluster", object_id=cluster.id, user_id=user.id)
return cluster
@api_router.post("/clusters/{cluster_id}/test")
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")
return {"cluster_id": cluster.id, "status": "configured", "message": "Provider connection is ready for live token validation."}
@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)
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",
)
)
cluster.last_sync_status = "success"
cluster.last_sync_error = None
db.add(Job(kind="proxmox.sync", status="success", progress=100, logs=[f"Synced {cluster.name}"]))
db.commit()
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()
@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("/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.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.get("/ipam/addresses", response_model=list[IpAddressRead])
def ipam_addresses(_: CurrentUser, db: Session = Depends(get_db)):
from app.models.domain import IpAddress
return db.scalars(select(IpAddress).order_by(IpAddress.address)).all()
@api_router.post("/ipam/addresses", response_model=IpAddressRead)
def reserve_ip(payload: IpReservationCreate, user: CurrentUser, db: Session = Depends(get_db)):
from app.models.domain import IpAddress
address = IpAddress(subnet_id=payload.subnet_id, address=payload.address, status=payload.status, note=payload.note)
db.add(address)
db.commit()
db.refresh(address)
write_audit(db, action="ipam.address.created", object_type="ip_address", object_id=address.id, user_id=user.id)
return address
@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.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.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)
db.commit()
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("/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)
db.commit()
db.refresh(policy)
write_audit(db, action="policy.created", object_type="policy", object_id=policy.id, user_id=user.id)
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 = 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.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}
+26
View File
@@ -0,0 +1,26 @@
from functools import lru_cache
from pydantic import AnyHttpUrl, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
project_name: str = "NexaFabric"
environment: str = "development"
api_host: str = "0.0.0.0"
api_port: int = 8000
database_url: str = "sqlite:///./nexafabric.db"
redis_url: str = "redis://localhost:6379/0"
jwt_secret: str = Field(default="dev-only-change-me")
jwt_access_token_minutes: int = 15
jwt_refresh_token_days: int = 14
token_encryption_key: str = "dev-only-change-me"
demo_admin_password: str = "ChangeMe_UseEnvInstead"
cors_origins: list[AnyHttpUrl] | list[str] = ["http://localhost:5173", "http://localhost:8080"]
@lru_cache
def get_settings() -> Settings:
return Settings()
+52
View File
@@ -0,0 +1,52 @@
from datetime import datetime, timedelta, timezone
from typing import Any
from jose import jwt
from passlib.context import CryptContext
from app.core.config import get_settings
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(password: str, password_hash: str) -> bool:
return pwd_context.verify(password, password_hash)
def create_token(subject: str, token_type: str, expires_delta: timedelta, claims: dict[str, Any] | None = None) -> str:
settings = get_settings()
now = datetime.now(timezone.utc)
payload: dict[str, Any] = {
"sub": subject,
"typ": token_type,
"iat": now,
"exp": now + expires_delta,
}
if claims:
payload.update(claims)
return jwt.encode(payload, settings.jwt_secret, algorithm="HS256")
def create_access_token(subject: str, permissions: list[str]) -> str:
settings = get_settings()
return create_token(
subject,
"access",
timedelta(minutes=settings.jwt_access_token_minutes),
{"permissions": permissions},
)
def create_refresh_token(subject: str) -> str:
settings = get_settings()
return create_token(subject, "refresh", timedelta(days=settings.jwt_refresh_token_days))
def decode_token(token: str) -> dict[str, Any]:
return jwt.decode(token, get_settings().jwt_secret, algorithms=["HS256"])
+25
View File
@@ -0,0 +1,25 @@
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.core.config import get_settings
class Base(DeclarativeBase):
pass
settings = get_settings()
connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {}
engine = create_engine(settings.database_url, pool_pre_ping=True, connect_args=connect_args)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()
+42
View File
@@ -0,0 +1,42 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.v1.router import api_router
from app.core.config import get_settings
from app.db.session import Base, SessionLocal, engine
from app.seed.demo import seed_demo_data
def create_app() -> FastAPI:
settings = get_settings()
app = FastAPI(
title=settings.project_name,
description="SDN-like network and security control plane for Proxmox VE.",
version="0.1.0",
docs_url="/api/docs",
openapi_url="/api/openapi.json",
)
app.add_middleware(
CORSMiddleware,
allow_origins=[str(origin) for origin in settings.cors_origins],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.on_event("startup")
def startup() -> None:
Base.metadata.create_all(bind=engine)
with SessionLocal() as db:
seed_demo_data(db)
@app.get("/healthz")
def healthz() -> dict[str, str]:
return {"status": "ok"}
app.include_router(api_router, prefix="/api/v1")
return app
app = create_app()
+2
View File
@@ -0,0 +1,2 @@
from app.models.domain import * # noqa: F403
+254
View File
@@ -0,0 +1,254 @@
from datetime import datetime
from enum import StrEnum
from uuid import uuid4
from sqlalchemy import JSON, Boolean, DateTime, Enum, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.session import Base
def new_id() -> str:
return str(uuid4())
class ClusterMode(StrEnum):
read_only = "read_only"
write_enabled = "write_enabled"
class JobStatus(StrEnum):
queued = "queued"
running = "running"
success = "success"
failed = "failed"
cancelled = "cancelled"
class IpStatus(StrEnum):
free = "free"
reserved = "reserved"
assigned = "assigned"
deprecated = "deprecated"
conflict = "conflict"
class RuleAction(StrEnum):
allow = "allow"
deny = "deny"
reject = "reject"
class Direction(StrEnum):
ingress = "ingress"
egress = "egress"
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class User(Base, TimestampMixin):
__tablename__ = "users"
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
display_name: Mapped[str] = mapped_column(String(255))
password_hash: Mapped[str] = mapped_column(String(512))
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
failed_login_attempts: Mapped[int] = mapped_column(Integer, default=0)
roles: Mapped[list["Role"]] = relationship(secondary="user_roles", back_populates="users")
class Role(Base, TimestampMixin):
__tablename__ = "roles"
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
name: Mapped[str] = mapped_column(String(100), unique=True)
permissions: Mapped[list[str]] = mapped_column(JSON, default=list)
users: Mapped[list[User]] = relationship(secondary="user_roles", back_populates="roles")
class UserRole(Base):
__tablename__ = "user_roles"
user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), primary_key=True)
role_id: Mapped[str] = mapped_column(ForeignKey("roles.id"), primary_key=True)
class Tenant(Base, TimestampMixin):
__tablename__ = "tenants"
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
name: Mapped[str] = mapped_column(String(255), unique=True)
description: Mapped[str | None] = mapped_column(Text)
class Project(Base, TimestampMixin):
__tablename__ = "projects"
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id"), index=True)
name: Mapped[str] = mapped_column(String(255))
description: Mapped[str | None] = mapped_column(Text)
tenant: Mapped[Tenant] = relationship()
class Cluster(Base, TimestampMixin):
__tablename__ = "clusters"
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
name: Mapped[str] = mapped_column(String(255), unique=True)
api_url: Mapped[str] = mapped_column(String(512))
provider: Mapped[str] = mapped_column(String(100), default="proxmox")
mode: Mapped[ClusterMode] = mapped_column(Enum(ClusterMode), default=ClusterMode.read_only)
token_ref: Mapped[str | None] = mapped_column(String(512))
verify_tls: Mapped[bool] = mapped_column(Boolean, default=True)
last_sync_at: Mapped[datetime | None] = mapped_column(DateTime)
last_sync_status: Mapped[str | None] = mapped_column(String(100))
last_sync_error: Mapped[str | None] = mapped_column(Text)
class Node(Base, TimestampMixin):
__tablename__ = "nodes"
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
cluster_id: Mapped[str] = mapped_column(ForeignKey("clusters.id"), index=True)
name: Mapped[str] = mapped_column(String(255))
status: Mapped[str] = mapped_column(String(100), default="unknown")
cpu_count: Mapped[int] = mapped_column(Integer, default=0)
memory_mb: Mapped[int] = mapped_column(Integer, default=0)
cluster: Mapped[Cluster] = relationship()
class Workload(Base, TimestampMixin):
__tablename__ = "workloads"
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
cluster_id: Mapped[str] = mapped_column(ForeignKey("clusters.id"), index=True)
node_id: Mapped[str] = mapped_column(ForeignKey("nodes.id"), index=True)
project_id: Mapped[str | None] = mapped_column(ForeignKey("projects.id"), index=True)
external_id: Mapped[str] = mapped_column(String(100))
name: Mapped[str] = mapped_column(String(255))
kind: Mapped[str] = mapped_column(String(50))
status: Mapped[str] = mapped_column(String(100), default="unknown")
tags: Mapped[list[str]] = mapped_column(JSON, default=list)
class Network(Base, TimestampMixin):
__tablename__ = "networks"
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
cluster_id: Mapped[str] = mapped_column(ForeignKey("clusters.id"), index=True)
project_id: Mapped[str | None] = mapped_column(ForeignKey("projects.id"), index=True)
name: Mapped[str] = mapped_column(String(255))
kind: Mapped[str] = mapped_column(String(50))
vlan_id: Mapped[int | None] = mapped_column(Integer)
mtu: Mapped[int] = mapped_column(Integer, default=1500)
gateway: Mapped[str | None] = mapped_column(String(100))
dns: Mapped[list[str]] = mapped_column(JSON, default=list)
dhcp_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
tags: Mapped[list[str]] = mapped_column(JSON, default=list)
description: Mapped[str | None] = mapped_column(Text)
class Subnet(Base, TimestampMixin):
__tablename__ = "subnets"
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
network_id: Mapped[str] = mapped_column(ForeignKey("networks.id"), index=True)
cidr: Mapped[str] = mapped_column(String(100))
gateway: Mapped[str | None] = mapped_column(String(100))
dns: Mapped[list[str]] = mapped_column(JSON, default=list)
dhcp_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
class IpAddress(Base, TimestampMixin):
__tablename__ = "ip_addresses"
__table_args__ = (UniqueConstraint("subnet_id", "address"),)
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
subnet_id: Mapped[str] = mapped_column(ForeignKey("subnets.id"), index=True)
address: Mapped[str] = mapped_column(String(100))
status: Mapped[IpStatus] = mapped_column(Enum(IpStatus), default=IpStatus.free)
workload_id: Mapped[str | None] = mapped_column(ForeignKey("workloads.id"), index=True)
note: Mapped[str | None] = mapped_column(Text)
class SecurityGroup(Base, TimestampMixin):
__tablename__ = "security_groups"
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
project_id: Mapped[str | None] = mapped_column(ForeignKey("projects.id"), index=True)
name: Mapped[str] = mapped_column(String(255))
description: Mapped[str | None] = mapped_column(Text)
class SecurityRule(Base, TimestampMixin):
__tablename__ = "security_rules"
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
security_group_id: Mapped[str] = mapped_column(ForeignKey("security_groups.id"), index=True)
direction: Mapped[Direction] = mapped_column(Enum(Direction))
action: Mapped[RuleAction] = mapped_column(Enum(RuleAction))
protocol: Mapped[str] = mapped_column(String(20), default="any")
source: Mapped[str] = mapped_column(String(255), default="any")
destination: Mapped[str] = mapped_column(String(255), default="any")
port: Mapped[str | None] = mapped_column(String(100))
priority: Mapped[int] = mapped_column(Integer, default=1000)
logging: Mapped[bool] = mapped_column(Boolean, default=False)
description: Mapped[str | None] = mapped_column(Text)
class Policy(Base, TimestampMixin):
__tablename__ = "policies"
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
project_id: Mapped[str | None] = mapped_column(ForeignKey("projects.id"), index=True)
name: Mapped[str] = mapped_column(String(255))
version: Mapped[int] = mapped_column(Integer, default=1)
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
definition: Mapped[dict] = mapped_column(JSON, default=dict)
last_compiled: Mapped[dict | None] = mapped_column(JSON)
class ServiceCatalogItem(Base, TimestampMixin):
__tablename__ = "service_catalog"
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
name: Mapped[str] = mapped_column(String(255), unique=True)
protocol: Mapped[str] = mapped_column(String(20))
ports: Mapped[str] = mapped_column(String(100))
editable: Mapped[bool] = mapped_column(Boolean, default=True)
class Job(Base, TimestampMixin):
__tablename__ = "jobs"
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
kind: Mapped[str] = mapped_column(String(100))
status: Mapped[JobStatus] = mapped_column(Enum(JobStatus), default=JobStatus.queued)
progress: Mapped[int] = mapped_column(Integer, default=0)
started_at: Mapped[datetime | None] = mapped_column(DateTime)
finished_at: Mapped[datetime | None] = mapped_column(DateTime)
logs: Mapped[list[str]] = mapped_column(JSON, default=list)
error: Mapped[str | None] = mapped_column(Text)
class AuditLog(Base):
__tablename__ = "audit_logs"
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id"), index=True)
action: Mapped[str] = mapped_column(String(100), index=True)
object_type: Mapped[str] = mapped_column(String(100), index=True)
object_id: Mapped[str | None] = mapped_column(String(100), index=True)
old_values: Mapped[dict | None] = mapped_column(JSON)
new_values: Mapped[dict | None] = mapped_column(JSON)
ip_address: Mapped[str | None] = mapped_column(String(100))
user_agent: Mapped[str | None] = mapped_column(String(512))
result: Mapped[str] = mapped_column(String(100), default="success")
error_text: Mapped[str | None] = mapped_column(Text)
+2
View File
@@ -0,0 +1,2 @@
from app.schemas.domain import * # noqa: F403
+183
View File
@@ -0,0 +1,183 @@
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, EmailStr, Field
class OrmModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
class TokenPair(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
class LoginRequest(BaseModel):
email: EmailStr
password: str
class UserRead(OrmModel):
id: str
email: EmailStr
display_name: str
is_active: bool
class ClusterCreate(BaseModel):
name: str
api_url: str
api_token: str = Field(min_length=8)
mode: str = "read_only"
verify_tls: bool = True
class SecurityGroupCreate(BaseModel):
project_id: str | None = None
name: str
description: str | None = None
class PolicyCreate(BaseModel):
project_id: str | None = None
name: str
enabled: bool = True
definition: dict[str, Any]
class IpReservationCreate(BaseModel):
subnet_id: str
address: str
status: str = "reserved"
note: str | None = None
class ClusterRead(OrmModel):
id: str
name: str
api_url: str
provider: str
mode: str
last_sync_at: datetime | None
last_sync_status: str | None
last_sync_error: str | None
class NodeRead(OrmModel):
id: str
cluster_id: str
name: str
status: str
cpu_count: int
memory_mb: int
class WorkloadRead(OrmModel):
id: str
cluster_id: str
node_id: str
project_id: str | None
external_id: str
name: str
kind: str
status: str
tags: list[str]
class NetworkRead(OrmModel):
id: str
cluster_id: str
project_id: str | None
name: str
kind: str
vlan_id: int | None
mtu: int
gateway: str | None
dns: list[str]
dhcp_enabled: bool
tags: list[str]
description: str | None
class SubnetRead(OrmModel):
id: str
network_id: str
cidr: str
gateway: str | None
dns: list[str]
dhcp_enabled: bool
class IpAddressRead(OrmModel):
id: str
subnet_id: str
address: str
status: str
workload_id: str | None
note: str | None
class TenantRead(OrmModel):
id: str
name: str
description: str | None
class ProjectRead(OrmModel):
id: str
tenant_id: str
name: str
description: str | None
class SecurityGroupRead(OrmModel):
id: str
project_id: str | None
name: str
description: str | None
class PolicyRead(OrmModel):
id: str
project_id: str | None
name: str
version: int
enabled: bool
definition: dict[str, Any]
last_compiled: dict[str, Any] | None
class FirewallPreview(BaseModel):
policy_id: str
dry_run: bool = True
generated_rules: list[dict[str, Any]]
warnings: list[str]
conflicts: list[str]
class JobRead(OrmModel):
id: str
kind: str
status: str
progress: int
started_at: datetime | None
finished_at: datetime | None
logs: list[str]
error: str | None
class AuditLogRead(OrmModel):
id: str
created_at: datetime
user_id: str | None
action: str
object_type: str
object_id: str | None
old_values: dict[str, Any] | None
new_values: dict[str, Any] | None
ip_address: str | None
user_agent: str | None
result: str
error_text: str | None
+181
View File
@@ -0,0 +1,181 @@
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.security import hash_password
from app.models.domain import (
AuditLog,
Cluster,
IpAddress,
Network,
Node,
Policy,
Project,
Role,
SecurityGroup,
ServiceCatalogItem,
Subnet,
Tenant,
User,
Workload,
)
SERVICES = [
("SSH", "tcp", "22"),
("HTTP", "tcp", "80"),
("HTTPS", "tcp", "443"),
("DNS", "tcp/udp", "53"),
("LDAP", "tcp", "389"),
("LDAPS", "tcp", "636"),
("RDP", "tcp", "3389"),
("PostgreSQL", "tcp", "5432"),
("MySQL/MariaDB", "tcp", "3306"),
("MSSQL", "tcp", "1433"),
("Redis", "tcp", "6379"),
("SMB", "tcp", "445"),
("SMTP", "tcp", "25"),
("SMTPS", "tcp", "465"),
("IMAPS", "tcp", "993"),
("POP3S", "tcp", "995"),
("Proxmox API", "tcp", "8006"),
("Proxmox SPICE", "tcp", "3128"),
]
def seed_demo_data(db: Session) -> None:
if db.scalar(select(User).where(User.email == "admin@nexafabric.local")):
return
super_admin = Role(name="Super Admin", permissions=["*"])
roles = [
super_admin,
Role(name="Network Admin", permissions=["networks:*", "ipam:*", "clusters:read"]),
Role(name="Security Admin", permissions=["policies:*", "firewall:*", "security-groups:*"]),
Role(name="Tenant Admin", permissions=["tenants:read", "projects:*"]),
Role(name="Auditor", permissions=["audit:read", "clusters:read", "policies:read"]),
Role(name="Read Only User", permissions=["*:read"]),
]
user = User(
email="admin@nexafabric.local",
display_name="NexaFabric Administrator",
password_hash=hash_password(get_settings().demo_admin_password),
roles=[super_admin],
)
db.add_all(roles + [user])
tenants = [
Tenant(name="Platform", description="Shared infrastructure and platform services"),
Tenant(name="Finance", description="Finance applications"),
Tenant(name="Research", description="Lab and engineering workloads"),
]
db.add_all(tenants)
db.flush()
projects = [
Project(tenant_id=tenants[0].id, name="Core Services", description="DNS, auth, monitoring"),
Project(tenant_id=tenants[1].id, name="ERP", description="Finance ERP workloads"),
Project(tenant_id=tenants[2].id, name="Lab", description="Research lab systems"),
]
db.add_all(projects)
db.flush()
cluster = Cluster(
name="Demo Proxmox Cluster",
api_url="https://pve-demo.local:8006",
token_ref="demo-token-reference",
last_sync_status="success",
)
db.add(cluster)
db.flush()
nodes = [
Node(cluster_id=cluster.id, name="pve-01", status="online", cpu_count=32, memory_mb=131072),
Node(cluster_id=cluster.id, name="pve-02", status="online", cpu_count=32, memory_mb=131072),
Node(cluster_id=cluster.id, name="pve-03", status="warning", cpu_count=24, memory_mb=98304),
]
db.add_all(nodes)
db.flush()
networks = [
Network(cluster_id=cluster.id, project_id=projects[0].id, name="mgmt", kind="bridge", vlan_id=10, gateway="10.10.10.1", dns=["10.10.10.10"], tags=["management"], description="Management bridge"),
Network(cluster_id=cluster.id, project_id=projects[0].id, name="services", kind="vlan", vlan_id=20, gateway="10.20.0.1", dns=["10.20.0.10"], tags=["shared"], description="Shared services"),
Network(cluster_id=cluster.id, project_id=projects[1].id, name="finance-app", kind="vxlan", vlan_id=120, gateway="10.120.0.1", dns=["10.20.0.10"], tags=["finance"], description="Finance application tier"),
Network(cluster_id=cluster.id, project_id=projects[2].id, name="research-lab", kind="vnet", vlan_id=220, gateway="10.220.0.1", dns=["10.20.0.10"], tags=["lab"], description="Research tenant network"),
]
db.add_all(networks)
db.flush()
subnets = [
Subnet(network_id=networks[0].id, cidr="10.10.10.0/24", gateway="10.10.10.1", dns=["10.10.10.10"]),
Subnet(network_id=networks[1].id, cidr="10.20.0.0/24", gateway="10.20.0.1", dns=["10.20.0.10"]),
Subnet(network_id=networks[2].id, cidr="10.120.0.0/24", gateway="10.120.0.1", dns=["10.20.0.10"]),
]
db.add_all(subnets)
db.flush()
workloads = []
for idx in range(10):
project = projects[idx % len(projects)]
workloads.append(
Workload(
cluster_id=cluster.id,
node_id=nodes[idx % len(nodes)].id,
project_id=project.id,
external_id=str(100 + idx),
name=f"{project.name.lower().replace(' ', '-')}-{idx + 1}",
kind="qemu" if idx % 3 else "lxc",
status="running" if idx != 7 else "stopped",
tags=["web"] if idx % 2 else ["db"],
)
)
db.add_all(workloads)
db.flush()
db.add_all(
[
IpAddress(subnet_id=subnets[0].id, address="10.10.10.20", status="assigned", workload_id=workloads[0].id),
IpAddress(subnet_id=subnets[1].id, address="10.20.0.50", status="reserved", note="Load balancer VIP"),
IpAddress(subnet_id=subnets[2].id, address="10.120.0.99", status="conflict", note="Duplicate detected during import"),
]
)
groups = [
SecurityGroup(project_id=projects[0].id, name="Management", description="Administrative access"),
SecurityGroup(project_id=projects[1].id, name="Finance Web", description="Finance web tier"),
SecurityGroup(project_id=projects[1].id, name="Finance DB", description="Finance database tier"),
]
db.add_all(groups)
db.add_all(
[
Policy(
project_id=projects[1].id,
name="Finance web to database",
definition={
"source": "sg:Finance Web",
"destination": "sg:Finance DB",
"service": {"protocol": "tcp", "ports": "5432"},
"action": "allow",
"direction": "egress",
"logging": True,
"description": "Allow PostgreSQL from finance web tier to database tier.",
},
),
Policy(
project_id=projects[0].id,
name="Management SSH",
definition={
"source": "network:mgmt",
"destination": "any",
"service": {"protocol": "tcp", "ports": "22"},
"action": "allow",
"direction": "ingress",
},
),
]
)
db.add_all([ServiceCatalogItem(name=name, protocol=proto, ports=ports, editable=True) for name, proto, ports in SERVICES])
db.add(AuditLog(user_id=user.id, action="seed.created", object_type="system", result="success"))
db.commit()
+32
View File
@@ -0,0 +1,32 @@
from sqlalchemy.orm import Session
from app.models.domain import AuditLog
def write_audit(
db: Session,
*,
action: str,
object_type: str,
object_id: str | None = None,
user_id: str | None = None,
old_values: dict | None = None,
new_values: dict | None = None,
result: str = "success",
error_text: str | None = None,
) -> AuditLog:
audit = AuditLog(
action=action,
object_type=object_type,
object_id=object_id,
user_id=user_id,
old_values=old_values,
new_values=new_values,
result=result,
error_text=error_text,
)
db.add(audit)
db.commit()
db.refresh(audit)
return audit
@@ -0,0 +1,29 @@
from app.models.domain import Cluster, Policy
from app.schemas.domain import FirewallPreview
from app.services.policy_engine import PolicyEngine
from app.services.providers.base import ProviderConnection
from app.services.providers.registry import get_provider
class FirewallOrchestrator:
def __init__(self) -> None:
self.policy_engine = PolicyEngine()
async def preview(self, cluster: Cluster, policy: Policy) -> FirewallPreview:
compiled = self.policy_engine.compile(policy)
provider = get_provider(cluster.provider)
connection = ProviderConnection(
api_url=cluster.api_url,
token=cluster.token_ref or "",
verify_tls=cluster.verify_tls,
read_only=cluster.mode == "read_only",
)
provider_preview = await provider.preview_rules(connection, compiled["rules"])
return FirewallPreview(
policy_id=policy.id,
dry_run=True,
generated_rules=provider_preview["generated"],
warnings=[*compiled["warnings"], *provider_preview.get("warnings", [])],
conflicts=compiled["conflicts"],
)
+35
View File
@@ -0,0 +1,35 @@
from typing import Any
from app.models.domain import Policy
class PolicyEngine:
def compile(self, policy: Policy) -> dict[str, Any]:
definition = policy.definition or {}
source = definition.get("source", "any")
destination = definition.get("destination", "any")
service = definition.get("service", {"protocol": "any", "ports": "any"})
action = definition.get("action", "allow")
direction = definition.get("direction", "ingress")
generated_rule = {
"policy_id": policy.id,
"policy_version": policy.version,
"source": source,
"destination": destination,
"protocol": service.get("protocol", "any"),
"ports": service.get("ports", "any"),
"direction": direction,
"action": action,
"logging": bool(definition.get("logging", False)),
"description": definition.get("description", policy.name),
}
warnings = []
if source == "any" and destination == "any":
warnings.append("Policy targets all sources and destinations.")
if action == "allow" and service.get("ports") == "any":
warnings.append("Broad allow policy uses all ports.")
return {"rules": [generated_rule], "warnings": warnings, "conflicts": []}
+50
View File
@@ -0,0 +1,50 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True)
class ProviderConnection:
api_url: str
token: str
verify_tls: bool = True
read_only: bool = True
class HypervisorProvider(ABC):
@abstractmethod
async def test_connection(self, connection: ProviderConnection) -> dict[str, Any]:
raise NotImplementedError
class InventoryProvider(ABC):
@abstractmethod
async def sync_inventory(self, connection: ProviderConnection) -> dict[str, list[dict[str, Any]]]:
raise NotImplementedError
class NetworkProvider(ABC):
@abstractmethod
async def list_networks(self, connection: ProviderConnection) -> list[dict[str, Any]]:
raise NotImplementedError
class FirewallProvider(ABC):
@abstractmethod
async def preview_rules(self, connection: ProviderConnection, rules: list[dict[str, Any]]) -> dict[str, Any]:
raise NotImplementedError
@abstractmethod
async def apply_rules(self, connection: ProviderConnection, rules: list[dict[str, Any]]) -> dict[str, Any]:
raise NotImplementedError
class Provider(
HypervisorProvider,
InventoryProvider,
NetworkProvider,
FirewallProvider,
ABC,
):
name: str
+35
View File
@@ -0,0 +1,35 @@
from typing import Any
from app.services.providers.base import Provider, ProviderConnection
class DemoProvider(Provider):
name = "demo"
async def test_connection(self, connection: ProviderConnection) -> dict[str, Any]:
return {"version": "demo", "api_url": connection.api_url, "mode": "offline"}
async def sync_inventory(self, connection: ProviderConnection) -> dict[str, list[dict[str, Any]]]:
return {
"nodes": [
{"node": "demo-pve-01", "status": "online", "maxcpu": 16, "maxmem": 68719476736},
{"node": "demo-pve-02", "status": "online", "maxcpu": 16, "maxmem": 68719476736},
],
"workloads": [
{"vmid": 201, "name": "demo-web-01", "type": "qemu", "status": "running", "node": "demo-pve-01"},
{"vmid": 202, "name": "demo-db-01", "type": "qemu", "status": "running", "node": "demo-pve-02"},
],
"networks": [
{"name": "vmbr0", "type": "bridge", "vlan": 10},
{"name": "vnet-prod", "type": "vnet", "vlan": 120},
],
}
async def list_networks(self, connection: ProviderConnection) -> list[dict[str, Any]]:
return (await self.sync_inventory(connection))["networks"]
async def preview_rules(self, connection: ProviderConnection, rules: list[dict[str, Any]]) -> dict[str, Any]:
return {"provider": self.name, "generated": rules, "warnings": ["Demo provider preview."]}
async def apply_rules(self, connection: ProviderConnection, rules: list[dict[str, Any]]) -> dict[str, Any]:
return {"applied": False, "reason": "Demo provider never applies firewall changes.", "rules": rules}
+56
View File
@@ -0,0 +1,56 @@
from typing import Any
import httpx
from app.services.providers.base import Provider, ProviderConnection
class ProxmoxProvider(Provider):
name = "proxmox"
async def test_connection(self, connection: ProviderConnection) -> dict[str, Any]:
async with httpx.AsyncClient(verify=connection.verify_tls, timeout=10) as client:
response = await client.get(
f"{connection.api_url.rstrip('/')}/api2/json/version",
headers={"Authorization": connection.token},
)
response.raise_for_status()
return response.json().get("data", {})
async def sync_inventory(self, connection: ProviderConnection) -> dict[str, list[dict[str, Any]]]:
async with httpx.AsyncClient(verify=connection.verify_tls, timeout=20) as client:
resources = await client.get(
f"{connection.api_url.rstrip('/')}/api2/json/cluster/resources",
headers={"Authorization": connection.token},
)
resources.raise_for_status()
data = resources.json().get("data", [])
nodes = [item for item in data if item.get("type") == "node"]
workloads = [item for item in data if item.get("type") in {"qemu", "lxc"}]
networks = await self.list_networks(connection)
return {"nodes": nodes, "workloads": workloads, "networks": networks}
async def list_networks(self, connection: ProviderConnection) -> list[dict[str, Any]]:
async with httpx.AsyncClient(verify=connection.verify_tls, timeout=20) as client:
resources = await client.get(
f"{connection.api_url.rstrip('/')}/api2/json/cluster/resources",
headers={"Authorization": connection.token},
)
resources.raise_for_status()
data = resources.json().get("data", [])
return [item for item in data if item.get("type") in {"network", "sdn"}]
async def preview_rules(self, connection: ProviderConnection, rules: list[dict[str, Any]]) -> dict[str, Any]:
return {
"provider": self.name,
"read_only": connection.read_only,
"generated": rules,
"warnings": ["Preview only. No Proxmox firewall changes were sent."],
}
async def apply_rules(self, connection: ProviderConnection, rules: list[dict[str, Any]]) -> dict[str, Any]:
if connection.read_only:
return {"applied": False, "reason": "Cluster is read-only", "rules": rules}
return {"applied": False, "reason": "Apply adapter intentionally requires explicit implementation", "rules": rules}
@@ -0,0 +1,10 @@
from app.services.providers.base import Provider
from app.services.providers.demo import DemoProvider
from app.services.providers.proxmox import ProxmoxProvider
_providers: dict[str, Provider] = {"proxmox": ProxmoxProvider(), "demo": DemoProvider()}
def get_provider(name: str) -> Provider:
return _providers[name]
+12
View File
@@ -0,0 +1,12 @@
import time
def main() -> None:
print("NexaFabric worker started. Configure Celery queues for production job execution.", flush=True)
while True:
time.sleep(30)
if __name__ == "__main__":
main()