From a18df0018ccf58884bd810330245e34763d9413b Mon Sep 17 00:00:00 2001 From: twothatit Date: Sat, 7 Feb 2026 21:13:50 +0100 Subject: [PATCH] bugfix --- app/models.py | 7 ++- app/routers/settings.py | 21 ++++--- app/services/netbird_service.py | 8 ++- app/services/npm_service.py | 104 ++++++++++++++++++++++++-------- app/utils/config.py | 14 +++-- app/utils/validators.py | 3 +- install.sh | 34 +++++++---- static/index.html | 16 +++-- static/js/app.js | 11 ++-- tests/conftest.py | 3 +- tests/test_customer_api.py | 3 +- 11 files changed, 157 insertions(+), 67 deletions(-) diff --git a/app/models.py b/app/models.py index 6022a72..6eb7e0e 100644 --- a/app/models.py +++ b/app/models.py @@ -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, diff --git a/app/routers/settings.py b/app/routers/settings.py index b96c1e7..99073cc 100644 --- a/app/routers/settings.py +++ b/app/routers/settings.py @@ -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 diff --git a/app/services/netbird_service.py b/app/services/netbird_service.py index decc152..bc5a5c1 100644 --- a/app/services/netbird_service.py +++ b/app/services/netbird_service.py @@ -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: diff --git a/app/services/npm_service.py b/app/services/npm_service.py index fb81683..b29c173 100644 --- a/app/services/npm_service.py +++ b/app/services/npm_service.py @@ -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": "", "secret": ""} 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 ) diff --git a/app/utils/config.py b/app/utils/config.py index 141421d..03f321a 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -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, diff --git a/app/utils/validators.py b/app/utils/validators.py index acbe954..f88e7ff 100644 --- a/app/utils/validators.py +++ b/app/utils/validators.py @@ -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) diff --git a/install.sh b/install.sh index 0b53e03..4285448 100644 --- a/install.sh +++ b/install.sh @@ -202,6 +202,9 @@ echo -e "${BLUE}${BOLD}[Step 5/10]${NC} ${BLUE}Nginx Proxy Manager Configuration echo -e "${CYAN}NetBird MSP needs to integrate with Nginx Proxy Manager (NPM).${NC}\n" # NPM API URL +echo -e "${YELLOW}NPM uses JWT authentication (email + password login).${NC}" +echo -e "${YELLOW}There are no static API keys — the system logs in automatically.${NC}\n" + while true; do read -p "NPM API URL [http://nginx-proxy-manager:81/api]: " NPM_API_URL NPM_API_URL=${NPM_API_URL:-http://nginx-proxy-manager:81/api} @@ -212,19 +215,25 @@ while true; do fi done -# NPM API Token -echo -e "\n${YELLOW}To get your NPM API Token:${NC}" -echo -e " 1. Login to Nginx Proxy Manager" -echo -e " 2. Go to Users → Your User" -echo -e " 3. Copy the API Token\n" - +# NPM Login Email +echo "" while true; do - read -sp "NPM API Token: " NPM_API_TOKEN - echo "" - if [ ${#NPM_API_TOKEN} -ge 20 ]; then + read -p "NPM Login Email (your NPM admin email): " NPM_EMAIL + if [[ "$NPM_EMAIL" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then break else - echo -e "${RED}Token seems too short. Please enter the complete token.${NC}" + echo -e "${RED}Invalid email address.${NC}" + fi +done + +# NPM Login Password +while true; do + read -sp "NPM Login Password: " NPM_PASSWORD + echo "" + if [ ${#NPM_PASSWORD} -ge 1 ]; then + break + else + echo -e "${RED}Password cannot be empty.${NC}" fi done @@ -296,6 +305,7 @@ echo -e " Admin Username: ${GREEN}$ADMIN_USERNAME${NC}" echo -e " Admin Email: ${GREEN}$ADMIN_EMAIL${NC}" echo -e " Base Domain: ${GREEN}$BASE_DOMAIN${NC}" echo -e " NPM API URL: ${GREEN}$NPM_API_URL${NC}" +echo -e " NPM Login: ${GREEN}$NPM_EMAIL${NC}" echo -e " Data Directory: ${GREEN}$DATA_DIR${NC}" echo -e " Install Dir: ${GREEN}$INSTALL_DIR${NC}\n" @@ -408,7 +418,8 @@ if not existing_config: base_domain='$BASE_DOMAIN', admin_email='$ADMIN_EMAIL', npm_api_url='$NPM_API_URL', - npm_api_token_encrypted=encrypt_value('$NPM_API_TOKEN'), + npm_api_email_encrypted=encrypt_value('$NPM_EMAIL'), + npm_api_password_encrypted=encrypt_value('$NPM_PASSWORD'), netbird_management_image='$NETBIRD_MANAGEMENT_IMAGE', netbird_signal_image='$NETBIRD_SIGNAL_IMAGE', netbird_relay_image='$NETBIRD_RELAY_IMAGE', @@ -517,6 +528,7 @@ Admin Username: $ADMIN_USERNAME Admin Email: $ADMIN_EMAIL Base Domain: $BASE_DOMAIN NPM API URL: $NPM_API_URL +NPM Login: $NPM_EMAIL Data Directory: $DATA_DIR NOTE: All settings are stored in the database and editable via Web UI. diff --git a/static/index.html b/static/index.html index b1779f4..f1b8d14 100644 --- a/static/index.html +++ b/static/index.html @@ -295,18 +295,24 @@
+

NPM uses JWT authentication. Enter your NPM login credentials (email + password). The system will automatically log in and obtain tokens for API calls.

- + +
http:// or https:// - must include /api at the end
- + + +
+
+
+
- - + +
-
diff --git a/static/js/app.js b/static/js/app.js index 1d75db7..928f285 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -451,7 +451,7 @@ async function loadSettings() { 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('npm-credentials-status').textContent = cfg.npm_credentials_set ? 'Credentials are set (leave empty to keep current)' : 'No NPM credentials 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 || ''; @@ -482,12 +482,15 @@ document.getElementById('settings-system-form').addEventListener('submit', async 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; + const email = document.getElementById('cfg-npm-api-email').value; + const password = document.getElementById('cfg-npm-api-password').value; + if (email) payload.npm_api_email = email; + if (password) payload.npm_api_password = password; try { await api('PUT', '/settings/system', payload); showSettingsAlert('success', 'NPM settings saved.'); - document.getElementById('cfg-npm-api-token').value = ''; + document.getElementById('cfg-npm-api-email').value = ''; + document.getElementById('cfg-npm-api-password').value = ''; loadSettings(); } catch (err) { showSettingsAlert('danger', 'Failed: ' + err.message); diff --git a/tests/conftest.py b/tests/conftest.py index 93a0193..181356a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -43,7 +43,8 @@ def db_session(): 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"), + npm_api_email_encrypted=encrypt_value("admin@npm.local"), + npm_api_password_encrypted=encrypt_value("test-npm-password"), data_dir="/tmp/netbird-test", docker_network="test-network", relay_base_port=3478, diff --git a/tests/test_customer_api.py b/tests/test_customer_api.py index 50d602d..9c6f6f3 100644 --- a/tests/test_customer_api.py +++ b/tests/test_customer_api.py @@ -45,7 +45,8 @@ def test_db(): 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"), + npm_api_email_encrypted=encrypt_value("admin@npm.local"), + npm_api_password_encrypted=encrypt_value("test-npm-password"), ) session.add(config) session.commit()