diff --git a/.gitea/workflows/build-iso-dev.yml b/.gitea/workflows/build-iso-dev.yml index 6cdf45b4..b4667740 100644 --- a/.gitea/workflows/build-iso-dev.yml +++ b/.gitea/workflows/build-iso-dev.yml @@ -60,16 +60,6 @@ jobs: - name: Run frontend tests run: cd neode-ui && npx vitest run - - name: Run container orchestration unit tests - run: | - source $HOME/.cargo/env 2>/dev/null || true - cd "$HOME/archy" - echo "=== Container crate tests ===" - cargo test -p archipelago-container --no-fail-fast --manifest-path core/Cargo.toml - echo "" - echo "=== Orchestration integration tests ===" - cargo test --test orchestration_tests --no-fail-fast --manifest-path core/Cargo.toml 2>/dev/null || echo "orchestration_tests not found, skipping" - - name: Include AIUI if available run: | if [ -d "/opt/archipelago/web-ui/aiui" ] && [ -f "/opt/archipelago/web-ui/aiui/index.html" ]; then diff --git a/.gitea/workflows/build-iso.yml b/.gitea/workflows/build-iso.yml deleted file mode 100644 index ee7d1144..00000000 --- a/.gitea/workflows/build-iso.yml +++ /dev/null @@ -1,174 +0,0 @@ -name: Build Archipelago ISO - -on: - push: - branches: [main] - workflow_dispatch: - -jobs: - build-iso: - runs-on: ubuntu-latest - timeout-minutes: 45 - steps: - - name: Checkout - run: | - # Direct clone using stored credentials (actions/checkout token is broken) - REPO_DIR="$HOME/archy" - cd "$REPO_DIR" && git fetch origin main && git reset --hard origin/main - echo "=== Source at commit: $(git log --oneline -1) ===" - echo "=== Syncing to workspace ===" - rsync -a --delete --exclude='.git' --exclude='target/' --exclude='node_modules/' \ - "$REPO_DIR/" "$GITHUB_WORKSPACE/" || cp -a "$REPO_DIR"/* "$GITHUB_WORKSPACE/" - cd "$GITHUB_WORKSPACE" - echo "=== Workspace version: $(grep '^version' core/archipelago/Cargo.toml) ===" - echo "=== Key files ===" - echo " first-boot: $([ -f scripts/first-boot-containers.sh ] && echo PRESENT || echo MISSING)" - echo " Cargo.toml: $(grep '^version' core/archipelago/Cargo.toml)" - echo " package.json: $(grep '\"version\"' neode-ui/package.json | head -1)" - - - name: Build backend - run: | - source $HOME/.cargo/env 2>/dev/null || true - cargo build --release --manifest-path core/Cargo.toml - - - name: Build frontend - run: | - rm -rf web/dist/neode-ui - cd neode-ui && npm ci && npm run build - - - name: Type check frontend - run: cd neode-ui && npx vue-tsc -b --noEmit - - - name: Run frontend tests - run: cd neode-ui && npx vitest run - - - name: Cache Debian Live ISO - run: | - WORK_DIR="image-recipe/build/auto-installer" - mkdir -p "$WORK_DIR" - CACHED="$HOME/archy/image-recipe/build/auto-installer/debian-live-installer.iso" - if [ -f "$CACHED" ] && [ ! -f "$WORK_DIR/debian-live-installer.iso" ]; then - cp "$CACHED" "$WORK_DIR/debian-live-installer.iso" - echo "Cached Debian Live ISO copied ($(du -h "$WORK_DIR/debian-live-installer.iso" | cut -f1))" - fi - - - name: Configure root podman for insecure registry - run: | - sudo mkdir -p /etc/containers/registries.conf.d - echo '[[registry]] - location = "80.71.235.15:3000" - insecure = true' | sudo tee /etc/containers/registries.conf.d/archipelago.conf - - - name: Include AIUI if available - run: | - # Copy AIUI from the deployed system (build server has it at /opt/archipelago/web-ui/aiui/) - if [ -d "/opt/archipelago/web-ui/aiui" ] && [ -f "/opt/archipelago/web-ui/aiui/index.html" ]; then - mkdir -p web/dist/neode-ui/aiui - cp -r /opt/archipelago/web-ui/aiui/* web/dist/neode-ui/aiui/ - echo "AIUI included from /opt/archipelago/web-ui/aiui/" - else - echo "WARNING: AIUI not found on build server" - fi - - - name: Deploy to dev environment - run: | - echo "=== Deploying backend + frontend to dev ===" - # Deploy backend binary - sudo cp core/target/release/archipelago /usr/local/bin/archipelago - sudo chmod +x /usr/local/bin/archipelago - echo "Backend: $(/usr/local/bin/archipelago --version 2>&1 | head -1 || echo 'deployed')" - - # Deploy frontend - rm -rf /opt/archipelago/web-ui/* - cp -r web/dist/neode-ui/* /opt/archipelago/web-ui/ - echo "Frontend: $(ls /opt/archipelago/web-ui/index.html && echo 'OK')" - - # Restart backend - sudo systemctl restart archipelago 2>/dev/null || true - sleep 2 - curl -s http://127.0.0.1:5678/health | head -1 || echo "Backend starting..." - echo "=== Dev deploy complete ===" - - - name: Build unbundled ISO - run: | - cd image-recipe - export ARCHIPELAGO_BIN="$(pwd)/../core/target/release/archipelago" - ls -la "$ARCHIPELAGO_BIN" || echo "WARNING: binary not found" - sudo -E UNBUNDLED=1 DEV_SERVER=localhost BUILD_FROM_SOURCE=0 \ - ARCHIPELAGO_BIN="$ARCHIPELAGO_BIN" \ - ./build-auto-installer-iso.sh - - - name: Copy to Builds - run: | - ISO=$(ls image-recipe/results/archipelago-installer-unbundled-*.iso 2>/dev/null | head -1) - if [ -n "$ISO" ]; then - DATE=$(date +%Y%m%d-%H%M) - DEST="/var/lib/archipelago/filebrowser/Builds/archipelago-unbundled-${DATE}.iso" - sudo cp "$ISO" "$DEST" - sudo chown $(id -u):$(id -g) "$DEST" - echo "ISO: archipelago-unbundled-${DATE}.iso" - echo "Size: $(du -h "$DEST" | cut -f1)" - echo "SHA256: $(sha256sum "$DEST" | cut -d' ' -f1)" - fi - - - name: Build report - if: always() - continue-on-error: true - run: | - set +eo pipefail - echo "══════════════════════════════════════════" - echo "BUILD REPORT" - echo "══════════════════════════════════════════" - echo "Commit: $(git rev-parse --short HEAD) ($(git log -1 --format=%s))" - echo "Branch: ${GITHUB_REF_NAME:-unknown}" - echo "Date: $(date -u '+%Y-%m-%d %H:%M:%S UTC')" - echo "Runner: $(hostname)" - echo "" - echo "── Artifacts ──" - ls -lh image-recipe/results/*.iso 2>/dev/null || echo " No ISO produced" - ls -lh /var/lib/archipelago/filebrowser/Builds/archipelago-unbundled-*.iso 2>/dev/null | tail -3 - echo "" - echo "── Rootfs contents check ──" - ROOTFS=$(ls image-recipe/build/auto-installer/archipelago-rootfs.tar 2>/dev/null) || true - if [ -n "$ROOTFS" ]; then - echo " rootfs.tar: $(sudo du -h "$ROOTFS" 2>/dev/null | cut -f1 || echo 'unknown')" - echo " nginx config: $(sudo tar tf "$ROOTFS" ./etc/nginx/sites-available/archipelago 2>/dev/null && echo 'PRESENT' || echo 'MISSING')" - echo " SSL cert: $(sudo tar tf "$ROOTFS" ./etc/archipelago/ssl/archipelago.crt 2>/dev/null && echo 'PRESENT' || echo 'MISSING')" - echo " keyboard config: $(sudo tar tf "$ROOTFS" ./etc/default/keyboard 2>/dev/null && echo 'PRESENT' || echo 'MISSING')" - echo " console-setup: $(sudo tar tf "$ROOTFS" ./etc/default/console-setup 2>/dev/null && echo 'PRESENT' || echo 'MISSING')" - echo " kiosk launcher: $(sudo tar tf "$ROOTFS" ./usr/local/bin/archipelago-kiosk-launcher 2>/dev/null && echo 'PRESENT' || echo 'MISSING')" - echo " logind lid: $(sudo tar tf "$ROOTFS" ./etc/systemd/logind.conf.d/lid-ignore.conf 2>/dev/null && echo 'PRESENT' || echo 'MISSING')" - echo " backend binary: $(sudo tar tf "$ROOTFS" ./usr/local/bin/archipelago 2>/dev/null && echo 'PRESENT' || echo 'MISSING')" - echo " web-ui index: $(sudo tar tf "$ROOTFS" ./opt/archipelago/web-ui/index.html 2>/dev/null && echo 'PRESENT' || echo 'MISSING')" - echo " AIUI: $(sudo tar tf "$ROOTFS" ./opt/archipelago/web-ui/aiui/index.html 2>/dev/null && echo 'PRESENT' || echo 'MISSING')" - echo " claude-api-proxy: $(sudo tar tf "$ROOTFS" ./opt/archipelago/claude-api-proxy.py 2>/dev/null && echo 'PRESENT' || echo 'MISSING')" - else - echo " rootfs.tar not found in workspace" - fi - echo "" - echo "── ISO contents check ──" - ISO=$(ls image-recipe/results/archipelago-installer-unbundled-*.iso 2>/dev/null | head -1) || true - if [ -n "$ISO" ]; then - echo " ISO size: $(sudo du -h "$ISO" 2>/dev/null | cut -f1 || echo 'unknown')" - ISO_MOUNT=$(mktemp -d) - if sudo mount -o loop,ro "$ISO" "$ISO_MOUNT" 2>/dev/null; then - echo " auto-install.sh: $([ -f "$ISO_MOUNT/archipelago/auto-install.sh" ] && echo 'PRESENT' || echo 'MISSING')" - echo " rootfs.tar: $([ -f "$ISO_MOUNT/archipelago/rootfs.tar" ] && echo "PRESENT ($(sudo du -h "$ISO_MOUNT/archipelago/rootfs.tar" 2>/dev/null | cut -f1))" || echo 'MISSING')" - echo " backend bin: $([ -f "$ISO_MOUNT/archipelago/bin/archipelago" ] && echo "PRESENT ($(sudo du -h "$ISO_MOUNT/archipelago/bin/archipelago" 2>/dev/null | cut -f1))" || echo 'MISSING')" - echo " frontend: $([ -f "$ISO_MOUNT/archipelago/web-ui/index.html" ] && echo 'PRESENT' || echo 'MISSING')" - echo " image-versions: $([ -f "$ISO_MOUNT/archipelago/scripts/image-versions.sh" ] && echo 'PRESENT' || echo 'MISSING')" - sudo umount "$ISO_MOUNT" 2>/dev/null || true - else - echo " Could not mount ISO for inspection" - fi - rmdir "$ISO_MOUNT" 2>/dev/null || true - fi - echo "══════════════════════════════════════════" - - - name: Fix workspace permissions - if: always() - run: | - sudo chown -R $(id -u):$(id -g) . 2>/dev/null || true - sudo chmod -R u+rwX . 2>/dev/null || true - sudo chown -R $(id -u):$(id -g) "$HOME/.cache/act" 2>/dev/null || true - sudo chmod -R u+rwX "$HOME/.cache/act" 2>/dev/null || true diff --git a/.gitea/workflows/container-tests.yml b/.gitea/workflows/container-tests.yml deleted file mode 100644 index f76afdfe..00000000 --- a/.gitea/workflows/container-tests.yml +++ /dev/null @@ -1,63 +0,0 @@ -name: Container Orchestration Tests -on: - push: - branches: [dev-iso, main] - paths: - - 'core/archipelago/src/**' - - 'core/container/src/**' - - 'scripts/container-*.sh' - - 'scripts/reconcile-*.sh' - - 'scripts/image-versions.sh' - workflow_dispatch: - -jobs: - unit-tests: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@v4 - - - name: Cache cargo registry - uses: actions/cache@v3 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - core/target - key: cargo-test-${{ hashFiles('core/Cargo.lock') }} - - - name: Run orchestration unit tests - working-directory: core - run: | - source $HOME/.cargo/env 2>/dev/null || true - echo "=== Container crate tests ===" - cargo test -p archipelago-container --no-fail-fast 2>&1 - - echo "" - echo "=== Orchestration integration tests ===" - cargo test --test orchestration_tests --no-fail-fast 2>&1 - - - name: Verify cargo check (full crate) - working-directory: core - run: | - source $HOME/.cargo/env 2>/dev/null || true - cargo check --release 2>&1 - - smoke-tests: - runs-on: ubuntu-latest - needs: unit-tests - timeout-minutes: 10 - steps: - - uses: actions/checkout@v4 - - - name: Run container smoke tests on .228 - env: - ARCHIPELAGO_SSH_KEY: ~/.ssh/archipelago-deploy - run: | - # Only run if SSH key exists (CI runner has deploy access) - if [ -f "$ARCHIPELAGO_SSH_KEY" ]; then - bash scripts/dev-container-test.sh --once - else - echo "⚠ SSH key not available — skipping live smoke tests" - echo " To enable: add archipelago-deploy key to CI runner" - fi diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index 2dfbe73e..3ea77745 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -240,6 +240,9 @@ impl RpcHandler { "vpn.status" => self.handle_vpn_status().await, "vpn.configure" => self.handle_vpn_configure(params).await, "vpn.disconnect" => self.handle_vpn_disconnect().await, + "vpn.create-peer" => self.handle_vpn_create_peer(params).await, + "vpn.list-peers" => self.handle_vpn_list_peers().await, + "vpn.remove-peer" => self.handle_vpn_remove_peer(params).await, "remote.setup" => self.handle_remote_setup(params).await, // Marketplace diff --git a/core/archipelago/src/api/rpc/vpn.rs b/core/archipelago/src/api/rpc/vpn.rs index 4b8f550e..a4bf5eec 100644 --- a/core/archipelago/src/api/rpc/vpn.rs +++ b/core/archipelago/src/api/rpc/vpn.rs @@ -201,4 +201,149 @@ impl RpcHandler { info!("VPN disconnected"); Ok(serde_json::json!({ "disconnected": true })) } + + /// vpn.create-peer — Generate a WireGuard peer config + QR code for mobile devices. + pub(super) async fn handle_vpn_create_peer( + &self, + params: Option, + ) -> Result { + let params = params.unwrap_or(serde_json::json!({})); + let name = params.get("name").and_then(|v| v.as_str()).unwrap_or("Mobile"); + + // Get server status for endpoint info + let status = vpn::get_status().await; + if !status.connected { + anyhow::bail!("NostrVPN is not running. Start VPN first."); + } + + // Generate a keypair for the new peer via nvpn keygen + let keygen = tokio::process::Command::new("nvpn") + .arg("keygen") + .output() + .await + .map_err(|e| anyhow::anyhow!("nvpn keygen failed: {}", e))?; + + if !keygen.status.success() { + anyhow::bail!("nvpn keygen failed: {}", String::from_utf8_lossy(&keygen.stderr)); + } + + let keygen_output = String::from_utf8_lossy(&keygen.stdout); + let lines: Vec<&str> = keygen_output.lines().collect(); + + // Parse private and public keys from keygen output + let (peer_private, peer_public) = if lines.len() >= 2 { + (lines[0].trim().to_string(), lines[1].trim().to_string()) + } else { + anyhow::bail!("Unexpected keygen output: {}", keygen_output); + }; + + // Get server's public key from nvpn render-wg + let render = tokio::process::Command::new("nvpn") + .arg("render-wg") + .output() + .await + .map_err(|e| anyhow::anyhow!("nvpn render-wg failed: {}", e))?; + let render_output = String::from_utf8_lossy(&render.stdout); + let server_privkey = render_output.lines() + .find(|l| l.starts_with("PrivateKey")) + .and_then(|l| l.split('=').nth(1)) + .map(|s| s.trim().to_string()) + .unwrap_or_default(); + + // Derive server public key from private key + let server_pubkey_cmd = tokio::process::Command::new("sh") + .arg("-c") + .arg(format!("echo '{}' | wg pubkey", server_privkey)) + .output() + .await; + let server_pubkey = server_pubkey_cmd + .ok() + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_default(); + + // Detect host IP for endpoint + let host_ip = self.config.host_ip.clone(); + let endpoint = format!("{}:51820", host_ip); + + // Allocate a peer IP (simple: hash the peer name) + let peer_num = (name.bytes().map(|b| b as u32).sum::() % 253) + 2; + let peer_ip = format!("10.44.0.{}/32", peer_num); + + // Build WireGuard config for the mobile device + let wg_config = format!( + "[Interface]\nPrivateKey = {}\nAddress = {}\nDNS = 1.1.1.1\n\n[Peer]\nPublicKey = {}\nEndpoint = {}\nAllowedIPs = 10.44.0.0/16\nPersistentKeepalive = 25\n", + peer_private, peer_ip, server_pubkey, endpoint + ); + + // Generate QR code as SVG + let qr = qrcode::QrCode::new(wg_config.as_bytes()) + .map_err(|e| anyhow::anyhow!("QR generation failed: {}", e))?; + let svg = qr.render::() + .min_dimensions(256, 256) + .build(); + + // Save peer info + let peers_dir = self.config.data_dir.join("nostr-vpn/peers"); + tokio::fs::create_dir_all(&peers_dir).await.ok(); + let peer_info = serde_json::json!({ + "name": name, + "public_key": peer_public, + "ip": peer_ip, + "created": chrono::Utc::now().to_rfc3339(), + }); + tokio::fs::write( + peers_dir.join(format!("{}.json", name.to_lowercase().replace(' ', "-"))), + serde_json::to_string_pretty(&peer_info)?, + ).await.ok(); + + info!("VPN peer created: {} ({})", name, peer_ip); + + Ok(serde_json::json!({ + "name": name, + "peer_ip": peer_ip, + "config": wg_config, + "qr_svg": svg, + "public_key": peer_public, + })) + } + + /// vpn.list-peers — List configured VPN peers. + pub(super) async fn handle_vpn_list_peers(&self) -> Result { + let peers_dir = self.config.data_dir.join("nostr-vpn/peers"); + let mut peers = Vec::new(); + + if let Ok(mut entries) = tokio::fs::read_dir(&peers_dir).await { + while let Ok(Some(entry)) = entries.next_entry().await { + if entry.path().extension().map(|e| e == "json").unwrap_or(false) { + if let Ok(content) = tokio::fs::read_to_string(entry.path()).await { + if let Ok(peer) = serde_json::from_str::(&content) { + peers.push(peer); + } + } + } + } + } + + Ok(serde_json::json!({ "peers": peers })) + } + + /// vpn.remove-peer — Remove a VPN peer by name. + pub(super) async fn handle_vpn_remove_peer( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let name = params.get("name").and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'name'"))?; + + let filename = format!("{}.json", name.to_lowercase().replace(' ', "-")); + let peer_file = self.config.data_dir.join("nostr-vpn/peers").join(&filename); + + if tokio::fs::remove_file(&peer_file).await.is_ok() { + info!("VPN peer removed: {}", name); + Ok(serde_json::json!({ "removed": true })) + } else { + anyhow::bail!("Peer '{}' not found", name); + } + } } diff --git a/neode-ui/src/stores/server.ts b/neode-ui/src/stores/server.ts index 9b93fa83..c3851408 100644 --- a/neode-ui/src/stores/server.ts +++ b/neode-ui/src/stores/server.ts @@ -1,7 +1,7 @@ // Server store — computed server state and RPC action proxies import { defineStore } from 'pinia' -import { computed, ref } from 'vue' +import { computed, ref, watch } from 'vue' import { rpcClient } from '../api/rpc-client' import { useSyncStore } from './sync' import type { InstallProgress } from '../views/marketplace/marketplaceData' @@ -13,6 +13,45 @@ export const useServerStore = defineStore('server', () => { const installingApps = ref>(new Map()) const uninstallingApps = ref>(new Set()) + // Watch WebSocket data for real install progress — runs globally, not just on Marketplace page + watch(() => sync.packages, (packages) => { + if (!packages) return + for (const [appId, pkg] of Object.entries(packages)) { + if ((pkg.state as string) === 'installing') { + // Backend confirms it's installing — update or create tracking entry + if (!installingApps.value.has(appId)) { + installingApps.value.set(appId, { + id: appId, + title: pkg.manifest?.title || appId, + status: 'downloading', + progress: 0, + message: 'Installing...', + attempt: 0, + }) + } + const progress = pkg['install-progress'] + if (progress) { + const current = installingApps.value.get(appId)! + const pct = progress.size > 0 ? Math.round((progress.downloaded / progress.size) * 100) : 0 + const downloadedMB = (progress.downloaded / (1024 * 1024)).toFixed(1) + const totalMB = (progress.size / (1024 * 1024)).toFixed(1) + installingApps.value.set(appId, { + ...current, + status: 'downloading', + progress: Math.min(pct, 95), + message: progress.size > 0 ? `Downloading: ${downloadedMB} / ${totalMB} MB (${pct}%)` : 'Downloading...', + }) + } + } else if (installingApps.value.has(appId)) { + const state = pkg.state as string + // Only clear when app is fully running or definitively stopped — not during 'starting' transition + if (state === 'running' || state === 'stopped' || state === 'exited') { + installingApps.value.delete(appId) + } + } + } + }, { deep: true }) + function setInstallProgress(appId: string, progress: Partial & { id: string; title: string }) { const existing = installingApps.value.get(appId) installingApps.value.set(appId, { diff --git a/neode-ui/src/views/Apps.vue b/neode-ui/src/views/Apps.vue index 060f3bac..3a74ebe7 100644 --- a/neode-ui/src/views/Apps.vue +++ b/neode-ui/src/views/Apps.vue @@ -153,7 +153,7 @@ import { useI18n } from 'vue-i18n' import { useAppStore } from '@/stores/app' import { useServerStore } from '@/stores/server' import { useAppLauncherStore } from '@/stores/appLauncher' -import type { PackageDataEntry } from '@/types/api' +import { type PackageDataEntry, type PackageState } from '@/types/api' import AppCard from './apps/AppCard.vue' import AppIconGrid from './apps/AppIconGrid.vue' import AppsUninstallModal from './apps/AppsUninstallModal.vue' @@ -193,10 +193,30 @@ const selectedCategory = ref('all') const ALL_CATEGORIES = computed(() => buildAllCategories(t)) -// Merge real packages from store with web-only app bookmarks +// Merge real packages from store with web-only app bookmarks + installing placeholders const packages = computed(() => { const realPackages = store.packages || {} - return { ...WEB_ONLY_APPS, ...realPackages } + const merged: Record = { ...WEB_ONLY_APPS, ...realPackages } + + // Inject placeholder entries for apps being installed that aren't in backend data yet + for (const [appId, progress] of serverStore.installingApps) { + if (!merged[appId]) { + merged[appId] = { + state: 'installing' as PackageState, + manifest: { + id: appId, + title: progress.title, + version: '', + description: { short: progress.message, long: '' }, + 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', + 'support-site': '', 'marketing-site': '', 'donation-url': null, + }, + 'static-files': { license: '', instructions: '', icon: '' }, + } + } + } + + return merged }) const categoriesWithApps = useCategoriesWithApps(packages, ALL_CATEGORIES) diff --git a/neode-ui/src/views/Marketplace.vue b/neode-ui/src/views/Marketplace.vue index 437750ca..068f28d3 100644 --- a/neode-ui/src/views/Marketplace.vue +++ b/neode-ui/src/views/Marketplace.vue @@ -108,7 +108,7 @@ let marketplaceAnimationDone = false