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:
@@ -127,6 +127,10 @@ 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"),
|
||||
("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):
|
||||
|
||||
@@ -88,6 +88,7 @@ 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)
|
||||
deployment_status: Mapped[str] = mapped_column(
|
||||
String(20), default="pending", nullable=False
|
||||
)
|
||||
@@ -116,6 +117,7 @@ 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),
|
||||
"deployment_status": self.deployment_status,
|
||||
"deployed_at": self.deployed_at.isoformat() if self.deployed_at else None,
|
||||
"last_health_check": (
|
||||
@@ -205,6 +207,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 +273,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,
|
||||
}
|
||||
|
||||
+109
-2
@@ -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.
|
||||
|
||||
|
||||
@@ -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,87 @@
|
||||
"""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 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)}
|
||||
@@ -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
|
||||
deployment.netbird_api_token_encrypted = (
|
||||
encrypt_value(netbird_api_token) if netbird_api_token else deployment.netbird_api_token_encrypted
|
||||
)
|
||||
deployment.deployment_status = "running"
|
||||
deployment.deployed_at = datetime.utcnow()
|
||||
else:
|
||||
@@ -354,6 +365,7 @@ 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,
|
||||
deployment_status="running",
|
||||
deployed_at=datetime.utcnow(),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -174,6 +177,27 @@ class SystemConfigUpdate(BaseModel):
|
||||
raise ValueError("auto_update_check_time must be in HH:MM 24h format")
|
||||
return v
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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)
|
||||
|
||||
@field_validator("ssl_mode")
|
||||
@classmethod
|
||||
def validate_ssl_mode(cls, v: Optional[str]) -> Optional[str]:
|
||||
|
||||
Reference in New Issue
Block a user