commit 14e77101201076f018b4284d1956b07419291a5b Author: nessi Date: Thu Jul 9 12:10:35 2026 +0200 chore: initial project setup with backend, frontend, CI/CD, and documentation 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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..51e7b0c --- /dev/null +++ b/.env.example @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bb14d8b --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 + diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..f00707c --- /dev/null +++ b/.gitlab-ci.yml @@ -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 + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..64d2946 --- /dev/null +++ b/LICENSE @@ -0,0 +1,12 @@ +GNU AFFERO GENERAL PUBLIC LICENSE +Version 3, 19 November 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. +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 + diff --git a/README.md b/README.md new file mode 100644 index 0000000..a2e035b --- /dev/null +++ b/README.md @@ -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. + diff --git a/agent/README.md b/agent/README.md new file mode 100644 index 0000000..f06587d --- /dev/null +++ b/agent/README.md @@ -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. + diff --git a/agent/nexafabric_agent.py b/agent/nexafabric_agent.py new file mode 100644 index 0000000..be0440a --- /dev/null +++ b/agent/nexafabric_agent.py @@ -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() + diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..2e7129d --- /dev/null +++ b/backend/Dockerfile @@ -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"] + diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..d82c04b --- /dev/null +++ b/backend/alembic.ini @@ -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 + diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..5fb8503 --- /dev/null +++ b/backend/alembic/env.py @@ -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() + diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..3a525d6 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -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"} + diff --git a/backend/alembic/versions/.gitkeep b/backend/alembic/versions/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/alembic/versions/.gitkeep @@ -0,0 +1 @@ + diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..cfb9dae --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1,2 @@ +"""NexaFabric backend package.""" + diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..b05dfd6 --- /dev/null +++ b/backend/app/api/__init__.py @@ -0,0 +1,2 @@ +"""API package.""" + diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py new file mode 100644 index 0000000..d8c1a33 --- /dev/null +++ b/backend/app/api/deps.py @@ -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 + diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py new file mode 100644 index 0000000..fc0ec7f --- /dev/null +++ b/backend/app/api/v1/__init__.py @@ -0,0 +1,2 @@ +"""API v1 package.""" + diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py new file mode 100644 index 0000000..e34ee71 --- /dev/null +++ b/backend/app/api/v1/auth.py @@ -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()} + diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py new file mode 100644 index 0000000..7ef560c --- /dev/null +++ b/backend/app/api/v1/router.py @@ -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} diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..166ee94 --- /dev/null +++ b/backend/app/core/config.py @@ -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() diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..66a89e2 --- /dev/null +++ b/backend/app/core/security.py @@ -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"]) + diff --git a/backend/app/db/session.py b/backend/app/db/session.py new file mode 100644 index 0000000..0434f8b --- /dev/null +++ b/backend/app/db/session.py @@ -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() + diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..2a47687 --- /dev/null +++ b/backend/app/main.py @@ -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() + diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..5818c11 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,2 @@ +from app.models.domain import * # noqa: F403 + diff --git a/backend/app/models/domain.py b/backend/app/models/domain.py new file mode 100644 index 0000000..a075a97 --- /dev/null +++ b/backend/app/models/domain.py @@ -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) + diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..eb3b5af --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -0,0 +1,2 @@ +from app.schemas.domain import * # noqa: F403 + diff --git a/backend/app/schemas/domain.py b/backend/app/schemas/domain.py new file mode 100644 index 0000000..e5762a1 --- /dev/null +++ b/backend/app/schemas/domain.py @@ -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 diff --git a/backend/app/seed/demo.py b/backend/app/seed/demo.py new file mode 100644 index 0000000..d85a1af --- /dev/null +++ b/backend/app/seed/demo.py @@ -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() diff --git a/backend/app/services/audit.py b/backend/app/services/audit.py new file mode 100644 index 0000000..627d8e0 --- /dev/null +++ b/backend/app/services/audit.py @@ -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 + diff --git a/backend/app/services/firewall_orchestrator.py b/backend/app/services/firewall_orchestrator.py new file mode 100644 index 0000000..b02cf38 --- /dev/null +++ b/backend/app/services/firewall_orchestrator.py @@ -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"], + ) + diff --git a/backend/app/services/policy_engine.py b/backend/app/services/policy_engine.py new file mode 100644 index 0000000..7b442be --- /dev/null +++ b/backend/app/services/policy_engine.py @@ -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": []} + diff --git a/backend/app/services/providers/base.py b/backend/app/services/providers/base.py new file mode 100644 index 0000000..a490eac --- /dev/null +++ b/backend/app/services/providers/base.py @@ -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 + diff --git a/backend/app/services/providers/demo.py b/backend/app/services/providers/demo.py new file mode 100644 index 0000000..2dc7eb2 --- /dev/null +++ b/backend/app/services/providers/demo.py @@ -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} diff --git a/backend/app/services/providers/proxmox.py b/backend/app/services/providers/proxmox.py new file mode 100644 index 0000000..0db1935 --- /dev/null +++ b/backend/app/services/providers/proxmox.py @@ -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} + diff --git a/backend/app/services/providers/registry.py b/backend/app/services/providers/registry.py new file mode 100644 index 0000000..d079620 --- /dev/null +++ b/backend/app/services/providers/registry.py @@ -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] diff --git a/backend/app/workers/worker.py b/backend/app/workers/worker.py new file mode 100644 index 0000000..4b62f2c --- /dev/null +++ b/backend/app/workers/worker.py @@ -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() + diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..a47befe --- /dev/null +++ b/backend/pyproject.toml @@ -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 = ["."] diff --git a/backend/tests/test_app.py b/backend/tests/test_app.py new file mode 100644 index 0000000..e088957 --- /dev/null +++ b/backend/tests/test_app.py @@ -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 + diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile new file mode 100644 index 0000000..26f1327 --- /dev/null +++ b/deploy/caddy/Caddyfile @@ -0,0 +1,8 @@ +:80 { + route /api/* { + reverse_proxy api:8000 + } + + reverse_proxy frontend:80 +} + diff --git a/deploy/systemd/nexafabric-agent.service b/deploy/systemd/nexafabric-agent.service new file mode 100644 index 0000000..8277aec --- /dev/null +++ b/deploy/systemd/nexafabric-agent.service @@ -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 + diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..68d66ff --- /dev/null +++ b/docker-compose.prod.yml @@ -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: + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..cb4b018 --- /dev/null +++ b/docker-compose.yml @@ -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: + diff --git a/docs/api-examples.md b/docs/api-examples.md new file mode 100644 index 0000000..7ac506c --- /dev/null +++ b/docs/api-examples.md @@ -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" +``` + diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..860da04 --- /dev/null +++ b/docs/architecture.md @@ -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. + diff --git a/docs/backup-restore.md b/docs/backup-restore.md new file mode 100644 index 0000000..159019d --- /dev/null +++ b/docs/backup-restore.md @@ -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. + diff --git a/docs/developer-guide.md b/docs/developer-guide.md new file mode 100644 index 0000000..d06f30b --- /dev/null +++ b/docs/developer-guide.md @@ -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`. + diff --git a/docs/firewall-orchestration.md b/docs/firewall-orchestration.md new file mode 100644 index 0000000..d8ab33c --- /dev/null +++ b/docs/firewall-orchestration.md @@ -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. + diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..e935631 --- /dev/null +++ b/docs/installation.md @@ -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. + diff --git a/docs/ipam.md b/docs/ipam.md new file mode 100644 index 0000000..79ea194 --- /dev/null +++ b/docs/ipam.md @@ -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. + diff --git a/docs/provider-concept.md b/docs/provider-concept.md new file mode 100644 index 0000000..3e7e1c1 --- /dev/null +++ b/docs/provider-concept.md @@ -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. + diff --git a/docs/rbac.md b/docs/rbac.md new file mode 100644 index 0000000..97d78fe --- /dev/null +++ b/docs/rbac.md @@ -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. + diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..bbb523a --- /dev/null +++ b/docs/security.md @@ -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. + diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..85e9101 --- /dev/null +++ b/docs/troubleshooting.md @@ -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. + diff --git a/docs/upgrade.md b/docs/upgrade.md new file mode 100644 index 0000000..3615d96 --- /dev/null +++ b/docs/upgrade.md @@ -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. + diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..1a7b36a --- /dev/null +++ b/frontend/Dockerfile @@ -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 + diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..55940f8 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + NexaFabric + + +
+ + + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..9e90a4f --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,10 @@ +server { + listen 80; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri /index.html; + } +} + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..56501c7 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..89dfb13 --- /dev/null +++ b/frontend/playwright.config.ts @@ -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"] } }, + ], +}); + diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..1d92651 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,7 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..6cb5464 --- /dev/null +++ b/frontend/src/App.tsx @@ -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 ( + + + + } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + ); +} + diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..42f104c --- /dev/null +++ b/frontend/src/api/client.ts @@ -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; +}; + +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(path: string, init: RequestInit = {}): Promise { + 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; +} + +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; +} + diff --git a/frontend/src/components/DataTable.tsx b/frontend/src/components/DataTable.tsx new file mode 100644 index 0000000..b816192 --- /dev/null +++ b/frontend/src/components/DataTable.tsx @@ -0,0 +1,37 @@ +type DataTableProps> = { + columns: Array<{ key: keyof T; label: string; render?: (row: T) => string }>; + rows: T[]; +}; + +export function DataTable>({ columns, rows }: DataTableProps) { + return ( +
+
+ + + + {columns.map((column) => ( + + ))} + + + + {rows.map((row, index) => ( + + {columns.map((column) => ( + + ))} + + ))} + +
+ {column.label} +
+ {column.render ? column.render(row) : String(row[column.key] ?? "")} +
+
+ {rows.length === 0 ?
No records found.
: null} +
+ ); +} + diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..f1f038f --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -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 ( +
+ +
+
+
SDN-like network and security operations
+ +
+
+ +
+
+
+ ); +} + diff --git a/frontend/src/components/PageHeader.tsx b/frontend/src/components/PageHeader.tsx new file mode 100644 index 0000000..25f39af --- /dev/null +++ b/frontend/src/components/PageHeader.tsx @@ -0,0 +1,14 @@ +type PageHeaderProps = { + title: string; + subtitle?: string; +}; + +export function PageHeader({ title, subtitle }: PageHeaderProps) { + return ( +
+

{title}

+ {subtitle ?

{subtitle}

: null} +
+ ); +} + diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..db2cb03 --- /dev/null +++ b/frontend/src/main.tsx @@ -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( + + + , +); + diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..ad37431 --- /dev/null +++ b/frontend/src/pages/Dashboard.tsx @@ -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("/dashboard") }); + + return ( + <> + +
+ {cards.map(([key, label, Icon]) => ( +
+
+ {label} + +
+
{data?.[key] ?? 0}
+
+ ))} +
+
+
+
+ + Faulty Nodes +
+ {(data?.faulty_nodes ?? []).map((node) => ( +
+ {node.name} + {node.status} +
+ ))} +
+
+
Top Talkers
+ {(data?.top_talkers ?? []).map((item) => ( +
+ {item.name} + {Math.round(item.bytes / 1_000_000)} MB +
+ ))} +
+
+ + ); +} + diff --git a/frontend/src/pages/FirewallPreview.tsx b/frontend/src/pages/FirewallPreview.tsx new file mode 100644 index 0000000..3887236 --- /dev/null +++ b/frontend/src/pages/FirewallPreview.tsx @@ -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("/policies") }); + const preview = useMutation({ + mutationFn: (policyId: string) => api>(`/firewall/preview/${policyId}`, { method: "POST" }), + }); + const firstPolicy = policies.data?.[0]; + + return ( + <> + +
+ +
+          {preview.data ? JSON.stringify(preview.data, null, 2) : "No preview generated yet."}
+        
+
+ + ); +} + diff --git a/frontend/src/pages/ListPage.tsx b/frontend/src/pages/ListPage.tsx new file mode 100644 index 0000000..99ff3fd --- /dev/null +++ b/frontend/src/pages/ListPage.tsx @@ -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>(path) }); + const rows = Array.isArray(data) ? data : data ? [data] : []; + + return ( + <> + + {isLoading ?
Loading...
: null} + {error ?
Failed to load data.
: null} + {!isLoading && !error ? : null} + + ); +} diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx new file mode 100644 index 0000000..31e6029 --- /dev/null +++ b/frontend/src/pages/Login.tsx @@ -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 ( +
+
+
+
+ +
+
+

NexaFabric

+

Sign in to the control plane

+
+
+ + + {error ?
{error}
: null} + +
+
+ ); +} + diff --git a/frontend/src/pages/PolicyDesigner.tsx b/frontend/src/pages/PolicyDesigner.tsx new file mode 100644 index 0000000..b5d2545 --- /dev/null +++ b/frontend/src/pages/PolicyDesigner.tsx @@ -0,0 +1,48 @@ +import { Save, Wand2 } from "lucide-react"; + +import { PageHeader } from "../components/PageHeader"; + +export function PolicyDesigner() { + return ( + <> + +
+
+
+ {["Source", "Destination", "Service", "Action", "Direction", "Logging"].map((label) => ( + + ))} +
+ +
+ + +
+
+ +
+ + ); +} + diff --git a/frontend/src/stores/theme.ts b/frontend/src/stores/theme.ts new file mode 100644 index 0000000..31e3db0 --- /dev/null +++ b/frontend/src/stores/theme.ts @@ -0,0 +1,17 @@ +import { create } from "zustand"; + +type ThemeState = { + dark: boolean; + toggle: () => void; +}; + +export const useTheme = create((set) => ({ + dark: localStorage.getItem("nexafabric.theme") === "dark", + toggle: () => + set((state) => { + const dark = !state.dark; + localStorage.setItem("nexafabric.theme", dark ? "dark" : "light"); + return { dark }; + }), +})); + diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..bf99b71 --- /dev/null +++ b/frontend/src/styles.css @@ -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; +} + diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts new file mode 100644 index 0000000..d14fea5 --- /dev/null +++ b/frontend/tailwind.config.ts @@ -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) / )", + panel: "rgb(var(--color-panel) / )", + border: "rgb(var(--color-border) / )", + accent: "rgb(var(--color-accent) / )", + danger: "rgb(var(--color-danger) / )", + }, + }, + }, + plugins: [], +} satisfies Config; + diff --git a/frontend/tests/App.test.tsx b/frontend/tests/App.test.tsx new file mode 100644 index 0000000..48019a9 --- /dev/null +++ b/frontend/tests/App.test.tsx @@ -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( + + + , + ); + expect(screen.getByText("NexaFabric")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Sign In" })).toBeInTheDocument(); + }); +}); diff --git a/frontend/tests/e2e/login.spec.ts b/frontend/tests/e2e/login.spec.ts new file mode 100644 index 0000000..633f4c1 --- /dev/null +++ b/frontend/tests/e2e/login.spec.ts @@ -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(); +}); + diff --git a/frontend/tests/setup.ts b/frontend/tests/setup.ts new file mode 100644 index 0000000..a86a67e --- /dev/null +++ b/frontend/tests/setup.ts @@ -0,0 +1,2 @@ +import "@testing-library/jest-dom/vitest"; + diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..7335d26 --- /dev/null +++ b/frontend/tsconfig.json @@ -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": [] +} + diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..8b88827 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,17 @@ +/// +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", + }, +}); diff --git a/nginx/default.conf b/nginx/default.conf new file mode 100644 index 0000000..1f9747a --- /dev/null +++ b/nginx/default.conf @@ -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; + } +} + diff --git a/scripts/backup.sh b/scripts/backup.sh new file mode 100644 index 0000000..4132178 --- /dev/null +++ b/scripts/backup.sh @@ -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" + diff --git a/scripts/install-agent.sh b/scripts/install-agent.sh new file mode 100644 index 0000000..b50bf23 --- /dev/null +++ b/scripts/install-agent.sh @@ -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 + diff --git a/scripts/restore.sh b/scripts/restore.sh new file mode 100644 index 0000000..ba6dc60 --- /dev/null +++ b/scripts/restore.sh @@ -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" + diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..80dce3a --- /dev/null +++ b/tests/README.md @@ -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. +