feat: v1.2.0-alpha — E2E encrypted mesh relay, steganography, relay status polling

Phase 5 mesh networking:
- E2E encrypted TX relay (X25519 + ChaCha20-Poly1305) — non-Archy nodes
  relay encrypted blobs transparently via Meshcore native routing
- Steganographic encoding modes (WeatherStation, SensorNetwork) — traffic
  looks like sensor data on the wire, 0xAA marker, configurable per-node
- Pre-flight Bitcoin Core health check on relay node — specific error codes
  (bitcoin_unreachable, bitcoin_syncing, tx_rejected) instead of generic fails
- mesh.relay-status RPC endpoint — frontend polls for relay result every 3s
- On-Chain / Lightning tabs in Off-Grid Bitcoin panel
- Archy Peers vs Mesh Broadcast relay mode selector
- Mesh view fills viewport (no page scroll), internal panel scrolling
- Version bump to 1.2.0-alpha

Also includes: deploy hardening, container fixes, IndeedHub updates,
boot screen, dashboard improvements, MASTER_PLAN task tracking

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-03-17 23:56:37 +00:00
parent 70f1348c15
commit d37ec1dea5
48 changed files with 3432 additions and 438 deletions

View File

@ -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

View File

@ -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

View File

@ -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`.

View File

@ -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`.

View File

@ -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.

View File

@ -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.

View File

@ -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`

View File

@ -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` |

View File

