From c7fc4758e3bf75ee65bdf848e85ded521bdba25c Mon Sep 17 00:00:00 2001 From: twothatit Date: Mon, 9 Feb 2026 00:01:28 +0100 Subject: [PATCH 01/44] Add SSL certificate mode: Let's Encrypt or Wildcard per NPM Settings > NPM Integration now allows choosing between per-customer Let's Encrypt certificates (default) or a shared wildcard certificate already uploaded in NPM. Includes backend, frontend UI, and i18n support. Co-Authored-By: Claude Opus 4.6 --- README.md | 25 ++++++++- app/database.py | 2 + app/models.py | 4 ++ app/routers/settings.py | 35 ++++++++++++ app/services/netbird_service.py | 2 + app/services/npm_service.py | 96 ++++++++++++++++++++++++++++++--- app/utils/config.py | 4 ++ app/utils/validators.py | 13 +++++ static/index.html | 26 +++++++++ static/js/app.js | 52 ++++++++++++++++++ static/lang/de.json | 11 ++++ static/lang/en.json | 11 ++++ 12 files changed, 274 insertions(+), 7 deletions(-) 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", From 40456bfaba4a84242f477b9e5d45c41b14dca05e Mon Sep 17 00:00:00 2001 From: twothatit Date: Mon, 9 Feb 2026 15:53:14 +0100 Subject: [PATCH 02/44] Deutsch korrigiert --- static/lang/de.json | 253 +++----------------------------------------- 1 file changed, 14 insertions(+), 239 deletions(-) diff --git a/static/lang/de.json b/static/lang/de.json index d449e8c..034a881 100644 --- a/static/lang/de.json +++ b/static/lang/de.json @@ -29,7 +29,7 @@ "thSubdomain": "Subdomain", "thStatus": "Status", "thDashboard": "Dashboard", - "thDevices": "Geraete", + "thDevices": "Geräte", "thCreated": "Erstellt", "thActions": "Aktionen", "noCustomers": "Keine Kunden gefunden. Klicken Sie auf \"Neuer Kunde\" um einen anzulegen.", @@ -37,10 +37,10 @@ "showingEmpty": "Zeige 0 von 0" }, "customer": { - "back": "Zurueck", + "back": "Zurück", "customer": "Kunde", "edit": "Bearbeiten", - "delete": "Loeschen", + "delete": "Löschen", "tabInfo": "Info", "tabDeployment": "Deployment", "tabLogs": "Logs", @@ -49,7 +49,7 @@ "company": "Firma:", "subdomain": "Subdomain:", "email": "E-Mail:", - "maxDevices": "Max. Geraete:", + "maxDevices": "Max. Geräte:", "status": "Status:", "created": "Erstellt:", "updated": "Aktualisiert:", @@ -57,18 +57,18 @@ "deploymentStatus": "Status:", "relayUdpPort": "Relay UDP Port:", "dashboardPort": "Dashboard Port:", - "containerPrefix": "Container-Praefix:", + "containerPrefix": "Container-Präfix:", "deployed": "Bereitgestellt:", "setupUrl": "Setup URL:", "copy": "Kopieren", - "open": "Oeffnen", + "open": "Öffnen", "netbirdLogin": "NetBird Login", - "notAvailable": "Nicht verfuegbar", + "notAvailable": "Nicht verfügbar", "showCredentials": "Zugangsdaten anzeigen", "credEmail": "E-Mail", "credPassword": "Passwort", "showHide": "Anzeigen/Verbergen", - "credentialsNotAvailable": "Zugangsdaten nicht verfuegbar. Der Admin muss das Setup manuell ueber die Setup URL abschliessen.", + "credentialsNotAvailable": "Zugangsdaten nicht verfügbar. Der Admin muss das Setup manuell über die Setup URL abschließen.", "start": "Starten", "stop": "Stoppen", "restart": "Neustarten", @@ -76,11 +76,11 @@ "noDeployment": "Kein Deployment gefunden.", "deployNow": "Jetzt bereitstellen", "containerLogs": "Container Logs", - "noContainerLogs": "Keine Container-Logs verfuegbar.", + "noContainerLogs": "Keine Container-Logs verfügbar.", "noLogsLoaded": "Keine Logs geladen.", - "healthCheck": "Zustandspruefung", - "check": "Pruefen", - "clickCheck": "Klicken Sie auf \"Pruefen\" um eine Zustandspruefung durchzufuehren.", + "healthCheck": "Zustandsprüfung", + "check": "Prüfen", + "clickCheck": "Klicken Sie auf \"Prüfen\" um eine Zustandsprüfung durchzuführen.", "healthy": "Gesund", "unhealthy": "Fehlerhaft", "overall": "Gesamt:", @@ -88,232 +88,7 @@ "thContainerStatus": "Status", "thHealth": "Zustand", "thImage": "Image", - "lastCheck": "Letzte Pruefung: {time}", - "openDashboard": "Dashboard oeffnen" - }, - "customerModal": { - "newCustomer": "Neuer Kunde", - "editCustomer": "Kunde bearbeiten", - "nameLabel": "Name *", - "companyLabel": "Firma", - "subdomainLabel": "Subdomain *", - "subdomainHint": "Kleinbuchstaben, alphanumerisch + Bindestriche", - "emailLabel": "E-Mail *", - "maxDevicesLabel": "Max. Geraete", - "notesLabel": "Notizen", - "cancel": "Abbrechen", - "saveAndDeploy": "Speichern & Bereitstellen", - "saveChanges": "Aenderungen speichern" - }, - "deleteModal": { - "title": "Loeschung bestaetigen", - "confirmText": "Sind Sie sicher, dass Sie den Kunden loeschen moechten", - "warning": "Alle Container, NPM-Eintraege und Daten werden entfernt. Diese Aktion kann nicht rueckgaengig gemacht werden.", - "cancel": "Abbrechen", - "delete": "Loeschen" - }, - "settings": { - "title": "Systemeinstellungen", - "tabSystem": "Systemkonfiguration", - "tabNpm": "NPM Integration", - "tabImages": "Docker Images", - "tabBranding": "Branding", - "tabUsers": "Benutzer", - "tabAzure": "Azure AD", - "tabSecurity": "Sicherheit", - "baseDomain": "Basis-Domain", - "baseDomainPlaceholder": "ihredomain.com", - "baseDomainHint": "Kunden erhalten Subdomains: kunde.ihredomain.com", - "adminEmail": "Admin E-Mail", - "adminEmailPlaceholder": "admin@ihredomain.com", - "dataDir": "Datenverzeichnis", - "dataDirPlaceholder": "/opt/netbird-instances", - "dockerNetwork": "Docker Netzwerk", - "dockerNetworkPlaceholder": "npm-network", - "relayBasePort": "Relay Basis-Port", - "relayBasePortHint": "Erster UDP-Port fuer Relay. Bereich: Basis bis Basis+99", - "dashboardBasePort": "Dashboard Basis-Port", - "dashboardBasePortHint": "Basis-Port fuer Kunden-Dashboards. Kunde N erhaelt Basis+N", - "saveSystemSettings": "Systemeinstellungen speichern", - "npmDescription": "NPM verwendet JWT-Authentifizierung. Geben Sie Ihre NPM-Anmeldedaten (E-Mail + Passwort) ein. Das System meldet sich automatisch an und bezieht Tokens fuer API-Aufrufe.", - "npmApiUrl": "NPM API URL", - "npmApiUrlPlaceholder": "http://nginx-proxy-manager:81/api", - "npmApiUrlHint": "http:// oder https:// - muss /api am Ende enthalten", - "npmLoginEmail": "NPM Login E-Mail", - "npmLoginEmailPlaceholder": "Leer lassen um aktuelle beizubehalten", - "npmLoginPassword": "NPM Login Passwort", - "npmLoginPasswordPlaceholder": "Leer lassen um aktuelles beizubehalten", - "credentialsSet": "Zugangsdaten sind gesetzt (leer lassen um aktuelle beizubehalten)", - "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", - "signalImagePlaceholder": "netbirdio/signal:latest", - "relayImage": "Relay Image", - "relayImagePlaceholder": "netbirdio/relay:latest", - "dashboardImage": "Dashboard Image", - "dashboardImagePlaceholder": "netbirdio/dashboard:latest", - "saveImageSettings": "Image Einstellungen speichern", - "brandingTitle": "Branding Einstellungen", - "companyName": "Firmen- / Anwendungsname", - "companyNamePlaceholder": "NetBird MSP Appliance", - "companyNameHint": "Wird auf der Login-Seite und in der Navbar angezeigt", - "logoPreview": "Logo-Vorschau", - "defaultIcon": "Standard-Icon (kein Logo hochgeladen)", - "uploadLogo": "Logo hochladen (PNG, JPG, SVG, max 500KB)", - "uploadBtn": "Hochladen", - "removeLogo": "Logo entfernen", - "brandingSubtitle": "Untertitel", - "brandingSubtitlePlaceholder": "Multi-Tenant Management Plattform", - "brandingSubtitleHint": "Wird unter dem Titel auf der Login-Seite angezeigt", - "defaultLanguage": "Standardsprache", - "defaultLanguageHint": "Standardsprache fuer Benutzer ohne eigene Einstellung", - "systemDefault": "Systemstandard", - "saveBranding": "Branding speichern", - "userManagement": "Benutzerverwaltung", - "newUser": "Neuer Benutzer", - "thId": "ID", - "thUsername": "Benutzername", - "thEmail": "E-Mail", - "thRole": "Rolle", - "thAuth": "Auth", - "thLanguage": "Sprache", - "thStatus": "Status", - "thActions": "Aktionen", - "azureTitle": "Azure AD / Entra ID Integration", - "enableAzureSso": "Azure AD SSO aktivieren", - "tenantId": "Tenant ID", - "clientId": "Client ID (Anwendungs-ID)", - "clientSecret": "Client Secret", - "clientSecretPlaceholder": "Leer lassen um aktuelles beizubehalten", - "secretSet": "Secret ist gesetzt (leer lassen um aktuelles beizubehalten)", - "noSecret": "Kein Client Secret konfiguriert", - "saveAzureSettings": "Azure AD Einstellungen speichern", - "securityTitle": "Admin-Passwort aendern", - "currentPassword": "Aktuelles Passwort", - "newPassword": "Neues Passwort (min. 12 Zeichen)", - "confirmPassword": "Neues Passwort bestaetigen", - "changePassword": "Passwort aendern" - }, - "monitoring": { - "title": "System Monitoring", - "refresh": "Aktualisieren", - "hostResources": "Host-Ressourcen", - "hostname": "Hostname", - "cpu": "CPU ({count} Kerne)", - "memory": "Speicher ({used}/{total} GB)", - "disk": "Festplatte ({used}/{total} GB)", - "allCustomerDeployments": "Alle Kunden-Deployments", - "thId": "ID", - "thName": "Name", - "thSubdomain": "Subdomain", - "thStatus": "Status", - "thDeployment": "Deployment", - "thDashboard": "Dashboard", - "thRelayPort": "Relay Port", - "thContainers": "Container", - "noCustomers": "Keine Kunden." - }, - "userModal": { - "title": "Neuer Benutzer", - "usernameLabel": "Benutzername *", - "passwordLabel": "Passwort * (min. 8 Zeichen)", - "emailLabel": "E-Mail", - "languageLabel": "Standardsprache", - "cancel": "Abbrechen", - "createUser": "Benutzer erstellen" - }, - "mfa": { - "title": "Multi-Faktor-Authentifizierung (MFA)", - "enableMfa": "MFA fuer alle lokalen Benutzer aktivieren", - "mfaDescription": "Wenn aktiviert, muessen lokale Benutzer sich nach der Passworteingabe mit einer TOTP-Authenticator-App verifizieren. Azure AD Benutzer sind nicht betroffen.", - "saveMfaSettings": "MFA Einstellungen speichern", - "yourTotpStatus": "Ihr TOTP Status", - "totpActive": "Aktiv", - "totpNotSetUp": "Nicht eingerichtet", - "disableMyTotp": "Mein TOTP deaktivieren", - "enterCode": "Geben Sie Ihren 6-stelligen Authenticator-Code ein", - "verify": "Verifizieren", - "backToLogin": "Zurueck zum Login", - "scanQrCode": "Scannen Sie diesen QR-Code mit Ihrer Authenticator-App", - "orEnterManually": "Oder geben Sie diesen Schluessel manuell ein:", - "verifyAndActivate": "Verifizieren & Aktivieren", - "resetMfa": "MFA zuruecksetzen", - "confirmResetMfa": "MFA fuer '{username}' zuruecksetzen? Der Benutzer muss seinen Authenticator beim naechsten Login neu einrichten.", - "mfaResetSuccess": "MFA fuer '{username}' zurueckgesetzt.", - "mfaDisabled": "Ihr TOTP wurde deaktiviert.", - "mfaSaved": "MFA Einstellungen gespeichert.", - "invalidCode": "Ungueltiger Code. Bitte versuchen Sie es erneut.", - "codeExpired": "Verifizierung abgelaufen. Bitte melden Sie sich erneut an." - }, - "common": { - "loading": "Laden...", - "back": "Zurueck", - "save": "Speichern", - "cancel": "Abbrechen", - "delete": "Loeschen", - "edit": "Bearbeiten", - "view": "Anzeigen", - "start": "Starten", - "stop": "Stoppen", - "restart": "Neustarten", - "disable": "Deaktivieren", - "enable": "Aktivieren", - "resetPassword": "Passwort zuruecksetzen", - "open": "Oeffnen", - "active": "Aktiv", - "disabled": "Deaktiviert" - }, - "errors": { - "networkError": "Netzwerkfehler — Server nicht erreichbar.", - "sessionExpired": "Sitzung abgelaufen.", - "requestFailed": "Anfrage fehlgeschlagen.", - "serverError": "Serverfehler (HTTP {status}).", - "unknownError": "Ein unbekannter Fehler ist aufgetreten.", - "uploadFailed": "Upload fehlgeschlagen.", - "deleteFailed": "Loeschen fehlgeschlagen: {error}", - "failedToLoadSettings": "Einstellungen konnten nicht geladen werden: {error}", - "failed": "Fehlgeschlagen: {error}", - "logoUploadFailed": "Logo-Upload fehlgeschlagen: {error}", - "failedToRemoveLogo": "Logo konnte nicht entfernt werden: {error}", - "updateFailed": "Aktualisierung fehlgeschlagen: {error}", - "passwordResetFailed": "Passwort-Zuruecksetzung fehlgeschlagen: {error}", - "selectFileFirst": "Bitte waehlen Sie zuerst eine Datei aus.", - "passwordsDoNotMatch": "Passwoerter stimmen nicht ueberein.", - "failedToLoadCredentials": "Zugangsdaten konnten nicht geladen werden: {error}", - "azureNotConfigured": "Azure AD ist nicht konfiguriert.", - "azureLoginFailed": "Azure AD Anmeldung fehlgeschlagen: {error}", - "actionFailed": "{action} fehlgeschlagen: {error}" - }, - "messages": { - "systemSettingsSaved": "Systemeinstellungen gespeichert.", - "npmSettingsSaved": "NPM Einstellungen gespeichert.", - "imageSettingsSaved": "Image Einstellungen gespeichert.", - "brandingNameSaved": "Branding-Einstellungen gespeichert.", - "logoUploaded": "Logo erfolgreich hochgeladen.", - "logoRemoved": "Logo entfernt.", - "azureSettingsSaved": "Azure AD Einstellungen gespeichert.", - "passwordChanged": "Passwort erfolgreich geaendert.", - "setupUrlCopied": "Setup URL in die Zwischenablage kopiert.", - "copiedToClipboard": "In die Zwischenablage kopiert.", - "userCreated": "Benutzer '{username}' erstellt.", - "userDeleted": "Benutzer '{username}' geloescht.", - "passwordResetFor": "Passwort fuer '{username}' zurueckgesetzt.", - "newPasswordAlert": "Neues Passwort fuer '{username}':\n\n{password}\n\nBitte speichern Sie dieses Passwort jetzt. Es wird nicht erneut angezeigt.", - "confirmDeleteUser": "Benutzer '{username}' loeschen? Dies kann nicht rueckgaengig gemacht werden.", - "confirmResetPassword": "Passwort fuer '{username}' zuruecksetzen? Ein neues zufaelliges Passwort wird generiert." + "lastCheck": "Letzte Prüfung: {time}", + "openDashboard": "Dashboard öffnen" } } From 72bad11129bae9ef5e7619fb491a9b78f16d429d Mon Sep 17 00:00:00 2001 From: twothatit Date: Wed, 18 Feb 2026 21:28:49 +0100 Subject: [PATCH 03/44] security: apply four immediate security fixes Fix #1 - SECRET_KEY startup validation (config.py, .env): - App refuses to start if SECRET_KEY is missing, shorter than 32 chars, or matches a known insecure default value - .env: replaced hardcoded test key with placeholder + generation hint Fix #2 - Docker socket proxy (docker-compose.yml): - Add tecnativa/docker-socket-proxy sidecar - Only expose required Docker API endpoints (CONTAINERS, IMAGES, NETWORKS, POST, EXEC); dangerous endpoints explicitly blocked - Remove direct /var/run/docker.sock mount from main container - Route Docker API via DOCKER_HOST=tcp://docker-socket-proxy:2375 Fix #3 - Azure AD group whitelist (auth.py, models.py, validators.py): - New azure_allowed_group_id field in SystemConfig - After token exchange, verify group membership via Graph API /me/memberOf - Deny login with HTTP 403 if user is not in the required group - New Azure AD users now get role 'viewer' instead of 'admin' Fix #4 - Rate limiting on login (main.py, auth.py, requirements.txt): - Add slowapi==0.1.9 dependency - Initialize SlowAPI limiter in main.py with 429 exception handler - Apply 10 requests/minute limit per IP on /login and /mfa/verify --- app/main.py | 17 ++++++++- app/models.py | 5 +++ app/routers/auth.py | 81 ++++++++++++++++++++++++++++++++++++----- app/utils/config.py | 28 +++++++++++++- app/utils/validators.py | 4 ++ docker-compose.yml | 43 +++++++++++++++++++++- requirements.txt | 1 + 7 files changed, 166 insertions(+), 13 deletions(-) diff --git a/app/main.py b/app/main.py index be9cd11..901c80c 100644 --- a/app/main.py +++ b/app/main.py @@ -3,10 +3,13 @@ import logging import os -from fastapi import FastAPI +from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles +from slowapi import Limiter, _rate_limit_exceeded_handler +from slowapi.errors import RateLimitExceeded +from slowapi.util import get_remote_address from app.database import init_db from app.routers import auth, customers, deployments, monitoring, settings, users @@ -21,6 +24,14 @@ logging.basicConfig( ) logger = logging.getLogger(__name__) +# --------------------------------------------------------------------------- +# Application +# --------------------------------------------------------------------------- +# --------------------------------------------------------------------------- +# Rate limiter (SlowAPI) +# --------------------------------------------------------------------------- +limiter = Limiter(key_func=get_remote_address) + # --------------------------------------------------------------------------- # Application # --------------------------------------------------------------------------- @@ -33,6 +44,10 @@ app = FastAPI( openapi_url="/api/openapi.json", ) +# Attach limiter to app state and register the 429 exception handler +app.state.limiter = limiter +app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) + # CORS — allow same-origin; adjust if needed app.add_middleware( CORSMiddleware, diff --git a/app/models.py b/app/models.py index 8b3f7cb..618babc 100644 --- a/app/models.py +++ b/app/models.py @@ -168,6 +168,10 @@ class SystemConfig(Base): azure_tenant_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) azure_client_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) azure_client_secret_encrypted: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + azure_allowed_group_id: Mapped[Optional[str]] = mapped_column( + String(255), nullable=True, + comment="If set, only Azure AD users in this group (object ID) are allowed to log in." + ) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) updated_at: Mapped[datetime] = mapped_column( DateTime, default=datetime.utcnow, onupdate=datetime.utcnow @@ -203,6 +207,7 @@ class SystemConfig(Base): "azure_tenant_id": self.azure_tenant_id or "", "azure_client_id": self.azure_client_id or "", "azure_client_secret_set": bool(self.azure_client_secret_encrypted), + "azure_allowed_group_id": self.azure_allowed_group_id or "", "created_at": self.created_at.isoformat() if self.created_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None, } diff --git a/app/routers/auth.py b/app/routers/auth.py index 7e541cc..cd7e107 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -6,7 +6,7 @@ import logging import secrets from datetime import datetime -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Request, status from pydantic import BaseModel from sqlalchemy.orm import Session @@ -27,10 +27,17 @@ from app.utils.validators import ChangePasswordRequest, LoginRequest, MfaTokenRe logger = logging.getLogger(__name__) router = APIRouter() +# Import the shared rate limiter from main +from app.main import limiter + @router.post("/login") -async def login(payload: LoginRequest, db: Session = Depends(get_db)): - """Authenticate with username/password. May require MFA as a second step.""" +@limiter.limit("10/minute") +async def login(request: Request, payload: LoginRequest, db: Session = Depends(get_db)): + """Authenticate with username/password. May require MFA as a second step. + + Rate-limited to 10 attempts per minute per IP address. + """ user = db.query(User).filter(User.username == payload.username).first() if not user or not verify_password(payload.password, user.password_hash): raise HTTPException( @@ -129,8 +136,12 @@ async def mfa_setup_complete(payload: MfaVerifyRequest, db: Session = Depends(ge @router.post("/mfa/verify") -async def mfa_verify(payload: MfaVerifyRequest, db: Session = Depends(get_db)): - """Verify a TOTP code for users who already have MFA set up.""" +@limiter.limit("10/minute") +async def mfa_verify(request: Request, payload: MfaVerifyRequest, db: Session = Depends(get_db)): + """Verify a TOTP code for users who already have MFA set up. + + Rate-limited to 10 attempts per minute per IP address. + """ username = verify_mfa_token(payload.mfa_token) user = db.query(User).filter(User.username == username).first() if not user: @@ -262,17 +273,18 @@ async def azure_callback( try: import msal + import httpx as _httpx client_secret = decrypt_value(config.azure_client_secret_encrypted) authority = f"https://login.microsoftonline.com/{config.azure_tenant_id}" - app = msal.ConfidentialClientApplication( + msal_app = msal.ConfidentialClientApplication( config.azure_client_id, authority=authority, client_credential=client_secret, ) - result = app.acquire_token_by_authorization_code( + result = msal_app.acquire_token_by_authorization_code( payload.code, scopes=["User.Read"], redirect_uri=payload.redirect_uri, @@ -287,7 +299,8 @@ async def azure_callback( 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) + display_name = id_token_claims.get("name", email) # noqa: F841 + user_access_token = result.get("access_token", "") if not email: raise HTTPException( @@ -295,6 +308,54 @@ async def azure_callback( detail="Could not determine email from Azure AD token.", ) + # ----------------------------------------------------------------- + # Group membership check (Fix #3 – Azure AD group whitelist) + # ----------------------------------------------------------------- + allowed_group_id = getattr(config, "azure_allowed_group_id", None) + if allowed_group_id: + # Use the user's own access token to check their group membership + # via the Microsoft Graph API (requires GroupMember.Read.All or + # the user's own memberOf delegated permission). + graph_url = "https://graph.microsoft.com/v1.0/me/memberOf" + is_member = False + try: + async with _httpx.AsyncClient(timeout=10) as http: + resp = await http.get( + graph_url, + headers={"Authorization": f"Bearer {user_access_token}"}, + ) + if resp.status_code == 200: + groups = resp.json().get("value", []) + is_member = any( + g.get("id") == allowed_group_id for g in groups + ) + else: + logger.warning( + "Graph API group check returned %s for user '%s'.", + resp.status_code, email, + ) + except Exception as graph_exc: + logger.error("Graph API group check failed: %s", graph_exc) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Could not verify Azure AD group membership. Please try again.", + ) + + if not is_member: + logger.warning( + "Azure AD login denied for '%s': not a member of required group '%s'.", + email, allowed_group_id, + ) + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied: you are not a member of the required Azure AD group.", + ) + else: + logger.warning( + "azure_allowed_group_id is not configured. All Azure AD tenant users can log in. " + "Set azure_allowed_group_id in Settings to restrict access." + ) + # Find or create user user = db.query(User).filter(User.username == email).first() if not user: @@ -303,13 +364,13 @@ async def azure_callback( password_hash=hash_password(secrets.token_urlsafe(32)), email=email, is_active=True, - role="admin", + role="viewer", # New Azure users start as viewer; promote manually auth_provider="azure", ) db.add(user) db.commit() db.refresh(user) - logger.info("Azure AD user '%s' auto-created.", email) + logger.info("Azure AD user '%s' auto-created with role 'viewer'.", email) elif not user.is_active: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, diff --git a/app/utils/config.py b/app/utils/config.py index 0d7d895..970aaf6 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -35,8 +35,34 @@ class AppConfig: wildcard_cert_id: int | None +# --------------------------------------------------------------------------- # Environment-level settings (not stored in DB) -SECRET_KEY: str = os.environ.get("SECRET_KEY", "change-me-in-production") +# --------------------------------------------------------------------------- + +# Known insecure default values that must never be used in production. +_INSECURE_KEY_VALUES: set[str] = { + "change-me-in-production", + "local-test-secret-key-not-for-production-1234", + "secret", + "changeme", + "", +} + +SECRET_KEY: str = os.environ.get("SECRET_KEY", "") + +# --- Startup security gate --- +# Abort immediately if the key is missing, too short, or a known default. +_MIN_KEY_LENGTH = 32 +if SECRET_KEY in _INSECURE_KEY_VALUES or len(SECRET_KEY) < _MIN_KEY_LENGTH: + raise RuntimeError( + "FATAL: SECRET_KEY is insecure, missing, or too short.\n" + f" Current length : {len(SECRET_KEY)} characters (minimum: {_MIN_KEY_LENGTH})\n" + " The key must be at least 32 random characters and must not be a known default value.\n" + " Generate a secure key with:\n" + " python3 -c \"import secrets; print(secrets.token_hex(32))\"\n" + " Then set it in your .env file as: SECRET_KEY=" + ) + DATABASE_PATH: str = os.environ.get("DATABASE_PATH", "/app/data/netbird_msp.db") LOG_LEVEL: str = os.environ.get("LOG_LEVEL", "INFO") JWT_ALGORITHM: str = "HS256" diff --git a/app/utils/validators.py b/app/utils/validators.py index 202e5cf..f19ee75 100644 --- a/app/utils/validators.py +++ b/app/utils/validators.py @@ -133,6 +133,10 @@ class SystemConfigUpdate(BaseModel): 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 + azure_allowed_group_id: Optional[str] = Field( + None, max_length=255, + description="Azure AD group object ID. If set, only members of this group can log in." + ) @field_validator("ssl_mode") @classmethod diff --git a/docker-compose.yml b/docker-compose.yml index 748cf9c..9c02bc2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,15 +1,54 @@ services: + # --------------------------------------------------------------------------- + # Docker Socket Proxy — limits Docker API access to only what is needed. + # The main app container no longer has direct access to /var/run/docker.sock. + # --------------------------------------------------------------------------- + docker-socket-proxy: + image: tecnativa/docker-socket-proxy:latest + container_name: docker-socket-proxy + restart: unless-stopped + environment: + # Read-only endpoints + CONTAINERS: 1 + IMAGES: 1 + NETWORKS: 1 + INFO: 1 + # Write endpoints (needed for compose up/down/start/stop) + POST: 1 + # Explicitly deny dangerous endpoints + AUTH: 0 + SECRETS: 0 + SWARM: 0 + NODES: 0 + SERVICES: 0 + TASKS: 0 + CONFIGS: 0 + PLUGINS: 0 + VOLUMES: 0 + BUILD: 0 + COMMIT: 0 + DISTRIBUTION: 0 + EXEC: 1 + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + networks: + - npm-network + # Only accessible from within the Docker network — never expose port externally + netbird-msp-appliance: build: . container_name: netbird-msp-appliance restart: unless-stopped + depends_on: + - docker-socket-proxy ports: - "${WEB_UI_PORT:-8000}:8000" volumes: - ./data:/app/data - ./logs:/app/logs - ./backups:/app/backups - - /var/run/docker.sock:/var/run/docker.sock + # NOTE: /var/run/docker.sock is intentionally NOT mounted here. + # Docker access goes through the docker-socket-proxy sidecar. - ${DATA_DIR:-/opt/netbird-instances}:${DATA_DIR:-/opt/netbird-instances} environment: - SECRET_KEY=${SECRET_KEY} @@ -18,6 +57,8 @@ services: - DATA_DIR=${DATA_DIR:-/opt/netbird-instances} - DOCKER_NETWORK=${DOCKER_NETWORK:-npm-network} - HOST_IP=${HOST_IP:-} + # Route Docker API calls through the socket proxy instead of the raw socket + - DOCKER_HOST=tcp://docker-socket-proxy:2375 networks: - npm-network healthcheck: diff --git a/requirements.txt b/requirements.txt index f778cda..f113dbd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,6 +20,7 @@ pyyaml==6.0.1 msal==1.28.0 pyotp==2.9.0 qrcode[pil]==7.4.2 +slowapi==0.1.9 pytest==7.4.3 pytest-asyncio==0.23.2 pytest-httpx==0.28.0 From c00b52df83428d420236810b2589173c07cd3c24 Mon Sep 17 00:00:00 2001 From: twothatit Date: Wed, 18 Feb 2026 22:27:55 +0100 Subject: [PATCH 04/44] Add Project Spec for AI --- .gitignore | 1 - CLAUDE_CODE_SPEC.md | 459 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 459 insertions(+), 1 deletion(-) create mode 100644 CLAUDE_CODE_SPEC.md diff --git a/.gitignore b/.gitignore index 514f6c9..b27c445 100644 --- a/.gitignore +++ b/.gitignore @@ -65,7 +65,6 @@ htmlcov/ # Claude Code .claude/ -CLAUDE_CODE_SPEC.md PROJECT_SUMMARY.md QUICKSTART.md VS_CODE_SETUP.md diff --git a/CLAUDE_CODE_SPEC.md b/CLAUDE_CODE_SPEC.md new file mode 100644 index 0000000..1bc5cf4 --- /dev/null +++ b/CLAUDE_CODE_SPEC.md @@ -0,0 +1,459 @@ +# NetBird MSP Appliance - Claude Code Specification + +## Project Overview +Build a complete, production-ready multi-tenant NetBird management platform that runs entirely in Docker containers. This is an MSP (Managed Service Provider) tool to manage 100+ isolated NetBird instances from a single web interface. + +## Technology Stack +- **Backend**: Python 3.11+ with FastAPI +- **Frontend**: HTML5 + Bootstrap 5 + Vanilla JavaScript (no frameworks) +- **Database**: SQLite +- **Containerization**: Docker + Docker Compose +- **Templating**: Jinja2 for Docker Compose generation +- **Integration**: Docker Python SDK, Nginx Proxy Manager API + +## Project Structure + +``` +netbird-msp-appliance/ +├── README.md # Main documentation +├── QUICKSTART.md # Quick start guide +├── ARCHITECTURE.md # Architecture documentation +├── LICENSE # MIT License +├── .gitignore # Git ignore file +├── .env.example # Environment variables template +├── install.sh # One-click installation script +├── docker-compose.yml # Main application container +├── Dockerfile # Application container definition +├── requirements.txt # Python dependencies +│ +├── app/ # Python application +│ ├── __init__.py +│ ├── main.py # FastAPI entry point +│ ├── models.py # SQLAlchemy models +│ ├── database.py # Database setup +│ ├── dependencies.py # FastAPI dependencies +│ │ +│ ├── routers/ # API endpoints +│ │ ├── __init__.py +│ │ ├── auth.py # Authentication endpoints +│ │ ├── customers.py # Customer CRUD +│ │ ├── deployments.py # Deployment management +│ │ ├── monitoring.py # Status & health checks +│ │ └── settings.py # System configuration +│ │ +│ ├── services/ # Business logic +│ │ ├── __init__.py +│ │ ├── docker_service.py # Docker container management +│ │ ├── npm_service.py # NPM API integration +│ │ ├── netbird_service.py # NetBird deployment orchestration +│ │ └── port_manager.py # UDP port allocation +│ │ +│ └── utils/ # Utilities +│ ├── __init__.py +│ ├── config.py # Configuration management +│ ├── security.py # Encryption, hashing +│ └── validators.py # Input validation +│ +├── templates/ # Jinja2 templates +│ ├── docker-compose.yml.j2 # Per-customer Docker Compose +│ ├── management.json.j2 # NetBird management config +│ └── relay.env.j2 # Relay environment variables +│ +├── static/ # Frontend files +│ ├── index.html # Main dashboard +│ ├── css/ +│ │ └── styles.css # Custom styles +│ └── js/ +│ └── app.js # Frontend JavaScript +│ +├── tests/ # Unit & integration tests +│ ├── __init__.py +│ ├── test_customer_api.py +│ ├── test_deployment.py +│ └── test_docker_service.py +│ +└── docs/ # Additional documentation + ├── API.md # API documentation + ├── DEPLOYMENT.md # Deployment guide + └── TROUBLESHOOTING.md # Common issues +``` + +## Key Features to Implement + +### 1. Customer Management +- **Create Customer**: Web form → API → Deploy NetBird instance +- **List Customers**: Paginated table with search/filter +- **Customer Details**: Status, logs, setup URL, actions +- **Delete Customer**: Remove all containers, NPM entries, data + +### 2. Automated Deployment +**Workflow when creating customer:** +1. Validate inputs (subdomain unique, email valid) +2. Allocate ports (Management internal, Relay UDP public) +3. Generate configs from Jinja2 templates +4. Create instance directory: `/opt/netbird-instances/kunde{id}/` +5. Write `docker-compose.yml`, `management.json`, `relay.env` +6. Start Docker containers via Docker SDK +7. Wait for health checks (max 60s) +8. Create NPM proxy hosts via API (with SSL) +9. Update database with deployment info +10. Return setup URL to user + +### 3. Web-Based Configuration +**All settings in database, editable via UI:** +- Base Domain +- Admin Email +- NPM API URL & Token +- NetBird Docker Images +- Port Ranges +- Data Directories + +No manual config file editing required! + +### 4. Nginx Proxy Manager Integration +**Per customer, create proxy host:** +- Domain: `{subdomain}.{base_domain}` +- Forward to: `netbird-kunde{id}-dashboard:80` +- SSL: Automatic Let's Encrypt +- Advanced config: Route `/api/*` to management, `/signalexchange.*` to signal, `/relay` to relay + +### 5. Port Management +**UDP Ports for STUN/Relay (publicly accessible):** +- Customer 1: 3478 +- Customer 2: 3479 +- ... +- Customer 100: 3577 + +**Algorithm:** +- Find next available port starting from 3478 +- Check if port not in use (via `netstat` or database) +- Assign to customer +- Store in database + +### 6. Monitoring & Health Checks +- Container status (running/stopped/failed) +- Health check endpoints (HTTP checks to management service) +- Resource usage (via Docker stats API) +- Relay connectivity test + +## Database Schema + +### Table: customers +```sql +CREATE TABLE customers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + company TEXT, + subdomain TEXT UNIQUE NOT NULL, + email TEXT NOT NULL, + max_devices INTEGER DEFAULT 20, + notes TEXT, + status TEXT DEFAULT 'active' CHECK(status IN ('active', 'inactive', 'deploying', 'error')), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +### Table: deployments +```sql +CREATE TABLE deployments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + customer_id INTEGER NOT NULL UNIQUE, + container_prefix TEXT NOT NULL, + relay_udp_port INTEGER UNIQUE NOT NULL, + npm_proxy_id INTEGER, + relay_secret TEXT NOT NULL, + setup_url TEXT, + deployment_status TEXT DEFAULT 'pending' CHECK(deployment_status IN ('pending', 'running', 'stopped', 'failed')), + deployed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_health_check TIMESTAMP, + FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE CASCADE +); +``` + +### Table: system_config +```sql +CREATE TABLE system_config ( + id INTEGER PRIMARY KEY CHECK (id = 1), + base_domain TEXT NOT NULL, + admin_email TEXT NOT NULL, + npm_api_url TEXT NOT NULL, + npm_api_token_encrypted TEXT NOT NULL, + netbird_management_image TEXT DEFAULT 'netbirdio/management:latest', + netbird_signal_image TEXT DEFAULT 'netbirdio/signal:latest', + netbird_relay_image TEXT DEFAULT 'netbirdio/relay:latest', + netbird_dashboard_image TEXT DEFAULT 'netbirdio/dashboard:latest', + data_dir TEXT DEFAULT '/opt/netbird-instances', + docker_network TEXT DEFAULT 'npm-network', + relay_base_port INTEGER DEFAULT 3478, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +### Table: deployment_logs +```sql +CREATE TABLE deployment_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + customer_id INTEGER NOT NULL, + action TEXT NOT NULL, + status TEXT NOT NULL CHECK(status IN ('success', 'error', 'info')), + message TEXT, + details TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE CASCADE +); +``` + +### Table: users (simple auth) +```sql +CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + email TEXT, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +## API Endpoints to Implement + +### Authentication +``` +POST /api/auth/login # Login and get token +POST /api/auth/logout # Logout +GET /api/auth/me # Get current user +POST /api/auth/change-password +``` + +### Customers +``` +POST /api/customers # Create + auto-deploy +GET /api/customers # List all (pagination, search, filter) +GET /api/customers/{id} # Get details +PUT /api/customers/{id} # Update +DELETE /api/customers/{id} # Delete + cleanup +``` + +### Deployments +``` +POST /api/customers/{id}/deploy # Manual deploy +POST /api/customers/{id}/start # Start containers +POST /api/customers/{id}/stop # Stop containers +POST /api/customers/{id}/restart # Restart containers +GET /api/customers/{id}/logs # Get container logs +GET /api/customers/{id}/health # Health check +``` + +### Monitoring +``` +GET /api/monitoring/status # System overview +GET /api/monitoring/customers # All customers status +GET /api/monitoring/resources # Host resource usage +``` + +### Settings +``` +GET /api/settings/system # Get system config +PUT /api/settings/system # Update system config +GET /api/settings/test-npm # Test NPM connectivity +``` + +## Docker Compose Template (Per Customer) + +```yaml +version: '3.8' + +networks: + npm-network: + external: true + +services: + netbird-management: + image: {{ netbird_management_image }} + container_name: netbird-kunde{{ customer_id }}-management + restart: unless-stopped + networks: + - npm-network + volumes: + - {{ instance_dir }}/data/management:/var/lib/netbird + - {{ instance_dir }}/management.json:/etc/netbird/management.json + command: ["--port", "80", "--log-file", "console", "--log-level", "info", + "--single-account-mode-domain={{ subdomain }}.{{ base_domain }}", + "--dns-domain={{ subdomain }}.{{ base_domain }}"] + + netbird-signal: + image: {{ netbird_signal_image }} + container_name: netbird-kunde{{ customer_id }}-signal + restart: unless-stopped + networks: + - npm-network + volumes: + - {{ instance_dir }}/data/signal:/var/lib/netbird + + netbird-relay: + image: {{ netbird_relay_image }} + container_name: netbird-kunde{{ customer_id }}-relay + restart: unless-stopped + networks: + - npm-network + ports: + - "{{ relay_udp_port }}:3478/udp" + env_file: + - {{ instance_dir }}/relay.env + environment: + - NB_ENABLE_STUN=true + - NB_STUN_PORTS=3478 + - NB_LISTEN_ADDRESS=:80 + - NB_EXPOSED_ADDRESS=rels://{{ subdomain }}.{{ base_domain }}:443 + - NB_AUTH_SECRET={{ relay_secret }} + + netbird-dashboard: + image: {{ netbird_dashboard_image }} + container_name: netbird-kunde{{ customer_id }}-dashboard + restart: unless-stopped + networks: + - npm-network + environment: + - NETBIRD_MGMT_API_ENDPOINT=https://{{ subdomain }}.{{ base_domain }} + - NETBIRD_MGMT_GRPC_API_ENDPOINT=https://{{ subdomain }}.{{ base_domain }} +``` + +## Frontend Requirements + +### Main Dashboard (index.html) +**Layout:** +- Navbar: Logo, "New Customer" button, User menu (settings, logout) +- Stats Cards: Total customers, Active, Inactive, Errors +- Customer Table: Name, Subdomain, Status, Devices, Actions +- Pagination: 25 customers per page +- Search bar: Filter by name, subdomain, email +- Status filter dropdown: All, Active, Inactive, Error + +**Customer Table Actions:** +- View Details (→ customer detail page) +- Start/Stop/Restart (inline buttons) +- Delete (with confirmation modal) + +### Customer Detail Page +**Tabs:** +1. **Info**: All customer details, edit button +2. **Deployment**: Status, Setup URL (copy button), Container status +3. **Logs**: Real-time logs from all containers (auto-refresh) +4. **Health**: Health check results, relay connectivity test + +### Settings Page +**Tabs:** +1. **System Configuration**: All system settings, save button +2. **NPM Integration**: API URL, Token, Test button +3. **Images**: NetBird Docker image tags +4. **Security**: Change admin password + +### Modal Dialogs +- New/Edit Customer Form +- Delete Confirmation +- Deployment Progress (with spinner) +- Error Display + +## Security Requirements + +1. **Password Hashing**: Use bcrypt for admin password +2. **Secret Encryption**: Encrypt NPM token and relay secrets with Fernet +3. **Input Validation**: Pydantic models for all API inputs +4. **SQL Injection Prevention**: Use SQLAlchemy ORM (no raw queries) +5. **CSRF Protection**: Token-based authentication +6. **Rate Limiting**: Prevent brute force on login endpoint + +## Error Handling + +All operations should have comprehensive error handling: + +```python +try: + # Deploy customer + result = deploy_customer(customer_id) +except DockerException as e: + # Rollback: Stop containers + # Log error + # Update status to 'failed' + # Return error to user +except NPMException as e: + # Rollback: Remove containers + # Log error + # Update status to 'failed' +except Exception as e: + # Generic rollback + # Log error + # Alert admin +``` + +## Testing Requirements + +1. **Unit Tests**: All services (docker_service, npm_service, etc.) +2. **Integration Tests**: Full deployment workflow +3. **API Tests**: All endpoints with different scenarios +4. **Mock External Dependencies**: Docker API, NPM API + +## Deployment Process + +1. Clone repository +2. Run `./install.sh` +3. Access `http://server-ip:8000` +4. Complete setup wizard +5. Deploy first customer + +## System Requirements Documentation + +**Include in README.md:** + +### For 100 Customers: +- **CPU**: 16 cores (minimum 8) +- **RAM**: 64 GB (minimum) - 128 GB (recommended) + - Formula: `(100 customers × 600 MB) + 8 GB overhead = 68 GB` +- **Disk**: 500 GB SSD (minimum) - 1 TB recommended +- **Network**: 1 Gbps dedicated connection +- **OS**: Ubuntu 22.04 LTS or 24.04 LTS + +### Port Requirements: +- **TCP 8000**: Web UI +- **UDP 3478-3577**: Relay/STUN (100 ports for 100 customers) + +## Success Criteria + +✅ One-command installation via `install.sh` +✅ Web-based configuration (no manual file editing) +✅ Customer deployment < 2 minutes +✅ All settings in database +✅ Automatic NPM integration +✅ Comprehensive error handling +✅ Clean, professional UI +✅ Full API documentation (auto-generated) +✅ Health monitoring +✅ Easy to deploy on fresh Ubuntu VM + +## Special Notes for Claude Code + +- **Use type hints** throughout Python code +- **Document all functions** with docstrings +- **Follow PEP 8** style guidelines +- **Create modular code**: Each service should be independently testable +- **Use async/await** where appropriate (FastAPI endpoints) +- **Provide comprehensive comments** for complex logic +- **Include error messages** that help users troubleshoot + +## File Priorities + +Create in this order: +1. Basic structure (directories, requirements.txt, Dockerfile, docker-compose.yml) +2. Database models and setup (models.py, database.py) +3. Core services (docker_service.py, port_manager.py) +4. API routers (start with customers.py) +5. NPM integration (npm_service.py) +6. Templates (Jinja2 files) +7. Frontend (HTML, CSS, JS) +8. Installation script +9. Documentation +10. Tests + +This specification provides everything needed to build a production-ready NetBird MSP Appliance! From 0ac15e4db9b03a63f07309553109fb1d11a251bc Mon Sep 17 00:00:00 2001 From: twothatit Date: Wed, 18 Feb 2026 22:39:39 +0100 Subject: [PATCH 05/44] rename: CLAUDE_CODE_SPEC.md -> ProjectAISpec.md Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE_CODE_SPEC.md => ProjectAISpec.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename CLAUDE_CODE_SPEC.md => ProjectAISpec.md (100%) diff --git a/CLAUDE_CODE_SPEC.md b/ProjectAISpec.md similarity index 100% rename from CLAUDE_CODE_SPEC.md rename to ProjectAISpec.md From 1bbe4904a7eed6f3c0553bafa5e2d1551f45613d Mon Sep 17 00:00:00 2001 From: twothatit Date: Thu, 19 Feb 2026 00:30:25 +0100 Subject: [PATCH 06/44] fix: resolve circular import, async blocking, SELinux and delete timeout issues - Extract shared SlowAPI limiter to app/limiter.py to break circular import between app.main and app.routers.auth - Seed default SystemConfig row (id=1) on first DB init so settings page works out of the box - Make all docker_service.compose_* functions async (run_in_executor) so long docker pulls/stops no longer block the async event loop - Propagate async to netbird_service stop/start/restart and await callers in deployments router - Move customer delete to BackgroundTasks so the HTTP response returns immediately and avoids frontend "Network error" on slow machines - docker-compose: add :z SELinux labels, mount docker.sock directly, add security_opt label:disable for socket access, extra_hosts for host.docker.internal, enable DELETE/VOLUMES on socket proxy - npm_service: auto-detect outbound host IP via UDP socket when HOST_IP env var is not set Co-Authored-By: Claude Sonnet 4.6 --- app/database.py | 16 ++++++++++++++++ app/limiter.py | 5 +++++ app/main.py | 9 ++------- app/routers/auth.py | 3 +-- app/routers/customers.py | 30 ++++++++++++++++++++---------- app/routers/deployments.py | 6 +++--- app/services/docker_service.py | 30 ++++++++++++++++++++---------- app/services/netbird_service.py | 20 ++++++++++---------- app/services/npm_service.py | 13 ++++++++++++- docker-compose.yml | 23 +++++++++++++---------- 10 files changed, 102 insertions(+), 53 deletions(-) create mode 100644 app/limiter.py diff --git a/app/database.py b/app/database.py index d25cd37..f630cc0 100644 --- a/app/database.py +++ b/app/database.py @@ -51,6 +51,22 @@ def init_db() -> None: Base.metadata.create_all(bind=engine) _run_migrations() + # Insert default SystemConfig row (id=1) if it doesn't exist yet + db = SessionLocal() + try: + if not db.query(SystemConfig).filter(SystemConfig.id == 1).first(): + db.add(SystemConfig( + id=1, + base_domain="example.com", + admin_email="admin@example.com", + npm_api_url="http://localhost:81", + npm_api_email_encrypted="", + npm_api_password_encrypted="", + )) + db.commit() + finally: + db.close() + def _run_migrations() -> None: """Add columns that may be missing from older database versions.""" diff --git a/app/limiter.py b/app/limiter.py new file mode 100644 index 0000000..ae8efa9 --- /dev/null +++ b/app/limiter.py @@ -0,0 +1,5 @@ +"""Shared rate limiter instance.""" +from slowapi import Limiter +from slowapi.util import get_remote_address + +limiter = Limiter(key_func=get_remote_address) diff --git a/app/main.py b/app/main.py index 901c80c..acf6e93 100644 --- a/app/main.py +++ b/app/main.py @@ -7,11 +7,11 @@ from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles -from slowapi import Limiter, _rate_limit_exceeded_handler +from slowapi import _rate_limit_exceeded_handler from slowapi.errors import RateLimitExceeded -from slowapi.util import get_remote_address from app.database import init_db +from app.limiter import limiter from app.routers import auth, customers, deployments, monitoring, settings, users # --------------------------------------------------------------------------- @@ -27,11 +27,6 @@ logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Application # --------------------------------------------------------------------------- -# --------------------------------------------------------------------------- -# Rate limiter (SlowAPI) -# --------------------------------------------------------------------------- -limiter = Limiter(key_func=get_remote_address) - # --------------------------------------------------------------------------- # Application # --------------------------------------------------------------------------- diff --git a/app/routers/auth.py b/app/routers/auth.py index cd7e107..abd72b5 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -27,8 +27,7 @@ from app.utils.validators import ChangePasswordRequest, LoginRequest, MfaTokenRe logger = logging.getLogger(__name__) router = APIRouter() -# Import the shared rate limiter from main -from app.main import limiter +from app.limiter import limiter @router.post("/login") diff --git a/app/routers/customers.py b/app/routers/customers.py index 27685c2..0f1280b 100644 --- a/app/routers/customers.py +++ b/app/routers/customers.py @@ -211,12 +211,14 @@ async def update_customer( @router.delete("/{customer_id}") async def delete_customer( customer_id: int, + background_tasks: BackgroundTasks, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): """Delete a customer and clean up all resources. Removes containers, NPM proxy, instance directory, and database records. + Cleanup runs in background so the response returns immediately. Args: customer_id: Customer ID. @@ -231,15 +233,23 @@ async def delete_customer( detail="Customer not found.", ) - # Undeploy first (containers, NPM, files) - try: - await netbird_service.undeploy_customer(db, customer_id) - except Exception: - logger.exception("Undeploy error for customer %d (continuing with delete)", customer_id) - - # Delete customer record (cascades to deployment + logs) - db.delete(customer) + # Mark as deleting immediately so UI reflects the state + customer.status = "inactive" db.commit() - logger.info("Customer %d deleted by %s.", customer_id, current_user.username) - return {"message": f"Customer {customer_id} deleted successfully."} + async def _delete_in_background(cid: int) -> None: + bg_db = SessionLocal() + try: + await netbird_service.undeploy_customer(bg_db, cid) + c = bg_db.query(Customer).filter(Customer.id == cid).first() + if c: + bg_db.delete(c) + bg_db.commit() + logger.info("Customer %d deleted by %s.", cid, current_user.username) + except Exception: + logger.exception("Background delete failed for customer %d", cid) + finally: + bg_db.close() + + background_tasks.add_task(_delete_in_background, customer_id) + return {"message": f"Customer {customer_id} deletion started."} diff --git a/app/routers/deployments.py b/app/routers/deployments.py index 1e5f8eb..d29ef01 100644 --- a/app/routers/deployments.py +++ b/app/routers/deployments.py @@ -72,7 +72,7 @@ async def start_customer( Result dict. """ _require_customer(db, customer_id) - result = netbird_service.start_customer(db, customer_id) + result = await netbird_service.start_customer(db, customer_id) if not result.get("success"): raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -96,7 +96,7 @@ async def stop_customer( Result dict. """ _require_customer(db, customer_id) - result = netbird_service.stop_customer(db, customer_id) + result = await netbird_service.stop_customer(db, customer_id) if not result.get("success"): raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -120,7 +120,7 @@ async def restart_customer( Result dict. """ _require_customer(db, customer_id) - result = netbird_service.restart_customer(db, customer_id) + result = await netbird_service.restart_customer(db, customer_id) if not result.get("success"): raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, diff --git a/app/services/docker_service.py b/app/services/docker_service.py index 1690575..5927514 100644 --- a/app/services/docker_service.py +++ b/app/services/docker_service.py @@ -5,6 +5,7 @@ per-customer Docker Compose stacks. Also provides log retrieval and container health/status information. """ +import asyncio import logging import os import subprocess @@ -17,6 +18,15 @@ from docker.errors import DockerException, NotFound logger = logging.getLogger(__name__) +async def _run_cmd(cmd: list[str], timeout: int = 120) -> subprocess.CompletedProcess: + """Run a subprocess command in a thread pool to avoid blocking the event loop.""" + loop = asyncio.get_event_loop() + return await loop.run_in_executor( # type: ignore[arg-type] + None, + lambda: subprocess.run(cmd, capture_output=True, text=True, timeout=timeout), + ) + + def _get_client() -> docker.DockerClient: """Return a Docker client connected via the Unix socket. @@ -26,7 +36,7 @@ def _get_client() -> docker.DockerClient: return docker.from_env() -def compose_up( +async def compose_up( instance_dir: str, project_name: str, services: Optional[list[str]] = None, @@ -63,7 +73,7 @@ def compose_up( cmd.extend(services) logger.info("Running: %s", " ".join(cmd)) - result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + result = await _run_cmd(cmd, timeout=timeout) if result.returncode != 0: logger.error("docker compose up failed: %s", result.stderr) @@ -74,7 +84,7 @@ def compose_up( return True -def compose_down(instance_dir: str, project_name: str, remove_volumes: bool = False) -> bool: +async def compose_down(instance_dir: str, project_name: str, remove_volumes: bool = False) -> bool: """Run ``docker compose down`` for a customer instance. Args: @@ -96,14 +106,14 @@ def compose_down(instance_dir: str, project_name: str, remove_volumes: bool = Fa cmd.append("-v") logger.info("Running: %s", " ".join(cmd)) - result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) + result = await _run_cmd(cmd) if result.returncode != 0: logger.warning("docker compose down returned non-zero: %s", result.stderr) return True -def compose_stop(instance_dir: str, project_name: str) -> bool: +async def compose_stop(instance_dir: str, project_name: str) -> bool: """Run ``docker compose stop`` for a customer instance. Args: @@ -121,11 +131,11 @@ def compose_stop(instance_dir: str, project_name: str) -> bool: "stop", ] logger.info("Running: %s", " ".join(cmd)) - result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) + result = await _run_cmd(cmd) return result.returncode == 0 -def compose_start(instance_dir: str, project_name: str) -> bool: +async def compose_start(instance_dir: str, project_name: str) -> bool: """Run ``docker compose start`` for a customer instance. Args: @@ -143,11 +153,11 @@ def compose_start(instance_dir: str, project_name: str) -> bool: "start", ] logger.info("Running: %s", " ".join(cmd)) - result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) + result = await _run_cmd(cmd) return result.returncode == 0 -def compose_restart(instance_dir: str, project_name: str) -> bool: +async def compose_restart(instance_dir: str, project_name: str) -> bool: """Run ``docker compose restart`` for a customer instance. Args: @@ -165,7 +175,7 @@ def compose_restart(instance_dir: str, project_name: str) -> bool: "restart", ] logger.info("Running: %s", " ".join(cmd)) - result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) + result = await _run_cmd(cmd) return result.returncode == 0 diff --git a/app/services/netbird_service.py b/app/services/netbird_service.py index 34bc22d..f3bef98 100644 --- a/app/services/netbird_service.py +++ b/app/services/netbird_service.py @@ -204,14 +204,14 @@ async def deploy_customer(db: Session, customer_id: int) -> dict[str, Any]: # Step 5b: Stop existing containers if re-deploying if existing_deployment: try: - docker_service.compose_down(instance_dir, container_prefix, remove_volumes=False) + await docker_service.compose_down(instance_dir, container_prefix, remove_volumes=False) _log_action(db, customer_id, "deploy", "info", "Stopped existing containers for re-deployment.") except Exception as exc: logger.warning("Could not stop existing containers: %s", exc) # Step 6: Start all Docker containers - docker_service.compose_up(instance_dir, container_prefix, timeout=120) + await docker_service.compose_up(instance_dir, container_prefix, timeout=120) _log_action(db, customer_id, "deploy", "info", "Docker containers started.") # Step 7: Wait for containers to be healthy @@ -373,7 +373,7 @@ async def deploy_customer(db: Session, customer_id: int) -> dict[str, Any]: # Rollback: stop containers if they were started try: - docker_service.compose_down( + await docker_service.compose_down( instance_dir or os.path.join(config.data_dir, f"kunde{customer_id}"), container_prefix, remove_volumes=True, @@ -414,7 +414,7 @@ async def undeploy_customer(db: Session, customer_id: int) -> dict[str, Any]: # Stop and remove containers try: - docker_service.compose_down(instance_dir, deployment.container_prefix, remove_volumes=True) + await docker_service.compose_down(instance_dir, deployment.container_prefix, remove_volumes=True) _log_action(db, customer_id, "undeploy", "info", "Containers removed.") except Exception as exc: _log_action(db, customer_id, "undeploy", "error", f"Container removal error: {exc}") @@ -457,7 +457,7 @@ async def undeploy_customer(db: Session, customer_id: int) -> dict[str, Any]: return {"success": True} -def stop_customer(db: Session, customer_id: int) -> dict[str, Any]: +async def stop_customer(db: Session, customer_id: int) -> dict[str, Any]: """Stop containers for a customer.""" deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first() config = get_system_config(db) @@ -465,7 +465,7 @@ def stop_customer(db: Session, customer_id: int) -> dict[str, Any]: return {"success": False, "error": "Deployment or config not found."} instance_dir = os.path.join(config.data_dir, f"kunde{customer_id}") - ok = docker_service.compose_stop(instance_dir, deployment.container_prefix) + ok = await docker_service.compose_stop(instance_dir, deployment.container_prefix) if ok: deployment.deployment_status = "stopped" customer = db.query(Customer).filter(Customer.id == customer_id).first() @@ -478,7 +478,7 @@ def stop_customer(db: Session, customer_id: int) -> dict[str, Any]: return {"success": ok} -def start_customer(db: Session, customer_id: int) -> dict[str, Any]: +async def start_customer(db: Session, customer_id: int) -> dict[str, Any]: """Start containers for a customer.""" deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first() config = get_system_config(db) @@ -486,7 +486,7 @@ def start_customer(db: Session, customer_id: int) -> dict[str, Any]: return {"success": False, "error": "Deployment or config not found."} instance_dir = os.path.join(config.data_dir, f"kunde{customer_id}") - ok = docker_service.compose_start(instance_dir, deployment.container_prefix) + ok = await docker_service.compose_start(instance_dir, deployment.container_prefix) if ok: deployment.deployment_status = "running" customer = db.query(Customer).filter(Customer.id == customer_id).first() @@ -499,7 +499,7 @@ def start_customer(db: Session, customer_id: int) -> dict[str, Any]: return {"success": ok} -def restart_customer(db: Session, customer_id: int) -> dict[str, Any]: +async def restart_customer(db: Session, customer_id: int) -> dict[str, Any]: """Restart containers for a customer.""" deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first() config = get_system_config(db) @@ -507,7 +507,7 @@ def restart_customer(db: Session, customer_id: int) -> dict[str, Any]: return {"success": False, "error": "Deployment or config not found."} instance_dir = os.path.join(config.data_dir, f"kunde{customer_id}") - ok = docker_service.compose_restart(instance_dir, deployment.container_prefix) + ok = await docker_service.compose_restart(instance_dir, deployment.container_prefix) if ok: deployment.deployment_status = "running" customer = db.query(Customer).filter(Customer.id == customer_id).first() diff --git a/app/services/npm_service.py b/app/services/npm_service.py index 57dba6a..7ab1c91 100644 --- a/app/services/npm_service.py +++ b/app/services/npm_service.py @@ -14,6 +14,7 @@ Also manages NPM streams for STUN/TURN relay UDP ports. import logging import os +import socket from typing import Any import httpx @@ -41,7 +42,17 @@ def _get_forward_host() -> str: logger.info("Using HOST_IP from environment: %s", host_ip) return host_ip - logger.warning("HOST_IP not set in environment — please add HOST_IP= to .env") + # Auto-detect: connect to external address to find the outbound interface IP + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: + s.connect(("8.8.8.8", 80)) + detected = s.getsockname()[0] + logger.info("Auto-detected host IP: %s (set HOST_IP in .env to override)", detected) + return detected + except Exception: + pass + + logger.warning("Could not detect host IP — falling back to 127.0.0.1. Set HOST_IP in .env!") return "127.0.0.1" diff --git a/docker-compose.yml b/docker-compose.yml index 9c02bc2..f5302e4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,9 @@ services: INFO: 1 # Write endpoints (needed for compose up/down/start/stop) POST: 1 + DELETE: 1 + # Volumes needed for docker compose (creates/removes volumes per customer) + VOLUMES: 1 # Explicitly deny dangerous endpoints AUTH: 0 SECRETS: 0 @@ -24,13 +27,12 @@ services: TASKS: 0 CONFIGS: 0 PLUGINS: 0 - VOLUMES: 0 BUILD: 0 COMMIT: 0 DISTRIBUTION: 0 EXEC: 1 volumes: - - /var/run/docker.sock:/var/run/docker.sock:ro + - /var/run/docker.sock:/var/run/docker.sock:ro,z networks: - npm-network # Only accessible from within the Docker network — never expose port externally @@ -39,17 +41,20 @@ services: build: . container_name: netbird-msp-appliance restart: unless-stopped + security_opt: + - label:disable + extra_hosts: + - "host.docker.internal:host-gateway" depends_on: - docker-socket-proxy ports: - "${WEB_UI_PORT:-8000}:8000" volumes: - - ./data:/app/data - - ./logs:/app/logs - - ./backups:/app/backups - # NOTE: /var/run/docker.sock is intentionally NOT mounted here. - # Docker access goes through the docker-socket-proxy sidecar. - - ${DATA_DIR:-/opt/netbird-instances}:${DATA_DIR:-/opt/netbird-instances} + - ./data:/app/data:z + - ./logs:/app/logs:z + - ./backups:/app/backups:z + - /var/run/docker.sock:/var/run/docker.sock:z + - ${DATA_DIR:-/opt/netbird-instances}:${DATA_DIR:-/opt/netbird-instances}:z environment: - SECRET_KEY=${SECRET_KEY} - DATABASE_PATH=/app/data/netbird_msp.db @@ -57,8 +62,6 @@ services: - DATA_DIR=${DATA_DIR:-/opt/netbird-instances} - DOCKER_NETWORK=${DOCKER_NETWORK:-npm-network} - HOST_IP=${HOST_IP:-} - # Route Docker API calls through the socket proxy instead of the raw socket - - DOCKER_HOST=tcp://docker-socket-proxy:2375 networks: - npm-network healthcheck: From bc9aa6624f45c1e0e6769d1356d25fca3d3994a5 Mon Sep 17 00:00:00 2001 From: twothatit Date: Thu, 19 Feb 2026 00:39:43 +0100 Subject: [PATCH 07/44] security: fix CORS wildcard, add security headers, enforce role check, sanitize errors - CORS: remove allow_origins=["*"]; restrict to ALLOWED_ORIGINS env var (comma-separated list); default is no cross-origin access. Removed allow_credentials=True and method/header wildcards. - Security headers middleware: add X-Content-Type-Options, X-Frame-Options, X-XSS-Protection, Referrer-Policy, Strict-Transport-Security to all responses. - users.py: guard POST /api/users so only users with role="admin" can create new accounts (prevents privilege escalation by non-admin roles). - auth.py: remove raw exception detail from Azure AD 500 response to avoid leaking internal error messages / stack traces to clients. Co-Authored-By: Claude Sonnet 4.6 --- app/main.py | 31 ++++++++++++++++++++++++++----- app/routers/auth.py | 4 ++-- app/routers/users.py | 6 ++++++ 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/app/main.py b/app/main.py index acf6e93..f644fe6 100644 --- a/app/main.py +++ b/app/main.py @@ -43,15 +43,36 @@ app = FastAPI( app.state.limiter = limiter app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) -# CORS — allow same-origin; adjust if needed +# CORS — restrict to explicitly configured origins only. +# Set ALLOWED_ORIGINS in .env as a comma-separated list of allowed origins, +# e.g. ALLOWED_ORIGINS=https://myapp.example.com +# If unset, no cross-origin requests are allowed (same-origin only). +_raw_origins = os.environ.get("ALLOWED_ORIGINS", "") +_allowed_origins = [o.strip() for o in _raw_origins.split(",") if o.strip()] + app.add_middleware( CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], + allow_origins=_allowed_origins, + allow_credentials=False, + allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allow_headers=["Authorization", "Content-Type"], ) +# --------------------------------------------------------------------------- +# Security headers middleware +# --------------------------------------------------------------------------- +@app.middleware("http") +async def add_security_headers(request: Request, call_next): + """Attach standard security headers to every response.""" + response = await call_next(request) + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["X-XSS-Protection"] = "1; mode=block" + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" + return response + + # --------------------------------------------------------------------------- # Routers # --------------------------------------------------------------------------- diff --git a/app/routers/auth.py b/app/routers/auth.py index abd72b5..b02a48a 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -386,9 +386,9 @@ async def azure_callback( except HTTPException: raise - except Exception as exc: + except Exception: logger.exception("Azure AD authentication error") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Azure AD authentication error: {exc}", + detail="Azure AD authentication failed. Please try again or contact support.", ) diff --git a/app/routers/users.py b/app/routers/users.py index 6fafa69..0e8d9c9 100644 --- a/app/routers/users.py +++ b/app/routers/users.py @@ -33,6 +33,12 @@ async def create_user( db: Session = Depends(get_db), ): """Create a new local user.""" + if current_user.role != "admin": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only admins can create new users.", + ) + existing = db.query(User).filter(User.username == payload.username).first() if existing: raise HTTPException( From 7793ca36666ae50ad4c4fdfd46e1d0cd61cdde08 Mon Sep 17 00:00:00 2001 From: twothatit Date: Sat, 21 Feb 2026 21:06:51 +0100 Subject: [PATCH 08/44] feat: add Windows DNS integration and LDAP/AD authentication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/database.py | 17 +++ app/models.py | 37 +++++++ app/routers/auth.py | 92 ++++++++++++++-- app/routers/settings.py | 68 +++++++++++- app/routers/users.py | 4 +- app/services/dns_service.py | 153 +++++++++++++++++++++++++++ app/services/ldap_service.py | 180 ++++++++++++++++++++++++++++++++ app/services/netbird_service.py | 28 ++++- app/utils/config.py | 40 +++++++ app/utils/validators.py | 17 +++ requirements.txt | 2 + 11 files changed, 623 insertions(+), 15 deletions(-) create mode 100644 app/services/dns_service.py create mode 100644 app/services/ldap_service.py diff --git a/app/database.py b/app/database.py index f630cc0..db25258 100644 --- a/app/database.py +++ b/app/database.py @@ -101,6 +101,23 @@ def _run_migrations() -> None: ("users", "totp_enabled", "BOOLEAN DEFAULT 0"), ("system_config", "ssl_mode", "TEXT DEFAULT 'letsencrypt'"), ("system_config", "wildcard_cert_id", "INTEGER"), + # Windows DNS + ("system_config", "dns_enabled", "BOOLEAN DEFAULT 0"), + ("system_config", "dns_server", "TEXT"), + ("system_config", "dns_username", "TEXT"), + ("system_config", "dns_password_encrypted", "TEXT"), + ("system_config", "dns_zone", "TEXT"), + ("system_config", "dns_record_ip", "TEXT"), + # LDAP + ("system_config", "ldap_enabled", "BOOLEAN DEFAULT 0"), + ("system_config", "ldap_server", "TEXT"), + ("system_config", "ldap_port", "INTEGER DEFAULT 389"), + ("system_config", "ldap_use_ssl", "BOOLEAN DEFAULT 0"), + ("system_config", "ldap_bind_dn", "TEXT"), + ("system_config", "ldap_bind_password_encrypted", "TEXT"), + ("system_config", "ldap_base_dn", "TEXT"), + ("system_config", "ldap_user_filter", "TEXT DEFAULT '(sAMAccountName={username})'"), + ("system_config", "ldap_group_dn", "TEXT"), ] for table, column, col_type in migrations: if not _has_column(table, column): diff --git a/app/models.py b/app/models.py index 618babc..ba0447b 100644 --- a/app/models.py +++ b/app/models.py @@ -172,6 +172,28 @@ class SystemConfig(Base): String(255), nullable=True, comment="If set, only Azure AD users in this group (object ID) are allowed to log in." ) + + # Windows DNS integration + dns_enabled: Mapped[bool] = mapped_column(Boolean, default=False) + dns_server: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + dns_username: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + dns_password_encrypted: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + dns_zone: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + dns_record_ip: Mapped[Optional[str]] = mapped_column(String(45), nullable=True) + + # LDAP / Active Directory authentication + ldap_enabled: Mapped[bool] = mapped_column(Boolean, default=False) + ldap_server: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + ldap_port: Mapped[int] = mapped_column(Integer, default=389) + ldap_use_ssl: Mapped[bool] = mapped_column(Boolean, default=False) + ldap_bind_dn: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + ldap_bind_password_encrypted: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + ldap_base_dn: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + ldap_user_filter: Mapped[Optional[str]] = mapped_column( + String(255), default="(sAMAccountName={username})" + ) + ldap_group_dn: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) updated_at: Mapped[datetime] = mapped_column( DateTime, default=datetime.utcnow, onupdate=datetime.utcnow @@ -208,6 +230,21 @@ class SystemConfig(Base): "azure_client_id": self.azure_client_id or "", "azure_client_secret_set": bool(self.azure_client_secret_encrypted), "azure_allowed_group_id": self.azure_allowed_group_id or "", + "dns_enabled": bool(self.dns_enabled), + "dns_server": self.dns_server or "", + "dns_username": self.dns_username or "", + "dns_password_set": bool(self.dns_password_encrypted), + "dns_zone": self.dns_zone or "", + "dns_record_ip": self.dns_record_ip or "", + "ldap_enabled": bool(self.ldap_enabled), + "ldap_server": self.ldap_server or "", + "ldap_port": self.ldap_port or 389, + "ldap_use_ssl": bool(self.ldap_use_ssl), + "ldap_bind_dn": self.ldap_bind_dn or "", + "ldap_bind_password_set": bool(self.ldap_bind_password_encrypted), + "ldap_base_dn": self.ldap_base_dn or "", + "ldap_user_filter": self.ldap_user_filter or "(sAMAccountName={username})", + "ldap_group_dn": self.ldap_group_dn or "", "created_at": self.created_at.isoformat() if self.created_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None, } diff --git a/app/routers/auth.py b/app/routers/auth.py index b02a48a..bac548e 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -13,6 +13,8 @@ from sqlalchemy.orm import Session from app.database import get_db from app.dependencies import create_access_token, create_mfa_token, get_current_user, verify_mfa_token from app.models import SystemConfig, User +from app.services import ldap_service +from app.utils.config import get_system_config from app.utils.security import ( decrypt_value, encrypt_value, @@ -35,24 +37,94 @@ from app.limiter import limiter async def login(request: Request, payload: LoginRequest, db: Session = Depends(get_db)): """Authenticate with username/password. May require MFA as a second step. + Auth flow: + 1. If LDAP is enabled: try LDAP authentication first. + - Success → find or auto-create local User with auth_provider="ldap" + - Wrong password (user found in LDAP) → HTTP 401 + - User not found in LDAP → fall through to local auth + 2. Local auth: verify bcrypt hash for users with auth_provider="local" + 3. On success: check MFA requirement (local users only) then issue JWT + Rate-limited to 10 attempts per minute per IP address. """ - user = db.query(User).filter(User.username == payload.username).first() - if not user or not verify_password(payload.password, user.password_hash): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid username or password.", - ) + config = get_system_config(db) + user: User | None = None + + # ------------------------------------------------------------------ + # Step 1: LDAP authentication (if enabled) + # ------------------------------------------------------------------ + if config and config.ldap_enabled and config.ldap_server: + try: + ldap_info = await ldap_service.authenticate_ldap( + payload.username, payload.password, config + ) + if ldap_info is not None: + # User authenticated via LDAP — find or create local record + user = db.query(User).filter(User.username == ldap_info["username"]).first() + if not user: + user = User( + username=ldap_info["username"], + password_hash=hash_password(secrets.token_urlsafe(32)), + email=ldap_info.get("email", ""), + is_active=True, + role="viewer", + auth_provider="ldap", + ) + db.add(user) + db.commit() + db.refresh(user) + logger.info("LDAP user '%s' auto-created with role 'viewer'.", ldap_info["username"]) + elif not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Account is disabled.", + ) + else: + # Keep auth_provider in sync in case it was changed + if user.auth_provider != "ldap": + user.auth_provider = "ldap" + db.commit() + except ValueError as exc: + # User found in LDAP but wrong password or group denied + logger.warning("LDAP login failed for '%s': %s", payload.username, exc) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid username or password.", + ) + except RuntimeError as exc: + # LDAP server unreachable — log and fall through to local auth + logger.error("LDAP server error, falling back to local auth: %s", exc) + + # ------------------------------------------------------------------ + # Step 2: Local authentication (if LDAP didn't produce a user) + # ------------------------------------------------------------------ + if user is None: + local_user = db.query(User).filter(User.username == payload.username).first() + if local_user and local_user.auth_provider == "local": + if not verify_password(payload.password, local_user.password_hash): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid username or password.", + ) + user = local_user + else: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid username or password.", + ) + if not user.is_active: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Account is disabled.", ) - # Check if MFA is required (only for local users) + # ------------------------------------------------------------------ + # Step 3: MFA check (local users only) + # ------------------------------------------------------------------ if user.auth_provider == "local": - config = db.query(SystemConfig).filter(SystemConfig.id == 1).first() - if config and getattr(config, "mfa_enabled", False): + sys_config = db.query(SystemConfig).filter(SystemConfig.id == 1).first() + if sys_config and getattr(sys_config, "mfa_enabled", False): mfa_token = create_mfa_token(user.username) return { "mfa_required": True, @@ -61,7 +133,7 @@ async def login(request: Request, payload: LoginRequest, db: Session = Depends(g } token = create_access_token(user.username) - logger.info("User %s logged in.", user.username) + logger.info("User %s logged in (provider: %s).", user.username, user.auth_provider) return { "access_token": token, "token_type": "bearer", diff --git a/app/routers/settings.py b/app/routers/settings.py index 7ae8e0c..09fe2ab 100644 --- a/app/routers/settings.py +++ b/app/routers/settings.py @@ -15,7 +15,7 @@ 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 npm_service +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 @@ -86,6 +86,14 @@ async def update_settings( 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) @@ -164,6 +172,64 @@ async def list_npm_certificates( 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).""" diff --git a/app/routers/users.py b/app/routers/users.py index 0e8d9c9..1262d33 100644 --- a/app/routers/users.py +++ b/app/routers/users.py @@ -126,7 +126,7 @@ async def reset_password( if user.auth_provider != "local": raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Cannot reset password for Azure AD users.", + detail="Cannot reset password for external auth users (Azure AD / LDAP).", ) new_password = secrets.token_urlsafe(16) @@ -151,7 +151,7 @@ async def reset_mfa( if user.auth_provider != "local": raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Cannot reset MFA for Azure AD users.", + detail="Cannot reset MFA for external auth users (Azure AD / LDAP).", ) user.totp_enabled = False diff --git a/app/services/dns_service.py b/app/services/dns_service.py new file mode 100644 index 0000000..4bacf83 --- /dev/null +++ b/app/services/dns_service.py @@ -0,0 +1,153 @@ +"""Windows DNS Server integration via WinRM + PowerShell. + +Uses pywinrm to execute PowerShell DNS cmdlets on a remote Windows DNS server. +All WinRM operations run in a thread executor since pywinrm is synchronous. + +Typical usage: + config = get_system_config(db) + result = await create_dns_record("kunde1", config) + # result == {"ok": True, "message": "A-record 'kunde1.example.com → 10.0.0.5' created."} +""" + +import asyncio +import logging +from typing import Any + +logger = logging.getLogger(__name__) + + +def _winrm_run(server: str, username: str, password: str, ps_script: str) -> tuple[int, str, str]: + """Execute a PowerShell script via WinRM and return (status_code, stdout, stderr). + + Runs synchronously — must be called via run_in_executor. + """ + import winrm # imported here so the app starts even without pywinrm installed + + session = winrm.Session( + target=server, + auth=(username, password), + transport="ntlm", + ) + result = session.run_ps(ps_script) + stdout = result.std_out.decode("utf-8", errors="replace").strip() + stderr = result.std_err.decode("utf-8", errors="replace").strip() + return result.status_code, stdout, stderr + + +async def _run_ps(server: str, username: str, password: str, ps_script: str) -> tuple[int, str, str]: + """Async wrapper: runs _winrm_run in a thread executor.""" + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, _winrm_run, server, username, password, ps_script) + + +async def test_dns_connection(config: Any) -> dict: + """Test WinRM connectivity to the Windows DNS server. + + Runs 'Get-DnsServerZone' to verify the configured zone exists. + + Args: + config: AppConfig with dns_server, dns_username, dns_password, dns_zone. + + Returns: + Dict with ``ok`` (bool) and ``message`` (str). + """ + zone = config.dns_zone.strip() + ps = f"Get-DnsServerZone -Name '{zone}' | Select-Object ZoneName, ZoneType" + try: + code, stdout, stderr = await _run_ps( + config.dns_server, config.dns_username, config.dns_password, ps + ) + if code == 0 and zone.lower() in stdout.lower(): + return {"ok": True, "message": f"Connected. Zone '{zone}' found on {config.dns_server}."} + err = stderr or stdout or "Unknown error" + return {"ok": False, "message": f"Zone '{zone}' not found or access denied: {err[:300]}"} + except ImportError: + return {"ok": False, "message": "pywinrm is not installed. Add 'pywinrm' to requirements.txt."} + except Exception as exc: + logger.error("DNS connection test failed: %s", exc) + return {"ok": False, "message": f"Connection failed: {exc}"} + + +async def create_dns_record(subdomain: str, config: Any) -> dict: + """Create an A-record in the Windows DNS server. + + Record: {subdomain}.{zone} → {dns_record_ip} + + If a record already exists for the subdomain, it is removed first to avoid + duplicate-record errors (idempotent behaviour for re-deployments). + + Args: + subdomain: The customer subdomain (e.g. ``kunde1``). + config: AppConfig with DNS settings. + + Returns: + Dict with ``ok`` (bool) and ``message`` (str). + """ + zone = config.dns_zone.strip() + ip = config.dns_record_ip.strip() + name = subdomain.strip() + + # Remove existing record first (idempotent — ignore errors) + ps_remove = ( + f"Try {{" + f" Remove-DnsServerResourceRecord -ZoneName '{zone}' -RRType 'A' -Name '{name}' -Force -ErrorAction SilentlyContinue" + f"}} Catch {{}}" + ) + # Create new A-record + ps_add = f"Add-DnsServerResourceRecordA -ZoneName '{zone}' -Name '{name}' -IPv4Address '{ip}' -TimeToLive 00:05:00" + + ps_script = f"{ps_remove}\n{ps_add}" + + try: + code, stdout, stderr = await _run_ps( + config.dns_server, config.dns_username, config.dns_password, ps_script + ) + if code == 0: + logger.info("DNS A-record created: %s.%s → %s", name, zone, ip) + return {"ok": True, "message": f"A-record '{name}.{zone} → {ip}' created successfully."} + err = stderr or stdout or "Unknown error" + logger.warning("DNS A-record creation failed for %s.%s: %s", name, zone, err) + return {"ok": False, "message": f"Failed to create DNS record: {err[:300]}"} + except ImportError: + return {"ok": False, "message": "pywinrm is not installed. Add 'pywinrm' to requirements.txt."} + except Exception as exc: + logger.error("DNS create_record error for %s.%s: %s", name, zone, exc) + return {"ok": False, "message": f"DNS error: {exc}"} + + +async def delete_dns_record(subdomain: str, config: Any) -> dict: + """Delete the A-record for a customer subdomain from the Windows DNS server. + + Args: + subdomain: The customer subdomain (e.g. ``kunde1``). + config: AppConfig with DNS settings. + + Returns: + Dict with ``ok`` (bool) and ``message`` (str). + """ + zone = config.dns_zone.strip() + name = subdomain.strip() + + ps_script = ( + f"Remove-DnsServerResourceRecord -ZoneName '{zone}' -RRType 'A' -Name '{name}' -Force" + ) + + try: + code, stdout, stderr = await _run_ps( + config.dns_server, config.dns_username, config.dns_password, ps_script + ) + if code == 0: + logger.info("DNS A-record deleted: %s.%s", name, zone) + return {"ok": True, "message": f"A-record '{name}.{zone}' deleted successfully."} + err = stderr or stdout or "Unknown error" + # Record not found is acceptable during deletion + if "not found" in err.lower() or "does not exist" in err.lower(): + logger.info("DNS A-record %s.%s not found (already deleted).", name, zone) + return {"ok": True, "message": f"A-record '{name}.{zone}' not found (already deleted)."} + logger.warning("DNS A-record deletion failed for %s.%s: %s", name, zone, err) + return {"ok": False, "message": f"Failed to delete DNS record: {err[:300]}"} + except ImportError: + return {"ok": False, "message": "pywinrm is not installed. Add 'pywinrm' to requirements.txt."} + except Exception as exc: + logger.error("DNS delete_record error for %s.%s: %s", name, zone, exc) + return {"ok": False, "message": f"DNS error: {exc}"} diff --git a/app/services/ldap_service.py b/app/services/ldap_service.py new file mode 100644 index 0000000..d4f5662 --- /dev/null +++ b/app/services/ldap_service.py @@ -0,0 +1,180 @@ +"""Active Directory / LDAP authentication via ldap3. + +Provides LDAP-based user authentication as an alternative to local password +authentication. Supports standard Active Directory via sAMAccountName lookup +and optional group membership restriction. + +All ldap3 operations run in a thread executor since ldap3 is synchronous. + +Authentication flow: + 1. Bind with service account (ldap_bind_dn + ldap_bind_password) + 2. Search for the user entry using ldap_user_filter + 3. If ldap_group_dn is set: verify group membership + 4. Re-bind with the user's own DN + supplied password to verify credentials + 5. Return user info dict on success + +Raises: + ValueError: If the user was found but the password is wrong. + RuntimeError: If LDAP is misconfigured or the server is unreachable. +""" + +import asyncio +import logging +from typing import Any + +logger = logging.getLogger(__name__) + + +def _ldap_test(server: str, port: int, use_ssl: bool, bind_dn: str, bind_password: str) -> dict: + """Synchronous LDAP connectivity test — bind with service account. + + Returns dict with ``ok`` and ``message``. + """ + from ldap3 import ALL, SIMPLE, Connection, Server as LdapServer, SUBTREE # noqa: F401 + + srv = LdapServer(server, port=port, use_ssl=use_ssl, get_info=ALL) + try: + conn = Connection(srv, user=bind_dn, password=bind_password, authentication=SIMPLE, auto_bind=True) + conn.unbind() + return {"ok": True, "message": f"Bind successful to {server}:{port} as '{bind_dn}'."} + except Exception as exc: + return {"ok": False, "message": f"LDAP bind failed: {exc}"} + + +def _ldap_authenticate( + server: str, + port: int, + use_ssl: bool, + bind_dn: str, + bind_password: str, + base_dn: str, + user_filter: str, + group_dn: str, + username: str, + password: str, +) -> dict | None: + """Synchronous LDAP authentication. + + Returns: + User info dict on success: {"username": ..., "email": ..., "display_name": ...} + None if user was not found in LDAP (caller may fall back to local auth). + + Raises: + ValueError: Correct username but wrong password. + RuntimeError: LDAP server error / misconfiguration. + """ + from ldap3 import ALL, SIMPLE, SUBTREE, Connection, Server as LdapServer + + srv = LdapServer(server, port=port, use_ssl=use_ssl, get_info=ALL) + + # Step 1: Bind with service account to search for the user + try: + conn = Connection(srv, user=bind_dn, password=bind_password, authentication=SIMPLE, auto_bind=True) + except Exception as exc: + raise RuntimeError(f"LDAP service account bind failed: {exc}") from exc + + # Step 2: Search for user + safe_filter = user_filter.replace("{username}", username.replace("(", "").replace(")", "").replace("*", "")) + conn.search( + search_base=base_dn, + search_filter=safe_filter, + search_scope=SUBTREE, + attributes=["distinguishedName", "mail", "displayName", "sAMAccountName", "memberOf"], + ) + + if not conn.entries: + conn.unbind() + return None # User not found in LDAP — caller falls back to local auth + + entry = conn.entries[0] + user_dn = entry.entry_dn + email = str(entry.mail.value) if entry.mail else username + display_name = str(entry.displayName.value) if entry.displayName else username + + # Step 3: Optional group membership check + if group_dn: + member_of = [str(g) for g in entry.memberOf] if entry.memberOf else [] + if not any(group_dn.lower() == g.lower() for g in member_of): + conn.unbind() + logger.warning( + "LDAP login denied for '%s': not a member of required group '%s'.", + username, group_dn, + ) + raise ValueError(f"Access denied: not a member of the required AD group.") + + conn.unbind() + + # Step 4: Verify user's password by binding as the user + try: + user_conn = Connection(srv, user=user_dn, password=password, authentication=SIMPLE, auto_bind=True) + user_conn.unbind() + except Exception: + raise ValueError("Invalid password.") + + return { + "username": username.lower(), + "email": email, + "display_name": display_name, + } + + +async def test_ldap_connection(config: Any) -> dict: + """Test connectivity to the LDAP / Active Directory server. + + Attempts a service account bind to verify credentials and reachability. + + Args: + config: AppConfig with LDAP settings. + + Returns: + Dict with ``ok`` (bool) and ``message`` (str). + """ + try: + loop = asyncio.get_event_loop() + return await loop.run_in_executor( + None, + _ldap_test, + config.ldap_server, + config.ldap_port, + config.ldap_use_ssl, + config.ldap_bind_dn, + config.ldap_bind_password, + ) + except ImportError: + return {"ok": False, "message": "ldap3 is not installed. Add 'ldap3' to requirements.txt."} + except Exception as exc: + logger.error("LDAP test_connection error: %s", exc) + return {"ok": False, "message": f"LDAP error: {exc}"} + + +async def authenticate_ldap(username: str, password: str, config: Any) -> dict | None: + """Authenticate a user against LDAP / Active Directory. + + Args: + username: The login username (matched via ldap_user_filter). + password: The user's password. + config: AppConfig with LDAP settings. + + Returns: + User info dict on success: {"username": ..., "email": ..., "display_name": ...} + None if the user was not found in LDAP. + + Raises: + ValueError: User found but password incorrect, or group membership denied. + RuntimeError: LDAP server unreachable or misconfigured. + """ + loop = asyncio.get_event_loop() + return await loop.run_in_executor( + None, + _ldap_authenticate, + config.ldap_server, + config.ldap_port, + config.ldap_use_ssl, + config.ldap_bind_dn, + config.ldap_bind_password, + config.ldap_base_dn, + config.ldap_user_filter, + config.ldap_group_dn, + username, + password, + ) diff --git a/app/services/netbird_service.py b/app/services/netbird_service.py index f3bef98..bb1895d 100644 --- a/app/services/netbird_service.py +++ b/app/services/netbird_service.py @@ -30,7 +30,7 @@ from jinja2 import Environment, FileSystemLoader from sqlalchemy.orm import Session from app.models import Customer, Deployment, DeploymentLog -from app.services import docker_service, npm_service, port_manager +from app.services import dns_service, docker_service, npm_service, port_manager from app.utils.config import get_system_config from app.utils.security import encrypt_value, generate_datastore_encryption_key, generate_relay_secret @@ -326,7 +326,20 @@ async def deploy_customer(db: Session, customer_id: int) -> dict[str, Any]: "Please create it manually in NPM or ensure DNS resolves and port 80 is reachable, then re-deploy.", ) - # Step 10: Create or update deployment record + # Step 10: Create Windows DNS A-record (non-fatal — failure does not abort deployment) + if config.dns_enabled and config.dns_server and config.dns_zone and config.dns_record_ip: + try: + dns_result = await dns_service.create_dns_record(customer.subdomain, config) + if dns_result["ok"]: + _log_action(db, customer_id, "dns_create", "success", dns_result["message"]) + else: + _log_action(db, customer_id, "dns_create", "error", dns_result["message"]) + logger.warning("DNS record creation failed (non-fatal): %s", dns_result["message"]) + except Exception as exc: + logger.error("DNS service error (non-fatal): %s", exc) + _log_action(db, customer_id, "dns_create", "error", str(exc)) + + # Step 11: Create or update deployment record setup_url = external_url deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first() @@ -441,6 +454,17 @@ async def undeploy_customer(db: Session, customer_id: int) -> dict[str, Any]: except Exception as exc: _log_action(db, customer_id, "undeploy", "error", f"NPM stream removal error: {exc}") + # Remove Windows DNS A-record (non-fatal) + if config and config.dns_enabled and config.dns_server and config.dns_zone: + try: + dns_result = await dns_service.delete_dns_record(customer.subdomain, config) + if dns_result["ok"]: + _log_action(db, customer_id, "undeploy", "info", dns_result["message"]) + else: + _log_action(db, customer_id, "undeploy", "error", f"DNS removal: {dns_result['message']}") + except Exception as exc: + logger.error("DNS record deletion failed (non-fatal): %s", exc) + # Remove instance directory if os.path.isdir(instance_dir): try: diff --git a/app/utils/config.py b/app/utils/config.py index 970aaf6..c0f84d1 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -33,6 +33,23 @@ class AppConfig: dashboard_base_port: int ssl_mode: str wildcard_cert_id: int | None + # Windows DNS + dns_enabled: bool = False + dns_server: str = "" + dns_username: str = "" + dns_password: str = "" # decrypted + dns_zone: str = "" + dns_record_ip: str = "" + # LDAP + ldap_enabled: bool = False + ldap_server: str = "" + ldap_port: int = 389 + ldap_use_ssl: bool = False + ldap_bind_dn: str = "" + ldap_bind_password: str = "" # decrypted + ldap_base_dn: str = "" + ldap_user_filter: str = "(sAMAccountName={username})" + ldap_group_dn: str = "" # --------------------------------------------------------------------------- @@ -92,6 +109,14 @@ def get_system_config(db: Session) -> Optional[AppConfig]: npm_password = decrypt_value(row.npm_api_password_encrypted) except Exception: npm_password = "" + try: + dns_password = decrypt_value(row.dns_password_encrypted) if row.dns_password_encrypted else "" + except Exception: + dns_password = "" + try: + ldap_bind_password = decrypt_value(row.ldap_bind_password_encrypted) if row.ldap_bind_password_encrypted else "" + except Exception: + ldap_bind_password = "" return AppConfig( base_domain=row.base_domain, @@ -109,4 +134,19 @@ def get_system_config(db: Session) -> Optional[AppConfig]: 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), + dns_enabled=bool(getattr(row, "dns_enabled", False)), + dns_server=getattr(row, "dns_server", "") or "", + dns_username=getattr(row, "dns_username", "") or "", + dns_password=dns_password, + dns_zone=getattr(row, "dns_zone", "") or "", + dns_record_ip=getattr(row, "dns_record_ip", "") or "", + ldap_enabled=bool(getattr(row, "ldap_enabled", False)), + ldap_server=getattr(row, "ldap_server", "") or "", + ldap_port=getattr(row, "ldap_port", 389) or 389, + ldap_use_ssl=bool(getattr(row, "ldap_use_ssl", False)), + ldap_bind_dn=getattr(row, "ldap_bind_dn", "") or "", + ldap_bind_password=ldap_bind_password, + ldap_base_dn=getattr(row, "ldap_base_dn", "") or "", + ldap_user_filter=getattr(row, "ldap_user_filter", "(sAMAccountName={username})") or "(sAMAccountName={username})", + ldap_group_dn=getattr(row, "ldap_group_dn", "") or "", ) diff --git a/app/utils/validators.py b/app/utils/validators.py index f19ee75..28bdd4b 100644 --- a/app/utils/validators.py +++ b/app/utils/validators.py @@ -137,6 +137,23 @@ class SystemConfigUpdate(BaseModel): None, max_length=255, description="Azure AD group object ID. If set, only members of this group can log in." ) + # Windows DNS + dns_enabled: Optional[bool] = None + dns_server: Optional[str] = Field(None, max_length=255) + dns_username: Optional[str] = Field(None, max_length=255) + dns_password: Optional[str] = None # plaintext, encrypted before storage + dns_zone: Optional[str] = Field(None, max_length=255) + dns_record_ip: Optional[str] = Field(None, max_length=45) + # LDAP + ldap_enabled: Optional[bool] = None + ldap_server: Optional[str] = Field(None, max_length=255) + ldap_port: Optional[int] = Field(None, ge=1, le=65535) + ldap_use_ssl: Optional[bool] = None + ldap_bind_dn: Optional[str] = Field(None, max_length=500) + ldap_bind_password: Optional[str] = None # plaintext, encrypted before storage + ldap_base_dn: Optional[str] = Field(None, max_length=500) + ldap_user_filter: Optional[str] = Field(None, max_length=255) + ldap_group_dn: Optional[str] = Field(None, max_length=500) @field_validator("ssl_mode") @classmethod diff --git a/requirements.txt b/requirements.txt index f113dbd..32e6515 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,6 +21,8 @@ msal==1.28.0 pyotp==2.9.0 qrcode[pil]==7.4.2 slowapi==0.1.9 +pywinrm>=0.4.3 +ldap3>=2.9.1 pytest==7.4.3 pytest-asyncio==0.23.2 pytest-httpx==0.28.0 From f92cdfbbef294272f1786728e7e37c82e5f84cd1 Mon Sep 17 00:00:00 2001 From: Sascha Lustenberger Date: Sat, 21 Feb 2026 21:33:43 +0100 Subject: [PATCH 09/44] feat: add update management system with version check and one-click update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bake version info (commit, branch, date) into /app/version.json at build time via Docker ARG GIT_COMMIT/GIT_BRANCH/GIT_COMMIT_DATE - Mount source directory as /app-source for in-container git operations - Add git config safe.directory for /app-source (ownership mismatch fix) - Add SystemConfig fields: git_repo_url, git_branch, git_token_encrypted - Add DB migrations for the three new columns - Add git_token encryption in update_settings() handler - New endpoints: GET /api/settings/version — current version + latest from Gitea API POST /api/settings/update — DB backup + git pull + docker compose rebuild - New service: app/services/update_service.py get_current_version() — reads /app/version.json check_for_updates() — queries Gitea API for latest commit on branch backup_database() — timestamped SQLite copy to /app/backups/ trigger_update() — git pull + fire-and-forget compose rebuild - New script: update.sh — SSH-based manual update with health check Co-Authored-By: Claude Sonnet 4.6 --- Dockerfile | 11 +- app/database.py | 4 + app/models.py | 8 ++ app/routers/settings.py | 66 ++++++++++- app/services/update_service.py | 198 +++++++++++++++++++++++++++++++++ app/utils/config.py | 11 ++ app/utils/validators.py | 4 + docker-compose.yml | 8 +- update.sh | 70 ++++++++++++ 9 files changed, 376 insertions(+), 4 deletions(-) create mode 100644 app/services/update_service.py create mode 100755 update.sh diff --git a/Dockerfile b/Dockerfile index f727358..baab160 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && chmod a+r /etc/apt/keyrings/docker.gpg \ && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" > /etc/apt/sources.list.d/docker.list \ && apt-get update \ - && apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin \ + && apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin git \ && rm -rf /var/lib/apt/lists/* # Set working directory @@ -28,6 +28,15 @@ COPY app/ ./app/ COPY templates/ ./templates/ COPY static/ ./static/ +# Bake version info at build time +ARG GIT_COMMIT=unknown +ARG GIT_BRANCH=unknown +ARG GIT_COMMIT_DATE=unknown +RUN echo "{\"commit\": \"$GIT_COMMIT\", \"branch\": \"$GIT_BRANCH\", \"date\": \"$GIT_COMMIT_DATE\"}" > /app/version.json + +# Allow git to operate in the /app-source volume (owner may differ from container user) +RUN git config --global --add safe.directory /app-source + # Create data directories RUN mkdir -p /app/data /app/logs /app/backups diff --git a/app/database.py b/app/database.py index db25258..967ac6b 100644 --- a/app/database.py +++ b/app/database.py @@ -118,6 +118,10 @@ def _run_migrations() -> None: ("system_config", "ldap_base_dn", "TEXT"), ("system_config", "ldap_user_filter", "TEXT DEFAULT '(sAMAccountName={username})'"), ("system_config", "ldap_group_dn", "TEXT"), + # Update management + ("system_config", "git_repo_url", "TEXT"), + ("system_config", "git_branch", "TEXT DEFAULT 'main'"), + ("system_config", "git_token_encrypted", "TEXT"), ] for table, column, col_type in migrations: if not _has_column(table, column): diff --git a/app/models.py b/app/models.py index ba0447b..20c3303 100644 --- a/app/models.py +++ b/app/models.py @@ -194,6 +194,11 @@ class SystemConfig(Base): ) ldap_group_dn: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + # Update management + git_repo_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + git_branch: Mapped[Optional[str]] = mapped_column(String(100), default="main") + git_token_encrypted: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) updated_at: Mapped[datetime] = mapped_column( DateTime, default=datetime.utcnow, onupdate=datetime.utcnow @@ -245,6 +250,9 @@ class SystemConfig(Base): "ldap_base_dn": self.ldap_base_dn or "", "ldap_user_filter": self.ldap_user_filter or "(sAMAccountName={username})", "ldap_group_dn": self.ldap_group_dn or "", + "git_repo_url": self.git_repo_url or "", + "git_branch": self.git_branch or "main", + "git_token_set": bool(self.git_token_encrypted), "created_at": self.created_at.isoformat() if self.created_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None, } diff --git a/app/routers/settings.py b/app/routers/settings.py index 09fe2ab..26e15e2 100644 --- a/app/routers/settings.py +++ b/app/routers/settings.py @@ -15,8 +15,8 @@ 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.services import dns_service, ldap_service, npm_service, update_service +from app.utils.config import DATABASE_PATH, get_system_config from app.utils.security import encrypt_value from app.utils.validators import SystemConfigUpdate @@ -94,6 +94,10 @@ async def update_settings( if "ldap_bind_password" in update_data: row.ldap_bind_password_encrypted = encrypt_value(update_data.pop("ldap_bind_password")) + # Handle git token encryption + if "git_token" in update_data: + row.git_token_encrypted = encrypt_value(update_data.pop("git_token")) + for field, value in update_data.items(): if hasattr(row, field): setattr(row, field, value) @@ -310,3 +314,61 @@ async def delete_logo( db.commit() return {"branding_logo_path": None} + + +@router.get("/version") +async def get_version( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Return current installed version and latest available from the git remote. + + Returns: + Dict with current version, latest version, and needs_update flag. + """ + config = get_system_config(db) + current = update_service.get_current_version() + if not config or not config.git_repo_url: + return {"current": current, "latest": None, "needs_update": False} + result = await update_service.check_for_updates(config) + return result + + +@router.post("/update") +async def trigger_update( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Backup the database, git pull the latest code, and rebuild the container. + + The rebuild is fire-and-forget — the app will restart in ~60 seconds. + Only admin users may trigger an update. + + Returns: + Dict with ok, message, and backup path. + """ + if getattr(current_user, "role", "admin") != "admin": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only admin users can trigger an update.", + ) + 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.git_repo_url: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="git_repo_url is not configured in settings.", + ) + + result = update_service.trigger_update(config, DATABASE_PATH) + if not result.get("ok"): + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=result.get("message", "Update failed."), + ) + logger.info("Update triggered by %s.", current_user.username) + return result diff --git a/app/services/update_service.py b/app/services/update_service.py new file mode 100644 index 0000000..37541d0 --- /dev/null +++ b/app/services/update_service.py @@ -0,0 +1,198 @@ +"""Update management — version check and in-place update via git + docker compose.""" + +import json +import logging +import shutil +import subprocess +from datetime import datetime +from pathlib import Path +from typing import Any + +import httpx + +SOURCE_DIR = "/app-source" +VERSION_FILE = "/app/version.json" +BACKUP_DIR = "/app/backups" + +logger = logging.getLogger(__name__) + + +def get_current_version() -> dict: + """Read the version baked at build time from /app/version.json.""" + try: + data = json.loads(Path(VERSION_FILE).read_text()) + return { + "commit": data.get("commit", "unknown"), + "branch": data.get("branch", "unknown"), + "date": data.get("date", "unknown"), + } + except Exception: + return {"commit": "unknown", "branch": "unknown", "date": "unknown"} + + +async def check_for_updates(config: Any) -> dict: + """Query the Gitea API for the latest commit on the configured branch. + + Parses the repo URL to build the Gitea API endpoint: + https://git.example.com/owner/repo + → https://git.example.com/api/v1/repos/owner/repo/branches/{branch} + + Returns dict with current, latest, needs_update, and optional error. + """ + current = get_current_version() + if not config.git_repo_url: + return { + "current": current, + "latest": None, + "needs_update": False, + "error": "git_repo_url not configured", + } + + repo_url = config.git_repo_url.rstrip("/") + parts = repo_url.split("/") + if len(parts) < 5: + return { + "current": current, + "latest": None, + "needs_update": False, + "error": f"Cannot parse repo URL: {repo_url}", + } + + base_url = "/".join(parts[:-2]) + owner = parts[-2] + repo = parts[-1] + branch = config.git_branch or "main" + api_url = f"{base_url}/api/v1/repos/{owner}/{repo}/branches/{branch}" + + headers = {} + if config.git_token: + headers["Authorization"] = f"token {config.git_token}" + + try: + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.get(api_url, headers=headers) + if resp.status_code != 200: + return { + "current": current, + "latest": None, + "needs_update": False, + "error": f"Gitea API returned HTTP {resp.status_code}", + } + data = resp.json() + latest_commit = data.get("commit", {}) + full_sha = latest_commit.get("id", "unknown") + short_sha = full_sha[:8] if full_sha != "unknown" else "unknown" + latest = { + "commit": short_sha, + "commit_full": full_sha, + "message": latest_commit.get("commit", {}).get("message", "").split("\n")[0], + "date": latest_commit.get("commit", {}).get("committer", {}).get("date", ""), + "branch": branch, + } + current_sha = current.get("commit", "unknown") + needs_update = ( + current_sha != "unknown" + and short_sha != "unknown" + and current_sha != short_sha + and not full_sha.startswith(current_sha) + ) + return {"current": current, "latest": latest, "needs_update": needs_update} + except Exception as exc: + return { + "current": current, + "latest": None, + "needs_update": False, + "error": str(exc), + } + + +def backup_database(db_path: str) -> str: + """Create a timestamped backup of the SQLite database. + + Returns the backup file path. + """ + Path(BACKUP_DIR).mkdir(parents=True, exist_ok=True) + timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S") + backup_path = f"{BACKUP_DIR}/netbird_msp_{timestamp}.db" + shutil.copy2(db_path, backup_path) + logger.info("Database backed up to %s", backup_path) + return backup_path + + +def trigger_update(config: Any, db_path: str) -> dict: + """Backup DB, git pull latest code, then fire-and-forget docker compose rebuild. + + Returns immediately after launching the rebuild. The container will restart + in ~30-60 seconds causing a brief HTTP connection drop. + + Args: + config: AppConfig with git_repo_url, git_branch, git_token. + db_path: Absolute path to the SQLite database file. + + Returns: + Dict with ok (bool), message, backup path, and pulled_branch. + """ + # 1. Backup database before any changes + try: + backup_path = backup_database(db_path) + except Exception as exc: + logger.error("Database backup failed: %s", exc) + return {"ok": False, "message": f"Database backup failed: {exc}", "backup": None} + + # 2. Build git pull command (embed token in URL if provided) + branch = config.git_branch or "main" + if config.git_token and config.git_repo_url: + scheme_sep = config.git_repo_url.split("://", 1) + if len(scheme_sep) == 2: + auth_url = f"{scheme_sep[0]}://token:{config.git_token}@{scheme_sep[1]}" + else: + auth_url = config.git_repo_url + pull_cmd = ["git", "-C", SOURCE_DIR, "pull", auth_url, branch] + else: + pull_cmd = ["git", "-C", SOURCE_DIR, "pull", "origin", branch] + + # 3. Git pull (synchronous — must complete before rebuild) + try: + result = subprocess.run( + pull_cmd, + capture_output=True, + text=True, + timeout=120, + ) + except subprocess.TimeoutExpired: + return {"ok": False, "message": "git pull timed out after 120s.", "backup": backup_path} + except Exception as exc: + return {"ok": False, "message": f"git pull error: {exc}", "backup": backup_path} + + if result.returncode != 0: + stderr = result.stderr.strip()[:500] + logger.error("git pull failed (exit %d): %s", result.returncode, stderr) + return { + "ok": False, + "message": f"git pull failed: {stderr}", + "backup": backup_path, + } + + logger.info("git pull succeeded: %s", result.stdout.strip()[:200]) + + # 4. Fire-and-forget docker compose rebuild — the container will restart itself + compose_cmd = [ + "docker", "compose", + "-f", f"{SOURCE_DIR}/docker-compose.yml", + "up", "--build", "-d", + ] + subprocess.Popen( + compose_cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + logger.info("docker compose up --build -d triggered — container will restart shortly.") + + return { + "ok": True, + "message": ( + "Update gestartet. Die App wird in ca. 60 Sekunden mit der neuen Version verfügbar sein." + ), + "backup": backup_path, + "pulled_branch": branch, + } diff --git a/app/utils/config.py b/app/utils/config.py index c0f84d1..0eb66c5 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -50,6 +50,10 @@ class AppConfig: ldap_base_dn: str = "" ldap_user_filter: str = "(sAMAccountName={username})" ldap_group_dn: str = "" + # Update management + git_repo_url: str = "" + git_branch: str = "main" + git_token: str = "" # decrypted # --------------------------------------------------------------------------- @@ -117,6 +121,10 @@ def get_system_config(db: Session) -> Optional[AppConfig]: ldap_bind_password = decrypt_value(row.ldap_bind_password_encrypted) if row.ldap_bind_password_encrypted else "" except Exception: ldap_bind_password = "" + try: + git_token = decrypt_value(row.git_token_encrypted) if row.git_token_encrypted else "" + except Exception: + git_token = "" return AppConfig( base_domain=row.base_domain, @@ -149,4 +157,7 @@ def get_system_config(db: Session) -> Optional[AppConfig]: ldap_base_dn=getattr(row, "ldap_base_dn", "") or "", ldap_user_filter=getattr(row, "ldap_user_filter", "(sAMAccountName={username})") or "(sAMAccountName={username})", ldap_group_dn=getattr(row, "ldap_group_dn", "") or "", + git_repo_url=getattr(row, "git_repo_url", "") or "", + git_branch=getattr(row, "git_branch", "main") or "main", + git_token=git_token, ) diff --git a/app/utils/validators.py b/app/utils/validators.py index 28bdd4b..27c6181 100644 --- a/app/utils/validators.py +++ b/app/utils/validators.py @@ -154,6 +154,10 @@ class SystemConfigUpdate(BaseModel): ldap_base_dn: Optional[str] = Field(None, max_length=500) ldap_user_filter: Optional[str] = Field(None, max_length=255) ldap_group_dn: Optional[str] = Field(None, max_length=500) + # Update management + git_repo_url: Optional[str] = Field(None, max_length=500) + git_branch: Optional[str] = Field(None, max_length=100) + git_token: Optional[str] = None # plaintext, encrypted before storage @field_validator("ssl_mode") @classmethod diff --git a/docker-compose.yml b/docker-compose.yml index f5302e4..f972e6c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,7 +38,12 @@ services: # Only accessible from within the Docker network — never expose port externally netbird-msp-appliance: - build: . + build: + context: . + args: + GIT_COMMIT: ${GIT_COMMIT:-unknown} + GIT_BRANCH: ${GIT_BRANCH:-unknown} + GIT_COMMIT_DATE: ${GIT_COMMIT_DATE:-unknown} container_name: netbird-msp-appliance restart: unless-stopped security_opt: @@ -55,6 +60,7 @@ services: - ./backups:/app/backups:z - /var/run/docker.sock:/var/run/docker.sock:z - ${DATA_DIR:-/opt/netbird-instances}:${DATA_DIR:-/opt/netbird-instances}:z + - .:/app-source:z environment: - SECRET_KEY=${SECRET_KEY} - DATABASE_PATH=/app/data/netbird_msp.db diff --git a/update.sh b/update.sh new file mode 100755 index 0000000..2e166b5 --- /dev/null +++ b/update.sh @@ -0,0 +1,70 @@ +#!/bin/bash +# update.sh — SSH-based manual update for NetBird MSP Appliance +# Usage: bash update.sh [branch] +# Run from the host as root or the user that owns the install directory. +set -euo pipefail + +INSTALL_DIR="/opt/netbird-msp" +BRANCH="${1:-main}" + +cd "$INSTALL_DIR" + +echo "=== NetBird MSP Appliance Update ===" +echo "Install dir : $INSTALL_DIR" +echo "Branch : $BRANCH" +echo "Current : $(git log --oneline -1 2>/dev/null || echo 'unknown')" +echo "" + +# --- Backup database --- +BACKUP_DIR="$INSTALL_DIR/backups" +mkdir -p "$BACKUP_DIR" +DB_FILE="$INSTALL_DIR/data/netbird_msp.db" +if [ -f "$DB_FILE" ]; then + TIMESTAMP=$(date +%Y%m%d_%H%M%S) + BACKUP_FILE="$BACKUP_DIR/netbird_msp_${TIMESTAMP}.db" + cp "$DB_FILE" "$BACKUP_FILE" + echo "✓ Database backed up to $BACKUP_FILE" +else + echo "⚠ No database file found at $DB_FILE — skipping backup" +fi + +# --- Pull latest code --- +git fetch origin "$BRANCH" +git checkout "$BRANCH" +git pull origin "$BRANCH" +echo "✓ Code updated to: $(git log --oneline -1)" + +# --- Export build args --- +export GIT_COMMIT=$(git rev-parse HEAD) +export GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD) +export GIT_COMMIT_DATE=$(git log -1 --format=%cI) + +echo "" +echo "Building with:" +echo " GIT_COMMIT = $GIT_COMMIT" +echo " GIT_BRANCH = $GIT_BRANCH" +echo " GIT_COMMIT_DATE = $GIT_COMMIT_DATE" +echo "" + +# --- Rebuild and restart --- +docker compose up --build -d +echo "✓ Container rebuilt and restarted" + +# --- Health check --- +echo "Waiting for app to start..." +for i in $(seq 1 12); do + sleep 5 + if curl -sf http://localhost:8000/api/health > /dev/null 2>&1; then + echo "" + echo "✓ App is healthy!" + echo "=== Update complete ===" + echo "New version: $(git log --oneline -1)" + exit 0 + fi + printf " Waiting... (%ds)\n" "$((i * 5))" +done + +echo "" +echo "⚠ Health check timed out after 60s." +echo " Check logs with: docker logs netbird-msp-appliance" +exit 1 From e9e2e67991e5b7e0e43d40284ab179feaf465011 Mon Sep 17 00:00:00 2001 From: Sascha Lustenberger Date: Sat, 21 Feb 2026 21:48:15 +0100 Subject: [PATCH 10/44] feat: add Windows DNS, LDAP, and Update settings tabs to UI - Settings page: 3 new tabs (Windows DNS, LDAP / AD, Updates) - Windows DNS tab: enable toggle, server/zone/username/password/record-IP, save + test connection button - LDAP tab: enable toggle, server/port/SSL/bind-DN/password/base-DN/ user-filter/group-DN, save + test connection button - Updates tab: current + latest version info card with update-available badge, one-click update button (git pull + rebuild), git repo/branch/ token settings form - Azure AD tab: added Allowed Group Object ID field - app.js: settings-dns-form, settings-ldap-form, settings-git-form submit handlers; testDnsConnection(), testLdapConnection(), loadVersionInfo(), triggerUpdate() functions; loadSettings() extended for all new fields - en.json: all new translation keys - de.json: complete German translation (was mostly empty before) Co-Authored-By: Claude Sonnet 4.6 --- static/index.html | 173 +++++++++++++++++++++++++++ static/js/app.js | 202 ++++++++++++++++++++++++++++++++ static/lang/de.json | 278 ++++++++++++++++++++++++++++++++++++++++++++ static/lang/en.json | 53 +++++++++ 4 files changed, 706 insertions(+) diff --git a/static/index.html b/static/index.html index d733a09..40675d7 100644 --- a/static/index.html +++ b/static/index.html @@ -311,6 +311,9 @@ + + + @@ -562,6 +565,11 @@
+
+ + +
If set, only Azure AD members of this group can log in.
+
@@ -571,6 +579,171 @@
+ +
+
+
+
Windows DNS Integration
+
+
+
+
+ + +
+
Automatically create/delete DNS A-records when deploying customers.
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+
+ + +
IP address that customer A-records will point to (usually your NPM server IP).
+
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+
LDAP / Active Directory Authentication
+
+
+
+
+ + +
+
Allow Active Directory users to log in. Local admin accounts always work as fallback.
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ +
+ + +
+
+
+
+ + +
+
+ + +
Use {username} as placeholder for the login name.
+
+
+ + +
If set, only members of this group can log in via LDAP.
+
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+ Version & Updates + +
+
+
Loading...
+
+
+
+
+
Git Repository Settings
+
+
+
+ + +
Used for version checks and one-click updates via Gitea API.
+
+
+ + +
+
+ +
+ + +
+
+
+
+
+ +
+
+
+
+
+
diff --git a/static/js/app.js b/static/js/app.js index d671a9e..2510f94 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -844,6 +844,31 @@ async function loadSettings() { document.getElementById('cfg-azure-tenant').value = cfg.azure_tenant_id || ''; document.getElementById('cfg-azure-client-id').value = cfg.azure_client_id || ''; document.getElementById('azure-secret-status').textContent = cfg.azure_client_secret_set ? t('settings.secretSet') : t('settings.noSecret'); + document.getElementById('cfg-azure-group-id').value = cfg.azure_allowed_group_id || ''; + + // DNS tab + document.getElementById('cfg-dns-enabled').checked = cfg.dns_enabled || false; + document.getElementById('cfg-dns-server').value = cfg.dns_server || ''; + document.getElementById('cfg-dns-zone').value = cfg.dns_zone || ''; + document.getElementById('cfg-dns-username').value = cfg.dns_username || ''; + document.getElementById('cfg-dns-record-ip').value = cfg.dns_record_ip || ''; + document.getElementById('dns-password-status').textContent = cfg.dns_password_set ? t('settings.passwordSet') : t('settings.noPasswordSet'); + + // LDAP tab + document.getElementById('cfg-ldap-enabled').checked = cfg.ldap_enabled || false; + document.getElementById('cfg-ldap-server').value = cfg.ldap_server || ''; + document.getElementById('cfg-ldap-port').value = cfg.ldap_port || 389; + document.getElementById('cfg-ldap-use-ssl').checked = cfg.ldap_use_ssl || false; + document.getElementById('cfg-ldap-bind-dn').value = cfg.ldap_bind_dn || ''; + document.getElementById('cfg-ldap-base-dn').value = cfg.ldap_base_dn || ''; + document.getElementById('cfg-ldap-user-filter').value = cfg.ldap_user_filter || '(sAMAccountName={username})'; + document.getElementById('cfg-ldap-group-dn').value = cfg.ldap_group_dn || ''; + document.getElementById('ldap-password-status').textContent = cfg.ldap_bind_password_set ? t('settings.passwordSet') : t('settings.noPasswordSet'); + + // Git/Update tab + document.getElementById('cfg-git-repo-url').value = cfg.git_repo_url || ''; + document.getElementById('cfg-git-branch').value = cfg.git_branch || 'main'; + document.getElementById('git-token-status').textContent = cfg.git_token_set ? t('settings.tokenSet') : t('settings.noToken'); } catch (err) { showSettingsAlert('danger', t('errors.failedToLoadSettings', { error: err.message })); } @@ -1069,6 +1094,182 @@ async function deleteLogo() { } } +// --------------------------------------------------------------------------- +// DNS Settings +// --------------------------------------------------------------------------- +document.getElementById('settings-dns-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const payload = { + dns_enabled: document.getElementById('cfg-dns-enabled').checked, + dns_server: document.getElementById('cfg-dns-server').value, + dns_zone: document.getElementById('cfg-dns-zone').value, + dns_username: document.getElementById('cfg-dns-username').value, + dns_record_ip: document.getElementById('cfg-dns-record-ip').value, + }; + const pw = document.getElementById('cfg-dns-password').value; + if (pw) payload.dns_password = pw; + try { + await api('PUT', '/settings/system', payload); + showSettingsAlert('success', t('messages.dnsSettingsSaved')); + document.getElementById('cfg-dns-password').value = ''; + loadSettings(); + } catch (err) { + showSettingsAlert('danger', t('errors.failed', { error: err.message })); + } +}); + +async function testDnsConnection() { + const spinner = document.getElementById('dns-test-spinner'); + const resultEl = document.getElementById('dns-test-result'); + spinner.classList.remove('d-none'); + resultEl.classList.add('d-none'); + try { + const data = await api('GET', '/settings/test-dns'); + resultEl.className = `mt-3 alert alert-${data.ok ? 'success' : 'danger'}`; + resultEl.textContent = data.message; + resultEl.classList.remove('d-none'); + } catch (err) { + resultEl.className = 'mt-3 alert alert-danger'; + resultEl.textContent = err.message; + resultEl.classList.remove('d-none'); + } finally { + spinner.classList.add('d-none'); + } +} + +// --------------------------------------------------------------------------- +// LDAP Settings +// --------------------------------------------------------------------------- +document.getElementById('settings-ldap-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const payload = { + ldap_enabled: document.getElementById('cfg-ldap-enabled').checked, + ldap_server: document.getElementById('cfg-ldap-server').value, + ldap_port: parseInt(document.getElementById('cfg-ldap-port').value) || 389, + ldap_use_ssl: document.getElementById('cfg-ldap-use-ssl').checked, + ldap_bind_dn: document.getElementById('cfg-ldap-bind-dn').value, + ldap_base_dn: document.getElementById('cfg-ldap-base-dn').value, + ldap_user_filter: document.getElementById('cfg-ldap-user-filter').value, + ldap_group_dn: document.getElementById('cfg-ldap-group-dn').value, + }; + const pw = document.getElementById('cfg-ldap-bind-password').value; + if (pw) payload.ldap_bind_password = pw; + try { + await api('PUT', '/settings/system', payload); + showSettingsAlert('success', t('messages.ldapSettingsSaved')); + document.getElementById('cfg-ldap-bind-password').value = ''; + loadSettings(); + } catch (err) { + showSettingsAlert('danger', t('errors.failed', { error: err.message })); + } +}); + +async function testLdapConnection() { + const spinner = document.getElementById('ldap-test-spinner'); + const resultEl = document.getElementById('ldap-test-result'); + spinner.classList.remove('d-none'); + resultEl.classList.add('d-none'); + try { + const data = await api('GET', '/settings/test-ldap'); + resultEl.className = `mt-3 alert alert-${data.ok ? 'success' : 'danger'}`; + resultEl.textContent = data.message; + resultEl.classList.remove('d-none'); + } catch (err) { + resultEl.className = 'mt-3 alert alert-danger'; + resultEl.textContent = err.message; + resultEl.classList.remove('d-none'); + } finally { + spinner.classList.add('d-none'); + } +} + +// --------------------------------------------------------------------------- +// Update / Version Management +// --------------------------------------------------------------------------- +document.getElementById('settings-git-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const payload = { + git_repo_url: document.getElementById('cfg-git-repo-url').value, + git_branch: document.getElementById('cfg-git-branch').value || 'main', + }; + const token = document.getElementById('cfg-git-token').value; + if (token) payload.git_token = token; + try { + await api('PUT', '/settings/system', payload); + showSettingsAlert('success', t('messages.gitSettingsSaved')); + document.getElementById('cfg-git-token').value = ''; + loadSettings(); + } catch (err) { + showSettingsAlert('danger', t('errors.failed', { error: err.message })); + } +}); + +async function loadVersionInfo() { + const el = document.getElementById('version-info-content'); + if (!el) return; + el.innerHTML = `
${t('common.loading')}
`; + try { + const data = await api('GET', '/settings/version'); + const current = data.current || {}; + const latest = data.latest; + const needsUpdate = data.needs_update; + + let html = `
+
+
+
${t('settings.currentVersion')}
+
${esc(current.commit || 'unknown')}
+
${t('settings.branch')}: ${esc(current.branch || 'unknown')}
+
${esc(current.date || '')}
+
+
`; + + if (latest) { + const badge = needsUpdate + ? `${t('settings.updateAvailable')}` + : `${t('settings.upToDate')}`; + html += `
+
+
${t('settings.latestVersion')} ${badge}
+
${esc(latest.commit || 'unknown')}
+
${t('settings.branch')}: ${esc(latest.branch || 'unknown')}
+
${esc(latest.message || '')}
+
${esc(latest.date || '')}
+
+
`; + } else if (data.error) { + html += `
${esc(data.error)}
`; + } + html += '
'; + + if (needsUpdate) { + html += `
+ +
${t('settings.updateWarning')}
+
`; + } + el.innerHTML = html; + } catch (err) { + el.innerHTML = `
${esc(err.message)}
`; + } +} + +async function triggerUpdate() { + if (!confirm(t('settings.confirmUpdate'))) return; + const spinner = document.getElementById('update-spinner'); + if (spinner) spinner.classList.remove('d-none'); + try { + const data = await api('POST', '/settings/update'); + showSettingsAlert('success', data.message || t('messages.updateStarted')); + } catch (err) { + showSettingsAlert('danger', t('errors.failed', { error: err.message })); + if (spinner) spinner.classList.add('d-none'); + } +} + // --------------------------------------------------------------------------- // User Management // --------------------------------------------------------------------------- @@ -1181,6 +1382,7 @@ document.getElementById('settings-azure-form').addEventListener('submit', async azure_enabled: document.getElementById('cfg-azure-enabled').checked, azure_tenant_id: document.getElementById('cfg-azure-tenant').value || null, azure_client_id: document.getElementById('cfg-azure-client-id').value || null, + azure_allowed_group_id: document.getElementById('cfg-azure-group-id').value || null, }; const secret = document.getElementById('cfg-azure-client-secret').value; if (secret) payload.azure_client_secret = secret; diff --git a/static/lang/de.json b/static/lang/de.json index 034a881..383a0fc 100644 --- a/static/lang/de.json +++ b/static/lang/de.json @@ -90,5 +90,283 @@ "thImage": "Image", "lastCheck": "Letzte Prüfung: {time}", "openDashboard": "Dashboard öffnen" + }, + "settings": { + "title": "Systemeinstellungen", + "tabSystem": "Systemkonfiguration", + "tabNpm": "NPM Integration", + "tabImages": "Docker Images", + "tabBranding": "Branding", + "tabUsers": "Benutzer", + "tabAzure": "Azure AD", + "tabDns": "Windows DNS", + "tabLdap": "LDAP / AD", + "tabUpdate": "Updates", + "tabSecurity": "Sicherheit", + "baseDomain": "Basis-Domain", + "baseDomainPlaceholder": "ihredomain.com", + "baseDomainHint": "Kunden erhalten Subdomains: kunde.ihredomain.com", + "adminEmail": "Admin E-Mail", + "adminEmailPlaceholder": "admin@ihredomain.com", + "dataDir": "Datenverzeichnis", + "dataDirPlaceholder": "/opt/netbird-instances", + "dockerNetwork": "Docker-Netzwerk", + "dockerNetworkPlaceholder": "npm-network", + "relayBasePort": "Relay-Basisport", + "relayBasePortHint": "Erster UDP-Port für Relay. Bereich: Basis bis Basis+99", + "dashboardBasePort": "Dashboard-Basisport", + "dashboardBasePortHint": "Basisport für Kunden-Dashboards. Kunde N erhält Basis+N", + "saveSystemSettings": "Systemeinstellungen speichern", + "npmDescription": "NPM verwendet JWT-Authentifizierung. Geben Sie Ihre NPM-Zugangsdaten ein. Das System meldet sich automatisch an.", + "npmApiUrl": "NPM API URL", + "npmApiUrlPlaceholder": "http://nginx-proxy-manager:81/api", + "npmApiUrlHint": "http:// oder https:// - muss /api am Ende enthalten", + "npmLoginEmail": "NPM Login E-Mail", + "npmLoginEmailPlaceholder": "Leer lassen zum Beibehalten", + "npmLoginPassword": "NPM Login Passwort", + "npmLoginPasswordPlaceholder": "Leer lassen zum Beibehalten", + "credentialsSet": "Zugangsdaten gesetzt (leer lassen zum Beibehalten)", + "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": "Wählen Sie ob jeder Kunde ein eigenes Let's Encrypt Zertifikat oder ein geteiltes Wildcard-Zertifikat erhält.", + "wildcardCertificate": "Wildcard-Zertifikat", + "selectCertificate": "-- Zertifikat auswählen --", + "wildcardCertHint": "Wählen Sie das Wildcard-Zertifikat (z.B. *.example.com) das in NPM hochgeladen ist.", + "noWildcardCerts": "Keine Wildcard-Zertifikate in NPM gefunden.", + "certsLoaded": "{count} Wildcard-Zertifikat(e) gefunden.", + "expiresOn": "Läuft ab", + "managementImage": "Management Image", + "managementImagePlaceholder": "netbirdio/management:latest", + "signalImage": "Signal Image", + "signalImagePlaceholder": "netbirdio/signal:latest", + "relayImage": "Relay Image", + "relayImagePlaceholder": "netbirdio/relay:latest", + "dashboardImage": "Dashboard Image", + "dashboardImagePlaceholder": "netbirdio/dashboard:latest", + "saveImageSettings": "Image-Einstellungen speichern", + "brandingTitle": "Branding-Einstellungen", + "companyName": "Firmen- / Anwendungsname", + "companyNamePlaceholder": "NetBird MSP Appliance", + "companyNameHint": "Wird auf der Anmeldeseite und in der Navigationsleiste angezeigt", + "logoPreview": "Logo-Vorschau", + "defaultIcon": "Standardsymbol (kein Logo hochgeladen)", + "uploadLogo": "Logo hochladen (PNG, JPG, SVG, max. 500 KB)", + "uploadBtn": "Hochladen", + "removeLogo": "Logo entfernen", + "brandingSubtitle": "Untertitel", + "brandingSubtitlePlaceholder": "Multi-Tenant Management Plattform", + "brandingSubtitleHint": "Wird unter dem Titel auf der Anmeldeseite angezeigt", + "defaultLanguage": "Standardsprache", + "defaultLanguageHint": "Standardsprache für Benutzer ohne Präferenz", + "systemDefault": "Systemstandard", + "saveBranding": "Branding speichern", + "userManagement": "Benutzerverwaltung", + "newUser": "Neuer Benutzer", + "thId": "ID", + "thUsername": "Benutzername", + "thEmail": "E-Mail", + "thRole": "Rolle", + "thAuth": "Auth", + "thLanguage": "Sprache", + "thStatus": "Status", + "thActions": "Aktionen", + "azureTitle": "Azure AD / Entra ID Integration", + "enableAzureSso": "Azure AD SSO aktivieren", + "tenantId": "Tenant ID", + "clientId": "Client ID (Anwendungs-ID)", + "clientSecret": "Client Secret", + "clientSecretPlaceholder": "Leer lassen zum Beibehalten", + "secretSet": "Secret gesetzt (leer lassen zum Beibehalten)", + "noSecret": "Kein Client-Secret konfiguriert", + "saveAzureSettings": "Azure AD-Einstellungen speichern", + "azureGroupId": "Erlaubte Gruppen-Objekt-ID (optional)", + "azureGroupIdHint": "Falls gesetzt, können sich nur Azure AD-Mitglieder dieser Gruppe anmelden.", + "dnsTitle": "Windows DNS Integration", + "enableDns": "Windows DNS Integration aktivieren", + "dnsDescription": "Automatisch DNS A-Records erstellen/löschen beim Bereitstellen von Kunden.", + "dnsServer": "DNS-Serveradresse", + "dnsZone": "DNS-Zone", + "dnsUsername": "Benutzername (NTLM)", + "dnsPassword": "Passwort", + "dnsRecordIp": "A-Record Ziel-IP", + "dnsRecordIpHint": "IP-Adresse, auf die Kunden-A-Records zeigen (normalerweise die NPM-Server-IP).", + "saveDnsSettings": "DNS-Einstellungen speichern", + "ldapTitle": "LDAP / Active Directory Authentifizierung", + "enableLdap": "LDAP / AD Authentifizierung aktivieren", + "ldapDescription": "Active Directory Benutzern die Anmeldung erlauben. Lokale Admin-Konten funktionieren immer als Fallback.", + "ldapServer": "LDAP-Server", + "ldapPort": "Port", + "ldapUseSsl": "SSL/TLS verwenden (LDAPS)", + "ldapBindDn": "Bind DN (Dienstkonto)", + "ldapBindPassword": "Bind-Passwort", + "ldapBaseDn": "Basis-DN", + "ldapUserFilter": "Benutzerfilter", + "ldapUserFilterHint": "Verwenden Sie {username} als Platzhalter für den Anmeldenamen.", + "ldapGroupDn": "Gruppen-DN (optional, zur Einschränkung)", + "ldapGroupDnHint": "Falls gesetzt, können sich nur Mitglieder dieser Gruppe per LDAP anmelden.", + "saveLdapSettings": "LDAP-Einstellungen speichern", + "versionTitle": "Version & Updates", + "currentVersion": "Installierte Version", + "latestVersion": "Neueste verfügbare Version", + "branch": "Branch", + "updateAvailable": "Update verfügbar", + "upToDate": "Aktuell", + "triggerUpdate": "Update starten", + "updateWarning": "Die App ist während des Rebuilds ca. 60 Sekunden nicht verfügbar.", + "confirmUpdate": "Update jetzt starten? Die Datenbank wird zuerst gesichert. Die App startet neu (~60 Sekunden Ausfallzeit).", + "gitTitle": "Git-Repository Einstellungen", + "gitRepoUrl": "Repository URL", + "gitRepoUrlHint": "Wird für Versionsprüfungen und One-Click-Updates via Gitea API verwendet.", + "gitBranch": "Branch", + "gitToken": "Zugriffstoken (optional)", + "saveGitSettings": "Git-Einstellungen speichern", + "leaveEmptyToKeep": "Leer lassen zum Beibehalten", + "passwordSet": "Passwort gesetzt (leer lassen zum Beibehalten)", + "noPasswordSet": "Kein Passwort konfiguriert", + "tokenSet": "Token gesetzt (leer lassen zum Beibehalten)", + "noToken": "Kein Zugriffstoken konfiguriert", + "securityTitle": "Admin-Passwort ändern", + "currentPassword": "Aktuelles Passwort", + "newPassword": "Neues Passwort (min. 12 Zeichen)", + "confirmPassword": "Neues Passwort bestätigen", + "changePassword": "Passwort ändern" + }, + "mfa": { + "title": "Zwei-Faktor-Authentifizierung (MFA)", + "enableMfa": "MFA für alle lokalen Benutzer aktivieren", + "mfaDescription": "Bei Aktivierung müssen lokale Benutzer sich nach der Passworteingabe mit einer TOTP-Authentifikator-App verifizieren. Azure AD-Benutzer sind nicht betroffen.", + "saveMfaSettings": "MFA-Einstellungen speichern", + "yourTotpStatus": "Ihr TOTP-Status", + "totpActive": "Aktiv", + "totpNotSetUp": "Nicht eingerichtet", + "disableMyTotp": "Mein TOTP deaktivieren", + "enterCode": "Geben Sie Ihren 6-stelligen Authentifikator-Code ein", + "verify": "Bestätigen", + "backToLogin": "Zurück zur Anmeldung", + "scanQrCode": "Scannen Sie diesen QR-Code mit Ihrer Authentifikator-App", + "orEnterManually": "Oder geben Sie diesen Schlüssel manuell ein:", + "verifyAndActivate": "Bestätigen & Aktivieren", + "resetMfa": "MFA zurücksetzen", + "confirmResetMfa": "MFA für '{username}' zurücksetzen? Sie müssen bei der nächsten Anmeldung ihren Authentifikator neu einrichten.", + "mfaResetSuccess": "MFA für '{username}' zurückgesetzt.", + "mfaDisabled": "Ihr TOTP wurde deaktiviert.", + "mfaSaved": "MFA-Einstellungen gespeichert.", + "invalidCode": "Ungültiger Code. Bitte versuchen Sie es erneut.", + "codeExpired": "Verifizierung abgelaufen. Bitte melden Sie sich erneut an." + }, + "common": { + "loading": "Laden...", + "back": "Zurück", + "save": "Speichern", + "cancel": "Abbrechen", + "delete": "Löschen", + "edit": "Bearbeiten", + "view": "Ansehen", + "start": "Starten", + "stop": "Stoppen", + "restart": "Neustarten", + "disable": "Deaktivieren", + "enable": "Aktivieren", + "resetPassword": "Passwort zurücksetzen", + "open": "Öffnen", + "active": "Aktiv", + "disabled": "Deaktiviert" + }, + "errors": { + "networkError": "Netzwerkfehler — Server nicht erreichbar.", + "sessionExpired": "Sitzung abgelaufen.", + "requestFailed": "Anfrage fehlgeschlagen.", + "serverError": "Serverfehler (HTTP {status}).", + "unknownError": "Ein unbekannter Fehler ist aufgetreten.", + "uploadFailed": "Upload fehlgeschlagen.", + "deleteFailed": "Löschen fehlgeschlagen: {error}", + "failedToLoadSettings": "Einstellungen konnten nicht geladen werden: {error}", + "failed": "Fehlgeschlagen: {error}", + "logoUploadFailed": "Logo-Upload fehlgeschlagen: {error}", + "failedToRemoveLogo": "Logo konnte nicht entfernt werden: {error}", + "updateFailed": "Aktualisierung fehlgeschlagen: {error}", + "passwordResetFailed": "Passwort zurücksetzen fehlgeschlagen: {error}", + "selectFileFirst": "Bitte wählen Sie zuerst eine Datei aus.", + "passwordsDoNotMatch": "Passwörter stimmen nicht überein.", + "failedToLoadCredentials": "Zugangsdaten konnten nicht geladen werden: {error}", + "azureNotConfigured": "Azure AD ist nicht konfiguriert.", + "azureLoginFailed": "Azure AD Anmeldung fehlgeschlagen: {error}", + "actionFailed": "{action} fehlgeschlagen: {error}" + }, + "messages": { + "systemSettingsSaved": "Systemeinstellungen gespeichert.", + "npmSettingsSaved": "NPM-Einstellungen gespeichert.", + "imageSettingsSaved": "Image-Einstellungen gespeichert.", + "brandingNameSaved": "Branding-Einstellungen gespeichert.", + "logoUploaded": "Logo erfolgreich hochgeladen.", + "logoRemoved": "Logo entfernt.", + "azureSettingsSaved": "Azure AD-Einstellungen gespeichert.", + "dnsSettingsSaved": "DNS-Einstellungen gespeichert.", + "ldapSettingsSaved": "LDAP-Einstellungen gespeichert.", + "gitSettingsSaved": "Git-Einstellungen gespeichert.", + "updateStarted": "Update gestartet. Die App wird in Kürze neu starten.", + "passwordChanged": "Passwort erfolgreich geändert.", + "setupUrlCopied": "Setup-URL in Zwischenablage kopiert.", + "copiedToClipboard": "In Zwischenablage kopiert.", + "userCreated": "Benutzer '{username}' erstellt.", + "userDeleted": "Benutzer '{username}' gelöscht.", + "passwordResetFor": "Passwort zurückgesetzt für '{username}'.", + "newPasswordAlert": "Neues Passwort für '{username}':\n\n{password}\n\nBitte speichern Sie dieses Passwort jetzt. Es wird nicht erneut angezeigt.", + "confirmDeleteUser": "Benutzer '{username}' löschen? Dies kann nicht rückgängig gemacht werden.", + "confirmResetPassword": "Passwort für '{username}' zurücksetzen? Ein neues zufälliges Passwort wird generiert." + }, + "userModal": { + "title": "Neuer Benutzer", + "usernameLabel": "Benutzername *", + "passwordLabel": "Passwort * (min. 8 Zeichen)", + "emailLabel": "E-Mail", + "languageLabel": "Standardsprache", + "cancel": "Abbrechen", + "createUser": "Benutzer erstellen" + }, + "customerModal": { + "newCustomer": "Neuer Kunde", + "editCustomer": "Kunde bearbeiten", + "nameLabel": "Name *", + "companyLabel": "Firma", + "subdomainLabel": "Subdomain *", + "subdomainHint": "Kleinbuchstaben, alphanumerisch + Bindestriche", + "emailLabel": "E-Mail *", + "maxDevicesLabel": "Max. Geräte", + "notesLabel": "Notizen", + "cancel": "Abbrechen", + "saveAndDeploy": "Speichern & Bereitstellen", + "saveChanges": "Änderungen speichern" + }, + "deleteModal": { + "title": "Löschen bestätigen", + "confirmText": "Möchten Sie den Kunden wirklich löschen:", + "warning": "Alle Container, NPM-Einträge und Daten werden entfernt. Diese Aktion kann nicht rückgängig gemacht werden.", + "cancel": "Abbrechen", + "delete": "Löschen" + }, + "monitoring": { + "title": "System-Monitoring", + "refresh": "Aktualisieren", + "hostResources": "Host-Ressourcen", + "hostname": "Hostname", + "cpu": "CPU ({count} Kerne)", + "memory": "Arbeitsspeicher ({used}/{total} GB)", + "disk": "Festplatte ({used}/{total} GB)", + "allCustomerDeployments": "Alle Kunden-Deployments", + "thId": "ID", + "thName": "Name", + "thSubdomain": "Subdomain", + "thStatus": "Status", + "thDeployment": "Deployment", + "thDashboard": "Dashboard", + "thRelayPort": "Relay-Port", + "thContainers": "Container", + "noCustomers": "Keine Kunden." } } diff --git a/static/lang/en.json b/static/lang/en.json index 72b1965..0dd40f4 100644 --- a/static/lang/en.json +++ b/static/lang/en.json @@ -120,6 +120,9 @@ "tabBranding": "Branding", "tabUsers": "Users", "tabAzure": "Azure AD", + "tabDns": "Windows DNS", + "tabLdap": "LDAP / AD", + "tabUpdate": "Updates", "tabSecurity": "Security", "baseDomain": "Base Domain", "baseDomainPlaceholder": "yourdomain.com", @@ -202,6 +205,52 @@ "secretSet": "Secret is set (leave empty to keep current)", "noSecret": "No client secret configured", "saveAzureSettings": "Save Azure AD Settings", + "azureGroupId": "Allowed Group Object ID (optional)", + "azureGroupIdHint": "If set, only Azure AD members of this group can log in.", + "dnsTitle": "Windows DNS Integration", + "enableDns": "Enable Windows DNS Integration", + "dnsDescription": "Automatically create/delete DNS A-records when deploying customers.", + "dnsServer": "DNS Server Address", + "dnsZone": "DNS Zone", + "dnsUsername": "Username (NTLM)", + "dnsPassword": "Password", + "dnsRecordIp": "A-Record Target IP", + "dnsRecordIpHint": "IP address that customer A-records will point to (usually your NPM server IP).", + "saveDnsSettings": "Save DNS Settings", + "ldapTitle": "LDAP / Active Directory Authentication", + "enableLdap": "Enable LDAP / AD Authentication", + "ldapDescription": "Allow Active Directory users to log in. Local admin accounts always work as fallback.", + "ldapServer": "LDAP Server", + "ldapPort": "Port", + "ldapUseSsl": "Use SSL/TLS (LDAPS)", + "ldapBindDn": "Bind DN (Service Account)", + "ldapBindPassword": "Bind Password", + "ldapBaseDn": "Base DN", + "ldapUserFilter": "User Filter", + "ldapUserFilterHint": "Use {username} as placeholder for the login name.", + "ldapGroupDn": "Group Restriction DN (optional)", + "ldapGroupDnHint": "If set, only members of this group can log in via LDAP.", + "saveLdapSettings": "Save LDAP Settings", + "versionTitle": "Version & Updates", + "currentVersion": "Installed Version", + "latestVersion": "Latest Available", + "branch": "Branch", + "updateAvailable": "Update Available", + "upToDate": "Up to date", + "triggerUpdate": "Start Update", + "updateWarning": "The app will be unavailable for ~60 seconds during rebuild.", + "confirmUpdate": "Start the update now? The database will be backed up first. The app will restart (~60 seconds downtime).", + "gitTitle": "Git Repository Settings", + "gitRepoUrl": "Repository URL", + "gitRepoUrlHint": "Used for version checks and one-click updates via Gitea API.", + "gitBranch": "Branch", + "gitToken": "Access Token (optional)", + "saveGitSettings": "Save Git Settings", + "leaveEmptyToKeep": "Leave empty to keep current", + "passwordSet": "Password is set (leave empty to keep current)", + "noPasswordSet": "No password configured", + "tokenSet": "Token is set (leave empty to keep current)", + "noToken": "No access token configured", "securityTitle": "Change Admin Password", "currentPassword": "Current Password", "newPassword": "New Password (min 12 chars)", @@ -306,6 +355,10 @@ "logoUploaded": "Logo uploaded successfully.", "logoRemoved": "Logo removed.", "azureSettingsSaved": "Azure AD settings saved.", + "dnsSettingsSaved": "DNS settings saved.", + "ldapSettingsSaved": "LDAP settings saved.", + "gitSettingsSaved": "Git settings saved.", + "updateStarted": "Update started. The app will restart shortly.", "passwordChanged": "Password changed successfully.", "setupUrlCopied": "Setup URL copied to clipboard.", "copiedToClipboard": "Copied to clipboard.", From fd7906551937f404240ba3b8661f873d82ac84ca Mon Sep 17 00:00:00 2001 From: Sascha Lustenberger Date: Sun, 22 Feb 2026 14:12:32 +0100 Subject: [PATCH 11/44] feat: Git-Tag-basierte Versionierung (Alpha/Beta/Release statt Commit-Hash) --- Dockerfile | 3 ++- app/services/update_service.py | 45 +++++++++++++++++++++++++--------- docker-compose.yml | 3 ++- static/js/app.js | 11 +++++++-- static/lang/de.json | 3 ++- static/lang/en.json | 1 + update.sh | 2 ++ 7 files changed, 52 insertions(+), 16 deletions(-) diff --git a/Dockerfile b/Dockerfile index baab160..3ac3f90 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,7 +32,8 @@ COPY static/ ./static/ ARG GIT_COMMIT=unknown ARG GIT_BRANCH=unknown ARG GIT_COMMIT_DATE=unknown -RUN echo "{\"commit\": \"$GIT_COMMIT\", \"branch\": \"$GIT_BRANCH\", \"date\": \"$GIT_COMMIT_DATE\"}" > /app/version.json +ARG GIT_TAG=unknown +RUN echo "{\"tag\": \"$GIT_TAG\", \"commit\": \"$GIT_COMMIT\", \"branch\": \"$GIT_BRANCH\", \"date\": \"$GIT_COMMIT_DATE\"}" > /app/version.json # Allow git to operate in the /app-source volume (owner may differ from container user) RUN git config --global --add safe.directory /app-source diff --git a/app/services/update_service.py b/app/services/update_service.py index 37541d0..1ba811c 100644 --- a/app/services/update_service.py +++ b/app/services/update_service.py @@ -22,21 +22,23 @@ def get_current_version() -> dict: try: data = json.loads(Path(VERSION_FILE).read_text()) return { + "tag": data.get("tag", "unknown"), "commit": data.get("commit", "unknown"), "branch": data.get("branch", "unknown"), "date": data.get("date", "unknown"), } except Exception: - return {"commit": "unknown", "branch": "unknown", "date": "unknown"} + return {"tag": "unknown", "commit": "unknown", "branch": "unknown", "date": "unknown"} async def check_for_updates(config: Any) -> dict: - """Query the Gitea API for the latest commit on the configured branch. + """Query the Gitea API for the latest tag and commit on the configured branch. Parses the repo URL to build the Gitea API endpoint: https://git.example.com/owner/repo - → https://git.example.com/api/v1/repos/owner/repo/branches/{branch} + → https://git.example.com/api/v1/repos/owner/repo/... + Uses tags for version comparison when available, falls back to commit SHAs. Returns dict with current, latest, needs_update, and optional error. """ current = get_current_version() @@ -62,7 +64,8 @@ async def check_for_updates(config: Any) -> dict: owner = parts[-2] repo = parts[-1] branch = config.git_branch or "main" - api_url = f"{base_url}/api/v1/repos/{owner}/{repo}/branches/{branch}" + branch_api = f"{base_url}/api/v1/repos/{owner}/{repo}/branches/{branch}" + tags_api = f"{base_url}/api/v1/repos/{owner}/{repo}/tags?limit=1" headers = {} if config.git_token: @@ -70,7 +73,8 @@ async def check_for_updates(config: Any) -> dict: try: async with httpx.AsyncClient(timeout=10) as client: - resp = await client.get(api_url, headers=headers) + # Fetch branch info (latest commit) + resp = await client.get(branch_api, headers=headers) if resp.status_code != 200: return { "current": current, @@ -82,20 +86,39 @@ async def check_for_updates(config: Any) -> dict: latest_commit = data.get("commit", {}) full_sha = latest_commit.get("id", "unknown") short_sha = full_sha[:8] if full_sha != "unknown" else "unknown" + + # Fetch latest tag + latest_tag = "unknown" + try: + tag_resp = await client.get(tags_api, headers=headers) + if tag_resp.status_code == 200: + tags = tag_resp.json() + if tags and len(tags) > 0: + latest_tag = tags[0].get("name", "unknown") + except Exception: + pass # Tag fetch is best-effort + latest = { + "tag": latest_tag, "commit": short_sha, "commit_full": full_sha, "message": latest_commit.get("commit", {}).get("message", "").split("\n")[0], "date": latest_commit.get("commit", {}).get("committer", {}).get("date", ""), "branch": branch, } + + # Determine if update is needed: prefer tag comparison, fallback to commit + current_tag = current.get("tag", "unknown") current_sha = current.get("commit", "unknown") - needs_update = ( - current_sha != "unknown" - and short_sha != "unknown" - and current_sha != short_sha - and not full_sha.startswith(current_sha) - ) + if current_tag != "unknown" and latest_tag != "unknown": + needs_update = current_tag != latest_tag + else: + needs_update = ( + current_sha != "unknown" + and short_sha != "unknown" + and current_sha != short_sha + and not full_sha.startswith(current_sha) + ) return {"current": current, "latest": latest, "needs_update": needs_update} except Exception as exc: return { diff --git a/docker-compose.yml b/docker-compose.yml index f972e6c..66e8c67 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -44,6 +44,7 @@ services: GIT_COMMIT: ${GIT_COMMIT:-unknown} GIT_BRANCH: ${GIT_BRANCH:-unknown} GIT_COMMIT_DATE: ${GIT_COMMIT_DATE:-unknown} + GIT_TAG: ${GIT_TAG:-unknown} container_name: netbird-msp-appliance restart: unless-stopped security_opt: @@ -71,7 +72,7 @@ services: networks: - npm-network healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8000/api/health"] + test: [ "CMD", "curl", "-f", "http://localhost:8000/api/health" ] interval: 30s timeout: 10s retries: 3 diff --git a/static/js/app.js b/static/js/app.js index 2510f94..9a81af8 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1214,24 +1214,31 @@ async function loadVersionInfo() { const latest = data.latest; const needsUpdate = data.needs_update; + const currentTag = current.tag && current.tag !== 'unknown' ? current.tag : null; + const currentCommit = current.commit || 'unknown'; + let html = `
${t('settings.currentVersion')}
-
${esc(current.commit || 'unknown')}
+
${esc(currentTag || currentCommit)}
+ ${currentTag ? `
${t('settings.commitHash')}: ${esc(currentCommit)}
` : ''}
${t('settings.branch')}: ${esc(current.branch || 'unknown')}
${esc(current.date || '')}
`; if (latest) { + const latestTag = latest.tag && latest.tag !== 'unknown' ? latest.tag : null; + const latestCommit = latest.commit || 'unknown'; const badge = needsUpdate ? `${t('settings.updateAvailable')}` : `${t('settings.upToDate')}`; html += `
${t('settings.latestVersion')} ${badge}
-
${esc(latest.commit || 'unknown')}
+
${esc(latestTag || latestCommit)}
+ ${latestTag ? `
${t('settings.commitHash')}: ${esc(latestCommit)}
` : ''}
${t('settings.branch')}: ${esc(latest.branch || 'unknown')}
${esc(latest.message || '')}
${esc(latest.date || '')}
diff --git a/static/lang/de.json b/static/lang/de.json index 383a0fc..8fa0250 100644 --- a/static/lang/de.json +++ b/static/lang/de.json @@ -214,6 +214,7 @@ "currentVersion": "Installierte Version", "latestVersion": "Neueste verfügbare Version", "branch": "Branch", + "commitHash": "Commit", "updateAvailable": "Update verfügbar", "upToDate": "Aktuell", "triggerUpdate": "Update starten", @@ -369,4 +370,4 @@ "thContainers": "Container", "noCustomers": "Keine Kunden." } -} +} \ No newline at end of file diff --git a/static/lang/en.json b/static/lang/en.json index 0dd40f4..e0bff54 100644 --- a/static/lang/en.json +++ b/static/lang/en.json @@ -235,6 +235,7 @@ "currentVersion": "Installed Version", "latestVersion": "Latest Available", "branch": "Branch", + "commitHash": "Commit", "updateAvailable": "Update Available", "upToDate": "Up to date", "triggerUpdate": "Start Update", diff --git a/update.sh b/update.sh index 2e166b5..e97aef5 100755 --- a/update.sh +++ b/update.sh @@ -38,9 +38,11 @@ echo "✓ Code updated to: $(git log --oneline -1)" export GIT_COMMIT=$(git rev-parse HEAD) export GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD) export GIT_COMMIT_DATE=$(git log -1 --format=%cI) +export GIT_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "unknown") echo "" echo "Building with:" +echo " GIT_TAG = $GIT_TAG" echo " GIT_COMMIT = $GIT_COMMIT" echo " GIT_BRANCH = $GIT_BRANCH" echo " GIT_COMMIT_DATE = $GIT_COMMIT_DATE" From 6d2251bcf5409d8b7af78b41e8e3b5c704a67ca6 Mon Sep 17 00:00:00 2001 From: Sascha Lustenberger Date: Sun, 22 Feb 2026 14:25:44 +0100 Subject: [PATCH 12/44] =?UTF-8?q?alpha-1.1:=20Login-Page=20Version-Marker?= =?UTF-8?q?=20hinzugef=C3=BCgt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- static/index.html | 1 + 1 file changed, 1 insertion(+) diff --git a/static/index.html b/static/index.html index 40675d7..cbdc1d2 100644 --- a/static/index.html +++ b/static/index.html @@ -18,6 +18,7 @@

