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
130 lines
5.7 KiB
Python
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)}
|