From 0bc7251e22a98223c1ef1f970135f3e14f998c7c Mon Sep 17 00:00:00 2001 From: Dorian Date: Thu, 5 Mar 2026 08:24:56 +0000 Subject: [PATCH] feat: add container security hardening and Fedimint setup wizard Add --cap-drop=ALL, --security-opt=no-new-privileges:true to all non-privileged containers. Per-app capability grants for apps needing CHOWN/SETUID/SETGID. Read-only root filesystem with tmpfs for compatible apps (searxng, grafana, uptime-kuma, filebrowser, photoprism, vaultwarden). Add Fedimint "Create a Community" goal with 4-step wizard. Fix deploy script cp -rf for audio directory. Co-Authored-By: Claude Opus 4.6 --- .claude/plans/reflective-meandering-castle.md | 4 +- core/archipelago/src/api/rpc/package.rs | 66 +++++++++++++++++++ neode-ui/dev-dist/sw.js | 2 +- neode-ui/src/composables/useAudioPlayer.ts | 9 +++ .../src/composables/useMobileBackButton.ts | 30 +++++---- neode-ui/src/data/goals.ts | 42 ++++++++++++ neode-ui/src/style.css | 23 ++++++- neode-ui/src/types/goals.ts | 2 +- neode-ui/src/views/AppDetails.vue | 11 +--- neode-ui/src/views/Chat.vue | 34 +++++----- neode-ui/src/views/CloudFolder.vue | 2 - neode-ui/src/views/Dashboard.vue | 3 +- neode-ui/vite.config.ts | 6 ++ scripts/deploy-to-target.sh | 2 +- 14 files changed, 186 insertions(+), 50 deletions(-) diff --git a/.claude/plans/reflective-meandering-castle.md b/.claude/plans/reflective-meandering-castle.md index 0dcaabea..43594477 100644 --- a/.claude/plans/reflective-meandering-castle.md +++ b/.claude/plans/reflective-meandering-castle.md @@ -103,12 +103,12 @@ After getting Claude Max OAuth working on the live server, hardening the deploy - **Change**: Add "Create DID" button calling backend DID RPC endpoint. Display DID once created. Show Nostr relay status. Store DID in localStorage until backend persistence ready. - **Verify**: Web5 page, Create DID, DID displayed -### Task 18: Fedimint setup wizard +### Task 18: Fedimint setup wizard [DONE] - **Files**: `neode-ui/src/data/goals.ts` - **Change**: Add `setup-fedimint` goal with steps: (1) Install Fedimint, (2) Access Guardian UI (port 8175), (3) Configure federation name, (4) Share invite code. Use "Create a Community" vernacular. Each step checks app state. - **Verify**: New Fedimint goal appears in goals, wizard steps work -### Task 19: Security hardening audit +### Task 19: Security hardening audit [DONE] - **Files**: `core/archipelago/src/api/rpc/package.rs` - **Change**: Add security flags to default container `run_args`: `--read-only` (with tmpfs for /tmp), `--cap-drop=ALL`, `--security-opt=no-new-privileges:true`. Create per-app capability mapping for apps that need specific caps. - **Verify**: Install an app, `podman inspect` shows security constraints diff --git a/core/archipelago/src/api/rpc/package.rs b/core/archipelago/src/api/rpc/package.rs index 42994fc9..4055ffa5 100644 --- a/core/archipelago/src/api/rpc/package.rs +++ b/core/archipelago/src/api/rpc/package.rs @@ -122,6 +122,27 @@ impl RpcHandler { run_args.push("--network=archy-net"); } + // Security hardening (skip for privileged containers like Tailscale) + let security_caps: Vec = if !is_tailscale { + get_app_capabilities(package_id) + } else { + vec![] + }; + let readonly_compatible = !is_tailscale && is_readonly_compatible(package_id); + + if !is_tailscale { + run_args.push("--cap-drop=ALL"); + run_args.push("--security-opt=no-new-privileges:true"); + for cap in &security_caps { + run_args.push(cap); + } + if readonly_compatible { + run_args.push("--read-only"); + run_args.push("--tmpfs=/tmp:rw,noexec,nosuid,size=256m"); + run_args.push("--tmpfs=/run:rw,noexec,nosuid,size=64m"); + } + } + // Create data directories if they don't exist for volume in &volumes { if let Some(host_path) = volume.split(':').next() { @@ -776,6 +797,51 @@ fn is_valid_docker_image(image: &str) -> bool { true } +/// Per-app Linux capabilities needed beyond the default cap-drop=ALL. +/// Most apps need CHOWN/SETUID/SETGID for internal user switching. +fn get_app_capabilities(app_id: &str) -> Vec { + match app_id { + // Apps that need user switching and file ownership changes + "nextcloud" | "homeassistant" | "home-assistant" | "btcpay-server" | "btcpayserver" + | "jellyfin" | "onlyoffice" | "onlyoffice-documentserver" | "portainer" => vec![ + "--cap-add=CHOWN".to_string(), + "--cap-add=SETUID".to_string(), + "--cap-add=SETGID".to_string(), + "--cap-add=DAC_OVERRIDE".to_string(), + ], + // Nginx Proxy Manager needs to bind low ports + "nginx-proxy-manager" => vec![ + "--cap-add=CHOWN".to_string(), + "--cap-add=SETUID".to_string(), + "--cap-add=SETGID".to_string(), + "--cap-add=NET_BIND_SERVICE".to_string(), + ], + // Bitcoin and Lightning need file ownership ops + "bitcoin" | "bitcoin-core" | "bitcoin-knots" | "lnd" | "fedimint" => vec![ + "--cap-add=CHOWN".to_string(), + "--cap-add=SETUID".to_string(), + "--cap-add=SETGID".to_string(), + ], + // Grafana runs as specific UID (472) + "grafana" => vec![ + "--cap-add=CHOWN".to_string(), + "--cap-add=SETUID".to_string(), + "--cap-add=SETGID".to_string(), + ], + // Minimal apps (searxng, filebrowser, uptime-kuma, etc.) need no extra caps + _ => vec![], + } +} + +/// Apps safe to run with --read-only root filesystem. +/// These work correctly with volume mounts + tmpfs for /tmp and /run. +fn is_readonly_compatible(app_id: &str) -> bool { + matches!( + app_id, + "searxng" | "grafana" | "uptime-kuma" | "filebrowser" | "photoprism" | "vaultwarden" + ) +} + /// Get app-specific configuration /// Returns: (ports, volumes, env_vars, custom_command, custom_args) fn get_app_config( diff --git a/neode-ui/dev-dist/sw.js b/neode-ui/dev-dist/sw.js index 102f8f5a..a6e880ca 100644 --- a/neode-ui/dev-dist/sw.js +++ b/neode-ui/dev-dist/sw.js @@ -82,7 +82,7 @@ define(['./workbox-21a80088'], (function (workbox) { 'use strict'; "revision": "3ca0b8505b4bec776b69afdba2768812" }, { "url": "index.html", - "revision": "0.qmc1lepk3f" + "revision": "0.0ddc43l70qk" }], {}); workbox.cleanupOutdatedCaches(); workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { diff --git a/neode-ui/src/composables/useAudioPlayer.ts b/neode-ui/src/composables/useAudioPlayer.ts index 82dbeba4..ce91f5ac 100644 --- a/neode-ui/src/composables/useAudioPlayer.ts +++ b/neode-ui/src/composables/useAudioPlayer.ts @@ -6,6 +6,7 @@ const currentName = ref('') const playing = ref(false) const currentTime = ref(0) const duration = ref(0) +const error = ref(null) function play(src: string, name: string) { if (!audio.value) { @@ -15,6 +16,7 @@ function play(src: string, name: string) { }) audio.value.addEventListener('loadedmetadata', () => { duration.value = audio.value?.duration ?? 0 + error.value = null }) audio.value.addEventListener('ended', () => { playing.value = false @@ -24,8 +26,14 @@ function play(src: string, name: string) { }) audio.value.addEventListener('play', () => { playing.value = true + error.value = null + }) + audio.value.addEventListener('error', () => { + playing.value = false + error.value = 'Could not play audio. File Browser may not be running.' }) } + error.value = null if (currentSrc.value === src && playing.value) { audio.value.pause() @@ -78,5 +86,6 @@ export function useAudioPlayer() { duration, progress, currentSrc, + error, } } diff --git a/neode-ui/src/composables/useMobileBackButton.ts b/neode-ui/src/composables/useMobileBackButton.ts index 8ca95363..309b3b5f 100644 --- a/neode-ui/src/composables/useMobileBackButton.ts +++ b/neode-ui/src/composables/useMobileBackButton.ts @@ -25,24 +25,28 @@ export function useMobileBackButton() { function updateTabBarHeight() { if (typeof window === 'undefined') return - + // Try to find the mobile tab bar element const tabBar = document.querySelector('[data-mobile-tab-bar]') as HTMLElement - if (tabBar) { + if (tabBar && tabBar.offsetHeight > 0) { tabBarHeight.value = tabBar.offsetHeight - } else { - // Fallback: read from CSS variable if available - const cssVar = getComputedStyle(document.documentElement) - .getPropertyValue('--mobile-tab-bar-height') - .trim() - - if (cssVar) { - const height = parseFloat(cssVar) - if (!isNaN(height)) { - tabBarHeight.value = height - } + return + } + + // Fallback: read from CSS variable if available + const cssVar = getComputedStyle(document.documentElement) + .getPropertyValue('--mobile-tab-bar-height') + .trim() + + if (cssVar) { + const height = parseFloat(cssVar) + if (!isNaN(height) && height > 0) { + tabBarHeight.value = height + return } } + + // Final fallback: keep current value (don't reset to 0) } onMounted(() => { diff --git a/neode-ui/src/data/goals.ts b/neode-ui/src/data/goals.ts index 3ff03e37..474a49fe 100644 --- a/neode-ui/src/data/goals.ts +++ b/neode-ui/src/data/goals.ts @@ -189,6 +189,48 @@ export const GOALS: GoalDefinition[] = [ estimatedTime: '~40 min + sync time', difficulty: 'intermediate', }, + { + id: 'setup-fedimint', + title: 'Create a Community', + subtitle: 'Start a Fedimint federation for private, scalable Bitcoin', + icon: 'community', + category: 'community', + requiredApps: ['bitcoin-knots', 'fedimint'], + steps: [ + { + id: 'install-bitcoin', + title: 'Install Bitcoin Node', + description: 'Bitcoin Knots provides the base layer that Fedimint connects to for on-chain transactions and consensus.', + appId: 'bitcoin-knots', + action: 'install', + isAutomatic: true, + }, + { + id: 'install-fedimint', + title: 'Install Fedimint', + description: 'Fedimint is a federated Bitcoin mint. Guardians collectively manage funds using threshold signatures — no single point of failure.', + appId: 'fedimint', + action: 'install', + isAutomatic: true, + }, + { + id: 'configure-guardian', + title: 'Set Up Guardian UI', + description: 'Open the Guardian UI (port 8175) to configure your federation name, set the guardian threshold, and initialize the mint.', + action: 'configure', + isAutomatic: false, + }, + { + id: 'share-invite', + title: 'Share Invite Code', + description: 'Generate and share your federation invite code with community members so they can join and start using ecash.', + action: 'info', + isAutomatic: false, + }, + ], + estimatedTime: '~30 min + sync time', + difficulty: 'intermediate', + }, { id: 'create-identity', title: 'Create My Identity', diff --git a/neode-ui/src/style.css b/neode-ui/src/style.css index eb79c2b4..1e9ee928 100644 --- a/neode-ui/src/style.css +++ b/neode-ui/src/style.css @@ -180,6 +180,13 @@ background: transparent; } + /* On mobile, leave room for close button + tab bar below AIUI */ + @media (max-width: 767px) { + .chat-iframe-mobile { + padding-bottom: calc(var(--mobile-tab-bar-height, 72px) + 52px); + } + } + /* Chat placeholder (no AIUI URL) */ .chat-placeholder { flex: 1; @@ -1175,6 +1182,16 @@ html:has(body.video-background-active)::before { background: rgba(0, 0, 0, 0.4); } +/* ── Mobile floating back/close button (always 8px above tab bar) ──── */ +.mobile-back-btn { + position: fixed; + left: 1rem; + right: 1rem; + bottom: calc(var(--mobile-tab-bar-height, 72px) + 8px); + z-index: 40; + filter: drop-shadow(0 10px 25px rgba(0, 0, 0, 0.5)); +} + /* ── Cloud Audio Player (mini bar) ──── */ .cloud-audio-player { @@ -1194,8 +1211,9 @@ html:has(body.video-background-active)::before { display: flex; align-items: center; justify-content: center; - width: 2.25rem; - height: 2.25rem; + width: 2.75rem; + height: 2.75rem; + min-width: 2.75rem; border-radius: 50%; background: rgba(255, 255, 255, 0.1); border: none; @@ -1203,6 +1221,7 @@ html:has(body.video-background-active)::before { cursor: pointer; flex-shrink: 0; transition: all 0.15s ease; + padding: 0; } .cloud-audio-player-btn:hover { background: rgba(255, 255, 255, 0.2); diff --git a/neode-ui/src/types/goals.ts b/neode-ui/src/types/goals.ts index 8fd88338..2b69bf09 100644 --- a/neode-ui/src/types/goals.ts +++ b/neode-ui/src/types/goals.ts @@ -5,7 +5,7 @@ export interface GoalDefinition { title: string subtitle: string icon: string - category: 'commerce' | 'payments' | 'storage' | 'identity' | 'network' | 'backup' + category: 'commerce' | 'payments' | 'storage' | 'identity' | 'network' | 'backup' | 'community' requiredApps: string[] steps: GoalStep[] estimatedTime: string diff --git a/neode-ui/src/views/AppDetails.vue b/neode-ui/src/views/AppDetails.vue index ec20a434..93407cf4 100644 --- a/neode-ui/src/views/AppDetails.vue +++ b/neode-ui/src/views/AppDetails.vue @@ -9,13 +9,9 @@ -