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 @@ +
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')}
+${t('customer.credentialsNotAvailable')}
`} +${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 => `| ${t('monitoring.thName')} | ${t('monitoring.thStatus')} |
|---|