From 121f17e44e58daef5c1f3b1d44a1e062c52b92c5 Mon Sep 17 00:00:00 2001 From: Dorian Date: Fri, 27 Mar 2026 13:32:54 +0000 Subject: [PATCH] fix: container install flow, filebrowser auth, AppCard enrichment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix .198-style fresh installs: systemd service ExecStartPre creates /run/user/1000, enable podman.socket, chmod 644 /etc/hosts - Filebrowser: add /data volume for database (fixes read-only crash), secure auth with random password via backend RPC (no more admin/admin) - AppCard: enrich installing state with marketplace metadata (icon, title, description, tier badge, author, version) - Registry: btcpayserver 1.13.5 → 1.13.7, images mirrored - ReadWritePaths: add home container paths for rootless podman Co-Authored-By: Claude Opus 4.6 (1M context) --- core/archipelago/src/api/rpc/dispatcher.rs | 1 + .../archipelago/src/api/rpc/package/config.rs | 12 ++- .../src/api/rpc/package/install.rs | 93 +++++++++++++++++ .../archipelago-scripts/install-to-disk.sh | 1 + image-recipe/build-auto-installer-iso.sh | 1 + image-recipe/configs/archipelago.service | 3 +- image-recipe/scripts/install-podman.sh | 9 ++ .../api/__tests__/filebrowser-client.test.ts | 13 ++- neode-ui/src/api/filebrowser-client.ts | 19 ++-- neode-ui/src/types/api.ts | 1 + neode-ui/src/views/apps/AppCard.vue | 99 +++++++++++++------ neode-ui/src/views/discover/curatedApps.ts | 2 +- .../src/views/marketplace/marketplaceData.ts | 4 +- scripts/first-boot-containers.sh | 11 ++- 14 files changed, 215 insertions(+), 54 deletions(-) diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index 7e0c05f4..0fed132c 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -38,6 +38,7 @@ impl RpcHandler { "package.stop" => self.handle_package_stop(params).await, "package.restart" => self.handle_package_restart(params).await, "package.uninstall" => self.handle_package_uninstall(params).await, + "app.filebrowser-token" => self.handle_filebrowser_token().await, // Bundled app management (for pre-loaded container images) "bundled-app-start" => self.handle_bundled_app_start(params).await, diff --git a/core/archipelago/src/api/rpc/package/config.rs b/core/archipelago/src/api/rpc/package/config.rs index f0789ab7..29f98e8c 100644 --- a/core/archipelago/src/api/rpc/package/config.rs +++ b/core/archipelago/src/api/rpc/package/config.rs @@ -562,10 +562,18 @@ pub(super) async fn get_app_config( .unwrap_or(8083); ( vec![format!("{}:80", host_port)], - vec!["/var/lib/archipelago/filebrowser:/srv".to_string()], + vec![ + "/var/lib/archipelago/filebrowser:/srv".to_string(), + "/var/lib/archipelago/filebrowser-data:/data".to_string(), + ], vec![], None, - None, + Some(vec![ + "--database=/data/database.db".to_string(), + "--root=/srv".to_string(), + "--address=0.0.0.0".to_string(), + "--port=80".to_string(), + ]), ) } "nginx-proxy-manager" => ( diff --git a/core/archipelago/src/api/rpc/package/install.rs b/core/archipelago/src/api/rpc/package/install.rs index e36fbf03..dc2f1c17 100644 --- a/core/archipelago/src/api/rpc/package/install.rs +++ b/core/archipelago/src/api/rpc/package/install.rs @@ -404,6 +404,67 @@ printtoconsole=1\n", /// Run post-install hooks (Nextcloud trusted domains, Bitcoin UI container). async fn run_post_install_hooks(&self, package_id: &str) { + if package_id == "filebrowser" { + tokio::spawn(async move { + // Wait for filebrowser to start and initialize its database + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + + // Generate a random password (32 bytes, hex-encoded) + let mut buf = [0u8; 32]; + rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut buf); + let password = hex::encode(buf); + + // Get a JWT token with default credentials + let login_res = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .unwrap_or_default() + .post("http://127.0.0.1:8083/api/login") + .json(&serde_json::json!({"username": "admin", "password": "admin"})) + .send() + .await; + + let token = match login_res { + Ok(resp) if resp.status().is_success() => { + resp.text().await.unwrap_or_default().trim_matches('"').to_string() + } + _ => { + tracing::warn!("FileBrowser not ready for password change — keeping default"); + return; + } + }; + + // Change admin password via filebrowser API + let change_res = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .unwrap_or_default() + .put("http://127.0.0.1:8083/api/users/1") + .header("X-Auth", &token) + .json(&serde_json::json!({"password": password})) + .send() + .await; + + match change_res { + Ok(resp) if resp.status().is_success() => { + let secret_dir = "/var/lib/archipelago/secrets/filebrowser"; + let _ = tokio::fs::create_dir_all(secret_dir).await; + let _ = tokio::fs::write( + format!("{}/password", secret_dir), + &password, + ).await; + info!("FileBrowser admin password secured (default credentials replaced)"); + } + Ok(resp) => { + tracing::warn!("FileBrowser password change failed: {}", resp.status()); + } + Err(e) => { + tracing::warn!("FileBrowser password change error: {}", e); + } + } + }); + } + if package_id == "nextcloud" { let host_ip = self.config.host_ip.clone(); tokio::spawn(async move { @@ -464,4 +525,36 @@ printtoconsole=1\n", }); } } + + /// Get a fresh FileBrowser JWT token for the frontend. + /// Reads the stored random password and authenticates to filebrowser's API. + pub(in crate::api::rpc) async fn handle_filebrowser_token( + &self, + ) -> Result { + let secret_path = "/var/lib/archipelago/secrets/filebrowser/password"; + let password = tokio::fs::read_to_string(secret_path) + .await + .unwrap_or_else(|_| "admin".to_string()); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .unwrap_or_default(); + + let resp = client + .post("http://127.0.0.1:8083/api/login") + .json(&serde_json::json!({"username": "admin", "password": password})) + .send() + .await + .context("Failed to connect to FileBrowser")?; + + if !resp.status().is_success() { + return Err(anyhow::anyhow!("FileBrowser login failed ({})", resp.status())); + } + + let token = resp.text().await.unwrap_or_default(); + let token = token.trim_matches('"'); + + Ok(serde_json::json!({ "token": token })) + } } diff --git a/image-recipe/archipelago-scripts/install-to-disk.sh b/image-recipe/archipelago-scripts/install-to-disk.sh index 36fca8e8..4d5c710e 100755 --- a/image-recipe/archipelago-scripts/install-to-disk.sh +++ b/image-recipe/archipelago-scripts/install-to-disk.sh @@ -142,6 +142,7 @@ cat > /mnt/archipelago/etc/hosts < /mnt/target/etc/hosts </dev/null | awk "{print $$1}")" > /var/lib/archipelago/host-ip.env' ExecStart=/usr/local/bin/archipelago Restart=on-failure @@ -22,7 +23,7 @@ ProtectSystem=strict ProtectHome=no # PrivateTmp disabled: rootless podman runtime lives in /tmp/podman-run-UID/ # and must be shared between the service and SSH-created containers -ReadWritePaths=/var/lib/archipelago /etc/containers /var/lib/containers /run/containers /run/user /tmp +ReadWritePaths=/var/lib/archipelago /etc/containers /var/lib/containers /run/containers /run/user /tmp /home/archipelago/.local/share/containers /home/archipelago/.config/containers /etc # Privilege restriction — restored with rootless podman (no sudo needed) NoNewPrivileges=yes diff --git a/image-recipe/scripts/install-podman.sh b/image-recipe/scripts/install-podman.sh index d745fa4f..47b70a76 100755 --- a/image-recipe/scripts/install-podman.sh +++ b/image-recipe/scripts/install-podman.sh @@ -52,6 +52,15 @@ mkdir -p /home/archipelago/.config/systemd/user # Enable lingering for archipelago user (allows user services to run without login) loginctl enable-linger archipelago || true +# Ensure /run/user/1000 exists for podman socket +mkdir -p /run/user/1000 +chown archipelago:archipelago /run/user/1000 +chmod 700 /run/user/1000 + +# Enable podman API socket for archipelago user (backend connects via this) +su - archipelago -c "XDG_RUNTIME_DIR=/run/user/1000 systemctl --user enable podman.socket" || true +su - archipelago -c "XDG_RUNTIME_DIR=/run/user/1000 systemctl --user start podman.socket" || true + # Set proper permissions chown -R archipelago:archipelago /home/archipelago/.config chown -R archipelago:archipelago /home/archipelago/.local diff --git a/neode-ui/src/api/__tests__/filebrowser-client.test.ts b/neode-ui/src/api/__tests__/filebrowser-client.test.ts index 9f2e1a9c..98708e3f 100644 --- a/neode-ui/src/api/__tests__/filebrowser-client.test.ts +++ b/neode-ui/src/api/__tests__/filebrowser-client.test.ts @@ -40,19 +40,18 @@ describe('FileBrowserClient', () => { }) describe('login', () => { - it('authenticates and stores token', async () => { - mockFetch.mockResolvedValueOnce(jsonResponse('"jwt-token-123"')) + it('authenticates via backend RPC and stores token', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ result: { token: 'jwt-token-123' } })) - // We need a fresh instance to test login — use the exported singleton - const result = await fileBrowserClient.login('admin', 'admin') + const result = await fileBrowserClient.login() expect(result).toBe(true) expect(fileBrowserClient.isAuthenticated).toBe(true) expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining('/app/filebrowser/api/login'), + '/rpc/v1', expect.objectContaining({ method: 'POST', - body: JSON.stringify({ username: 'admin', password: 'admin' }), + body: JSON.stringify({ method: 'app.filebrowser-token' }), }), ) }) @@ -60,7 +59,7 @@ describe('FileBrowserClient', () => { it('returns false on failed login', async () => { mockFetch.mockResolvedValueOnce(jsonResponse(null, 403)) - const result = await fileBrowserClient.login('admin', 'wrong') + const result = await fileBrowserClient.login() expect(result).toBe(false) }) diff --git a/neode-ui/src/api/filebrowser-client.ts b/neode-ui/src/api/filebrowser-client.ts index 3cc6f1c7..c65a94f0 100644 --- a/neode-ui/src/api/filebrowser-client.ts +++ b/neode-ui/src/api/filebrowser-client.ts @@ -52,20 +52,21 @@ class FileBrowserClient { return match ? match[1]! : null } - async login(username = 'admin', password = 'admin'): Promise { + async login(): Promise { try { - const res = await fetch(`${this.baseUrl}/api/login`, { + // Get a filebrowser JWT via the authenticated backend (no credentials exposed to browser) + const rpcRes = await fetch('/rpc/v1', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username, password }), + body: JSON.stringify({ method: 'app.filebrowser-token' }), + credentials: 'same-origin', }) - if (!res.ok) return false - const text = await res.text() - // FileBrowser returns the JWT as a plain string (possibly quoted) - const token = text.replace(/^"|"$/g, '') - // Store token as cookie — the only auth mechanism we use + if (!rpcRes.ok) return false + const rpcData = await rpcRes.json() + const token = rpcData?.result?.token + if (!token) return false + const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toUTCString() - // Only set Secure flag on HTTPS — on HTTP it silently prevents the cookie from being stored const secure = window.location.protocol === 'https:' ? '; Secure' : '' document.cookie = `auth=${token}; path=/app/filebrowser; SameSite=Lax${secure}; expires=${expires}` this._authenticated = true diff --git a/neode-ui/src/types/api.ts b/neode-ui/src/types/api.ts index 230d9677..c4852ffd 100644 --- a/neode-ui/src/types/api.ts +++ b/neode-ui/src/types/api.ts @@ -107,6 +107,7 @@ export interface Manifest { 'donation-url': string | null author?: string website?: string + tier?: string interfaces?: { main?: { ui?: string diff --git a/neode-ui/src/views/apps/AppCard.vue b/neode-ui/src/views/apps/AppCard.vue index ba009343..563a0a10 100644 --- a/neode-ui/src/views/apps/AppCard.vue +++ b/neode-ui/src/views/apps/AppCard.vue @@ -39,43 +39,51 @@
-

- {{ pkg.manifest.title }} -

-

- {{ pkg.manifest?.description?.short || '' }} -

-
+
+

+ {{ title }} +

- - - - - - {{ getStatusLabel(pkg.state, pkg.health) }} - - - v{{ pkg.manifest.version }} - + v-if="tier && tier !== 'optional'" + class="tier-badge" + :class="tier === 'core' ? 'tier-badge-core' : 'tier-badge-recommended'" + >{{ tier }}
+

{{ version ? `v${version}` : '' }}

+

{{ author }}

+

+ {{ description }} +

+ +
+ + + + + + + {{ getStatusLabel(pkg.state, pkg.health) }} + +
+