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

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