@ -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<u8> }` and `BroadcastChannel { channel: u8, payload: Vec<u8> }` 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<BlockHeaderCache>`, `relay_tracker: Arc<RelayTracker>`, `dead_man_switch: Arc<DeadManSwitch>`, `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<Self> where Self: Sized;
async fn initialize(&mut self) -> Result<DeviceInfo>;
async fn send_text(&mut self, dest: &[u8; 6], msg: &[u8]) -> Result<()>;
async fn try_recv_frame(&mut self) -> Result<Option<InboundFrame>>;
// ...
}
```
- Implement for `MeshcoreDevice`, stub Meshtastic/WiFi/BLE
- `listener.rs` uses `Box<dyn MeshDevice>`
- **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
```

10
core/Cargo.lock generated
View File

@ -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"

View File

@ -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"]

View File

@ -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,

View File

@ -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<serde_json::Value>,
) -> Result<serde_json::Value> {
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<serde_json::Value>,
) -> Result<serde_json::Value> {
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,
}))
}
}

View File

@ -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,

View File

@ -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);

View File

@ -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<String> = 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 {

View File

@ -182,12 +182,23 @@ impl MemoryTracker {
/// Query container memory stats from podman.
async fn check_container_memory() -> HashMap<String, u64> {
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<u64> {
/// Query all containers and their health status.
async fn check_containers() -> Vec<ContainerHealth> {
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<ContainerHealth> {
let containers: Vec<serde_json::Value> =
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<ContainerHealth> {
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<ContainerHealth> {
/// 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
}
}
}

View File

@ -93,6 +93,8 @@ pub struct RelayTracker {
tx_requests: RwLock<HashMap<u64, PendingRelay>>,
/// Pending Lightning relay requests.
lightning_requests: RwLock<HashMap<u64, PendingRelay>>,
/// Completed relay results (kept for 5 minutes for frontend polling).
completed_results: RwLock<Vec<RelayResult>>,
}
#[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<String>,
pub error: Option<String>,
pub error_code: Option<String>,
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<RelayResult> {
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<Vec<u8>> {
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()

View File

@ -55,10 +55,30 @@ pub struct MeshState {
pub event_tx: broadcast::Sender<MeshEvent>,
pub cmd_tx: mpsc::Sender<MeshCommand>,
next_message_id: RwLock<u64>,
/// Block header cache — populated when receiving headers from internet-connected peers.
pub block_header_cache: Arc<super::bitcoin_relay::BlockHeaderCache>,
/// Relay tracker — stores completed relay results for frontend polling.
pub relay_tracker: Option<Arc<super::bitcoin_relay::RelayTracker>>,
/// Steganography mode for outgoing/incoming messages.
pub stego_mode: super::steganography::SteganographyMode,
/// Chunk reassembly buffer for multi-frame messages.
chunk_buffer: RwLock<HashMap<(u32, u8), ChunkAssembly>>,
}
/// In-progress chunk reassembly for a multi-frame message.
struct ChunkAssembly {
chunks: HashMap<u8, String>,
total: u8,
created: std::time::Instant,
}
impl MeshState {
pub fn new(channel_name: &str) -> (Arc<Self>, broadcast::Receiver<MeshEvent>, mpsc::Receiver<MeshCommand>) {
pub fn new(
channel_name: &str,
block_header_cache: Arc<super::bitcoin_relay::BlockHeaderCache>,
relay_tracker: Option<Arc<super::bitcoin_relay::RelayTracker>>,
stego_mode: super::steganography::SteganographyMode,
) -> (Arc<Self>, broadcast::Receiver<MeshEvent>, mpsc::Receiver<MeshCommand>) {
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<Vec<u8>> {
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<MeshState>,
) -> Option<Vec<u8>> {
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<Vec<u8>> {
// 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<u32, [u8; 32]>,
) -> Option<Vec<u8>> {
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<MeshState>,
) -> Option<Vec<u8>> {
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<MeshState>, 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::<serde_json::Value>().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::<serde_json::Value>().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);

View File

@ -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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
/// 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<String>,
}
/// Lightning invoice relay request.

View File

@ -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();

View File

@ -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();

View File

@ -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<Self> {
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<Vec<u8>> {
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<u8>)> {
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<Vec<u8>> {
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<Vec<u8>> {
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<u8> = (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<u8> = (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<u8> = (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<u8> = (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);
}
}

View File

@ -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<Box<dyn crate::transport::NodeTransport>> = 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);
}
});
}

View File

@ -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);

View File

@ -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
<!-- Done tasks are moved here -->

@ -1 +1 @@
Subproject commit 8d56fe392d844a4a8713ce4f4985c01ada499022
Subproject commit 99dd6894fd54945b008933fe520d2a7cc50568b8

View File

@ -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"), {

View File

@ -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

View File

@ -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",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

@ -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(() => {

View File

@ -0,0 +1,457 @@
<template>
<Transition name="boot-fade">
<div v-if="visible" class="boot-screen" @click="handleClick">
<!-- Particle starfield -->
<canvas ref="canvasRef" class="boot-stars" />
<!-- Two-column layout: terminal left, orb right -->
<div class="boot-layout" :class="{ 'boot-layout-centered': bootDone }">
<!-- Left: Terminal log (fades out when done) -->
<Transition name="terminal-fade">
<div v-if="!bootDone" class="boot-left">
<div class="boot-terminal" ref="terminalRef">
<p v-for="(line, i) in logLines" :key="i" class="boot-log-line" :class="line.type">
<span class="boot-log-ts">{{ line.prefix }}</span>
<span>{{ line.text }}</span>
</p>
<span class="boot-cursor">_</span>
</div>
<div class="boot-progress-wrap">
<svg class="boot-arc" viewBox="0 0 200 12" preserveAspectRatio="none">
<rect x="0" y="4" width="200" height="4" rx="2" fill="rgba(255,255,255,0.06)" />
<rect x="0" y="4" :width="progress * 2" height="4" rx="2" fill="url(#boot-grad)" />
<defs>
<linearGradient id="boot-grad" x1="0" y1="0" x2="200" y2="0" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#fb923c" />
<stop offset="1" stop-color="#f59e0b" />
</linearGradient>
</defs>
</svg>
<span class="boot-pct">{{ Math.round(progress) }}%</span>
</div>
</div>
</Transition>
<!-- Right (or center when done): The orb / screensaver -->
<div class="boot-right">
<div class="boot-orb" :class="{ 'boot-orb-screensaver': bootDone }">
<!-- Viz ring segments -->
<div class="boot-viz-ring">
<div
v-for="(_, i) in 48"
:key="i"
class="boot-viz-seg"
:style="{ '--si': i, '--sd': `${(i / 48) * 360}deg` }"
:class="{ 'boot-seg-lit': i < litBars }"
/>
</div>
<!-- Center: screensaver-style bordered frame with pixel icon / logo -->
<div class="boot-center-icon">
<div class="logo-gradient-border boot-icon-frame">
<div class="boot-icon-inner">
<Transition name="icon-morph" mode="out-in">
<div v-if="!bootDone" :key="currentIcon" class="boot-pixel-wrap" :class="{ 'boot-glitch': glitching }">
<div v-html="icons[currentIcon]" />
</div>
<div v-else key="logo" class="boot-logo-inner-logo">
<AnimatedLogo size="xl" no-border fit />
</div>
</Transition>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
import AnimatedLogo from '@/components/AnimatedLogo.vue'
const props = defineProps<{ visible: boolean }>()
const emit = defineEmits<{ ready: [] }>()
const canvasRef = ref<HTMLCanvasElement | null>(null)
const terminalRef = ref<HTMLElement | null>(null)
const bootDone = ref(false)
const currentIcon = ref(0)
const progress = ref(0)
const litBars = ref(0)
const glitching = ref(false)
// 16x16 pixel art icons
const icons = [
// Big smiley warm and friendly
`<svg viewBox="0 0 16 16" class="boot-svg">
<rect x="4" y="5" width="2" height="3" fill="white"/>
<rect x="10" y="5" width="2" height="3" fill="white"/>
<rect x="4" y="5" width="2" height="1" fill="rgba(255,255,255,0.5)"/>
<rect x="10" y="5" width="2" height="1" fill="rgba(255,255,255,0.5)"/>
<rect x="3" y="10" width="2" height="1" fill="white"/>
<rect x="11" y="10" width="2" height="1" fill="white"/>
<rect x="5" y="11" width="6" height="1" fill="white"/>
</svg>`,
// Bitcoin
`<svg viewBox="0 0 16 16" class="boot-svg">
<rect x="5" y="1" width="2" height="2" fill="#f7931a"/><rect x="9" y="1" width="2" height="2" fill="#f7931a"/>
<rect x="4" y="3" width="2" height="10" fill="#f7931a"/><rect x="6" y="3" width="4" height="2" fill="#f7931a"/>
<rect x="10" y="4" width="2" height="3" fill="#f7931a"/><rect x="6" y="7" width="4" height="2" fill="#f7931a"/>
<rect x="10" y="8" width="2" height="4" fill="#f7931a"/><rect x="6" y="11" width="4" height="2" fill="#f7931a"/>
<rect x="5" y="13" width="2" height="2" fill="#f7931a"/><rect x="9" y="13" width="2" height="2" fill="#f7931a"/>
</svg>`,
// Lightning
`<svg viewBox="0 0 16 16" class="boot-svg">
<rect x="8" y="0" width="3" height="3" fill="#fbbf24"/><rect x="6" y="3" width="3" height="3" fill="#fbbf24"/>
<rect x="4" y="6" width="8" height="2" fill="#fbbf24"/><rect x="7" y="8" width="3" height="3" fill="#fbbf24"/>
<rect x="5" y="11" width="3" height="3" fill="#fbbf24"/><rect x="3" y="14" width="3" height="2" fill="#fbbf24"/>
</svg>`,
// Shield
`<svg viewBox="0 0 16 16" class="boot-svg">
<rect x="3" y="1" width="10" height="2" fill="#60a5fa"/><rect x="2" y="3" width="2" height="7" fill="#60a5fa"/>
<rect x="12" y="3" width="2" height="7" fill="#60a5fa"/><rect x="4" y="10" width="2" height="2" fill="#60a5fa"/>
<rect x="10" y="10" width="2" height="2" fill="#60a5fa"/><rect x="6" y="12" width="4" height="2" fill="#60a5fa"/>
<rect x="7" y="5" width="2" height="4" fill="white" opacity="0.5"/><rect x="6" y="6" width="4" height="2" fill="white" opacity="0.5"/>
</svg>`,
// Key
`<svg viewBox="0 0 16 16" class="boot-svg">
<circle cx="5" cy="6" r="3" fill="none" stroke="#4ade80" stroke-width="1.5"/>
<rect x="7" y="5" width="7" height="2" fill="#4ade80"/>
<rect x="12" y="7" width="2" height="2" fill="#4ade80"/><rect x="10" y="7" width="2" height="2" fill="#4ade80"/>
</svg>`,
// Mesh nodes
`<svg viewBox="0 0 16 16" class="boot-svg">
<circle cx="8" cy="8" r="2" fill="white"/>
<circle cx="3" cy="3" r="1.5" fill="#a78bfa"/><circle cx="13" cy="3" r="1.5" fill="#a78bfa"/>
<circle cx="3" cy="13" r="1.5" fill="#a78bfa"/><circle cx="13" cy="13" r="1.5" fill="#a78bfa"/>
<line x1="8" y1="8" x2="3" y2="3" stroke="#a78bfa" stroke-width="0.5" opacity="0.5"/>
<line x1="8" y1="8" x2="13" y2="3" stroke="#a78bfa" stroke-width="0.5" opacity="0.5"/>
<line x1="8" y1="8" x2="3" y2="13" stroke="#a78bfa" stroke-width="0.5" opacity="0.5"/>
<line x1="8" y1="8" x2="13" y2="13" stroke="#a78bfa" stroke-width="0.5" opacity="0.5"/>
</svg>`,
]
interface LogLine { prefix: string; text: string; type: string }
const logLines = ref<LogLine[]>([])
const bootMessages = [
{ delay: 500, prefix: 'sys', text: 'Archipelago v0.1.0', type: 'info' },
{ delay: 1500, prefix: 'sec', text: 'Loading ed25519 keys...', type: 'info' },
{ delay: 3000, prefix: ' ok', text: 'Cryptographic keys loaded', type: 'success' },
{ delay: 4500, prefix: 'net', text: 'Binding to port 5678', type: 'info' },
{ delay: 5500, prefix: ' ok', text: 'Nginx proxy detected', type: 'success' },
{ delay: 7000, prefix: ' id', text: 'Initializing identity store...', type: 'info' },
{ delay: 8500, prefix: ' ok', text: 'DID resolver online', type: 'success' },
{ delay: 10000, prefix: 'btc', text: 'Connecting to Bitcoin node...', type: 'info' },
{ delay: 12000, prefix: 'lnd', text: 'Lightning daemon syncing', type: 'info' },
{ delay: 14000, prefix: ' ok', text: 'LND chain synced', type: 'success' },
{ delay: 16000, prefix: 'pod', text: 'Scanning containers...', type: 'info' },
{ delay: 17500, prefix: ' ok', text: '12 containers discovered', type: 'success' },
{ delay: 19000, prefix: 'sec', text: 'AppArmor profiles verified', type: 'success' },
{ delay: 20500, prefix: 'dwn', text: 'DWN node connected', type: 'success' },
{ delay: 22000, prefix: 'msh', text: 'Mesh radio initialized', type: 'success' },
{ delay: 23500, prefix: '***', text: 'ALL SYSTEMS OPERATIONAL', type: 'ready' },
]
// Starfield
let animFrame = 0
const stars: { x: number; y: number; z: number }[] = []
function initStars(c: HTMLCanvasElement) {
for (let i = 0; i < 180; i++) {
stars.push({ x: (Math.random() - 0.5) * c.width * 3, y: (Math.random() - 0.5) * c.height * 3, z: Math.random() * 1500 + 500 })
}
}
function drawStars(c: HTMLCanvasElement, ctx: CanvasRenderingContext2D) {
ctx.fillStyle = '#0a0a0a'
ctx.fillRect(0, 0, c.width, c.height)
const speed = 0.6 + (progress.value / 100) * 2.5
const cx = c.width / 2, cy = c.height / 2
for (const s of stars) {
s.z -= speed
if (s.z <= 0) { s.z = 1500; s.x = (Math.random() - 0.5) * c.width * 3; s.y = (Math.random() - 0.5) * c.height * 3 }
const sx = (s.x / s.z) * 300 + cx, sy = (s.y / s.z) * 300 + cy
if (sx < 0 || sx > c.width || sy < 0 || sy > c.height) continue
const size = Math.max(0.5, (1 - s.z / 1500) * 2)
const alpha = Math.min(1, (1 - s.z / 1500) * 1.2)
ctx.beginPath(); ctx.arc(sx, sy, size, 0, Math.PI * 2)
ctx.fillStyle = `rgba(255,255,255,${alpha * 0.7})`; ctx.fill()
}
animFrame = requestAnimationFrame(() => drawStars(c, ctx))
}
function triggerGlitch() { glitching.value = true; setTimeout(() => { glitching.value = false }, 200) }
function handleClick() {
if (!bootDone.value) return
// Clear intro flag so App.vue's SplashScreen plays the full intro sequence
localStorage.removeItem('neode_intro_seen')
// Also clear onboarding flag so it goes through onboarding after intro
localStorage.removeItem('neode_onboarding_complete')
emit('ready')
}
// Health check
async function checkHealth(): Promise<boolean> {
try {
const ac = new AbortController()
const t = setTimeout(() => ac.abort(), 3000)
const res = await fetch('/rpc/v1', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'server.echo', params: { message: 'boot' } }),
signal: ac.signal,
})
clearTimeout(t)
return res.status !== 502 && res.status !== 503
} catch { return false }
}
let iconInterval: ReturnType<typeof setInterval> | null = null
let pollInterval: ReturnType<typeof setInterval> | null = null
let logTimeouts: ReturnType<typeof setTimeout>[] = []
function startPolling() {
iconInterval = setInterval(() => {
if (!bootDone.value) { currentIcon.value = (currentIcon.value + 1) % icons.length; triggerGlitch() }
}, 2500)
// Feed boot log messages the visual sequence drives the timeline
const lastMsgDelay = bootMessages[bootMessages.length - 1]!.delay
for (const msg of bootMessages) {
logTimeouts.push(setTimeout(() => {
logLines.value.push({ prefix: msg.prefix, text: msg.text, type: msg.type })
if (logLines.value.length > 8) logLines.value.shift()
const idx = bootMessages.indexOf(msg)
progress.value = Math.min(95, ((idx + 1) / bootMessages.length) * 100)
litBars.value = Math.round((progress.value / 100) * 48)
nextTick(() => { if (terminalRef.value) terminalRef.value.scrollTop = terminalRef.value.scrollHeight })
}, msg.delay))
}
// After the last message, start polling for real server readiness
// (visual sequence must complete before we transition)
logTimeouts.push(setTimeout(() => {
// In dev/mock mode the server may already be ready check and complete
const finishBoot = () => {
stopPolling()
progress.value = 100
litBars.value = 48
setTimeout(() => { bootDone.value = true }, 1200)
}
// Check immediately
checkHealth().then(r => {
if (r) { finishBoot(); return }
// Not ready yet poll until it is
pollInterval = setInterval(async () => {
if (await checkHealth()) finishBoot()
}, 2000)
})
}, lastMsgDelay + 1500))
// Reset mock boot timer on fresh page load
fetch('/rpc/v1', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 0, method: 'server.echo', params: { message: 'boot-reset' } }),
}).catch(() => {})
}
function stopPolling() {
if (iconInterval) { clearInterval(iconInterval); iconInterval = null }
if (pollInterval) { clearInterval(pollInterval); pollInterval = null }
for (const t of logTimeouts) clearTimeout(t)
logTimeouts = []
}
function initCanvas() {
const c = canvasRef.value
if (!c) return
c.width = window.innerWidth; c.height = window.innerHeight
const ctx = c.getContext('2d')
if (ctx) { initStars(c); drawStars(c, ctx) }
}
watch(() => props.visible, v => { if (v) { startPolling(); nextTick(initCanvas) } })
onMounted(() => { if (props.visible) { startPolling(); nextTick(initCanvas) } })
onBeforeUnmount(() => { stopPolling(); cancelAnimationFrame(animFrame) })
defineExpose({ startPolling })
</script>
<style scoped>
.boot-screen {
position: fixed; inset: 0; z-index: 9000;
display: flex; align-items: center; justify-content: center;
cursor: default; overflow: hidden;
}
.boot-screen:has(.boot-click-prompt) { cursor: pointer; }
.boot-stars { position: absolute; inset: 0; width: 100%; height: 100%; }
/* Two-column layout */
.boot-layout {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 3rem;
max-width: 900px; width: 90%; padding: 0 1rem;
transition: justify-content 0.8s ease;
}
.boot-layout-centered { justify-content: center; }
/* Left column: terminal */
.boot-left {
flex: 1; min-width: 0; max-width: 400px;
}
.boot-terminal {
max-height: 200px; overflow: hidden;
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
font-size: 11px; line-height: 1.8;
mask-image: linear-gradient(to bottom, transparent 0%, black 15%, black 100%);
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 15%, black 100%);
}
.boot-log-line { white-space: nowrap; overflow: hidden; animation: log-in 0.3s ease both; }
.boot-log-line.info { color: rgba(255,255,255,0.35); }
.boot-log-line.success { color: #4ade80; }
.boot-log-line.ready { color: #fb923c; font-weight: 600; text-shadow: 0 0 10px rgba(251,146,60,0.5); }
.boot-log-ts { color: rgba(255,255,255,0.15); margin-right: 8px; font-weight: 500; }
.boot-log-line.success .boot-log-ts { color: rgba(74,222,128,0.4); }
.boot-log-line.ready .boot-log-ts { color: rgba(251,146,60,0.6); }
@keyframes log-in { from { opacity:0; transform:translateY(6px); } to { opacity:1; transform:translateY(0); } }
.boot-cursor { color: rgba(251,146,60,0.7); animation: blink 1s step-end infinite; font-family: monospace; font-size: 12px; }
@keyframes blink { 50% { opacity: 0; } }
.boot-progress-wrap { display: flex; align-items: center; gap: 10px; margin-top: 12px; }
.boot-arc { flex: 1; height: 12px; }
.boot-pct { font-family: 'SF Mono', monospace; font-size: 10px; color: rgba(255,255,255,0.25); min-width: 28px; text-align: right; }
/* Right column: orb */
.boot-right {
flex-shrink: 0; display: flex; flex-direction: column; align-items: center; gap: 1.5rem;
}
.boot-orb {
position: relative; width: 220px; height: 220px;
transition: width 0.8s ease, height 0.8s ease;
}
@media (min-width: 640px) { .boot-orb { width: 280px; height: 280px; } }
@media (min-width: 768px) { .boot-orb { width: 320px; height: 320px; } }
.boot-orb-screensaver {
width: 280px; height: 280px;
}
@media (min-width: 640px) { .boot-orb-screensaver { width: 360px; height: 360px; } }
@media (min-width: 768px) { .boot-orb-screensaver { width: 400px; height: 400px; } }
/* Viz ring */
.boot-viz-ring { position: absolute; inset: 0; --vr: 100px; }
@media (min-width: 640px) { .boot-viz-ring { --vr: 130px; } }
@media (min-width: 768px) { .boot-viz-ring { --vr: 150px; } }
.boot-orb-screensaver .boot-viz-ring { --vr: 130px; }
@media (min-width: 640px) { .boot-orb-screensaver .boot-viz-ring { --vr: 170px; } }
@media (min-width: 768px) { .boot-orb-screensaver .boot-viz-ring { --vr: 190px; } }
.boot-viz-seg {
position: absolute; left: 50%; top: 50%;
width: 3px; height: 18px; margin-left: -1.5px; margin-top: -9px;
border-radius: 1.5px; transform-origin: center center;
transform: rotate(var(--sd)) translateY(calc(-1 * var(--vr)));
background: rgba(255,255,255,0.05);
transition: background 0.4s ease, height 0.4s ease, box-shadow 0.4s ease;
}
.boot-seg-lit {
background: linear-gradient(to bottom, rgba(251,146,60,0.8), rgba(245,158,11,0.3));
box-shadow: 0 0 5px rgba(251,146,60,0.2);
height: 22px; margin-top: -11px;
}
/* When done, all segments pulse like screensaver */
.boot-orb-screensaver .boot-viz-seg {
background: linear-gradient(to bottom, rgba(255,255,255,0.4), rgba(255,255,255,0.1));
box-shadow: none; height: 24px; margin-top: -12px;
animation: seg-pulse 14s ease-in-out infinite;
animation-delay: calc(var(--si) * 0.02s);
}
@keyframes seg-pulse {
0%,14.3%,28.6%,42.9%,57.1%,71.4%,92.9%,100% { opacity:0.3; transform: rotate(var(--sd)) translateY(calc(-1 * var(--vr))) scaleY(0.4); }
7.1%,21.4%,35.7%,50%,64.3% { opacity:0.9; transform: rotate(var(--sd)) translateY(calc(-1 * var(--vr))) scaleY(1); }
78.6%,85.7% { opacity:1; transform: rotate(var(--sd)) translateY(calc(-1 * var(--vr))) scaleY(1.5); }
}
/* Center icon */
.boot-center-icon {
position: absolute; left: 50%; top: 50%; transform: translate(-50%,-50%); z-index: 10;
filter: drop-shadow(0 0 30px rgba(255,255,255,0.1));
}
.boot-icon-frame {
width: 140px; height: 140px;
display: flex; align-items: center; justify-content: center; overflow: hidden;
}
@media (min-width: 640px) { .boot-icon-frame { width: 180px; height: 180px; } }
@media (min-width: 768px) { .boot-icon-frame { width: 220px; height: 220px; } }
.boot-orb-screensaver .boot-icon-frame {
width: 192px; height: 192px;
}
@media (min-width: 640px) { .boot-orb-screensaver .boot-icon-frame { width: 256px; height: 256px; } }
@media (min-width: 768px) { .boot-orb-screensaver .boot-icon-frame { width: 320px; height: 320px; } }
.boot-icon-inner {
position: absolute; inset: 3px;
display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,0.85); border-radius: inherit;
}
.boot-pixel-wrap { width: 72px; height: 72px; }
@media (min-width: 640px) { .boot-pixel-wrap { width: 90px; height: 90px; } }
.boot-logo-inner-logo { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
:deep(.boot-svg) { width: 100%; height: 100%; image-rendering: pixelated; image-rendering: crisp-edges; }
/* Glitch */
.boot-glitch { animation: glitch 0.2s steps(3) both; }
@keyframes glitch {
0% { transform: translate(0); filter: none; }
25% { transform: translate(2px,-1px); filter: hue-rotate(90deg); }
50% { transform: translate(-2px,1px); filter: hue-rotate(-90deg) brightness(1.4); }
75% { transform: translate(1px,2px); filter: hue-rotate(45deg); }
100% { transform: translate(0); filter: none; }
}
/* Icon morph */
.icon-morph-enter-active { transition: opacity 0.3s ease, transform 0.3s ease, filter 0.3s ease; }
.icon-morph-leave-active { transition: opacity 0.2s ease, transform 0.2s ease, filter 0.2s ease; }
.icon-morph-enter-from { opacity:0; transform: scale(0.5) rotate(-10deg); filter: blur(4px); }
.icon-morph-leave-to { opacity:0; transform: scale(1.4) rotate(10deg); filter: blur(4px); }
/* Click prompt */
.boot-click-prompt {
color: rgba(255,255,255,0.4); font-size: 0.8rem; font-weight: 500;
letter-spacing: 0.1em; text-transform: uppercase;
animation: prompt-breathe 3s ease-in-out infinite;
}
@keyframes prompt-breathe {
0%,100% { opacity: 0.3; } 50% { opacity: 0.7; }
}
.prompt-fade-enter-active { transition: opacity 1s ease 0.5s; }
.prompt-fade-enter-from { opacity: 0; }
/* Terminal fade out */
.terminal-fade-leave-active { transition: opacity 0.8s ease, transform 0.8s ease; }
.terminal-fade-leave-to { opacity: 0; transform: translateX(-30px); }
/* Boot screen fade out */
.boot-fade-leave-active { transition: opacity 1.2s ease; }
.boot-fade-leave-to { opacity: 0; }
/* Mobile: stack vertically */
@media (max-width: 767px) {
.boot-layout { flex-direction: column-reverse; gap: 2rem; }
.boot-left { max-width: 100%; }
.boot-orb { width: 200px; height: 200px; }
.boot-orb-screensaver { width: 260px; height: 260px; }
}
</style>

View File

@ -0,0 +1,131 @@
<template>
<Teleport to="body">
<div v-if="show" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="close" @keydown.escape="close">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true">
<h2 class="text-lg font-bold text-white mb-4">{{ t('web5.receiveBitcoinTitle') }}</h2>
<!-- Method tabs -->
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
<button
v-for="m in (['lightning', 'onchain', 'ecash'] as const)"
:key="m"
@click="receiveMethod = m"
class="flex-1 px-2 py-1.5 rounded text-xs font-medium capitalize transition-colors"
:class="receiveMethod === m ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ m === 'onchain' ? 'On-chain' : m }}</button>
</div>
<!-- Lightning -->
<div v-if="receiveMethod === 'lightning'">
<div class="mb-3">
<label class="text-white/60 text-sm block mb-1">Amount (sats)</label>
<input v-model.number="invoiceAmount" type="number" min="1" placeholder="1000" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
</div>
<div class="mb-3">
<label class="text-white/60 text-sm block mb-1">Memo (optional)</label>
<input v-model="invoiceMemo" type="text" placeholder="Payment for..." class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
</div>
<div v-if="invoiceResult" class="mb-3 p-2 bg-white/5 rounded-lg">
<p class="text-white/50 text-xs mb-1">Invoice (share with sender):</p>
<p class="text-xs font-mono text-white/80 break-all">{{ invoiceResult }}</p>
<button @click="copyText(invoiceResult)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">Copy</button>
</div>
</div>
<!-- On-chain -->
<div v-if="receiveMethod === 'onchain'">
<div v-if="onchainAddress" class="mb-3 p-3 bg-white/5 rounded-lg text-center">
<p class="text-white/50 text-xs mb-2">Your Bitcoin address:</p>
<p class="text-sm font-mono text-white/90 break-all">{{ onchainAddress }}</p>
<button @click="copyText(onchainAddress)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">Copy</button>
</div>
<div v-else class="mb-3 text-center">
<p class="text-white/50 text-sm mb-2">{{ t('web5.generateFreshAddress') }}</p>
</div>
</div>
<!-- Ecash -->
<div v-if="receiveMethod === 'ecash'">
<div class="mb-3">
<label class="text-white/60 text-sm block mb-1">Paste ecash token</label>
<textarea v-model="ecashToken" rows="3" placeholder="cashuSend_..." class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30"></textarea>
</div>
<div v-if="ecashResult" class="mb-3 text-xs text-green-400">{{ ecashResult }}</div>
</div>
<div v-if="error" class="mb-3 text-xs text-red-400">{{ error }}</div>
<div class="flex gap-3">
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
<button @click="receive" :disabled="processing" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-green-500/20 border-green-500/30 disabled:opacity-50">
{{ processing ? 'Processing...' : receiveMethod === 'onchain' ? 'Generate Address' : receiveMethod === 'lightning' ? 'Create Invoice' : 'Receive' }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
const { t } = useI18n()
defineProps<{ show: boolean }>()
const emit = defineEmits<{ close: []; received: [] }>()
const receiveMethod = ref<'lightning' | 'onchain' | 'ecash'>('lightning')
const invoiceAmount = ref<number>(0)
const invoiceMemo = ref('')
const invoiceResult = ref('')
const onchainAddress = ref('')
const ecashToken = ref('')
const ecashResult = ref('')
const processing = ref(false)
const error = ref('')
function close() {
invoiceResult.value = ''
onchainAddress.value = ''
ecashToken.value = ''
ecashResult.value = ''
error.value = ''
emit('close')
}
function copyText(text: string) {
navigator.clipboard.writeText(text).catch(() => {})
}
async function receive() {
processing.value = true
error.value = ''
try {
if (receiveMethod.value === 'lightning') {
if (!invoiceAmount.value) { error.value = 'Enter an amount'; return }
const res = await rpcClient.call<{ payment_request: string }>({
method: 'lnd.addinvoice',
params: { amount_sats: invoiceAmount.value, memo: invoiceMemo.value || undefined },
})
invoiceResult.value = res.payment_request
} else if (receiveMethod.value === 'onchain') {
const res = await rpcClient.call<{ address: string }>({ method: 'lnd.newaddress' })
onchainAddress.value = res.address
} else {
if (!ecashToken.value.trim()) { error.value = 'Paste an ecash token'; return }
await rpcClient.call<{ amount_sats: number }>({
method: 'wallet.ecash-receive',
params: { token: ecashToken.value.trim() },
})
ecashResult.value = 'Token received successfully!'
emit('received')
}
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed'
} finally {
processing.value = false
}
}
</script>

View File

@ -0,0 +1,138 @@
<template>
<Teleport to="body">
<div v-if="show" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="close" @keydown.escape="close">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true">
<h2 class="text-lg font-bold text-white mb-4">{{ t('web5.sendBitcoinTitle') }}</h2>
<!-- Method tabs -->
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
<button
v-for="m in (['auto', 'lightning', 'onchain', 'ecash'] as const)"
:key="m"
@click="sendMethod = m"
class="flex-1 px-2 py-1.5 rounded text-xs font-medium capitalize transition-colors"
:class="sendMethod === m ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ m === 'onchain' ? 'On-chain' : m }}</button>
</div>
<div v-if="sendMethod === 'auto'" class="mb-3 p-2 bg-white/5 rounded-lg">
<p class="text-xs text-white/50">Auto-selects method based on amount: ecash &lt; 1k sats, Lightning 1k500k, on-chain &gt; 500k</p>
</div>
<div class="mb-3">
<label class="text-white/60 text-sm block mb-1">Amount (sats)</label>
<input v-model.number="amount" type="number" min="1" placeholder="1000" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
</div>
<div v-if="effectiveMethod !== 'ecash'" class="mb-3">
<label class="text-white/60 text-sm block mb-1">
{{ effectiveMethod === 'lightning' ? 'Lightning Invoice (BOLT11)' : 'Bitcoin Address' }}
</label>
<textarea v-model="dest" rows="2" :placeholder="effectiveMethod === 'lightning' ? 'lnbc...' : 'bc1...'" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-white/30"></textarea>
</div>
<div v-if="ecashToken && effectiveMethod === 'ecash'" class="mb-3 p-2 bg-white/5 rounded-lg">
<p class="text-white/50 text-xs mb-1">Token (share with recipient):</p>
<p class="text-xs font-mono text-white/80 break-all">{{ ecashToken }}</p>
<button @click="copyText(ecashToken)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">Copy</button>
</div>
<div v-if="resultTxid" class="mb-3 p-2 bg-green-500/10 border border-green-500/20 rounded-lg">
<p class="text-green-400 text-xs">Sent! TX: {{ resultTxid }}</p>
</div>
<div v-if="resultHash" class="mb-3 p-2 bg-green-500/10 border border-green-500/20 rounded-lg">
<p class="text-green-400 text-xs">Paid! Hash: {{ resultHash }}</p>
</div>
<div v-if="error" class="mb-3 text-xs text-red-400">{{ error }}</div>
<div class="flex gap-3">
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
<button @click="send" :disabled="processing || !amount" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-orange-500/20 border-orange-500/30 disabled:opacity-50">
{{ processing ? 'Sending...' : 'Send' }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
const { t } = useI18n()
const props = defineProps<{ show: boolean }>()
const emit = defineEmits<{ close: []; sent: [] }>()
const sendMethod = ref<'auto' | 'lightning' | 'onchain' | 'ecash'>('auto')
const amount = ref<number>(0)
const dest = ref('')
const processing = ref(false)
const error = ref('')
const resultTxid = ref('')
const resultHash = ref('')
const ecashToken = ref('')
const effectiveMethod = computed(() => {
if (sendMethod.value !== 'auto') return sendMethod.value
const amt = amount.value || 0
if (amt <= 0) return 'lightning'
if (amt < 1000) return 'ecash'
if (amt > 500000) return 'onchain'
return 'lightning'
})
function close() {
error.value = ''
resultTxid.value = ''
resultHash.value = ''
ecashToken.value = ''
emit('close')
}
function copyText(text: string) {
navigator.clipboard.writeText(text).catch(() => {})
}
async function send() {
if (!amount.value || processing.value) return
processing.value = true
error.value = ''
ecashToken.value = ''
resultTxid.value = ''
resultHash.value = ''
const method = effectiveMethod.value
try {
if (method === 'ecash') {
const res = await rpcClient.call<{ token: string }>({
method: 'wallet.ecash-send',
params: { amount_sats: amount.value },
})
ecashToken.value = res.token
} else if (method === 'lightning') {
if (!dest.value.trim()) { error.value = t('web5.pasteInvoice'); return }
const res = await rpcClient.call<{ payment_hash: string }>({
method: 'lnd.payinvoice',
params: { payment_request: dest.value.trim() },
})
resultHash.value = res.payment_hash
} else {
if (!dest.value.trim()) { error.value = t('web5.enterBitcoinAddress'); return }
const res = await rpcClient.call<{ txid: string }>({
method: 'lnd.sendcoins',
params: { addr: dest.value.trim(), amount: amount.value },
})
resultTxid.value = res.txid
}
emit('sent')
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : t('web5.sendFailed')
} finally {
processing.value = false
}
}
</script>

View File

@ -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,
}
})

View File

@ -333,7 +333,6 @@ const HTTPS_PROXY_PATHS: Record<string, string> = {
'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]

View File

@ -1,19 +1,20 @@
<template>
<div class="pb-6">
<!-- Desktop: tabs + search in one row -->
<div class="hidden md:flex items-center gap-4 mb-4">
<!-- Desktop: page tabs + category tabs + search -->
<div class="hidden md:flex mb-4 items-center gap-4">
<div class="mode-switcher flex-shrink-0">
<button
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': activeTab === 'apps' }"
@click="activeTab = 'apps'"
>My Apps</button>
<button class="mode-switcher-btn" :class="{ 'mode-switcher-btn-active': activeTab === 'apps' }" @click="activeTab = 'apps'; router.replace({ query: {} })">My Apps</button>
<RouterLink to="/dashboard/marketplace" class="mode-switcher-btn">App Store</RouterLink>
<button class="mode-switcher-btn" :class="{ 'mode-switcher-btn-active': activeTab === 'services' }" @click="activeTab = 'services'; router.replace({ query: { tab: 'services' } })">Services</button>
</div>
<div v-if="activeTab === 'apps' && categoriesWithApps.length > 1" class="mode-switcher flex-shrink-0">
<button
v-for="category in categoriesWithApps"
:key="category.id"
@click="selectedCategory = category.id"
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': activeTab === 'services' }"
@click="activeTab = 'services'"
>Services</button>
:class="{ 'mode-switcher-btn-active': selectedCategory === category.id }"
>{{ category.name }}</button>
</div>
<input
v-model="searchQuery"
@ -24,21 +25,8 @@
/>
</div>
<!-- Mobile: tabs + search -->
<!-- Mobile: search only (tabs handled by Dashboard.vue header) -->
<div class="md:hidden mb-4">
<div class="mode-switcher mode-switcher-full mb-3">
<button
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': activeTab === 'apps' }"
@click="activeTab = 'apps'"
>My Apps</button>
<RouterLink to="/dashboard/marketplace" class="mode-switcher-btn">App Store</RouterLink>
<button
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': activeTab === 'services' }"
@click="activeTab = 'services'"
>Services</button>
</div>
<input
v-model="searchQuery"
type="text"
@ -253,6 +241,8 @@
</div>
</Transition>
<!-- Action error toast -->
<Transition name="fade">
<div v-if="actionError" class="fixed bottom-20 left-1/2 -translate-x-1/2 z-50 max-w-md w-full px-4" role="alert" aria-live="assertive">
@ -267,7 +257,7 @@
<script setup lang="ts">
import { computed, ref, onBeforeUnmount } from 'vue'
import { useRouter, RouterLink } from 'vue-router'
import { useRouter, useRoute, RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '../stores/app'
@ -277,33 +267,88 @@ import { PackageState, type PackageDataEntry } from '../types/api'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
const router = useRouter()
const route = useRoute()
const store = useAppStore()
// Tabs
const activeTab = ref<'apps' | 'services'>('apps')
// Tabs support ?tab=services from Marketplace link
const activeTab = ref<'apps' | 'services'>(
route.query.tab === 'services' ? 'services' : 'apps'
)
// Service container name patterns (backend/infra, not user-facing)
// Exact container names or prefixes that are backend services (not user-facing)
const SERVICE_NAMES = new Set([
'archy-mempool-db', 'archy-btcpay-db', 'archy-nbxplorer', 'archy-tor',
'immich_postgres', 'immich_redis',
'penpot-postgres', 'penpot-valkey', 'penpot-backend', 'penpot-exporter',
'indeedhub-postgres', 'indeedhub-redis', 'indeedhub-minio',
'indeedhub-relay', 'indeedhub-build_api_1', 'indeedhub-build_ffmpeg-worker_1',
'indeedhub-build_postgres_1', 'indeedhub-build_redis_1', 'indeedhub-build_minio_1',
'indeedhub-build_minio-init_1', 'indeedhub-build_relay_1',
'mysql-mempool',
])
function isServiceContainer(id: string): boolean {
if (SERVICE_NAMES.has(id)) return true
const lower = id.toLowerCase()
return lower.includes('_db') || lower.includes('-db') && !lower.includes('indeedhub')
? SERVICE_NAMES.has(id)
: false
// Catch any indeedhub-build_* compose infrastructure containers
if (id.startsWith('indeedhub-build_')) return true
// Catch database containers
if (id.endsWith('_db') || id.endsWith('-db')) return true
return false
}
// Search
const searchQuery = ref('')
// Category filter (same categories as App Store)
const selectedCategory = ref('all')
// Known app category mappings (matches App Store categorisation)
const APP_CATEGORY_MAP: Record<string, string> = {
'bitcoin-knots': 'money', 'bitcoin-ui': 'money', 'electrumx': 'money', 'electrs': 'money',
'lnd': 'money', 'mempool': 'money', 'mempool-web': 'money', 'btcpay-server': 'commerce',
'fedimint': 'money', 'fedimint-gateway': 'money',
'indeedhub': 'media', 'jellyfin': 'media', 'photoprism': 'media', 'immich': 'media',
'nextcloud': 'data', 'vaultwarden': 'data', 'filebrowser': 'data', 'onlyoffice': 'data',
'homeassistant': 'home', 'lorabell': 'home', 'endurain': 'home',
'searxng': 'community', 'ollama': 'community', 'grafana': 'data',
'nostr-rs-relay': 'nostr', 'nostrudel': 'nostr',
'tailscale': 'networking', 'nginx-proxy-manager': 'networking', 'portainer': 'networking',
'uptime-kuma': 'networking', 'dwn': 'data',
'botfights': 'l484', 'nwnn': 'l484', '484-kitchen': 'l484',
'call-the-operator': 'l484', 'syntropy-institute': 'l484', 't-zero': 'l484',
}
function getAppCategory(id: string, pkg: PackageDataEntry): string {
// Check hardcoded map first, then manifest category, then fallback
if (APP_CATEGORY_MAP[id]) return APP_CATEGORY_MAP[id]
const cat = (pkg.manifest as unknown as Record<string, unknown>)?.category as string | undefined
return cat || 'other'
}
const ALL_CATEGORIES = computed(() => [
{ id: 'all', name: t('marketplace.all') },
{ id: 'community', name: t('marketplace.community') },
{ id: 'nostr', name: 'Nostr' },
{ id: 'commerce', name: t('marketplace.commerce') },
{ id: 'money', name: t('marketplace.money') },
{ id: 'data', name: t('marketplace.data') },
{ id: 'media', name: 'Media' },
{ id: 'home', name: t('marketplace.homeCategory') },
{ id: 'networking', name: t('marketplace.networking') },
{ id: 'l484', name: 'L484' },
{ id: 'other', name: t('marketplace.other') },
])
const categoriesWithApps = computed(() => {
const entries = Object.entries(packages.value).filter(([id]) => !isServiceContainer(id))
return ALL_CATEGORIES.value.filter(cat => {
if (cat.id === 'all') return true
return entries.some(([id, pkg]) => getAppCategory(id, pkg) === cat.id)
})
})
// Track loading states for each app action
const loadingActions = ref<Record<string, boolean>>({})
@ -381,10 +426,14 @@ const packages = computed(() => {
// Web-only apps first (alphabetically), then all other apps (alphabetically)
const sortedPackageEntries = computed(() => {
const entries = Object.entries(packages.value)
// Filter by active tab
const filtered = entries.filter(([id]) => {
// Filter by active tab and category
const filtered = entries.filter(([id, pkg]) => {
const isSvc = isServiceContainer(id)
return activeTab.value === 'services' ? isSvc : !isSvc
if (activeTab.value === 'services' ? !isSvc : isSvc) return false
if (activeTab.value === 'apps' && selectedCategory.value !== 'all') {
return getAppCategory(id, pkg) === selectedCategory.value
}
return true
})
return filtered.sort(([idA, a], [idB, b]) => {
const aWeb = isWebOnlyApp(idA) ? 0 : 1

View File

@ -189,38 +189,22 @@
:class="{ 'glass-throw-mobile-tabs': showZoomIn }"
style="background: rgba(0, 0, 0, 0.25); backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); transform: translateZ(0);"
>
<div class="glass-card p-2 rounded-lg flex gap-2 relative">
<!-- Animated Active Indicator -->
<div
class="absolute top-2 bottom-2 rounded-lg bg-white/20 transition-all duration-300 ease-out"
:style="{
left: `${appsTabIndicatorLeft}px`,
width: `${appsTabIndicatorWidth}px`,
}"
></div>
<div class="mode-switcher mode-switcher-full">
<RouterLink
ref="appsTabRef"
to="/dashboard/apps"
class="flex-1 px-4 py-2 rounded-lg font-medium transition-all duration-300 text-center relative z-10"
:class="{
'bg-white/20 text-white': route.path === '/dashboard/apps' || route.path.startsWith('/dashboard/apps/'),
'text-white/60 hover:text-white/80': !(route.path === '/dashboard/apps' || route.path.startsWith('/dashboard/apps/'))
}"
>
My Apps
</RouterLink>
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': (route.path === '/dashboard/apps' || route.path.startsWith('/dashboard/apps/')) && route.query.tab !== 'services' }"
>My Apps</RouterLink>
<RouterLink
ref="marketplaceTabRef"
to="/dashboard/marketplace"
class="flex-1 px-4 py-2 rounded-lg font-medium transition-all duration-300 text-center relative z-10"
:class="{
'bg-white/20 text-white': route.path === '/dashboard/marketplace' || route.path.startsWith('/dashboard/marketplace/'),
'text-white/60 hover:text-white/80': !(route.path === '/dashboard/marketplace' || route.path.startsWith('/dashboard/marketplace/'))
}"
>
App Store
</RouterLink>
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': route.path === '/dashboard/marketplace' || route.path.startsWith('/dashboard/marketplace/') }"
>App Store</RouterLink>
<RouterLink
to="/dashboard/apps?tab=services"
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': route.query.tab === 'services' }"
>Services</RouterLink>
</div>
</div>
@ -232,38 +216,27 @@
style="background: rgba(0, 0, 0, 0.25); backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); transform: translateZ(0);"
:style="{ top: showAppsTabs ? '80px' : '0' }"
>
<div class="glass-card p-2 rounded-lg flex gap-2 relative">
<!-- Animated Active Indicator -->
<div
class="absolute top-2 bottom-2 rounded-lg bg-white/20 transition-all duration-300 ease-out"
:style="{
left: `${networkTabIndicatorLeft}px`,
width: `${networkTabIndicatorWidth}px`,
}"
></div>
<div class="mode-switcher mode-switcher-full">
<RouterLink
to="/dashboard/web5"
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': route.path === '/dashboard/web5' || route.path.startsWith('/dashboard/web5/') }"
>Web5</RouterLink>
<RouterLink
ref="cloudTabRef"
to="/dashboard/cloud"
class="flex-1 px-4 py-2 rounded-lg font-medium transition-all duration-300 text-center relative z-10"
:class="{
'bg-white/20 text-white': route.path === '/dashboard/cloud' || route.path.startsWith('/dashboard/cloud/'),
'text-white/60 hover:text-white/80': !(route.path === '/dashboard/cloud' || route.path.startsWith('/dashboard/cloud/'))
}"
>
Cloud
</RouterLink>
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': route.path === '/dashboard/cloud' || route.path.startsWith('/dashboard/cloud/') }"
>Cloud</RouterLink>
<RouterLink
ref="serverTabRef"
to="/dashboard/server"
class="flex-1 px-4 py-2 rounded-lg font-medium transition-all duration-300 text-center relative z-10"
:class="{
'bg-white/20 text-white': route.path === '/dashboard/server' || route.path.startsWith('/dashboard/server/'),
'text-white/60 hover:text-white/80': !(route.path === '/dashboard/server' || route.path.startsWith('/dashboard/server/'))
}"
>
Network
</RouterLink>
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': route.path === '/dashboard/server' || route.path.startsWith('/dashboard/server/') }"
>Network</RouterLink>
<RouterLink
to="/dashboard/mesh"
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': route.path === '/dashboard/mesh' || route.path.startsWith('/dashboard/mesh/') }"
>Mesh</RouterLink>
</div>
</div>
@ -273,7 +246,7 @@
<Transition :name="getTransitionName(route)">
<div :key="route.path" class="view-wrapper">
<div
v-if="route.path === '/dashboard/chat'"
v-if="route.path === '/dashboard/chat' || route.path === '/dashboard/mesh'"
class="h-full"
>
<component :is="Component" />
@ -524,7 +497,7 @@ const showNetworkTabs = computed(() => {
if (typeof window === 'undefined') return false
if (window.innerWidth >= 768) return false
if (route.name === 'cloud-folder') return false
return route.path.includes('/server') || route.path.includes('/cloud')
return route.path.includes('/server') || route.path.includes('/cloud') || route.path.includes('/web5') || route.path.includes('/mesh')
})
// Top padding for content div to clear fixed mobile tab overlays
@ -700,7 +673,7 @@ const desktopNavItems = computed(() => {
const gamerMobileNav: NavItem[] = [
{ path: '/dashboard', label: 'Home', icon: 'home' },
{ path: '/dashboard/apps', label: 'Apps', icon: 'apps', isCombined: true },
{ path: '/dashboard/cloud', label: 'Network', icon: 'server', isCombined: true },
{ path: '/dashboard/web5', label: 'Web5', icon: 'web5', isCombined: true },
{ path: '/dashboard/settings', label: 'Settings', icon: 'settings' },
]
@ -754,6 +727,7 @@ async function handleLogout() {
// Track previous route for transition logic
let previousPath = ''
let previousTab = ''
// Tab order for vertical transitions
const tabOrder = [
@ -809,38 +783,38 @@ function getTransitionName(currentRoute: RouteLocationNormalizedLoaded) {
let transitionName = 'fade'
// Mobile: Horizontal slide transitions between Apps and Marketplace (check first on mobile)
// Mobile: Horizontal slide transitions between sub-tabs
if (typeof window !== 'undefined' && window.innerWidth < 768) {
// From Marketplace to Apps: slide right
if (wasMarketplaceList && isAppsList) {
transitionName = 'slide-right'
// Apps group: My Apps (0) App Store (1) Services (2)
const isServices = currentPath === '/dashboard/apps' && currentRoute.query.tab === 'services'
const wasServices = previousTab === 'services'
const currentAppsIdx = isServices ? 2
: currentPath === '/dashboard/marketplace' ? 1
: currentPath === '/dashboard/apps' ? 0 : -1
const prevAppsIdx = wasServices ? 2
: previousPath === '/dashboard/marketplace' ? 1
: previousPath === '/dashboard/apps' ? 0 : -1
// Web5 group: Web5 (0) Cloud (1) Network (2) Mesh (3)
const web5TabOrder = ['/dashboard/web5', '/dashboard/cloud', '/dashboard/server', '/dashboard/mesh']
const currentWeb5Idx = web5TabOrder.indexOf(currentPath)
const prevWeb5Idx = web5TabOrder.indexOf(previousPath)
// Apps sub-tab transitions
if (currentAppsIdx !== -1 && prevAppsIdx !== -1 && currentAppsIdx !== prevAppsIdx) {
transitionName = currentAppsIdx > prevAppsIdx ? 'slide-left' : 'slide-right'
}
// From Apps to Marketplace: slide left
else if (wasAppsList && isMarketplaceList) {
transitionName = 'slide-left'
// Web5 sub-tab transitions
else if (currentWeb5Idx !== -1 && prevWeb5Idx !== -1 && currentWeb5Idx !== prevWeb5Idx) {
transitionName = currentWeb5Idx > prevWeb5Idx ? 'slide-left' : 'slide-right'
}
// From Network to Cloud: slide right
else if (previousPath === '/dashboard/server' && isCloudList) {
transitionName = 'slide-right'
}
// From Cloud to Network: slide left
else if (wasCloudList && currentPath === '/dashboard/server') {
transitionName = 'slide-left'
}
// Vertical transition: between main tabs (mobile fallback)
// Vertical transition: between main bottom nav tabs
else {
const currentIndex = tabOrder.indexOf(currentPath)
const previousIndex = tabOrder.indexOf(previousPath)
if (currentIndex !== -1 && previousIndex !== -1 && currentIndex !== previousIndex) {
// Moving down the menu (visual down)
if (currentIndex > previousIndex) {
transitionName = 'slide-down'
}
// Moving up the menu (visual up)
else {
transitionName = 'slide-up'
}
transitionName = currentIndex > previousIndex ? 'slide-down' : 'slide-up'
}
}
}
@ -892,6 +866,7 @@ function getTransitionName(currentRoute: RouteLocationNormalizedLoaded) {
// Update previous path for next transition
previousPath = currentPath
previousTab = (currentRoute.query.tab as string) || ''
return transitionName
}

View File

@ -226,7 +226,7 @@
</div>
</div>
<!-- Web5 Overview -->
<!-- Wallet Overview -->
<div
data-controller-container
tabindex="0"
@ -237,42 +237,123 @@
<div class="home-card-shell">
<div class="home-card-inner p-6 flex flex-col h-full min-h-0">
<div class="home-card-header flex items-start justify-between mb-4 shrink-0">
<div class="home-card-text">
<h2 class="text-xl font-semibold text-white mb-1">{{ t('home.web5') }}</h2>
<p class="text-sm text-white/70">{{ t('home.web5Desc') }}</p>
<div class="home-card-text flex items-start gap-4">
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</div>
<div>
<h2 class="text-xl font-semibold text-white mb-1">{{ t('web5.wallet') }}</h2>
<p class="text-sm text-white/70">{{ walletConnected ? t('common.connected') : t('common.disconnected') }}</p>
</div>
</div>
<div class="flex items-center gap-2">
<!-- Incoming Transactions Badge -->
<button
v-if="incomingTxCount > 0"
@click="showIncomingTxPanel = !showIncomingTxPanel"
class="incoming-tx-badge shrink-0"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
<span>Incoming {{ incomingTxCount }}</span>
<span class="incoming-tx-ping"></span>
</button>
<RouterLink to="/dashboard/web5" :aria-label="t('home.goToWeb5')" class="text-white/60 hover:text-white transition-colors">
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</RouterLink>
</div>
<RouterLink to="/dashboard/web5" :aria-label="t('home.goToWeb5')" class="text-white/60 hover:text-white transition-colors">
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</RouterLink>
</div>
<!-- Incoming Transactions Panel -->
<transition name="incoming-tx-slide">
<div v-if="showIncomingTxPanel && incomingTransactions.length > 0" class="mb-4 rounded-xl overflow-hidden border border-green-500/20">
<div class="px-4 py-2.5 bg-green-500/10 border-b border-green-500/15 flex items-center justify-between">
<span class="text-xs font-medium text-green-400 uppercase tracking-wide">Incoming Transactions</span>
<button @click="showIncomingTxPanel = false" class="text-white/40 hover:text-white/70 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<div class="divide-y divide-white/5">
<div
v-for="tx in incomingTransactions"
:key="tx.tx_hash"
class="flex items-center justify-between gap-3 px-4 py-3 hover:bg-white/5 cursor-pointer transition-colors"
@click="openInMempool(tx.tx_hash)"
>
<div class="flex items-center gap-3 min-w-0 flex-1">
<div
class="w-7 h-7 rounded-full flex items-center justify-center shrink-0"
:class="tx.num_confirmations === 0 ? 'bg-yellow-500/15' : 'bg-green-500/15'"
>
<svg class="w-3.5 h-3.5" :class="tx.num_confirmations === 0 ? 'text-yellow-400' : 'text-green-400'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-green-400">+{{ tx.amount_sats.toLocaleString() }} sats</span>
<span
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium"
:class="tx.num_confirmations === 0 ? 'bg-yellow-500/15 text-yellow-400' : 'bg-green-500/15 text-green-400'"
>
{{ tx.num_confirmations === 0 ? 'Unconfirmed' : tx.num_confirmations + ' conf' }}
</span>
</div>
<p class="text-[11px] text-white/40 font-mono truncate mt-0.5">{{ tx.tx_hash }}</p>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<span class="text-[11px] text-white/40">{{ formatTxTime(tx.time_stamp) }}</span>
<svg class="w-3.5 h-3.5 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</div>
</div>
</div>
</div>
</transition>
<div class="home-card-stats space-y-3 mb-4 flex-1 min-h-0">
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<div class="w-2 h-2 rounded-full" :class="web5DidStatus === 'Active' ? 'bg-green-400' : 'bg-white/30'"></div>
<span class="text-sm text-white/80">{{ t('home.didStatus') }}</span>
<span class="text-lg text-orange-500 font-bold"></span>
<span class="text-sm text-white/80">{{ t('web5.onChain') }}</span>
</div>
<span class="text-sm font-medium" :class="web5DidStatus === 'Active' ? 'text-green-400' : 'text-white/50'">{{ web5DidStatus }}</span>
<span class="text-orange-500 text-sm font-medium">{{ walletOnchain.toLocaleString() }} sats</span>
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<div class="w-2 h-2 rounded-full" :class="web5DwnStatus === 'Synced' ? 'bg-green-400' : 'bg-white/30'"></div>
<span class="text-sm text-white/80">{{ t('home.dwnSync') }}</span>
<svg class="w-5 h-5 text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span class="text-sm text-white/80">{{ t('web5.lightning') }}</span>
</div>
<span class="text-sm font-medium" :class="web5DwnStatus === 'Synced' ? 'text-green-400' : 'text-white/50'">{{ web5DwnStatus }}</span>
<span class="text-yellow-400 text-sm font-medium">{{ walletLightning.toLocaleString() }} sats</span>
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<div class="w-2 h-2 rounded-full bg-white/30"></div>
<span class="text-sm text-white/80">{{ t('home.credentials') }}</span>
<svg class="w-5 h-5 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-sm text-white/80">{{ t('web5.ecash') }}</span>
</div>
<span class="text-sm text-white/50 font-medium">{{ web5CredentialCount }}</span>
<span class="text-purple-400 text-sm font-medium">{{ walletEcash.toLocaleString() }} sats</span>
</div>
</div>
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
<RouterLink to="/dashboard/web5" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
{{ t('home.manageWeb5') }}
<div class="home-card-buttons grid grid-cols-3 gap-2 mt-auto pt-4 shrink-0">
<button @click="showSendModal = true" class="home-card-btn px-3 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
{{ t('common.send') }}
</button>
<button @click="showReceiveModal = true" class="home-card-btn px-3 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
{{ t('web5.receiveBitcoin') }}
</button>
<RouterLink to="/dashboard/web5" class="home-card-btn px-3 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
Web5
</RouterLink>
</div>
</div>
@ -392,12 +473,18 @@
</RouterLink>
</div>
</div>
<!-- Send/Receive Bitcoin Modals -->
<SendBitcoinModal :show="showSendModal" @close="showSendModal = false" @sent="loadWeb5Status()" />
<ReceiveBitcoinModal :show="showReceiveModal" @close="showReceiveModal = false" @received="loadWeb5Status()" />
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch, onBeforeUnmount, onMounted } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import SendBitcoinModal from '@/components/SendBitcoinModal.vue'
import ReceiveBitcoinModal from '@/components/ReceiveBitcoinModal.vue'
import { useAppStore } from '../stores/app'
const { t } = useI18n()
@ -628,29 +715,83 @@ onMounted(async () => {
loadWeb5Status()
})
// Web5 status (fetched from RPC instead of hardcoded)
const web5DidStatus = ref('--')
const web5DwnStatus = ref('--')
const web5CredentialCount = ref('--')
// Send/Receive modals
const showSendModal = ref(false)
const showReceiveModal = ref(false)
// Wallet balances and transactions (fetched from RPC)
const walletConnected = ref(false)
const walletOnchain = ref(0)
const walletLightning = ref(0)
const walletEcash = ref(0)
const showIncomingTxPanel = ref(false)
interface WalletTransaction {
tx_hash: string
amount_sats: number
direction: 'incoming' | 'outgoing'
num_confirmations: number
time_stamp: number
total_fees: number
dest_addresses: string[]
label: string
block_height: number
}
const walletTransactions = ref<WalletTransaction[]>([])
const incomingTransactions = computed(() =>
walletTransactions.value.filter(tx => tx.direction === 'incoming' && tx.num_confirmations < 3)
)
const incomingTxCount = computed(() => incomingTransactions.value.length)
function formatTxTime(timestamp: number): string {
if (!timestamp) return ''
const date = new Date(timestamp * 1000)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMin = Math.floor(diffMs / 60000)
if (diffMin < 1) return 'Just now'
if (diffMin < 60) return `${diffMin}m ago`
const diffHours = Math.floor(diffMin / 60)
if (diffHours < 24) return `${diffHours}h ago`
const diffDays = Math.floor(diffHours / 24)
if (diffDays < 7) return `${diffDays}d ago`
return date.toLocaleDateString()
}
function openInMempool(txHash: string) {
router.push({ name: 'app-session', params: { appId: 'mempool' }, query: { path: `/tx/${txHash}` } })
}
async function loadWeb5Status() {
try {
const identity = await rpcClient.call<{ did: string }>({ method: 'identity.get', timeout: 5000 })
web5DidStatus.value = identity.did ? 'Active' : 'Inactive'
const res = await rpcClient.call<{
balance_sats: number
channel_balance_sats: number
}>({ method: 'lnd.getinfo', timeout: 5000 })
walletOnchain.value = res.balance_sats || 0
walletLightning.value = res.channel_balance_sats || 0
walletConnected.value = true
} catch {
web5DidStatus.value = '--'
walletConnected.value = false
walletOnchain.value = 0
walletLightning.value = 0
}
try {
const dwn = await rpcClient.call<{ status: string }>({ method: 'dwn.health', timeout: 5000 })
web5DwnStatus.value = dwn.status === 'ok' ? 'Synced' : dwn.status || '--'
const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance', timeout: 5000 })
walletEcash.value = res.balance_sats ?? 0
} catch {
web5DwnStatus.value = '--'
walletEcash.value = 0
}
try {
const creds = await rpcClient.call<{ credentials: unknown[] }>({ method: 'identity.list-credentials', timeout: 5000 })
web5CredentialCount.value = String(creds.credentials?.length ?? 0)
const res = await rpcClient.call<{ transactions: WalletTransaction[]; incoming_pending_count: number }>({ method: 'lnd.gettransactions', timeout: 5000 })
walletTransactions.value = res.transactions || []
const pending = res.incoming_pending_count || 0
if (pending > 0 && !showIncomingTxPanel.value) {
showIncomingTxPanel.value = true
}
} catch {
web5CredentialCount.value = '0'
walletTransactions.value = []
}
}

View File

@ -79,6 +79,7 @@
<div class="mode-switcher flex-shrink-0">
<RouterLink to="/dashboard/apps" class="mode-switcher-btn">My Apps</RouterLink>
<RouterLink to="/dashboard/marketplace" class="mode-switcher-btn mode-switcher-btn-active">App Store</RouterLink>
<RouterLink to="/dashboard/apps?tab=services" class="mode-switcher-btn">Services</RouterLink>
</div>
<div class="mode-switcher flex-shrink-0">
<button
@ -101,7 +102,7 @@
/>
</div>
<!-- Mobile: Search only (categories handled by floating filter modal) -->
<!-- Mobile: search (tabs handled by Dashboard.vue header) -->
<div class="md:hidden mb-4">
<input
v-model="searchQuery"

View File

@ -35,6 +35,8 @@ const relayingLn = ref(false)
const relayResult = ref('')
const meshSendAddr = ref('')
const meshSendAmount = ref('')
const relayMode = ref<'archy' | 'broadcast'>('archy')
const sendTab = ref<'onchain' | 'lightning'>('onchain')
const deadmanConfiguring = ref(false)
const deadmanInterval = ref('21600')
const deadmanEnabled = ref(false)
@ -72,11 +74,15 @@ async function handleMeshSendBitcoin() {
params: { addr: meshSendAddr.value.trim(), amount_sats: parseInt(meshSendAmount.value) },
})
// Step 2: Relay via mesh
relayResult.value = 'Sending via mesh radio...'
const relayRes = await mesh.relayTransaction(rawRes.raw_tx_hex)
relayResult.value = `Sent via mesh! Request #${relayRes.request_id} — waiting for broadcast confirmation from peers`
relayResult.value = relayMode.value === 'broadcast'
? 'Broadcasting via mesh network...'
: 'Sending to Archy peers (encrypted)...'
const relayRes = await mesh.relayTransaction(rawRes.raw_tx_hex, relayMode.value)
relayResult.value = `Sent via mesh! Request #${relayRes.request_id} — waiting for relay peer to broadcast...`
meshSendAddr.value = ''
meshSendAmount.value = ''
// Step 3: Poll for relay result (every 3s for 90s)
pollRelayStatus(relayRes.request_id)
} catch (err: unknown) {
relayResult.value = err instanceof Error ? err.message : 'Send failed'
} finally {
@ -84,6 +90,30 @@ async function handleMeshSendBitcoin() {
}
}
function pollRelayStatus(requestId: number) {
let attempts = 0
const maxAttempts = 30
const interval = setInterval(async () => {
attempts++
try {
const res = await mesh.relayStatus(requestId)
if (res.status === 'confirmed' && res.txid) {
relayResult.value = `TX broadcast! txid: ${res.txid.slice(0, 8)}...${res.txid.slice(-8)}`
clearInterval(interval)
} else if (res.status === 'failed') {
const code = res.error_code ? ` [${res.error_code}]` : ''
relayResult.value = `Relay failed${code}: ${res.error || 'unknown error'}`
clearInterval(interval)
} else if (attempts >= maxAttempts) {
relayResult.value += ' (timed out waiting for confirmation)'
clearInterval(interval)
}
} catch {
if (attempts >= maxAttempts) clearInterval(interval)
}
}, 3000)
}
async function handleRelayTx() {
if (!txHexInput.value.trim()) return
relayingTx.value = true
@ -221,6 +251,7 @@ function openChat(peer: MeshPeer) {
activeChatChannel.value = null
sendError.value = ''
messageText.value = ''
activeTab.value = 'chat'
mesh.markChatRead(peer.contact_id)
nextTick(() => scrollChatToBottom())
}
@ -230,6 +261,7 @@ function openChannelChat(channel: { index: number; name: string }) {
activeChatPeer.value = null
sendError.value = ''
messageText.value = ''
activeTab.value = 'chat'
nextTick(() => scrollChatToBottom())
}
@ -468,6 +500,11 @@ function truncatePubkey(hex: string | null): string {
<h3 class="mesh-panel-title">Off-Grid Bitcoin</h3>
<p class="mesh-panel-sub">Relay transactions and receive block headers via mesh radio</p>
<!-- Relay status notification -->
<div v-if="relayResult" class="mesh-relay-result" :class="relayResult.includes('failed') || relayResult.includes('Failed') ? 'error' : 'success'">
{{ relayResult }}
</div>
<!-- Block Headers -->
<div class="mesh-bitcoin-section">
<div class="mesh-bitcoin-section-header">
@ -483,46 +520,61 @@ function truncatePubkey(hex: string | null): string {
</div>
</div>
<!-- Send Bitcoin (creates TX + auto-relays) -->
<div class="mesh-bitcoin-section">
<div class="mesh-bitcoin-section-header">
<span class="mesh-bitcoin-label">Send Bitcoin (Off-Grid)</span>
</div>
<!-- On-Chain / Lightning tabs -->
<div class="mesh-send-tabs">
<button class="mesh-send-tab" :class="{ active: sendTab === 'onchain' }" @click="sendTab = 'onchain'">On-Chain</button>
<button class="mesh-send-tab" :class="{ active: sendTab === 'lightning' }" @click="sendTab = 'lightning'">Lightning</button>
</div>
<!-- On-Chain tab -->
<div v-if="sendTab === 'onchain'" class="mesh-bitcoin-section">
<p class="mesh-bitcoin-hint">Creates a signed transaction locally and relays via mesh peers</p>
<input v-model="meshSendAddr" class="mesh-bitcoin-input" placeholder="Bitcoin address (bc1...)" />
<input v-model="meshSendAmount" class="mesh-bitcoin-input mesh-bitcoin-input-sm" type="number" placeholder="Amount (sats)" min="546" />
<div class="mesh-relay-mode">
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'archy' }">
<input type="radio" v-model="relayMode" value="archy" />
<span>Archy Peers <small>(E2E encrypted, direct)</small></span>
</label>
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'broadcast' }">
<input type="radio" v-model="relayMode" value="broadcast" />
<span>Mesh Broadcast <small>(multi-hop, wider reach)</small></span>
</label>
</div>
<button class="glass-button" :disabled="!meshSendAddr.trim() || !meshSendAmount || relayingTx" @click="handleMeshSendBitcoin">
{{ relayingTx ? 'Sending...' : 'Send Bitcoin via Mesh' }}
{{ relayingTx ? 'Sending...' : 'Send via Mesh' }}
</button>
<details class="mesh-bitcoin-advanced">
<summary class="mesh-bitcoin-label">Raw TX Relay</summary>
<div style="margin-top: 8px;">
<textarea v-model="txHexInput" class="mesh-bitcoin-input" placeholder="Paste raw transaction hex..." rows="3" />
<button class="glass-button" :disabled="!txHexInput.trim() || relayingTx" @click="handleRelayTx">
{{ relayingTx ? 'Relaying...' : 'Relay Raw TX' }}
</button>
</div>
</details>
</div>
<!-- Send Lightning (auto-relays invoice) -->
<div class="mesh-bitcoin-section">
<div class="mesh-bitcoin-section-header">
<span class="mesh-bitcoin-label">Pay Lightning Invoice (Off-Grid)</span>
</div>
<!-- Lightning tab -->
<div v-if="sendTab === 'lightning'" class="mesh-bitcoin-section">
<p class="mesh-bitcoin-hint">Relays a Lightning invoice to an internet-connected peer for payment</p>
<input v-model="bolt11Input" class="mesh-bitcoin-input" placeholder="lnbc... (bolt11 invoice)" />
<input v-model="bolt11AmountInput" class="mesh-bitcoin-input mesh-bitcoin-input-sm" type="number" placeholder="Amount (sats)" />
<div class="mesh-relay-mode">
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'archy' }">
<input type="radio" v-model="relayMode" value="archy" />
<span>Archy Peers <small>(E2E encrypted, direct)</small></span>
</label>
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'broadcast' }">
<input type="radio" v-model="relayMode" value="broadcast" />
<span>Mesh Broadcast <small>(multi-hop, wider reach)</small></span>
</label>
</div>
<button class="glass-button" :disabled="!bolt11Input.trim() || !bolt11AmountInput || relayingLn" @click="handleRelayLightning">
{{ relayingLn ? 'Relaying...' : 'Pay via Mesh' }}
</button>
</div>
<!-- Advanced: Raw TX Relay -->
<details class="mesh-bitcoin-advanced">
<summary class="mesh-bitcoin-label">Advanced: Raw TX Relay</summary>
<div class="mesh-bitcoin-section" style="margin-top: 8px;">
<textarea v-model="txHexInput" class="mesh-bitcoin-input" placeholder="Paste raw transaction hex..." rows="3" />
<button class="glass-button" :disabled="!txHexInput.trim() || relayingTx" @click="handleRelayTx">
{{ relayingTx ? 'Relaying...' : 'Relay Raw TX' }}
</button>
</div>
</details>
<div v-if="relayResult" class="mesh-relay-result" :class="relayResult.includes('failed') ? 'error' : 'success'">
{{ relayResult }}
</div>
</div>
<!-- Dead Man's Switch Panel -->
@ -790,7 +842,7 @@ function truncatePubkey(hex: string | null): string {
flex-direction: column;
gap: 12px;
min-height: 0;
overflow: hidden;
overflow-y: auto;
}
.mesh-right {
@ -800,6 +852,7 @@ function truncatePubkey(hex: string | null): string {
display: flex;
flex-direction: column;
gap: 12px;
overflow: hidden;
}
/* ─── Status card ─── */
@ -1493,6 +1546,59 @@ function truncatePubkey(hex: string | null): string {
.mesh-bitcoin-input::placeholder { color: rgba(255,255,255,0.3); }
.mesh-bitcoin-input:focus { outline: none; border-color: rgba(251,146,60,0.4); }
.mesh-bitcoin-input-sm { max-width: 200px; }
.mesh-relay-mode {
display: flex;
gap: 8px;
margin: 8px 0;
}
.mesh-relay-mode-option {
flex: 1;
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid rgba(255,255,255,0.1);
background: rgba(0,0,0,0.2);
cursor: pointer;
font-size: 0.78rem;
color: rgba(255,255,255,0.6);
transition: all 0.2s ease;
}
.mesh-relay-mode-option:hover { border-color: rgba(255,255,255,0.2); }
.mesh-relay-mode-option.active {
border-color: rgba(251,146,60,0.4);
background: rgba(251,146,60,0.08);
color: rgba(255,255,255,0.9);
}
.mesh-relay-mode-option input[type="radio"] { display: none; }
.mesh-relay-mode-icon { font-size: 1rem; }
.mesh-relay-mode-option small { display: block; font-size: 0.65rem; color: rgba(255,255,255,0.4); }
.mesh-send-tabs {
display: flex;
gap: 0;
margin-bottom: 12px;
border-radius: 8px;
overflow: hidden;
border: 1px solid rgba(255,255,255,0.1);
}
.mesh-send-tab {
flex: 1;
padding: 8px 16px;
background: rgba(0,0,0,0.2);
color: rgba(255,255,255,0.5);
border: none;
cursor: pointer;
font-size: 0.82rem;
font-weight: 600;
transition: all 0.2s ease;
}
.mesh-send-tab:first-child { border-right: 1px solid rgba(255,255,255,0.1); }
.mesh-send-tab:hover { color: rgba(255,255,255,0.7); background: rgba(255,255,255,0.03); }
.mesh-send-tab.active {
color: #fb923c;
background: rgba(251,146,60,0.08);
}
.mesh-block-list { display: flex; flex-direction: column; gap: 4px; }
.mesh-block-row {
display: flex;

View File

@ -1,29 +1,51 @@
<template>
<div class="min-h-full flex items-center justify-center">
<div class="flex flex-col items-center gap-4 opacity-0 root-redirect-fade">
<svg class="animate-spin h-8 w-8 text-white/60" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<div class="min-h-full">
<BootScreen :visible="showBootScreen" @ready="onServerReady" />
<div v-if="!showBootScreen" class="min-h-full flex items-center justify-center">
<div class="flex flex-col items-center gap-4 opacity-0 root-redirect-fade">
<svg class="animate-spin h-8 w-8 text-white/60" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { isOnboardingComplete } from '@/composables/useOnboarding'
import BootScreen from '@/components/BootScreen.vue'
const router = useRouter()
const route = useRoute()
const showBootScreen = ref(false)
onMounted(async () => {
async function quickHealthCheck(): Promise<boolean> {
try {
const ac = new AbortController()
const t = setTimeout(() => ac.abort(), 2000)
const res = await fetch('/rpc/v1', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'server.echo', params: { message: 'ping' } }),
signal: ac.signal,
})
clearTimeout(t)
return res.status !== 502 && res.status !== 503
} catch {
return false
}
}
async function proceedToApp() {
const devMode = import.meta.env.VITE_DEV_MODE
if (devMode === 'setup' || devMode === 'existing') {
router.replace('/login').catch(() => {})
return
}
// Check localStorage first for instant redirect (avoids 5s timeout)
const localComplete = localStorage.getItem('neode_onboarding_complete') === '1'
if (localComplete) {
router.replace('/login').catch(() => {})
@ -41,11 +63,51 @@ onMounted(async () => {
seenOnboarding = false
}
router.replace(seenOnboarding ? '/login' : '/onboarding/intro').catch(() => {})
}
function onServerReady() {
// Clear flags so splash intro plays on reload
localStorage.removeItem('neode_intro_seen')
localStorage.removeItem('neode_onboarding_complete')
// Reload with ?intro=1 so we know to skip boot and let App.vue handle splash
window.location.href = '/?intro=1'
}
onMounted(async () => {
const devMode = import.meta.env.VITE_DEV_MODE
// Coming back from boot screen do nothing, let App.vue's SplashScreen take over
if (route.query.intro === '1') {
// Clean the URL without navigating
window.history.replaceState({}, '', '/')
return
}
// Standard dev modes
if (devMode === 'setup' || devMode === 'existing') {
proceedToApp()
return
}
// Boot dev mode always show boot screen
if (devMode === 'boot') {
showBootScreen.value = true
return
}
// Production: quick health check
const isUp = await quickHealthCheck()
if (isUp) {
proceedToApp()
return
}
// Server not ready show boot screen
showBootScreen.value = true
})
</script>
<style scoped>
/* Only show spinner after 500ms delay — most redirects happen instantly */
.root-redirect-fade {
animation: root-fade-in 0.3s ease 0.5s forwards;
}

View File

@ -332,30 +332,51 @@
<h2 class="text-xl font-semibold text-white mb-2">{{ t('web5.wallet') }}</h2>
<p class="text-white/70 text-sm mb-4">{{ t('web5.walletSubtitle') }}</p>
</div>
<!-- Incoming Transactions Badge -->
<!-- Transaction Activity Badge -->
<button
v-if="incomingTxCount > 0"
v-if="txActivityCount > 0"
@click="showIncomingTxPanel = !showIncomingTxPanel"
class="incoming-tx-badge shrink-0"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
</svg>
<span>Incoming {{ incomingTxCount }}</span>
<span v-if="incomingTxCount > 0">Incoming {{ incomingTxCount }}</span>
<span v-if="meshRelayActive" class="ml-1">Mesh TX</span>
<span class="incoming-tx-ping"></span>
</button>
</div>
<!-- Incoming Transactions Panel -->
<!-- Transaction Activity Panel -->
<transition name="incoming-tx-slide">
<div v-if="showIncomingTxPanel && incomingTransactions.length > 0" class="mb-4 rounded-xl overflow-hidden border border-green-500/20">
<div v-if="showIncomingTxPanel && (incomingTransactions.length > 0 || meshRelayActive)" class="mb-4 rounded-xl overflow-hidden border border-green-500/20">
<div class="px-4 py-2.5 bg-green-500/10 border-b border-green-500/15 flex items-center justify-between">
<span class="text-xs font-medium text-green-400 uppercase tracking-wide">Incoming Transactions</span>
<span class="text-xs font-medium text-green-400 uppercase tracking-wide">Transactions</span>
<button @click="showIncomingTxPanel = false" class="text-white/40 hover:text-white/70 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<div class="divide-y divide-white/5">
<!-- Mesh Relay TX (outgoing via mesh) -->
<div v-if="meshRelayActive" class="incoming-tx-row">
<div class="flex items-center gap-3 min-w-0 flex-1">
<div class="incoming-tx-icon" style="background: rgba(251,146,60,0.15);">
<svg class="w-3.5 h-3.5 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18" />
</svg>
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-orange-400">Mesh Relay</span>
<span class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-orange-500/15 text-orange-400">
{{ sendResultTxid ? 'Broadcast' : 'Sending...' }}
</span>
</div>
<p class="text-[11px] text-white/40 mt-0.5">{{ meshRelayStatus }}</p>
</div>
</div>
</div>
<!-- Incoming TXs -->
<div
v-for="tx in incomingTransactions"
:key="tx.tx_hash"
@ -2540,6 +2561,7 @@ const incomingTransactions = computed(() =>
walletTransactions.value.filter(tx => tx.direction === 'incoming' && tx.num_confirmations < 3)
)
const incomingTxCount = computed(() => incomingTransactions.value.length)
const txActivityCount = computed(() => incomingTxCount.value + (meshRelayActive.value ? 1 : 0))
async function loadTransactions() {
try {

View File

@ -96,8 +96,52 @@ section_end() {
echo " (${elapsed}s)"
}
# ── Progress bar ──────────────────────────────────────────────
CURRENT_STEP=0
BAR_WIDTH=30
calculate_total_steps() {
local total=4 # SSH, prereqs, health, git state
if [[ "$QUICK" == "true" ]]; then
total=$((total + 1)) # sync only
echo $total; return
fi
total=$((total + 1)) # sync code
total=$((total + 1)) # frontend build
if [[ "$FRONTEND_ONLY" != "true" ]]; then
total=$((total + 1)) # backend build
fi
if [[ "$LIVE" == "true" ]]; then
total=$((total + 14)) # rollback, frontend, AIUI, nginx, systemd, claude proxy, dev mode, data dirs, nostr-provider, filebrowser, manifest, restart, HTTPS, health check
if [[ "$FRONTEND_ONLY" != "true" ]]; then
total=$((total + 1)) # deploy backend binary
total=$((total + 16)) # container rebuilds
fi
total=$((total + 3)) # UFW, IndeedHub fix, container doctor
fi
echo $total
}
TOTAL_STEPS=$(calculate_total_steps)
progress() {
CURRENT_STEP=$((CURRENT_STEP + 1))
local pct=$((CURRENT_STEP * 100 / TOTAL_STEPS))
local filled=$((pct * BAR_WIDTH / 100))
local empty=$((BAR_WIDTH - filled))
local bar
bar=$(printf '%*s' "$filled" '' | tr ' ' '█')$(printf '%*s' "$empty" '' | tr ' ' '░')
printf "\033[1;36m━━━ [%s] %3d%% (%d/%d)\033[0m %s\n" "$bar" "$pct" "$CURRENT_STEP" "$TOTAL_STEPS" "$1"
}
# ─────────────────────────────────────────────────────────────
# SSH connectivity pre-flight check
echo "$(timestamp) Checking SSH connectivity..."
progress "Checking SSH connectivity"
if ! ssh $SSH_OPTS -o ConnectTimeout=5 "$TARGET_HOST" "echo ok" >/dev/null 2>&1; then
echo " ERROR: Cannot connect to $TARGET_HOST"
echo " Check that the server is on and reachable."
@ -106,7 +150,7 @@ fi
echo " Connected."
# Install prerequisites if missing (rsync for code sync, python3 for Claude API proxy)
echo "$(timestamp) Checking prerequisites..."
progress "Checking prerequisites"
ssh $SSH_OPTS "$TARGET_HOST" '
NEED_INSTALL=""
command -v rsync >/dev/null 2>&1 || NEED_INSTALL="$NEED_INSTALL rsync"
@ -125,6 +169,7 @@ ssh $SSH_OPTS "$TARGET_HOST" '
' 2>&1
# Pre-deploy health check (informational — warns but does not block)
progress "Pre-deploy health check"
TARGET_IP_ONLY="$(echo "$TARGET_HOST" | cut -d@ -f2)"
PRE_HEALTH=$(curl -s -o /dev/null -w '%{http_code}' --connect-timeout 5 "http://$TARGET_IP_ONLY/health" 2>/dev/null || echo "000")
if [ "$PRE_HEALTH" = "200" ]; then
@ -134,6 +179,30 @@ else
fi
echo ""
# Git state check — detect uncommitted changes and record deploy version
progress "Checking git state"
DEPLOY_COMMIT=$(git -C "$PROJECT_DIR" rev-parse --short HEAD 2>/dev/null || echo "unknown")
DEPLOY_COMMIT_FULL=$(git -C "$PROJECT_DIR" rev-parse HEAD 2>/dev/null || echo "unknown")
DEPLOY_BRANCH=$(git -C "$PROJECT_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
DIRTY_FILES=$(git -C "$PROJECT_DIR" status --porcelain 2>/dev/null | grep -v '^??' | grep -v '\.claude/memory/' || true)
DEPLOY_DIRTY=false
echo "$(timestamp) Git state: $DEPLOY_BRANCH @ $DEPLOY_COMMIT"
if [ -n "$DIRTY_FILES" ]; then
DEPLOY_DIRTY=true
DIRTY_COUNT=$(echo "$DIRTY_FILES" | wc -l | tr -d ' ')
echo " ⚠️ WARNING: $DIRTY_COUNT uncommitted change(s) — deploying working directory, NOT last commit"
echo "$DIRTY_FILES" | head -10 | sed 's/^/ /'
[ "$DIRTY_COUNT" -gt 10 ] && echo " ... and $((DIRTY_COUNT - 10)) more"
echo ""
echo " To deploy clean: commit or stash changes first"
echo " Continuing in 3 seconds... (Ctrl+C to abort)"
sleep 3
else
echo " Working tree clean — deploying commit $DEPLOY_COMMIT"
fi
echo ""
# When --canary: deploy to 198 first, verify health, then deploy to 228
if [ "$CANARY" = true ]; then
echo "🐤 Canary deploy: .198 first, then .228 if healthy..."
@ -264,8 +333,26 @@ if [ "$BOTH" = true ]; then
fi
' 2>/dev/null || true
# Write deploy manifest to .198
DEPLOY_TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
ssh $SSH_OPTS "$TARGET_198" "sudo tee /opt/archipelago/deploy-manifest.json > /dev/null" << MANIFEST_198_EOF
{
"commit": "$DEPLOY_COMMIT_FULL",
"commit_short": "$DEPLOY_COMMIT",
"branch": "$DEPLOY_BRANCH",
"dirty": $DEPLOY_DIRTY,
"deployed_at": "$DEPLOY_TS",
"deployed_from": "$(hostname)",
"target": "$TARGET_198"
}
MANIFEST_198_EOF
ssh $SSH_OPTS "$TARGET_198" "sudo systemctl start archipelago && sudo systemctl restart nginx"
# Run container doctor on .198
echo " Running container doctor on .198..."
"$SCRIPT_DIR/container-doctor.sh" "$TARGET_198" 2>&1 | sed 's/^/ /' || true
# Post-deploy health check on .198
echo " Checking .198 health..."
HEALTH_198="fail"
@ -286,7 +373,7 @@ fi
# Sync code
section_start
echo "$(timestamp) 📦 Syncing code..."
progress "Syncing code"
rsync -avz --delete \
-e "ssh $SSH_OPTS" \
--exclude 'node_modules' \
@ -306,20 +393,17 @@ fi
# Build on target
echo ""
echo "$(timestamp) 🔨 Building on target..."
# Frontend
progress "Building frontend"
section_start
echo "$(timestamp) Building frontend (vue-tsc + vite)..."
ssh $SSH_OPTS "$TARGET_HOST" "cd $TARGET_DIR/neode-ui && npm install --silent && npm run build" 2>&1 | sed 's/^/ /'
section_end
# Backend (if Rust is installed) — skip with --frontend-only
if [ "$FRONTEND_ONLY" = true ]; then
echo "$(timestamp) Skipping backend build (--frontend-only)"
echo " Skipping backend build (--frontend-only)"
elif ssh $SSH_OPTS "$TARGET_HOST" "source ~/.cargo/env 2>/dev/null && command -v cargo" >/dev/null 2>&1; then
progress "Building backend (Rust release)"
section_start
echo "$(timestamp) Building backend (Rust release — this takes 1-2 min)..."
ssh $SSH_OPTS "$TARGET_HOST" "source ~/.cargo/env && cd $TARGET_DIR/core && cargo build --release 2>&1" | sed 's/^/ /'
section_end
else
@ -327,11 +411,9 @@ else
fi
if [ "$LIVE" = true ]; then
echo ""
echo "$(timestamp) 🚀 Deploying to live system..."
# Create rollback backup before deploying
echo "$(timestamp) Creating rollback backup..."
progress "Creating rollback backup"
ssh $SSH_OPTS "$TARGET_HOST" '
sudo mkdir -p /opt/archipelago/rollback
[ -f /usr/local/bin/archipelago ] && sudo cp /usr/local/bin/archipelago /opt/archipelago/rollback/archipelago.bak 2>/dev/null || true
@ -340,20 +422,21 @@ if [ "$LIVE" = true ]; then
# Deploy backend (check if binary exists) — skip with --frontend-only
if [ "$FRONTEND_ONLY" = true ]; then
echo "$(timestamp) Skipping backend deploy (--frontend-only)"
echo " Skipping backend deploy (--frontend-only)"
elif ssh $SSH_OPTS "$TARGET_HOST" "[ -f $TARGET_DIR/core/target/release/archipelago ]" 2>/dev/null; then
echo "$(timestamp) Deploying backend binary..."
progress "Deploying backend binary"
ssh $SSH_OPTS "$TARGET_HOST" "sudo systemctl stop archipelago"
ssh $SSH_OPTS "$TARGET_HOST" "sudo cp $TARGET_DIR/core/target/release/archipelago /usr/local/bin/"
fi
# Deploy frontend (preserve aiui/ and claude-login.html — they are NOT part of the neode-ui build)
echo "$(timestamp) Deploying frontend..."
progress "Deploying frontend"
ssh $SSH_OPTS "$TARGET_HOST" "sudo find /opt/archipelago/web-ui -mindepth 1 -maxdepth 1 ! -name 'aiui' ! -name 'claude-login.html' -exec rm -rf {} +"
ssh $SSH_OPTS "$TARGET_HOST" "sudo cp -rf $TARGET_DIR/web/dist/neode-ui/* /opt/archipelago/web-ui/"
ssh $SSH_OPTS "$TARGET_HOST" "sudo chown -R 1000:1000 /opt/archipelago/web-ui"
# Build and deploy AIUI (non-fatal — never delete existing AIUI on failure)
progress "Building & deploying AIUI"
AIUI_DIR="$PROJECT_DIR/../AIUI"
AIUI_DIST="$AIUI_DIR/packages/app/dist"
# Auto-build AIUI if dist is missing or older than source
@ -378,10 +461,10 @@ if [ "$LIVE" = true ]; then
fi
# Sync nginx config from image-recipe (single source of truth)
progress "Syncing nginx configuration"
NGINX_CFG="$PROJECT_DIR/image-recipe/configs/nginx-archipelago.conf"
SNIPPETS_DIR="$PROJECT_DIR/image-recipe/configs/snippets"
if [ -f "$NGINX_CFG" ]; then
echo "$(timestamp) Syncing nginx config..."
scp $SSH_OPTS "$NGINX_CFG" "$TARGET_HOST:/tmp/nginx-archipelago.conf" 2>/dev/null || true
ssh $SSH_OPTS "$TARGET_HOST" '
sudo cp /tmp/nginx-archipelago.conf /etc/nginx/sites-available/archipelago
@ -391,7 +474,6 @@ if [ "$LIVE" = true ]; then
# Sync nginx snippet files (HTTPS app proxies, PWA headers — included by main config)
if [ -d "$SNIPPETS_DIR" ]; then
echo "$(timestamp) Syncing nginx snippets..."
ssh $SSH_OPTS "$TARGET_HOST" "sudo mkdir -p /etc/nginx/snippets" 2>/dev/null || true
for f in "$SNIPPETS_DIR"/*.conf; do
[ -f "$f" ] && scp $SSH_OPTS "$f" "$TARGET_HOST:/tmp/nginx-snippet-$(basename $f)" 2>/dev/null || true
@ -413,9 +495,9 @@ if [ "$LIVE" = true ]; then
ssh $SSH_OPTS "$TARGET_HOST" 'sudo nginx -t 2>&1 && echo " nginx config OK" || echo " ⚠️ nginx config test failed"' 2>/dev/null || true
# Sync systemd service file (single source of truth: image-recipe/configs/)
progress "Syncing systemd service"
SERVICE_FILE="$PROJECT_DIR/image-recipe/configs/archipelago.service"
if [ -f "$SERVICE_FILE" ]; then
echo "$(timestamp) Syncing systemd service file..."
scp $SSH_OPTS "$SERVICE_FILE" "$TARGET_HOST:/tmp/archipelago.service" 2>/dev/null || true
ssh $SSH_OPTS "$TARGET_HOST" '
if ! diff -q /tmp/archipelago.service /etc/systemd/system/archipelago.service >/dev/null 2>&1; then
@ -430,7 +512,7 @@ if [ "$LIVE" = true ]; then
fi
# Deploy Claude API proxy (auto-install if missing)
echo "$(timestamp) Setting up Claude API proxy..."
progress "Setting up Claude API proxy"
ssh $SSH_OPTS "$TARGET_HOST" '
echo " Updating Claude API proxy on port 3142..."
# Check for API key in existing service or setup-aiui-server.sh
@ -510,7 +592,7 @@ PYEOF
' 2>/dev/null || true
# Dev mode for Tailscale HTTP access (cookies need Secure flag disabled over plain HTTP)
echo "$(timestamp) Checking dev mode..."
progress "Configuring dev mode"
ssh $SSH_OPTS "$TARGET_HOST" '
if [ -f /etc/systemd/system/archipelago.service.d/override.conf ] && grep -q "ARCHIPELAGO_DEV_MODE=true" /etc/systemd/system/archipelago.service.d/override.conf 2>/dev/null; then
echo " Dev mode already enabled"
@ -524,7 +606,7 @@ PYEOF
' 2>/dev/null || true
# Create data directories for DWN, content sharing, federation, identities
echo "$(timestamp) Ensuring data directories for DWN, content, federation..."
progress "Creating data directories"
ssh $SSH_OPTS "$TARGET_HOST" '
sudo mkdir -p /var/lib/archipelago/dwn/messages
sudo mkdir -p /var/lib/archipelago/dwn/protocols
@ -537,12 +619,11 @@ PYEOF
' 2>/dev/null || true
# Deploy nostr-provider.js for NIP-07 iframe signing (window.nostr support)
echo "$(timestamp) Deploying nostr-provider.js..."
progress "Deploying nostr-provider.js"
scp $SSH_OPTS "$PROJECT_DIR/neode-ui/public/nostr-provider.js" "$TARGET_HOST:/tmp/nostr-provider.js" 2>/dev/null && \
ssh $SSH_OPTS "$TARGET_HOST" 'sudo cp /tmp/nostr-provider.js /opt/archipelago/web-ui/nostr-provider.js && echo " nostr-provider.js deployed"' 2>/dev/null || echo " (nostr-provider.js not found, skipping)"
# Sync nginx config (includes all app proxies, NIP-07 sub_filter, AIUI proxy, external URL proxies)
echo "$(timestamp) Syncing nginx config..."
# Sync nginx config (second pass — includes HTTPS snippets)
scp $SSH_OPTS "$PROJECT_DIR/image-recipe/configs/nginx-archipelago.conf" "$TARGET_HOST:/tmp/nginx-archipelago.conf" 2>/dev/null && \
ssh $SSH_OPTS "$TARGET_HOST" '
sudo cp /tmp/nginx-archipelago.conf /etc/nginx/sites-available/archipelago
@ -557,7 +638,7 @@ PYEOF
fi
# Fix FileBrowser — recreate if read-only root, create if missing
echo "$(timestamp) Checking FileBrowser..."
progress "Checking FileBrowser"
ssh $SSH_OPTS "$TARGET_HOST" '
DOCKER=podman
command -v podman >/dev/null 2>&1 || DOCKER=docker
@ -582,19 +663,34 @@ PYEOF
fi
' 2>/dev/null || true
# Write deploy manifest — stamps the server with exactly what was deployed
progress "Writing deploy manifest"
DEPLOY_TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
ssh $SSH_OPTS "$TARGET_HOST" "sudo tee /opt/archipelago/deploy-manifest.json > /dev/null" << MANIFEST_EOF
{
"commit": "$DEPLOY_COMMIT_FULL",
"commit_short": "$DEPLOY_COMMIT",
"branch": "$DEPLOY_BRANCH",
"dirty": $DEPLOY_DIRTY,
"deployed_at": "$DEPLOY_TS",
"deployed_from": "$(hostname)",
"target": "$TARGET_HOST"
}
MANIFEST_EOF
# Restart services
echo "$(timestamp) Restarting services..."
progress "Restarting services"
ssh $SSH_OPTS "$TARGET_HOST" "sudo systemctl start archipelago && sudo systemctl restart nginx"
# Set up HTTPS for PWA installability (browsers require secure context)
echo "$(timestamp) Setting up HTTPS for PWA install..."
progress "Setting up HTTPS"
ssh $SSH_OPTS "$TARGET_HOST" "sudo bash $TARGET_DIR/scripts/setup-https-dev.sh" 2>&1 | sed 's/^/ /' || true
if [ "$FRONTEND_ONLY" = true ]; then
echo "$(timestamp) Skipping container rebuilds (--frontend-only)"
echo " Skipping container rebuilds (--frontend-only)"
else
# Rebuild and recreate LND UI container (port 8081 so Launch from UI and http://host:8081 both work)
echo "$(timestamp) Rebuilding LND UI..."
progress "Rebuilding LND UI"
if ssh $SSH_OPTS "$TARGET_HOST" "cd $TARGET_DIR/docker/lnd-ui && (command -v podman >/dev/null 2>&1 && sudo podman build --no-cache -t lnd-ui:latest . || sudo docker build --no-cache -t lnd-ui:latest .)" 2>&1 | tail -12 | sed 's/^/ /'; then
echo " Recreating LND UI container (port 8081)..."
ssh $SSH_OPTS "$TARGET_HOST" '
@ -608,7 +704,7 @@ PYEOF
fi
# Rebuild and recreate ElectrumX UI container (port 50002)
echo "$(timestamp) Rebuilding ElectrumX UI..."
progress "Rebuilding ElectrumX UI"
if ssh $SSH_OPTS "$TARGET_HOST" "cd $TARGET_DIR/docker/electrs-ui && (command -v podman >/dev/null 2>&1 && sudo podman build --no-cache -t electrs-ui:latest . || sudo docker build --no-cache -t electrs-ui:latest .)" 2>&1 | tail -12 | sed 's/^/ /'; then
echo " Recreating ElectrumX UI container (port 50002, host network)..."
ssh $SSH_OPTS "$TARGET_HOST" '
@ -623,7 +719,7 @@ PYEOF
# Rebuild and recreate Bitcoin UI container (host network, port 8334 in nginx.conf)
# Host network required: bitcoin-ui proxies Bitcoin RPC at 127.0.0.1:8332
echo "$(timestamp) Rebuilding Bitcoin UI..."
progress "Rebuilding Bitcoin UI"
if ssh $SSH_OPTS "$TARGET_HOST" "cd $TARGET_DIR/docker/bitcoin-ui && (command -v podman >/dev/null 2>&1 && sudo podman build --no-cache -t bitcoin-ui:latest . || sudo docker build --no-cache -t bitcoin-ui:latest .)" 2>&1 | tail -12 | sed 's/^/ /'; then
echo " Recreating Bitcoin UI container (port 8334, host network)..."
ssh $SSH_OPTS "$TARGET_HOST" '
@ -638,7 +734,7 @@ PYEOF
# Bitcoin Knots: required for Mempool, ElectrumX, BTCPay, Fedimint
TARGET_IP="$(echo "$TARGET_HOST" | cut -d@ -f2)"
echo "$(timestamp) Ensuring Bitcoin Knots..."
progress "Ensuring Bitcoin Knots"
ssh $SSH_OPTS "$TARGET_HOST" "
DOCKER=podman
command -v podman >/dev/null 2>&1 || DOCKER=docker
@ -672,7 +768,7 @@ PYEOF
" 2>&1 | sed 's/^/ /' || true
# Fix Mempool: clean duplicates, ensure full stack - mysql, backend (8999), frontend (4080)
echo "$(timestamp) Fixing Mempool stack..."
progress "Fixing Mempool stack"
ssh $SSH_OPTS "$TARGET_HOST" "
DOCKER=podman
command -v podman >/dev/null 2>&1 || DOCKER=docker
@ -770,7 +866,7 @@ PYEOF
" 2>&1 | sed 's/^/ /' || true
# Fix BTCPay Server: requires PostgreSQL + NBXplorer (BTCPay needs NBXplorer for block indexing)
echo "$(timestamp) Fixing BTCPay Server stack..."
progress "Fixing BTCPay stack"
TARGET_IP="$(echo "$TARGET_HOST" | cut -d@ -f2)"
ssh $SSH_OPTS "$TARGET_HOST" "
DOCKER=podman
@ -847,7 +943,7 @@ PYEOF
" 2>&1 | sed 's/^/ /' || true
# Ensure Immich stack (postgres + redis + server) - creates if missing
echo "$(timestamp) Ensuring Immich stack..."
progress "Ensuring Immich stack"
ssh $SSH_OPTS "$TARGET_HOST" "
DOCKER=podman
command -v podman >/dev/null 2>&1 || DOCKER=docker
@ -889,7 +985,7 @@ PYEOF
" 2>&1 | sed 's/^/ /' || true
# Tor: global hidden services - each service gets its own .onion address
echo "$(timestamp) Setting up Tor..."
progress "Setting up Tor"
TARGET_IP="$(echo "$TARGET_HOST" | cut -d@ -f2)"
ssh $SSH_OPTS "$TARGET_HOST" "
DOCKER=podman
@ -983,7 +1079,7 @@ print("torrc generated with %d services" % (len(lines) // 3))
# Recreate Fedimint with FM_API_URL for Guardian UI (fixes "Api URL must be configured")
section_start
echo "$(timestamp) Fixing Fedimint API URL..."
progress "Fixing Fedimint"
TARGET_IP="$(echo "$TARGET_HOST" | cut -d@ -f2)"
TIMEOUT_CMD=""
command -v timeout >/dev/null 2>&1 && TIMEOUT_CMD="timeout 90"
@ -1055,7 +1151,7 @@ print("torrc generated with %d services" % (len(lines) // 3))
section_end
# LND: Lightning Network Daemon (requires bitcoin-knots on archy-net)
echo "$(timestamp) Ensuring LND..."
progress "Ensuring LND"
ssh $SSH_OPTS "$TARGET_HOST" '
DOCKER=podman
command -v podman >/dev/null 2>&1 || DOCKER=docker
@ -1106,7 +1202,7 @@ LNDCONF
' 2>&1 | sed 's/^/ /' || true
# Home Assistant
echo "$(timestamp) Ensuring Home Assistant..."
progress "Ensuring Home Assistant"
ssh $SSH_OPTS "$TARGET_HOST" '
DOCKER=podman
command -v podman >/dev/null 2>&1 || DOCKER=docker
@ -1129,7 +1225,7 @@ LNDCONF
' 2>&1 | sed 's/^/ /' || true
# Grafana
echo "$(timestamp) Ensuring Grafana..."
progress "Ensuring Grafana"
ssh $SSH_OPTS "$TARGET_HOST" '
DOCKER=podman
command -v podman >/dev/null 2>&1 || DOCKER=docker
@ -1153,7 +1249,7 @@ LNDCONF
' 2>&1 | sed 's/^/ /' || true
# Jellyfin
echo "$(timestamp) Ensuring Jellyfin..."
progress "Ensuring Jellyfin"
ssh $SSH_OPTS "$TARGET_HOST" '
DOCKER=podman
command -v podman >/dev/null 2>&1 || DOCKER=docker
@ -1176,7 +1272,7 @@ LNDCONF
' 2>&1 | sed 's/^/ /' || true
# Vaultwarden
echo "$(timestamp) Ensuring Vaultwarden..."
progress "Ensuring Vaultwarden"
ssh $SSH_OPTS "$TARGET_HOST" '
DOCKER=podman
command -v podman >/dev/null 2>&1 || DOCKER=docker
@ -1198,7 +1294,7 @@ LNDCONF
' 2>&1 | sed 's/^/ /' || true
# SearXNG (privacy search engine — used by AIUI web search)
echo "$(timestamp) Ensuring SearXNG..."
progress "Ensuring SearXNG"
ssh $SSH_OPTS "$TARGET_HOST" '
DOCKER=podman
command -v podman >/dev/null 2>&1 || DOCKER=docker
@ -1218,7 +1314,7 @@ LNDCONF
' 2>&1 | sed 's/^/ /' || true
# Ollama (local LLM inference — used by AIUI)
echo "$(timestamp) Ensuring Ollama..."
progress "Ensuring Ollama"
ssh $SSH_OPTS "$TARGET_HOST" '
DOCKER=podman
command -v podman >/dev/null 2>&1 || DOCKER=docker
@ -1241,6 +1337,7 @@ LNDCONF
fi # end FRONTEND_ONLY guard
# Ensure UFW allows forwarded traffic (required for podman container port access from LAN)
progress "Fixing UFW forward policy"
ssh $SSH_OPTS "$TARGET_HOST" '
if grep -q "DEFAULT_FORWARD_POLICY=\"DROP\"" /etc/default/ufw 2>/dev/null; then
sudo sed -i "s/DEFAULT_FORWARD_POLICY=\"DROP\"/DEFAULT_FORWARD_POLICY=\"ACCEPT\"/" /etc/default/ufw
@ -1249,38 +1346,73 @@ LNDCONF
fi
' 2>&1 | sed 's/^/ /' || true
# Fix IndeedHub for iframe + NIP-07: remove X-Frame-Options, inject nostr-provider.js
# Fix IndeedHub for iframe + NIP-07: remove X-Frame-Options, inject nostr-provider.js,
# resolve container IPs for nginx proxy (DNS resolver 127.0.0.11 is unreliable in podman)
progress "Fixing IndeedHub for NIP-07"
ssh $SSH_OPTS "$TARGET_HOST" '
if sudo podman ps --format "{{.Names}}" 2>/dev/null | grep -q "^indeedhub$"; then
CHANGED=false
NETWORK=$(sudo podman inspect indeedhub --format "{{range \$k, \$v := .NetworkSettings.Networks}}{{\$k}}{{end}}" 2>/dev/null)
# Remove X-Frame-Options so iframe works
if sudo podman exec indeedhub grep -q "X-Frame-Options" /etc/nginx/conf.d/default.conf 2>/dev/null; then
sudo podman exec indeedhub sed -i "/X-Frame-Options/d" /etc/nginx/conf.d/default.conf
CHANGED=true
echo " Removed X-Frame-Options from IndeedHub"
fi
# Inject nostr-provider.js for NIP-07 signing
if ! sudo podman exec indeedhub test -f /usr/share/nginx/html/nostr-provider.js 2>/dev/null; then
sudo podman cp /opt/archipelago/web-ui/nostr-provider.js indeedhub:/usr/share/nginx/html/nostr-provider.js 2>/dev/null
echo " Copied nostr-provider.js into IndeedHub"
fi
# Add nostr-provider.js + sub_filter to nginx config
if ! sudo podman exec indeedhub grep -q "nostr-provider" /etc/nginx/conf.d/default.conf 2>/dev/null; then
sudo podman exec indeedhub cat /etc/nginx/conf.d/default.conf > /tmp/ih-nginx.conf 2>/dev/null
sed -i "/try_files.*index.html/i\\ sub_filter_once on;\n sub_filter '"'"'</head>'"'"' '"'"'<script src=\"/nostr-provider.js\"></script></head>'"'"';" /tmp/ih-nginx.conf
# Add nostr-provider location block before sw.js block
sed -i "/location = \/sw.js {/i\\ location = /nostr-provider.js {\n add_header Cache-Control \"no-cache, no-store, must-revalidate\";\n expires off;\n }\n" /tmp/ih-nginx.conf
# Add sub_filter for nostr-provider injection
sed -i "/try_files.*index.html/a\\ sub_filter_once on;\n sub_filter '"'"'</head>'"'"' '"'"'<script src=\"/nostr-provider.js\"></script></head>'"'"';" /tmp/ih-nginx.conf
sudo podman cp /tmp/ih-nginx.conf indeedhub:/etc/nginx/conf.d/default.conf 2>/dev/null
rm -f /tmp/ih-nginx.conf
CHANGED=true
echo " Injected nostr-provider.js into IndeedHub nginx"
fi
# Replace DNS-based upstream resolution with hardcoded container IPs
# (podman DNS resolver 127.0.0.11 is unreliable, causing 502 errors)
API_IP=$(sudo podman inspect indeedhub-build_api_1 --format "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}" 2>/dev/null)
MINIO_IP=$(sudo podman inspect indeedhub-minio --format "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}" 2>/dev/null)
RELAY_IP=$(sudo podman inspect indeedhub-relay --format "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}" 2>/dev/null)
if [ -n "$API_IP" ] && [ -n "$MINIO_IP" ] && [ -n "$RELAY_IP" ]; then
sudo podman exec indeedhub cat /etc/nginx/conf.d/default.conf > /tmp/ih-nginx.conf 2>/dev/null
# Remove DNS resolver lines and replace upstream variables with hardcoded IPs
sed -i "s|resolver 127.0.0.11 valid=30s ipv6=off;||g" /tmp/ih-nginx.conf
sed -i "s|set \$api_upstream http://api:4000;|set \$api_upstream http://$API_IP:4000;|g" /tmp/ih-nginx.conf
sed -i "s|set \$minio_upstream http://minio:9000;|set \$minio_upstream http://$MINIO_IP:9000;|g" /tmp/ih-nginx.conf
sed -i "s|set \$relay_upstream http://relay:8080;|set \$relay_upstream http://$RELAY_IP:8080;|g" /tmp/ih-nginx.conf
sed -i "s|proxy_set_header Host \$host;|proxy_set_header Host \$http_host;|g" /tmp/ih-nginx.conf
sudo podman cp /tmp/ih-nginx.conf indeedhub:/etc/nginx/conf.d/default.conf 2>/dev/null
rm -f /tmp/ih-nginx.conf
CHANGED=true
echo " Patched IndeedHub nginx with container IPs (API=$API_IP MINIO=$MINIO_IP RELAY=$RELAY_IP)"
fi
if [ "$CHANGED" = true ]; then
sudo podman exec indeedhub nginx -s reload 2>/dev/null
fi
fi
' 2>&1 | sed 's/^/ /' || true
# Run container doctor — auto-fix common container health issues
progress "Running container doctor"
"$SCRIPT_DIR/container-doctor.sh" "$TARGET_HOST" 2>&1 | sed 's/^/ /' || true
# Post-deploy health check — wait up to 60s for server to come healthy
echo ""
echo "$(timestamp) 🩺 Post-deploy health check..."
progress "Post-deploy health check"
HEALTH_OK=false
for i in $(seq 1 12); do
POST_HEALTH=$(curl -s -o /dev/null -w '%{http_code}' --connect-timeout 5 "http://$TARGET_IP_ONLY/health" 2>/dev/null || echo "000")
@ -1320,8 +1452,14 @@ LNDCONF
DEPLOY_END=$(date +%s)
DEPLOY_ELAPSED=$((DEPLOY_END - DEPLOY_START))
# Append to local deploy history log (gitignored)
DEPLOY_LOG="$PROJECT_DIR/scripts/deploy-history.log"
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) | $DEPLOY_BRANCH@$DEPLOY_COMMIT | dirty=$DEPLOY_DIRTY | target=$TARGET_HOST | ${DEPLOY_ELAPSED}s" >> "$DEPLOY_LOG"
echo ""
echo "$(timestamp) ✅ Deployed to live system! (${DEPLOY_ELAPSED}s total)"
echo " Commit: $DEPLOY_BRANCH @ $DEPLOY_COMMIT (dirty=$DEPLOY_DIRTY)"
echo " Backend: $(ssh $SSH_OPTS "$TARGET_HOST" 'sudo systemctl is-active archipelago')"
echo " Web UI: http://$TARGET_IP_ONLY"
echo " PWA install: https://$TARGET_IP_ONLY (use HTTPS, accept cert once, then Install app)"

View File

@ -670,4 +670,13 @@ for c in bitcoin-knots lnd btcpay-server fedimint homeassistant grafana uptime-k
done
log "Post-boot validation: $RUNNING/$TOTAL core containers running"
# 12. Run container doctor for any remaining issues
log "Running container doctor..."
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
if [ -x "$SCRIPT_DIR/container-doctor.sh" ]; then
bash "$SCRIPT_DIR/container-doctor.sh" --local 2>&1 | tee -a "$LOG"
elif [ -x "/opt/archipelago/scripts/container-doctor.sh" ]; then
bash "/opt/archipelago/scripts/container-doctor.sh" --local 2>&1 | tee -a "$LOG"
fi
log "First-boot container creation complete"

View File

@ -0,0 +1,233 @@
#!/bin/bash
set -e
# Fix corrupted IndeedHub containers + SearXNG
# All images were exported as the same (wrong) image during multi-node deploy.
# This script: stops broken containers, removes them, recreates with correct images.
echo "=== IndeedHub Container Fix Script ==="
# Detect node IP (Tailscale or LAN)
NODE_IP=$(hostname -I | awk '{for(i=1;i<=NF;i++) if($i ~ /^100\./) print $i}')
if [ -z "$NODE_IP" ]; then
NODE_IP=$(hostname -I | awk '{print $1}')
fi
echo "Node IP: $NODE_IP"
NETWORK="indeedhub-build_indeedhub-network"
# Load custom images if tar exists
if [ -f /tmp/indeedhub-images.tar ]; then
echo "Loading custom images from tar..."
sudo podman load < /tmp/indeedhub-images.tar 2>&1 | tail -5
fi
# Verify correct images are available
echo "Verifying images..."
for img in "docker.io/library/redis:7-alpine" "docker.io/minio/minio:latest" "docker.io/library/postgres:16-alpine" "docker.io/scsibug/nostr-rs-relay:latest" "docker.io/searxng/searxng:latest" "localhost/indeedhub:latest" "localhost/indeedhub-build_api:latest" "localhost/indeedhub-build_ffmpeg-worker:latest"; do
if ! sudo podman image exists "$img" 2>/dev/null; then
echo "ERROR: Missing image $img"
exit 1
fi
done
echo "All images verified."
# Ensure network exists
if ! sudo podman network exists "$NETWORK" 2>/dev/null; then
echo "Creating network $NETWORK..."
sudo podman network create "$NETWORK" 2>/dev/null || true
fi
# Stop all affected containers
echo "Stopping containers..."
for c in indeedhub indeedhub-build_api_1 indeedhub-build_ffmpeg-worker_1 indeedhub-relay indeedhub-redis indeedhub-minio indeedhub-postgres searxng; do
sudo podman stop "$c" 2>/dev/null || true
done
# Remove all affected containers
echo "Removing containers..."
for c in indeedhub indeedhub-build_api_1 indeedhub-build_ffmpeg-worker_1 indeedhub-relay indeedhub-redis indeedhub-minio indeedhub-postgres searxng; do
sudo podman rm -f "$c" 2>/dev/null || true
done
# 1. PostgreSQL (must start first — others depend on it)
echo "Creating postgres..."
sudo podman run -d --name indeedhub-postgres \
--restart unless-stopped \
--network "$NETWORK" --network-alias postgres \
-v indeedhub-postgres-data:/var/lib/postgresql/data \
-e POSTGRES_USER=indeedhub \
-e POSTGRES_PASSWORD=indeehhub-archy-2026 \
-e POSTGRES_DB=indeedhub \
docker.io/library/postgres:16-alpine
# Wait for postgres to be ready
echo "Waiting for postgres..."
for i in $(seq 1 15); do
if sudo podman exec indeedhub-postgres pg_isready -U indeedhub 2>/dev/null; then
echo "Postgres ready."
break
fi
sleep 2
done
# 2. Redis
echo "Creating redis..."
sudo podman run -d --name indeedhub-redis \
--restart unless-stopped \
--network "$NETWORK" --network-alias redis \
-v indeedhub-redis-data:/data \
docker.io/library/redis:7-alpine \
redis-server --appendonly yes
# 3. MinIO
echo "Creating minio..."
sudo podman run -d --name indeedhub-minio \
--restart unless-stopped \
--network "$NETWORK" --network-alias minio \
-v indeedhub-minio-data:/data \
-e MINIO_ROOT_USER=indeeadmin \
-e MINIO_ROOT_PASSWORD=indeeadmin2026 \
docker.io/minio/minio:latest \
server /data --console-address ":9001"
# 4. Nostr Relay
echo "Creating relay..."
sudo podman run -d --name indeedhub-relay \
--restart unless-stopped \
--network "$NETWORK" --network-alias relay \
-v indeedhub-relay-data:/usr/src/app/db \
docker.io/scsibug/nostr-rs-relay:latest
# 5. API
echo "Creating api..."
sudo podman run -d --name indeedhub-build_api_1 \
--restart unless-stopped \
--network "$NETWORK" --network-alias api \
-e ENVIRONMENT=production \
-e PORT=4000 \
-e DOMAIN="$NODE_IP" \
-e FRONTEND_URL="http://$NODE_IP" \
-e DATABASE_HOST=postgres \
-e DATABASE_PORT=5432 \
-e DATABASE_USER=indeedhub \
-e DATABASE_PASSWORD=indeehhub-archy-2026 \
-e DATABASE_NAME=indeedhub \
-e QUEUE_HOST=redis \
-e QUEUE_PORT=6379 \
-e "QUEUE_PASSWORD=" \
-e S3_ENDPOINT=http://minio:9000 \
-e AWS_REGION=us-east-1 \
-e AWS_ACCESS_KEY=indeeadmin \
-e AWS_SECRET_KEY=indeeadmin2026 \
-e S3_PRIVATE_BUCKET_NAME=indeedhub-private \
-e S3_PUBLIC_BUCKET_NAME=indeedhub-public \
-e S3_PUBLIC_BUCKET_URL=/storage \
-e "BTCPAY_URL=" \
-e "BTCPAY_API_KEY=" \
-e "BTCPAY_STORE_ID=" \
-e "BTCPAY_WEBHOOK_SECRET=" \
-e NOSTR_JWT_SECRET=archipelago-indeehhub-jwt-secret-2026 \
-e NOSTR_JWT_EXPIRES_IN=7d \
-e AES_MASTER_SECRET=0123456789abcdef0123456789abcdef \
-e "ADMIN_API_KEY=" \
-e NODE_OPTIONS=--max-old-space-size=1024 \
--health-cmd "wget --no-verbose --tries=1 --spider http://localhost:4000/nostr-auth/health || exit 1" \
--health-interval 60s \
--health-timeout 30s \
--health-retries 5 \
--health-start-period 60s \
localhost/indeedhub-build_api:latest \
sh -c "echo 'Running database migrations...' && npx typeorm migration:run -d dist/database/ormconfig.js && echo 'Migrations complete.' && npm run start:prod"
# 6. FFmpeg Worker
echo "Creating ffmpeg-worker..."
sudo podman run -d --name indeedhub-build_ffmpeg-worker_1 \
--restart unless-stopped \
--network "$NETWORK" --network-alias ffmpeg-worker \
-e ENVIRONMENT=production \
-e DATABASE_HOST=postgres \
-e DATABASE_PORT=5432 \
-e DATABASE_USER=indeedhub \
-e DATABASE_PASSWORD=indeehhub-archy-2026 \
-e DATABASE_NAME=indeedhub \
-e QUEUE_HOST=redis \
-e QUEUE_PORT=6379 \
-e "QUEUE_PASSWORD=" \
-e S3_ENDPOINT=http://minio:9000 \
-e AWS_REGION=us-east-1 \
-e AWS_ACCESS_KEY=indeeadmin \
-e AWS_SECRET_KEY=indeeadmin2026 \
-e S3_PRIVATE_BUCKET_NAME=indeedhub-private \
-e S3_PUBLIC_BUCKET_NAME=indeedhub-public \
-e S3_PUBLIC_BUCKET_URL=/storage \
-e AES_MASTER_SECRET=0123456789abcdef0123456789abcdef \
localhost/indeedhub-build_ffmpeg-worker:latest
# 7. IndeedHub Frontend
echo "Creating indeedhub frontend..."
sudo podman run -d --name indeedhub \
--restart unless-stopped \
--network "$NETWORK" \
-p 7777:7777 \
--label "com.archipelago.app=indeedhub" \
--label "com.archipelago.title=IndeedHub" \
--label "com.archipelago.version=0.1.0" \
--label "com.archipelago.category=media" \
--label "com.archipelago.port=7777" \
localhost/indeedhub:latest
# Fix IndeedHub for iframe: remove X-Frame-Options, inject nostr-provider, hardcode container IPs
sleep 3
if sudo podman ps --format '{{.Names}}' 2>/dev/null | grep -q "^indeedhub$"; then
sudo podman exec indeedhub sed -i "/X-Frame-Options/d" /etc/nginx/conf.d/default.conf 2>/dev/null || true
# Inject nostr-provider.js if available
if [ -f /opt/archipelago/web-ui/nostr-provider.js ]; then
sudo podman cp /opt/archipelago/web-ui/nostr-provider.js indeedhub:/usr/share/nginx/html/nostr-provider.js 2>/dev/null || true
fi
# Add nostr-provider location block + sub_filter
if ! sudo podman exec indeedhub grep -q "nostr-provider" /etc/nginx/conf.d/default.conf 2>/dev/null; then
sudo podman exec indeedhub cat /etc/nginx/conf.d/default.conf > /tmp/ih-nginx.conf 2>/dev/null
sed -i "/location = \/sw.js {/i\\ location = /nostr-provider.js {\n add_header Cache-Control \"no-cache, no-store, must-revalidate\";\n expires off;\n }\n" /tmp/ih-nginx.conf
sed -i "/try_files.*index.html/a\\ sub_filter_once on;\n sub_filter '</head>' '<script src=\"/nostr-provider.js\"></script></head>';" /tmp/ih-nginx.conf
sudo podman cp /tmp/ih-nginx.conf indeedhub:/etc/nginx/conf.d/default.conf 2>/dev/null || true
rm -f /tmp/ih-nginx.conf
fi
# Replace DNS-based upstream resolution with hardcoded container IPs
# (podman DNS resolver 127.0.0.11 is unreliable, causing 502 errors)
API_IP=$(sudo podman inspect indeedhub-build_api_1 --format "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}" 2>/dev/null)
MINIO_IP=$(sudo podman inspect indeedhub-minio --format "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}" 2>/dev/null)
RELAY_IP=$(sudo podman inspect indeedhub-relay --format "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}" 2>/dev/null)
if [ -n "$API_IP" ] && [ -n "$MINIO_IP" ] && [ -n "$RELAY_IP" ]; then
sudo podman exec indeedhub cat /etc/nginx/conf.d/default.conf > /tmp/ih-nginx.conf 2>/dev/null
sed -i "s|resolver 127.0.0.11 valid=30s ipv6=off;||g" /tmp/ih-nginx.conf
sed -i "s|set \$api_upstream http://api:4000;|set \$api_upstream http://$API_IP:4000;|g" /tmp/ih-nginx.conf
sed -i "s|set \$minio_upstream http://minio:9000;|set \$minio_upstream http://$MINIO_IP:9000;|g" /tmp/ih-nginx.conf
sed -i "s|set \$relay_upstream http://relay:8080;|set \$relay_upstream http://$RELAY_IP:8080;|g" /tmp/ih-nginx.conf
sed -i "s|proxy_set_header Host \$host;|proxy_set_header Host \$http_host;|g" /tmp/ih-nginx.conf
sudo podman cp /tmp/ih-nginx.conf indeedhub:/etc/nginx/conf.d/default.conf 2>/dev/null || true
rm -f /tmp/ih-nginx.conf
echo "Patched IndeedHub nginx with container IPs (API=$API_IP MINIO=$MINIO_IP RELAY=$RELAY_IP)"
fi
sudo podman exec indeedhub nginx -s reload 2>/dev/null || true
echo "Applied IndeedHub iframe fix."
fi
# 8. SearXNG (standalone — no cap-drop ALL, searxng needs write access to /etc/searxng/)
echo "Creating searxng..."
sudo podman run -d --name searxng \
--restart unless-stopped \
-p 8888:8080 \
docker.io/searxng/searxng:latest
echo ""
echo "=== Verifying container status ==="
sleep 5
sudo podman ps -a --filter name=indeedhub --filter name=searxng --format "table {{.Names}}\t{{.Status}}" 2>&1
echo ""
echo "=== FIX COMPLETE ==="