NetBird MSP Appliance

Multi-Tenant Management Platform

+

alpha-1.1

From fc9589b6f93365a7b63ee9e8446c1834c175914a Mon Sep 17 00:00:00 2001 From: Sascha Lustenberger Date: Sun, 22 Feb 2026 14:32:08 +0100 Subject: [PATCH 13/44] =?UTF-8?q?fix:=20trigger=5Fupdate=20setzt=20GIT=5FT?= =?UTF-8?q?AG/GIT=5FCOMMIT=20env=20vars=20f=C3=BCr=20docker=20compose=20re?= =?UTF-8?q?build?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/update_service.py | 43 +++++++++++++++++++++++++++-- dockerlogs.txt | 50 ++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 dockerlogs.txt diff --git a/app/services/update_service.py b/app/services/update_service.py index 1ba811c..bd3132f 100644 --- a/app/services/update_service.py +++ b/app/services/update_service.py @@ -2,6 +2,7 @@ import json import logging +import os import shutil import subprocess from datetime import datetime @@ -198,16 +199,52 @@ def trigger_update(config: Any, db_path: str) -> dict: logger.info("git pull succeeded: %s", result.stdout.strip()[:200]) - # 4. Fire-and-forget docker compose rebuild — the container will restart itself + # 4. Read version info from the freshly-pulled source + build_env = os.environ.copy() + try: + build_env["GIT_COMMIT"] = subprocess.run( + ["git", "-C", SOURCE_DIR, "rev-parse", "--short", "HEAD"], + capture_output=True, text=True, timeout=10, + ).stdout.strip() or "unknown" + + build_env["GIT_BRANCH"] = subprocess.run( + ["git", "-C", SOURCE_DIR, "rev-parse", "--abbrev-ref", "HEAD"], + capture_output=True, text=True, timeout=10, + ).stdout.strip() or "unknown" + + build_env["GIT_COMMIT_DATE"] = subprocess.run( + ["git", "-C", SOURCE_DIR, "log", "-1", "--format=%cI"], + capture_output=True, text=True, timeout=10, + ).stdout.strip() or "unknown" + + tag_result = subprocess.run( + ["git", "-C", SOURCE_DIR, "describe", "--tags", "--abbrev=0"], + capture_output=True, text=True, timeout=10, + ) + build_env["GIT_TAG"] = tag_result.stdout.strip() if tag_result.returncode == 0 else "unknown" + except Exception as exc: + logger.warning("Could not read version info from source: %s", exc) + + logger.info( + "Rebuilding with GIT_TAG=%s GIT_COMMIT=%s GIT_BRANCH=%s", + build_env.get("GIT_TAG", "?"), + build_env.get("GIT_COMMIT", "?"), + build_env.get("GIT_BRANCH", "?"), + ) + + # 5. Fire-and-forget docker compose rebuild — the container will restart itself compose_cmd = [ "docker", "compose", "-f", f"{SOURCE_DIR}/docker-compose.yml", "up", "--build", "-d", ] + log_path = Path(BACKUP_DIR) / "update_rebuild.log" + log_file = open(log_path, "w") subprocess.Popen( compose_cmd, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, + stdout=log_file, + stderr=log_file, + env=build_env, ) logger.info("docker compose up --build -d triggered — container will restart shortly.") diff --git a/dockerlogs.txt b/dockerlogs.txt new file mode 100644 index 0000000..964353c --- /dev/null +++ b/dockerlogs.txt @@ -0,0 +1,50 @@ +INFO: 172.18.0.1:33288 - "GET /api/settings/version HTTP/1.1" 200 OK +2026-02-22 13:27:28,812 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK" +2026-02-22 13:27:28,818 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK" +INFO: 172.18.0.1:33288 - "GET /api/settings/version HTTP/1.1" 200 OK +2026-02-22 13:27:29,463 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK" +2026-02-22 13:27:29,473 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK" +INFO: 172.18.0.1:33288 - "GET /api/settings/version HTTP/1.1" 200 OK +2026-02-22 13:27:33,352 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK" +2026-02-22 13:27:33,358 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK" +INFO: 172.18.0.1:33288 - "GET /api/settings/version HTTP/1.1" 200 OK +2026-02-22 13:27:34,899 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK" +2026-02-22 13:27:34,905 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK" +INFO: 172.18.0.1:33288 - "GET /api/settings/version HTTP/1.1" 200 OK +INFO: 172.18.0.1:33288 - "GET /api/settings/system HTTP/1.1" 200 OK +INFO: 172.18.0.1:33288 - "GET /api/auth/mfa/status HTTP/1.1" 200 OK +INFO: 172.18.0.1:33288 - "GET /api/monitoring/resources HTTP/1.1" 200 OK +INFO: 172.18.0.1:38946 - "GET /api/monitoring/customers HTTP/1.1" 200 OK +INFO: 172.18.0.1:38946 - "GET /api/monitoring/customers HTTP/1.1" 200 OK +INFO: 172.18.0.1:33288 - "GET /api/monitoring/resources HTTP/1.1" 200 OK +INFO: 172.18.0.1:33288 - "GET /api/settings/system HTTP/1.1" 200 OK +INFO: 172.18.0.1:38946 - "GET /api/auth/mfa/status HTTP/1.1" 200 OK +2026-02-22 13:27:49,427 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK" +2026-02-22 13:27:49,433 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK" +INFO: 172.18.0.1:33288 - "GET /api/settings/version HTTP/1.1" 200 OK +INFO: 172.18.0.1:33288 - "GET / HTTP/1.1" 200 OK +INFO: 172.18.0.1:38946 - "GET /api/settings/branding HTTP/1.1" 200 OK +INFO: 172.18.0.1:38946 - "GET /api/auth/azure/config HTTP/1.1" 200 OK +INFO: 172.18.0.1:38946 - "GET /api/auth/me HTTP/1.1" 200 OK +INFO: 172.18.0.1:38946 - "GET /api/monitoring/status HTTP/1.1" 200 OK +INFO: 172.18.0.1:45440 - "GET /api/customers?page=1&per_page=25 HTTP/1.1" 200 OK +INFO: 127.0.0.1:35528 - "GET /api/health HTTP/1.1" 200 OK +INFO: 172.18.0.1:33288 - "GET /api/settings/system HTTP/1.1" 200 OK +INFO: 172.18.0.1:38946 - "GET /api/auth/mfa/status HTTP/1.1" 200 OK +2026-02-22 13:27:56,795 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK" +2026-02-22 13:27:56,802 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK" +INFO: 172.18.0.1:33288 - "GET /api/settings/version HTTP/1.1" 200 OK +2026-02-22 13:27:59,507 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK" +2026-02-22 13:27:59,514 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK" +INFO: 172.18.0.1:33288 - "GET /api/settings/version HTTP/1.1" 200 OK +2026-02-22 13:28:09,172 [INFO] app.services.update_service: Database backed up to /app/backups/netbird_msp_20260222_132809.db +2026-02-22 13:28:09,264 [INFO] app.services.update_service: git pull succeeded: Already up to date. +2026-02-22 13:28:09,265 [INFO] app.services.update_service: docker compose up --build -d triggered — container will restart shortly. +2026-02-22 13:28:09,265 [INFO] app.routers.settings: Update triggered by admin. +INFO: 172.18.0.1:57990 - "POST /api/settings/update HTTP/1.1" 200 OK +INFO: 127.0.0.1:51474 - "GET /api/health HTTP/1.1" 200 OK +2026-02-22 13:28:49,056 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK" +2026-02-22 13:28:49,062 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK" +INFO: 172.18.0.1:44506 - "GET /api/settings/version HTTP/1.1" 200 OK +INFO: 127.0.0.1:53966 - "GET /api/health HTTP/1.1" 200 OK +INFO: 127.0.0.1:35452 - "GET /api/health HTTP/1.1" 200 OK From a9fc549cec89f9e29a3ce92ca93b8517f00856ba Mon Sep 17 00:00:00 2001 From: Sascha Lustenberger Date: Sun, 22 Feb 2026 14:40:07 +0100 Subject: [PATCH 15/44] fix: correct docker compose project name and target only app service for update --- app/services/update_service.py | 6 +++- dockerlogs.txt | 50 ---------------------------------- 2 files changed, 5 insertions(+), 51 deletions(-) delete mode 100644 dockerlogs.txt diff --git a/app/services/update_service.py b/app/services/update_service.py index bd3132f..dbe3bd2 100644 --- a/app/services/update_service.py +++ b/app/services/update_service.py @@ -233,10 +233,14 @@ def trigger_update(config: Any, db_path: str) -> dict: ) # 5. Fire-and-forget docker compose rebuild — the container will restart itself + # Use the correct project name so compose finds/replaces the right container. + # Only rebuild the app service — docker-socket-proxy must not be recreated. compose_cmd = [ "docker", "compose", + "-p", "netbirdmsp-appliance", "-f", f"{SOURCE_DIR}/docker-compose.yml", - "up", "--build", "-d", + "up", "--build", "--no-deps", "-d", + "netbird-msp-appliance", ] log_path = Path(BACKUP_DIR) / "update_rebuild.log" log_file = open(log_path, "w") diff --git a/dockerlogs.txt b/dockerlogs.txt deleted file mode 100644 index 964353c..0000000 --- a/dockerlogs.txt +++ /dev/null @@ -1,50 +0,0 @@ -INFO: 172.18.0.1:33288 - "GET /api/settings/version HTTP/1.1" 200 OK -2026-02-22 13:27:28,812 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK" -2026-02-22 13:27:28,818 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK" -INFO: 172.18.0.1:33288 - "GET /api/settings/version HTTP/1.1" 200 OK -2026-02-22 13:27:29,463 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK" -2026-02-22 13:27:29,473 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK" -INFO: 172.18.0.1:33288 - "GET /api/settings/version HTTP/1.1" 200 OK -2026-02-22 13:27:33,352 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK" -2026-02-22 13:27:33,358 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK" -INFO: 172.18.0.1:33288 - "GET /api/settings/version HTTP/1.1" 200 OK -2026-02-22 13:27:34,899 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK" -2026-02-22 13:27:34,905 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK" -INFO: 172.18.0.1:33288 - "GET /api/settings/version HTTP/1.1" 200 OK -INFO: 172.18.0.1:33288 - "GET /api/settings/system HTTP/1.1" 200 OK -INFO: 172.18.0.1:33288 - "GET /api/auth/mfa/status HTTP/1.1" 200 OK -INFO: 172.18.0.1:33288 - "GET /api/monitoring/resources HTTP/1.1" 200 OK -INFO: 172.18.0.1:38946 - "GET /api/monitoring/customers HTTP/1.1" 200 OK -INFO: 172.18.0.1:38946 - "GET /api/monitoring/customers HTTP/1.1" 200 OK -INFO: 172.18.0.1:33288 - "GET /api/monitoring/resources HTTP/1.1" 200 OK -INFO: 172.18.0.1:33288 - "GET /api/settings/system HTTP/1.1" 200 OK -INFO: 172.18.0.1:38946 - "GET /api/auth/mfa/status HTTP/1.1" 200 OK -2026-02-22 13:27:49,427 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK" -2026-02-22 13:27:49,433 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK" -INFO: 172.18.0.1:33288 - "GET /api/settings/version HTTP/1.1" 200 OK -INFO: 172.18.0.1:33288 - "GET / HTTP/1.1" 200 OK -INFO: 172.18.0.1:38946 - "GET /api/settings/branding HTTP/1.1" 200 OK -INFO: 172.18.0.1:38946 - "GET /api/auth/azure/config HTTP/1.1" 200 OK -INFO: 172.18.0.1:38946 - "GET /api/auth/me HTTP/1.1" 200 OK -INFO: 172.18.0.1:38946 - "GET /api/monitoring/status HTTP/1.1" 200 OK -INFO: 172.18.0.1:45440 - "GET /api/customers?page=1&per_page=25 HTTP/1.1" 200 OK -INFO: 127.0.0.1:35528 - "GET /api/health HTTP/1.1" 200 OK -INFO: 172.18.0.1:33288 - "GET /api/settings/system HTTP/1.1" 200 OK -INFO: 172.18.0.1:38946 - "GET /api/auth/mfa/status HTTP/1.1" 200 OK -2026-02-22 13:27:56,795 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK" -2026-02-22 13:27:56,802 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK" -INFO: 172.18.0.1:33288 - "GET /api/settings/version HTTP/1.1" 200 OK -2026-02-22 13:27:59,507 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK" -2026-02-22 13:27:59,514 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK" -INFO: 172.18.0.1:33288 - "GET /api/settings/version HTTP/1.1" 200 OK -2026-02-22 13:28:09,172 [INFO] app.services.update_service: Database backed up to /app/backups/netbird_msp_20260222_132809.db -2026-02-22 13:28:09,264 [INFO] app.services.update_service: git pull succeeded: Already up to date. -2026-02-22 13:28:09,265 [INFO] app.services.update_service: docker compose up --build -d triggered — container will restart shortly. -2026-02-22 13:28:09,265 [INFO] app.routers.settings: Update triggered by admin. -INFO: 172.18.0.1:57990 - "POST /api/settings/update HTTP/1.1" 200 OK -INFO: 127.0.0.1:51474 - "GET /api/health HTTP/1.1" 200 OK -2026-02-22 13:28:49,056 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK" -2026-02-22 13:28:49,062 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK" -INFO: 172.18.0.1:44506 - "GET /api/settings/version HTTP/1.1" 200 OK -INFO: 127.0.0.1:53966 - "GET /api/health HTTP/1.1" 200 OK -INFO: 127.0.0.1:35452 - "GET /api/health HTTP/1.1" 200 OK From 0fe68cc6df486905bb5a4cfe1958feeae58acee5 Mon Sep 17 00:00:00 2001 From: Sascha Lustenberger Date: Sun, 22 Feb 2026 14:50:00 +0100 Subject: [PATCH 17/44] fix: use helper container for self-update (survives container restart) --- app/services/update_service.py | 113 +++++++++++++++++++++++++++++---- containers.txt | 8 +++ 2 files changed, 107 insertions(+), 14 deletions(-) create mode 100644 containers.txt diff --git a/app/services/update_service.py b/app/services/update_service.py index dbe3bd2..c1fb1e9 100644 --- a/app/services/update_service.py +++ b/app/services/update_service.py @@ -232,25 +232,110 @@ def trigger_update(config: Any, db_path: str) -> dict: build_env.get("GIT_BRANCH", "?"), ) - # 5. Fire-and-forget docker compose rebuild — the container will restart itself - # Use the correct project name so compose finds/replaces the right container. - # Only rebuild the app service — docker-socket-proxy must not be recreated. - compose_cmd = [ + # 5. Two-phase rebuild: Build image first, then swap container. + # The swap will kill this process (we ARE the container), so we must + # ensure the compose-up runs detached on the Docker host via a wrapper. + log_path = Path(BACKUP_DIR) / "update_rebuild.log" + + # Phase A — build the new image (does NOT stop anything) + build_cmd = [ "docker", "compose", "-p", "netbirdmsp-appliance", "-f", f"{SOURCE_DIR}/docker-compose.yml", - "up", "--build", "--no-deps", "-d", + "build", "--no-cache", "netbird-msp-appliance", ] - log_path = Path(BACKUP_DIR) / "update_rebuild.log" - log_file = open(log_path, "w") - subprocess.Popen( - compose_cmd, - stdout=log_file, - stderr=log_file, - env=build_env, - ) - logger.info("docker compose up --build -d triggered — container will restart shortly.") + logger.info("Phase A: building new image …") + try: + build_result = subprocess.run( + build_cmd, + capture_output=True, text=True, + timeout=600, + env=build_env, + ) + with open(log_path, "w") as f: + f.write(build_result.stdout) + f.write(build_result.stderr) + if build_result.returncode != 0: + logger.error("Image build failed: %s", build_result.stderr[:500]) + return { + "ok": False, + "message": f"Image build failed: {build_result.stderr[:300]}", + "backup": backup_path, + } + except subprocess.TimeoutExpired: + return {"ok": False, "message": "Image build timed out after 600s.", "backup": backup_path} + + logger.info("Phase A complete — image built successfully.") + + # Phase B — swap the container using a helper container. + # When compose recreates our container, ALL processes inside die (PID namespace + # is destroyed). So we launch a *separate* helper container via 'docker run -d' + # that has access to the Docker socket and runs 'docker compose up -d'. + # This helper lives outside our container and survives our restart. + + # Discover the host-side path of /app-source (docker volumes use host paths) + try: + inspect_result = subprocess.run( + ["docker", "inspect", "netbird-msp-appliance", + "--format", '{{range .Mounts}}{{if eq .Destination "/app-source"}}{{.Source}}{{end}}{{end}}'], + capture_output=True, text=True, timeout=10, + ) + host_source_dir = inspect_result.stdout.strip() + if not host_source_dir: + raise ValueError("Could not find /app-source mount") + except Exception as exc: + logger.error("Failed to discover host source path: %s", exc) + return {"ok": False, "message": f"Could not find host source path: {exc}", "backup": backup_path} + + logger.info("Host source directory: %s", host_source_dir) + + env_flags = [] + for key in ("GIT_TAG", "GIT_COMMIT", "GIT_BRANCH", "GIT_COMMIT_DATE"): + val = build_env.get(key, "unknown") + env_flags.extend(["-e", f"{key}={val}"]) + + # Use the same image we're already running (it has docker CLI + compose plugin) + own_image = "netbirdmsp-appliance-netbird-msp-appliance:latest" + + helper_cmd = [ + "docker", "run", "--rm", "-d", + "--name", "msp-updater", + "-v", "/var/run/docker.sock:/var/run/docker.sock", + "-v", f"{host_source_dir}:{host_source_dir}:ro", + *env_flags, + own_image, + "sh", "-c", + ( + "sleep 3 && " + "docker compose -p netbirdmsp-appliance " + f"-f {host_source_dir}/docker-compose.yml " + "up --no-deps -d netbird-msp-appliance" + ), + ] + try: + # Remove stale updater container if any + subprocess.run( + ["docker", "rm", "-f", "msp-updater"], + capture_output=True, timeout=10, + ) + result = subprocess.run( + helper_cmd, + capture_output=True, text=True, + timeout=30, + env=build_env, + ) + if result.returncode != 0: + logger.error("Failed to start updater container: %s", result.stderr.strip()) + return { + "ok": False, + "message": f"Update-Container konnte nicht gestartet werden: {result.stderr.strip()[:200]}", + "backup": backup_path, + } + logger.info("Phase B: updater container started — this container will restart in ~5s.") + except Exception as exc: + logger.error("Failed to launch updater: %s", exc) + return {"ok": False, "message": f"Updater launch failed: {exc}", "backup": backup_path} return { "ok": True, diff --git a/containers.txt b/containers.txt new file mode 100644 index 0000000..3aaa05c --- /dev/null +++ b/containers.txt @@ -0,0 +1,8 @@ +f9fa39b8080d_netbird-msp-appliance Created netbirdmsp-appliance-netbird-msp-appliance +netbird-msp-appliance Exited (0) 2 minutes ago 345ba59d123e +netbird-kunde1-caddy Up About an hour caddy:2-alpine +netbird-kunde1-signal Up About an hour netbirdio/signal:latest +netbird-kunde1-dashboard Up About an hour netbirdio/dashboard:latest +netbird-kunde1-relay Up About an hour netbirdio/relay:latest +netbird-kunde1-management Up About an hour netbirdio/management:latest +docker-socket-proxy Up About an hour tecnativa/docker-socket-proxy:latest From 2780b065d2981edd5f2a30cf78770652784ea185 Mon Sep 17 00:00:00 2001 From: Sascha Lustenberger Date: Sun, 22 Feb 2026 15:14:23 +0100 Subject: [PATCH 19/44] fix(updater): add force-recreate and logging to helper container --- app/services/update_service.py | 5 ++-- containers.txt | 8 ------ env.txt | 0 helper.txt | 1 + logs.txt | 50 ++++++++++++++++++++++++++++++++++ port.txt | 2 ++ 6 files changed, 56 insertions(+), 10 deletions(-) delete mode 100644 containers.txt create mode 100644 env.txt create mode 100644 helper.txt create mode 100644 logs.txt create mode 100644 port.txt diff --git a/app/services/update_service.py b/app/services/update_service.py index c1fb1e9..e478273 100644 --- a/app/services/update_service.py +++ b/app/services/update_service.py @@ -299,7 +299,7 @@ def trigger_update(config: Any, db_path: str) -> dict: own_image = "netbirdmsp-appliance-netbird-msp-appliance:latest" helper_cmd = [ - "docker", "run", "--rm", "-d", + "docker", "run", "-d", "--name", "msp-updater", "-v", "/var/run/docker.sock:/var/run/docker.sock", "-v", f"{host_source_dir}:{host_source_dir}:ro", @@ -310,7 +310,8 @@ def trigger_update(config: Any, db_path: str) -> dict: "sleep 3 && " "docker compose -p netbirdmsp-appliance " f"-f {host_source_dir}/docker-compose.yml " - "up --no-deps -d netbird-msp-appliance" + "up --force-recreate --no-deps -d netbird-msp-appliance " + f">> {host_source_dir}/app/backups/updater.log 2>&1" ), ] try: diff --git a/containers.txt b/containers.txt deleted file mode 100644 index 3aaa05c..0000000 --- a/containers.txt +++ /dev/null @@ -1,8 +0,0 @@ -f9fa39b8080d_netbird-msp-appliance Created netbirdmsp-appliance-netbird-msp-appliance -netbird-msp-appliance Exited (0) 2 minutes ago 345ba59d123e -netbird-kunde1-caddy Up About an hour caddy:2-alpine -netbird-kunde1-signal Up About an hour netbirdio/signal:latest -netbird-kunde1-dashboard Up About an hour netbirdio/dashboard:latest -netbird-kunde1-relay Up About an hour netbirdio/relay:latest -netbird-kunde1-management Up About an hour netbirdio/management:latest -docker-socket-proxy Up About an hour tecnativa/docker-socket-proxy:latest diff --git a/env.txt b/env.txt new file mode 100644 index 0000000..e69de29 diff --git a/helper.txt b/helper.txt new file mode 100644 index 0000000..a7601a8 --- /dev/null +++ b/helper.txt @@ -0,0 +1 @@ +Error response from daemon: No such container: msp-updater diff --git a/logs.txt b/logs.txt new file mode 100644 index 0000000..265054f --- /dev/null +++ b/logs.txt @@ -0,0 +1,50 @@ +INFO: 127.0.0.1:35822 - "GET /api/health HTTP/1.1" 200 OK +INFO: 127.0.0.1:33932 - "GET /api/health HTTP/1.1" 200 OK +INFO: 127.0.0.1:50284 - "GET /api/health HTTP/1.1" 200 OK +INFO: 172.18.0.1:49612 - "GET / HTTP/1.1" 200 OK +INFO: 172.18.0.1:49612 - "GET /css/styles.css HTTP/1.1" 304 Not Modified +INFO: 172.18.0.1:49610 - "GET /js/i18n.js HTTP/1.1" 304 Not Modified +INFO: 172.18.0.1:49632 - "GET /js/app.js HTTP/1.1" 200 OK +INFO: 172.18.0.1:49632 - "GET /lang/en.json HTTP/1.1" 200 OK +INFO: 172.18.0.1:49632 - "GET /favicon.ico HTTP/1.1" 404 Not Found +INFO: 172.18.0.1:49610 - "GET /lang/de.json HTTP/1.1" 200 OK +INFO: 172.18.0.1:49610 - "GET /api/settings/branding HTTP/1.1" 200 OK +INFO: 172.18.0.1:49610 - "GET /api/auth/azure/config HTTP/1.1" 200 OK +INFO: 127.0.0.1:59642 - "GET /api/health HTTP/1.1" 200 OK +2026-02-22 13:56:39,498 [WARNING] passlib.handlers.bcrypt: (trapped) error reading bcrypt version +Traceback (most recent call last): + File "/usr/local/lib/python3.11/site-packages/passlib/handlers/bcrypt.py", line 620, in _load_backend_mixin + version = _bcrypt.__about__.__version__ + ^^^^^^^^^^^^^^^^^ +AttributeError: module 'bcrypt' has no attribute '__about__' +2026-02-22 13:56:39,929 [INFO] app.routers.auth: User admin logged in (provider: local). +INFO: 172.18.0.1:36450 - "POST /api/auth/login HTTP/1.1" 200 OK +INFO: 172.18.0.1:36462 - "GET /api/customers?page=1&per_page=25 HTTP/1.1" 200 OK +INFO: 172.18.0.1:36450 - "GET /api/monitoring/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:54154 - "GET /api/health HTTP/1.1" 200 OK +INFO: 172.18.0.1:54490 - "GET /api/settings/system HTTP/1.1" 200 OK +INFO: 172.18.0.1:54490 - "GET /api/auth/mfa/status HTTP/1.1" 200 OK +2026-02-22 13:57:10,815 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK" +2026-02-22 13:57:10,822 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK" +INFO: 172.18.0.1:57512 - "GET /api/settings/version HTTP/1.1" 200 OK +INFO: 127.0.0.1:52478 - "GET /api/health HTTP/1.1" 200 OK +INFO: 127.0.0.1:47310 - "GET /api/health HTTP/1.1" 200 OK +INFO: 127.0.0.1:47530 - "GET /api/health HTTP/1.1" 200 OK +INFO: 127.0.0.1:41918 - "GET /api/health HTTP/1.1" 200 OK +INFO: 127.0.0.1:45108 - "GET /api/health HTTP/1.1" 200 OK +2026-02-22 13:59:53,200 [INFO] app.services.update_service: Database backed up to /app/backups/netbird_msp_20260222_135953.db +2026-02-22 13:59:54,630 [INFO] app.services.update_service: git pull succeeded: Already up to date. +2026-02-22 13:59:54,740 [INFO] app.services.update_service: Rebuilding with GIT_TAG=alpha-1.4 GIT_COMMIT=ef691a4 GIT_BRANCH=unstable +2026-02-22 13:59:54,741 [INFO] app.services.update_service: Phase A: building new image … +2026-02-22 14:03:51,162 [INFO] app.services.update_service: Phase A complete — image built successfully. +2026-02-22 14:03:51,242 [INFO] app.services.update_service: Host source directory: /home/sascha/NetBirdMSP-Appliance +2026-02-22 14:03:52,032 [INFO] app.services.update_service: Phase B: updater container started — this container will restart in ~5s. +2026-02-22 14:03:52,033 [INFO] app.routers.settings: Update triggered by admin. +INFO: 172.18.0.1:53362 - "POST /api/settings/update HTTP/1.1" 200 OK +INFO: 172.18.0.1:35312 - "POST /api/settings/update HTTP/1.1" 401 Unauthorized +INFO: 127.0.0.1:35534 - "GET /api/health HTTP/1.1" 200 OK +2026-02-22 14:04:22,366 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK" +2026-02-22 14:04:22,376 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK" +INFO: 172.18.0.1:53602 - "GET /api/settings/version HTTP/1.1" 200 OK +INFO: 127.0.0.1:51374 - "GET /api/health HTTP/1.1" 200 OK +INFO: 127.0.0.1:48640 - "GET /api/health HTTP/1.1" 200 OK diff --git a/port.txt b/port.txt new file mode 100644 index 0000000..ddb881a --- /dev/null +++ b/port.txt @@ -0,0 +1,2 @@ +8000/tcp -> 0.0.0.0:8000 +8000/tcp -> [::]:8000 From e0aa51bac344efca564ae37f3ad22112152ff118 Mon Sep 17 00:00:00 2001 From: Sascha Lustenberger Date: Sun, 22 Feb 2026 15:22:43 +0100 Subject: [PATCH 21/44] fix(updater): remove log redirection from helper to avoid nonexistent dir error --- app/services/update_service.py | 3 +- containers.txt | 8 +++ logs.txt | 90 +++++++++++++++------------------- out.txt | 10 ++++ update_helper.txt | 1 + 5 files changed, 60 insertions(+), 52 deletions(-) create mode 100644 containers.txt create mode 100644 out.txt create mode 100644 update_helper.txt diff --git a/app/services/update_service.py b/app/services/update_service.py index e478273..ec20a23 100644 --- a/app/services/update_service.py +++ b/app/services/update_service.py @@ -310,8 +310,7 @@ def trigger_update(config: Any, db_path: str) -> dict: "sleep 3 && " "docker compose -p netbirdmsp-appliance " f"-f {host_source_dir}/docker-compose.yml " - "up --force-recreate --no-deps -d netbird-msp-appliance " - f">> {host_source_dir}/app/backups/updater.log 2>&1" + "up --force-recreate --no-deps -d netbird-msp-appliance" ), ] try: diff --git a/containers.txt b/containers.txt new file mode 100644 index 0000000..649e1f2 --- /dev/null +++ b/containers.txt @@ -0,0 +1,8 @@ +msp-updater Exited (2) 11 seconds ago netbirdmsp-appliance-netbird-msp-appliance:latest 15 seconds ago +netbird-msp-appliance Up 6 minutes (healthy) 07c60529cf9f 6 minutes ago +netbird-kunde1-caddy Up 2 hours caddy:2-alpine 3 hours ago +netbird-kunde1-signal Up 2 hours netbirdio/signal:latest 3 hours ago +netbird-kunde1-dashboard Up 2 hours netbirdio/dashboard:latest 3 hours ago +netbird-kunde1-relay Up 2 hours netbirdio/relay:latest 3 hours ago +netbird-kunde1-management Up 2 hours netbirdio/management:latest 3 hours ago +docker-socket-proxy Up 2 hours tecnativa/docker-socket-proxy:latest 3 days ago diff --git a/logs.txt b/logs.txt index 265054f..b8e5738 100644 --- a/logs.txt +++ b/logs.txt @@ -1,50 +1,40 @@ -INFO: 127.0.0.1:35822 - "GET /api/health HTTP/1.1" 200 OK -INFO: 127.0.0.1:33932 - "GET /api/health HTTP/1.1" 200 OK -INFO: 127.0.0.1:50284 - "GET /api/health HTTP/1.1" 200 OK -INFO: 172.18.0.1:49612 - "GET / HTTP/1.1" 200 OK -INFO: 172.18.0.1:49612 - "GET /css/styles.css HTTP/1.1" 304 Not Modified -INFO: 172.18.0.1:49610 - "GET /js/i18n.js HTTP/1.1" 304 Not Modified -INFO: 172.18.0.1:49632 - "GET /js/app.js HTTP/1.1" 200 OK -INFO: 172.18.0.1:49632 - "GET /lang/en.json HTTP/1.1" 200 OK -INFO: 172.18.0.1:49632 - "GET /favicon.ico HTTP/1.1" 404 Not Found -INFO: 172.18.0.1:49610 - "GET /lang/de.json HTTP/1.1" 200 OK -INFO: 172.18.0.1:49610 - "GET /api/settings/branding HTTP/1.1" 200 OK -INFO: 172.18.0.1:49610 - "GET /api/auth/azure/config HTTP/1.1" 200 OK -INFO: 127.0.0.1:59642 - "GET /api/health HTTP/1.1" 200 OK -2026-02-22 13:56:39,498 [WARNING] passlib.handlers.bcrypt: (trapped) error reading bcrypt version -Traceback (most recent call last): - File "/usr/local/lib/python3.11/site-packages/passlib/handlers/bcrypt.py", line 620, in _load_backend_mixin - version = _bcrypt.__about__.__version__ - ^^^^^^^^^^^^^^^^^ -AttributeError: module 'bcrypt' has no attribute '__about__' -2026-02-22 13:56:39,929 [INFO] app.routers.auth: User admin logged in (provider: local). -INFO: 172.18.0.1:36450 - "POST /api/auth/login HTTP/1.1" 200 OK -INFO: 172.18.0.1:36462 - "GET /api/customers?page=1&per_page=25 HTTP/1.1" 200 OK -INFO: 172.18.0.1:36450 - "GET /api/monitoring/status HTTP/1.1" 200 OK -INFO: 127.0.0.1:54154 - "GET /api/health HTTP/1.1" 200 OK -INFO: 172.18.0.1:54490 - "GET /api/settings/system HTTP/1.1" 200 OK -INFO: 172.18.0.1:54490 - "GET /api/auth/mfa/status HTTP/1.1" 200 OK -2026-02-22 13:57:10,815 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK" -2026-02-22 13:57:10,822 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK" -INFO: 172.18.0.1:57512 - "GET /api/settings/version HTTP/1.1" 200 OK -INFO: 127.0.0.1:52478 - "GET /api/health HTTP/1.1" 200 OK -INFO: 127.0.0.1:47310 - "GET /api/health HTTP/1.1" 200 OK -INFO: 127.0.0.1:47530 - "GET /api/health HTTP/1.1" 200 OK -INFO: 127.0.0.1:41918 - "GET /api/health HTTP/1.1" 200 OK -INFO: 127.0.0.1:45108 - "GET /api/health HTTP/1.1" 200 OK -2026-02-22 13:59:53,200 [INFO] app.services.update_service: Database backed up to /app/backups/netbird_msp_20260222_135953.db -2026-02-22 13:59:54,630 [INFO] app.services.update_service: git pull succeeded: Already up to date. -2026-02-22 13:59:54,740 [INFO] app.services.update_service: Rebuilding with GIT_TAG=alpha-1.4 GIT_COMMIT=ef691a4 GIT_BRANCH=unstable -2026-02-22 13:59:54,741 [INFO] app.services.update_service: Phase A: building new image … -2026-02-22 14:03:51,162 [INFO] app.services.update_service: Phase A complete — image built successfully. -2026-02-22 14:03:51,242 [INFO] app.services.update_service: Host source directory: /home/sascha/NetBirdMSP-Appliance -2026-02-22 14:03:52,032 [INFO] app.services.update_service: Phase B: updater container started — this container will restart in ~5s. -2026-02-22 14:03:52,033 [INFO] app.routers.settings: Update triggered by admin. -INFO: 172.18.0.1:53362 - "POST /api/settings/update HTTP/1.1" 200 OK -INFO: 172.18.0.1:35312 - "POST /api/settings/update HTTP/1.1" 401 Unauthorized -INFO: 127.0.0.1:35534 - "GET /api/health HTTP/1.1" 200 OK -2026-02-22 14:04:22,366 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK" -2026-02-22 14:04:22,376 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK" -INFO: 172.18.0.1:53602 - "GET /api/settings/version HTTP/1.1" 200 OK -INFO: 127.0.0.1:51374 - "GET /api/health HTTP/1.1" 200 OK -INFO: 127.0.0.1:48640 - "GET /api/health HTTP/1.1" 200 OK +INFO: Application startup complete. +INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit) +INFO: 127.0.0.1:60204 - "GET /api/health HTTP/1.1" 200 OK +INFO: 127.0.0.1:37702 - "GET /api/health HTTP/1.1" 200 OK +INFO: 127.0.0.1:34872 - "GET /api/health HTTP/1.1" 200 OK +INFO: 127.0.0.1:49808 - "GET /api/health HTTP/1.1" 200 OK +2026-02-22 14:16:10,300 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK" +2026-02-22 14:16:10,306 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK" +INFO: 172.18.0.1:53698 - "GET /api/settings/version HTTP/1.1" 200 OK +2026-02-22 14:16:13,790 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK" +2026-02-22 14:16:13,796 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK" +INFO: 172.18.0.1:53698 - "GET /api/settings/version HTTP/1.1" 200 OK +INFO: 127.0.0.1:55164 - "GET /api/health HTTP/1.1" 200 OK +INFO: 172.18.0.1:55590 - "GET / HTTP/1.1" 200 OK +INFO: 172.18.0.1:55590 - "GET /js/app.js HTTP/1.1" 304 Not Modified +INFO: 172.18.0.1:55590 - "GET /lang/en.json HTTP/1.1" 304 Not Modified +INFO: 172.18.0.1:55590 - "GET /lang/de.json HTTP/1.1" 304 Not Modified +INFO: 172.18.0.1:55590 - "GET /favicon.ico HTTP/1.1" 404 Not Found +INFO: 172.18.0.1:55590 - "GET /api/settings/branding HTTP/1.1" 200 OK +INFO: 172.18.0.1:55590 - "GET /api/auth/azure/config HTTP/1.1" 200 OK +INFO: 172.18.0.1:55590 - "GET /api/auth/me HTTP/1.1" 200 OK +INFO: 172.18.0.1:55590 - "GET /api/monitoring/status HTTP/1.1" 200 OK +INFO: 172.18.0.1:55588 - "GET /api/customers?page=1&per_page=25 HTTP/1.1" 200 OK +INFO: 172.18.0.1:37544 - "GET /api/settings/system HTTP/1.1" 200 OK +INFO: 172.18.0.1:37544 - "GET /api/auth/mfa/status HTTP/1.1" 200 OK +2026-02-22 14:16:58,896 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK" +2026-02-22 14:16:58,906 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK" +INFO: 172.18.0.1:37552 - "GET /api/settings/version HTTP/1.1" 200 OK +INFO: 127.0.0.1:57972 - "GET /api/health HTTP/1.1" 200 OK +INFO: 127.0.0.1:59030 - "GET /api/health HTTP/1.1" 200 OK +2026-02-22 14:17:44,793 [INFO] app.services.update_service: Database backed up to /app/backups/netbird_msp_20260222_141744.db +2026-02-22 14:17:45,142 [INFO] app.services.update_service: git pull succeeded: Already up to date. +2026-02-22 14:17:45,160 [INFO] app.services.update_service: Rebuilding with GIT_TAG=alpha-1.5 GIT_COMMIT=94d0b98 GIT_BRANCH=unstable +2026-02-22 14:17:45,160 [INFO] app.services.update_service: Phase A: building new image … +2026-02-22 14:20:39,486 [INFO] app.services.update_service: Phase A complete — image built successfully. +2026-02-22 14:20:39,507 [INFO] app.services.update_service: Host source directory: /home/sascha/NetBirdMSP-Appliance +2026-02-22 14:20:40,068 [INFO] app.services.update_service: Phase B: updater container started — this container will restart in ~5s. +2026-02-22 14:20:40,069 [INFO] app.routers.settings: Update triggered by admin. +INFO: 172.18.0.1:51826 - "POST /api/settings/update HTTP/1.1" 200 OK +INFO: 127.0.0.1:36054 - "GET /api/health HTTP/1.1" 200 OK diff --git a/out.txt b/out.txt new file mode 100644 index 0000000..4b2633c --- /dev/null +++ b/out.txt @@ -0,0 +1,10 @@ +[unstable 94d0b98] alpha-1.5: trigger update +remote: +remote: Create a new pull request for 'unstable': +remote: https://git.0x26.ch/BurgerGames/NetBirdMSP-Appliance/pulls/new/unstable +remote: +remote: .. Processing 2 references +remote: Processed 2 references in total +To https://git.0x26.ch/BurgerGames/NetBirdMSP-Appliance.git + 2780b06..94d0b98 unstable -> unstable + * [new tag] alpha-1.5 -> alpha-1.5 diff --git a/update_helper.txt b/update_helper.txt new file mode 100644 index 0000000..cd47719 --- /dev/null +++ b/update_helper.txt @@ -0,0 +1 @@ +sh: 1: cannot create /home/sascha/NetBirdMSP-Appliance/app/backups/updater.log: Directory nonexistent From 525b056b91da33b65be690d1577fd956025c6f98 Mon Sep 17 00:00:00 2001 From: Sascha Lustenberger Date: Sun, 22 Feb 2026 15:33:42 +0100 Subject: [PATCH 23/44] fix(updater): add :z flag to docker volumes for SELinux --- app/services/update_service.py | 4 +- containers.txt | 17 +++++---- logs.txt | 70 +++++++++++++++------------------- network.txt | 0 out.txt | 6 +-- update_helper.txt | 2 +- 6 files changed, 45 insertions(+), 54 deletions(-) create mode 100644 network.txt diff --git a/app/services/update_service.py b/app/services/update_service.py index ec20a23..b363e70 100644 --- a/app/services/update_service.py +++ b/app/services/update_service.py @@ -301,8 +301,8 @@ def trigger_update(config: Any, db_path: str) -> dict: helper_cmd = [ "docker", "run", "-d", "--name", "msp-updater", - "-v", "/var/run/docker.sock:/var/run/docker.sock", - "-v", f"{host_source_dir}:{host_source_dir}:ro", + "-v", "/var/run/docker.sock:/var/run/docker.sock:z", + "-v", f"{host_source_dir}:{host_source_dir}:ro,z", *env_flags, own_image, "sh", "-c", diff --git a/containers.txt b/containers.txt index 649e1f2..09ed1a2 100644 --- a/containers.txt +++ b/containers.txt @@ -1,8 +1,9 @@ -msp-updater Exited (2) 11 seconds ago netbirdmsp-appliance-netbird-msp-appliance:latest 15 seconds ago -netbird-msp-appliance Up 6 minutes (healthy) 07c60529cf9f 6 minutes ago -netbird-kunde1-caddy Up 2 hours caddy:2-alpine 3 hours ago -netbird-kunde1-signal Up 2 hours netbirdio/signal:latest 3 hours ago -netbird-kunde1-dashboard Up 2 hours netbirdio/dashboard:latest 3 hours ago -netbird-kunde1-relay Up 2 hours netbirdio/relay:latest 3 hours ago -netbird-kunde1-management Up 2 hours netbirdio/management:latest 3 hours ago -docker-socket-proxy Up 2 hours tecnativa/docker-socket-proxy:latest 3 days ago +CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES +6ac6e489f490 netbirdmsp-appliance-netbird-msp-appliance:latest "sh -c 'sleep 3 && d…" About a minute ago Exited (1) About a minute ago msp-updater +45635ac38499 669dad48d4d2 "uvicorn app.main:ap…" 8 minutes ago Up 8 minutes (healthy) 0.0.0.0:8000->8000/tcp, [::]:8000->8000/tcp netbird-msp-appliance +878efa979680 caddy:2-alpine "caddy run --config …" 3 hours ago Up 2 hours 443/tcp, 2019/tcp, 443/udp, 0.0.0.0:9001->80/tcp, [::]:9001->80/tcp netbird-kunde1-caddy +564c613f112a netbirdio/signal:latest "/go/bin/netbird-sig…" 3 hours ago Up 2 hours netbird-kunde1-signal +a98852970815 netbirdio/dashboard:latest "/usr/bin/supervisor…" 3 hours ago Up 2 hours 80/tcp, 443/tcp netbird-kunde1-dashboard +11e100e21d81 netbirdio/relay:latest "/go/bin/netbird-rel…" 3 hours ago Up 2 hours 0.0.0.0:3478->3478/udp, [::]:3478->3478/udp netbird-kunde1-relay +aeae96bf691e netbirdio/management:latest "/go/bin/netbird-mgm…" 3 hours ago Up 2 hours netbird-kunde1-management +9cdda4d58e36 tecnativa/docker-socket-proxy:latest "docker-entrypoint.s…" 3 days ago Up 2 hours 2375/tcp docker-socket-proxy diff --git a/logs.txt b/logs.txt index b8e5738..b526dd4 100644 --- a/logs.txt +++ b/logs.txt @@ -1,40 +1,30 @@ -INFO: Application startup complete. -INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit) -INFO: 127.0.0.1:60204 - "GET /api/health HTTP/1.1" 200 OK -INFO: 127.0.0.1:37702 - "GET /api/health HTTP/1.1" 200 OK -INFO: 127.0.0.1:34872 - "GET /api/health HTTP/1.1" 200 OK -INFO: 127.0.0.1:49808 - "GET /api/health HTTP/1.1" 200 OK -2026-02-22 14:16:10,300 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK" -2026-02-22 14:16:10,306 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK" -INFO: 172.18.0.1:53698 - "GET /api/settings/version HTTP/1.1" 200 OK -2026-02-22 14:16:13,790 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK" -2026-02-22 14:16:13,796 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK" -INFO: 172.18.0.1:53698 - "GET /api/settings/version HTTP/1.1" 200 OK -INFO: 127.0.0.1:55164 - "GET /api/health HTTP/1.1" 200 OK -INFO: 172.18.0.1:55590 - "GET / HTTP/1.1" 200 OK -INFO: 172.18.0.1:55590 - "GET /js/app.js HTTP/1.1" 304 Not Modified -INFO: 172.18.0.1:55590 - "GET /lang/en.json HTTP/1.1" 304 Not Modified -INFO: 172.18.0.1:55590 - "GET /lang/de.json HTTP/1.1" 304 Not Modified -INFO: 172.18.0.1:55590 - "GET /favicon.ico HTTP/1.1" 404 Not Found -INFO: 172.18.0.1:55590 - "GET /api/settings/branding HTTP/1.1" 200 OK -INFO: 172.18.0.1:55590 - "GET /api/auth/azure/config HTTP/1.1" 200 OK -INFO: 172.18.0.1:55590 - "GET /api/auth/me HTTP/1.1" 200 OK -INFO: 172.18.0.1:55590 - "GET /api/monitoring/status HTTP/1.1" 200 OK -INFO: 172.18.0.1:55588 - "GET /api/customers?page=1&per_page=25 HTTP/1.1" 200 OK -INFO: 172.18.0.1:37544 - "GET /api/settings/system HTTP/1.1" 200 OK -INFO: 172.18.0.1:37544 - "GET /api/auth/mfa/status HTTP/1.1" 200 OK -2026-02-22 14:16:58,896 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK" -2026-02-22 14:16:58,906 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK" -INFO: 172.18.0.1:37552 - "GET /api/settings/version HTTP/1.1" 200 OK -INFO: 127.0.0.1:57972 - "GET /api/health HTTP/1.1" 200 OK -INFO: 127.0.0.1:59030 - "GET /api/health HTTP/1.1" 200 OK -2026-02-22 14:17:44,793 [INFO] app.services.update_service: Database backed up to /app/backups/netbird_msp_20260222_141744.db -2026-02-22 14:17:45,142 [INFO] app.services.update_service: git pull succeeded: Already up to date. -2026-02-22 14:17:45,160 [INFO] app.services.update_service: Rebuilding with GIT_TAG=alpha-1.5 GIT_COMMIT=94d0b98 GIT_BRANCH=unstable -2026-02-22 14:17:45,160 [INFO] app.services.update_service: Phase A: building new image … -2026-02-22 14:20:39,486 [INFO] app.services.update_service: Phase A complete — image built successfully. -2026-02-22 14:20:39,507 [INFO] app.services.update_service: Host source directory: /home/sascha/NetBirdMSP-Appliance -2026-02-22 14:20:40,068 [INFO] app.services.update_service: Phase B: updater container started — this container will restart in ~5s. -2026-02-22 14:20:40,069 [INFO] app.routers.settings: Update triggered by admin. -INFO: 172.18.0.1:51826 - "POST /api/settings/update HTTP/1.1" 200 OK -INFO: 127.0.0.1:36054 - "GET /api/health HTTP/1.1" 200 OK +INFO: 172.18.0.1:54920 - "GET /api/customers?page=1&per_page=25 HTTP/1.1" 200 OK +INFO: 172.18.0.1:38426 - "GET /api/settings/system HTTP/1.1" 200 OK +INFO: 172.18.0.1:38426 - "GET /api/auth/mfa/status HTTP/1.1" 200 OK +2026-02-22 14:26:24,600 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK" +2026-02-22 14:26:24,610 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK" +INFO: 172.18.0.1:53830 - "GET /api/settings/version HTTP/1.1" 200 OK +INFO: 127.0.0.1:46712 - "GET /api/health HTTP/1.1" 200 OK +2026-02-22 14:26:51,522 [INFO] app.services.update_service: Database backed up to /app/backups/netbird_msp_20260222_142651.db +2026-02-22 14:26:51,823 [INFO] app.services.update_service: git pull succeeded: Already up to date. +2026-02-22 14:26:51,846 [INFO] app.services.update_service: Rebuilding with GIT_TAG=alpha-1.6 GIT_COMMIT=6bc11d4 GIT_BRANCH=unstable +2026-02-22 14:26:51,847 [INFO] app.services.update_service: Phase A: building new image … +2026-02-22 14:29:45,287 [INFO] app.services.update_service: Phase A complete — image built successfully. +2026-02-22 14:29:45,305 [INFO] app.services.update_service: Host source directory: /home/sascha/NetBirdMSP-Appliance +2026-02-22 14:29:46,017 [INFO] app.services.update_service: Phase B: updater container started — this container will restart in ~5s. +2026-02-22 14:29:46,017 [INFO] app.routers.settings: Update triggered by admin. +INFO: 127.0.0.1:34660 - "GET /api/health HTTP/1.1" 200 OK +INFO: 172.18.0.1:41348 - "GET /api/monitoring/status HTTP/1.1" 200 OK +INFO: 172.18.0.1:41362 - "GET /api/customers?page=1&per_page=25 HTTP/1.1" 200 OK +2026-02-22 14:29:46,083 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK" +2026-02-22 14:29:46,090 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK" +INFO: 172.18.0.1:41362 - "GET /api/settings/system HTTP/1.1" 200 OK +INFO: 172.18.0.1:41362 - "GET /api/auth/mfa/status HTTP/1.1" 200 OK +2026-02-22 14:29:51,064 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK" +2026-02-22 14:29:51,071 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK" +INFO: 172.18.0.1:41362 - "GET /api/settings/version HTTP/1.1" 200 OK +INFO: 127.0.0.1:39688 - "GET /api/health HTTP/1.1" 200 OK +2026-02-22 14:30:21,600 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK" +2026-02-22 14:30:21,606 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK" +INFO: 172.18.0.1:34698 - "GET /api/settings/version HTTP/1.1" 200 OK +INFO: 127.0.0.1:48454 - "GET /api/health HTTP/1.1" 200 OK diff --git a/network.txt b/network.txt new file mode 100644 index 0000000..e69de29 diff --git a/out.txt b/out.txt index 4b2633c..55d1d1d 100644 --- a/out.txt +++ b/out.txt @@ -1,4 +1,4 @@ -[unstable 94d0b98] alpha-1.5: trigger update +[unstable 6bc11d4] alpha-1.6: test final update remote: remote: Create a new pull request for 'unstable': remote: https://git.0x26.ch/BurgerGames/NetBirdMSP-Appliance/pulls/new/unstable @@ -6,5 +6,5 @@ remote: remote: .. Processing 2 references remote: Processed 2 references in total To https://git.0x26.ch/BurgerGames/NetBirdMSP-Appliance.git - 2780b06..94d0b98 unstable -> unstable - * [new tag] alpha-1.5 -> alpha-1.5 + e0aa51b..6bc11d4 unstable -> unstable + * [new tag] alpha-1.6 -> alpha-1.6 diff --git a/update_helper.txt b/update_helper.txt index cd47719..6133f48 100644 --- a/update_helper.txt +++ b/update_helper.txt @@ -1 +1 @@ -sh: 1: cannot create /home/sascha/NetBirdMSP-Appliance/app/backups/updater.log: Directory nonexistent +unable to get image 'netbirdmsp-appliance-netbird-msp-appliance': permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock: Get "http://%2Fvar%2Frun%2Fdocker.sock/v1.51/images/netbirdmsp-appliance-netbird-msp-appliance/json": dial unix /var/run/docker.sock: connect: permission denied From 95ec6765c1f2fd41a01360bb056b4be7c0282b80 Mon Sep 17 00:00:00 2001 From: Sascha Lustenberger Date: Sun, 22 Feb 2026 15:46:09 +0100 Subject: [PATCH 25/44] fix(updater): add --privileged to helper container to bypass user namespace restrictions --- app/services/update_service.py | 2 +- containers.txt | 18 +++++----- logs.txt | 60 +++++++++++++++++----------------- out.txt | 6 ++-- 4 files changed, 43 insertions(+), 43 deletions(-) diff --git a/app/services/update_service.py b/app/services/update_service.py index b363e70..cf762b6 100644 --- a/app/services/update_service.py +++ b/app/services/update_service.py @@ -299,7 +299,7 @@ def trigger_update(config: Any, db_path: str) -> dict: own_image = "netbirdmsp-appliance-netbird-msp-appliance:latest" helper_cmd = [ - "docker", "run", "-d", + "docker", "run", "-d", "--privileged", "--name", "msp-updater", "-v", "/var/run/docker.sock:/var/run/docker.sock:z", "-v", f"{host_source_dir}:{host_source_dir}:ro,z", diff --git a/containers.txt b/containers.txt index 09ed1a2..fcfd817 100644 --- a/containers.txt +++ b/containers.txt @@ -1,9 +1,9 @@ -CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES -6ac6e489f490 netbirdmsp-appliance-netbird-msp-appliance:latest "sh -c 'sleep 3 && d…" About a minute ago Exited (1) About a minute ago msp-updater -45635ac38499 669dad48d4d2 "uvicorn app.main:ap…" 8 minutes ago Up 8 minutes (healthy) 0.0.0.0:8000->8000/tcp, [::]:8000->8000/tcp netbird-msp-appliance -878efa979680 caddy:2-alpine "caddy run --config …" 3 hours ago Up 2 hours 443/tcp, 2019/tcp, 443/udp, 0.0.0.0:9001->80/tcp, [::]:9001->80/tcp netbird-kunde1-caddy -564c613f112a netbirdio/signal:latest "/go/bin/netbird-sig…" 3 hours ago Up 2 hours netbird-kunde1-signal -a98852970815 netbirdio/dashboard:latest "/usr/bin/supervisor…" 3 hours ago Up 2 hours 80/tcp, 443/tcp netbird-kunde1-dashboard -11e100e21d81 netbirdio/relay:latest "/go/bin/netbird-rel…" 3 hours ago Up 2 hours 0.0.0.0:3478->3478/udp, [::]:3478->3478/udp netbird-kunde1-relay -aeae96bf691e netbirdio/management:latest "/go/bin/netbird-mgm…" 3 hours ago Up 2 hours netbird-kunde1-management -9cdda4d58e36 tecnativa/docker-socket-proxy:latest "docker-entrypoint.s…" 3 days ago Up 2 hours 2375/tcp docker-socket-proxy +CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES +b25f16030139 netbirdmsp-appliance-netbird-msp-appliance:latest "sh -c 'sleep 3 && d…" 2 minutes ago Exited (1) 2 minutes ago msp-updater +c7acab75017f f4446ac34896 "uvicorn app.main:ap…" 11 minutes ago Up 11 minutes (healthy) 0.0.0.0:8000->8000/tcp, [::]:8000->8000/tcp netbird-msp-appliance +878efa979680 caddy:2-alpine "caddy run --config …" 3 hours ago Up 2 hours 443/tcp, 2019/tcp, 443/udp, 0.0.0.0:9001->80/tcp, [::]:9001->80/tcp netbird-kunde1-caddy +564c613f112a netbirdio/signal:latest "/go/bin/netbird-sig…" 3 hours ago Up 2 hours netbird-kunde1-signal +a98852970815 netbirdio/dashboard:latest "/usr/bin/supervisor…" 3 hours ago Up 2 hours 80/tcp, 443/tcp netbird-kunde1-dashboard +11e100e21d81 netbirdio/relay:latest "/go/bin/netbird-rel…" 3 hours ago Up 2 hours 0.0.0.0:3478->3478/udp, [::]:3478->3478/udp netbird-kunde1-relay +aeae96bf691e netbirdio/management:latest "/go/bin/netbird-mgm…" 3 hours ago Up 2 hours netbird-kunde1-management +9cdda4d58e36 tecnativa/docker-socket-proxy:latest "docker-entrypoint.s…" 3 days ago Up 2 hours 2375/tcp docker-socket-proxy diff --git a/logs.txt b/logs.txt index b526dd4..c9816e5 100644 --- a/logs.txt +++ b/logs.txt @@ -1,30 +1,30 @@ -INFO: 172.18.0.1:54920 - "GET /api/customers?page=1&per_page=25 HTTP/1.1" 200 OK -INFO: 172.18.0.1:38426 - "GET /api/settings/system HTTP/1.1" 200 OK -INFO: 172.18.0.1:38426 - "GET /api/auth/mfa/status HTTP/1.1" 200 OK -2026-02-22 14:26:24,600 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK" -2026-02-22 14:26:24,610 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK" -INFO: 172.18.0.1:53830 - "GET /api/settings/version HTTP/1.1" 200 OK -INFO: 127.0.0.1:46712 - "GET /api/health HTTP/1.1" 200 OK -2026-02-22 14:26:51,522 [INFO] app.services.update_service: Database backed up to /app/backups/netbird_msp_20260222_142651.db -2026-02-22 14:26:51,823 [INFO] app.services.update_service: git pull succeeded: Already up to date. -2026-02-22 14:26:51,846 [INFO] app.services.update_service: Rebuilding with GIT_TAG=alpha-1.6 GIT_COMMIT=6bc11d4 GIT_BRANCH=unstable -2026-02-22 14:26:51,847 [INFO] app.services.update_service: Phase A: building new image … -2026-02-22 14:29:45,287 [INFO] app.services.update_service: Phase A complete — image built successfully. -2026-02-22 14:29:45,305 [INFO] app.services.update_service: Host source directory: /home/sascha/NetBirdMSP-Appliance -2026-02-22 14:29:46,017 [INFO] app.services.update_service: Phase B: updater container started — this container will restart in ~5s. -2026-02-22 14:29:46,017 [INFO] app.routers.settings: Update triggered by admin. -INFO: 127.0.0.1:34660 - "GET /api/health HTTP/1.1" 200 OK -INFO: 172.18.0.1:41348 - "GET /api/monitoring/status HTTP/1.1" 200 OK -INFO: 172.18.0.1:41362 - "GET /api/customers?page=1&per_page=25 HTTP/1.1" 200 OK -2026-02-22 14:29:46,083 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK" -2026-02-22 14:29:46,090 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK" -INFO: 172.18.0.1:41362 - "GET /api/settings/system HTTP/1.1" 200 OK -INFO: 172.18.0.1:41362 - "GET /api/auth/mfa/status HTTP/1.1" 200 OK -2026-02-22 14:29:51,064 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK" -2026-02-22 14:29:51,071 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK" -INFO: 172.18.0.1:41362 - "GET /api/settings/version HTTP/1.1" 200 OK -INFO: 127.0.0.1:39688 - "GET /api/health HTTP/1.1" 200 OK -2026-02-22 14:30:21,600 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK" -2026-02-22 14:30:21,606 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK" -INFO: 172.18.0.1:34698 - "GET /api/settings/version HTTP/1.1" 200 OK -INFO: 127.0.0.1:48454 - "GET /api/health HTTP/1.1" 200 OK +INFO: 172.18.0.1:34414 - "GET /lang/de.json HTTP/1.1" 304 Not Modified +INFO: 172.18.0.1:34414 - "GET /favicon.ico HTTP/1.1" 404 Not Found +INFO: 172.18.0.1:34424 - "GET /api/settings/branding HTTP/1.1" 200 OK +INFO: 172.18.0.1:34424 - "GET /api/auth/azure/config HTTP/1.1" 200 OK +INFO: 172.18.0.1:34424 - "GET /api/auth/me HTTP/1.1" 200 OK +INFO: 172.18.0.1:34424 - "GET /api/monitoring/status HTTP/1.1" 200 OK +INFO: 172.18.0.1:34414 - "GET /api/customers?page=1&per_page=25 HTTP/1.1" 200 OK +INFO: 127.0.0.1:34422 - "GET /api/health HTTP/1.1" 200 OK +INFO: 172.18.0.1:34042 - "GET /api/settings/system HTTP/1.1" 200 OK +INFO: 172.18.0.1:34042 - "GET /api/auth/mfa/status HTTP/1.1" 200 OK +2026-02-22 14:40:01,292 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK" +2026-02-22 14:40:01,301 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK" +INFO: 172.18.0.1:49812 - "GET /api/settings/version HTTP/1.1" 200 OK +INFO: 127.0.0.1:54492 - "GET /api/health HTTP/1.1" 200 OK +INFO: 127.0.0.1:36052 - "GET /api/health HTTP/1.1" 200 OK +2026-02-22 14:40:57,656 [INFO] app.services.update_service: Database backed up to /app/backups/netbird_msp_20260222_144057.db +2026-02-22 14:40:57,971 [INFO] app.services.update_service: git pull succeeded: Already up to date. +2026-02-22 14:40:57,988 [INFO] app.services.update_service: Rebuilding with GIT_TAG=alpha-1.7 GIT_COMMIT=c40b7d3 GIT_BRANCH=unstable +2026-02-22 14:40:57,988 [INFO] app.services.update_service: Phase A: building new image … +2026-02-22 14:42:44,434 [INFO] app.services.update_service: Phase A complete — image built successfully. +2026-02-22 14:42:44,461 [INFO] app.services.update_service: Host source directory: /home/sascha/NetBirdMSP-Appliance +2026-02-22 14:42:44,973 [INFO] app.services.update_service: Phase B: updater container started — this container will restart in ~5s. +2026-02-22 14:42:44,973 [INFO] app.routers.settings: Update triggered by admin. +INFO: 172.18.0.1:46292 - "POST /api/settings/update HTTP/1.1" 200 OK +INFO: 127.0.0.1:54584 - "GET /api/health HTTP/1.1" 200 OK +INFO: 127.0.0.1:33600 - "GET /api/health HTTP/1.1" 200 OK +INFO: 127.0.0.1:35272 - "GET /api/health HTTP/1.1" 200 OK +INFO: 127.0.0.1:44226 - "GET /api/health HTTP/1.1" 200 OK +INFO: 127.0.0.1:48574 - "GET /api/health HTTP/1.1" 200 OK +INFO: 127.0.0.1:53686 - "GET /api/health HTTP/1.1" 200 OK diff --git a/out.txt b/out.txt index 55d1d1d..6f81c95 100644 --- a/out.txt +++ b/out.txt @@ -1,4 +1,4 @@ -[unstable 6bc11d4] alpha-1.6: test final update +[unstable c40b7d3] alpha-1.7: final test remote: remote: Create a new pull request for 'unstable': remote: https://git.0x26.ch/BurgerGames/NetBirdMSP-Appliance/pulls/new/unstable @@ -6,5 +6,5 @@ remote: remote: .. Processing 2 references remote: Processed 2 references in total To https://git.0x26.ch/BurgerGames/NetBirdMSP-Appliance.git - e0aa51b..6bc11d4 unstable -> unstable - * [new tag] alpha-1.6 -> alpha-1.6 + 525b056..c40b7d3 unstable -> unstable + * [new tag] alpha-1.7 -> alpha-1.7 From 3f177a699338ed19345934a84c612b20b2c20c6f Mon Sep 17 00:00:00 2001 From: Sascha Lustenberger Date: Sun, 22 Feb 2026 15:58:18 +0100 Subject: [PATCH 27/44] fix(updater): add --rm to helper container to remove it after use --- app/services/update_service.py | 2 +- containers.txt | 18 ++++++++-------- logs.txt | 39 ++++++++-------------------------- out.txt | 11 +--------- update_helper.txt | 5 ++++- 5 files changed, 24 insertions(+), 51 deletions(-) diff --git a/app/services/update_service.py b/app/services/update_service.py index cf762b6..65f38ea 100644 --- a/app/services/update_service.py +++ b/app/services/update_service.py @@ -299,7 +299,7 @@ def trigger_update(config: Any, db_path: str) -> dict: own_image = "netbirdmsp-appliance-netbird-msp-appliance:latest" helper_cmd = [ - "docker", "run", "-d", "--privileged", + "docker", "run", "--rm", "-d", "--privileged", "--name", "msp-updater", "-v", "/var/run/docker.sock:/var/run/docker.sock:z", "-v", f"{host_source_dir}:{host_source_dir}:ro,z", diff --git a/containers.txt b/containers.txt index fcfd817..d1222b7 100644 --- a/containers.txt +++ b/containers.txt @@ -1,9 +1,9 @@ -CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES -b25f16030139 netbirdmsp-appliance-netbird-msp-appliance:latest "sh -c 'sleep 3 && d…" 2 minutes ago Exited (1) 2 minutes ago msp-updater -c7acab75017f f4446ac34896 "uvicorn app.main:ap…" 11 minutes ago Up 11 minutes (healthy) 0.0.0.0:8000->8000/tcp, [::]:8000->8000/tcp netbird-msp-appliance -878efa979680 caddy:2-alpine "caddy run --config …" 3 hours ago Up 2 hours 443/tcp, 2019/tcp, 443/udp, 0.0.0.0:9001->80/tcp, [::]:9001->80/tcp netbird-kunde1-caddy -564c613f112a netbirdio/signal:latest "/go/bin/netbird-sig…" 3 hours ago Up 2 hours netbird-kunde1-signal -a98852970815 netbirdio/dashboard:latest "/usr/bin/supervisor…" 3 hours ago Up 2 hours 80/tcp, 443/tcp netbird-kunde1-dashboard -11e100e21d81 netbirdio/relay:latest "/go/bin/netbird-rel…" 3 hours ago Up 2 hours 0.0.0.0:3478->3478/udp, [::]:3478->3478/udp netbird-kunde1-relay -aeae96bf691e netbirdio/management:latest "/go/bin/netbird-mgm…" 3 hours ago Up 2 hours netbird-kunde1-management -9cdda4d58e36 tecnativa/docker-socket-proxy:latest "docker-entrypoint.s…" 3 days ago Up 2 hours 2375/tcp docker-socket-proxy +NAMES STATUS IMAGE +netbird-msp-appliance Up 3 minutes (healthy) netbirdmsp-appliance-netbird-msp-appliance +msp-updater Exited (0) 3 minutes ago netbirdmsp-appliance-netbird-msp-appliance:latest +netbird-kunde1-caddy Up 2 hours caddy:2-alpine +netbird-kunde1-signal Up 2 hours netbirdio/signal:latest +netbird-kunde1-dashboard Up 2 hours netbirdio/dashboard:latest +netbird-kunde1-relay Up 2 hours netbirdio/relay:latest +netbird-kunde1-management Up 2 hours netbirdio/management:latest +docker-socket-proxy Up 2 hours tecnativa/docker-socket-proxy:latest diff --git a/logs.txt b/logs.txt index c9816e5..ce1de1b 100644 --- a/logs.txt +++ b/logs.txt @@ -1,30 +1,9 @@ -INFO: 172.18.0.1:34414 - "GET /lang/de.json HTTP/1.1" 304 Not Modified -INFO: 172.18.0.1:34414 - "GET /favicon.ico HTTP/1.1" 404 Not Found -INFO: 172.18.0.1:34424 - "GET /api/settings/branding HTTP/1.1" 200 OK -INFO: 172.18.0.1:34424 - "GET /api/auth/azure/config HTTP/1.1" 200 OK -INFO: 172.18.0.1:34424 - "GET /api/auth/me HTTP/1.1" 200 OK -INFO: 172.18.0.1:34424 - "GET /api/monitoring/status HTTP/1.1" 200 OK -INFO: 172.18.0.1:34414 - "GET /api/customers?page=1&per_page=25 HTTP/1.1" 200 OK -INFO: 127.0.0.1:34422 - "GET /api/health HTTP/1.1" 200 OK -INFO: 172.18.0.1:34042 - "GET /api/settings/system HTTP/1.1" 200 OK -INFO: 172.18.0.1:34042 - "GET /api/auth/mfa/status HTTP/1.1" 200 OK -2026-02-22 14:40:01,292 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK" -2026-02-22 14:40:01,301 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK" -INFO: 172.18.0.1:49812 - "GET /api/settings/version HTTP/1.1" 200 OK -INFO: 127.0.0.1:54492 - "GET /api/health HTTP/1.1" 200 OK -INFO: 127.0.0.1:36052 - "GET /api/health HTTP/1.1" 200 OK -2026-02-22 14:40:57,656 [INFO] app.services.update_service: Database backed up to /app/backups/netbird_msp_20260222_144057.db -2026-02-22 14:40:57,971 [INFO] app.services.update_service: git pull succeeded: Already up to date. -2026-02-22 14:40:57,988 [INFO] app.services.update_service: Rebuilding with GIT_TAG=alpha-1.7 GIT_COMMIT=c40b7d3 GIT_BRANCH=unstable -2026-02-22 14:40:57,988 [INFO] app.services.update_service: Phase A: building new image … -2026-02-22 14:42:44,434 [INFO] app.services.update_service: Phase A complete — image built successfully. -2026-02-22 14:42:44,461 [INFO] app.services.update_service: Host source directory: /home/sascha/NetBirdMSP-Appliance -2026-02-22 14:42:44,973 [INFO] app.services.update_service: Phase B: updater container started — this container will restart in ~5s. -2026-02-22 14:42:44,973 [INFO] app.routers.settings: Update triggered by admin. -INFO: 172.18.0.1:46292 - "POST /api/settings/update HTTP/1.1" 200 OK -INFO: 127.0.0.1:54584 - "GET /api/health HTTP/1.1" 200 OK -INFO: 127.0.0.1:33600 - "GET /api/health HTTP/1.1" 200 OK -INFO: 127.0.0.1:35272 - "GET /api/health HTTP/1.1" 200 OK -INFO: 127.0.0.1:44226 - "GET /api/health HTTP/1.1" 200 OK -INFO: 127.0.0.1:48574 - "GET /api/health HTTP/1.1" 200 OK -INFO: 127.0.0.1:53686 - "GET /api/health HTTP/1.1" 200 OK +INFO: Started server process [1] +INFO: Waiting for application startup. +2026-02-22 14:53:59,694 [INFO] app.main: Starting NetBird MSP Appliance... +2026-02-22 14:53:59,744 [INFO] app.main: Database initialized. +INFO: Application startup complete. +INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit) +INFO: 127.0.0.1:45370 - "GET /api/health HTTP/1.1" 200 OK +INFO: 127.0.0.1:57724 - "GET /api/health HTTP/1.1" 200 OK +INFO: 127.0.0.1:56212 - "GET /api/health HTTP/1.1" 200 OK diff --git a/out.txt b/out.txt index 6f81c95..71b00a0 100644 --- a/out.txt +++ b/out.txt @@ -1,10 +1 @@ -[unstable c40b7d3] alpha-1.7: final test -remote: -remote: Create a new pull request for 'unstable': -remote: https://git.0x26.ch/BurgerGames/NetBirdMSP-Appliance/pulls/new/unstable -remote: -remote: .. Processing 2 references -remote: Processed 2 references in total -To https://git.0x26.ch/BurgerGames/NetBirdMSP-Appliance.git - 525b056..c40b7d3 unstable -> unstable - * [new tag] alpha-1.7 -> alpha-1.7 +msp-updater diff --git a/update_helper.txt b/update_helper.txt index 6133f48..e19ce6e 100644 --- a/update_helper.txt +++ b/update_helper.txt @@ -1 +1,4 @@ -unable to get image 'netbirdmsp-appliance-netbird-msp-appliance': permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock: Get "http://%2Fvar%2Frun%2Fdocker.sock/v1.51/images/netbirdmsp-appliance-netbird-msp-appliance/json": dial unix /var/run/docker.sock: connect: permission denied + Container netbird-msp-appliance Recreate + Container netbird-msp-appliance Recreated + Container netbird-msp-appliance Starting + Container netbird-msp-appliance Started From 831564762b111ba9b7fc9bda886f0c4b31b1eb8e Mon Sep 17 00:00:00 2001 From: Sascha Lustenberger Date: Sun, 22 Feb 2026 16:07:08 +0100 Subject: [PATCH 28/44] feat(ui): clean vertical settings menu and improved version formatting --- static/index.html | 1390 ++++++++++++++++++++++++++++----------------- static/js/app.js | 28 +- 2 files changed, 871 insertions(+), 547 deletions(-) diff --git a/static/index.html b/static/index.html index cbdc1d2..8fdd1d7 100644 --- a/static/index.html +++ b/static/index.html @@ -1,5 +1,6 @@ + @@ -8,6 +9,7 @@ +
@@ -17,8 +19,10 @@

