Compare commits

..

10 Commits

Author SHA1 Message Date
Sascha Lustenberger | techlan gmbh
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 <noreply@anthropic.com>
2026-02-24 20:08:18 +01:00
Sascha Lustenberger | techlan gmbh
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 <noreply@anthropic.com>
2026-02-24 19:42:12 +01:00
Sascha Lustenberger | techlan gmbh
0bc7c0ba9f feat(ui): add SVG favicon for NetBird MSP Appliance
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 16:54:21 +01:00
Sascha Lustenberger | techlan gmbh
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 <noreply@anthropic.com>
2026-02-24 11:12:17 +01:00
Sascha Lustenberger | techlan gmbh
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 <noreply@anthropic.com>
2026-02-24 10:58:02 +01:00
Sascha Lustenberger | techlan gmbh
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 <noreply@anthropic.com>
2026-02-24 10:51:25 +01:00
Sascha Lustenberger | techlan gmbh
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 <noreply@anthropic.com>
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
13 changed files with 237 additions and 92 deletions

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

View File

@@ -95,8 +95,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 +140,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!**

View File

@@ -237,6 +237,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 +248,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
} }

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,7 +264,7 @@ 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 npm_proxy_id = None
npm_stream_id = None npm_stream_id = None
if not local_mode: if not local_mode:
@@ -294,27 +294,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.
@@ -387,7 +366,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 +402,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 +422,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,17 +452,16 @@ 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() customer.status = "inactive"
if customer:
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.")
else: else:
@@ -505,17 +472,16 @@ 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() customer.status = "active"
if customer:
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.")
else: else:
@@ -526,17 +492,16 @@ 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() customer.status = "active"
if customer:
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.")
else: else:

View File

@@ -259,7 +259,16 @@ 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"
"}\n"
"location ^~ /signalexchange.SignalExchange/ {\n"
f" grpc_pass grpc://{forward_host}:{forward_port};\n"
" grpc_set_header Host $host;\n"
"}\n"
),
"meta": { "meta": {
"letsencrypt_agree": True, "letsencrypt_agree": True,
"letsencrypt_email": admin_email, "letsencrypt_email": admin_email,

View File

@@ -15,10 +15,45 @@ 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__)
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."""
try: try:
@@ -112,7 +147,11 @@ async def check_for_updates(config: Any) -> dict:
# 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 = (
@@ -213,6 +252,16 @@ 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)
# 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,
@@ -236,6 +285,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:
@@ -274,13 +332,20 @@ def trigger_update(config: Any, db_path: str) -> dict:
# ensure the compose-up runs detached on the Docker host via a wrapper. # ensure the compose-up runs detached on the Docker host via a wrapper.
log_path = Path(BACKUP_DIR) / "update_rebuild.log" log_path = Path(BACKUP_DIR) / "update_rebuild.log"
# Detect compose project name at runtime — avoids hard-coding a name that
# may differ from the directory used at deploy time.
project_name = _get_compose_project_name()
# 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) # Phase A — build the new image (does NOT stop anything)
build_cmd = [ build_cmd = [
"docker", "compose", "docker", "compose",
"-p", "netbirdmsp-appliance", "-p", project_name,
"-f", f"{SOURCE_DIR}/docker-compose.yml", "-f", f"{SOURCE_DIR}/docker-compose.yml",
"build", "--no-cache", "build", "--no-cache",
"netbird-msp-appliance", SERVICE_NAME,
] ]
logger.info("Phase A: building new image …") logger.info("Phase A: building new image …")
try: try:
@@ -332,22 +397,19 @@ def trigger_update(config: Any, db_path: str) -> dict:
val = build_env.get(key, "unknown") val = build_env.get(key, "unknown")
env_flags.extend(["-e", f"{key}={val}"]) env_flags.extend(["-e", f"{key}={val}"])
# Use the same image we're already running (it has docker CLI + compose plugin)
own_image = "netbirdmsp-appliance-netbird-msp-appliance:latest"
helper_cmd = [ helper_cmd = [
"docker", "run", "--rm", "-d", "--privileged", "docker", "run", "--rm", "-d", "--privileged",
"--name", "msp-updater", "--name", "msp-updater",
"-v", "/var/run/docker.sock:/var/run/docker.sock:z", "-v", "/var/run/docker.sock:/var/run/docker.sock:z",
"-v", f"{host_source_dir}:{host_source_dir}:ro,z", "-v", f"{host_source_dir}:{host_source_dir}:ro,z",
*env_flags, *env_flags,
own_image, service_image, # freshly built image — has docker CLI + compose plugin
"sh", "-c", "sh", "-c",
( (
"sleep 3 && " "sleep 3 && "
"docker compose -p netbirdmsp-appliance " f"docker compose -p {project_name} "
f"-f {host_source_dir}/docker-compose.yml " f"-f {host_source_dir}/docker-compose.yml "
"up --force-recreate --no-deps -d netbird-msp-appliance" f"up --force-recreate --no-deps -d {SERVICE_NAME}"
), ),
] ]
try: try:

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
static/favicon.svg Normal file
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

View File

@@ -5,6 +5,14 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NetBird MSP Appliance</title> <title>NetBird MSP Appliance</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<script>
// Apply dark mode before page renders to prevent flash
(function () {
const saved = localStorage.getItem('darkMode');
if (saved === 'dark') document.documentElement.setAttribute('data-bs-theme', 'dark');
})();
</script>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.min.css" rel="stylesheet">
<link href="/static/css/styles.css" rel="stylesheet"> <link href="/static/css/styles.css" rel="stylesheet">
@@ -21,7 +29,7 @@
<h3 class="mt-2" id="login-title">NetBird MSP Appliance</h3> <h3 class="mt-2" id="login-title">NetBird MSP Appliance</h3>
<p class="text-muted" id="login-subtitle" data-i18n="login.subtitle">Multi-Tenant Management <p class="text-muted" id="login-subtitle" data-i18n="login.subtitle">Multi-Tenant Management
Platform</p> Platform</p>
<p class="text-muted small mb-0" style="opacity:0.6;"><i class="bi bi-tag me-1"></i>alpha-1.1 <p class="text-muted small mb-0" style="opacity:0.6;"><i class="bi bi-tag me-1"></i>
</p> </p>
</div> </div>
<div id="login-error" class="alert alert-danger d-none"></div> <div id="login-error" class="alert alert-danger d-none"></div>
@@ -108,6 +116,10 @@
<span id="nav-brand-name">NetBird MSP</span> <span id="nav-brand-name">NetBird MSP</span>
</a> </a>
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<!-- Dark Mode Toggle -->
<button class="btn btn-outline-light btn-sm me-2" id="darkmode-toggle" onclick="toggleDarkMode()" title="Toggle dark mode">
<i id="darkmode-icon" class="bi bi-moon-fill"></i>
</button>
<!-- Language Switcher --> <!-- Language Switcher -->
<div class="dropdown me-2"> <div class="dropdown me-2">
<button class="btn btn-outline-light btn-sm dropdown-toggle" id="language-switcher-btn" <button class="btn btn-outline-light btn-sm dropdown-toggle" id="language-switcher-btn"

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 {

View File

@@ -108,7 +108,7 @@
"groupExternal": "Umsysteme", "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": "admin@ihredomain.com", "adminEmailPlaceholder": "admin@ihredomain.com",
"dataDir": "Datenverzeichnis", "dataDir": "Datenverzeichnis",
@@ -118,7 +118,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",

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
} }
} }

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 }}