3 Commits

15 changed files with 1004 additions and 610 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.
@@ -299,7 +336,7 @@ 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", "--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",

View File

@@ -1,9 +0,0 @@
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
b25f16030139 netbirdmsp-appliance-netbird-msp-appliance:latest "sh -c 'sleep 3 && d…" 2 minutes ago Exited (1) 2 minutes ago msp-updater
c7acab75017f f4446ac34896 "uvicorn app.main:ap…" 11 minutes ago Up 11 minutes (healthy) 0.0.0.0:8000->8000/tcp, [::]:8000->8000/tcp netbird-msp-appliance
878efa979680 caddy:2-alpine "caddy run --config …" 3 hours ago Up 2 hours 443/tcp, 2019/tcp, 443/udp, 0.0.0.0:9001->80/tcp, [::]:9001->80/tcp netbird-kunde1-caddy
564c613f112a netbirdio/signal:latest "/go/bin/netbird-sig…" 3 hours ago Up 2 hours netbird-kunde1-signal
a98852970815 netbirdio/dashboard:latest "/usr/bin/supervisor…" 3 hours ago Up 2 hours 80/tcp, 443/tcp netbird-kunde1-dashboard
11e100e21d81 netbirdio/relay:latest "/go/bin/netbird-rel…" 3 hours ago Up 2 hours 0.0.0.0:3478->3478/udp, [::]:3478->3478/udp netbird-kunde1-relay
aeae96bf691e netbirdio/management:latest "/go/bin/netbird-mgm…" 3 hours ago Up 2 hours netbird-kunde1-management
9cdda4d58e36 tecnativa/docker-socket-proxy:latest "docker-entrypoint.s…" 3 days ago Up 2 hours 2375/tcp docker-socket-proxy

View File

View File

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

View File

@@ -1,30 +0,0 @@
INFO: 172.18.0.1:34414 - "GET /lang/de.json HTTP/1.1" 304 Not Modified
INFO: 172.18.0.1:34414 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 172.18.0.1:34424 - "GET /api/settings/branding HTTP/1.1" 200 OK
INFO: 172.18.0.1:34424 - "GET /api/auth/azure/config HTTP/1.1" 200 OK
INFO: 172.18.0.1:34424 - "GET /api/auth/me HTTP/1.1" 200 OK
INFO: 172.18.0.1:34424 - "GET /api/monitoring/status HTTP/1.1" 200 OK
INFO: 172.18.0.1:34414 - "GET /api/customers?page=1&per_page=25 HTTP/1.1" 200 OK
INFO: 127.0.0.1:34422 - "GET /api/health HTTP/1.1" 200 OK
INFO: 172.18.0.1:34042 - "GET /api/settings/system HTTP/1.1" 200 OK
INFO: 172.18.0.1:34042 - "GET /api/auth/mfa/status HTTP/1.1" 200 OK
2026-02-22 14:40:01,292 [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:40:01,301 [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:49812 - "GET /api/settings/version HTTP/1.1" 200 OK
INFO: 127.0.0.1:54492 - "GET /api/health HTTP/1.1" 200 OK
INFO: 127.0.0.1:36052 - "GET /api/health HTTP/1.1" 200 OK
2026-02-22 14:40:57,656 [INFO] app.services.update_service: Database backed up to /app/backups/netbird_msp_20260222_144057.db
2026-02-22 14:40:57,971 [INFO] app.services.update_service: git pull succeeded: Already up to date.
2026-02-22 14:40:57,988 [INFO] app.services.update_service: Rebuilding with GIT_TAG=alpha-1.7 GIT_COMMIT=c40b7d3 GIT_BRANCH=unstable
2026-02-22 14:40:57,988 [INFO] app.services.update_service: Phase A: building new image …
2026-02-22 14:42:44,434 [INFO] app.services.update_service: Phase A complete — image built successfully.
2026-02-22 14:42:44,461 [INFO] app.services.update_service: Host source directory: /home/sascha/NetBirdMSP-Appliance
2026-02-22 14:42:44,973 [INFO] app.services.update_service: Phase B: updater container started — this container will restart in ~5s.
2026-02-22 14:42:44,973 [INFO] app.routers.settings: Update triggered by admin.
INFO: 172.18.0.1:46292 - "POST /api/settings/update HTTP/1.1" 200 OK
INFO: 127.0.0.1:54584 - "GET /api/health HTTP/1.1" 200 OK
INFO: 127.0.0.1:33600 - "GET /api/health HTTP/1.1" 200 OK
INFO: 127.0.0.1:35272 - "GET /api/health HTTP/1.1" 200 OK
INFO: 127.0.0.1:44226 - "GET /api/health HTTP/1.1" 200 OK
INFO: 127.0.0.1:48574 - "GET /api/health HTTP/1.1" 200 OK
INFO: 127.0.0.1:53686 - "GET /api/health HTTP/1.1" 200 OK

View File

10
out.txt
View File

@@ -1,10 +0,0 @@
[unstable c40b7d3] alpha-1.7: final test
remote:
remote: Create a new pull request for 'unstable':
remote: https://git.0x26.ch/BurgerGames/NetBirdMSP-Appliance/pulls/new/unstable
remote:
remote: .. Processing 2 references
remote: Processed 2 references in total
To https://git.0x26.ch/BurgerGames/NetBirdMSP-Appliance.git
525b056..c40b7d3 unstable -> unstable
* [new tag] alpha-1.7 -> alpha-1.7

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

@@ -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",
@@ -370,4 +373,4 @@
"confirmDeleteUser": "Delete user '{username}'? This cannot be undone.", "confirmDeleteUser": "Delete user '{username}'? This cannot be undone.",
"confirmResetPassword": "Reset password for '{username}'? A new random password will be generated." "confirmResetPassword": "Reset password for '{username}'? A new random password will be generated."
} }
} }

View File

@@ -1 +0,0 @@
unable to get image 'netbirdmsp-appliance-netbird-msp-appliance': permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock: Get "http://%2Fvar%2Frun%2Fdocker.sock/v1.51/images/netbirdmsp-appliance-netbird-msp-appliance/json": dial unix /var/run/docker.sock: connect: permission denied