Compare commits

...
5 Commits
Author SHA1 Message Date
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 666 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_check_time", "TEXT DEFAULT '03:00'"),
("system_config", "auto_update_apply_enabled", "BOOLEAN DEFAULT 0"), ("system_config", "auto_update_apply_enabled", "BOOLEAN DEFAULT 0"),
("system_config", "auto_update_last_run_at", "TEXT"), ("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: for table, column, col_type in migrations:
if not _has_column(table, column): if not _has_column(table, column):
+1 -1
View File
@@ -34,7 +34,7 @@ logger = logging.getLogger(__name__)
app = FastAPI( app = FastAPI(
title="NetBird MSP Appliance", title="NetBird MSP Appliance",
description="Multi-tenant NetBird management platform for MSPs", description="Multi-tenant NetBird management platform for MSPs",
version="1.3.0", version="1.4.1",
docs_url="/api/docs", docs_url="/api/docs",
redoc_url="/api/redoc", redoc_url="/api/redoc",
openapi_url="/api/openapi.json", 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) setup_url: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
netbird_admin_email: 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_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( deployment_status: Mapped[str] = mapped_column(
String(20), default="pending", nullable=False String(20), default="pending", nullable=False
) )
@@ -116,6 +118,10 @@ class Deployment(Base):
"relay_secret": "***", # Never expose secrets "relay_secret": "***", # Never expose secrets
"setup_url": self.setup_url, "setup_url": self.setup_url,
"has_credentials": bool(self.netbird_admin_email and self.netbird_admin_password), "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, "deployment_status": self.deployment_status,
"deployed_at": self.deployed_at.isoformat() if self.deployed_at else None, "deployed_at": self.deployed_at.isoformat() if self.deployed_at else None,
"last_health_check": ( "last_health_check": (
@@ -205,6 +211,12 @@ class SystemConfig(Base):
auto_update_apply_enabled: Mapped[bool] = mapped_column(Boolean, default=False) auto_update_apply_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
auto_update_last_run_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) 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) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
@@ -265,6 +277,8 @@ class SystemConfig(Base):
"auto_update_last_run_at": ( "auto_update_last_run_at": (
self.auto_update_last_run_at.isoformat() if self.auto_update_last_run_at else None 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, "created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None,
} }
+111 -2
View File
@@ -1,6 +1,7 @@
"""Deployment management API — start, stop, restart, logs, health for customers.""" """Deployment management API — start, stop, restart, logs, health for customers."""
import logging import logging
from datetime import datetime
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -8,8 +9,9 @@ from sqlalchemy.orm import Session
from app.database import SessionLocal, get_db from app.database import SessionLocal, get_db
from app.dependencies import get_current_user from app.dependencies import get_current_user
from app.models import Customer, Deployment, SystemConfig, User from app.models import Customer, Deployment, SystemConfig, User
from app.services import docker_service, image_service, netbird_service from app.services import docker_service, image_service, netbird_client_update_service, netbird_service
from app.utils.security import decrypt_value from app.utils.security import decrypt_value, encrypt_value
from app.utils.validators import NetbirdApiTokenPayload, NetbirdClientAutoUpdatePayload
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@@ -260,6 +262,113 @@ async def update_customer_images(
return {"message": f"Containers updated for '{customer.name}'."} 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)
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: def _require_customer(db: Session, customer_id: int) -> Customer:
"""Helper to fetch a customer or raise 404. """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.database import SessionLocal, get_db
from app.dependencies import get_current_user from app.dependencies import get_current_user
from app.models import Customer, Deployment, SystemConfig, 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__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@@ -241,6 +243,48 @@ async def customers_local_update_status(
return results 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") @router.post("/customers/update-all")
async def update_all_customers( async def update_all_customers(
current_user: User = Depends(get_current_user), 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, "name": customer.name,
"email": admin_email, "email": admin_email,
"password": admin_password, "password": admin_password,
"create_pat": True,
"pat_expire_in": 365,
}).encode("utf-8") }).encode("utf-8")
setup_ok = False setup_ok = False
netbird_api_token: str | None = None
for attempt in range(10): for attempt in range(10):
try: try:
req = urllib.request.Request( 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: with urllib.request.urlopen(req, timeout=10) as resp:
if resp.status in (200, 201): if resp.status in (200, 201):
setup_ok = True 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", _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 break
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", errors="replace") 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.setup_url = setup_url
deployment.netbird_admin_email = encrypt_value(admin_email) if setup_ok else deployment.netbird_admin_email 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_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.deployment_status = "running"
deployment.deployed_at = datetime.utcnow() deployment.deployed_at = datetime.utcnow()
else: else:
@@ -354,6 +365,8 @@ async def deploy_customer(db: Session, customer_id: int) -> dict[str, Any]:
setup_url=setup_url, setup_url=setup_url,
netbird_admin_email=encrypt_value(admin_email) if setup_ok else None, 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_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", deployment_status="running",
deployed_at=datetime.utcnow(), deployed_at=datetime.utcnow(),
) )
+57 -2
View File
@@ -8,17 +8,23 @@ SystemConfig.auto_update_check_enabled / auto_update_check_time.
import asyncio import asyncio
import logging import logging
from datetime import datetime from datetime import datetime, timedelta
from app.database import SessionLocal from app.database import SessionLocal
from app.models import Deployment, SystemConfig 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__) logger = logging.getLogger(__name__)
_POLL_INTERVAL_SECONDS = 60 _POLL_INTERVAL_SECONDS = 60
_task: asyncio.Task | None = None _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: def start() -> None:
"""Start the background polling task. Safe to call once at app startup.""" """Start the background polling task. Safe to call once at app startup."""
@@ -36,15 +42,64 @@ def stop() -> None:
_task = None _task = None
_last_token_renewal_date = None
async def _poll_loop() -> None: async def _poll_loop() -> None:
while True: while True:
try: try:
await _tick() await _tick()
except Exception: except Exception:
logger.exception("Scheduler tick failed") logger.exception("Scheduler tick failed")
try:
await _token_renewal_tick()
except Exception:
logger.exception("Token renewal tick failed")
await asyncio.sleep(_POLL_INTERVAL_SECONDS) 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: async def _tick() -> None:
db = SessionLocal() db = SessionLocal()
try: try:
+24
View File
@@ -162,6 +162,9 @@ class SystemConfigUpdate(BaseModel):
auto_update_check_enabled: Optional[bool] = None auto_update_check_enabled: Optional[bool] = None
auto_update_check_time: Optional[str] = Field(None, max_length=5) auto_update_check_time: Optional[str] = Field(None, max_length=5)
auto_update_apply_enabled: Optional[bool] = None 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") @field_validator("auto_update_check_time")
@classmethod @classmethod
@@ -218,6 +221,27 @@ class SystemConfigUpdate(BaseModel):
return v.lower().strip() 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 # 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> <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> </div>
</form> </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> </div>
</div> </div>
+192
View File
@@ -571,6 +571,121 @@ function goToPage(page) {
loadCustomers(); 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';
container.innerHTML = `
<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>`;
} catch (err) {
container.innerHTML = `<div class="alert alert-warning py-2 small mb-0">${esc(err.message)}</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 // Search & filter listeners
document.getElementById('search-input').addEventListener('input', debounce(() => { customersPage = 1; loadCustomers(); }, 300)); document.getElementById('search-input').addEventListener('input', debounce(() => { customersPage = 1; loadCustomers(); }, 300));
document.getElementById('status-filter').addEventListener('change', () => { customersPage = 1; loadCustomers(); }); document.getElementById('status-filter').addEventListener('change', () => { customersPage = 1; loadCustomers(); });
@@ -812,6 +927,14 @@ async function viewCustomer(id) {
` : `<p class="text-muted mb-0">${t('customer.credentialsNotAvailable')}</p>`} ` : `<p class="text-muted mb-0">${t('customer.credentialsNotAvailable')}</p>`}
</div> </div>
</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"> <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-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> <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 +947,7 @@ async function viewCustomer(id) {
</div> </div>
<div id="detail-update-result"></div> <div id="detail-update-result"></div>
`; `;
loadCustomerNetbirdUpdates(id, d.has_netbird_api_token);
} else { } else {
document.getElementById('detail-deployment-content').innerHTML = ` document.getElementById('detail-deployment-content').innerHTML = `
<p class="text-muted">${t('customer.noDeployment')}</p> <p class="text-muted">${t('customer.noDeployment')}</p>
@@ -947,6 +1071,13 @@ async function loadSettings() {
? new Date(cfg.auto_update_last_run_at).toLocaleString() ? new Date(cfg.auto_update_last_run_at).toLocaleString()
: t('monitoring.autoUpdateNever'); : 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 // Branding tab
document.getElementById('cfg-branding-name').value = cfg.branding_name || ''; document.getElementById('cfg-branding-name').value = cfg.branding_name || '';
document.getElementById('cfg-branding-subtitle').value = cfg.branding_subtitle || ''; 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 = `<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 // Test NPM connection
async function testNpmConnection() { async function testNpmConnection() {
const spinner = document.getElementById('npm-test-spinner'); const spinner = document.getElementById('npm-test-spinner');
+21 -1
View File
@@ -91,7 +91,27 @@
"lastCheck": "Letzte Prüfung: {time}", "lastCheck": "Letzte Prüfung: {time}",
"openDashboard": "Dashboard öffnen", "openDashboard": "Dashboard öffnen",
"updateImages": "Images aktualisieren", "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": { "settings": {
"title": "Systemeinstellungen", "title": "Systemeinstellungen",
+21 -1
View File
@@ -91,7 +91,27 @@
"lastCheck": "Last check: {time}", "lastCheck": "Last check: {time}",
"openDashboard": "Open Dashboard", "openDashboard": "Open Dashboard",
"updateImages": "Update Images", "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": { "customerModal": {
"newCustomer": "New Customer", "newCustomer": "New Customer",
+5
View File
@@ -20,6 +20,11 @@ services:
image: {{ netbird_management_image }} image: {{ netbird_management_image }}
container_name: netbird-{{ subdomain }}-management container_name: netbird-{{ subdomain }}-management
restart: unless-stopped 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: networks:
- {{ docker_network }} - {{ docker_network }}
volumes: volumes: