diff --git a/app/database.py b/app/database.py index c32f965..024950c 100644 --- a/app/database.py +++ b/app/database.py @@ -127,6 +127,10 @@ def _run_migrations() -> None: ("system_config", "auto_update_check_time", "TEXT DEFAULT '03:00'"), ("system_config", "auto_update_apply_enabled", "BOOLEAN DEFAULT 0"), ("system_config", "auto_update_last_run_at", "TEXT"), + # NetBird client (peer) automatic-updates master default + per-customer PAT + ("deployments", "netbird_api_token_encrypted", "TEXT"), + ("system_config", "netbird_client_auto_update_version", "TEXT DEFAULT 'disabled'"), + ("system_config", "netbird_client_auto_update_always", "BOOLEAN DEFAULT 0"), ] for table, column, col_type in migrations: if not _has_column(table, column): diff --git a/app/models.py b/app/models.py index 02fd990..f550724 100644 --- a/app/models.py +++ b/app/models.py @@ -88,6 +88,7 @@ class Deployment(Base): setup_url: Mapped[Optional[str]] = mapped_column(Text, nullable=True) netbird_admin_email: Mapped[Optional[str]] = mapped_column(Text, nullable=True) netbird_admin_password: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + netbird_api_token_encrypted: Mapped[Optional[str]] = mapped_column(Text, nullable=True) deployment_status: Mapped[str] = mapped_column( String(20), default="pending", nullable=False ) @@ -116,6 +117,7 @@ class Deployment(Base): "relay_secret": "***", # Never expose secrets "setup_url": self.setup_url, "has_credentials": bool(self.netbird_admin_email and self.netbird_admin_password), + "has_netbird_api_token": bool(self.netbird_api_token_encrypted), "deployment_status": self.deployment_status, "deployed_at": self.deployed_at.isoformat() if self.deployed_at else None, "last_health_check": ( @@ -205,6 +207,12 @@ class SystemConfig(Base): auto_update_apply_enabled: Mapped[bool] = mapped_column(Boolean, default=False) auto_update_last_run_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + # Master default for the NetBird *client* (peer) automatic-updates feature + # (Settings > Clients > Automatic Updates inside each customer's own + # NetBird dashboard) — pushed to customers via the NetBird Management API. + netbird_client_auto_update_version: Mapped[str] = mapped_column(String(50), default="disabled") + netbird_client_auto_update_always: Mapped[bool] = mapped_column(Boolean, default=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) updated_at: Mapped[datetime] = mapped_column( DateTime, default=datetime.utcnow, onupdate=datetime.utcnow @@ -265,6 +273,8 @@ class SystemConfig(Base): "auto_update_last_run_at": ( self.auto_update_last_run_at.isoformat() if self.auto_update_last_run_at else None ), + "netbird_client_auto_update_version": self.netbird_client_auto_update_version or "disabled", + "netbird_client_auto_update_always": bool(self.netbird_client_auto_update_always), "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/deployments.py b/app/routers/deployments.py index 47a378f..e48ad37 100644 --- a/app/routers/deployments.py +++ b/app/routers/deployments.py @@ -8,8 +8,9 @@ from sqlalchemy.orm import Session from app.database import SessionLocal, get_db from app.dependencies import get_current_user from app.models import Customer, Deployment, SystemConfig, User -from app.services import docker_service, image_service, netbird_service -from app.utils.security import decrypt_value +from app.services import docker_service, image_service, netbird_client_update_service, netbird_service +from app.utils.security import decrypt_value, encrypt_value +from app.utils.validators import NetbirdApiTokenPayload, NetbirdClientAutoUpdatePayload logger = logging.getLogger(__name__) router = APIRouter() @@ -260,6 +261,112 @@ async def update_customer_images( return {"message": f"Containers updated for '{customer.name}'."} +@router.get("/{customer_id}/netbird-updates") +async def get_customer_netbird_updates( + customer_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Fetch a customer's *live* NetBird client automatic-updates setting. + + Reads directly from the customer's NetBird Management API — always + reflects reality, including changes made manually in their own dashboard. + """ + _require_customer(db, customer_id) + deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first() + if not deployment: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No deployment found for this customer.") + if not deployment.netbird_api_token_encrypted: + return {"has_token": False, "version": None, "always": None} + + token = decrypt_value(deployment.netbird_api_token_encrypted) + result = await netbird_client_update_service.get_current_settings(deployment.container_prefix, token) + if not result["ok"]: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=result["error"]) + + settings = result["settings"] + return { + "has_token": True, + "version": settings.get("auto_update_version", "disabled"), + "always": bool(settings.get("auto_update_always", False)), + } + + +@router.put("/{customer_id}/netbird-updates") +async def set_customer_netbird_updates( + customer_id: int, + payload: NetbirdClientAutoUpdatePayload, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Push a client automatic-updates version/mode to a single customer. + + Use this to override the master default for one customer specifically — + e.g. a customer on a legacy client that must not jump straight to latest. + """ + if current_user.role != "admin": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin only.") + + _require_customer(db, customer_id) + deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first() + if not deployment: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No deployment found for this customer.") + if not deployment.netbird_api_token_encrypted: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No NetBird API token registered for this customer. Paste one via PUT .../netbird-api-token first.", + ) + + token = decrypt_value(deployment.netbird_api_token_encrypted) + result = await netbird_client_update_service.push_auto_update_settings( + deployment.container_prefix, token, payload.version, payload.always + ) + if not result["ok"]: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=result["error"]) + + logger.info( + "NetBird client auto-update set for customer %d (%s): version=%s always=%s by %s", + customer_id, deployment.container_prefix, payload.version, payload.always, current_user.username, + ) + return {"ok": True} + + +@router.put("/{customer_id}/netbird-api-token") +async def set_customer_netbird_api_token( + customer_id: int, + payload: NetbirdApiTokenPayload, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Manually register a NetBird Personal Access Token for a customer. + + Needed for customers deployed before automatic PAT capture — create a + PAT once in that customer's dashboard (Settings > Service Users / + Personal Access Tokens) and paste it here. New deployments capture one + automatically during setup. + """ + if current_user.role != "admin": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin only.") + + _require_customer(db, customer_id) + deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first() + if not deployment: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No deployment found for this customer.") + + # Validate the token actually works before storing it. + result = await netbird_client_update_service.get_current_settings(deployment.container_prefix, payload.token) + if not result["ok"]: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Token could not be verified against this customer's NetBird instance: {result['error']}", + ) + + deployment.netbird_api_token_encrypted = encrypt_value(payload.token) + db.commit() + logger.info("NetBird API token registered for customer %d by %s.", customer_id, current_user.username) + return {"ok": True} + + def _require_customer(db: Session, customer_id: int) -> Customer: """Helper to fetch a customer or raise 404. diff --git a/app/routers/monitoring.py b/app/routers/monitoring.py index 1f81f61..e32c933 100644 --- a/app/routers/monitoring.py +++ b/app/routers/monitoring.py @@ -13,7 +13,9 @@ from sqlalchemy.orm import Session from app.database import SessionLocal, get_db from app.dependencies import get_current_user from app.models import Customer, Deployment, SystemConfig, User -from app.services import docker_service, image_service +from app.services import docker_service, image_service, netbird_client_update_service +from app.utils.security import decrypt_value +from app.utils.validators import NetbirdClientAutoUpdatePayload logger = logging.getLogger(__name__) router = APIRouter() @@ -241,6 +243,48 @@ async def customers_local_update_status( return results +@router.post("/netbird-updates/apply-all") +async def apply_netbird_client_updates_to_all( + payload: NetbirdClientAutoUpdatePayload, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +) -> dict[str, Any]: + """Push a NetBird client automatic-updates version/mode to every customer. + + Skips (and reports) customers without a registered API token — they need + a token pasted in via the per-customer endpoint first (older deployments + predating automatic token capture). + """ + if current_user.role != "admin": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin only.") + + deployments = db.query(Deployment).all() + results = [] + for dep in deployments: + customer = dep.customer + if not dep.netbird_api_token_encrypted: + results.append({ + "customer_id": customer.id, "customer_name": customer.name, + "success": False, "error": "No API token registered.", + }) + continue + token = decrypt_value(dep.netbird_api_token_encrypted) + res = await netbird_client_update_service.push_auto_update_settings( + dep.container_prefix, token, payload.version, payload.always + ) + results.append({ + "customer_id": customer.id, "customer_name": customer.name, + "success": res["ok"], "error": res.get("error"), + }) + + success_count = sum(1 for r in results if r["success"]) + return { + "message": f"Applied to {success_count} of {len(results)} customer(s).", + "updated": success_count, + "results": results, + } + + @router.post("/customers/update-all") async def update_all_customers( current_user: User = Depends(get_current_user), diff --git a/app/services/netbird_client_update_service.py b/app/services/netbird_client_update_service.py new file mode 100644 index 0000000..1b9456c --- /dev/null +++ b/app/services/netbird_client_update_service.py @@ -0,0 +1,87 @@ +"""Central control of the NetBird *client* (peer) Automatic Updates feature. + +This is the "Settings > Clients > Automatic Updates" toggle inside each +customer's own NetBird dashboard (netbirdio/netbird, added in v0.61.0) — not +to be confused with updating the NetBird Docker images themselves +(app/services/image_service.py). + +Talked to over the customer's NetBird Management REST API, authenticated +with a Personal Access Token captured during initial deployment (see +netbird_service.deploy_customer) or pasted in manually for customers +deployed before this feature existed. Requests go over the internal Docker +network directly to the customer's management container — never through +their public dashboard URL. +""" + +import json +import logging +from typing import Any + +import httpx + +logger = logging.getLogger(__name__) + +_TIMEOUT = 10 + + +def _base_url(container_prefix: str) -> str: + return f"http://{container_prefix}-management:80" + + +async def get_current_settings(container_prefix: str, token: str) -> dict[str, Any]: + """Fetch the customer's current account settings. + + Returns: + {"ok": True, "account_id": ..., "settings": {...}} on success, or + {"ok": False, "error": "..."} on failure. + """ + base_url = _base_url(container_prefix) + headers = {"Authorization": f"Token {token}"} + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.get(f"{base_url}/api/accounts", headers=headers) + if resp.status_code != 200: + return {"ok": False, "error": f"GET /api/accounts -> HTTP {resp.status_code}: {resp.text[:300]}"} + accounts = resp.json() + if not accounts: + return {"ok": False, "error": "No account returned by /api/accounts."} + account = accounts[0] + return {"ok": True, "account_id": account["id"], "settings": account.get("settings", {})} + except Exception as exc: + logger.warning("Failed to fetch NetBird account settings for %s: %s", container_prefix, exc) + return {"ok": False, "error": str(exc)} + + +async def push_auto_update_settings( + container_prefix: str, token: str, version: str, always: bool +) -> dict[str, Any]: + """Set the client automatic-updates version/mode for one customer. + + NetBird's account PUT endpoint expects the *entire* settings object, not + a partial patch, so this fetches current settings first and only + overwrites the two auto-update fields. + """ + current = await get_current_settings(container_prefix, token) + if not current["ok"]: + return current + + settings = dict(current["settings"]) + settings["auto_update_version"] = version + settings["auto_update_always"] = always + + base_url = _base_url(container_prefix) + account_id = current["account_id"] + headers = {"Authorization": f"Token {token}", "Content-Type": "application/json"} + body = {"settings": settings} + + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.put( + f"{base_url}/api/accounts/{account_id}", headers=headers, content=json.dumps(body) + ) + if resp.status_code != 200: + return {"ok": False, "error": f"PUT /api/accounts/{account_id} -> HTTP {resp.status_code}: {resp.text[:300]}"} + return {"ok": True} + except Exception as exc: + logger.warning("Failed to push NetBird auto-update settings for %s: %s", container_prefix, exc) + return {"ok": False, "error": str(exc)} diff --git a/app/services/netbird_service.py b/app/services/netbird_service.py index 7f40783..5ae9242 100644 --- a/app/services/netbird_service.py +++ b/app/services/netbird_service.py @@ -231,9 +231,12 @@ async def deploy_customer(db: Session, customer_id: int) -> dict[str, Any]: "name": customer.name, "email": admin_email, "password": admin_password, + "create_pat": True, + "pat_expire_in": 365, }).encode("utf-8") setup_ok = False + netbird_api_token: str | None = None for attempt in range(10): try: req = urllib.request.Request( @@ -245,8 +248,13 @@ async def deploy_customer(db: Session, customer_id: int) -> dict[str, Any]: with urllib.request.urlopen(req, timeout=10) as resp: if resp.status in (200, 201): setup_ok = True + setup_body = json.loads(resp.read().decode("utf-8")) + netbird_api_token = setup_body.get("personal_access_token") _log_action(db, customer_id, "deploy", "info", - f"Admin user created: {admin_email}") + f"Admin user created: {admin_email}" + + (" (API token captured for central management)" + if netbird_api_token else + " (no API token — NB_SETUP_PAT_ENABLED not active yet on this instance)")) break except urllib.error.HTTPError as e: body = e.read().decode("utf-8", errors="replace") @@ -340,6 +348,9 @@ async def deploy_customer(db: Session, customer_id: int) -> dict[str, Any]: deployment.setup_url = setup_url deployment.netbird_admin_email = encrypt_value(admin_email) if setup_ok else deployment.netbird_admin_email deployment.netbird_admin_password = encrypt_value(admin_password) if setup_ok else deployment.netbird_admin_password + deployment.netbird_api_token_encrypted = ( + encrypt_value(netbird_api_token) if netbird_api_token else deployment.netbird_api_token_encrypted + ) deployment.deployment_status = "running" deployment.deployed_at = datetime.utcnow() else: @@ -354,6 +365,7 @@ async def deploy_customer(db: Session, customer_id: int) -> dict[str, Any]: setup_url=setup_url, netbird_admin_email=encrypt_value(admin_email) if setup_ok else None, netbird_admin_password=encrypt_value(admin_password) if setup_ok else None, + netbird_api_token_encrypted=encrypt_value(netbird_api_token) if netbird_api_token else None, deployment_status="running", deployed_at=datetime.utcnow(), ) diff --git a/app/utils/validators.py b/app/utils/validators.py index 686015c..4fdd8d9 100644 --- a/app/utils/validators.py +++ b/app/utils/validators.py @@ -162,6 +162,9 @@ class SystemConfigUpdate(BaseModel): auto_update_check_enabled: Optional[bool] = None auto_update_check_time: Optional[str] = Field(None, max_length=5) auto_update_apply_enabled: Optional[bool] = None + # Master default for the NetBird client (peer) automatic-updates feature + netbird_client_auto_update_version: Optional[str] = Field(None, max_length=50) + netbird_client_auto_update_always: Optional[bool] = None @field_validator("auto_update_check_time") @classmethod @@ -174,6 +177,27 @@ class SystemConfigUpdate(BaseModel): raise ValueError("auto_update_check_time must be in HH:MM 24h format") return v + +# --------------------------------------------------------------------------- +# NetBird client (peer) automatic updates +# --------------------------------------------------------------------------- +class NetbirdClientAutoUpdatePayload(BaseModel): + """Push a client auto-update version/mode to one or all customers.""" + + version: str = Field(..., max_length=50, description="'latest', 'disabled', or a version e.g. '0.61.0'") + always: bool = False + + +class NetbirdApiTokenPayload(BaseModel): + """Manually register a NetBird Personal Access Token for a customer. + + Needed for customers deployed before automatic PAT capture existed — + create a PAT once in that customer's own NetBird dashboard + (Settings > Service Users / Personal Access Tokens) and paste it here. + """ + + token: str = Field(..., min_length=10, max_length=500) + @field_validator("ssl_mode") @classmethod def validate_ssl_mode(cls, v: Optional[str]) -> Optional[str]: diff --git a/static/index.html b/static/index.html index afb6d8d..2031359 100644 --- a/static/index.html +++ b/static/index.html @@ -668,6 +668,33 @@ +
+
NetBird Client Auto-Updates (all customers)
+

