Compare commits

...
19 Commits
Author SHA1 Message Date
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
twothatITandClaude Sonnet 4.6 9ace554427 fix(cache): bust browser cache for JS and i18n files after updates
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-03-10 21:57:36 +01:00
twothatITandClaude Sonnet 4.6 f48c851ef0 fix(cache): bust browser cache for JS and i18n files after updates
After a container update, browsers serve stale app.js and lang/*.json
from cache, causing old UI code and missing translations to appear.

- serve_index() now reads the git commit hash and injects ?v=COMMIT into
  all static asset URLs (app.js, i18n.js, styles.css) in index.html
- window.STATIC_VERSION is injected into the page so i18n.js can append
  the same version to lang/*.json fetch calls
- index.html itself is served with Cache-Control: no-cache so the browser
  always revalidates it and picks up new asset URLs on next load

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-03-10 21:57:18 +01:00
twothatITandClaude Sonnet 4.6 7d694c62bd feat(deploy): redeploy dialog with keep-data or fresh-deploy option
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-03-10 21:34:26 +01:00
twothatITandClaude Sonnet 4.6 d1bb6a633e feat(deploy): redeploy dialog with keep-data or fresh-deploy option
Add a confirmation modal when clicking Redeploy that lets the user choose:
- Keep Data: containers are recreated without wiping the instance directory.
  NetBird database, peer configs, and encryption keys are preserved.
- Fresh Deploy: full undeploy (removes all data) then redeploy from scratch.

Backend changes:
- POST /customers/{id}/deploy accepts keep_data query param (default false)
- When keep_data=true, undeploy_customer is skipped entirely
- deploy_customer now reuses existing npm_proxy_id/stream_id when the
  deployment record is still present (avoids duplicate NPM proxy entries)
- DNS record creation is skipped on keep_data redeploy (already exists)

Frontend changes:
- customerAction('deploy') opens the redeploy modal instead of calling API
- showRedeployModal(id) shows the two-option confirmation card dialog
- confirmRedeploy(keepData) calls the API with the correct parameter
- i18n keys added in en.json and de.json

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-03-10 21:34:12 +01:00
twothatITandClaude Sonnet 4.6 b39a502257 fix(images): use Docker Registry v2 API for correct digest comparison
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-03-02 15:15:22 +01:00
twothatITandClaude Sonnet 4.6 dee07d7b8e fix(images): use Docker Registry v2 API for correct digest comparison
The Docker Hub REST API returns per-platform manifest digests, while
docker image inspect RepoDigests stores the manifest list digest.
These two values never match, causing update_available to always be
True even after a fresh pull.

Fix: use registry-1.docker.io/v2/{name}/manifests/{tag} with anonymous
auth and read the Docker-Content-Digest response header, which is the
exact same digest that docker pull stores in RepoDigests.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-03-02 15:15:05 +01:00
twothatITandClaude Sonnet 4.6 351caec893 docs: update README with all current features and correct settings
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-02-25 08:34:29 +01:00
twothatITandClaude Sonnet 4.6 dd04408dc2 docs: update README with all current features and correct settings
- Add NetBird Container Updates section (digest check, pull, bulk update)
- Add update indicators, dark mode, LDAP/AD, Windows DNS to features
- Correct Settings table: add all tabs, remove incorrect Monitoring entry
- Split Docker Images tab out of System tab
- Add sudo install note for fresh Debian minimal
- Rewrite Updating NetBird Images section with new UI-based workflow
- Add new monitoring/image API endpoints to API docs

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-02-25 08:33:20 +01:00
twothatITandClaude Sonnet 4.6 6373722c2b chore(release): merge unstable → main for beta-1.0
Promotes alpha-1.25 to beta-1.0 (stable branch).

Highlights:
- NetBird container update management (check / pull / update per customer + bulk)
- Visual update badges on dashboard and customer detail
- Dark mode toggle with localStorage persistence
- User role management for Azure AD / LDAP users
- Branding logo persistence across updates (Docker volume)
- Favicon, NPM stream removal, MFA (TOTP)
- LDAP / Active Directory and Azure AD SSO
- Windows DNS integration
- Settings restructure and Git branch dropdown

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-02-24 21:58:24 +01:00
twothatIT 2713e67259 Deutsch korrektur 2026-02-09 15:55:01 +01:00
17 changed files with 773 additions and 133 deletions
+143 -22
View File
@@ -38,11 +38,20 @@ A management solution for running isolated NetBird instances for your MSP busine
- **Docker-Based** — Everything runs in containers for easy deployment
### Dashboard
- **Modern Web UI** — Responsive Bootstrap 5 interface
- **Modern Web UI** — Responsive Bootstrap 5 interface with dark/light mode toggle
- **Real-Time Monitoring** — Container status, health checks, resource usage
- **Container Logs** — View logs per container directly in the browser
- **Start / Stop / Restart** — Control customer instances from the dashboard
- **Customer Status Tracking** — Automatic status sync (active / inactive / error)
- **Update Indicators** — Per-customer badges when container images are outdated
### NetBird Container Updates
- **Docker Hub Digest Check** — Compare locally pulled image digests against Docker Hub without pulling
- **One-Click Pull** — Pull all NetBird images from Docker Hub via Settings
- **Bulk Update** — Update all outdated customer containers at once from the Monitoring page
- **Per-Customer Update** — Update a single customer's containers from the customer detail view
- **Zero Data Loss** — Container recreation preserves all bind-mounted volumes
- **Sequential Updates** — Customers are updated one at a time to minimize risk
### Multi-Language (i18n)
- **English and German** — Full UI translation
@@ -55,13 +64,18 @@ A management solution for running isolated NetBird instances for your MSP busine
- **Login Page** — Branding is applied to the login page automatically
- **Configurable Docker Images** — Use custom or specific NetBird image versions
### Security
### Authentication & User Management
- **JWT Authentication** — Token-based API authentication
- **Multi-Factor Authentication (MFA)** — Optional TOTP-based MFA for all local users, activatable in Security settings
- **Azure AD / OIDC** — Optional single sign-on via Microsoft Entra ID (exempt from MFA)
- **Encrypted Credentials** — NPM passwords, relay secrets, and TOTP secrets are Fernet-encrypted at rest
- **LDAP / Active Directory** — Allow AD users to authenticate; local admin accounts always work as fallback
- **Encrypted Credentials** — NPM passwords, relay secrets, TOTP secrets, and LDAP bind passwords are Fernet-encrypted at rest
- **User Management** — Create, edit, delete admin users, reset passwords and MFA
### Integrations
- **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
---
## Architecture
@@ -178,6 +192,18 @@ The following tools and services must be available **before** running the instal
### Install Prerequisites (Ubuntu/Debian)
> **Note:** On a fresh Debian minimal install, `sudo` is not pre-installed. Install it as root first:
```bash
# As root — only needed on fresh Debian minimal (sudo not pre-installed):
apt update && apt install -y sudo
# Install remaining prerequisites:
sudo apt install -y curl git openssl
```
If `sudo` is already available (Ubuntu, most standard installs):
```bash
sudo apt update
sudo apt install -y curl git openssl
@@ -266,17 +292,32 @@ HOST_IP=<your-server-ip>
### Web UI Settings
Available under **Settings** in the web interface:
Available under **Settings** in the web interface, organized into tabs:
#### User Management
| Tab | Settings |
|-----|----------|
| **System** | Base domain, admin email, Docker images, port ranges, data directory |
| **NPM Integration** | NPM API URL, login credentials, SSL certificate mode (Let's Encrypt / Wildcard), wildcard certificate selection |
| **Branding** | Platform name, subtitle, logo upload, default language |
| **Azure AD** | Azure AD / Entra ID SSO configuration (tenant ID, client ID/secret, optional group restriction) |
| **Users** | Create/edit/delete admin users, per-user language preference, MFA reset |
| **Azure AD** | Azure AD / Entra ID SSO configuration |
| **LDAP / AD** | LDAP/Active Directory authentication (server, base DN, bind credentials, group restriction), enable/disable |
| **Security** | Change admin password, enable/disable MFA globally, manage own TOTP |
| **Monitoring** | System resources, Docker stats |
#### System
| Tab | Settings |
|-----|----------|
| **Branding** | Platform name, subtitle, logo upload, default language |
| **NetBird Docker Images** | Configured NetBird image tags (management, signal, relay, dashboard), pull images from Docker Hub |
| **NetBird MSP System** | Base domain, admin email, port ranges, data directory |
| **NetBird MSP Updates** | Appliance version info, check for updates, switch release branch |
#### External Systems
| Tab | Settings |
|-----|----------|
| **NPM Proxy** | NPM API URL, login credentials, SSL certificate mode (Let's Encrypt / Wildcard), wildcard certificate selection |
| **Windows DNS** | Windows DNS server integration for automatic DNS A-record creation/deletion on customer deploy/delete |
Changes are applied immediately without restart.
@@ -308,11 +349,42 @@ Changes are applied immediately without restart.
### Monitoring
The dashboard shows:
The **Monitoring** page shows:
- **System Overview** — Total customers, active/inactive, errors
- **Resource Usage** — RAM, CPU per container
- **Container Health** — Running/stopped per container with color-coded status
- **Deployment Logs** — Action history per customer
- **Host Resources** — CPU, RAM, disk usage of the host machine
- **Customer Status** — Container health per customer (running/stopped)
- **NetBird Container Updates** — Compare local image digests against Docker Hub, pull new images, and update all outdated customer containers
### NetBird Container Updates
#### Workflow
1. **Check for updates** — Go to **Monitoring > NetBird Container Updates**, click **"Check Updates"**
- Compares local image digests against Docker Hub
- Shows which images have a new version available
- Shows which customer containers are running outdated images
- An orange badge appears next to customers in the dashboard list that need updating
2. **Pull new images** — Go to **Settings > NetBird Docker Images**, click **"Pull from Docker Hub"**
- Pulls all 4 NetBird images (`management`, `signal`, `relay`, `dashboard`) in the background
- Wait for the pull to complete before updating customers
3. **Update customers** — Return to **Monitoring > NetBird Container Updates**, click **"Update All Customers"**
- Recreates containers for all customers whose running image is outdated
- Customers are updated **sequentially** — one at a time
- All bind-mounted volumes (database, keys, config) are preserved — **no data loss**
- A per-customer results table is shown after completion
#### Per-Customer Update
To update a single customer:
1. Open the customer detail view
2. Go to the **Deployment** tab
3. Click **"Update Images"**
#### Update Badges
The dashboard customer list shows an orange **"Update"** badge next to any customer whose running containers are using an outdated local image. This check is fast (local-only, no network call) and runs automatically when the dashboard loads.
### Language Settings
@@ -320,9 +392,13 @@ The dashboard shows:
- **Per-user default** — Set in Settings > Users during user creation
- **System default** — Set in Settings > Branding
### Dark Mode
Toggle dark/light mode using the moon/sun icon in the top navigation bar. The preference is saved in the browser.
### Multi-Factor Authentication (MFA)
TOTP-based MFA can be enabled globally for all local users. Azure AD users are not affected (they use their own MFA).
TOTP-based MFA can be enabled globally for all local users. Azure AD and LDAP users are not affected (they use their own authentication systems).
#### Enable MFA
1. Go to **Settings > Security**
@@ -344,9 +420,30 @@ When MFA is enabled and a user logs in for the first time:
- **Disable own TOTP** — In Settings > Security, click "Disable my TOTP" to remove your own MFA setup
- **Disable MFA globally** — Uncheck the toggle in Settings > Security to allow login without MFA
### LDAP / Active Directory Authentication
Active Directory users can log in to the appliance using their AD credentials. Local admin accounts always work as a fallback regardless of LDAP status.
#### Setup
1. Go to **Settings > LDAP / AD**
2. Enable **"LDAP / AD Authentication"**
3. Enter LDAP server, port, bind DN (service account), bind password, and base DN
4. Optionally restrict access to members of a specific AD group
5. Click **Save LDAP Settings**
### Windows DNS Integration
Automatically create and delete DNS A-records in a Windows DNS server when customers are deployed or deleted.
#### Setup
1. Go to **Settings > Windows DNS**
2. Enable **"Windows DNS Integration"**
3. Enter the DNS server details
4. Click **Save DNS Settings**
### SSL Certificate Mode
The appliance supports two SSL certificate modes for customer proxy hosts, configurable under **Settings > NPM Integration**:
The appliance supports two SSL certificate modes for customer proxy hosts, configurable under **Settings > NPM Proxy**:
#### Let's Encrypt (default)
Each customer gets an individual Let's Encrypt certificate via HTTP-01 validation. This is the default behavior and requires no additional setup beyond a valid admin email.
@@ -356,7 +453,7 @@ Use a pre-existing wildcard certificate (e.g. `*.yourdomain.com`) already upload
**Setup:**
1. Upload a wildcard certificate in Nginx Proxy Manager (e.g. via DNS challenge)
2. Go to **Settings > NPM Integration**
2. Go to **Settings > NPM Proxy**
3. Set **SSL Mode** to "Wildcard Certificate"
4. Click the refresh button to load certificates from NPM
5. Select your wildcard certificate from the dropdown
@@ -396,10 +493,12 @@ POST /api/customers/{id}/stop # Stop containers
POST /api/customers/{id}/restart # Restart containers
GET /api/customers/{id}/logs # Get container logs
GET /api/customers/{id}/health # Health check
POST /api/customers/{id}/update-images # Recreate containers with new images
GET /api/settings/branding # Get branding (public, no auth)
GET /api/settings/npm-certificates # List NPM SSL certificates
PUT /api/settings # Update system settings
GET /api/users # List users
POST /api/users # Create user
POST /api/users/{id}/reset-mfa # Reset user's MFA
@@ -409,6 +508,11 @@ POST /api/auth/mfa/setup/complete # Verify first TOTP code
POST /api/auth/mfa/verify # Verify TOTP code on login
GET /api/auth/mfa/status # Get MFA status
POST /api/auth/mfa/disable # Disable own TOTP
GET /api/monitoring/images/check # Check Hub vs local digests for all images
POST /api/monitoring/images/pull # Pull all NetBird images from Docker Hub (background)
GET /api/monitoring/customers/local-update-status # Fast local-only update check (no network)
POST /api/monitoring/customers/update-all # Recreate outdated containers for all customers
```
### Example: Create Customer via API
@@ -488,11 +592,28 @@ The database migrations run automatically on startup.
### Updating NetBird Images
Via the Web UI:
1. Settings > System Configuration
2. Change image tags (e.g., `netbirdio/management:0.35.0`)
3. Click "Save"
4. Re-deploy individual customers to apply the new images
NetBird image updates are managed entirely through the Web UI — no manual config changes required.
#### Step 1 — Pull new images
1. Go to **Settings > NetBird Docker Images**
2. Click **"Pull from Docker Hub"**
3. Wait for the pull to complete (progress shown inline)
#### Step 2 — Check which customers need updating
1. Go to **Monitoring > NetBird Container Updates**
2. Click **"Check Updates"**
3. The table shows per-image Hub vs. local digest comparison and which customers are running outdated containers
#### Step 3 — Update customer containers
- **All customers**: Click **"Update All Customers"** in the Monitoring page
- Customers are updated sequentially, one at a time
- A results table is shown after completion
- **Single customer**: Open the customer detail view > **Deployment** tab > **"Update Images"**
> All bind-mounted volumes (database, keys, config files) are preserved. Container recreation does not cause data loss.
---
@@ -546,7 +667,7 @@ MIT License — see [LICENSE](LICENSE) file for details.
## Built With AI
This software was developed with [Claude Code](https://claude.ai/claude-code) (Anthropic Claude Opus 4.6) — from architecture and backend logic to frontend UI and deployment scripts.
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.
## Acknowledgments
+26 -6
View File
@@ -33,7 +33,7 @@ logger = logging.getLogger(__name__)
app = FastAPI(
title="NetBird MSP Appliance",
description="Multi-tenant NetBird management platform for MSPs",
version="1.0.0",
version="1.2.0",
docs_url="/api/docs",
redoc_url="/api/redoc",
openapi_url="/api/openapi.json",
@@ -90,16 +90,36 @@ STATIC_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "static")
if os.path.isdir(STATIC_DIR):
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
# Serve index.html at root
from fastapi.responses import FileResponse
# Serve index.html at root — inject cache-busting version into static asset URLs
# so the browser always loads fresh JS/CSS after a container update.
from fastapi.responses import FileResponse, HTMLResponse
from app.services import update_service
_STATIC_ASSETS = (
'"/static/js/app.js"',
'"/static/js/i18n.js"',
'"/static/css/styles.css"',
)
def _cache_bust_index(html: str, version: str) -> str:
# Inject version as a global JS variable so i18n.js can bust lang file caches too
html = html.replace("</head>", f'<script>window.STATIC_VERSION="{version}";</script>\n</head>', 1)
for asset in _STATIC_ASSETS:
busted = asset.rstrip('"') + f'?v={version}"'
html = html.replace(asset, busted)
return html
@app.get("/", include_in_schema=False)
async def serve_index():
"""Serve the main dashboard."""
"""Serve the main dashboard with cache-busted static asset URLs."""
index_path = os.path.join(STATIC_DIR, "index.html")
if os.path.isfile(index_path):
return FileResponse(index_path)
if not os.path.isfile(index_path):
return JSONResponse({"message": "NetBird MSP Appliance API is running."})
version = update_service.get_current_version().get("commit", "unknown")
html = open(index_path, encoding="utf-8").read()
html = _cache_bust_index(html, version)
return HTMLResponse(content=html, headers={"Cache-Control": "no-cache"})
# ---------------------------------------------------------------------------
+19 -2
View File
@@ -78,22 +78,36 @@ async def create_customer(
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("")
async def list_customers(
page: int = Query(default=1, ge=1),
per_page: int = Query(default=25, ge=1, le=100),
search: Optional[str] = Query(default=None),
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),
db: Session = Depends(get_db),
):
"""List customers with pagination, search, and status filter.
"""List customers with pagination, search, status filter, and sorting.
Args:
page: Page number (1-indexed).
per_page: Items per page.
search: Search in name, subdomain, email.
status_filter: Filter by status.
sort_by: Column to sort by — one of SORTABLE_CUSTOMER_COLUMNS.
sort_order: "asc" or "desc".
Returns:
Paginated customer list with metadata.
@@ -113,8 +127,11 @@ async def list_customers(
query = query.filter(Customer.status == status_filter)
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 = (
query.order_by(Customer.created_at.desc())
query.order_by(sort_expr, Customer.id.asc())
.offset((page - 1) * per_page)
.limit(per_page)
.all()
+14 -5
View File
@@ -2,7 +2,7 @@
import logging
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.database import SessionLocal, get_db
@@ -19,6 +19,14 @@ router = APIRouter()
async def manual_deploy(
customer_id: int,
background_tasks: BackgroundTasks,
keep_data: bool = Query(
False,
description=(
"If True, preserve existing NetBird data (database, keys, peers). "
"Containers are recreated without wiping the instance directory. "
"If False (default), the instance is fully removed and redeployed from scratch."
),
),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
@@ -29,6 +37,7 @@ async def manual_deploy(
Args:
customer_id: Customer ID.
keep_data: Whether to preserve existing NetBird data.
Returns:
Acknowledgement dict.
@@ -40,12 +49,12 @@ async def manual_deploy(
customer.status = "deploying"
db.commit()
async def _deploy_bg(cid: int) -> None:
async def _deploy_bg(cid: int, keep: bool) -> None:
bg_db = SessionLocal()
try:
# Remove existing deployment if present
existing = bg_db.query(Deployment).filter(Deployment.customer_id == cid).first()
if existing:
if existing and not keep:
# Full redeploy: remove everything first
await netbird_service.undeploy_customer(bg_db, cid)
await netbird_service.deploy_customer(bg_db, cid)
except Exception:
@@ -53,7 +62,7 @@ async def manual_deploy(
finally:
bg_db.close()
background_tasks.add_task(_deploy_bg, customer_id)
background_tasks.add_task(_deploy_bg, customer_id, keep_data)
return {"message": "Deployment started in background.", "status": "deploying"}
+32 -9
View File
@@ -1,7 +1,9 @@
"""Monitoring API — system overview, customer statuses, host resources."""
import asyncio
import logging
import platform
import time
from typing import Any
import psutil
@@ -16,6 +18,13 @@ from app.services import docker_service, image_service
logger = logging.getLogger(__name__)
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")
async def system_status(
@@ -58,8 +67,7 @@ async def all_customers_status(
.all()
)
results: list[dict[str, Any]] = []
for c in customers:
async def _build_entry(c: Customer) -> dict[str, Any]:
entry: dict[str, Any] = {
"id": c.id,
"name": c.name,
@@ -67,7 +75,7 @@ async def all_customers_status(
"status": c.status,
}
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["containers"] = containers
entry["relay_udp_port"] = c.deployment.relay_udp_port
@@ -76,9 +84,11 @@ async def all_customers_status(
else:
entry["deployment_status"] = None
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")
@@ -205,15 +215,28 @@ async def customers_local_update_status(
Compares running container image IDs against locally stored images.
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()
if not config:
return []
deployments = db.query(Deployment).all()
results = []
for dep in deployments:
cs = image_service.get_customer_container_image_status(dep.container_prefix, config)
results.append({"customer_id": dep.customer_id, "needs_update": cs["needs_update"]})
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"]}
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
+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.
"""
import asyncio
import logging
import os
import shutil
@@ -358,13 +359,15 @@ async def trigger_update(
current_user: User = Depends(get_current_user),
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.
Returns:
Dict with ok, message, and backup path.
"""
if getattr(current_user, "role", "admin") != "admin":
raise HTTPException(
@@ -383,11 +386,37 @@ async def trigger_update(
detail="git_repo_url is not configured in settings.",
)
result = update_service.trigger_update(config, DATABASE_PATH)
if not result.get("ok"):
current_status = update_service.get_update_status()
if current_status.get("state") == "running":
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=result.get("message", "Update failed."),
status_code=status.HTTP_409_CONFLICT,
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)
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:
"""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:
docker.DockerClient instance.
"""
return docker.from_env()
global _client
if _client is None:
_client = docker.from_env()
return _client
async def compose_up(
@@ -212,6 +223,16 @@ def get_container_status(container_prefix: str) -> list[dict[str, Any]]:
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:
"""Retrieve recent logs from a container.
+73 -19
View File
@@ -38,33 +38,49 @@ def _parse_image_name(image: str) -> tuple[str, str]:
async def get_hub_digest(image: str) -> str | None:
"""Fetch the current digest from Docker Hub for an image:tag.
"""Fetch the manifest-list digest from the Docker Registry v2 API.
Uses the Docker Hub REST API — does NOT pull the image.
Returns the digest string (sha256:...) or None on failure.
Uses anonymous auth against registry-1.docker.io — does NOT pull the image.
Returns the Docker-Content-Digest header value (sha256:...) which is identical
to the digest stored in local RepoDigests after a pull, enabling correct comparison.
"""
name, tag = _parse_image_name(image)
url = f"https://hub.docker.com/v2/repositories/{name}/tags/{tag}/"
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(url)
if resp.status_code != 200:
logger.warning("Docker Hub API returned %d for %s", resp.status_code, image)
# Step 1: obtain anonymous pull token
token_resp = await client.get(
"https://auth.docker.io/token",
params={"service": "registry.docker.io", "scope": f"repository:{name}:pull"},
)
if token_resp.status_code != 200:
logger.warning("Failed to get registry token for %s", image)
return None
data = resp.json()
images = data.get("images", [])
# Prefer linux/amd64 digest
for img in images:
if img.get("os") == "linux" and img.get("architecture") in ("amd64", ""):
d = img.get("digest")
if d:
return d
# Fallback: first available digest
if images:
return images[0].get("digest")
token = token_resp.json().get("token")
# Step 2: fetch manifest — prefer manifest list (multi-arch) so the digest
# matches what `docker pull` stores in RepoDigests.
manifest_resp = await client.get(
f"https://registry-1.docker.io/v2/{name}/manifests/{tag}",
headers={
"Authorization": f"Bearer {token}",
"Accept": (
"application/vnd.docker.distribution.manifest.list.v2+json, "
"application/vnd.oci.image.index.v1+json, "
"application/vnd.docker.distribution.manifest.v2+json"
),
},
)
if manifest_resp.status_code != 200:
logger.warning("Registry API returned %d for %s", manifest_resp.status_code, image)
return None
# The Docker-Content-Digest header is the canonical digest
digest = manifest_resp.headers.get("docker-content-digest")
if digest:
return digest
return None
except Exception as exc:
logger.warning("Failed to fetch Docker Hub digest for %s: %s", image, exc)
logger.warning("Failed to fetch registry digest for %s: %s", image, exc)
return None
@@ -195,6 +211,44 @@ 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()
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())
return {"services": services, "needs_update": needs_update}
def get_customer_container_image_status(container_prefix: str, config) -> dict[str, Any]:
"""Check which service containers are running outdated local images.
+13 -6
View File
@@ -264,10 +264,12 @@ async def deploy_customer(db: Session, customer_id: int) -> dict[str, Any]:
_log_action(db, customer_id, "deploy", "info",
"Auto-setup failed — admin must complete setup manually.")
# Step 9: Create NPM proxy host (production only)
npm_proxy_id = None
npm_stream_id = None
if not local_mode:
# Step 9: Create NPM proxy host (production only).
# If an existing deployment already has an NPM proxy, reuse it — this happens
# when keep_data=True was passed and undeploy_customer was NOT called beforehand.
npm_proxy_id = existing_deployment.npm_proxy_id if existing_deployment else None
npm_stream_id = existing_deployment.npm_stream_id if existing_deployment else None
if not local_mode and not npm_proxy_id:
forward_host = npm_service._get_forward_host()
npm_result = await npm_service.create_proxy_host(
api_url=config.npm_api_url,
@@ -304,9 +306,14 @@ async def deploy_customer(db: Session, customer_id: int) -> dict[str, Any]:
"SSL certificate not created automatically. "
"Please create it manually in NPM or ensure DNS resolves and port 80 is reachable, then re-deploy.",
)
elif npm_proxy_id and not local_mode:
_log_action(db, customer_id, "deploy", "info",
f"Reusing existing NPM proxy (ID {npm_proxy_id}) — data preserved.")
# Step 10: Create Windows DNS A-record (non-fatal — failure does not abort deployment)
if config.dns_enabled and config.dns_server and config.dns_zone and config.dns_record_ip:
# Step 10: Create Windows DNS A-record (non-fatal — failure does not abort deployment).
# Skip if an existing deployment is being kept (DNS record already exists).
if config.dns_enabled and config.dns_server and config.dns_zone and config.dns_record_ip \
and not existing_deployment:
try:
dns_result = await dns_service.create_dns_record(customer.subdomain, config)
if dns_result["ok"]:
+94 -16
View File
@@ -12,9 +12,12 @@ Let's Encrypt SSL certificates.
Also manages NPM streams for STUN/TURN relay UDP ports.
"""
import base64
import json
import logging
import os
import socket
import time
from typing import Any
import httpx
@@ -24,6 +27,14 @@ logger = logging.getLogger(__name__)
# Timeout for NPM API calls (seconds)
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:
"""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]:
"""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:
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}"}
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:
count = len(resp.json())
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:
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}"}
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:
result = []
for cert in resp.json():
@@ -263,10 +333,14 @@ async def create_proxy_host(
"location ^~ /management.ManagementService/ {\n"
f" grpc_pass grpc://{forward_host}:{forward_port};\n"
" grpc_set_header Host $host;\n"
" grpc_read_timeout 3600s;\n"
" grpc_send_timeout 3600s;\n"
"}\n"
"location ^~ /signalexchange.SignalExchange/ {\n"
f" grpc_pass grpc://{forward_host}:{forward_port};\n"
" grpc_set_header Host $host;\n"
" grpc_read_timeout 3600s;\n"
" grpc_send_timeout 3600s;\n"
"}\n"
),
"meta": {
@@ -278,14 +352,15 @@ async def create_proxy_host(
try:
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 = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
resp = await client.post(
f"{api_url}/nginx/proxy-hosts", json=payload, headers=headers
resp, headers = await _request_with_reauth(
client, "POST", api_url, npm_email, npm_password,
"/nginx/proxy-hosts", headers, json=payload,
)
if resp.status_code in (200, 201):
data = resp.json()
@@ -538,14 +613,15 @@ async def create_stream(
try:
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}",
"Content-Type": "application/json",
}
resp = await client.post(
f"{api_url}/nginx/streams", json=payload, headers=headers
resp, headers = await _request_with_reauth(
client, "POST", api_url, npm_email, npm_password,
"/nginx/streams", headers, json=payload,
)
if resp.status_code in (200, 201):
data = resp.json()
@@ -583,10 +659,11 @@ async def delete_stream(
"""
try:
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}"}
resp = await client.delete(
f"{api_url}/nginx/streams/{stream_id}", headers=headers
resp, headers = await _request_with_reauth(
client, "DELETE", api_url, npm_email, npm_password,
f"/nginx/streams/{stream_id}", headers,
)
if resp.status_code in (200, 204):
logger.info("Deleted NPM stream %d", stream_id)
@@ -619,10 +696,11 @@ async def delete_proxy_host(
"""
try:
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}"}
resp = await client.delete(
f"{api_url}/nginx/proxy-hosts/{proxy_id}", headers=headers
resp, headers = await _request_with_reauth(
client, "DELETE", api_url, npm_email, npm_password,
f"/nginx/proxy-hosts/{proxy_id}", headers,
)
if resp.status_code in (200, 204):
logger.info("Deleted NPM proxy host %d", proxy_id)
+42
View File
@@ -20,6 +20,32 @@ SERVICE_NAME = "netbird-msp-appliance"
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:
"""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.
"""
# 1. Backup database before any changes
_set_status("running", "backup", "Datenbank wird gesichert …")
try:
backup_path = backup_database(db_path)
except Exception as 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}
# 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]
# 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).
# The .git dir may be owned by the host user after manual operations.
try:
@@ -270,13 +299,16 @@ def trigger_update(config: Any, db_path: str) -> dict:
timeout=120,
)
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}
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}
if result.returncode != 0:
stderr = result.stderr.strip()[:500]
logger.error("git pull failed (exit %d): %s", result.returncode, stderr)
_set_status("failed", "pull", f"git pull failed: {stderr}")
return {
"ok": False,
"message": f"git pull failed: {stderr}",
@@ -348,6 +380,7 @@ def trigger_update(config: Any, db_path: str) -> dict:
SERVICE_NAME,
]
logger.info("Phase A: building new image …")
_set_status("running", "build", "Docker-Image wird gebaut (kann mehrere Minuten dauern) …")
try:
build_result = subprocess.run(
build_cmd,
@@ -360,15 +393,18 @@ def trigger_update(config: Any, db_path: str) -> dict:
f.write(build_result.stderr)
if build_result.returncode != 0:
logger.error("Image build failed: %s", build_result.stderr[:500])
_set_status("failed", "build", f"Image build failed: {build_result.stderr[:300]}")
return {
"ok": False,
"message": f"Image build failed: {build_result.stderr[:300]}",
"backup": backup_path,
}
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}
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.
# 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")
except Exception as 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}
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:
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 {
"ok": False,
"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.")
except Exception as 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 {
+20
View File
@@ -1,5 +1,25 @@
/* 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 */
body.i18n-loading #login-page,
body.i18n-loading #app-page {
+49 -6
View File
@@ -254,13 +254,13 @@
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th data-i18n="dashboard.thId">ID</th>
<th data-i18n="dashboard.thName">Name</th>
<th data-i18n="dashboard.thSubdomain">Subdomain</th>
<th data-i18n="dashboard.thStatus">Status</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 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 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 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.thDevices">Devices</th>
<th data-i18n="dashboard.thCreated">Created</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 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>
</tr>
</thead>
@@ -1267,6 +1267,49 @@
</div>
</div>
<!-- Modal: Redeploy Confirmation -->
<div class="modal fade" id="redeploy-modal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header bg-primary text-white">
<h5 class="modal-title" data-i18n="redeployModal.title">Redeploy Customer</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p><span data-i18n="redeployModal.intro">How should</span> <strong id="redeploy-customer-name"></strong> <span data-i18n="redeployModal.intro2">be redeployed?</span></p>
<input type="hidden" id="redeploy-customer-id">
<div class="row g-3 mt-1">
<div class="col-md-6">
<div class="card h-100 border-success" style="cursor:pointer" onclick="confirmRedeploy(true)" id="redeploy-card-keep">
<div class="card-body">
<h6 class="card-title text-success"><i class="bi bi-shield-check me-2"></i><span data-i18n="redeployModal.keepTitle">Keep Data</span></h6>
<p class="card-text small" data-i18n="redeployModal.keepDesc">Containers are stopped and restarted. The NetBird database, peer configurations, and encryption keys are preserved. Use this after a config change or image update.</p>
</div>
<div class="card-footer bg-success bg-opacity-10 text-success small" data-i18n="redeployModal.keepNote">
Peers stay connected after restart.
</div>
</div>
</div>
<div class="col-md-6">
<div class="card h-100 border-danger" style="cursor:pointer" onclick="confirmRedeploy(false)" id="redeploy-card-fresh">
<div class="card-body">
<h6 class="card-title text-danger"><i class="bi bi-trash me-2"></i><span data-i18n="redeployModal.freshTitle">Fresh Deploy</span></h6>
<p class="card-text small" data-i18n="redeployModal.freshDesc">All existing data is deleted — containers, volumes, config files, and the NetBird database. A completely new instance is created. All peers must re-enroll.</p>
</div>
<div class="card-footer bg-danger bg-opacity-10 text-danger small" data-i18n="redeployModal.freshNote">
All peer data is lost. Cannot be undone.
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" data-i18n="common.cancel">Cancel</button>
</div>
</div>
</div>
</div>
<!-- Modal: Delete Confirmation -->
<div class="modal fade" id="delete-modal" tabindex="-1">
<div class="modal-dialog">
+123 -6
View File
@@ -12,6 +12,8 @@ let currentPage = 'dashboard';
let currentCustomerId = null;
let currentCustomerData = null;
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 azureConfig = { azure_enabled: false };
@@ -458,7 +460,7 @@ async function loadStats() {
async function loadCustomers() {
const search = document.getElementById('search-input').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 (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) {
updateSortHeaders();
const tbody = document.getElementById('customers-table-body');
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>`;
@@ -667,9 +694,13 @@ async function confirmDeleteCustomer() {
}
// ---------------------------------------------------------------------------
// Customer Actions (start/stop/restart)
// Customer Actions (start/stop/restart/deploy)
// ---------------------------------------------------------------------------
async function customerAction(id, action) {
async function customerAction(id, action, name) {
if (action === 'deploy') {
showRedeployModal(id, name);
return;
}
try {
await api('POST', `/customers/${id}/${action}`);
if (currentPage === 'dashboard') loadCustomers();
@@ -679,6 +710,29 @@ async function customerAction(id, action) {
}
}
function showRedeployModal(id, name) {
// Prefer passed name, fallback to dashboard table row, then 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-name').textContent = name;
new bootstrap.Modal(document.getElementById('redeploy-modal')).show();
}
async function confirmRedeploy(keepData) {
const id = document.getElementById('redeploy-customer-id').value;
bootstrap.Modal.getInstance(document.getElementById('redeploy-modal'))?.hide();
try {
await api('POST', `/customers/${id}/deploy?keep_data=${keepData}`);
if (currentPage === 'dashboard') loadCustomers();
if (currentCustomerId == id) viewCustomer(id);
} catch (err) {
alert(t('errors.actionFailed', { action: 'deploy', error: err.message }));
}
}
// ---------------------------------------------------------------------------
// Customer Detail
// ---------------------------------------------------------------------------
@@ -762,7 +816,7 @@ async function viewCustomer(id) {
<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-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})">
<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')}
@@ -1347,11 +1401,12 @@ async function loadVersionInfo() {
if (needsUpdate) {
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>
<i class="bi bi-arrow-repeat me-1"></i>${t('settings.triggerUpdate')}
</button>
<div class="text-muted small mt-1">${t('settings.updateWarning')}</div>
<div class="small mt-2 d-none" id="update-progress-text"></div>
</div>`;
}
el.innerHTML = html;
@@ -1363,14 +1418,76 @@ async function loadVersionInfo() {
async function triggerUpdate() {
if (!confirm(t('settings.confirmUpdate'))) return;
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 (btn) btn.disabled = true;
setProgress(t('settings.updateStepStarting'));
try {
const data = await api('POST', '/settings/update');
showSettingsAlert('success', data.message || t('messages.updateStarted'));
} catch (err) {
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'));
}
// ---------------------------------------------------------------------------
+2 -1
View File
@@ -27,7 +27,8 @@ function detectLanguage() {
async function loadLanguage(lang) {
if (translations[lang]) return;
try {
const resp = await fetch(`/static/lang/${lang}.json`);
const v = window.STATIC_VERSION ? `?v=${window.STATIC_VERSION}` : '';
const resp = await fetch(`/static/lang/${lang}.json${v}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
translations[lang] = await resp.json();
} catch (err) {
+19
View File
@@ -230,6 +230,14 @@
"triggerUpdate": "Update starten",
"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).",
"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",
"gitRepoUrl": "Repository URL",
"gitRepoUrlHint": "Wird für Versionsprüfungen und One-Click-Updates via Gitea API verwendet.",
@@ -354,6 +362,17 @@
"saveAndDeploy": "Speichern & Bereitstellen",
"saveChanges": "Änderungen speichern"
},
"redeployModal": {
"title": "Kunde neu bereitstellen",
"intro": "Wie soll",
"intro2": "neu bereitgestellt werden?",
"keepTitle": "Daten behalten",
"keepDesc": "Container werden gestoppt und neu gestartet. Die NetBird-Datenbank, Peer-Konfigurationen und Verschlüsselungsschlüssel bleiben erhalten. Verwenden Sie dies nach einer Konfigurationsänderung oder einem Image-Update.",
"keepNote": "Peers bleiben nach dem Neustart verbunden.",
"freshTitle": "Neu aufsetzen",
"freshDesc": "Alle bestehenden Daten werden gelöscht — Container, Volumes, Konfigurationsdateien und die NetBird-Datenbank. Eine komplett neue Instanz wird erstellt. Alle Peers müssen sich neu registrieren.",
"freshNote": "Alle Peer-Daten gehen verloren. Kann nicht rückgängig gemacht werden."
},
"deleteModal": {
"title": "Löschen bestätigen",
"confirmText": "Möchten Sie den Kunden wirklich löschen:",
+19
View File
@@ -107,6 +107,17 @@
"saveAndDeploy": "Save & Deploy",
"saveChanges": "Save Changes"
},
"redeployModal": {
"title": "Redeploy Customer",
"intro": "How should",
"intro2": "be redeployed?",
"keepTitle": "Keep Data",
"keepDesc": "Containers are stopped and restarted. The NetBird database, peer configurations, and encryption keys are preserved. Use this after a config change or image update.",
"keepNote": "Peers stay connected after restart.",
"freshTitle": "Fresh Deploy",
"freshDesc": "All existing data is deleted — containers, volumes, config files, and the NetBird database. A completely new instance is created. All peers must re-enroll.",
"freshNote": "All peer data is lost. Cannot be undone."
},
"deleteModal": {
"title": "Confirm Deletion",
"confirmText": "Are you sure you want to delete customer",
@@ -251,6 +262,14 @@
"triggerUpdate": "Start Update",
"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).",
"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",
"gitRepoUrl": "Repository URL",
"gitRepoUrlHint": "Used for version checks and one-click updates via Gitea API.",