14 Commits

Author SHA1 Message Date
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
2780b065d2 fix(updater): add force-recreate and logging to helper container 2026-02-22 15:14:23 +01:00
ef691a4308 alpha-1.4: test helper-container update 2026-02-22 14:51:43 +01:00
0fe68cc6df fix: use helper container for self-update (survives container restart) 2026-02-22 14:50:00 +01:00
314393d61a alpha-1.3: update test 2026-02-22 14:42:53 +01:00
7 changed files with 1102 additions and 570 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

@@ -334,6 +334,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

@@ -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
@@ -130,6 +131,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.
@@ -232,25 +269,110 @@ 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.
# Use the correct project name so compose finds/replaces the right container. # The swap will kill this process (we ARE the container), so we must
# Only rebuild the app service — docker-socket-proxy must not be recreated. # ensure the compose-up runs detached on the Docker host via a wrapper.
compose_cmd = [ log_path = Path(BACKUP_DIR) / "update_rebuild.log"
# Phase A — build the new image (does NOT stop anything)
build_cmd = [
"docker", "compose", "docker", "compose",
"-p", "netbirdmsp-appliance", "-p", "netbirdmsp-appliance",
"-f", f"{SOURCE_DIR}/docker-compose.yml", "-f", f"{SOURCE_DIR}/docker-compose.yml",
"up", "--build", "--no-deps", "-d", "build", "--no-cache",
"netbird-msp-appliance", "netbird-msp-appliance",
] ]
log_path = Path(BACKUP_DIR) / "update_rebuild.log" logger.info("Phase A: building new image …")
log_file = open(log_path, "w") try:
subprocess.Popen( build_result = subprocess.run(
compose_cmd, build_cmd,
stdout=log_file, capture_output=True, text=True,
stderr=log_file, 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])
return {
"ok": False,
"message": f"Image build failed: {build_result.stderr[:300]}",
"backup": backup_path,
}
except subprocess.TimeoutExpired:
return {"ok": False, "message": "Image build timed out after 600s.", "backup": backup_path}
logger.info("Phase A complete — image built successfully.")
# 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)
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}"])
# Use the same image we're already running (it has docker CLI + compose plugin)
own_image = "netbirdmsp-appliance-netbird-msp-appliance:latest"
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,
own_image,
"sh", "-c",
(
"sleep 3 && "
"docker compose -p netbirdmsp-appliance "
f"-f {host_source_dir}/docker-compose.yml "
"up --force-recreate --no-deps -d netbird-msp-appliance"
),
]
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())
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)
return {"ok": False, "message": f"Updater launch failed: {exc}", "backup": backup_path}
return { return {
"ok": True, "ok": True,

File diff suppressed because it is too large Load Diff

View File

@@ -366,7 +366,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 +465,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 +511,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 +872,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 +1186,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 +1258,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 +1274,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 +1344,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,16 +93,19 @@
}, },
"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: kunde.ihredomain.com",

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",