Add i18n, branding, user management, health checks, and cleanup for deployment

- Multi-language support (EN/DE) with i18n engine and language files
- Configurable branding (name, subtitle, logo) in Settings
- Global default language and per-user language preference
- User management router with CRUD endpoints
- Customer status sync on start/stop/restart
- Health check fixes: derive status from container state, remove broken wget healthcheck
- Caddy reverse proxy and dashboard env templates for customer stacks
- Updated README with real hardware specs, prerequisites, and new features
- Removed .claude settings (JWT tokens) and build artifacts from tracking
- Updated .gitignore for .claude/ and Windows artifacts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-08 17:24:05 +01:00
parent c4d68db2f4
commit 41ba835a99
28 changed files with 2550 additions and 661 deletions

View File

@@ -1,15 +1,17 @@
"""Authentication API endpoints — login, logout, current user, password change."""
"""Authentication API endpoints — login, logout, current user, password change, Azure AD."""
import logging
import secrets
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
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.models import SystemConfig, User
from app.utils.security import decrypt_value, hash_password, verify_password
from app.utils.validators import ChangePasswordRequest, LoginRequest
logger = logging.getLogger(__name__)
@@ -95,3 +97,115 @@ async def change_password(
db.commit()
logger.info("Password changed for user %s.", current_user.username)
return {"message": "Password changed successfully."}
class AzureCallbackRequest(BaseModel):
"""Azure AD auth code callback payload."""
code: str
redirect_uri: str
@router.get("/azure/config")
async def get_azure_config(db: Session = Depends(get_db)):
"""Public endpoint — returns Azure AD config for the login page."""
config = db.query(SystemConfig).filter(SystemConfig.id == 1).first()
if not config or not config.azure_enabled:
return {"azure_enabled": False}
return {
"azure_enabled": True,
"azure_tenant_id": config.azure_tenant_id,
"azure_client_id": config.azure_client_id,
}
@router.post("/azure/callback")
async def azure_callback(
payload: AzureCallbackRequest,
db: Session = Depends(get_db),
):
"""Exchange Azure AD authorization code for tokens and authenticate."""
config = db.query(SystemConfig).filter(SystemConfig.id == 1).first()
if not config or not config.azure_enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Azure AD authentication is not enabled.",
)
if not config.azure_tenant_id or not config.azure_client_id or not config.azure_client_secret_encrypted:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Azure AD is not fully configured.",
)
try:
import msal
client_secret = decrypt_value(config.azure_client_secret_encrypted)
authority = f"https://login.microsoftonline.com/{config.azure_tenant_id}"
app = msal.ConfidentialClientApplication(
config.azure_client_id,
authority=authority,
client_credential=client_secret,
)
result = app.acquire_token_by_authorization_code(
payload.code,
scopes=["User.Read"],
redirect_uri=payload.redirect_uri,
)
if "error" in result:
logger.warning("Azure AD token exchange failed: %s", result.get("error_description", result["error"]))
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=result.get("error_description", "Azure AD authentication failed."),
)
id_token_claims = result.get("id_token_claims", {})
email = id_token_claims.get("preferred_username") or id_token_claims.get("email", "")
display_name = id_token_claims.get("name", email)
if not email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Could not determine email from Azure AD token.",
)
# Find or create user
user = db.query(User).filter(User.username == email).first()
if not user:
user = User(
username=email,
password_hash=hash_password(secrets.token_urlsafe(32)),
email=email,
is_active=True,
role="admin",
auth_provider="azure",
)
db.add(user)
db.commit()
db.refresh(user)
logger.info("Azure AD user '%s' auto-created.", email)
elif not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Account is disabled.",
)
token = create_access_token(user.username)
logger.info("Azure AD user '%s' logged in.", user.username)
return {
"access_token": token,
"token_type": "bearer",
"user": user.to_dict(),
}
except HTTPException:
raise
except Exception as exc:
logger.exception("Azure AD authentication error")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Azure AD authentication error: {exc}",
)

View File

@@ -9,6 +9,7 @@ from app.database import SessionLocal, get_db
from app.dependencies import get_current_user
from app.models import Customer, Deployment, User
from app.services import docker_service, netbird_service
from app.utils.security import decrypt_value
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -174,6 +175,38 @@ async def check_customer_health(
return netbird_service.get_customer_health(db, customer_id)
@router.get("/{customer_id}/credentials")
async def get_customer_credentials(
customer_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Get the NetBird admin credentials for a customer's deployment.
Args:
customer_id: Customer ID.
Returns:
Dict with email and password.
"""
_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.",
)
if not deployment.netbird_admin_email or not deployment.netbird_admin_password:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No credentials available. Admin must complete setup manually.",
)
return {
"email": decrypt_value(deployment.netbird_admin_email),
"password": decrypt_value(deployment.netbird_admin_password),
}
def _require_customer(db: Session, customer_id: int) -> Customer:
"""Helper to fetch a customer or raise 404.

View File

@@ -71,6 +71,7 @@ async def all_customers_status(
entry["deployment_status"] = c.deployment.deployment_status
entry["containers"] = containers
entry["relay_udp_port"] = c.deployment.relay_udp_port
entry["dashboard_port"] = c.deployment.dashboard_port
entry["setup_url"] = c.deployment.setup_url
else:
entry["deployment_status"] = None

View File

@@ -5,9 +5,11 @@ There is no .env file. Every setting lives in the ``system_config`` table
"""
import logging
import os
import shutil
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, status
from sqlalchemy.orm import Session
from app.database import get_db
@@ -21,6 +23,10 @@ from app.utils.validators import SystemConfigUpdate
logger = logging.getLogger(__name__)
router = APIRouter()
UPLOAD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "static", "uploads")
MAX_LOGO_SIZE = 512 * 1024 # 500 KB
ALLOWED_LOGO_TYPES = {"image/png", "image/jpeg", "image/svg+xml"}
@router.get("/system")
async def get_settings(
@@ -75,6 +81,11 @@ async def update_settings(
raw_password = update_data.pop("npm_api_password")
row.npm_api_password_encrypted = encrypt_value(raw_password)
# Handle Azure client secret encryption
if "azure_client_secret" in update_data:
raw_secret = update_data.pop("azure_client_secret")
row.azure_client_secret_encrypted = encrypt_value(raw_secret)
for field, value in update_data.items():
if hasattr(row, field):
setattr(row, field, value)
@@ -116,3 +127,85 @@ async def test_npm(
config.npm_api_url, config.npm_api_email, config.npm_api_password
)
return result
@router.get("/branding")
async def get_branding(db: Session = Depends(get_db)):
"""Public endpoint — returns branding info for the login page (no auth required)."""
row = db.query(SystemConfig).filter(SystemConfig.id == 1).first()
if not row:
return {
"branding_name": "NetBird MSP Appliance",
"branding_subtitle": "Multi-Tenant Management Platform",
"branding_logo_path": None,
"default_language": "en",
}
return {
"branding_name": row.branding_name or "NetBird MSP Appliance",
"branding_subtitle": row.branding_subtitle or "Multi-Tenant Management Platform",
"branding_logo_path": row.branding_logo_path,
"default_language": row.default_language or "en",
}
@router.post("/branding/logo")
async def upload_logo(
file: UploadFile = File(...),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Upload a branding logo image (PNG, JPG, SVG, max 500KB)."""
if file.content_type not in ALLOWED_LOGO_TYPES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File type '{file.content_type}' not allowed. Use PNG, JPG, or SVG.",
)
content = await file.read()
if len(content) > MAX_LOGO_SIZE:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File too large ({len(content)} bytes). Maximum is {MAX_LOGO_SIZE} bytes.",
)
os.makedirs(UPLOAD_DIR, exist_ok=True)
ext_map = {"image/png": ".png", "image/jpeg": ".jpg", "image/svg+xml": ".svg"}
ext = ext_map.get(file.content_type, ".png")
filename = f"logo{ext}"
filepath = os.path.join(UPLOAD_DIR, filename)
with open(filepath, "wb") as f:
f.write(content)
logo_url = f"/static/uploads/{filename}"
row = db.query(SystemConfig).filter(SystemConfig.id == 1).first()
if row:
row.branding_logo_path = logo_url
row.updated_at = datetime.utcnow()
db.commit()
logger.info("Logo uploaded by %s: %s", current_user.username, logo_url)
return {"branding_logo_path": logo_url}
@router.delete("/branding/logo")
async def delete_logo(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Remove the branding logo and reset to default icon."""
row = db.query(SystemConfig).filter(SystemConfig.id == 1).first()
if row and row.branding_logo_path:
old_path = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
row.branding_logo_path.lstrip("/"),
)
if os.path.isfile(old_path):
os.remove(old_path)
row.branding_logo_path = None
row.updated_at = datetime.utcnow()
db.commit()
return {"branding_logo_path": None}

131
app/routers/users.py Normal file
View File

@@ -0,0 +1,131 @@
"""User management API — CRUD operations for local users."""
import logging
import secrets
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 User
from app.utils.security import hash_password
from app.utils.validators import UserCreate, UserUpdate
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("")
async def list_users(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""List all users."""
users = db.query(User).order_by(User.id).all()
return [u.to_dict() for u in users]
@router.post("")
async def create_user(
payload: UserCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Create a new local user."""
existing = db.query(User).filter(User.username == payload.username).first()
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Username '{payload.username}' already exists.",
)
user = User(
username=payload.username,
password_hash=hash_password(payload.password),
email=payload.email,
is_active=True,
role="admin",
auth_provider="local",
default_language=payload.default_language,
)
db.add(user)
db.commit()
db.refresh(user)
logger.info("User '%s' created by '%s'.", user.username, current_user.username)
return user.to_dict()
@router.put("/{user_id}")
async def update_user(
user_id: int,
payload: UserUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Update an existing user (email, is_active, role)."""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found.")
update_data = payload.model_dump(exclude_none=True)
for field, value in update_data.items():
if hasattr(user, field):
setattr(user, field, value)
db.commit()
db.refresh(user)
logger.info("User '%s' updated by '%s'.", user.username, current_user.username)
return user.to_dict()
@router.delete("/{user_id}")
async def delete_user(
user_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Delete a user (cannot delete yourself)."""
if user_id == current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="You cannot delete your own account.",
)
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found.")
username = user.username
db.delete(user)
db.commit()
logger.info("User '%s' deleted by '%s'.", username, current_user.username)
return {"message": f"User '{username}' deleted."}
@router.post("/{user_id}/reset-password")
async def reset_password(
user_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Generate a new random password for a user."""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found.")
if user.auth_provider != "local":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot reset password for Azure AD users.",
)
new_password = secrets.token_urlsafe(16)
user.password_hash = hash_password(new_password)
db.commit()
logger.info("Password reset for user '%s' by '%s'.", user.username, current_user.username)
return {"message": "Password reset successfully.", "new_password": new_password}