diff --git a/README.md b/README.md index a31bf68..a2a2ce7 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ A management solution for running isolated NetBird instances for your MSP busine - **Complete Isolation** — Each customer gets their own NetBird stack with separate data - **One-Click Deployment** — Deploy new customer instances in under 2 minutes - **Nginx Proxy Manager Integration** — Automatic SSL certificates and reverse proxy setup +- **SSL Certificate Modes** — Choose between per-customer Let's Encrypt certificates or a shared wildcard certificate - **Docker-Based** — Everything runs in containers for easy deployment ### Dashboard @@ -269,7 +270,8 @@ Available under **Settings** in the web interface: | Tab | Settings | |-----|----------| -| **System** | Base domain, admin email, NPM credentials, Docker images, port ranges, data directory | +| **System** | Base domain, admin email, Docker images, port ranges, data directory | +| **NPM Integration** | NPM API URL, login credentials, SSL certificate mode (Let's Encrypt / Wildcard), wildcard certificate selection | | **Branding** | Platform name, subtitle, logo upload, default language | | **Users** | Create/edit/delete admin users, per-user language preference, MFA reset | | **Azure AD** | Azure AD / Entra ID SSO configuration | @@ -342,6 +344,26 @@ When MFA is enabled and a user logs in for the first time: - **Disable own TOTP** — In Settings > Security, click "Disable my TOTP" to remove your own MFA setup - **Disable MFA globally** — Uncheck the toggle in Settings > Security to allow login without MFA +### SSL Certificate Mode + +The appliance supports two SSL certificate modes for customer proxy hosts, configurable under **Settings > NPM Integration**: + +#### Let's Encrypt (default) +Each customer gets an individual Let's Encrypt certificate via HTTP-01 validation. This is the default behavior and requires no additional setup beyond a valid admin email. + +#### Wildcard Certificate +Use a pre-existing wildcard certificate (e.g. `*.yourdomain.com`) already uploaded in NPM. All customer proxy hosts share this certificate — no per-customer LE validation needed. + +**Setup:** +1. Upload a wildcard certificate in Nginx Proxy Manager (e.g. via DNS challenge) +2. Go to **Settings > NPM Integration** +3. Set **SSL Mode** to "Wildcard Certificate" +4. Click the refresh button to load certificates from NPM +5. Select your wildcard certificate from the dropdown +6. Click **Save NPM Settings** + +New customer deployments will automatically use the selected wildcard certificate. + --- ## API Documentation @@ -376,6 +398,7 @@ GET /api/customers/{id}/logs # Get container logs GET /api/customers/{id}/health # Health check GET /api/settings/branding # Get branding (public, no auth) +GET /api/settings/npm-certificates # List NPM SSL certificates PUT /api/settings # Update system settings GET /api/users # List users POST /api/users # Create user diff --git a/app/database.py b/app/database.py index 29d1f97..d25cd37 100644 --- a/app/database.py +++ b/app/database.py @@ -83,6 +83,8 @@ def _run_migrations() -> None: ("system_config", "mfa_enabled", "BOOLEAN DEFAULT 0"), ("users", "totp_secret_encrypted", "TEXT"), ("users", "totp_enabled", "BOOLEAN DEFAULT 0"), + ("system_config", "ssl_mode", "TEXT DEFAULT 'letsencrypt'"), + ("system_config", "wildcard_cert_id", "INTEGER"), ] for table, column, col_type in migrations: if not _has_column(table, column): diff --git a/app/models.py b/app/models.py index c6682de..8b3f7cb 100644 --- a/app/models.py +++ b/app/models.py @@ -161,6 +161,8 @@ class SystemConfig(Base): ) branding_logo_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) default_language: Mapped[Optional[str]] = mapped_column(String(10), default="en") + ssl_mode: Mapped[str] = mapped_column(String(20), default="letsencrypt") + wildcard_cert_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) mfa_enabled: Mapped[bool] = mapped_column(Boolean, default=False) azure_enabled: Mapped[bool] = mapped_column(Boolean, default=False) azure_tenant_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) @@ -194,6 +196,8 @@ class SystemConfig(Base): "branding_subtitle": self.branding_subtitle or "Multi-Tenant Management Platform", "branding_logo_path": self.branding_logo_path, "default_language": self.default_language or "en", + "ssl_mode": self.ssl_mode or "letsencrypt", + "wildcard_cert_id": self.wildcard_cert_id, "mfa_enabled": bool(self.mfa_enabled), "azure_enabled": bool(self.azure_enabled), "azure_tenant_id": self.azure_tenant_id or "", diff --git a/app/routers/settings.py b/app/routers/settings.py index a6990b7..7ae8e0c 100644 --- a/app/routers/settings.py +++ b/app/routers/settings.py @@ -129,6 +129,41 @@ async def test_npm( 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("/branding") async def get_branding(db: Session = Depends(get_db)): """Public endpoint — returns branding info for the login page (no auth required).""" diff --git a/app/services/netbird_service.py b/app/services/netbird_service.py index 1a38e44..34bc22d 100644 --- a/app/services/netbird_service.py +++ b/app/services/netbird_service.py @@ -277,6 +277,8 @@ async def deploy_customer(db: Session, customer_id: int) -> dict[str, Any]: forward_host=forward_host, forward_port=dashboard_port, admin_email=config.admin_email, + ssl_mode=config.ssl_mode, + wildcard_cert_id=config.wildcard_cert_id, ) npm_proxy_id = npm_result.get("proxy_id") if npm_result.get("error"): diff --git a/app/services/npm_service.py b/app/services/npm_service.py index f32ab42..57dba6a 100644 --- a/app/services/npm_service.py +++ b/app/services/npm_service.py @@ -112,6 +112,45 @@ async def test_npm_connection(api_url: str, email: str, password: str) -> dict[s return {"ok": False, "message": f"Unexpected error: {exc}"} +async def list_certificates(api_url: str, email: str, password: str) -> dict[str, Any]: + """Fetch all SSL certificates from NPM. + + Args: + api_url: NPM API base URL. + email: NPM login email. + password: NPM login password. + + Returns: + Dict with ``certificates`` list on success, or ``error`` on failure. + """ + 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/certificates", headers=headers) + if resp.status_code == 200: + result = [] + for cert in resp.json(): + domains = cert.get("domain_names", []) + result.append({ + "id": cert.get("id"), + "domain_names": domains, + "provider": cert.get("provider", "unknown"), + "expires_on": cert.get("expires_on"), + "is_wildcard": any(d.startswith("*.") for d in domains), + }) + return {"certificates": result} + return {"error": f"NPM returned {resp.status_code}: {resp.text[:200]}"} + except RuntimeError as exc: + return {"error": str(exc)} + except httpx.ConnectError: + return {"error": "Connection refused. Is NPM running and reachable?"} + except httpx.TimeoutException: + return {"error": "Connection timed out."} + except Exception as exc: + return {"error": f"Unexpected error: {exc}"} + + async def _find_cert_by_domain( client: httpx.AsyncClient, api_url: str, headers: dict, domain: str ) -> int | None: @@ -169,6 +208,8 @@ async def create_proxy_host( forward_host: str, forward_port: int = 80, admin_email: str = "", + ssl_mode: str = "letsencrypt", + wildcard_cert_id: int | None = None, ) -> dict[str, Any]: """Create a proxy host entry in NPM with SSL for a customer. @@ -265,7 +306,10 @@ async def create_proxy_host( return {"error": error_msg} # Step 2: Request SSL certificate and enable HTTPS - ssl_ok = await _request_ssl(client, api_url, headers, proxy_id, domain, admin_email) + ssl_ok = await _request_ssl( + client, api_url, headers, proxy_id, domain, admin_email, + ssl_mode=ssl_mode, wildcard_cert_id=wildcard_cert_id, + ) return {"proxy_id": proxy_id, "ssl": ssl_ok} except RuntimeError as exc: @@ -283,13 +327,14 @@ async def _request_ssl( proxy_id: int, domain: str, admin_email: str, + ssl_mode: str = "letsencrypt", + wildcard_cert_id: int | None = None, ) -> bool: - """Request a Let's Encrypt SSL certificate and enable HTTPS on the proxy host. + """Request an SSL certificate and enable HTTPS on the proxy host. - Flow: - 1. Create LE certificate via NPM API (HTTP-01 validation, up to 120s) - 2. Assign certificate to the proxy host - 3. Enable ssl_forced + hsts on the proxy host + Supports two modes: + - ``letsencrypt``: Create a per-domain LE certificate (HTTP-01 validation). + - ``wildcard``: Assign a pre-existing wildcard certificate from NPM. Args: client: httpx client (already authenticated). @@ -298,10 +343,49 @@ async def _request_ssl( proxy_id: The proxy host ID. domain: The domain to certify. admin_email: Contact email for LE. + ssl_mode: ``"letsencrypt"`` or ``"wildcard"``. + wildcard_cert_id: NPM certificate ID for wildcard mode. Returns: True if SSL was successfully enabled, False otherwise. """ + # Wildcard mode: assign the pre-existing wildcard cert directly + if ssl_mode == "wildcard" and wildcard_cert_id: + logger.info( + "Wildcard mode: assigning cert id=%s to proxy host %s for %s", + wildcard_cert_id, proxy_id, domain, + ) + ssl_update = { + "certificate_id": wildcard_cert_id, + "ssl_forced": True, + "hsts_enabled": True, + "http2_support": True, + } + try: + update_resp = await client.put( + f"{api_url}/nginx/proxy-hosts/{proxy_id}", + json=ssl_update, + headers=headers, + ) + if update_resp.status_code in (200, 201): + logger.info( + "SSL enabled on proxy host %s (wildcard cert_id=%s)", + proxy_id, wildcard_cert_id, + ) + return True + logger.error( + "Failed to assign wildcard cert %s to proxy host %s: HTTP %s — %s", + wildcard_cert_id, proxy_id, + update_resp.status_code, update_resp.text[:300], + ) + return False + except Exception as exc: + logger.error( + "Failed to assign wildcard cert to proxy host %s: %s", proxy_id, exc, + ) + return False + + # Let's Encrypt mode (default) if not admin_email: logger.warning("No admin email set — skipping SSL certificate for %s", domain) return False diff --git a/app/utils/config.py b/app/utils/config.py index 69716fa..0d7d895 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -31,6 +31,8 @@ class AppConfig: docker_network: str relay_base_port: int dashboard_base_port: int + ssl_mode: str + wildcard_cert_id: int | None # Environment-level settings (not stored in DB) @@ -79,4 +81,6 @@ def get_system_config(db: Session) -> Optional[AppConfig]: docker_network=row.docker_network, relay_base_port=row.relay_base_port, dashboard_base_port=getattr(row, "dashboard_base_port", 9000) or 9000, + ssl_mode=getattr(row, "ssl_mode", "letsencrypt") or "letsencrypt", + wildcard_cert_id=getattr(row, "wildcard_cert_id", None), ) diff --git a/app/utils/validators.py b/app/utils/validators.py index 881a2c3..202e5cf 100644 --- a/app/utils/validators.py +++ b/app/utils/validators.py @@ -126,12 +126,25 @@ class SystemConfigUpdate(BaseModel): branding_name: Optional[str] = Field(None, max_length=255) branding_subtitle: Optional[str] = Field(None, max_length=255) default_language: Optional[str] = Field(None, max_length=10) + ssl_mode: Optional[str] = Field(None, max_length=20) + wildcard_cert_id: Optional[int] = Field(None, ge=0) mfa_enabled: Optional[bool] = None azure_enabled: Optional[bool] = None azure_tenant_id: Optional[str] = Field(None, max_length=255) azure_client_id: Optional[str] = Field(None, max_length=255) azure_client_secret: Optional[str] = None # encrypted before storage + @field_validator("ssl_mode") + @classmethod + def validate_ssl_mode(cls, v: Optional[str]) -> Optional[str]: + """SSL mode must be 'letsencrypt' or 'wildcard'.""" + if v is None: + return v + allowed = {"letsencrypt", "wildcard"} + if v not in allowed: + raise ValueError(f"ssl_mode must be one of: {', '.join(sorted(allowed))}") + return v + @field_validator("base_domain") @classmethod def validate_domain(cls, v: Optional[str]) -> Optional[str]: diff --git a/static/index.html b/static/index.html index b2433d9..d733a09 100644 --- a/static/index.html +++ b/static/index.html @@ -381,6 +381,32 @@ + +
+
+
SSL Certificate Mode
+
+
+ + +
Choose how SSL certificates are assigned to customer proxy hosts.
+
+
diff --git a/static/js/app.js b/static/js/app.js index 1e5e89d..d671a9e 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -816,6 +816,14 @@ async function loadSettings() { document.getElementById('cfg-dashboard-base-port').value = cfg.dashboard_base_port || 9000; document.getElementById('cfg-npm-api-url').value = cfg.npm_api_url || ''; document.getElementById('npm-credentials-status').textContent = cfg.npm_credentials_set ? t('settings.credentialsSet') : t('settings.noCredentials'); + + // SSL mode + document.getElementById('cfg-ssl-mode').value = cfg.ssl_mode || 'letsencrypt'; + onSslModeChange(); + if (cfg.ssl_mode === 'wildcard') { + loadNpmCertificates(cfg.wildcard_cert_id); + } + 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 || ''; @@ -876,6 +884,14 @@ document.getElementById('settings-npm-form').addEventListener('submit', async (e const password = document.getElementById('cfg-npm-api-password').value; if (email) payload.npm_api_email = email; if (password) payload.npm_api_password = password; + + // SSL mode + const sslMode = document.getElementById('cfg-ssl-mode').value; + payload.ssl_mode = sslMode; + if (sslMode === 'wildcard') { + const certId = document.getElementById('cfg-wildcard-cert-id').value; + if (certId) payload.wildcard_cert_id = parseInt(certId); + } try { await api('PUT', '/settings/system', payload); showSettingsAlert('success', t('messages.npmSettingsSaved')); @@ -924,6 +940,42 @@ async function testNpmConnection() { } } +// SSL mode toggle +function onSslModeChange() { + const mode = document.getElementById('cfg-ssl-mode').value; + const section = document.getElementById('wildcard-cert-section'); + section.style.display = mode === 'wildcard' ? '' : 'none'; +} + +// Load NPM wildcard certificates into dropdown +async function loadNpmCertificates(preselectId) { + const select = document.getElementById('cfg-wildcard-cert-id'); + const statusEl = document.getElementById('wildcard-cert-status'); + select.innerHTML = ``; + statusEl.textContent = t('common.loading'); + statusEl.className = 'mt-1 text-muted'; + + try { + const certs = await api('GET', '/settings/npm-certificates'); + const wildcards = certs.filter(c => c.is_wildcard || (c.domain_names && c.domain_names.some(d => d.startsWith('*.')))); + wildcards.forEach(c => { + const domains = (c.domain_names || []).join(', '); + const expires = c.expires_on ? ` (${t('settings.expiresOn')}: ${new Date(c.expires_on).toLocaleDateString()})` : ''; + const opt = document.createElement('option'); + opt.value = c.id; + opt.textContent = `${domains}${expires}`; + select.appendChild(opt); + }); + if (preselectId) select.value = preselectId; + statusEl.textContent = t('settings.certsLoaded', { count: wildcards.length }); + statusEl.className = wildcards.length > 0 ? 'mt-1 text-success small' : 'mt-1 text-warning small'; + if (wildcards.length === 0) statusEl.textContent = t('settings.noWildcardCerts'); + } catch (err) { + statusEl.textContent = t('errors.failed', { error: err.message }); + statusEl.className = 'mt-1 text-danger small'; + } +} + // Change password form document.getElementById('change-password-form').addEventListener('submit', async (e) => { e.preventDefault(); diff --git a/static/lang/de.json b/static/lang/de.json index 9ea1ec8..d449e8c 100644 --- a/static/lang/de.json +++ b/static/lang/de.json @@ -147,6 +147,17 @@ "noCredentials": "Keine NPM-Zugangsdaten konfiguriert", "saveNpmSettings": "NPM Einstellungen speichern", "testConnection": "Verbindung testen", + "sslModeTitle": "SSL-Zertifikat Modus", + "sslMode": "SSL Modus", + "sslModeLetsencrypt": "Let's Encrypt (pro Kunde)", + "sslModeWildcard": "Wildcard-Zertifikat", + "sslModeHint": "Waehlen Sie, ob jeder Kunde ein eigenes Let's Encrypt Zertifikat erhaelt oder ein gemeinsames Wildcard-Zertifikat verwendet wird.", + "wildcardCertificate": "Wildcard-Zertifikat", + "selectCertificate": "-- Zertifikat waehlen --", + "wildcardCertHint": "Waehlen Sie das Wildcard-Zertifikat (z.B. *.example.com), das bereits in NPM hochgeladen ist.", + "noWildcardCerts": "Keine Wildcard-Zertifikate in NPM gefunden.", + "certsLoaded": "{count} Wildcard-Zertifikat(e) gefunden.", + "expiresOn": "Ablaufdatum", "managementImage": "Management Image", "managementImagePlaceholder": "netbirdio/management:latest", "signalImage": "Signal Image", diff --git a/static/lang/en.json b/static/lang/en.json index 3d048e5..72b1965 100644 --- a/static/lang/en.json +++ b/static/lang/en.json @@ -147,6 +147,17 @@ "noCredentials": "No NPM credentials configured", "saveNpmSettings": "Save NPM Settings", "testConnection": "Test Connection", + "sslModeTitle": "SSL Certificate Mode", + "sslMode": "SSL Mode", + "sslModeLetsencrypt": "Let's Encrypt (per customer)", + "sslModeWildcard": "Wildcard Certificate", + "sslModeHint": "Choose whether each customer gets an individual Let's Encrypt certificate or uses a shared wildcard certificate.", + "wildcardCertificate": "Wildcard Certificate", + "selectCertificate": "-- Select certificate --", + "wildcardCertHint": "Select the wildcard certificate (e.g. *.example.com) already uploaded in NPM.", + "noWildcardCerts": "No wildcard certificates found in NPM.", + "certsLoaded": "{count} wildcard certificate(s) found.", + "expiresOn": "Expires", "managementImage": "Management Image", "managementImagePlaceholder": "netbirdio/management:latest", "signalImage": "Signal Image",