diff --git a/CHANGELOG.md b/CHANGELOG.md index 5780a258..ff456bf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## v1.7.70-alpha (2026-05-19) + +- NetBird is being corrected from the peer/client daemon image to the self-hosted NetBird control-plane stack with a launchable dashboard on port `8087`, a combined management/signal/relay server on `8086`, and STUN on UDP `3478`. +- App sessions now always launch local apps through direct host ports and carry an explicit dashboard return target, so closing an iframe returns to the launching dashboard screen instead of falling through to browser history or a 404. +- Mobile app launches ignore stale desktop panel state and route into the full app-session webview consistently. +- The desktop sidebar now pins the logo/version at the top and controller/online/mode controls at the bottom, with only the navigation section scrolling on shorter screens. +- Validation passed with catalog JSON checks, `scripts/image-versions.sh` syntax check, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`. + ## v1.7.69-alpha (2026-05-19) - App installs now allow up to 10 minutes for the initial `package.install` RPC to return, matching slow container image pulls and preventing apps from disappearing from My Apps while the backend is still pulling or retrying mirrors. diff --git a/app-catalog/catalog.json b/app-catalog/catalog.json index 6f29dc99..7d652116 100644 --- a/app-catalog/catalog.json +++ b/app-catalog/catalog.json @@ -279,17 +279,17 @@ "id": "netbird", "title": "NetBird", "version": "0.71.2", - "description": "WireGuard mesh VPN client for secure remote access through NetBird Cloud or a self-hosted management server.", + "description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN service.", "icon": "/assets/img/app-icons/netbird.svg", "author": "NetBird", "category": "networking", "tier": "recommended", - "dockerImage": "docker.io/netbirdio/netbird:0.71.2", + "dockerImage": "docker.io/netbirdio/dashboard:v2.38.0", "repoUrl": "https://github.com/netbirdio/netbird", "containerConfig": { + "ports": ["8087:80", "8086:80", "3478:3478/udp"], "volumes": ["/var/lib/archipelago/netbird:/var/lib/netbird"], - "env": ["NB_SETUP_KEY=", "NB_MANAGEMENT_URL="], - "args": ["up"] + "notes": "Installed as a two-container stack: netbird dashboard on 8087 and netbird-server control plane on 8086 plus UDP 3478. For production clients, publish a DNS name over HTTPS with gRPC/WebSocket routing." } }, { diff --git a/core/archipelago/src/api/rpc/package/config.rs b/core/archipelago/src/api/rpc/package/config.rs index 5aaa7b93..5cc40a68 100644 --- a/core/archipelago/src/api/rpc/package/config.rs +++ b/core/archipelago/src/api/rpc/package/config.rs @@ -219,7 +219,7 @@ pub(super) fn get_app_capabilities(app_id: &str) -> Vec { ], // VPN/mesh daemons need TUN + NET_ADMIN. // Note: --device=/dev/net/tun is added separately in install.rs - "nostr-vpn" | "fips" | "netbird" => vec![ + "nostr-vpn" | "fips" => vec![ "--cap-add=NET_ADMIN".to_string(), "--cap-add=NET_RAW".to_string(), ], @@ -329,7 +329,6 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec ("nvpn status || exit 1", "30s", "3"), - "netbird" => ("netbird status || exit 1", "30s", "3"), "fips" => ("fipsctl status || exit 1", "30s", "3"), _ => return vec![], }; @@ -390,7 +389,7 @@ pub(super) fn get_memory_limit(app_id: &str) -> &'static str { "nostr-rs-relay" | "nostr-relay" => "256m", "routstr" => "512m", "nostr-vpn" => "256m", - "netbird" => "256m", + "netbird" => "1g", "fips" => "256m", "nginx-proxy-manager" => "256m", // Databases @@ -496,6 +495,7 @@ pub(super) fn all_container_names(package_id: &str) -> Vec { "indeedhub-ffmpeg".into(), "indeedhub".into(), ], + "netbird" => vec!["netbird".into(), "netbird-server".into()], "nostr-vpn" => vec![ "nostr-vpn".into(), "archy-nostr-vpn".into(), @@ -584,6 +584,7 @@ pub(super) fn get_data_dirs_for_app(package_id: &str) -> Vec { format!("{}/penpot-assets", base), format!("{}/penpot-postgres", base), ], + "netbird" => vec![format!("{}/netbird", base)], _ => vec![format!("{}/{}", base, package_id)], } } diff --git a/core/archipelago/src/api/rpc/package/install.rs b/core/archipelago/src/api/rpc/package/install.rs index e95901ba..13f7d9b4 100644 --- a/core/archipelago/src/api/rpc/package/install.rs +++ b/core/archipelago/src/api/rpc/package/install.rs @@ -241,6 +241,9 @@ impl RpcHandler { if package_id == "indeedhub" { return self.install_indeedhub_stack().await; } + if package_id == "netbird" { + return self.install_netbird_stack().await; + } // Dependency checks. Prefer the scanner's cached package state so a // congested Podman API does not turn an already-running dependency into @@ -552,7 +555,6 @@ impl RpcHandler { "uptime-kuma" | "gitea" | "tailscale" - | "netbird" | "vaultwarden" | "homeassistant" | "home-assistant" @@ -627,10 +629,6 @@ impl RpcHandler { run_args.push("--tmpfs=/tmp:rw,exec,size=256m"); } - if package_id == "netbird" { - run_args.push("--device=/dev/net/tun:/dev/net/tun"); - } - // Create data directories (mkdir only — chown happens AFTER config files are written) for volume in &volumes { if let Some(host_path) = volume.split(':').next() { diff --git a/core/archipelago/src/api/rpc/package/stacks.rs b/core/archipelago/src/api/rpc/package/stacks.rs index 4b7639e0..b6dc25e6 100644 --- a/core/archipelago/src/api/rpc/package/stacks.rs +++ b/core/archipelago/src/api/rpc/package/stacks.rs @@ -6,6 +6,7 @@ use crate::api::rpc::RpcHandler; use crate::data_model::InstallPhase; use anyhow::{Context, Result}; +use base64::Engine; use tracing::info; use super::install::{install_log, patch_indeedhub_nostr_provider}; @@ -310,6 +311,9 @@ fn mempool_stack_app_ids() -> &'static [&'static str] { const REGISTRY: &str = "146.59.87.168:3000/lfg2025"; +const NETBIRD_DASHBOARD_IMAGE: &str = "docker.io/netbirdio/dashboard:v2.38.0"; +const NETBIRD_SERVER_IMAGE: &str = "docker.io/netbirdio/netbird-server:0.71.2"; + /// Pull an image with retry and exponential backoff (3 attempts). async fn pull_image_with_retry(image: &str) -> Result<()> { const MAX_ATTEMPTS: u32 = 3; @@ -1357,6 +1361,187 @@ impl RpcHandler { "message": "IndeedHub stack installed (7 containers)", })) } + + /// Install self-hosted NetBird (dashboard + combined management/signal/relay server). + pub(super) async fn install_netbird_stack(&self) -> Result { + if let Some(adopted) = + adopt_stack_if_exists("netbird", "netbird", &["netbird", "netbird-server"]).await? + { + return Ok(adopted); + } + + install_log("INSTALL START: netbird stack (dashboard + server)").await; + info!("Installing self-hosted NetBird stack"); + + self.set_install_phase("netbird", InstallPhase::PullingImage) + .await; + for (i, image) in [NETBIRD_DASHBOARD_IMAGE, NETBIRD_SERVER_IMAGE] + .iter() + .enumerate() + { + self.set_install_progress("netbird", i as u64, 2).await; + pull_image_with_retry(image) + .await + .with_context(|| format!("Failed to pull NetBird image: {}", image))?; + } + self.set_install_progress("netbird", 2, 2).await; + + for name in ["netbird", "netbird-server"] { + let _ = tokio::process::Command::new("podman") + .args(["rm", "-f", name]) + .status() + .await; + } + let _ = tokio::process::Command::new("podman") + .args(["network", "rm", "-f", "netbird-net"]) + .status() + .await; + + self.set_install_phase("netbird", InstallPhase::CreatingContainer) + .await; + + tokio::fs::create_dir_all("/var/lib/archipelago/netbird") + .await + .context("Failed to create NetBird data directory")?; + + let host_ip = self.config.host_ip.clone(); + let dashboard_origin = format!("http://{}:8087", host_ip); + let mgmt_origin = format!("http://{}:8086", host_ip); + let relay_secret = read_or_generate_b64_secret("netbird-relay-auth-secret").await; + let encryption_key = read_or_generate_b64_secret("netbird-store-encryption-key").await; + let config = format!( + r#"server: + listenAddress: ":80" + exposedAddress: "{mgmt_origin}" + stunPorts: + - 3478 + metricsPort: 9090 + healthcheckAddress: ":9000" + logLevel: "info" + logFile: "console" + authSecret: "{relay_secret}" + dataDir: "/var/lib/netbird" + auth: + issuer: "{mgmt_origin}/oauth2" + localAuthDisabled: false + signKeyRefreshEnabled: true + dashboardRedirectURIs: + - "{dashboard_origin}/nb-auth" + - "{dashboard_origin}/nb-silent-auth" + cliRedirectURIs: + - "http://localhost:53000/" + store: + engine: "sqlite" + encryptionKey: "{encryption_key}" +"# + ); + tokio::fs::write("/var/lib/archipelago/netbird/config.yaml", config) + .await + .context("Failed to write NetBird config.yaml")?; + + let dashboard_env = format!( + r#"NETBIRD_MGMT_API_ENDPOINT={mgmt_origin} +NETBIRD_MGMT_GRPC_API_ENDPOINT={mgmt_origin} +AUTH_AUDIENCE=netbird-dashboard +AUTH_CLIENT_ID=netbird-dashboard +AUTH_CLIENT_SECRET= +AUTH_AUTHORITY={mgmt_origin}/oauth2 +USE_AUTH0=false +AUTH_SUPPORTED_SCOPES=openid profile email groups +AUTH_REDIRECT_URI=/nb-auth +AUTH_SILENT_REDIRECT_URI=/nb-silent-auth +NGINX_SSL_PORT=443 +LETSENCRYPT_DOMAIN=none +"# + ); + tokio::fs::write("/var/lib/archipelago/netbird/dashboard.env", dashboard_env) + .await + .context("Failed to write NetBird dashboard.env")?; + + let _ = tokio::process::Command::new("podman") + .args(["network", "create", "netbird-net"]) + .status() + .await; + + let mut server_cmd = tokio::process::Command::new("podman"); + server_cmd.args([ + "run", + "-d", + "--name", + "netbird-server", + "--network", + "netbird-net", + "--network-alias", + "netbird-server", + "--restart=unless-stopped", + "-p", + "8086:80", + "-p", + "3478:3478/udp", + "-v", + "/var/lib/archipelago/netbird/data:/var/lib/netbird", + "-v", + "/var/lib/archipelago/netbird/config.yaml:/etc/netbird/config.yaml:ro", + NETBIRD_SERVER_IMAGE, + "--config", + "/etc/netbird/config.yaml", + ]); + run_required_stack_command("netbird", "create server", &mut server_cmd).await?; + + self.set_install_phase("netbird", InstallPhase::StartingContainer) + .await; + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + + let mut dashboard_cmd = tokio::process::Command::new("podman"); + dashboard_cmd.args([ + "run", + "-d", + "--name", + "netbird", + "--network", + "netbird-net", + "--restart=unless-stopped", + "-p", + "8087:80", + "--env-file", + "/var/lib/archipelago/netbird/dashboard.env", + NETBIRD_DASHBOARD_IMAGE, + ]); + run_required_stack_command("netbird", "create dashboard", &mut dashboard_cmd).await?; + + wait_for_stack_containers("netbird", &["netbird-server", "netbird"], 60).await?; + + self.set_install_phase("netbird", InstallPhase::WaitingHealthy) + .await; + self.set_install_phase("netbird", InstallPhase::PostInstall) + .await; + self.set_install_phase("netbird", InstallPhase::Done).await; + self.clear_install_progress("netbird").await; + + install_log("INSTALL OK: netbird stack").await; + info!("NetBird stack installed"); + Ok(serde_json::json!({ + "success": true, + "package_id": "netbird", + "message": "NetBird self-hosted stack installed", + })) + } +} + +async fn read_or_generate_b64_secret(name: &str) -> String { + let path = format!("/var/lib/archipelago/secrets/{}", name); + if let Ok(val) = tokio::fs::read_to_string(&path).await { + let trimmed = val.trim().to_string(); + if !trimmed.is_empty() { + return trimmed; + } + } + let mut buf = [0u8; 32]; + rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut buf); + let secret = base64::engine::general_purpose::STANDARD.encode(buf); + let _ = tokio::fs::create_dir_all("/var/lib/archipelago/secrets").await; + let _ = tokio::fs::write(&path, &secret).await; + secret } #[cfg(test)] diff --git a/core/archipelago/src/container/docker_packages.rs b/core/archipelago/src/container/docker_packages.rs index 5ccad96d..d24afa86 100644 --- a/core/archipelago/src/container/docker_packages.rs +++ b/core/archipelago/src/container/docker_packages.rs @@ -61,6 +61,7 @@ impl DockerPackageScanner { "indeedhub-build_minio-init_1", "indeedhub-build_relay_1", "indeedhub-build_ffmpeg-worker_1", + "netbird-server", "buildx_buildkit_default", ]; @@ -481,7 +482,7 @@ fn get_app_metadata(app_id: &str) -> AppMetadata { }, "netbird" => AppMetadata { title: "NetBird".to_string(), - description: "WireGuard mesh VPN client for secure remote access".to_string(), + description: "Self-hosted WireGuard mesh VPN control plane and dashboard".to_string(), icon: "/assets/img/app-icons/netbird.svg".to_string(), repo: "https://github.com/netbirdio/netbird".to_string(), tier: "", diff --git a/core/archipelago/src/container/image_versions.rs b/core/archipelago/src/container/image_versions.rs index 8f6bbb9c..fd41353f 100644 --- a/core/archipelago/src/container/image_versions.rs +++ b/core/archipelago/src/container/image_versions.rs @@ -168,7 +168,8 @@ fn image_var_for_app(app_id: &str) -> Option<&'static str> { "nginx-proxy-manager" => Some("NPM_IMAGE"), "portainer" => Some("PORTAINER_IMAGE"), "tailscale" => Some("TAILSCALE_IMAGE"), - "netbird" => Some("NETBIRD_IMAGE"), + "netbird" => Some("NETBIRD_DASHBOARD_IMAGE"), + "netbird-server" => Some("NETBIRD_SERVER_IMAGE"), // Fedimint "fedimint" | "fedimintd" => Some("FEDIMINT_IMAGE"), @@ -300,6 +301,10 @@ pub fn containers_for_stack(app_id: &str) -> Vec<(&'static str, &'static str)> { ("penpot-exporter", "PENPOT_EXPORTER_IMAGE"), ("penpot-frontend", "PENPOT_FRONTEND_IMAGE"), ], + "netbird" => vec![ + ("netbird", "NETBIRD_DASHBOARD_IMAGE"), + ("netbird-server", "NETBIRD_SERVER_IMAGE"), + ], _ => vec![], } } diff --git a/neode-ui/public/catalog.json b/neode-ui/public/catalog.json index 6f29dc99..7d652116 100644 --- a/neode-ui/public/catalog.json +++ b/neode-ui/public/catalog.json @@ -279,17 +279,17 @@ "id": "netbird", "title": "NetBird", "version": "0.71.2", - "description": "WireGuard mesh VPN client for secure remote access through NetBird Cloud or a self-hosted management server.", + "description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN service.", "icon": "/assets/img/app-icons/netbird.svg", "author": "NetBird", "category": "networking", "tier": "recommended", - "dockerImage": "docker.io/netbirdio/netbird:0.71.2", + "dockerImage": "docker.io/netbirdio/dashboard:v2.38.0", "repoUrl": "https://github.com/netbirdio/netbird", "containerConfig": { + "ports": ["8087:80", "8086:80", "3478:3478/udp"], "volumes": ["/var/lib/archipelago/netbird:/var/lib/netbird"], - "env": ["NB_SETUP_KEY=", "NB_MANAGEMENT_URL="], - "args": ["up"] + "notes": "Installed as a two-container stack: netbird dashboard on 8087 and netbird-server control plane on 8086 plus UDP 3478. For production clients, publish a DNS name over HTTPS with gRPC/WebSocket routing." } }, { diff --git a/neode-ui/src/stores/appLauncher.ts b/neode-ui/src/stores/appLauncher.ts index 70f0fe8e..baad7db0 100644 --- a/neode-ui/src/stores/appLauncher.ts +++ b/neode-ui/src/stores/appLauncher.ts @@ -93,6 +93,8 @@ const PORT_TO_APP_ID: Record = { '8334': 'bitcoin-knots', '8888': 'searxng', '9000': 'portainer', + '8087': 'netbird', + '8086': 'netbird', '9980': 'onlyoffice', '11434': 'ollama', '2283': 'immich', @@ -151,13 +153,20 @@ export const useAppLauncherStore = defineStore('appLauncher', () => { const panelAppId = ref(null) /** Open app in session view — panel mode uses store, overlay/fullscreen uses route */ + function dashboardReturnPath(): string { + const current = router.currentRoute.value + const fullPath = current.fullPath || '/dashboard/apps' + if (!fullPath.startsWith('/dashboard') || current.name === 'app-session') return '/dashboard/apps' + return fullPath + } + function openSession(appId: string) { const mode = localStorage.getItem(DISPLAY_MODE_KEY) || 'panel' if (mode === 'panel' && !isMobileViewport()) { panelAppId.value = appId } else { panelAppId.value = null - router.push({ name: 'app-session', params: { appId } }) + router.push({ name: 'app-session', params: { appId }, query: { returnTo: dashboardReturnPath() } }) } } diff --git a/neode-ui/src/views/AppSession.vue b/neode-ui/src/views/AppSession.vue index 34635780..99424450 100644 --- a/neode-ui/src/views/AppSession.vue +++ b/neode-ui/src/views/AppSession.vue @@ -165,11 +165,6 @@ function closeRouteSession() { const fallbackPath = typeof fallback === 'string' && fallback.startsWith('/dashboard') ? fallback : '/dashboard/apps' - const previous = router.options.history.state.back - if (typeof previous === 'string' && previous.startsWith('/dashboard') && router.resolve(previous).name !== 'app-session') { - router.back() - return - } router.replace(fallbackPath).catch(() => {}) } @@ -193,7 +188,8 @@ function setMode(mode: DisplayMode) { if (isInlinePanel.value && mode !== 'panel') { const id = appId.value emit('close') - router.push({ name: 'app-session', params: { appId: id } }) + const returnTo = route.fullPath.startsWith('/dashboard') ? route.fullPath : '/dashboard/apps' + router.push({ name: 'app-session', params: { appId: id }, query: { returnTo } }) return } diff --git a/neode-ui/src/views/appSession/appSessionConfig.ts b/neode-ui/src/views/appSession/appSessionConfig.ts index 1fdf2f7d..a1eae91d 100644 --- a/neode-ui/src/views/appSession/appSessionConfig.ts +++ b/neode-ui/src/views/appSession/appSessionConfig.ts @@ -34,6 +34,7 @@ export const APP_PORTS: Record = { 'nginx-proxy-manager': 8081, 'gitea': 3001, 'portainer': 9000, + 'netbird': 8087, 'tailscale': 8240, 'uptime-kuma': 3002, 'fedimint': 8175, diff --git a/neode-ui/src/views/dashboard/DashboardMobileNav.vue b/neode-ui/src/views/dashboard/DashboardMobileNav.vue index 0f5f2098..47f307ea 100644 --- a/neode-ui/src/views/dashboard/DashboardMobileNav.vue +++ b/neode-ui/src/views/dashboard/DashboardMobileNav.vue @@ -142,7 +142,7 @@ const mobileTabBar = ref(null) // App sessions own their mobile controls. Normal mobile launches use the route // session; keeping this guard also protects any desktop-panel state on resize. -const isAppSessionActive = computed(() => route.name === 'app-session' || !!appLauncher.panelAppId) +const isAppSessionActive = computed(() => route.name === 'app-session') // Show persistent tabs for Apps/Marketplace on mobile const showAppsTabs = computed(() => { diff --git a/neode-ui/src/views/dashboard/DashboardSidebar.vue b/neode-ui/src/views/dashboard/DashboardSidebar.vue index e20ebc4a..adfd03a9 100644 --- a/neode-ui/src/views/dashboard/DashboardSidebar.vue +++ b/neode-ui/src/views/dashboard/DashboardSidebar.vue @@ -2,11 +2,11 @@