Controls the "Automatic Updates" setting inside every customer's own NetBird dashboard (Settings > Clients). Set the default here, then push it to all customers at once. Individual customers can still be overridden from their detail page.

+
+
+
+ + +
+
+ +
+
+ + +
+
+
+ + +
+
+
diff --git a/static/js/app.js b/static/js/app.js index 49301a5..f0a733f 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -571,6 +571,121 @@ function goToPage(page) { loadCustomers(); } +// --------------------------------------------------------------------------- +// NetBird client (peer) automatic-updates — per-customer +// --------------------------------------------------------------------------- +function _nbuVersionOptions(selected) { + const opts = [ + ['disabled', t('customer.nbuDisabled')], + ['latest', t('customer.nbuLatest')], + ['custom', t('customer.nbuCustom')], + ]; + const isCustom = selected && selected !== 'disabled' && selected !== 'latest'; + return opts.map(([v, label]) => + `` + ).join(''); +} + +async function loadCustomerNetbirdUpdates(id, hasToken) { + const container = document.getElementById('nbu-container'); + if (!container) return; + + if (!hasToken) { + container.innerHTML = ` +

${t('customer.nbuNoToken')}

+
+ + +
+
`; + return; + } + + container.innerHTML = ``; + try { + const data = await api('GET', `/customers/${id}/netbird-updates`); + const isCustom = data.version && data.version !== 'disabled' && data.version !== 'latest'; + container.innerHTML = ` +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+
`; + } catch (err) { + container.innerHTML = `
${esc(err.message)}
`; + } +} + +async function saveCustomerNetbirdToken(id) { + const input = document.getElementById('nbu-token-input'); + const resultEl = document.getElementById('nbu-token-result'); + const token = input.value.trim(); + if (!token) return; + resultEl.innerHTML = ``; + try { + await api('PUT', `/customers/${id}/netbird-api-token`, { token }); + showToast(t('customer.nbuTokenSaved')); + loadCustomerNetbirdUpdates(id, true); + } catch (err) { + resultEl.innerHTML = `${esc(err.message)}`; + } +} + +async function _readNbuForm() { + const select = document.getElementById('nbu-version-select').value; + const version = select === 'custom' ? document.getElementById('nbu-custom-version').value.trim() : select; + const always = document.getElementById('nbu-always').checked; + return { version, always }; +} + +async function saveCustomerNetbirdUpdate(id) { + const resultEl = document.getElementById('nbu-result'); + const payload = await _readNbuForm(); + if (!payload.version) { + resultEl.innerHTML = `${t('customer.nbuVersionRequired')}`; + return; + } + resultEl.innerHTML = ``; + try { + await api('PUT', `/customers/${id}/netbird-updates`, payload); + showToast(t('customer.nbuSaved')); + loadCustomerNetbirdUpdates(id, true); + } catch (err) { + resultEl.innerHTML = `${esc(err.message)}`; + } +} + +async function syncCustomerNetbirdFromMaster(id) { + const resultEl = document.getElementById('nbu-result'); + resultEl.innerHTML = ``; + try { + const cfg = await api('GET', '/settings/system'); + await api('PUT', `/customers/${id}/netbird-updates`, { + version: cfg.netbird_client_auto_update_version, + always: cfg.netbird_client_auto_update_always, + }); + showToast(t('customer.nbuSynced')); + loadCustomerNetbirdUpdates(id, true); + } catch (err) { + resultEl.innerHTML = `${esc(err.message)}`; + } +} + // Search & filter listeners document.getElementById('search-input').addEventListener('input', debounce(() => { customersPage = 1; loadCustomers(); }, 300)); document.getElementById('status-filter').addEventListener('change', () => { customersPage = 1; loadCustomers(); }); @@ -812,6 +927,14 @@ async function viewCustomer(id) { ` : `

${t('customer.credentialsNotAvailable')}

`} +
+
+ ${t('customer.netbirdClientUpdates')} +
+
+ +
+
@@ -824,6 +947,7 @@ async function viewCustomer(id) {
`; + loadCustomerNetbirdUpdates(id, d.has_netbird_api_token); } else { document.getElementById('detail-deployment-content').innerHTML = `

${t('customer.noDeployment')}

@@ -947,6 +1071,13 @@ async function loadSettings() { ? new Date(cfg.auto_update_last_run_at).toLocaleString() : t('monitoring.autoUpdateNever'); + const nbuVersion = cfg.netbird_client_auto_update_version || 'disabled'; + const nbuIsCustom = nbuVersion !== 'disabled' && nbuVersion !== 'latest'; + document.getElementById('cfg-nbu-version-select').value = nbuIsCustom ? 'custom' : nbuVersion; + document.getElementById('cfg-nbu-custom-version').value = nbuIsCustom ? nbuVersion : ''; + document.getElementById('cfg-nbu-custom-version').classList.toggle('d-none', !nbuIsCustom); + document.getElementById('cfg-nbu-always').checked = cfg.netbird_client_auto_update_always || false; + // Branding tab document.getElementById('cfg-branding-name').value = cfg.branding_name || ''; document.getElementById('cfg-branding-subtitle').value = cfg.branding_subtitle || ''; @@ -1080,6 +1211,67 @@ document.getElementById('settings-auto-update-form').addEventListener('submit', } }); +function _readNbuMasterForm() { + const select = document.getElementById('cfg-nbu-version-select').value; + const version = select === 'custom' ? document.getElementById('cfg-nbu-custom-version').value.trim() : select; + const always = document.getElementById('cfg-nbu-always').checked; + return { version, always }; +} + +// NetBird client auto-update master default form +document.getElementById('settings-nbu-master-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const { version, always } = _readNbuMasterForm(); + if (!version) { + showSettingsAlert('danger', t('customer.nbuVersionRequired')); + return; + } + try { + await api('PUT', '/settings/system', { + netbird_client_auto_update_version: version, + netbird_client_auto_update_always: always, + }); + showSettingsAlert('success', t('messages.imageSettingsSaved')); + } catch (err) { + showSettingsAlert('danger', t('errors.failed', { error: err.message })); + } +}); + +async function applyNetbirdUpdatesToAll() { + const { version, always } = _readNbuMasterForm(); + if (!version) { + showSettingsAlert('danger', t('customer.nbuVersionRequired')); + return; + } + if (!confirm(t('customer.nbuConfirmApplyAll'))) return; + + const btn = document.getElementById('btn-nbu-apply-all'); + const resultDiv = document.getElementById('nbu-apply-all-result'); + btn.disabled = true; + resultDiv.innerHTML = `${t('common.loading')}`; + try { + const data = await api('POST', '/monitoring/netbird-updates/apply-all', { version, always }); + const rows = data.results.map(r => ` + ${esc(r.customer_name)} + ${r.success + ? ' OK' + : ' Error'} + ${esc(r.error || '')} + `).join(''); + resultDiv.innerHTML = `
+ ${esc(data.message)} + + + ${rows} +
${t('monitoring.thName')}${t('monitoring.thStatus')}
+
`; + } catch (err) { + resultDiv.innerHTML = `
${esc(err.message)}
`; + } finally { + btn.disabled = false; + } +} + // Test NPM connection async function testNpmConnection() { const spinner = document.getElementById('npm-test-spinner'); diff --git a/static/lang/de.json b/static/lang/de.json index 4b02698..f39e7ce 100644 --- a/static/lang/de.json +++ b/static/lang/de.json @@ -91,7 +91,27 @@ "lastCheck": "Letzte Prüfung: {time}", "openDashboard": "Dashboard öffnen", "updateImages": "Images aktualisieren", - "updateInProgress": "Container werden aktualisiert — bitte warten…" + "updateInProgress": "Container werden aktualisiert — bitte warten…", + "netbirdClientUpdates": "NetBird Client Auto-Updates", + "nbuNoToken": "Kein API-Token für diesen Kunden hinterlegt. Bei Neu-Deployments wird das automatisch erfasst — für bestehende Kunden einmalig ein Personal Access Token im Kunden-Dashboard erstellen (Settings → Service Users) und hier einfügen.", + "nbuTokenPlaceholder": "Personal Access Token einfügen…", + "nbuSaveToken": "Prüfen & Speichern", + "nbuTokenSaved": "Token gespeichert.", + "nbuVersion": "Client-Version", + "nbuDisabled": "Deaktiviert", + "nbuLatest": "Neueste Version", + "nbuCustom": "Bestimmte Version", + "nbuForce": "Automatische Updates erzwingen", + "nbuSave": "Speichern", + "nbuSyncMaster": "Vom Standard übernehmen", + "nbuSaved": "Einstellung übernommen.", + "nbuSynced": "Standard-Einstellung übernommen.", + "nbuVersionRequired": "Bitte eine Version angeben.", + "nbuMasterTitle": "NetBird Client Auto-Updates (alle Kunden)", + "nbuMasterHint": "Steuert die \"Automatische Updates\"-Einstellung im NetBird-Dashboard jedes Kunden (Settings → Clients). Hier den Standard festlegen und auf alle Kunden anwenden. Einzelne Kunden können weiterhin über ihre Detailseite abweichend eingestellt werden.", + "nbuSaveDefault": "Standard speichern", + "nbuApplyAll": "Auf alle Kunden anwenden", + "nbuConfirmApplyAll": "Diese Update-Einstellung auf alle Kunden mit hinterlegtem API-Token anwenden?" }, "settings": { "title": "Systemeinstellungen", diff --git a/static/lang/en.json b/static/lang/en.json index 291dc07..fdc220b 100644 --- a/static/lang/en.json +++ b/static/lang/en.json @@ -91,7 +91,27 @@ "lastCheck": "Last check: {time}", "openDashboard": "Open Dashboard", "updateImages": "Update Images", - "updateInProgress": "Updating containers — please wait…" + "updateInProgress": "Updating containers — please wait…", + "netbirdClientUpdates": "NetBird Client Auto-Updates", + "nbuNoToken": "No API token registered for this customer. New deployments capture one automatically — for existing customers, create a Personal Access Token once in their dashboard (Settings → Service Users) and paste it here.", + "nbuTokenPlaceholder": "Paste Personal Access Token…", + "nbuSaveToken": "Verify & Save", + "nbuTokenSaved": "Token saved.", + "nbuVersion": "Client version", + "nbuDisabled": "Disabled", + "nbuLatest": "Latest version", + "nbuCustom": "Specific version", + "nbuForce": "Force automatic updates", + "nbuSave": "Save", + "nbuSyncMaster": "Sync from default", + "nbuSaved": "Setting applied.", + "nbuSynced": "Default setting applied.", + "nbuVersionRequired": "Please specify a version.", + "nbuMasterTitle": "NetBird Client Auto-Updates (all customers)", + "nbuMasterHint": "Controls the \"Automatic Updates\" setting inside every customer's own NetBird dashboard (Settings → Clients). Set the default here, then push it to all customers at once. Individual customers can still be overridden from their detail page.", + "nbuSaveDefault": "Save Default", + "nbuApplyAll": "Apply to All Customers", + "nbuConfirmApplyAll": "Apply this update setting to every customer with a registered API token?" }, "customerModal": { "newCustomer": "New Customer", diff --git a/templates/docker-compose.yml.j2 b/templates/docker-compose.yml.j2 index c31bb42..ecb3849 100644 --- a/templates/docker-compose.yml.j2 +++ b/templates/docker-compose.yml.j2 @@ -20,6 +20,11 @@ services: image: {{ netbird_management_image }} container_name: netbird-{{ subdomain }}-management restart: unless-stopped + environment: + # Allows the MSP appliance to request a Personal Access Token during the + # one-time /api/setup bootstrap call. The endpoint itself locks down + # (412) as soon as the first user exists, so leaving this on is safe. + - NB_SETUP_PAT_ENABLED=true networks: - {{ docker_network }} volumes: