Compare commits

...
8 Commits
Author SHA1 Message Date
twothatIT 276f4214a7 chore(release): bump version to 1.4.2 2026-08-19 11:22:08 +02:00
twothatIT a8ed87faa9 feat(customer): show NetBird API token renewal/expiry date
Displays when the per-customer token was last (re-)issued and its
computed expiry (365 days from renewal) on the Auto-Updates card, so it's
visible at a glance without checking the database.
2026-08-19 11:21:55 +02:00
twothatIT 1bc768151a feat(customer): allow changing an already-registered NetBird API token
The per-customer Auto-Updates card only showed the token input when no
token was registered yet, so there was no way to replace one once set
(e.g. rotating it manually). Adds a "Change token" link that reveals the
same input/save flow at any time.
2026-08-19 11:21:10 +02:00
twothatIT 1e05ddfe69 chore(release): bump version to 1.4.1 2026-08-19 11:17:38 +02:00
twothatIT e9afdb226c feat(netbird): auto-renew NetBird client-update API tokens before expiry
The tokens captured for central update control expire (NetBird enforces a
365-day max on Personal Access Tokens), and nothing was renewing them —
discovered that the 47 tokens created via the browser-automation bulk
onboarding were actually only 30-day tokens (left the UI's default
expiration field untouched instead of setting 365), so they would have
silently broken automatic-update control next month with no warning.

- Bumped all existing tokens to fresh 365-day ones via the API (using the
  still-valid old token as bearer — no re-login needed)
- Added netbird_api_token_renewed_at per deployment
- Scheduler now checks daily and renews any token older than 300 days
  automatically, so this never needs to be done by hand again
2026-08-19 11:15:49 +02:00
twothatIT 8a84e60a27 fix(validators): restore SystemConfigUpdate validators broken by misplaced classes
The two new NetbirdClientAutoUpdatePayload/NetbirdApiTokenPayload classes
were accidentally inserted between two of SystemConfigUpdate's own
@field_validator methods, which closed that class early — the ssl_mode,
base_domain, npm_api_url, and admin_email validators ended up attached to
NetbirdApiTokenPayload instead, whose fields don't exist. Pydantic raised
PydanticUserError on import, crash-looping the whole appliance container.
Moved the two new classes after all of SystemConfigUpdate's validators.
2026-08-19 09:35:12 +02:00
twothatIT a91a95825a chore(release): bump version to 1.4.0 2026-08-19 09:29:47 +02:00
twothatIT 6d333223a8 feat(netbird): central control of client Automatic Updates across all customers
Lets the MSP admin control NetBird's own "Settings > Clients > Automatic
Updates" feature (client/peer auto-update, v0.61.0+) for every customer from
one place, instead of logging into each customer's dashboard individually.

- New deployments automatically capture a Personal Access Token during the
  existing /api/setup bootstrap call (create_pat=true), requiring
  NB_SETUP_PAT_ENABLED=true on the management container (now set by default
  in the compose template). Token is encrypted at rest per customer.
- Existing customers (deployed before this existed) can have a token pasted
  in manually from their own dashboard — verified before being stored.
- Settings > Docker Images: master default (version + force-update toggle)
  plus "Apply to All Customers" which pushes it to everyone with a token.
- Customer detail page: shows the customer's live current setting (read
  from their NetBird API, not cached) with per-customer override or
  "sync from default".
- New app/services/netbird_client_update_service.py wraps the customer's
  NetBird Management API (GET/PUT /api/accounts) for this.
2026-08-19 09:24:13 +02:00
14 changed files with 701 additions and 9 deletions
+5
View File
@@ -127,6 +127,11 @@ 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"),
("deployments", "netbird_api_token_renewed_at", "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):
+1 -1
View File
@@ -34,7 +34,7 @@ logger = logging.getLogger(__name__)
app = FastAPI(
title="NetBird MSP Appliance",
description="Multi-tenant NetBird management platform for MSPs",
version="1.3.0",
version="1.4.2",
docs_url="/api/docs",
redoc_url="/api/redoc",
openapi_url="/api/openapi.json",
+14
View File
@@ -88,6 +88,8 @@ 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)
netbird_api_token_renewed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
deployment_status: Mapped[str] = mapped_column(
String(20), default="pending", nullable=False
)
@@ -116,6 +118,10 @@ 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),
"netbird_api_token_renewed_at": (
self.netbird_api_token_renewed_at.isoformat() if self.netbird_api_token_renewed_at else None
),
"deployment_status": self.deployment_status,
"deployed_at": self.deployed_at.isoformat() if self.deployed_at else None,
"last_health_check": (
@@ -205,6 +211,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 +277,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,
}
+115 -2
View File
@@ -1,6 +1,7 @@
"""Deployment management API — start, stop, restart, logs, health for customers."""
import logging
from datetime import datetime
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
@@ -8,8 +9,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 +262,117 @@ 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,
"token_renewed_at": (
deployment.netbird_api_token_renewed_at.isoformat()
if deployment.netbird_api_token_renewed_at else None
),
"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)
deployment.netbird_api_token_renewed_at = datetime.utcnow()
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.
+45 -1
View File
@@ -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),
@@ -0,0 +1,129 @@
"""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 renew_token(container_prefix: str, token: str) -> dict[str, Any]:
"""Mint a fresh 365-day Personal Access Token using the current one.
The old token is left in place (it naturally expires and NetBird gives no
reliable way to identify "our" token among a user's other PATs by content
alone, only by name — which isn't safe to assume is unique). One
harmless unused token lingering until its own expiry is an acceptable
trade-off for not risking deleting a token that turns out to be in use.
"""
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/users", headers=headers)
if resp.status_code != 200:
return {"ok": False, "error": f"GET /api/users -> HTTP {resp.status_code}"}
users = resp.json()
me = next((u for u in users if u.get("is_current")), None)
if not me:
return {"ok": False, "error": "Could not identify current user from /api/users."}
create_resp = await client.post(
f"{base_url}/api/users/{me['id']}/tokens",
headers={**headers, "Content-Type": "application/json"},
content=json.dumps({"name": "MSP Central Management", "expires_in": 365}),
)
if create_resp.status_code not in (200, 201):
return {"ok": False, "error": f"POST tokens -> HTTP {create_resp.status_code}: {create_resp.text[:200]}"}
new_token = create_resp.json().get("plain_token")
if not new_token:
return {"ok": False, "error": "Token creation response had no plain_token."}
verify_resp = await client.get(f"{base_url}/api/accounts", headers={"Authorization": f"Token {new_token}"})
if verify_resp.status_code != 200:
return {"ok": False, "error": "New token failed verification."}
return {"ok": True, "token": new_token}
except Exception as exc:
logger.warning("Failed to renew NetBird API token 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)}
+14 -1
View File
@@ -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
if netbird_api_token:
deployment.netbird_api_token_encrypted = encrypt_value(netbird_api_token)
deployment.netbird_api_token_renewed_at = datetime.utcnow()
deployment.deployment_status = "running"
deployment.deployed_at = datetime.utcnow()
else:
@@ -354,6 +365,8 @@ 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,
netbird_api_token_renewed_at=datetime.utcnow() if netbird_api_token else None,
deployment_status="running",
deployed_at=datetime.utcnow(),
)
+57 -2
View File
@@ -8,17 +8,23 @@ SystemConfig.auto_update_check_enabled / auto_update_check_time.
import asyncio
import logging
from datetime import datetime
from datetime import datetime, timedelta
from app.database import SessionLocal
from app.models import Deployment, SystemConfig
from app.services import image_service
from app.services import image_service, netbird_client_update_service
from app.utils.security import decrypt_value, encrypt_value
logger = logging.getLogger(__name__)
_POLL_INTERVAL_SECONDS = 60
_task: asyncio.Task | None = None
# NetBird PATs we mint are issued for 365 days; renew well before that so a
# missed tick or a slow rollout never risks the token actually expiring.
_TOKEN_RENEW_AFTER_DAYS = 300
_TOKEN_RENEW_CHECK_HOUR = 4 # run once per day, distinct from the image-check hour
def start() -> None:
"""Start the background polling task. Safe to call once at app startup."""
@@ -36,15 +42,64 @@ def stop() -> None:
_task = None
_last_token_renewal_date = None
async def _poll_loop() -> None:
while True:
try:
await _tick()
except Exception:
logger.exception("Scheduler tick failed")
try:
await _token_renewal_tick()
except Exception:
logger.exception("Token renewal tick failed")
await asyncio.sleep(_POLL_INTERVAL_SECONDS)
async def _token_renewal_tick() -> None:
"""Once a day, renew any NetBird client-update API token nearing its
365-day expiry — keeps central update control working indefinitely
without anyone needing to notice or act.
"""
global _last_token_renewal_date
now = datetime.now()
if now.hour != _TOKEN_RENEW_CHECK_HOUR:
return
if _last_token_renewal_date == now.date():
return
_last_token_renewal_date = now.date()
db = SessionLocal()
try:
cutoff = now - timedelta(days=_TOKEN_RENEW_AFTER_DAYS)
deployments = (
db.query(Deployment)
.filter(Deployment.netbird_api_token_encrypted.isnot(None))
.all()
)
due = [
d for d in deployments
if d.netbird_api_token_renewed_at is None or d.netbird_api_token_renewed_at < cutoff
]
if not due:
return
logger.info("Renewing NetBird API token for %d customer(s)...", len(due))
for d in due:
token = decrypt_value(d.netbird_api_token_encrypted)
result = await netbird_client_update_service.renew_token(d.container_prefix, token)
if result["ok"]:
d.netbird_api_token_encrypted = encrypt_value(result["token"])
d.netbird_api_token_renewed_at = now
db.commit()
logger.info("Renewed NetBird API token for %s.", d.container_prefix)
else:
logger.warning("Failed to renew NetBird API token for %s: %s", d.container_prefix, result.get("error"))
finally:
db.close()
async def _tick() -> None:
db = SessionLocal()
try:
+24
View File
@@ -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
@@ -218,6 +221,27 @@ class SystemConfigUpdate(BaseModel):
return v.lower().strip()
# ---------------------------------------------------------------------------
# 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)
# ---------------------------------------------------------------------------
# Users
# ---------------------------------------------------------------------------
+27
View File
@@ -668,6 +668,33 @@
<button type="submit" class="btn btn-primary btn-sm"><i class="bi bi-save me-1"></i><span data-i18n="monitoring.saveAutoUpdateSettings">Save Automation</span></button>
</div>
</form>
<hr>
<h6 data-i18n="customer.nbuMasterTitle">NetBird Client Auto-Updates (all customers)</h6>
<p class="text-muted small" data-i18n="customer.nbuMasterHint">Controls the "Automatic Updates" setting inside every customer's own NetBird dashboard (Settings &gt; Clients). Set the default here, then push it to all customers at once. Individual customers can still be overridden from their detail page.</p>
<form id="settings-nbu-master-form">
<div class="row g-2 align-items-end">
<div class="col-auto">
<label class="form-label small mb-1" data-i18n="customer.nbuVersion">Client version</label>
<select class="form-select form-select-sm" id="cfg-nbu-version-select" onchange="document.getElementById('cfg-nbu-custom-version').classList.toggle('d-none', this.value !== 'custom')">
<option value="disabled" data-i18n="customer.nbuDisabled">Disabled</option>
<option value="latest" data-i18n="customer.nbuLatest">Latest</option>
<option value="custom" data-i18n="customer.nbuCustom">Specific version</option>
</select>
</div>
<div class="col-auto">
<input type="text" class="form-control form-control-sm d-none" id="cfg-nbu-custom-version" placeholder="0.61.0">
</div>
<div class="col-auto form-check pb-1">
<input class="form-check-input" type="checkbox" id="cfg-nbu-always">
<label class="form-check-label small" for="cfg-nbu-always" data-i18n="customer.nbuForce">Force automatic updates</label>
</div>
</div>
<div class="mt-3">
<button type="submit" class="btn btn-primary btn-sm me-2"><i class="bi bi-save me-1"></i><span data-i18n="customer.nbuSaveDefault">Save Default</span></button>
<button type="button" class="btn btn-outline-warning btn-sm" id="btn-nbu-apply-all" onclick="applyNetbirdUpdatesToAll()"><i class="bi bi-broadcast me-1"></i><span data-i18n="customer.nbuApplyAll">Apply to All Customers</span></button>
</div>
</form>
<div id="nbu-apply-all-result" class="mt-3"></div>
</div>
</div>
</div>
+217
View File
@@ -571,6 +571,146 @@ 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]) =>
`<option value="${v}" ${(!isCustom && v === selected) || (isCustom && v === 'custom') ? 'selected' : ''}>${label}</option>`
).join('');
}
async function loadCustomerNetbirdUpdates(id, hasToken) {
const container = document.getElementById('nbu-container');
if (!container) return;
if (!hasToken) {
container.innerHTML = `
<p class="text-muted small mb-2">${t('customer.nbuNoToken')}</p>
<div class="input-group input-group-sm">
<input type="text" class="form-control" id="nbu-token-input" placeholder="${t('customer.nbuTokenPlaceholder')}">
<button class="btn btn-outline-primary" onclick="saveCustomerNetbirdToken(${id})">${t('customer.nbuSaveToken')}</button>
</div>
<div id="nbu-token-result" class="small mt-1"></div>`;
return;
}
container.innerHTML = `<span class="spinner-border spinner-border-sm"></span>`;
try {
const data = await api('GET', `/customers/${id}/netbird-updates`);
const isCustom = data.version && data.version !== 'disabled' && data.version !== 'latest';
let tokenInfoHtml = '';
if (data.token_renewed_at) {
const renewedDate = new Date(data.token_renewed_at);
const expiresDate = new Date(renewedDate);
expiresDate.setDate(expiresDate.getDate() + 365);
tokenInfoHtml = `<div class="small text-muted mb-2">${t('customer.nbuTokenRenewed', { date: renewedDate.toLocaleDateString() })} &middot; ${t('customer.nbuTokenExpires', { date: expiresDate.toLocaleDateString() })}</div>`;
}
container.innerHTML = `
${tokenInfoHtml}
<div class="row g-2 align-items-end">
<div class="col-auto">
<label class="form-label small mb-1">${t('customer.nbuVersion')}</label>
<select class="form-select form-select-sm" id="nbu-version-select" onchange="document.getElementById('nbu-custom-version').classList.toggle('d-none', this.value !== 'custom')">
${_nbuVersionOptions(data.version)}
</select>
</div>
<div class="col-auto">
<input type="text" class="form-control form-control-sm ${isCustom ? '' : 'd-none'}" id="nbu-custom-version" placeholder="0.61.0" value="${isCustom ? esc(data.version) : ''}">
</div>
<div class="col-auto form-check pb-1">
<input class="form-check-input" type="checkbox" id="nbu-always" ${data.always ? 'checked' : ''}>
<label class="form-check-label small" for="nbu-always">${t('customer.nbuForce')}</label>
</div>
<div class="col-auto">
<button class="btn btn-primary btn-sm" onclick="saveCustomerNetbirdUpdate(${id})">${t('customer.nbuSave')}</button>
<button class="btn btn-outline-secondary btn-sm" onclick="syncCustomerNetbirdFromMaster(${id})">${t('customer.nbuSyncMaster')}</button>
</div>
</div>
<div id="nbu-result" class="small mt-2"></div>
<div class="mt-2">
<a href="#" class="small" onclick="event.preventDefault(); showChangeNetbirdToken(${id})">${t('customer.nbuChangeToken')}</a>
</div>
<div id="nbu-change-token-area" class="mt-2 d-none"></div>`;
} catch (err) {
container.innerHTML = `<div class="alert alert-warning py-2 small mb-0">${esc(err.message)}</div>`;
}
}
function showChangeNetbirdToken(id) {
const area = document.getElementById('nbu-change-token-area');
area.classList.remove('d-none');
area.innerHTML = `
<div class="input-group input-group-sm">
<input type="text" class="form-control" id="nbu-token-input" placeholder="${t('customer.nbuTokenPlaceholder')}">
<button class="btn btn-outline-primary" onclick="saveCustomerNetbirdToken(${id})">${t('customer.nbuSaveToken')}</button>
</div>
<div id="nbu-token-result" class="small mt-1"></div>`;
}
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 = `<span class="spinner-border spinner-border-sm"></span>`;
try {
await api('PUT', `/customers/${id}/netbird-api-token`, { token });
showToast(t('customer.nbuTokenSaved'));
loadCustomerNetbirdUpdates(id, true);
} catch (err) {
resultEl.innerHTML = `<span class="text-danger">${esc(err.message)}</span>`;
}
}
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 = `<span class="text-danger">${t('customer.nbuVersionRequired')}</span>`;
return;
}
resultEl.innerHTML = `<span class="spinner-border spinner-border-sm"></span>`;
try {
await api('PUT', `/customers/${id}/netbird-updates`, payload);
showToast(t('customer.nbuSaved'));
loadCustomerNetbirdUpdates(id, true);
} catch (err) {
resultEl.innerHTML = `<span class="text-danger">${esc(err.message)}</span>`;
}
}
async function syncCustomerNetbirdFromMaster(id) {
const resultEl = document.getElementById('nbu-result');
resultEl.innerHTML = `<span class="spinner-border spinner-border-sm"></span>`;
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 = `<span class="text-danger">${esc(err.message)}</span>`;
}
}
// 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 +952,14 @@ async function viewCustomer(id) {
` : `<p class="text-muted mb-0">${t('customer.credentialsNotAvailable')}</p>`}
</div>
</div>
<div class="card mt-3">
<div class="card-header">
<strong><i class="bi bi-phone me-1"></i>${t('customer.netbirdClientUpdates')}</strong>
</div>
<div class="card-body" id="nbu-container">
<span class="spinner-border spinner-border-sm"></span>
</div>
</div>
<div class="mt-3">
<button class="btn btn-success btn-sm me-1" onclick="customerAction(${id},'start')"><i class="bi bi-play-circle me-1"></i>${t('customer.start')}</button>
<button class="btn btn-warning btn-sm me-1" onclick="customerAction(${id},'stop')"><i class="bi bi-stop-circle me-1"></i>${t('customer.stop')}</button>
@@ -824,6 +972,7 @@ async function viewCustomer(id) {
</div>
<div id="detail-update-result"></div>
`;
loadCustomerNetbirdUpdates(id, d.has_netbird_api_token);
} else {
document.getElementById('detail-deployment-content').innerHTML = `
<p class="text-muted">${t('customer.noDeployment')}</p>
@@ -947,6 +1096,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 +1236,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 = `<span class="spinner-border spinner-border-sm me-2"></span>${t('common.loading')}`;
try {
const data = await api('POST', '/monitoring/netbird-updates/apply-all', { version, always });
const rows = data.results.map(r => `<tr>
<td>${esc(r.customer_name)}</td>
<td>${r.success
? '<span class="badge bg-success"><i class="bi bi-check-lg"></i> OK</span>'
: '<span class="badge bg-danger"><i class="bi bi-x-lg"></i> Error</span>'}</td>
<td class="small text-muted">${esc(r.error || '')}</td>
</tr>`).join('');
resultDiv.innerHTML = `<div class="alert alert-${data.updated === data.results.length ? 'success' : 'warning'}">
<strong>${esc(data.message)}</strong>
<table class="table table-sm mb-0 mt-2">
<thead><tr><th>${t('monitoring.thName')}</th><th>${t('monitoring.thStatus')}</th><th></th></tr></thead>
<tbody>${rows}</tbody>
</table>
</div>`;
} catch (err) {
resultDiv.innerHTML = `<div class="alert alert-danger">${esc(err.message)}</div>`;
} finally {
btn.disabled = false;
}
}
// Test NPM connection
async function testNpmConnection() {
const spinner = document.getElementById('npm-test-spinner');
+24 -1
View File
@@ -91,7 +91,30 @@
"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.",
"nbuChangeToken": "Token ändern",
"nbuTokenRenewed": "Token erneuert am {date}",
"nbuTokenExpires": "gültig bis {date}",
"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",
+24 -1
View File
@@ -91,7 +91,30 @@
"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.",
"nbuChangeToken": "Change token",
"nbuTokenRenewed": "Token renewed on {date}",
"nbuTokenExpires": "valid until {date}",
"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",
+5
View File
@@ -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: