This commit is contained in:
2026-02-07 21:13:50 +01:00
parent 3d8ab57f31
commit a18df0018c
11 changed files with 157 additions and 67 deletions

View File

@@ -128,7 +128,8 @@ class SystemConfig(Base):
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)
npm_api_email_encrypted: Mapped[str] = mapped_column(Text, nullable=False)
npm_api_password_encrypted: Mapped[str] = mapped_column(Text, nullable=False)
netbird_management_image: Mapped[str] = mapped_column(
String(255), default="netbirdio/management:latest"
)
@@ -154,12 +155,12 @@ class SystemConfig(Base):
)
def to_dict(self) -> dict:
"""Serialize config to dictionary (token masked)."""
"""Serialize config to dictionary (credentials 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),
"npm_credentials_set": bool(self.npm_api_email_encrypted and self.npm_api_password_encrypted),
"netbird_management_image": self.netbird_management_image,
"netbird_signal_image": self.netbird_signal_image,
"netbird_relay_image": self.netbird_relay_image,

View File

@@ -49,7 +49,7 @@ async def update_settings(
):
"""Update system configuration values.
Only provided (non-None) fields are updated. The NPM API token is
Only provided (non-None) fields are updated. NPM credentials are
encrypted before storage.
Args:
@@ -67,10 +67,13 @@ async def update_settings(
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)
# 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)
for field, value in update_data.items():
if hasattr(row, field):
@@ -103,11 +106,13 @@ async def test_npm(
status_code=status.HTTP_404_NOT_FOUND,
detail="System configuration not initialized.",
)
if not config.npm_api_url or not config.npm_api_token:
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 token not configured.",
detail="NPM API URL or credentials not configured.",
)
result = await npm_service.test_npm_connection(config.npm_api_url, config.npm_api_token)
result = await npm_service.test_npm_connection(
config.npm_api_url, config.npm_api_email, config.npm_api_password
)
return result

View File

@@ -162,7 +162,8 @@ async def deploy_customer(db: Session, customer_id: int) -> dict[str, Any]:
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,
npm_email=config.npm_api_email,
npm_password=config.npm_api_password,
domain=domain,
forward_host=dashboard_container,
forward_port=80,
@@ -260,10 +261,11 @@ async def undeploy_customer(db: Session, customer_id: int) -> dict[str, Any]:
_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:
if deployment.npm_proxy_id and config.npm_api_email:
try:
await npm_service.delete_proxy_host(
config.npm_api_url, config.npm_api_token, deployment.npm_proxy_id
config.npm_api_url, config.npm_api_email, config.npm_api_password,
deployment.npm_proxy_id,
)
_log_action(db, customer_id, "undeploy", "info", "NPM proxy host removed.")
except Exception as exc:

View File