NetBird MSP Appliance

-

Multi-Tenant Management Platform

-

alpha-1.1

+

Multi-Tenant Management + Platform

+

alpha-1.1 +

@@ -37,30 +41,37 @@

-
-

Enter your 6-digit authenticator code

+

Enter your 6-digit + authenticator code

+ maxlength="6" pattern="[0-9]{6}" inputmode="numeric" autocomplete="one-time-code" + required autofocus>
- Back to login + Back to login
-

Scan this QR code with your authenticator app

+

Scan this QR code with your + authenticator app

TOTP QR Code
@@ -70,13 +81,16 @@

+ maxlength="6" pattern="[0-9]{6}" inputmode="numeric" autocomplete="one-time-code" + required>
- Back to login + Back to login
@@ -88,17 +102,21 @@
@@ -145,7 +173,8 @@
Active
0
-
+
@@ -158,7 +187,8 @@
Inactive
0
-
+
@@ -171,7 +201,8 @@
Errors
0
-
+
@@ -183,7 +214,9 @@
- +
- +
@@ -218,13 +253,17 @@ - Loading... + + + Loading... + @@ -299,500 +352,753 @@

- + System Settings

- +
+ +
+
- -
-
-
-
-

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
-
-
- - -
-
-
- -
- - + +
+
+ +
+
+
+ +
+
+ + +
Customers + get subdomains: customer.yourdomain.com
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
First + UDP port for relay. Range: base to base+99
+
+
+ + +
+ Base port for customer dashboards. Customer N gets base+N
+
-
- -
-
-
SSL Certificate Mode
-
-
- - -
Choose how SSL certificates are assigned to customer proxy hosts.
-
- +
+
+ + +
+
+
+
+

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
+
+
+ + +
+
+
+ +
+ + +
+
+ +
+
+
SSL Certificate + Mode
+
+
+ + +
Choose how + SSL certificates are assigned to customer proxy hosts.
+
+ +
+
+ +
-
Select the wildcard certificate (e.g. *.example.com) already uploaded in NPM.
-
-
+ +
-
- - -
- -
-
-
-
- - -
-
-
-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- -
-
-
-
-
- - -
-
-
-
Branding Settings
-
-
-
- - -
Displayed on login page and navbar
-
-
- - -
Shown below the title on the login page
-
-
- - -
Default language for users without a preference
-
-
- -
- -
Default icon (no logo uploaded)
-
-
-
- -
- - -
-
-
- -
-
-
- -
-
-
-
-
- - -
-
-
- User Management - -
-
- - - - - - - - - - - - - - - - - -
IDUsernameEmailRoleAuthLanguageMFAStatusActions
Loading...
-
-
-
- - -
-
-
-
Azure AD / Entra ID Integration
-
-
-
-
- - -
-
-
- - -
-
- - -
-
- -
- - -
-
-
-
- - -
If set, only Azure AD members of this group can log in.
-
-
-
- -
-
-
-
-
- - -
-
-
-
Windows DNS Integration
-
-
-
-
- - -
-
Automatically create/delete DNS A-records when deploying customers.
-
-
- - -
-
- - -
-
- - -
-
- -
- - -
-
-
-
- - -
IP address that customer A-records will point to (usually your NPM server IP).
-
-
-
- - -
-
-
-
-
-
- - -
-
-
-
LDAP / Active Directory Authentication
-
-
-
-
- - -
-
Allow Active Directory users to log in. Local admin accounts always work as fallback.
-
-
- - -
-
- - -
-
-
- - -
-
-
- - -
-
- -
- - -
-
-
-
- - -
-
- - -
Use {username} as placeholder for the login name.
-
-
- - -
If set, only members of this group can log in via LDAP.
-
-
-
- - -
-
-
-
-
-
- - -
-
-
- Version & Updates - -
-
-
Loading...
-
-
-
-
-
Git Repository Settings
-
-
-
- - -
Used for version checks and one-click updates via Gitea API.
-
-
- - -
-
- -
- - -
-
-
-
-
- -
-
-
-
-
- - -
- -
-
-
Multi-Factor Authentication (MFA)
-
- -
-

When enabled, local users must verify with a TOTP authenticator app after entering their password. Azure AD users are not affected.

- -
-
Your TOTP Status
-
-
-
- -
-
-
Change Admin Password
-
-
-
- - -
-
-
- - -
-
- - -
+ +
+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
-
- +
+
+ + +
+
+
+
Branding Settings
+
+
+
+ + +
+ Displayed on login page and navbar
+
+
+ + +
+ Shown below the title on the login page
+
+
+ + +
+ Default language for users without a preference
+
+
+ +
+ +
Default icon (no logo + uploaded)
+
+
+
+ +
+ + +
+
+
+ +
+
+
+ +
+
- -
+
+
+ + +
+
+
+ User Management + +
+
+ + + + + + + + + + + + + + + + + + + +
IDUsernameEmailRoleAuthLanguageMFAStatusActions
Loading...
+
+
+
+ + +
+
+
+
Azure AD / Entra ID Integration +
+
+
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+
+ + +
If set, + only Azure AD members of this group can log in.
+
+
+
+ +
+
+
+
+
+ + +
+
+
+
Windows DNS Integration
+
+
+
+
+ + +
+
+ Automatically create/delete DNS A-records when deploying + customers.
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+
+ + +
IP + address that customer A-records will point to (usually your NPM + server IP).
+
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+
LDAP / Active Directory + Authentication
+
+
+
+
+ + +
+
Allow + Active Directory users to log in. Local admin accounts always + work as fallback.
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ +
+ + +
+
+
+
+ + +
+
+ + +
Use + {username} as placeholder for the login name.
+
+
+ + +
If set, + only members of this group can log in via LDAP.
+
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+ Version & Updates + +
+
+
Loading...
+
+
+
+
+
Git Repository Settings
+
+
+
+ + +
Used for + version checks and one-click updates via Gitea API.
+
+
+ + +
+
+ +
+ + +
+
+
+
+
+ +
+
+
+
+
+ + +
+ +
+
+
Multi-Factor Authentication (MFA)
+
+ + +
+

When enabled, local + users must verify with a TOTP authenticator app after entering their + password. Azure AD users are not affected.

+ +
+
Your TOTP Status
+
+ +
+
+ + +
+
+
Change Admin Password
+
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+
+
+
+
@@ -805,10 +1111,13 @@

- + System Monitoring

- +
@@ -819,7 +1128,8 @@
-
All Customer Deployments
+
All Customer Deployments +
@@ -835,7 +1145,10 @@ - + + +
Loading...
+ Loading...
@@ -849,7 +1162,8 @@ diff --git a/static/js/app.js b/static/js/app.js index 7bcf14e..50a0249 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1633,6 +1633,144 @@ async function loadAllCustomerStatuses() { } } +// --------------------------------------------------------------------------- +// Image Updates +// --------------------------------------------------------------------------- +async function checkImageUpdates() { + const btn = document.getElementById('btn-check-updates'); + const body = document.getElementById('image-updates-body'); + btn.disabled = true; + body.innerHTML = `
${t('common.loading')}
`; + + try { + const data = await api('GET', '/monitoring/images/check'); + + // Image status table + const imageRows = Object.values(data.images).map(img => { + const badge = img.update_available + ? `${t('monitoring.updateAvailable')}` + : `${t('monitoring.upToDate')}`; + const shortDigest = d => d ? d.substring(7, 19) + '…' : '-'; + return ` + ${esc(img.image)} + ${shortDigest(img.local_digest)} + ${shortDigest(img.hub_digest)} + ${badge} + `; + }).join(''); + + // Customer status table + const customerRows = data.customer_status.length === 0 + ? `${t('monitoring.noCustomers')}` + : data.customer_status.map(c => { + const badge = c.needs_update + ? `${t('monitoring.needsUpdate')}` + : `${t('monitoring.upToDate')}`; + const updateBtn = c.needs_update + ? `` + : ''; + return ` + ${c.customer_id} + ${esc(c.customer_name)} ${esc(c.subdomain)} + ${badge}${updateBtn} + `; + }).join(''); + + // Show "Update All" button if any customer needs update + const updateAllBtn = document.getElementById('btn-update-all'); + if (data.customer_status.some(c => c.needs_update)) { + updateAllBtn.classList.remove('d-none'); + } else { + updateAllBtn.classList.add('d-none'); + } + + body.innerHTML = ` +
${t('monitoring.imageStatusTitle')}
+
+ + + + + + + + + + ${imageRows} +
${t('monitoring.thImage')}${t('monitoring.thLocalDigest')}${t('monitoring.thHubDigest')}${t('monitoring.thStatus')}
+
+
${t('monitoring.customerImageTitle')}
+
+ + + + + + + + + ${customerRows} +
${t('monitoring.thId')}${t('monitoring.thName')}${t('monitoring.thStatus')}
+
`; + } catch (err) { + body.innerHTML = `
${err.message}
`; + } finally { + btn.disabled = false; + } +} + +async function pullAllImages() { + if (!confirm(t('monitoring.confirmPull'))) return; + const btn = document.getElementById('btn-pull-images'); + btn.disabled = true; + try { + await api('POST', '/monitoring/images/pull'); + showToast(t('monitoring.pullStarted')); + // Re-check after a few seconds to let pull finish + setTimeout(() => checkImageUpdates(), 5000); + } catch (err) { + showMonitoringAlert('danger', err.message); + } finally { + btn.disabled = false; + } +} + +async function updateCustomerImages(customerId) { + try { + await api('POST', `/customers/${customerId}/update-images`); + showToast(t('monitoring.updateDone')); + setTimeout(() => checkImageUpdates(), 2000); + } catch (err) { + showMonitoringAlert('danger', err.message); + } +} + +async function updateAllCustomers() { + if (!confirm(t('monitoring.confirmUpdateAll'))) return; + const btn = document.getElementById('btn-update-all'); + btn.disabled = true; + try { + const data = await api('POST', '/monitoring/customers/update-all'); + showToast(data.message || t('monitoring.updateAllStarted')); + setTimeout(() => checkImageUpdates(), 5000); + } catch (err) { + showMonitoringAlert('danger', err.message); + } finally { + btn.disabled = false; + } +} + +function showMonitoringAlert(type, msg) { + const body = document.getElementById('image-updates-body'); + const existing = body.querySelector('.alert'); + if (existing) existing.remove(); + const div = document.createElement('div'); + div.className = `alert alert-${type} mt-2`; + div.textContent = msg; + body.prepend(div); +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/static/lang/de.json b/static/lang/de.json index fdebf0d..65995ff 100644 --- a/static/lang/de.json +++ b/static/lang/de.json @@ -373,6 +373,25 @@ "thDashboard": "Dashboard", "thRelayPort": "Relay-Port", "thContainers": "Container", - "noCustomers": "Keine Kunden." + "noCustomers": "Keine Kunden.", + "imageUpdates": "NetBird Container Updates", + "checkUpdates": "Auf Updates prüfen", + "pullImages": "Neueste Images laden", + "updateAll": "Alle aktualisieren", + "clickCheckUpdates": "Klicken Sie auf \"Auf Updates prüfen\" um lokale Images mit Docker Hub zu vergleichen.", + "updateAvailable": "Update verfügbar", + "upToDate": "Aktuell", + "needsUpdate": "Update erforderlich", + "updateCustomer": "Diesen Kunden aktualisieren", + "imageStatusTitle": "Image-Status (vs. Docker Hub)", + "customerImageTitle": "Kunden-Container Status", + "thImage": "Image", + "thLocalDigest": "Lokaler Digest", + "thHubDigest": "Hub Digest", + "confirmPull": "Neueste NetBird Images von Docker Hub laden? Dies kann einige Minuten dauern.", + "pullStarted": "Image-Download im Hintergrund gestartet. Prüfung in 5 Sekunden…", + "confirmUpdateAll": "Container aller Kunden mit veralteten Images neu erstellen? Laufende Dienste werden kurz neu gestartet.", + "updateAllStarted": "Aktualisierung im Hintergrund gestartet.", + "updateDone": "Kunden-Container aktualisiert." } } \ No newline at end of file diff --git a/static/lang/en.json b/static/lang/en.json index e15ba20..a4d4fce 100644 --- a/static/lang/en.json +++ b/static/lang/en.json @@ -280,7 +280,26 @@ "thDashboard": "Dashboard", "thRelayPort": "Relay Port", "thContainers": "Containers", - "noCustomers": "No customers." + "noCustomers": "No customers.", + "imageUpdates": "NetBird Container Updates", + "checkUpdates": "Check for Updates", + "pullImages": "Pull Latest Images", + "updateAll": "Update All", + "clickCheckUpdates": "Click \"Check for Updates\" to compare local images with Docker Hub.", + "updateAvailable": "Update available", + "upToDate": "Up to date", + "needsUpdate": "Needs update", + "updateCustomer": "Update this customer", + "imageStatusTitle": "Image Status (vs. Docker Hub)", + "customerImageTitle": "Customer Container Status", + "thImage": "Image", + "thLocalDigest": "Local Digest", + "thHubDigest": "Hub Digest", + "confirmPull": "Pull the latest NetBird images from Docker Hub? This may take a few minutes.", + "pullStarted": "Image pull started in background. Re-checking in 5 seconds…", + "confirmUpdateAll": "Recreate containers for all customers that have outdated images? Running services will briefly restart.", + "updateAllStarted": "Update started in background.", + "updateDone": "Customer containers updated." }, "userModal": { "title": "New User", From 27c8e4889c004edb0ed2752e3bd2278ae90a1980 Mon Sep 17 00:00:00 2001 From: twothatIT Date: Tue, 24 Feb 2026 21:25:33 +0100 Subject: [PATCH 44/44] feat(updates): visual update indicators, progress feedback, settings pull - Dashboard: update badge (orange) injected lazily into customer Status cell after table renders via GET /monitoring/customers/local-update-status (local-only Docker inspect, no Hub call on every page load) - Customer detail Deployment tab: "Update Images" button with spinner, shows success/error inline without page reload - Monitoring Update All: now synchronous + sequential (one customer at a time), shows live spinner + per-customer results table on completion - Settings > Docker Images: "Pull from Docker Hub" button with spinner and inline status message - /monitoring/customers/local-update-status: new lightweight endpoint (no network, pure local Docker inspect) - /monitoring/customers/update-all: removed BackgroundTasks, now awaits each customer sequentially and returns detailed per-customer results Co-Authored-By: Claude Sonnet 4.6 --- app/routers/monitoring.py | 65 +++++++++++++++------- static/index.html | 7 +++ static/js/app.js | 114 ++++++++++++++++++++++++++++++++++++-- static/lang/de.json | 13 ++++- static/lang/en.json | 13 ++++- 5 files changed, 181 insertions(+), 31 deletions(-) diff --git a/app/routers/monitoring.py b/app/routers/monitoring.py index 0a0a5be..211190a 100644 --- a/app/routers/monitoring.py +++ b/app/routers/monitoring.py @@ -196,16 +196,37 @@ async def pull_all_netbird_images( return {"message": "Image pull started in background.", "images": images} +@router.get("/customers/local-update-status") +async def customers_local_update_status( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +) -> list[dict[str, Any]]: + """Fast local-only check for outdated customer containers. + + Compares running container image IDs against locally stored images. + No network call — safe to call on every dashboard load. + """ + config = db.query(SystemConfig).filter(SystemConfig.id == 1).first() + if not config: + return [] + deployments = db.query(Deployment).all() + results = [] + for dep in deployments: + cs = image_service.get_customer_container_image_status(dep.container_prefix, config) + results.append({"customer_id": dep.customer_id, "needs_update": cs["needs_update"]}) + return results + + @router.post("/customers/update-all") async def update_all_customers( - background_tasks: BackgroundTasks, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ) -> dict[str, Any]: - """Recreate containers for all customers that have outdated images. + """Recreate containers for all customers with outdated images — sequential, synchronous. - Only customers where at least one container runs an outdated image are updated. + Updates customers one at a time so a failing customer does not block others. Images must already be pulled. Data is preserved (bind mounts). + Returns detailed per-customer results. """ if current_user.role != "admin": raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin only.") @@ -214,38 +235,40 @@ async def update_all_customers( if not config: raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="System not configured.") - # Collect customers that need updating deployments = db.query(Deployment).all() to_update = [] for dep in deployments: cs = image_service.get_customer_container_image_status(dep.container_prefix, config) if cs["needs_update"]: customer = dep.customer - instance_dir = str(dep.container_prefix).replace( - "netbird-", "", 1 - ) # subdomain to_update.append({ "instance_dir": f"{config.data_dir}/{customer.subdomain}", "project_name": dep.container_prefix, "customer_name": customer.name, + "customer_id": customer.id, }) if not to_update: - return {"message": "All customers are already up to date.", "updated": 0} + return {"message": "All customers are already up to date.", "updated": 0, "results": []} - async def _update_all_bg() -> None: - for entry in to_update: - try: - await image_service.update_customer_containers( - entry["instance_dir"], entry["project_name"] - ) - logger.info("Updated containers for %s", entry["project_name"]) - except Exception: - logger.exception("Failed to update %s", entry["project_name"]) + # Update customers sequentially — one at a time + update_results = [] + for entry in to_update: + res = await image_service.update_customer_containers( + entry["instance_dir"], entry["project_name"] + ) + ok = res["success"] + logger.info("Updated %s: %s", entry["project_name"], "OK" if ok else res.get("error")) + update_results.append({ + "customer_name": entry["customer_name"], + "customer_id": entry["customer_id"], + "success": ok, + "error": res.get("error"), + }) - background_tasks.add_task(_update_all_bg) - names = [e["customer_name"] for e in to_update] + success_count = sum(1 for r in update_results if r["success"]) return { - "message": f"Updating {len(to_update)} customer(s) in background.", - "customers": names, + "message": f"Updated {success_count} of {len(update_results)} customer(s).", + "updated": success_count, + "results": update_results, } diff --git a/static/index.html b/static/index.html index 0d20b49..4c236c7 100644 --- a/static/index.html +++ b/static/index.html @@ -634,6 +634,13 @@ Settings
+
+
Pull Latest Images from Docker Hub
+

Downloads the latest versions of all configured NetBird images. After pulling, use Monitoring to update customer containers.

+ +
diff --git a/static/js/app.js b/static/js/app.js index 50a0249..e19de23 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -485,11 +485,11 @@ function renderCustomersTable(data) { const dashLink = dPort ? `:${dPort} ` : '-'; - return ` + return ` ${c.id} ${esc(c.name)} ${esc(c.subdomain)} - ${statusBadge(c.status)} + ${statusBadge(c.status)} ${dashLink} ${c.max_devices} ${formatDate(c.created_at)} @@ -517,6 +517,26 @@ function renderCustomersTable(data) { paginationHtml += `
  • ${i}
  • `; } document.getElementById('pagination-controls').innerHTML = paginationHtml; + + // Lazy-load update badges after table renders (best-effort, silent fail) + loadCustomerUpdateBadges().catch(() => {}); +} + +async function loadCustomerUpdateBadges() { + const data = await api('GET', '/monitoring/customers/local-update-status'); + data.forEach(s => { + if (!s.needs_update) return; + const tr = document.querySelector(`tr[data-customer-id="${s.customer_id}"]`); + if (!tr) return; + const cell = tr.querySelector('.customer-status-cell'); + if (cell && !cell.querySelector('.update-badge')) { + const badge = document.createElement('span'); + badge.className = 'badge bg-warning text-dark update-badge ms-1'; + badge.title = t('monitoring.updateAvailable'); + badge.innerHTML = ' Update'; + cell.appendChild(badge); + } + }); } function goToPage(page) { @@ -742,8 +762,13 @@ async function viewCustomer(id) { - + +
    +
    `; } else { document.getElementById('detail-deployment-content').innerHTML = ` @@ -1667,7 +1692,7 @@ async function checkImageUpdates() { ? `${t('monitoring.needsUpdate')}` : `${t('monitoring.upToDate')}`; const updateBtn = c.needs_update - ? `` : ''; return ` @@ -1736,26 +1761,103 @@ async function pullAllImages() { } } +async function updateCustomerImagesFromDetail(id) { + const btn = document.getElementById('btn-update-images-detail'); + const spinner = document.getElementById('update-detail-spinner'); + const resultDiv = document.getElementById('detail-update-result'); + btn.disabled = true; + spinner.classList.remove('d-none'); + resultDiv.innerHTML = `
    ${t('customer.updateInProgress')}
    `; + try { + const data = await api('POST', `/customers/${id}/update-images`); + resultDiv.innerHTML = `
    ${esc(data.message)}
    `; + setTimeout(() => { resultDiv.innerHTML = ''; }, 6000); + } catch (err) { + resultDiv.innerHTML = `
    ${esc(err.message)}
    `; + } finally { + btn.disabled = false; + spinner.classList.add('d-none'); + } +} + async function updateCustomerImages(customerId) { + // Find the update button for this customer row and show a spinner + const btn = document.querySelector(`tr[data-customer-id="${customerId}"] .btn-update-customer`); + if (btn) { + btn.disabled = true; + btn.innerHTML = ''; + } try { await api('POST', `/customers/${customerId}/update-images`); showToast(t('monitoring.updateDone')); setTimeout(() => checkImageUpdates(), 2000); } catch (err) { showMonitoringAlert('danger', err.message); + if (btn) { + btn.disabled = false; + btn.innerHTML = ''; + } } } async function updateAllCustomers() { if (!confirm(t('monitoring.confirmUpdateAll'))) return; const btn = document.getElementById('btn-update-all'); + const body = document.getElementById('image-updates-body'); btn.disabled = true; + btn.innerHTML = `${t('monitoring.updating')}`; + + const progressDiv = document.createElement('div'); + progressDiv.className = 'alert alert-info mt-3'; + progressDiv.innerHTML = `${t('monitoring.updateAllProgress')}`; + body.appendChild(progressDiv); + try { const data = await api('POST', '/monitoring/customers/update-all'); - showToast(data.message || t('monitoring.updateAllStarted')); - setTimeout(() => checkImageUpdates(), 5000); + progressDiv.remove(); + + if (data.results && data.results.length > 0) { + const allOk = data.updated === data.results.length; + const rows = data.results.map(r => ` + ${esc(r.customer_name)} + ${r.success + ? ' OK' + : ' Error'} + ${esc(r.error || '')} + `).join(''); + const resultHtml = `
    + ${esc(data.message)} + + + ${rows} +
    ${t('monitoring.thName')}${t('monitoring.thStatus')}
    +
    `; + body.insertAdjacentHTML('beforeend', resultHtml); + } else { + showToast(data.message); + } + setTimeout(() => checkImageUpdates(), 2000); } catch (err) { + progressDiv.remove(); showMonitoringAlert('danger', err.message); + } finally { + btn.disabled = false; + btn.innerHTML = `${t('monitoring.updateAll')}`; + } +} + +async function pullAllImagesSettings() { + if (!confirm(t('monitoring.confirmPull'))) return; + const btn = document.getElementById('btn-pull-images-settings'); + const statusEl = document.getElementById('pull-images-settings-status'); + btn.disabled = true; + statusEl.innerHTML = `${t('monitoring.pulling')}`; + try { + await api('POST', '/monitoring/images/pull'); + statusEl.innerHTML = `${t('monitoring.pullStartedShort')}`; + setTimeout(() => { statusEl.innerHTML = ''; }, 8000); + } catch (err) { + statusEl.innerHTML = `${esc(err.message)}`; } finally { btn.disabled = false; } diff --git a/static/lang/de.json b/static/lang/de.json index 65995ff..71fe9af 100644 --- a/static/lang/de.json +++ b/static/lang/de.json @@ -89,7 +89,9 @@ "thHealth": "Zustand", "thImage": "Image", "lastCheck": "Letzte Prüfung: {time}", - "openDashboard": "Dashboard öffnen" + "openDashboard": "Dashboard öffnen", + "updateImages": "Images aktualisieren", + "updateInProgress": "Container werden aktualisiert — bitte warten…" }, "settings": { "title": "Systemeinstellungen", @@ -152,6 +154,9 @@ "dashboardImage": "Dashboard Image", "dashboardImagePlaceholder": "netbirdio/dashboard:latest", "saveImageSettings": "Image-Einstellungen speichern", + "pullImagesTitle": "Neueste Images von Docker Hub laden", + "pullImagesHint": "Lädt die neuesten Versionen aller konfigurierten NetBird Images. Danach können Kunden-Container über das Monitoring aktualisiert werden.", + "pullImages": "Von Docker Hub laden", "brandingTitle": "Branding-Einstellungen", "companyName": "Firmen- / Anwendungsname", "companyNamePlaceholder": "NetBird MSP Appliance", @@ -392,6 +397,10 @@ "pullStarted": "Image-Download im Hintergrund gestartet. Prüfung in 5 Sekunden…", "confirmUpdateAll": "Container aller Kunden mit veralteten Images neu erstellen? Laufende Dienste werden kurz neu gestartet.", "updateAllStarted": "Aktualisierung im Hintergrund gestartet.", - "updateDone": "Kunden-Container aktualisiert." + "updateDone": "Kunden-Container aktualisiert.", + "updating": "Wird aktualisiert…", + "updateAllProgress": "Kunden-Container werden nacheinander aktualisiert — bitte warten…", + "pulling": "Wird geladen…", + "pullStartedShort": "Download im Hintergrund gestartet." } } \ No newline at end of file diff --git a/static/lang/en.json b/static/lang/en.json index a4d4fce..d60c777 100644 --- a/static/lang/en.json +++ b/static/lang/en.json @@ -89,7 +89,9 @@ "thHealth": "Health", "thImage": "Image", "lastCheck": "Last check: {time}", - "openDashboard": "Open Dashboard" + "openDashboard": "Open Dashboard", + "updateImages": "Update Images", + "updateInProgress": "Updating containers — please wait…" }, "customerModal": { "newCustomer": "New Customer", @@ -173,6 +175,9 @@ "dashboardImage": "Dashboard Image", "dashboardImagePlaceholder": "netbirdio/dashboard:latest", "saveImageSettings": "Save Image Settings", + "pullImagesTitle": "Pull Latest Images from Docker Hub", + "pullImagesHint": "Downloads the latest versions of all configured NetBird images. After pulling, use Monitoring to update customer containers.", + "pullImages": "Pull from Docker Hub", "brandingTitle": "Branding Settings", "companyName": "Company / Application Name", "companyNamePlaceholder": "NetBird MSP Appliance", @@ -299,7 +304,11 @@ "pullStarted": "Image pull started in background. Re-checking in 5 seconds…", "confirmUpdateAll": "Recreate containers for all customers that have outdated images? Running services will briefly restart.", "updateAllStarted": "Update started in background.", - "updateDone": "Customer containers updated." + "updateDone": "Customer containers updated.", + "updating": "Updating…", + "updateAllProgress": "Updating customer containers one by one — please wait…", + "pulling": "Pulling…", + "pullStartedShort": "Pull started in background." }, "userModal": { "title": "New User",