Compare commits

...
Author SHA1 Message Date
archipelago 608f4c17f0 chore: release v1.7.76-alpha 2026-05-19 21:55:48 -04:00
archipelago 92c58141af fix(apps): stabilize saleor and netbird launch 2026-05-19 21:45:17 -04:00
archipelago 7b2f4cb05f chore: sync cargo lock for v1.7.75-alpha 2026-05-19 20:27:34 -04:00
archipelago e65e76cd9d chore: release v1.7.75-alpha 2026-05-19 20:19:24 -04:00
archipelago 6d03ed5a69 docs: add v1.7.75-alpha changelog 2026-05-19 20:11:41 -04:00
archipelago 522c046525 feat(apps): add saleor and harden netbird repair 2026-05-19 20:11:22 -04:00
archipelago 56f956973e chore: release v1.7.74-alpha 2026-05-19 19:29:15 -04:00
archipelago bd69ef41d5 fix(apps): repair netbird login and iframe focus 2026-05-19 19:21:43 -04:00
archipelago eeb08fc78f chore: release v1.7.73-alpha 2026-05-19 18:40:10 -04:00
archipelago 1836b035b4 fix(mobile): improve app store search and launches 2026-05-19 18:29:04 -04:00
archipelago 3e01e57c8d chore: release v1.7.72-alpha 2026-05-19 17:42:11 -04:00
archipelago ca3e2ee0ca fix(settings): update whats new release notes 2026-05-19 17:33:45 -04:00
archipelago 5859ef77e7 chore: release v1.7.71-alpha 2026-05-19 17:30:20 -04:00
archipelago f0bd49d03d fix(apps): repair netbird install and app icons 2026-05-19 17:20:32 -04:00
archipelago cede77f3bc chore: update release lockfile 2026-05-19 16:17:13 -04:00
archipelago dd8a6cd9d7 chore: release v1.7.70-alpha 2026-05-19 16:10:43 -04:00
archipelago ab96c97cb9 fix(apps): self-host netbird and stabilize app sessions 2026-05-19 16:02:35 -04:00
archipelago 881779005a chore: update release lockfile 2026-05-19 14:45:20 -04:00
archipelago 20bc9f250c chore: release v1.7.69-alpha 2026-05-19 14:39:15 -04:00
archipelago 87be717f40 fix(apps): keep slow installs visible 2026-05-19 14:29:20 -04:00
archipelago 75d147b69f fix(release): verify published OTA artifacts 2026-05-19 12:10:42 -04:00
archipelago edaece8716 chore: update release lockfile 2026-05-19 09:41:57 -04:00
archipelago ab27fb97f8 chore: release v1.7.68-alpha 2026-05-19 09:37:47 -04:00
archipelago d736364ad7 fix(apps): stabilize btcpay and public proxy launch flows 2026-05-19 09:26:43 -04:00
60 changed files with 2134 additions and 264 deletions
+79
View File
@@ -1,5 +1,84 @@
# Changelog
## v1.7.76-alpha (2026-05-20)
- Saleor installs now use dashboard port `9010`, avoiding the existing Portainer `9000` binding on the test node while keeping API `8000`, Mailpit `8025`, and Jaeger `16686` unchanged.
- Saleor's Valkey cache no longer bind-mounts `/var/lib/archipelago/saleor-cache`, and the dashboard container has the minimal rootless nginx capabilities it needs to chown cache files, bind port 80 inside the container, and drop workers to the nginx user.
- NetBird's browser proxy now sends API, OAuth, relay, WebSocket, and management traffic through the stable host-published server port at `169.254.1.2:8086`, avoiding stale rootless Podman DNS/IPs after `netbird-server` restarts.
- Mobile App Store category chips now stay visible above the tab bar, Discover is available on mobile, and category selection updates the page route/query so the selected category is actually shown.
- Apps that require a real browser tab now open directly from the app icon tap instead of first entering an in-shell app-session route, including BTCPay, Grafana, Home Assistant, Vaultwarden, Nextcloud, Portainer, OnlyOffice, Tailscale, Uptime Kuma, Gitea, and Nginx Proxy Manager.
- Validation passed with catalog JSON checks, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`; live checks on `100.70.96.88` confirmed Saleor dashboard `9010`/API `8000` and NetBird API/OAuth routes survive `netbird-server` restart.
## v1.7.75-alpha (2026-05-19)
- Saleor is now published as a recommended commerce app with catalog metadata, icon, direct app-session launch on port `9000`, scanner metadata, image pins, and a full stack installer for dashboard, API, worker, PostgreSQL, Valkey, Mailpit, and Jaeger.
- Existing NetBird installs are repaired more aggressively by rewriting unified-origin config, recreating the dashboard/proxy containers, restarting the server, preserving data, and handling exact `/api` and `/oauth2` routes plus dashboard logout redirects through the local proxy.
- Desktop dashboard scrolling now hands focus back from the sidebar to the main content when the pointer or wheel moves over the main pane, preventing the sidebar scroll area from trapping wheel input on short screens.
- Validation passed with catalog JSON checks, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml` before release.
## v1.7.74-alpha (2026-05-19)
- App-session right panels now re-focus the iframe after load and when the frame area is activated, so wheel/touch scrolling works immediately after switching tabs or selecting an app on shorter screens.
- NetBird now launches through a unified local origin on port `8087` that proxies the dashboard plus `/oauth2`, `/api`, relay, WebSocket, and gRPC routes to `netbird-server`, fixing the embedded login flow that previously ended in `Unauthenticated` or `404 page not found` after logout.
- Existing NetBird installs are repaired on adopt/start by rewriting `config.yaml`, `dashboard.env`, and the local nginx proxy config, then creating the missing `netbird-dashboard` and `netbird` proxy containers when needed while preserving NetBird data.
- Saleor is still pending and is not included in this release; its registry/installer work remains local until it can be validated separately.
- Validation passed with catalog JSON checks, `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.73-alpha (2026-05-19)
- Mobile app launches for iframe-blocked apps now open the direct app URL in a new browser tab immediately instead of landing in a broken in-shell webview that requires a second tap.
- Mobile My Apps/Websites tabs now react to route query changes, App Store pages label the mobile view as Discover, mobile filters have safe bottom spacing, and App Store search ignores the current category so searches cover all available apps.
- My Apps search now surfaces matching App Store entries when the app is not installed, making it possible to jump directly from a failed My Apps search to the installable app details.
- NetBird self-host installs now prefer a `100.x` tailnet/CGNAT address for dashboard, management, relay, STUN, and auth redirect origins when one is present; live repair on `100.89.209.89` updated the existing stack from LAN origins to `100.89.209.89` and restored `netbird-server`.
- App-session iframe frames now focus automatically and wrap the iframe in a scroll host so wheel/touch scrolling works in the active right frame without requiring an initial click.
## v1.7.72-alpha (2026-05-19)
- Settings What's New now includes the missing release notes for `v1.7.68-alpha` through `v1.7.71-alpha`, so the modal reflects the current OTA history instead of stopping at `v1.7.67-alpha`.
- The follow-up release carries the NetBird install fix, Gitea icon polish, mobile app-session fallback updates, and rounder app icon masks from `v1.7.71-alpha` with the Settings modal notes included.
- The local Cargo lockfile version metadata is kept in sync with the release bump after the previous release build updated it.
## v1.7.71-alpha (2026-05-19)
- NetBird stack installs now pre-create `/var/lib/archipelago/netbird/data` before binding it into `netbird-server`, fixing the failed install/start path seen on `100.70.96.88` where Podman rejected the missing host directory.
- NetBird start/restart ordering now starts `netbird-server` before the dashboard container so lifecycle actions bring the control plane up before the UI.
- App-session invalid IDs and panel-mode fallbacks now return to `/dashboard/apps`, avoiding the stale `/apps` route that could render a 404.
- Mobile launches for apps that block iframes now stay inside the Archipelago app-session fallback instead of automatically opening an external browser tab.
- Installed Gitea containers now report the packaged Gitea icon, and app icon masks use a rounder radius on mobile grids, app cards, and detail headers.
- Validation passed with `npm run type-check`, focused Vitest app-session/app-grid tests, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`.
## 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.
- Live diagnostics on `100.70.96.88` confirmed the Gitea install did not fail; the primary registry pull timed out after 300 seconds, the fallback mirror succeeded, and Gitea came up healthy on `3001` while the frontend had already timed out at 15 seconds.
- Gitea and other Docker-image app installs now stay visible during slow registry pulls instead of being marked as failed by the browser before backend install progress can complete.
- Gitea is now categorized as a known Data app in My Apps, so a running Gitea container appears with installed apps instead of being filtered into the Websites/Services split.
- NetBird `0.71.2` is now available in the app catalog and fallback marketplace data as a recommended networking app using the official `docker.io/netbirdio/netbird:0.71.2` image.
- NetBird installs get persistent state under `/var/lib/archipelago/netbird`, `NET_ADMIN`/`NET_RAW`, `/dev/net/tun`, `slirp4netns`, image-version pinning, backend metadata, and health checks through `netbird status`.
- The Archipelago terminal now includes `nano` on new disk installs and ISO builds, and self-update installs it on existing nodes if it is missing.
- Validation passed with catalog JSON checks, shell syntax checks, `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.68-alpha (2026-05-19)
- BTCPay Server now ships on the official `docker.io/btcpayserver/btcpayserver:2.3.9` image, fixing the plugin catalog crash caused by newer plugin dependency version metadata while preserving existing datadirs and Postgres databases.
- BTCPay release and first-boot health checks no longer depend on `curl` inside the container; they use a bash TCP probe that works with the official image out of the box.
- Host nginx now serves Nginx Proxy Manager HTTP-01 challenge files before the Archipelago SPA fallback and is marked as the default HTTP/HTTPS virtual host, so public proxy hosts can issue certificates without hijacking local API traffic.
- Nginx Proxy Manager first-boot, runtime repair, and container-doctor paths now pre-create the ACME webroot, keep bind mounts owned by the rootless Archipelago user, and sync issued public proxy hosts into host nginx vhosts.
- The Nginx Proxy Manager host-nginx sync now skips proxy hosts with missing certificate files and rolls back the generated nginx include if validation fails, preventing a bad certificate path from poisoning later nginx reloads.
- App session close buttons now return to the previous dashboard screen when possible and otherwise fall back to My Apps, avoiding the 404 page after closing an app launched from an invalid or stale history entry.
- System Update confirmation and mirror modals now teleport to the document body with a full-screen overlay, so they cover the whole app instead of only the right-hand dashboard panel.
- Mobile app launches stay inside Archipelago's app-session webview and hide desktop-only new-tab launch affordances, including apps such as Home Assistant that previously looked like they would leave the mobile shell.
- Live recovery on `100.70.96.88` upgraded only the `btcpay-server` container to `docker.io/btcpayserver/btcpayserver:2.3.9`, preserved the existing datadir and Postgres database, and confirmed the container is healthy after a pre-upgrade backup.
- Public validation confirmed `spay.tx1138.com`/`www` redirect to BTCPay login over HTTPS and `sapien.tx1138.com`/`www` serve the L484 page over HTTPS using the issued Let's Encrypt certificates.
## v1.7.67-alpha (2026-05-18)
- Home dashboard status cards now keep the last known good system, VPN, Bitcoin, and FIPS values while route changes or transient RPC failures are in flight, avoiding false "not configured" or "not running" flashes.
+36 -2
View File
@@ -52,18 +52,35 @@
{
"id": "btcpay-server",
"title": "BTCPay Server",
"version": "1.13.7",
"version": "2.3.9",
"description": "Self-hosted Bitcoin payment processor.",
"icon": "/assets/img/app-icons/btcpay-server.png",
"author": "BTCPay Server Foundation",
"category": "commerce",
"tier": "core",
"dockerImage": "146.59.87.168:3000/lfg2025/btcpayserver:1.13.7",
"dockerImage": "docker.io/btcpayserver/btcpayserver:2.3.9",
"repoUrl": "https://github.com/btcpayserver/btcpayserver",
"requires": [
"bitcoin-knots"
]
},
{
"id": "saleor",
"title": "Saleor",
"version": "3.23",
"description": "Composable commerce platform with GraphQL API, dashboard, worker, mail testing, and tracing.",
"icon": "/assets/img/app-icons/saleor.svg",
"author": "Saleor",
"category": "commerce",
"tier": "recommended",
"dockerImage": "ghcr.io/saleor/saleor:3.23",
"repoUrl": "https://github.com/saleor/saleor",
"containerConfig": {
"ports": ["9010:80", "8000:8000", "8025:8025", "16686:16686"],
"volumes": ["/var/lib/archipelago/saleor:/app/media", "/var/lib/archipelago/saleor-db:/var/lib/postgresql/data"],
"notes": "Installed as a Saleor stack: dashboard on 9010, API on 8000, Mailpit on 8025, and Jaeger on 16686. Supporting containers include PostgreSQL, Valkey, Celery worker, and services required by Saleor."
}
},
{
"id": "mempool",
"title": "Mempool Explorer",
@@ -275,6 +292,23 @@
"args": ["sh", "-c", "tailscaled --tun=userspace-networking & sleep 2; tailscale web --listen 0.0.0.0:8240 & wait"]
}
},
{
"id": "netbird",
"title": "NetBird",
"version": "0.71.2",
"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/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"],
"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."
}
},
{
"id": "uptime-kuma",
"title": "Uptime Kuma",
+2 -2
View File
@@ -1,11 +1,11 @@
app:
id: btcpay-server
name: BTCPay Server
version: 1.13.7
version: 2.3.9
description: Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries.
container:
image: git.tx1138.com/lfg2025/btcpayserver:1.13.7
image: docker.io/btcpayserver/btcpayserver:2.3.9
pull_policy: if-not-present
network: archy-net
secret_env:
+1 -1
View File
@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "archipelago"
version = "1.7.67-alpha"
version = "1.7.76-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.7.67-alpha"
version = "1.7.76-alpha"
edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"]
+27 -2
View File
@@ -31,6 +31,7 @@ fn is_platform_managed_app(app_id: &str) -> bool {
| "fedimint"
| "fedimint-gateway"
| "indeedhub"
| "saleor"
| "immich"
)
}
@@ -217,7 +218,7 @@ pub(super) fn get_app_capabilities(app_id: &str) -> Vec<String> {
"--cap-add=DAC_OVERRIDE".to_string(),
"--cap-add=NET_BIND_SERVICE".to_string(),
],
// Nostr VPN and FIPS: mesh networking daemons need TUN + NET_ADMIN
// VPN/mesh daemons need TUN + NET_ADMIN.
// Note: --device=/dev/net/tun is added separately in install.rs
"nostr-vpn" | "fips" => vec![
"--cap-add=NET_ADMIN".to_string(),
@@ -262,7 +263,7 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => return vec![],
"lnd" => ("lncli getinfo || exit 1", "30s", "3"),
"btcpay-server" | "btcpayserver" => {
("curl -sf http://localhost:49392/ || exit 1", "30s", "3")
("bash -ec '</dev/tcp/127.0.0.1/49392'", "30s", "3")
}
"mempool-api" => (
http_probe_cmd("http://localhost:8999/api/v1/backend-info"),
@@ -389,6 +390,7 @@ pub(super) fn get_memory_limit(app_id: &str) -> &'static str {
"nostr-rs-relay" | "nostr-relay" => "256m",
"routstr" => "512m",
"nostr-vpn" => "256m",
"netbird" => "1g",
"fips" => "256m",
"nginx-proxy-manager" => "256m",
// Databases
@@ -494,6 +496,20 @@ pub(super) fn all_container_names(package_id: &str) -> Vec<String> {
"indeedhub-ffmpeg".into(),
"indeedhub".into(),
],
"netbird" => vec![
"netbird".into(),
"netbird-dashboard".into(),
"netbird-server".into(),
],
"saleor" => vec![
"saleor-db".into(),
"saleor-cache".into(),
"saleor-api".into(),
"saleor-worker".into(),
"saleor-jaeger".into(),
"saleor-mailpit".into(),
"saleor".into(),
],
"nostr-vpn" => vec![
"nostr-vpn".into(),
"archy-nostr-vpn".into(),
@@ -582,6 +598,8 @@ pub(super) fn get_data_dirs_for_app(package_id: &str) -> Vec<String> {
format!("{}/penpot-assets", base),
format!("{}/penpot-postgres", base),
],
"netbird" => vec![format!("{}/netbird", base)],
"saleor" => vec![format!("{}/saleor", base), format!("{}/saleor-db", base)],
_ => vec![format!("{}/{}", base, package_id)],
}
}
@@ -1061,6 +1079,13 @@ pub(super) async fn get_app_config(
None,
None,
),
"saleor" => (
vec!["9010:80".to_string(), "8000:8000".to_string()],
vec!["/var/lib/archipelago/saleor:/app/media".to_string()],
vec![],
None,
None,
),
"nostr-rs-relay" => (
vec!["18081:8080".to_string()],
vec!["/var/lib/archipelago/nostr-rs-relay:/usr/src/app/db".to_string()],
@@ -288,6 +288,16 @@ pub(super) fn startup_order(package_id: &str) -> &'static [&'static str] {
"btcpay-server" | "btcpayserver" | "btcpay" => {
&["archy-btcpay-db", "archy-nbxplorer", "btcpay-server"]
}
"netbird" => &["netbird-server", "netbird-dashboard", "netbird"],
"saleor" => &[
"saleor-db",
"saleor-cache",
"saleor-jaeger",
"saleor-mailpit",
"saleor-api",
"saleor-worker",
"saleor",
],
"penpot" | "penpot-frontend" => &[
"penpot-postgres",
"penpot-valkey",
@@ -389,6 +399,14 @@ mod tests {
);
}
#[test]
fn netbird_start_order_starts_server_before_dashboard() {
assert_eq!(
startup_order("netbird"),
&["netbird-server", "netbird-dashboard", "netbird"]
);
}
#[test]
fn unpruned_bitcoin_required_for_electrum_indexers_and_mempool() {
for package_id in [
@@ -241,6 +241,12 @@ impl RpcHandler {
if package_id == "indeedhub" {
return self.install_indeedhub_stack().await;
}
if package_id == "netbird" {
return self.install_netbird_stack().await;
}
if package_id == "saleor" {
return self.install_saleor_stack().await;
}
// Dependency checks. Prefer the scanner's cached package state so a
// congested Podman API does not turn an already-running dependency into
@@ -874,6 +874,7 @@ async fn repair_before_package_start(container_name: &str) {
}
async fn repair_nginx_proxy_manager_container() {
repair_nginx_proxy_manager_dirs().await;
if !nginx_proxy_manager_has_legacy_admin_port().await {
cleanup_nginx_proxy_manager_ports().await;
return;
@@ -890,6 +891,27 @@ async fn repair_nginx_proxy_manager_container() {
}
}
async fn repair_nginx_proxy_manager_dirs() {
let _ = tokio::process::Command::new("sudo")
.args([
"mkdir",
"-p",
"/var/lib/archipelago/nginx-proxy-manager/data/letsencrypt-acme-challenge/.well-known/acme-challenge",
"/var/lib/archipelago/nginx-proxy-manager/letsencrypt",
])
.output()
.await;
let _ = tokio::process::Command::new("sudo")
.args([
"chown",
"-R",
"1000:1000",
"/var/lib/archipelago/nginx-proxy-manager",
])
.output()
.await;
}
async fn nginx_proxy_manager_has_legacy_admin_port() -> bool {
if let Ok(output) = podman_control(&["port", "nginx-proxy-manager", "81/tcp"]).await {
if output.status.success()
@@ -927,12 +949,21 @@ async fn recreate_nginx_proxy_manager_container() -> Result<()> {
.args([
"mkdir",
"-p",
"/var/lib/archipelago/nginx-proxy-manager/data",
"/var/lib/archipelago/nginx-proxy-manager/data/letsencrypt-acme-challenge/.well-known/acme-challenge",
"/var/lib/archipelago/nginx-proxy-manager/letsencrypt",
])
.output()
.await
.context("failed to create nginx-proxy-manager data directories")?;
let _ = tokio::process::Command::new("sudo")
.args([
"chown",
"-R",
"1000:1000",
"/var/lib/archipelago/nginx-proxy-manager",
])
.output()
.await;
let image = crate::container::image_versions::pinned_image_for_app("nginx-proxy-manager")
.unwrap_or_else(|| "docker.io/jc21/nginx-proxy-manager:latest".to_string());
+858 -2
View File
@@ -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};
@@ -97,6 +98,8 @@ async fn repair_stack_before_adopt(stack_name: &str) {
}
}
"indeedhub" => repair_indeedhub_network_aliases().await,
"netbird" => repair_netbird_unified_origin().await,
"saleor" => repair_saleor_network_aliases().await,
_ => {}
}
}
@@ -143,6 +146,133 @@ pub(in crate::api::rpc::package) async fn repair_indeedhub_network_aliases() {
}
}
async fn repair_netbird_unified_origin() {
let host_ip = detect_netbird_public_host_ip()
.await
.unwrap_or_else(|| "127.0.0.1".to_string());
let _ = write_netbird_config_files(&host_ip).await;
for container in ["netbird", "netbird-dashboard"] {
let _ = tokio::process::Command::new("podman")
.args(["rm", "-f", container])
.output()
.await;
}
let _ = tokio::process::Command::new("podman")
.args(["network", "create", "netbird-net"])
.output()
.await;
let _ = pull_image_with_retry(NETBIRD_DASHBOARD_IMAGE).await;
let _ = pull_image_with_retry(NETBIRD_PROXY_IMAGE).await;
let _ = tokio::process::Command::new("podman")
.args([
"network",
"disconnect",
"-f",
"netbird-net",
"netbird-server",
])
.output()
.await;
let _ = tokio::process::Command::new("podman")
.args([
"network",
"connect",
"--alias",
"netbird-server",
"netbird-net",
"netbird-server",
])
.output()
.await;
let _ = tokio::process::Command::new("podman")
.args(["restart", "netbird-server"])
.output()
.await;
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let _ = tokio::process::Command::new("podman")
.args([
"run",
"-d",
"--name",
"netbird-dashboard",
"--network",
"netbird-net",
"--restart=unless-stopped",
"--env-file",
"/var/lib/archipelago/netbird/dashboard.env",
NETBIRD_DASHBOARD_IMAGE,
])
.output()
.await;
let _ = tokio::process::Command::new("podman")
.args([
"run",
"-d",
"--name",
"netbird",
"--network",
"netbird-net",
"--restart=unless-stopped",
"-p",
"8087:80",
"-v",
"/var/lib/archipelago/netbird/nginx.conf:/etc/nginx/conf.d/default.conf:ro",
NETBIRD_PROXY_IMAGE,
])
.output()
.await;
}
async fn repair_saleor_network_aliases() {
let _ = tokio::process::Command::new("podman")
.args(["network", "create", "saleor-net"])
.output()
.await;
for (container, alias) in [
("saleor-db", "db"),
("saleor-cache", "cache"),
("saleor-jaeger", "jaeger"),
("saleor-mailpit", "mailpit"),
("saleor-api", "api"),
("saleor-worker", "worker"),
("saleor", "saleor"),
] {
let exists = tokio::process::Command::new("podman")
.args(["container", "exists", container])
.status()
.await
.map(|s| s.success())
.unwrap_or(false);
if !exists {
continue;
}
let _ = tokio::process::Command::new("podman")
.args(["network", "disconnect", "-f", "saleor-net", container])
.output()
.await;
let _ = tokio::process::Command::new("podman")
.args([
"network",
"connect",
"--alias",
alias,
"saleor-net",
container,
])
.output()
.await;
}
}
async fn run_required_stack_command(
stack_name: &str,
label: &str,
@@ -310,6 +440,16 @@ 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";
const NETBIRD_PROXY_IMAGE: &str = "docker.io/library/nginx:1.27-alpine";
const SALEOR_API_IMAGE: &str = "ghcr.io/saleor/saleor:3.23";
const SALEOR_DASHBOARD_IMAGE: &str = "ghcr.io/saleor/saleor-dashboard:3.23";
const SALEOR_POSTGRES_IMAGE: &str = "docker.io/library/postgres:15-alpine";
const SALEOR_VALKEY_IMAGE: &str = "docker.io/valkey/valkey:8.1-alpine";
const SALEOR_JAEGER_IMAGE: &str = "docker.io/jaegertracing/jaeger:latest";
const SALEOR_MAILPIT_IMAGE: &str = "docker.io/axllent/mailpit:latest";
/// Pull an image with retry and exponential backoff (3 attempts).
async fn pull_image_with_retry(image: &str) -> Result<()> {
const MAX_ATTEMPTS: u32 = 3;
@@ -587,7 +727,7 @@ impl RpcHandler {
let images = [
&format!("{}/postgres:15.17", REGISTRY),
&format!("{}/nbxplorer:2.6.0", REGISTRY),
&format!("{}/btcpayserver:1.13.7", REGISTRY),
"docker.io/btcpayserver/btcpayserver:2.3.9",
];
self.set_install_phase("btcpay-server", InstallPhase::PullingImage)
.await;
@@ -743,7 +883,7 @@ impl RpcHandler {
"BTCPAY_POSTGRES=User ID=btcpay;Password={};Host=archy-btcpay-db;Port=5432;Database=btcpay;Include Error Detail=true",
db_pass
),
&format!("{}/btcpayserver:1.13.7", REGISTRY),
"docker.io/btcpayserver/btcpayserver:2.3.9",
])
.output()
.await
@@ -1357,6 +1497,722 @@ 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<serde_json::Value> {
if let Some(adopted) = adopt_stack_if_exists(
"netbird",
"netbird",
&["netbird-server", "netbird-dashboard", "netbird"],
)
.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,
NETBIRD_PROXY_IMAGE,
]
.iter()
.enumerate()
{
self.set_install_progress("netbird", i as u64, 3).await;
pull_image_with_retry(image)
.await
.with_context(|| format!("Failed to pull NetBird image: {}", image))?;
}
self.set_install_progress("netbird", 3, 3).await;
for name in ["netbird", "netbird-dashboard", "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/data")
.await
.context("Failed to create NetBird data directory")?;
let host_ip = detect_netbird_public_host_ip()
.await
.unwrap_or_else(|| self.config.host_ip.clone());
write_netbird_config_files(&host_ip).await?;
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-dashboard",
"--network",
"netbird-net",
"--restart=unless-stopped",
"--env-file",
"/var/lib/archipelago/netbird/dashboard.env",
NETBIRD_DASHBOARD_IMAGE,
]);
run_required_stack_command("netbird", "create dashboard", &mut dashboard_cmd).await?;
let mut proxy_cmd = tokio::process::Command::new("podman");
proxy_cmd.args([
"run",
"-d",
"--name",
"netbird",
"--network",
"netbird-net",
"--restart=unless-stopped",
"-p",
"8087:80",
"-v",
"/var/lib/archipelago/netbird/nginx.conf:/etc/nginx/conf.d/default.conf:ro",
NETBIRD_PROXY_IMAGE,
]);
run_required_stack_command("netbird", "create unified proxy", &mut proxy_cmd).await?;
wait_for_stack_containers(
"netbird",
&["netbird-server", "netbird-dashboard", "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",
}))
}
/// Install Saleor stack (PostgreSQL + Valkey + API + worker + dashboard + Jaeger + Mailpit).
pub(super) async fn install_saleor_stack(&self) -> Result<serde_json::Value> {
if let Some(adopted) = adopt_stack_if_exists(
"saleor",
"saleor",
&[
"saleor-db",
"saleor-cache",
"saleor-jaeger",
"saleor-mailpit",
"saleor-api",
"saleor-worker",
"saleor",
],
)
.await?
{
return Ok(adopted);
}
install_log("INSTALL START: saleor stack (postgres + valkey + api + worker + dashboard)")
.await;
info!("Installing Saleor stack");
let images = [
SALEOR_POSTGRES_IMAGE,
SALEOR_VALKEY_IMAGE,
SALEOR_API_IMAGE,
SALEOR_DASHBOARD_IMAGE,
SALEOR_JAEGER_IMAGE,
SALEOR_MAILPIT_IMAGE,
];
self.set_install_phase("saleor", InstallPhase::PullingImage)
.await;
let n_images = images.len() as u64;
for (i, image) in images.iter().enumerate() {
self.set_install_progress("saleor", i as u64, n_images)
.await;
pull_image_with_retry(image)
.await
.with_context(|| format!("Failed to pull Saleor image: {}", image))?;
}
self.set_install_progress("saleor", n_images, n_images)
.await;
for name in [
"saleor",
"saleor-api",
"saleor-worker",
"saleor-db",
"saleor-cache",
"saleor-jaeger",
"saleor-mailpit",
] {
let _ = tokio::process::Command::new("podman")
.args(["rm", "-f", name])
.status()
.await;
}
let _ = tokio::process::Command::new("podman")
.args(["network", "rm", "-f", "saleor-net"])
.status()
.await;
self.set_install_phase("saleor", InstallPhase::CreatingContainer)
.await;
let _ = tokio::process::Command::new("sudo")
.args([
"mkdir",
"-p",
"/var/lib/archipelago/saleor",
"/var/lib/archipelago/saleor-db",
"/var/lib/archipelago/saleor-cache",
])
.output()
.await;
let user = std::env::var("USER").unwrap_or_else(|_| "archipelago".to_string());
for dir in [
"/var/lib/archipelago/saleor",
"/var/lib/archipelago/saleor-db",
"/var/lib/archipelago/saleor-cache",
] {
let _ = tokio::process::Command::new("sudo")
.args(["chown", "-R", &format!("{}:{}", user, user), dir])
.output()
.await;
}
let _ = tokio::process::Command::new("podman")
.args(["network", "create", "saleor-net"])
.status()
.await;
let db_pass = super::config::read_or_generate_secret("saleor-db-password").await;
let secret_key = super::config::read_or_generate_secret("saleor-secret-key").await;
let admin_pass = super::config::read_or_generate_secret("saleor-admin-password").await;
let host_ip = &self.config.host_ip;
let dashboard_url = format!("http://{}:9010/", host_ip);
let api_url = format!("http://{}:8000/graphql/", host_ip);
let allowed_hosts = format!("localhost,127.0.0.1,api,saleor-api,{}", host_ip);
let database_url = format!("postgres://saleor:{}@db/saleor", db_pass);
let mut db_cmd = tokio::process::Command::new("podman");
db_cmd.args([
"run",
"-d",
"--name",
"saleor-db",
"--network",
"saleor-net",
"--network-alias",
"db",
"--restart=unless-stopped",
"--cap-drop=ALL",
"--cap-add=CHOWN",
"--cap-add=DAC_OVERRIDE",
"--cap-add=FOWNER",
"--cap-add=SETGID",
"--cap-add=SETUID",
"--security-opt=no-new-privileges:true",
"--memory=512m",
"--pids-limit=4096",
"--health-cmd=pg_isready -U saleor || exit 1",
"--health-interval=30s",
"--health-retries=3",
"-v",
"/var/lib/archipelago/saleor-db:/var/lib/postgresql/data",
"-e",
"POSTGRES_USER=saleor",
"-e",
&format!("POSTGRES_PASSWORD={}", db_pass),
"-e",
"POSTGRES_DB=saleor",
SALEOR_POSTGRES_IMAGE,
]);
run_required_stack_command("saleor", "create postgres", &mut db_cmd).await?;
let mut cache_cmd = tokio::process::Command::new("podman");
cache_cmd.args([
"run",
"-d",
"--name",
"saleor-cache",
"--network",
"saleor-net",
"--network-alias",
"cache",
"--restart=unless-stopped",
"--cap-drop=ALL",
"--cap-add=SETGID",
"--cap-add=SETUID",
"--security-opt=no-new-privileges:true",
"--memory=128m",
"--pids-limit=2048",
"--health-cmd=valkey-cli ping || exit 1",
"--health-interval=30s",
"--health-retries=3",
SALEOR_VALKEY_IMAGE,
]);
run_required_stack_command("saleor", "create cache", &mut cache_cmd).await?;
let mut jaeger_cmd = tokio::process::Command::new("podman");
jaeger_cmd.args([
"run",
"-d",
"--name",
"saleor-jaeger",
"--network",
"saleor-net",
"--network-alias",
"jaeger",
"--restart=unless-stopped",
"--cap-drop=ALL",
"--security-opt=no-new-privileges:true",
"--memory=512m",
"--pids-limit=4096",
"-p",
"16686:16686",
"-p",
"4317:4317",
"-p",
"4318:4318",
"--tmpfs",
"/tmp:rw,nosuid,nodev,size=128m",
SALEOR_JAEGER_IMAGE,
]);
run_required_stack_command("saleor", "create jaeger", &mut jaeger_cmd).await?;
let mut mailpit_cmd = tokio::process::Command::new("podman");
mailpit_cmd.args([
"run",
"-d",
"--name",
"saleor-mailpit",
"--network",
"saleor-net",
"--network-alias",
"mailpit",
"--restart=unless-stopped",
"--cap-drop=ALL",
"--security-opt=no-new-privileges:true",
"--memory=128m",
"--pids-limit=2048",
"-p",
"1025:1025",
"-p",
"8025:8025",
SALEOR_MAILPIT_IMAGE,
]);
run_required_stack_command("saleor", "create mailpit", &mut mailpit_cmd).await?;
tokio::time::sleep(std::time::Duration::from_secs(8)).await;
let saleor_env = vec![
"-e".to_string(),
"CACHE_URL=redis://cache:6379/0".to_string(),
"-e".to_string(),
"CELERY_BROKER_URL=redis://cache:6379/1".to_string(),
"-e".to_string(),
format!("DATABASE_URL={}", database_url),
"-e".to_string(),
"DEFAULT_CHANNEL_SLUG=default-channel".to_string(),
"-e".to_string(),
"DEFAULT_FROM_EMAIL=noreply@example.com".to_string(),
"-e".to_string(),
"EMAIL_URL=smtp://mailpit:1025".to_string(),
"-e".to_string(),
format!("SECRET_KEY={}", secret_key),
"-e".to_string(),
"OTEL_SERVICE_NAME=saleor".to_string(),
"-e".to_string(),
"OTEL_TRACES_EXPORTER=otlp".to_string(),
"-e".to_string(),
"OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4317".to_string(),
"-e".to_string(),
"HTTP_IP_FILTER_ALLOW_LOOPBACK_IPS=True".to_string(),
"-e".to_string(),
"HTTP_IP_FILTER_ENABLED=False".to_string(),
"-e".to_string(),
format!("DASHBOARD_URL={}", dashboard_url),
"-e".to_string(),
format!("ALLOWED_HOSTS={}", allowed_hosts),
];
let mut migrate_cmd = tokio::process::Command::new("podman");
migrate_cmd.args([
"run",
"--rm",
"--network",
"saleor-net",
"-v",
"/var/lib/archipelago/saleor:/app/media",
]);
migrate_cmd.args(&saleor_env);
migrate_cmd.args([SALEOR_API_IMAGE, "python3", "manage.py", "migrate"]);
run_required_stack_command("saleor", "run migrations", &mut migrate_cmd).await?;
let mut populate_cmd = tokio::process::Command::new("podman");
populate_cmd.args([
"run",
"--rm",
"--network",
"saleor-net",
"-v",
"/var/lib/archipelago/saleor:/app/media",
]);
populate_cmd.args(&saleor_env);
populate_cmd.args([
SALEOR_API_IMAGE,
"python3",
"manage.py",
"populatedb",
"--createsuperuser",
]);
let populate = populate_cmd.output().await;
if let Ok(output) = populate {
if !output.status.success() {
install_log(&format!(
"INSTALL WARN: saleor - populate sample data skipped: {}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
))
.await;
}
}
let mut admin_cmd = tokio::process::Command::new("podman");
admin_cmd.args([
"run",
"--rm",
"--network",
"saleor-net",
"-v",
"/var/lib/archipelago/saleor:/app/media",
]);
admin_cmd.args(&saleor_env);
admin_cmd.args([
"-e",
"DJANGO_SUPERUSER_EMAIL=admin@example.com",
"-e",
&format!("DJANGO_SUPERUSER_PASSWORD={}", admin_pass),
SALEOR_API_IMAGE,
"python3",
"manage.py",
"createsuperuser",
"--noinput",
]);
run_required_stack_command("saleor", "create admin user", &mut admin_cmd).await?;
install_log("INSTALL INFO: saleor admin email admin@example.com; password stored in /var/lib/archipelago/secrets/saleor-admin-password").await;
let mut api_cmd = tokio::process::Command::new("podman");
api_cmd.args([
"run",
"-d",
"--name",
"saleor-api",
"--network",
"saleor-net",
"--network-alias",
"api",
"--restart=unless-stopped",
"--cap-drop=ALL",
"--cap-add=CHOWN",
"--cap-add=DAC_OVERRIDE",
"--cap-add=FOWNER",
"--cap-add=SETGID",
"--cap-add=SETUID",
"--security-opt=no-new-privileges:true",
"--memory=1g",
"--pids-limit=4096",
"-p",
"8000:8000",
"-v",
"/var/lib/archipelago/saleor:/app/media",
]);
api_cmd.args(&saleor_env);
api_cmd.arg(SALEOR_API_IMAGE);
run_required_stack_command("saleor", "create api", &mut api_cmd).await?;
let mut worker_cmd = tokio::process::Command::new("podman");
worker_cmd.args([
"run",
"-d",
"--name",
"saleor-worker",
"--network",
"saleor-net",
"--restart=unless-stopped",
"--cap-drop=ALL",
"--cap-add=CHOWN",
"--cap-add=DAC_OVERRIDE",
"--cap-add=FOWNER",
"--cap-add=SETGID",
"--cap-add=SETUID",
"--security-opt=no-new-privileges:true",
"--memory=1g",
"--pids-limit=4096",
"-v",
"/var/lib/archipelago/saleor:/app/media",
]);
worker_cmd.args(&saleor_env);
worker_cmd.args([
SALEOR_API_IMAGE,
"celery",
"-A",
"saleor",
"--app=saleor.celeryconf:app",
"worker",
"--loglevel=info",
"-B",
]);
run_required_stack_command("saleor", "create worker", &mut worker_cmd).await?;
self.set_install_phase("saleor", 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",
"saleor",
"--network",
"saleor-net",
"--restart=unless-stopped",
"--cap-drop=ALL",
"--cap-add=CHOWN",
"--cap-add=DAC_OVERRIDE",
"--cap-add=FOWNER",
"--cap-add=NET_BIND_SERVICE",
"--cap-add=SETGID",
"--cap-add=SETUID",
"--security-opt=no-new-privileges:true",
"--memory=256m",
"--pids-limit=2048",
"-p",
"9010:80",
"-e",
&format!("API_URL={}", api_url),
"-e",
&format!("APP_MOUNT_URI={}", dashboard_url),
SALEOR_DASHBOARD_IMAGE,
]);
run_required_stack_command("saleor", "create dashboard", &mut dashboard_cmd).await?;
wait_for_stack_containers(
"saleor",
&[
"saleor-db",
"saleor-cache",
"saleor-jaeger",
"saleor-mailpit",
"saleor-api",
"saleor-worker",
"saleor",
],
120,
)
.await?;
self.set_install_phase("saleor", InstallPhase::WaitingHealthy)
.await;
self.set_install_phase("saleor", InstallPhase::PostInstall)
.await;
self.set_install_phase("saleor", InstallPhase::Done).await;
self.clear_install_progress("saleor").await;
install_log("INSTALL OK: saleor stack").await;
info!("Saleor stack installed");
Ok(serde_json::json!({
"success": true,
"package_id": "saleor",
"message": "Saleor stack installed (7 containers)",
}))
}
}
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
}
async fn write_netbird_config_files(host_ip: &str) -> Result<()> {
let public_origin = format!("http://{}:8087", host_ip);
let server_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: "{public_origin}"
stunPorts:
- 3478
metricsPort: 9090
healthcheckAddress: ":9000"
logLevel: "info"
logFile: "console"
authSecret: "{relay_secret}"
dataDir: "/var/lib/netbird"
auth:
issuer: "{public_origin}/oauth2"
localAuthDisabled: false
signKeyRefreshEnabled: true
dashboardRedirectURIs:
- "{public_origin}/nb-auth"
- "{public_origin}/nb-silent-auth"
dashboardPostLogoutRedirectURIs:
- "{public_origin}/"
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={public_origin}
NETBIRD_MGMT_GRPC_API_ENDPOINT={public_origin}
AUTH_AUDIENCE=netbird-dashboard
AUTH_CLIENT_ID=netbird-dashboard
AUTH_CLIENT_SECRET=
AUTH_AUTHORITY={public_origin}/oauth2
USE_AUTH0=false
AUTH_SUPPORTED_SCOPES=openid profile email groups
AUTH_REDIRECT_URI=/nb-auth
AUTH_SILENT_REDIRECT_URI=/nb-silent-auth
NETBIRD_TOKEN_SOURCE=accessToken
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 nginx_conf = format!(
r#"server {{
listen 80;
server_name _;
# Route API/auth through the host-published server port. Rootless Podman
# can give netbird-server a new container IP on restart while nginx keeps
# an old resolved address, which breaks login with 502s.
set $netbird_server http://169.254.1.2:8086;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
location ~ ^/(relay|ws-proxy/) {{
proxy_pass $netbird_server;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 1d;
}}
location ~ ^/(api|oauth2)(/|$) {{
proxy_pass $netbird_server;
}}
location ~ ^/(signalexchange\.SignalExchange|management\.ManagementService)/ {{
grpc_pass grpc://netbird-server:80;
grpc_read_timeout 1d;
grpc_send_timeout 1d;
}}
location / {{
proxy_pass http://netbird-dashboard:80;
}}
}}
# Direct server remains available for diagnostics at {server_origin}.
"#
);
tokio::fs::write("/var/lib/archipelago/netbird/nginx.conf", nginx_conf)
.await
.context("Failed to write NetBird nginx.conf")?;
Ok(())
}
async fn detect_netbird_public_host_ip() -> Option<String> {
let output = tokio::process::Command::new("hostname")
.args(["-I"])
.output()
.await
.ok()?;
let stdout = String::from_utf8_lossy(&output.stdout);
stdout
.split_whitespace()
.find(|ip| ip.starts_with("100.") && ip.contains('.'))
.map(str::to_string)
}
#[cfg(test)]
@@ -61,6 +61,14 @@ impl DockerPackageScanner {
"indeedhub-build_minio-init_1",
"indeedhub-build_relay_1",
"indeedhub-build_ffmpeg-worker_1",
"netbird-server",
"netbird-dashboard",
"saleor-api",
"saleor-worker",
"saleor-db",
"saleor-cache",
"saleor-jaeger",
"saleor-mailpit",
"buildx_buildkit_default",
];
@@ -139,7 +147,9 @@ impl DockerPackageScanner {
let metadata = get_app_metadata(&app_id);
// Resolve UI address: separate UI containers > static map > dynamic ports
let lan_address = if let Some(ui_address) = ui_containers.get(&app_id) {
let lan_address = if app_id == "netbird" {
reachable_lan_address(&app_id, netbird_configured_launch_url().await).await
} else if let Some(ui_address) = ui_containers.get(&app_id) {
// Apps with separate UI containers (e.g. archy-bitcoin-ui, archy-lnd-ui)
debug!("Using UI container for {}: {}", app_id, ui_address);
reachable_lan_address(&app_id, Some(ui_address.clone())).await
@@ -281,7 +291,8 @@ fn get_app_tier(app_id: &str) -> &'static str {
"uptime-kuma" => "recommended",
"grafana" => "recommended",
"searxng" => "recommended",
"tailscale" => "recommended",
"saleor" => "recommended",
"tailscale" | "netbird" => "recommended",
"portainer" => "recommended",
// Optional: everything else
_ => "optional",
@@ -479,6 +490,27 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
repo: "https://github.com/tailscale/tailscale".to_string(),
tier: "",
},
"netbird" => AppMetadata {
title: "NetBird".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: "",
},
"saleor" => AppMetadata {
title: "Saleor".to_string(),
description: "Composable commerce platform with GraphQL API and dashboard. Admin email: admin@example.com; password is stored on the node at /var/lib/archipelago/secrets/saleor-admin-password".to_string(),
icon: "/assets/img/app-icons/saleor.svg".to_string(),
repo: "https://github.com/saleor/saleor".to_string(),
tier: "",
},
"gitea" => AppMetadata {
title: "Gitea".to_string(),
description: "Self-hosted Git service with repository and package hosting".to_string(),
icon: "/assets/img/app-icons/gitea.svg".to_string(),
repo: "https://gitea.com".to_string(),
tier: "",
},
"indeedhub" | "indeehub" => AppMetadata {
title: "IndeedHub".to_string(),
description: "Decentralized media streaming platform".to_string(),
@@ -658,6 +690,18 @@ fn extract_lan_address(ports: &[String]) -> Option<String> {
None
}
async fn netbird_configured_launch_url() -> Option<String> {
let env = tokio::fs::read_to_string("/var/lib/archipelago/netbird/dashboard.env")
.await
.ok()?;
env.lines()
.find_map(|line| line.strip_prefix("NETBIRD_MGMT_API_ENDPOINT="))
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned)
.or_else(|| PodmanClient::lan_address_for("netbird"))
}
async fn reachable_lan_address(app_id: &str, candidate: Option<String>) -> Option<String> {
let url = candidate?;
if !requires_reachable_launch(app_id) {
@@ -700,6 +744,7 @@ fn requires_reachable_launch(app_id: &str) -> bool {
| "tailscale"
| "immich"
| "searxng"
| "saleor"
)
}
@@ -168,6 +168,15 @@ 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_DASHBOARD_IMAGE"),
"netbird-dashboard" => Some("NETBIRD_DASHBOARD_IMAGE"),
"netbird-server" => Some("NETBIRD_SERVER_IMAGE"),
"saleor" => Some("SALEOR_DASHBOARD_IMAGE"),
"saleor-api" | "saleor-worker" => Some("SALEOR_API_IMAGE"),
"saleor-db" => Some("SALEOR_POSTGRES_IMAGE"),
"saleor-cache" => Some("SALEOR_VALKEY_IMAGE"),
"saleor-jaeger" => Some("SALEOR_JAEGER_IMAGE"),
"saleor-mailpit" => Some("SALEOR_MAILPIT_IMAGE"),
// Fedimint
"fedimint" | "fedimintd" => Some("FEDIMINT_IMAGE"),
@@ -299,6 +308,20 @@ 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_PROXY_IMAGE"),
("netbird-dashboard", "NETBIRD_DASHBOARD_IMAGE"),
("netbird-server", "NETBIRD_SERVER_IMAGE"),
],
"saleor" => vec![
("saleor-db", "SALEOR_POSTGRES_IMAGE"),
("saleor-cache", "SALEOR_VALKEY_IMAGE"),
("saleor-api", "SALEOR_API_IMAGE"),
("saleor-worker", "SALEOR_API_IMAGE"),
("saleor-jaeger", "SALEOR_JAEGER_IMAGE"),
("saleor-mailpit", "SALEOR_MAILPIT_IMAGE"),
("saleor", "SALEOR_DASHBOARD_IMAGE"),
],
_ => vec![],
}
}
+2
View File
@@ -129,6 +129,7 @@ impl PodmanClient {
"filebrowser" => "http://localhost:8083",
"nginx-proxy-manager" => "http://localhost:8081",
"portainer" => "http://localhost:9000",
"saleor" => "http://localhost:9010",
"uptime-kuma" => "http://localhost:3002",
"fedimint" | "fedimintd" => "http://localhost:8175",
"fedimint-gateway" => "http://localhost:8176",
@@ -136,6 +137,7 @@ impl PodmanClient {
"indeedhub" => "http://localhost:7778",
"dwn" => "http://localhost:3100",
"endurain" => "http://localhost:8080",
"netbird" => "http://localhost:8087",
"electrs" | "archy-electrs-ui" => "http://localhost:50002",
_ => return None,
};
+1 -1
View File
@@ -1169,7 +1169,7 @@
<div class="card-header"><span class="card-name">btcpay-server</span><span class="card-badge badge-red">archy-net</span></div>
<div class="card-desc">Self-hosted Bitcoin payment processor. Accept Bitcoin payments with invoices, checkout pages, and POS.</div>
<div class="card-layman">Your own payment terminal for Bitcoin. Create invoices, get paid, no middleman taking a cut.</div>
<div class="card-image">btcpayserver:1.13.7</div>
<div class="card-image">btcpayserver:2.3.9</div>
<div class="card-ports">Ports: <span>23000</span></div>
<div class="card-details">
<div class="detail-row"><span class="detail-label">Memory</span><span class="detail-value">1 GB</span></div>
@@ -337,6 +337,7 @@ RUN apt-get update && apt-get -y full-upgrade && apt-get install -y --no-install
curl \
git \
vim-tiny \
nano \
ca-certificates \
openssl \
chrony \
@@ -179,6 +179,7 @@ chroot /mnt/archipelago apt-get install -y \
wget \
htop \
vim-tiny \
nano \
ca-certificates \
chrony
+9 -2
View File
@@ -8,7 +8,7 @@ resolver 1.1.1.1 8.8.8.8 valid=300s ipv6=off;
resolver_timeout 5s;
server {
listen 80;
listen 80 default_server;
server_name _;
root /opt/archipelago/web-ui;
@@ -23,6 +23,13 @@ server {
add_header X-DNS-Prefetch-Control "off" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://*.basemaps.cartocdn.com https://tile.openstreetmap.org; font-src 'self' data:; connect-src 'self' ws: wss: http://$host:* https:; frame-src 'self' http://$host:* https:; frame-ancestors 'self'; base-uri 'self'; form-action 'self';" always;
# Serve Nginx Proxy Manager HTTP-01 challenge files before the SPA fallback.
location ^~ /.well-known/acme-challenge/ {
default_type text/plain;
root /var/lib/archipelago/nginx-proxy-manager/data/letsencrypt-acme-challenge;
try_files $uri =404;
}
# AIUI SPA (Chat mode iframe) — SPA fallback for client-side routing
location /aiui/ {
try_files $uri $uri/ /aiui/index.html;
@@ -908,7 +915,7 @@ server {
# HTTPS - required for PWA install (Add to Home Screen) from dev servers
server {
listen 443 ssl;
listen 443 ssl default_server;
server_name _;
ssl_certificate /etc/archipelago/ssl/archipelago.crt;
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "neode-ui",
"version": "1.7.67-alpha",
"version": "1.7.76-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neode-ui",
"version": "1.7.67-alpha",
"version": "1.7.76-alpha",
"dependencies": {
"@types/dompurify": "^3.0.5",
"@vue-leaflet/vue-leaflet": "^0.10.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "neode-ui",
"private": true,
"version": "1.7.67-alpha",
"version": "1.7.76-alpha",
"type": "module",
"scripts": {
"start": "./start-dev.sh",
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="NetBird">
<defs>
<linearGradient id="g" x1="18" y1="14" x2="110" y2="116" gradientUnits="userSpaceOnUse">
<stop stop-color="#24c8ff"/>
<stop offset="1" stop-color="#3157ff"/>
</linearGradient>
</defs>
<rect width="128" height="128" rx="28" fill="#071422"/>
<path d="M28 72c16-30 39-46 72-50-11 13-18 26-21 40 10-1 19 1 29 5-19 4-35 13-48 27-8 8-18 12-30 12 7-7 12-14 15-22-7 0-13-4-17-12Z" fill="url(#g)"/>
<circle cx="82" cy="43" r="6" fill="#fff" opacity=".95"/>
<path d="M36 72c10 3 20 4 30 2" fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" opacity=".8"/>
</svg>

After

Width:  |  Height:  |  Size: 702 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="Saleor">
<rect width="128" height="128" rx="30" fill="#111827"/>
<path d="M34 42c0-10 9-18 22-18h38v16H56c-5 0-8 2-8 5 0 4 4 5 13 7l15 3c15 3 24 11 24 24 0 15-12 25-31 25H31V88h39c8 0 13-3 13-8 0-4-4-6-12-8l-16-3C41 66 34 57 34 42Z" fill="#fff"/>
<path d="M29 103h70v8H29z" fill="#7C3AED"/>
</svg>

After

Width:  |  Height:  |  Size: 389 B

+36 -2
View File
@@ -52,18 +52,35 @@
{
"id": "btcpay-server",
"title": "BTCPay Server",
"version": "1.13.7",
"version": "2.3.9",
"description": "Self-hosted Bitcoin payment processor.",
"icon": "/assets/img/app-icons/btcpay-server.png",
"author": "BTCPay Server Foundation",
"category": "commerce",
"tier": "core",
"dockerImage": "146.59.87.168:3000/lfg2025/btcpayserver:1.13.7",
"dockerImage": "docker.io/btcpayserver/btcpayserver:2.3.9",
"repoUrl": "https://github.com/btcpayserver/btcpayserver",
"requires": [
"bitcoin-knots"
]
},
{
"id": "saleor",
"title": "Saleor",
"version": "3.23",
"description": "Composable commerce platform with GraphQL API, dashboard, worker, mail testing, and tracing.",
"icon": "/assets/img/app-icons/saleor.svg",
"author": "Saleor",
"category": "commerce",
"tier": "recommended",
"dockerImage": "ghcr.io/saleor/saleor:3.23",
"repoUrl": "https://github.com/saleor/saleor",
"containerConfig": {
"ports": ["9010:80", "8000:8000", "8025:8025", "16686:16686"],
"volumes": ["/var/lib/archipelago/saleor:/app/media", "/var/lib/archipelago/saleor-db:/var/lib/postgresql/data"],
"notes": "Installed as a Saleor stack: dashboard on 9010, API on 8000, Mailpit on 8025, and Jaeger on 16686. Supporting containers include PostgreSQL, Valkey, Celery worker, and services required by Saleor."
}
},
{
"id": "mempool",
"title": "Mempool Explorer",
@@ -275,6 +292,23 @@
"args": ["sh", "-c", "tailscaled --tun=userspace-networking & sleep 2; tailscale web --listen 0.0.0.0:8240 & wait"]
}
},
{
"id": "netbird",
"title": "NetBird",
"version": "0.71.2",
"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/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"],
"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."
}
},
{
"id": "uptime-kuma",
"title": "Uptime Kuma",
+1 -2
View File
@@ -532,7 +532,7 @@ class RPCClient {
return this.call({
method: 'package.install',
params: { id, 'marketplace-url': marketplaceUrl, version },
timeout: 15000,
timeout: 600000,
})
}
@@ -940,4 +940,3 @@ class RPCClient {
}
export const rpcClient = new RPCClient()
+47 -1
View File
@@ -17,6 +17,15 @@ const NEW_TAB_PORTS = new Set([
])
const NEW_TAB_APP_IDS = new Set([
'btcpay-server',
'grafana',
'photoprism',
'homeassistant',
'vaultwarden',
'nextcloud',
'portainer',
'onlyoffice',
'tailscale',
'nginx-proxy-manager',
'uptime-kuma',
'gitea',
@@ -93,6 +102,9 @@ const PORT_TO_APP_ID: Record<string, string> = {
'8334': 'bitcoin-knots',
'8888': 'searxng',
'9000': 'portainer',
'9010': 'saleor',
'8087': 'netbird',
'8086': 'netbird',
'9980': 'onlyoffice',
'11434': 'ollama',
'2283': 'immich',
@@ -107,6 +119,27 @@ const PORT_TO_APP_ID: Record<string, string> = {
'3010': 'thunderhub',
}
const APP_ID_TO_PORT: Record<string, string> = {
'btcpay-server': '23000',
grafana: '3000',
photoprism: '2342',
homeassistant: '8123',
vaultwarden: '8082',
nextcloud: '8085',
portainer: '9000',
onlyoffice: '8044',
tailscale: '8240',
'nginx-proxy-manager': '8081',
'uptime-kuma': '3002',
gitea: '3001',
}
function directAppUrl(appId: string): string | null {
const port = APP_ID_TO_PORT[appId]
if (!port || typeof window === 'undefined') return null
return `http://${window.location.hostname}:${port}`
}
const APPROVED_ORIGINS_KEY = 'neode_nostr_approved_origins'
@@ -151,13 +184,26 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
const panelAppId = ref<string | null>(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 launchUrl = NEW_TAB_APP_IDS.has(appId) ? directAppUrl(appId) : null
if (launchUrl) {
window.open(launchUrl, '_blank', 'noopener,noreferrer')
return
}
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() } })
}
}
+51 -3
View File
@@ -1659,6 +1659,45 @@ html:has(body.video-background-active)::before {
filter: drop-shadow(0 10px 25px rgba(0, 0, 0, 0.5));
}
.mobile-filter-btn {
bottom: calc(var(--mobile-tab-bar-height, 72px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + 12px);
filter: drop-shadow(0 10px 25px rgba(0, 0, 0, 0.5));
}
.mobile-filter-sheet {
padding-bottom: calc(var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + 1.5rem);
}
.mobile-category-strip {
display: flex;
gap: 0.5rem;
overflow-x: auto;
overscroll-behavior-x: contain;
padding-bottom: 0.25rem;
scrollbar-width: none;
}
.mobile-category-strip::-webkit-scrollbar {
display: none;
}
.mobile-category-pill {
flex: 0 0 auto;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.78);
padding: 0.55rem 0.9rem;
font-size: 0.85rem;
font-weight: 600;
}
.mobile-category-pill-active {
border-color: rgba(255, 255, 255, 0.36);
background: rgba(255, 255, 255, 0.2);
color: white;
}
/* ── Cloud Audio Player (mini bar) ──── */
.cloud-audio-player {
@@ -2098,7 +2137,7 @@ html:has(body.video-background-active)::before {
position: relative;
width: 60px;
height: 60px;
border-radius: 14px;
border-radius: 18px;
overflow: visible;
background: rgba(255, 255, 255, 0.08);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
@@ -2108,7 +2147,16 @@ html:has(body.video-background-active)::before {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 14px;
border-radius: 18px;
}
.app-card-icon {
border-radius: 16px;
}
.app-detail-icon {
border-radius: 22px;
object-fit: cover;
}
/* Status dot — top-right of icon */
@@ -2140,7 +2188,7 @@ html:has(body.video-background-active)::before {
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.6);
border-radius: 14px;
border-radius: 18px;
}
.app-icon-label {
+1 -1
View File
@@ -158,7 +158,7 @@ const { t } = useI18n()
const appId = computed(() => {
const id = route.params.id
if (typeof id !== 'string' || !/^[a-z0-9][a-z0-9._-]*$/.test(id) || id.length > 64) {
router.replace('/apps')
router.replace('/dashboard/apps')
return ''
}
return id
+30 -8
View File
@@ -91,6 +91,7 @@
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAppLauncherStore } from '@/stores/appLauncher'
import { useAppStore } from '@/stores/app'
import { useScreensaverStore } from '@/stores/screensaver'
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
import AppSessionHeader from './appSession/AppSessionHeader.vue'
@@ -116,6 +117,7 @@ const isInlinePanel = computed(() => !!props.appIdProp)
const route = useRoute()
const router = useRouter()
const store = useAppStore()
const screensaverStore = useScreensaverStore()
const sessionRef = ref<HTMLElement | null>(null)
@@ -138,7 +140,7 @@ const displayMode = ref<DisplayMode>(
const appId = computed(() => {
const id = props.appIdProp || (route.params.appId as string)
if (typeof id !== 'string' || !/^[a-z0-9][a-z0-9._-]*$/.test(id) || id.length > 64) {
router.replace('/apps')
router.replace('/dashboard/apps')
return ''
}
return id
@@ -146,7 +148,7 @@ const appId = computed(() => {
const appTitle = computed(() => resolveAppTitle(appId.value))
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
const mustOpenNewTab = computed(() => !isMobile && NEW_TAB_APPS.has(appId.value))
const mustOpenNewTab = computed(() => NEW_TAB_APPS.has(appId.value))
const screensaverReason = computed(() => `app-session:${appId.value}`)
const screensaverSuppressedApps = new Set([
'indeedhub',
@@ -157,9 +159,18 @@ const screensaverSuppressedApps = new Set([
])
const appUrl = computed(() => {
return resolveAppUrl(appId.value, route.query.path as string | undefined)
const runtimeUrl = store.data?.['package-data']?.[appId.value]?.installed?.['interface-addresses']?.main?.['lan-address'] || undefined
return resolveAppUrl(appId.value, route.query.path as string | undefined, runtimeUrl)
})
function closeRouteSession() {
const fallback = route.query.returnTo
const fallbackPath = typeof fallback === 'string' && fallback.startsWith('/dashboard')
? fallback
: '/dashboard/apps'
router.replace(fallbackPath).catch(() => {})
}
// --- Identity & Nostr bridge ---
const iframeRef = computed(() => frameRef.value?.iframeRef ?? null)
@@ -180,7 +191,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
}
@@ -188,7 +200,11 @@ function setMode(mode: DisplayMode) {
if (!isInlinePanel.value && mode === 'panel') {
const id = appId.value
const launcher = useAppLauncherStore()
router.push({ name: 'apps' }).then(() => {
const fallback = route.query.returnTo
const fallbackPath = typeof fallback === 'string' && fallback.startsWith('/dashboard')
? fallback
: '/dashboard/apps'
router.push(fallbackPath).then(() => {
launcher.panelAppId = id
})
return
@@ -295,7 +311,7 @@ function handleBackdropClick() {
function closeSession() {
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
if (isInlinePanel.value) emit('close')
else router.back()
else closeRouteSession()
}
function onKeyDown(e: KeyboardEvent) {
@@ -328,11 +344,11 @@ watch(displayMode, (mode) => {
})
onMounted(() => {
// Apps that block iframes (X-Frame-Options) -- open in new tab, close session
// Apps that block iframes open externally instead of landing in a broken webview.
if (mustOpenNewTab.value && appUrl.value) {
window.open(appUrl.value, '_blank', 'noopener,noreferrer')
if (isInlinePanel.value) emit('close')
else router.back()
else closeRouteSession()
return
}
@@ -518,6 +534,12 @@ onBeforeUnmount(() => {
opacity: 0;
}
.app-session-frame-scroll-host {
overflow: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
/* Mobile: full-bleed app sessions — no border, no radius, no shadow */
@media (max-width: 767px) {
.app-session-root {
+57 -2
View File
@@ -106,10 +106,34 @@
</div>
<!-- No Results -->
<div v-if="filteredPackageEntries.length === 0 && searchQuery" class="text-center py-12">
<div v-if="filteredPackageEntries.length === 0 && marketplaceMatches.length === 0 && searchQuery" class="text-center py-12">
<p class="text-white/70">{{ t('apps.noResults', { query: searchQuery }) }}</p>
</div>
<div v-if="marketplaceMatches.length > 0" class="mb-5">
<div class="flex items-center gap-3 mb-3">
<span class="discover-terminal-tag">app store</span>
<h2 class="text-lg font-bold text-white">Available in Discover</h2>
<div class="flex-1 h-px bg-white/10"></div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<button
v-for="app in marketplaceMatches"
:key="app.id"
type="button"
class="glass-card p-4 text-left flex items-center gap-3 hover:bg-orange-500/5 hover:border-orange-500/15 transition-colors"
@click="openMarketplaceResult(app)"
>
<img v-if="app.icon" :src="app.icon" :alt="app.title" class="w-12 h-12 rounded-xl object-cover bg-white/10" />
<div v-else class="w-12 h-12 rounded-xl bg-white/10 flex-shrink-0"></div>
<div class="min-w-0 flex-1">
<p class="font-semibold text-white truncate">{{ app.title }}</p>
<p class="text-xs text-white/50 truncate">Available in App Store</p>
</div>
</button>
</div>
</div>
<!-- Mobile: iPhone-style icon grid -->
<div class="md:hidden">
<AppIconGrid
@@ -236,10 +260,12 @@ import AppCard from './apps/AppCard.vue'
import AppIconGrid from './apps/AppIconGrid.vue'
import AppsUninstallModal from './apps/AppsUninstallModal.vue'
import { useAppsActions } from './apps/useAppsActions'
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
import {
type AppsTab, filterEntriesForTab, isWebOnlyApp, isWebsitePackage, opensInTab, resolveRuntimeLaunchUrl,
WEB_ONLY_APPS, WEB_ONLY_APP_URLS, buildAllCategories, useCategoriesWithApps,
} from './apps/appsConfig'
import { getCuratedAppList, INSTALLED_ALIASES, type MarketplaceApp } from './marketplace/marketplaceData'
const { t } = useI18n()
const router = useRouter()
@@ -247,6 +273,7 @@ const route = useRoute()
const store = useAppStore()
const serverStore = useServerStore()
const actions = useAppsActions()
const { setCurrentApp } = useMarketplaceApp()
const showSideload = ref(false)
const sideloading = ref(false)
const sideloadError = ref('')
@@ -266,6 +293,10 @@ const activeTab = ref<AppsTab>(
route.query.tab === 'websites' || route.query.tab === 'services' ? 'websites' : 'apps'
)
watch(() => route.query.tab, (tab) => {
activeTab.value = tab === 'websites' || tab === 'services' ? 'websites' : 'apps'
})
// Search (debounced)
const searchQuery = ref('')
const debouncedSearchQuery = ref('')
@@ -309,6 +340,19 @@ const packages = computed(() => {
const categoriesWithApps = useCategoriesWithApps(packages, ALL_CATEGORIES)
const curatedApps = getCuratedAppList()
const marketplaceMatches = computed(() => {
const q = debouncedSearchQuery.value.trim().toLowerCase()
if (!q || activeTab.value !== 'apps') return [] as MarketplaceApp[]
return curatedApps.filter(app => {
if (isInstalledInMyApps(app.id)) return false
return app.title?.toLowerCase().includes(q) ||
app.id.toLowerCase().includes(q) ||
app.author?.toLowerCase().includes(q) ||
(typeof app.description === 'string' && app.description.toLowerCase().includes(q))
}).slice(0, 6)
})
const isLoadingApps = computed(() => !store.hasLoadedInitialData && !connectionError.value)
// Connection error state
@@ -352,6 +396,17 @@ const filteredPackageEntries = computed(() => {
)
})
function isInstalledInMyApps(appId: string): boolean {
if (appId in packages.value) return true
const aliases = INSTALLED_ALIASES[appId]
return aliases ? aliases.some(alias => alias in packages.value) : false
}
function openMarketplaceResult(app: MarketplaceApp) {
setCurrentApp(app)
router.push({ name: 'marketplace-app-detail', params: { id: app.id }, query: { from: 'apps' } }).catch(() => {})
}
// Uninstall modal
const uninstallModal = ref({ show: false, appId: '', appTitle: '' })
@@ -464,7 +519,7 @@ async function submitSideload() {
version: 'sideload',
containerConfig,
},
timeout: 15000,
timeout: 600000,
})
closeSideload()
sideloadForm.value = { id: '', image: '', title: '', port: '', description: '' }
+11
View File
@@ -67,6 +67,9 @@
data-controller-zone="main"
class="flex-1 overflow-hidden relative pb-0 glass-piece z-10"
:class="{ 'glass-throw-main': showZoomIn }"
tabindex="-1"
@pointerenter="activateMainScroll"
@wheel.capture="activateMainScroll"
>
<div data-controller-main-entry class="absolute top-4 right-4 md:top-6 md:right-8 z-20">
<!-- Controller zone entry point - no switcher -->
@@ -234,6 +237,14 @@ function restoreScroll(path: string) {
})
}
function activateMainScroll() {
const active = document.activeElement as HTMLElement | null
if (active?.closest?.('[data-controller-zone="sidebar"]')) {
active.blur()
document.getElementById('main-content')?.focus({ preventScroll: true })
}
}
watch(() => route.path, (newPath) => {
const isAppDetails = isDetailRoute(newPath)
const wasAppDetails = showAltBackground.value
+21 -17
View File
@@ -33,8 +33,22 @@
/>
</div>
<!-- Mobile: search -->
<!-- Mobile: categories + search -->
<div class="md:hidden mb-4">
<div class="flex items-center gap-2 mb-3">
<span class="discover-terminal-tag">discover</span>
<h1 class="text-lg font-bold text-white">App Store</h1>
</div>
<div class="mobile-category-strip mb-3" aria-label="App Store categories">
<button class="mobile-category-pill mobile-category-pill-active" type="button">Discover</button>
<button
v-for="category in categoriesWithApps"
:key="category.id"
@click="navigateToMarketplace(category.id)"
class="mobile-category-pill"
type="button"
>{{ category.name }}</button>
</div>
<input
v-model="searchQuery"
type="text"
@@ -163,11 +177,6 @@
<p class="text-white/60 text-xl mt-4 font-mono">// Cypherpunks write code. We run nodes.</p>
</div>
<FilterModal
:categories="categoriesWithApps"
:selected-category="selectedCategory"
@select-category="selectCategory"
/>
</div>
</template>
@@ -187,7 +196,6 @@ import { useToast } from '@/composables/useToast'
import DiscoverHero from './discover/DiscoverHero.vue'
import FeaturedApps from './discover/FeaturedApps.vue'
import AppGrid from './discover/AppGrid.vue'
import FilterModal from './discover/FilterModal.vue'
import type { MarketplaceApp, FeaturedApp } from './discover/types'
import { getCuratedAppList, INSTALLED_ALIASES, FEATURED_DEFINITIONS, categorizeCommunityApp, fetchAppCatalog, type CatalogFeatured } from './discover/curatedApps'
@@ -224,13 +232,6 @@ const categories = computed(() => [
// been removed in favour of the store's phase-aware mapping.
const installingApps = serverStore.installingApps
function selectCategory(id: string) {
selectedCategory.value = id
if (id === 'nostr' && nostrApps.value.length === 0 && !nostrLoading.value) {
loadNostrMarketplace()
}
}
function navigateToMarketplace(categoryId: string) {
router.push({ name: 'marketplace', query: { category: categoryId } })
}
@@ -312,6 +313,9 @@ const categoriesWithApps = computed(() => {
const filteredApps = computed(() => {
let apps = allApps.value
if (selectedCategory.value && selectedCategory.value !== 'all' && !searchQuery.value) {
apps = apps.filter(app => app.category === selectedCategory.value)
}
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
apps = apps.filter(app =>
@@ -383,7 +387,7 @@ function isStartingUp(appId: string): boolean {
function getAppTier(appId: string): string {
const core = ['bitcoin-knots', 'bitcoin', 'lnd', 'mempool', 'btcpay-server', 'dwn', 'filebrowser']
const recommended = ['fedimint', 'thunderhub', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'portainer']
const recommended = ['fedimint', 'thunderhub', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'netbird', 'portainer']
if (core.includes(appId)) return 'core'
if (recommended.includes(appId)) return 'recommended'
return 'optional'
@@ -487,7 +491,7 @@ async function installApp(app: MarketplaceApp) {
router.push('/dashboard/apps').catch(() => {})
try {
const installUrl = app.url || app.manifestUrl || app.s9pkUrl
await rpcClient.call({ method: 'package.install', params: { id: app.id, url: installUrl, version: app.version }, timeout: 15000 })
await rpcClient.call({ method: 'package.install', params: { id: app.id, url: installUrl, version: app.version }, timeout: 600000 })
} catch (err) {
if (import.meta.env.DEV) console.error('Installation failed:', err)
failInstall(app, err)
@@ -504,7 +508,7 @@ async function installCommunityApp(app: MarketplaceApp) {
if ((app as Record<string, unknown>).containerConfig) {
installParams.containerConfig = (app as Record<string, unknown>).containerConfig
}
await rpcClient.call({ method: 'package.install', params: installParams, timeout: 15000 })
await rpcClient.call({ method: 'package.install', params: installParams, timeout: 600000 })
} catch (err) {
if (import.meta.env.DEV) console.error('[Discover] Installation failed:', err)
failInstall(app, err)
+34 -11
View File
@@ -35,8 +35,27 @@
/>
</div>
<!-- Mobile: search (tabs handled by Dashboard.vue header) -->
<!-- Mobile: categories + search (tabs handled by Dashboard.vue header) -->
<div class="md:hidden mb-4">
<div class="flex items-center gap-2 mb-3">
<span class="discover-terminal-tag">discover</span>
<h1 class="text-lg font-bold text-white">App Store</h1>
</div>
<div class="mobile-category-strip mb-3" aria-label="App Store categories">
<button
@click="router.push({ name: 'discover' })"
class="mobile-category-pill"
type="button"
>Discover</button>
<button
v-for="category in categoriesWithApps"
:key="category.id"
@click="selectCategory(category.id)"
class="mobile-category-pill"
:class="{ 'mobile-category-pill-active': selectedCategory === category.id }"
type="button"
>{{ category.name }}</button>
</div>
<input
v-model="searchQuery"
type="text"
@@ -96,11 +115,6 @@
</div>
<!-- End Scrollable Apps Section -->
<MarketplaceFilterModal
:categories="categoriesWithApps"
:selected-category="selectedCategory"
@select="selectCategory"
/>
</div>
</template>
@@ -109,7 +123,7 @@ let marketplaceAnimationDone = false
</script>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRouter, useRoute, RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
@@ -119,7 +133,6 @@ import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
import { useAppLauncherStore } from '@/stores/appLauncher'
import { useToast } from '@/composables/useToast'
import MarketplaceAppCard from './marketplace/MarketplaceAppCard.vue'
import MarketplaceFilterModal from './marketplace/MarketplaceFilterModal.vue'
import {
type MarketplaceApp,
INSTALLED_ALIASES,
@@ -166,11 +179,21 @@ const electrumxArchiveWarning = 'You need a full archival bitcoin node before do
// Select category and trigger Nostr relay discovery when 'nostr' is chosen
function selectCategory(id: string) {
selectedCategory.value = id
const query = id === 'all' ? {} : { category: id }
router.replace({ name: 'marketplace', query }).catch(() => {})
if (id === 'nostr' && nostrApps.value.length === 0 && !nostrLoading.value) {
loadNostrMarketplace()
}
}
watch(() => route.query.category, (category) => {
const next = typeof category === 'string' && category ? category : 'all'
selectedCategory.value = next
if (next === 'nostr' && nostrApps.value.length === 0 && !nostrLoading.value) {
loadNostrMarketplace()
}
})
// Community marketplace state
const loadingCommunity = ref(false)
const communityError = ref('')
@@ -258,7 +281,7 @@ const categoriesWithApps = computed(() => {
const filteredApps = computed(() => {
let apps = allApps.value
if (selectedCategory.value && selectedCategory.value !== 'all') {
if (selectedCategory.value && selectedCategory.value !== 'all' && !searchQuery.value) {
apps = apps.filter(app => app.category === selectedCategory.value)
}
@@ -415,7 +438,7 @@ async function installApp(app: MarketplaceApp) {
await rpcClient.call({
method: 'package.install',
params: { id: app.id, url: installUrl, version: app.version },
timeout: 15000,
timeout: 600000,
})
} catch (err) {
if (import.meta.env.DEV) console.error('Installation failed:', err)
@@ -441,7 +464,7 @@ async function installCommunityApp(app: MarketplaceApp) {
await rpcClient.call({
method: 'package.install',
params: installParams,
timeout: 15000,
timeout: 600000,
})
} catch (err) {
if (import.meta.env.DEV) console.error('[Marketplace] Installation failed:', err)
+2 -2
View File
@@ -616,7 +616,7 @@ async function installApp() {
await rpcClient.call({
method: 'package.install',
params: installParams,
timeout: 15000,
timeout: 600000,
})
} else {
// Package-based installation
@@ -628,7 +628,7 @@ async function installApp() {
url: installUrl,
version: app.value.version,
},
timeout: 15000,
timeout: 600000,
})
}
+88 -86
View File
@@ -344,94 +344,96 @@
</Transition>
</Teleport>
<!-- Add-mirror modal -->
<Transition name="fade">
<div v-if="addingMirror" class="fixed inset-0 z-50 flex items-center justify-center bg-black/10 backdrop-blur-md" @click.self="cancelAddMirror">
<div class="glass-card p-6 max-w-md w-full mx-4">
<h3 class="text-lg font-semibold text-white mb-1">Add update mirror</h3>
<p class="text-sm text-white/60 mb-5">
The URL should point directly at a <span class="font-mono text-white/80">manifest.json</span> served by a Gitea mirror or equivalent. It's added to the end of the list; use "Make primary" to change order.
</p>
<form class="space-y-3" @submit.prevent="submitMirror">
<div>
<label class="block text-xs text-white/60 mb-1">Manifest URL</label>
<input
v-model="mirrorDraft.url"
type="text"
autofocus
placeholder="https://host/.../manifest.json"
class="w-full px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none font-mono"
/>
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Label (optional)</label>
<input
v-model="mirrorDraft.label"
type="text"
placeholder="Home VPS"
class="w-full px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none"
/>
</div>
<div class="flex gap-3 justify-end pt-2">
<button
type="button"
@click="cancelAddMirror"
class="glass-button rounded-lg px-4 py-2 text-sm font-medium"
>{{ t('common.cancel') }}</button>
<button
type="submit"
class="glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="mirrorSaving || !mirrorDraft.url.trim()"
>{{ mirrorSaving ? 'Adding…' : 'Add mirror' }}</button>
</div>
</form>
</div>
</div>
</Transition>
<!-- Confirmation modal -->
<Transition name="fade">
<div v-if="confirmAction" class="fixed inset-0 z-50 flex items-center justify-center bg-black/10 backdrop-blur-md" @click.self="cancelConfirm">
<div class="glass-card p-6 max-w-sm w-full mx-4">
<h3 class="text-lg font-semibold text-white mb-3">
{{ confirmAction === 'rollback'
? t('systemUpdate.rollbackTitle')
: confirmAction === 'git-apply'
? t('systemUpdate.gitApplyTitle')
: confirmAction === 'cancel-download'
? t('systemUpdate.cancelDownloadTitle')
: t('systemUpdate.applyTitle') }}
</h3>
<p class="text-sm text-white/70 mb-6">
{{ confirmAction === 'rollback'
? t('systemUpdate.rollbackMessage')
: confirmAction === 'git-apply'
? t('systemUpdate.gitApplyMessage')
: confirmAction === 'cancel-download'
? t('systemUpdate.cancelDownloadConfirm')
: t('systemUpdate.applyMessage') }}
</p>
<div class="flex gap-3 justify-end">
<button @click="cancelConfirm" class="glass-button rounded-lg px-4 py-2 text-sm font-medium">
{{ t('common.cancel') }}
</button>
<button
@click="executeConfirm"
class="glass-button rounded-lg px-4 py-2 text-sm font-medium"
:class="(confirmAction === 'rollback' || confirmAction === 'cancel-download') ? 'bg-red-500/20 border-red-400/30' : 'bg-orange-500/20 border-orange-400/30'"
>
{{ confirmAction === 'rollback'
? t('systemUpdate.rollbackButton')
: confirmAction === 'git-apply'
? t('systemUpdate.pullAndRebuild')
: confirmAction === 'cancel-download'
? t('systemUpdate.cancelDownloadButton')
: t('systemUpdate.applyNow') }}
</button>
<Teleport to="body">
<!-- Add-mirror modal -->
<Transition name="fade">
<div v-if="addingMirror" class="fixed inset-0 z-[3000] flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="cancelAddMirror">
<div class="glass-card p-6 max-w-md w-full">
<h3 class="text-lg font-semibold text-white mb-1">Add update mirror</h3>
<p class="text-sm text-white/60 mb-5">
The URL should point directly at a <span class="font-mono text-white/80">manifest.json</span> served by a Gitea mirror or equivalent. It's added to the end of the list; use "Make primary" to change order.
</p>
<form class="space-y-3" @submit.prevent="submitMirror">
<div>
<label class="block text-xs text-white/60 mb-1">Manifest URL</label>
<input
v-model="mirrorDraft.url"
type="text"
autofocus
placeholder="https://host/.../manifest.json"
class="w-full px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none font-mono"
/>
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Label (optional)</label>
<input
v-model="mirrorDraft.label"
type="text"
placeholder="Home VPS"
class="w-full px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none"
/>
</div>
<div class="flex gap-3 justify-end pt-2">
<button
type="button"
@click="cancelAddMirror"
class="glass-button rounded-lg px-4 py-2 text-sm font-medium"
>{{ t('common.cancel') }}</button>
<button
type="submit"
class="glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="mirrorSaving || !mirrorDraft.url.trim()"
>{{ mirrorSaving ? 'Adding…' : 'Add mirror' }}</button>
</div>
</form>
</div>
</div>
</div>
</Transition>
</Transition>
<!-- Confirmation modal -->
<Transition name="fade">
<div v-if="confirmAction" class="fixed inset-0 z-[3000] flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="cancelConfirm">
<div class="glass-card p-6 max-w-sm w-full">
<h3 class="text-lg font-semibold text-white mb-3">
{{ confirmAction === 'rollback'
? t('systemUpdate.rollbackTitle')
: confirmAction === 'git-apply'
? t('systemUpdate.gitApplyTitle')
: confirmAction === 'cancel-download'
? t('systemUpdate.cancelDownloadTitle')
: t('systemUpdate.applyTitle') }}
</h3>
<p class="text-sm text-white/70 mb-6">
{{ confirmAction === 'rollback'
? t('systemUpdate.rollbackMessage')
: confirmAction === 'git-apply'
? t('systemUpdate.gitApplyMessage')
: confirmAction === 'cancel-download'
? t('systemUpdate.cancelDownloadConfirm')
: t('systemUpdate.applyMessage') }}
</p>
<div class="flex gap-3 justify-end">
<button @click="cancelConfirm" class="glass-button rounded-lg px-4 py-2 text-sm font-medium">
{{ t('common.cancel') }}
</button>
<button
@click="executeConfirm"
class="glass-button rounded-lg px-4 py-2 text-sm font-medium"
:class="(confirmAction === 'rollback' || confirmAction === 'cancel-download') ? 'bg-red-500/20 border-red-400/30' : 'bg-orange-500/20 border-orange-400/30'"
>
{{ confirmAction === 'rollback'
? t('systemUpdate.rollbackButton')
: confirmAction === 'git-apply'
? t('systemUpdate.pullAndRebuild')
: confirmAction === 'cancel-download'
? t('systemUpdate.cancelDownloadButton')
: t('systemUpdate.applyNow') }}
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
@@ -5,7 +5,7 @@
<img
:src="icon"
:alt="pkg.manifest.title"
class="w-20 h-20 rounded-xl shadow-xl flex-shrink-0"
class="app-detail-icon w-20 h-20 shadow-xl flex-shrink-0"
@error="handleImageError"
/>
@@ -119,7 +119,7 @@
<img
:src="icon"
:alt="pkg.manifest.title"
class="w-20 h-20 rounded-xl shadow-xl flex-shrink-0"
class="app-detail-icon w-20 h-20 shadow-xl flex-shrink-0"
@error="handleImageError"
/>
@@ -44,6 +44,7 @@ export const ROUTE_TO_PACKAGE_KEY: Record<string, string> = {
portainer: 'portainer',
'uptime-kuma': 'uptime-kuma',
tailscale: 'tailscale',
netbird: 'netbird',
}
/** Backend may register under variant container names */
@@ -9,16 +9,24 @@
</div>
</Transition>
<iframe
<div
v-if="appUrl && !iframeBlocked"
ref="iframeRef"
:key="refreshKey"
:src="appUrl"
class="absolute inset-0 w-full h-full border-0 iframe-scrollbar-hide"
title="App content"
@load="$emit('iframeLoad')"
@error="$emit('iframeError')"
/>
class="absolute inset-0 app-session-frame-scroll-host"
tabindex="-1"
@pointerdown="focusIframe"
@focusin="focusIframe"
>
<iframe
ref="iframeRef"
:key="refreshKey"
:src="appUrl"
class="w-full h-full border-0 iframe-scrollbar-hide"
title="App content"
tabindex="0"
@load="handleIframeLoad"
@error="$emit('iframeError')"
/>
</div>
<!-- Iframe blocked fallback -->
<Transition name="content-fade">
@@ -69,9 +77,9 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { nextTick, ref, watch } from 'vue'
defineProps<{
const props = defineProps<{
appUrl: string
appId: string
appTitle: string
@@ -82,7 +90,7 @@ defineProps<{
refreshKey: number
}>()
defineEmits<{
const emit = defineEmits<{
iframeLoad: []
iframeError: []
refresh: []
@@ -91,5 +99,21 @@ defineEmits<{
const iframeRef = ref<HTMLIFrameElement | null>(null)
function focusIframe() {
iframeRef.value?.focus({ preventScroll: true })
}
async function handleIframeLoad() {
emit('iframeLoad')
await nextTick()
requestAnimationFrame(focusIframe)
}
watch(() => [props.appUrl, props.refreshKey, props.iframeBlocked], async () => {
if (!props.appUrl || props.iframeBlocked) return
await nextTick()
requestAnimationFrame(focusIframe)
}, { immediate: true })
defineExpose({ iframeRef })
</script>
@@ -14,6 +14,7 @@ export const APP_PORTS: Record<string, number> = {
'archy-electrs-ui': 50002,
'mempool-electrs': 50002,
'btcpay-server': 23000,
'saleor': 9010,
'lnd': 18083,
'archy-lnd-ui': 18083,
'mempool': 4080,
@@ -34,6 +35,7 @@ export const APP_PORTS: Record<string, number> = {
'nginx-proxy-manager': 8081,
'gitea': 3001,
'portainer': 9000,
'netbird': 8087,
'tailscale': 8240,
'uptime-kuma': 3002,
'fedimint': 8175,
@@ -70,7 +72,7 @@ export const EXTERNAL_URLS: Record<string, string> = {
export const APP_TITLES: Record<string, string> = {
'bitcoin-knots': 'Bitcoin Knots', 'bitcoin-core': 'Bitcoin Core',
'btcpay-server': 'BTCPay Server', 'indeedhub': 'Indeehub',
'btcpay-server': 'BTCPay Server', 'saleor': 'Saleor', 'indeedhub': 'Indeehub',
'botfights': 'BotFights', 'gitea': 'Gitea', '484-kitchen': '484 Kitchen', 'arch-presentation': 'Presentation',
'homeassistant': 'Home Assistant', 'uptime-kuma': 'Uptime Kuma',
'nginx-proxy-manager': 'Nginx Proxy Manager',
@@ -98,7 +100,9 @@ export const NEW_TAB_APPS = new Set([
export const IFRAME_BLOCKED_APPS = new Set<string>([])
/** Resolve app URL using direct port mapping (source of truth) */
export function resolveAppUrl(id: string, routeQueryPath?: string): string {
export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?: string): string {
if (id === 'netbird' && runtimeUrl) return runtimeUrl
// External HTTPS apps
const ext = EXTERNAL_URLS[id]
if (ext) return ext
+3 -3
View File
@@ -31,7 +31,7 @@
<img
:src="icon"
:alt="title"
class="w-14 h-14 rounded-lg object-cover bg-white/10"
class="app-card-icon w-14 h-14 object-cover bg-white/10"
@error="handleImageError"
/>
<div class="flex-1 min-w-0 overflow-hidden">
@@ -145,7 +145,7 @@
class="flex-1 px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium flex items-center justify-center gap-1.5"
>
{{ t('common.launch') }}
<svg v-if="opensInTab(id)" class="w-3.5 h-3.5 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>
<svg v-if="opensInTab(id)" class="hidden md:block w-3.5 h-3.5 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>
</button>
<!-- Start (play icon) -->
<button
@@ -266,7 +266,7 @@ const tier = computed(() => {
const t = props.pkg.manifest?.tier
if (t && t !== '') return t
const core = ['bitcoin-knots', 'bitcoin', 'lnd', 'mempool', 'btcpay-server', 'dwn', 'filebrowser']
const recommended = ['fedimint', 'thunderhub', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'portainer']
const recommended = ['fedimint', 'thunderhub', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'netbird', 'portainer']
if (core.includes(props.id)) return 'core'
if (recommended.includes(props.id)) return 'recommended'
return 'optional'
@@ -35,6 +35,11 @@ describe('AppIconGrid', () => {
setActivePinia(createPinia())
vi.clearAllMocks()
localStorage.clear()
Object.defineProperty(window, 'innerWidth', {
value: 1024,
writable: true,
configurable: true,
})
Object.defineProperty(window, 'location', {
value: { hostname: '192.168.1.198' },
writable: true,
@@ -55,4 +60,24 @@ describe('AppIconGrid', () => {
expect(mockWindowOpen).not.toHaveBeenCalled()
expect(useAppLauncherStore().panelAppId).toBe('lnd')
})
it('routes desktop new-tab apps through app session on mobile', async () => {
Object.defineProperty(window, 'innerWidth', {
value: 390,
writable: true,
configurable: true,
})
const wrapper = mount(AppIconGrid, {
props: { apps: [['gitea', makePkg('gitea')]] },
global: {
plugins: [createPinia()],
},
})
await wrapper.get('.app-icon-item').trigger('click')
expect(mockWindowOpen).not.toHaveBeenCalled()
expect(useAppLauncherStore().panelAppId).toBeNull()
})
})
+5 -4
View File
@@ -50,14 +50,14 @@ export function isServicePackage(id: string, pkg?: PackageDataEntry): boolean {
// Known app -> category mappings (matches App Store categorisation)
export const APP_CATEGORY_MAP: Record<string, string> = {
'bitcoin-knots': 'money', 'bitcoin-ui': 'money', 'electrumx': 'money', 'electrs': 'money',
'lnd': 'money', 'mempool': 'money', 'mempool-web': 'money', 'btcpay-server': 'commerce',
'lnd': 'money', 'mempool': 'money', 'mempool-web': 'money', 'btcpay-server': 'commerce', 'saleor': 'commerce',
'fedimint': 'money', 'fedimint-gateway': 'money',
'indeedhub': 'media', 'jellyfin': 'media', 'photoprism': 'media', 'immich': 'media',
'nextcloud': 'data', 'vaultwarden': 'data', 'filebrowser': 'data', 'cryptpad': 'data',
'homeassistant': 'home', 'lorabell': 'home', 'endurain': 'home',
'searxng': 'community', 'ollama': 'community', 'grafana': 'data',
'searxng': 'community', 'ollama': 'community', 'grafana': 'data', 'gitea': 'data',
'nostrudel': 'nostr',
'tailscale': 'networking', 'nginx-proxy-manager': 'networking', 'portainer': 'networking',
'tailscale': 'networking', 'netbird': 'networking', 'nginx-proxy-manager': 'networking', 'portainer': 'networking',
'uptime-kuma': 'networking', 'dwn': 'data',
'botfights': 'community', 'nwnn': 'l484', '484-kitchen': 'l484',
'call-the-operator': 'l484', 'syntropy-institute': 'l484', 't-zero': 'l484',
@@ -166,7 +166,8 @@ const APP_ICON_FALLBACKS: Record<string, string> = {
}
export function resolveAppIcon(id: string, pkg: PackageDataEntry, curatedIcon?: string): string {
const icon = (pkg["static-files"]?.icon || "").trim()
const rawIcon = (pkg["static-files"]?.icon || "").trim()
const icon = rawIcon === '/assets/img/favico.png' ? '' : rawIcon
if (
icon.startsWith("/") ||
icon.startsWith("http://") ||
@@ -22,6 +22,7 @@
to="/dashboard/apps?tab=websites"
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': route.query.tab === 'services' || route.query.tab === 'websites' }"
@click.prevent="router.push({ path: '/dashboard/apps', query: { tab: 'websites' } })"
>Websites</RouterLink>
</div>
</div>
@@ -142,7 +143,7 @@ const mobileTabBar = ref<HTMLElement | null>(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(() => {
@@ -2,11 +2,11 @@
<aside
v-show="!chatFullscreen"
data-controller-zone="sidebar"
class="hidden md:flex w-[256px] flex-shrink-0 relative flex-col z-10"
class="hidden md:flex w-[256px] h-screen flex-shrink-0 sticky top-0 relative flex-col z-10"
:class="{ 'sidebar-animate': showZoomIn }"
>
<div class="sidebar-shell">
<div class="sidebar-inner flex flex-col min-h-full">
<div class="sidebar-inner flex flex-col h-full min-h-0">
<div class="sidebar-logo flex items-center gap-3 mb-8 p-6 pb-0 shrink-0">
<AnimatedLogo />
<div class="min-w-0 flex-1">
@@ -15,7 +15,7 @@
</div>
</div>
<nav class="sidebar-nav flex-1 min-h-0 space-y-2 p-6 pt-4" :aria-label="t('dashboard.mainNav')">
<nav class="sidebar-nav flex-1 min-h-0 overflow-y-auto overscroll-contain space-y-2 px-6 py-4" :aria-label="t('dashboard.mainNav')">
<RouterLink
v-for="(item, idx) in desktopNavItems"
:key="item.path"
@@ -74,21 +74,23 @@
</button>
</nav>
<div class="sidebar-controller px-6 pb-2 shrink-0">
<ControllerIndicator />
<CompanionIndicator />
</div>
<!-- Online status -->
<div class="px-6 pb-2 shrink-0">
<div class="rounded-lg bg-white/5 border border-white/10 px-4 py-2.5">
<OnlineStatusPill />
<div class="sidebar-bottom shrink-0">
<div class="sidebar-controller px-6 pb-2">
<ControllerIndicator />
<CompanionIndicator />
</div>
</div>
<!-- Mode switcher -->
<div class="px-6 pb-6 shrink-0">
<ModeSwitcher />
<!-- Online status -->
<div class="px-6 pb-2">
<div class="rounded-lg bg-white/5 border border-white/10 px-4 py-2.5">
<OnlineStatusPill />
</div>
</div>
<!-- Mode switcher -->
<div class="px-6 pb-6">
<ModeSwitcher />
</div>
</div>
</div>
</div>
@@ -47,7 +47,7 @@
.sidebar-shell {
width: 100%;
height: 100%;
min-height: 100vh;
min-height: 0;
background: rgba(0, 0, 0, 0.25);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
@@ -85,6 +85,24 @@
overflow: hidden;
}
.sidebar-nav {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.24) transparent;
}
.sidebar-nav::-webkit-scrollbar {
width: 6px;
}
.sidebar-nav::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.22);
border-radius: 999px;
}
.sidebar-bottom {
background: linear-gradient(to top, rgba(0, 0, 0, 0.18), transparent 100%);
}
/* Only hide sidebar content when doing the login entrance animation */
.sidebar-animate .sidebar-inner {
opacity: 0;
+3 -4
View File
@@ -4,8 +4,7 @@
<Teleport to="body">
<button
@click="showFilter = true"
class="md:hidden fixed right-4 z-40 w-14 h-14 rounded-full glass-button flex items-center justify-center shadow-2xl mobile-back-btn"
style="left: auto;"
class="md:hidden fixed right-4 z-[2400] w-14 h-14 rounded-full glass-button flex items-center justify-center shadow-2xl mobile-filter-btn"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
@@ -17,10 +16,10 @@
<Transition name="modal">
<div
v-if="showFilter"
class="fixed inset-0 z-50 flex items-end justify-center md:hidden bg-black/10 backdrop-blur-md"
class="fixed inset-0 z-[3000] flex items-end justify-center md:hidden bg-black/10 backdrop-blur-md"
@click.self="closeFilter"
>
<div ref="filterModalRef" class="glass-card p-6 w-full rounded-t-3xl max-h-[80vh] overflow-y-auto">
<div ref="filterModalRef" class="glass-card p-6 w-full rounded-t-3xl max-h-[80vh] overflow-y-auto mobile-filter-sheet">
<div class="flex items-center justify-between mb-6">
<h2 class="text-2xl font-bold text-white">Filter</h2>
<button @click="closeFilter" class="text-white/60 hover:text-white transition-colors">
+7 -3
View File
@@ -78,7 +78,8 @@ export function getCuratedAppList(): MarketplaceApp[] {
return [
{ id: 'bitcoin-knots', title: 'Bitcoin Knots', version: '28.1.0', description: 'Run a full Bitcoin node. Validate and relay blocks and transactions on the Bitcoin network.', icon: '/assets/img/app-icons/bitcoin-knots.webp', author: 'Bitcoin Knots', dockerImage: `${R}/bitcoin-knots:latest`, repoUrl: 'https://github.com/bitcoinknots/bitcoin' },
{ id: 'bitcoin-core', title: 'Bitcoin Core', version: '28.4', description: 'Reference implementation of the Bitcoin protocol. Run a full node validating and relaying blocks on the Bitcoin network.', icon: '/assets/img/app-icons/bitcoin-core.svg', author: 'Bitcoin Core contributors', dockerImage: 'docker.io/bitcoin/bitcoin:28.4', repoUrl: 'https://github.com/bitcoin/bitcoin' },
{ id: 'btcpay-server', title: 'BTCPay Server', version: '1.13.7', description: 'Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries or fees.', icon: '/assets/img/app-icons/btcpay-server.png', author: 'BTCPay Server Foundation', dockerImage: `${R}/btcpayserver:1.13.7`, repoUrl: 'https://github.com/btcpayserver/btcpayserver' },
{ id: 'btcpay-server', title: 'BTCPay Server', version: '2.3.9', description: 'Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries or fees.', icon: '/assets/img/app-icons/btcpay-server.png', author: 'BTCPay Server Foundation', dockerImage: 'docker.io/btcpayserver/btcpayserver:2.3.9', repoUrl: 'https://github.com/btcpayserver/btcpayserver' },
{ id: 'saleor', title: 'Saleor', version: '3.23', category: 'commerce', description: 'Composable commerce platform with GraphQL API, dashboard, worker, mail testing, and tracing.', icon: '/assets/img/app-icons/saleor.svg', author: 'Saleor', dockerImage: 'ghcr.io/saleor/saleor:3.23', repoUrl: 'https://github.com/saleor/saleor' },
{ id: 'lnd', title: 'LND', version: '0.18.4', description: 'Lightning Network Daemon. Fast and cheap Bitcoin payments through the Lightning Network.', icon: '/assets/img/app-icons/lnd.svg', author: 'Lightning Labs', dockerImage: `${R}/lnd:v0.18.4-beta`, repoUrl: 'https://github.com/lightningnetwork/lnd' },
{ id: 'mempool', title: 'Mempool Explorer', version: '3.0.0', description: 'Self-hosted Bitcoin blockchain and mempool visualizer. Monitor transactions without revealing your addresses to third parties.', icon: '/assets/img/app-icons/mempool.webp', author: 'Mempool', dockerImage: `${R}/mempool-frontend:v3.0.0`, repoUrl: 'https://github.com/mempool/mempool' },
{ id: 'homeassistant', title: 'Home Assistant', version: '2024.1', description: 'Open-source home automation. Control smart home devices privately, on your own hardware.', icon: '/assets/img/app-icons/homeassistant.png', author: 'Home Assistant', dockerImage: `${R}/home-assistant:2024.1`, repoUrl: 'https://github.com/home-assistant/core' },
@@ -96,6 +97,7 @@ export function getCuratedAppList(): MarketplaceApp[] {
{ id: 'portainer', title: 'Portainer', version: '2.19.4', description: 'Container management UI. Manage your containerized services through the web.', icon: '/assets/img/app-icons/portainer.webp', author: 'Portainer', dockerImage: `${R}/portainer:latest`, repoUrl: 'https://github.com/portainer/portainer' },
{ id: 'uptime-kuma', title: 'Uptime Kuma', version: '1.23.0', description: 'Self-hosted uptime monitoring. Track HTTP, TCP, DNS, and more.', icon: '/assets/img/app-icons/uptime-kuma.webp', author: 'Uptime Kuma', dockerImage: `${R}/uptime-kuma:1`, repoUrl: 'https://github.com/louislam/uptime-kuma' },
{ id: 'tailscale', title: 'Tailscale', version: '1.78.0', description: 'Zero-config VPN. Secure remote access with WireGuard mesh networking.', icon: '/assets/img/app-icons/tailscale.webp', author: 'Tailscale', dockerImage: `${R}/tailscale:stable`, repoUrl: 'https://github.com/tailscale/tailscale' },
{ id: 'netbird', title: 'NetBird', version: '0.71.2', description: 'Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN.', icon: '/assets/img/app-icons/netbird.svg', author: 'NetBird', dockerImage: 'docker.io/netbirdio/dashboard:v2.38.0', repoUrl: 'https://github.com/netbirdio/netbird' },
{ id: 'electrumx', title: 'ElectrumX', version: '1.18.0', description: 'Electrum protocol server. Index the blockchain for fast wallet lookups, privately.', icon: '/assets/img/app-icons/electrumx.png', author: 'Luke Childs', dockerImage: `${R}/electrumx:v1.18.0`, repoUrl: 'https://github.com/spesmilo/electrumx' },
{ id: 'fedimint', title: 'Fedimint', version: '0.10.0', description: 'Federated Bitcoin mint. Private, scalable Bitcoin through federated guardians.', icon: '/assets/img/app-icons/fedimint.png', author: 'Fedimint', dockerImage: `${R}/fedimintd:v0.10.0`, repoUrl: 'https://github.com/fedimint/fedimint' },
{ id: 'indeedhub', title: 'Indeehub', version: '1.0.0', description: 'Bitcoin documentary streaming with Nostr identity. Stream sovereignty content.', icon: '/assets/img/app-icons/indeedhub.png', author: 'Indeehub Team', dockerImage: `${R}/indeedhub:1.0.0`, repoUrl: 'https://github.com/indeedhub/indeedhub' },
@@ -119,6 +121,7 @@ export const INSTALLED_ALIASES: Record<string, string[]> = {
mempool: ['mempool', 'mempool-web', 'archy-mempool-web'],
bitcoin: ['bitcoin-knots'],
btcpay: ['btcpay-server'],
saleor: ['saleor'],
immich: ['immich-server', 'immich-app', 'immich_server'],
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
fedimint: ['fedimint-gateway'],
@@ -132,6 +135,7 @@ export const INSTALLED_ALIASES: Record<string, string[]> = {
lnd: ['lnd'],
filebrowser: ['filebrowser'],
tailscale: ['tailscale'],
netbird: ['netbird'],
ollama: ['ollama'],
indeedhub: ['indeedhub'],
botfights: ['botfights'],
@@ -187,11 +191,11 @@ export function categorizeCommunityApp(app: MarketplaceApp): string {
const combined = `${id} ${title} ${description}`
if (id.includes('bitcoin') || id.includes('btc') || id.includes('lightning') || id.includes('lnd') || id.includes('electr') || id.includes('fedimint') || id.includes('cashu') || combined.includes('wallet')) return 'money'
if (id.includes('btcpay') || id.includes('commerce') || id.includes('shop') || id.includes('pos') || combined.includes('merchant')) return 'commerce'
if (id.includes('btcpay') || id.includes('saleor') || id.includes('commerce') || id.includes('shop') || id.includes('pos') || combined.includes('merchant')) return 'commerce'
if (id.includes('cloud') || id.includes('nextcloud') || id.includes('storage') || id.includes('file') || id.includes('photo') || id.includes('immich') || id.includes('jellyfin') || id.includes('media') || id.includes('vault') || combined.includes('password manager')) return 'data'
if (id.includes('home-assistant') || id.includes('homeassistant') || combined.includes('home automation')) return 'home'
if (id.includes('nostr') || combined.includes('nostr relay')) return 'nostr'
if (id.includes('vpn') || id.includes('wireguard') || id.includes('tailscale') || id.includes('proxy') || id.includes('dns') || id.includes('tor') || combined.includes('network')) return 'networking'
if (id.includes('vpn') || id.includes('wireguard') || id.includes('tailscale') || id.includes('netbird') || id.includes('proxy') || id.includes('dns') || id.includes('tor') || combined.includes('network')) return 'networking'
if (id.includes('matrix') || id.includes('mastodon') || id.includes('chat') || id.includes('social') || combined.includes('messaging')) return 'community'
return 'other'
}
@@ -3,8 +3,7 @@
<Teleport to="body">
<button
@click="showModal = true"
class="md:hidden fixed right-4 z-40 w-14 h-14 rounded-full glass-button flex items-center justify-center shadow-2xl mobile-back-btn"
style="left: auto;"
class="md:hidden fixed right-4 z-[2400] w-14 h-14 rounded-full glass-button flex items-center justify-center shadow-2xl mobile-filter-btn"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
@@ -16,10 +15,10 @@
<Transition name="modal">
<div
v-if="showModal"
class="fixed inset-0 z-50 flex items-end justify-center md:hidden bg-black/10 backdrop-blur-md"
class="fixed inset-0 z-[3000] flex items-end justify-center md:hidden bg-black/10 backdrop-blur-md"
@click.self="close()"
>
<div ref="modalRef" class="glass-card p-6 w-full rounded-t-3xl max-h-[80vh] overflow-y-auto">
<div ref="modalRef" class="glass-card p-6 w-full rounded-t-3xl max-h-[80vh] overflow-y-auto mobile-filter-sheet">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<h2 class="text-2xl font-bold text-white">{{ t('marketplace.filterByCategory') }}</h2>
@@ -47,6 +47,7 @@ export const INSTALLED_ALIASES: Record<string, string[]> = {
mempool: ['mempool-web', 'mempool-api', 'archy-mempool-web', 'archy-mempool-db'],
bitcoin: ['bitcoin-knots'],
btcpay: ['btcpay-server', 'archy-btcpay-db', 'archy-nbxplorer'],
saleor: ['saleor'],
immich: ['immich-server', 'immich-app', 'immich_server', 'immich_postgres', 'immich_redis'],
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
fedimint: ['fedimint-gateway'],
@@ -60,13 +61,14 @@ export const INSTALLED_ALIASES: Record<string, string[]> = {
lnd: ['lnd', 'archy-lnd-ui'],
filebrowser: ['filebrowser'],
tailscale: ['tailscale'],
netbird: ['netbird'],
ollama: ['ollama'],
}
/** Get app tier classification (matches backend get_app_tier) */
export function getAppTier(appId: string): string {
const core = ['bitcoin-knots', 'bitcoin', 'lnd', 'mempool', 'btcpay-server', 'dwn', 'filebrowser']
const recommended = ['fedimint', 'thunderhub', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'portainer']
const recommended = ['fedimint', 'thunderhub', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'netbird', 'portainer', 'saleor']
if (core.includes(appId)) return 'core'
if (recommended.includes(appId)) return 'recommended'
return 'optional'
@@ -88,7 +90,7 @@ export function categorizeCommunityApp(app: MarketplaceApp): string {
return 'money'
}
if (id.includes('btcpay') || id.includes('commerce') || id.includes('shop') ||
if (id.includes('btcpay') || id.includes('saleor') || id.includes('commerce') || id.includes('shop') ||
id.includes('store') || id.includes('pos') || id.includes('payment') ||
combined.includes('merchant') || combined.includes('invoice')) {
return 'commerce'
@@ -113,7 +115,7 @@ export function categorizeCommunityApp(app: MarketplaceApp): string {
return 'nostr'
}
if (id.includes('vpn') || id.includes('wireguard') || id.includes('tailscale') ||
if (id.includes('vpn') || id.includes('wireguard') || id.includes('tailscale') || id.includes('netbird') ||
id.includes('proxy') || id.includes('dns') || id.includes('pihole') ||
id.includes('adguard') || id.includes('nginx') || id.includes('tor') ||
combined.includes('network') || combined.includes('firewall')) {
@@ -148,14 +150,26 @@ export function getCuratedAppList(): MarketplaceApp[] {
{
id: 'btcpay-server',
title: 'BTCPay Server',
version: '1.13.7',
version: '2.3.9',
description: 'Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries or fees.',
icon: '/assets/img/app-icons/btcpay-server.png',
author: 'BTCPay Server Foundation',
dockerImage: `${REGISTRY}/btcpayserver:1.13.7`,
dockerImage: 'docker.io/btcpayserver/btcpayserver:2.3.9',
manifestUrl: undefined,
repoUrl: 'https://github.com/btcpayserver/btcpayserver'
},
{
id: 'saleor',
title: 'Saleor',
version: '3.23',
category: 'commerce',
description: 'Composable commerce platform with GraphQL API, dashboard, worker, mail testing, and tracing.',
icon: '/assets/img/app-icons/saleor.svg',
author: 'Saleor',
dockerImage: 'ghcr.io/saleor/saleor:3.23',
manifestUrl: undefined,
repoUrl: 'https://github.com/saleor/saleor'
},
{
id: 'lnd',
title: 'LND',
@@ -365,6 +379,17 @@ export function getCuratedAppList(): MarketplaceApp[] {
manifestUrl: undefined,
repoUrl: 'https://github.com/tailscale/tailscale'
},
{
id: 'netbird',
title: 'NetBird',
version: '0.71.2',
description: 'Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN.',
icon: '/assets/img/app-icons/netbird.svg',
author: 'NetBird',
dockerImage: 'docker.io/netbirdio/dashboard:v2.38.0',
manifestUrl: undefined,
repoUrl: 'https://github.com/netbirdio/netbird'
},
{
id: 'fedimint',
title: 'Fedimint',
@@ -180,6 +180,114 @@ init()
</button>
</div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- v1.7.76-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.76-alpha</span>
<span class="text-xs text-white/40">May 20, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Saleor now installs on dashboard port 9010 instead of conflicting with Portainer on 9000, keeps its API on 8000, and starts cleanly in rootless Podman with the nginx capabilities it needs.</p>
<p>NetBird API and OAuth routes now proxy through the stable host-published server port, so login/logout routes survive a netbird-server restart without 502s from stale Podman DNS.</p>
<p>Mobile App Store categories are now visible as horizontal chips above the tab bar, Discover is reachable on mobile, category choices update the actual view, and apps that require a real tab open directly from the icon tap.</p>
</div>
</div>
<!-- v1.7.75-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.75-alpha</span>
<span class="text-xs text-white/40">May 19, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Saleor joined the App Store as a recommended commerce stack with dashboard, API, worker, PostgreSQL, Valkey, Mailpit, and Jaeger containers.</p>
<p>NetBird repair now rewrites the unified-origin config and recreates the browser-facing proxy/dashboard while preserving existing control-plane data.</p>
<p>Desktop dashboard scrolling hands focus back from the sidebar to the main content when the pointer or wheel moves over the main pane.</p>
</div>
</div>
<!-- v1.7.74-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.74-alpha</span>
<span class="text-xs text-white/40">May 19, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>App-session right panels now re-focus the iframe after load and when the frame area is activated, so scrolling works immediately after selecting an app or switching tabs on shorter screens.</p>
<p>NetBird now uses a unified local launch origin on port 8087 that serves the dashboard and proxies auth/API routes to the server, fixing the Unauthenticated and 404 logout/login loop.</p>
<p>Existing NetBird installs are repaired during adopt/start by rewriting the config files and creating the missing dashboard/proxy containers while preserving data.</p>
<p>Saleor is not in this release yet; it remains pending for separate validation.</p>
</div>
</div>
<!-- v1.7.73-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.73-alpha</span>
<span class="text-xs text-white/40">May 19, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Mobile apps that block iframe embedding now open directly in a browser tab instead of first landing in a broken in-shell webview.</p>
<p>App Store search covers all apps while searching, My Apps search can surface matching installable App Store entries, and mobile My Apps/Websites tab switching updates the view reliably.</p>
<p>NetBird installs prefer a 100.x tailnet address when available, and app sessions gained iframe auto-focus plus a scroll host for right-frame scrolling.</p>
</div>
</div>
<!-- v1.7.72-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.72-alpha</span>
<span class="text-xs text-white/40">May 19, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Settings What's New is caught up again. The modal now includes the missing entries for v1.7.68-alpha through v1.7.71-alpha instead of stopping at v1.7.67-alpha.</p>
<p>The release lockfile metadata is also kept in sync with the previous release bump.</p>
</div>
</div>
<!-- v1.7.71-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.71-alpha</span>
<span class="text-xs text-white/40">May 19, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>NetBird stack installs now create the exact persistent data directory before binding it into the server container, fixing the failed install path seen on the test node.</p>
<p>NetBird start and restart actions bring up the control-plane server before the dashboard, so lifecycle actions use the correct dependency order.</p>
<p>App-session fallbacks now return to My Apps under /dashboard, mobile iframe-blocked apps stay inside Archipelago with an explicit fallback, and installed Gitea containers show the packaged Gitea icon with rounder app icon masks.</p>
</div>
</div>
<!-- v1.7.70-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.70-alpha</span>
<span class="text-xs text-white/40">May 19, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>NetBird was corrected from the peer/client daemon image to the self-hosted control-plane stack, with a launchable dashboard on port 8087, management/signal/relay server on 8086, and STUN on UDP 3478.</p>
<p>Local app launches use direct host ports and carry an explicit dashboard return target, so closing an app session goes back to the launching dashboard screen instead of falling through to browser history or a 404.</p>
<p>Mobile launches ignore stale desktop panel state and route into the full app-session webview. The desktop sidebar also keeps top and bottom regions pinned while only the middle navigation scrolls on short screens.</p>
</div>
</div>
<!-- v1.7.69-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.69-alpha</span>
<span class="text-xs text-white/40">May 19, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>App installs now allow up to 10 minutes for slow initial install RPCs, matching large container pulls and preventing apps from disappearing from My Apps while the backend is still pulling or retrying mirrors.</p>
<p>Gitea is now categorized as a known Data app and stays visible during slow registry pulls. Live diagnostics confirmed the Gitea container came up healthy on port 3001 after the frontend had previously timed out too early.</p>
<p>NetBird was added to the catalog as a recommended networking app, and the Archipelago terminal includes nano on new installs and existing-node self-update fallback.</p>
</div>
</div>
<!-- v1.7.68-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.68-alpha</span>
<span class="text-xs text-white/40">May 19, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>BTCPay Server now ships on the official btcpayserver image, fixing the plugin catalog crash caused by newer plugin dependency metadata while preserving existing data and Postgres databases.</p>
<p>BTCPay health checks no longer require curl inside the container, and Nginx Proxy Manager certificate challenge handling now avoids hijacking local API traffic while syncing issued public proxy hosts into host nginx.</p>
<p>System Update confirmation and mirror modals now cover the whole app, app-session close returns to the previous dashboard screen, and mobile app launches stay inside Archipelago's app-session webview.</p>
</div>
</div>
<!-- v1.7.67-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
+19 -19
View File
@@ -1,30 +1,30 @@
{
"version": "1.7.67-alpha",
"release_date": "2026-05-18",
"version": "1.7.76-alpha",
"release_date": "2026-05-19",
"changelog": [
"Home dashboard status cards now keep the last known good system, VPN, Bitcoin, and FIPS values while route changes or transient RPC failures are in flight, avoiding false \"not configured\" or \"not running\" flashes.",
"Home, Web5 Monitoring, and the Monitoring page headline cards now share the same live system-stat snapshot for CPU, memory, disk, uptime, and load so the visible numbers agree across the UI.",
"Settings What's New is filled through `v1.7.67-alpha`, including the missing historical `v1.7.44-alpha` through `v1.7.66-alpha` entries.",
"Bitcoin/Knots/Core shell lifecycle specs now match the Rust app config memory policy: 8 GiB on normal hosts, 4 GiB on low-memory hosts, and pruned Knots uses a larger dbcache on hosts with enough RAM to improve IBD throughput.",
"ElectrumX/electrs shell lifecycle specs now match the 4 GiB memory policy used by the Rust app config, reducing drift between first boot, reconcile, and app lifecycle paths.",
"Live assessment of `100.70.96.88` identified the current IBD bottlenecks as CPU/thermal/I/O pressure rather than RAM exhaustion, with follow-up work planned for existing-node swap repair, kiosk Chromium CPU reduction, and reconcile failure cleanup."
"Saleor installs now use dashboard port `9010`, avoiding the existing Portainer `9000` binding on the test node while keeping API `8000`, Mailpit `8025`, and Jaeger `16686` unchanged.",
"Saleor's Valkey cache no longer bind-mounts `/var/lib/archipelago/saleor-cache`, and the dashboard container has the minimal rootless nginx capabilities it needs to chown cache files, bind port 80 inside the container, and drop workers to the nginx user.",
"NetBird's browser proxy now sends API, OAuth, relay, WebSocket, and management traffic through the stable host-published server port at `169.254.1.2:8086`, avoiding stale rootless Podman DNS/IPs after `netbird-server` restarts.",
"Mobile App Store category chips now stay visible above the tab bar, Discover is available on mobile, and category selection updates the page route/query so the selected category is actually shown.",
"Apps that require a real browser tab now open directly from the app icon tap instead of first entering an in-shell app-session route, including BTCPay, Grafana, Home Assistant, Vaultwarden, Nextcloud, Portainer, OnlyOffice, Tailscale, Uptime Kuma, Gitea, and Nginx Proxy Manager.",
"Validation passed with catalog JSON checks, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`; live checks on `100.70.96.88` confirmed Saleor dashboard `9010`/API `8000` and NetBird API/OAuth routes survive `netbird-server` restart."
],
"components": [
{
"name": "archipelago",
"current_version": "1.7.67-alpha",
"new_version": "1.7.67-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.67-alpha/archipelago",
"sha256": "68140909683260ffb1e9109a2244b32e51c7b14c18398d92f391cfb0f802f770",
"size_bytes": 42943728
"current_version": "1.7.76-alpha",
"new_version": "1.7.76-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.76-alpha/archipelago",
"sha256": "ced129f183e71d9fe27669c2b091fc8069dd628ba06afa0a40d470a99cb52f2b",
"size_bytes": 43062136
},
{
"name": "archipelago-frontend-1.7.67-alpha.tar.gz",
"current_version": "1.7.67-alpha",
"new_version": "1.7.67-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.67-alpha/archipelago-frontend-1.7.67-alpha.tar.gz",
"sha256": "4f432a66bd976208010307cd68296d3f46d4e125b0edfe82e4d4d9fef13d6f1b",
"size_bytes": 166474514
"name": "archipelago-frontend-1.7.76-alpha.tar.gz",
"current_version": "1.7.76-alpha",
"new_version": "1.7.76-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.76-alpha/archipelago-frontend-1.7.76-alpha.tar.gz",
"sha256": "86f1f4932f48d79fbf4cde6052fa77ab917520b857f19e70809fad1e07dc5e25",
"size_bytes": 166480865
}
]
}
+19 -19
View File
@@ -1,30 +1,30 @@
{
"version": "1.7.67-alpha",
"release_date": "2026-05-18",
"version": "1.7.76-alpha",
"release_date": "2026-05-19",
"changelog": [
"Home dashboard status cards now keep the last known good system, VPN, Bitcoin, and FIPS values while route changes or transient RPC failures are in flight, avoiding false \"not configured\" or \"not running\" flashes.",
"Home, Web5 Monitoring, and the Monitoring page headline cards now share the same live system-stat snapshot for CPU, memory, disk, uptime, and load so the visible numbers agree across the UI.",
"Settings What's New is filled through `v1.7.67-alpha`, including the missing historical `v1.7.44-alpha` through `v1.7.66-alpha` entries.",
"Bitcoin/Knots/Core shell lifecycle specs now match the Rust app config memory policy: 8 GiB on normal hosts, 4 GiB on low-memory hosts, and pruned Knots uses a larger dbcache on hosts with enough RAM to improve IBD throughput.",
"ElectrumX/electrs shell lifecycle specs now match the 4 GiB memory policy used by the Rust app config, reducing drift between first boot, reconcile, and app lifecycle paths.",
"Live assessment of `100.70.96.88` identified the current IBD bottlenecks as CPU/thermal/I/O pressure rather than RAM exhaustion, with follow-up work planned for existing-node swap repair, kiosk Chromium CPU reduction, and reconcile failure cleanup."
"Saleor installs now use dashboard port `9010`, avoiding the existing Portainer `9000` binding on the test node while keeping API `8000`, Mailpit `8025`, and Jaeger `16686` unchanged.",
"Saleor's Valkey cache no longer bind-mounts `/var/lib/archipelago/saleor-cache`, and the dashboard container has the minimal rootless nginx capabilities it needs to chown cache files, bind port 80 inside the container, and drop workers to the nginx user.",
"NetBird's browser proxy now sends API, OAuth, relay, WebSocket, and management traffic through the stable host-published server port at `169.254.1.2:8086`, avoiding stale rootless Podman DNS/IPs after `netbird-server` restarts.",
"Mobile App Store category chips now stay visible above the tab bar, Discover is available on mobile, and category selection updates the page route/query so the selected category is actually shown.",
"Apps that require a real browser tab now open directly from the app icon tap instead of first entering an in-shell app-session route, including BTCPay, Grafana, Home Assistant, Vaultwarden, Nextcloud, Portainer, OnlyOffice, Tailscale, Uptime Kuma, Gitea, and Nginx Proxy Manager.",
"Validation passed with catalog JSON checks, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`; live checks on `100.70.96.88` confirmed Saleor dashboard `9010`/API `8000` and NetBird API/OAuth routes survive `netbird-server` restart."
],
"components": [
{
"name": "archipelago",
"current_version": "1.7.67-alpha",
"new_version": "1.7.67-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.67-alpha/archipelago",
"sha256": "68140909683260ffb1e9109a2244b32e51c7b14c18398d92f391cfb0f802f770",
"size_bytes": 42943728
"current_version": "1.7.76-alpha",
"new_version": "1.7.76-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.76-alpha/archipelago",
"sha256": "ced129f183e71d9fe27669c2b091fc8069dd628ba06afa0a40d470a99cb52f2b",
"size_bytes": 43062136
},
{
"name": "archipelago-frontend-1.7.67-alpha.tar.gz",
"current_version": "1.7.67-alpha",
"new_version": "1.7.67-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.67-alpha/archipelago-frontend-1.7.67-alpha.tar.gz",
"sha256": "4f432a66bd976208010307cd68296d3f46d4e125b0edfe82e4d4d9fef13d6f1b",
"size_bytes": 166474514
"name": "archipelago-frontend-1.7.76-alpha.tar.gz",
"current_version": "1.7.76-alpha",
"new_version": "1.7.76-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.76-alpha/archipelago-frontend-1.7.76-alpha.tar.gz",
"sha256": "86f1f4932f48d79fbf4cde6052fa77ab917520b857f19e70809fad1e07dc5e25",
"size_bytes": 166480865
}
]
}
+17
View File
@@ -510,6 +510,22 @@ fix_missing_rootless_ports() {
$fixed && return 0 || return 1
}
# ── Fix 11: Nginx Proxy Manager public host bridge ───────────
# Host nginx owns public 80/443 on Archipelago. Mirror NPM proxy hosts into
# host nginx so issued certs and public traffic reach the intended upstreams.
fix_npm_public_hosts() {
local script="/opt/archipelago/scripts/sync-npm-public-hosts.sh"
[ -x "$script" ] || script="$SCRIPT_DIR/sync-npm-public-hosts.sh"
[ -x "$script" ] || return 1
[ -f /var/lib/archipelago/nginx-proxy-manager/data/database.sqlite ] || return 1
if "$script" >/dev/null 2>&1; then
log "Synced Nginx Proxy Manager public hosts into host nginx"
return 0
fi
return 1
}
# ── Main ─────────────────────────────────────────────────────
# If remote host provided, run via SSH
@@ -539,6 +555,7 @@ run_fix "exit-127" fix_exit_127
run_fix "netns-egress" fix_rootless_netns_egress
run_fix "stopped-core" fix_stopped_core_containers
run_fix "rootless-ports" fix_missing_rootless_ports
run_fix "npm-public-hosts" fix_npm_public_hosts
echo ""
if [ $FIXES_APPLIED -gt 0 ]; then
+1 -1
View File
@@ -273,7 +273,7 @@ load_spec_btcpay-server() {
SPEC_PORTS="23000:49392"
SPEC_VOLUMES="/var/lib/archipelago/btcpay:/datadir"
SPEC_MEMORY="$(mem_limit btcpay-server)"
SPEC_HEALTH_CMD="curl -sf http://localhost:49392/ || exit 1"
SPEC_HEALTH_CMD="bash -ec '</dev/tcp/127.0.0.1/49392'"
SPEC_ENV="ASPNETCORE_URLS=http://0.0.0.0:49392 BTCPAY_PROTOCOL=http BTCPAY_HOST=$HOST_IP:23000 BTCPAY_CHAINS=btc BTCPAY_BTCEXPLORERURL=http://archy-nbxplorer:32838 BTCPAY_BTCRPCURL=http://bitcoin-knots:8332 BTCPAY_BTCRPCUSER=$BITCOIN_RPC_USER BTCPAY_BTCRPCPASSWORD=$BITCOIN_RPC_PASS BTCPAY_POSTGRES=Username=btcpay;Password=$BTCPAY_DB_PASS;Host=archy-btcpay-db;Port=5432;Database=btcpay"
SPEC_TIER="2"
SPEC_DATA_DIR="/var/lib/archipelago/btcpay"
+12 -6
View File
@@ -156,6 +156,14 @@ mkdir -p "$PROJECT_ROOT/releases"
"$SCRIPT_DIR/create-release-manifest.sh" --version "$VERSION" --date "$RELEASE_DATE" --output "$PROJECT_ROOT/releases/manifest.json" 2>&1 | grep -v "^$"
cp "$PROJECT_ROOT/releases/manifest.json" "$PROJECT_ROOT/release-manifest.json"
echo "[6b/8] Staging release artifacts for validation..."
VERSION_DIR="$PROJECT_ROOT/releases/v${VERSION}"
FRONTEND_ARCHIVE="/tmp/archipelago-frontend-${VERSION}.tar.gz"
mkdir -p "$VERSION_DIR"
install -m 0755 "$PROJECT_ROOT/core/target/release/archipelago" "$VERSION_DIR/archipelago"
install -m 0644 "$FRONTEND_ARCHIVE" "$VERSION_DIR/archipelago-frontend-${VERSION}.tar.gz"
"$SCRIPT_DIR/check-release-manifest.sh"
echo "[7/8] Committing version bump..."
git -C "$PROJECT_ROOT" add \
core/archipelago/Cargo.toml \
@@ -179,15 +187,13 @@ echo " - Version bumped in Cargo.toml and package.json"
echo " - Changelog updated in CHANGELOG.md"
echo " - Release manifest: releases/manifest.json"
echo " - Release manifest copy: release-manifest.json"
echo " - Staged artifacts: releases/v${VERSION}/"
echo " - Git tag: v${VERSION}"
echo ""
echo "Next steps:"
echo " 1. Review: git log --oneline -5"
echo " 2. Create Gitea release v${VERSION} and upload artifacts:"
echo " archipelago"
echo " archipelago-frontend-${VERSION}.tar.gz"
echo " 3. Push to gitea-local and gitea-vps2:"
echo " git push gitea-local main --tags && git push gitea-vps2 main --tags"
echo " 4. Verify manifest is live on both mirrors:"
echo " 2. Publish commits, tag, artifacts, and verify download URLs:"
echo " scripts/publish-release-assets.sh ${VERSION} gitea-vps2"
echo " 3. Verify manifest is live on both mirrors:"
echo " curl -fsS http://localhost:3000/lfg2025/archy/raw/branch/main/releases/manifest.json"
echo " curl -fsS http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json"
+3 -2
View File
@@ -625,7 +625,7 @@ deploy_node() {
echo ' Creating btcpay-server...'
sudo mkdir -p /var/lib/archipelago/btcpay
\$DOCKER run -d --name btcpay-server --restart unless-stopped \$NET_OPT \
--health-cmd 'curl -sf http://localhost:49392/' --health-interval=30s --health-timeout=10s --health-retries=3 \
--health-cmd "bash -ec '</dev/tcp/127.0.0.1/49392'" --health-interval=30s --health-timeout=10s --health-retries=3 \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
--security-opt no-new-privileges:true \
-p 23000:49392 -v /var/lib/archipelago/btcpay:/datadir \
@@ -909,7 +909,8 @@ LNDCONF
if \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -qx nginx-proxy-manager; then
\$DOCKER start nginx-proxy-manager 2>/dev/null || true
else
sudo mkdir -p /var/lib/archipelago/nginx-proxy-manager/data /var/lib/archipelago/nginx-proxy-manager/letsencrypt
sudo mkdir -p /var/lib/archipelago/nginx-proxy-manager/data/letsencrypt-acme-challenge/.well-known/acme-challenge /var/lib/archipelago/nginx-proxy-manager/letsencrypt
sudo chown -R 1000:1000 /var/lib/archipelago/nginx-proxy-manager 2>/dev/null || true
\$DOCKER run -d --name nginx-proxy-manager --restart unless-stopped \
--health-cmd 'curl -sf http://localhost:81/' --health-interval=30s --health-timeout=5s --health-retries=3 \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add NET_BIND_SERVICE \
+8 -3
View File
@@ -481,10 +481,13 @@ log "archy-net network ready"
log "Fixing rootless podman UID mapping..."
# Containers running as root (UID 0 → host UID 100000)
for dir in lnd electrumx btcpay nbxplorer jellyfin vaultwarden \
home-assistant fedimint fedimint-gateway photoprism ollama filebrowser \
nextcloud uptime-kuma nginx-proxy-manager portainer nostr-rs-relay; do
home-assistant fedimint fedimint-gateway photoprism ollama filebrowser \
nextcloud uptime-kuma portainer nostr-rs-relay; do
[ -d "/var/lib/archipelago/$dir" ] && chown -R 100000:100000 "/var/lib/archipelago/$dir" 2>/dev/null
done
# Nginx Proxy Manager runs as root in the rootless user namespace, which maps to
# the archipelago user on host bind mounts. Keep certbot's webroot writable.
[ -d /var/lib/archipelago/nginx-proxy-manager ] && chown -R 1000:1000 /var/lib/archipelago/nginx-proxy-manager 2>/dev/null
# Bitcoin Knots: container UID 101 → host UID 100101
[ -d /var/lib/archipelago/bitcoin ] && chown -R 100101:100101 /var/lib/archipelago/bitcoin 2>/dev/null
# Postgres: container UID 70 → host UID 100070
@@ -875,7 +878,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q btcpay-server; then
log "Creating BTCPay Server..."
mkdir -p /var/lib/archipelago/btcpay/Main
$DOCKER run -d --name btcpay-server --restart unless-stopped \
--health-cmd="curl -sf http://localhost:49392/ || exit 1" --health-interval=120s --health-timeout=5s --health-retries=3 \
--health-cmd="bash -ec '</dev/tcp/127.0.0.1/49392'" --health-interval=120s --health-timeout=5s --health-retries=3 \
--memory=$(mem_limit btcpay-server) --network archy-net --network-alias btcpay-server \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
--security-opt no-new-privileges:true \
@@ -1181,6 +1184,8 @@ track_container "filebrowser"
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q nginx-proxy-manager; then
log "Creating Nginx Proxy Manager..."
mkdir -p /var/lib/archipelago/nginx-proxy-manager/data /var/lib/archipelago/nginx-proxy-manager/letsencrypt
mkdir -p /var/lib/archipelago/nginx-proxy-manager/data/letsencrypt-acme-challenge/.well-known/acme-challenge
chown -R 1000:1000 /var/lib/archipelago/nginx-proxy-manager 2>/dev/null || true
NPM_ADMIN_PORT=$(alloc_port nginx-proxy-manager 8081)
NPM_HTTP_PORT=$(alloc_port nginx-proxy-manager-http 8084)
NPM_HTTPS_PORT=$(alloc_port nginx-proxy-manager-https 8444)
+12 -1
View File
@@ -24,7 +24,7 @@ MEMPOOL_WEB_IMAGE="$ARCHY_REGISTRY/mempool-frontend:v3.0.0"
MARIADB_IMAGE="$ARCHY_REGISTRY/mariadb:11.4.10"
# BTCPay
BTCPAY_IMAGE="$ARCHY_REGISTRY/btcpayserver:1.13.7"
BTCPAY_IMAGE="docker.io/btcpayserver/btcpayserver:2.3.9"
NBXPLORER_IMAGE="$ARCHY_REGISTRY/nbxplorer:2.6.0"
POSTGRES_IMAGE="$ARCHY_REGISTRY/postgres:15.17"
BTCPAY_POSTGRES_IMAGE="$ARCHY_REGISTRY/postgres:15.17"
@@ -48,9 +48,20 @@ PORTAINER_IMAGE="$ARCHY_REGISTRY/portainer:latest"
# Networking
TAILSCALE_IMAGE="$ARCHY_REGISTRY/tailscale:stable"
NETBIRD_DASHBOARD_IMAGE="docker.io/netbirdio/dashboard:v2.38.0"
NETBIRD_SERVER_IMAGE="docker.io/netbirdio/netbird-server:0.71.2"
NETBIRD_PROXY_IMAGE="docker.io/library/nginx:1.27-alpine"
ALPINE_TOR_IMAGE="$ARCHY_REGISTRY/alpine-tor:0.4.8.13"
ADGUARDHOME_IMAGE="$ARCHY_REGISTRY/adguardhome:v0.107.55"
# Saleor stack
SALEOR_API_IMAGE="ghcr.io/saleor/saleor:3.23"
SALEOR_DASHBOARD_IMAGE="ghcr.io/saleor/saleor-dashboard:3.23"
SALEOR_POSTGRES_IMAGE="docker.io/library/postgres:15-alpine"
SALEOR_VALKEY_IMAGE="docker.io/valkey/valkey:8.1-alpine"
SALEOR_JAEGER_IMAGE="docker.io/jaegertracing/jaeger:latest"
SALEOR_MAILPIT_IMAGE="docker.io/axllent/mailpit:latest"
# Fedimint
FEDIMINT_IMAGE="$ARCHY_REGISTRY/fedimintd:v0.10.0"
FEDIMINT_GATEWAY_IMAGE="$ARCHY_REGISTRY/gatewayd:v0.10.0"
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env bash
# Publish an Archipelago OTA release to a Gitea remote and verify downloads.
set -euo pipefail
VERSION="${1:-}"
REMOTE="${2:-gitea-vps2}"
if [ -z "$VERSION" ]; then
echo "Usage: $0 VERSION [remote]"
exit 1
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
VERSION_DIR="$PROJECT_ROOT/releases/v${VERSION}"
BACKEND="$VERSION_DIR/archipelago"
FRONTEND="$VERSION_DIR/archipelago-frontend-${VERSION}.tar.gz"
fail() { echo "Error: $*" >&2; exit 1; }
[ -f "$PROJECT_ROOT/releases/manifest.json" ] || fail "releases/manifest.json missing"
[ -f "$BACKEND" ] || fail "backend artifact missing: $BACKEND"
[ -f "$FRONTEND" ] || fail "frontend artifact missing: $FRONTEND"
"$SCRIPT_DIR/check-release-manifest.sh"
remote_url=$(git -C "$PROJECT_ROOT" remote get-url "$REMOTE")
case "$remote_url" in
http://*@*) ;;
*) fail "$REMOTE must be an authenticated http:// Gitea remote URL for API uploads" ;;
esac
auth=${remote_url#http://}
auth=${auth%@*}
host_path=${remote_url#http://$auth@}
host=${host_path%%/*}
repo_path=${host_path#*/}
repo_path=${repo_path%.git}
api="http://$host/api/v1/repos/$repo_path"
release_url="$api/releases/tags/v${VERSION}"
echo "Pushing main and v${VERSION} to $REMOTE..."
git -C "$PROJECT_ROOT" push "$REMOTE" main "refs/tags/v${VERSION}"
release_json=$(curl -fsS -u "$auth" "$release_url" || true)
if [ -z "$release_json" ]; then
echo "Creating Gitea release v${VERSION}..."
release_body=$(python3 - "$VERSION" <<'PY'
import json
import sys
version = sys.argv[1]
print(json.dumps({
"tag_name": f"v{version}",
"target_commitish": "main",
"name": f"v{version}",
"body": f"Archipelago v{version} release artifacts for OTA updates.",
"draft": False,
"prerelease": True,
}))
PY
)
release_json=$(curl -fsS -u "$auth" -H 'Content-Type: application/json' -d "$release_body" "$api/releases")
fi
release_id=$(printf '%s' "$release_json" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
asset_names=$(curl -fsS -u "$auth" "$api/releases/$release_id/assets" | python3 -c 'import json,sys; print("\n".join(a["name"] for a in json.load(sys.stdin)))')
upload_asset() {
local path="$1"
local name="$2"
if printf '%s\n' "$asset_names" | grep -Fxq "$name"; then
echo "Asset $name already exists; leaving it in place."
return
fi
echo "Uploading $name..."
curl --fail --show-error --silent --http1.1 --connect-timeout 20 --max-time 900 \
-u "$auth" \
-F "attachment=@$path" \
"$api/releases/$release_id/assets?name=$name" >/dev/null
asset_names=$(printf '%s\n%s\n' "$asset_names" "$name")
}
upload_asset "$BACKEND" "archipelago"
upload_asset "$FRONTEND" "archipelago-frontend-${VERSION}.tar.gz"
echo "Verifying public download URLs from manifest..."
python3 - "$PROJECT_ROOT/releases/manifest.json" <<'PY' | while read -r url size; do
import json
import sys
manifest = json.load(open(sys.argv[1]))
for component in manifest["components"]:
print(component["download_url"], component["size_bytes"])
PY
headers=$(curl -fsSI -L --max-time 60 "$url") || fail "download URL failed: $url"
actual_size=$(printf '%s\n' "$headers" | awk 'tolower($1)=="content-length:" { size=$2 } END { gsub("\r", "", size); print size }')
[ "$actual_size" = "$size" ] || fail "download size mismatch for $url (expected $size, got ${actual_size:-missing})"
done
echo "Release v${VERSION} published and verified on $REMOTE."
+10 -1
View File
@@ -75,6 +75,15 @@ fi
cd "$REPO_DIR"
if ! command -v nano >/dev/null 2>&1; then
log "Installing nano for Archipelago terminal..."
if sudo apt-get update -qq 2>>"$LOG_FILE" && sudo apt-get install -y -qq nano 2>>"$LOG_FILE"; then
ok "nano installed"
else
warn "Unable to install nano automatically; continuing update"
fi
fi
# Fetch latest
log "Fetching from origin..."
git fetch origin main --quiet 2>>"$LOG_FILE"
@@ -186,7 +195,7 @@ fi
# for backward compatibility with older binaries that still look there.
SCRIPTS_DEST="/opt/archipelago/scripts"
sudo mkdir -p "$SCRIPTS_DEST"
for script in image-versions.sh reconcile-containers.sh container-specs.sh container-doctor.sh app-surface-smoke-test.sh bitcoin-stack-lifecycle-test.sh; do
for script in image-versions.sh reconcile-containers.sh container-specs.sh container-doctor.sh sync-npm-public-hosts.sh app-surface-smoke-test.sh bitcoin-stack-lifecycle-test.sh; do
src="$REPO_DIR/scripts/$script"
if [ -f "$src" ]; then
sudo install -m 755 "$src" "$SCRIPTS_DEST/$script"
+119
View File
@@ -0,0 +1,119 @@
#!/bin/bash
set -euo pipefail
DB="/var/lib/archipelago/nginx-proxy-manager/data/database.sqlite"
OUT="/etc/nginx/conf.d/public-npm-proxy-hosts.conf"
ACME_ROOT="/var/lib/archipelago/nginx-proxy-manager/data/letsencrypt-acme-challenge"
LE_ROOT="/var/lib/archipelago/nginx-proxy-manager/letsencrypt/live"
[ -f "$DB" ] || exit 0
mkdir -p "$ACME_ROOT/.well-known/acme-challenge"
chown -R 1000:1000 /var/lib/archipelago/nginx-proxy-manager 2>/dev/null || true
tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT
python3 - "$DB" "$ACME_ROOT" "$LE_ROOT" >"$tmp" <<'PY'
import json
import os
import sqlite3
import sys
db, acme_root, le_root = sys.argv[1:]
con = sqlite3.connect(db)
con.row_factory = sqlite3.Row
rows = con.execute(
"""
select p.id, p.domain_names, p.forward_scheme, p.forward_host, p.forward_port,
p.certificate_id, p.ssl_forced, c.provider
from proxy_host p
left join certificate c on c.id = p.certificate_id
where p.enabled = 1 and p.certificate_id > 0
order by p.id
"""
).fetchall()
print("# Generated by sync-npm-public-hosts.sh; do not edit by hand.")
for row in rows:
try:
domains = [d for d in json.loads(row["domain_names"] or "[]") if d]
except Exception:
domains = []
if not domains:
continue
cert_id = row["certificate_id"]
cert = f"{le_root}/npm-{cert_id}/fullchain.pem"
key = f"{le_root}/npm-{cert_id}/privkey.pem"
if row["provider"] != "letsencrypt":
continue
if not os.path.isfile(cert) or not os.path.isfile(key):
continue
names = " ".join(domains)
scheme = row["forward_scheme"] or "http"
host = row["forward_host"]
port = row["forward_port"]
if not host or not port:
continue
# NPM containers use this name to reach host-published services; host nginx
# itself should use loopback for the same services.
nginx_host = "127.0.0.1" if host == "host.containers.internal" else host
print(f"""
server {{
listen 80;
server_name {names};
location ^~ /.well-known/acme-challenge/ {{
default_type text/plain;
root {acme_root};
try_files $uri =404;
}}
location / {{
return 301 https://$host$request_uri;
}}
}}
server {{
listen 443 ssl;
server_name {names};
ssl_certificate {cert};
ssl_certificate_key {key};
location / {{
proxy_pass {scheme}://{nginx_host}:{port};
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Scheme https;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}}
}}
""")
PY
backup=""
if [ -f "$OUT" ]; then
backup=$(mktemp)
cp "$OUT" "$backup"
fi
restore_previous() {
if [ -n "$backup" ] && [ -f "$backup" ]; then
install -m 0644 "$backup" "$OUT"
else
rm -f "$OUT"
fi
}
if ! install -m 0644 "$tmp" "$OUT" || ! nginx -t >/dev/null; then
restore_previous
nginx -t >/dev/null 2>&1 || true
exit 1
fi
systemctl reload nginx
+1 -1
View File
@@ -135,7 +135,7 @@ image_for() {
case "$1" in
bitcoin-knots) echo "146.59.87.168:3000/lfg2025/bitcoin-knots:latest" ;;
bitcoin-core) echo "docker.io/bitcoin/bitcoin:28.4" ;;
btcpay-server) echo "146.59.87.168:3000/lfg2025/btcpayserver:1.13.7" ;;
btcpay-server) echo "docker.io/btcpayserver/btcpayserver:2.3.9" ;;
lnd) echo "146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta" ;;
mempool) echo "146.59.87.168:3000/lfg2025/mempool-frontend:v3.0.0" ;;
homeassistant) echo "146.59.87.168:3000/lfg2025/home-assistant:2024.1" ;;