Compare commits

..
14 Commits
Author SHA1 Message Date
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
twothatIT 51fbd44809 chore(release): bump version to 1.3.0 2026-08-19 09:12:08 +02:00
twothatIT e53539231e fix(monitoring): repair silent-false-positive update badge + auto-update scheduling
Customer container status checks looked up containers by an exact expected
name. When a docker compose recreate got interrupted (e.g. a hung command
previously killed the whole update-all batch on timeout), Compose could leave
the old container renamed with a random hash prefix instead of removed. The
exact-name lookup then found nothing, returned None, and that silently
counted as "up to date" (green "Aktuell") instead of surfacing as unknown —
affecting 5 customers on the appliance whose containers were actually still
running under orphaned names.

- _run_cmd no longer raises on subprocess timeout, so one stuck customer
  can't abort the rest of a batch update
- repair_container_naming() self-heals orphaned hash-renamed containers by
  renaming them back before every status check and before recreate
- update-all loop now catches per-customer exceptions instead of aborting
- status responses expose "unknown" separately from "needs_update" so the UI
  shows a distinct grey badge instead of a false-positive green one
- new settings: automatic daily update check (on/off + time), with an
  independent toggle for whether it also auto-recreates customer containers
2026-08-19 09:10:29 +02:00
twothatITandClaude Sonnet 5 0e38b8083c docs(readme): update Updates section and API reference to match current app
- Document the one-click background update with live progress as the
  recommended path (manual git pull/compose is now the fallback)
- Add sortable customer columns feature
- Add missing API endpoints (settings/version, settings/update, update/status,
  sort_by/sort_order on GET /customers)
- Add missing Performance Tuning entry to table of contents
- De-pin the "Built With AI" model name so it doesn't go stale

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-23 15:32:54 +02:00
twothatITandClaude Sonnet 5 0e2b292408 feat(customers): sortable table columns + default ascending ID order
- Customer list now defaults to ascending ID order instead of newest-first,
  so the table starts at customer #1 instead of the highest ID
- Add sort_by/sort_order query params to GET /customers (whitelisted column
  map to prevent SQL injection via arbitrary column names)
- Make ID/Name/Subdomain/Status/Devices/Created column headers clickable,
  toggling asc/desc with a visual arrow indicator

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-23 15:16:31 +02:00
twothatITandClaude Sonnet 5 a5988af6a3 fix(update): stop blocking event loop during rebuild + fix infinite spinner
The update endpoint ran the entire git pull + docker build (up to 10 min)
synchronously inside the request handler, blocking the whole server for
everyone while it ran. Separately, the frontend spinner was only hidden on
error, never on success, so it spun forever even when the update worked.

- Run the update in a background thread; the request returns immediately
- Add GET /settings/update/status for progress polling (backup/pull/build/restart)
- Frontend polls status, then waits for the app to come back after the
  container restart, and shows a clear done/timeout message instead of an
  endless spinner

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-23 15:09:42 +02:00
twothatITandClaude Sonnet 5 c5189d88fe perf(npm): cache NPM JWT instead of re-authenticating on every API call
Every NPM helper (proxy host create/update/delete, streams, certs) did a
fresh POST /api/tokens login before its actual request, adding an avoidable
round-trip to every proxy/stream operation.

- Cache the JWT per (api_url, email), sized from its 'exp' claim
- Transparently re-authenticate and retry once on a 401 (e.g. after an NPM
  restart invalidates a cached token), so a stale cache entry can't cause a
  hard failure

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-23 14:51:23 +02:00
twothatITandClaude Sonnet 5 ac843da4ca perf(monitoring): stop blocking event loop with synchronous Docker calls
Customer search and detail loads were intermittently slow because every
customer-table render (including each search keystroke) triggered
/monitoring/customers/local-update-status, which looped synchronously over
all customers doing blocking `docker inspect` subprocess calls on the event
loop — stalling all other in-flight requests, including search itself.

- Offload per-service image/container inspection to the thread pool and run
  checks concurrently instead of sequentially (image_service, docker_service)
- Reuse a single Docker SDK client instead of reconnecting per customer
- Cache local-update-status results for 20s since the underlying data only
  changes after an image pull, not on every keystroke
- Parallelize /monitoring/customers container status lookups

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-23 14:44:56 +02:00
twothatITandClaude Sonnet 4.6 f6b7eb2dae fix(npm): add gRPC read/send timeouts to proxy host location blocks
Adds grpc_read_timeout 3600s and grpc_send_timeout 3600s to both
ManagementService and SignalExchange location blocks to prevent
long-lived gRPC connections from being dropped by Nginx.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-06 12:01:14 +02:00
twothatITandClaude Sonnet 4.6 8ede0f0a3c fix(deploy): fix redeploy button broken by JSON.stringify double quotes
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-03-10 22:13:23 +01:00
twothatITandClaude Sonnet 4.6 8040973227 fix(deploy): fix redeploy button broken by JSON.stringify double quotes
JSON.stringify('Name') produces "Name" with double quotes which breaks
the onclick attribute. Use data-customer-name attribute instead and
read it via this.dataset.customerName to avoid quoting issues.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-03-10 22:13:06 +01:00
twothatITandClaude Sonnet 4.6 3cdc82f919 fix(deploy): show customer name in redeploy modal instead of ID
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-03-10 22:08:35 +01:00
twothatITandClaude Sonnet 4.6 40595fc381 fix(deploy): show customer name in redeploy modal instead of ID
The modal was showing '#2' instead of the customer name when opened
from the customer detail view, because the dashboard table row was
not visible. Now the name is passed directly from the button's onclick
context where data.name is already available.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-03-10 22:08:17 +01:00
22 changed files with 1364 additions and 80 deletions
+18 -4
View File
@@ -23,6 +23,7 @@ A management solution for running isolated NetBird instances for your MSP busine
- [Troubleshooting](#troubleshooting) - [Troubleshooting](#troubleshooting)
- [Updates](#updates) - [Updates](#updates)
- [Security Best Practices](#security-best-practices) - [Security Best Practices](#security-best-practices)
- [Performance Tuning](#performance-tuning)
- [License](#license) - [License](#license)
--- ---
@@ -44,6 +45,7 @@ A management solution for running isolated NetBird instances for your MSP busine
- **Start / Stop / Restart** — Control customer instances from the dashboard - **Start / Stop / Restart** — Control customer instances from the dashboard
- **Customer Status Tracking** — Automatic status sync (active / inactive / error) - **Customer Status Tracking** — Automatic status sync (active / inactive / error)
- **Update Indicators** — Per-customer badges when container images are outdated - **Update Indicators** — Per-customer badges when container images are outdated
- **Sortable Columns** — Click any customer list column header (ID, Name, Subdomain, Status, Devices, Created) to sort ascending/descending
### NetBird Container Updates ### NetBird Container Updates
- **Docker Hub Digest Check** — Compare locally pulled image digests against Docker Hub without pulling - **Docker Hub Digest Check** — Compare locally pulled image digests against Docker Hub without pulling
@@ -74,7 +76,7 @@ A management solution for running isolated NetBird instances for your MSP busine
### Integrations ### Integrations
- **Windows DNS** — Automatically create and delete DNS A-records when deploying or removing customers - **Windows DNS** — Automatically create and delete DNS A-records when deploying or removing customers
- **MSP Updates** — In-UI appliance update check with configurable release branch - **MSP Updates** — In-UI appliance version check, configurable release branch, and one-click background update with live progress
--- ---
@@ -483,7 +485,7 @@ http://your-server:8000/api/docs
**Common Endpoints:** **Common Endpoints:**
``` ```
POST /api/customers # Create customer + deploy POST /api/customers # Create customer + deploy
GET /api/customers # List all customers GET /api/customers # List customers (supports search, status filter, sort_by/sort_order)
GET /api/customers/{id} # Get customer details GET /api/customers/{id} # Get customer details
PUT /api/customers/{id} # Update customer PUT /api/customers/{id} # Update customer
DELETE /api/customers/{id} # Delete customer DELETE /api/customers/{id} # Delete customer
@@ -498,6 +500,9 @@ POST /api/customers/{id}/update-images # Recreate containers with new images
GET /api/settings/branding # Get branding (public, no auth) GET /api/settings/branding # Get branding (public, no auth)
GET /api/settings/npm-certificates # List NPM SSL certificates GET /api/settings/npm-certificates # List NPM SSL certificates
PUT /api/settings # Update system settings PUT /api/settings # Update system settings
GET /api/settings/version # Current + latest available appliance version
POST /api/settings/update # Start appliance update in the background
GET /api/settings/update/status # Poll update progress (backup/pull/build/restart)
GET /api/users # List users GET /api/users # List users
POST /api/users # Create user POST /api/users # Create user
@@ -581,6 +586,15 @@ docker logs -f netbird-msp-appliance
### Updating the Appliance ### Updating the Appliance
The recommended way to update is the built-in one-click updater:
1. Go to **Settings > NetBird MSP Updates**
2. Configure the Git repository URL and branch (defaults to `main`) if not already set
3. Click **"Update starten"** ("Start Update")
This backs up the database, pulls the configured branch, rebuilds the container image, and swaps in the new container — all in the background. The page shows live progress (backup → pull → build → restart) and automatically detects when the app is back up, so there's no need to babysit a terminal. The app is unavailable for roughly 30-60 seconds during the container swap.
**Manual update (fallback, e.g. no Web UI access):**
```bash ```bash
cd /opt/netbird-msp cd /opt/netbird-msp
git pull git pull
@@ -588,7 +602,7 @@ docker compose down
docker compose up -d --build docker compose up -d --build
``` ```
The database migrations run automatically on startup. The database migrations run automatically on startup either way.
### Updating NetBird Images ### Updating NetBird Images
@@ -667,7 +681,7 @@ MIT License — see [LICENSE](LICENSE) file for details.
## Built With AI ## Built With AI
This software was developed with [Claude Code](https://claude.ai/claude-code) (Anthropic Claude Sonnet 4.6) — from architecture and backend logic to frontend UI and deployment scripts. This software was developed and is continuously maintained with [Claude Code](https://claude.ai/claude-code) (Anthropic) — from architecture and backend logic to frontend UI and deployment scripts.
## Acknowledgments ## Acknowledgments
+9
View File
@@ -122,6 +122,15 @@ def _run_migrations() -> None:
("system_config", "git_repo_url", "TEXT"), ("system_config", "git_repo_url", "TEXT"),
("system_config", "git_branch", "TEXT DEFAULT 'main'"), ("system_config", "git_branch", "TEXT DEFAULT 'main'"),
("system_config", "git_token_encrypted", "TEXT"), ("system_config", "git_token_encrypted", "TEXT"),
# Automatic NetBird image update check/apply
("system_config", "auto_update_check_enabled", "BOOLEAN DEFAULT 0"),
("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: for table, column, col_type in migrations:
if not _has_column(table, column): if not _has_column(table, column):
+9 -1
View File
@@ -13,6 +13,7 @@ from slowapi.errors import RateLimitExceeded
from app.database import init_db from app.database import init_db
from app.limiter import limiter from app.limiter import limiter
from app.routers import auth, customers, deployments, monitoring, settings, users from app.routers import auth, customers, deployments, monitoring, settings, users
from app.services import scheduler_service
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Logging # Logging
@@ -33,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.0.0", version="1.4.0",
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",
@@ -140,3 +141,10 @@ async def startup_event():
logger.info("Starting NetBird MSP Appliance...") logger.info("Starting NetBird MSP Appliance...")
init_db() init_db()
logger.info("Database initialized.") logger.info("Database initialized.")
scheduler_service.start()
@app.on_event("shutdown")
async def shutdown_event():
"""Stop background tasks on shutdown."""
scheduler_service.stop()
+22
View File
@@ -88,6 +88,7 @@ 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)
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 +117,7 @@ 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),
"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": (
@@ -199,6 +201,18 @@ class SystemConfig(Base):
git_branch: Mapped[Optional[str]] = mapped_column(String(100), default="main") git_branch: Mapped[Optional[str]] = mapped_column(String(100), default="main")
git_token_encrypted: Mapped[Optional[str]] = mapped_column(Text, nullable=True) git_token_encrypted: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# Automatic NetBird image update check/apply
auto_update_check_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
auto_update_check_time: Mapped[Optional[str]] = mapped_column(String(5), default="03:00")
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) 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
@@ -253,6 +267,14 @@ class SystemConfig(Base):
"git_repo_url": self.git_repo_url or "", "git_repo_url": self.git_repo_url or "",
"git_branch": self.git_branch or "main", "git_branch": self.git_branch or "main",
"git_token_set": bool(self.git_token_encrypted), "git_token_set": bool(self.git_token_encrypted),
"auto_update_check_enabled": bool(self.auto_update_check_enabled),
"auto_update_check_time": self.auto_update_check_time or "03:00",
"auto_update_apply_enabled": bool(self.auto_update_apply_enabled),
"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, "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,
} }
+19 -2
View File
@@ -78,22 +78,36 @@ async def create_customer(
return response return response
SORTABLE_CUSTOMER_COLUMNS = {
"id": Customer.id,
"name": Customer.name,
"subdomain": Customer.subdomain,
"status": Customer.status,
"max_devices": Customer.max_devices,
"created_at": Customer.created_at,
}
@router.get("") @router.get("")
async def list_customers( async def list_customers(
page: int = Query(default=1, ge=1), page: int = Query(default=1, ge=1),
per_page: int = Query(default=25, ge=1, le=100), per_page: int = Query(default=25, ge=1, le=100),
search: Optional[str] = Query(default=None), search: Optional[str] = Query(default=None),
status_filter: Optional[str] = Query(default=None, alias="status"), status_filter: Optional[str] = Query(default=None, alias="status"),
sort_by: str = Query(default="id"),
sort_order: str = Query(default="asc", pattern="^(asc|desc)$"),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""List customers with pagination, search, and status filter. """List customers with pagination, search, status filter, and sorting.
Args: Args:
page: Page number (1-indexed). page: Page number (1-indexed).
per_page: Items per page. per_page: Items per page.
search: Search in name, subdomain, email. search: Search in name, subdomain, email.
status_filter: Filter by status. status_filter: Filter by status.
sort_by: Column to sort by — one of SORTABLE_CUSTOMER_COLUMNS.
sort_order: "asc" or "desc".
Returns: Returns:
Paginated customer list with metadata. Paginated customer list with metadata.
@@ -113,8 +127,11 @@ async def list_customers(
query = query.filter(Customer.status == status_filter) query = query.filter(Customer.status == status_filter)
total = query.count() total = query.count()
sort_column = SORTABLE_CUSTOMER_COLUMNS.get(sort_by, Customer.id)
sort_expr = sort_column.desc() if sort_order == "desc" else sort_column.asc()
customers = ( customers = (
query.order_by(Customer.created_at.desc()) query.order_by(sort_expr, Customer.id.asc())
.offset((page - 1) * per_page) .offset((page - 1) * per_page)
.limit(per_page) .limit(per_page)
.all() .all()
+109 -2
View File
@@ -8,8 +8,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 +261,112 @@ 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)
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.
+92 -16
View File
@@ -1,7 +1,9 @@
"""Monitoring API — system overview, customer statuses, host resources.""" """Monitoring API — system overview, customer statuses, host resources."""
import asyncio
import logging import logging
import platform import platform
import time
from typing import Any from typing import Any
import psutil import psutil
@@ -11,11 +13,20 @@ 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()
# Short-lived cache for the local update-status badges. This endpoint is
# triggered on every customer-table render (i.e. every search keystroke), but
# the underlying data (which images are outdated) only changes after an image
# pull + container recreate, so a few seconds of staleness is harmless.
_update_status_cache: dict[str, Any] = {"data": None, "expires": 0.0}
_UPDATE_STATUS_TTL_SECONDS = 20
@router.get("/status") @router.get("/status")
async def system_status( async def system_status(
@@ -58,8 +69,7 @@ async def all_customers_status(
.all() .all()
) )
results: list[dict[str, Any]] = [] async def _build_entry(c: Customer) -> dict[str, Any]:
for c in customers:
entry: dict[str, Any] = { entry: dict[str, Any] = {
"id": c.id, "id": c.id,
"name": c.name, "name": c.name,
@@ -67,7 +77,7 @@ async def all_customers_status(
"status": c.status, "status": c.status,
} }
if c.deployment: if c.deployment:
containers = docker_service.get_container_status(c.deployment.container_prefix) containers = await docker_service.get_container_status_async(c.deployment.container_prefix)
entry["deployment_status"] = c.deployment.deployment_status entry["deployment_status"] = c.deployment.deployment_status
entry["containers"] = containers entry["containers"] = containers
entry["relay_udp_port"] = c.deployment.relay_udp_port entry["relay_udp_port"] = c.deployment.relay_udp_port
@@ -76,9 +86,11 @@ async def all_customers_status(
else: else:
entry["deployment_status"] = None entry["deployment_status"] = None
entry["containers"] = [] entry["containers"] = []
results.append(entry) return entry
return results # Fetch container status for all customers concurrently instead of one
# blocking Docker SDK call at a time.
return await asyncio.gather(*[_build_entry(c) for c in customers])
@router.get("/resources") @router.get("/resources")
@@ -149,6 +161,7 @@ async def check_image_updates(
"subdomain": customer.subdomain, "subdomain": customer.subdomain,
"container_prefix": dep.container_prefix, "container_prefix": dep.container_prefix,
"needs_update": cs["needs_update"], "needs_update": cs["needs_update"],
"unknown": cs.get("unknown", False),
"services": cs["services"], "services": cs["services"],
}) })
@@ -205,16 +218,71 @@ async def customers_local_update_status(
Compares running container image IDs against locally stored images. Compares running container image IDs against locally stored images.
No network call — safe to call on every dashboard load. No network call — safe to call on every dashboard load.
Results are cached for a few seconds since this is triggered on every
customer-table render (including every search keystroke) but the
underlying data rarely changes.
""" """
now = time.monotonic()
if _update_status_cache["data"] is not None and now < _update_status_cache["expires"]:
return _update_status_cache["data"]
config = db.query(SystemConfig).filter(SystemConfig.id == 1).first() config = db.query(SystemConfig).filter(SystemConfig.id == 1).first()
if not config: if not config:
return [] return []
deployments = db.query(Deployment).all() deployments = db.query(Deployment).all()
async def _check(dep: Deployment) -> dict[str, Any]:
cs = await image_service.get_customer_container_image_status_async(dep.container_prefix, config)
return {"customer_id": dep.customer_id, "needs_update": cs["needs_update"], "unknown": cs.get("unknown", False)}
results = await asyncio.gather(*[_check(dep) for dep in deployments])
results = list(results)
_update_status_cache["data"] = results
_update_status_cache["expires"] = now + _UPDATE_STATUS_TTL_SECONDS
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 = [] results = []
for dep in deployments: for dep in deployments:
cs = image_service.get_customer_container_image_status(dep.container_prefix, config) customer = dep.customer
results.append({"customer_id": dep.customer_id, "needs_update": cs["needs_update"]}) if not dep.netbird_api_token_encrypted:
return results 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")
@@ -251,19 +319,27 @@ async def update_all_customers(
if not to_update: if not to_update:
return {"message": "All customers are already up to date.", "updated": 0, "results": []} return {"message": "All customers are already up to date.", "updated": 0, "results": []}
# Update customers sequentially — one at a time # Update customers sequentially — one at a time. A failure for one
# customer (e.g. a hung docker compose call) must not abort the rest of
# the batch, otherwise later customers silently never get updated.
update_results = [] update_results = []
for entry in to_update: for entry in to_update:
res = await image_service.update_customer_containers( try:
entry["instance_dir"], entry["project_name"] res = await image_service.update_customer_containers(
) entry["instance_dir"], entry["project_name"]
ok = res["success"] )
logger.info("Updated %s: %s", entry["project_name"], "OK" if ok else res.get("error")) ok = res["success"]
error = res.get("error")
except Exception as exc:
logger.exception("Unexpected error updating %s", entry["project_name"])
ok = False
error = str(exc)
logger.info("Updated %s: %s", entry["project_name"], "OK" if ok else error)
update_results.append({ update_results.append({
"customer_name": entry["customer_name"], "customer_name": entry["customer_name"],
"customer_id": entry["customer_id"], "customer_id": entry["customer_id"],
"success": ok, "success": ok,
"error": res.get("error"), "error": error,
}) })
success_count = sum(1 for r in update_results if r["success"]) success_count = sum(1 for r in update_results if r["success"])
+39 -10
View File
@@ -4,6 +4,7 @@ There is no .env file. Every setting lives in the ``system_config`` table
(singleton row with id=1) and is editable via the Web UI settings page. (singleton row with id=1) and is editable via the Web UI settings page.
""" """
import asyncio
import logging import logging
import os import os
import shutil import shutil
@@ -358,13 +359,15 @@ async def trigger_update(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Backup the database, git pull the latest code, and rebuild the container. """Kick off backup + git pull + container rebuild in the background.
Returns immediately — the actual work (which can take several minutes,
especially the ``--no-cache`` image build) runs in a background thread so
it doesn't block this request or any other user's requests while it
runs. Progress can be polled via GET /settings/update/status until the
container restarts with the new version.
The rebuild is fire-and-forget — the app will restart in ~60 seconds.
Only admin users may trigger an update. Only admin users may trigger an update.
Returns:
Dict with ok, message, and backup path.
""" """
if getattr(current_user, "role", "admin") != "admin": if getattr(current_user, "role", "admin") != "admin":
raise HTTPException( raise HTTPException(
@@ -383,11 +386,37 @@ async def trigger_update(
detail="git_repo_url is not configured in settings.", detail="git_repo_url is not configured in settings.",
) )
result = update_service.trigger_update(config, DATABASE_PATH) current_status = update_service.get_update_status()
if not result.get("ok"): if current_status.get("state") == "running":
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_409_CONFLICT,
detail=result.get("message", "Update failed."), detail="An update is already in progress.",
) )
# Snapshot the only fields trigger_update() needs — avoids passing a
# SQLAlchemy instance into a background thread after this request's
# session may already be closed.
class _ConfigSnapshot:
git_repo_url = config.git_repo_url
git_branch = config.git_branch
git_token = config.git_token
asyncio.create_task(asyncio.to_thread(update_service.trigger_update, _ConfigSnapshot(), DATABASE_PATH))
logger.info("Update triggered by %s.", current_user.username) logger.info("Update triggered by %s.", current_user.username)
return result return {
"ok": True,
"message": "Update gestartet. Dies kann mehrere Minuten dauern — Fortschritt via Status sichtbar.",
}
@router.get("/update/status")
async def update_status(
current_user: User = Depends(get_current_user),
):
"""Return progress of the currently running (or last) update.
Note: once the container restarts mid-update, this endpoint stops
responding for a few seconds — that itself is a signal the swap is
happening. The frontend falls back to polling for the app coming back up.
"""
return update_service.get_update_status()
+23 -2
View File
@@ -27,13 +27,24 @@ async def _run_cmd(cmd: list[str], timeout: int = 120) -> subprocess.CompletedPr
) )
_client: Optional[docker.DockerClient] = None
def _get_client() -> docker.DockerClient: def _get_client() -> docker.DockerClient:
"""Return a Docker client connected via the Unix socket. """Return a shared Docker client connected via the Unix socket.
The client is created once and reused — creating a new client per call
(as `docker.from_env()` does) re-negotiates the API version and opens a
fresh connection every time, which is wasteful when called once per
customer in a loop.
Returns: Returns:
docker.DockerClient instance. docker.DockerClient instance.
""" """
return docker.from_env() global _client
if _client is None:
_client = docker.from_env()
return _client
async def compose_up( async def compose_up(
@@ -212,6 +223,16 @@ def get_container_status(container_prefix: str) -> list[dict[str, Any]]:
return results return results
async def get_container_status_async(container_prefix: str) -> list[dict[str, Any]]:
"""Thread-offloaded wrapper around get_container_status().
Use this when checking status for multiple customers so the Docker SDK
calls run in the thread pool instead of blocking the event loop.
"""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, get_container_status, container_prefix)
def get_container_logs(container_name: str, tail: int = 200) -> str: def get_container_logs(container_name: str, tail: int = 200) -> str:
"""Retrieve recent logs from a container. """Retrieve recent logs from a container.
+144 -6
View File
@@ -19,13 +19,38 @@ logger = logging.getLogger(__name__)
NETBIRD_SERVICES = ["management", "signal", "relay", "dashboard"] NETBIRD_SERVICES = ["management", "signal", "relay", "dashboard"]
class _TimeoutResult:
"""Stand-in for subprocess.CompletedProcess when a command times out.
A hung `docker compose up -d` used to raise TimeoutExpired straight out of
_run_cmd, which killed the whole update-all loop mid-recreate and left the
old container renamed-but-not-removed (orphaned with a hash-prefixed name).
Returning a failed result instead lets callers handle it gracefully and
keeps the batch loop going for the remaining customers.
"""
def __init__(self, cmd: list[str], timeout: int):
self.returncode = -1
self.stdout = ""
self.stderr = f"Command timed out after {timeout}s: {' '.join(cmd)}"
async def _run_cmd(cmd: list[str], timeout: int = 300) -> subprocess.CompletedProcess: async def _run_cmd(cmd: list[str], timeout: int = 300) -> subprocess.CompletedProcess:
"""Run a subprocess command without blocking the event loop.""" """Run a subprocess command without blocking the event loop.
Never raises on timeout — returns a failed CompletedProcess-like result
instead, so a single hung docker/compose call can't abort a batch of
otherwise-independent operations (e.g. updating multiple customers).
"""
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
return await loop.run_in_executor( try:
None, return await loop.run_in_executor(
lambda: subprocess.run(cmd, capture_output=True, text=True, timeout=timeout), None,
) lambda: subprocess.run(cmd, capture_output=True, text=True, timeout=timeout),
)
except subprocess.TimeoutExpired:
logger.error("Command timed out after %ds: %s", timeout, " ".join(cmd))
return _TimeoutResult(cmd, timeout)
def _parse_image_name(image: str) -> tuple[str, str]: def _parse_image_name(image: str) -> tuple[str, str]:
@@ -123,6 +148,60 @@ def get_container_image_id(container_name: str) -> str | None:
return None return None
def repair_container_naming(container_prefix: str, services: list[str] = NETBIRD_SERVICES) -> list[str]:
"""Rename orphaned containers back to their expected compose name.
When a `docker compose up -d` is interrupted mid-recreate (e.g. a timeout
killing the process), Compose can leave the *old* container renamed with a
random hash prefix (e.g. "4e45e71fcb7b_netbird-acme-management") instead
of removing it, while never creating the correctly-named replacement. The
container itself keeps running fine — it's just invisible to every lookup
that expects the exact name, which used to silently read as "no container
found" and get reported as "up to date" instead of "unknown".
This finds any such orphan (a container whose name *contains* the expected
name but isn't an exact match) and, only when no container already holds
the exact expected name, renames it back. Safe no-op otherwise.
Returns the list of service names that were repaired.
"""
repaired = []
for svc in services:
expected_name = f"{container_prefix}-{svc}"
exact = subprocess.run(
["docker", "inspect", expected_name, "--format", "{{.Id}}"],
capture_output=True, text=True, timeout=10,
)
if exact.returncode == 0:
continue # already correctly named
found = subprocess.run(
["docker", "ps", "-a", "--filter", f"name={expected_name}", "--format", "{{.Names}}"],
capture_output=True, text=True, timeout=10,
)
candidates = [n for n in found.stdout.strip().splitlines() if n and n != expected_name]
if not candidates:
continue # container genuinely doesn't exist (not deployed / not running)
orphan = candidates[0]
rename = subprocess.run(
["docker", "rename", orphan, expected_name],
capture_output=True, text=True, timeout=10,
)
if rename.returncode == 0:
logger.warning(
"Repaired orphaned container naming for %s: '%s' -> '%s'",
container_prefix, orphan, expected_name,
)
repaired.append(svc)
else:
logger.error(
"Failed to repair orphaned container '%s' -> '%s': %s",
orphan, expected_name, rename.stderr,
)
return repaired
def get_local_image_id(image: str) -> str | None: def get_local_image_id(image: str) -> str | None:
"""Get the full image ID (sha256:...) of a locally stored image.""" """Get the full image ID (sha256:...) of a locally stored image."""
try: try:
@@ -211,6 +290,52 @@ async def pull_all_images(config) -> dict[str, Any]:
} }
async def get_customer_container_image_status_async(container_prefix: str, config) -> dict[str, Any]:
"""Async, thread-offloaded version of get_customer_container_image_status().
Runs the per-service `docker inspect` subprocess calls concurrently in the
thread pool instead of sequentially blocking the event loop — use this
whenever checking status for multiple customers (e.g. dashboard/search
badge refresh, monitoring overview).
Returns:
services: dict mapping service name to status info
needs_update: True if any service has a different image ID than locally stored
"""
service_images = {
"management": config.netbird_management_image,
"signal": config.netbird_signal_image,
"relay": config.netbird_relay_image,
"dashboard": config.netbird_dashboard_image,
}
loop = asyncio.get_event_loop()
# Self-heal any container left orphaned under a hash-prefixed name by a
# previously interrupted recreate, so the lookups below find it by its
# real, expected name instead of silently returning "not found".
await loop.run_in_executor(
None, repair_container_naming, container_prefix, list(service_images.keys())
)
async def _check(svc: str, image: str) -> tuple[str, dict[str, Any]]:
container_name = f"{container_prefix}-{svc}"
container_id, local_id = await asyncio.gather(
loop.run_in_executor(None, get_container_image_id, container_name),
loop.run_in_executor(None, get_local_image_id, image),
)
if container_id and local_id:
up_to_date = container_id == local_id
else:
up_to_date = None # container not running or image not pulled
return svc, {"container": container_name, "image": image, "up_to_date": up_to_date}
pairs = await asyncio.gather(*[_check(svc, image) for svc, image in service_images.items()])
services = dict(pairs)
needs_update = any(s["up_to_date"] is False for s in services.values())
unknown = any(s["up_to_date"] is None for s in services.values())
return {"services": services, "needs_update": needs_update, "unknown": unknown}
def get_customer_container_image_status(container_prefix: str, config) -> dict[str, Any]: def get_customer_container_image_status(container_prefix: str, config) -> dict[str, Any]:
"""Check which service containers are running outdated local images. """Check which service containers are running outdated local images.
@@ -227,6 +352,11 @@ def get_customer_container_image_status(container_prefix: str, config) -> dict[s
"relay": config.netbird_relay_image, "relay": config.netbird_relay_image,
"dashboard": config.netbird_dashboard_image, "dashboard": config.netbird_dashboard_image,
} }
# Self-heal any container left orphaned under a hash-prefixed name by a
# previously interrupted recreate (see repair_container_naming docstring).
repair_container_naming(container_prefix, list(service_images.keys()))
services: dict[str, Any] = {} services: dict[str, Any] = {}
for svc, image in service_images.items(): for svc, image in service_images.items():
container_name = f"{container_prefix}-{svc}" container_name = f"{container_prefix}-{svc}"
@@ -242,7 +372,8 @@ def get_customer_container_image_status(container_prefix: str, config) -> dict[s
"up_to_date": up_to_date, "up_to_date": up_to_date,
} }
needs_update = any(s["up_to_date"] is False for s in services.values()) needs_update = any(s["up_to_date"] is False for s in services.values())
return {"services": services, "needs_update": needs_update} unknown = any(s["up_to_date"] is None for s in services.values())
return {"services": services, "needs_update": needs_update, "unknown": unknown}
async def update_customer_containers(instance_dir: str, project_name: str) -> dict[str, Any]: async def update_customer_containers(instance_dir: str, project_name: str) -> dict[str, Any]:
@@ -254,6 +385,13 @@ async def update_customer_containers(instance_dir: str, project_name: str) -> di
compose_file = os.path.join(instance_dir, "docker-compose.yml") compose_file = os.path.join(instance_dir, "docker-compose.yml")
if not os.path.isfile(compose_file): if not os.path.isfile(compose_file):
return {"success": False, "error": f"docker-compose.yml not found at {compose_file}"} return {"success": False, "error": f"docker-compose.yml not found at {compose_file}"}
# Repair any container still orphaned under a hash-prefixed name from a
# previous interrupted recreate before Compose tries to touch it again —
# otherwise Compose keeps colliding with the same stuck rename.
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, repair_container_naming, project_name)
cmd = [ cmd = [
"docker", "compose", "docker", "compose",
"-f", compose_file, "-f", compose_file,
@@ -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)}
+13 -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
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.deployment_status = "running"
deployment.deployed_at = datetime.utcnow() deployment.deployed_at = datetime.utcnow()
else: else:
@@ -354,6 +365,7 @@ 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,
deployment_status="running", deployment_status="running",
deployed_at=datetime.utcnow(), deployed_at=datetime.utcnow(),
) )
+94 -16
View File
@@ -12,9 +12,12 @@ Let's Encrypt SSL certificates.
Also manages NPM streams for STUN/TURN relay UDP ports. Also manages NPM streams for STUN/TURN relay UDP ports.
""" """
import base64
import json
import logging import logging
import os import os
import socket import socket
import time
from typing import Any from typing import Any
import httpx import httpx
@@ -24,6 +27,14 @@ logger = logging.getLogger(__name__)
# Timeout for NPM API calls (seconds) # Timeout for NPM API calls (seconds)
NPM_TIMEOUT = 30 NPM_TIMEOUT = 30
# Cached JWTs, keyed by (api_url, email). NPM issues a token that stays valid
# for a while (per its 'exp' claim), so re-logging in on every single API
# call — as this module used to do — adds a full extra round-trip per action
# for no reason.
_token_cache: dict[tuple[str, str], dict[str, Any]] = {}
_TOKEN_SAFETY_MARGIN = 60 # refresh this many seconds before actual expiry
_DEFAULT_TOKEN_TTL = 3600 # fallback if the 'exp' claim can't be parsed
def _get_forward_host() -> str: def _get_forward_host() -> str:
"""Get the host machine's real IP address for NPM forwarding. """Get the host machine's real IP address for NPM forwarding.
@@ -90,6 +101,61 @@ async def _npm_login(client: httpx.AsyncClient, api_url: str, email: str, passwo
) )
def _decode_jwt_exp(token: str) -> float | None:
"""Best-effort decode of a JWT's 'exp' claim, without verifying the signature.
We only use this to size our own cache TTL — NPM itself still enforces
the real expiry server-side, so an inaccurate read here is harmless.
"""
try:
payload_b64 = token.split(".")[1]
padding = "=" * (-len(payload_b64) % 4)
payload = json.loads(base64.urlsafe_b64decode(payload_b64 + padding))
return payload.get("exp")
except Exception:
return None
async def _get_token(
client: httpx.AsyncClient, api_url: str, email: str, password: str, force_refresh: bool = False
) -> str:
"""Return a cached NPM JWT if still valid, otherwise log in and cache it."""
cache_key = (api_url, email)
if not force_refresh:
cached = _token_cache.get(cache_key)
if cached and time.time() < cached["expires_at"]:
return cached["token"]
token = await _npm_login(client, api_url, email, password)
exp = _decode_jwt_exp(token)
expires_at = (exp - _TOKEN_SAFETY_MARGIN) if exp else (time.time() + _DEFAULT_TOKEN_TTL)
_token_cache[cache_key] = {"token": token, "expires_at": expires_at}
return token
async def _request_with_reauth(
client: httpx.AsyncClient,
method: str,
api_url: str,
email: str,
password: str,
path: str,
headers: dict,
**kwargs: Any,
) -> tuple[httpx.Response, dict]:
"""Perform a request; if the cached token was rejected, refresh and retry once.
Returns the response and the (possibly updated) headers dict, so callers
can reuse the fresh token for any further requests in the same session.
"""
resp = await client.request(method, f"{api_url}{path}", headers=headers, **kwargs)
if resp.status_code == 401:
token = await _get_token(client, api_url, email, password, force_refresh=True)
headers = {**headers, "Authorization": f"Bearer {token}"}
resp = await client.request(method, f"{api_url}{path}", headers=headers, **kwargs)
return resp, headers
async def test_npm_connection(api_url: str, email: str, password: str) -> dict[str, Any]: async def test_npm_connection(api_url: str, email: str, password: str) -> dict[str, Any]:
"""Test connectivity to NPM by logging in and listing proxy hosts. """Test connectivity to NPM by logging in and listing proxy hosts.
@@ -103,9 +169,11 @@ async def test_npm_connection(api_url: str, email: str, password: str) -> dict[s
""" """
try: try:
async with httpx.AsyncClient(timeout=NPM_TIMEOUT) as client: async with httpx.AsyncClient(timeout=NPM_TIMEOUT) as client:
token = await _npm_login(client, api_url, email, password) token = await _get_token(client, api_url, email, password)
headers = {"Authorization": f"Bearer {token}"} headers = {"Authorization": f"Bearer {token}"}
resp = await client.get(f"{api_url}/nginx/proxy-hosts", headers=headers) resp, headers = await _request_with_reauth(
client, "GET", api_url, email, password, "/nginx/proxy-hosts", headers
)
if resp.status_code == 200: if resp.status_code == 200:
count = len(resp.json()) count = len(resp.json())
return {"ok": True, "message": f"Connected. Login OK. {count} proxy hosts found."} return {"ok": True, "message": f"Connected. Login OK. {count} proxy hosts found."}
@@ -136,9 +204,11 @@ async def list_certificates(api_url: str, email: str, password: str) -> dict[str
""" """
try: try:
async with httpx.AsyncClient(timeout=NPM_TIMEOUT) as client: async with httpx.AsyncClient(timeout=NPM_TIMEOUT) as client:
token = await _npm_login(client, api_url, email, password) token = await _get_token(client, api_url, email, password)
headers = {"Authorization": f"Bearer {token}"} headers = {"Authorization": f"Bearer {token}"}
resp = await client.get(f"{api_url}/nginx/certificates", headers=headers) resp, headers = await _request_with_reauth(
client, "GET", api_url, email, password, "/nginx/certificates", headers
)
if resp.status_code == 200: if resp.status_code == 200:
result = [] result = []
for cert in resp.json(): for cert in resp.json():
@@ -263,10 +333,14 @@ async def create_proxy_host(
"location ^~ /management.ManagementService/ {\n" "location ^~ /management.ManagementService/ {\n"
f" grpc_pass grpc://{forward_host}:{forward_port};\n" f" grpc_pass grpc://{forward_host}:{forward_port};\n"
" grpc_set_header Host $host;\n" " grpc_set_header Host $host;\n"
" grpc_read_timeout 3600s;\n"
" grpc_send_timeout 3600s;\n"
"}\n" "}\n"
"location ^~ /signalexchange.SignalExchange/ {\n" "location ^~ /signalexchange.SignalExchange/ {\n"
f" grpc_pass grpc://{forward_host}:{forward_port};\n" f" grpc_pass grpc://{forward_host}:{forward_port};\n"
" grpc_set_header Host $host;\n" " grpc_set_header Host $host;\n"
" grpc_read_timeout 3600s;\n"
" grpc_send_timeout 3600s;\n"
"}\n" "}\n"
), ),
"meta": { "meta": {
@@ -278,14 +352,15 @@ async def create_proxy_host(
try: try:
async with httpx.AsyncClient(timeout=180) as client: # Long timeout for LE cert async with httpx.AsyncClient(timeout=180) as client: # Long timeout for LE cert
token = await _npm_login(client, api_url, npm_email, npm_password) token = await _get_token(client, api_url, npm_email, npm_password)
headers = { headers = {
"Authorization": f"Bearer {token}", "Authorization": f"Bearer {token}",
"Content-Type": "application/json", "Content-Type": "application/json",
} }
resp = await client.post( resp, headers = await _request_with_reauth(
f"{api_url}/nginx/proxy-hosts", json=payload, headers=headers client, "POST", api_url, npm_email, npm_password,
"/nginx/proxy-hosts", headers, json=payload,
) )
if resp.status_code in (200, 201): if resp.status_code in (200, 201):
data = resp.json() data = resp.json()
@@ -538,14 +613,15 @@ async def create_stream(
try: try:
async with httpx.AsyncClient(timeout=NPM_TIMEOUT) as client: async with httpx.AsyncClient(timeout=NPM_TIMEOUT) as client:
token = await _npm_login(client, api_url, npm_email, npm_password) token = await _get_token(client, api_url, npm_email, npm_password)
headers = { headers = {
"Authorization": f"Bearer {token}", "Authorization": f"Bearer {token}",
"Content-Type": "application/json", "Content-Type": "application/json",
} }
resp = await client.post( resp, headers = await _request_with_reauth(
f"{api_url}/nginx/streams", json=payload, headers=headers client, "POST", api_url, npm_email, npm_password,
"/nginx/streams", headers, json=payload,
) )
if resp.status_code in (200, 201): if resp.status_code in (200, 201):
data = resp.json() data = resp.json()
@@ -583,10 +659,11 @@ async def delete_stream(
""" """
try: try:
async with httpx.AsyncClient(timeout=NPM_TIMEOUT) as client: async with httpx.AsyncClient(timeout=NPM_TIMEOUT) as client:
token = await _npm_login(client, api_url, npm_email, npm_password) token = await _get_token(client, api_url, npm_email, npm_password)
headers = {"Authorization": f"Bearer {token}"} headers = {"Authorization": f"Bearer {token}"}
resp = await client.delete( resp, headers = await _request_with_reauth(
f"{api_url}/nginx/streams/{stream_id}", headers=headers client, "DELETE", api_url, npm_email, npm_password,
f"/nginx/streams/{stream_id}", headers,
) )
if resp.status_code in (200, 204): if resp.status_code in (200, 204):
logger.info("Deleted NPM stream %d", stream_id) logger.info("Deleted NPM stream %d", stream_id)
@@ -619,10 +696,11 @@ async def delete_proxy_host(
""" """
try: try:
async with httpx.AsyncClient(timeout=NPM_TIMEOUT) as client: async with httpx.AsyncClient(timeout=NPM_TIMEOUT) as client:
token = await _npm_login(client, api_url, npm_email, npm_password) token = await _get_token(client, api_url, npm_email, npm_password)
headers = {"Authorization": f"Bearer {token}"} headers = {"Authorization": f"Bearer {token}"}
resp = await client.delete( resp, headers = await _request_with_reauth(
f"{api_url}/nginx/proxy-hosts/{proxy_id}", headers=headers client, "DELETE", api_url, npm_email, npm_password,
f"/nginx/proxy-hosts/{proxy_id}", headers,
) )
if resp.status_code in (200, 204): if resp.status_code in (200, 204):
logger.info("Deleted NPM proxy host %d", proxy_id) logger.info("Deleted NPM proxy host %d", proxy_id)
+119
View File
@@ -0,0 +1,119 @@
"""Background scheduler for automatic NetBird image update checks.
No external scheduler dependency (APScheduler etc.) — a single asyncio task
started at app startup wakes up once a minute, and only actually does
anything once per day at the configured HH:MM, controlled entirely by
SystemConfig.auto_update_check_enabled / auto_update_check_time.
"""
import asyncio
import logging
from datetime import datetime
from app.database import SessionLocal
from app.models import Deployment, SystemConfig
from app.services import image_service
logger = logging.getLogger(__name__)
_POLL_INTERVAL_SECONDS = 60
_task: asyncio.Task | None = None
def start() -> None:
"""Start the background polling task. Safe to call once at app startup."""
global _task
if _task is None or _task.done():
_task = asyncio.create_task(_poll_loop())
logger.info("Automatic update scheduler started.")
def stop() -> None:
"""Cancel the background polling task."""
global _task
if _task is not None:
_task.cancel()
_task = None
async def _poll_loop() -> None:
while True:
try:
await _tick()
except Exception:
logger.exception("Scheduler tick failed")
await asyncio.sleep(_POLL_INTERVAL_SECONDS)
async def _tick() -> None:
db = SessionLocal()
try:
config = db.query(SystemConfig).filter(SystemConfig.id == 1).first()
if not config or not config.auto_update_check_enabled:
return
now = datetime.now()
target_time = config.auto_update_check_time or "03:00"
current_hhmm = now.strftime("%H:%M")
if current_hhmm != target_time:
return
last_run = config.auto_update_last_run_at
if last_run and last_run.date() == now.date():
return # already ran today
# Claim this run immediately so a slow run can't overlap the next tick.
config.auto_update_last_run_at = now
db.commit()
apply_enabled = bool(config.auto_update_apply_enabled)
logger.info(
"Running scheduled NetBird image update check (auto-apply=%s)...", apply_enabled
)
await _run_check_and_optionally_apply(config, apply_enabled)
finally:
db.close()
async def _run_check_and_optionally_apply(config: SystemConfig, apply_enabled: bool) -> None:
hub_status = await image_service.check_all_images(config)
if not hub_status["any_update_available"]:
logger.info("Scheduled check: all NetBird images already up to date.")
return
logger.info("Scheduled check: new NetBird image(s) available — pulling.")
pull_result = await image_service.pull_all_images(config)
if not pull_result["all_success"]:
logger.error("Scheduled image pull had failures: %s", pull_result["results"])
if not apply_enabled:
logger.info("Auto-apply disabled — images pulled, customer containers left untouched.")
return
db = SessionLocal()
try:
deployments = db.query(Deployment).all()
to_update = []
for dep in deployments:
cs = image_service.get_customer_container_image_status(dep.container_prefix, config)
if cs["needs_update"]:
customer = dep.customer
to_update.append({
"instance_dir": f"{config.data_dir}/{customer.subdomain}",
"project_name": dep.container_prefix,
"customer_name": customer.name,
})
logger.info("Scheduled auto-apply: updating %d customer(s)...", len(to_update))
for entry in to_update:
try:
res = await image_service.update_customer_containers(
entry["instance_dir"], entry["project_name"]
)
logger.info(
"Scheduled update for %s: %s",
entry["customer_name"], "OK" if res["success"] else res.get("error"),
)
except Exception:
logger.exception("Scheduled update failed for %s", entry["customer_name"])
finally:
db.close()
+42
View File
@@ -20,6 +20,32 @@ SERVICE_NAME = "netbird-msp-appliance"
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# In-memory progress tracker for the currently running (or last) update.
# The container gets replaced mid-update, so this deliberately does NOT need
# to survive a restart — the frontend detects completion by polling until the
# app comes back up and reports a new version, not by reading a final status
# here. It exists so the UI can show *something* other than a frozen spinner
# while the backup/pull/build steps are in progress.
_update_status: dict[str, Any] = {
"state": "idle", # idle | running | failed
"step": "",
"message": "",
"started_at": None,
}
def get_update_status() -> dict[str, Any]:
"""Return a snapshot of the current update progress."""
return dict(_update_status)
def _set_status(state: str, step: str, message: str = "") -> None:
_update_status["state"] = state
_update_status["step"] = step
_update_status["message"] = message
if step == "backup":
_update_status["started_at"] = datetime.utcnow().isoformat()
def _get_compose_project_name() -> str: def _get_compose_project_name() -> str:
"""Detect the compose project name from the running container's labels. """Detect the compose project name from the running container's labels.
@@ -233,10 +259,12 @@ def trigger_update(config: Any, db_path: str) -> dict:
Dict with ok (bool), message, backup path, and pulled_branch. Dict with ok (bool), message, backup path, and pulled_branch.
""" """
# 1. Backup database before any changes # 1. Backup database before any changes
_set_status("running", "backup", "Datenbank wird gesichert …")
try: try:
backup_path = backup_database(db_path) backup_path = backup_database(db_path)
except Exception as exc: except Exception as exc:
logger.error("Database backup failed: %s", exc) logger.error("Database backup failed: %s", exc)
_set_status("failed", "backup", f"Database backup failed: {exc}")
return {"ok": False, "message": f"Database backup failed: {exc}", "backup": None} return {"ok": False, "message": f"Database backup failed: {exc}", "backup": None}
# 2. Build git pull command (embed token in URL if provided) # 2. Build git pull command (embed token in URL if provided)
@@ -252,6 +280,7 @@ def trigger_update(config: Any, db_path: str) -> dict:
pull_cmd = ["git", "-C", SOURCE_DIR, "pull", "origin", branch] pull_cmd = ["git", "-C", SOURCE_DIR, "pull", "origin", branch]
# 3. Git pull (synchronous — must complete before rebuild) # 3. Git pull (synchronous — must complete before rebuild)
_set_status("running", "pull", f"Code wird von Branch '{branch}' geholt …")
# Ensure .git directory is owned by the process user (root inside container). # Ensure .git directory is owned by the process user (root inside container).
# The .git dir may be owned by the host user after manual operations. # The .git dir may be owned by the host user after manual operations.
try: try:
@@ -270,13 +299,16 @@ def trigger_update(config: Any, db_path: str) -> dict:
timeout=120, timeout=120,
) )
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
_set_status("failed", "pull", "git pull timed out after 120s.")
return {"ok": False, "message": "git pull timed out after 120s.", "backup": backup_path} return {"ok": False, "message": "git pull timed out after 120s.", "backup": backup_path}
except Exception as exc: except Exception as exc:
_set_status("failed", "pull", f"git pull error: {exc}")
return {"ok": False, "message": f"git pull error: {exc}", "backup": backup_path} return {"ok": False, "message": f"git pull error: {exc}", "backup": backup_path}
if result.returncode != 0: if result.returncode != 0:
stderr = result.stderr.strip()[:500] stderr = result.stderr.strip()[:500]
logger.error("git pull failed (exit %d): %s", result.returncode, stderr) logger.error("git pull failed (exit %d): %s", result.returncode, stderr)
_set_status("failed", "pull", f"git pull failed: {stderr}")
return { return {
"ok": False, "ok": False,
"message": f"git pull failed: {stderr}", "message": f"git pull failed: {stderr}",
@@ -348,6 +380,7 @@ def trigger_update(config: Any, db_path: str) -> dict:
SERVICE_NAME, SERVICE_NAME,
] ]
logger.info("Phase A: building new image …") logger.info("Phase A: building new image …")
_set_status("running", "build", "Docker-Image wird gebaut (kann mehrere Minuten dauern) …")
try: try:
build_result = subprocess.run( build_result = subprocess.run(
build_cmd, build_cmd,
@@ -360,15 +393,18 @@ def trigger_update(config: Any, db_path: str) -> dict:
f.write(build_result.stderr) f.write(build_result.stderr)
if build_result.returncode != 0: if build_result.returncode != 0:
logger.error("Image build failed: %s", build_result.stderr[:500]) logger.error("Image build failed: %s", build_result.stderr[:500])
_set_status("failed", "build", f"Image build failed: {build_result.stderr[:300]}")
return { return {
"ok": False, "ok": False,
"message": f"Image build failed: {build_result.stderr[:300]}", "message": f"Image build failed: {build_result.stderr[:300]}",
"backup": backup_path, "backup": backup_path,
} }
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
_set_status("failed", "build", "Image build timed out after 600s.")
return {"ok": False, "message": "Image build timed out after 600s.", "backup": backup_path} return {"ok": False, "message": "Image build timed out after 600s.", "backup": backup_path}
logger.info("Phase A complete — image built successfully.") logger.info("Phase A complete — image built successfully.")
_set_status("running", "restart", "Container wird neu gestartet …")
# Phase B — swap the container using a helper container. # Phase B — swap the container using a helper container.
# When compose recreates our container, ALL processes inside die (PID namespace # When compose recreates our container, ALL processes inside die (PID namespace
@@ -388,6 +424,7 @@ def trigger_update(config: Any, db_path: str) -> dict:
raise ValueError("Could not find /app-source mount") raise ValueError("Could not find /app-source mount")
except Exception as exc: except Exception as exc:
logger.error("Failed to discover host source path: %s", exc) logger.error("Failed to discover host source path: %s", exc)
_set_status("failed", "restart", f"Could not find host source path: {exc}")
return {"ok": False, "message": f"Could not find host source path: {exc}", "backup": backup_path} return {"ok": False, "message": f"Could not find host source path: {exc}", "backup": backup_path}
logger.info("Host source directory: %s", host_source_dir) logger.info("Host source directory: %s", host_source_dir)
@@ -426,6 +463,10 @@ def trigger_update(config: Any, db_path: str) -> dict:
) )
if result.returncode != 0: if result.returncode != 0:
logger.error("Failed to start updater container: %s", result.stderr.strip()) logger.error("Failed to start updater container: %s", result.stderr.strip())
_set_status(
"failed", "restart",
f"Update-Container konnte nicht gestartet werden: {result.stderr.strip()[:200]}",
)
return { return {
"ok": False, "ok": False,
"message": f"Update-Container konnte nicht gestartet werden: {result.stderr.strip()[:200]}", "message": f"Update-Container konnte nicht gestartet werden: {result.stderr.strip()[:200]}",
@@ -434,6 +475,7 @@ def trigger_update(config: Any, db_path: str) -> dict:
logger.info("Phase B: updater container started — this container will restart in ~5s.") logger.info("Phase B: updater container started — this container will restart in ~5s.")
except Exception as exc: except Exception as exc:
logger.error("Failed to launch updater: %s", exc) logger.error("Failed to launch updater: %s", exc)
_set_status("failed", "restart", f"Updater launch failed: {exc}")
return {"ok": False, "message": f"Updater launch failed: {exc}", "backup": backup_path} return {"ok": False, "message": f"Updater launch failed: {exc}", "backup": backup_path}
return { return {
+39
View File
@@ -158,6 +158,45 @@ class SystemConfigUpdate(BaseModel):
git_repo_url: Optional[str] = Field(None, max_length=500) git_repo_url: Optional[str] = Field(None, max_length=500)
git_branch: Optional[str] = Field(None, max_length=100) git_branch: Optional[str] = Field(None, max_length=100)
git_token: Optional[str] = None # plaintext, encrypted before storage git_token: Optional[str] = None # plaintext, encrypted before storage
# Automatic NetBird image update check/apply
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
def validate_auto_update_check_time(cls, v: Optional[str]) -> Optional[str]:
"""Must be HH:MM in 24h format."""
if v is None:
return v
import re
if not re.fullmatch(r"([01]\d|2[0-3]):[0-5]\d", v):
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") @field_validator("ssl_mode")
@classmethod @classmethod
+20
View File
@@ -1,5 +1,25 @@
/* NetBird MSP Appliance - Custom Styles */ /* NetBird MSP Appliance - Custom Styles */
/* Sortable table headers */
.sortable-th {
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.sortable-th:hover {
color: var(--bs-primary);
}
.sortable-th .sort-icon {
opacity: 0.35;
}
.sortable-th.sort-asc .sort-icon,
.sortable-th.sort-desc .sort-icon {
opacity: 1;
}
/* i18n FOUC prevention */ /* i18n FOUC prevention */
body.i18n-loading #login-page, body.i18n-loading #login-page,
body.i18n-loading #app-page { body.i18n-loading #app-page {
+60 -6
View File
@@ -254,13 +254,13 @@
<table class="table table-hover mb-0"> <table class="table table-hover mb-0">
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
<th data-i18n="dashboard.thId">ID</th> <th class="sortable-th" data-sort-col="id" onclick="setCustomerSort('id')"><span data-i18n="dashboard.thId">ID</span><i class="bi bi-arrow-down-up sort-icon ms-1"></i></th>
<th data-i18n="dashboard.thName">Name</th> <th class="sortable-th" data-sort-col="name" onclick="setCustomerSort('name')"><span data-i18n="dashboard.thName">Name</span><i class="bi bi-arrow-down-up sort-icon ms-1"></i></th>
<th data-i18n="dashboard.thSubdomain">Subdomain</th> <th class="sortable-th" data-sort-col="subdomain" onclick="setCustomerSort('subdomain')"><span data-i18n="dashboard.thSubdomain">Subdomain</span><i class="bi bi-arrow-down-up sort-icon ms-1"></i></th>
<th data-i18n="dashboard.thStatus">Status</th> <th class="sortable-th" data-sort-col="status" onclick="setCustomerSort('status')"><span data-i18n="dashboard.thStatus">Status</span><i class="bi bi-arrow-down-up sort-icon ms-1"></i></th>
<th data-i18n="dashboard.thDashboard">Dashboard</th> <th data-i18n="dashboard.thDashboard">Dashboard</th>
<th data-i18n="dashboard.thDevices">Devices</th> <th class="sortable-th" data-sort-col="max_devices" onclick="setCustomerSort('max_devices')"><span data-i18n="dashboard.thDevices">Devices</span><i class="bi bi-arrow-down-up sort-icon ms-1"></i></th>
<th data-i18n="dashboard.thCreated">Created</th> <th class="sortable-th" data-sort-col="created_at" onclick="setCustomerSort('created_at')"><span data-i18n="dashboard.thCreated">Created</span><i class="bi bi-arrow-down-up sort-icon ms-1"></i></th>
<th data-i18n="dashboard.thActions">Actions</th> <th data-i18n="dashboard.thActions">Actions</th>
</tr> </tr>
</thead> </thead>
@@ -641,6 +641,60 @@
<i class="bi bi-cloud-download me-1"></i><span data-i18n="settings.pullImages">Pull from Docker Hub</span> <i class="bi bi-cloud-download me-1"></i><span data-i18n="settings.pullImages">Pull from Docker Hub</span>
</button> </button>
<span id="pull-images-settings-status" class="ms-2 text-muted small"></span> <span id="pull-images-settings-status" class="ms-2 text-muted small"></span>
<hr>
<h6 data-i18n="monitoring.autoUpdateTitle">Automatic Updates</h6>
<p class="text-muted small" data-i18n="monitoring.autoUpdateHint">Automatically checks for new NetBird images daily at the chosen time.</p>
<form id="settings-auto-update-form">
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="cfg-auto-update-check-enabled">
<label class="form-check-label" for="cfg-auto-update-check-enabled" data-i18n="monitoring.autoUpdateCheckEnabled">Enable automatic update check</label>
</div>
<div class="row g-3 align-items-end">
<div class="col-md-3">
<label class="form-label" data-i18n="monitoring.autoUpdateCheckTime">Daily check time</label>
<input type="time" class="form-control" id="cfg-auto-update-check-time" value="03:00">
</div>
</div>
<div class="form-check mt-3">
<input class="form-check-input" type="checkbox" id="cfg-auto-update-apply-enabled">
<label class="form-check-label" for="cfg-auto-update-apply-enabled" data-i18n="monitoring.autoUpdateApplyEnabled">Automatically update customer containers after check</label>
<div class="form-text" data-i18n="monitoring.autoUpdateApplyHint">When enabled, all customer containers are automatically recreated after a new image is found (services briefly restart). When disabled, only the images are pulled — updating customers stays a manual step.</div>
</div>
<div class="mt-3 small text-muted">
<span data-i18n="monitoring.autoUpdateLastRun">Last automatic check</span>:
<span id="auto-update-last-run">-</span>
</div>
<div class="mt-3">
<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>
</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>
+319 -10
View File
@@ -12,6 +12,8 @@ let currentPage = 'dashboard';
let currentCustomerId = null; let currentCustomerId = null;
let currentCustomerData = null; let currentCustomerData = null;
let customersPage = 1; let customersPage = 1;
let customersSortBy = 'id';
let customersSortOrder = 'asc';
let brandingData = { branding_name: 'NetBird MSP Appliance', branding_logo_path: null, version: 'alpha-1.1' }; let brandingData = { branding_name: 'NetBird MSP Appliance', branding_logo_path: null, version: 'alpha-1.1' };
let azureConfig = { azure_enabled: false }; let azureConfig = { azure_enabled: false };
@@ -458,7 +460,7 @@ async function loadStats() {
async function loadCustomers() { async function loadCustomers() {
const search = document.getElementById('search-input').value; const search = document.getElementById('search-input').value;
const status = document.getElementById('status-filter').value; const status = document.getElementById('status-filter').value;
let url = `/customers?page=${customersPage}&per_page=25`; let url = `/customers?page=${customersPage}&per_page=25&sort_by=${customersSortBy}&sort_order=${customersSortOrder}`;
if (search) url += `&search=${encodeURIComponent(search)}`; if (search) url += `&search=${encodeURIComponent(search)}`;
if (status) url += `&status=${encodeURIComponent(status)}`; if (status) url += `&status=${encodeURIComponent(status)}`;
@@ -470,7 +472,32 @@ async function loadCustomers() {
} }
} }
function setCustomerSort(column) {
if (customersSortBy === column) {
customersSortOrder = customersSortOrder === 'asc' ? 'desc' : 'asc';
} else {
customersSortBy = column;
customersSortOrder = 'asc';
}
customersPage = 1;
loadCustomers();
}
function updateSortHeaders() {
document.querySelectorAll('.sortable-th').forEach(th => {
const col = th.getAttribute('data-sort-col');
const icon = th.querySelector('.sort-icon');
th.classList.remove('sort-asc', 'sort-desc');
if (icon) icon.className = 'bi bi-arrow-down-up sort-icon ms-1';
if (col === customersSortBy) {
th.classList.add(customersSortOrder === 'asc' ? 'sort-asc' : 'sort-desc');
if (icon) icon.className = `bi bi-arrow-${customersSortOrder === 'asc' ? 'up' : 'down'} sort-icon ms-1`;
}
});
}
function renderCustomersTable(data) { function renderCustomersTable(data) {
updateSortHeaders();
const tbody = document.getElementById('customers-table-body'); const tbody = document.getElementById('customers-table-body');
if (!data.items || data.items.length === 0) { if (!data.items || data.items.length === 0) {
tbody.innerHTML = `<tr><td colspan="8" class="text-center text-muted py-4">${t('dashboard.noCustomers')}</td></tr>`; tbody.innerHTML = `<tr><td colspan="8" class="text-center text-muted py-4">${t('dashboard.noCustomers')}</td></tr>`;
@@ -544,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(); });
@@ -669,9 +811,9 @@ async function confirmDeleteCustomer() {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Customer Actions (start/stop/restart/deploy) // Customer Actions (start/stop/restart/deploy)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function customerAction(id, action) { async function customerAction(id, action, name) {
if (action === 'deploy') { if (action === 'deploy') {
showRedeployModal(id); showRedeployModal(id, name);
return; return;
} }
try { try {
@@ -683,9 +825,12 @@ async function customerAction(id, action) {
} }
} }
function showRedeployModal(id) { function showRedeployModal(id, name) {
const row = document.querySelector(`tr[data-customer-id="${id}"]`); // Prefer passed name, fallback to dashboard table row, then ID
const name = row ? row.querySelector('td')?.textContent?.trim() : `#${id}`; if (!name) {
const row = document.querySelector(`tr[data-customer-id="${id}"]`);
name = row ? row.querySelector('td')?.textContent?.trim() : `#${id}`;
}
document.getElementById('redeploy-customer-id').value = id; document.getElementById('redeploy-customer-id').value = id;
document.getElementById('redeploy-customer-name').textContent = name; document.getElementById('redeploy-customer-name').textContent = name;
new bootstrap.Modal(document.getElementById('redeploy-modal')).show(); new bootstrap.Modal(document.getElementById('redeploy-modal')).show();
@@ -782,11 +927,19 @@ 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>
<button class="btn btn-info btn-sm me-1" onclick="customerAction(${id},'restart')"><i class="bi bi-arrow-repeat me-1"></i>${t('customer.restart')}</button> <button class="btn btn-info btn-sm me-1" onclick="customerAction(${id},'restart')"><i class="bi bi-arrow-repeat me-1"></i>${t('customer.restart')}</button>
<button class="btn btn-outline-primary btn-sm me-1" onclick="customerAction(${id},'deploy')"><i class="bi bi-rocket me-1"></i>${t('customer.reDeploy')}</button> <button class="btn btn-outline-primary btn-sm me-1" data-customer-name="${esc(data.name)}" onclick="customerAction(${id},'deploy',this.dataset.customerName)"><i class="bi bi-rocket me-1"></i>${t('customer.reDeploy')}</button>
<button class="btn btn-outline-warning btn-sm" id="btn-update-images-detail" onclick="updateCustomerImagesFromDetail(${id})"> <button class="btn btn-outline-warning btn-sm" id="btn-update-images-detail" onclick="updateCustomerImagesFromDetail(${id})">
<span id="update-detail-spinner" class="spinner-border spinner-border-sm d-none me-1"></span> <span id="update-detail-spinner" class="spinner-border spinner-border-sm d-none me-1"></span>
<i class="bi bi-arrow-repeat me-1"></i>${t('customer.updateImages')} <i class="bi bi-arrow-repeat me-1"></i>${t('customer.updateImages')}
@@ -794,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>
@@ -910,6 +1064,20 @@ async function loadSettings() {
document.getElementById('cfg-relay-image').value = cfg.netbird_relay_image || ''; document.getElementById('cfg-relay-image').value = cfg.netbird_relay_image || '';
document.getElementById('cfg-dashboard-image').value = cfg.netbird_dashboard_image || ''; document.getElementById('cfg-dashboard-image').value = cfg.netbird_dashboard_image || '';
document.getElementById('cfg-auto-update-check-enabled').checked = cfg.auto_update_check_enabled || false;
document.getElementById('cfg-auto-update-check-time').value = cfg.auto_update_check_time || '03:00';
document.getElementById('cfg-auto-update-apply-enabled').checked = cfg.auto_update_apply_enabled || false;
document.getElementById('auto-update-last-run').textContent = cfg.auto_update_last_run_at
? new Date(cfg.auto_update_last_run_at).toLocaleString()
: 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 || '';
@@ -1028,6 +1196,82 @@ document.getElementById('settings-images-form').addEventListener('submit', async
} }
}); });
// Automatic update settings form
document.getElementById('settings-auto-update-form').addEventListener('submit', async (e) => {
e.preventDefault();
try {
await api('PUT', '/settings/system', {
auto_update_check_enabled: document.getElementById('cfg-auto-update-check-enabled').checked,
auto_update_check_time: document.getElementById('cfg-auto-update-check-time').value || '03:00',
auto_update_apply_enabled: document.getElementById('cfg-auto-update-apply-enabled').checked,
});
showSettingsAlert('success', t('messages.imageSettingsSaved'));
} catch (err) {
showSettingsAlert('danger', t('errors.failed', { error: err.message }));
}
});
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');
@@ -1371,11 +1615,12 @@ async function loadVersionInfo() {
if (needsUpdate) { if (needsUpdate) {
html += `<div class="mt-3"> html += `<div class="mt-3">
<button class="btn btn-warning" onclick="triggerUpdate()"> <button class="btn btn-warning" id="update-trigger-btn" onclick="triggerUpdate()">
<span class="spinner-border spinner-border-sm d-none me-1" id="update-spinner"></span> <span class="spinner-border spinner-border-sm d-none me-1" id="update-spinner"></span>
<i class="bi bi-arrow-repeat me-1"></i>${t('settings.triggerUpdate')} <i class="bi bi-arrow-repeat me-1"></i>${t('settings.triggerUpdate')}
</button> </button>
<div class="text-muted small mt-1">${t('settings.updateWarning')}</div> <div class="text-muted small mt-1">${t('settings.updateWarning')}</div>
<div class="small mt-2 d-none" id="update-progress-text"></div>
</div>`; </div>`;
} }
el.innerHTML = html; el.innerHTML = html;
@@ -1387,14 +1632,76 @@ async function loadVersionInfo() {
async function triggerUpdate() { async function triggerUpdate() {
if (!confirm(t('settings.confirmUpdate'))) return; if (!confirm(t('settings.confirmUpdate'))) return;
const spinner = document.getElementById('update-spinner'); const spinner = document.getElementById('update-spinner');
const btn = document.getElementById('update-trigger-btn');
const progressText = document.getElementById('update-progress-text');
const setProgress = (msg) => {
if (!progressText) return;
progressText.classList.remove('d-none');
progressText.textContent = msg;
};
const stopUpdateUi = () => {
if (spinner) spinner.classList.add('d-none');
if (btn) btn.disabled = false;
};
if (spinner) spinner.classList.remove('d-none'); if (spinner) spinner.classList.remove('d-none');
if (btn) btn.disabled = true;
setProgress(t('settings.updateStepStarting'));
try { try {
const data = await api('POST', '/settings/update'); const data = await api('POST', '/settings/update');
showSettingsAlert('success', data.message || t('messages.updateStarted')); showSettingsAlert('success', data.message || t('messages.updateStarted'));
} catch (err) { } catch (err) {
showSettingsAlert('danger', t('errors.failed', { error: err.message })); showSettingsAlert('danger', t('errors.failed', { error: err.message }));
if (spinner) spinner.classList.add('d-none'); stopUpdateUi();
return;
} }
// Phase 1: poll build/pull progress until the container restarts
// (connection drops, which is expected and is our cue to move to phase 2).
const stepLabelKey = {
backup: 'settings.updateStepBackup',
pull: 'settings.updateStepPull',
build: 'settings.updateStepBuild',
restart: 'settings.updateStepRestart',
};
for (let i = 0; i < 200; i++) {
await new Promise(r => setTimeout(r, 2000));
try {
const st = await api('GET', '/settings/update/status');
if (st.state === 'failed') {
showSettingsAlert('danger', st.message || t('errors.requestFailed'));
stopUpdateUi();
return;
}
setProgress(t(stepLabelKey[st.step] || 'settings.updateStepStarting') + (st.message ? `${st.message}` : ''));
} catch (err) {
// Connection dropped — the container is very likely mid-restart. Move on.
break;
}
}
// Phase 2: wait for the app to come back up, then reload version info.
setProgress(t('settings.updateStepReconnecting'));
for (let i = 0; i < 90; i++) {
await new Promise(r => setTimeout(r, 2000));
try {
await api('GET', '/settings/version');
setProgress(t('settings.updateStepDone'));
stopUpdateUi();
showSettingsAlert('success', t('settings.updateStepDone'));
await loadVersionInfo();
return;
} catch (err) {
// still restarting — keep polling
}
}
// Gave up waiting — surface this instead of spinning forever.
stopUpdateUi();
setProgress('');
if (progressText) progressText.classList.add('d-none');
showSettingsAlert('warning', t('settings.updateStepTimeout'));
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1714,7 +2021,9 @@ async function checkImageUpdates() {
: data.customer_status.map(c => { : data.customer_status.map(c => {
const badge = c.needs_update const badge = c.needs_update
? `<span class="badge bg-warning text-dark">${t('monitoring.needsUpdate')}</span>` ? `<span class="badge bg-warning text-dark">${t('monitoring.needsUpdate')}</span>`
: `<span class="badge bg-success">${t('monitoring.upToDate')}</span>`; : c.unknown
? `<span class="badge bg-secondary" title="${t('monitoring.statusUnknownHint')}">${t('monitoring.statusUnknown')}</span>`
: `<span class="badge bg-success">${t('monitoring.upToDate')}</span>`;
const updateBtn = c.needs_update const updateBtn = c.needs_update
? `<button class="btn btn-sm btn-outline-warning ms-2 btn-update-customer" onclick="updateCustomerImages(${c.customer_id})" ? `<button class="btn btn-sm btn-outline-warning ms-2 btn-update-customer" onclick="updateCustomerImages(${c.customer_id})"
title="${t('monitoring.updateCustomer')}"><i class="bi bi-arrow-repeat"></i></button>` title="${t('monitoring.updateCustomer')}"><i class="bi bi-arrow-repeat"></i></button>`
+41 -2
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",
@@ -230,6 +250,14 @@
"triggerUpdate": "Update starten", "triggerUpdate": "Update starten",
"updateWarning": "Die App ist während des Rebuilds ca. 60 Sekunden nicht verfügbar.", "updateWarning": "Die App ist während des Rebuilds ca. 60 Sekunden nicht verfügbar.",
"confirmUpdate": "Update jetzt starten? Die Datenbank wird zuerst gesichert. Die App startet neu (~60 Sekunden Ausfallzeit).", "confirmUpdate": "Update jetzt starten? Die Datenbank wird zuerst gesichert. Die App startet neu (~60 Sekunden Ausfallzeit).",
"updateStepStarting": "Update wird gestartet …",
"updateStepBackup": "Datenbank wird gesichert …",
"updateStepPull": "Code wird geholt …",
"updateStepBuild": "Docker-Image wird gebaut (kann mehrere Minuten dauern) …",
"updateStepRestart": "Container wird neu gestartet …",
"updateStepReconnecting": "Container startet neu — warte auf Verbindung …",
"updateStepDone": "Update abgeschlossen.",
"updateStepTimeout": "Update läuft länger als erwartet. Bitte Server-Logs prüfen oder die Seite in ein paar Minuten neu laden.",
"gitTitle": "Git-Repository Einstellungen", "gitTitle": "Git-Repository Einstellungen",
"gitRepoUrl": "Repository URL", "gitRepoUrl": "Repository URL",
"gitRepoUrlHint": "Wird für Versionsprüfungen und One-Click-Updates via Gitea API verwendet.", "gitRepoUrlHint": "Wird für Versionsprüfungen und One-Click-Updates via Gitea API verwendet.",
@@ -412,6 +440,17 @@
"updating": "Wird aktualisiert…", "updating": "Wird aktualisiert…",
"updateAllProgress": "Kunden-Container werden nacheinander aktualisiert — bitte warten…", "updateAllProgress": "Kunden-Container werden nacheinander aktualisiert — bitte warten…",
"pulling": "Wird geladen…", "pulling": "Wird geladen…",
"pullStartedShort": "Download im Hintergrund gestartet." "pullStartedShort": "Download im Hintergrund gestartet.",
"statusUnknown": "Unbekannt",
"statusUnknownHint": "Container konnte nicht eindeutig zugeordnet werden (z. B. nicht gestartet). Kein verifiziertes \"Aktuell\".",
"autoUpdateTitle": "Automatische Aktualisierung",
"autoUpdateHint": "Prüft täglich zur gewählten Uhrzeit automatisch auf neue NetBird-Images.",
"autoUpdateCheckEnabled": "Automatische Update-Prüfung aktivieren",
"autoUpdateCheckTime": "Uhrzeit der täglichen Prüfung",
"autoUpdateApplyEnabled": "Kunden-Container nach Prüfung automatisch aktualisieren",
"autoUpdateApplyHint": "Wenn aktiviert, werden nach einer gefundenen Aktualisierung automatisch alle Kunden-Container neu erstellt (kurzer Neustart der Dienste). Wenn deaktiviert, werden nur die Images geladen — die Aktualisierung der Kunden erfolgt weiterhin manuell.",
"autoUpdateLastRun": "Letzte automatische Prüfung",
"autoUpdateNever": "Noch nie ausgeführt",
"saveAutoUpdateSettings": "Automatisierung speichern"
} }
} }
+41 -2
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",
@@ -262,6 +282,14 @@
"triggerUpdate": "Start Update", "triggerUpdate": "Start Update",
"updateWarning": "The app will be unavailable for ~60 seconds during rebuild.", "updateWarning": "The app will be unavailable for ~60 seconds during rebuild.",
"confirmUpdate": "Start the update now? The database will be backed up first. The app will restart (~60 seconds downtime).", "confirmUpdate": "Start the update now? The database will be backed up first. The app will restart (~60 seconds downtime).",
"updateStepStarting": "Starting update …",
"updateStepBackup": "Backing up database …",
"updateStepPull": "Fetching code …",
"updateStepBuild": "Building Docker image (can take several minutes) …",
"updateStepRestart": "Restarting container …",
"updateStepReconnecting": "Container is restarting — waiting for connection …",
"updateStepDone": "Update complete.",
"updateStepTimeout": "Update is taking longer than expected. Check the server logs or reload this page in a few minutes.",
"gitTitle": "Git Repository Settings", "gitTitle": "Git Repository Settings",
"gitRepoUrl": "Repository URL", "gitRepoUrl": "Repository URL",
"gitRepoUrlHint": "Used for version checks and one-click updates via Gitea API.", "gitRepoUrlHint": "Used for version checks and one-click updates via Gitea API.",
@@ -319,7 +347,18 @@
"updating": "Updating…", "updating": "Updating…",
"updateAllProgress": "Updating customer containers one by one — please wait…", "updateAllProgress": "Updating customer containers one by one — please wait…",
"pulling": "Pulling…", "pulling": "Pulling…",
"pullStartedShort": "Pull started in background." "pullStartedShort": "Pull started in background.",
"statusUnknown": "Unknown",
"statusUnknownHint": "Container could not be matched reliably (e.g. not running). Not a verified \"up to date\".",
"autoUpdateTitle": "Automatic Updates",
"autoUpdateHint": "Automatically checks for new NetBird images daily at the chosen time.",
"autoUpdateCheckEnabled": "Enable automatic update check",
"autoUpdateCheckTime": "Daily check time",
"autoUpdateApplyEnabled": "Automatically update customer containers after check",
"autoUpdateApplyHint": "When enabled, all customer containers are automatically recreated after a new image is found (services briefly restart). When disabled, only the images are pulled — updating customers stays a manual step.",
"autoUpdateLastRun": "Last automatic check",
"autoUpdateNever": "Never run",
"saveAutoUpdateSettings": "Save Automation"
}, },
"userModal": { "userModal": {
"title": "New User", "title": "New User",
+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: