Compare commits

..
49 Commits
Author SHA1 Message Date
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
twothatITandClaude Sonnet 4.6 27c8e4889c feat(updates): visual update indicators, progress feedback, settings pull
- Dashboard: update badge (orange) injected lazily into customer Status cell
  after table renders via GET /monitoring/customers/local-update-status
  (local-only Docker inspect, no Hub call on every page load)
- Customer detail Deployment tab: "Update Images" button with spinner,
  shows success/error inline without page reload
- Monitoring Update All: now synchronous + sequential (one customer at a
  time), shows live spinner + per-customer results table on completion
- Settings > Docker Images: "Pull from Docker Hub" button with spinner
  and inline status message
- /monitoring/customers/local-update-status: new lightweight endpoint
  (no network, pure local Docker inspect)
- /monitoring/customers/update-all: removed BackgroundTasks, now awaits
  each customer sequentially and returns detailed per-customer results

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-02-24 21:25:33 +01:00
twothatITandClaude Sonnet 4.6 848ead0b2c feat(updates): NetBird container image update management
- New image_service.py: Docker Hub digest check (no pull), local digest/ID
  comparison, pull_all_images, per-customer container image status, and
  update_customer_containers (docker compose up -d, data-safe)
- Monitoring endpoints: GET /images/check (hub vs local + per-customer
  needs_update), POST /images/pull (background), POST /customers/update-all
- Deployment endpoint: POST /{id}/update-images (single-customer update)
- Monitoring page: "NetBird Container Updates" card with Check / Pull / Update
  All buttons; image status table and per-customer update table with inline
  update buttons
- i18n: added keys in en.json and de.json

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-02-24 21:01:56 +01:00
Sascha Lustenberger | techlan gmbhandClaude Sonnet 4.6 796824c400 feat(users): allow role assignment for Azure AD and LDAP users
- Backend: add admin-only guard + role validation to PUT /users/{id}
- Backend: prevent admins from changing their own role
- Frontend: role toggle button (person-check / person-dash) per user row
- Frontend: admin badge green, viewer badge secondary, ldap badge blue
- i18n: add makeAdmin / makeViewer translations (de + en)

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-02-24 20:27:54 +01:00
Sascha Lustenberger | techlan gmbhandClaude Sonnet 4.6 8103fffcb8 fix(docker): persist branding uploads across container rebuilds
Mount ./data/uploads into /app/static/uploads so uploaded logos
survive image rebuilds during the update process.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-02-24 20:19:22 +01:00
Sascha Lustenberger | techlan gmbhandClaude Sonnet 4.6 13408225b4 feat(ui): add dark mode toggle to navbar
Uses Bootstrap 5.3 native data-bs-theme with localStorage persistence.
Inline script in <head> prevents flash on page load.
Moon/sun icon in top-right navbar switches between light and dark.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-02-24 20:08:18 +01:00
Sascha Lustenberger | techlan gmbhandClaude Sonnet 4.6 0f77aaa176 fix(deploy): remove NPM stream creation on customer deploy/undeploy
STUN/TURN UDP relay no longer requires NPM stream entries.
NetBird uses rels:// WebSocket relay via NPM proxy host instead.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-02-24 19:42:12 +01:00
Sascha Lustenberger | techlan gmbhandClaude Sonnet 4.6 0bc7c0ba9f feat(ui): add SVG favicon for NetBird MSP Appliance
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-02-24 16:54:21 +01:00
Sascha Lustenberger | techlan gmbhandClaude Sonnet 4.6 27428b69a0 fix(netbird): query customer before use in stop/start/restart
In stop_customer, start_customer and restart_customer the local variable
'customer' was referenced on the instance_dir line before it was assigned
(it was only queried after the docker compose call). This caused an
UnboundLocalError (HTTP 500) on every stop/start/restart action.

Fix: move the customer query to the top of each function alongside the
deployment and config queries.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-02-24 11:12:17 +01:00
Sascha Lustenberger | techlan gmbhandClaude Sonnet 4.6 582f92eec4 fix(update): add git safe.directory and fetch --tags after pull
- Register SOURCE_DIR as git safe.directory before pulling so the
  process (root inside container) can access repos owned by a host user
- Run 'git fetch --tags' after pull so git describe always finds the
  latest tag for version.json — git pull does not reliably fetch all tags

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-02-24 10:58:02 +01:00
Sascha Lustenberger | techlan gmbhandClaude Sonnet 4.6 1d27226b6f fix(update): detect compose project name at runtime instead of hardcoding
The project name was hardcoded as 'netbirdmsp-appliance' but Docker Compose
derives the project name from the install directory name ('netbird-msp').
This caused Phase A to build an image under the wrong project name and
Phase B to start the replacement container under a mismatched project,
leaving the old container running indefinitely.

