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.
This commit is contained in:
2026-08-19 09:24:13 +02:00
parent 51fbd44809
commit 6d333223a8
12 changed files with 558 additions and 6 deletions
+109 -2
View File
@@ -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.
+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),