From 84a56c80defecf765e26c89f079d1b2e3f8da85d Mon Sep 17 00:00:00 2001 From: Dorian Date: Thu, 19 Mar 2026 12:44:31 +0000 Subject: [PATCH] =?UTF-8?q?security+feat:=20v1.3.0=20=E2=80=94=20pentest?= =?UTF-8?q?=20remediation,=20container=20reliability,=20UI=20overhaul?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security (33 pentest findings addressed): - CRITICAL: backend binds 127.0.0.1, path traversal in tor.rs/dwn fixed - HIGH: federation requires signatures, XSS login redirect, RBAC viewer restricted - HIGH: tar slip prevention, S3 SSRF validation, backup ID validation - MEDIUM: remember-me random secret, TOTP session rotation, password re-auth - LOW: CSP unsafe-inline removed, CORS dev-only, onion/webhook validation Container reliability: - Memory limits on all 37 containers (OOM prevention) - Exited vs stopped state distinction with health-aware status badges - Crash recovery coordination (no more restart cascade) - User-stopped tracking survives reboots - Tiered boot recovery (databases → core → services → apps) UI: - Wallet TransactionsModal, health-aware app status badges - Restart button on containers, exited/crashed red state - Mesh view overhaul, glass button updates, BaseModal/ToggleSwitch - Apps sticky header removed, dev faucet, mutable mock wallet Infrastructure: - LND REST port 8080 exposed over Tor (LND Connect fix) - Nginx cookie_session fix, deploy script Tor config updated - Dev environment: podman auto-start, boot mode simulation Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/memory/MEMORY.md | 1 + .claude/memory/project_environments.md | 21 + .../project_repo_cleanup_and_dev_env.md | 78 +++ CHANGELOG.md | 81 +++ core/archipelago/src/api/handler.rs | 9 +- core/archipelago/src/api/rpc/auth.rs | 16 +- core/archipelago/src/api/rpc/backup_rpc.rs | 69 +++ core/archipelago/src/api/rpc/content.rs | 31 +- core/archipelago/src/api/rpc/federation.rs | 3 +- core/archipelago/src/api/rpc/mod.rs | 62 +- core/archipelago/src/api/rpc/package.rs | 14 + core/archipelago/src/api/rpc/system.rs | 12 + core/archipelago/src/api/rpc/tor.rs | 16 + core/archipelago/src/api/rpc/totp.rs | 14 +- core/archipelago/src/api/rpc/webhooks.rs | 136 ++++- core/archipelago/src/auth.rs | 4 +- core/archipelago/src/backup/full.rs | 42 +- core/archipelago/src/config.rs | 2 +- .../src/container/docker_packages.rs | 7 +- core/archipelago/src/crash_recovery.rs | 97 +++- core/archipelago/src/data_model.rs | 4 + core/archipelago/src/health_monitor.rs | 30 +- core/archipelago/src/main.rs | 6 +- core/archipelago/src/network/dwn_store.rs | 17 + core/archipelago/src/network/router.rs | 26 + core/archipelago/src/nostr_relays.rs | 84 ++- core/archipelago/src/session.rs | 66 ++- core/archipelago/src/webhooks.rs | 1 + core/container/src/podman_client.rs | 26 + core/container/src/runtime.rs | 4 + image-recipe/build-auto-installer-iso.sh | 1 + image-recipe/configs/nginx-archipelago.conf | 22 +- neode-ui/dev-dist/sw.js | 2 +- neode-ui/mock-backend.js | 1 + neode-ui/public/assets/icon/web5.svg | 3 + .../src/components/AppLauncherOverlay.vue | 6 +- neode-ui/src/components/BaseModal.vue | 89 +++ neode-ui/src/components/EasyHome.vue | 43 +- neode-ui/src/components/HelpGuideModal.vue | 41 +- neode-ui/src/components/PWAUpdatePrompt.vue | 66 +-- .../src/components/ReceiveBitcoinModal.vue | 45 +- neode-ui/src/components/SendBitcoinModal.vue | 41 +- neode-ui/src/components/ToggleSwitch.vue | 25 + neode-ui/src/components/TransactionsModal.vue | 116 ++++ neode-ui/src/components/cloud/ShareModal.vue | 6 +- neode-ui/src/locales/en.json | 48 ++ neode-ui/src/locales/es.json | 48 ++ neode-ui/src/router/index.ts | 2 +- neode-ui/src/stores/aiPermissions.ts | 5 +- neode-ui/src/stores/appLauncher.ts | 12 +- neode-ui/src/stores/spotlight.ts | 13 +- neode-ui/src/style.css | 307 +++++----- neode-ui/src/types/api.ts | 1 + neode-ui/src/views/AppDetails.vue | 56 +- neode-ui/src/views/AppSession.vue | 2 +- neode-ui/src/views/Apps.vue | 111 ++-- neode-ui/src/views/Chat.vue | 2 +- neode-ui/src/views/Cloud.vue | 12 +- neode-ui/src/views/CloudFolder.vue | 6 + neode-ui/src/views/Dashboard.vue | 13 +- neode-ui/src/views/Federation.vue | 4 +- neode-ui/src/views/GoalDetail.vue | 29 +- neode-ui/src/views/Kiosk.vue | 2 +- neode-ui/src/views/KioskRecovery.vue | 2 +- neode-ui/src/views/Login.vue | 7 +- neode-ui/src/views/Marketplace.vue | 30 +- neode-ui/src/views/Mesh.vue | 543 +++++++++++++----- neode-ui/src/views/Monitoring.vue | 10 +- neode-ui/src/views/PeerFiles.vue | 2 +- neode-ui/src/views/Server.vue | 172 +++--- neode-ui/src/views/Settings.vue | 64 +-- neode-ui/src/views/Web5.vue | 207 ++++--- neode-ui/src/views/apps/LightningChannels.vue | 14 +- scripts/deploy-to-target.sh | 8 +- scripts/dev-start.sh | 36 +- scripts/first-boot-containers.sh | 164 ++++-- scripts/setup-https-dev.sh | 33 +- 77 files changed, 2485 insertions(+), 966 deletions(-) create mode 100644 .claude/memory/project_environments.md create mode 100644 .claude/memory/project_repo_cleanup_and_dev_env.md create mode 100644 neode-ui/public/assets/icon/web5.svg create mode 100644 neode-ui/src/components/BaseModal.vue create mode 100644 neode-ui/src/components/ToggleSwitch.vue create mode 100644 neode-ui/src/components/TransactionsModal.vue diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md index 0af792d3..6c14b3ec 100644 --- a/.claude/memory/MEMORY.md +++ b/.claude/memory/MEMORY.md @@ -5,6 +5,7 @@ - [deploy-automation.md](deploy-automation.md) — Deploy script automation TODOs (API key, AIUI nginx, swap) ## Servers & Deploy +- [project_environments.md](project_environments.md) — Four environments: dev mode, dev server/prod, demo - [tailscale_servers.md](tailscale_servers.md) — Tailscale server details (archipelago-2, archipelago-3) - [reference_tailscale_nodes.md](reference_tailscale_nodes.md) — All node IPs and SSH commands - [second-server.md](second-server.md) — Second dev server (archipelago-2 via Tailscale) diff --git a/.claude/memory/project_environments.md b/.claude/memory/project_environments.md new file mode 100644 index 00000000..58134979 --- /dev/null +++ b/.claude/memory/project_environments.md @@ -0,0 +1,21 @@ +--- +name: Four Environments +description: Clear distinction between dev mode (local mock), dev server (228), demo (Portainer), and prod (same as dev server) +type: project +--- + +Four distinct environments — use correct terminology: + +| Name | What | Where | Backend | Deploy | +|------|------|-------|---------|--------| +| **Dev mode** | Local macOS, mock backend | `localhost:8100` | `mock-backend.js` on `:5959` | `npm run dev:mock` | +| **Dev server / Prod** | Primary build/test/live server | `192.168.1.228` (+ fleet) | Real Rust backend + Podman | `deploy-to-target.sh --live` | +| **Demo** | Public demo instance | Remote server | Mock Node.js via Docker | Portainer Stacks / `docker-compose.demo.yml` | + +- Dev server and prod are the SAME machine (192.168.1.228) — "prod" just means "the live deployment" +- Demo is completely separate — user deploys via Portainer UI, Claude has no SSH access +- Dev mode is local-only, no containers needed, fastest iteration + +**Why:** User corrected ambiguous usage of "dev servers (prod)" — these are the same thing, not two separate environments. + +**How to apply:** Always say "dev mode" for local mock, "dev server" or "prod" for 228, "demo" for the Portainer instance. Never conflate them. diff --git a/.claude/memory/project_repo_cleanup_and_dev_env.md b/.claude/memory/project_repo_cleanup_and_dev_env.md new file mode 100644 index 00000000..12a5cc75 --- /dev/null +++ b/.claude/memory/project_repo_cleanup_and_dev_env.md @@ -0,0 +1,78 @@ +--- +name: Repo Cleanup & Dev Environment Overhaul (2026-03-18) +description: Major session — repo cleanup to archy-archive, demo seeding, dev-start.sh rewrite, ThunderHub/Fedimint/ecash, Podman install, wallet mock endpoints +type: project +--- + +## What Was Done + +### 1. Repo Cleanup +- Moved ~200 files (docs, scripts, loops, legacy Docker UIs, duplicate videos) to `~/Projects/archy-archive/` (outside repo) +- Kept: all active docs (BETA-PROGRESS, MASTER_PLAN, architecture, ADRs, api-reference, developer-guide, troubleshooting, operations-runbook), all source code, active scripts +- Three "user's call" docs kept: `multi-node-architecture.md`, `marketplace-protocol.md`, `app-developer-guide.md` + +### 2. docker-compose.yml Switched from Regtest to Signet +- All Bitcoin/LND/Fedimint containers now use **signet** (not regtest) +- Ports updated: RPC 38332, P2P 38333 +- Removed archived `bitcoin-ui` and `lnd-ui` nginx services (referenced deleted `docker/` dir) +- Added ThunderHub service (port 3010) to main compose + +### 3. New Testnet Compose (`docker-compose.testnet.yml`) +- Standalone signet stack: bitcoind + LND + ThunderHub + Fedimint +- Config at `testnet/thunderhub-config.yaml` +- README at `testnet/README.md` with faucet links and commands + +### 4. Mock Backend Enhancements (`neode-ui/mock-backend.js`) +- **Container socket auto-detection**: tries `DOCKER_HOST` → Podman TMPDIR socket → Docker socket → null (simulation). No more `/var/run/docker.sock` spam +- **8 static dev apps** (was 6): added ThunderHub (port 3010) and Fedimint (port 8175) +- **25+ new RPC endpoints**: lnd.getinfo, lnd.newaddress, lnd.createinvoice, lnd.payinvoice, lnd.sendcoins, lnd.listchannels, lnd.openchannel, lnd.closechannel, wallet.ecash-balance, wallet.ecash-send, wallet.ecash-receive, wallet.ecash-history, wallet.networking-profits, bitcoin.getinfo, system.stats, update.status, network.list-requests, dev.faucet, etc. +- **Fedimint version** synced to 0.10.0, port fixed from 8174 → 8175 +- **5 realistic notifications** (was empty array) +- **Mock ThunderHub UI** at `/app/thunderhub/` — full HTML dashboard + +### 5. Dev Scripts Fixed +- `neode-ui/start-dev.sh`: removed broken `start-docker-apps.sh` call, fixed EAGAIN via safe `while read` loop +- `neode-ui/stop-dev.sh`: removed broken `stop-docker-apps.sh` call +- `neode-ui/package.json`: removed stale `prebuild`, added `--raw` to concurrently (fixes EAGAIN pgrep spawn) +- `scripts/dev-start.sh`: complete rewrite with 8 options including boot mode and testnet stack + +### 6. ThunderHub Added Everywhere +- Icon: `neode-ui/public/assets/img/app-icons/thunderhub.svg` +- Mock backend: portMappings, marketplaceMetadata, staticDevApps, marketplace.get() +- Marketplace.vue: getCuratedAppList(), recommended tier +- appLauncher.ts: PORT_TO_APP_ID `'3010': 'thunderhub'` + +### 7. Podman Installed on Mac +- `podman 5.8.1` + `podman-compose 1.5.0` via Homebrew +- Machine initialized and running + +### 8. Home Wallet Card +- Fixed `lnd.getinfo` response to include `balance_sats` and `channel_balance_sats` +- Fixed `lnd.gettransactions` to use `amount_sats` and include `incoming_pending_count` +- Added **Faucet button** (green) — calls `dev.faucet` RPC +- Grid changed from 3-col to 4-col (Send, Receive, Faucet, Web5) + +### 9. Developer Onboarding Docs +- `neode-ui/README.md`: full rewrite +- `neode-ui/DEV-SCRIPTS.md`: updated with actual 8 static apps + +## Current State / Resume Here +- **`npm start` works** — no Docker needed, all wallet actions mocked, 8 apps visible +- **Send/Receive modals** open from Home wallet card — if still issues, check browser console +- **Faucet button** calls dev.faucet and refreshes balances +- **Not yet tested**: `podman-compose -f docker-compose.testnet.yml up` (signet sync ~10 min) +- **Not yet committed** — all changes are local, uncommitted +- **Demo prod server** not redeployed — push changes then redeploy via Portainer + +## Key Files Modified This Session +- `neode-ui/mock-backend.js` (major — container socket, 25+ RPC endpoints, ThunderHub mock UI) +- `neode-ui/src/views/Home.vue` (faucet button, 4-col grid) +- `neode-ui/src/views/Marketplace.vue` (ThunderHub entry) +- `neode-ui/src/stores/appLauncher.ts` (ThunderHub port) +- `neode-ui/start-dev.sh`, `neode-ui/stop-dev.sh`, `neode-ui/package.json` +- `scripts/dev-start.sh` (complete rewrite) +- `docker-compose.yml` (regtest→signet, ThunderHub, removed archived UIs) +- `docker-compose.testnet.yml` (new) +- `testnet/thunderhub-config.yaml`, `testnet/README.md` (new) +- `neode-ui/public/assets/img/app-icons/thunderhub.svg` (new) +- `neode-ui/README.md`, `neode-ui/DEV-SCRIPTS.md` (rewrites) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8367a34c..9498355b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,87 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.3.0] - 2026-03-19 + +### Security + +#### Pentest Remediation (33 findings, all addressed) +- **Critical**: Backend now binds to 127.0.0.1 only — no more direct LAN access to port 5678 +- **Critical**: Fixed path traversal in Tor service management that could allow `sudo rm -rf` on arbitrary directories +- **Critical**: Fixed unauthenticated file read/delete via DWN recordId path traversal +- **High**: Federation peers now require cryptographic signature — unsigned peers rejected +- **High**: Login redirect XSS vulnerability fixed with proper URL validation +- **High**: Viewer role restricted to read-only node methods (was granting sign/export access) +- **High**: Backup restore/verify now validates IDs against path traversal +- **High**: Tar archive extraction validates every entry path (prevents tar slip attacks) +- **High**: S3 backup endpoints require HTTPS and reject private IP ranges +- **Medium**: Remember-me token secret now uses cryptographic random (not machine-id) +- **Medium**: Destructive operations (factory reset, onboarding reset) now require password re-verification +- **Medium**: Session token rotated after TOTP verification (prevents interception reuse) +- **Medium**: Webhook URL validation hardened against IPv6 bypass, DNS rebinding, redirect chains +- **Low**: CORS localhost:8100 only included in dev mode +- **Low**: CSP `unsafe-inline` removed from `script-src` +- **Low**: Content filenames validated against path separators and hidden file prefixes +- **Low**: Nostr relay URLs restricted to `wss://` with private IP rejection +- **Low**: Onion address validation enforces v3 format (56 base32 chars) +- **Low**: Router detection restricted to private IP ranges only + +#### Nginx Authentication +- Fixed session cookie name mismatch (`session_id` → `session`) across all nginx auth checks +- LND Connect info endpoint now properly authenticated + +### Container Reliability + +#### Memory Limits (prevents OOM crashes) +- All 37 containers in `first-boot-containers.sh` now have `--memory=` limits +- Automatic RAM tier detection — reduced limits on 8GB machines +- Prevents a single runaway container from crashing the entire system + +#### Smart Container States +- New `exited` state distinguishes crashed containers from intentionally stopped ones +- Crashed containers show red "crashed" badge with restart button +- Health-aware status: "healthy" (green), "starting up" (yellow spinner), "unhealthy" (orange pulse) +- Restart button added next to Stop on running containers + +#### Crash Recovery Improvements +- Boot recovery and health monitor now coordinate via shared flag (no more restart cascade) +- User-stopped containers tracked in `user-stopped.json` — survive reboots without auto-restart +- Boot recovery uses tiered ordering: databases → core → services → apps → UIs +- Health monitor waits for boot recovery to complete before starting checks + +### UI Improvements + +#### Home Dashboard +- Wallet card now matches Web5 wallet display +- New Transactions modal with full history (incoming/outgoing, amounts, confirmations) +- Transactions button in header — switches to "Incoming" badge when pending transactions exist +- Dev faucet button (dev mode only) with mutable wallet state +- Fixed system stats crash (`cpu_usage_percent` field name mismatch) + +#### Apps & App Details +- Container restart button (icon) next to Stop on all running apps +- Exited/crashed containers show "Restart" instead of "Start" with red styling +- Removed broken sticky header from Apps page +- Health-aware status badges throughout + +#### Mesh, Cloud, Settings & More +- Mesh view overhaul with improved layout +- Glass button styling updates across components +- New BaseModal and ToggleSwitch components +- Updated translations (English + Spanish) +- Spotlight search improvements + +### Infrastructure + +#### LND Connect +- Tor hidden service now exposes LND REST port (8080) for remote wallet connections +- Fixed in ISO build script, deploy script, and live servers + +#### Dev Environment +- Mock backend has mutable wallet state (faucet/send/receive actually change balances) +- Testnet stack option auto-starts Podman machine on macOS +- Boot mode simulation for testing startup screens + ## [1.2.0] - 2026-03-14 ### Fixed diff --git a/core/archipelago/src/api/handler.rs b/core/archipelago/src/api/handler.rs index 70224d8d..19f51e37 100644 --- a/core/archipelago/src/api/handler.rs +++ b/core/archipelago/src/api/handler.rs @@ -77,11 +77,14 @@ impl ApiHandler { /// Allowed CORS origins derived from the config host IP. fn allowed_origins(&self) -> Vec { - vec![ + let mut origins = vec![ format!("http://{}", self.config.host_ip), format!("https://{}", self.config.host_ip), - "http://localhost:8100".to_string(), // Vite dev server - ] + ]; + if self.config.dev_mode { + origins.push("http://localhost:8100".to_string()); // Vite dev server + } + origins } /// Validate the Origin header against allowed origins. diff --git a/core/archipelago/src/api/rpc/auth.rs b/core/archipelago/src/api/rpc/auth.rs index 9d9c040e..3ce50a23 100644 --- a/core/archipelago/src/api/rpc/auth.rs +++ b/core/archipelago/src/api/rpc/auth.rs @@ -76,7 +76,21 @@ impl RpcHandler { Ok(serde_json::json!(complete)) } - pub(super) async fn handle_auth_reset_onboarding(&self) -> Result { + pub(super) async fn handle_auth_reset_onboarding( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let password = params + .get("password") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing password — re-authentication required"))?; + + let valid = self.auth_manager.verify_password(password).await?; + if !valid { + return Err(anyhow::anyhow!("Password Incorrect")); + } + self.auth_manager.reset_onboarding().await?; Ok(serde_json::json!(true)) } diff --git a/core/archipelago/src/api/rpc/backup_rpc.rs b/core/archipelago/src/api/rpc/backup_rpc.rs index 10b918cc..2892de02 100644 --- a/core/archipelago/src/api/rpc/backup_rpc.rs +++ b/core/archipelago/src/api/rpc/backup_rpc.rs @@ -1,8 +1,61 @@ use super::RpcHandler; use crate::backup::full; use anyhow::{Context, Result}; +use std::net::IpAddr; use tracing::info; +/// Validate an S3 endpoint URL: require https, reject private/loopback IPs and localhost. +fn validate_s3_endpoint(endpoint: &str) -> Result<()> { + // Require HTTPS scheme + if !endpoint.starts_with("https://") { + anyhow::bail!("S3 endpoint must use https://"); + } + + // Extract host from URL (strip scheme, path, port) + let after_scheme = &endpoint["https://".len()..]; + let host_port = after_scheme.split('/').next().unwrap_or(""); + // Strip port if present (handle IPv6 bracket notation) + let host = if host_port.starts_with('[') { + // IPv6: [::1]:443 + host_port.split(']').next().unwrap_or("").trim_start_matches('[') + } else { + host_port.split(':').next().unwrap_or("") + }; + + if host.is_empty() { + anyhow::bail!("S3 endpoint missing host"); + } + + // Reject localhost + if host == "localhost" || host.ends_with(".localhost") { + anyhow::bail!("S3 endpoint must not point to localhost"); + } + + // Parse as IP and reject private/reserved ranges + if let Ok(ip) = host.parse::() { + let is_private = match ip { + IpAddr::V4(v4) => { + v4.is_loopback() // 127.0.0.0/8 + || v4.octets()[0] == 10 // 10.0.0.0/8 + || (v4.octets()[0] == 172 && (v4.octets()[1] & 0xf0) == 16) // 172.16.0.0/12 + || (v4.octets()[0] == 192 && v4.octets()[1] == 168) // 192.168.0.0/16 + || (v4.octets()[0] == 169 && v4.octets()[1] == 254) // 169.254.0.0/16 + || v4.is_unspecified() // 0.0.0.0 + } + IpAddr::V6(v6) => { + v6.is_loopback() // ::1 + || (v6.segments()[0] & 0xfe00) == 0xfc00 // fc00::/7 + || v6.is_unspecified() // :: + } + }; + if is_private { + anyhow::bail!("S3 endpoint must not point to a private or reserved IP address"); + } + } + + Ok(()) +} + impl RpcHandler { /// Create a full encrypted backup. Params: { passphrase, description? } pub(super) async fn handle_backup_create( @@ -55,6 +108,11 @@ impl RpcHandler { .as_str() .ok_or_else(|| anyhow::anyhow!("Missing 'passphrase' parameter"))?; + // Validate backup ID to prevent path traversal + if id.is_empty() || id.len() > 128 || id.contains('/') || id.contains('\\') || id.contains("..") || id.contains('\0') { + anyhow::bail!("Invalid backup ID"); + } + let result = full::verify_backup(&self.config.data_dir, id, passphrase).await?; Ok(serde_json::json!({ @@ -78,6 +136,11 @@ impl RpcHandler { .as_str() .ok_or_else(|| anyhow::anyhow!("Missing 'passphrase' parameter"))?; + // Validate backup ID to prevent path traversal + if id.is_empty() || id.len() > 128 || id.contains('/') || id.contains('\\') || id.contains("..") || id.contains('\0') { + anyhow::bail!("Invalid backup ID"); + } + full::restore_full_backup(&self.config.data_dir, id, passphrase).await?; Ok(serde_json::json!({ "restored": true, "id": id })) @@ -183,6 +246,9 @@ impl RpcHandler { anyhow::bail!("Invalid backup ID"); } + // Validate endpoint to prevent SSRF against internal services + validate_s3_endpoint(endpoint)?; + let bak_path = full::backup_file_path(&self.config.data_dir, id); if !bak_path.exists() { anyhow::bail!("Backup not found: {}", id); @@ -255,6 +321,9 @@ impl RpcHandler { anyhow::bail!("Invalid backup ID"); } + // Validate endpoint to prevent SSRF against internal services + validate_s3_endpoint(endpoint)?; + let key = format!("archipelago-backups/{}.tar.gz.enc", id); let url = format!("{}/{}/{}", endpoint.trim_end_matches('/'), bucket, key); diff --git a/core/archipelago/src/api/rpc/content.rs b/core/archipelago/src/api/rpc/content.rs index 1d3a6a25..32c97166 100644 --- a/core/archipelago/src/api/rpc/content.rs +++ b/core/archipelago/src/api/rpc/content.rs @@ -4,6 +4,16 @@ use crate::network::dwn_store::DwnStore; use anyhow::{Context, Result}; use tracing::debug; +/// Validate a v3 Tor onion address. +/// Must be exactly 62 chars: 56 base32 characters (a-z, 2-7) followed by ".onion". +fn is_valid_v3_onion(addr: &str) -> bool { + if addr.len() != 62 || !addr.ends_with(".onion") { + return false; + } + let prefix = &addr[..56]; + prefix.chars().all(|c| c.is_ascii_lowercase() || ('2'..='7').contains(&c)) +} + const FILE_CATALOG_PROTOCOL: &str = "https://archipelago.dev/protocols/file-catalog/v1"; impl RpcHandler { @@ -25,10 +35,16 @@ impl RpcHandler { .get("filename") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing filename"))?; - // Prevent path traversal - if filename.contains("..") || filename.contains('\0') { + // Validate filename: prevent path traversal, hidden files, and excessive length + if filename.contains("..") || filename.contains('\0') || filename.contains('/') || filename.contains('\\') { anyhow::bail!("Invalid filename: path traversal not allowed"); } + if filename.starts_with('.') { + anyhow::bail!("Invalid filename: hidden files not allowed"); + } + if filename.is_empty() || filename.len() > 255 { + anyhow::bail!("Invalid filename: must be 1-255 characters"); + } let mime_type = params .get("mime_type") .and_then(|v| v.as_str()) @@ -191,8 +207,9 @@ impl RpcHandler { .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing content_id"))?; - if !onion.ends_with(".onion") || onion.len() < 10 { - return Err(anyhow::anyhow!("Invalid onion address")); + // Validate v3 onion address: 56 base32 chars + ".onion" = 62 chars total + if !is_valid_v3_onion(onion) { + return Err(anyhow::anyhow!("Invalid v3 onion address")); } let socks_proxy = reqwest::Proxy::all("socks5h://127.0.0.1:9050") @@ -252,9 +269,9 @@ impl RpcHandler { .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing onion address"))?; - // Validate onion address format - if !onion.ends_with(".onion") || onion.len() < 10 { - return Err(anyhow::anyhow!("Invalid onion address")); + // Validate v3 onion address: 56 base32 chars + ".onion" = 62 chars total + if !is_valid_v3_onion(onion) { + return Err(anyhow::anyhow!("Invalid v3 onion address")); } // Connect via Tor SOCKS proxy to the peer's content catalog endpoint diff --git a/core/archipelago/src/api/rpc/federation.rs b/core/archipelago/src/api/rpc/federation.rs index 236b9d1a..1e517493 100644 --- a/core/archipelago/src/api/rpc/federation.rs +++ b/core/archipelago/src/api/rpc/federation.rs @@ -371,7 +371,8 @@ impl RpcHandler { } } None => { - tracing::warn!(peer_did = %did, "Peer-joined without signature — accepting but unverified"); + tracing::warn!(peer_did = %did, "Rejected peer-joined: missing signature"); + anyhow::bail!("Missing signature — all federation peers must be cryptographically verified"); } } diff --git a/core/archipelago/src/api/rpc/mod.rs b/core/archipelago/src/api/rpc/mod.rs index 4627859d..e3692288 100644 --- a/core/archipelago/src/api/rpc/mod.rs +++ b/core/archipelago/src/api/rpc/mod.rs @@ -442,7 +442,7 @@ impl RpcHandler { "auth.changePassword" => self.handle_auth_change_password(params, &session_token).await, "auth.onboardingComplete" => self.handle_auth_onboarding_complete().await, "auth.isOnboardingComplete" => self.handle_auth_is_onboarding_complete().await, - "auth.resetOnboarding" => self.handle_auth_reset_onboarding().await, + "auth.resetOnboarding" => self.handle_auth_reset_onboarding(params).await, // Container orchestration (for Archipelago-managed containers) "container-install" => self.handle_container_install(params).await, @@ -789,7 +789,7 @@ impl RpcHandler { self.metrics_store.record_rpc_latency(elapsed_ms).await; // Build response (cache successful results for cacheable methods) - let rpc_resp = match result { + let mut rpc_resp = match result { Ok(data) => { if is_cacheable { self.response_cache.set(rpc_req.method.clone(), data.clone()).await; @@ -893,8 +893,62 @@ impl RpcHandler { } } - // On successful TOTP verification, the session is already upgraded to full - // (handled inside handle_login_totp/handle_login_backup) + // On successful TOTP verification, set the rotated session cookie + if (rpc_req.method == "auth.login.totp" || rpc_req.method == "auth.login.backup") + && rpc_resp.error.is_none() + { + // Extract token (clone to release immutable borrow before mutable borrow below) + let new_token_opt = rpc_resp + .result + .as_ref() + .and_then(|r| r.get("new_session_token")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + if let Some(new_token) = new_token_opt { + let csrf_token = derive_csrf_token(&new_token); + let remember_token = self.session_store.create_remember_token(); + response.headers_mut().append( + "Set-Cookie", + format!( + "session={}; HttpOnly; SameSite=Strict; Path=/{}", + new_token, + self.cookie_suffix() + ) + .parse() + .unwrap(), + ); + response.headers_mut().append( + "Set-Cookie", + format!( + "csrf_token={}; SameSite=Strict; Path=/{}", + csrf_token, + self.cookie_suffix() + ) + .parse() + .unwrap(), + ); + response.headers_mut().append( + "Set-Cookie", + format!( + "remember={}; HttpOnly; SameSite=Strict; Path=/; Max-Age={}{}", + remember_token, + REMEMBER_TTL, + self.cookie_suffix() + ) + .parse() + .unwrap(), + ); + // Strip the token from the response body — don't leak it to JS + if let Some(result) = rpc_resp.result.as_mut() { + if let Some(obj) = result.as_object_mut() { + obj.remove("new_session_token"); + } + } + let body_bytes = serde_json::to_vec(&rpc_resp).unwrap_or_default(); + *response.body_mut() = hyper::Body::from(body_bytes); + } + } // On password change, rotate the session token for the caller if rpc_req.method == "auth.changePassword" && rpc_resp.error.is_none() { diff --git a/core/archipelago/src/api/rpc/package.rs b/core/archipelago/src/api/rpc/package.rs index 2644a4b8..ce088aad 100644 --- a/core/archipelago/src/api/rpc/package.rs +++ b/core/archipelago/src/api/rpc/package.rs @@ -686,6 +686,12 @@ printtoconsole=1\n", rpc_pass); sorted }; + // Clear user-stopped flag — user explicitly started this app + crate::crash_recovery::clear_user_stopped(&self.config.data_dir, package_id).await; + for name in &to_start { + crate::crash_recovery::clear_user_stopped(&self.config.data_dir, name).await; + } + for name in to_start { let _ = tokio::process::Command::new("podman") .args(["start", &name]) @@ -707,9 +713,13 @@ printtoconsole=1\n", rpc_pass); .ok_or_else(|| anyhow::anyhow!("Missing package id"))?; validate_app_id(package_id)?; + // Mark as user-stopped so health monitor and crash recovery don't auto-restart + crate::crash_recovery::mark_user_stopped(&self.config.data_dir, package_id).await; + let containers = get_containers_for_app(package_id).await?; if containers.is_empty() { let container_name = format!("archy-{}", package_id); + crate::crash_recovery::mark_user_stopped(&self.config.data_dir, &container_name).await; let _ = tokio::process::Command::new("podman") .args(["stop", &container_name]) .output() @@ -717,6 +727,9 @@ printtoconsole=1\n", rpc_pass); return Ok(serde_json::Value::Null); } + for name in &containers { + crate::crash_recovery::mark_user_stopped(&self.config.data_dir, name).await; + } for name in containers { let _ = tokio::process::Command::new("podman") .args(["stop", &name]) @@ -1025,6 +1038,7 @@ printtoconsole=1\n", rpc_pass); fn create_installing_entry(package_id: &str) -> PackageDataEntry { PackageDataEntry { state: PackageState::Installing, + health: None, static_files: StaticFiles { license: String::new(), instructions: String::new(), diff --git a/core/archipelago/src/api/rpc/system.rs b/core/archipelago/src/api/rpc/system.rs index 99ede29c..5fa8028c 100644 --- a/core/archipelago/src/api/rpc/system.rs +++ b/core/archipelago/src/api/rpc/system.rs @@ -609,6 +609,18 @@ impl RpcHandler { anyhow::bail!("Factory reset requires {{ \"confirm\": true }}"); } + // Require password re-authentication for destructive operations + let password = params + .as_ref() + .and_then(|p| p.get("password")) + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing password — re-authentication required"))?; + + let valid = self.auth_manager.verify_password(password).await?; + if !valid { + return Err(anyhow::anyhow!("Password Incorrect")); + } + tracing::warn!("Factory reset initiated — wiping ALL user data and containers"); let data_dir = &self.config.data_dir; diff --git a/core/archipelago/src/api/rpc/tor.rs b/core/archipelago/src/api/rpc/tor.rs index 64b0d4a6..4fb40ca7 100644 --- a/core/archipelago/src/api/rpc/tor.rs +++ b/core/archipelago/src/api/rpc/tor.rs @@ -118,6 +118,11 @@ impl RpcHandler { .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing name"))?; + // Validate name to prevent path traversal + if name.is_empty() || name.len() > 64 || !name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') { + return Err(anyhow::anyhow!("Invalid service name (alphanumeric, hyphens, underscores only)")); + } + let onion = read_onion_address(name); Ok(serde_json::json!({ "name": name, "onion_address": onion })) } @@ -134,6 +139,11 @@ impl RpcHandler { .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing name"))?; + // Validate name to prevent path traversal + if name.is_empty() || name.len() > 64 || !name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') { + return Err(anyhow::anyhow!("Invalid service name (alphanumeric, hyphens, underscores only)")); + } + let base = tor_data_dir(); let service_dir = format!("{}/hidden_service_{}", base, name); @@ -277,6 +287,12 @@ impl RpcHandler { .get("app_id") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing app_id"))?; + + // Validate app_id to prevent path traversal + if app_id.is_empty() || app_id.len() > 64 || !app_id.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') { + return Err(anyhow::anyhow!("Invalid app_id (alphanumeric, hyphens, underscores only)")); + } + let enabled = params .get("enabled") .and_then(|v| v.as_bool()) diff --git a/core/archipelago/src/api/rpc/totp.rs b/core/archipelago/src/api/rpc/totp.rs index cb9538d6..5979a739 100644 --- a/core/archipelago/src/api/rpc/totp.rs +++ b/core/archipelago/src/api/rpc/totp.rs @@ -192,10 +192,11 @@ impl RpcHandler { let _ = self.auth_manager.update_totp(data).await; } - // Upgrade pending session to full - self.session_store.upgrade_to_full(token).await; + // Upgrade pending session to full (rotates token) + let new_token = self.session_store.upgrade_to_full(token).await + .ok_or_else(|| anyhow::anyhow!("Session expired. Please log in again."))?; - Ok(serde_json::json!({ "success": true })) + Ok(serde_json::json!({ "success": true, "new_session_token": new_token })) } None => { anyhow::bail!("Invalid code. Please try again."); @@ -241,13 +242,14 @@ impl RpcHandler { totp_data.backup_codes.remove(idx); self.auth_manager.update_totp(totp_data).await?; - // Upgrade pending session to full - self.session_store.upgrade_to_full(token).await; + // Upgrade pending session to full (rotates token) + let new_token = self.session_store.upgrade_to_full(token).await + .ok_or_else(|| anyhow::anyhow!("Session expired. Please log in again."))?; tracing::info!("Login via backup code (codes remaining: {})", self.auth_manager.get_totp_data().await?.map(|d| d.backup_codes.len()).unwrap_or(0)); - Ok(serde_json::json!({ "success": true })) + Ok(serde_json::json!({ "success": true, "new_session_token": new_token })) } None => { anyhow::bail!("Invalid backup code"); diff --git a/core/archipelago/src/api/rpc/webhooks.rs b/core/archipelago/src/api/rpc/webhooks.rs index 2d55344e..9287259b 100644 --- a/core/archipelago/src/api/rpc/webhooks.rs +++ b/core/archipelago/src/api/rpc/webhooks.rs @@ -3,6 +3,90 @@ use crate::webhooks; use anyhow::Result; use tracing::info; +/// Check if a hostname/IP points to a private or internal address. +/// Handles: IPv4, IPv6 (including mapped IPv4 like ::ffff:127.0.0.1), +/// decimal/octal IP representations, and well-known internal hostnames. +fn is_webhook_host_private(host: &str) -> bool { + // Strip IPv6 brackets if present + let h = host.trim_start_matches('[').trim_end_matches(']'); + + // Check well-known internal hostnames + let lower = h.to_lowercase(); + if lower == "localhost" + || lower == "localhost.localdomain" + || lower.ends_with(".local") + || lower.ends_with(".internal") + || lower == "metadata.google.internal" + || lower == "169.254.169.254" + { + return true; + } + + // Try to parse as IP address + if let Ok(ip) = h.parse::() { + return match ip { + std::net::IpAddr::V4(v4) => { + v4.is_loopback() + || v4.is_private() + || v4.is_link_local() + || v4.is_unspecified() + || v4.octets()[0] == 100 && (64..=127).contains(&v4.octets()[1]) // CGNAT + } + std::net::IpAddr::V6(v6) => { + if v6.is_loopback() || v6.is_unspecified() { + return true; + } + // Check IPv4-mapped IPv6 (::ffff:x.x.x.x) + if let Some(v4) = v6.to_ipv4_mapped() { + return v4.is_loopback() + || v4.is_private() + || v4.is_link_local() + || v4.is_unspecified(); + } + // Unique local (fd00::/8, fc00::/7) + let segments = v6.segments(); + (segments[0] & 0xfe00) == 0xfc00 + || (segments[0] & 0xffc0) == 0xfe80 // link-local + } + }; + } + + // Detect decimal IP notation (e.g., "2130706433" = 127.0.0.1) + if let Ok(decimal) = h.parse::() { + let octets = decimal.to_be_bytes(); + let v4 = std::net::Ipv4Addr::new(octets[0], octets[1], octets[2], octets[3]); + return v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified(); + } + + // Detect octal IP notation (e.g., "0177.0.0.1" = 127.0.0.1) + if h.contains('.') { + let parts: Vec<&str> = h.split('.').collect(); + if parts.len() == 4 { + let mut octets = [0u8; 4]; + let mut all_ok = true; + for (i, part) in parts.iter().enumerate() { + let val = if part.starts_with("0x") || part.starts_with("0X") { + u64::from_str_radix(part.trim_start_matches("0x").trim_start_matches("0X"), 16).ok() + } else if part.starts_with('0') && part.len() > 1 { + u64::from_str_radix(part, 8).ok() + } else { + part.parse::().ok() + }; + match val { + Some(v) if v <= 255 => octets[i] = v as u8, + _ => { all_ok = false; break; } + } + } + if all_ok { + let v4 = std::net::Ipv4Addr::new(octets[0], octets[1], octets[2], octets[3]); + return v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified(); + } + } + } + + false +} + impl RpcHandler { /// webhook.get-config — Get current webhook configuration. pub(super) async fn handle_webhook_get_config(&self) -> Result { @@ -28,37 +112,35 @@ impl RpcHandler { config.enabled = enabled; } if let Some(url) = params.get("url").and_then(|v| v.as_str()) { - // Validate webhook URL scheme and reject obviously dangerous targets + // Validate webhook URL scheme and reject dangerous targets if !url.is_empty() { - if !url.starts_with("https://") && !url.starts_with("http://") { - anyhow::bail!("Webhook URL must use HTTP(S)"); - } - if !self.config.dev_mode && !url.starts_with("https://") { - anyhow::bail!("Webhook URL must use HTTPS in production"); - } - // Extract host portion and reject private/internal addresses - let host_part = url - .trim_start_matches("https://") - .trim_start_matches("http://") - .split('/') - .next() - .unwrap_or("") - .split(':') - .next() - .unwrap_or(""); - let is_private = host_part == "localhost" - || host_part == "127.0.0.1" - || host_part == "::1" - || host_part.starts_with("10.") - || host_part.starts_with("172.") - || host_part.starts_with("192.168.") - || host_part.starts_with("169.254."); - if is_private && !self.config.dev_mode { - anyhow::bail!("Webhook URL must not point to private/local addresses"); - } if url.len() > 2048 { anyhow::bail!("Webhook URL too long"); } + // Parse URL properly to handle edge cases (IPv6, userinfo, etc.) + let parsed = reqwest::Url::parse(url) + .map_err(|_| anyhow::anyhow!("Invalid webhook URL"))?; + // Require https:// in production + if !self.config.dev_mode && parsed.scheme() != "https" { + anyhow::bail!("Webhook URL must use HTTPS in production"); + } + if parsed.scheme() != "https" && parsed.scheme() != "http" { + anyhow::bail!("Webhook URL must use HTTP(S)"); + } + // Reject URLs with userinfo (user:pass@host) — can be used for credential smuggling + if parsed.username() != "" || parsed.password().is_some() { + anyhow::bail!("Webhook URL must not contain credentials"); + } + // Extract and validate the host + let host = parsed.host_str().unwrap_or(""); + if host.is_empty() { + anyhow::bail!("Webhook URL must have a valid host"); + } + // Reject private/internal addresses (handle IPv4, IPv6, decimal/octal IPs, hostnames) + let is_private = is_webhook_host_private(host); + if is_private && !self.config.dev_mode { + anyhow::bail!("Webhook URL must not point to private/local addresses"); + } } config.url = url.to_string(); } diff --git a/core/archipelago/src/auth.rs b/core/archipelago/src/auth.rs index e48c0f4b..939b6806 100644 --- a/core/archipelago/src/auth.rs +++ b/core/archipelago/src/auth.rs @@ -39,7 +39,9 @@ impl UserRole { || method == "system.temperature" || method == "system.disk-status" || method == "system.detect-usb-devices" - || method.starts_with("node.") + || method == "node.did" + || method == "node.tor-address" + || method == "node.nostr-pubkey" || method.starts_with("federation.list") || method.starts_with("dwn.status") || method.starts_with("dwn.list") diff --git a/core/archipelago/src/backup/full.rs b/core/archipelago/src/backup/full.rs index 28e303d7..1b9aef67 100644 --- a/core/archipelago/src/backup/full.rs +++ b/core/archipelago/src/backup/full.rs @@ -435,10 +435,46 @@ fn create_tar_gz(data_dir: &Path) -> Result> { fn extract_tar_gz(data_dir: &Path, tar_gz_data: &[u8]) -> Result<()> { let gz = GzDecoder::new(tar_gz_data); let mut archive = Archive::new(gz); + let canonical_base = data_dir + .canonicalize() + .context("Failed to canonicalize data_dir")?; - archive - .unpack(data_dir) - .context("Failed to extract backup archive")?; + for entry_result in archive.entries().context("Failed to read tar entries")? { + let mut entry = entry_result.context("Failed to read tar entry")?; + let entry_path = entry.path().context("Failed to get entry path")?.to_path_buf(); + + // Reject entries with path traversal components + for component in entry_path.components() { + if matches!(component, std::path::Component::ParentDir) { + anyhow::bail!( + "Tar entry contains path traversal: {}", + entry_path.display() + ); + } + } + + let target = data_dir.join(&entry_path); + // Verify the resolved path stays within data_dir + // For new files that don't exist yet, check the parent directory + let check_path = if target.exists() { + target.canonicalize()? + } else if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent)?; + parent.canonicalize()?.join(target.file_name().unwrap_or_default()) + } else { + target.clone() + }; + if !check_path.starts_with(&canonical_base) { + anyhow::bail!( + "Tar entry escapes target directory: {}", + entry_path.display() + ); + } + + entry + .unpack(&target) + .with_context(|| format!("Failed to extract: {}", entry_path.display()))?; + } debug!("Backup extracted to {:?}", data_dir); Ok(()) diff --git a/core/archipelago/src/config.rs b/core/archipelago/src/config.rs index b140ab13..6da54d97 100644 --- a/core/archipelago/src/config.rs +++ b/core/archipelago/src/config.rs @@ -217,7 +217,7 @@ mod tests { fn test_default_config_values() { let config = Config::default(); assert_eq!(config.data_dir, PathBuf::from("/var/lib/archipelago")); - assert_eq!(config.bind_host, "0.0.0.0"); + assert_eq!(config.bind_host, "127.0.0.1"); assert_eq!(config.bind_port, 5678); assert_eq!(config.log_level, "info"); assert_eq!(config.host_ip, "127.0.0.1"); diff --git a/core/archipelago/src/container/docker_packages.rs b/core/archipelago/src/container/docker_packages.rs index 2796ce48..1202a9e9 100644 --- a/core/archipelago/src/container/docker_packages.rs +++ b/core/archipelago/src/container/docker_packages.rs @@ -145,6 +145,7 @@ impl DockerPackageScanner { let package = PackageDataEntry { state: package_state.clone(), + health: container.health.clone(), static_files: StaticFiles { license: "MIT".to_string(), instructions: metadata.description.clone(), @@ -592,9 +593,8 @@ fn extract_lan_address(ports: &[String]) -> Option { fn convert_state(container_state: &ContainerState) -> (PackageState, ServiceStatus) { match container_state { ContainerState::Running => (PackageState::Running, ServiceStatus::Running), - ContainerState::Stopped | ContainerState::Exited => { - (PackageState::Stopped, ServiceStatus::Stopped) - } + ContainerState::Stopped => (PackageState::Stopped, ServiceStatus::Stopped), + ContainerState::Exited => (PackageState::Exited, ServiceStatus::Stopped), ContainerState::Created => (PackageState::Stopped, ServiceStatus::Stopped), ContainerState::Paused => (PackageState::Stopped, ServiceStatus::Stopped), ContainerState::Unknown(_) => (PackageState::Stopped, ServiceStatus::Stopped), @@ -607,6 +607,7 @@ fn package_state_str(state: &PackageState) -> &str { PackageState::Installed => "installed", PackageState::Stopping => "stopping", PackageState::Stopped => "stopped", + PackageState::Exited => "exited", PackageState::Starting => "starting", PackageState::Running => "running", PackageState::Restarting => "restarting", diff --git a/core/archipelago/src/crash_recovery.rs b/core/archipelago/src/crash_recovery.rs index 6299b6f2..4f795075 100644 --- a/core/archipelago/src/crash_recovery.rs +++ b/core/archipelago/src/crash_recovery.rs @@ -11,11 +11,64 @@ use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; use tokio::fs; use tracing::{info, warn}; const PID_FILE: &str = "archipelago.pid"; const CONTAINER_STATE_FILE: &str = "running-containers.json"; +const USER_STOPPED_FILE: &str = "user-stopped.json"; + +/// Shared flag: true once boot recovery is complete. Health monitor should wait for this. +pub static RECOVERY_COMPLETE: AtomicBool = AtomicBool::new(false); + +/// Mark boot recovery as complete. Call after crash recovery + start_stopped_containers finish. +pub fn mark_recovery_complete() { + RECOVERY_COMPLETE.store(true, Ordering::SeqCst); + info!("Boot recovery complete — health monitor may proceed"); +} + +/// Check if boot recovery is done. +pub fn is_recovery_complete() -> bool { + RECOVERY_COMPLETE.load(Ordering::SeqCst) +} + +// ── User-stopped tracking ─────────────────────────────────────────────── +// When a user explicitly stops a container via the UI, we record it here +// so crash recovery and health monitor don't auto-restart it. + +/// Load the set of user-stopped containers from disk. +pub async fn load_user_stopped(data_dir: &Path) -> std::collections::HashSet { + let path = data_dir.join(USER_STOPPED_FILE); + match fs::read_to_string(&path).await { + Ok(content) => serde_json::from_str(&content).unwrap_or_default(), + Err(_) => std::collections::HashSet::new(), + } +} + +/// Save the set of user-stopped containers to disk. +pub async fn save_user_stopped(data_dir: &Path, stopped: &std::collections::HashSet) { + let path = data_dir.join(USER_STOPPED_FILE); + if let Ok(json) = serde_json::to_string_pretty(stopped) { + let _ = fs::write(&path, json).await; + } +} + +/// Mark a container as user-stopped (won't be auto-restarted). +pub async fn mark_user_stopped(data_dir: &Path, name: &str) { + let mut stopped = load_user_stopped(data_dir).await; + stopped.insert(name.to_string()); + save_user_stopped(data_dir, &stopped).await; +} + +/// Clear user-stopped flag (container was manually started by user). +pub async fn clear_user_stopped(data_dir: &Path, name: &str) { + let mut stopped = load_user_stopped(data_dir).await; + if stopped.remove(name) { + save_user_stopped(data_dir, &stopped).await; + } +} #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RunningContainerRecord { @@ -241,7 +294,8 @@ fn is_process_running(pid: u32) -> bool { /// Start all stopped containers that were previously installed. /// Runs on every startup to ensure containers come back after clean reboots. /// The crash recovery (PID-based) handles dirty shutdowns; this handles clean ones. -pub async fn start_stopped_containers() -> RecoveryReport { +/// Skips containers that the user intentionally stopped via the UI. +pub async fn start_stopped_containers(data_dir: &Path) -> RecoveryReport { let output = match tokio::time::timeout( std::time::Duration::from_secs(30), tokio::process::Command::new("podman") @@ -257,7 +311,7 @@ pub async fn start_stopped_containers() -> RecoveryReport { } }; - let names: Vec = match output { + let all_names: Vec = match output { Ok(o) if o.status.success() => { String::from_utf8_lossy(&o.stdout) .lines() @@ -268,17 +322,52 @@ pub async fn start_stopped_containers() -> RecoveryReport { _ => Vec::new(), }; + if all_names.is_empty() { + return RecoveryReport { total: 0, recovered: 0, failed: Vec::new() }; + } + + // Filter out user-stopped containers + let user_stopped = load_user_stopped(data_dir).await; + let names: Vec = all_names.into_iter() + .filter(|n| { + if user_stopped.contains(n) { + info!("Skipping user-stopped container: {}", n); + false + } else { + true + } + }) + .collect(); + if names.is_empty() { return RecoveryReport { total: 0, recovered: 0, failed: Vec::new() }; } - info!("Starting {} stopped containers after boot...", names.len()); - let records: Vec = names.iter() + // Sort by startup tier: databases first, then core, then dependent services, then apps + let mut records: Vec = names.iter() .map(|n| RunningContainerRecord { name: n.clone(), image: String::new() }) .collect(); + records.sort_by_key(|r| container_boot_tier(&r.name)); + + info!("Starting {} stopped containers after boot (skipped {} user-stopped)...", + records.len(), user_stopped.len()); recover_containers(&records).await } +/// Simple tier ordering for boot recovery (mirrors health_monitor tiers). +fn container_boot_tier(name: &str) -> u8 { + let id = name.strip_prefix("archy-").unwrap_or(name); + match id { + "btcpay-db" | "mempool-db" | "penpot-postgres" | "immich_postgres" + | "immich_redis" | "penpot-valkey" => 0, + "bitcoin-knots" | "bitcoin-core" | "bitcoin" => 1, + "lnd" | "electrumx" | "mempool-electrs" | "electrs" | "nbxplorer" => 2, + "mempool-web" | "bitcoin-ui" | "lnd-ui" | "electrs-ui" + | "penpot-frontend" | "penpot-exporter" => 4, + _ => 3, + } +} + /// Spawn a background task that periodically saves the container snapshot. pub fn spawn_snapshot_task(data_dir: PathBuf) { tokio::spawn(async move { diff --git a/core/archipelago/src/data_model.rs b/core/archipelago/src/data_model.rs index e7e8534a..c0365588 100644 --- a/core/archipelago/src/data_model.rs +++ b/core/archipelago/src/data_model.rs @@ -102,6 +102,7 @@ pub enum PackageState { Installed, Stopping, Stopped, + Exited, Starting, Running, Restarting, @@ -117,6 +118,9 @@ pub enum PackageState { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct PackageDataEntry { pub state: PackageState, + /// Container health: "healthy", "unhealthy", "starting", or null + #[serde(skip_serializing_if = "Option::is_none")] + pub health: Option, #[serde(rename = "static-files")] pub static_files: StaticFiles, pub manifest: Manifest, diff --git a/core/archipelago/src/health_monitor.rs b/core/archipelago/src/health_monitor.rs index 7cfa6525..0c0a0cee 100644 --- a/core/archipelago/src/health_monitor.rs +++ b/core/archipelago/src/health_monitor.rs @@ -350,8 +350,26 @@ async fn restart_container(name: &str) -> bool { /// Spawn the health monitor background task. pub fn spawn_health_monitor(state: Arc, data_dir: PathBuf) { tokio::spawn(async move { - // Wait 2 minutes for containers to start up - tokio::time::sleep(std::time::Duration::from_secs(120)).await; + // Wait for boot recovery to complete before starting health checks. + // This prevents the health monitor from fighting with crash_recovery + // which is starting containers in tier order. + info!("Health monitor: waiting for boot recovery to complete..."); + let wait_start = std::time::Instant::now(); + loop { + if crate::crash_recovery::is_recovery_complete() { + break; + } + // Safety timeout: start anyway after 5 minutes even if recovery hangs + if wait_start.elapsed().as_secs() > 300 { + warn!("Health monitor: boot recovery did not complete within 5 minutes, starting anyway"); + break; + } + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + } + // Additional cooldown after recovery to let containers stabilize + info!("Health monitor: recovery done, waiting 60s for containers to stabilize..."); + tokio::time::sleep(std::time::Duration::from_secs(60)).await; + info!("Health monitor: starting health checks"); let mut tracker = RestartTracker::new(); let mut mem_tracker = MemoryTracker::new(); @@ -378,6 +396,9 @@ pub fn spawn_health_monitor(state: Arc, data_dir: PathBuf) { continue; } + // Load user-stopped list to skip intentionally stopped containers + let user_stopped = crate::crash_recovery::load_user_stopped(&data_dir).await; + // Sort containers by startup tier so databases restart before dependent services let mut unhealthy: Vec<&ContainerHealth> = Vec::new(); let mut state_changed = false; @@ -392,6 +413,11 @@ pub fn spawn_health_monitor(state: Arc, data_dir: PathBuf) { continue; } if container.state == "exited" || container.state == "stopped" { + // Skip user-stopped containers + if user_stopped.contains(&container.name) { + debug!("Skipping user-stopped container: {}", container.name); + continue; + } unhealthy.push(container); } } diff --git a/core/archipelago/src/main.rs b/core/archipelago/src/main.rs index 3984eaf7..daf27519 100644 --- a/core/archipelago/src/main.rs +++ b/core/archipelago/src/main.rs @@ -92,13 +92,17 @@ async fn main() -> Result<()> { } // Start any stopped containers (handles clean reboot) - let boot_report = crash_recovery::start_stopped_containers().await; + // Skips user-stopped containers, uses tier ordering + let boot_report = crash_recovery::start_stopped_containers(&data_dir).await; if boot_report.total > 0 { info!( "🔄 Boot startup: {}/{} containers started (failed: {:?})", boot_report.recovered, boot_report.total, boot_report.failed ); } + + // Signal to health monitor that boot recovery is done + crash_recovery::mark_recovery_complete(); }); } diff --git a/core/archipelago/src/network/dwn_store.rs b/core/archipelago/src/network/dwn_store.rs index b54f2831..0b2c889f 100644 --- a/core/archipelago/src/network/dwn_store.rs +++ b/core/archipelago/src/network/dwn_store.rs @@ -121,8 +121,24 @@ impl DwnStore { Ok(message) } + /// Validate a record ID to prevent path traversal. + fn validate_record_id(record_id: &str) -> Result<()> { + if record_id.is_empty() + || record_id.len() > 128 + || !record_id + .chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_') + { + return Err(anyhow::anyhow!( + "Invalid record ID (alphanumeric, hyphens, underscores only)" + )); + } + Ok(()) + } + /// Read a message by record ID. pub async fn read_message(&self, record_id: &str) -> Result> { + Self::validate_record_id(record_id)?; let path = self.messages_dir.join(format!("{}.json", record_id)); if !path.exists() { return Ok(None); @@ -137,6 +153,7 @@ impl DwnStore { /// Delete a message by record ID. pub async fn delete_message(&self, record_id: &str) -> Result { + Self::validate_record_id(record_id)?; let path = self.messages_dir.join(format!("{}.json", record_id)); if !path.exists() { return Ok(false); diff --git a/core/archipelago/src/network/router.rs b/core/archipelago/src/network/router.rs index d413c097..9a00fd3c 100644 --- a/core/archipelago/src/network/router.rs +++ b/core/archipelago/src/network/router.rs @@ -322,11 +322,37 @@ pub async fn save_router_config(data_dir: &Path, config: &RouterConfig) -> Resul fs::write(&path, data).await.context("Writing router config") } +/// Validate that an IP string is a private/LAN address (not public, not localhost). +fn is_valid_private_ip(ip_str: &str) -> bool { + let ip: std::net::IpAddr = match ip_str.parse() { + Ok(ip) => ip, + Err(_) => return false, // Reject hostnames + }; + match ip { + std::net::IpAddr::V4(v4) => { + // Allow only RFC1918 private ranges, reject localhost and public + let octets = v4.octets(); + let is_10 = octets[0] == 10; + let is_172_private = octets[0] == 172 && (16..=31).contains(&octets[1]); + let is_192_168 = octets[0] == 192 && octets[1] == 168; + is_10 || is_172_private || is_192_168 + } + std::net::IpAddr::V6(_) => false, // Reject IPv6 for gateway detection + } +} + /// Detect router type by probing common endpoints on the gateway. pub async fn detect_router_type(gateway_ip: &str) -> RouterType { + // Validate that gateway is a private IP — prevent SSRF to arbitrary hosts + if !is_valid_private_ip(gateway_ip) { + tracing::warn!(gateway = gateway_ip, "Rejected non-private gateway IP"); + return RouterType::Unknown; + } + let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(5)) .danger_accept_invalid_certs(true) + .redirect(reqwest::redirect::Policy::none()) .build() .unwrap_or_default(); diff --git a/core/archipelago/src/nostr_relays.rs b/core/archipelago/src/nostr_relays.rs index c4e2cf58..2e1a4a18 100644 --- a/core/archipelago/src/nostr_relays.rs +++ b/core/archipelago/src/nostr_relays.rs @@ -158,17 +158,77 @@ pub async fn get_stats(data_dir: &Path) -> Result { }) } -/// Normalize a relay URL (ensure wss:// prefix). +/// Normalize and validate a relay URL. +/// Only allows wss:// scheme (not ws://) for security. +/// Rejects URLs pointing to private/internal IPs. fn normalize_relay_url(url: &str) -> Result { let trimmed = url.trim(); if trimmed.is_empty() { return Err(anyhow::anyhow!("Relay URL cannot be empty")); } - if trimmed.starts_with("wss://") || trimmed.starts_with("ws://") { - Ok(trimmed.to_string()) - } else { - Ok(format!("wss://{}", trimmed)) + if trimmed.len() > 2048 { + return Err(anyhow::anyhow!("Relay URL too long")); } + + // Apply wss:// prefix if no scheme + let with_scheme = if trimmed.starts_with("wss://") || trimmed.starts_with("ws://") { + trimmed.to_string() + } else { + format!("wss://{}", trimmed) + }; + + // Only allow wss:// scheme for security + if !with_scheme.starts_with("wss://") { + return Err(anyhow::anyhow!("Relay URL must use wss:// scheme")); + } + + // Extract host portion for SSRF validation + let host = with_scheme + .strip_prefix("wss://") + .unwrap_or("") + .split('/') + .next() + .unwrap_or("") + .split(':') + .next() + .unwrap_or(""); + + if host.is_empty() { + return Err(anyhow::anyhow!("Relay URL must have a valid host")); + } + + // Reject private/internal addresses + if is_relay_host_private(host) { + return Err(anyhow::anyhow!("Relay URL must not point to private/local addresses")); + } + + Ok(with_scheme) +} + +/// Check if a relay host points to a private or internal address. +fn is_relay_host_private(host: &str) -> bool { + let lower = host.to_lowercase(); + if lower == "localhost" || lower == "localhost.localdomain" || lower.ends_with(".local") { + return true; + } + if let Ok(ip) = host.parse::() { + return match ip { + std::net::IpAddr::V4(v4) => { + v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified() + } + std::net::IpAddr::V6(v6) => { + if v6.is_loopback() || v6.is_unspecified() { + return true; + } + if let Some(v4) = v6.to_ipv4_mapped() { + return v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified(); + } + let segments = v6.segments(); + (segments[0] & 0xfe00) == 0xfc00 || (segments[0] & 0xffc0) == 0xfe80 + } + }; + } + false } #[cfg(test)] @@ -183,9 +243,9 @@ mod tests { } #[test] - fn test_normalize_relay_url_with_ws() { - let result = normalize_relay_url("ws://relay.example.com").unwrap(); - assert_eq!(result, "ws://relay.example.com"); + fn test_normalize_relay_url_rejects_ws() { + let result = normalize_relay_url("ws://relay.example.com"); + assert!(result.is_err(), "ws:// scheme should be rejected — only wss:// is allowed"); } #[test] @@ -209,6 +269,14 @@ mod tests { assert!(result.is_err()); } + #[test] + fn test_normalize_relay_url_rejects_private_ips() { + assert!(normalize_relay_url("wss://127.0.0.1").is_err()); + assert!(normalize_relay_url("wss://localhost").is_err()); + assert!(normalize_relay_url("wss://192.168.1.1").is_err()); + assert!(normalize_relay_url("wss://10.0.0.1").is_err()); + } + #[test] fn test_seed_defaults_has_expected_count() { let store = seed_defaults(); diff --git a/core/archipelago/src/session.rs b/core/archipelago/src/session.rs index 62886da4..e7a394ac 100644 --- a/core/archipelago/src/session.rs +++ b/core/archipelago/src/session.rs @@ -1,4 +1,5 @@ use hmac::{Hmac, Mac}; +use rand::RngCore; use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::net::IpAddr; @@ -234,16 +235,31 @@ impl SessionStore { None } - /// Upgrade a pending session to a full session. - pub async fn upgrade_to_full(&self, token: &str) { - let hash = hash_token(token); + /// Upgrade a pending session to a full session with token rotation. + /// Deletes the old pending session and creates a new full session with a fresh token. + /// Returns the new plaintext token so the caller can set it as the new cookie. + pub async fn upgrade_to_full(&self, token: &str) -> Option { + let old_hash = hash_token(token); let mut sessions = self.sessions.write().await; - if let Some(session) = sessions.get_mut(&hash) { - session.session_type = SessionType::Full; + // Only upgrade if the old session exists and is pending + if sessions.remove(&old_hash).is_some() { + let new_token_bytes: [u8; 32] = rand::random(); + let new_token = hex::encode(new_token_bytes); + let new_hash = hash_token(&new_token); let now = SystemTime::now(); - session.created_at = now; - session.last_activity = now; + self.evict_if_over_limit(&mut sessions); + sessions.insert( + new_hash, + Session { + created_at: now, + last_activity: now, + session_type: SessionType::Full, + }, + ); Self::save_to_disk_sync(&sessions, &self.persist_path); + Some(new_token) + } else { + None } } @@ -393,25 +409,21 @@ impl SessionStore { } pub fn load_or_create_remember_secret() -> Vec { - // Try existing secret file first (backwards compatibility) + // Try existing secret file first if let Ok(secret) = std::fs::read(REMEMBER_SECRET_FILE) { if secret.len() == 32 { return secret; } } - // Derive a deterministic secret from machine-id so it survives restarts - // without storing plaintext key material - let machine_id = std::fs::read_to_string("/etc/machine-id") - .unwrap_or_else(|_| uuid::Uuid::new_v4().to_string()); - let salt = b"archipelago-remember-me-v1"; - let mut hasher = sha2::Sha256::new(); - use sha2::Digest; - hasher.update(machine_id.trim().as_bytes()); - hasher.update(salt); - let secret = hasher.finalize(); - let secret_vec = secret.to_vec(); - let _ = std::fs::write(REMEMBER_SECRET_FILE, &secret_vec); - secret_vec + // Generate a cryptographically random 32-byte secret on first boot + let mut secret = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut secret); + // Ensure parent directory exists + if let Some(parent) = std::path::Path::new(REMEMBER_SECRET_FILE).parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = std::fs::write(REMEMBER_SECRET_FILE, &secret); + secret.to_vec() } } @@ -605,9 +617,15 @@ mod tests { let got = store.get_pending_secret(&token).await; assert_eq!(got, Some(secret)); - // Upgrade to full - store.upgrade_to_full(&token).await; - assert!(store.validate(&token).await); + // Upgrade to full — returns a new rotated token + let new_token = store.upgrade_to_full(&token).await; + assert!(new_token.is_some()); + let new_token = new_token.unwrap(); + + // Old token should be invalid (rotated) + assert!(!store.validate(&token).await); + // New token should be valid + assert!(store.validate(&new_token).await); } #[tokio::test] diff --git a/core/archipelago/src/webhooks.rs b/core/archipelago/src/webhooks.rs index 5fc7d129..6059a607 100644 --- a/core/archipelago/src/webhooks.rs +++ b/core/archipelago/src/webhooks.rs @@ -124,6 +124,7 @@ async fn send_http_webhook( let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(10)) + .redirect(reqwest::redirect::Policy::none()) .build() .context("Failed to create HTTP client")?; diff --git a/core/container/src/podman_client.rs b/core/container/src/podman_client.rs index 643d4c7f..d599fa64 100644 --- a/core/container/src/podman_client.rs +++ b/core/container/src/podman_client.rs @@ -20,12 +20,26 @@ pub struct ContainerStatus { pub id: String, pub name: String, pub state: ContainerState, + pub health: Option, // "healthy", "unhealthy", "starting", or None if no healthcheck + pub started_at: Option, pub image: String, pub created: String, pub ports: Vec, pub lan_address: Option, // Launch URL for UI access } +/// Parse health status from podman's Status string (e.g., "Up 5 minutes (healthy)") +fn parse_health_from_status(status: &str) -> Option { + if let Some(start) = status.rfind('(') { + if let Some(end) = status.rfind(')') { + if start < end { + return Some(status[start + 1..end].to_string()); + } + } + } + None +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum ContainerState { Created, @@ -301,6 +315,8 @@ impl PodmanClient { id: parts[0].to_string(), name: parts[1].to_string(), state: ContainerState::from(parts[2]), + health: None, + started_at: None, image: parts[3].to_string(), created: parts[4].to_string(), ports: vec![], // TODO: Parse ports from parts[5] @@ -387,10 +403,15 @@ impl PodmanClient { }; let lan_address = Self::lan_address_for(&name); + let status_str = container["Status"].as_str().unwrap_or(""); + let health = parse_health_from_status(status_str); + let started_at = container["StartedAt"].as_str().or_else(|| container["Started"].as_str()).map(|s| s.to_string()); result.push(ContainerStatus { id: container["Id"].as_str().unwrap_or("").to_string(), name, state: ContainerState::from(container["State"].as_str().unwrap_or("unknown")), + health, + started_at, image: container["Image"].as_str().unwrap_or("").to_string(), created: container["Created"].as_str().unwrap_or("").to_string(), ports, @@ -431,10 +452,15 @@ impl PodmanClient { }; let lan_address = Self::lan_address_for(&name); + let status_str = container["Status"].as_str().unwrap_or(""); + let health = parse_health_from_status(status_str); + let started_at = container["StartedAt"].as_str().or_else(|| container["Started"].as_str()).map(|s| s.to_string()); result.push(ContainerStatus { id: container["Id"].as_str().unwrap_or("").to_string(), name, state: ContainerState::from(container["State"].as_str().unwrap_or("unknown")), + health, + started_at, image: container["Image"].as_str().unwrap_or("").to_string(), created: container["Created"].as_str().unwrap_or("").to_string(), ports, diff --git a/core/container/src/runtime.rs b/core/container/src/runtime.rs index 6e5cce01..d023cb28 100644 --- a/core/container/src/runtime.rs +++ b/core/container/src/runtime.rs @@ -284,6 +284,8 @@ impl ContainerRuntime for DockerRuntime { id: parts[0].to_string(), name: parts[1].to_string(), state: crate::podman_client::ContainerState::from(parts[2]), + health: None, + started_at: None, image: parts[3].to_string(), created: parts[4].to_string(), ports: vec![], @@ -356,6 +358,8 @@ impl ContainerRuntime for DockerRuntime { state: ContainerState::from( container["State"].as_str().unwrap_or("unknown") ), + health: None, + started_at: None, image: container["Image"].as_str().unwrap_or("").to_string(), created: container["CreatedAt"].as_str().unwrap_or("").to_string(), ports, diff --git a/image-recipe/build-auto-installer-iso.sh b/image-recipe/build-auto-installer-iso.sh index 35282791..3e4d5f67 100755 --- a/image-recipe/build-auto-installer-iso.sh +++ b/image-recipe/build-auto-installer-iso.sh @@ -773,6 +773,7 @@ HiddenServicePort 50001 127.0.0.1:50001 HiddenServiceDir $TOR_DIR/hidden_service_lnd HiddenServicePort 9735 127.0.0.1:9735 +HiddenServicePort 8080 127.0.0.1:8080 HiddenServiceDir $TOR_DIR/hidden_service_btcpay HiddenServicePort 23000 127.0.0.1:23000 diff --git a/image-recipe/configs/nginx-archipelago.conf b/image-recipe/configs/nginx-archipelago.conf index 8f94294f..9a62e607 100644 --- a/image-recipe/configs/nginx-archipelago.conf +++ b/image-recipe/configs/nginx-archipelago.conf @@ -17,7 +17,7 @@ server { add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header X-DNS-Prefetch-Control "off" always; - add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; 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; + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; 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; # AIUI SPA (Chat mode iframe) # Use =404 fallback instead of index.html to prevent serving HTML with wrong @@ -37,7 +37,7 @@ server { # AIUI Claude API proxy — requires valid session cookie location /aiui/api/claude/ { - if ($cookie_session_id = "") { + if ($cookie_session = "") { return 401 '{"error":"Unauthorized"}'; } proxy_pass http://127.0.0.1:3142/; @@ -54,7 +54,7 @@ server { # AIUI OpenRouter API proxy — requires valid session cookie location /aiui/api/openrouter/ { - if ($cookie_session_id = "") { + if ($cookie_session = "") { return 401 '{"error":"Unauthorized"}'; } proxy_pass https://openrouter.ai/api/; @@ -69,7 +69,7 @@ server { # AIUI Ollama (local AI) proxy — localhost:11434 location /aiui/api/ollama/ { - if ($cookie_session_id = "") { + if ($cookie_session = "") { return 401 '{"error":"Unauthorized"}'; } proxy_pass http://127.0.0.1:11434/; @@ -85,7 +85,7 @@ server { # AIUI web search proxy — SearXNG on port 8888 location /aiui/api/web-search { - if ($cookie_session_id = "") { + if ($cookie_session = "") { return 401 '{"error":"Unauthorized"}'; } proxy_pass http://127.0.0.1:8888/search; @@ -154,7 +154,7 @@ server { location /lnd-connect-info { # Requires authenticated session — exposes LND admin macaroon - if ($cookie_session_id = "") { return 401; } + if ($cookie_session = "") { return 401; } proxy_pass http://127.0.0.1:5678/lnd-connect-info; proxy_http_version 1.1; proxy_set_header Host $host; @@ -725,7 +725,7 @@ server { add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header X-DNS-Prefetch-Control "off" always; - add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; 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; + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; 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; # AIUI SPA (Chat mode iframe) location /aiui/ { @@ -735,7 +735,7 @@ server { add_header Cache-Control "no-cache, no-store, must-revalidate"; } location /aiui/api/claude/ { - if ($cookie_session_id = "") { + if ($cookie_session = "") { return 401 '{"error":"Unauthorized"}'; } proxy_pass http://127.0.0.1:3142/; @@ -750,7 +750,7 @@ server { proxy_send_timeout 120s; } location /aiui/api/ollama/ { - if ($cookie_session_id = "") { + if ($cookie_session = "") { return 401 '{"error":"Unauthorized"}'; } proxy_pass http://127.0.0.1:11434/; @@ -764,7 +764,7 @@ server { # Connection header managed by nginx default } location /aiui/api/openrouter/ { - if ($cookie_session_id = "") { + if ($cookie_session = "") { return 401 '{"error":"Unauthorized"}'; } proxy_pass https://openrouter.ai/api/; @@ -808,7 +808,7 @@ server { location /lnd-connect-info { # Requires authenticated session — exposes LND admin macaroon - if ($cookie_session_id = "") { return 401; } + if ($cookie_session = "") { return 401; } proxy_pass http://127.0.0.1:5678/lnd-connect-info; proxy_http_version 1.1; proxy_set_header Host $host; diff --git a/neode-ui/dev-dist/sw.js b/neode-ui/dev-dist/sw.js index 940f5937..d6e12d0e 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.g6vfn35hb3c" + "revision": "0.3ur9h1c6gak" }], {}); workbox.cleanupOutdatedCaches(); workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { diff --git a/neode-ui/mock-backend.js b/neode-ui/mock-backend.js index 130276ab..bde2f60c 100755 --- a/neode-ui/mock-backend.js +++ b/neode-ui/mock-backend.js @@ -343,6 +343,7 @@ async function getDockerContainers() { version: '1.0.0', status: isRunning ? 'running' : 'stopped', state: isRunning ? 'running' : 'stopped', + health: isRunning ? 'healthy' : null, 'static-files': { license: 'MIT', instructions: metadata.description, diff --git a/neode-ui/public/assets/icon/web5.svg b/neode-ui/public/assets/icon/web5.svg new file mode 100644 index 00000000..07e72345 --- /dev/null +++ b/neode-ui/public/assets/icon/web5.svg @@ -0,0 +1,3 @@ + + + diff --git a/neode-ui/src/components/AppLauncherOverlay.vue b/neode-ui/src/components/AppLauncherOverlay.vue index ab976e38..7d68eac3 100644 --- a/neode-ui/src/components/AppLauncherOverlay.vue +++ b/neode-ui/src/components/AppLauncherOverlay.vue @@ -141,15 +141,15 @@ -
-

