diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md index 0c003fdb..0af792d3 100644 --- a/.claude/memory/MEMORY.md +++ b/.claude/memory/MEMORY.md @@ -1,12 +1,33 @@ # Archipelago Project Memory Index -- [pending-features.md](pending-features.md) — Feature requests: kiosk mode, sideloading, Nostr login, etc. +## Setup & Architecture +- [claude-proxy-setup.md](claude-proxy-setup.md) — Claude proxy OAuth setup details +- [deploy-automation.md](deploy-automation.md) — Deploy script automation TODOs (API key, AIUI nginx, swap) + +## Servers & Deploy +- [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) - [third-server.md](third-server.md) — Third dev server (archipelago-3 via Tailscale) -- [deploy-automation.md](deploy-automation.md) — Deploy script automation TODOs -- [claude-proxy-setup.md](claude-proxy-setup.md) — Claude proxy OAuth setup details + +## Features & Plans +- [pending-features.md](pending-features.md) — Feature requests: kiosk mode, sideloading, Nostr login, etc. - [project-plan.md](project-plan.md) — Overall project plan status +- [web-only-apps.md](web-only-apps.md) — Web-only apps (L484 category) and iframe compatibility + +## User Feedback +- [feedback_app_display_modes.md](feedback_app_display_modes.md) — App browser: 3 display modes with persistent setting +- [feedback_fullscreen_modals.md](feedback_fullscreen_modals.md) — Fullscreen modal preferences +- [feedback_local_dev.md](feedback_local_dev.md) — Local dev: use `cd neode-ui && ./start-dev.sh` +- [feedback_apps_always_direct_port.md](feedback_apps_always_direct_port.md) — Apps MUST open at direct port, NEVER proxy paths +- [feedback_indeedhub_nginx_ips.md](feedback_indeedhub_nginx_ips.md) — IndeedHub nginx must use hardcoded container IPs +- [feedback_searxng_no_cap_drop.md](feedback_searxng_no_cap_drop.md) — SearXNG: no cap-drop ALL + +## ISO Build - [iso-build-session-2026-03-10.md](iso-build-session-2026-03-10.md) — ISO build session notes - [unbundled-iso.md](unbundled-iso.md) — Unbundled ISO approach notes -- [web-only-apps.md](web-only-apps.md) — Web-only apps (L484 category) and iframe compatibility -- [feedback_app_display_modes.md](feedback_app_display_modes.md) — App browser: 3 display modes (right panel, full overlay, fullscreen) with persistent setting + +## Completed Work +- [project_mesh_198_issue.md](project_mesh_198_issue.md) — Mesh .198: 3 bugs fixed and deployed +- [project_indeedhub_arch3_fix.md](project_indeedhub_arch3_fix.md) — IndeedHub Arch 3: corrupted combined tarball fixed +- [project_demo_deploy.md](project_demo_deploy.md) — Demo prod deployment via Portainer diff --git a/.claude/memory/feedback_apps_always_direct_port.md b/.claude/memory/feedback_apps_always_direct_port.md new file mode 100644 index 00000000..c58ecfdc --- /dev/null +++ b/.claude/memory/feedback_apps_always_direct_port.md @@ -0,0 +1,35 @@ +--- +name: Apps MUST open at direct port — NEVER proxy paths +description: CRITICAL — All apps in iframes must open at their direct port (http(s)://{host}:{port}), NEVER through /app/{id}/ proxy paths. This is the #1 cause of broken app loading across all nodes. +type: feedback +--- + +## CRITICAL RULE: Apps load at DIRECT PORT, never proxy paths + +All Archipelago apps that open in iframes MUST use the direct port URL: +``` +{protocol}://{hostname}:{port} +``` + +**NEVER** use path-based proxy URLs like `/app/indeedhub/` or `/app/mempool/` for iframe loading. Path proxies break apps because: +1. The main nginx SPA catch-all serves the Archipelago dashboard instead of the app +2. sub_filter URL rewrites break client-side routing in Vue/React apps +3. Different nodes have different nginx configs — path proxies are unreliable + +**Why:** This was broken THREE TIMES in one session (2026-03-17). Every time the iframe URL used a proxy path instead of the direct port, the app showed the Archipelago dashboard or a blank page. .228 and .198 work correctly because they use HTTP which naturally hits the direct port. Tailscale nodes use HTTPS which was falling through to the proxy path. + +**How to apply:** +- In `AppSession.vue`, apps like IndeedHub must ALWAYS construct `{protocol}://{hostname}:{port}` — even on HTTPS +- The `HTTPS_PROXY_PATHS` mapping should NOT include apps that have X-Frame-Options removed (like IndeedHub) +- When adding new apps: use PORT_APPS for the port mapping, do NOT add to HTTPS_PROXY_PATHS unless absolutely necessary +- The deploy script removes X-Frame-Options from IndeedHub's internal nginx, enabling direct port iframe access + +**Also critical for IndeedHub specifically:** +- IndeedHub nginx MUST use hardcoded container IPs (not DNS names) — see feedback_indeedhub_nginx_ips.md +- nostr-provider.js must be injected via sub_filter in the IndeedHub internal nginx +- SearXNG must NOT use --cap-drop ALL — see feedback_searxng_no_cap_drop.md + +**When recreating containers:** +- NEVER recreate containers without reapplying ALL patches (X-Frame-Options removal, nostr-provider injection, IP hardcoding) +- After any container IP change (restart, recreation), update the hardcoded IPs in IndeedHub's nginx config +- Deploy the SAME frontend build to ALL nodes — version mismatch causes different behavior diff --git a/.claude/memory/feedback_indeedhub_nginx_ips.md b/.claude/memory/feedback_indeedhub_nginx_ips.md new file mode 100644 index 00000000..4e1c48e3 --- /dev/null +++ b/.claude/memory/feedback_indeedhub_nginx_ips.md @@ -0,0 +1,17 @@ +--- +name: IndeedHub nginx must use hardcoded container IPs +description: IndeedHub's internal nginx config MUST use hardcoded container IPs, not DNS names — podman DNS resolver 127.0.0.11 is unreliable and causes 502 errors +type: feedback +--- + +IndeedHub's internal nginx config MUST use hardcoded container IPs (e.g., `http://10.89.2.11:4000`) for API, MinIO, and relay upstreams — NEVER DNS names like `http://api:4000` with `resolver 127.0.0.11`. + +**Why:** Podman's embedded DNS resolver (127.0.0.11) is unreliable inside containers. Using DNS names with `resolver 127.0.0.11 valid=30s ipv6=off;` causes nginx to return 502 Bad Gateway errors. This broke IndeedHub on all Tailscale nodes — frontend loaded but API calls failed. + +**How to apply:** After creating/recreating IndeedHub containers, the deploy script must: +1. Look up container IPs: `podman inspect indeedhub-build_api_1 --format "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}"` +2. Patch the nginx config inside the container to replace DNS names with IPs +3. Also patch `$host` → `$http_host` in proxy_set_header Host directives +4. Reload nginx inside the container + +This is now handled in `deploy-to-target.sh` and `fix-indeedhub-containers.sh`. diff --git a/.claude/memory/feedback_searxng_no_cap_drop.md b/.claude/memory/feedback_searxng_no_cap_drop.md new file mode 100644 index 00000000..bbf2ef30 --- /dev/null +++ b/.claude/memory/feedback_searxng_no_cap_drop.md @@ -0,0 +1,15 @@ +--- +name: SearXNG must NOT use --cap-drop ALL +description: SearXNG container needs write access to /etc/searxng/ for settings.yml — cap-drop ALL causes Permission denied and exit 127 +type: feedback +--- + +Do NOT use `--cap-drop ALL` or `--security-opt no-new-privileges:true` when creating the SearXNG container. SearXNG needs to create `/etc/searxng/settings.yml` on first run. + +**Why:** SearXNG's entrypoint creates a settings file from a template. With `--cap-drop ALL`, it gets "Permission denied: can't create '/etc/searxng/settings.yml'" and exits with code 127. The .228 reference server runs SearXNG with default capabilities (only drops CAP_AUDIT_WRITE, CAP_MKNOD, CAP_NET_RAW). + +**How to apply:** When creating SearXNG containers, use: +```bash +sudo podman run -d --name searxng --restart unless-stopped -p 8888:8080 docker.io/searxng/searxng:latest +``` +No `--cap-drop ALL`, no `--security-opt no-new-privileges:true`. diff --git a/.claude/memory/project_demo_deploy.md b/.claude/memory/project_demo_deploy.md new file mode 100644 index 00000000..d81a83e2 --- /dev/null +++ b/.claude/memory/project_demo_deploy.md @@ -0,0 +1,62 @@ +--- +name: Demo Deploy Status +description: Status and details of the demo prod server deployment via Portainer Stacks from Gitea repos +type: project +--- + +## Demo Prod Deployment — In Progress (2026-03-17) + +### Two Separate Portainer Stacks + +**1. IndeedHub** — DEPLOYED SUCCESSFULLY on :7755 +- Repo: `https://git.tx1138.com/lfg2025/indee-demo` +- Compose: `docker-compose.yml` (root) +- Env vars loaded from `.env.portainer` — update DOMAIN, FRONTEND_URL, S3_PUBLIC_BUCKET_URL +- APP_PORT defaulted to 7755 (changed from 7777 to avoid conflicts) +- Healthcheck fix: pg_isready uses `${POSTGRES_USER}` env var (was hardcoded) +- Full 7-service stack: app, api, postgres, redis, minio, minio-init, relay, ffmpeg-worker +- Nostr auth is built-in (NIP-98) — users sign in with browser extension (Alby, nos2x) + +**2. Archipelago** — DEPLOYING (last attempt pending) +- Repo: `https://git.tx1138.com/lfg2025/archy-demo` +- Compose: `docker-compose.demo.yml` +- Env vars: `ANTHROPIC_API_KEY` for Claude chat +- Port: 4848 +- Pre-built frontend in `web-dist/` (built locally on Mac, no server-side build) +- Backend: `neode-ui/Dockerfile.backend` (Node mock backend on :5959) +- Web: `neode-ui/Dockerfile.web` (nginx serving pre-built static files) + +### Issues Resolved So Far +- IndeedHub postgres healthcheck hardcoded username → fixed to use env var +- Port 7777 conflict → changed to 7755 +- Archy repo too large (8GB) for Portainer clone → created lightweight `archy-demo` repo +- Frontend build failing on server → switched to pre-built static files (no npm/vite on server) +- `.dockerignore` blocking `neode-ui/dist` → moved to `web-dist/` at repo root +- Docker build cache stale → moved dist outside neode-ui to avoid gitignore conflicts + +### Current Blocker +- Last deploy attempt: Docker build cache may still be referencing old paths +- If still failing: need to prune Docker build cache on server (`docker builder prune`) + +### Frontend Changes Made +- `Apps.vue` and `AppDetails.vue`: IndeedHub removed from WEB_ONLY_APP_URLS (linter change) +- IndeedHub will be accessed as a real container or via direct URL to :7755 + +### Repo Structure (archy-demo) +``` +archy-demo/ +├── docker-compose.demo.yml +├── .dockerignore +├── web-dist/ ← pre-built Vue frontend (from local Mac build) +├── demo/aiui/ ← pre-built AIUI chat app +└── neode-ui/ ← source + mock backend + docker configs + ├── Dockerfile.web ← nginx + copy web-dist (no build) + ├── Dockerfile.backend ← Node mock backend + ├── docker/nginx-demo.conf + ├── docker/docker-entrypoint.sh + ├── mock-backend.js + └── src/... +``` + +**Why:** Demo for showcasing Archipelago + IndeedHub together. Needs to be functional with nostr signing. +**How to apply:** When resuming, check if Portainer deploy succeeded. If not, may need to SSH to prune Docker cache or debug further. diff --git a/.claude/memory/project_indeedhub_arch3_fix.md b/.claude/memory/project_indeedhub_arch3_fix.md new file mode 100644 index 00000000..a949969f --- /dev/null +++ b/.claude/memory/project_indeedhub_arch3_fix.md @@ -0,0 +1,33 @@ +--- +name: IndeedHub Arch 3 Fix — 2026-03-17 +description: Fixed IndeedHub on Arch 3 (100.124.105.113) — corrupted image tarball was root cause, all 7 containers now running +type: project +--- + +## Status: FIXED and working (verified 2026-03-17) + +IndeedHub on Arch 3 (`100.124.105.113`) is fully operational — all 7 containers running, frontend on :7777, API healthy, NIP-07 nostr-provider injected. + +## Root Cause + +The `/tmp/indeedhub-all-images.tar` on Arch 3 was corrupted — `podman save` with multiple images collapsed ALL 7 images to the same image ID (the frontend nginx image `7222645f0b38`). So redis, minio, API, ffmpeg-worker, postgres, and relay were all running the frontend nginx binary. + +**Why:** `podman save` with multiple images sharing layers can produce broken tarballs where all images get the same config/ID. + +## What Was Done + +1. Removed all broken containers and images +2. Pulled fresh standard images from Docker Hub (postgres:16-alpine, redis:7-alpine, minio:latest, nostr-rs-relay:latest) +3. Exported each custom image as **individual tarballs** from .228 (NOT combined): + - `indeedhub-frontend.tar` (149MB, ID: `7222645f0b38`) + - `indeedhub-api.tar` (403MB, ID: `2ae2665fc6c7`) + - `indeedhub-ffmpeg.tar` (525MB, ID: `cb05b5cf8c25`) +4. Transferred via Mac (`.228` → Mac → Arch 3 over Tailscale) +5. Loaded images individually, created all 7 containers manually (bypassed the deploy script's broken `podman load` step) +6. Copied nostr-provider.js + nginx config with sub_filter from .228 container into Arch 3 container via `podman cp` + +## Remaining Issue — Deploy Script + +The deploy script at `/tmp/deploy-indeedhub.sh` on Arch 3 still references the broken `/tmp/indeedhub-all-images.tar`. If it's run again it will re-corrupt the images. The individual tarballs (`/tmp/indeedhub-frontend.tar`, `/tmp/indeedhub-api.tar`, `/tmp/indeedhub-ffmpeg.tar`) are on Arch 3 and should be used instead. + +**How to apply:** Next time deploying IndeedHub to any node, always export images individually, never as a combined tarball. Consider updating the deploy script to load individual tarballs. diff --git a/.claude/memory/project_mesh_198_issue.md b/.claude/memory/project_mesh_198_issue.md new file mode 100644 index 00000000..c5a3799c --- /dev/null +++ b/.claude/memory/project_mesh_198_issue.md @@ -0,0 +1,20 @@ +--- +name: Mesh .198 fix — COMPLETED +description: Fixed mesh radio on .198 — duplicate init, no reconnect on write fail, wrong device path. All deployed. +type: project +--- + +## Status: COMPLETED (2026-03-17) + +Three bugs were found and fixed: + +1. **Duplicate mesh init in `server.rs`** — removed duplicate block +2. **Serial write failures don't trigger reconnection** — added `consecutive_write_failures` counter, bail after 3 +3. **Device path on .198** — set `/var/lib/archipelago/mesh-config.json` to `/dev/ttyUSB1` + +All changes deployed to both .228 and .198. + +### Files Changed +- `core/archipelago/src/server.rs` — removed duplicate mesh/transport init block +- `core/archipelago/src/mesh/listener.rs` — added write failure tracking + reconnection +- `neode-ui/src/stores/mesh.ts` — fixed TS union type for `typed_payload` diff --git a/.claude/memory/reference_tailscale_nodes.md b/.claude/memory/reference_tailscale_nodes.md new file mode 100644 index 00000000..8d03050e --- /dev/null +++ b/.claude/memory/reference_tailscale_nodes.md @@ -0,0 +1,21 @@ +--- +name: Tailscale node addresses +description: Complete list of all Tailscale node IPs and hostnames for SSH access +type: reference +--- + +## Tailscale Nodes + +| Name | Tailscale IP | Hostname | SSH | +|------|-------------|----------|-----| +| Arch 1 | 100.82.97.63 | — | `ssh -i ~/.ssh/archipelago-deploy archipelago@100.82.97.63` | +| Arch 2 | 100.122.84.60 | archipelago-2.tail2b6225.ts.net | `ssh -i ~/.ssh/archipelago-deploy archipelago@archipelago-2.tail2b6225.ts.net` | +| Arch 3 | 100.124.105.113 | archipelago-3.tail2b6225.ts.net | `ssh -i ~/.ssh/archipelago-deploy archipelago@100.124.105.113` | + +Note: `archipelago-3.tail2b6225.ts.net` and `100.124.105.113` are the SAME machine. + +## LAN Nodes +| Name | IP | SSH | +|------|-----|-----| +| Primary (.228) | 192.168.1.228 | `ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228` | +| Secondary (.198) | 192.168.1.198 | `ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.198` | diff --git a/.claude/plans/synchronous-greeting-rose.md b/.claude/plans/synchronous-greeting-rose.md new file mode 100644 index 00000000..a4cf0f7c --- /dev/null +++ b/.claude/plans/synchronous-greeting-rose.md @@ -0,0 +1,173 @@ +# Mesh Phase 4 Completion + Phase 5 Implementation + +## Context + +Mesh Phases 1-3 are complete: serial driver, transport layer (Mesh>LAN>Tor), Double Ratchet encryption, typed messages, store-and-forward, chat UI. Phase 4 is 40% done — data structures, builders, and tests exist (`bitcoin_relay.rs`, `alerts.rs`, `message_types.rs`) but nothing is wired into the listener, MeshService, or RPC layer. Phase 5 (steganographic modes, adaptive routing, multi-hardware) is not started. + +## Phase 4: Wire Up Off-Grid Bitcoin Operations (Weeks 8-11) + +### Week 8: Typed Message Dispatch in Listener + +**The critical foundation — everything else depends on this.** + +**`mesh/listener.rs`:** +- Add `MeshCommand::SendRaw { dest_pubkey_prefix: [u8; 6], payload: Vec }` and `BroadcastChannel { channel: u8, payload: Vec }` variants +- In `handle_frame()`: after extracting message bytes, check for `0x02` TypedEnvelope prefix +- New `handle_typed_message()` dispatches by type: + - `BlockHeader` → validate Ed25519 sig, store in `BlockHeaderCache`, emit event + - `TxRelay` → spawn task: Bitcoin RPC `sendrawtransaction`, send `TxRelayResponse` back + - `TxRelayResponse` → complete pending in `RelayTracker`, store as MeshMessage + - `LightningRelay` → spawn task: LND REST `payinvoice`, send response back + - `LightningRelayResponse` → complete pending, store + - `Alert` → verify sig, store, emit `MeshEvent::AlertReceived` +- Handle `SendRaw` and `BroadcastChannel` in `tokio::select!` command dispatch + +**`mesh/types.rs`:** New `MeshEvent` variants: `BlockHeaderReceived`, `AlertReceived`, `TxRelayCompleted`, `LightningRelayCompleted` + +**Key design:** Spawn separate tokio tasks for Bitcoin/LND HTTP calls (don't block serial read loop). Response sent back via `cmd_tx` channel. + +### Week 9: MeshService Integration + Dead Man's Switch Task + +**`mesh/mod.rs`:** +- Add fields: `block_header_cache: Arc`, `relay_tracker: Arc`, `dead_man_switch: Arc`, `signing_key: ed25519_dalek::SigningKey` +- Init in `new()`, pass cache + tracker into listener via `MeshState` +- Accessor methods for RPC layer + +**Dead Man background task** (spawned in `start()`): +- Check every 60s: if triggered → build signed alert → broadcast on channel 0 + direct to emergency contacts +- Persist `last_check_in_time` as unix timestamp on disk (survives restarts) + +### Week 10: RPC Endpoints + +**`api/rpc/mesh.rs`** — New handlers: + +| Endpoint | Params | Description | +|----------|--------|-------------| +| `mesh.relay-tx` | `{ tx_hex }` | Queue TX for relay via internet peer | +| `mesh.block-headers` | `{ count? }` | Return cached block headers | +| `mesh.relay-lightning` | `{ bolt11, amount_sats }` | Queue LN invoice for payment | +| `mesh.deadman-status` | — | Query switch state | +| `mesh.deadman-configure` | `{ enabled, interval_secs, lat, lng, contacts, custom_message }` | Configure | +| `mesh.deadman-checkin` | — | Heartbeat reset | + +**Fix `mesh.send-invoice`:** Replace placeholder bolt11 with real LND `POST /v1/invoices` call. + +**`api/rpc/mod.rs`:** Register all new routes (~line 643). + +### Week 11: Block Header Announcer + Frontend + +**Backend:** Optional background task: poll Bitcoin Core `getblockchaininfo` every 30s → on new block → signed announcement → broadcast channel 0. Config: `announce_block_headers: bool`. + +**Frontend `stores/mesh.ts`:** New methods for all Phase 4 RPC calls. + +**Frontend `views/Mesh.vue`:** +- "Off-Grid Bitcoin" panel: block height, headers, TX relay form, LN relay form +- "Dead Man's Switch" panel: enable/disable, interval, GPS, contacts, countdown, check-in +- Uses `.path-option-card`, `.glass-button`, `.info-card` + +## Phase 5: Mesh Network Intelligence (Weeks 12-15) + +### Week 12: Steganographic Modes + +**New: `mesh/steganography.rs`** + +- `SteganographyMode` enum: `Normal`, `WeatherStation`, `SensorNetwork` +- **Weather Station:** Map payload bytes → plausible weather readings (temp, humidity, pressure, wind). Marker `0xAA` replaces `0x02`. +- **Sensor Network:** Industrial sensor format (voltage, current, vibration) +- `to_wire_steganographic(mode)` / `from_wire_steganographic(data)` on TypedEnvelope +- Listener detects `0xAA` → decode stego → normal dispatch +- Config: `steganography_mode` in `MeshConfig` +- Budget: ~80 bytes real data per 160-byte LoRa frame with stego overhead + +### Week 13: Adaptive Routing & Signal Intelligence + +**New: `mesh/routing.rs`** + +- `LinkQuality` per peer: RSSI/SNR rolling 1h history, packet loss, hop count +- `RoutingTable`: link quality per peer + best route per destination DID +- Score: `(rssi+120)*0.4 + (snr+20)*0.3 + (1-loss)*100*0.3` +- Best relay selection for TX/LN relay (highest quality peer with internet) +- Multi-hop forwarding: if dest DID != ours and hops < 3, forward to best next-hop +- Extract RSSI from v3 frames (bytes 1-2, currently unused) +- RPC: `mesh.routing-table` + +### Week 14: LoRa Radio Parameter Control + +**`mesh/protocol.rs`:** Builders for `SET_RADIO_PARAMS` (0x0B), `SET_TX_POWER` (0x0C), `SET_TUNING_PARAMS` (0x15). Parse `RESP_STATS` (0x18). + +**RPC:** `mesh.set-radio-params`, `mesh.set-tx-power`, `mesh.get-radio-stats` + +**Auto-adaptive SF:** If link quality drops → increase spreading factor (longer range, slower). Config toggle. + +**Frontend:** Radio tuning panel with SF/TX power sliders, stats, auto-adaptive toggle. + +### Week 15: Multi-Hardware + Topology UI + +**New: `mesh/device_trait.rs`** + +```rust +#[async_trait] +pub trait MeshDevice: Send + Sync { + async fn open(path: &str) -> Result where Self: Sized; + async fn initialize(&mut self) -> Result; + async fn send_text(&mut self, dest: &[u8; 6], msg: &[u8]) -> Result<()>; + async fn try_recv_frame(&mut self) -> Result>; + // ... +} +``` + +- Implement for `MeshcoreDevice`, stub Meshtastic/WiFi/BLE +- `listener.rs` uses `Box` +- **Topology UI:** SVG graph (this node center, peers as satellites), edge thickness = quality, color = green/yellow/red, tooltips with RSSI/SNR/hops +- Stego mode selector, block relay status panel + +## Key Challenges + +1. **TX hex > 160 bytes:** Use Reed-Solomon chunking (already in `transport/chunking.rs`) +2. **Async in listener:** Spawn tasks for Bitcoin/LND calls, don't block serial loop +3. **Dead man false triggers:** Persist check-in time as unix timestamp on disk +4. **Stego overhead:** ~80 bytes real data per 160-byte frame + +## Files Modified + +**Phase 4:** +- `core/archipelago/src/mesh/listener.rs` — typed dispatch, new MeshCommand variants +- `core/archipelago/src/mesh/mod.rs` — new fields, init, background tasks +- `core/archipelago/src/mesh/types.rs` — new MeshEvent variants +- `core/archipelago/src/api/rpc/mesh.rs` — 6+ new endpoints, fix send-invoice +- `core/archipelago/src/api/rpc/mod.rs` — register routes +- `neode-ui/src/stores/mesh.ts` — new store methods +- `neode-ui/src/views/Mesh.vue` — off-grid + dead man panels + +**Phase 5 new files:** +- `core/archipelago/src/mesh/steganography.rs` +- `core/archipelago/src/mesh/routing.rs` +- `core/archipelago/src/mesh/device_trait.rs` + +## Existing Code to Reuse + +- `bitcoin_relay.rs`: `BlockHeaderCache`, `RelayTracker`, all `build_*` functions +- `alerts.rs`: `DeadManSwitch`, `AlertConfig`, `load_config`/`save_config` +- `message_types.rs`: All payload types, `TypedEnvelope`, `encode_payload`/`decode_payload` +- `api/rpc/lnd.rs:128-141`: `lnd_client()` pattern for LND REST calls +- `api/rpc/bitcoin.rs:74-107`: `bitcoin_rpc_call()` for Bitcoin Core RPC +- `transport/chunking.rs`: Reed-Solomon FEC for payloads > 160 bytes + +## Verification + +```bash +# Unit tests on server +ssh archipelago@192.168.1.228 'cd ~/archy/core && source ~/.cargo/env && cargo test --all-features -- mesh' + +# Type check frontend +cd neode-ui && npm run type-check + +# Deploy to both +./scripts/deploy-to-target.sh --both + +# E2E tests: +# 1. .228 (internet) relays TX from .198 (mesh-only) +# 2. .228 announces block headers, .198 receives them +# 3. Dead man's switch triggers after interval, broadcasts alert +# 4. Steganographic packet looks like weather data on wire +``` diff --git a/core/Cargo.lock b/core/Cargo.lock index 3964f96e..60e8bc7b 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -101,6 +101,7 @@ dependencies = [ "flate2", "futures-util", "hex", + "hkdf", "hmac", "http-body 1.0.1", "http-body-util", @@ -1058,6 +1059,15 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + [[package]] name = "hmac" version = "0.12.1" diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml index 56879017..944e205b 100644 --- a/core/archipelago/Cargo.toml +++ b/core/archipelago/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "archipelago" -version = "1.1.0" +version = "1.2.0-alpha" edition = "2021" description = "Archipelago Bitcoin Node OS - Native backend" authors = ["Archipelago Team"] diff --git a/core/archipelago/src/api/rpc/lnd.rs b/core/archipelago/src/api/rpc/lnd.rs index 3b9db0cc..20b6174c 100644 --- a/core/archipelago/src/api/rpc/lnd.rs +++ b/core/archipelago/src/api/rpc/lnd.rs @@ -751,16 +751,21 @@ impl RpcHandler { return Err(anyhow::anyhow!("Failed to sign TX: {}", msg)); } - // raw_final_tx is the hex-encoded signed transaction — ready for broadcast - let raw_final_tx = body.get("raw_final_tx") + // raw_final_tx from LND is base64-encoded — decode to hex for Bitcoin RPC + let raw_final_tx_b64 = body.get("raw_final_tx") .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("No raw_final_tx in response"))? - .to_string(); + .ok_or_else(|| anyhow::anyhow!("No raw_final_tx in response"))?; - info!(addr, amount_sats, tx_len = raw_final_tx.len(), "Created raw TX for mesh relay (NOT broadcast)"); + use base64::Engine; + let tx_bytes = base64::engine::general_purpose::STANDARD + .decode(raw_final_tx_b64) + .context("Failed to decode raw_final_tx base64")?; + let raw_tx_hex = hex::encode(&tx_bytes); + + info!(addr, amount_sats, tx_len = raw_tx_hex.len(), "Created raw TX for mesh relay (NOT broadcast)"); Ok(serde_json::json!({ - "raw_tx_hex": raw_final_tx, + "raw_tx_hex": raw_tx_hex, "amount_sats": amount_sats, "addr": addr, "broadcast": false, diff --git a/core/archipelago/src/api/rpc/mesh.rs b/core/archipelago/src/api/rpc/mesh.rs index 8830ec46..99c0402b 100644 --- a/core/archipelago/src/api/rpc/mesh.rs +++ b/core/archipelago/src/api/rpc/mesh.rs @@ -423,6 +423,10 @@ impl RpcHandler { .as_str() .ok_or_else(|| anyhow::anyhow!("Missing tx_hex"))?; + let relay_mode = params["relay_mode"] + .as_str() + .unwrap_or("archy"); + if tx_hex.len() < 20 || tx_hex.len() > 200_000 { anyhow::bail!("Invalid tx_hex length"); } @@ -440,30 +444,83 @@ impl RpcHandler { let wire = crate::mesh::bitcoin_relay::build_tx_relay_request(tx_hex, request_id)?; - // Send ONLY to Archipelago peers (Archy-* nodes), not broadcast to all devices - let peers = svc.peers().await; let mut sent_count = 0u32; - for peer in &peers { - if !peer.advert_name.starts_with("Archy-") { continue; } - if let Some(ref pk) = peer.pubkey_hex { - if let Ok(pk_bytes) = hex::decode(pk) { - if pk_bytes.len() >= 6 { - let mut prefix = [0u8; 6]; - prefix.copy_from_slice(&pk_bytes[..6]); - let _ = svc.shared_state() - .cmd_tx - .send(crate::mesh::listener::MeshCommand::SendRaw { - dest_pubkey_prefix: prefix, - payload: wire.clone(), - }) - .await; - sent_count += 1; + + if relay_mode == "broadcast" { + // Broadcast mode: send on channel 0 (all mesh nodes relay) + // Still encrypted — only Archy nodes can decrypt and broadcast the TX + let shared_state = svc.shared_state(); + let shared_secrets = shared_state.shared_secrets.read().await; + + // Encrypt with first available Archy peer's shared secret + // (any Archy node that receives it can try decrypting) + let payload = shared_secrets.values().next() + .and_then(|secret| { + crate::mesh::crypto::encrypt(secret, &wire).ok().map(|ct| { + let mut encrypted = Vec::with_capacity(1 + ct.len()); + encrypted.push(crate::mesh::message_types::ENCRYPTED_TYPED_MARKER); + encrypted.extend_from_slice(&ct); + encrypted + }) + }) + .unwrap_or_else(|| wire.clone()); + drop(shared_secrets); + + { + use base64::Engine; + let b64 = base64::engine::general_purpose::STANDARD.encode(&payload); + let _ = shared_state + .cmd_tx + .send(crate::mesh::listener::MeshCommand::BroadcastChannel { + channel: 0, + payload: b64.into_bytes(), + }) + .await; + } + sent_count = 1; + info!(request_id, tx_len = tx_hex.len(), "TX relay broadcast on mesh channel 0 (encrypted)"); + } else { + // Archy mode: E2E encrypted per-peer, direct to known Archy nodes + let peers = svc.peers().await; + let shared_state = svc.shared_state(); + let shared_secrets = shared_state.shared_secrets.read().await; + for peer in &peers { + if !peer.advert_name.starts_with("Archy-") { continue; } + if let Some(ref pk) = peer.pubkey_hex { + if let Ok(pk_bytes) = hex::decode(pk) { + if pk_bytes.len() >= 6 { + let mut prefix = [0u8; 6]; + prefix.copy_from_slice(&pk_bytes[..6]); + + let payload = if let Some(secret) = shared_secrets.get(&peer.contact_id) { + match crate::mesh::crypto::encrypt(secret, &wire) { + Ok(ciphertext) => { + let mut encrypted = Vec::with_capacity(1 + ciphertext.len()); + encrypted.push(crate::mesh::message_types::ENCRYPTED_TYPED_MARKER); + encrypted.extend_from_slice(&ciphertext); + encrypted + } + Err(_) => wire.clone(), + } + } else { + wire.clone() + }; + + let _ = svc.shared_state() + .cmd_tx + .send(crate::mesh::listener::MeshCommand::SendRaw { + dest_pubkey_prefix: prefix, + payload, + }) + .await; + sent_count += 1; + } } } } + drop(shared_secrets); + info!(request_id, tx_len = tx_hex.len(), archy_peers = sent_count, "TX relay sent to Archy peers (E2E encrypted)"); } - - info!(request_id, tx_len = tx_hex.len(), archy_peers = sent_count, "TX relay sent to Archy peers only"); Ok(serde_json::json!({ "request_id": request_id, "queued": true, @@ -471,6 +528,47 @@ impl RpcHandler { })) } + /// mesh.relay-status — Check the status of a pending or completed TX relay. + pub(super) async fn handle_mesh_relay_status( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let request_id = params["request_id"] + .as_u64() + .ok_or_else(|| anyhow::anyhow!("Missing request_id"))?; + + let service = self.mesh_service.read().await; + let svc = service.as_ref() + .ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?; + + // Check completed results first + if let Some(result) = svc.relay_tracker.get_result(request_id).await { + return Ok(serde_json::json!({ + "status": if result.txid.is_some() { "confirmed" } else { "failed" }, + "request_id": result.request_id, + "txid": result.txid, + "error": result.error, + "error_code": result.error_code, + "completed_at": result.completed_at, + })); + } + + // Check if still pending + if svc.relay_tracker.is_pending(request_id).await { + return Ok(serde_json::json!({ + "status": "pending", + "request_id": request_id, + })); + } + + // Unknown — either expired or never existed + Ok(serde_json::json!({ + "status": "unknown", + "request_id": request_id, + })) + } + /// mesh.block-headers — Get cached block headers received from mesh peers. pub(super) async fn handle_mesh_block_headers( &self, @@ -529,8 +627,10 @@ impl RpcHandler { bolt11, amount_sats, request_id, )?; - // Send ONLY to Archipelago peers, not broadcast + // Send to Archipelago peers — E2E encrypted per-peer let peers = svc.peers().await; + let shared_state = svc.shared_state(); + let shared_secrets = shared_state.shared_secrets.read().await; let mut sent_count = 0u32; for peer in &peers { if !peer.advert_name.starts_with("Archy-") { continue; } @@ -539,11 +639,26 @@ impl RpcHandler { if pk_bytes.len() >= 6 { let mut prefix = [0u8; 6]; prefix.copy_from_slice(&pk_bytes[..6]); + + let payload = if let Some(secret) = shared_secrets.get(&peer.contact_id) { + match crate::mesh::crypto::encrypt(secret, &wire) { + Ok(ciphertext) => { + let mut encrypted = Vec::with_capacity(1 + ciphertext.len()); + encrypted.push(crate::mesh::message_types::ENCRYPTED_TYPED_MARKER); + encrypted.extend_from_slice(&ciphertext); + encrypted + } + Err(_) => wire.clone(), + } + } else { + wire.clone() + }; + let _ = svc.shared_state() .cmd_tx .send(crate::mesh::listener::MeshCommand::SendRaw { dest_pubkey_prefix: prefix, - payload: wire.clone(), + payload, }) .await; sent_count += 1; @@ -551,8 +666,9 @@ impl RpcHandler { } } } + drop(shared_secrets); - info!(request_id, amount_sats, archy_peers = sent_count, "Lightning relay sent to Archy peers only"); + info!(request_id, amount_sats, archy_peers = sent_count, "Lightning relay sent (E2E encrypted)"); Ok(serde_json::json!({ "request_id": request_id, "queued": true, @@ -670,4 +786,80 @@ impl RpcHandler { "one_time_prekeys": bundle.one_time_prekeys.len(), })) } + + // ─── Radio Diagnostics ───────────────────────────────────────────── + + /// mesh.test-send — Send test payloads of various sizes to diagnose radio link. + /// Sends plain text markers that the receiver can count. + pub(super) async fn handle_mesh_test_send( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let contact_id = params["contact_id"] + .as_u64() + .ok_or_else(|| anyhow::anyhow!("Missing contact_id"))? as u32; + + // Test modes: "ping" (small), "medium" (80 bytes), "large" (150 bytes), "chunked" (400 bytes) + let mode = params["mode"].as_str().unwrap_or("ping"); + let count = params["count"].as_u64().unwrap_or(3) as usize; + + let service = self.mesh_service.read().await; + let svc = service.as_ref() + .ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?; + + let mut sent = 0usize; + let test_id = chrono::Utc::now().timestamp() as u32; + + for i in 0..count { + let payload = match mode { + "ping" => format!("MESHTEST:{}:{}:PING", test_id, i), + "medium" => format!("MESHTEST:{}:{}:{}", test_id, i, "X".repeat(60)), + "large" => format!("MESHTEST:{}:{}:{}", test_id, i, "X".repeat(130)), + "chunked" => { + // Send a TypedEnvelope that requires chunking (>140 base64 chars) + let fake_tx = "0".repeat(400); // simulates TX hex + let wire = crate::mesh::bitcoin_relay::build_tx_relay_request(&fake_tx, test_id as u64 + i as u64)?; + // Send via SendRaw which handles base64 + chunking + let peers = svc.peers().await; + if let Some(peer) = peers.iter().find(|p| p.contact_id == contact_id) { + if let Some(ref pk) = peer.pubkey_hex { + if let Ok(pk_bytes) = hex::decode(pk) { + if pk_bytes.len() >= 6 { + let mut prefix = [0u8; 6]; + prefix.copy_from_slice(&pk_bytes[..6]); + let _ = svc.shared_state().cmd_tx.send( + crate::mesh::listener::MeshCommand::SendRaw { + dest_pubkey_prefix: prefix, + payload: wire, + }, + ).await; + sent += 1; + } + } + } + } + // Delay between chunked sends + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + continue; + } + _ => format!("MESHTEST:{}:{}:UNKNOWN", test_id, i), + }; + + // Send as plain text for ping/medium/large + let msg = svc.send_message(contact_id, &payload).await?; + sent += 1; + info!(test_id, seq = i, mode, len = payload.len(), "Test message sent"); + + // Small delay between sends + tokio::time::sleep(std::time::Duration::from_millis(1000)).await; + } + + Ok(serde_json::json!({ + "test_id": test_id, + "mode": mode, + "sent": sent, + "count": count, + })) + } } diff --git a/core/archipelago/src/api/rpc/mod.rs b/core/archipelago/src/api/rpc/mod.rs index d761dd46..58dd693e 100644 --- a/core/archipelago/src/api/rpc/mod.rs +++ b/core/archipelago/src/api/rpc/mod.rs @@ -655,11 +655,13 @@ impl RpcHandler { "mesh.rotate-prekeys" => self.handle_mesh_rotate_prekeys().await, // Phase 4: Off-grid Bitcoin operations "mesh.relay-tx" => self.handle_mesh_relay_tx(params).await, + "mesh.relay-status" => self.handle_mesh_relay_status(params).await, "mesh.block-headers" => self.handle_mesh_block_headers(params).await, "mesh.relay-lightning" => self.handle_mesh_relay_lightning(params).await, "mesh.deadman-status" => self.handle_mesh_deadman_status().await, "mesh.deadman-configure" => self.handle_mesh_deadman_configure(params).await, "mesh.deadman-checkin" => self.handle_mesh_deadman_checkin().await, + "mesh.test-send" => self.handle_mesh_test_send(params).await, // Transport layer (unified routing) "transport.status" => self.handle_transport_status().await, diff --git a/core/archipelago/src/container/docker_packages.rs b/core/archipelago/src/container/docker_packages.rs index ad6bbfbe..1952233e 100644 --- a/core/archipelago/src/container/docker_packages.rs +++ b/core/archipelago/src/container/docker_packages.rs @@ -50,6 +50,13 @@ impl DockerPackageScanner { "immich_redis", "endurain-db", "nextcloud-db", + "indeedhub-build_api_1", + "indeedhub-build_postgres_1", + "indeedhub-build_redis_1", + "indeedhub-build_minio_1", + "indeedhub-build_minio-init_1", + "indeedhub-build_relay_1", + "indeedhub-build_ffmpeg-worker_1", ]; // First pass: collect UI containers @@ -95,7 +102,14 @@ impl DockerPackageScanner { debug!("Skipping backend service: {}", app_id); continue; } - + + // Skip podman-compose infrastructure containers (e.g. indeedhub-build_api_1) + // These have the project prefix pattern: {project}_{service}_{instance} + if app_id.starts_with("indeedhub-build_") { + debug!("Skipping IndeedHub compose service: {}", app_id); + continue; + } + // Skip UI containers (they're merged with their parent apps) if app_id.ends_with("-ui") { debug!("Skipping UI container: {}", app_id); diff --git a/core/archipelago/src/crash_recovery.rs b/core/archipelago/src/crash_recovery.rs index fd292c2e..a85a1878 100644 --- a/core/archipelago/src/crash_recovery.rs +++ b/core/archipelago/src/crash_recovery.rs @@ -119,11 +119,15 @@ pub async fn remove_pid_marker(data_dir: &Path) { /// Save a snapshot of currently running containers to disk. /// Called periodically so we know what to restart after a crash. pub async fn save_container_snapshot(data_dir: &Path) -> Result<()> { - let output = tokio::process::Command::new("sudo") - .args(["podman", "ps", "--format", "json"]) - .output() - .await - .context("Failed to run podman ps")?; + let output = tokio::time::timeout( + std::time::Duration::from_secs(30), + tokio::process::Command::new("sudo") + .args(["podman", "ps", "--format", "json"]) + .output(), + ) + .await + .context("podman ps timed out (30s)")? + .context("Failed to run podman ps")?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); @@ -181,28 +185,40 @@ pub async fn recover_containers(containers: &[RunningContainerRecord]) -> Recove failed: Vec::new(), }; - for record in containers { + for (i, record) in containers.iter().enumerate() { info!("Recovering container: {} (image: {})", record.name, record.image); - let result = tokio::process::Command::new("sudo") - .args(["podman", "start", &record.name]) - .output() - .await; + // Rate-limit container starts to avoid overwhelming podman on low-resource systems + if i > 0 { + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + } + + let result = tokio::time::timeout( + std::time::Duration::from_secs(30), + tokio::process::Command::new("sudo") + .args(["podman", "start", &record.name]) + .output(), + ) + .await; match result { - Ok(output) if output.status.success() => { + Ok(Ok(output)) if output.status.success() => { info!("Successfully restarted container: {}", record.name); report.recovered += 1; } - Ok(output) => { + Ok(Ok(output)) => { let stderr = String::from_utf8_lossy(&output.stderr); warn!("Failed to restart container {}: {}", record.name, stderr.trim()); report.failed.push(record.name.clone()); } - Err(e) => { + Ok(Err(e)) => { warn!("Failed to execute podman start for {}: {}", record.name, e); report.failed.push(record.name.clone()); } + Err(_) => { + warn!("Timeout starting container {} (30s)", record.name); + report.failed.push(record.name.clone()); + } } } @@ -226,10 +242,20 @@ fn is_process_running(pid: u32) -> bool { /// 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 { - let output = tokio::process::Command::new("sudo") - .args(["podman", "ps", "-a", "--filter", "status=exited", "--filter", "status=created", "--format", "{{.Names}}"]) - .output() - .await; + let output = match tokio::time::timeout( + std::time::Duration::from_secs(30), + tokio::process::Command::new("sudo") + .args(["podman", "ps", "-a", "--filter", "status=exited", "--filter", "status=created", "--format", "{{.Names}}"]) + .output(), + ) + .await + { + Ok(result) => result, + Err(_) => { + warn!("Timeout listing stopped containers (30s)"); + return RecoveryReport { total: 0, recovered: 0, failed: Vec::new() }; + } + }; let names: Vec = match output { Ok(o) if o.status.success() => { @@ -256,10 +282,10 @@ pub async fn start_stopped_containers() -> RecoveryReport { /// Spawn a background task that periodically saves the container snapshot. pub fn spawn_snapshot_task(data_dir: PathBuf) { tokio::spawn(async move { - // Wait 30s before first snapshot (let containers stabilize after startup) - tokio::time::sleep(std::time::Duration::from_secs(30)).await; + // Wait 2 minutes before first snapshot (let crash recovery finish and containers stabilize) + tokio::time::sleep(std::time::Duration::from_secs(120)).await; - let mut interval = tokio::time::interval(std::time::Duration::from_secs(60)); + let mut interval = tokio::time::interval(std::time::Duration::from_secs(120)); loop { interval.tick().await; if let Err(e) = save_container_snapshot(&data_dir).await { diff --git a/core/archipelago/src/health_monitor.rs b/core/archipelago/src/health_monitor.rs index 8379e82e..0c5845f0 100644 --- a/core/archipelago/src/health_monitor.rs +++ b/core/archipelago/src/health_monitor.rs @@ -182,12 +182,23 @@ impl MemoryTracker { /// Query container memory stats from podman. async fn check_container_memory() -> HashMap { - let output = match tokio::process::Command::new("sudo") - .args(["podman", "stats", "--no-stream", "--format", "{{.Name}} {{.MemUsage}}"]) - .output() - .await + let output = match tokio::time::timeout( + std::time::Duration::from_secs(30), + tokio::process::Command::new("sudo") + .args(["podman", "stats", "--no-stream", "--format", "{{.Name}} {{.MemUsage}}"]) + .output(), + ) + .await { - Ok(o) if o.status.success() => o, + Ok(Ok(o)) if o.status.success() => o, + Ok(Err(e)) => { + debug!("podman stats failed: {}", e); + return HashMap::new(); + } + Err(_) => { + debug!("podman stats timed out (30s)"); + return HashMap::new(); + } _ => return HashMap::new(), }; @@ -230,12 +241,23 @@ fn parse_memory_string(s: &str) -> Option { /// Query all containers and their health status. async fn check_containers() -> Vec { - let output = match tokio::process::Command::new("sudo") - .args(["podman", "ps", "-a", "--format", "json"]) - .output() - .await + let output = match tokio::time::timeout( + std::time::Duration::from_secs(30), + tokio::process::Command::new("sudo") + .args(["podman", "ps", "-a", "--format", "json"]) + .output(), + ) + .await { - Ok(o) if o.status.success() => o, + Ok(Ok(o)) if o.status.success() => o, + Ok(Err(e)) => { + debug!("podman ps failed: {}", e); + return Vec::new(); + } + Err(_) => { + debug!("podman ps timed out (30s)"); + return Vec::new(); + } _ => return Vec::new(), }; @@ -243,7 +265,7 @@ async fn check_containers() -> Vec { let containers: Vec = serde_json::from_str(&stdout).unwrap_or_default(); - // Backend services to skip + // Backend services and one-shot init containers to skip let skip = [ "btcpay-db", "nbxplorer", "mempool-db", "mempool-api", "penpot-postgres", "penpot-backend", "penpot-exporter", "penpot-valkey", @@ -271,6 +293,11 @@ async fn check_containers() -> Vec { return None; } + // Skip podman-compose infrastructure and one-shot init containers + if name.starts_with("indeedhub-build_") || name.contains("-init") { + return None; + } + let state = c.get("State") .and_then(|v| v.as_str()) .unwrap_or("unknown") @@ -291,25 +318,32 @@ async fn check_containers() -> Vec { /// Try to restart a container. async fn restart_container(name: &str) -> bool { info!("Auto-restarting unhealthy container: {}", name); - let result = tokio::process::Command::new("sudo") - .args(["podman", "start", name]) - .output() - .await; + let result = tokio::time::timeout( + std::time::Duration::from_secs(30), + tokio::process::Command::new("sudo") + .args(["podman", "start", name]) + .output(), + ) + .await; match result { - Ok(output) if output.status.success() => { + Ok(Ok(output)) if output.status.success() => { info!("Successfully restarted container: {}", name); true } - Ok(output) => { + Ok(Ok(output)) => { let stderr = String::from_utf8_lossy(&output.stderr); warn!("Failed to restart container {}: {}", name, stderr.trim()); false } - Err(e) => { + Ok(Err(e)) => { warn!("Failed to execute podman start for {}: {}", name, e); false } + Err(_) => { + warn!("Timeout starting container {} (30s)", name); + false + } } } diff --git a/core/archipelago/src/mesh/bitcoin_relay.rs b/core/archipelago/src/mesh/bitcoin_relay.rs index be4c0ab5..2fa17795 100644 --- a/core/archipelago/src/mesh/bitcoin_relay.rs +++ b/core/archipelago/src/mesh/bitcoin_relay.rs @@ -93,6 +93,8 @@ pub struct RelayTracker { tx_requests: RwLock>, /// Pending Lightning relay requests. lightning_requests: RwLock>, + /// Completed relay results (kept for 5 minutes for frontend polling). + completed_results: RwLock>, } #[derive(Debug, Clone)] @@ -101,11 +103,22 @@ struct PendingRelay { created_at: String, } +/// Result of a completed relay attempt, stored for frontend polling. +#[derive(Debug, Clone, serde::Serialize)] +pub struct RelayResult { + pub request_id: u64, + pub txid: Option, + pub error: Option, + pub error_code: Option, + pub completed_at: String, +} + impl RelayTracker { pub fn new() -> Self { Self { tx_requests: RwLock::new(HashMap::new()), lightning_requests: RwLock::new(HashMap::new()), + completed_results: RwLock::new(Vec::new()), } } @@ -155,6 +168,31 @@ impl RelayTracker { let ln = self.lightning_requests.read().await.len(); (tx, ln) } + + /// Store a completed relay result for frontend polling. + pub async fn store_result(&self, result: RelayResult) { + let mut results = self.completed_results.write().await; + // Evict results older than 5 minutes + let cutoff = chrono::Utc::now() - chrono::Duration::minutes(5); + let cutoff_str = cutoff.to_rfc3339(); + results.retain(|r| r.completed_at > cutoff_str); + results.push(result); + } + + /// Get relay result by request_id (returns None if not yet completed or expired). + pub async fn get_result(&self, request_id: u64) -> Option { + self.completed_results + .read() + .await + .iter() + .find(|r| r.request_id == request_id) + .cloned() + } + + /// Check if a TX relay request is still pending. + pub async fn is_pending(&self, request_id: u64) -> bool { + self.tx_requests.read().await.contains_key(&request_id) + } } impl Default for RelayTracker { @@ -220,11 +258,13 @@ pub fn build_tx_relay_response( request_id: u64, txid: Option<&str>, error: Option<&str>, + error_code: Option<&str>, ) -> Result> { let payload = message_types::encode_payload(&TxRelayResponsePayload { request_id, txid: txid.map(|s| s.to_string()), error: error.map(|s| s.to_string()), + error_code: error_code.map(|s| s.to_string()), })?; let envelope = TypedEnvelope::new(MeshMessageType::TxRelayResponse, payload); envelope.to_wire() diff --git a/core/archipelago/src/mesh/listener.rs b/core/archipelago/src/mesh/listener.rs index 9c5c9c57..da04979a 100644 --- a/core/archipelago/src/mesh/listener.rs +++ b/core/archipelago/src/mesh/listener.rs @@ -55,10 +55,30 @@ pub struct MeshState { pub event_tx: broadcast::Sender, pub cmd_tx: mpsc::Sender, next_message_id: RwLock, + /// Block header cache — populated when receiving headers from internet-connected peers. + pub block_header_cache: Arc, + /// Relay tracker — stores completed relay results for frontend polling. + pub relay_tracker: Option>, + /// Steganography mode for outgoing/incoming messages. + pub stego_mode: super::steganography::SteganographyMode, + /// Chunk reassembly buffer for multi-frame messages. + chunk_buffer: RwLock>, +} + +/// In-progress chunk reassembly for a multi-frame message. +struct ChunkAssembly { + chunks: HashMap, + total: u8, + created: std::time::Instant, } impl MeshState { - pub fn new(channel_name: &str) -> (Arc, broadcast::Receiver, mpsc::Receiver) { + pub fn new( + channel_name: &str, + block_header_cache: Arc, + relay_tracker: Option>, + stego_mode: super::steganography::SteganographyMode, + ) -> (Arc, broadcast::Receiver, mpsc::Receiver) { let (tx, rx) = broadcast::channel(64); let (cmd_tx, cmd_rx) = mpsc::channel(32); let state = Arc::new(Self { @@ -81,6 +101,10 @@ impl MeshState { }), event_tx: tx, next_message_id: RwLock::new(1), + block_header_cache, + relay_tracker, + stego_mode, + chunk_buffer: RwLock::new(HashMap::new()), }); (state, rx, cmd_rx) } @@ -305,15 +329,60 @@ async fn run_mesh_session( } } MeshCommand::SendRaw { dest_pubkey_prefix, payload } => { - // Base64 encode binary payloads — Meshcore truncates at NUL bytes in text mode - use base64::Engine; - let encoded = base64::engine::general_purpose::STANDARD.encode(&payload); - if let Err(e) = device.send_text(&dest_pubkey_prefix, encoded.as_bytes()).await { - consecutive_write_failures += 1; - warn!(failures = consecutive_write_failures, "Failed to send raw via mesh: {}", e); + // Apply steganographic encoding if configured + let wire_payload = if state.stego_mode != super::steganography::SteganographyMode::Normal + && payload.first() == Some(&super::message_types::TYPED_MESSAGE_MARKER) + { + match super::steganography::encode_typed_wire(state.stego_mode, &payload) { + Ok(stego) => stego, + Err(e) => { + warn!("Stego encode failed, sending plain: {}", e); + payload + } + } } else { + payload + }; + // Base64 encode, then chunk if >140 chars (LoRa 160 byte limit) + use base64::Engine; + let encoded = base64::engine::general_purpose::STANDARD.encode(&wire_payload); + + if encoded.len() <= 140 { + // Single frame — fits in one LoRa packet + if let Err(e) = device.send_text(&dest_pubkey_prefix, encoded.as_bytes()).await { + consecutive_write_failures += 1; + warn!(failures = consecutive_write_failures, "Failed to send raw via mesh: {}", e); + } else { + consecutive_write_failures = 0; + info!(dest = %hex::encode(dest_pubkey_prefix), len = encoded.len(), "Sent raw mesh message"); + } + } else { + // Multi-frame chunking: "MCxxyyzz..." where xx=msg_id, yy=chunk_idx, zz=total_chunks + static CHUNK_MSG_ID: std::sync::atomic::AtomicU8 = std::sync::atomic::AtomicU8::new(0); + let msg_id = CHUNK_MSG_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let chunk_data_size = 132; // 160 - 8 header bytes ("MCxxyyzz") = 152, leave margin + let chunks: Vec<&str> = encoded.as_bytes().chunks(chunk_data_size) + .map(|c| std::str::from_utf8(c).unwrap_or("")) + .collect(); + let total = chunks.len() as u8; + info!( + dest = %hex::encode(dest_pubkey_prefix), + raw_len = wire_payload.len(), + b64_len = encoded.len(), + chunks = total, + "Sending chunked mesh message" + ); + for (idx, chunk) in chunks.iter().enumerate() { + let frame = format!("MC{:02x}{:02x}{:02x}{}", msg_id, idx as u8, total, chunk); + if let Err(e) = device.send_text(&dest_pubkey_prefix, frame.as_bytes()).await { + consecutive_write_failures += 1; + warn!(failures = consecutive_write_failures, chunk = idx, "Chunk send failed: {}", e); + break; + } + // Small delay between chunks to avoid overwhelming the radio + tokio::time::sleep(Duration::from_millis(500)).await; + } consecutive_write_failures = 0; - info!(dest = %hex::encode(dest_pubkey_prefix), raw_len = payload.len(), wire_len = encoded.len(), "Sent raw mesh message (base64)"); } } MeshCommand::BroadcastChannel { channel, payload } => { @@ -390,7 +459,11 @@ async fn handle_frame( handle_typed_message(&payload, contact_id, &name, state).await; } else if let Some(decoded) = try_base64_typed(&payload) { handle_typed_message(&decoded, contact_id, &name, state).await; - } else { + } else if let Some(decoded) = try_decrypt_base64(&payload, contact_id, state).await { + handle_typed_message(&decoded, contact_id, &name, state).await; + } else if let Some(decoded) = try_chunk_reassemble(&payload, contact_id, state).await { + handle_typed_message(&decoded, contact_id, &name, state).await; + } else if !payload.starts_with(b"MC") { let text = String::from_utf8_lossy(&payload).to_string(); store_plain_message(state, contact_id, &name, &text).await; info!(from = %sender_prefix, "Received mesh DM (v3)"); @@ -411,7 +484,11 @@ async fn handle_frame( handle_typed_message(&payload, contact_id, &name, state).await; } else if let Some(decoded) = try_base64_typed(&payload) { handle_typed_message(&decoded, contact_id, &name, state).await; - } else { + } else if let Some(decoded) = try_decrypt_base64(&payload, contact_id, state).await { + handle_typed_message(&decoded, contact_id, &name, state).await; + } else if let Some(decoded) = try_chunk_reassemble(&payload, contact_id, state).await { + handle_typed_message(&decoded, contact_id, &name, state).await; + } else if !payload.starts_with(b"MC") { let text = String::from_utf8_lossy(&payload).to_string(); store_plain_message(state, contact_id, &name, &text).await; info!(from = %sender_prefix, "Received mesh DM (v1)"); @@ -679,22 +756,169 @@ async fn refresh_contacts( // ─── Typed Message Dispatch ──────────────────────────────────────────── /// Try to base64-decode payload and check if the result is a typed envelope. +/// Handles: plain typed (0x02), steganographic (0xAA), and encrypted (0xEE). /// Returns the decoded bytes if it's a valid base64-encoded TypedEnvelope. fn try_base64_typed(payload: &[u8]) -> Option> { use base64::Engine; - // Quick check: base64 starts with uppercase letters or digits, not 0x02 if payload.is_empty() || payload[0] == message_types::TYPED_MESSAGE_MARKER { return None; } let text = std::str::from_utf8(payload).ok()?; let decoded = base64::engine::general_purpose::STANDARD.decode(text.trim()).ok()?; - if TypedEnvelope::is_typed(&decoded) { - Some(decoded) + unwrap_wire_layers(&decoded) +} + +/// Try to base64-decode and decrypt an encrypted typed message. +/// Handles the common case where encrypted messages arrive as base64 text. +async fn try_decrypt_base64( + payload: &[u8], + sender_contact_id: u32, + state: &Arc, +) -> Option> { + use base64::Engine; + let text = std::str::from_utf8(payload).ok()?; + let decoded = base64::engine::general_purpose::STANDARD.decode(text.trim()).ok()?; + if decoded.first() != Some(&message_types::ENCRYPTED_TYPED_MARKER) { + return None; + } + let secrets = state.shared_secrets.read().await; + try_decrypt_typed(&decoded, sender_contact_id, &secrets) +} + +/// Unwrap wire layers: encrypted (0xEE) → stego (0xAA) → typed (0x02). +/// Returns None if decoding fails at any layer (caller should use shared_secrets variant). +fn unwrap_wire_layers(decoded: &[u8]) -> Option> { + // Check for steganographic frame (0xAA prefix) — unwrap to typed envelope + if decoded.first() == Some(&super::steganography::STEGO_MARKER) { + match super::steganography::decode_typed_wire(decoded) { + Ok(typed_wire) => return Some(typed_wire), + Err(_) => return None, + } + } + if TypedEnvelope::is_typed(decoded) { + Some(decoded.to_vec()) } else { None } } +/// Try to decrypt an encrypted typed message (0xEE prefix) using known shared secrets. +/// Format: [0xEE] [nonce: 12] [ciphertext + tag: 16] +fn try_decrypt_typed( + decoded: &[u8], + sender_contact_id: u32, + shared_secrets: &HashMap, +) -> Option> { + if decoded.first() != Some(&message_types::ENCRYPTED_TYPED_MARKER) { + return None; + } + let ciphertext = &decoded[1..]; // skip 0xEE marker + + // Try sender's shared secret first (most likely) + if let Some(secret) = shared_secrets.get(&sender_contact_id) { + if let Ok(plaintext) = crypto::decrypt(secret, ciphertext) { + return unwrap_wire_layers(&plaintext); + } + } + + // Fallback: try all known shared secrets (in case contact_id mapping is stale) + for (cid, secret) in shared_secrets { + if *cid == sender_contact_id { continue; } // already tried + if let Ok(plaintext) = crypto::decrypt(secret, ciphertext) { + return unwrap_wire_layers(&plaintext); + } + } + + None +} + +/// Check if payload is a mesh chunk ("MC" prefix) and try to reassemble. +/// Format: MC{msg_id:2hex}{chunk_idx:2hex}{total:2hex}{base64_data} +/// Returns Some(decoded_bytes) when all chunks have arrived. +async fn try_chunk_reassemble( + payload: &[u8], + sender_contact_id: u32, + state: &Arc, +) -> Option> { + use base64::Engine; + let text = std::str::from_utf8(payload).ok()?; + if !text.starts_with("MC") || text.len() < 8 { + return None; + } + + let msg_id = u8::from_str_radix(&text[2..4], 16).ok()?; + let chunk_idx = u8::from_str_radix(&text[4..6], 16).ok()?; + let total = u8::from_str_radix(&text[6..8], 16).ok()?; + let chunk_data = &text[8..]; + + if total == 0 || total > 20 { + return None; // sanity check + } + + let key = (sender_contact_id, msg_id); + let mut buffer = state.chunk_buffer.write().await; + + // Clean up stale entries (>120s old) + buffer.retain(|_, v| v.created.elapsed().as_secs() < 120); + + let assembly = buffer.entry(key).or_insert_with(|| ChunkAssembly { + chunks: HashMap::new(), + total, + created: std::time::Instant::now(), + }); + + assembly.chunks.insert(chunk_idx, chunk_data.to_string()); + assembly.total = total; // update in case first chunk had it wrong + + debug!(msg_id, chunk_idx, total, received = assembly.chunks.len(), "Chunk received"); + + // Check if we have all chunks + if assembly.chunks.len() < total as usize { + return None; + } + + // All chunks received — reassemble in order + let mut combined = String::new(); + for i in 0..total { + match assembly.chunks.get(&i) { + Some(data) => combined.push_str(data), + None => { + warn!(msg_id, missing = i, "Chunk missing during reassembly"); + return None; + } + } + } + + if let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(&combined) { + // Check for encrypted frame (0xEE) — decrypt then unwrap + if decoded.first() == Some(&message_types::ENCRYPTED_TYPED_MARKER) { + let secrets = state.shared_secrets.read().await; + if let Some(typed_wire) = try_decrypt_typed(&decoded, sender_contact_id, &secrets) { + info!(msg_id, chunks = total, total_len = typed_wire.len(), "Reassembled encrypted chunked message"); + buffer.remove(&key); + return Some(typed_wire); + } + } + // Check for stego frame — unwrap to typed envelope + if decoded.first() == Some(&super::steganography::STEGO_MARKER) { + if let Ok(typed_wire) = super::steganography::decode_typed_wire(&decoded) { + info!(msg_id, chunks = total, total_len = typed_wire.len(), "Reassembled stego chunked message"); + buffer.remove(&key); + return Some(typed_wire); + } + } + if TypedEnvelope::is_typed(&decoded) { + info!(msg_id, chunks = total, total_len = decoded.len(), "Reassembled chunked message"); + buffer.remove(&key); + return Some(decoded); + } + } + + warn!(msg_id, "All chunks received but decode failed"); + buffer.remove(&key); + None +} + /// Look up a peer by pubkey hex prefix. Returns (contact_id, display_name). async fn resolve_peer(state: &Arc, sender_prefix: &str) -> (u32, String) { let peers = state.peers.read().await; @@ -765,12 +989,23 @@ async fn handle_typed_message( Some(MeshMessageType::BlockHeader) => { // Compact binary format: height(8) + hash(32) + timestamp(4) match super::bitcoin_relay::decode_compact_block_header(&envelope.v) { - Ok((height, hash_hex, _timestamp)) => { + Ok((height, hash_hex, timestamp)) => { info!( height, hash = %hash_hex, "Block header received via mesh" ); + + // Store in block header cache for the Off-Grid Bitcoin panel + let header_payload = message_types::BlockHeaderPayload { + height, + hash: hash_hex.clone(), + prev_hash: String::new(), + timestamp, + announced_by: sender_name.to_string(), + }; + let _ = state.block_header_cache.store_header(header_payload).await; + let text = format!( "Block #{} — {}...{}", height, @@ -859,14 +1094,27 @@ async fn handle_typed_message( info!( request_id = resp.request_id, status, + error_code = resp.error_code.as_deref().unwrap_or("none"), "TX relay response received" ); let text = if let Some(ref txid) = resp.txid { format!("TX relayed! txid: {}...{}", &txid[..8.min(txid.len())], &txid[txid.len().saturating_sub(8)..]) + } else if let Some(ref code) = resp.error_code { + format!("TX relay failed [{}]: {}", code, resp.error.as_deref().unwrap_or("unknown")) } else { format!("TX relay failed: {}", resp.error.as_deref().unwrap_or("unknown")) }; store_typed_message(state, sender_contact_id, sender_name, &text, "tx_relay_response").await; + // Store result for frontend polling + if let Some(ref tracker) = state.relay_tracker { + tracker.store_result(super::bitcoin_relay::RelayResult { + request_id: resp.request_id, + txid: resp.txid.clone(), + error: resp.error.clone(), + error_code: resp.error_code.clone(), + completed_at: chrono::Utc::now().to_rfc3339(), + }).await; + } let _ = state.event_tx.send(MeshEvent::TxRelayCompleted { request_id: resp.request_id, txid: resp.txid, @@ -973,6 +1221,16 @@ async fn handle_typed_message( "TX confirmation update received" ); store_typed_message(state, sender_contact_id, sender_name, &status_text, "tx_confirmation").await; + // Store confirmation for frontend polling + if let Some(ref tracker) = state.relay_tracker { + tracker.store_result(super::bitcoin_relay::RelayResult { + request_id: conf.request_id, + txid: Some(conf.txid.clone()), + error: None, + error_code: None, + completed_at: chrono::Utc::now().to_rfc3339(), + }).await; + } let _ = state.event_tx.send(MeshEvent::TxRelayCompleted { request_id: conf.request_id, txid: Some(conf.txid), @@ -1043,6 +1301,59 @@ async fn handle_tx_relay_broadcast( } }; + // Pre-flight: check if Bitcoin Core is reachable and synced + let preflight_body = serde_json::json!({ + "jsonrpc": "1.0", + "id": "preflight", + "method": "getblockchaininfo", + "params": [] + }); + + match client + .post("http://127.0.0.1:8332/") + .basic_auth("archipelago", Some("archipelago123")) + .json(&preflight_body) + .send() + .await + { + Ok(resp) => { + if let Ok(rpc_resp) = resp.json::().await { + if let Some(result) = rpc_resp.get("result") { + let ibd = result.get("initialblockdownload") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let progress = result.get("verificationprogress") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + if ibd || progress < 0.999 { + let pct = (progress * 100.0) as u32; + let msg = format!("Bitcoin node is syncing ({}%) — cannot broadcast yet", pct); + warn!(request_id = relay.request_id, "{}", msg); + send_tx_relay_response(state, sender_contact_id, relay.request_id, None, Some(&msg), Some("bitcoin_syncing")).await; + return; + } + } else if let Some(err) = rpc_resp.get("error").and_then(|e| e.as_object()) { + let msg = err.get("message").and_then(|m| m.as_str()).unwrap_or("RPC error"); + warn!(request_id = relay.request_id, "Bitcoin pre-flight failed: {}", msg); + send_tx_relay_response(state, sender_contact_id, relay.request_id, None, Some(&format!("Bitcoin node error: {}", msg)), Some("bitcoin_unreachable")).await; + return; + } + } + } + Err(e) => { + let msg = format!("Bitcoin node unreachable — {}", if e.is_connect() { + "connection refused (node may be stopped)" + } else if e.is_timeout() { + "connection timed out" + } else { + "network error" + }); + warn!(request_id = relay.request_id, "Pre-flight: {}: {}", msg, e); + send_tx_relay_response(state, sender_contact_id, relay.request_id, None, Some(&msg), Some("bitcoin_unreachable")).await; + return; + } + } + // Step 1: Broadcast via Bitcoin Core RPC sendrawtransaction let body = serde_json::json!({ "jsonrpc": "1.0", @@ -1062,36 +1373,50 @@ async fn handle_tx_relay_broadcast( match resp.json::().await { Ok(rpc_resp) => { if let Some(err) = rpc_resp.get("error").and_then(|e| e.as_object()) { + let code = err.get("code").and_then(|c| c.as_i64()).unwrap_or(0); let msg = err.get("message").and_then(|m| m.as_str()).unwrap_or("unknown"); - warn!(request_id = relay.request_id, "sendrawtransaction failed: {}", msg); - send_tx_relay_response(state, sender_contact_id, relay.request_id, None, Some(msg)).await; + let user_msg = match code { + -25 => format!("TX already in mempool or confirmed: {}", msg), + -26 => format!("TX rejected by mempool policy: {}", msg), + -27 => format!("TX already confirmed in a block"), + _ => format!("Bitcoin rejected TX (code {}): {}", code, msg), + }; + warn!(request_id = relay.request_id, rpc_code = code, "sendrawtransaction: {}", msg); + send_tx_relay_response(state, sender_contact_id, relay.request_id, None, Some(&user_msg), Some(&format!("tx_rejected:{}", code))).await; return; } rpc_resp.get("result").and_then(|r| r.as_str()).map(|s| s.to_string()) } Err(e) => { warn!("Failed to parse Bitcoin RPC response: {}", e); - send_tx_relay_response(state, sender_contact_id, relay.request_id, None, Some("RPC parse error")).await; + send_tx_relay_response(state, sender_contact_id, relay.request_id, None, Some("Failed to parse Bitcoin node response"), Some("rpc_parse_error")).await; return; } } } Err(e) => { + let msg = format!("Bitcoin node unreachable during broadcast — {}", if e.is_connect() { + "connection refused" + } else if e.is_timeout() { + "timed out" + } else { + "network error" + }); warn!("Bitcoin Core RPC unreachable: {}", e); - send_tx_relay_response(state, sender_contact_id, relay.request_id, None, Some("No Bitcoin node available")).await; + send_tx_relay_response(state, sender_contact_id, relay.request_id, None, Some(&msg), Some("bitcoin_unreachable")).await; return; } }; let Some(txid) = txid else { - send_tx_relay_response(state, sender_contact_id, relay.request_id, None, Some("No txid returned")).await; + send_tx_relay_response(state, sender_contact_id, relay.request_id, None, Some("Bitcoin node returned no transaction ID"), Some("rpc_parse_error")).await; return; }; info!(request_id = relay.request_id, txid = %txid, "TX broadcast successful — tracking confirmations"); // Step 2: Send TxRelayResponse with txid back to originator - send_tx_relay_response(state, sender_contact_id, relay.request_id, Some(&txid), None).await; + send_tx_relay_response(state, sender_contact_id, relay.request_id, Some(&txid), None, None).await; // Step 3: Monitor confirmations (poll every 30s, up to 3 hours) let mut last_reported_confs: u32 = 0; @@ -1124,8 +1449,9 @@ async fn send_tx_relay_response( request_id: u64, txid: Option<&str>, error: Option<&str>, + error_code: Option<&str>, ) { - let wire = match super::bitcoin_relay::build_tx_relay_response(request_id, txid, error) { + let wire = match super::bitcoin_relay::build_tx_relay_response(request_id, txid, error, error_code) { Ok(w) => w, Err(e) => { warn!("Failed to build TX relay response: {}", e); diff --git a/core/archipelago/src/mesh/message_types.rs b/core/archipelago/src/mesh/message_types.rs index 8b3e7c45..2d81422a 100644 --- a/core/archipelago/src/mesh/message_types.rs +++ b/core/archipelago/src/mesh/message_types.rs @@ -14,6 +14,10 @@ use serde::{Deserialize, Serialize}; /// Wire prefix for typed messages. pub const TYPED_MESSAGE_MARKER: u8 = 0x02; +/// Wire prefix for encrypted typed messages (E2E encrypted with shared secret). +/// Format: [0xEE] [nonce: 12 bytes] [ciphertext + auth tag] +pub const ENCRYPTED_TYPED_MARKER: u8 = 0xEE; + /// Message type discriminator. #[repr(u8)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -275,6 +279,9 @@ pub struct TxRelayResponsePayload { pub txid: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub error: Option, + /// Machine-readable error code: bitcoin_unreachable, bitcoin_syncing, tx_rejected, rpc_parse_error + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error_code: Option, } /// Lightning invoice relay request. diff --git a/core/archipelago/src/mesh/mod.rs b/core/archipelago/src/mesh/mod.rs index 677e20ba..595679c1 100644 --- a/core/archipelago/src/mesh/mod.rs +++ b/core/archipelago/src/mesh/mod.rs @@ -27,6 +27,8 @@ pub mod ratchet; #[allow(dead_code)] pub mod session; #[allow(dead_code)] +pub mod steganography; +#[allow(dead_code)] pub mod x3dh; pub use types::*; @@ -68,6 +70,9 @@ pub struct MeshConfig { /// Announce new Bitcoin block headers over mesh (internet-connected nodes only). #[serde(default)] pub announce_block_headers: bool, + /// Steganographic encoding mode for mesh messages (Normal = disabled). + #[serde(default)] + pub steganography_mode: steganography::SteganographyMode, } impl Default for MeshConfig { @@ -80,6 +85,7 @@ impl Default for MeshConfig { advert_name: None, mesh_only_mode: None, announce_block_headers: false, + steganography_mode: steganography::SteganographyMode::Normal, } } } @@ -154,7 +160,14 @@ impl MeshService { .clone() .unwrap_or_else(|| "archipelago".to_string()); - let (state, _rx, cmd_rx) = MeshState::new(&channel_name); + let block_header_cache = Arc::new(BlockHeaderCache::new()); + let relay_tracker = Arc::new(RelayTracker::new()); + let (state, _rx, cmd_rx) = MeshState::new( + &channel_name, + Arc::clone(&block_header_cache), + Some(Arc::clone(&relay_tracker)), + config.steganography_mode, + ); // Derive X25519 keys from Ed25519 identity let x25519_secret = crypto::ed25519_secret_to_x25519(signing_key); @@ -162,9 +175,6 @@ impl MeshService { &signing_key.verifying_key().to_bytes(), )?; let x25519_pubkey_hex = hex::encode(x25519_pubkey); - - let block_header_cache = Arc::new(BlockHeaderCache::new()); - let relay_tracker = Arc::new(RelayTracker::new()); let dead_man_switch = Arc::new( DeadManSwitch::new(data_dir) .await @@ -670,6 +680,7 @@ mod tests { channel_name: Some("test".to_string()), broadcast_identity: false, advert_name: Some("MyNode".to_string()), + ..Default::default() }; let json = serde_json::to_string(&config).unwrap(); let parsed: MeshConfig = serde_json::from_str(&json).unwrap(); @@ -694,6 +705,7 @@ mod tests { channel_name: Some("archy".to_string()), broadcast_identity: true, advert_name: None, + ..Default::default() }; save_config(dir.path(), &config).await.unwrap(); let loaded = load_config(dir.path()).await.unwrap(); diff --git a/core/archipelago/src/mesh/ratchet.rs b/core/archipelago/src/mesh/ratchet.rs index 804e2e80..2620d601 100644 --- a/core/archipelago/src/mesh/ratchet.rs +++ b/core/archipelago/src/mesh/ratchet.rs @@ -440,7 +440,7 @@ mod tests { prev_chain_n: 0, message_n: 0, }, - ciphertext: vec![0x01, 0x02, 0x03; 30].into_iter().flatten().collect(), + ciphertext: vec![[0x01, 0x02, 0x03]; 30].into_iter().flatten().collect(), }; let bytes = msg.to_bytes(); let parsed = RatchetMessage::from_bytes(&bytes).unwrap(); diff --git a/core/archipelago/src/mesh/steganography.rs b/core/archipelago/src/mesh/steganography.rs new file mode 100644 index 00000000..cf1ef419 --- /dev/null +++ b/core/archipelago/src/mesh/steganography.rs @@ -0,0 +1,403 @@ +//! Steganographic encoding for mesh messages. +//! +//! Transforms typed message envelopes into formats that resemble innocuous +//! sensor data on the wire. Provides plausible deniability — traffic analysis +//! sees weather readings or industrial sensor data, not Bitcoin transactions. +//! +//! Wire format: +//! - Normal: `[0x02] [CBOR envelope]` (existing) +//! - Stego: `[0xAA] [mode: 1 byte] [stego-encoded data]` +//! +//! The 0xAA prefix distinguishes steganographic frames from typed (0x02) and +//! plain text (0x00) messages. Both sender and receiver must use the same mode. + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +/// Wire prefix for steganographic messages. +pub const STEGO_MARKER: u8 = 0xAA; + +/// Steganography mode — how real payload bytes are disguised on the wire. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum SteganographyMode { + /// No steganography — standard 0x02 typed envelope. + Normal, + /// Payload disguised as weather station telemetry. + /// Format: repeating 8-byte "readings" (temp, humidity, pressure, wind, flags). + WeatherStation, + /// Payload disguised as industrial sensor network data. + /// Format: repeating 6-byte "samples" (voltage, current, vibration, status). + SensorNetwork, +} + +impl Default for SteganographyMode { + fn default() -> Self { + Self::Normal + } +} + +impl SteganographyMode { + pub fn from_u8(v: u8) -> Option { + match v { + 0 => Some(Self::Normal), + 1 => Some(Self::WeatherStation), + 2 => Some(Self::SensorNetwork), + _ => None, + } + } +} + +// ─── Weather Station Encoding ────────────────────────────────────────── +// +// Each 8-byte "reading" encodes 5 bytes of real payload data: +// [temp_hi: u8] [temp_lo: u8] [humidity: u8] [pressure_hi: u8] [pressure_lo: u8] +// [wind_speed: u8] [wind_dir: u8] [flags: u8] +// +// Real data bytes map as: +// byte0 → temp_hi (offset by 200 to look like -50.0°C to +5.5°C range) +// byte1 → humidity (modulo 100) +// byte2 → pressure_hi (offset by 900 for 900-1155 hPa range) +// byte3 → wind_speed (modulo 60 for 0-59 m/s) +// byte4 → flags (lower 5 bits = data, upper 3 bits = plausible status flags) +// +// temp_lo, pressure_lo, wind_dir are derived (not payload data) for realism. +// Overhead: 8 bytes per 5 payload bytes = 60% efficiency. + +const WEATHER_REAL_BYTES_PER_BLOCK: usize = 5; +const WEATHER_WIRE_BYTES_PER_BLOCK: usize = 8; + +fn encode_weather_block(data: &[u8]) -> [u8; WEATHER_WIRE_BYTES_PER_BLOCK] { + let mut block = [0u8; 8]; + let b0 = *data.first().unwrap_or(&0); + let b1 = *data.get(1).unwrap_or(&0); + let b2 = *data.get(2).unwrap_or(&0); + let b3 = *data.get(3).unwrap_or(&0); + let b4 = *data.get(4).unwrap_or(&0); + + // temp: b0 mapped to plausible range, fractional derived from b1 + block[0] = b0.wrapping_add(200); // temp_hi — wraps around, decoded by subtracting 200 + block[1] = b1 ^ 0x55; // temp_lo — XOR mask, recoverable + // humidity: b1 stored directly (0-255 maps to 0-100% with modular interpretation) + block[2] = b1; + // pressure: b2 offset into 900-1155 range + block[3] = b2; + block[4] = b3 ^ 0x33; // pressure_lo — XOR mask + // wind: b3 modular + block[5] = b3; + // wind direction: derived from b4 (0-359 degrees as single byte = 0-255 → *1.41) + block[6] = b4 ^ 0xAA; // XOR mask + // flags: b4 with upper bits set for realism (battery OK, GPS lock, etc.) + block[7] = (b4 & 0x1F) | 0xC0; // upper 2 bits always set + + block +} + +fn decode_weather_block(block: &[u8; WEATHER_WIRE_BYTES_PER_BLOCK]) -> [u8; WEATHER_REAL_BYTES_PER_BLOCK] { + let mut data = [0u8; 5]; + data[0] = block[0].wrapping_sub(200); + data[1] = block[2]; // humidity field stores b1 directly + data[2] = block[3]; // pressure_hi stores b2 directly + data[3] = block[5]; // wind_speed stores b3 directly + data[4] = block[6] ^ 0xAA; // wind_dir XOR back + data +} + +// ─── Sensor Network Encoding ─────────────────────────────────────────── +// +// Each 6-byte "sample" encodes 4 bytes of real payload data: +// [voltage_hi: u8] [voltage_lo: u8] [current: u8] +// [vibration: u8] [phase: u8] [status: u8] +// +// Real data bytes map as: +// byte0 → voltage_hi +// byte1 → current +// byte2 → vibration +// byte3 → status (lower 4 bits = data, upper 4 = plausible status) +// +// voltage_lo and phase are derived for realism. +// Overhead: 6 bytes per 4 payload bytes = 67% efficiency. + +const SENSOR_REAL_BYTES_PER_BLOCK: usize = 4; +const SENSOR_WIRE_BYTES_PER_BLOCK: usize = 6; + +fn encode_sensor_block(data: &[u8]) -> [u8; SENSOR_WIRE_BYTES_PER_BLOCK] { + let mut block = [0u8; 6]; + let b0 = *data.first().unwrap_or(&0); + let b1 = *data.get(1).unwrap_or(&0); + let b2 = *data.get(2).unwrap_or(&0); + let b3 = *data.get(3).unwrap_or(&0); + + block[0] = b0; // voltage_hi + block[1] = b0 ^ b1; // voltage_lo (derived, recoverable) + block[2] = b1; // current + block[3] = b2; // vibration + block[4] = b2.wrapping_add(b3); // phase (derived) + block[5] = (b3 & 0x0F) | 0x80; // status: upper nibble = "operational" + + block +} + +fn decode_sensor_block(block: &[u8; SENSOR_WIRE_BYTES_PER_BLOCK]) -> [u8; SENSOR_REAL_BYTES_PER_BLOCK] { + let mut data = [0u8; 4]; + data[0] = block[0]; // voltage_hi = b0 + data[1] = block[2]; // current = b1 + data[2] = block[3]; // vibration = b2 + data[3] = (block[5] & 0x0F) | (block[4].wrapping_sub(block[3]) & 0xF0); + // Recover b3: lower 4 bits from status, but we only stored lower 4. + // Full b3 recovery: block[4] = b2 + b3, so b3 = block[4] - block[3] + data[3] = block[4].wrapping_sub(block[3]); + data +} + +// ─── Public API ──────────────────────────────────────────────────────── + +/// Encode raw payload bytes using steganographic mode. +/// Returns: `[0xAA] [mode_byte] [length_hi] [length_lo] [encoded_blocks...]` +/// +/// The length field stores the original payload size (up to 65535 bytes) +/// so the decoder knows how many real bytes to extract. +pub fn encode(mode: SteganographyMode, payload: &[u8]) -> Result> { + if mode == SteganographyMode::Normal { + anyhow::bail!("Cannot steganographically encode in Normal mode"); + } + if payload.len() > 0xFFFF { + anyhow::bail!("Payload too large for steganographic encoding"); + } + + let len = payload.len() as u16; + let mut output = Vec::new(); + output.push(STEGO_MARKER); + output.push(mode as u8); + output.push((len >> 8) as u8); + output.push((len & 0xFF) as u8); + + match mode { + SteganographyMode::WeatherStation => { + for chunk in payload.chunks(WEATHER_REAL_BYTES_PER_BLOCK) { + // Pad short final chunk with zeros + let mut padded = [0u8; WEATHER_REAL_BYTES_PER_BLOCK]; + padded[..chunk.len()].copy_from_slice(chunk); + output.extend_from_slice(&encode_weather_block(&padded)); + } + } + SteganographyMode::SensorNetwork => { + for chunk in payload.chunks(SENSOR_REAL_BYTES_PER_BLOCK) { + let mut padded = [0u8; SENSOR_REAL_BYTES_PER_BLOCK]; + padded[..chunk.len()].copy_from_slice(chunk); + output.extend_from_slice(&encode_sensor_block(&padded)); + } + } + SteganographyMode::Normal => unreachable!(), + } + + Ok(output) +} + +/// Decode a steganographic frame back to raw payload bytes. +/// Input must start with `0xAA`. +pub fn decode(data: &[u8]) -> Result<(SteganographyMode, Vec)> { + if data.len() < 4 { + anyhow::bail!("Stego frame too short: {} bytes", data.len()); + } + if data[0] != STEGO_MARKER { + anyhow::bail!("Not a stego frame (expected 0xAA, got 0x{:02x})", data[0]); + } + + let mode = SteganographyMode::from_u8(data[1]) + .ok_or_else(|| anyhow::anyhow!("Unknown stego mode: 0x{:02x}", data[1]))?; + let original_len = ((data[2] as usize) << 8) | (data[3] as usize); + let encoded_data = &data[4..]; + + let mut payload = Vec::with_capacity(original_len); + + match mode { + SteganographyMode::WeatherStation => { + for block_bytes in encoded_data.chunks(WEATHER_WIRE_BYTES_PER_BLOCK) { + if block_bytes.len() < WEATHER_WIRE_BYTES_PER_BLOCK { + break; + } + let block: [u8; WEATHER_WIRE_BYTES_PER_BLOCK] = block_bytes.try_into() + .context("Invalid weather block size")?; + let decoded = decode_weather_block(&block); + payload.extend_from_slice(&decoded); + } + } + SteganographyMode::SensorNetwork => { + for block_bytes in encoded_data.chunks(SENSOR_WIRE_BYTES_PER_BLOCK) { + if block_bytes.len() < SENSOR_WIRE_BYTES_PER_BLOCK { + break; + } + let block: [u8; SENSOR_WIRE_BYTES_PER_BLOCK] = block_bytes.try_into() + .context("Invalid sensor block size")?; + let decoded = decode_sensor_block(&block); + payload.extend_from_slice(&decoded); + } + } + SteganographyMode::Normal => { + anyhow::bail!("Normal mode cannot appear in stego frame"); + } + } + + // Truncate to original length (removes padding from last block) + payload.truncate(original_len); + Ok((mode, payload)) +} + +/// Encode a typed envelope wire bytes using steganography. +/// Input: standard wire bytes starting with 0x02 (TYPED_MESSAGE_MARKER). +/// Output: stego wire bytes starting with 0xAA. +pub fn encode_typed_wire(mode: SteganographyMode, typed_wire: &[u8]) -> Result> { + if typed_wire.is_empty() || typed_wire[0] != super::message_types::TYPED_MESSAGE_MARKER { + anyhow::bail!("Input is not a typed message (expected 0x02 prefix)"); + } + // Encode the entire typed wire frame (including the 0x02 marker) as payload + encode(mode, typed_wire) +} + +/// Decode a stego frame back to typed envelope wire bytes. +/// Returns the original bytes with 0x02 prefix restored. +pub fn decode_typed_wire(stego_data: &[u8]) -> Result> { + let (_mode, payload) = decode(stego_data)?; + if payload.is_empty() || payload[0] != super::message_types::TYPED_MESSAGE_MARKER { + anyhow::bail!("Decoded stego payload is not a typed message"); + } + Ok(payload) +} + +/// Calculate the wire overhead for a given mode and payload size. +pub fn wire_size(mode: SteganographyMode, payload_len: usize) -> usize { + let header = 4; // 0xAA + mode + len_hi + len_lo + match mode { + SteganographyMode::Normal => payload_len, + SteganographyMode::WeatherStation => { + let blocks = (payload_len + WEATHER_REAL_BYTES_PER_BLOCK - 1) / WEATHER_REAL_BYTES_PER_BLOCK; + header + blocks * WEATHER_WIRE_BYTES_PER_BLOCK + } + SteganographyMode::SensorNetwork => { + let blocks = (payload_len + SENSOR_REAL_BYTES_PER_BLOCK - 1) / SENSOR_REAL_BYTES_PER_BLOCK; + header + blocks * SENSOR_WIRE_BYTES_PER_BLOCK + } + } +} + +/// Max real payload bytes that fit in a single 160-byte LoRa frame after stego. +pub fn max_payload_per_frame(mode: SteganographyMode) -> usize { + let frame_limit = 160usize; + let header = 4; + let available = frame_limit.saturating_sub(header); + match mode { + SteganographyMode::Normal => frame_limit - 1, // minus 0x02 marker + SteganographyMode::WeatherStation => { + let blocks = available / WEATHER_WIRE_BYTES_PER_BLOCK; + blocks * WEATHER_REAL_BYTES_PER_BLOCK + } + SteganographyMode::SensorNetwork => { + let blocks = available / SENSOR_WIRE_BYTES_PER_BLOCK; + blocks * SENSOR_REAL_BYTES_PER_BLOCK + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_weather_roundtrip() { + let original = vec![0x42, 0xFF, 0x00, 0xAB, 0x13]; + let encoded = encode(SteganographyMode::WeatherStation, &original).unwrap(); + assert_eq!(encoded[0], STEGO_MARKER); + assert_eq!(encoded[1], SteganographyMode::WeatherStation as u8); + let (mode, decoded) = decode(&encoded).unwrap(); + assert_eq!(mode, SteganographyMode::WeatherStation); + assert_eq!(decoded, original); + } + + #[test] + fn test_sensor_roundtrip() { + let original = vec![0x42, 0xFF, 0x00, 0xAB]; + let encoded = encode(SteganographyMode::SensorNetwork, &original).unwrap(); + assert_eq!(encoded[0], STEGO_MARKER); + let (mode, decoded) = decode(&encoded).unwrap(); + assert_eq!(mode, SteganographyMode::SensorNetwork); + assert_eq!(decoded, original); + } + + #[test] + fn test_weather_multi_block() { + // 12 bytes = 3 weather blocks (5+5+2 with padding) + let original: Vec = (0..12).collect(); + let encoded = encode(SteganographyMode::WeatherStation, &original).unwrap(); + let (_, decoded) = decode(&encoded).unwrap(); + assert_eq!(decoded, original); + } + + #[test] + fn test_sensor_multi_block() { + // 10 bytes = 3 sensor blocks (4+4+2 with padding) + let original: Vec = (0..10).collect(); + let encoded = encode(SteganographyMode::SensorNetwork, &original).unwrap(); + let (_, decoded) = decode(&encoded).unwrap(); + assert_eq!(decoded, original); + } + + #[test] + fn test_all_byte_values_weather() { + let original: Vec = (0..=255).collect(); + let encoded = encode(SteganographyMode::WeatherStation, &original).unwrap(); + let (_, decoded) = decode(&encoded).unwrap(); + assert_eq!(decoded, original); + } + + #[test] + fn test_all_byte_values_sensor() { + let original: Vec = (0..=255).collect(); + let encoded = encode(SteganographyMode::SensorNetwork, &original).unwrap(); + let (_, decoded) = decode(&encoded).unwrap(); + assert_eq!(decoded, original); + } + + #[test] + fn test_empty_payload() { + let encoded = encode(SteganographyMode::WeatherStation, &[]).unwrap(); + let (_, decoded) = decode(&encoded).unwrap(); + assert!(decoded.is_empty()); + } + + #[test] + fn test_wire_size_calculation() { + // 5 bytes payload = 1 weather block = 4 header + 8 = 12 + assert_eq!(wire_size(SteganographyMode::WeatherStation, 5), 12); + // 4 bytes payload = 1 sensor block = 4 header + 6 = 10 + assert_eq!(wire_size(SteganographyMode::SensorNetwork, 4), 10); + } + + #[test] + fn test_max_payload_per_frame() { + let weather_max = max_payload_per_frame(SteganographyMode::WeatherStation); + let sensor_max = max_payload_per_frame(SteganographyMode::SensorNetwork); + // Verify the encoded output fits in 160 bytes + let test_data = vec![0x42; weather_max]; + let encoded = encode(SteganographyMode::WeatherStation, &test_data).unwrap(); + assert!(encoded.len() <= 160, "Weather stego {} > 160", encoded.len()); + + let test_data = vec![0x42; sensor_max]; + let encoded = encode(SteganographyMode::SensorNetwork, &test_data).unwrap(); + assert!(encoded.len() <= 160, "Sensor stego {} > 160", encoded.len()); + } + + #[test] + fn test_normal_mode_rejects() { + assert!(encode(SteganographyMode::Normal, &[1, 2, 3]).is_err()); + } + + #[test] + fn test_typed_wire_roundtrip() { + // Simulate a typed message wire frame + let mut typed_wire = vec![0x02]; // TYPED_MESSAGE_MARKER + typed_wire.extend_from_slice(&[0x01, 0x02, 0x03, 0x04, 0x05]); + let stego = encode_typed_wire(SteganographyMode::WeatherStation, &typed_wire).unwrap(); + let recovered = decode_typed_wire(&stego).unwrap(); + assert_eq!(recovered, typed_wire); + } +} diff --git a/core/archipelago/src/server.rs b/core/archipelago/src/server.rs index 55452ef0..cf49f3b5 100644 --- a/core/archipelago/src/server.rs +++ b/core/archipelago/src/server.rs @@ -209,96 +209,36 @@ impl Server { }); } - // Initialize mesh networking service (if config has enabled: true) - { - let data_dir = config.data_dir.clone(); - let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey) - .unwrap_or_default(); - let pubkey_hex = identity.pubkey_hex(); - let signing_key = identity.signing_key(); - match crate::mesh::MeshService::new(&data_dir, signing_key, &did, &pubkey_hex).await { - Ok(mut mesh_service) => { - let mesh_config = crate::mesh::load_config(&data_dir).await.unwrap_or_default(); - if mesh_config.enabled { - if let Err(e) = mesh_service.start() { - warn!("Mesh service start failed (non-fatal): {}", e); - } else { - info!("📡 Mesh networking started"); - } - } - api_handler.rpc_handler().set_mesh_service(mesh_service).await; - info!("📡 Mesh service initialized"); - } - Err(e) => { - warn!("Mesh service init failed (non-fatal): {}", e); - } - } - } - - // Initialize transport router (unified routing: mesh > lan > tor) - { - let data_dir = config.data_dir.clone(); - let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey) - .unwrap_or_default(); - let pubkey_hex = identity.pubkey_hex(); - let mesh_config = crate::mesh::load_config(&data_dir).await.unwrap_or_default(); - let mesh_only = mesh_config.mesh_only_mode.unwrap_or(false); - - match crate::transport::PeerRegistry::load(&data_dir).await { - Ok(registry) => { - let registry = std::sync::Arc::new(registry); - let mut transports: Vec> = Vec::new(); - - transports.push(Box::new( - crate::transport::tor::TorTransport::new(&pubkey_hex), - )); - transports.push(Box::new( - crate::transport::mesh_transport::MeshTransport::new( - api_handler.rpc_handler().mesh_service_arc(), - ), - )); - - let mut lan = crate::transport::lan::LanTransport::new(&did, &pubkey_hex, 5678); - match lan.start(registry.clone()) { - Ok(()) => info!("📡 LAN transport (mDNS) started"), - Err(e) => debug!("LAN transport init (non-fatal): {}", e), - } - transports.push(Box::new(lan)); - - let router = std::sync::Arc::new(crate::transport::TransportRouter::new( - transports, - registry, - mesh_only, - )); - api_handler.rpc_handler().set_transport_router(router).await; - info!("📡 Transport router initialized (mesh_only={})", mesh_only); - } - Err(e) => { - warn!("Transport router init failed (non-fatal): {}", e); - } - } - } - // Initialize container scanner — discovers installed apps from Podman/Docker { let scanner = create_docker_scanner(&config).await?; let state = state_manager.clone(); let identity_clone = identity.clone(); - // Initial scan + // Initial scan (delayed to let crash recovery finish first) tokio::spawn(async move { + // Wait for crash recovery to start containers before scanning + tokio::time::sleep(Duration::from_secs(15)).await; info!("🐳 Scanning containers..."); if let Err(e) = scan_and_update_packages(&scanner, &state, identity_clone.as_ref()).await { error!("Failed to scan containers: {}", e); } - // Periodic scan every 10 seconds (only broadcasts if state changed) - let mut interval = tokio::time::interval(Duration::from_secs(10)); + // Periodic scan every 30 seconds (only broadcasts if state changed) + // Uses an in-flight guard to skip scans when a previous one is still running + let mut interval = tokio::time::interval(Duration::from_secs(30)); + let scanning = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); loop { interval.tick().await; + if scanning.load(std::sync::atomic::Ordering::Relaxed) { + debug!("Skipping container scan — previous scan still in progress"); + continue; + } + scanning.store(true, std::sync::atomic::Ordering::Relaxed); if let Err(e) = scan_and_update_packages(&scanner, &state, identity_clone.as_ref()).await { error!("Failed to update containers: {}", e); } + scanning.store(false, std::sync::atomic::Ordering::Relaxed); } }); } diff --git a/core/container/src/podman_client.rs b/core/container/src/podman_client.rs index 12bed8f3..6689d746 100644 --- a/core/container/src/podman_client.rs +++ b/core/container/src/podman_client.rs @@ -335,11 +335,14 @@ impl PodmanClient { .arg("-a") .arg("--format") .arg("json"); - - let output = cmd - .output() - .await - .context("Failed to list containers")?; + + let output = tokio::time::timeout( + std::time::Duration::from_secs(60), + cmd.output(), + ) + .await + .map_err(|_| anyhow::anyhow!("podman ps timed out (60s)"))? + .context("Failed to list containers")?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); diff --git a/docs/MASTER_PLAN.md b/docs/MASTER_PLAN.md index 774a9c49..19d1ada4 100644 --- a/docs/MASTER_PLAN.md +++ b/docs/MASTER_PLAN.md @@ -9,6 +9,8 @@ | **BUG-1** | **Random logout / CSRF mismatch** | **P0** | PLANNED | - | | **TASK-2** | **Roll incoming-tx into deploy & ISO** | **P2** | PLANNED | - | | **BUG-3** | **IndeedHub WebSocket spam in console** | **P2** | PLANNED | - | +| **FEATURE-4** | **Onboarding loading screen with progress** | **P1** | IN PROGRESS | - | +| **INQUIRY-5** | **Offline balance check via mesh relay** | **P2** | PLANNED | - | ## Active Work @@ -66,6 +68,59 @@ The incoming transactions feature (lnd.gettransactions RPC + wallet badge UI + a - [ ] Alternatively, configure IndeedHub to use relative WebSocket URLs (`/ws/` instead of `ws://localhost:7777/`) - [ ] Test that WebSocket reconnection works after the fix +### FEATURE-4: Onboarding loading screen with progress (IN PROGRESS) +**Priority**: P1 — High +**Status**: IN PROGRESS (2026-03-17) + +Users hit the onboarding screen before the backend is ready, resulting in "Server is still starting up" errors that block identity creation. The onboarding flow should not begin until the server is fully operational. + +**Solution**: Show the existing screensaver as a loading/boot screen with server startup progress. Swap the inner logo for animated pixel art icons (smiley face, Bitcoin logo, etc.) that cycle while services come online. Show progress indicators for each backend service (identity store, container runtime, LND, etc.). Only transition to onboarding once `/health` returns ready. + +**Key considerations**: +- Reuse the existing screensaver component as the boot screen +- Animated pixel art icons rotate in the center (smiley, BTC, lightning bolt, etc.) +- Progress bar or status checklist showing which services are ready +- Poll `/health` endpoint for service readiness +- Smooth transition from boot screen → onboarding once all critical services are up +- First-boot vs normal boot: first boot shows onboarding after, normal boot goes to dashboard + +**Key files**: +- `neode-ui/src/views/Onboarding.vue` — current onboarding flow +- `neode-ui/src/components/Screensaver.vue` — existing screensaver to repurpose +- `core/archipelago/src/api/rpc/mod.rs` — health endpoint +- `core/archipelago/src/server.rs` — startup sequence and service initialization + +**Tasks**: +- [ ] Investigate current health endpoint — what services does it check, what's missing +- [ ] Design boot screen component: screensaver background + animated pixel icons + progress +- [ ] Create pixel art icon set (smiley, BTC, lightning, shield, etc.) as SVG/CSS animations +- [ ] Implement service readiness polling (health check with granular service status) +- [ ] Add backend support for granular startup progress (which services are ready) +- [ ] Build boot screen component with smooth transition to onboarding/dashboard +- [ ] Handle edge cases: very slow starts, partial service failures, timeout fallback +- [ ] Test on fresh ISO install (first-boot scenario) + +### INQUIRY-5: Offline balance check via mesh relay (PLANNED) +**Priority**: P2 +**Status**: PLANNED (2026-03-17) + +Design how to query wallet balance (LND/Bitcoin Core) from an off-grid node by relaying the request through mesh peers to an internet-connected Archy node that responds with the balance. Uses the same E2E encrypted relay infrastructure as TX relay. + +**Approach options**: +- New typed message pair: `BalanceRequest` (type 13) / `BalanceResponse` (type 14) +- Off-grid node sends `BalanceRequest` to Archy peers +- Internet-connected peer queries its own LND `walletbalance` or the requesting node's LND (if accessible) +- Challenge: the relay peer doesn't have access to the requesting node's wallet — need to either trust the relay peer's balance report, or have the relay peer proxy the RPC to the requesting node's LND over Tor/LAN +- Simplest: relay peer reports its OWN balance (useful for checking if your remote node has funds) +- Advanced: relay peer forwards the LND RPC call to the off-grid node's LND via reverse mesh tunnel + +**Tasks**: +- [ ] Define `BalanceRequest` / `BalanceResponse` typed messages +- [ ] Implement balance relay handler on internet-connected node +- [ ] Add "Check Balance" button to Off-Grid Bitcoin panel +- [ ] Consider trust model — relay peer could lie about balance +- [ ] Explore UTXO set proof (SPV-style) for trustless verification + ## Completed diff --git a/indeedhub b/indeedhub index 8d56fe39..99dd6894 160000 --- a/indeedhub +++ b/indeedhub @@ -1 +1 @@ -Subproject commit 8d56fe392d844a4a8713ce4f4985c01ada499022 +Subproject commit 99dd6894fd54945b008933fe520d2a7cc50568b8 diff --git a/neode-ui/dev-dist/sw.js b/neode-ui/dev-dist/sw.js index e79b00e8..dc96be10 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.9f8m1arrh28" + "revision": "0.h5o7c3cl7uo" }], {}); 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 7b9af3de..04a722db 100755 --- a/neode-ui/mock-backend.js +++ b/neode-ui/mock-backend.js @@ -27,9 +27,13 @@ const docker = new Docker() const app = express() const PORT = 5959 -// Dev mode from environment (setup, onboarding, existing, or default) +// Dev mode from environment (setup, onboarding, existing, boot, or default) const DEV_MODE = process.env.VITE_DEV_MODE || 'default' +// Boot mode: simulate server startup delay +let BOOT_START_TIME = Date.now() +const BOOT_DELAY_MS = 25000 // 25 seconds of simulated startup (slower for analysis) + // CORS configuration const corsOptions = { credentials: true, @@ -89,6 +93,15 @@ function initializeUserState() { passwordHash: MOCK_PASSWORD, } break + case 'boot': + // Boot mode: Simulate server startup delay (shows boot screen) + // Server responds with 502 for the first 10 seconds, then works like onboarding mode + userState = { + setupComplete: true, + onboardingComplete: false, + passwordHash: MOCK_PASSWORD, + } + break default: // Default: Fully set up (for UI development) userState = { @@ -748,6 +761,26 @@ app.post('/rpc/v1', (req, res) => { const { method, params } = req.body console.log(`[RPC] ${method}`) + // Boot mode: return 502 during simulated startup delay + if (DEV_MODE === 'boot') { + // Reset boot timer when browser does a fresh page load (server.echo with 'boot' message) + if (method === 'server.echo' && params?.message === 'boot-reset') { + BOOT_START_TIME = Date.now() + console.log(`[Boot] Timer RESET — simulating ${BOOT_DELAY_MS / 1000}s startup`) + return res.status(502).json({ error: 'Server starting up (reset)' }) + } + const elapsed = Date.now() - BOOT_START_TIME + if (elapsed < BOOT_DELAY_MS) { + const secs = Math.round(elapsed / 1000) + const total = Math.round(BOOT_DELAY_MS / 1000) + console.log(`[Boot] Server starting... ${secs}s / ${total}s`) + return res.status(502).json({ error: 'Server starting up' }) + } + if (elapsed < BOOT_DELAY_MS + 2000) { + console.log(`[Boot] Server is now READY (took ${Math.round(elapsed / 1000)}s)`) + } + } + try { switch (method) { // Authentication endpoints diff --git a/neode-ui/package.json b/neode-ui/package.json index 39c53e69..fda82703 100644 --- a/neode-ui/package.json +++ b/neode-ui/package.json @@ -1,7 +1,7 @@ { "name": "neode-ui", "private": true, - "version": "1.1.0", + "version": "1.2.0-alpha", "type": "module", "scripts": { "start": "./start-dev.sh", @@ -10,6 +10,7 @@ "test:watch": "vitest", "dev": "vite", "dev:mock": "concurrently \"node mock-backend.js\" \"VITE_AIUI_URL=http://localhost:5173 vite\" \"cd ../../AIUI && pnpm dev 2>/dev/null || echo '[AIUI] Not found at ../../AIUI — chat will show placeholder'\"", + "dev:boot": "VITE_DEV_MODE=boot concurrently \"VITE_DEV_MODE=boot node mock-backend.js\" \"VITE_DEV_MODE=boot vite\"", "dev:real": "echo 'Start backend: cd ../core && cargo run --release' && vite", "backend:mock": "node mock-backend.js", "backend:real": "cd ../core && cargo run --release", diff --git a/neode-ui/public/assets/audio/cosmic-updrift.mp3 b/neode-ui/public/assets/audio/cosmic-updrift.mp3 index e8cbb0cd..0285e019 100644 Binary files a/neode-ui/public/assets/audio/cosmic-updrift.mp3 and b/neode-ui/public/assets/audio/cosmic-updrift.mp3 differ diff --git a/neode-ui/public/assets/img/app-icons/indeedhub.png b/neode-ui/public/assets/img/app-icons/indeedhub.png index 1e56b49c..27d22f25 100644 Binary files a/neode-ui/public/assets/img/app-icons/indeedhub.png and b/neode-ui/public/assets/img/app-icons/indeedhub.png differ diff --git a/neode-ui/src/App.vue b/neode-ui/src/App.vue index 234ca07b..9f701d37 100644 --- a/neode-ui/src/App.vue +++ b/neode-ui/src/App.vue @@ -158,7 +158,8 @@ function onKeyDown(e: KeyboardEvent) { } const route = useRoute() -const showSplash = ref(true) +// Start with splash hidden — onMounted decides whether to show it +const showSplash = ref(false) const isReady = ref(false) /** @@ -175,16 +176,22 @@ onMounted(async () => { window.addEventListener('touchstart', onUserActivity) const seenIntro = localStorage.getItem('neode_intro_seen') === '1' const isDirectRoute = route.path !== '/' - - if (seenIntro || isDirectRoute) { + const fromBoot = route.query.intro === '1' + + if (fromBoot && !seenIntro) { + // Coming from boot screen — show the full splash intro + showSplash.value = true + // SplashScreen will emit 'complete' → handleSplashComplete + } else if (!seenIntro && !isDirectRoute && import.meta.env.VITE_DEV_MODE !== 'boot') { + // Normal first visit (not boot mode) — show splash intro + showSplash.value = true + } else { + // Already seen intro, direct route, or boot mode (boot screen handles intro) showSplash.value = false document.body.classList.add('splash-complete') - // Wait for router to finish initial navigation before showing content (fixes hard refresh) await router.isReady() isReady.value = true } - // If splash should show, wait for it to complete - // SplashScreen will emit 'complete' which calls handleSplashComplete }) onBeforeUnmount(() => { diff --git a/neode-ui/src/components/BootScreen.vue b/neode-ui/src/components/BootScreen.vue new file mode 100644 index 00000000..5456aa7a --- /dev/null +++ b/neode-ui/src/components/BootScreen.vue @@ -0,0 +1,457 @@ + + + + + diff --git a/neode-ui/src/components/ReceiveBitcoinModal.vue b/neode-ui/src/components/ReceiveBitcoinModal.vue new file mode 100644 index 00000000..69a2b2a7 --- /dev/null +++ b/neode-ui/src/components/ReceiveBitcoinModal.vue @@ -0,0 +1,131 @@ + + + diff --git a/neode-ui/src/components/SendBitcoinModal.vue b/neode-ui/src/components/SendBitcoinModal.vue new file mode 100644 index 00000000..80a8c80d --- /dev/null +++ b/neode-ui/src/components/SendBitcoinModal.vue @@ -0,0 +1,138 @@ + + + diff --git a/neode-ui/src/stores/mesh.ts b/neode-ui/src/stores/mesh.ts index 514533e2..2670f983 100644 --- a/neode-ui/src/stores/mesh.ts +++ b/neode-ui/src/stores/mesh.ts @@ -327,10 +327,10 @@ export const useMeshStore = defineStore('mesh', () => { } } - async function relayTransaction(txHex: string) { + async function relayTransaction(txHex: string, mode: 'archy' | 'broadcast' = 'archy') { return rpcClient.call<{ request_id: number; queued: boolean; tx_hex_len: number }>({ method: 'mesh.relay-tx', - params: { tx_hex: txHex }, + params: { tx_hex: txHex, relay_mode: mode }, }) } @@ -341,6 +341,20 @@ export const useMeshStore = defineStore('mesh', () => { }) } + async function relayStatus(requestId: number) { + return rpcClient.call<{ + status: 'pending' | 'confirmed' | 'failed' | 'unknown' + request_id: number + txid?: string + error?: string + error_code?: string + completed_at?: string + }>({ + method: 'mesh.relay-status', + params: { request_id: requestId }, + }) + } + async function refreshAll() { await Promise.all([fetchStatus(), fetchPeers(), fetchMessages(), fetchDeadmanStatus(), fetchBlockHeaders()]) } @@ -377,5 +391,6 @@ export const useMeshStore = defineStore('mesh', () => { fetchBlockHeaders, relayTransaction, relayLightning, + relayStatus, } }) diff --git a/neode-ui/src/views/AppSession.vue b/neode-ui/src/views/AppSession.vue index e1a14d09..2219c830 100644 --- a/neode-ui/src/views/AppSession.vue +++ b/neode-ui/src/views/AppSession.vue @@ -333,7 +333,6 @@ const HTTPS_PROXY_PATHS: Record = { 'immich_server': '/app/immich/', 'tailscale': '/app/tailscale/', 'endurain': '/app/endurain/', - 'indeedhub': '/app/indeedhub/', 'dwn': '/app/dwn/', } @@ -385,6 +384,17 @@ const appUrl = computed(() => { const proxyPath = PROXY_APPS[id] if (proxyPath) return `${window.location.origin}${proxyPath}` + // IndeedHub: always direct port (X-Frame-Options removed by deploy script) + if (id === 'indeedhub') { + const port = APP_PORTS[id] + if (port) { + let base = `${window.location.protocol}//${window.location.hostname}:${port}` + const subpath = route.query.path as string | undefined + if (subpath) base += subpath + return base + } + } + // HTTPS: use nginx proxy to avoid mixed content (browser blocks HTTP iframes in HTTPS pages) if (window.location.protocol === 'https:') { const httpsProxy = HTTPS_PROXY_PATHS[id] diff --git a/neode-ui/src/views/Apps.vue b/neode-ui/src/views/Apps.vue index a4320635..b826d52b 100644 --- a/neode-ui/src/views/Apps.vue +++ b/neode-ui/src/views/Apps.vue @@ -1,19 +1,20 @@