First Build alpha 0.1

This commit is contained in:
2026-02-07 12:18:20 +01:00
parent 29e83436b2
commit 42a3cc9d9f
36 changed files with 4982 additions and 51 deletions

59
.gitignore vendored
View File

@@ -1,14 +1,63 @@
# ---> VisualStudioCode # Python
__pycache__/
*.py[cod]
*$py.class
*.so
*.egg-info/
dist/
build/
.eggs/
# Virtual environment
venv/
.venv/
env/
# IDE
.vscode/* .vscode/*
!.vscode/settings.json !.vscode/settings.json
!.vscode/tasks.json !.vscode/tasks.json
!.vscode/launch.json !.vscode/launch.json
!.vscode/extensions.json !.vscode/extensions.json
!.vscode/*.code-snippets !.vscode/*.code-snippets
.idea/
# Local History for Visual Studio Code *.swp
*.swo
*~
.history/ .history/
# Built Visual Studio Code Extensions
*.vsix *.vsix
# Environment & secrets
.env
.env.local
.env.production
# Database
*.db
*.sqlite
*.sqlite3
data/
# Logs
logs/
*.log
# Backups
backups/
# OS
.DS_Store
Thumbs.db
desktop.ini
# Docker
docker-compose.override.yml
# Build artifacts
*.tar.gz
*.zip
# Test
.pytest_cache/
.coverage
htmlcov/

34
Dockerfile Normal file
View File

@@ -0,0 +1,34 @@
FROM python:3.11-slim
LABEL maintainer="NetBird MSP Appliance"
LABEL description="Multi-tenant NetBird management platform"
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
# Set working directory
WORKDIR /app
# Copy requirements first for caching
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY app/ ./app/
COPY templates/ ./templates/
COPY static/ ./static/
# Create data directories
RUN mkdir -p /app/data /app/logs /app/backups
# Expose port
EXPOSE 8000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
CMD curl -f http://localhost:8000/api/health || exit 1
# Run the application
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--log-level", "info"]

1
app/__init__.py Normal file
View File

@@ -0,0 +1 @@
# NetBird MSP Appliance

57
app/database.py Normal file
View File

@@ -0,0 +1,57 @@
"""Database setup and session management for NetBird MSP Appliance."""
import os
import sys
from typing import Generator
from sqlalchemy import create_engine, event
from sqlalchemy.orm import Session, sessionmaker, declarative_base
DATABASE_PATH = os.environ.get("DATABASE_PATH", "/app/data/netbird_msp.db")
DATABASE_URL = f"sqlite:///{DATABASE_PATH}"
engine = create_engine(
DATABASE_URL,
connect_args={"check_same_thread": False},
echo=False,
)
# Enable WAL mode and foreign keys for SQLite
@event.listens_for(engine, "connect")
def _set_sqlite_pragma(dbapi_connection, connection_record) -> None:
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db() -> Generator[Session, None, None]:
"""Yield a database session, ensuring it is closed after use."""
db = SessionLocal()
try:
yield db
finally:
db.close()
def init_db() -> None:
"""Create all database tables."""
from app.models import ( # noqa: F401
Customer,
Deployment,
DeploymentLog,
SystemConfig,
User,
)
Base.metadata.create_all(bind=engine)
if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1] == "init":
init_db()
print("Database initialized successfully.")

77
app/dependencies.py Normal file
View File

@@ -0,0 +1,77 @@
"""FastAPI dependencies — JWT authentication, database session, rate limiting."""
from datetime import datetime, timedelta
from typing import Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import User
from app.utils.config import JWT_ALGORITHM, JWT_EXPIRE_MINUTES, SECRET_KEY
security_scheme = HTTPBearer(auto_error=False)
def create_access_token(username: str, expires_delta: Optional[timedelta] = None) -> str:
"""Create a JWT access token.
Args:
username: The user identity to encode.
expires_delta: Custom expiration; defaults to JWT_EXPIRE_MINUTES.
Returns:
Encoded JWT string.
"""
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=JWT_EXPIRE_MINUTES))
payload = {"sub": username, "exp": expire}
return jwt.encode(payload, SECRET_KEY, algorithm=JWT_ALGORITHM)
def get_current_user(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security_scheme),
db: Session = Depends(get_db),
) -> User:
"""Validate the JWT bearer token and return the authenticated user.
Args:
credentials: Bearer token from the Authorization header.
db: Database session.
Returns:
The authenticated User ORM object.
Raises:
HTTPException: If the token is missing, invalid, or the user is inactive.
"""
if credentials is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required.",
headers={"WWW-Authenticate": "Bearer"},
)
token = credentials.credentials
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALGORITHM])
username: Optional[str] = payload.get("sub")
if username is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token payload.",
)
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token.",
)
user = db.query(User).filter(User.username == username).first()
if user is None or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found or inactive.",
)
return user

90
app/main.py Normal file
View File

@@ -0,0 +1,90 @@
"""FastAPI entry point for NetBird MSP Appliance."""
import logging
import os
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from app.database import init_db
from app.routers import auth, customers, deployments, monitoring, settings
# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO").upper()
logging.basicConfig(
level=getattr(logging, LOG_LEVEL, logging.INFO),
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Application
# ---------------------------------------------------------------------------
app = FastAPI(
title="NetBird MSP Appliance",
description="Multi-tenant NetBird management platform for MSPs",
version="1.0.0",
docs_url="/api/docs",
redoc_url="/api/redoc",
openapi_url="/api/openapi.json",
)
# CORS — allow same-origin; adjust if needed
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ---------------------------------------------------------------------------
# Routers
# ---------------------------------------------------------------------------
app.include_router(auth.router, prefix="/api/auth", tags=["Authentication"])
app.include_router(settings.router, prefix="/api/settings", tags=["Settings"])
app.include_router(customers.router, prefix="/api/customers", tags=["Customers"])
app.include_router(deployments.router, prefix="/api/customers", tags=["Deployments"])
app.include_router(monitoring.router, prefix="/api/monitoring", tags=["Monitoring"])
# ---------------------------------------------------------------------------
# Static files — serve the frontend SPA
# ---------------------------------------------------------------------------
STATIC_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "static")
if os.path.isdir(STATIC_DIR):
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
# Serve index.html at root
from fastapi.responses import FileResponse
@app.get("/", include_in_schema=False)
async def serve_index():
"""Serve the main dashboard."""
index_path = os.path.join(STATIC_DIR, "index.html")
if os.path.isfile(index_path):
return FileResponse(index_path)
return JSONResponse({"message": "NetBird MSP Appliance API is running."})
# ---------------------------------------------------------------------------
# Health endpoint (unauthenticated)
# ---------------------------------------------------------------------------
@app.get("/api/health", tags=["Health"])
async def health_check():
"""Simple health check endpoint for Docker HEALTHCHECK."""
return {"status": "ok", "service": "netbird-msp-appliance"}
# ---------------------------------------------------------------------------
# Startup event
# ---------------------------------------------------------------------------
@app.on_event("startup")
async def startup_event():
"""Initialize database tables on startup."""
logger.info("Starting NetBird MSP Appliance...")
init_db()
logger.info("Database initialized.")

232
app/models.py Normal file
View File

@@ -0,0 +1,232 @@
"""SQLAlchemy ORM models for NetBird MSP Appliance."""
from datetime import datetime
from typing import Optional
from sqlalchemy import (
Boolean,
CheckConstraint,
DateTime,
ForeignKey,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Customer(Base):
"""Customer model representing an MSP client."""
__tablename__ = "customers"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
company: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
subdomain: Mapped[str] = mapped_column(String(63), unique=True, nullable=False)
email: Mapped[str] = mapped_column(String(255), nullable=False)
max_devices: Mapped[int] = mapped_column(Integer, default=20)
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(
String(20),
default="active",
nullable=False,
)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
)
__table_args__ = (
CheckConstraint(
"status IN ('active', 'inactive', 'deploying', 'error')",
name="ck_customer_status",
),
)
deployment: Mapped[Optional["Deployment"]] = relationship(
"Deployment", back_populates="customer", uselist=False, cascade="all, delete-orphan"
)
logs: Mapped[list["DeploymentLog"]] = relationship(
"DeploymentLog", back_populates="customer", cascade="all, delete-orphan"
)
def to_dict(self) -> dict:
"""Serialize customer to dictionary."""
return {
"id": self.id,
"name": self.name,
"company": self.company,
"subdomain": self.subdomain,
"email": self.email,
"max_devices": self.max_devices,
"notes": self.notes,
"status": self.status,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
class Deployment(Base):
"""Deployment model tracking a customer's NetBird instance."""
__tablename__ = "deployments"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
customer_id: Mapped[int] = mapped_column(
Integer, ForeignKey("customers.id", ondelete="CASCADE"), unique=True, nullable=False
)
container_prefix: Mapped[str] = mapped_column(String(100), nullable=False)
relay_udp_port: Mapped[int] = mapped_column(Integer, unique=True, nullable=False)
npm_proxy_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
relay_secret: Mapped[str] = mapped_column(Text, nullable=False)
setup_url: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
deployment_status: Mapped[str] = mapped_column(
String(20), default="pending", nullable=False
)
deployed_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
last_health_check: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
__table_args__ = (
CheckConstraint(
"deployment_status IN ('pending', 'running', 'stopped', 'failed')",
name="ck_deployment_status",
),
)
customer: Mapped["Customer"] = relationship("Customer", back_populates="deployment")
def to_dict(self) -> dict:
"""Serialize deployment to dictionary."""
return {
"id": self.id,
"customer_id": self.customer_id,
"container_prefix": self.container_prefix,
"relay_udp_port": self.relay_udp_port,
"npm_proxy_id": self.npm_proxy_id,
"relay_secret": "***", # Never expose secrets
"setup_url": self.setup_url,
"deployment_status": self.deployment_status,
"deployed_at": self.deployed_at.isoformat() if self.deployed_at else None,
"last_health_check": (
self.last_health_check.isoformat() if self.last_health_check else None
),
}
class SystemConfig(Base):
"""Singleton system configuration — always id=1."""
__tablename__ = "system_config"
id: Mapped[int] = mapped_column(
Integer, primary_key=True, default=1
)
base_domain: Mapped[str] = mapped_column(String(255), nullable=False)
admin_email: Mapped[str] = mapped_column(String(255), nullable=False)
npm_api_url: Mapped[str] = mapped_column(String(500), nullable=False)
npm_api_token_encrypted: Mapped[str] = mapped_column(Text, nullable=False)
netbird_management_image: Mapped[str] = mapped_column(
String(255), default="netbirdio/management:latest"
)
netbird_signal_image: Mapped[str] = mapped_column(
String(255), default="netbirdio/signal:latest"
)
netbird_relay_image: Mapped[str] = mapped_column(
String(255), default="netbirdio/relay:latest"
)
netbird_dashboard_image: Mapped[str] = mapped_column(
String(255), default="netbirdio/dashboard:latest"
)
data_dir: Mapped[str] = mapped_column(String(500), default="/opt/netbird-instances")
docker_network: Mapped[str] = mapped_column(String(100), default="npm-network")
relay_base_port: Mapped[int] = mapped_column(Integer, default=3478)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
)
__table_args__ = (
CheckConstraint("id = 1", name="ck_system_config_singleton"),
)
def to_dict(self) -> dict:
"""Serialize config to dictionary (token masked)."""
return {
"base_domain": self.base_domain,
"admin_email": self.admin_email,
"npm_api_url": self.npm_api_url,
"npm_api_token_set": bool(self.npm_api_token_encrypted),
"netbird_management_image": self.netbird_management_image,
"netbird_signal_image": self.netbird_signal_image,
"netbird_relay_image": self.netbird_relay_image,
"netbird_dashboard_image": self.netbird_dashboard_image,
"data_dir": self.data_dir,
"docker_network": self.docker_network,
"relay_base_port": self.relay_base_port,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
class DeploymentLog(Base):
"""Log entries for deployment actions."""
__tablename__ = "deployment_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
customer_id: Mapped[int] = mapped_column(
Integer, ForeignKey("customers.id", ondelete="CASCADE"), nullable=False
)
action: Mapped[str] = mapped_column(String(100), nullable=False)
status: Mapped[str] = mapped_column(String(20), nullable=False)
message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
details: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
__table_args__ = (
CheckConstraint(
"status IN ('success', 'error', 'info')",
name="ck_log_status",
),
)
customer: Mapped["Customer"] = relationship("Customer", back_populates="logs")
def to_dict(self) -> dict:
"""Serialize log entry to dictionary."""
return {
"id": self.id,
"customer_id": self.customer_id,
"action": self.action,
"status": self.status,
"message": self.message,
"details": self.details,
"created_at": self.created_at.isoformat() if self.created_at else None,
}
class User(Base):
"""Admin user model."""
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
username: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
def to_dict(self) -> dict:
"""Serialize user to dictionary (no password)."""
return {
"id": self.id,
"username": self.username,
"email": self.email,
"is_active": self.is_active,
"created_at": self.created_at.isoformat() if self.created_at else None,
}

0
app/routers/__init__.py Normal file
View File

97
app/routers/auth.py Normal file
View File

@@ -0,0 +1,97 @@
"""Authentication API endpoints — login, logout, current user, password change."""
import logging
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.database import get_db
from app.dependencies import create_access_token, get_current_user
from app.models import User
from app.utils.security import hash_password, verify_password
from app.utils.validators import ChangePasswordRequest, LoginRequest
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post("/login")
async def login(payload: LoginRequest, db: Session = Depends(get_db)):
"""Authenticate and return a JWT token.
Args:
payload: Username and password.
db: Database session.
Returns:
JSON with ``access_token`` and ``token_type``.
"""
user = db.query(User).filter(User.username == payload.username).first()
if not user or not verify_password(payload.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid username or password.",
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Account is disabled.",
)
token = create_access_token(user.username)
logger.info("User %s logged in.", user.username)
return {
"access_token": token,
"token_type": "bearer",
"user": user.to_dict(),
}
@router.post("/logout")
async def logout(current_user: User = Depends(get_current_user)):
"""Logout (client-side token discard).
Returns:
Confirmation message.
"""
logger.info("User %s logged out.", current_user.username)
return {"message": "Logged out successfully."}
@router.get("/me")
async def get_me(current_user: User = Depends(get_current_user)):
"""Return the current authenticated user's profile.
Returns:
User dict (no password hash).
"""
return current_user.to_dict()
@router.post("/change-password")
async def change_password(
payload: ChangePasswordRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Change the current user's password.
Args:
payload: Current and new password.
current_user: Authenticated user.
db: Database session.
Returns:
Confirmation message.
"""
if not verify_password(payload.current_password, current_user.password_hash):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Current password is incorrect.",
)
current_user.password_hash = hash_password(payload.new_password)
db.commit()
logger.info("Password changed for user %s.", current_user.username)
return {"message": "Password changed successfully."}

231
app/routers/customers.py Normal file
View File

@@ -0,0 +1,231 @@
"""Customer CRUD API endpoints with automatic deployment on create."""
import logging
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.database import get_db
from app.dependencies import get_current_user
from app.models import Customer, Deployment, DeploymentLog, User
from app.services import netbird_service
from app.utils.validators import CustomerCreate, CustomerUpdate
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_customer(
payload: CustomerCreate,
background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Create a new customer and trigger auto-deployment.
Validates that the subdomain is unique, creates the customer record,
and launches deployment in the background.
Args:
payload: Customer creation data.
background_tasks: FastAPI background task runner.
Returns:
Created customer dict with deployment status.
"""
# Check subdomain uniqueness
existing = db.query(Customer).filter(Customer.subdomain == payload.subdomain).first()
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Subdomain '{payload.subdomain}' is already in use.",
)
customer = Customer(
name=payload.name,
company=payload.company,
subdomain=payload.subdomain,
email=payload.email,
max_devices=payload.max_devices,
notes=payload.notes,
status="deploying",
)
db.add(customer)
db.commit()
db.refresh(customer)
logger.info("Customer %d (%s) created by %s.", customer.id, customer.subdomain, current_user.username)
# Deploy in background
result = await netbird_service.deploy_customer(db, customer.id)
response = customer.to_dict()
response["deployment"] = result
return response
@router.get("")
async def list_customers(
page: int = Query(default=1, ge=1),
per_page: int = Query(default=25, ge=1, le=100),
search: Optional[str] = Query(default=None),
status_filter: Optional[str] = Query(default=None, alias="status"),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""List customers with pagination, search, and status filter.
Args:
page: Page number (1-indexed).
per_page: Items per page.
search: Search in name, subdomain, email.
status_filter: Filter by status.
Returns:
Paginated customer list with metadata.
"""
query = db.query(Customer)
if search:
like_term = f"%{search}%"
query = query.filter(
(Customer.name.ilike(like_term))
| (Customer.subdomain.ilike(like_term))
| (Customer.email.ilike(like_term))
| (Customer.company.ilike(like_term))
)
if status_filter:
query = query.filter(Customer.status == status_filter)
total = query.count()
customers = (
query.order_by(Customer.created_at.desc())
.offset((page - 1) * per_page)
.limit(per_page)
.all()
)
items = []
for c in customers:
data = c.to_dict()
if c.deployment:
data["deployment"] = c.deployment.to_dict()
else:
data["deployment"] = None
items.append(data)
return {
"items": items,
"total": total,
"page": page,
"per_page": per_page,
"pages": (total + per_page - 1) // per_page if total > 0 else 1,
}
@router.get("/{customer_id}")
async def get_customer(
customer_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Get detailed customer information including deployment and logs.
Args:
customer_id: Customer ID.
Returns:
Customer dict with deployment info and recent logs.
"""
customer = db.query(Customer).filter(Customer.id == customer_id).first()
if not customer:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Customer not found.",
)
data = customer.to_dict()
data["deployment"] = customer.deployment.to_dict() if customer.deployment else None
data["logs"] = [
log.to_dict()
for log in db.query(DeploymentLog)
.filter(DeploymentLog.customer_id == customer_id)
.order_by(DeploymentLog.created_at.desc())
.limit(50)
.all()
]
return data
@router.put("/{customer_id}")
async def update_customer(
customer_id: int,
payload: CustomerUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Update customer information.
Args:
customer_id: Customer ID.
payload: Fields to update.
Returns:
Updated customer dict.
"""
customer = db.query(Customer).filter(Customer.id == customer_id).first()
if not customer:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Customer not found.",
)
update_data = payload.model_dump(exclude_none=True)
for field, value in update_data.items():
if hasattr(customer, field):
setattr(customer, field, value)
customer.updated_at = datetime.utcnow()
db.commit()
db.refresh(customer)
logger.info("Customer %d updated by %s.", customer_id, current_user.username)
return customer.to_dict()
@router.delete("/{customer_id}")
async def delete_customer(
customer_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Delete a customer and clean up all resources.
Removes containers, NPM proxy, instance directory, and database records.
Args:
customer_id: Customer ID.
Returns:
Confirmation message.
"""
customer = db.query(Customer).filter(Customer.id == customer_id).first()
if not customer:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Customer not found.",
)
# Undeploy first (containers, NPM, files)
await netbird_service.undeploy_customer(db, customer_id)
# Delete customer record (cascades to deployment + logs)
db.delete(customer)
db.commit()
logger.info("Customer %d deleted by %s.", customer_id, current_user.username)
return {"message": f"Customer {customer_id} deleted successfully."}

185
app/routers/deployments.py Normal file
View File

@@ -0,0 +1,185 @@
"""Deployment management API — start, stop, restart, logs, health for customers."""
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.database import get_db
from app.dependencies import get_current_user
from app.models import Customer, Deployment, User
from app.services import docker_service, netbird_service
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post("/{customer_id}/deploy")
async def manual_deploy(
customer_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Manually trigger deployment for a customer.
Use this to re-deploy a customer whose previous deployment failed.
Args:
customer_id: Customer ID.
Returns:
Deployment result dict.
"""
customer = db.query(Customer).filter(Customer.id == customer_id).first()
if not customer:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Customer not found.")
# Remove existing deployment if present
existing = db.query(Deployment).filter(Deployment.customer_id == customer_id).first()
if existing:
await netbird_service.undeploy_customer(db, customer_id)
result = await netbird_service.deploy_customer(db, customer_id)
if not result.get("success"):
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=result.get("error", "Deployment failed."),
)
return result
@router.post("/{customer_id}/start")
async def start_customer(
customer_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Start containers for a customer.
Args:
customer_id: Customer ID.
Returns:
Result dict.
"""
_require_customer(db, customer_id)
result = netbird_service.start_customer(db, customer_id)
if not result.get("success"):
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=result.get("error", "Failed to start containers."),
)
return result
@router.post("/{customer_id}/stop")
async def stop_customer(
customer_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Stop containers for a customer.
Args:
customer_id: Customer ID.
Returns:
Result dict.
"""
_require_customer(db, customer_id)
result = netbird_service.stop_customer(db, customer_id)
if not result.get("success"):
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=result.get("error", "Failed to stop containers."),
)
return result
@router.post("/{customer_id}/restart")
async def restart_customer(
customer_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Restart containers for a customer.
Args:
customer_id: Customer ID.
Returns:
Result dict.
"""
_require_customer(db, customer_id)
result = netbird_service.restart_customer(db, customer_id)
if not result.get("success"):
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=result.get("error", "Failed to restart containers."),
)
return result
@router.get("/{customer_id}/logs")
async def get_customer_logs(
customer_id: int,
tail: int = 200,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Get container logs for a customer.
Args:
customer_id: Customer ID.
tail: Number of log lines per container.
Returns:
Dict mapping container name to log text.
"""
_require_customer(db, customer_id)
deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first()
if not deployment:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No deployment found for this customer.",
)
logs = docker_service.get_all_container_logs(deployment.container_prefix, tail=tail)
return {"logs": logs}
@router.get("/{customer_id}/health")
async def check_customer_health(
customer_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Run a health check on a customer's deployment.
Args:
customer_id: Customer ID.
Returns:
Health check results.
"""
_require_customer(db, customer_id)
return netbird_service.get_customer_health(db, customer_id)
def _require_customer(db: Session, customer_id: int) -> Customer:
"""Helper to fetch a customer or raise 404.
Args:
db: Database session.
customer_id: Customer ID.
Returns:
Customer ORM object.
Raises:
HTTPException: If customer not found.
"""
customer = db.query(Customer).filter(Customer.id == customer_id).first()
if not customer:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Customer not found.")
return customer

116
app/routers/monitoring.py Normal file
View File

@@ -0,0 +1,116 @@
"""Monitoring API — system overview, customer statuses, host resources."""
import logging
import platform
from typing import Any
import psutil
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.database import get_db
from app.dependencies import get_current_user
from app.models import Customer, Deployment, User
from app.services import docker_service
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/status")
async def system_status(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict[str, Any]:
"""System overview with aggregated customer statistics.
Returns:
Counts by status and total customers.
"""
total = db.query(Customer).count()
active = db.query(Customer).filter(Customer.status == "active").count()
inactive = db.query(Customer).filter(Customer.status == "inactive").count()
deploying = db.query(Customer).filter(Customer.status == "deploying").count()
error = db.query(Customer).filter(Customer.status == "error").count()
return {
"total_customers": total,
"active": active,
"inactive": inactive,
"deploying": deploying,
"error": error,
}
@router.get("/customers")
async def all_customers_status(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> list[dict[str, Any]]:
"""Get deployment status for every customer.
Returns:
List of dicts with customer info and container statuses.
"""
customers = (
db.query(Customer)
.order_by(Customer.id)
.all()
)
results: list[dict[str, Any]] = []
for c in customers:
entry: dict[str, Any] = {
"id": c.id,
"name": c.name,
"subdomain": c.subdomain,
"status": c.status,
}
if c.deployment:
containers = docker_service.get_container_status(c.deployment.container_prefix)
entry["deployment_status"] = c.deployment.deployment_status
entry["containers"] = containers
entry["relay_udp_port"] = c.deployment.relay_udp_port
entry["setup_url"] = c.deployment.setup_url
else:
entry["deployment_status"] = None
entry["containers"] = []
results.append(entry)
return results
@router.get("/resources")
async def host_resources(
current_user: User = Depends(get_current_user),
) -> dict[str, Any]:
"""Return host system resource usage.
Returns:
CPU, memory, disk, and network information.
"""
cpu_percent = psutil.cpu_percent(interval=1)
cpu_count = psutil.cpu_count()
mem = psutil.virtual_memory()
disk = psutil.disk_usage("/")
return {
"hostname": platform.node(),
"os": f"{platform.system()} {platform.release()}",
"cpu": {
"percent": cpu_percent,
"count": cpu_count,
},
"memory": {
"total_gb": round(mem.total / (1024 ** 3), 1),
"used_gb": round(mem.used / (1024 ** 3), 1),
"available_gb": round(mem.available / (1024 ** 3), 1),
"percent": mem.percent,
},
"disk": {
"total_gb": round(disk.total / (1024 ** 3), 1),
"used_gb": round(disk.used / (1024 ** 3), 1),
"free_gb": round(disk.free / (1024 ** 3), 1),
"percent": disk.percent,
},
}

113
app/routers/settings.py Normal file
View File

@@ -0,0 +1,113 @@
"""System configuration API — read/write all settings from the database.
There is no .env file. Every setting lives in the ``system_config`` table
(singleton row with id=1) and is editable via the Web UI settings page.
"""
import logging
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.database import get_db
from app.dependencies import get_current_user
from app.models import SystemConfig, User
from app.services import npm_service
from app.utils.config import get_system_config
from app.utils.security import encrypt_value
from app.utils.validators import SystemConfigUpdate
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/system")
async def get_settings(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Return all system configuration values (token masked).
Returns:
System config dict.
"""
row = db.query(SystemConfig).filter(SystemConfig.id == 1).first()
if not row:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="System configuration not initialized. Run install.sh first.",
)
return row.to_dict()
@router.put("/system")
async def update_settings(
payload: SystemConfigUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Update system configuration values.
Only provided (non-None) fields are updated. The NPM API token is
encrypted before storage.
Args:
payload: Fields to update.
Returns:
Updated system config dict.
"""
row = db.query(SystemConfig).filter(SystemConfig.id == 1).first()
if not row:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="System configuration not initialized.",
)
update_data = payload.model_dump(exclude_none=True)
# Handle NPM token encryption
if "npm_api_token" in update_data:
raw_token = update_data.pop("npm_api_token")
row.npm_api_token_encrypted = encrypt_value(raw_token)
for field, value in update_data.items():
if hasattr(row, field):
setattr(row, field, value)
row.updated_at = datetime.utcnow()
db.commit()
db.refresh(row)
logger.info("System configuration updated by %s.", current_user.username)
return row.to_dict()
@router.get("/test-npm")
async def test_npm(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Test connectivity to the Nginx Proxy Manager API.
Loads the NPM URL and decrypted token from the database and attempts
to list proxy hosts.
Returns:
Dict with ``ok`` and ``message``.
"""
config = get_system_config(db)
if not config:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="System configuration not initialized.",
)
if not config.npm_api_url or not config.npm_api_token:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="NPM API URL or token not configured.",
)
result = await npm_service.test_npm_connection(config.npm_api_url, config.npm_api_token)
return result

0
app/services/__init__.py Normal file
View File

View File

@@ -0,0 +1,334 @@
"""Docker container management via the Python Docker SDK.
Responsible for creating, starting, stopping, restarting, and removing
per-customer Docker Compose stacks. Also provides log retrieval and
container health/status information.
"""
import logging
import os
import subprocess
import time
from typing import Any, Optional
import docker
from docker.errors import DockerException, NotFound
logger = logging.getLogger(__name__)
def _get_client() -> docker.DockerClient:
"""Return a Docker client connected via the Unix socket.
Returns:
docker.DockerClient instance.
"""
return docker.from_env()
def compose_up(instance_dir: str, project_name: str) -> bool:
"""Run ``docker compose up -d`` for a customer instance.
Args:
instance_dir: Absolute path to the customer's instance directory.
project_name: Docker Compose project name (e.g. ``netbird-kunde5``).
Returns:
True on success.
Raises:
RuntimeError: If ``docker compose up`` fails.
"""
compose_file = os.path.join(instance_dir, "docker-compose.yml")
if not os.path.isfile(compose_file):
raise FileNotFoundError(f"docker-compose.yml not found at {compose_file}")
cmd = [
"docker", "compose",
"-f", compose_file,
"-p", project_name,
"up", "-d", "--remove-orphans",
]
logger.info("Running: %s", " ".join(cmd))
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
if result.returncode != 0:
logger.error("docker compose up failed: %s", result.stderr)
raise RuntimeError(f"docker compose up failed: {result.stderr}")
logger.info("docker compose up succeeded for %s", project_name)
return True
def compose_down(instance_dir: str, project_name: str, remove_volumes: bool = False) -> bool:
"""Run ``docker compose down`` for a customer instance.
Args:
instance_dir: Absolute path to the customer's instance directory.
project_name: Docker Compose project name.
remove_volumes: Whether to also remove volumes.
Returns:
True on success.
"""
compose_file = os.path.join(instance_dir, "docker-compose.yml")
cmd = [
"docker", "compose",
"-f", compose_file,
"-p", project_name,
"down",
]
if remove_volumes:
cmd.append("-v")
logger.info("Running: %s", " ".join(cmd))
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
if result.returncode != 0:
logger.warning("docker compose down returned non-zero: %s", result.stderr)
return True
def compose_stop(instance_dir: str, project_name: str) -> bool:
"""Run ``docker compose stop`` for a customer instance.
Args:
instance_dir: Absolute path to the customer's instance directory.
project_name: Docker Compose project name.
Returns:
True on success.
"""
compose_file = os.path.join(instance_dir, "docker-compose.yml")
cmd = [
"docker", "compose",
"-f", compose_file,
"-p", project_name,
"stop",
]
logger.info("Running: %s", " ".join(cmd))
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
return result.returncode == 0
def compose_start(instance_dir: str, project_name: str) -> bool:
"""Run ``docker compose start`` for a customer instance.
Args:
instance_dir: Absolute path to the customer's instance directory.
project_name: Docker Compose project name.
Returns:
True on success.
"""
compose_file = os.path.join(instance_dir, "docker-compose.yml")
cmd = [
"docker", "compose",
"-f", compose_file,
"-p", project_name,
"start",
]
logger.info("Running: %s", " ".join(cmd))
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
return result.returncode == 0
def compose_restart(instance_dir: str, project_name: str) -> bool:
"""Run ``docker compose restart`` for a customer instance.
Args:
instance_dir: Absolute path to the customer's instance directory.
project_name: Docker Compose project name.
Returns:
True on success.
"""
compose_file = os.path.join(instance_dir, "docker-compose.yml")
cmd = [
"docker", "compose",
"-f", compose_file,
"-p", project_name,
"restart",
]
logger.info("Running: %s", " ".join(cmd))
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
return result.returncode == 0
def get_container_status(container_prefix: str) -> list[dict[str, Any]]:
"""Get the status of all containers matching a prefix.
Args:
container_prefix: Container name prefix (e.g. ``netbird-kunde5``).
Returns:
List of dicts with container name, status, and health info.
"""
client = _get_client()
results: list[dict[str, Any]] = []
try:
containers = client.containers.list(all=True, filters={"name": container_prefix})
for c in containers:
health = "N/A"
if c.attrs.get("State", {}).get("Health"):
health = c.attrs["State"]["Health"].get("Status", "N/A")
results.append({
"name": c.name,
"status": c.status,
"health": health,
"image": str(c.image.tags[0]) if c.image.tags else str(c.image.id[:12]),
"created": c.attrs.get("Created", ""),
})
except DockerException as exc:
logger.error("Failed to get container status: %s", exc)
return results
def get_container_logs(container_name: str, tail: int = 200) -> str:
"""Retrieve recent logs from a container.
Args:
container_name: Full container name.
tail: Number of log lines to retrieve.
Returns:
Log text.
"""
client = _get_client()
try:
container = client.containers.get(container_name)
return container.logs(tail=tail, timestamps=True).decode("utf-8", errors="replace")
except NotFound:
return f"Container {container_name} not found."
except DockerException as exc:
return f"Error retrieving logs: {exc}"
def get_all_container_logs(container_prefix: str, tail: int = 100) -> dict[str, str]:
"""Get logs for all containers matching a prefix.
Args:
container_prefix: Container name prefix.
tail: Lines per container.
Returns:
Dict mapping container name to log text.
"""
client = _get_client()
logs: dict[str, str] = {}
try:
containers = client.containers.list(all=True, filters={"name": container_prefix})
for c in containers:
try:
logs[c.name] = c.logs(tail=tail, timestamps=True).decode(
"utf-8", errors="replace"
)
except DockerException:
logs[c.name] = "Error retrieving logs."
except DockerException as exc:
logger.error("Failed to list containers: %s", exc)
return logs
def wait_for_healthy(container_prefix: str, timeout: int = 60) -> bool:
"""Wait until all containers with the given prefix are running.
Args:
container_prefix: Container name prefix.
timeout: Maximum seconds to wait.
Returns:
True if all containers started within timeout.
"""
client = _get_client()
deadline = time.time() + timeout
while time.time() < deadline:
try:
containers = client.containers.list(
all=True, filters={"name": container_prefix}
)
if not containers:
time.sleep(2)
continue
all_running = all(c.status == "running" for c in containers)
if all_running:
logger.info("All containers for %s are running.", container_prefix)
return True
except DockerException as exc:
logger.warning("Health check error: %s", exc)
time.sleep(3)
logger.warning("Timeout waiting for %s containers to start.", container_prefix)
return False
def get_docker_stats(container_prefix: str) -> list[dict[str, Any]]:
"""Retrieve resource usage stats for containers matching a prefix.
Args:
container_prefix: Container name prefix.
Returns:
List of dicts with CPU, memory, and network stats.
"""
client = _get_client()
stats_list: list[dict[str, Any]] = []
try:
containers = client.containers.list(filters={"name": container_prefix})
for c in containers:
try:
raw = c.stats(stream=False)
cpu_delta = (
raw.get("cpu_stats", {}).get("cpu_usage", {}).get("total_usage", 0)
- raw.get("precpu_stats", {}).get("cpu_usage", {}).get("total_usage", 0)
)
system_delta = (
raw.get("cpu_stats", {}).get("system_cpu_usage", 0)
- raw.get("precpu_stats", {}).get("system_cpu_usage", 0)
)
num_cpus = len(
raw.get("cpu_stats", {}).get("cpu_usage", {}).get("percpu_usage", [1])
)
cpu_pct = 0.0
if system_delta > 0:
cpu_pct = (cpu_delta / system_delta) * num_cpus * 100
mem_usage = raw.get("memory_stats", {}).get("usage", 0)
mem_limit = raw.get("memory_stats", {}).get("limit", 1)
stats_list.append({
"name": c.name,
"cpu_percent": round(cpu_pct, 2),
"memory_usage_mb": round(mem_usage / 1024 / 1024, 1),
"memory_limit_mb": round(mem_limit / 1024 / 1024, 1),
"memory_percent": round((mem_usage / mem_limit) * 100, 1) if mem_limit else 0,
})
except DockerException:
stats_list.append({"name": c.name, "error": "Failed to get stats"})
except DockerException as exc:
logger.error("Failed to get docker stats: %s", exc)
return stats_list
def remove_instance_containers(container_prefix: str) -> bool:
"""Force-remove all containers matching a prefix.
Args:
container_prefix: Container name prefix.
Returns:
True if removal succeeded.
"""
client = _get_client()
try:
containers = client.containers.list(all=True, filters={"name": container_prefix})
for c in containers:
logger.info("Removing container %s", c.name)
c.remove(force=True)
return True
except DockerException as exc:
logger.error("Failed to remove containers: %s", exc)
return False

View File

@@ -0,0 +1,396 @@
"""NetBird deployment orchestration service.
Coordinates the full customer deployment lifecycle:
1. Validate inputs
2. Allocate ports
3. Generate configs from Jinja2 templates
4. Create instance directory and write files
5. Start Docker containers
6. Wait for health checks
7. Create NPM proxy hosts
8. Update database
Includes comprehensive rollback on failure.
"""
import logging
import os
import shutil
from datetime import datetime
from typing import Any
from jinja2 import Environment, FileSystemLoader
from sqlalchemy.orm import Session
from app.models import Customer, Deployment, DeploymentLog, SystemConfig
from app.services import docker_service, npm_service, port_manager
from app.utils.config import get_system_config
from app.utils.security import encrypt_value, generate_relay_secret
logger = logging.getLogger(__name__)
# Path to Jinja2 templates
TEMPLATE_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "templates")
def _get_jinja_env() -> Environment:
"""Create a Jinja2 environment for template rendering."""
return Environment(
loader=FileSystemLoader(TEMPLATE_DIR),
keep_trailing_newline=True,
)
def _log_action(
db: Session, customer_id: int, action: str, status: str, message: str, details: str = ""
) -> None:
"""Write a deployment log entry.
Args:
db: Active session.
customer_id: The customer this log belongs to.
action: Action name (e.g. ``deploy``, ``stop``).
status: ``success``, ``error``, or ``info``.
message: Human-readable message.
details: Additional details (optional).
"""
log = DeploymentLog(
customer_id=customer_id,
action=action,
status=status,
message=message,
details=details,
)
db.add(log)
db.commit()
async def deploy_customer(db: Session, customer_id: int) -> dict[str, Any]:
"""Execute the full deployment workflow for a customer.
Args:
db: Active session.
customer_id: Customer to deploy.
Returns:
Dict with ``success``, ``setup_url``, or ``error``.
"""
customer = db.query(Customer).filter(Customer.id == customer_id).first()
if not customer:
return {"success": False, "error": "Customer not found."}
config = get_system_config(db)
if not config:
return {"success": False, "error": "System not configured. Please set up system settings first."}
# Update status to deploying
customer.status = "deploying"
db.commit()
_log_action(db, customer_id, "deploy", "info", "Deployment started.")
allocated_port = None
instance_dir = None
container_prefix = f"netbird-kunde{customer_id}"
try:
# Step 1: Allocate relay UDP port
allocated_port = port_manager.allocate_port(db, config.relay_base_port)
_log_action(db, customer_id, "deploy", "info", f"Allocated UDP port {allocated_port}.")
# Step 2: Generate relay secret
relay_secret = generate_relay_secret()
# Step 3: Create instance directory
instance_dir = os.path.join(config.data_dir, f"kunde{customer_id}")
os.makedirs(instance_dir, exist_ok=True)
os.makedirs(os.path.join(instance_dir, "data", "management"), exist_ok=True)
os.makedirs(os.path.join(instance_dir, "data", "signal"), exist_ok=True)
_log_action(db, customer_id, "deploy", "info", f"Created directory {instance_dir}.")
# Step 4: Render templates
jinja_env = _get_jinja_env()
template_vars = {
"customer_id": customer_id,
"subdomain": customer.subdomain,
"base_domain": config.base_domain,
"instance_dir": instance_dir,
"relay_udp_port": allocated_port,
"relay_secret": relay_secret,
"netbird_management_image": config.netbird_management_image,
"netbird_signal_image": config.netbird_signal_image,
"netbird_relay_image": config.netbird_relay_image,
"netbird_dashboard_image": config.netbird_dashboard_image,
"docker_network": config.docker_network,
}
# docker-compose.yml
dc_template = jinja_env.get_template("docker-compose.yml.j2")
dc_content = dc_template.render(**template_vars)
with open(os.path.join(instance_dir, "docker-compose.yml"), "w") as f:
f.write(dc_content)
# management.json
mgmt_template = jinja_env.get_template("management.json.j2")
mgmt_content = mgmt_template.render(**template_vars)
with open(os.path.join(instance_dir, "management.json"), "w") as f:
f.write(mgmt_content)
# relay.env
relay_template = jinja_env.get_template("relay.env.j2")
relay_content = relay_template.render(**template_vars)
with open(os.path.join(instance_dir, "relay.env"), "w") as f:
f.write(relay_content)
_log_action(db, customer_id, "deploy", "info", "Configuration files generated.")
# Step 5: Start Docker containers
docker_service.compose_up(instance_dir, container_prefix)
_log_action(db, customer_id, "deploy", "info", "Docker containers started.")
# Step 6: Wait for containers to be healthy
healthy = docker_service.wait_for_healthy(container_prefix, timeout=60)
if not healthy:
_log_action(
db, customer_id, "deploy", "error",
"Containers did not become healthy within 60 seconds."
)
# Don't fail completely — containers might still come up
# Step 7: Create NPM proxy host
domain = f"{customer.subdomain}.{config.base_domain}"
dashboard_container = f"netbird-kunde{customer_id}-dashboard"
npm_result = await npm_service.create_proxy_host(
api_url=config.npm_api_url,
api_token=config.npm_api_token,
domain=domain,
forward_host=dashboard_container,
forward_port=80,
admin_email=config.admin_email,
subdomain=customer.subdomain,
customer_id=customer_id,
)
npm_proxy_id = npm_result.get("proxy_id")
if npm_result.get("error"):
_log_action(
db, customer_id, "deploy", "error",
f"NPM proxy creation failed: {npm_result['error']}",
)
# Continue — deployment works without NPM, admin can fix later
# Step 8: Create deployment record
setup_url = f"https://{domain}"
deployment = Deployment(
customer_id=customer_id,
container_prefix=container_prefix,
relay_udp_port=allocated_port,
npm_proxy_id=npm_proxy_id,
relay_secret=encrypt_value(relay_secret),
setup_url=setup_url,
deployment_status="running",
deployed_at=datetime.utcnow(),
)
db.add(deployment)
customer.status = "active"
db.commit()
_log_action(db, customer_id, "deploy", "success", f"Deployment complete. URL: {setup_url}")
return {"success": True, "setup_url": setup_url}
except Exception as exc:
logger.exception("Deployment failed for customer %d", customer_id)
# Rollback: stop containers if they were started
try:
docker_service.compose_down(
instance_dir or os.path.join(config.data_dir, f"kunde{customer_id}"),
container_prefix,
remove_volumes=True,
)
except Exception:
pass
# Rollback: remove instance directory
if instance_dir and os.path.isdir(instance_dir):
try:
shutil.rmtree(instance_dir)
except Exception:
pass
customer.status = "error"
db.commit()
_log_action(
db, customer_id, "deploy", "error",
f"Deployment failed: {exc}",
details=str(exc),
)
return {"success": False, "error": str(exc)}
async def undeploy_customer(db: Session, customer_id: int) -> dict[str, Any]:
"""Remove all resources for a customer deployment.
Args:
db: Active session.
customer_id: Customer to undeploy.
Returns:
Dict with ``success`` bool.
"""
customer = db.query(Customer).filter(Customer.id == customer_id).first()
if not customer:
return {"success": False, "error": "Customer not found."}
deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first()
config = get_system_config(db)
if deployment and config:
instance_dir = os.path.join(config.data_dir, f"kunde{customer_id}")
# Stop and remove containers
try:
docker_service.compose_down(instance_dir, deployment.container_prefix, remove_volumes=True)
_log_action(db, customer_id, "undeploy", "info", "Containers removed.")
except Exception as exc:
_log_action(db, customer_id, "undeploy", "error", f"Container removal error: {exc}")
# Remove NPM proxy host
if deployment.npm_proxy_id and config.npm_api_token:
try:
await npm_service.delete_proxy_host(
config.npm_api_url, config.npm_api_token, deployment.npm_proxy_id
)
_log_action(db, customer_id, "undeploy", "info", "NPM proxy host removed.")
except Exception as exc:
_log_action(db, customer_id, "undeploy", "error", f"NPM removal error: {exc}")
# Remove instance directory
if os.path.isdir(instance_dir):
try:
shutil.rmtree(instance_dir)
_log_action(db, customer_id, "undeploy", "info", "Instance directory removed.")
except Exception as exc:
_log_action(db, customer_id, "undeploy", "error", f"Directory removal error: {exc}")
# Remove deployment record
db.delete(deployment)
db.commit()
_log_action(db, customer_id, "undeploy", "success", "Undeployment complete.")
return {"success": True}
def stop_customer(db: Session, customer_id: int) -> dict[str, Any]:
"""Stop containers for a customer.
Args:
db: Active session.
customer_id: Customer whose containers to stop.
Returns:
Dict with ``success`` bool.
"""
deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first()
config = get_system_config(db)
if not deployment or not config:
return {"success": False, "error": "Deployment or config not found."}
instance_dir = os.path.join(config.data_dir, f"kunde{customer_id}")
ok = docker_service.compose_stop(instance_dir, deployment.container_prefix)
if ok:
deployment.deployment_status = "stopped"
db.commit()
_log_action(db, customer_id, "stop", "success", "Containers stopped.")
else:
_log_action(db, customer_id, "stop", "error", "Failed to stop containers.")
return {"success": ok}
def start_customer(db: Session, customer_id: int) -> dict[str, Any]:
"""Start containers for a customer.
Args:
db: Active session.
customer_id: Customer whose containers to start.
Returns:
Dict with ``success`` bool.
"""
deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first()
config = get_system_config(db)
if not deployment or not config:
return {"success": False, "error": "Deployment or config not found."}
instance_dir = os.path.join(config.data_dir, f"kunde{customer_id}")
ok = docker_service.compose_start(instance_dir, deployment.container_prefix)
if ok:
deployment.deployment_status = "running"
db.commit()
_log_action(db, customer_id, "start", "success", "Containers started.")
else:
_log_action(db, customer_id, "start", "error", "Failed to start containers.")
return {"success": ok}
def restart_customer(db: Session, customer_id: int) -> dict[str, Any]:
"""Restart containers for a customer.
Args:
db: Active session.
customer_id: Customer whose containers to restart.
Returns:
Dict with ``success`` bool.
"""
deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first()
config = get_system_config(db)
if not deployment or not config:
return {"success": False, "error": "Deployment or config not found."}
instance_dir = os.path.join(config.data_dir, f"kunde{customer_id}")
ok = docker_service.compose_restart(instance_dir, deployment.container_prefix)
if ok:
deployment.deployment_status = "running"
db.commit()
_log_action(db, customer_id, "restart", "success", "Containers restarted.")
else:
_log_action(db, customer_id, "restart", "error", "Failed to restart containers.")
return {"success": ok}
def get_customer_health(db: Session, customer_id: int) -> dict[str, Any]:
"""Check health of a customer's deployment.
Args:
db: Active session.
customer_id: Customer ID.
Returns:
Dict with container statuses and overall health.
"""
deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first()
if not deployment:
return {"healthy": False, "error": "No deployment found.", "containers": []}
containers = docker_service.get_container_status(deployment.container_prefix)
all_running = all(c["status"] == "running" for c in containers) if containers else False
# Update last health check time
deployment.last_health_check = datetime.utcnow()
if all_running:
deployment.deployment_status = "running"
elif containers:
deployment.deployment_status = "failed"
db.commit()
return {
"healthy": all_running,
"containers": containers,
"deployment_status": deployment.deployment_status,
"last_check": deployment.last_health_check.isoformat(),
}

234
app/services/npm_service.py Normal file
View File

@@ -0,0 +1,234 @@
"""Nginx Proxy Manager API integration.
Creates, updates, and deletes proxy host entries so each customer's NetBird
dashboard is accessible at ``{subdomain}.{base_domain}`` with automatic
Let's Encrypt SSL certificates.
"""
import logging
from typing import Any, Optional
import httpx
logger = logging.getLogger(__name__)
# Timeout for NPM API calls (seconds)
NPM_TIMEOUT = 30
async def test_npm_connection(api_url: str, api_token: str) -> dict[str, Any]:
"""Test connectivity to the Nginx Proxy Manager API.
Args:
api_url: NPM API base URL (e.g. ``http://npm:81/api``).
api_token: Bearer token for authentication.
Returns:
Dict with ``ok`` (bool) and ``message`` (str).
"""
headers = {"Authorization": f"Bearer {api_token}"}
try:
async with httpx.AsyncClient(timeout=NPM_TIMEOUT) as client:
resp = await client.get(f"{api_url}/nginx/proxy-hosts", headers=headers)
if resp.status_code == 200:
count = len(resp.json())
return {"ok": True, "message": f"Connected. {count} proxy hosts found."}
return {
"ok": False,
"message": f"NPM returned status {resp.status_code}: {resp.text[:200]}",
}
except httpx.ConnectError:
return {"ok": False, "message": "Connection refused. Is NPM running?"}
except httpx.TimeoutException:
return {"ok": False, "message": "Connection timed out."}
except Exception as exc:
return {"ok": False, "message": f"Unexpected error: {exc}"}
async def create_proxy_host(
api_url: str,
api_token: str,
domain: str,
forward_host: str,
forward_port: int = 80,
admin_email: str = "",
subdomain: str = "",
customer_id: int = 0,
) -> dict[str, Any]:
"""Create a proxy host entry in NPM with SSL for a customer.
The proxy routes traffic as follows:
- ``/`` -> dashboard container (port 80)
- ``/api`` -> management container (port 80)
- ``/signalexchange.*`` -> signal container (port 80)
- ``/relay`` -> relay container (port 80)
Args:
api_url: NPM API base URL.
api_token: Bearer token.
domain: Full domain (e.g. ``kunde1.example.com``).
forward_host: Container name for the dashboard.
forward_port: Port to forward to (default 80).
admin_email: Email for Let's Encrypt.
subdomain: Customer subdomain for building container names.
customer_id: Customer ID for building container names.
Returns:
Dict with ``proxy_id`` on success or ``error`` on failure.
"""
headers = {
"Authorization": f"Bearer {api_token}",
"Content-Type": "application/json",
}
# Build advanced Nginx config to route sub-paths to different containers
mgmt_container = f"netbird-kunde{customer_id}-management"
signal_container = f"netbird-kunde{customer_id}-signal"
relay_container = f"netbird-kunde{customer_id}-relay"
advanced_config = f"""
# NetBird Management API
location /api {{
proxy_pass http://{mgmt_container}:80;
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;
}}
# NetBird Signal (gRPC-Web)
location /signalexchange. {{
grpc_pass grpc://{signal_container}:80;
grpc_set_header Host $host;
}}
# NetBird Relay (WebSocket)
location /relay {{
proxy_pass http://{relay_container}:80;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}}
"""
payload = {
"domain_names": [domain],
"forward_scheme": "http",
"forward_host": forward_host,
"forward_port": forward_port,
"certificate_id": 0,
"ssl_forced": True,
"hsts_enabled": True,
"hsts_subdomains": False,
"http2_support": True,
"block_exploits": True,
"allow_websocket_upgrade": True,
"access_list_id": 0,
"advanced_config": advanced_config.strip(),
"meta": {
"letsencrypt_agree": True,
"letsencrypt_email": admin_email,
"dns_challenge": False,
},
}
try:
async with httpx.AsyncClient(timeout=NPM_TIMEOUT) as client:
resp = await client.post(
f"{api_url}/nginx/proxy-hosts", json=payload, headers=headers
)
if resp.status_code in (200, 201):
data = resp.json()
proxy_id = data.get("id")
logger.info("Created NPM proxy host %s (id=%s)", domain, proxy_id)
# Request SSL certificate
await _request_ssl(client, api_url, headers, proxy_id, domain, admin_email)
return {"proxy_id": proxy_id}
else:
error_msg = f"NPM returned {resp.status_code}: {resp.text[:300]}"
logger.error("Failed to create proxy host: %s", error_msg)
return {"error": error_msg}
except Exception as exc:
logger.error("NPM API error: %s", exc)
return {"error": str(exc)}
async def _request_ssl(
client: httpx.AsyncClient,
api_url: str,
headers: dict,
proxy_id: int,
domain: str,
admin_email: str,
) -> None:
"""Request a Let's Encrypt SSL certificate for a proxy host.
Args:
client: httpx client.
api_url: NPM API base URL.
headers: Auth headers.
proxy_id: The proxy host ID.
domain: The domain to certify.
admin_email: Contact email for LE.
"""
ssl_payload = {
"domain_names": [domain],
"meta": {
"letsencrypt_agree": True,
"letsencrypt_email": admin_email,
"dns_challenge": False,
},
}
try:
resp = await client.post(
f"{api_url}/nginx/certificates", json=ssl_payload, headers=headers
)
if resp.status_code in (200, 201):
cert_id = resp.json().get("id")
# Assign certificate to proxy host
await client.put(
f"{api_url}/nginx/proxy-hosts/{proxy_id}",
json={"certificate_id": cert_id},
headers=headers,
)
logger.info("SSL certificate assigned to proxy host %s", proxy_id)
else:
logger.warning("SSL request returned %s: %s", resp.status_code, resp.text[:200])
except Exception as exc:
logger.warning("SSL certificate request failed: %s", exc)
async def delete_proxy_host(
api_url: str, api_token: str, proxy_id: int
) -> bool:
"""Delete a proxy host from NPM.
Args:
api_url: NPM API base URL.
api_token: Bearer token.
proxy_id: The proxy host ID to delete.
Returns:
True on success.
"""
headers = {"Authorization": f"Bearer {api_token}"}
try:
async with httpx.AsyncClient(timeout=NPM_TIMEOUT) as client:
resp = await client.delete(
f"{api_url}/nginx/proxy-hosts/{proxy_id}", headers=headers
)
if resp.status_code in (200, 204):
logger.info("Deleted NPM proxy host %d", proxy_id)
return True
logger.warning(
"Failed to delete proxy host %d: %s %s",
proxy_id, resp.status_code, resp.text[:200],
)
return False
except Exception as exc:
logger.error("NPM delete error: %s", exc)
return False

View File

@@ -0,0 +1,110 @@
"""UDP port allocation service for NetBird relay/STUN ports.
Manages the range starting at relay_base_port (default 3478). Each customer
gets one unique UDP port. The manager checks both the database and the OS
to avoid collisions.
"""
import logging
import socket
from typing import Optional
from sqlalchemy.orm import Session
from app.models import Deployment
logger = logging.getLogger(__name__)
def _is_udp_port_in_use(port: int) -> bool:
"""Check whether a UDP port is currently bound on the host.
Args:
port: UDP port number to probe.
Returns:
True if the port is in use.
"""
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
sock.bind(("0.0.0.0", port))
return False
except OSError:
return True
finally:
sock.close()
def get_allocated_ports(db: Session) -> set[int]:
"""Return the set of relay UDP ports already assigned in the database.
Args:
db: Active SQLAlchemy session.
Returns:
Set of port numbers.
"""
rows = db.query(Deployment.relay_udp_port).all()
return {r[0] for r in rows}
def allocate_port(db: Session, base_port: int = 3478, max_ports: int = 100) -> int:
"""Find and return the next available relay UDP port.
Scans from *base_port* to *base_port + max_ports - 1*, skipping ports
that are either already in the database or currently bound on the host.
Args:
db: Active SQLAlchemy session.
base_port: Start of the port range.
max_ports: Number of ports in the range.
Returns:
An available port number.
Raises:
RuntimeError: If no port in the range is available.
"""
allocated = get_allocated_ports(db)
for port in range(base_port, base_port + max_ports):
if port in allocated:
continue
if _is_udp_port_in_use(port):
logger.warning("Port %d is in use on the host, skipping.", port)
continue
logger.info("Allocated relay UDP port %d.", port)
return port
raise RuntimeError(
f"No available relay ports in range {base_port}-{base_port + max_ports - 1}. "
"All 100 ports are allocated."
)
def release_port(db: Session, port: int) -> None:
"""Mark a port as released (informational logging only).
The actual release happens when the Deployment row is deleted. This
helper exists for explicit logging in rollback scenarios.
Args:
db: Active SQLAlchemy session.
port: The port to release.
"""
logger.info("Released relay UDP port %d.", port)
def validate_port_available(db: Session, port: int) -> bool:
"""Check if a specific port is available both in DB and on the host.
Args:
db: Active SQLAlchemy session.
port: Port number to check.
Returns:
True if the port is available.
"""
allocated = get_allocated_ports(db)
if port in allocated:
return False
return not _is_udp_port_in_use(port)

0
app/utils/__init__.py Normal file
View File

74
app/utils/config.py Normal file
View File

@@ -0,0 +1,74 @@
"""Configuration management — loads all settings from the database (system_config table).
There is NO .env file for application config. The install.sh script collects values
interactively and seeds them into the database. The Web UI settings page allows
editing every value at runtime.
"""
import os
from dataclasses import dataclass
from typing import Optional
from sqlalchemy.orm import Session
from app.utils.security import decrypt_value
@dataclass
class AppConfig:
"""In-memory snapshot of system configuration."""
base_domain: str
admin_email: str
npm_api_url: str
npm_api_token: str # decrypted
netbird_management_image: str
netbird_signal_image: str
netbird_relay_image: str
netbird_dashboard_image: str
data_dir: str
docker_network: str
relay_base_port: int
# Environment-level settings (not stored in DB)
SECRET_KEY: str = os.environ.get("SECRET_KEY", "change-me-in-production")
DATABASE_PATH: str = os.environ.get("DATABASE_PATH", "/app/data/netbird_msp.db")
LOG_LEVEL: str = os.environ.get("LOG_LEVEL", "INFO")
JWT_ALGORITHM: str = "HS256"
JWT_EXPIRE_MINUTES: int = 480 # 8 hours
def get_system_config(db: Session) -> Optional[AppConfig]:
"""Load the singleton SystemConfig row and return an AppConfig dataclass.
Args:
db: Active SQLAlchemy session.
Returns:
AppConfig instance or None if the system_config row does not exist yet.
"""
from app.models import SystemConfig
row = db.query(SystemConfig).filter(SystemConfig.id == 1).first()
if row is None:
return None
try:
npm_token = decrypt_value(row.npm_api_token_encrypted)
except Exception:
npm_token = ""
return AppConfig(
base_domain=row.base_domain,
admin_email=row.admin_email,
npm_api_url=row.npm_api_url,
npm_api_token=npm_token,
netbird_management_image=row.netbird_management_image,
netbird_signal_image=row.netbird_signal_image,
netbird_relay_image=row.netbird_relay_image,
netbird_dashboard_image=row.netbird_dashboard_image,
data_dir=row.data_dir,
docker_network=row.docker_network,
relay_base_port=row.relay_base_port,
)

91
app/utils/security.py Normal file
View File

@@ -0,0 +1,91 @@
"""Security utilities — password hashing (bcrypt) and token encryption (Fernet)."""
import os
import secrets
from cryptography.fernet import Fernet
from passlib.context import CryptContext
# ---------------------------------------------------------------------------
# Password hashing (bcrypt)
# ---------------------------------------------------------------------------
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(plain: str) -> str:
"""Hash a plaintext password with bcrypt.
Args:
plain: The plaintext password.
Returns:
Bcrypt hash string.
"""
return pwd_context.hash(plain)
def verify_password(plain: str, hashed: str) -> bool:
"""Verify a plaintext password against a bcrypt hash.
Args:
plain: The plaintext password to check.
hashed: The stored bcrypt hash.
Returns:
True if the password matches.
"""
return pwd_context.verify(plain, hashed)
# ---------------------------------------------------------------------------
# Fernet encryption for secrets (NPM token, relay secrets, etc.)
# ---------------------------------------------------------------------------
def _get_fernet() -> Fernet:
"""Derive a Fernet key from the application SECRET_KEY.
The SECRET_KEY from the environment is used as the basis. We pad/truncate
it to produce a valid 32-byte URL-safe-base64 key that Fernet requires.
"""
import base64
import hashlib
secret = os.environ.get("SECRET_KEY", "change-me-in-production")
# Derive a stable 32-byte key via SHA-256
key_bytes = hashlib.sha256(secret.encode()).digest()
fernet_key = base64.urlsafe_b64encode(key_bytes)
return Fernet(fernet_key)
def encrypt_value(plaintext: str) -> str:
"""Encrypt a string value with Fernet.
Args:
plaintext: Value to encrypt.
Returns:
Encrypted string (base64-encoded Fernet token).
"""
f = _get_fernet()
return f.encrypt(plaintext.encode()).decode()
def decrypt_value(ciphertext: str) -> str:
"""Decrypt a Fernet-encrypted string.
Args:
ciphertext: Encrypted value.
Returns:
Original plaintext string.
"""
f = _get_fernet()
return f.decrypt(ciphertext.encode()).decode()
def generate_relay_secret() -> str:
"""Generate a cryptographically secure relay secret.
Returns:
A 32-character hex string.
"""
return secrets.token_hex(16)

165
app/utils/validators.py Normal file
View File

@@ -0,0 +1,165 @@
"""Input validation with Pydantic models for all API endpoints."""
import re
from typing import Optional
from pydantic import BaseModel, Field, field_validator
# ---------------------------------------------------------------------------
# Auth
# ---------------------------------------------------------------------------
class LoginRequest(BaseModel):
"""Login credentials."""
username: str = Field(..., min_length=1, max_length=100)
password: str = Field(..., min_length=1)
class ChangePasswordRequest(BaseModel):
"""Password change payload."""
current_password: str = Field(..., min_length=1)
new_password: str = Field(..., min_length=12, max_length=128)
# ---------------------------------------------------------------------------
# Customer
# ---------------------------------------------------------------------------
class CustomerCreate(BaseModel):
"""Payload to create a new customer."""
name: str = Field(..., min_length=1, max_length=255)
company: Optional[str] = Field(None, max_length=255)
subdomain: str = Field(..., min_length=1, max_length=63)
email: str = Field(..., max_length=255)
max_devices: int = Field(default=20, ge=1, le=10000)
notes: Optional[str] = None
@field_validator("subdomain")
@classmethod
def validate_subdomain(cls, v: str) -> str:
"""Subdomain must be lowercase alphanumeric + hyphens, no leading/trailing hyphen."""
v = v.lower().strip()
if not re.match(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$", v):
raise ValueError(
"Subdomain must be lowercase, alphanumeric with hyphens, "
"2-63 chars, no leading/trailing hyphen."
)
return v
@field_validator("email")
@classmethod
def validate_email(cls, v: str) -> str:
"""Basic email format check."""
pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
if not re.match(pattern, v):
raise ValueError("Invalid email address.")
return v.lower().strip()
class CustomerUpdate(BaseModel):
"""Payload to update an existing customer."""
name: Optional[str] = Field(None, min_length=1, max_length=255)
company: Optional[str] = Field(None, max_length=255)
email: Optional[str] = Field(None, max_length=255)
max_devices: Optional[int] = Field(None, ge=1, le=10000)
notes: Optional[str] = None
status: Optional[str] = None
@field_validator("email")
@classmethod
def validate_email(cls, v: Optional[str]) -> Optional[str]:
"""Basic email format check."""
if v is None:
return v
pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
if not re.match(pattern, v):
raise ValueError("Invalid email address.")
return v.lower().strip()
@field_validator("status")
@classmethod
def validate_status(cls, v: Optional[str]) -> Optional[str]:
"""Status must be one of the allowed values."""
if v is None:
return v
allowed = {"active", "inactive", "deploying", "error"}
if v not in allowed:
raise ValueError(f"Status must be one of: {', '.join(sorted(allowed))}")
return v
# ---------------------------------------------------------------------------
# Settings
# ---------------------------------------------------------------------------
class SystemConfigUpdate(BaseModel):
"""Payload to update system configuration."""
base_domain: Optional[str] = Field(None, min_length=1, max_length=255)
admin_email: Optional[str] = Field(None, max_length=255)
npm_api_url: Optional[str] = Field(None, max_length=500)
npm_api_token: Optional[str] = None # plaintext, will be encrypted before storage
netbird_management_image: Optional[str] = Field(None, max_length=255)
netbird_signal_image: Optional[str] = Field(None, max_length=255)
netbird_relay_image: Optional[str] = Field(None, max_length=255)
netbird_dashboard_image: Optional[str] = Field(None, max_length=255)
data_dir: Optional[str] = Field(None, max_length=500)
docker_network: Optional[str] = Field(None, max_length=100)
relay_base_port: Optional[int] = Field(None, ge=1024, le=65535)
@field_validator("base_domain")
@classmethod
def validate_domain(cls, v: Optional[str]) -> Optional[str]:
"""Validate domain format."""
if v is None:
return v
pattern = r"^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$"
if not re.match(pattern, v):
raise ValueError("Invalid domain format.")
return v.lower().strip()
@field_validator("npm_api_url")
@classmethod
def validate_npm_url(cls, v: Optional[str]) -> Optional[str]:
"""NPM URL must start with http(s)://."""
if v is None:
return v
if not re.match(r"^https?://", v):
raise ValueError("NPM API URL must start with http:// or https://")
return v.rstrip("/")
@field_validator("admin_email")
@classmethod
def validate_email(cls, v: Optional[str]) -> Optional[str]:
"""Validate admin email."""
if v is None:
return v
pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
if not re.match(pattern, v):
raise ValueError("Invalid email address.")
return v.lower().strip()
# ---------------------------------------------------------------------------
# Query params
# ---------------------------------------------------------------------------
class CustomerListParams(BaseModel):
"""Query parameters for listing customers."""
page: int = Field(default=1, ge=1)
per_page: int = Field(default=25, ge=1, le=100)
search: Optional[str] = None
status: Optional[str] = None
@field_validator("status")
@classmethod
def validate_status(cls, v: Optional[str]) -> Optional[str]:
"""Filter status validation."""
if v is None or v == "":
return None
allowed = {"active", "inactive", "deploying", "error"}
if v not in allowed:
raise ValueError(f"Status must be one of: {', '.join(sorted(allowed))}")
return v

33
docker-compose.yml Normal file
View File

@@ -0,0 +1,33 @@
version: '3.8'
services:
netbird-msp-appliance:
build: .
container_name: netbird-msp-appliance
restart: unless-stopped
ports:
- "${WEB_UI_PORT:-8000}:8000"
volumes:
- ./data:/app/data
- ./logs:/app/logs
- ./backups:/app/backups
- /var/run/docker.sock:/var/run/docker.sock
- ${DATA_DIR:-/opt/netbird-instances}:${DATA_DIR:-/opt/netbird-instances}
environment:
- SECRET_KEY=${SECRET_KEY}
- DATABASE_PATH=/app/data/netbird_msp.db
- LOG_LEVEL=${LOG_LEVEL:-INFO}
- DATA_DIR=${DATA_DIR:-/opt/netbird-instances}
- DOCKER_NETWORK=${DOCKER_NETWORK:-npm-network}
networks:
- npm-network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
networks:
npm-network:
external: true

View File

@@ -2,7 +2,8 @@
# NetBird MSP Appliance - Interactive Installation Script # NetBird MSP Appliance - Interactive Installation Script
# This script sets up the complete NetBird MSP management platform # This script sets up the complete NetBird MSP management platform
# All configuration is done interactively - no .env file editing needed! # All configuration is done interactively and stored in the DATABASE.
# There is NO .env file for application config!
set -e # Exit on error set -e # Exit on error
@@ -29,6 +30,8 @@ cat << 'BANNER'
║ ║ ║ ║
║ Multi-Tenant NetBird Management Platform ║ ║ Multi-Tenant NetBird Management Platform ║
║ ║ ║ ║
║ All config stored in database - no .env editing! ║
║ ║
╚═══════════════════════════════════════════════════════════╝ ╚═══════════════════════════════════════════════════════════╝
BANNER BANNER
echo -e "${NC}" echo -e "${NC}"
@@ -53,7 +56,7 @@ echo -e "${BLUE}${BOLD}[Step 1/10]${NC} ${BLUE}Checking system requirements...${
CPU_CORES=$(nproc) CPU_CORES=$(nproc)
echo -e "CPU Cores: ${CYAN}$CPU_CORES${NC}" echo -e "CPU Cores: ${CYAN}$CPU_CORES${NC}"
if [ "$CPU_CORES" -lt 4 ]; then if [ "$CPU_CORES" -lt 4 ]; then
echo -e "${YELLOW}Warning: Only $CPU_CORES CPU cores detected.${NC}" echo -e "${YELLOW}Warning: Only $CPU_CORES CPU cores detected.${NC}"
echo -e "${YELLOW} Minimum 8 cores recommended for 100 customers.${NC}" echo -e "${YELLOW} Minimum 8 cores recommended for 100 customers.${NC}"
else else
echo -e "${GREEN}✓ CPU cores: Sufficient${NC}" echo -e "${GREEN}✓ CPU cores: Sufficient${NC}"
@@ -63,7 +66,7 @@ fi
TOTAL_RAM=$(free -g | awk '/^Mem:/{print $2}') TOTAL_RAM=$(free -g | awk '/^Mem:/{print $2}')
echo -e "RAM: ${CYAN}${TOTAL_RAM}GB${NC}" echo -e "RAM: ${CYAN}${TOTAL_RAM}GB${NC}"
if [ "$TOTAL_RAM" -lt 32 ]; then if [ "$TOTAL_RAM" -lt 32 ]; then
echo -e "${YELLOW}Warning: Only ${TOTAL_RAM}GB RAM detected.${NC}" echo -e "${YELLOW}Warning: Only ${TOTAL_RAM}GB RAM detected.${NC}"
echo -e "${YELLOW} Minimum 64GB recommended for 100 customers.${NC}" echo -e "${YELLOW} Minimum 64GB recommended for 100 customers.${NC}"
else else
echo -e "${GREEN}✓ RAM: Sufficient${NC}" echo -e "${GREEN}✓ RAM: Sufficient${NC}"
@@ -73,7 +76,7 @@ fi
DISK_SPACE=$(df -BG / | awk 'NR==2 {print $4}' | sed 's/G//') DISK_SPACE=$(df -BG / | awk 'NR==2 {print $4}' | sed 's/G//')
echo -e "Free Disk Space: ${CYAN}${DISK_SPACE}GB${NC}" echo -e "Free Disk Space: ${CYAN}${DISK_SPACE}GB${NC}"
if [ "$DISK_SPACE" -lt 200 ]; then if [ "$DISK_SPACE" -lt 200 ]; then
echo -e "${YELLOW}Warning: Only ${DISK_SPACE}GB free disk space.${NC}" echo -e "${YELLOW}Warning: Only ${DISK_SPACE}GB free disk space.${NC}"
echo -e "${YELLOW} Minimum 500GB recommended.${NC}" echo -e "${YELLOW} Minimum 500GB recommended.${NC}"
else else
echo -e "${GREEN}✓ Disk space: Sufficient${NC}" echo -e "${GREEN}✓ Disk space: Sufficient${NC}"
@@ -284,7 +287,7 @@ sleep 1
clear clear
# ============================================================================ # ============================================================================
# STEP 8: INSTALLATION # STEP 8: INSTALLATION (stores config in DATABASE, not .env)
# ============================================================================ # ============================================================================
echo -e "${BLUE}${BOLD}[Step 8/10]${NC} ${BLUE}Installation${NC}\n" echo -e "${BLUE}${BOLD}[Step 8/10]${NC} ${BLUE}Installation${NC}\n"
@@ -322,52 +325,27 @@ else
echo -e "${GREEN}✓ Docker network '$DOCKER_NETWORK' created${NC}" echo -e "${GREEN}✓ Docker network '$DOCKER_NETWORK' created${NC}"
fi fi
# Generate secret key # Generate secret key for encryption (only env-level secret)
echo "Generating encryption keys..." echo "Generating encryption keys..."
SECRET_KEY=$(openssl rand -base64 32) SECRET_KEY=$(openssl rand -base64 32)
echo -e "${GREEN}✓ Encryption keys generated${NC}" echo -e "${GREEN}✓ Encryption keys generated${NC}"
# Create .env file # Create MINIMAL .env — only container-level vars needed by docker-compose.yml
echo "Creating configuration..." # All application config goes into the DATABASE, not here!
echo "Creating minimal container environment..."
cat > "$INSTALL_DIR/.env" << ENVEOF cat > "$INSTALL_DIR/.env" << ENVEOF
# NetBird MSP Appliance Configuration # Container-level environment only (NOT application config!)
# Generated on $(date) # All settings are stored in the database and editable via Web UI.
# DO NOT EDIT - Use Web UI to change settings
# Security
SECRET_KEY=$SECRET_KEY SECRET_KEY=$SECRET_KEY
ADMIN_USERNAME=$ADMIN_USERNAME DATABASE_PATH=/app/data/netbird_msp.db
ADMIN_PASSWORD=$ADMIN_PASSWORD
# Nginx Proxy Manager
NPM_API_URL=$NPM_API_URL
NPM_API_TOKEN=$NPM_API_TOKEN
# System
DATA_DIR=$DATA_DIR DATA_DIR=$DATA_DIR
DOCKER_NETWORK=$DOCKER_NETWORK DOCKER_NETWORK=$DOCKER_NETWORK
BASE_DOMAIN=$BASE_DOMAIN
ADMIN_EMAIL=$ADMIN_EMAIL
# NetBird Images
NETBIRD_MANAGEMENT_IMAGE=$NETBIRD_MANAGEMENT_IMAGE
NETBIRD_SIGNAL_IMAGE=$NETBIRD_SIGNAL_IMAGE
NETBIRD_RELAY_IMAGE=$NETBIRD_RELAY_IMAGE
NETBIRD_DASHBOARD_IMAGE=$NETBIRD_DASHBOARD_IMAGE
# Database
DATABASE_PATH=/app/data/netbird_msp.db
# Logging
LOG_LEVEL=INFO LOG_LEVEL=INFO
# Port Configuration
RELAY_BASE_PORT=3478
WEB_UI_PORT=8000 WEB_UI_PORT=8000
ENVEOF ENVEOF
chmod 600 "$INSTALL_DIR/.env" chmod 600 "$INSTALL_DIR/.env"
echo -e "${GREEN}✓ Configuration file created${NC}" echo -e "${GREEN}✓ Container environment created${NC}"
# Copy application files # Copy application files
echo "Copying application files..." echo "Copying application files..."
@@ -391,10 +369,64 @@ else
exit 1 exit 1
fi fi
# Initialize database # Initialize database tables
echo "Initializing database..." echo "Initializing database..."
docker exec $CONTAINER_NAME python -m app.database init || true docker exec $CONTAINER_NAME python -m app.database init || true
echo -e "${GREEN}✓ Database initialized${NC}" echo -e "${GREEN}✓ Database tables created${NC}"
# Seed all configuration into the database (system_config + users table)
echo "Seeding configuration into database..."
docker exec $CONTAINER_NAME python -c "
import os
os.environ['SECRET_KEY'] = '$SECRET_KEY'
from app.database import SessionLocal, init_db
from app.models import SystemConfig, User
from app.utils.security import hash_password, encrypt_value
init_db()
db = SessionLocal()
# Create admin user
existing_user = db.query(User).filter(User.username == '$ADMIN_USERNAME').first()
if not existing_user:
user = User(
username='$ADMIN_USERNAME',
password_hash=hash_password('$ADMIN_PASSWORD'),
email='$ADMIN_EMAIL',
)
db.add(user)
print('Admin user created.')
else:
print('Admin user already exists.')
# Create system config (singleton row)
existing_config = db.query(SystemConfig).filter(SystemConfig.id == 1).first()
if not existing_config:
config = SystemConfig(
id=1,
base_domain='$BASE_DOMAIN',
admin_email='$ADMIN_EMAIL',
npm_api_url='$NPM_API_URL',
npm_api_token_encrypted=encrypt_value('$NPM_API_TOKEN'),
netbird_management_image='$NETBIRD_MANAGEMENT_IMAGE',
netbird_signal_image='$NETBIRD_SIGNAL_IMAGE',
netbird_relay_image='$NETBIRD_RELAY_IMAGE',
netbird_dashboard_image='$NETBIRD_DASHBOARD_IMAGE',
data_dir='$DATA_DIR',
docker_network='$DOCKER_NETWORK',
relay_base_port=3478,
)
db.add(config)
print('System configuration saved to database.')
else:
print('System configuration already exists.')
db.commit()
db.close()
print('Database seeding complete.')
"
echo -e "${GREEN}✓ Configuration stored in database${NC}"
clear clear
@@ -448,6 +480,11 @@ echo -e " Web Interface: ${GREEN}http://${SERVER_IP}:8000${NC}"
echo -e " Username: ${GREEN}${ADMIN_USERNAME}${NC}" echo -e " Username: ${GREEN}${ADMIN_USERNAME}${NC}"
echo -e " Password: ${CYAN}<the password you entered>${NC}\n" echo -e " Password: ${CYAN}<the password you entered>${NC}\n"
echo -e "${BLUE}${BOLD}Configuration:${NC}\n"
echo -e " ${YELLOW}All settings are stored in the database${NC}"
echo -e " ${YELLOW}Edit them anytime via Web UI > Settings${NC}"
echo -e " ${YELLOW}NO .env file editing needed!${NC}\n"
echo -e "${BLUE}${BOLD}Next Steps:${NC}\n" echo -e "${BLUE}${BOLD}Next Steps:${NC}\n"
echo -e " 1. ${CYAN}Access the web interface${NC}" echo -e " 1. ${CYAN}Access the web interface${NC}"
echo -e " 2. ${CYAN}Review system settings${NC} (all editable via Web UI)" echo -e " 2. ${CYAN}Review system settings${NC} (all editable via Web UI)"
@@ -464,11 +501,10 @@ echo -e "${BLUE}${BOLD}Important Notes:${NC}\n"
echo -e " ${YELLOW}${NC} All settings can be changed via the Web UI" echo -e " ${YELLOW}${NC} All settings can be changed via the Web UI"
echo -e " ${YELLOW}${NC} Installation directory: ${INSTALL_DIR}" echo -e " ${YELLOW}${NC} Installation directory: ${INSTALL_DIR}"
echo -e " ${YELLOW}${NC} Customer data directory: ${DATA_DIR}" echo -e " ${YELLOW}${NC} Customer data directory: ${DATA_DIR}"
echo -e " ${YELLOW}${NC} Database: ${INSTALL_DIR}/data/netbird_msp.db"
echo -e " ${YELLOW}${NC} Backup your database regularly\n" echo -e " ${YELLOW}${NC} Backup your database regularly\n"
echo -e "${GREEN}${BOLD}Happy MSP-ing! 🚀${NC}\n" # Save installation summary (no secrets!)
# Save installation summary
cat > "$INSTALL_DIR/INSTALLATION_SUMMARY.txt" << SUMMARY cat > "$INSTALL_DIR/INSTALLATION_SUMMARY.txt" << SUMMARY
NetBird MSP Appliance - Installation Summary NetBird MSP Appliance - Installation Summary
============================================= =============================================
@@ -483,6 +519,9 @@ Base Domain: $BASE_DOMAIN
NPM API URL: $NPM_API_URL NPM API URL: $NPM_API_URL
Data Directory: $DATA_DIR Data Directory: $DATA_DIR
NOTE: All settings are stored in the database and editable via Web UI.
No manual config file editing needed!
Access: Access:
------- -------
Web UI: http://${SERVER_IP}:8000 Web UI: http://${SERVER_IP}:8000

20
requirements.txt Normal file
View File

@@ -0,0 +1,20 @@
# NetBird MSP Appliance - Python Dependencies
fastapi==0.104.1
uvicorn[standard]==0.24.0
sqlalchemy==2.0.23
aiosqlite==0.19.0
pydantic==2.5.2
pydantic-settings==2.1.0
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
bcrypt==4.1.2
cryptography==41.0.7
python-multipart==0.0.6
httpx==0.25.2
jinja2==3.1.2
docker==7.0.0
psutil==5.9.7
pyyaml==6.0.1
pytest==7.4.3
pytest-asyncio==0.23.2
pytest-httpx==0.28.0

175
static/css/styles.css Normal file
View File

@@ -0,0 +1,175 @@
/* NetBird MSP Appliance - Custom Styles */
/* Login */
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
}
.login-card {
width: 100%;
max-width: 420px;
border-radius: 12px;
}
/* Stats cards */
.stat-card {
border-radius: 10px;
transition: transform 0.15s;
}
.stat-card:hover {
transform: translateY(-2px);
}
.stat-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
}
/* Table */
.table th {
font-weight: 600;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #6c757d;
}
.table td {
vertical-align: middle;
}
/* Log viewer */
.log-viewer {
max-height: 600px;
overflow-y: auto;
}
.log-entry {
padding: 4px 8px;
border-bottom: 1px solid #f0f0f0;
font-size: 0.85rem;
font-family: 'Consolas', 'Monaco', monospace;
}
.log-entry:last-child {
border-bottom: none;
}
.log-time {
color: #6c757d;
margin-right: 8px;
}
.log-pre {
background: #1e1e1e;
color: #d4d4d4;
padding: 12px;
border-radius: 6px;
max-height: 300px;
overflow-y: auto;
font-size: 0.8rem;
white-space: pre-wrap;
word-wrap: break-word;
}
/* Toast notification */
.toast-notification {
position: fixed;
bottom: 20px;
right: 20px;
background: #198754;
color: white;
padding: 12px 24px;
border-radius: 8px;
font-size: 0.9rem;
z-index: 9999;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
animation: toast-in 0.3s ease, toast-out 0.3s ease 2.7s;
}
@keyframes toast-in {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes toast-out {
from { opacity: 1; }
to { opacity: 0; }
}
/* Badge improvements */
.badge {
font-weight: 500;
font-size: 0.75rem;
padding: 0.35em 0.65em;
}
/* Page transitions */
.page-content {
animation: fade-in 0.15s ease;
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
/* Progress bars in monitoring */
.progress {
border-radius: 6px;
}
.progress-bar {
font-size: 0.75rem;
font-weight: 600;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.stat-card .fs-3 {
font-size: 1.5rem !important;
}
.btn-group-sm .btn {
padding: 0.2rem 0.4rem;
}
}
/* Custom scrollbar */
.log-pre::-webkit-scrollbar,
.log-viewer::-webkit-scrollbar {
width: 6px;
}
.log-pre::-webkit-scrollbar-thumb,
.log-viewer::-webkit-scrollbar-thumb {
background: #555;
border-radius: 3px;
}
/* Navbar brand */
.navbar-brand {
font-weight: 700;
letter-spacing: 0.5px;
}
/* Card improvements */
.card {
border-radius: 10px;
border: 1px solid rgba(0, 0, 0, 0.08);
}
.card-header {
font-weight: 600;
background: rgba(0, 0, 0, 0.02);
}

509
static/index.html Normal file
View File

@@ -0,0 +1,509 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NetBird MSP Appliance</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.min.css" rel="stylesheet">
<link href="/static/css/styles.css" rel="stylesheet">
</head>
<body>
<!-- Login Page -->
<div id="login-page" class="d-none">
<div class="login-container">
<div class="card login-card shadow">
<div class="card-body p-5">
<div class="text-center mb-4">
<i class="bi bi-hdd-network fs-1 text-primary"></i>
<h3 class="mt-2">NetBird MSP Appliance</h3>
<p class="text-muted">Multi-Tenant Management Platform</p>
</div>
<div id="login-error" class="alert alert-danger d-none"></div>
<form id="login-form">
<div class="mb-3">
<label class="form-label">Username</label>
<input type="text" class="form-control" id="login-username" required autofocus>
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<input type="password" class="form-control" id="login-password" required>
</div>
<button type="submit" class="btn btn-primary w-100" id="login-btn">
<span class="spinner-border spinner-border-sm d-none me-1" id="login-spinner"></span>
Sign In
</button>
</form>
</div>
</div>
</div>
</div>
<!-- Main Application -->
<div id="app-page" class="d-none">
<!-- Navbar -->
<nav class="navbar navbar-expand-lg navbar-dark bg-dark sticky-top">
<div class="container-fluid">
<a class="navbar-brand" href="#"><i class="bi bi-hdd-network me-2"></i>NetBird MSP</a>
<div class="d-flex align-items-center">
<button class="btn btn-success btn-sm me-3" onclick="showNewCustomerModal()">
<i class="bi bi-plus-lg me-1"></i>New Customer
</button>
<div class="dropdown">
<button class="btn btn-outline-light btn-sm dropdown-toggle" data-bs-toggle="dropdown">
<i class="bi bi-person-circle me-1"></i><span id="nav-username">Admin</span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="#" onclick="showPage('settings')"><i class="bi bi-gear me-2"></i>Settings</a></li>
<li><a class="dropdown-item" href="#" onclick="showPage('monitoring')"><i class="bi bi-activity me-2"></i>Monitoring</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item text-danger" href="#" onclick="logout()"><i class="bi bi-box-arrow-right me-2"></i>Logout</a></li>
</ul>
</div>
</div>
</div>
</nav>
<!-- Page: Dashboard -->
<div id="page-dashboard" class="page-content">
<div class="container-fluid p-4">
<!-- Stats Cards -->
<div class="row g-3 mb-4">
<div class="col-md-3">
<div class="card stat-card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<div class="text-muted small">Total Customers</div>
<div class="fs-3 fw-bold" id="stat-total">0</div>
</div>
<div class="stat-icon bg-primary bg-opacity-10 text-primary"><i class="bi bi-people"></i></div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<div class="text-muted small">Active</div>
<div class="fs-3 fw-bold text-success" id="stat-active">0</div>
</div>
<div class="stat-icon bg-success bg-opacity-10 text-success"><i class="bi bi-check-circle"></i></div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<div class="text-muted small">Inactive</div>
<div class="fs-3 fw-bold text-warning" id="stat-inactive">0</div>
</div>
<div class="stat-icon bg-warning bg-opacity-10 text-warning"><i class="bi bi-pause-circle"></i></div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<div class="text-muted small">Errors</div>
<div class="fs-3 fw-bold text-danger" id="stat-error">0</div>
</div>
<div class="stat-icon bg-danger bg-opacity-10 text-danger"><i class="bi bi-exclamation-triangle"></i></div>
</div>
</div>
</div>
</div>
</div>
<!-- Search & Filter -->
<div class="card shadow-sm mb-4">
<div class="card-body">
<div class="row g-2">
<div class="col-md-6">
<input type="text" class="form-control" id="search-input" placeholder="Search by name, subdomain, email...">
</div>
<div class="col-md-3">
<select class="form-select" id="status-filter">
<option value="">All Statuses</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="deploying">Deploying</option>
<option value="error">Error</option>
</select>
</div>
<div class="col-md-3 text-end">
<button class="btn btn-outline-secondary" onclick="loadCustomers()"><i class="bi bi-arrow-clockwise me-1"></i>Refresh</button>
</div>
</div>
</div>
</div>
<!-- Customers Table -->
<div class="card shadow-sm">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>ID</th>
<th>Name</th>
<th>Company</th>
<th>Subdomain</th>
<th>Status</th>
<th>Devices</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="customers-table-body">
<tr><td colspan="8" class="text-center text-muted py-4">Loading...</td></tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="card-footer d-flex justify-content-between align-items-center">
<div class="text-muted small" id="pagination-info">Showing 0 of 0</div>
<nav>
<ul class="pagination pagination-sm mb-0" id="pagination-controls"></ul>
</nav>
</div>
</div>
</div>
</div>
<!-- Page: Customer Detail -->
<div id="page-customer-detail" class="page-content d-none">
<div class="container-fluid p-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<button class="btn btn-outline-secondary btn-sm me-2" onclick="showPage('dashboard')"><i class="bi bi-arrow-left me-1"></i>Back</button>
<span class="fs-4 fw-bold" id="detail-customer-name">Customer</span>
<span class="badge ms-2" id="detail-customer-status">active</span>
</div>
<div>
<button class="btn btn-outline-primary btn-sm me-1" onclick="editCurrentCustomer()"><i class="bi bi-pencil me-1"></i>Edit</button>
<button class="btn btn-outline-danger btn-sm" onclick="deleteCurrentCustomer()"><i class="bi bi-trash me-1"></i>Delete</button>
</div>
</div>
<ul class="nav nav-tabs mb-3" id="detail-tabs">
<li class="nav-item"><a class="nav-link active" data-bs-toggle="tab" href="#tab-info">Info</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-deployment">Deployment</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-logs">Logs</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-health">Health</a></li>
</ul>
<div class="tab-content">
<!-- Tab: Info -->
<div class="tab-pane fade show active" id="tab-info">
<div class="card shadow-sm">
<div class="card-body" id="detail-info-content">Loading...</div>
</div>
</div>
<!-- Tab: Deployment -->
<div class="tab-pane fade" id="tab-deployment">
<div class="card shadow-sm">
<div class="card-body" id="detail-deployment-content">Loading...</div>
</div>
</div>
<!-- Tab: Logs -->
<div class="tab-pane fade" id="tab-logs">
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between">
<span>Container Logs</span>
<button class="btn btn-sm btn-outline-secondary" onclick="loadCustomerLogs()"><i class="bi bi-arrow-clockwise"></i> Refresh</button>
</div>
<div class="card-body">
<div id="detail-logs-content" class="log-viewer">No logs loaded.</div>
</div>
</div>
</div>
<!-- Tab: Health -->
<div class="tab-pane fade" id="tab-health">
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between">
<span>Health Check</span>
<button class="btn btn-sm btn-outline-secondary" onclick="loadCustomerHealth()"><i class="bi bi-arrow-clockwise"></i> Check</button>
</div>
<div class="card-body" id="detail-health-content">Click "Check" to run a health check.</div>
</div>
</div>
</div>
</div>
</div>
<!-- Page: Settings -->
<div id="page-settings" class="page-content d-none">
<div class="container-fluid p-4">
<h4 class="mb-4"><i class="bi bi-gear me-2"></i>System Settings</h4>
<div id="settings-alert" class="d-none"></div>
<ul class="nav nav-tabs mb-3">
<li class="nav-item"><a class="nav-link active" data-bs-toggle="tab" href="#settings-system">System Configuration</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#settings-npm">NPM Integration</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#settings-images">Docker Images</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#settings-security">Security</a></li>
</ul>
<div class="tab-content">
<!-- System Config -->
<div class="tab-pane fade show active" id="settings-system">
<div class="card shadow-sm">
<div class="card-body">
<form id="settings-system-form">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Base Domain</label>
<input type="text" class="form-control" id="cfg-base-domain" placeholder="yourdomain.com">
<div class="form-text">Customers get subdomains: kunde.yourdomain.com</div>
</div>
<div class="col-md-6">
<label class="form-label">Admin Email</label>
<input type="email" class="form-control" id="cfg-admin-email" placeholder="admin@yourdomain.com">
</div>
<div class="col-md-6">
<label class="form-label">Data Directory</label>
<input type="text" class="form-control" id="cfg-data-dir" placeholder="/opt/netbird-instances">
</div>
<div class="col-md-6">
<label class="form-label">Docker Network</label>
<input type="text" class="form-control" id="cfg-docker-network" placeholder="npm-network">
</div>
<div class="col-md-6">
<label class="form-label">Relay Base Port</label>
<input type="number" class="form-control" id="cfg-relay-base-port" min="1024" max="65535">
<div class="form-text">First UDP port for relay. Range: base to base+99</div>
</div>
</div>
<div class="mt-4">
<button type="submit" class="btn btn-primary"><i class="bi bi-save me-1"></i>Save System Settings</button>
</div>
</form>
</div>
</div>
</div>
<!-- NPM Integration -->
<div class="tab-pane fade" id="settings-npm">
<div class="card shadow-sm">
<div class="card-body">
<form id="settings-npm-form">
<div class="row g-3">
<div class="col-md-8">
<label class="form-label">NPM API URL</label>
<input type="url" class="form-control" id="cfg-npm-api-url" placeholder="http://nginx-proxy-manager:81/api">
</div>
<div class="col-md-8">
<label class="form-label">NPM API Token</label>
<div class="input-group">
<input type="password" class="form-control" id="cfg-npm-api-token" placeholder="Leave empty to keep current token">
<button class="btn btn-outline-secondary" type="button" onclick="togglePasswordVisibility('cfg-npm-api-token')"><i class="bi bi-eye"></i></button>
</div>
<div class="form-text" id="npm-token-status"></div>
</div>
</div>
<div class="mt-4">
<button type="submit" class="btn btn-primary me-2"><i class="bi bi-save me-1"></i>Save NPM Settings</button>
<button type="button" class="btn btn-outline-info" id="test-npm-btn" onclick="testNpmConnection()">
<span class="spinner-border spinner-border-sm d-none me-1" id="npm-test-spinner"></span>
<i class="bi bi-plug me-1"></i>Test Connection
</button>
</div>
</form>
<div id="npm-test-result" class="mt-3 d-none"></div>
</div>
</div>
</div>
<!-- Docker Images -->
<div class="tab-pane fade" id="settings-images">
<div class="card shadow-sm">
<div class="card-body">
<form id="settings-images-form">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Management Image</label>
<input type="text" class="form-control" id="cfg-mgmt-image" placeholder="netbirdio/management:latest">
</div>
<div class="col-md-6">
<label class="form-label">Signal Image</label>
<input type="text" class="form-control" id="cfg-signal-image" placeholder="netbirdio/signal:latest">
</div>
<div class="col-md-6">
<label class="form-label">Relay Image</label>
<input type="text" class="form-control" id="cfg-relay-image" placeholder="netbirdio/relay:latest">
</div>
<div class="col-md-6">
<label class="form-label">Dashboard Image</label>
<input type="text" class="form-control" id="cfg-dashboard-image" placeholder="netbirdio/dashboard:latest">
</div>
</div>
<div class="mt-4">
<button type="submit" class="btn btn-primary"><i class="bi bi-save me-1"></i>Save Image Settings</button>
</div>
</form>
</div>
</div>
</div>
<!-- Security -->
<div class="tab-pane fade" id="settings-security">
<div class="card shadow-sm">
<div class="card-body">
<h5 class="mb-3">Change Admin Password</h5>
<form id="change-password-form">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Current Password</label>
<input type="password" class="form-control" id="pw-current" required>
</div>
<div class="col-md-6"></div>
<div class="col-md-6">
<label class="form-label">New Password (min 12 chars)</label>
<input type="password" class="form-control" id="pw-new" required minlength="12">
</div>
<div class="col-md-6">
<label class="form-label">Confirm New Password</label>
<input type="password" class="form-control" id="pw-confirm" required minlength="12">
</div>
</div>
<div class="mt-4">
<button type="submit" class="btn btn-warning"><i class="bi bi-shield-lock me-1"></i>Change Password</button>
</div>
</form>
<div id="password-result" class="mt-3 d-none"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Page: Monitoring -->
<div id="page-monitoring" class="page-content d-none">
<div class="container-fluid p-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0"><i class="bi bi-activity me-2"></i>System Monitoring</h4>
<button class="btn btn-outline-secondary btn-sm" onclick="loadMonitoring()"><i class="bi bi-arrow-clockwise me-1"></i>Refresh</button>
</div>
<!-- Host Resources -->
<div class="card shadow-sm mb-4">
<div class="card-header">Host Resources</div>
<div class="card-body" id="monitoring-resources">Loading...</div>
</div>
<!-- Customer Statuses -->
<div class="card shadow-sm">
<div class="card-header">All Customer Deployments</div>
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead class="table-light">
<tr>
<th>ID</th>
<th>Name</th>
<th>Subdomain</th>
<th>Status</th>
<th>Deployment</th>
<th>Relay Port</th>
<th>Containers</th>
</tr>
</thead>
<tbody id="monitoring-customers-body">
<tr><td colspan="7" class="text-center text-muted py-4">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Modal: New/Edit Customer -->
<div class="modal fade" id="customer-modal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="customer-modal-title">New Customer</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="customer-modal-error" class="alert alert-danger d-none"></div>
<form id="customer-form">
<input type="hidden" id="customer-edit-id">
<div class="mb-3">
<label class="form-label">Name *</label>
<input type="text" class="form-control" id="cust-name" required>
</div>
<div class="mb-3">
<label class="form-label">Company</label>
<input type="text" class="form-control" id="cust-company">
</div>
<div class="mb-3">
<label class="form-label">Subdomain *</label>
<div class="input-group">
<input type="text" class="form-control" id="cust-subdomain" required pattern="[a-z0-9][a-z0-9-]*[a-z0-9]">
<span class="input-group-text" id="cust-subdomain-suffix">.domain.com</span>
</div>
<div class="form-text">Lowercase, alphanumeric + hyphens</div>
</div>
<div class="mb-3">
<label class="form-label">Email *</label>
<input type="email" class="form-control" id="cust-email" required>
</div>
<div class="mb-3">
<label class="form-label">Max Devices</label>
<input type="number" class="form-control" id="cust-max-devices" value="20" min="1">
</div>
<div class="mb-3">
<label class="form-label">Notes</label>
<textarea class="form-control" id="cust-notes" rows="2"></textarea>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="customer-save-btn" onclick="saveCustomer()">
<span class="spinner-border spinner-border-sm d-none me-1" id="customer-save-spinner"></span>
Save & Deploy
</button>
</div>
</div>
</div>
</div>
<!-- Modal: Delete Confirmation -->
<div class="modal fade" id="delete-modal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-danger text-white">
<h5 class="modal-title">Confirm Deletion</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>Are you sure you want to delete customer <strong id="delete-customer-name"></strong>?</p>
<p class="text-danger">This will remove all containers, NPM entries, and data. This action cannot be undone.</p>
<input type="hidden" id="delete-customer-id">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" onclick="confirmDeleteCustomer()">
<span class="spinner-border spinner-border-sm d-none me-1" id="delete-spinner"></span>
Delete
</button>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="/static/js/app.js"></script>
</body>
</html>

694
static/js/app.js Normal file
View File

@@ -0,0 +1,694 @@
/**
* NetBird MSP Appliance - Frontend Application
* Vanilla JavaScript with Bootstrap 5
*/
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
let authToken = localStorage.getItem('authToken') || null;
let currentUser = null;
let currentPage = 'dashboard';
let currentCustomerId = null;
let currentCustomerData = null;
let customersPage = 1;
// ---------------------------------------------------------------------------
// API helper
// ---------------------------------------------------------------------------
async function api(method, path, body = null) {
const opts = {
method,
headers: { 'Content-Type': 'application/json' },
};
if (authToken) {
opts.headers['Authorization'] = `Bearer ${authToken}`;
}
if (body) {
opts.body = JSON.stringify(body);
}
const resp = await fetch(`/api${path}`, opts);
if (resp.status === 401) {
logout();
throw new Error('Session expired.');
}
const data = await resp.json();
if (!resp.ok) {
throw new Error(data.detail || data.message || 'Request failed.');
}
return data;
}
// ---------------------------------------------------------------------------
// Auth
// ---------------------------------------------------------------------------
function initApp() {
if (authToken) {
api('GET', '/auth/me')
.then(user => {
currentUser = user;
document.getElementById('nav-username').textContent = user.username;
showAppPage();
loadDashboard();
})
.catch(() => {
authToken = null;
localStorage.removeItem('authToken');
showLoginPage();
});
} else {
showLoginPage();
}
}
function showLoginPage() {
document.getElementById('login-page').classList.remove('d-none');
document.getElementById('app-page').classList.add('d-none');
}
function showAppPage() {
document.getElementById('login-page').classList.add('d-none');
document.getElementById('app-page').classList.remove('d-none');
}
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const errorEl = document.getElementById('login-error');
const spinner = document.getElementById('login-spinner');
errorEl.classList.add('d-none');
spinner.classList.remove('d-none');
try {
const data = await api('POST', '/auth/login', {
username: document.getElementById('login-username').value,
password: document.getElementById('login-password').value,
});
authToken = data.access_token;
localStorage.setItem('authToken', authToken);
currentUser = data.user;
document.getElementById('nav-username').textContent = currentUser.username;
showAppPage();
loadDashboard();
} catch (err) {
errorEl.textContent = err.message;
errorEl.classList.remove('d-none');
} finally {
spinner.classList.add('d-none');
}
});
function logout() {
api('POST', '/auth/logout').catch(() => {});
authToken = null;
currentUser = null;
localStorage.removeItem('authToken');
showLoginPage();
}
// ---------------------------------------------------------------------------
// Navigation
// ---------------------------------------------------------------------------
function showPage(page) {
document.querySelectorAll('.page-content').forEach(el => el.classList.add('d-none'));
document.getElementById(`page-${page}`).classList.remove('d-none');
currentPage = page;
if (page === 'dashboard') loadDashboard();
else if (page === 'settings') loadSettings();
else if (page === 'monitoring') loadMonitoring();
}
// ---------------------------------------------------------------------------
// Dashboard
// ---------------------------------------------------------------------------
async function loadDashboard() {
await Promise.all([loadStats(), loadCustomers()]);
}
async function loadStats() {
try {
const data = await api('GET', '/monitoring/status');
document.getElementById('stat-total').textContent = data.total_customers;
document.getElementById('stat-active').textContent = data.active;
document.getElementById('stat-inactive').textContent = data.inactive;
document.getElementById('stat-error').textContent = data.error;
} catch (err) {
console.error('Failed to load stats:', err);
}
}
async function loadCustomers() {
const search = document.getElementById('search-input').value;
const status = document.getElementById('status-filter').value;
let url = `/customers?page=${customersPage}&per_page=25`;
if (search) url += `&search=${encodeURIComponent(search)}`;
if (status) url += `&status=${encodeURIComponent(status)}`;
try {
const data = await api('GET', url);
renderCustomersTable(data);
} catch (err) {
console.error('Failed to load customers:', err);
}
}
function renderCustomersTable(data) {
const tbody = document.getElementById('customers-table-body');
if (!data.items || data.items.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" class="text-center text-muted py-4">No customers found. Click "New Customer" to create one.</td></tr>';
document.getElementById('pagination-info').textContent = 'Showing 0 of 0';
document.getElementById('pagination-controls').innerHTML = '';
return;
}
tbody.innerHTML = data.items.map(c => `
<tr>
<td>${c.id}</td>
<td><a href="#" onclick="viewCustomer(${c.id})" class="text-decoration-none fw-semibold">${esc(c.name)}</a></td>
<td>${esc(c.company || '-')}</td>
<td><code>${esc(c.subdomain)}</code></td>
<td>${statusBadge(c.status)}</td>
<td>${c.max_devices}</td>
<td>${formatDate(c.created_at)}</td>
<td>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-primary" title="View" onclick="viewCustomer(${c.id})"><i class="bi bi-eye"></i></button>
${c.deployment && c.deployment.deployment_status === 'running'
? `<button class="btn btn-outline-warning" title="Stop" onclick="customerAction(${c.id},'stop')"><i class="bi bi-stop-circle"></i></button>`
: `<button class="btn btn-outline-success" title="Start" onclick="customerAction(${c.id},'start')"><i class="bi bi-play-circle"></i></button>`
}
<button class="btn btn-outline-info" title="Restart" onclick="customerAction(${c.id},'restart')"><i class="bi bi-arrow-repeat"></i></button>
<button class="btn btn-outline-danger" title="Delete" onclick="showDeleteModal(${c.id},'${esc(c.name)}')"><i class="bi bi-trash"></i></button>
</div>
</td>
</tr>
`).join('');
// Pagination
const start = (data.page - 1) * data.per_page + 1;
const end = Math.min(data.page * data.per_page, data.total);
document.getElementById('pagination-info').textContent = `Showing ${start}-${end} of ${data.total}`;
let paginationHtml = '';
for (let i = 1; i <= data.pages; i++) {
paginationHtml += `<li class="page-item ${i === data.page ? 'active' : ''}"><a class="page-link" href="#" onclick="goToPage(${i})">${i}</a></li>`;
}
document.getElementById('pagination-controls').innerHTML = paginationHtml;
}
function goToPage(page) {
customersPage = page;
loadCustomers();
}
// Search & filter listeners
document.getElementById('search-input').addEventListener('input', debounce(() => { customersPage = 1; loadCustomers(); }, 300));
document.getElementById('status-filter').addEventListener('change', () => { customersPage = 1; loadCustomers(); });
// ---------------------------------------------------------------------------
// Customer CRUD
// ---------------------------------------------------------------------------
function showNewCustomerModal() {
document.getElementById('customer-modal-title').textContent = 'New Customer';
document.getElementById('customer-edit-id').value = '';
document.getElementById('customer-form').reset();
document.getElementById('cust-max-devices').value = '20';
document.getElementById('customer-modal-error').classList.add('d-none');
document.getElementById('customer-save-btn').textContent = 'Save & Deploy';
// Update subdomain suffix
api('GET', '/settings/system').then(cfg => {
document.getElementById('cust-subdomain-suffix').textContent = `.${cfg.base_domain || 'domain.com'}`;
}).catch(() => {});
const modal = new bootstrap.Modal(document.getElementById('customer-modal'));
// Enable subdomain field for new customers
document.getElementById('cust-subdomain').disabled = false;
modal.show();
}
function editCurrentCustomer() {
if (!currentCustomerData) return;
const c = currentCustomerData;
document.getElementById('customer-modal-title').textContent = 'Edit Customer';
document.getElementById('customer-edit-id').value = c.id;
document.getElementById('cust-name').value = c.name;
document.getElementById('cust-company').value = c.company || '';
document.getElementById('cust-subdomain').value = c.subdomain;
document.getElementById('cust-subdomain').disabled = true; // Can't change subdomain
document.getElementById('cust-email').value = c.email;
document.getElementById('cust-max-devices').value = c.max_devices;
document.getElementById('cust-notes').value = c.notes || '';
document.getElementById('customer-modal-error').classList.add('d-none');
document.getElementById('customer-save-btn').textContent = 'Save Changes';
const modal = new bootstrap.Modal(document.getElementById('customer-modal'));
modal.show();
}
async function saveCustomer() {
const errorEl = document.getElementById('customer-modal-error');
const spinner = document.getElementById('customer-save-spinner');
errorEl.classList.add('d-none');
spinner.classList.remove('d-none');
const editId = document.getElementById('customer-edit-id').value;
const payload = {
name: document.getElementById('cust-name').value,
company: document.getElementById('cust-company').value || null,
email: document.getElementById('cust-email').value,
max_devices: parseInt(document.getElementById('cust-max-devices').value) || 20,
notes: document.getElementById('cust-notes').value || null,
};
try {
if (editId) {
await api('PUT', `/customers/${editId}`, payload);
} else {
payload.subdomain = document.getElementById('cust-subdomain').value.toLowerCase();
await api('POST', '/customers', payload);
}
bootstrap.Modal.getInstance(document.getElementById('customer-modal')).hide();
loadDashboard();
if (editId && currentCustomerId == editId) {
viewCustomer(editId);
}
} catch (err) {
errorEl.textContent = err.message;
errorEl.classList.remove('d-none');
} finally {
spinner.classList.add('d-none');
}
}
function showDeleteModal(id, name) {
document.getElementById('delete-customer-id').value = id;
document.getElementById('delete-customer-name').textContent = name;
new bootstrap.Modal(document.getElementById('delete-modal')).show();
}
function deleteCurrentCustomer() {
if (!currentCustomerData) return;
showDeleteModal(currentCustomerData.id, currentCustomerData.name);
}
async function confirmDeleteCustomer() {
const id = document.getElementById('delete-customer-id').value;
const spinner = document.getElementById('delete-spinner');
spinner.classList.remove('d-none');
try {
await api('DELETE', `/customers/${id}`);
bootstrap.Modal.getInstance(document.getElementById('delete-modal')).hide();
showPage('dashboard');
} catch (err) {
alert('Delete failed: ' + err.message);
} finally {
spinner.classList.add('d-none');
}
}
// ---------------------------------------------------------------------------
// Customer Actions (start/stop/restart)
// ---------------------------------------------------------------------------
async function customerAction(id, action) {
try {
await api('POST', `/customers/${id}/${action}`);
if (currentPage === 'dashboard') loadCustomers();
if (currentCustomerId == id) viewCustomer(id);
} catch (err) {
alert(`${action} failed: ${err.message}`);
}
}
// ---------------------------------------------------------------------------
// Customer Detail
// ---------------------------------------------------------------------------
async function viewCustomer(id) {
currentCustomerId = id;
showPage('customer-detail');
try {
const data = await api('GET', `/customers/${id}`);
currentCustomerData = data;
document.getElementById('detail-customer-name').textContent = data.name;
const badge = document.getElementById('detail-customer-status');
badge.innerHTML = statusBadge(data.status);
// Info tab
document.getElementById('detail-info-content').innerHTML = `
<div class="row g-3">
<div class="col-md-6"><strong>Name:</strong> ${esc(data.name)}</div>
<div class="col-md-6"><strong>Company:</strong> ${esc(data.company || '-')}</div>
<div class="col-md-6"><strong>Subdomain:</strong> <code>${esc(data.subdomain)}</code></div>
<div class="col-md-6"><strong>Email:</strong> ${esc(data.email)}</div>
<div class="col-md-6"><strong>Max Devices:</strong> ${data.max_devices}</div>
<div class="col-md-6"><strong>Status:</strong> ${statusBadge(data.status)}</div>
<div class="col-md-6"><strong>Created:</strong> ${formatDate(data.created_at)}</div>
<div class="col-md-6"><strong>Updated:</strong> ${formatDate(data.updated_at)}</div>
${data.notes ? `<div class="col-12"><strong>Notes:</strong> ${esc(data.notes)}</div>` : ''}
</div>
`;
// Deployment tab
if (data.deployment) {
const d = data.deployment;
document.getElementById('detail-deployment-content').innerHTML = `
<div class="row g-3">
<div class="col-md-6"><strong>Status:</strong> ${statusBadge(d.deployment_status)}</div>
<div class="col-md-6"><strong>Relay UDP Port:</strong> ${d.relay_udp_port}</div>
<div class="col-md-6"><strong>Container Prefix:</strong> <code>${esc(d.container_prefix)}</code></div>
<div class="col-md-6"><strong>Deployed:</strong> ${formatDate(d.deployed_at)}</div>
<div class="col-12">
<strong>Setup URL:</strong>
<div class="input-group mt-1">
<input type="text" class="form-control" value="${esc(d.setup_url || '')}" readonly id="setup-url-input">
<button class="btn btn-outline-secondary" onclick="copySetupUrl()"><i class="bi bi-clipboard"></i> Copy</button>
</div>
</div>
</div>
<div class="mt-3">
<button class="btn btn-success btn-sm me-1" onclick="customerAction(${id},'start')"><i class="bi bi-play-circle me-1"></i>Start</button>
<button class="btn btn-warning btn-sm me-1" onclick="customerAction(${id},'stop')"><i class="bi bi-stop-circle me-1"></i>Stop</button>
<button class="btn btn-info btn-sm me-1" onclick="customerAction(${id},'restart')"><i class="bi bi-arrow-repeat me-1"></i>Restart</button>
<button class="btn btn-outline-primary btn-sm" onclick="customerAction(${id},'deploy')"><i class="bi bi-rocket me-1"></i>Re-Deploy</button>
</div>
`;
} else {
document.getElementById('detail-deployment-content').innerHTML = `
<p class="text-muted">No deployment found.</p>
<button class="btn btn-primary" onclick="customerAction(${id},'deploy')"><i class="bi bi-rocket me-1"></i>Deploy Now</button>
`;
}
// Logs tab (preview from deployment_logs table)
if (data.logs && data.logs.length > 0) {
document.getElementById('detail-logs-content').innerHTML = data.logs.map(l =>
`<div class="log-entry log-${l.status}"><span class="log-time">${formatDate(l.created_at)}</span> <span class="badge bg-${l.status === 'success' ? 'success' : l.status === 'error' ? 'danger' : 'info'}">${l.status}</span> <strong>${esc(l.action)}</strong>: ${esc(l.message)}</div>`
).join('');
}
} catch (err) {
document.getElementById('detail-info-content').innerHTML = `<div class="alert alert-danger">${err.message}</div>`;
}
}
async function loadCustomerLogs() {
if (!currentCustomerId) return;
try {
const data = await api('GET', `/customers/${currentCustomerId}/logs`);
const content = document.getElementById('detail-logs-content');
if (!data.logs || Object.keys(data.logs).length === 0) {
content.innerHTML = '<p class="text-muted">No container logs available.</p>';
return;
}
let html = '';
for (const [name, logText] of Object.entries(data.logs)) {
html += `<h6 class="mt-3"><i class="bi bi-box me-1"></i>${esc(name)}</h6><pre class="log-pre">${esc(logText)}</pre>`;
}
content.innerHTML = html;
} catch (err) {
document.getElementById('detail-logs-content').innerHTML = `<div class="alert alert-danger">${err.message}</div>`;
}
}
async function loadCustomerHealth() {
if (!currentCustomerId) return;
try {
const data = await api('GET', `/customers/${currentCustomerId}/health`);
const content = document.getElementById('detail-health-content');
let html = `<div class="mb-3"><strong>Overall:</strong> ${data.healthy ? '<span class="text-success">Healthy</span>' : '<span class="text-danger">Unhealthy</span>'}</div>`;
if (data.containers && data.containers.length > 0) {
html += '<table class="table table-sm"><thead><tr><th>Container</th><th>Status</th><th>Health</th><th>Image</th></tr></thead><tbody>';
data.containers.forEach(c => {
const statusClass = c.status === 'running' ? 'text-success' : 'text-danger';
html += `<tr><td>${esc(c.name)}</td><td class="${statusClass}">${c.status}</td><td>${c.health}</td><td><code>${esc(c.image)}</code></td></tr>`;
});
html += '</tbody></table>';
}
html += `<div class="text-muted small">Last check: ${formatDate(data.last_check)}</div>`;
content.innerHTML = html;
} catch (err) {
document.getElementById('detail-health-content').innerHTML = `<div class="alert alert-danger">${err.message}</div>`;
}
}
function copySetupUrl() {
const input = document.getElementById('setup-url-input');
navigator.clipboard.writeText(input.value).then(() => {
showToast('Setup URL copied to clipboard.');
});
}
// ---------------------------------------------------------------------------
// Settings
// ---------------------------------------------------------------------------
async function loadSettings() {
try {
const cfg = await api('GET', '/settings/system');
document.getElementById('cfg-base-domain').value = cfg.base_domain || '';
document.getElementById('cfg-admin-email').value = cfg.admin_email || '';
document.getElementById('cfg-data-dir').value = cfg.data_dir || '';
document.getElementById('cfg-docker-network').value = cfg.docker_network || '';
document.getElementById('cfg-relay-base-port').value = cfg.relay_base_port || 3478;
document.getElementById('cfg-npm-api-url').value = cfg.npm_api_url || '';
document.getElementById('npm-token-status').textContent = cfg.npm_api_token_set ? 'Token is set (leave empty to keep current)' : 'No token configured';
document.getElementById('cfg-mgmt-image').value = cfg.netbird_management_image || '';
document.getElementById('cfg-signal-image').value = cfg.netbird_signal_image || '';
document.getElementById('cfg-relay-image').value = cfg.netbird_relay_image || '';
document.getElementById('cfg-dashboard-image').value = cfg.netbird_dashboard_image || '';
} catch (err) {
showSettingsAlert('danger', 'Failed to load settings: ' + err.message);
}
}
// System settings form
document.getElementById('settings-system-form').addEventListener('submit', async (e) => {
e.preventDefault();
try {
await api('PUT', '/settings/system', {
base_domain: document.getElementById('cfg-base-domain').value,
admin_email: document.getElementById('cfg-admin-email').value,
data_dir: document.getElementById('cfg-data-dir').value,
docker_network: document.getElementById('cfg-docker-network').value,
relay_base_port: parseInt(document.getElementById('cfg-relay-base-port').value),
});
showSettingsAlert('success', 'System settings saved.');
} catch (err) {
showSettingsAlert('danger', 'Failed: ' + err.message);
}
});
// NPM settings form
document.getElementById('settings-npm-form').addEventListener('submit', async (e) => {
e.preventDefault();
const payload = { npm_api_url: document.getElementById('cfg-npm-api-url').value };
const token = document.getElementById('cfg-npm-api-token').value;
if (token) payload.npm_api_token = token;
try {
await api('PUT', '/settings/system', payload);
showSettingsAlert('success', 'NPM settings saved.');
document.getElementById('cfg-npm-api-token').value = '';
loadSettings();
} catch (err) {
showSettingsAlert('danger', 'Failed: ' + err.message);
}
});
// Image settings form
document.getElementById('settings-images-form').addEventListener('submit', async (e) => {
e.preventDefault();
try {
await api('PUT', '/settings/system', {
netbird_management_image: document.getElementById('cfg-mgmt-image').value,
netbird_signal_image: document.getElementById('cfg-signal-image').value,
netbird_relay_image: document.getElementById('cfg-relay-image').value,
netbird_dashboard_image: document.getElementById('cfg-dashboard-image').value,
});
showSettingsAlert('success', 'Image settings saved.');
} catch (err) {
showSettingsAlert('danger', 'Failed: ' + err.message);
}
});
// Test NPM connection
async function testNpmConnection() {
const spinner = document.getElementById('npm-test-spinner');
const resultEl = document.getElementById('npm-test-result');
spinner.classList.remove('d-none');
resultEl.classList.add('d-none');
try {
const data = await api('GET', '/settings/test-npm');
resultEl.className = `mt-3 alert alert-${data.ok ? 'success' : 'danger'}`;
resultEl.textContent = data.message;
resultEl.classList.remove('d-none');
} catch (err) {
resultEl.className = 'mt-3 alert alert-danger';
resultEl.textContent = err.message;
resultEl.classList.remove('d-none');
} finally {
spinner.classList.add('d-none');
}
}
// Change password form
document.getElementById('change-password-form').addEventListener('submit', async (e) => {
e.preventDefault();
const resultEl = document.getElementById('password-result');
const newPw = document.getElementById('pw-new').value;
const confirmPw = document.getElementById('pw-confirm').value;
if (newPw !== confirmPw) {
resultEl.className = 'mt-3 alert alert-danger';
resultEl.textContent = 'Passwords do not match.';
resultEl.classList.remove('d-none');
return;
}
try {
await api('POST', '/auth/change-password', {
current_password: document.getElementById('pw-current').value,
new_password: newPw,
});
resultEl.className = 'mt-3 alert alert-success';
resultEl.textContent = 'Password changed successfully.';
resultEl.classList.remove('d-none');
document.getElementById('change-password-form').reset();
} catch (err) {
resultEl.className = 'mt-3 alert alert-danger';
resultEl.textContent = err.message;
resultEl.classList.remove('d-none');
}
});
function showSettingsAlert(type, msg) {
const el = document.getElementById('settings-alert');
el.className = `alert alert-${type} alert-dismissible fade show`;
el.innerHTML = `${msg}<button type="button" class="btn-close" data-bs-dismiss="alert"></button>`;
el.classList.remove('d-none');
setTimeout(() => el.classList.add('d-none'), 5000);
}
function togglePasswordVisibility(inputId) {
const input = document.getElementById(inputId);
input.type = input.type === 'password' ? 'text' : 'password';
}
// ---------------------------------------------------------------------------
// Monitoring
// ---------------------------------------------------------------------------
async function loadMonitoring() {
await Promise.all([loadResources(), loadAllCustomerStatuses()]);
}
async function loadResources() {
try {
const data = await api('GET', '/monitoring/resources');
document.getElementById('monitoring-resources').innerHTML = `
<div class="row g-3">
<div class="col-md-3">
<div class="text-muted small">Hostname</div>
<div class="fw-bold">${esc(data.hostname)}</div>
<div class="text-muted small">${esc(data.os)}</div>
</div>
<div class="col-md-3">
<div class="text-muted small">CPU (${data.cpu.count} cores)</div>
<div class="progress mt-1" style="height: 20px;">
<div class="progress-bar ${data.cpu.percent > 80 ? 'bg-danger' : data.cpu.percent > 50 ? 'bg-warning' : 'bg-success'}"
style="width: ${data.cpu.percent}%">${data.cpu.percent}%</div>
</div>
</div>
<div class="col-md-3">
<div class="text-muted small">Memory (${data.memory.used_gb}/${data.memory.total_gb} GB)</div>
<div class="progress mt-1" style="height: 20px;">
<div class="progress-bar ${data.memory.percent > 80 ? 'bg-danger' : data.memory.percent > 50 ? 'bg-warning' : 'bg-success'}"
style="width: ${data.memory.percent}%">${data.memory.percent}%</div>
</div>
</div>
<div class="col-md-3">
<div class="text-muted small">Disk (${data.disk.used_gb}/${data.disk.total_gb} GB)</div>
<div class="progress mt-1" style="height: 20px;">
<div class="progress-bar ${data.disk.percent > 80 ? 'bg-danger' : data.disk.percent > 50 ? 'bg-warning' : 'bg-success'}"
style="width: ${data.disk.percent}%">${data.disk.percent}%</div>
</div>
</div>
</div>
`;
} catch (err) {
document.getElementById('monitoring-resources').innerHTML = `<div class="alert alert-danger">${err.message}</div>`;
}
}
async function loadAllCustomerStatuses() {
try {
const data = await api('GET', '/monitoring/customers');
const tbody = document.getElementById('monitoring-customers-body');
if (!data || data.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-4">No customers.</td></tr>';
return;
}
tbody.innerHTML = data.map(c => {
const containerInfo = c.containers.map(ct => `${ct.name}: ${ct.status}`).join(', ') || '-';
return `<tr>
<td>${c.id}</td>
<td>${esc(c.name)}</td>
<td><code>${esc(c.subdomain)}</code></td>
<td>${statusBadge(c.status)}</td>
<td>${c.deployment_status ? statusBadge(c.deployment_status) : '-'}</td>
<td>${c.relay_udp_port || '-'}</td>
<td class="small">${esc(containerInfo)}</td>
</tr>`;
}).join('');
} catch (err) {
document.getElementById('monitoring-customers-body').innerHTML = `<tr><td colspan="7" class="text-danger">${err.message}</td></tr>`;
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function statusBadge(status) {
const map = {
active: 'success', running: 'success',
inactive: 'secondary', stopped: 'secondary',
deploying: 'info', pending: 'info',
error: 'danger', failed: 'danger',
};
const color = map[status] || 'secondary';
return `<span class="badge bg-${color}">${status}</span>`;
}
function formatDate(isoStr) {
if (!isoStr) return '-';
const d = new Date(isoStr);
return d.toLocaleDateString('de-DE') + ' ' + d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
}
function esc(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function debounce(fn, delay) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
function showToast(message) {
// Simple inline notification
const el = document.createElement('div');
el.className = 'toast-notification';
el.textContent = message;
document.body.appendChild(el);
setTimeout(() => el.remove(), 3000);
}
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
document.addEventListener('DOMContentLoaded', initApp);

View File

@@ -0,0 +1,67 @@
version: '3.8'
networks:
{{ docker_network }}:
external: true
services:
netbird-management:
image: {{ netbird_management_image }}
container_name: netbird-kunde{{ customer_id }}-management
restart: unless-stopped
networks:
- {{ docker_network }}
volumes:
- {{ instance_dir }}/data/management:/var/lib/netbird
- {{ instance_dir }}/management.json:/etc/netbird/management.json
command:
- "--port"
- "80"
- "--log-file"
- "console"
- "--log-level"
- "info"
- "--single-account-mode-domain={{ subdomain }}.{{ base_domain }}"
- "--dns-domain={{ subdomain }}.{{ base_domain }}"
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:80/api/accounts"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
netbird-signal:
image: {{ netbird_signal_image }}
container_name: netbird-kunde{{ customer_id }}-signal
restart: unless-stopped
networks:
- {{ docker_network }}
volumes:
- {{ instance_dir }}/data/signal:/var/lib/netbird
netbird-relay:
image: {{ netbird_relay_image }}
container_name: netbird-kunde{{ customer_id }}-relay
restart: unless-stopped
networks:
- {{ docker_network }}
ports:
- "{{ relay_udp_port }}:3478/udp"
env_file:
- {{ instance_dir }}/relay.env
environment:
- NB_ENABLE_STUN=true
- NB_STUN_PORTS=3478
- NB_LISTEN_ADDRESS=:80
- NB_EXPOSED_ADDRESS=rels://{{ subdomain }}.{{ base_domain }}:443
- NB_AUTH_SECRET={{ relay_secret }}
netbird-dashboard:
image: {{ netbird_dashboard_image }}
container_name: netbird-kunde{{ customer_id }}-dashboard
restart: unless-stopped
networks:
- {{ docker_network }}
environment:
- NETBIRD_MGMT_API_ENDPOINT=https://{{ subdomain }}.{{ base_domain }}
- NETBIRD_MGMT_GRPC_API_ENDPOINT=https://{{ subdomain }}.{{ base_domain }}

View File

@@ -0,0 +1,63 @@
{
"Stuns": [
{
"Proto": "udp",
"URI": "stun:{{ subdomain }}.{{ base_domain }}:{{ relay_udp_port }}",
"Username": "",
"Password": null
}
],
"TURNConfig": {
"Turns": [
{
"Proto": "udp",
"URI": "turn:{{ subdomain }}.{{ base_domain }}:{{ relay_udp_port }}",
"Username": "netbird",
"Password": "{{ relay_secret }}"
}
],
"CredentialsTTL": "12h",
"Secret": "{{ relay_secret }}",
"TimeBasedCredentials": false
},
"Relay": {
"Addresses": [
"rels://{{ subdomain }}.{{ base_domain }}:443"
],
"CredentialsTTL": "12h",
"Secret": "{{ relay_secret }}"
},
"Signal": {
"Proto": "https",
"URI": "{{ subdomain }}.{{ base_domain }}:443",
"Username": "",
"Password": null
},
"HttpConfig": {
"AuthIssuer": "https://{{ subdomain }}.{{ base_domain }}",
"AuthAudience": "{{ subdomain }}.{{ base_domain }}",
"OIDCConfigEndpoint": ""
},
"IdpManagerConfig": {
"ManagerType": "none"
},
"DeviceAuthorizationFlow": {
"Provider": "none"
},
"PKCEAuthorizationFlow": {
"ProviderConfig": {
"Audience": "{{ subdomain }}.{{ base_domain }}",
"ClientID": "",
"ClientSecret": "",
"Domain": "",
"AuthorizationEndpoint": "",
"TokenEndpoint": "",
"Scope": "openid profile email",
"RedirectURLs": [
"https://{{ subdomain }}.{{ base_domain }}/auth/callback"
],
"UseIDToken": false
}
},
"DataStoreEncryptionKey": "{{ relay_secret }}"
}

7
templates/relay.env.j2 Normal file
View File

@@ -0,0 +1,7 @@
# NetBird Relay Environment - Customer {{ customer_id }}
# {{ subdomain }}.{{ base_domain }}
NB_AUTH_SECRET={{ relay_secret }}
NB_LISTEN_ADDRESS=:80
NB_EXPOSED_ADDRESS=rels://{{ subdomain }}.{{ base_domain }}:443
NB_ENABLE_STUN=true
NB_STUN_PORTS=3478

0
tests/__init__.py Normal file
View File

89
tests/conftest.py Normal file
View File

@@ -0,0 +1,89 @@
"""Shared test fixtures for the NetBird MSP Appliance test suite."""
import os
import pytest
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker
# Override env vars BEFORE importing app modules
os.environ["SECRET_KEY"] = "test-secret-key-for-unit-tests"
os.environ["DATABASE_PATH"] = ":memory:"
from app.database import Base
from app.models import Customer, Deployment, DeploymentLog, SystemConfig, User
from app.utils.security import hash_password, encrypt_value
@pytest.fixture()
def db_session():
"""Create an in-memory SQLite database session for tests."""
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
@event.listens_for(engine, "connect")
def _set_pragma(dbapi_conn, _):
cursor = dbapi_conn.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
Base.metadata.create_all(bind=engine)
Session = sessionmaker(bind=engine)
session = Session()
# Seed admin user
admin = User(
username="admin",
password_hash=hash_password("testpassword123"),
email="admin@test.com",
)
session.add(admin)
# Seed system config
config = SystemConfig(
id=1,
base_domain="test.example.com",
admin_email="admin@test.com",
npm_api_url="http://localhost:81/api",
npm_api_token_encrypted=encrypt_value("test-npm-token"),
data_dir="/tmp/netbird-test",
docker_network="test-network",
relay_base_port=3478,
)
session.add(config)
session.commit()
yield session
session.close()
@pytest.fixture()
def sample_customer(db_session):
"""Create and return a sample customer."""
customer = Customer(
name="Test Customer",
company="Test Corp",
subdomain="testcust",
email="test@example.com",
max_devices=20,
status="active",
)
db_session.add(customer)
db_session.commit()
db_session.refresh(customer)
return customer
@pytest.fixture()
def sample_deployment(db_session, sample_customer):
"""Create and return a sample deployment for the sample customer."""
deployment = Deployment(
customer_id=sample_customer.id,
container_prefix=f"netbird-kunde{sample_customer.id}",
relay_udp_port=3478,
relay_secret=encrypt_value("test-relay-secret"),
setup_url=f"https://testcust.test.example.com",
deployment_status="running",
)
db_session.add(deployment)
db_session.commit()
db_session.refresh(deployment)
return deployment

220
tests/test_customer_api.py Normal file
View File

@@ -0,0 +1,220 @@
"""Unit and API tests for customer management."""
import os
import pytest
from unittest.mock import patch, AsyncMock, MagicMock
os.environ["SECRET_KEY"] = "test-secret-key-for-unit-tests"
os.environ["DATABASE_PATH"] = ":memory:"
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker
from app.database import Base, get_db
from app.main import app
from app.models import Customer, User, SystemConfig
from app.utils.security import hash_password, encrypt_value
from app.dependencies import get_current_user
# ---------------------------------------------------------------------------
# Test fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def test_db():
"""Create a test database."""
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
@event.listens_for(engine, "connect")
def _set_pragma(dbapi_conn, _):
cursor = dbapi_conn.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
Base.metadata.create_all(bind=engine)
Session = sessionmaker(bind=engine)
session = Session()
# Seed data
admin = User(username="admin", password_hash=hash_password("testpassword123"), email="admin@test.com")
session.add(admin)
config = SystemConfig(
id=1,
base_domain="test.example.com",
admin_email="admin@test.com",
npm_api_url="http://localhost:81/api",
npm_api_token_encrypted=encrypt_value("test-npm-token"),
)
session.add(config)
session.commit()
yield session
session.close()
@pytest.fixture()
def client(test_db):
"""Create a test client with overridden dependencies."""
admin = test_db.query(User).filter(User.username == "admin").first()
def override_get_db():
yield test_db
def override_get_user():
return admin
app.dependency_overrides[get_db] = override_get_db
app.dependency_overrides[get_current_user] = override_get_user
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestCustomerList:
"""Tests for GET /api/customers."""
def test_empty_list(self, client: TestClient):
"""List returns empty when no customers exist."""
resp = client.get("/api/customers")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 0
assert data["items"] == []
def test_list_with_customers(self, client: TestClient, test_db):
"""List returns customers after creating them."""
for i in range(3):
test_db.add(Customer(name=f"Customer {i}", subdomain=f"cust{i}", email=f"c{i}@test.com"))
test_db.commit()
resp = client.get("/api/customers")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 3
assert len(data["items"]) == 3
def test_search_filter(self, client: TestClient, test_db):
"""Search filters customers by name/subdomain/email."""
test_db.add(Customer(name="Alpha Corp", subdomain="alpha", email="alpha@test.com"))
test_db.add(Customer(name="Beta Inc", subdomain="beta", email="beta@test.com"))
test_db.commit()
resp = client.get("/api/customers?search=alpha")
data = resp.json()
assert data["total"] == 1
assert data["items"][0]["name"] == "Alpha Corp"
def test_status_filter(self, client: TestClient, test_db):
"""Status filter returns only matching customers."""
test_db.add(Customer(name="Active", subdomain="active1", email="a@t.com", status="active"))
test_db.add(Customer(name="Error", subdomain="error1", email="e@t.com", status="error"))
test_db.commit()
resp = client.get("/api/customers?status=error")
data = resp.json()
assert data["total"] == 1
assert data["items"][0]["status"] == "error"
class TestCustomerCreate:
"""Tests for POST /api/customers."""
@patch("app.services.netbird_service.deploy_customer", new_callable=AsyncMock)
def test_create_customer(self, mock_deploy, client: TestClient):
"""Creating a customer returns 201 and triggers deployment."""
mock_deploy.return_value = {"success": True, "setup_url": "https://new.test.example.com"}
resp = client.post("/api/customers", json={
"name": "New Customer",
"subdomain": "newcust",
"email": "new@test.com",
})
assert resp.status_code == 201
data = resp.json()
assert data["name"] == "New Customer"
assert data["subdomain"] == "newcust"
def test_duplicate_subdomain(self, client: TestClient, test_db):
"""Duplicate subdomain returns 409."""
test_db.add(Customer(name="Existing", subdomain="taken", email="e@test.com"))
test_db.commit()
resp = client.post("/api/customers", json={
"name": "Another",
"subdomain": "taken",
"email": "a@test.com",
})
assert resp.status_code == 409
def test_invalid_subdomain(self, client: TestClient):
"""Invalid subdomain format returns 422."""
resp = client.post("/api/customers", json={
"name": "Bad",
"subdomain": "UPPER_CASE!",
"email": "b@test.com",
})
assert resp.status_code == 422
def test_invalid_email(self, client: TestClient):
"""Invalid email returns 422."""
resp = client.post("/api/customers", json={
"name": "Bad Email",
"subdomain": "bademail",
"email": "not-an-email",
})
assert resp.status_code == 422
class TestCustomerDetail:
"""Tests for GET/PUT/DELETE /api/customers/{id}."""
def test_get_customer(self, client: TestClient, test_db):
"""Get customer returns full details."""
cust = Customer(name="Detail Test", subdomain="detail", email="d@test.com")
test_db.add(cust)
test_db.commit()
test_db.refresh(cust)
resp = client.get(f"/api/customers/{cust.id}")
assert resp.status_code == 200
assert resp.json()["name"] == "Detail Test"
def test_get_nonexistent(self, client: TestClient):
"""Get nonexistent customer returns 404."""
resp = client.get("/api/customers/999")
assert resp.status_code == 404
def test_update_customer(self, client: TestClient, test_db):
"""Update customer fields."""
cust = Customer(name="Before", subdomain="update1", email="u@test.com")
test_db.add(cust)
test_db.commit()
test_db.refresh(cust)
resp = client.put(f"/api/customers/{cust.id}", json={"name": "After"})
assert resp.status_code == 200
assert resp.json()["name"] == "After"
@patch("app.services.netbird_service.undeploy_customer", new_callable=AsyncMock)
def test_delete_customer(self, mock_undeploy, client: TestClient, test_db):
"""Delete customer returns success."""
mock_undeploy.return_value = {"success": True}
cust = Customer(name="ToDelete", subdomain="del1", email="del@test.com")
test_db.add(cust)
test_db.commit()
test_db.refresh(cust)
resp = client.delete(f"/api/customers/{cust.id}")
assert resp.status_code == 200
# Verify deleted
resp = client.get(f"/api/customers/{cust.id}")
assert resp.status_code == 404

174
tests/test_deployment.py Normal file
View File

@@ -0,0 +1,174 @@
"""Integration tests for the deployment workflow."""
import os
import pytest
from unittest.mock import patch, AsyncMock, MagicMock
os.environ["SECRET_KEY"] = "test-secret-key-for-unit-tests"
os.environ["DATABASE_PATH"] = ":memory:"
from app.models import Customer, Deployment, DeploymentLog
from app.services import netbird_service
class TestDeploymentWorkflow:
"""Tests for the full deploy/undeploy lifecycle."""
@patch("app.services.netbird_service.docker_service")
@patch("app.services.netbird_service.npm_service")
@patch("app.services.netbird_service.port_manager")
@pytest.mark.asyncio
async def test_successful_deployment(
self, mock_port_mgr, mock_npm, mock_docker, db_session, sample_customer
):
"""Full deployment creates containers, NPM entry, and DB records."""
mock_port_mgr.allocate_port.return_value = 3478
mock_docker.compose_up.return_value = True
mock_docker.wait_for_healthy.return_value = True
mock_npm.create_proxy_host = AsyncMock(return_value={"proxy_id": 42})
# Create temp dir for templates
os.makedirs("/tmp/netbird-test", exist_ok=True)
result = await netbird_service.deploy_customer(db_session, sample_customer.id)
assert result["success"] is True
assert "setup_url" in result
assert result["setup_url"].startswith("https://")
# Verify deployment record created
dep = db_session.query(Deployment).filter(
Deployment.customer_id == sample_customer.id
).first()
assert dep is not None
assert dep.deployment_status == "running"
assert dep.relay_udp_port == 3478
# Verify customer status updated
db_session.refresh(sample_customer)
assert sample_customer.status == "active"
@patch("app.services.netbird_service.docker_service")
@patch("app.services.netbird_service.npm_service")
@patch("app.services.netbird_service.port_manager")
@pytest.mark.asyncio
async def test_deployment_rollback_on_docker_failure(
self, mock_port_mgr, mock_npm, mock_docker, db_session, sample_customer
):
"""Failed docker compose up triggers rollback."""
mock_port_mgr.allocate_port.return_value = 3479
mock_docker.compose_up.side_effect = RuntimeError("Docker compose failed")
mock_docker.compose_down.return_value = True
os.makedirs("/tmp/netbird-test", exist_ok=True)
result = await netbird_service.deploy_customer(db_session, sample_customer.id)
assert result["success"] is False
assert "Docker compose failed" in result["error"]
# Verify rollback
db_session.refresh(sample_customer)
assert sample_customer.status == "error"
# Verify error log
logs = db_session.query(DeploymentLog).filter(
DeploymentLog.customer_id == sample_customer.id,
DeploymentLog.status == "error",
).all()
assert len(logs) >= 1
@patch("app.services.netbird_service.docker_service")
@patch("app.services.netbird_service.npm_service")
@pytest.mark.asyncio
async def test_undeploy_customer(
self, mock_npm, mock_docker, db_session, sample_customer, sample_deployment
):
"""Undeployment removes containers, NPM entry, and cleans up."""
mock_docker.compose_down.return_value = True
mock_npm.delete_proxy_host = AsyncMock(return_value=True)
result = await netbird_service.undeploy_customer(db_session, sample_customer.id)
assert result["success"] is True
# Verify deployment record removed
dep = db_session.query(Deployment).filter(
Deployment.customer_id == sample_customer.id
).first()
assert dep is None
class TestStartStopRestart:
"""Tests for start/stop/restart operations."""
@patch("app.services.netbird_service.docker_service")
def test_stop_customer(self, mock_docker, db_session, sample_customer, sample_deployment):
"""Stop sets deployment_status to stopped."""
mock_docker.compose_stop.return_value = True
result = netbird_service.stop_customer(db_session, sample_customer.id)
assert result["success"] is True
db_session.refresh(sample_deployment)
assert sample_deployment.deployment_status == "stopped"
@patch("app.services.netbird_service.docker_service")
def test_start_customer(self, mock_docker, db_session, sample_customer, sample_deployment):
"""Start sets deployment_status to running."""
mock_docker.compose_start.return_value = True
result = netbird_service.start_customer(db_session, sample_customer.id)
assert result["success"] is True
db_session.refresh(sample_deployment)
assert sample_deployment.deployment_status == "running"
@patch("app.services.netbird_service.docker_service")
def test_restart_customer(self, mock_docker, db_session, sample_customer, sample_deployment):
"""Restart sets deployment_status to running."""
mock_docker.compose_restart.return_value = True
result = netbird_service.restart_customer(db_session, sample_customer.id)
assert result["success"] is True
db_session.refresh(sample_deployment)
assert sample_deployment.deployment_status == "running"
def test_stop_nonexistent_deployment(self, db_session, sample_customer):
"""Stop fails gracefully when no deployment exists."""
result = netbird_service.stop_customer(db_session, sample_customer.id)
assert result["success"] is False
class TestHealthCheck:
"""Tests for health check functionality."""
@patch("app.services.netbird_service.docker_service")
def test_healthy_deployment(self, mock_docker, db_session, sample_customer, sample_deployment):
"""Health check returns healthy when all containers are running."""
mock_docker.get_container_status.return_value = [
{"name": "netbird-kunde1-management", "status": "running", "health": "healthy", "image": "test", "created": ""},
{"name": "netbird-kunde1-signal", "status": "running", "health": "N/A", "image": "test", "created": ""},
]
result = netbird_service.get_customer_health(db_session, sample_customer.id)
assert result["healthy"] is True
assert len(result["containers"]) == 2
@patch("app.services.netbird_service.docker_service")
def test_unhealthy_deployment(self, mock_docker, db_session, sample_customer, sample_deployment):
"""Health check returns unhealthy when a container is stopped."""
mock_docker.get_container_status.return_value = [
{"name": "netbird-kunde1-management", "status": "running", "health": "healthy", "image": "test", "created": ""},
{"name": "netbird-kunde1-signal", "status": "exited", "health": "N/A", "image": "test", "created": ""},
]
result = netbird_service.get_customer_health(db_session, sample_customer.id)
assert result["healthy"] is False
def test_health_no_deployment(self, db_session, sample_customer):
"""Health check handles missing deployment."""
result = netbird_service.get_customer_health(db_session, sample_customer.id)
assert result["healthy"] is False
assert "No deployment" in result["error"]

View File

@@ -0,0 +1,155 @@
"""Unit tests for the Docker service and port manager."""
import os
import pytest
from unittest.mock import patch, MagicMock
os.environ["SECRET_KEY"] = "test-secret-key-for-unit-tests"
os.environ["DATABASE_PATH"] = ":memory:"
from app.services import docker_service, port_manager
from app.models import Deployment
class TestPortManager:
"""Tests for UDP port allocation."""
def test_allocate_first_port(self, db_session):
"""First allocation returns base port."""
port = port_manager.allocate_port(db_session, base_port=3478)
assert port == 3478
def test_allocate_skips_used_ports(self, db_session, sample_deployment):
"""Allocation skips ports already in the database."""
# sample_deployment uses port 3478
port = port_manager.allocate_port(db_session, base_port=3478)
assert port == 3479
def test_allocate_raises_when_full(self, db_session):
"""Allocation raises RuntimeError when all ports are used."""
# Fill all ports
for i in range(100):
db_session.add(Deployment(
customer_id=1000 + i,
container_prefix=f"test-{i}",
relay_udp_port=3478 + i,
relay_secret="secret",
deployment_status="running",
))
db_session.commit()
with pytest.raises(RuntimeError, match="No available relay ports"):
port_manager.allocate_port(db_session, base_port=3478, max_ports=100)
def test_get_allocated_ports(self, db_session, sample_deployment):
"""Returns set of allocated ports."""
ports = port_manager.get_allocated_ports(db_session)
assert 3478 in ports
def test_validate_port_available(self, db_session):
"""Available port returns True."""
assert port_manager.validate_port_available(db_session, 3500) is True
def test_validate_port_taken(self, db_session, sample_deployment):
"""Allocated port returns False."""
assert port_manager.validate_port_available(db_session, 3478) is False
class TestDockerService:
"""Tests for Docker container management."""
@patch("app.services.docker_service.subprocess.run")
def test_compose_up_success(self, mock_run, tmp_path):
"""compose_up succeeds when docker compose returns 0."""
compose_file = tmp_path / "docker-compose.yml"
compose_file.write_text("version: '3.8'\nservices: {}")
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
result = docker_service.compose_up(str(tmp_path), "test-project")
assert result is True
mock_run.assert_called_once()
@patch("app.services.docker_service.subprocess.run")
def test_compose_up_failure(self, mock_run, tmp_path):
"""compose_up raises RuntimeError on failure."""
compose_file = tmp_path / "docker-compose.yml"
compose_file.write_text("version: '3.8'\nservices: {}")
mock_run.return_value = MagicMock(returncode=1, stderr="Some error")
with pytest.raises(RuntimeError, match="docker compose up failed"):
docker_service.compose_up(str(tmp_path), "test-project")
def test_compose_up_missing_file(self, tmp_path):
"""compose_up raises FileNotFoundError when compose file is missing."""
with pytest.raises(FileNotFoundError):
docker_service.compose_up(str(tmp_path), "test-project")
@patch("app.services.docker_service.subprocess.run")
def test_compose_stop(self, mock_run, tmp_path):
"""compose_stop returns True on success."""
compose_file = tmp_path / "docker-compose.yml"
compose_file.write_text("")
mock_run.return_value = MagicMock(returncode=0)
result = docker_service.compose_stop(str(tmp_path), "test-project")
assert result is True
@patch("app.services.docker_service._get_client")
def test_get_container_status(self, mock_get_client):
"""get_container_status returns formatted container info."""
mock_container = MagicMock()
mock_container.name = "netbird-kunde1-management"
mock_container.status = "running"
mock_container.attrs = {"State": {"Health": {"Status": "healthy"}}, "Created": "2024-01-01"}
mock_container.image.tags = ["netbirdio/management:latest"]
mock_client = MagicMock()
mock_client.containers.list.return_value = [mock_container]
mock_get_client.return_value = mock_client
result = docker_service.get_container_status("netbird-kunde1")
assert len(result) == 1
assert result[0]["name"] == "netbird-kunde1-management"
assert result[0]["status"] == "running"
assert result[0]["health"] == "healthy"
@patch("app.services.docker_service._get_client")
def test_get_container_logs(self, mock_get_client):
"""get_container_logs returns log text."""
mock_container = MagicMock()
mock_container.logs.return_value = b"2024-01-01 12:00:00 Started\n"
mock_client = MagicMock()
mock_client.containers.get.return_value = mock_container
mock_get_client.return_value = mock_client
result = docker_service.get_container_logs("netbird-kunde1-management")
assert "Started" in result
@patch("app.services.docker_service._get_client")
def test_get_container_logs_not_found(self, mock_get_client):
"""get_container_logs handles missing container."""
from docker.errors import NotFound
mock_client = MagicMock()
mock_client.containers.get.side_effect = NotFound("not found")
mock_get_client.return_value = mock_client
result = docker_service.get_container_logs("nonexistent")
assert "not found" in result
@patch("app.services.docker_service._get_client")
def test_remove_instance_containers(self, mock_get_client):
"""remove_instance_containers force-removes all matching containers."""
mock_c1 = MagicMock()
mock_c1.name = "netbird-kunde1-management"
mock_c2 = MagicMock()
mock_c2.name = "netbird-kunde1-signal"
mock_client = MagicMock()
mock_client.containers.list.return_value = [mock_c1, mock_c2]
mock_get_client.return_value = mock_client
result = docker_service.remove_instance_containers("netbird-kunde1")
assert result is True
mock_c1.remove.assert_called_once_with(force=True)
mock_c2.remove.assert_called_once_with(force=True)