{{ paymentError }}

+
+

{{ paymentError }}

-
diff --git a/neode-ui/src/components/BaseModal.vue b/neode-ui/src/components/BaseModal.vue new file mode 100644 index 00000000..f9c15474 --- /dev/null +++ b/neode-ui/src/components/BaseModal.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/neode-ui/src/components/EasyHome.vue b/neode-ui/src/components/EasyHome.vue index a7e924ef..f8486ab8 100644 --- a/neode-ui/src/components/EasyHome.vue +++ b/neode-ui/src/components/EasyHome.vue @@ -12,7 +12,18 @@ :style="{ '--card-stagger': idx }" >
-
+ +
+ +
+
{{ goalIcon(goal.icon) }}
@@ -28,7 +39,7 @@
{{ goal.estimatedTime }} - {{ goal.difficulty === 'beginner' ? 'Beginner' : 'Intermediate' }} + {{ goal.difficulty === 'beginner' ? t('easyHome.beginner') : t('easyHome.intermediate') }}
@@ -37,18 +48,40 @@ diff --git a/neode-ui/src/components/PWAUpdatePrompt.vue b/neode-ui/src/components/PWAUpdatePrompt.vue index b43eaf4b..4e0356a1 100644 --- a/neode-ui/src/components/PWAUpdatePrompt.vue +++ b/neode-ui/src/components/PWAUpdatePrompt.vue @@ -1,59 +1,33 @@ diff --git a/neode-ui/src/components/TransactionsModal.vue b/neode-ui/src/components/TransactionsModal.vue new file mode 100644 index 00000000..81b93452 --- /dev/null +++ b/neode-ui/src/components/TransactionsModal.vue @@ -0,0 +1,116 @@ + + + diff --git a/neode-ui/src/components/cloud/ShareModal.vue b/neode-ui/src/components/cloud/ShareModal.vue index c4b8b801..b1a481ab 100644 --- a/neode-ui/src/components/cloud/ShareModal.vue +++ b/neode-ui/src/components/cloud/ShareModal.vue @@ -28,10 +28,7 @@

Share this {{ isDir ? 'folder' : 'file' }}

Make visible to connected peers

- +
@@ -126,6 +123,7 @@ diff --git a/neode-ui/src/views/Chat.vue b/neode-ui/src/views/Chat.vue index 04822dd7..49aa568c 100644 --- a/neode-ui/src/views/Chat.vue +++ b/neode-ui/src/views/Chat.vue @@ -1,7 +1,7 @@