@@ -1,12 +1,17 @@
"""Nginx Proxy Manager API integration.
NPM uses JWT authentication — there are no static API tokens.
Every API session starts with a login (POST /api/tokens) using email + password,
which returns a short-lived JWT. That JWT is then used as Bearer token for all
subsequent requests.
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
from typing import Any
import httpx
@@ -16,29 +21,67 @@ logger = logging.getLogger(__name__)
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.
async def _npm_login(client: httpx.AsyncClient, api_url: str, email: str, password: str) -> str:
"""Authenticate with NPM and return a JWT token.
NPM does NOT support static API keys. Auth is always:
POST /api/tokens with {"identity": "<email>", "secret": "<password>"}
Args:
client: httpx async client.
api_url: NPM API base URL (e.g. ``http://npm:81/api``).
api_token: Bearer token for authentication.
email: NPM login email / identity.
password: NPM login password / secret.
Returns:
JWT token string.
Raises:
RuntimeError: If login fails.
"""
resp = await client.post(
f"{api_url}/tokens",
json={"identity": email, "secret": password},
)
if resp.status_code in (200, 201):
data = resp.json()
token = data.get("token")
if token:
logger.debug("NPM login successful for %s", email)
return token
raise RuntimeError("NPM login response did not contain a token.")
raise RuntimeError(
f"NPM login failed (HTTP {resp.status_code}): {resp.text[:300]}"
)
async def test_npm_connection(api_url: str, email: str, password: str) -> dict[str, Any]:
"""Test connectivity to NPM by logging in and listing proxy hosts.
Args:
api_url: NPM API base URL.
email: NPM login email.
password: NPM login password.
Returns:
Dict with ``ok`` (bool) and ``message`` (str).
"""
headers = {"Authorization": f"Bearer {api_token}"}
try:
async with httpx.AsyncClient(timeout=NPM_TIMEOUT) as client:
token = await _npm_login(client, api_url, email, password)
headers = {"Authorization": f"Bearer {token}"}
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": True, "message": f"Connected. Login OK. {count} proxy hosts found."}
return {
"ok": False,
"message": f"NPM returned status {resp.status_code}: {resp.text[:200]}",
"message": f"Login OK but listing hosts returned {resp.status_code}: {resp.text[:200]}",
}
except RuntimeError as exc:
return {"ok": False, "message": str(exc)}
except httpx.ConnectError:
return {"ok": False, "message": "Connection refused. Is NPM running?"}
return {"ok": False, "message": "Connection refused. Is NPM running and reachable?"}
except httpx.TimeoutException:
return {"ok": False, "message": "Connection timed out."}
except Exception as exc:
@@ -47,7 +90,8 @@ async def test_npm_connection(api_url: str, api_token: str) -> dict[str, Any]:
async def create_proxy_host(
api_url: str,
api_token: str,
npm_email: str,
npm_password: str,
domain: str,
forward_host: str,
forward_port: int = 80,
@@ -57,15 +101,13 @@ async def create_proxy_host(
) -> 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)
Logs in first to get a JWT, then creates the proxy host with advanced
routing config for management, signal, and relay containers.
Args:
api_url: NPM API base URL.
api_token: Bearer token.
npm_email: NPM login email.
npm_password: NPM login password.
domain: Full domain (e.g. ``kunde1.example.com``).
forward_host: Container name for the dashboard.
forward_port: Port to forward to (default 80).
@@ -76,11 +118,6 @@ async def create_proxy_host(
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"
@@ -136,6 +173,14 @@ location /relay {{
try:
async with httpx.AsyncClient(timeout=NPM_TIMEOUT) as client:
# Step 1: Login to NPM
token = await _npm_login(client, api_url, npm_email, npm_password)
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
# Step 2: Create proxy host
resp = await client.post(
f"{api_url}/nginx/proxy-hosts", json=payload, headers=headers
)
@@ -144,7 +189,7 @@ location /relay {{
proxy_id = data.get("id")
logger.info("Created NPM proxy host %s (id=%s)", domain, proxy_id)
# Request SSL certificate
# Step 3: Request SSL certificate
await _request_ssl(client, api_url, headers, proxy_id, domain, admin_email)
return {"proxy_id": proxy_id}
@@ -152,6 +197,9 @@ location /relay {{
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 RuntimeError as exc:
logger.error("NPM login failed: %s", exc)
return {"error": f"NPM login failed: {exc}"}
except Exception as exc:
logger.error("NPM API error: %s", exc)
return {"error": str(exc)}
@@ -168,9 +216,9 @@ async def _request_ssl(
"""Request a Let's Encrypt SSL certificate for a proxy host.
Args:
client: httpx client.
client: httpx client (already authenticated).
api_url: NPM API base URL.
headers: Auth headers.
headers: Auth headers with Bearer token.
proxy_id: The proxy host ID.
domain: The domain to certify.
admin_email: Contact email for LE.
@@ -203,21 +251,25 @@ async def _request_ssl(
async def delete_proxy_host(
api_url: str, api_token: str, proxy_id: int
api_url: str, npm_email: str, npm_password: str, proxy_id: int
) -> bool:
"""Delete a proxy host from NPM.
Logs in first to get a fresh JWT, then deletes the proxy host.
Args:
api_url: NPM API base URL.
api_token: Bearer token.
npm_email: NPM login email.
npm_password: NPM login password.
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:
token = await _npm_login(client, api_url, npm_email, npm_password)
headers = {"Authorization": f"Bearer {token}"}
resp = await client.delete(
f"{api_url}/nginx/proxy-hosts/{proxy_id}", headers=headers
)

View File

@@ -21,7 +21,8 @@ class AppConfig:
base_domain: str
admin_email: str
npm_api_url: str
npm_api_token: str # decrypted
npm_api_email: str # decrypted — NPM login email
npm_api_password: str # decrypted — NPM login password
netbird_management_image: str
netbird_signal_image: str
netbird_relay_image: str
@@ -55,15 +56,20 @@ def get_system_config(db: Session) -> Optional[AppConfig]:
return None
try:
npm_token = decrypt_value(row.npm_api_token_encrypted)
npm_email = decrypt_value(row.npm_api_email_encrypted)
except Exception:
npm_token = ""
npm_email = ""
try:
npm_password = decrypt_value(row.npm_api_password_encrypted)
except Exception:
npm_password = ""
return AppConfig(
base_domain=row.base_domain,
admin_email=row.admin_email,
npm_api_url=row.npm_api_url,
npm_api_token=npm_token,
npm_api_email=npm_email,
npm_api_password=npm_password,
netbird_management_image=row.netbird_management_image,
netbird_signal_image=row.netbird_signal_image,
netbird_relay_image=row.netbird_relay_image,

View File

@@ -100,7 +100,8 @@ class SystemConfigUpdate(BaseModel):
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
npm_api_email: Optional[str] = Field(None, max_length=255) # NPM login email
npm_api_password: Optional[str] = None # NPM login password, 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)