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
This commit is contained in:
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|
||||||
@@ -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()
|
||||||
|
|
||||||
@@ -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"]
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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()
|
||||||
|
|
||||||
@@ -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"}
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""NexaFabric backend package."""
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""API package."""
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""API v1 package."""
|
||||||
|
|
||||||
@@ -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()}
|
||||||
|
|
||||||
@@ -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}
|
||||||
@@ -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()
|
||||||
@@ -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"])
|
||||||
|
|
||||||
@@ -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()
|
||||||
|
|
||||||
@@ -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()
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
from app.models.domain import * # noqa: F403
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
from app.schemas.domain import * # noqa: F403
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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"],
|
||||||
|
)
|
||||||
|
|
||||||
@@ -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": []}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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}
|
||||||
@@ -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]
|
||||||
@@ -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()
|
||||||
|
|
||||||
@@ -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 = ["."]
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
:80 {
|
||||||
|
route /api/* {
|
||||||
|
reverse_proxy api:8000
|
||||||
|
}
|
||||||
|
|
||||||
|
reverse_proxy frontend:80
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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:
|
||||||
|
|
||||||
@@ -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:
|
||||||
|
|
||||||
@@ -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"
|
||||||
|
```
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|
||||||
@@ -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`.
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"] } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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>,
|
||||||
|
);
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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 };
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
@@ -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": []
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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"
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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"
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|
||||||
Reference in New Issue
Block a user