Compare commits
@@ -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
@@ -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.1.2",
|
||||
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"})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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">
|
||||
|
||||
+95
-5
@@ -667,9 +667,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 +683,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 +789,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 +1374,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 +1391,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
@@ -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) {
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user