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
+13
View File
@@ -0,0 +1,13 @@
PROJECT_NAME=NexaFabric
ENVIRONMENT=development
API_HOST=0.0.0.0
API_PORT=8000
DATABASE_URL=postgresql+psycopg://nexafabric:nexafabric@postgres:5432/nexafabric
REDIS_URL=redis://redis:6379/0
JWT_SECRET=change-this-to-a-long-random-secret
JWT_ACCESS_TOKEN_MINUTES=15
JWT_REFRESH_TOKEN_DAYS=14
TOKEN_ENCRYPTION_KEY=change-this-fernet-key-before-production
CORS_ORIGINS=http://localhost:5173,http://localhost:8080
DEMO_ADMIN_PASSWORD=ChangeMe_UseEnvInstead
VITE_API_BASE_URL=/api/v1
+43
View File
@@ -0,0 +1,43 @@
name: CI
on:
push:
pull_request:
jobs:
backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install backend
working-directory: backend
run: pip install ".[dev]"
- name: Lint backend
working-directory: backend
run: ruff check .
- name: Test backend
working-directory: backend
run: pytest
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: frontend/package.json
- name: Install frontend
working-directory: frontend
run: npm install
- name: Test frontend
working-directory: frontend
run: npm test
- name: Build frontend
working-directory: frontend
run: npm run build
+50
View File
@@ -0,0 +1,50 @@
stages:
- test
- build
- security
- release
backend:
image: python:3.12-slim
stage: test
script:
- cd backend
- pip install ".[dev]"
- ruff check .
- pytest
frontend:
image: node:22-alpine
stage: test
script:
- cd frontend
- npm install
- npm test
- npm run build
docker-build:
image: docker:27
stage: build
services:
- docker:27-dind
script:
- docker build -t nexafabric-api:$CI_COMMIT_SHA backend
- docker build -t nexafabric-frontend:$CI_COMMIT_SHA frontend
security-scan:
image: aquasec/trivy:latest
stage: security
script:
- trivy fs --exit-code 0 --severity HIGH,CRITICAL .
openapi:
image: python:3.12-slim
stage: release
script:
- cd backend
- pip install ".[dev]"
- python -c "import json; from app.main import app; print(json.dumps(app.openapi()))" > ../openapi.json
artifacts:
paths:
- openapi.json
+12
View File
@@ -0,0 +1,12 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
NexaFabric is distributed under the GNU Affero General Public License version 3.
The full license text is available at:
https://www.gnu.org/licenses/agpl-3.0.txt
+42
View File
@@ -0,0 +1,42 @@
# NexaFabric
NexaFabric is an open-source SDN-like network and security control plane for Proxmox VE environments. It runs as an external web application and provides inventory, IPAM, security groups, policy simulation, firewall previews, audit trails, and automation without patching Proxmox itself.
## What Is Included
- FastAPI backend with SQLAlchemy 2, Alembic-ready models, JWT auth, RBAC primitives, audit logging, and provider interfaces.
- React + TypeScript frontend with Vite, Tailwind CSS, TanStack Query, React Router, Zustand, dark/light mode, and production-oriented pages.
- PostgreSQL, Redis, worker, API, frontend, and reverse proxy through Docker Compose.
- Demo seed data for clusters, nodes, workloads, networks, tenants, policies, IPAM, jobs, and audit events.
- Tests, lint/type-check scripts, CI workflow, and operational documentation.
## Quick Start
```bash
cp .env.example .env
docker compose up --build
```
Then open:
- Frontend: http://localhost:8080
- API docs: http://localhost:8080/api/docs
Demo login:
- Email: `admin@nexafabric.local`
- Password: `ChangeMe_UseEnvInstead`
## Repository Layout
```text
backend/ FastAPI API, models, services, worker entrypoint, tests
frontend/ React application, API client, pages, component tests
docs/ Architecture, operations, security, provider and API docs
nginx/ Reverse proxy example
```
## Safety Model
NexaFabric never applies firewall changes without a preview, validation, and audit record. The included Proxmox provider is designed around read-only inventory first. Write-enabled orchestration is intentionally routed through explicit dry-run and apply workflows.
+14
View File
@@ -0,0 +1,14 @@
# NexaFabric Node Agent
The node agent is optional. NexaFabric works through the Proxmox API without it.
Planned agent responsibilities:
- Read nftables status.
- Report node network health.
- Apply explicitly approved rule bundles.
- Authenticate with token or mTLS.
- Expose a health check.
The current repository includes the service file and install script skeleton so packaging can be added without changing the control-plane architecture.
+28
View File
@@ -0,0 +1,28 @@
from http.server import BaseHTTPRequestHandler, HTTPServer
import json
import os
class HealthHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None:
if self.path != "/healthz":
self.send_response(404)
self.end_headers()
return
body = json.dumps({"status": "ok", "agent": "nexafabric"}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def main() -> None:
host = os.getenv("NEXAFABRIC_AGENT_HOST", "0.0.0.0")
port = int(os.getenv("NEXAFABRIC_AGENT_PORT", "9844"))
HTTPServer((host, port), HealthHandler).serve_forever()
if __name__ == "__main__":
main()
+14
View File
@@ -0,0 +1,14 @@
FROM python:3.12-slim AS runtime
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
COPY pyproject.toml ./
RUN pip install --no-cache-dir ".[dev]"
COPY app ./app
COPY tests ./tests
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+39
View File
@@ -0,0 +1,39 @@
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url = sqlite:///./nexafabric.db
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+49
View File
@@ -0,0 +1,49 @@
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from app.core.config import get_settings
from app.db.session import Base
from app.models import domain # noqa: F401
config = context.config
config.set_main_option("sqlalchemy.url", get_settings().database_url)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
context.configure(
url=config.get_main_option("sqlalchemy.url"),
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+24
View File
@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
+1
View File
@@ -0,0 +1 @@
+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()
+38
View File
@@ -0,0 +1,38 @@
[project]
name = "nexafabric-backend"
version = "0.1.0"
description = "NexaFabric FastAPI backend"
requires-python = ">=3.12"
dependencies = [
"alembic>=1.13.2",
"argon2-cffi>=23.1.0",
"celery>=5.4.0",
"cryptography>=43.0.0",
"email-validator>=2.2.0",
"fastapi>=0.115.0",
"httpx>=0.27.0",
"passlib>=1.7.4",
"psycopg[binary]>=3.2.1",
"pydantic-settings>=2.4.0",
"python-jose[cryptography]>=3.3.0",
"python-multipart>=0.0.9",
"redis>=5.0.8",
"sqlalchemy>=2.0.32",
"uvicorn[standard]>=0.30.6",
]
[project.optional-dependencies]
dev = [
"pytest>=8.3.2",
"pytest-asyncio>=0.23.8",
"ruff>=0.6.3",
"mypy>=1.11.1",
]
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]
+26
View File
@@ -0,0 +1,26 @@
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_healthz() -> None:
response = client.get("/healthz")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
def test_login_and_dashboard() -> None:
response = client.post(
"/api/v1/auth/login",
json={"email": "admin@nexafabric.local", "password": "ChangeMe_UseEnvInstead"},
)
assert response.status_code == 200
token = response.json()["access_token"]
dashboard = client.get("/api/v1/dashboard", headers={"Authorization": f"Bearer {token}"})
assert dashboard.status_code == 200
assert dashboard.json()["clusters"] >= 1
+8
View File
@@ -0,0 +1,8 @@
:80 {
route /api/* {
reverse_proxy api:8000
}
reverse_proxy frontend:80
}
+16
View File
@@ -0,0 +1,16 @@
[Unit]
Description=NexaFabric Node Agent
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
EnvironmentFile=-/etc/nexafabric/agent.env
ExecStart=/usr/local/bin/nexafabric-agent
Restart=on-failure
RestartSec=5
User=root
[Install]
WantedBy=multi-user.target
+64
View File
@@ -0,0 +1,64 @@
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
env_file: .env
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redis-data:/data
api:
build: ./backend
restart: unless-stopped
env_file: .env
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
worker:
build: ./backend
restart: unless-stopped
env_file: .env
command: ["python", "-m", "app.workers.worker"]
depends_on:
- api
frontend:
build: ./frontend
restart: unless-stopped
depends_on:
- api
proxy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./deploy/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
depends_on:
- frontend
- api
volumes:
postgres-data:
redis-data:
caddy-data:
caddy-config:
+69
View File
@@ -0,0 +1,69 @@
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: nexafabric
POSTGRES_USER: nexafabric
POSTGRES_PASSWORD: nexafabric
healthcheck:
test: ["CMD-SHELL", "pg_isready -U nexafabric -d nexafabric"]
interval: 10s
timeout: 5s
retries: 5
volumes:
- postgres-data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
api:
build:
context: ./backend
env_file: .env
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/healthz')"]
interval: 15s
timeout: 5s
retries: 5
worker:
build:
context: ./backend
env_file: .env
command: ["python", "-m", "app.workers.worker"]
depends_on:
api:
condition: service_healthy
frontend:
build:
context: ./frontend
environment:
VITE_API_BASE_URL: /api/v1
depends_on:
api:
condition: service_healthy
proxy:
image: nginx:1.27-alpine
ports:
- "8080:80"
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- api
- frontend
volumes:
postgres-data:
+24
View File
@@ -0,0 +1,24 @@
# API Examples
Login:
```bash
curl -X POST http://localhost:8080/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"admin@nexafabric.local","password":"ChangeMe_UseEnvInstead"}'
```
List clusters:
```bash
curl http://localhost:8080/api/v1/clusters \
-H "Authorization: Bearer $TOKEN"
```
Generate firewall preview:
```bash
curl -X POST http://localhost:8080/api/v1/firewall/preview/$POLICY_ID \
-H "Authorization: Bearer $TOKEN"
```
+20
View File
@@ -0,0 +1,20 @@
# Architecture
NexaFabric is split into five layers:
1. Frontend: React application for daily network, IPAM, policy, and audit operations.
2. API: FastAPI REST surface with OpenAPI docs, JWT auth, RBAC hooks, and validation.
3. Domain services: provider registry, policy engine, firewall orchestrator, audit service, and job coordination.
4. Persistence: PostgreSQL through SQLAlchemy models. Alembic is intended for production migrations.
5. Workers: background execution for sync, compile, drift detection, IPAM scans, cleanup, and backup export.
Provider interfaces are intentionally separated into `HypervisorProvider`, `InventoryProvider`, `NetworkProvider`, and `FirewallProvider`. The first implementation is Proxmox, but the API layer is not tied directly to Proxmox-specific code.
Firewall orchestration follows this flow:
1. Policy definition is compiled.
2. Conflicts and broad access warnings are calculated.
3. Provider-specific preview output is produced.
4. Audit log records the preview.
5. A later apply path must verify cluster write mode, acquire a lock, and write a second audit record.
+18
View File
@@ -0,0 +1,18 @@
# Backup And Restore
Back up:
- PostgreSQL database
- Environment file and secret material
- Reverse proxy configuration
- Optional worker queue state
Restore:
1. Stop API and workers.
2. Restore PostgreSQL.
3. Restore `.env` and encryption keys.
4. Start PostgreSQL and Redis.
5. Start API and workers.
6. Run health checks and verify audit log continuity.
+23
View File
@@ -0,0 +1,23 @@
# Developer Guide
Backend:
```bash
cd backend
pip install ".[dev]"
pytest
ruff check .
uvicorn app.main:app --reload
```
Frontend:
```bash
cd frontend
npm install
npm run dev
npm test
```
The API docs are served at `/api/docs`.
+16
View File
@@ -0,0 +1,16 @@
# Firewall Orchestration
NexaFabric must never overwrite productive firewall state without operator intent.
Required lifecycle:
1. Compile policy.
2. Generate preview.
3. Detect warnings and conflicts.
4. Persist audit record.
5. Require approval for apply.
6. Acquire a cluster/node lock.
7. Apply provider-specific rules.
8. Detect drift after apply.
9. Support rollback using previous compiled versions.
+25
View File
@@ -0,0 +1,25 @@
# Installation
## Docker Compose
```bash
cp .env.example .env
docker compose up --build
```
Change every secret in `.env` before using NexaFabric outside a local lab.
## Proxmox API Token
Create a least-privilege Proxmox API token and start in read-only mode. NexaFabric can inventory clusters, nodes, VMs, LXCs, networks, and firewall state before any write workflows are enabled.
Recommended initial scope:
- Cluster inventory read
- Node inventory read
- VM and LXC config read
- SDN and network read
- Firewall read
Enable write permissions only for a dedicated automation token after previews and approvals are working.
+12
View File
@@ -0,0 +1,12 @@
# IPAM Concept
NexaFabric tracks subnets, gateway, DNS, DHCP state, and individual IP addresses. IP states are:
- free
- reserved
- assigned
- deprecated
- conflict
The import workflow should scan existing VM and LXC network configuration, reserve discovered addresses, and flag duplicates as conflicts.
+11
View File
@@ -0,0 +1,11 @@
# Provider Concept
Providers implement these interfaces:
- `HypervisorProvider`
- `InventoryProvider`
- `NetworkProvider`
- `FirewallProvider`
The Proxmox provider is the first implementation. Future providers can be registered without changing API route code.
+13
View File
@@ -0,0 +1,13 @@
# RBAC Concept
Built-in roles:
- Super Admin
- Network Admin
- Security Admin
- Tenant Admin
- Auditor
- Read Only User
Permissions are stored as strings and can use wildcard entries. Tenant and project scoping should be enforced in query services as the implementation matures.
+12
View File
@@ -0,0 +1,12 @@
# Security Concept
- Passwords use Argon2id through Passlib.
- JWT access tokens are short-lived; refresh token rotation is part of the auth roadmap.
- API tokens are represented as references in the current scaffold and must be encrypted before production use.
- RBAC is role and permission based.
- Firewall changes require preview, validation, locking, and audit records.
- Proxmox write-enabled mode is explicit per cluster.
- No dangerous default password should be used in production.
- CORS origins are configured through environment settings.
- SQL queries use SQLAlchemy ORM constructs.
+19
View File
@@ -0,0 +1,19 @@
# Troubleshooting
## API Cannot Reach Proxmox
- Verify Proxmox URL and TLS settings.
- Check token format and least-privilege role assignment.
- Test routing from the API container.
## Login Fails
- Confirm demo seed data was created.
- Verify database connectivity.
- Reset the local admin password through a controlled maintenance task.
## Firewall Preview Is Empty
- Confirm at least one enabled policy exists.
- Check policy definition source, destination, service, action, and direction.
+10
View File
@@ -0,0 +1,10 @@
# Upgrade Guide
1. Read release notes.
2. Back up PostgreSQL and secrets.
3. Pull the new version.
4. Run database migrations.
5. Restart API, workers, frontend, and proxy.
6. Run health checks.
7. Generate a firewall preview before any apply workflow.
+12
View File
@@ -0,0 +1,12 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NexaFabric</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+10
View File
@@ -0,0 +1,10 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri /index.html;
}
}
+39
View File
@@ -0,0 +1,39 @@
{
"name": "nexafabric-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc -b && vite build",
"preview": "vite preview --host 0.0.0.0",
"lint": "eslint .",
"test": "vitest run",
"e2e": "playwright test"
},
"dependencies": {
"@tanstack/react-query": "^5.55.4",
"clsx": "^2.1.1",
"lucide-react": "^0.468.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2",
"zustand": "^4.5.5"
},
"devDependencies": {
"@playwright/test": "^1.46.1",
"@testing-library/jest-dom": "^6.4.8",
"@testing-library/react": "^16.0.1",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"eslint": "^9.9.1",
"jsdom": "^24.1.1",
"postcss": "^8.4.41",
"tailwindcss": "^3.4.10",
"typescript": "^5.5.4",
"vite": "^5.4.2",
"vitest": "^2.0.5"
}
}
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
use: {
baseURL: "http://localhost:5173",
trace: "on-first-retry",
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "tablet", use: { ...devices["iPad Pro 11"] } },
],
});
+7
View File
@@ -0,0 +1,7 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+41
View File
@@ -0,0 +1,41 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import { Layout } from "./components/Layout";
import { Dashboard } from "./pages/Dashboard";
import { FirewallPreview } from "./pages/FirewallPreview";
import { ListPage } from "./pages/ListPage";
import { Login } from "./pages/Login";
import { PolicyDesigner } from "./pages/PolicyDesigner";
const queryClient = new QueryClient();
export function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route element={<Layout />}>
<Route index element={<Dashboard />} />
<Route path="clusters" element={<ListPage title="Clusters" subtitle="Registered Proxmox clusters and sync state." path="/clusters" columns={[{ key: "name", label: "Name" }, { key: "api_url", label: "API URL" }, { key: "mode", label: "Mode" }, { key: "last_sync_status", label: "Sync" }]} />} />
<Route path="nodes" element={<ListPage title="Nodes" subtitle="Cluster nodes, capacity, and health." path="/nodes" columns={[{ key: "name", label: "Name" }, { key: "status", label: "Status" }, { key: "cpu_count", label: "CPU" }, { key: "memory_mb", label: "Memory MB" }]} />} />
<Route path="workloads" element={<ListPage title="VMs/LXCs" subtitle="Virtual machine and container inventory." path="/vms" columns={[{ key: "name", label: "Name" }, { key: "kind", label: "Kind" }, { key: "status", label: "Status" }, { key: "external_id", label: "VMID" }]} />} />
<Route path="networks" element={<ListPage title="Networks" subtitle="Bridges, VLANs, VNets, gateways, tags, and MTU." path="/networks" columns={[{ key: "name", label: "Name" }, { key: "kind", label: "Kind" }, { key: "vlan_id", label: "VLAN" }, { key: "gateway", label: "Gateway" }, { key: "mtu", label: "MTU" }]} />} />
<Route path="ipam" element={<ListPage title="IPAM" subtitle="Subnets and tracked IP address states." path="/ipam/addresses" columns={[{ key: "address", label: "Address" }, { key: "status", label: "Status" }, { key: "note", label: "Note" }]} />} />
<Route path="tenants" element={<ListPage title="Tenants" subtitle="Tenant and project boundaries for RBAC and policies." path="/tenants" columns={[{ key: "name", label: "Name" }, { key: "description", label: "Description" }]} />} />
<Route path="security-groups" element={<ListPage title="Security Groups" subtitle="Logical targets for microsegmentation rules." path="/security-groups" columns={[{ key: "name", label: "Name" }, { key: "description", label: "Description" }]} />} />
<Route path="policies" element={<ListPage title="Policies" subtitle="Versioned policy definitions and compile state." path="/policies" columns={[{ key: "name", label: "Name" }, { key: "version", label: "Version" }, { key: "enabled", label: "Enabled" }]} />} />
<Route path="designer" element={<PolicyDesigner />} />
<Route path="firewall" element={<FirewallPreview />} />
<Route path="jobs" element={<ListPage title="Jobs" subtitle="Background task state and execution logs." path="/jobs" columns={[{ key: "kind", label: "Kind" }, { key: "status", label: "Status" }, { key: "progress", label: "Progress" }]} />} />
<Route path="audit" element={<ListPage title="Audit Logs" subtitle="Security-relevant activity and change history." path="/audit" columns={[{ key: "created_at", label: "Time" }, { key: "action", label: "Action" }, { key: "object_type", label: "Object" }, { key: "result", label: "Result" }]} />} />
<Route path="users" element={<ListPage title="Users" subtitle="Local users, roles, and access state." path="/users" columns={[{ key: "email", label: "Email" }, { key: "display_name", label: "Name" }, { key: "is_active", label: "Active" }]} />} />
<Route path="settings" element={<ListPage title="Settings" subtitle="Runtime settings and safety defaults." path="/settings" columns={[{ key: "product", label: "Product" }, { key: "firewall_apply_requires_preview", label: "Preview Required" }, { key: "agent_optional", label: "Agent Optional" }]} />} />
</Route>
</Routes>
</BrowserRouter>
</QueryClientProvider>
);
}
+79
View File
@@ -0,0 +1,79 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "/api/v1";
export type Dashboard = {
clusters: number;
nodes: number;
workloads: number;
networks: number;
open_policy_violations: number;
faulty_nodes: Array<{ id: string; name: string; status: string }>;
top_talkers: Array<{ name: string; bytes: number }>;
};
export type Cluster = {
id: string;
name: string;
api_url: string;
provider: string;
mode: string;
last_sync_status: string | null;
};
export type Network = {
id: string;
name: string;
kind: string;
vlan_id: number | null;
gateway: string | null;
mtu: number;
tags: string[];
};
export type Policy = {
id: string;
name: string;
version: number;
enabled: boolean;
definition: Record<string, unknown>;
};
export type AuditLog = {
id: string;
created_at: string;
action: string;
object_type: string;
result: string;
};
export function token() {
return localStorage.getItem("nexafabric.token");
}
export function setToken(value: string) {
localStorage.setItem("nexafabric.token", value);
}
export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, {
...init,
headers: {
"Content-Type": "application/json",
...(token() ? { Authorization: `Bearer ${token()}` } : {}),
...init.headers,
},
});
if (!response.ok) {
throw new Error(await response.text());
}
return response.json() as Promise<T>;
}
export async function login(email: string, password: string) {
const data = await api<{ access_token: string }>("/auth/login", {
method: "POST",
body: JSON.stringify({ email, password }),
});
setToken(data.access_token);
return data;
}
+37
View File
@@ -0,0 +1,37 @@
type DataTableProps<T extends Record<string, unknown>> = {
columns: Array<{ key: keyof T; label: string; render?: (row: T) => string }>;
rows: T[];
};
export function DataTable<T extends Record<string, unknown>>({ columns, rows }: DataTableProps<T>) {
return (
<div className="overflow-hidden rounded-md border border-border bg-panel">
<div className="overflow-x-auto">
<table className="min-w-full text-left text-sm">
<thead className="border-b border-border text-xs uppercase text-slate-500 dark:text-slate-400">
<tr>
{columns.map((column) => (
<th key={String(column.key)} className="px-4 py-3 font-medium">
{column.label}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, index) => (
<tr key={String(row.id ?? index)} className="border-b border-border last:border-0">
{columns.map((column) => (
<td key={String(column.key)} className="px-4 py-3">
{column.render ? column.render(row) : String(row[column.key] ?? "")}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{rows.length === 0 ? <div className="p-8 text-center text-sm text-slate-500">No records found.</div> : null}
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
import { NavLink, Outlet, useNavigate } from "react-router-dom";
import {
Activity,
Blocks,
BookOpen,
BriefcaseBusiness,
ClipboardList,
Database,
Flame,
GitBranch,
LayoutDashboard,
LockKeyhole,
Moon,
Network,
Server,
Settings,
Shield,
Sun,
Users,
} from "lucide-react";
import { useEffect } from "react";
import { token } from "../api/client";
import { useTheme } from "../stores/theme";
const nav = [
{ to: "/", label: "Dashboard", icon: LayoutDashboard },
{ to: "/clusters", label: "Clusters", icon: Server },
{ to: "/nodes", label: "Nodes", icon: Activity },
{ to: "/workloads", label: "VMs/LXCs", icon: Blocks },
{ to: "/networks", label: "Networks", icon: Network },
{ to: "/ipam", label: "IPAM", icon: Database },
{ to: "/tenants", label: "Tenants", icon: BriefcaseBusiness },
{ to: "/security-groups", label: "Security Groups", icon: Shield },
{ to: "/policies", label: "Policies", icon: GitBranch },
{ to: "/designer", label: "Policy Designer", icon: LockKeyhole },
{ to: "/firewall", label: "Firewall Preview", icon: Flame },
{ to: "/jobs", label: "Jobs", icon: ClipboardList },
{ to: "/audit", label: "Audit Logs", icon: BookOpen },
{ to: "/users", label: "Users", icon: Users },
{ to: "/settings", label: "Settings", icon: Settings },
];
export function Layout() {
const navigate = useNavigate();
const { dark, toggle } = useTheme();
useEffect(() => {
document.documentElement.classList.toggle("dark", dark);
}, [dark]);
useEffect(() => {
if (!token()) navigate("/login");
}, [navigate]);
return (
<div className="min-h-screen bg-canvas text-slate-900 dark:text-slate-100">
<aside className="fixed inset-y-0 left-0 hidden w-64 border-r border-border bg-panel md:block">
<div className="flex h-16 items-center border-b border-border px-5">
<div>
<div className="text-lg font-semibold">NexaFabric</div>
<div className="text-xs text-slate-500 dark:text-slate-400">Control Plane</div>
</div>
</div>
<nav className="h-[calc(100vh-4rem)] overflow-y-auto p-3">
{nav.map((item) => {
const Icon = item.icon;
return (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
`mb-1 flex h-10 items-center gap-3 rounded-md px-3 text-sm ${
isActive ? "bg-accent text-white" : "text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
}`
}
>
<Icon size={18} />
<span>{item.label}</span>
</NavLink>
);
})}
</nav>
</aside>
<main className="md:pl-64">
<header className="sticky top-0 z-10 flex h-16 items-center justify-between border-b border-border bg-panel px-4 md:px-6">
<div className="text-sm text-slate-500 dark:text-slate-400">SDN-like network and security operations</div>
<button className="rounded-md border border-border p-2 hover:bg-slate-100 dark:hover:bg-slate-800" onClick={toggle} aria-label="Toggle theme">
{dark ? <Sun size={18} /> : <Moon size={18} />}
</button>
</header>
<div className="p-4 md:p-6">
<Outlet />
</div>
</main>
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
type PageHeaderProps = {
title: string;
subtitle?: string;
};
export function PageHeader({ title, subtitle }: PageHeaderProps) {
return (
<div className="mb-5">
<h1 className="text-2xl font-semibold tracking-normal">{title}</h1>
{subtitle ? <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{subtitle}</p> : null}
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./App";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
+58
View File
@@ -0,0 +1,58 @@
import { useQuery } from "@tanstack/react-query";
import { AlertTriangle, Boxes, Network, Server, ShieldAlert } from "lucide-react";
import { api, Dashboard as DashboardData } from "../api/client";
import { PageHeader } from "../components/PageHeader";
const cards = [
["clusters", "Clusters", Server],
["nodes", "Nodes", Boxes],
["workloads", "VMs/LXCs", Network],
["networks", "Networks", Network],
["open_policy_violations", "Policy Violations", ShieldAlert],
] as const;
export function Dashboard() {
const { data } = useQuery({ queryKey: ["dashboard"], queryFn: () => api<DashboardData>("/dashboard") });
return (
<>
<PageHeader title="Dashboard" subtitle="Operational overview across clusters, networks, policies, and sync health." />
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
{cards.map(([key, label, Icon]) => (
<div key={key} className="rounded-md border border-border bg-panel p-4">
<div className="mb-3 flex items-center justify-between text-slate-500">
<span className="text-sm">{label}</span>
<Icon size={18} />
</div>
<div className="text-3xl font-semibold">{data?.[key] ?? 0}</div>
</div>
))}
</div>
<div className="mt-6 grid gap-4 lg:grid-cols-2">
<section className="rounded-md border border-border bg-panel p-4">
<div className="mb-3 flex items-center gap-2 font-medium">
<AlertTriangle size={18} />
Faulty Nodes
</div>
{(data?.faulty_nodes ?? []).map((node) => (
<div key={node.id} className="flex justify-between border-t border-border py-3 text-sm">
<span>{node.name}</span>
<span className="text-danger">{node.status}</span>
</div>
))}
</section>
<section className="rounded-md border border-border bg-panel p-4">
<div className="mb-3 font-medium">Top Talkers</div>
{(data?.top_talkers ?? []).map((item) => (
<div key={item.name} className="flex justify-between border-t border-border py-3 text-sm">
<span>{item.name}</span>
<span>{Math.round(item.bytes / 1_000_000)} MB</span>
</div>
))}
</section>
</div>
</>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { Play } from "lucide-react";
import { api, Policy } from "../api/client";
import { PageHeader } from "../components/PageHeader";
export function FirewallPreview() {
const policies = useQuery({ queryKey: ["policies"], queryFn: () => api<Policy[]>("/policies") });
const preview = useMutation({
mutationFn: (policyId: string) => api<Record<string, unknown>>(`/firewall/preview/${policyId}`, { method: "POST" }),
});
const firstPolicy = policies.data?.[0];
return (
<>
<PageHeader title="Firewall Preview" subtitle="Compile policies into provider-specific rules before any apply operation." />
<div className="rounded-md border border-border bg-panel p-4">
<button
className="inline-flex h-10 items-center gap-2 rounded-md bg-accent px-4 text-sm text-white disabled:opacity-50"
disabled={!firstPolicy}
onClick={() => firstPolicy && preview.mutate(firstPolicy.id)}
>
<Play size={18} />
Generate Preview
</button>
<pre className="mt-4 max-h-[520px] overflow-auto rounded-md border border-border bg-canvas p-4 text-xs">
{preview.data ? JSON.stringify(preview.data, null, 2) : "No preview generated yet."}
</pre>
</div>
</>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { useQuery } from "@tanstack/react-query";
import { api } from "../api/client";
import { DataTable } from "../components/DataTable";
import { PageHeader } from "../components/PageHeader";
type Props = {
title: string;
subtitle: string;
path: string;
columns: Array<{ key: string; label: string }>;
};
export function ListPage({ title, subtitle, path, columns }: Props) {
const { data, isLoading, error } = useQuery({ queryKey: [path], queryFn: () => api<Record<string, unknown>[] | Record<string, unknown>>(path) });
const rows = Array.isArray(data) ? data : data ? [data] : [];
return (
<>
<PageHeader title={title} subtitle={subtitle} />
{isLoading ? <div className="rounded-md border border-border bg-panel p-8 text-sm">Loading...</div> : null}
{error ? <div className="rounded-md border border-danger p-4 text-sm text-danger">Failed to load data.</div> : null}
{!isLoading && !error ? <DataTable columns={columns as never} rows={rows} /> : null}
</>
);
}
+50
View File
@@ -0,0 +1,50 @@
import { FormEvent, useState } from "react";
import { useNavigate } from "react-router-dom";
import { ShieldCheck } from "lucide-react";
import { login } from "../api/client";
export function Login() {
const navigate = useNavigate();
const [email, setEmail] = useState("admin@nexafabric.local");
const [password, setPassword] = useState("ChangeMe_UseEnvInstead");
const [error, setError] = useState("");
async function submit(event: FormEvent) {
event.preventDefault();
setError("");
try {
await login(email, password);
navigate("/");
} catch {
setError("Login failed.");
}
}
return (
<div className="grid min-h-screen place-items-center bg-canvas px-4">
<form onSubmit={submit} className="w-full max-w-sm rounded-md border border-border bg-panel p-6 shadow-sm">
<div className="mb-6 flex items-center gap-3">
<div className="rounded-md bg-accent p-2 text-white">
<ShieldCheck size={22} />
</div>
<div>
<h1 className="text-xl font-semibold">NexaFabric</h1>
<p className="text-sm text-slate-500">Sign in to the control plane</p>
</div>
</div>
<label className="mb-4 block text-sm">
Email
<input className="mt-1 w-full rounded-md border border-border bg-transparent px-3 py-2" value={email} onChange={(event) => setEmail(event.target.value)} />
</label>
<label className="mb-4 block text-sm">
Password
<input className="mt-1 w-full rounded-md border border-border bg-transparent px-3 py-2" type="password" value={password} onChange={(event) => setPassword(event.target.value)} />
</label>
{error ? <div className="mb-4 rounded-md border border-danger px-3 py-2 text-sm text-danger">{error}</div> : null}
<button className="h-10 w-full rounded-md bg-accent text-sm font-medium text-white">Sign In</button>
</form>
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { Save, Wand2 } from "lucide-react";
import { PageHeader } from "../components/PageHeader";
export function PolicyDesigner() {
return (
<>
<PageHeader title="Policy Designer" subtitle="Build tenant-aware microsegmentation policies and inspect their intended impact." />
<div className="grid gap-4 lg:grid-cols-[1fr_360px]">
<section className="rounded-md border border-border bg-panel p-4">
<div className="grid gap-4 md:grid-cols-2">
{["Source", "Destination", "Service", "Action", "Direction", "Logging"].map((label) => (
<label key={label} className="text-sm">
{label}
<select className="mt-1 h-10 w-full rounded-md border border-border bg-transparent px-3">
<option>{label === "Action" ? "allow" : label === "Direction" ? "ingress" : "Any"}</option>
</select>
</label>
))}
</div>
<label className="mt-4 block text-sm">
Description
<input className="mt-1 h-10 w-full rounded-md border border-border bg-transparent px-3" placeholder="Policy intent" />
</label>
<div className="mt-5 flex gap-3">
<button className="inline-flex h-10 items-center gap-2 rounded-md border border-border px-4 text-sm">
<Wand2 size={18} />
Dry Run
</button>
<button className="inline-flex h-10 items-center gap-2 rounded-md bg-accent px-4 text-sm text-white">
<Save size={18} />
Save
</button>
</div>
</section>
<aside className="rounded-md border border-border bg-panel p-4">
<div className="mb-3 font-medium">Impact Preview</div>
<div className="space-y-3 text-sm text-slate-600 dark:text-slate-300">
<div className="rounded-md border border-border p-3">Affected VMs: calculated after dry run</div>
<div className="rounded-md border border-border p-3">Conflicts: none detected in draft</div>
<div className="rounded-md border border-border p-3">Generated rules: preview required before apply</div>
</div>
</aside>
</div>
</>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { create } from "zustand";
type ThemeState = {
dark: boolean;
toggle: () => void;
};
export const useTheme = create<ThemeState>((set) => ({
dark: localStorage.getItem("nexafabric.theme") === "dark",
toggle: () =>
set((state) => {
const dark = !state.dark;
localStorage.setItem("nexafabric.theme", dark ? "dark" : "light");
return { dark };
}),
}));
+39
View File
@@ -0,0 +1,39 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--color-canvas: 247 248 250;
--color-panel: 255 255 255;
--color-border: 214 219 226;
--color-accent: 20 132 122;
--color-danger: 205 54 65;
color-scheme: light;
}
.dark {
--color-canvas: 18 22 28;
--color-panel: 28 34 43;
--color-border: 63 72 86;
--color-accent: 61 185 171;
--color-danger: 239 92 101;
color-scheme: dark;
}
body {
margin: 0;
background: rgb(var(--color-canvas));
color: #18202a;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.dark body {
color: #edf2f7;
}
button,
input,
select {
font: inherit;
}
+19
View File
@@ -0,0 +1,19 @@
import type { Config } from "tailwindcss";
export default {
darkMode: "class",
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
canvas: "rgb(var(--color-canvas) / <alpha-value>)",
panel: "rgb(var(--color-panel) / <alpha-value>)",
border: "rgb(var(--color-border) / <alpha-value>)",
accent: "rgb(var(--color-accent) / <alpha-value>)",
danger: "rgb(var(--color-danger) / <alpha-value>)",
},
},
},
plugins: [],
} satisfies Config;
+17
View File
@@ -0,0 +1,17 @@
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { describe, expect, it } from "vitest";
import { Login } from "../src/pages/Login";
describe("Login", () => {
it("renders the product sign-in form", () => {
render(
<MemoryRouter>
<Login />
</MemoryRouter>,
);
expect(screen.getByText("NexaFabric")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Sign In" })).toBeInTheDocument();
});
});
+7
View File
@@ -0,0 +1,7 @@
import { expect, test } from "@playwright/test";
test("login screen is available", async ({ page }) => {
await page.goto("/login");
await expect(page.getByText("NexaFabric")).toBeVisible();
});
+2
View File
@@ -0,0 +1,2 @@
import "@testing-library/jest-dom/vitest";
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src", "tests"],
"references": []
}
+17
View File
@@ -0,0 +1,17 @@
/// <reference types="vitest" />
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/api": "http://localhost:8000",
},
},
test: {
environment: "jsdom",
setupFiles: "./tests/setup.ts",
},
});
+22
View File
@@ -0,0 +1,22 @@
server {
listen 80;
server_name _;
location /api/ {
proxy_pass http://api:8000/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /healthz {
proxy_pass http://api:8000/healthz;
}
location / {
proxy_pass http://frontend:80;
proxy_set_header Host $host;
}
}
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env sh
set -eu
mkdir -p backups
docker compose exec -T postgres pg_dump -U nexafabric nexafabric >"backups/nexafabric-$(date +%Y%m%d-%H%M%S).sql"
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env sh
set -eu
install -d /usr/local/lib/nexafabric /usr/local/bin /etc/nexafabric
install -m 0755 agent/nexafabric_agent.py /usr/local/lib/nexafabric/nexafabric_agent.py
cat >/usr/local/bin/nexafabric-agent <<'EOF'
#!/usr/bin/env sh
exec python3 /usr/local/lib/nexafabric/nexafabric_agent.py
EOF
chmod 0755 /usr/local/bin/nexafabric-agent
install -m 0644 deploy/systemd/nexafabric-agent.service /etc/systemd/system/nexafabric-agent.service
systemctl daemon-reload
systemctl enable --now nexafabric-agent.service
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env sh
set -eu
if [ $# -ne 1 ]; then
echo "Usage: scripts/restore.sh backups/file.sql" >&2
exit 1
fi
docker compose exec -T postgres psql -U nexafabric nexafabric <"$1"
+6
View File
@@ -0,0 +1,6 @@
# Test Strategy
- Backend unit and API integration tests live in `backend/tests`.
- Frontend component tests and Playwright E2E tests live in `frontend/tests`.
- CI runs backend lint/tests, frontend tests/typecheck/build, Docker builds, security scan, and OpenAPI artifact generation.