Compare commits

..

13 Commits

Author SHA1 Message Date
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
c3ab7a5a67 fix(api): correct extraction of commit date from gitea branches api 2026-02-22 22:57:07 +01:00
b955e4f464 feat(ui): settings menu restructure, git branch dropdown, and repo cleanup 2026-02-22 21:29:30 +01:00
831564762b feat(ui): clean vertical settings menu and improved version formatting 2026-02-22 16:07:08 +01:00
3f177a6993 fix(updater): add --rm to helper container to remove it after use 2026-02-22 15:58:18 +01:00
ea4afbd6ca alpha-1.8: final test with privileged 2026-02-22 15:49:44 +01:00
95ec6765c1 fix(updater): add --privileged to helper container to bypass user namespace restrictions 2026-02-22 15:46:09 +01:00
c40b7d3bc6 alpha-1.7: final test 2026-02-22 15:39:18 +01:00
525b056b91 fix(updater): add :z flag to docker volumes for SELinux 2026-02-22 15:33:42 +01:00
6bc11d4c5e alpha-1.6: test final update 2026-02-22 15:25:50 +01:00
e0aa51bac3 fix(updater): remove log redirection from helper to avoid nonexistent dir error 2026-02-22 15:22:43 +01:00
94d0b989d0 alpha-1.5: trigger update 2026-02-22 15:16:20 +01:00
16 changed files with 1060 additions and 650 deletions

15
.gitignore vendored
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

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
} }
@@ -334,6 +340,19 @@ 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),

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,
@@ -387,7 +387,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 +423,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:
@@ -488,7 +488,7 @@ async def stop_customer(db: Session, customer_id: int) -> dict[str, Any]:
if not deployment or not config: if not deployment or not config:
return {"success": False, "error": "Deployment or config not found."} return {"success": False, "error": "Deployment 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"
@@ -509,7 +509,7 @@ async def start_customer(db: Session, customer_id: int) -> dict[str, Any]:
if not deployment or not config: if not deployment or not config:
return {"success": False, "error": "Deployment or config not found."} return {"success": False, "error": "Deployment 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"
@@ -530,7 +530,7 @@ async def restart_customer(db: Session, customer_id: int) -> dict[str, Any]:
if not deployment or not config: if not deployment or not config:
return {"success": False, "error": "Deployment or config not found."} return {"success": False, "error": "Deployment 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"

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
@@ -103,15 +104,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 +135,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.
@@ -299,10 +340,10 @@ def trigger_update(config: Any, db_path: str) -> dict:
own_image = "netbirdmsp-appliance-netbird-msp-appliance:latest" own_image = "netbirdmsp-appliance-netbird-msp-appliance:latest"
helper_cmd = [ helper_cmd = [
"docker", "run", "-d", "docker", "run", "--rm", "-d", "--privileged",
"--name", "msp-updater", "--name", "msp-updater",
"-v", "/var/run/docker.sock:/var/run/docker.sock", "-v", "/var/run/docker.sock:/var/run/docker.sock:z",
"-v", f"{host_source_dir}:{host_source_dir}:ro", "-v", f"{host_source_dir}:{host_source_dir}:ro,z",
*env_flags, *env_flags,
own_image, own_image,
"sh", "-c", "sh", "-c",
@@ -310,8 +351,7 @@ def trigger_update(config: Any, db_path: str) -> dict:
"sleep 3 && " "sleep 3 && "
"docker compose -p netbirdmsp-appliance " "docker compose -p netbirdmsp-appliance "
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 " "up --force-recreate --no-deps -d netbird-msp-appliance"
f">> {host_source_dir}/app/backups/updater.log 2>&1"
), ),
] ]
try: try:

View File

View File

@@ -1 +0,0 @@
Error response from daemon: No such container: msp-updater

View File

@@ -1,50 +0,0 @@
INFO: 127.0.0.1:35822 - "GET /api/health HTTP/1.1" 200 OK
INFO: 127.0.0.1:33932 - "GET /api/health HTTP/1.1" 200 OK
INFO: 127.0.0.1:50284 - "GET /api/health HTTP/1.1" 200 OK
INFO: 172.18.0.1:49612 - "GET / HTTP/1.1" 200 OK
INFO: 172.18.0.1:49612 - "GET /css/styles.css HTTP/1.1" 304 Not Modified
INFO: 172.18.0.1:49610 - "GET /js/i18n.js HTTP/1.1" 304 Not Modified
INFO: 172.18.0.1:49632 - "GET /js/app.js HTTP/1.1" 200 OK
INFO: 172.18.0.1:49632 - "GET /lang/en.json HTTP/1.1" 200 OK
INFO: 172.18.0.1:49632 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 172.18.0.1:49610 - "GET /lang/de.json HTTP/1.1" 200 OK
INFO: 172.18.0.1:49610 - "GET /api/settings/branding HTTP/1.1" 200 OK
INFO: 172.18.0.1:49610 - "GET /api/auth/azure/config HTTP/1.1" 200 OK
INFO: 127.0.0.1:59642 - "GET /api/health HTTP/1.1" 200 OK
2026-02-22 13:56:39,498 [WARNING] passlib.handlers.bcrypt: (trapped) error reading bcrypt version
Traceback (most recent call last):
File "/usr/local/lib/python3.11/site-packages/passlib/handlers/bcrypt.py", line 620, in _load_backend_mixin
version = _bcrypt.__about__.__version__
^^^^^^^^^^^^^^^^^
AttributeError: module 'bcrypt' has no attribute '__about__'
2026-02-22 13:56:39,929 [INFO] app.routers.auth: User admin logged in (provider: local).
INFO: 172.18.0.1:36450 - "POST /api/auth/login HTTP/1.1" 200 OK
INFO: 172.18.0.1:36462 - "GET /api/customers?page=1&per_page=25 HTTP/1.1" 200 OK
INFO: 172.18.0.1:36450 - "GET /api/monitoring/status HTTP/1.1" 200 OK
INFO: 127.0.0.1:54154 - "GET /api/health HTTP/1.1" 200 OK
INFO: 172.18.0.1:54490 - "GET /api/settings/system HTTP/1.1" 200 OK
INFO: 172.18.0.1:54490 - "GET /api/auth/mfa/status HTTP/1.1" 200 OK
2026-02-22 13:57:10,815 [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:57:10,822 [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:57512 - "GET /api/settings/version HTTP/1.1" 200 OK
INFO: 127.0.0.1:52478 - "GET /api/health HTTP/1.1" 200 OK
INFO: 127.0.0.1:47310 - "GET /api/health HTTP/1.1" 200 OK
INFO: 127.0.0.1:47530 - "GET /api/health HTTP/1.1" 200 OK
INFO: 127.0.0.1:41918 - "GET /api/health HTTP/1.1" 200 OK
INFO: 127.0.0.1:45108 - "GET /api/health HTTP/1.1" 200 OK
2026-02-22 13:59:53,200 [INFO] app.services.update_service: Database backed up to /app/backups/netbird_msp_20260222_135953.db
2026-02-22 13:59:54,630 [INFO] app.services.update_service: git pull succeeded: Already up to date.
2026-02-22 13:59:54,740 [INFO] app.services.update_service: Rebuilding with GIT_TAG=alpha-1.4 GIT_COMMIT=ef691a4 GIT_BRANCH=unstable
2026-02-22 13:59:54,741 [INFO] app.services.update_service: Phase A: building new image …
2026-02-22 14:03:51,162 [INFO] app.services.update_service: Phase A complete — image built successfully.
2026-02-22 14:03:51,242 [INFO] app.services.update_service: Host source directory: /home/sascha/NetBirdMSP-Appliance
2026-02-22 14:03:52,032 [INFO] app.services.update_service: Phase B: updater container started — this container will restart in ~5s.
2026-02-22 14:03:52,033 [INFO] app.routers.settings: Update triggered by admin.
INFO: 172.18.0.1:53362 - "POST /api/settings/update HTTP/1.1" 200 OK
INFO: 172.18.0.1:35312 - "POST /api/settings/update HTTP/1.1" 401 Unauthorized
INFO: 127.0.0.1:35534 - "GET /api/health HTTP/1.1" 200 OK
2026-02-22 14:04:22,366 [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 14:04:22,376 [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:53602 - "GET /api/settings/version HTTP/1.1" 200 OK
INFO: 127.0.0.1:51374 - "GET /api/health HTTP/1.1" 200 OK
INFO: 127.0.0.1:48640 - "GET /api/health HTTP/1.1" 200 OK

View File

@@ -1,2 +0,0 @@
8000/tcp -> 0.0.0.0:8000
8000/tcp -> [::]:8000

File diff suppressed because it is too large Load Diff

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 };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -127,12 +127,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 +373,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;
@@ -465,9 +472,9 @@ function renderCustomersTable(data) {
<div class="btn-group btn-group-sm"> <div class="btn-group btn-group-sm">
<button class="btn btn-outline-primary" title="${t('common.view')}" onclick="viewCustomer(${c.id})"><i class="bi bi-eye"></i></button> <button class="btn btn-outline-primary" title="${t('common.view')}" onclick="viewCustomer(${c.id})"><i class="bi bi-eye"></i></button>
${c.deployment && c.deployment.deployment_status === 'running' ${c.deployment && c.deployment.deployment_status === 'running'
? `<button class="btn btn-outline-warning" title="${t('common.stop')}" onclick="customerAction(${c.id},'stop')"><i class="bi bi-stop-circle"></i></button>` ? `<button class="btn btn-outline-warning" title="${t('common.stop')}" onclick="customerAction(${c.id},'stop')"><i class="bi bi-stop-circle"></i></button>`
: `<button class="btn btn-outline-success" title="${t('common.start')}" onclick="customerAction(${c.id},'start')"><i class="bi bi-play-circle"></i></button>` : `<button class="btn btn-outline-success" title="${t('common.start')}" onclick="customerAction(${c.id},'start')"><i class="bi bi-play-circle"></i></button>`
} }
<button class="btn btn-outline-info" title="${t('common.restart')}" onclick="customerAction(${c.id},'restart')"><i class="bi bi-arrow-repeat"></i></button> <button class="btn btn-outline-info" title="${t('common.restart')}" onclick="customerAction(${c.id},'restart')"><i class="bi bi-arrow-repeat"></i></button>
<button class="btn btn-outline-danger" title="${t('common.delete')}" onclick="showDeleteModal(${c.id},'${esc(c.name)}')"><i class="bi bi-trash"></i></button> <button class="btn btn-outline-danger" title="${t('common.delete')}" onclick="showDeleteModal(${c.id},'${esc(c.name)}')"><i class="bi bi-trash"></i></button>
</div> </div>
@@ -511,7 +518,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);
@@ -872,6 +879,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 +1193,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 +1265,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,17 +1281,17 @@ 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>';
@@ -1305,9 +1351,9 @@ async function loadUsers() {
<td> <td>
<div class="btn-group btn-group-sm"> <div class="btn-group btn-group-sm">
${u.is_active ${u.is_active
? `<button class="btn btn-outline-warning" title="${t('common.disable')}" onclick="toggleUserActive(${u.id}, false)"><i class="bi bi-pause-circle"></i></button>` ? `<button class="btn btn-outline-warning" title="${t('common.disable')}" onclick="toggleUserActive(${u.id}, false)"><i class="bi bi-pause-circle"></i></button>`
: `<button class="btn btn-outline-success" title="${t('common.enable')}" onclick="toggleUserActive(${u.id}, true)"><i class="bi bi-play-circle"></i></button>` : `<button class="btn btn-outline-success" title="${t('common.enable')}" onclick="toggleUserActive(${u.id}, true)"><i class="bi bi-play-circle"></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.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>` : ''}
<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>

View File

@@ -93,19 +93,22 @@
}, },
"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": "admin@ihredomain.com", "adminEmailPlaceholder": "admin@ihredomain.com",
"dataDir": "Datenverzeichnis", "dataDir": "Datenverzeichnis",
@@ -115,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

@@ -114,16 +114,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",

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,7 +22,7 @@
# 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
} }
@@ -31,6 +31,6 @@
# 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 }}