Files
NetBirdMSP-Appliance/app/services/netbird_client_update_service.py
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

130 lines
5.7 KiB
Python

"""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)}