Fix: read the 'com.docker.compose.project' label from the running container
at update time. Both Phase A (build) and Phase B (docker compose up) now
use the detected project name. Falls back to SOURCE_DIR basename if the
inspect fails.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-02-24 10:51:25 +01:00
Sascha Lustenberger | techlan gmbhandClaude Sonnet 4.6 c70dc33f67 fix(caddy): route relay WebSocket traffic to relay container
Add /relay* location block to Caddyfile template so that NetBird relay
WebSocket connections (rels://) are correctly forwarded to the relay
container instead of falling through to the dashboard handler.

Without this fix, all relay WebSocket connections silently hit the
dashboard container, causing STUN/relay connectivity failures for all
deployed NetBird instances.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-02-24 10:31:08 +01:00
Sascha Lustenberger | techlan gmbh fb264bf7c6 Fix: Add grpc_pass to NPM advanced_config for Management and Signal endpoints 2026-02-23 14:49:43 +01:00
Sascha Lustenberger | techlan gmbh f3304b90c8 Fix: correctly detect update when current version is unknown 2026-02-23 13:11:04 +01:00
Sascha Lustenberger | techlan gmbh cda916f2af Fix: display dynamic version on login and use subdomain for customer directories instead of kunde{id} 2026-02-23 12:58:39 +01:00
twothatIT c3ab7a5a67 fix(api): correct extraction of commit date from gitea branches api 2026-02-22 22:57:07 +01:00
twothatIT b955e4f464 feat(ui): settings menu restructure, git branch dropdown, and repo cleanup 2026-02-22 21:29:30 +01:00
twothatIT 831564762b feat(ui): clean vertical settings menu and improved version formatting 2026-02-22 16:07:08 +01:00
twothatIT 3f177a6993 fix(updater): add --rm to helper container to remove it after use 2026-02-22 15:58:18 +01:00
twothatIT ea4afbd6ca alpha-1.8: final test with privileged 2026-02-22 15:49:44 +01:00
twothatIT 95ec6765c1 fix(updater): add --privileged to helper container to bypass user namespace restrictions 2026-02-22 15:46:09 +01:00
twothatIT c40b7d3bc6 alpha-1.7: final test 2026-02-22 15:39:18 +01:00
twothatIT 525b056b91 fix(updater): add :z flag to docker volumes for SELinux 2026-02-22 15:33:42 +01:00
twothatIT 6bc11d4c5e alpha-1.6: test final update 2026-02-22 15:25:50 +01:00
twothatIT e0aa51bac3 fix(updater): remove log redirection from helper to avoid nonexistent dir error 2026-02-22 15:22:43 +01:00
twothatIT 94d0b989d0 alpha-1.5: trigger update 2026-02-22 15:16:20 +01:00
twothatIT 2780b065d2 fix(updater): add force-recreate and logging to helper container 2026-02-22 15:14:23 +01:00
twothatIT ef691a4308 alpha-1.4: test helper-container update 2026-02-22 14:51:43 +01:00
twothatIT 0fe68cc6df fix: use helper container for self-update (survives container restart) 2026-02-22 14:50:00 +01:00
twothatIT 314393d61a alpha-1.3: update test 2026-02-22 14:42:53 +01:00
twothatIT a9fc549cec fix: correct docker compose project name and target only app service for update 2026-02-22 14:40:07 +01:00
twothatIT 41bbd6676b alpha-1.2: test update button 2026-02-22 14:34:46 +01:00
twothatIT 2713e67259 Deutsch korrektur 2026-02-09 15:55:01 +01:00
24 changed files with 2842 additions and 820 deletions
+15
View File
@@ -69,5 +69,20 @@ PROJECT_SUMMARY.md
QUICKSTART.md QUICKSTART.md
VS_CODE_SETUP.md VS_CODE_SETUP.md
# Gemini / Antigravity
.gemini/
# Windows artifacts # Windows artifacts
nul nul
# Debug / temp files (generated during development & testing)
out.txt
containers.txt
helper.txt
logs.txt
port.txt
env.txt
network.txt
update_helper.txt
state.txt
hostpath.txt
+6 -6
View File
@@ -91,7 +91,7 @@ netbird-msp-appliance/
1. Validate inputs (subdomain unique, email valid) 1. Validate inputs (subdomain unique, email valid)
2. Allocate ports (Management internal, Relay UDP public) 2. Allocate ports (Management internal, Relay UDP public)
3. Generate configs from Jinja2 templates 3. Generate configs from Jinja2 templates
4. Create instance directory: `/opt/netbird-instances/kunde{id}/` 4. Create instance directory: `/opt/netbird-instances/{subdomain}/`
5. Write `docker-compose.yml`, `management.json`, `relay.env` 5. Write `docker-compose.yml`, `management.json`, `relay.env`
6. Start Docker containers via Docker SDK 6. Start Docker containers via Docker SDK
7. Wait for health checks (max 60s) 7. Wait for health checks (max 60s)
@@ -113,7 +113,7 @@ No manual config file editing required!
### 4. Nginx Proxy Manager Integration ### 4. Nginx Proxy Manager Integration
**Per customer, create proxy host:** **Per customer, create proxy host:**
- Domain: `{subdomain}.{base_domain}` - Domain: `{subdomain}.{base_domain}`
- Forward to: `netbird-kunde{id}-dashboard:80` - Forward to: `netbird-{subdomain}-dashboard:80`
- SSL: Automatic Let's Encrypt - SSL: Automatic Let's Encrypt
- Advanced config: Route `/api/*` to management, `/signalexchange.*` to signal, `/relay` to relay - Advanced config: Route `/api/*` to management, `/signalexchange.*` to signal, `/relay` to relay
@@ -272,7 +272,7 @@ networks:
services: services:
netbird-management: netbird-management:
image: {{ netbird_management_image }} image: {{ netbird_management_image }}
container_name: netbird-kunde{{ customer_id }}-management container_name: netbird-{{ subdomain }}-management
restart: unless-stopped restart: unless-stopped
networks: networks:
- npm-network - npm-network
@@ -285,7 +285,7 @@ services:
netbird-signal: netbird-signal:
image: {{ netbird_signal_image }} image: {{ netbird_signal_image }}
container_name: netbird-kunde{{ customer_id }}-signal container_name: netbird-{{ subdomain }}-signal
restart: unless-stopped restart: unless-stopped
networks: networks:
- npm-network - npm-network
@@ -294,7 +294,7 @@ services:
netbird-relay: netbird-relay:
image: {{ netbird_relay_image }} image: {{ netbird_relay_image }}
container_name: netbird-kunde{{ customer_id }}-relay container_name: netbird-{{ subdomain }}-relay
restart: unless-stopped restart: unless-stopped
networks: networks:
- npm-network - npm-network
@@ -311,7 +311,7 @@ services:
netbird-dashboard: netbird-dashboard:
image: {{ netbird_dashboard_image }} image: {{ netbird_dashboard_image }}
container_name: netbird-kunde{{ customer_id }}-dashboard container_name: netbird-{{ subdomain }}-dashboard
restart: unless-stopped restart: unless-stopped
networks: networks:
- npm-network - npm-network
+148 -27
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 - **Docker-Based** — Everything runs in containers for easy deployment
### Dashboard ### 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 - **Real-Time Monitoring** — Container status, health checks, resource usage
- **Container Logs** — View logs per container directly in the browser - **Container Logs** — View logs per container directly in the browser
- **Start / Stop / Restart** — Control customer instances from the dashboard - **Start / Stop / Restart** — Control customer instances from the dashboard
- **Customer Status Tracking** — Automatic status sync (active / inactive / error) - **Customer Status Tracking** — Automatic status sync (active / inactive / error)
- **Update Indicators** — Per-customer badges when container images are outdated
### 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) ### Multi-Language (i18n)
- **English and German** — Full UI translation - **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 - **Login Page** — Branding is applied to the login page automatically
- **Configurable Docker Images** — Use custom or specific NetBird image versions - **Configurable Docker Images** — Use custom or specific NetBird image versions
### Security ### Authentication & User Management
- **JWT Authentication** — Token-based API authentication - **JWT Authentication** — Token-based API authentication
- **Multi-Factor Authentication (MFA)** — Optional TOTP-based MFA for all local users, activatable in Security settings - **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) - **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 - **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 ## Architecture
@@ -95,8 +109,8 @@ A management solution for running isolated NetBird instances for your MSP busine
| | Caddy | | | | Caddy | | | | Caddy | | | | Caddy | |
| +------------+ | | +------------+ | | +------------+ | | +------------+ |
+------------------+ +------------------+ +------------------+ +------------------+
kunde1.domain.de kundeN.domain.de customer-a.domain.de customer-x.domain.de
UDP 3478 UDP 3478+N-1 | |3478+N-1
``` ```
### Components per Customer Instance (5 containers): ### Components per Customer Instance (5 containers):
@@ -140,9 +154,9 @@ Example for 3 customers:
| Customer | Dashboard (TCP) | Relay (UDP) | | Customer | Dashboard (TCP) | Relay (UDP) |
|----------|----------------|-------------| |----------|----------------|-------------|
| Kunde 1 | 9001 | 3478 | | Customer-A | 9001 | 3478 |
| Kunde 2 | 9002 | 3479 | | Customer-C | 9002 | 3479 |
| Kunde 3 | 9003 | 3480 | | Customer-X | 9003 | 3480 |
**Your firewall must allow both the TCP dashboard ports and the UDP relay ports!** **Your firewall must allow both the TCP dashboard ports and the UDP relay ports!**
@@ -178,6 +192,18 @@ The following tools and services must be available **before** running the instal
### Install Prerequisites (Ubuntu/Debian) ### 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 ```bash
sudo apt update sudo apt update
sudo apt install -y curl git openssl sudo apt install -y curl git openssl
@@ -266,17 +292,32 @@ HOST_IP=<your-server-ip>
### Web UI Settings ### Web UI Settings
Available under **Settings** in the web interface: Available under **Settings** in the web interface, organized into tabs:
#### User Management
| Tab | Settings | | Tab | Settings |
|-----|----------| |-----|----------|
| **System** | Base domain, admin email, Docker images, port ranges, data directory | | **Azure AD** | Azure AD / Entra ID SSO configuration (tenant ID, client ID/secret, optional group restriction) |
| **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 |
| **Users** | Create/edit/delete admin users, per-user language preference, MFA reset | | **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 | | **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. Changes are applied immediately without restart.
@@ -308,11 +349,42 @@ Changes are applied immediately without restart.
### Monitoring ### Monitoring
The dashboard shows: The **Monitoring** page shows:
- **System Overview** — Total customers, active/inactive, errors - **System Overview** — Total customers, active/inactive, errors
- **Resource Usage** — RAM, CPU per container - **Host Resources** — CPU, RAM, disk usage of the host machine
- **Container Health** — Running/stopped per container with color-coded status - **Customer Status** — Container health per customer (running/stopped)
- **Deployment Logs** — Action history per customer - **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 ### Language Settings
@@ -320,9 +392,13 @@ The dashboard shows:
- **Per-user default** — Set in Settings > Users during user creation - **Per-user default** — Set in Settings > Users during user creation
- **System default** — Set in Settings > Branding - **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) ### 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 #### Enable MFA
1. Go to **Settings > Security** 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 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 - **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 ### 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) #### 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. 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:** **Setup:**
1. Upload a wildcard certificate in Nginx Proxy Manager (e.g. via DNS challenge) 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" 3. Set **SSL Mode** to "Wildcard Certificate"
4. Click the refresh button to load certificates from NPM 4. Click the refresh button to load certificates from NPM
5. Select your wildcard certificate from the dropdown 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 POST /api/customers/{id}/restart # Restart containers
GET /api/customers/{id}/logs # Get container logs GET /api/customers/{id}/logs # Get container logs
GET /api/customers/{id}/health # Health check 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/branding # Get branding (public, no auth)
GET /api/settings/npm-certificates # List NPM SSL certificates GET /api/settings/npm-certificates # List NPM SSL certificates
PUT /api/settings # Update system settings PUT /api/settings # Update system settings
GET /api/users # List users GET /api/users # List users
POST /api/users # Create user POST /api/users # Create user
POST /api/users/{id}/reset-mfa # Reset user's MFA 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 POST /api/auth/mfa/verify # Verify TOTP code on login
GET /api/auth/mfa/status # Get MFA status GET /api/auth/mfa/status # Get MFA status
POST /api/auth/mfa/disable # Disable own TOTP 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 ### Example: Create Customer via API
@@ -488,11 +592,28 @@ The database migrations run automatically on startup.
### Updating NetBird Images ### Updating NetBird Images
Via the Web UI: NetBird image updates are managed entirely through the Web UI — no manual config changes required.
1. Settings > System Configuration
2. Change image tags (e.g., `netbirdio/management:0.35.0`) #### Step 1 — Pull new images
3. Click "Save"
4. Re-deploy individual customers to apply the 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 ## 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 ## Acknowledgments
+26 -6
View File
@@ -33,7 +33,7 @@ logger = logging.getLogger(__name__)
app = FastAPI( app = FastAPI(
title="NetBird MSP Appliance", title="NetBird MSP Appliance",
description="Multi-tenant NetBird management platform for MSPs", description="Multi-tenant NetBird management platform for MSPs",
version="1.0.0", version="1.1.2",
docs_url="/api/docs", docs_url="/api/docs",
redoc_url="/api/redoc", redoc_url="/api/redoc",
openapi_url="/api/openapi.json", openapi_url="/api/openapi.json",
@@ -90,16 +90,36 @@ STATIC_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "static")
if os.path.isdir(STATIC_DIR): if os.path.isdir(STATIC_DIR):
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
# Serve index.html at root # Serve index.html at root — inject cache-busting version into static asset URLs
from fastapi.responses import FileResponse # 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) @app.get("/", include_in_schema=False)
async def serve_index(): 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") index_path = os.path.join(STATIC_DIR, "index.html")
if os.path.isfile(index_path): if not os.path.isfile(index_path):
return FileResponse(index_path)
return JSONResponse({"message": "NetBird MSP Appliance API is running."}) 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"})
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+60 -7
View File
@@ -2,13 +2,13 @@
import logging 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 sqlalchemy.orm import Session
from app.database import SessionLocal, get_db from app.database import SessionLocal, get_db
from app.dependencies import get_current_user from app.dependencies import get_current_user
from app.models import Customer, Deployment, User from app.models import Customer, Deployment, SystemConfig, User
from app.services import docker_service, netbird_service from app.services import docker_service, image_service, netbird_service
from app.utils.security import decrypt_value from app.utils.security import decrypt_value
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -19,6 +19,14 @@ router = APIRouter()
async def manual_deploy( async def manual_deploy(
customer_id: int, customer_id: int,
background_tasks: BackgroundTasks, 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), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
@@ -29,6 +37,7 @@ async def manual_deploy(
Args: Args:
customer_id: Customer ID. customer_id: Customer ID.
keep_data: Whether to preserve existing NetBird data.
Returns: Returns:
Acknowledgement dict. Acknowledgement dict.
@@ -40,12 +49,12 @@ async def manual_deploy(
customer.status = "deploying" customer.status = "deploying"
db.commit() db.commit()
async def _deploy_bg(cid: int) -> None: async def _deploy_bg(cid: int, keep: bool) -> None:
bg_db = SessionLocal() bg_db = SessionLocal()
try: try:
# Remove existing deployment if present
existing = bg_db.query(Deployment).filter(Deployment.customer_id == cid).first() 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.undeploy_customer(bg_db, cid)
await netbird_service.deploy_customer(bg_db, cid) await netbird_service.deploy_customer(bg_db, cid)
except Exception: except Exception:
@@ -53,7 +62,7 @@ async def manual_deploy(
finally: finally:
bg_db.close() 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"} return {"message": "Deployment started in background.", "status": "deploying"}
@@ -207,6 +216,50 @@ async def get_customer_credentials(
} }
@router.post("/{customer_id}/update-images")
async def update_customer_images(
customer_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Recreate a customer's containers to pick up newly pulled images.
Images must already be pulled via POST /monitoring/images/pull.
Bind-mounted data is preserved — no data loss.
"""
if current_user.role != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin only.")
customer = _require_customer(db, customer_id)
deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first()
if not deployment:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No deployment found for this customer.",
)
config = db.query(SystemConfig).filter(SystemConfig.id == 1).first()
if not config:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="System not configured."
)
instance_dir = f"{config.data_dir}/{customer.subdomain}"
result = await image_service.update_customer_containers(instance_dir, deployment.container_prefix)
if not result["success"]:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=result.get("error", "Failed to update containers."),
)
logger.info(
"Containers updated for customer '%s' (prefix: %s) by '%s'.",
customer.name, deployment.container_prefix, current_user.username,
)
return {"message": f"Containers updated for '{customer.name}'."}
def _require_customer(db: Session, customer_id: int) -> Customer: def _require_customer(db: Session, customer_id: int) -> Customer:
"""Helper to fetch a customer or raise 404. """Helper to fetch a customer or raise 404.
+189 -9
View File
@@ -1,21 +1,30 @@
"""Monitoring API — system overview, customer statuses, host resources.""" """Monitoring API — system overview, customer statuses, host resources."""
import asyncio
import logging import logging
import platform import platform
import time
from typing import Any from typing import Any
import psutil import psutil
from fastapi import APIRouter, Depends from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db from app.database import SessionLocal, get_db
from app.dependencies import get_current_user from app.dependencies import get_current_user
from app.models import Customer, Deployment, User from app.models import Customer, Deployment, SystemConfig, User
from app.services import docker_service from app.services import docker_service, image_service
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
# Short-lived cache for the local update-status badges. This endpoint is
# triggered on every customer-table render (i.e. every search keystroke), but
# the underlying data (which images are outdated) only changes after an image
# pull + container recreate, so a few seconds of staleness is harmless.
_update_status_cache: dict[str, Any] = {"data": None, "expires": 0.0}
_UPDATE_STATUS_TTL_SECONDS = 20
@router.get("/status") @router.get("/status")
async def system_status( async def system_status(
@@ -58,8 +67,7 @@ async def all_customers_status(
.all() .all()
) )
results: list[dict[str, Any]] = [] async def _build_entry(c: Customer) -> dict[str, Any]:
for c in customers:
entry: dict[str, Any] = { entry: dict[str, Any] = {
"id": c.id, "id": c.id,
"name": c.name, "name": c.name,
@@ -67,7 +75,7 @@ async def all_customers_status(
"status": c.status, "status": c.status,
} }
if c.deployment: if c.deployment:
containers = docker_service.get_container_status(c.deployment.container_prefix) containers = await docker_service.get_container_status_async(c.deployment.container_prefix)
entry["deployment_status"] = c.deployment.deployment_status entry["deployment_status"] = c.deployment.deployment_status
entry["containers"] = containers entry["containers"] = containers
entry["relay_udp_port"] = c.deployment.relay_udp_port entry["relay_udp_port"] = c.deployment.relay_udp_port
@@ -76,9 +84,11 @@ async def all_customers_status(
else: else:
entry["deployment_status"] = None entry["deployment_status"] = None
entry["containers"] = [] entry["containers"] = []
results.append(entry) return entry
return results # Fetch container status for all customers concurrently instead of one
# blocking Docker SDK call at a time.
return await asyncio.gather(*[_build_entry(c) for c in customers])
@router.get("/resources") @router.get("/resources")
@@ -115,3 +125,173 @@ async def host_resources(
"percent": disk.percent, "percent": disk.percent,
}, },
} }
@router.get("/images/check")
async def check_image_updates(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict[str, Any]:
"""Check all configured NetBird images for available updates on Docker Hub.
Compares local image digests against Docker Hub — no image is pulled.
Returns:
images: dict mapping image name to update status
any_update_available: bool
customer_status: list of per-customer container image status
"""
config = db.query(SystemConfig).filter(SystemConfig.id == 1).first()
if not config:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="System not configured.")
hub_status = await image_service.check_all_images(config)
# Per-customer local check (no network)
deployments = db.query(Deployment).all()
customer_status = []
for dep in deployments:
customer = dep.customer
cs = image_service.get_customer_container_image_status(dep.container_prefix, config)
customer_status.append({
"customer_id": customer.id,
"customer_name": customer.name,
"subdomain": customer.subdomain,
"container_prefix": dep.container_prefix,
"needs_update": cs["needs_update"],
"services": cs["services"],
})
return {**hub_status, "customer_status": customer_status}
@router.post("/images/pull")
async def pull_all_netbird_images(
background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict[str, Any]:
"""Pull all configured NetBird images from Docker Hub.
Runs in the background — returns immediately. After pulling, re-check
customer status via GET /images/check to see which customers need updating.
"""
if current_user.role != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin only.")
config = db.query(SystemConfig).filter(SystemConfig.id == 1).first()
if not config:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="System not configured.")
# Snapshot image list before background task starts
images = [
config.netbird_management_image,
config.netbird_signal_image,
config.netbird_relay_image,
config.netbird_dashboard_image,
]
async def _pull_bg() -> None:
bg_db = SessionLocal()
try:
cfg = bg_db.query(SystemConfig).filter(SystemConfig.id == 1).first()
if cfg:
await image_service.pull_all_images(cfg)
except Exception:
logger.exception("Background image pull failed")
finally:
bg_db.close()
background_tasks.add_task(_pull_bg)
return {"message": "Image pull started in background.", "images": images}
@router.get("/customers/local-update-status")
async def customers_local_update_status(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> list[dict[str, Any]]:
"""Fast local-only check for outdated customer containers.
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()
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
@router.post("/customers/update-all")
async def update_all_customers(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict[str, Any]:
"""Recreate containers for all customers with outdated images — sequential, synchronous.
Updates customers one at a time so a failing customer does not block others.
Images must already be pulled. Data is preserved (bind mounts).
Returns detailed per-customer results.
"""
if current_user.role != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin only.")
config = db.query(SystemConfig).filter(SystemConfig.id == 1).first()
if not config:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="System not configured.")
deployments = db.query(Deployment).all()
to_update = []
for dep in deployments:
cs = image_service.get_customer_container_image_status(dep.container_prefix, config)
if cs["needs_update"]:
customer = dep.customer
to_update.append({
"instance_dir": f"{config.data_dir}/{customer.subdomain}",
"project_name": dep.container_prefix,
"customer_name": customer.name,
"customer_id": customer.id,
})
if not to_update:
return {"message": "All customers are already up to date.", "updated": 0, "results": []}
# Update customers sequentially — one at a time
update_results = []
for entry in to_update:
res = await image_service.update_customer_containers(
entry["instance_dir"], entry["project_name"]
)
ok = res["success"]
logger.info("Updated %s: %s", entry["project_name"], "OK" if ok else res.get("error"))
update_results.append({
"customer_name": entry["customer_name"],
"customer_id": entry["customer_id"],
"success": ok,
"error": res.get("error"),
})
success_count = sum(1 for r in update_results if r["success"])
return {
"message": f"Updated {success_count} of {len(update_results)} customer(s).",
"updated": success_count,
"results": update_results,
}
+58 -10
View File
@@ -4,6 +4,7 @@ There is no .env file. Every setting lives in the ``system_config`` table
(singleton row with id=1) and is editable via the Web UI settings page. (singleton row with id=1) and is editable via the Web UI settings page.
""" """
import asyncio
import logging import logging
import os import os
import shutil import shutil
@@ -237,6 +238,10 @@ async def test_ldap(
@router.get("/branding") @router.get("/branding")
async def get_branding(db: Session = Depends(get_db)): async def get_branding(db: Session = Depends(get_db)):
"""Public endpoint — returns branding info for the login page (no auth required).""" """Public endpoint — returns branding info for the login page (no auth required)."""
current_version = update_service.get_current_version().get("tag", "alpha-1.1")
if current_version == "unknown":
current_version = "alpha-1.1"
row = db.query(SystemConfig).filter(SystemConfig.id == 1).first() row = db.query(SystemConfig).filter(SystemConfig.id == 1).first()
if not row: if not row:
return { return {
@@ -244,12 +249,14 @@ async def get_branding(db: Session = Depends(get_db)):
"branding_subtitle": "Multi-Tenant Management Platform", "branding_subtitle": "Multi-Tenant Management Platform",
"branding_logo_path": None, "branding_logo_path": None,
"default_language": "en", "default_language": "en",
"version": current_version
} }
return { return {
"branding_name": row.branding_name or "NetBird MSP Appliance", "branding_name": row.branding_name or "NetBird MSP Appliance",
"branding_subtitle": row.branding_subtitle or "Multi-Tenant Management Platform", "branding_subtitle": row.branding_subtitle or "Multi-Tenant Management Platform",
"branding_logo_path": row.branding_logo_path, "branding_logo_path": row.branding_logo_path,
"default_language": row.default_language or "en", "default_language": row.default_language or "en",
"version": current_version
} }
@@ -334,18 +341,33 @@ async def get_version(
return result return result
@router.get("/branches")
async def get_branches(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Return a list of available branches from the configured git remote."""
config = get_system_config(db)
if not config or not config.git_repo_url:
return []
branches = await update_service.get_remote_branches(config)
return branches
@router.post("/update") @router.post("/update")
async def trigger_update( async def trigger_update(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Backup the database, git pull the latest code, and rebuild the container. """Kick off backup + git pull + container rebuild in the background.
Returns immediately — the actual work (which can take several minutes,
especially the ``--no-cache`` image build) runs in a background thread so
it doesn't block this request or any other user's requests while it
runs. Progress can be polled via GET /settings/update/status until the
container restarts with the new version.
The rebuild is fire-and-forget — the app will restart in ~60 seconds.
Only admin users may trigger an update. Only admin users may trigger an update.
Returns:
Dict with ok, message, and backup path.
""" """
if getattr(current_user, "role", "admin") != "admin": if getattr(current_user, "role", "admin") != "admin":
raise HTTPException( raise HTTPException(
@@ -364,11 +386,37 @@ async def trigger_update(
detail="git_repo_url is not configured in settings.", detail="git_repo_url is not configured in settings.",
) )
result = update_service.trigger_update(config, DATABASE_PATH) current_status = update_service.get_update_status()
if not result.get("ok"): if current_status.get("state") == "running":
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_409_CONFLICT,
detail=result.get("message", "Update failed."), detail="An update is already in progress.",
) )
# Snapshot the only fields trigger_update() needs — avoids passing a
# SQLAlchemy instance into a background thread after this request's
# session may already be closed.
class _ConfigSnapshot:
git_repo_url = config.git_repo_url
git_branch = config.git_branch
git_token = config.git_token
asyncio.create_task(asyncio.to_thread(update_service.trigger_update, _ConfigSnapshot(), DATABASE_PATH))
logger.info("Update triggered by %s.", current_user.username) logger.info("Update triggered by %s.", current_user.username)
return result return {
"ok": True,
"message": "Update gestartet. Dies kann mehrere Minuten dauern — Fortschritt via Status sichtbar.",
}
@router.get("/update/status")
async def update_status(
current_user: User = Depends(get_current_user),
):
"""Return progress of the currently running (or last) update.
Note: once the container restarts mid-update, this endpoint stops
responding for a few seconds — that itself is a signal the swap is
happening. The frontend falls back to polling for the app coming back up.
"""
return update_service.get_update_status()
+20 -1
View File
@@ -70,12 +70,31 @@ async def update_user(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Update an existing user (email, is_active, role).""" """Update an existing user (email, is_active, role). Admin only."""
if current_user.role != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only admins can update users.",
)
user = db.query(User).filter(User.id == user_id).first() user = db.query(User).filter(User.id == user_id).first()
if not user: if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found.") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found.")
update_data = payload.model_dump(exclude_none=True) update_data = payload.model_dump(exclude_none=True)
if "role" in update_data:
if update_data["role"] not in ("admin", "viewer"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Role must be 'admin' or 'viewer'.",
)
if user_id == current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="You cannot change your own role.",
)
for field, value in update_data.items(): for field, value in update_data.items():
if hasattr(user, field): if hasattr(user, field):
setattr(user, field, value) setattr(user, field, value)
+23 -2
View File
@@ -27,13 +27,24 @@ async def _run_cmd(cmd: list[str], timeout: int = 120) -> subprocess.CompletedPr
) )
_client: Optional[docker.DockerClient] = None
def _get_client() -> docker.DockerClient: def _get_client() -> docker.DockerClient:
"""Return a Docker client connected via the Unix socket. """Return a shared Docker client connected via the Unix socket.
The client is created once and reused — creating a new client per call
(as `docker.from_env()` does) re-negotiates the API version and opens a
fresh connection every time, which is wasteful when called once per
customer in a loop.
Returns: Returns:
docker.DockerClient instance. docker.DockerClient instance.
""" """
return docker.from_env() global _client
if _client is None:
_client = docker.from_env()
return _client
async def compose_up( async def compose_up(
@@ -212,6 +223,16 @@ def get_container_status(container_prefix: str) -> list[dict[str, Any]]:
return results return results
async def get_container_status_async(container_prefix: str) -> list[dict[str, Any]]:
"""Thread-offloaded wrapper around get_container_status().
Use this when checking status for multiple customers so the Docker SDK
calls run in the thread pool instead of blocking the event loop.
"""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, get_container_status, container_prefix)
def get_container_logs(container_name: str, tail: int = 200) -> str: def get_container_logs(container_name: str, tail: int = 200) -> str:
"""Retrieve recent logs from a container. """Retrieve recent logs from a container.
+305
View File
@@ -0,0 +1,305 @@
"""NetBird Docker image update service.
Compares locally pulled images against Docker Hub to detect available updates.
Provides pull and per-customer container recreation functions without data loss.
"""
import asyncio
import json
import logging
import os
import subprocess
from typing import Any
import httpx
logger = logging.getLogger(__name__)
# Services that make up a customer's NetBird deployment
NETBIRD_SERVICES = ["management", "signal", "relay", "dashboard"]
async def _run_cmd(cmd: list[str], timeout: int = 300) -> subprocess.CompletedProcess:
"""Run a subprocess command without blocking the event loop."""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
None,
lambda: subprocess.run(cmd, capture_output=True, text=True, timeout=timeout),
)
def _parse_image_name(image: str) -> tuple[str, str]:
"""Split 'repo/name:tag' into ('repo/name', 'tag'). Defaults tag to 'latest'."""
if ":" in image:
name, tag = image.rsplit(":", 1)
else:
name, tag = image, "latest"
return name, tag
async def get_hub_digest(image: str) -> str | None:
"""Fetch the manifest-list digest from the Docker Registry v2 API.
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)
try:
async with httpx.AsyncClient(timeout=15) as client:
# 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
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 registry digest for %s: %s", image, exc)
return None
def get_local_digest(image: str) -> str | None:
"""Get the RepoDigest for a locally pulled image.
Returns the digest (sha256:...) or None if image not found locally.
"""
try:
result = subprocess.run(
["docker", "image", "inspect", image, "--format", "{{json .RepoDigests}}"],
capture_output=True, text=True, timeout=10,
)
if result.returncode != 0:
return None
digests = json.loads(result.stdout.strip())
if not digests:
return None
# RepoDigests look like "netbirdio/management@sha256:abc..."
for d in digests:
if "@" in d:
return d.split("@", 1)[1]
return None
except Exception as exc:
logger.warning("Failed to inspect local image %s: %s", image, exc)
return None
def get_container_image_id(container_name: str) -> str | None:
"""Get the full image ID (sha256:...) of a running or stopped container."""
try:
result = subprocess.run(
["docker", "inspect", container_name, "--format", "{{.Image}}"],
capture_output=True, text=True, timeout=10,
)
if result.returncode != 0:
return None
return result.stdout.strip() or None
except Exception:
return None
def get_local_image_id(image: str) -> str | None:
"""Get the full image ID (sha256:...) of a locally stored image."""
try:
result = subprocess.run(
["docker", "image", "inspect", image, "--format", "{{.Id}}"],
capture_output=True, text=True, timeout=10,
)
if result.returncode != 0:
return None
return result.stdout.strip() or None
except Exception:
return None
async def check_image_status(image: str) -> dict[str, Any]:
"""Check whether a configured image has an update available on Docker Hub.
Returns a dict with:
image: the image name:tag
local_digest: digest of locally cached image (or None)
hub_digest: latest digest from Docker Hub (or None)
update_available: True if hub_digest differs from local_digest
"""
hub_digest, local_digest = await asyncio.gather(
get_hub_digest(image),
asyncio.get_event_loop().run_in_executor(None, get_local_digest, image),
)
if hub_digest and local_digest:
update_available = hub_digest != local_digest
elif hub_digest and not local_digest:
# Image not pulled locally yet — needs pull
update_available = True
else:
update_available = False
return {
"image": image,
"local_digest": local_digest,
"hub_digest": hub_digest,
"update_available": update_available,
}
async def check_all_images(config) -> dict[str, Any]:
"""Check all 4 configured NetBird images for available updates.
Returns a dict with:
images: dict mapping image name -> status dict
any_update_available: bool
"""
images = [
config.netbird_management_image,
config.netbird_signal_image,
config.netbird_relay_image,
config.netbird_dashboard_image,
]
results = await asyncio.gather(*[check_image_status(img) for img in images])
by_image = {r["image"]: r for r in results}
any_update = any(r["update_available"] for r in results)
return {"images": by_image, "any_update_available": any_update}
async def pull_image(image: str) -> dict[str, Any]:
"""Pull a Docker image. Returns success/error dict."""
logger.info("Pulling image: %s", image)
result = await _run_cmd(["docker", "pull", image], timeout=600)
if result.returncode != 0:
logger.error("Failed to pull %s: %s", image, result.stderr)
return {"image": image, "success": False, "error": result.stderr[:500]}
return {"image": image, "success": True}
async def pull_all_images(config) -> dict[str, Any]:
"""Pull all 4 configured NetBird images. Returns results per image."""
images = [
config.netbird_management_image,
config.netbird_signal_image,
config.netbird_relay_image,
config.netbird_dashboard_image,
]
results = await asyncio.gather(*[pull_image(img) for img in images])
return {
"results": {r["image"]: r for r in results},
"all_success": all(r["success"] for r in results),
}
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.
Compares each running container's image ID against the locally stored image ID
for the configured image tag. This is a local check — no network call.
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,
}
services: dict[str, Any] = {}
for svc, image in service_images.items():
container_name = f"{container_prefix}-{svc}"
container_id = get_container_image_id(container_name)
local_id = 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
services[svc] = {
"container": container_name,
"image": image,
"up_to_date": up_to_date,
}
needs_update = any(s["up_to_date"] is False for s in services.values())
return {"services": services, "needs_update": needs_update}
async def update_customer_containers(instance_dir: str, project_name: str) -> dict[str, Any]:
"""Recreate customer containers to pick up newly pulled images.
Runs `docker compose up -d` in the customer's instance directory.
Images must already be pulled. Bind-mounted data is preserved — no data loss.
"""
compose_file = os.path.join(instance_dir, "docker-compose.yml")
if not os.path.isfile(compose_file):
return {"success": False, "error": f"docker-compose.yml not found at {compose_file}"}
cmd = [
"docker", "compose",
"-f", compose_file,
"-p", project_name,
"up", "-d", "--remove-orphans",
]
logger.info("Updating containers for %s", project_name)
result = await _run_cmd(cmd, timeout=300)
if result.returncode != 0:
return {"success": False, "error": result.stderr[:1000]}
return {"success": True}
+31 -59
View File
@@ -118,7 +118,7 @@ async def deploy_customer(db: Session, customer_id: int) -> dict[str, Any]:
allocated_port = None allocated_port = None
instance_dir = None instance_dir = None
container_prefix = f"netbird-kunde{customer_id}" container_prefix = f"netbird-{customer.subdomain}"
local_mode = _is_local_domain(config.base_domain) local_mode = _is_local_domain(config.base_domain)
existing_deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first() existing_deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first()
@@ -135,7 +135,7 @@ async def deploy_customer(db: Session, customer_id: int) -> dict[str, Any]:
# Step 2: Generate secrets (reuse existing key if instance data exists) # Step 2: Generate secrets (reuse existing key if instance data exists)
relay_secret = generate_relay_secret() relay_secret = generate_relay_secret()
datastore_key = _get_existing_datastore_key( datastore_key = _get_existing_datastore_key(
os.path.join(config.data_dir, f"kunde{customer_id}", "management.json") os.path.join(config.data_dir, customer.subdomain, "management.json")
) )
if datastore_key: if datastore_key:
_log_action(db, customer_id, "deploy", "info", _log_action(db, customer_id, "deploy", "info",
@@ -159,7 +159,7 @@ async def deploy_customer(db: Session, customer_id: int) -> dict[str, Any]:
relay_ws_protocol = "rels" relay_ws_protocol = "rels"
# Step 4: Create instance directory # Step 4: Create instance directory
instance_dir = os.path.join(config.data_dir, f"kunde{customer_id}") instance_dir = os.path.join(config.data_dir, customer.subdomain)
os.makedirs(instance_dir, exist_ok=True) os.makedirs(instance_dir, exist_ok=True)
os.makedirs(os.path.join(instance_dir, "data", "management"), exist_ok=True) os.makedirs(os.path.join(instance_dir, "data", "management"), exist_ok=True)
os.makedirs(os.path.join(instance_dir, "data", "signal"), exist_ok=True) os.makedirs(os.path.join(instance_dir, "data", "signal"), exist_ok=True)
@@ -225,7 +225,7 @@ async def deploy_customer(db: Session, customer_id: int) -> dict[str, Any]:
# Step 8: Auto-create admin user via NetBird setup API # Step 8: Auto-create admin user via NetBird setup API
admin_email = customer.email admin_email = customer.email
admin_password = secrets.token_urlsafe(16) admin_password = secrets.token_urlsafe(16)
management_container = f"netbird-kunde{customer_id}-management" management_container = f"netbird-{customer.subdomain}-management"
setup_api_url = f"http://{management_container}:80/api/setup" setup_api_url = f"http://{management_container}:80/api/setup"
setup_payload = json.dumps({ setup_payload = json.dumps({
"name": customer.name, "name": customer.name,
@@ -264,10 +264,12 @@ async def deploy_customer(db: Session, customer_id: int) -> dict[str, Any]:
_log_action(db, customer_id, "deploy", "info", _log_action(db, customer_id, "deploy", "info",
"Auto-setup failed — admin must complete setup manually.") "Auto-setup failed — admin must complete setup manually.")
# Step 9: Create NPM proxy host + stream (production only) # Step 9: Create NPM proxy host (production only).
npm_proxy_id = None # If an existing deployment already has an NPM proxy, reuse it — this happens
npm_stream_id = None # when keep_data=True was passed and undeploy_customer was NOT called beforehand.
if not local_mode: 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() forward_host = npm_service._get_forward_host()
npm_result = await npm_service.create_proxy_host( npm_result = await npm_service.create_proxy_host(
api_url=config.npm_api_url, api_url=config.npm_api_url,
@@ -294,27 +296,6 @@ async def deploy_customer(db: Session, customer_id: int) -> dict[str, Any]:
f"(SSL: {'OK' if ssl_ok else 'FAILED — check DNS and port 80 accessibility'})", f"(SSL: {'OK' if ssl_ok else 'FAILED — check DNS and port 80 accessibility'})",
) )
# Create NPM UDP stream for relay STUN port
stream_result = await npm_service.create_stream(
api_url=config.npm_api_url,
npm_email=config.npm_api_email,
npm_password=config.npm_api_password,
incoming_port=allocated_port,
forwarding_host=forward_host,
forwarding_port=allocated_port,
)
npm_stream_id = stream_result.get("stream_id")
if stream_result.get("error"):
_log_action(
db, customer_id, "deploy", "error",
f"NPM stream creation failed: {stream_result['error']}",
)
else:
_log_action(
db, customer_id, "deploy", "info",
f"NPM UDP stream created: port {allocated_port} -> {forward_host}:{allocated_port}",
)
# Note: Keep HTTPS configs even if SSL cert creation failed. # Note: Keep HTTPS configs even if SSL cert creation failed.
# SSL can be set up manually in NPM later. Switching to HTTP # SSL can be set up manually in NPM later. Switching to HTTP
# would break the dashboard when the user accesses via HTTPS. # would break the dashboard when the user accesses via HTTPS.
@@ -325,9 +306,14 @@ async def deploy_customer(db: Session, customer_id: int) -> dict[str, Any]:
"SSL certificate not created automatically. " "SSL certificate not created automatically. "
"Please create it manually in NPM or ensure DNS resolves and port 80 is reachable, then re-deploy.", "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) # 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: # 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: try:
dns_result = await dns_service.create_dns_record(customer.subdomain, config) dns_result = await dns_service.create_dns_record(customer.subdomain, config)
if dns_result["ok"]: if dns_result["ok"]:
@@ -387,7 +373,7 @@ async def deploy_customer(db: Session, customer_id: int) -> dict[str, Any]:
# Rollback: stop containers if they were started # Rollback: stop containers if they were started
try: try:
await docker_service.compose_down( await docker_service.compose_down(
instance_dir or os.path.join(config.data_dir, f"kunde{customer_id}"), instance_dir or os.path.join(config.data_dir, customer.subdomain),
container_prefix, container_prefix,
remove_volumes=True, remove_volumes=True,
) )
@@ -423,7 +409,7 @@ async def undeploy_customer(db: Session, customer_id: int) -> dict[str, Any]:
config = get_system_config(db) config = get_system_config(db)
if deployment and config: if deployment and config:
instance_dir = os.path.join(config.data_dir, f"kunde{customer_id}") instance_dir = os.path.join(config.data_dir, customer.subdomain)
# Stop and remove containers # Stop and remove containers
try: try:
@@ -443,17 +429,6 @@ async def undeploy_customer(db: Session, customer_id: int) -> dict[str, Any]:
except Exception as exc: except Exception as exc:
_log_action(db, customer_id, "undeploy", "error", f"NPM removal error: {exc}") _log_action(db, customer_id, "undeploy", "error", f"NPM removal error: {exc}")
# Remove NPM stream
if deployment.npm_stream_id and config.npm_api_email:
try:
await npm_service.delete_stream(
config.npm_api_url, config.npm_api_email, config.npm_api_password,
deployment.npm_stream_id,
)
_log_action(db, customer_id, "undeploy", "info", "NPM stream removed.")
except Exception as exc:
_log_action(db, customer_id, "undeploy", "error", f"NPM stream removal error: {exc}")
# Remove Windows DNS A-record (non-fatal) # Remove Windows DNS A-record (non-fatal)
if config and config.dns_enabled and config.dns_server and config.dns_zone: if config and config.dns_enabled and config.dns_server and config.dns_zone:
try: try:
@@ -484,16 +459,15 @@ async def undeploy_customer(db: Session, customer_id: int) -> dict[str, Any]:
async def stop_customer(db: Session, customer_id: int) -> dict[str, Any]: async def stop_customer(db: Session, customer_id: int) -> dict[str, Any]:
"""Stop containers for a customer.""" """Stop containers for a customer."""
deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first() deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first()
customer = db.query(Customer).filter(Customer.id == customer_id).first()
config = get_system_config(db) config = get_system_config(db)
if not deployment or not config: if not deployment or not config or not customer:
return {"success": False, "error": "Deployment or config not found."} return {"success": False, "error": "Deployment, customer or config not found."}
instance_dir = os.path.join(config.data_dir, f"kunde{customer_id}") instance_dir = os.path.join(config.data_dir, customer.subdomain)
ok = await docker_service.compose_stop(instance_dir, deployment.container_prefix) ok = await docker_service.compose_stop(instance_dir, deployment.container_prefix)
if ok: if ok:
deployment.deployment_status = "stopped" deployment.deployment_status = "stopped"
customer = db.query(Customer).filter(Customer.id == customer_id).first()
if customer:
customer.status = "inactive" customer.status = "inactive"
db.commit() db.commit()
_log_action(db, customer_id, "stop", "success", "Containers stopped.") _log_action(db, customer_id, "stop", "success", "Containers stopped.")
@@ -505,16 +479,15 @@ async def stop_customer(db: Session, customer_id: int) -> dict[str, Any]:
async def start_customer(db: Session, customer_id: int) -> dict[str, Any]: async def start_customer(db: Session, customer_id: int) -> dict[str, Any]:
"""Start containers for a customer.""" """Start containers for a customer."""
deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first() deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first()
customer = db.query(Customer).filter(Customer.id == customer_id).first()
config = get_system_config(db) config = get_system_config(db)
if not deployment or not config: if not deployment or not config or not customer:
return {"success": False, "error": "Deployment or config not found."} return {"success": False, "error": "Deployment, customer or config not found."}
instance_dir = os.path.join(config.data_dir, f"kunde{customer_id}") instance_dir = os.path.join(config.data_dir, customer.subdomain)
ok = await docker_service.compose_start(instance_dir, deployment.container_prefix) ok = await docker_service.compose_start(instance_dir, deployment.container_prefix)
if ok: if ok:
deployment.deployment_status = "running" deployment.deployment_status = "running"
customer = db.query(Customer).filter(Customer.id == customer_id).first()
if customer:
customer.status = "active" customer.status = "active"
db.commit() db.commit()
_log_action(db, customer_id, "start", "success", "Containers started.") _log_action(db, customer_id, "start", "success", "Containers started.")
@@ -526,16 +499,15 @@ async def start_customer(db: Session, customer_id: int) -> dict[str, Any]:
async def restart_customer(db: Session, customer_id: int) -> dict[str, Any]: async def restart_customer(db: Session, customer_id: int) -> dict[str, Any]:
"""Restart containers for a customer.""" """Restart containers for a customer."""
deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first() deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first()
customer = db.query(Customer).filter(Customer.id == customer_id).first()
config = get_system_config(db) config = get_system_config(db)
if not deployment or not config: if not deployment or not config or not customer:
return {"success": False, "error": "Deployment or config not found."} return {"success": False, "error": "Deployment, customer or config not found."}
instance_dir = os.path.join(config.data_dir, f"kunde{customer_id}") instance_dir = os.path.join(config.data_dir, customer.subdomain)
ok = await docker_service.compose_restart(instance_dir, deployment.container_prefix) ok = await docker_service.compose_restart(instance_dir, deployment.container_prefix)
if ok: if ok:
deployment.deployment_status = "running" deployment.deployment_status = "running"
customer = db.query(Customer).filter(Customer.id == customer_id).first()
if customer:
customer.status = "active" customer.status = "active"
db.commit() db.commit()
_log_action(db, customer_id, "restart", "success", "Containers restarted.") _log_action(db, customer_id, "restart", "success", "Containers restarted.")
+104 -17
View File
@@ -12,9 +12,12 @@ Let's Encrypt SSL certificates.
Also manages NPM streams for STUN/TURN relay UDP ports. Also manages NPM streams for STUN/TURN relay UDP ports.
""" """
import base64
import json
import logging import logging
import os import os
import socket import socket
import time
from typing import Any from typing import Any
import httpx import httpx
@@ -24,6 +27,14 @@ logger = logging.getLogger(__name__)
# Timeout for NPM API calls (seconds) # Timeout for NPM API calls (seconds)
NPM_TIMEOUT = 30 NPM_TIMEOUT = 30
# Cached JWTs, keyed by (api_url, email). NPM issues a token that stays valid
# for a while (per its 'exp' claim), so re-logging in on every single API
# call — as this module used to do — adds a full extra round-trip per action
# for no reason.
_token_cache: dict[tuple[str, str], dict[str, Any]] = {}
_TOKEN_SAFETY_MARGIN = 60 # refresh this many seconds before actual expiry
_DEFAULT_TOKEN_TTL = 3600 # fallback if the 'exp' claim can't be parsed
def _get_forward_host() -> str: def _get_forward_host() -> str:
"""Get the host machine's real IP address for NPM forwarding. """Get the host machine's real IP address for NPM forwarding.
@@ -90,6 +101,61 @@ async def _npm_login(client: httpx.AsyncClient, api_url: str, email: str, passwo
) )
def _decode_jwt_exp(token: str) -> float | None:
"""Best-effort decode of a JWT's 'exp' claim, without verifying the signature.
We only use this to size our own cache TTL — NPM itself still enforces
the real expiry server-side, so an inaccurate read here is harmless.
"""
try:
payload_b64 = token.split(".")[1]
padding = "=" * (-len(payload_b64) % 4)
payload = json.loads(base64.urlsafe_b64decode(payload_b64 + padding))
return payload.get("exp")
except Exception:
return None
async def _get_token(
client: httpx.AsyncClient, api_url: str, email: str, password: str, force_refresh: bool = False
) -> str:
"""Return a cached NPM JWT if still valid, otherwise log in and cache it."""
cache_key = (api_url, email)
if not force_refresh:
cached = _token_cache.get(cache_key)
if cached and time.time() < cached["expires_at"]:
return cached["token"]
token = await _npm_login(client, api_url, email, password)
exp = _decode_jwt_exp(token)
expires_at = (exp - _TOKEN_SAFETY_MARGIN) if exp else (time.time() + _DEFAULT_TOKEN_TTL)
_token_cache[cache_key] = {"token": token, "expires_at": expires_at}
return token
async def _request_with_reauth(
client: httpx.AsyncClient,
method: str,
api_url: str,
email: str,
password: str,
path: str,
headers: dict,
**kwargs: Any,
) -> tuple[httpx.Response, dict]:
"""Perform a request; if the cached token was rejected, refresh and retry once.
Returns the response and the (possibly updated) headers dict, so callers
can reuse the fresh token for any further requests in the same session.
"""
resp = await client.request(method, f"{api_url}{path}", headers=headers, **kwargs)
if resp.status_code == 401:
token = await _get_token(client, api_url, email, password, force_refresh=True)
headers = {**headers, "Authorization": f"Bearer {token}"}
resp = await client.request(method, f"{api_url}{path}", headers=headers, **kwargs)
return resp, headers
async def test_npm_connection(api_url: str, email: str, password: str) -> dict[str, Any]: async def test_npm_connection(api_url: str, email: str, password: str) -> dict[str, Any]:
"""Test connectivity to NPM by logging in and listing proxy hosts. """Test connectivity to NPM by logging in and listing proxy hosts.
@@ -103,9 +169,11 @@ async def test_npm_connection(api_url: str, email: str, password: str) -> dict[s
""" """
try: try:
async with httpx.AsyncClient(timeout=NPM_TIMEOUT) as client: async with httpx.AsyncClient(timeout=NPM_TIMEOUT) as client:
token = await _npm_login(client, api_url, email, password) token = await _get_token(client, api_url, email, password)
headers = {"Authorization": f"Bearer {token}"} headers = {"Authorization": f"Bearer {token}"}
resp = await client.get(f"{api_url}/nginx/proxy-hosts", headers=headers) resp, headers = await _request_with_reauth(
client, "GET", api_url, email, password, "/nginx/proxy-hosts", headers
)
if resp.status_code == 200: if resp.status_code == 200:
count = len(resp.json()) count = len(resp.json())
return {"ok": True, "message": f"Connected. Login OK. {count} proxy hosts found."} return {"ok": True, "message": f"Connected. Login OK. {count} proxy hosts found."}
@@ -136,9 +204,11 @@ async def list_certificates(api_url: str, email: str, password: str) -> dict[str
""" """
try: try:
async with httpx.AsyncClient(timeout=NPM_TIMEOUT) as client: async with httpx.AsyncClient(timeout=NPM_TIMEOUT) as client:
token = await _npm_login(client, api_url, email, password) token = await _get_token(client, api_url, email, password)
headers = {"Authorization": f"Bearer {token}"} headers = {"Authorization": f"Bearer {token}"}
resp = await client.get(f"{api_url}/nginx/certificates", headers=headers) resp, headers = await _request_with_reauth(
client, "GET", api_url, email, password, "/nginx/certificates", headers
)
if resp.status_code == 200: if resp.status_code == 200:
result = [] result = []
for cert in resp.json(): for cert in resp.json():
@@ -259,7 +329,20 @@ async def create_proxy_host(
"block_exploits": True, "block_exploits": True,
"allow_websocket_upgrade": True, "allow_websocket_upgrade": True,
"access_list_id": 0, "access_list_id": 0,
"advanced_config": "", "advanced_config": (
"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": { "meta": {
"letsencrypt_agree": True, "letsencrypt_agree": True,
"letsencrypt_email": admin_email, "letsencrypt_email": admin_email,
@@ -269,14 +352,15 @@ async def create_proxy_host(
try: try:
async with httpx.AsyncClient(timeout=180) as client: # Long timeout for LE cert async with httpx.AsyncClient(timeout=180) as client: # Long timeout for LE cert
token = await _npm_login(client, api_url, npm_email, npm_password) token = await _get_token(client, api_url, npm_email, npm_password)
headers = { headers = {
"Authorization": f"Bearer {token}", "Authorization": f"Bearer {token}",
"Content-Type": "application/json", "Content-Type": "application/json",
} }
resp = await client.post( resp, headers = await _request_with_reauth(
f"{api_url}/nginx/proxy-hosts", json=payload, headers=headers client, "POST", api_url, npm_email, npm_password,
"/nginx/proxy-hosts", headers, json=payload,
) )
if resp.status_code in (200, 201): if resp.status_code in (200, 201):
data = resp.json() data = resp.json()
@@ -529,14 +613,15 @@ async def create_stream(
try: try:
async with httpx.AsyncClient(timeout=NPM_TIMEOUT) as client: async with httpx.AsyncClient(timeout=NPM_TIMEOUT) as client:
token = await _npm_login(client, api_url, npm_email, npm_password) token = await _get_token(client, api_url, npm_email, npm_password)
headers = { headers = {
"Authorization": f"Bearer {token}", "Authorization": f"Bearer {token}",
"Content-Type": "application/json", "Content-Type": "application/json",
} }
resp = await client.post( resp, headers = await _request_with_reauth(
f"{api_url}/nginx/streams", json=payload, headers=headers client, "POST", api_url, npm_email, npm_password,
"/nginx/streams", headers, json=payload,
) )
if resp.status_code in (200, 201): if resp.status_code in (200, 201):
data = resp.json() data = resp.json()
@@ -574,10 +659,11 @@ async def delete_stream(
""" """
try: try:
async with httpx.AsyncClient(timeout=NPM_TIMEOUT) as client: async with httpx.AsyncClient(timeout=NPM_TIMEOUT) as client:
token = await _npm_login(client, api_url, npm_email, npm_password) token = await _get_token(client, api_url, npm_email, npm_password)
headers = {"Authorization": f"Bearer {token}"} headers = {"Authorization": f"Bearer {token}"}
resp = await client.delete( resp, headers = await _request_with_reauth(
f"{api_url}/nginx/streams/{stream_id}", headers=headers client, "DELETE", api_url, npm_email, npm_password,
f"/nginx/streams/{stream_id}", headers,
) )
if resp.status_code in (200, 204): if resp.status_code in (200, 204):
logger.info("Deleted NPM stream %d", stream_id) logger.info("Deleted NPM stream %d", stream_id)
@@ -610,10 +696,11 @@ async def delete_proxy_host(
""" """
try: try:
async with httpx.AsyncClient(timeout=NPM_TIMEOUT) as client: async with httpx.AsyncClient(timeout=NPM_TIMEOUT) as client:
token = await _npm_login(client, api_url, npm_email, npm_password) token = await _get_token(client, api_url, npm_email, npm_password)
headers = {"Authorization": f"Bearer {token}"} headers = {"Authorization": f"Bearer {token}"}
resp = await client.delete( resp, headers = await _request_with_reauth(
f"{api_url}/nginx/proxy-hosts/{proxy_id}", headers=headers client, "DELETE", api_url, npm_email, npm_password,
f"/nginx/proxy-hosts/{proxy_id}", headers,
) )
if resp.status_code in (200, 204): if resp.status_code in (200, 204):
logger.info("Deleted NPM proxy host %d", proxy_id) logger.info("Deleted NPM proxy host %d", proxy_id)
+245 -15
View File
@@ -5,6 +5,7 @@ import logging
import os import os
import shutil import shutil
import subprocess import subprocess
import httpx
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -14,9 +15,70 @@ import httpx
SOURCE_DIR = "/app-source" SOURCE_DIR = "/app-source"
VERSION_FILE = "/app/version.json" VERSION_FILE = "/app/version.json"
BACKUP_DIR = "/app/backups" BACKUP_DIR = "/app/backups"
CONTAINER_NAME = "netbird-msp-appliance"
SERVICE_NAME = "netbird-msp-appliance"
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# In-memory progress tracker for the currently running (or last) update.
# The container gets replaced mid-update, so this deliberately does NOT need
# to survive a restart — the frontend detects completion by polling until the
# app comes back up and reports a new version, not by reading a final status
# here. It exists so the UI can show *something* other than a frozen spinner
# while the backup/pull/build steps are in progress.
_update_status: dict[str, Any] = {
"state": "idle", # idle | running | failed
"step": "",
"message": "",
"started_at": None,
}
def get_update_status() -> dict[str, Any]:
"""Return a snapshot of the current update progress."""
return dict(_update_status)
def _set_status(state: str, step: str, message: str = "") -> None:
_update_status["state"] = state
_update_status["step"] = step
_update_status["message"] = message
if step == "backup":
_update_status["started_at"] = datetime.utcnow().isoformat()
def _get_compose_project_name() -> str:
"""Detect the compose project name from the running container's labels.
Docker Compose sets the label ``com.docker.compose.project`` on every
managed container. Reading it at runtime avoids hard-coding a project
name that may differ from the directory name used at deploy time.
Returns:
The compose project name (e.g. ``netbird-msp``).
"""
try:
result = subprocess.run(
[
"docker", "inspect", CONTAINER_NAME,
"--format",
'{{index .Config.Labels "com.docker.compose.project"}}',
],
capture_output=True, text=True, timeout=10,
)
if result.returncode == 0:
project = result.stdout.strip()
if project:
logger.info("Detected compose project name: %s", project)
return project
except Exception as exc:
logger.warning("Could not detect compose project name: %s", exc)
# Fallback: derive from SOURCE_DIR basename (mirrors Compose default behaviour)
fallback = Path(SOURCE_DIR).name
logger.warning("Using fallback compose project name: %s", fallback)
return fallback
def get_current_version() -> dict: def get_current_version() -> dict:
"""Read the version baked at build time from /app/version.json.""" """Read the version baked at build time from /app/version.json."""
@@ -103,15 +165,19 @@ async def check_for_updates(config: Any) -> dict:
"tag": latest_tag, "tag": latest_tag,
"commit": short_sha, "commit": short_sha,
"commit_full": full_sha, "commit_full": full_sha,
"message": latest_commit.get("commit", {}).get("message", "").split("\n")[0], "message": latest_commit.get("commit", {}).get("message", "").split("\n")[0] if latest_commit.get("commit") else "",
"date": latest_commit.get("commit", {}).get("committer", {}).get("date", ""), "date": latest_commit.get("timestamp", ""),
"branch": branch, "branch": branch,
} }
# Determine if update is needed: prefer tag comparison, fallback to commit # Determine if update is needed: prefer tag comparison, fallback to commit
current_tag = current.get("tag", "unknown") current_tag = current.get("tag", "unknown")
current_sha = current.get("commit", "unknown") current_sha = current.get("commit", "unknown")
if current_tag != "unknown" and latest_tag != "unknown":
# If we don't know our current version but the remote has one, we should update
if current_tag == "unknown" and current_sha == "unknown":
needs_update = latest_tag != "unknown" or short_sha != "unknown"
elif current_tag != "unknown" and latest_tag != "unknown":
needs_update = current_tag != latest_tag needs_update = current_tag != latest_tag
else: else:
needs_update = ( needs_update = (
@@ -130,6 +196,42 @@ async def check_for_updates(config: Any) -> dict:
} }
async def get_remote_branches(config: Any) -> list[str]:
"""Query the Gitea API for available branches on the configured repository.
Returns a list of branch names (e.g., ['main', 'unstable', 'development']).
If the repository URL is not configured or an error occurs, returns an empty list.
"""
if not config.git_repo_url:
return []
repo_url = config.git_repo_url.rstrip("/")
parts = repo_url.split("/")
if len(parts) < 5:
return []
base_url = "/".join(parts[:-2])
owner = parts[-2]
repo = parts[-1]
branches_api = f"{base_url}/api/v1/repos/{owner}/{repo}/branches?limit=100"
headers = {}
if config.git_token:
headers["Authorization"] = f"token {config.git_token}"
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(branches_api, headers=headers)
if resp.status_code == 200:
data = resp.json()
if isinstance(data, list):
return [branch.get("name") for branch in data if "name" in branch]
except Exception as exc:
logger.error("Error fetching branches: %s", exc)
return []
def backup_database(db_path: str) -> str: def backup_database(db_path: str) -> str:
"""Create a timestamped backup of the SQLite database. """Create a timestamped backup of the SQLite database.
@@ -157,10 +259,12 @@ def trigger_update(config: Any, db_path: str) -> dict:
Dict with ok (bool), message, backup path, and pulled_branch. Dict with ok (bool), message, backup path, and pulled_branch.
""" """
# 1. Backup database before any changes # 1. Backup database before any changes
_set_status("running", "backup", "Datenbank wird gesichert …")
try: try:
backup_path = backup_database(db_path) backup_path = backup_database(db_path)
except Exception as exc: except Exception as exc:
logger.error("Database backup failed: %s", exc) logger.error("Database backup failed: %s", exc)
_set_status("failed", "backup", f"Database backup failed: {exc}")
return {"ok": False, "message": f"Database backup failed: {exc}", "backup": None} return {"ok": False, "message": f"Database backup failed: {exc}", "backup": None}
# 2. Build git pull command (embed token in URL if provided) # 2. Build git pull command (embed token in URL if provided)
@@ -176,6 +280,17 @@ def trigger_update(config: Any, db_path: str) -> dict:
pull_cmd = ["git", "-C", SOURCE_DIR, "pull", "origin", branch] pull_cmd = ["git", "-C", SOURCE_DIR, "pull", "origin", branch]
# 3. Git pull (synchronous — must complete before rebuild) # 3. Git pull (synchronous — must complete before rebuild)
_set_status("running", "pull", f"Code wird von Branch '{branch}' geholt …")
# Ensure .git directory is owned by the process user (root inside container).
# The .git dir may be owned by the host user after manual operations.
try:
subprocess.run(
["git", "config", "--global", "--add", "safe.directory", SOURCE_DIR],
capture_output=True, timeout=10,
)
except Exception:
pass
try: try:
result = subprocess.run( result = subprocess.run(
pull_cmd, pull_cmd,
@@ -184,13 +299,16 @@ def trigger_update(config: Any, db_path: str) -> dict:
timeout=120, timeout=120,
) )
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
_set_status("failed", "pull", "git pull timed out after 120s.")
return {"ok": False, "message": "git pull timed out after 120s.", "backup": backup_path} return {"ok": False, "message": "git pull timed out after 120s.", "backup": backup_path}
except Exception as exc: except Exception as exc:
_set_status("failed", "pull", f"git pull error: {exc}")
return {"ok": False, "message": f"git pull error: {exc}", "backup": backup_path} return {"ok": False, "message": f"git pull error: {exc}", "backup": backup_path}
if result.returncode != 0: if result.returncode != 0:
stderr = result.stderr.strip()[:500] stderr = result.stderr.strip()[:500]
logger.error("git pull failed (exit %d): %s", result.returncode, stderr) logger.error("git pull failed (exit %d): %s", result.returncode, stderr)
_set_status("failed", "pull", f"git pull failed: {stderr}")
return { return {
"ok": False, "ok": False,
"message": f"git pull failed: {stderr}", "message": f"git pull failed: {stderr}",
@@ -199,6 +317,15 @@ def trigger_update(config: Any, db_path: str) -> dict:
logger.info("git pull succeeded: %s", result.stdout.strip()[:200]) logger.info("git pull succeeded: %s", result.stdout.strip()[:200])
# Fetch tags separately — git pull does not always pull all tags
try:
subprocess.run(
["git", "-C", SOURCE_DIR, "fetch", "--tags"],
capture_output=True, text=True, timeout=30,
)
except Exception as exc:
logger.warning("git fetch --tags failed (non-fatal): %s", exc)
# 4. Read version info from the freshly-pulled source # 4. Read version info from the freshly-pulled source
build_env = os.environ.copy() build_env = os.environ.copy()
try: try:
@@ -232,21 +359,124 @@ def trigger_update(config: Any, db_path: str) -> dict:
build_env.get("GIT_BRANCH", "?"), build_env.get("GIT_BRANCH", "?"),
) )
# 5. Fire-and-forget docker compose rebuild — the container will restart itself # 5. Two-phase rebuild: Build image first, then swap container.
compose_cmd = [ # The swap will kill this process (we ARE the container), so we must
"docker", "compose", # ensure the compose-up runs detached on the Docker host via a wrapper.
"-f", f"{SOURCE_DIR}/docker-compose.yml",
"up", "--build", "-d",
]
log_path = Path(BACKUP_DIR) / "update_rebuild.log" log_path = Path(BACKUP_DIR) / "update_rebuild.log"
log_file = open(log_path, "w")
subprocess.Popen( # Detect compose project name at runtime — avoids hard-coding a name that
compose_cmd, # may differ from the directory used at deploy time.
stdout=log_file, project_name = _get_compose_project_name()
stderr=log_file, # Image name follows Docker Compose convention: {project}-{service}
service_image = f"{project_name}-{SERVICE_NAME}:latest"
logger.info("Using project=%s image=%s", project_name, service_image)
# Phase A — build the new image (does NOT stop anything)
build_cmd = [
"docker", "compose",
"-p", project_name,
"-f", f"{SOURCE_DIR}/docker-compose.yml",
"build", "--no-cache",
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,
capture_output=True, text=True,
timeout=600,
env=build_env, env=build_env,
) )
logger.info("docker compose up --build -d triggered — container will restart shortly.") with open(log_path, "w") as f:
f.write(build_result.stdout)
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
# is destroyed). So we launch a *separate* helper container via 'docker run -d'
# that has access to the Docker socket and runs 'docker compose up -d'.
# This helper lives outside our container and survives our restart.
# Discover the host-side path of /app-source (docker volumes use host paths)
try:
inspect_result = subprocess.run(
["docker", "inspect", "netbird-msp-appliance",
"--format", '{{range .Mounts}}{{if eq .Destination "/app-source"}}{{.Source}}{{end}}{{end}}'],
capture_output=True, text=True, timeout=10,
)
host_source_dir = inspect_result.stdout.strip()
if not host_source_dir:
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)
env_flags = []
for key in ("GIT_TAG", "GIT_COMMIT", "GIT_BRANCH", "GIT_COMMIT_DATE"):
val = build_env.get(key, "unknown")
env_flags.extend(["-e", f"{key}={val}"])
helper_cmd = [
"docker", "run", "--rm", "-d", "--privileged",
"--name", "msp-updater",
"-v", "/var/run/docker.sock:/var/run/docker.sock:z",
"-v", f"{host_source_dir}:{host_source_dir}:ro,z",
*env_flags,
service_image, # freshly built image — has docker CLI + compose plugin
"sh", "-c",
(
"sleep 3 && "
f"docker compose -p {project_name} "
f"-f {host_source_dir}/docker-compose.yml "
f"up --force-recreate --no-deps -d {SERVICE_NAME}"
),
]
try:
# Remove stale updater container if any
subprocess.run(
["docker", "rm", "-f", "msp-updater"],
capture_output=True, timeout=10,
)
result = subprocess.run(
helper_cmd,
capture_output=True, text=True,
timeout=30,
env=build_env,
)
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]}",
"backup": backup_path,
}
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 { return {
"ok": True, "ok": True,
+1
View File
@@ -57,6 +57,7 @@ services:
- "${WEB_UI_PORT:-8000}:8000" - "${WEB_UI_PORT:-8000}:8000"
volumes: volumes:
- ./data:/app/data:z - ./data:/app/data:z
- ./data/uploads:/app/static/uploads:z
- ./logs:/app/logs:z - ./logs:/app/logs:z
- ./backups:/app/backups:z - ./backups:/app/backups:z
- /var/run/docker.sock:/var/run/docker.sock:z - /var/run/docker.sock:/var/run/docker.sock:z
-50
View File
@@ -1,50 +0,0 @@
INFO: 172.18.0.1:33288 - "GET /api/settings/version HTTP/1.1" 200 OK
2026-02-22 13:27:28,812 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK"
2026-02-22 13:27:28,818 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK"
INFO: 172.18.0.1:33288 - "GET /api/settings/version HTTP/1.1" 200 OK
2026-02-22 13:27:29,463 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK"
2026-02-22 13:27:29,473 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK"
INFO: 172.18.0.1:33288 - "GET /api/settings/version HTTP/1.1" 200 OK
2026-02-22 13:27:33,352 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK"
2026-02-22 13:27:33,358 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK"
INFO: 172.18.0.1:33288 - "GET /api/settings/version HTTP/1.1" 200 OK
2026-02-22 13:27:34,899 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK"
2026-02-22 13:27:34,905 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK"
INFO: 172.18.0.1:33288 - "GET /api/settings/version HTTP/1.1" 200 OK
INFO: 172.18.0.1:33288 - "GET /api/settings/system HTTP/1.1" 200 OK
INFO: 172.18.0.1:33288 - "GET /api/auth/mfa/status HTTP/1.1" 200 OK
INFO: 172.18.0.1:33288 - "GET /api/monitoring/resources HTTP/1.1" 200 OK
INFO: 172.18.0.1:38946 - "GET /api/monitoring/customers HTTP/1.1" 200 OK
INFO: 172.18.0.1:38946 - "GET /api/monitoring/customers HTTP/1.1" 200 OK
INFO: 172.18.0.1:33288 - "GET /api/monitoring/resources HTTP/1.1" 200 OK
INFO: 172.18.0.1:33288 - "GET /api/settings/system HTTP/1.1" 200 OK
INFO: 172.18.0.1:38946 - "GET /api/auth/mfa/status HTTP/1.1" 200 OK
2026-02-22 13:27:49,427 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK"
2026-02-22 13:27:49,433 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK"
INFO: 172.18.0.1:33288 - "GET /api/settings/version HTTP/1.1" 200 OK
INFO: 172.18.0.1:33288 - "GET / HTTP/1.1" 200 OK
INFO: 172.18.0.1:38946 - "GET /api/settings/branding HTTP/1.1" 200 OK
INFO: 172.18.0.1:38946 - "GET /api/auth/azure/config HTTP/1.1" 200 OK
INFO: 172.18.0.1:38946 - "GET /api/auth/me HTTP/1.1" 200 OK
INFO: 172.18.0.1:38946 - "GET /api/monitoring/status HTTP/1.1" 200 OK
INFO: 172.18.0.1:45440 - "GET /api/customers?page=1&per_page=25 HTTP/1.1" 200 OK
INFO: 127.0.0.1:35528 - "GET /api/health HTTP/1.1" 200 OK
INFO: 172.18.0.1:33288 - "GET /api/settings/system HTTP/1.1" 200 OK
INFO: 172.18.0.1:38946 - "GET /api/auth/mfa/status HTTP/1.1" 200 OK
2026-02-22 13:27:56,795 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK"
2026-02-22 13:27:56,802 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK"
INFO: 172.18.0.1:33288 - "GET /api/settings/version HTTP/1.1" 200 OK
2026-02-22 13:27:59,507 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK"
2026-02-22 13:27:59,514 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK"
INFO: 172.18.0.1:33288 - "GET /api/settings/version HTTP/1.1" 200 OK
2026-02-22 13:28:09,172 [INFO] app.services.update_service: Database backed up to /app/backups/netbird_msp_20260222_132809.db
2026-02-22 13:28:09,264 [INFO] app.services.update_service: git pull succeeded: Already up to date.
2026-02-22 13:28:09,265 [INFO] app.services.update_service: docker compose up --build -d triggered — container will restart shortly.
2026-02-22 13:28:09,265 [INFO] app.routers.settings: Update triggered by admin.
INFO: 172.18.0.1:57990 - "POST /api/settings/update HTTP/1.1" 200 OK
INFO: 127.0.0.1:51474 - "GET /api/health HTTP/1.1" 200 OK
2026-02-22 13:28:49,056 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK"
2026-02-22 13:28:49,062 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK"
INFO: 172.18.0.1:44506 - "GET /api/settings/version HTTP/1.1" 200 OK
INFO: 127.0.0.1:53966 - "GET /api/health HTTP/1.1" 200 OK
INFO: 127.0.0.1:35452 - "GET /api/health HTTP/1.1" 200 OK
+33
View File
@@ -188,3 +188,36 @@ body.i18n-loading #app-page {
font-weight: 600; font-weight: 600;
background: rgba(0, 0, 0, 0.02); background: rgba(0, 0, 0, 0.02);
} }
/* ---------------------------------------------------------------------------
Dark mode overrides (Bootstrap 5.3 data-bs-theme="dark")
Bootstrap handles most components automatically; only custom elements need
explicit overrides here.
--------------------------------------------------------------------------- */
[data-bs-theme="dark"] .card {
border-color: rgba(255, 255, 255, 0.08);
}
[data-bs-theme="dark"] .card-header {
background: rgba(255, 255, 255, 0.04);
}
[data-bs-theme="dark"] .log-entry {
border-bottom-color: rgba(255, 255, 255, 0.07);
}
[data-bs-theme="dark"] .log-time {
color: #9ca3af;
}
[data-bs-theme="dark"] .table th {
color: #9ca3af;
}
[data-bs-theme="dark"] .login-container {
background: linear-gradient(135deg, #0d0d1a 0%, #0a1020 50%, #071525 100%);
}
[data-bs-theme="dark"] .stat-card {
background: var(--bs-card-bg);
}
+21
View File
@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<!-- Blue rounded background -->
<rect width="32" height="32" rx="7" fill="#2563EB"/>
<!-- Bird silhouette: top-down view, wings spread, forked tail -->
<path fill="white" d="
M 16 7
C 15 8 14 9.5 14 11
C 11 10.5 7 11 4 14
C 8 15 12 14.5 14 14.5
L 15 22
L 13 26
L 16 24
L 19 26
L 17 22
L 18 14.5
C 20 14.5 24 15 28 14
C 25 11 21 10.5 18 11
C 18 9.5 17 8 16 7 Z
"/>
</svg>

After

Width:  |  Height:  |  Size: 496 B

+637 -217
View File
File diff suppressed because it is too large Load Diff
+434 -18
View File
@@ -12,7 +12,7 @@ let currentPage = 'dashboard';
let currentCustomerId = null; let currentCustomerId = null;
let currentCustomerData = null; let currentCustomerData = null;
let customersPage = 1; let customersPage = 1;
let brandingData = { branding_name: 'NetBird MSP Appliance', branding_logo_path: null }; let brandingData = { branding_name: 'NetBird MSP Appliance', branding_logo_path: null, version: 'alpha-1.1' };
let azureConfig = { azure_enabled: false }; let azureConfig = { azure_enabled: false };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -66,10 +66,35 @@ async function api(method, path, body = null) {
return data; return data;
} }
// ---------------------------------------------------------------------------
// Dark mode
// ---------------------------------------------------------------------------
function toggleDarkMode() {
const isDark = document.documentElement.getAttribute('data-bs-theme') === 'dark';
if (isDark) {
document.documentElement.removeAttribute('data-bs-theme');
localStorage.setItem('darkMode', 'light');
document.getElementById('darkmode-icon').className = 'bi bi-moon-fill';
} else {
document.documentElement.setAttribute('data-bs-theme', 'dark');
localStorage.setItem('darkMode', 'dark');
document.getElementById('darkmode-icon').className = 'bi bi-sun-fill';
}
}
function syncDarkmodeIcon() {
const icon = document.getElementById('darkmode-icon');
if (!icon) return;
icon.className = document.documentElement.getAttribute('data-bs-theme') === 'dark'
? 'bi bi-sun-fill'
: 'bi bi-moon-fill';
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Auth // Auth
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function initApp() { async function initApp() {
syncDarkmodeIcon();
await initI18n(); await initI18n();
await loadBranding(); await loadBranding();
await loadAzureLoginConfig(); await loadAzureLoginConfig();
@@ -127,12 +152,19 @@ function applyBranding() {
const name = brandingData.branding_name || 'NetBird MSP Appliance'; const name = brandingData.branding_name || 'NetBird MSP Appliance';
const subtitle = brandingData.branding_subtitle || t('login.subtitle'); const subtitle = brandingData.branding_subtitle || t('login.subtitle');
const logoPath = brandingData.branding_logo_path; const logoPath = brandingData.branding_logo_path;
const version = brandingData.version || 'alpha-1.1';
// Login page // Login page
document.getElementById('login-title').textContent = name; document.getElementById('login-title').textContent = name;
const subtitleEl = document.getElementById('login-subtitle'); const subtitleEl = document.getElementById('login-subtitle');
if (subtitleEl) subtitleEl.textContent = subtitle; if (subtitleEl) subtitleEl.textContent = subtitle;
document.title = name; document.title = name;
// Update version string in login page
const versionEl = document.querySelector('#login-page .text-muted.small.mb-0');
if (versionEl) {
versionEl.innerHTML = `<i class="bi bi-tag me-1"></i>${version}`;
}
if (logoPath) { if (logoPath) {
document.getElementById('login-logo').innerHTML = `<img src="${logoPath}" alt="Logo" style="max-height:64px;max-width:200px;" class="mb-1">`; document.getElementById('login-logo').innerHTML = `<img src="${logoPath}" alt="Logo" style="max-height:64px;max-width:200px;" class="mb-1">`;
} else { } else {
@@ -366,7 +398,7 @@ function logout() {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`, 'Authorization': `Bearer ${authToken}`,
}, },
}).catch(() => {}); }).catch(() => { });
} }
authToken = null; authToken = null;
currentUser = null; currentUser = null;
@@ -453,11 +485,11 @@ function renderCustomersTable(data) {
const dashLink = dPort const dashLink = dPort
? `<a href="${esc(dashUrl || 'http://localhost:' + dPort)}" target="_blank" class="text-decoration-none" title="${t('customer.openDashboard')}">:${dPort} <i class="bi bi-box-arrow-up-right"></i></a>` ? `<a href="${esc(dashUrl || 'http://localhost:' + dPort)}" target="_blank" class="text-decoration-none" title="${t('customer.openDashboard')}">:${dPort} <i class="bi bi-box-arrow-up-right"></i></a>`
: '-'; : '-';
return `<tr> return `<tr data-customer-id="${c.id}">
<td>${c.id}</td> <td>${c.id}</td>
<td><a href="#" onclick="viewCustomer(${c.id})" class="text-decoration-none fw-semibold">${esc(c.name)}</a></td> <td><a href="#" onclick="viewCustomer(${c.id})" class="text-decoration-none fw-semibold">${esc(c.name)}</a></td>
<td><code>${esc(c.subdomain)}</code></td> <td><code>${esc(c.subdomain)}</code></td>
<td>${statusBadge(c.status)}</td> <td><span class="customer-status-cell">${statusBadge(c.status)}</span></td>
<td>${dashLink}</td> <td>${dashLink}</td>
<td>${c.max_devices}</td> <td>${c.max_devices}</td>
<td>${formatDate(c.created_at)}</td> <td>${formatDate(c.created_at)}</td>
@@ -485,6 +517,26 @@ function renderCustomersTable(data) {
paginationHtml += `<li class="page-item ${i === data.page ? 'active' : ''}"><a class="page-link" href="#" onclick="goToPage(${i})">${i}</a></li>`; paginationHtml += `<li class="page-item ${i === data.page ? 'active' : ''}"><a class="page-link" href="#" onclick="goToPage(${i})">${i}</a></li>`;
} }
document.getElementById('pagination-controls').innerHTML = paginationHtml; document.getElementById('pagination-controls').innerHTML = paginationHtml;
// Lazy-load update badges after table renders (best-effort, silent fail)
loadCustomerUpdateBadges().catch(() => {});
}
async function loadCustomerUpdateBadges() {
const data = await api('GET', '/monitoring/customers/local-update-status');
data.forEach(s => {
if (!s.needs_update) return;
const tr = document.querySelector(`tr[data-customer-id="${s.customer_id}"]`);
if (!tr) return;
const cell = tr.querySelector('.customer-status-cell');
if (cell && !cell.querySelector('.update-badge')) {
const badge = document.createElement('span');
badge.className = 'badge bg-warning text-dark update-badge ms-1';
badge.title = t('monitoring.updateAvailable');
badge.innerHTML = '<i class="bi bi-arrow-repeat"></i> Update';
cell.appendChild(badge);
}
});
} }
function goToPage(page) { function goToPage(page) {
@@ -511,7 +563,7 @@ function showNewCustomerModal() {
// Update subdomain suffix // Update subdomain suffix
api('GET', '/settings/system').then(cfg => { api('GET', '/settings/system').then(cfg => {
document.getElementById('cust-subdomain-suffix').textContent = `.${cfg.base_domain || 'domain.com'}`; document.getElementById('cust-subdomain-suffix').textContent = `.${cfg.base_domain || 'domain.com'}`;
}).catch(() => {}); }).catch(() => { });
const modalEl = document.getElementById('customer-modal'); const modalEl = document.getElementById('customer-modal');
const modal = bootstrap.Modal.getOrCreateInstance(modalEl); const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
@@ -615,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 { try {
await api('POST', `/customers/${id}/${action}`); await api('POST', `/customers/${id}/${action}`);
if (currentPage === 'dashboard') loadCustomers(); if (currentPage === 'dashboard') loadCustomers();
@@ -627,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 // Customer Detail
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -710,8 +789,13 @@ 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-success btn-sm me-1" onclick="customerAction(${id},'start')"><i class="bi bi-play-circle me-1"></i>${t('customer.start')}</button>
<button class="btn btn-warning btn-sm me-1" onclick="customerAction(${id},'stop')"><i class="bi bi-stop-circle me-1"></i>${t('customer.stop')}</button> <button class="btn btn-warning btn-sm me-1" onclick="customerAction(${id},'stop')"><i class="bi bi-stop-circle me-1"></i>${t('customer.stop')}</button>
<button class="btn btn-info btn-sm me-1" onclick="customerAction(${id},'restart')"><i class="bi bi-arrow-repeat me-1"></i>${t('customer.restart')}</button> <button class="btn btn-info btn-sm me-1" onclick="customerAction(${id},'restart')"><i class="bi bi-arrow-repeat me-1"></i>${t('customer.restart')}</button>
<button class="btn btn-outline-primary btn-sm" 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')}
</button>
</div> </div>
<div id="detail-update-result"></div>
`; `;
} else { } else {
document.getElementById('detail-deployment-content').innerHTML = ` document.getElementById('detail-deployment-content').innerHTML = `
@@ -872,6 +956,9 @@ async function loadSettings() {
} catch (err) { } catch (err) {
showSettingsAlert('danger', t('errors.failedToLoadSettings', { error: err.message })); showSettingsAlert('danger', t('errors.failedToLoadSettings', { error: err.message }));
} }
// Automatically fetch branches once the base config is populated
await loadGitBranches();
} }
function updateLogoPreview(logoPath) { function updateLogoPreview(logoPath) {
@@ -1183,6 +1270,42 @@ async function testLdapConnection() {
} }
} }
async function loadGitBranches() {
const branchSelect = document.getElementById('cfg-git-branch');
const currentVal = branchSelect.value;
// Disable mapping while loading
branchSelect.disabled = true;
branchSelect.innerHTML = `<option value="${currentVal}">${currentVal} (Loading...)</option>`;
try {
const branches = await api('GET', '/settings/branches');
branchSelect.innerHTML = '';
// Always ensure the currently saved branch is an option
if (currentVal && !branches.includes(currentVal)) {
branches.unshift(currentVal);
}
if (branches.length === 0) {
branchSelect.innerHTML = `<option value="main">main</option>`;
} else {
branches.forEach(b => {
const opt = document.createElement('option');
opt.value = b;
opt.textContent = b;
if (b === currentVal) opt.selected = true;
branchSelect.appendChild(opt);
});
}
} catch (err) {
showSettingsAlert('warning', `Failed to load branches: ${err.message}`);
branchSelect.innerHTML = `<option value="${currentVal}">${currentVal}</option>`;
} finally {
branchSelect.disabled = false;
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Update / Version Management // Update / Version Management
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1219,12 +1342,12 @@ async function loadVersionInfo() {
let html = `<div class="row g-3"> let html = `<div class="row g-3">
<div class="col-md-6"> <div class="col-md-6">
<div class="border rounded p-3"> <div class="border rounded p-3 h-100">
<div class="text-muted small mb-1">${t('settings.currentVersion')}</div> <div class="text-muted small mb-1">${t('settings.currentVersion')}</div>
<div class="fw-bold fs-5">${esc(currentTag || currentCommit)}</div> <div class="fw-bold fs-5">${esc(currentTag || currentCommit)}</div>
${currentTag ? `<div class="text-muted small font-monospace">${t('settings.commitHash')}: ${esc(currentCommit)}</div>` : ''} ${currentTag ? `<div class="text-muted small font-monospace">${t('settings.commitHash')}: ${esc(currentCommit)}</div>` : ''}
<div class="text-muted small">${t('settings.branch')}: <strong>${esc(current.branch || 'unknown')}</strong></div> <div class="text-muted small">${t('settings.branch')}: <strong>${esc(current.branch || 'unknown')}</strong></div>
<div class="text-muted small">${esc(current.date || '')}</div> <div class="text-muted small mt-2"><i class="bi bi-clock me-1"></i>${formatDate(current.date)}</div>
</div> </div>
</div>`; </div>`;
@@ -1235,27 +1358,28 @@ async function loadVersionInfo() {
? `<span class="badge bg-warning text-dark ms-1">${t('settings.updateAvailable')}</span>` ? `<span class="badge bg-warning text-dark ms-1">${t('settings.updateAvailable')}</span>`
: `<span class="badge bg-success ms-1">${t('settings.upToDate')}</span>`; : `<span class="badge bg-success ms-1">${t('settings.upToDate')}</span>`;
html += `<div class="col-md-6"> html += `<div class="col-md-6">
<div class="border rounded p-3 ${needsUpdate ? 'border-warning' : ''}"> <div class="border rounded p-3 h-100 ${needsUpdate ? 'border-warning' : ''}">
<div class="text-muted small mb-1">${t('settings.latestVersion')} ${badge}</div> <div class="text-muted small mb-1">${t('settings.latestVersion')} ${badge}</div>
<div class="fw-bold fs-5">${esc(latestTag || latestCommit)}</div> <div class="fw-bold fs-5">${esc(latestTag || latestCommit)}</div>
${latestTag ? `<div class="text-muted small font-monospace">${t('settings.commitHash')}: ${esc(latestCommit)}</div>` : ''} ${latestTag ? `<div class="text-muted small font-monospace">${t('settings.commitHash')}: ${esc(latestCommit)}</div>` : ''}
<div class="text-muted small">${t('settings.branch')}: <strong>${esc(latest.branch || 'unknown')}</strong></div> <div class="text-muted small">${t('settings.branch')}: <strong>${esc(latest.branch || 'unknown')}</strong></div>
<div class="text-muted small">${esc(latest.message || '')}</div> <div class="text-muted small mt-2"><i class="bi bi-clock me-1"></i>${formatDate(latest.date)}</div>
<div class="text-muted small">${esc(latest.date || '')}</div> ${latest.message ? `<div class="text-muted small mt-1 border-top pt-1 text-truncate" title="${esc(latest.message)}"><i class="bi bi-chat-text me-1"></i>${esc(latest.message)}</div>` : ''}
</div> </div>
</div>`; </div>`;
} else if (data.error) { } else if (data.error) {
html += `<div class="col-md-6"><div class="alert alert-warning mb-0">${esc(data.error)}</div></div>`; html += `<div class="col-md-6"><div class="alert alert-warning h-100 mb-0">${esc(data.error)}</div></div>`;
} }
html += '</div>'; html += '</div>';
if (needsUpdate) { if (needsUpdate) {
html += `<div class="mt-3"> html += `<div class="mt-3">
<button class="btn btn-warning" onclick="triggerUpdate()"> <button class="btn btn-warning" id="update-trigger-btn" onclick="triggerUpdate()">
<span class="spinner-border spinner-border-sm d-none me-1" id="update-spinner"></span> <span class="spinner-border spinner-border-sm d-none me-1" id="update-spinner"></span>
<i class="bi bi-arrow-repeat me-1"></i>${t('settings.triggerUpdate')} <i class="bi bi-arrow-repeat me-1"></i>${t('settings.triggerUpdate')}
</button> </button>
<div class="text-muted small mt-1">${t('settings.updateWarning')}</div> <div class="text-muted small mt-1">${t('settings.updateWarning')}</div>
<div class="small mt-2 d-none" id="update-progress-text"></div>
</div>`; </div>`;
} }
el.innerHTML = html; el.innerHTML = html;
@@ -1267,14 +1391,76 @@ async function loadVersionInfo() {
async function triggerUpdate() { async function triggerUpdate() {
if (!confirm(t('settings.confirmUpdate'))) return; if (!confirm(t('settings.confirmUpdate'))) return;
const spinner = document.getElementById('update-spinner'); const spinner = document.getElementById('update-spinner');
const btn = document.getElementById('update-trigger-btn');
const progressText = document.getElementById('update-progress-text');
const setProgress = (msg) => {
if (!progressText) return;
progressText.classList.remove('d-none');
progressText.textContent = msg;
};
const stopUpdateUi = () => {
if (spinner) spinner.classList.add('d-none');
if (btn) btn.disabled = false;
};
if (spinner) spinner.classList.remove('d-none'); if (spinner) spinner.classList.remove('d-none');
if (btn) btn.disabled = true;
setProgress(t('settings.updateStepStarting'));
try { try {
const data = await api('POST', '/settings/update'); const data = await api('POST', '/settings/update');
showSettingsAlert('success', data.message || t('messages.updateStarted')); showSettingsAlert('success', data.message || t('messages.updateStarted'));
} catch (err) { } catch (err) {
showSettingsAlert('danger', t('errors.failed', { error: err.message })); showSettingsAlert('danger', t('errors.failed', { error: err.message }));
if (spinner) spinner.classList.add('d-none'); stopUpdateUi();
return;
} }
// Phase 1: poll build/pull progress until the container restarts
// (connection drops, which is expected and is our cue to move to phase 2).
const stepLabelKey = {
backup: 'settings.updateStepBackup',
pull: 'settings.updateStepPull',
build: 'settings.updateStepBuild',
restart: 'settings.updateStepRestart',
};
for (let i = 0; i < 200; i++) {
await new Promise(r => setTimeout(r, 2000));
try {
const st = await api('GET', '/settings/update/status');
if (st.state === 'failed') {
showSettingsAlert('danger', st.message || t('errors.requestFailed'));
stopUpdateUi();
return;
}
setProgress(t(stepLabelKey[st.step] || 'settings.updateStepStarting') + (st.message ? `${st.message}` : ''));
} catch (err) {
// Connection dropped — the container is very likely mid-restart. Move on.
break;
}
}
// Phase 2: wait for the app to come back up, then reload version info.
setProgress(t('settings.updateStepReconnecting'));
for (let i = 0; i < 90; i++) {
await new Promise(r => setTimeout(r, 2000));
try {
await api('GET', '/settings/version');
setProgress(t('settings.updateStepDone'));
stopUpdateUi();
showSettingsAlert('success', t('settings.updateStepDone'));
await loadVersionInfo();
return;
} catch (err) {
// still restarting — keep polling
}
}
// Gave up waiting — surface this instead of spinning forever.
stopUpdateUi();
setProgress('');
if (progressText) progressText.classList.add('d-none');
showSettingsAlert('warning', t('settings.updateStepTimeout'));
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1297,8 +1483,8 @@ async function loadUsers() {
<td>${u.id}</td> <td>${u.id}</td>
<td><strong>${esc(u.username)}</strong></td> <td><strong>${esc(u.username)}</strong></td>
<td>${esc(u.email || '-')}</td> <td>${esc(u.email || '-')}</td>
<td><span class="badge bg-info">${esc(u.role || 'admin')}</span></td> <td><span class="badge bg-${u.role === 'admin' ? 'success' : 'secondary'}">${esc(u.role || 'admin')}</span></td>
<td><span class="badge bg-${u.auth_provider === 'azure' ? 'primary' : 'secondary'}">${esc(u.auth_provider || 'local')}</span></td> <td><span class="badge bg-${u.auth_provider === 'azure' ? 'primary' : u.auth_provider === 'ldap' ? 'info' : 'secondary'}">${esc(u.auth_provider || 'local')}</span></td>
<td>${langDisplay}</td> <td>${langDisplay}</td>
<td>${mfaDisplay}</td> <td>${mfaDisplay}</td>
<td>${u.is_active ? `<span class="badge bg-success">${t('common.active')}</span>` : `<span class="badge bg-danger">${t('common.disabled')}</span>`}</td> <td>${u.is_active ? `<span class="badge bg-success">${t('common.active')}</span>` : `<span class="badge bg-danger">${t('common.disabled')}</span>`}</td>
@@ -1310,6 +1496,11 @@ async function loadUsers() {
} }
${u.auth_provider === 'local' ? `<button class="btn btn-outline-info" title="${t('common.resetPassword')}" onclick="resetUserPassword(${u.id}, '${esc(u.username)}')"><i class="bi bi-key"></i></button>` : ''} ${u.auth_provider === 'local' ? `<button class="btn btn-outline-info" title="${t('common.resetPassword')}" onclick="resetUserPassword(${u.id}, '${esc(u.username)}')"><i class="bi bi-key"></i></button>` : ''}
${u.totp_enabled ? `<button class="btn btn-outline-secondary" title="${t('mfa.resetMfa')}" onclick="resetUserMfa(${u.id}, '${esc(u.username)}')"><i class="bi bi-shield-x"></i></button>` : ''} ${u.totp_enabled ? `<button class="btn btn-outline-secondary" title="${t('mfa.resetMfa')}" onclick="resetUserMfa(${u.id}, '${esc(u.username)}')"><i class="bi bi-shield-x"></i></button>` : ''}
${currentUser && currentUser.role === 'admin' && u.id !== currentUser.id
? (u.role === 'admin'
? `<button class="btn btn-outline-secondary" title="${t('settings.makeViewer')}" onclick="toggleUserRole(${u.id}, 'admin')"><i class="bi bi-person-dash"></i></button>`
: `<button class="btn btn-outline-success" title="${t('settings.makeAdmin')}" onclick="toggleUserRole(${u.id}, 'viewer')"><i class="bi bi-person-check"></i></button>`)
: ''}
<button class="btn btn-outline-danger" title="${t('common.delete')}" onclick="deleteUser(${u.id}, '${esc(u.username)}')"><i class="bi bi-trash"></i></button> <button class="btn btn-outline-danger" title="${t('common.delete')}" onclick="deleteUser(${u.id}, '${esc(u.username)}')"><i class="bi bi-trash"></i></button>
</div> </div>
</td> </td>
@@ -1369,6 +1560,16 @@ async function toggleUserActive(id, active) {
} }
} }
async function toggleUserRole(id, currentRole) {
const newRole = currentRole === 'admin' ? 'viewer' : 'admin';
try {
await api('PUT', `/users/${id}`, { role: newRole });
loadUsers();
} catch (err) {
showSettingsAlert('danger', t('errors.updateFailed', { error: err.message }));
}
}
async function resetUserPassword(id, username) { async function resetUserPassword(id, username) {
if (!confirm(t('messages.confirmResetPassword', { username }))) return; if (!confirm(t('messages.confirmResetPassword', { username }))) return;
try { try {
@@ -1547,6 +1748,221 @@ async function loadAllCustomerStatuses() {
} }
} }
// ---------------------------------------------------------------------------
// Image Updates
// ---------------------------------------------------------------------------
async function checkImageUpdates() {
const btn = document.getElementById('btn-check-updates');
const body = document.getElementById('image-updates-body');
btn.disabled = true;
body.innerHTML = `<div class="text-muted"><span class="spinner-border spinner-border-sm me-2"></span>${t('common.loading')}</div>`;
try {
const data = await api('GET', '/monitoring/images/check');
// Image status table
const imageRows = Object.values(data.images).map(img => {
const badge = img.update_available
? `<span class="badge bg-warning text-dark">${t('monitoring.updateAvailable')}</span>`
: `<span class="badge bg-success">${t('monitoring.upToDate')}</span>`;
const shortDigest = d => d ? d.substring(7, 19) + '…' : '-';
return `<tr>
<td><code class="small">${esc(img.image)}</code></td>
<td class="small text-muted">${shortDigest(img.local_digest)}</td>
<td class="small text-muted">${shortDigest(img.hub_digest)}</td>
<td>${badge}</td>
</tr>`;
}).join('');
// Customer status table
const customerRows = data.customer_status.length === 0
? `<tr><td colspan="3" class="text-center text-muted py-3">${t('monitoring.noCustomers')}</td></tr>`
: data.customer_status.map(c => {
const badge = c.needs_update
? `<span class="badge bg-warning text-dark">${t('monitoring.needsUpdate')}</span>`
: `<span class="badge bg-success">${t('monitoring.upToDate')}</span>`;
const updateBtn = c.needs_update
? `<button class="btn btn-sm btn-outline-warning ms-2 btn-update-customer" onclick="updateCustomerImages(${c.customer_id})"
title="${t('monitoring.updateCustomer')}"><i class="bi bi-arrow-repeat"></i></button>`
: '';
return `<tr>
<td>${c.customer_id}</td>
<td>${esc(c.customer_name)} <code class="small text-muted">${esc(c.subdomain)}</code></td>
<td>${badge}${updateBtn}</td>
</tr>`;
}).join('');
// Show "Update All" button if any customer needs update
const updateAllBtn = document.getElementById('btn-update-all');
if (data.customer_status.some(c => c.needs_update)) {
updateAllBtn.classList.remove('d-none');
} else {
updateAllBtn.classList.add('d-none');
}
body.innerHTML = `
<h6 class="mb-2">${t('monitoring.imageStatusTitle')}</h6>
<div class="table-responsive mb-4">
<table class="table table-sm mb-0">
<thead class="table-light">
<tr>
<th>${t('monitoring.thImage')}</th>
<th>${t('monitoring.thLocalDigest')}</th>
<th>${t('monitoring.thHubDigest')}</th>
<th>${t('monitoring.thStatus')}</th>
</tr>
</thead>
<tbody>${imageRows}</tbody>
</table>
</div>
<h6 class="mb-2">${t('monitoring.customerImageTitle')}</h6>
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead class="table-light">
<tr>
<th>${t('monitoring.thId')}</th>
<th>${t('monitoring.thName')}</th>
<th>${t('monitoring.thStatus')}</th>
</tr>
</thead>
<tbody>${customerRows}</tbody>
</table>
</div>`;
} catch (err) {
body.innerHTML = `<div class="alert alert-danger">${err.message}</div>`;
} finally {
btn.disabled = false;
}
}
async function pullAllImages() {
if (!confirm(t('monitoring.confirmPull'))) return;
const btn = document.getElementById('btn-pull-images');
btn.disabled = true;
try {
await api('POST', '/monitoring/images/pull');
showToast(t('monitoring.pullStarted'));
// Re-check after a few seconds to let pull finish
setTimeout(() => checkImageUpdates(), 5000);
} catch (err) {
showMonitoringAlert('danger', err.message);
} finally {
btn.disabled = false;
}
}
async function updateCustomerImagesFromDetail(id) {
const btn = document.getElementById('btn-update-images-detail');
const spinner = document.getElementById('update-detail-spinner');
const resultDiv = document.getElementById('detail-update-result');
btn.disabled = true;
spinner.classList.remove('d-none');
resultDiv.innerHTML = `<div class="alert alert-info py-2 mt-2"><span class="spinner-border spinner-border-sm me-2"></span>${t('customer.updateInProgress')}</div>`;
try {
const data = await api('POST', `/customers/${id}/update-images`);
resultDiv.innerHTML = `<div class="alert alert-success py-2 mt-2"><i class="bi bi-check-circle me-1"></i>${esc(data.message)}</div>`;
setTimeout(() => { resultDiv.innerHTML = ''; }, 6000);
} catch (err) {
resultDiv.innerHTML = `<div class="alert alert-danger py-2 mt-2"><i class="bi bi-exclamation-circle me-1"></i>${esc(err.message)}</div>`;
} finally {
btn.disabled = false;
spinner.classList.add('d-none');
}
}
async function updateCustomerImages(customerId) {
// Find the update button for this customer row and show a spinner
const btn = document.querySelector(`tr[data-customer-id="${customerId}"] .btn-update-customer`);
if (btn) {
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
}
try {
await api('POST', `/customers/${customerId}/update-images`);
showToast(t('monitoring.updateDone'));
setTimeout(() => checkImageUpdates(), 2000);
} catch (err) {
showMonitoringAlert('danger', err.message);
if (btn) {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-arrow-repeat"></i>';
}
}
}
async function updateAllCustomers() {
if (!confirm(t('monitoring.confirmUpdateAll'))) return;
const btn = document.getElementById('btn-update-all');
const body = document.getElementById('image-updates-body');
btn.disabled = true;
btn.innerHTML = `<span class="spinner-border spinner-border-sm me-1"></span>${t('monitoring.updating')}`;
const progressDiv = document.createElement('div');
progressDiv.className = 'alert alert-info mt-3';
progressDiv.innerHTML = `<span class="spinner-border spinner-border-sm me-2"></span>${t('monitoring.updateAllProgress')}`;
body.appendChild(progressDiv);
try {
const data = await api('POST', '/monitoring/customers/update-all');
progressDiv.remove();
if (data.results && data.results.length > 0) {
const allOk = data.updated === data.results.length;
const rows = data.results.map(r => `<tr>
<td>${esc(r.customer_name)}</td>
<td>${r.success
? '<span class="badge bg-success"><i class="bi bi-check-lg"></i> OK</span>'
: '<span class="badge bg-danger"><i class="bi bi-x-lg"></i> Error</span>'}</td>
<td class="small text-muted">${esc(r.error || '')}</td>
</tr>`).join('');
const resultHtml = `<div class="alert alert-${allOk ? 'success' : 'warning'} mt-3">
<strong>${esc(data.message)}</strong>
<table class="table table-sm mb-0 mt-2">
<thead><tr><th>${t('monitoring.thName')}</th><th>${t('monitoring.thStatus')}</th><th></th></tr></thead>
<tbody>${rows}</tbody>
</table>
</div>`;
body.insertAdjacentHTML('beforeend', resultHtml);
} else {
showToast(data.message);
}
setTimeout(() => checkImageUpdates(), 2000);
} catch (err) {
progressDiv.remove();
showMonitoringAlert('danger', err.message);
} finally {
btn.disabled = false;
btn.innerHTML = `<i class="bi bi-lightning-charge-fill me-1"></i>${t('monitoring.updateAll')}`;
}
}
async function pullAllImagesSettings() {
if (!confirm(t('monitoring.confirmPull'))) return;
const btn = document.getElementById('btn-pull-images-settings');
const statusEl = document.getElementById('pull-images-settings-status');
btn.disabled = true;
statusEl.innerHTML = `<span class="spinner-border spinner-border-sm me-1"></span>${t('monitoring.pulling')}`;
try {
await api('POST', '/monitoring/images/pull');
statusEl.innerHTML = `<i class="bi bi-check-circle text-success me-1"></i>${t('monitoring.pullStartedShort')}`;
setTimeout(() => { statusEl.innerHTML = ''; }, 8000);
} catch (err) {
statusEl.innerHTML = `<span class="text-danger"><i class="bi bi-exclamation-circle me-1"></i>${esc(err.message)}</span>`;
} finally {
btn.disabled = false;
}
}
function showMonitoringAlert(type, msg) {
const body = document.getElementById('image-updates-body');
const existing = body.querySelector('.alert');
if (existing) existing.remove();
const div = document.createElement('div');
div.className = `alert alert-${type} mt-2`;
div.textContent = msg;
body.prepend(div);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+2 -1
View File
@@ -27,7 +27,8 @@ function detectLanguage() {
async function loadLanguage(lang) { async function loadLanguage(lang) {
if (translations[lang]) return; if (translations[lang]) return;
try { 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}`); if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
translations[lang] = await resp.json(); translations[lang] = await resp.json();
} catch (err) { } catch (err) {
+60 -8
View File
@@ -89,23 +89,28 @@
"thHealth": "Zustand", "thHealth": "Zustand",
"thImage": "Image", "thImage": "Image",
"lastCheck": "Letzte Prüfung: {time}", "lastCheck": "Letzte Prüfung: {time}",
"openDashboard": "Dashboard öffnen" "openDashboard": "Dashboard öffnen",
"updateImages": "Images aktualisieren",
"updateInProgress": "Container werden aktualisiert — bitte warten…"
}, },
"settings": { "settings": {
"title": "Systemeinstellungen", "title": "Systemeinstellungen",
"tabSystem": "Systemkonfiguration", "tabSystem": "NetBird MSP System",
"tabNpm": "NPM Integration", "tabNpm": "NPM Proxy",
"tabImages": "Docker Images", "tabImages": "NetBird Docker Images",
"tabBranding": "Branding", "tabBranding": "Branding",
"tabUsers": "Benutzer", "tabUsers": "Benutzer",
"tabAzure": "Azure AD", "tabAzure": "Azure AD",
"tabDns": "Windows DNS", "tabDns": "Windows DNS",
"tabLdap": "LDAP / AD", "tabLdap": "LDAP / AD",
"tabUpdate": "Updates", "tabUpdate": "NetBird MSP Updates",
"tabSecurity": "Sicherheit", "tabSecurity": "Sicherheit",
"groupUsers": "Benutzerverwaltung",
"groupSystem": "Systemkonfiguration",
"groupExternal": "Umsysteme",
"baseDomain": "Basis-Domain", "baseDomain": "Basis-Domain",
"baseDomainPlaceholder": "ihredomain.com", "baseDomainPlaceholder": "ihredomain.com",
"baseDomainHint": "Kunden erhalten Subdomains: kunde.ihredomain.com", "baseDomainHint": "Kunden erhalten Subdomains: kundenname.ihredomain.com",
"adminEmail": "Admin E-Mail", "adminEmail": "Admin E-Mail",
"adminEmailPlaceholder": "[email protected]", "adminEmailPlaceholder": "[email protected]",
"dataDir": "Datenverzeichnis", "dataDir": "Datenverzeichnis",
@@ -115,7 +120,7 @@
"relayBasePort": "Relay-Basisport", "relayBasePort": "Relay-Basisport",
"relayBasePortHint": "Erster UDP-Port für Relay. Bereich: Basis bis Basis+99", "relayBasePortHint": "Erster UDP-Port für Relay. Bereich: Basis bis Basis+99",
"dashboardBasePort": "Dashboard-Basisport", "dashboardBasePort": "Dashboard-Basisport",
"dashboardBasePortHint": "Basisport für Kunden-Dashboards. Kunde N erhält Basis+N", "dashboardBasePortHint": "Basisport für Kunden-Dashboards. Der erste Kunde erhält Basis+1",
"saveSystemSettings": "Systemeinstellungen speichern", "saveSystemSettings": "Systemeinstellungen speichern",
"npmDescription": "NPM verwendet JWT-Authentifizierung. Geben Sie Ihre NPM-Zugangsdaten ein. Das System meldet sich automatisch an.", "npmDescription": "NPM verwendet JWT-Authentifizierung. Geben Sie Ihre NPM-Zugangsdaten ein. Das System meldet sich automatisch an.",
"npmApiUrl": "NPM API URL", "npmApiUrl": "NPM API URL",
@@ -149,6 +154,9 @@
"dashboardImage": "Dashboard Image", "dashboardImage": "Dashboard Image",
"dashboardImagePlaceholder": "netbirdio/dashboard:latest", "dashboardImagePlaceholder": "netbirdio/dashboard:latest",
"saveImageSettings": "Image-Einstellungen speichern", "saveImageSettings": "Image-Einstellungen speichern",
"pullImagesTitle": "Neueste Images von Docker Hub laden",
"pullImagesHint": "Lädt die neuesten Versionen aller konfigurierten NetBird Images. Danach können Kunden-Container über das Monitoring aktualisiert werden.",
"pullImages": "Von Docker Hub laden",
"brandingTitle": "Branding-Einstellungen", "brandingTitle": "Branding-Einstellungen",
"companyName": "Firmen- / Anwendungsname", "companyName": "Firmen- / Anwendungsname",
"companyNamePlaceholder": "NetBird MSP Appliance", "companyNamePlaceholder": "NetBird MSP Appliance",
@@ -167,6 +175,8 @@
"saveBranding": "Branding speichern", "saveBranding": "Branding speichern",
"userManagement": "Benutzerverwaltung", "userManagement": "Benutzerverwaltung",
"newUser": "Neuer Benutzer", "newUser": "Neuer Benutzer",
"makeAdmin": "Zum Admin befördern",
"makeViewer": "Zum Viewer degradieren",
"thId": "ID", "thId": "ID",
"thUsername": "Benutzername", "thUsername": "Benutzername",
"thEmail": "E-Mail", "thEmail": "E-Mail",
@@ -220,6 +230,14 @@
"triggerUpdate": "Update starten", "triggerUpdate": "Update starten",
"updateWarning": "Die App ist während des Rebuilds ca. 60 Sekunden nicht verfügbar.", "updateWarning": "Die App ist während des Rebuilds ca. 60 Sekunden nicht verfügbar.",
"confirmUpdate": "Update jetzt starten? Die Datenbank wird zuerst gesichert. Die App startet neu (~60 Sekunden Ausfallzeit).", "confirmUpdate": "Update jetzt starten? Die Datenbank wird zuerst gesichert. Die App startet neu (~60 Sekunden Ausfallzeit).",
"updateStepStarting": "Update wird gestartet …",
"updateStepBackup": "Datenbank wird gesichert …",
"updateStepPull": "Code wird geholt …",
"updateStepBuild": "Docker-Image wird gebaut (kann mehrere Minuten dauern) …",
"updateStepRestart": "Container wird neu gestartet …",
"updateStepReconnecting": "Container startet neu — warte auf Verbindung …",
"updateStepDone": "Update abgeschlossen.",
"updateStepTimeout": "Update läuft länger als erwartet. Bitte Server-Logs prüfen oder die Seite in ein paar Minuten neu laden.",
"gitTitle": "Git-Repository Einstellungen", "gitTitle": "Git-Repository Einstellungen",
"gitRepoUrl": "Repository URL", "gitRepoUrl": "Repository URL",
"gitRepoUrlHint": "Wird für Versionsprüfungen und One-Click-Updates via Gitea API verwendet.", "gitRepoUrlHint": "Wird für Versionsprüfungen und One-Click-Updates via Gitea API verwendet.",
@@ -344,6 +362,17 @@
"saveAndDeploy": "Speichern & Bereitstellen", "saveAndDeploy": "Speichern & Bereitstellen",
"saveChanges": "Änderungen speichern" "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": { "deleteModal": {
"title": "Löschen bestätigen", "title": "Löschen bestätigen",
"confirmText": "Möchten Sie den Kunden wirklich löschen:", "confirmText": "Möchten Sie den Kunden wirklich löschen:",
@@ -368,6 +397,29 @@
"thDashboard": "Dashboard", "thDashboard": "Dashboard",
"thRelayPort": "Relay-Port", "thRelayPort": "Relay-Port",
"thContainers": "Container", "thContainers": "Container",
"noCustomers": "Keine Kunden." "noCustomers": "Keine Kunden.",
"imageUpdates": "NetBird Container Updates",
"checkUpdates": "Auf Updates prüfen",
"pullImages": "Neueste Images laden",
"updateAll": "Alle aktualisieren",
"clickCheckUpdates": "Klicken Sie auf \"Auf Updates prüfen\" um lokale Images mit Docker Hub zu vergleichen.",
"updateAvailable": "Update verfügbar",
"upToDate": "Aktuell",
"needsUpdate": "Update erforderlich",
"updateCustomer": "Diesen Kunden aktualisieren",
"imageStatusTitle": "Image-Status (vs. Docker Hub)",
"customerImageTitle": "Kunden-Container Status",
"thImage": "Image",
"thLocalDigest": "Lokaler Digest",
"thHubDigest": "Hub Digest",
"confirmPull": "Neueste NetBird Images von Docker Hub laden? Dies kann einige Minuten dauern.",
"pullStarted": "Image-Download im Hintergrund gestartet. Prüfung in 5 Sekunden…",
"confirmUpdateAll": "Container aller Kunden mit veralteten Images neu erstellen? Laufende Dienste werden kurz neu gestartet.",
"updateAllStarted": "Aktualisierung im Hintergrund gestartet.",
"updateDone": "Kunden-Container aktualisiert.",
"updating": "Wird aktualisiert…",
"updateAllProgress": "Kunden-Container werden nacheinander aktualisiert — bitte warten…",
"pulling": "Wird geladen…",
"pullStartedShort": "Download im Hintergrund gestartet."
} }
} }
+58 -6
View File
@@ -89,7 +89,9 @@
"thHealth": "Health", "thHealth": "Health",
"thImage": "Image", "thImage": "Image",
"lastCheck": "Last check: {time}", "lastCheck": "Last check: {time}",
"openDashboard": "Open Dashboard" "openDashboard": "Open Dashboard",
"updateImages": "Update Images",
"updateInProgress": "Updating containers — please wait…"
}, },
"customerModal": { "customerModal": {
"newCustomer": "New Customer", "newCustomer": "New Customer",
@@ -105,6 +107,17 @@
"saveAndDeploy": "Save & Deploy", "saveAndDeploy": "Save & Deploy",
"saveChanges": "Save Changes" "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": { "deleteModal": {
"title": "Confirm Deletion", "title": "Confirm Deletion",
"confirmText": "Are you sure you want to delete customer", "confirmText": "Are you sure you want to delete customer",
@@ -114,16 +127,19 @@
}, },
"settings": { "settings": {
"title": "System Settings", "title": "System Settings",
"tabSystem": "System Configuration", "tabSystem": "NetBird MSP System",
"tabNpm": "NPM Integration", "tabNpm": "NPM Proxy",
"tabImages": "Docker Images", "tabImages": "NetBird Docker Images",
"tabBranding": "Branding", "tabBranding": "Branding",
"tabUsers": "Users", "tabUsers": "Users",
"tabAzure": "Azure AD", "tabAzure": "Azure AD",
"tabDns": "Windows DNS", "tabDns": "Windows DNS",
"tabLdap": "LDAP / AD", "tabLdap": "LDAP / AD",
"tabUpdate": "Updates", "tabUpdate": "NetBird MSP Updates",
"tabSecurity": "Security", "tabSecurity": "Security",
"groupUsers": "User Management",
"groupSystem": "System Configuration",
"groupExternal": "External Systems",
"baseDomain": "Base Domain", "baseDomain": "Base Domain",
"baseDomainPlaceholder": "yourdomain.com", "baseDomainPlaceholder": "yourdomain.com",
"baseDomainHint": "Customers get subdomains: customer.yourdomain.com", "baseDomainHint": "Customers get subdomains: customer.yourdomain.com",
@@ -170,6 +186,9 @@
"dashboardImage": "Dashboard Image", "dashboardImage": "Dashboard Image",
"dashboardImagePlaceholder": "netbirdio/dashboard:latest", "dashboardImagePlaceholder": "netbirdio/dashboard:latest",
"saveImageSettings": "Save Image Settings", "saveImageSettings": "Save Image Settings",
"pullImagesTitle": "Pull Latest Images from Docker Hub",
"pullImagesHint": "Downloads the latest versions of all configured NetBird images. After pulling, use Monitoring to update customer containers.",
"pullImages": "Pull from Docker Hub",
"brandingTitle": "Branding Settings", "brandingTitle": "Branding Settings",
"companyName": "Company / Application Name", "companyName": "Company / Application Name",
"companyNamePlaceholder": "NetBird MSP Appliance", "companyNamePlaceholder": "NetBird MSP Appliance",
@@ -188,6 +207,8 @@
"saveBranding": "Save Branding", "saveBranding": "Save Branding",
"userManagement": "User Management", "userManagement": "User Management",
"newUser": "New User", "newUser": "New User",
"makeAdmin": "Promote to admin",
"makeViewer": "Demote to viewer",
"thId": "ID", "thId": "ID",
"thUsername": "Username", "thUsername": "Username",
"thEmail": "Email", "thEmail": "Email",
@@ -241,6 +262,14 @@
"triggerUpdate": "Start Update", "triggerUpdate": "Start Update",
"updateWarning": "The app will be unavailable for ~60 seconds during rebuild.", "updateWarning": "The app will be unavailable for ~60 seconds during rebuild.",
"confirmUpdate": "Start the update now? The database will be backed up first. The app will restart (~60 seconds downtime).", "confirmUpdate": "Start the update now? The database will be backed up first. The app will restart (~60 seconds downtime).",
"updateStepStarting": "Starting update …",
"updateStepBackup": "Backing up database …",
"updateStepPull": "Fetching code …",
"updateStepBuild": "Building Docker image (can take several minutes) …",
"updateStepRestart": "Restarting container …",
"updateStepReconnecting": "Container is restarting — waiting for connection …",
"updateStepDone": "Update complete.",
"updateStepTimeout": "Update is taking longer than expected. Check the server logs or reload this page in a few minutes.",
"gitTitle": "Git Repository Settings", "gitTitle": "Git Repository Settings",
"gitRepoUrl": "Repository URL", "gitRepoUrl": "Repository URL",
"gitRepoUrlHint": "Used for version checks and one-click updates via Gitea API.", "gitRepoUrlHint": "Used for version checks and one-click updates via Gitea API.",
@@ -275,7 +304,30 @@
"thDashboard": "Dashboard", "thDashboard": "Dashboard",
"thRelayPort": "Relay Port", "thRelayPort": "Relay Port",
"thContainers": "Containers", "thContainers": "Containers",
"noCustomers": "No customers." "noCustomers": "No customers.",
"imageUpdates": "NetBird Container Updates",
"checkUpdates": "Check for Updates",
"pullImages": "Pull Latest Images",
"updateAll": "Update All",
"clickCheckUpdates": "Click \"Check for Updates\" to compare local images with Docker Hub.",
"updateAvailable": "Update available",
"upToDate": "Up to date",
"needsUpdate": "Needs update",
"updateCustomer": "Update this customer",
"imageStatusTitle": "Image Status (vs. Docker Hub)",
"customerImageTitle": "Customer Container Status",
"thImage": "Image",
"thLocalDigest": "Local Digest",
"thHubDigest": "Hub Digest",
"confirmPull": "Pull the latest NetBird images from Docker Hub? This may take a few minutes.",
"pullStarted": "Image pull started in background. Re-checking in 5 seconds…",
"confirmUpdateAll": "Recreate containers for all customers that have outdated images? Running services will briefly restart.",
"updateAllStarted": "Update started in background.",
"updateDone": "Customer containers updated.",
"updating": "Updating…",
"updateAllProgress": "Updating customer containers one by one — please wait…",
"pulling": "Pulling…",
"pullStartedShort": "Pull started in background."
}, },
"userModal": { "userModal": {
"title": "New User", "title": "New User",
+10 -5
View File
@@ -5,15 +5,15 @@
:80 { :80 {
# Embedded IdP OAuth2/OIDC endpoints # Embedded IdP OAuth2/OIDC endpoints
handle /oauth2/* { handle /oauth2/* {
reverse_proxy netbird-kunde{{ customer_id }}-management:80 reverse_proxy netbird-{{ subdomain }}-management:80
} }
# NetBird Management API + gRPC # NetBird Management API + gRPC
handle /api/* { handle /api/* {
reverse_proxy netbird-kunde{{ customer_id }}-management:80 reverse_proxy netbird-{{ subdomain }}-management:80
} }
handle /management.ManagementService/* { handle /management.ManagementService/* {
reverse_proxy netbird-kunde{{ customer_id }}-management:80 { reverse_proxy netbird-{{ subdomain }}-management:80 {
transport http { transport http {
versions h2c versions h2c
} }
@@ -22,15 +22,20 @@
# NetBird Signal gRPC # NetBird Signal gRPC
handle /signalexchange.SignalExchange/* { handle /signalexchange.SignalExchange/* {
reverse_proxy netbird-kunde{{ customer_id }}-signal:80 { reverse_proxy netbird-{{ subdomain }}-signal:80 {
transport http { transport http {
versions h2c versions h2c
} }
} }
} }
# NetBird Relay WebSocket (rels://)
handle /relay* {
reverse_proxy netbird-{{ subdomain }}-relay:80
}
# Default: NetBird Dashboard # Default: NetBird Dashboard
handle { handle {
reverse_proxy netbird-kunde{{ customer_id }}-dashboard:80 reverse_proxy netbird-{{ subdomain }}-dashboard:80
} }
} }
+5 -5
View File
@@ -6,7 +6,7 @@ services:
# --- Caddy Reverse Proxy (entry point) --- # --- Caddy Reverse Proxy (entry point) ---
netbird-caddy: netbird-caddy:
image: caddy:2-alpine image: caddy:2-alpine
container_name: netbird-kunde{{ customer_id }}-caddy container_name: netbird-{{ subdomain }}-caddy
restart: unless-stopped restart: unless-stopped
networks: networks:
- {{ docker_network }} - {{ docker_network }}
@@ -18,7 +18,7 @@ services:
# --- NetBird Management (with embedded IdP) --- # --- NetBird Management (with embedded IdP) ---
netbird-management: netbird-management:
image: {{ netbird_management_image }} image: {{ netbird_management_image }}
container_name: netbird-kunde{{ customer_id }}-management container_name: netbird-{{ subdomain }}-management
restart: unless-stopped restart: unless-stopped
networks: networks:
- {{ docker_network }} - {{ docker_network }}
@@ -39,7 +39,7 @@ services:
# --- NetBird Signal --- # --- NetBird Signal ---
netbird-signal: netbird-signal:
image: {{ netbird_signal_image }} image: {{ netbird_signal_image }}
container_name: netbird-kunde{{ customer_id }}-signal container_name: netbird-{{ subdomain }}-signal
restart: unless-stopped restart: unless-stopped
networks: networks:
- {{ docker_network }} - {{ docker_network }}
@@ -49,7 +49,7 @@ services:
# --- NetBird Relay --- # --- NetBird Relay ---
netbird-relay: netbird-relay:
image: {{ netbird_relay_image }} image: {{ netbird_relay_image }}
container_name: netbird-kunde{{ customer_id }}-relay container_name: netbird-{{ subdomain }}-relay
restart: unless-stopped restart: unless-stopped
networks: networks:
- {{ docker_network }} - {{ docker_network }}
@@ -61,7 +61,7 @@ services:
# --- NetBird Dashboard --- # --- NetBird Dashboard ---
netbird-dashboard: netbird-dashboard:
image: {{ netbird_dashboard_image }} image: {{ netbird_dashboard_image }}
container_name: netbird-kunde{{ customer_id }}-dashboard container_name: netbird-{{ subdomain }}-dashboard
restart: unless-stopped restart: unless-stopped
networks: networks:
- {{ docker_network }} - {{ docker_network }}