Files
NetBirdMSP-Appliance/app/routers/settings.py
twothatit 7793ca3666 feat: add Windows DNS integration and LDAP/AD authentication
Windows DNS (WinRM):
- New dns_service.py: create/delete A-records via PowerShell over WinRM (NTLM)
- Idempotent create (removes existing record first), graceful delete
- DNS failures are non-fatal — deployment continues, error logged
- test-dns endpoint: GET /api/settings/test-dns
- Integrated into deploy_customer() and undeploy_customer()

LDAP / Active Directory auth:
- New ldap_service.py: service-account bind + user search + user bind (ldap3)
- Optional AD group restriction via ldap_group_dn
- Login flow: LDAP first → local fallback (prevents admin lockout)
- LDAP users auto-created with auth_provider="ldap" and role="viewer"
- test-ldap endpoint: GET /api/settings/test-ldap
- reset-password/reset-mfa guards extended to block LDAP users

All credentials (dns_password, ldap_bind_password) encrypted with Fernet.
New DB columns added via backwards-compatible migrations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 21:06:51 +01:00

313 lines
10 KiB
Python

"""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
import os
import shutil
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, 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 dns_service, ldap_service, 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()
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(
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. NPM credentials are
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 credentials encryption
if "npm_api_email" in update_data:
raw_email = update_data.pop("npm_api_email")
row.npm_api_email_encrypted = encrypt_value(raw_email)
if "npm_api_password" in update_data:
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)
# Handle DNS password encryption
if "dns_password" in update_data:
row.dns_password_encrypted = encrypt_value(update_data.pop("dns_password"))
# Handle LDAP bind password encryption
if "ldap_bind_password" in update_data:
row.ldap_bind_password_encrypted = encrypt_value(update_data.pop("ldap_bind_password"))
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_email or not config.npm_api_password:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="NPM API URL or credentials not configured.",
)
result = await npm_service.test_npm_connection(
config.npm_api_url, config.npm_api_email, config.npm_api_password
)
return result
@router.get("/npm-certificates")
async def list_npm_certificates(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""List all SSL certificates configured in NPM.
Used by the frontend to populate the wildcard certificate dropdown.
Returns:
List of certificate dicts with id, domain_names, provider, expires_on, is_wildcard.
"""
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_email or not config.npm_api_password:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="NPM API URL or credentials not configured.",
)
result = await npm_service.list_certificates(
config.npm_api_url, config.npm_api_email, config.npm_api_password
)
if "error" in result:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=result["error"],
)
return result["certificates"]
@router.get("/test-dns")
async def test_dns(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Test connectivity to the Windows DNS server via WinRM.
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.dns_enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Windows DNS integration is not enabled.",
)
if not config.dns_server or not config.dns_username or not config.dns_password:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="DNS server, username, or password not configured.",
)
return await dns_service.test_dns_connection(config)
@router.get("/test-ldap")
async def test_ldap(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Test connectivity to the LDAP / Active Directory server.
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.ldap_enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="LDAP authentication is not enabled.",
)
if not config.ldap_server or not config.ldap_bind_dn or not config.ldap_bind_password:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="LDAP server, bind DN, or bind password not configured.",
)
return await ldap_service.test_ldap_connection(config)
@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}