Compare commits
109 Commits
v1.2.0-alp
...
jay-contai
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9aaf8d4b95 | ||
|
|
ea222895be | ||
|
|
27f1b8d21b | ||
|
|
d71eae1815 | ||
|
|
3daf889f74 | ||
|
|
e96acc9023 | ||
|
|
2d47fd800e | ||
|
|
008573b6ac | ||
|
|
ae13c0dad2 | ||
|
|
fc1e763cff | ||
|
|
1f9124789f | ||
|
|
99e32b877f | ||
|
|
5af4c71ab7 | ||
|
|
059913d3dd | ||
|
|
08bb2c80d4 | ||
|
|
5c15c52113 | ||
|
|
aa78d92f7f | ||
|
|
997d9d36ff | ||
|
|
809e471e2b | ||
|
|
54451103f3 | ||
|
|
35f1aa2e13 | ||
|
|
74abbef00d | ||
|
|
5d8365f001 | ||
|
|
c16fa8013a | ||
|
|
0e0c97c203 | ||
|
|
0fe4ebc7d5 | ||
|
|
a7920de824 | ||
|
|
06d85e1d6f | ||
|
|
f5802f9ed0 | ||
|
|
028248dfd7 | ||
|
|
f5714a5b2e | ||
|
|
d37165ca52 | ||
|
|
13e4a738be | ||
|
|
01942cea95 | ||
|
|
24f86632d0 | ||
|
|
5099f6f763 | ||
|
|
bfbaa36709 | ||
|
|
ea1b1f826b | ||
|
|
77f550fb5e | ||
|
|
8e4d352393 | ||
|
|
3b35b1bee0 | ||
|
|
f3976ba03a | ||
|
|
5c3a3ffa8e | ||
|
|
2f60ef44ea | ||
|
|
3b7d541224 | ||
|
|
4d17c60da7 | ||
|
|
38dc845f57 | ||
|
|
c299199d37 | ||
|
|
b5024c29df | ||
|
|
196682f2f2 | ||
|
|
b31148a8b7 | ||
|
|
b4d204d1d6 | ||
|
|
c82158c7c8 | ||
|
|
9b6adfc42d | ||
|
|
f0a403b224 | ||
|
|
fc1120338d | ||
|
|
4c0c8a83a9 | ||
|
|
b3949fdcf7 | ||
|
|
c4853fe746 | ||
|
|
c5417640a2 | ||
|
|
1f732d8d08 | ||
|
|
867e56cb84 | ||
|
|
203b044646 | ||
|
|
d98a2512b7 | ||
|
|
93aaeb4abe | ||
|
|
12679b77b7 | ||
|
|
781cbf3263 | ||
|
|
f1d9ecc392 | ||
|
|
973beb887a | ||
|
|
cf184661d9 | ||
|
|
1a138c0409 | ||
|
|
f8794791f3 | ||
|
|
f8eefa87d2 | ||
|
|
96d722ed0f | ||
|
|
42a1526b70 | ||
|
|
86df0bcaf2 | ||
|
|
9fe680def1 | ||
|
|
9e15444228 | ||
|
|
c78a123e9c | ||
|
|
ca65a8172c | ||
|
|
f20f0650cf | ||
|
|
9b4aa712f2 | ||
|
|
e574b6dd18 | ||
|
|
6033199864 | ||
|
|
5e19a80f9d | ||
|
|
aabeb2e679 | ||
|
|
e8674a3801 | ||
|
|
ba6a0e6fe6 | ||
|
|
f292ebf63e | ||
|
|
1dfceeb957 | ||
|
|
c037db9d42 | ||
|
|
1a74a930f7 | ||
|
|
d1b48388fb | ||
|
|
8c800525c0 | ||
|
|
aad98dec08 | ||
|
|
a9bb5a28ce | ||
|
|
7cb4fd6812 | ||
|
|
75018da1da | ||
|
|
41ab499698 | ||
|
|
b8afb10ec6 | ||
|
|
165972e75c | ||
|
|
b7edada7fe | ||
|
|
a2bf51615f | ||
|
|
adcc3fddc7 | ||
|
|
7bbd8f889a | ||
|
|
12412c70db | ||
|
|
41ff1021ad | ||
|
|
00bfd62393 | ||
|
|
a6f1ab8d53 |
@@ -5,6 +5,7 @@
|
||||
- [deploy-automation.md](deploy-automation.md) — Deploy script automation TODOs (API key, AIUI nginx, swap)
|
||||
|
||||
## Servers & Deploy
|
||||
- [project_environments.md](project_environments.md) — Four environments: dev mode, dev server/prod, demo
|
||||
- [tailscale_servers.md](tailscale_servers.md) — Tailscale server details (archipelago-2, archipelago-3)
|
||||
- [reference_tailscale_nodes.md](reference_tailscale_nodes.md) — All node IPs and SSH commands
|
||||
- [second-server.md](second-server.md) — Second dev server (archipelago-2 via Tailscale)
|
||||
@@ -27,6 +28,12 @@
|
||||
- [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
|
||||
|
||||
## Infrastructure
|
||||
- [project_bitcoin_rpc_auth.md](project_bitcoin_rpc_auth.md) — Bitcoin rpcauth, system Tor, reboot survival, container resilience
|
||||
|
||||
## Deploy & Container Fixes
|
||||
- [project_deploy_session_2026_03_22.md](project_deploy_session_2026_03_22.md) — Fleet deploy fixes: credential mismatches, restart storms, rootless port 80, deploy script hardening
|
||||
|
||||
## 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
|
||||
|
||||
21
.claude/memory/project_bitcoin_rpc_auth.md
Normal file
21
.claude/memory/project_bitcoin_rpc_auth.md
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: Bitcoin RPC rpcauth architecture
|
||||
description: Bitcoin uses rpcauth (salted hash in config, password in secrets file), system Tor for containers, reboot survival
|
||||
type: project
|
||||
---
|
||||
|
||||
Bitcoin RPC uses `rpcauth` — salted HMAC-SHA256 hash in bitcoin.conf, plaintext password in `/var/lib/archipelago/secrets/bitcoin-rpc-password`. Credentials are STABLE across reboots, restarts, deploys.
|
||||
|
||||
**Why:** Cookie auth rotates on every Bitcoin restart, breaking all dependent containers with env-var-only credentials. The `rpcauth` approach keeps the password stable while never exposing plaintext in config files or CLI args.
|
||||
|
||||
**How to apply:**
|
||||
- Bitcoin: reads rpcauth from bitcoin.conf (no CLI credential flags, config generated by first-boot or deploy)
|
||||
- LND: `bitcoind.rpcuser/rpcpass` in lnd.conf (NOT rpccookie — LND v0.18.4 doesn't support it)
|
||||
- All containers: read password from secrets file at creation time, passed via env vars
|
||||
- Rust backend `bitcoin_rpc.rs`: reads from secrets file, cached with OnceCell
|
||||
- bitcoin-ui: mounts `/var/lib/archipelago/secrets:/secrets:ro`, start.sh reads password and injects nginx auth header
|
||||
- System Tor: `SocksPort 0.0.0.0:9050` + SocksPolicy, containers use `host.containers.internal:9050`
|
||||
- `podman-restart.service` enabled for container auto-start after reboot
|
||||
- Tor hidden service hostnames copied to `/var/lib/archipelago/tor-hostnames/` for readable access
|
||||
- .198 ElectrumX points at .228's full Bitcoin node (pruned node can't run ElectrumX locally)
|
||||
- Health monitor interval: 60 seconds — UI may briefly show "crashed" during restarts
|
||||
98
.claude/memory/project_deploy_session_2026_03_22.md
Normal file
98
.claude/memory/project_deploy_session_2026_03_22.md
Normal file
@@ -0,0 +1,98 @@
|
||||
---
|
||||
name: Deploy session 2026-03-22 findings
|
||||
description: Comprehensive deploy/build fixes made overnight — container issues, image tags, script improvements, remaining work
|
||||
type: project
|
||||
---
|
||||
|
||||
## Session Summary (2026-03-22 overnight)
|
||||
|
||||
Massive deploy infrastructure overhaul across all 5 nodes (.228, .198, Arch 1/2/3).
|
||||
|
||||
### Fixed in deploy-tailscale.sh
|
||||
- **Image tags**: Bitcoin Knots `28.1` (not `v28.1`), BTCPay `1.13.7` (not `1.14.5`), SearXNG `2026.3.20-6c7e9c197`
|
||||
- **Removed Immich** (3 containers) and **Penpot** (5 containers) from deploy + build
|
||||
- **Fedimint**: `FM_REL_NOTES_ACK=0_4_xyz` env var (NOT `FM_SKIP_REL_NOTES_ACK` or `FM_REQ_RELEASE_NOTES_ACK_V0_4`)
|
||||
- **Fedimint-gateway**: `--password` instead of `--bcrypt-password-hash` (v0.5.1 CLI change)
|
||||
- **FileBrowser**: added `--cap-add NET_BIND_SERVICE` for port 80 binding
|
||||
- **SearXNG**: added `/var/lib/archipelago/searxng:/etc/searxng` volume mount + caps
|
||||
- **Postgres**: pinned to `postgres:15` (data initialized with 15, incompatible with 16)
|
||||
- **Migration**: one-time flag file `/var/lib/archipelago/.rootless-migrated`
|
||||
- **Recreate-if-broken pattern**: containers that exist but are stopped get deleted and recreated
|
||||
- **Arch 2 hostname**: fixed from hardcoded hostname to `$TAILSCALE_ARCH2`
|
||||
- **Custom UI images**: graceful skip if not available, source extracted to repo (`docker/bitcoin-ui/`, `docker/electrs-ui/`)
|
||||
- **AIUI tar xattr**: silenced with `--no-xattrs` (only in deploy-tailscale.sh, NOT deploy-to-target.sh yet)
|
||||
- **Nginx MIME warning**: removed `text/html` from `sub_filter_types`
|
||||
|
||||
### Added
|
||||
- `--fleet` flag in deploy-to-target.sh: deploys .228 → .198 → Arch 1/2/3
|
||||
- `--both` lock fix: releases lock before recursive `--live` call
|
||||
- Container verification step (Step 26b): restarts exited containers, fixes permissions, checks Tor
|
||||
- IndeedHub backend stack rebuilt on .228 (7 containers)
|
||||
- IndeedHub nginx patched with direct IPs (podman DNS doesn't work with nginx resolver)
|
||||
|
||||
### Frontend changes
|
||||
- Replaced Immich with FileBrowser on Setup homescreen (`goals.ts`, `EasyHome.vue`)
|
||||
- `MEMPOOL_API_IMAGE` renamed to `MEMPOOL_BACKEND_IMAGE` in image-versions.sh
|
||||
- Nextcloud downgraded from 30 to 29 (one major version upgrade at a time)
|
||||
|
||||
### Session 2 fixes (same day)
|
||||
|
||||
**Critical pattern found: Container credential mismatches**
|
||||
- Deploy generates random passwords stored in `secrets/`. MariaDB/Postgres only use env vars on FIRST init — subsequent restarts ignore them. Container recreation with new passwords → auth failures → crash loops.
|
||||
- 50,000+ cumulative container restarts across fleet from this single root cause.
|
||||
|
||||
**Fixes applied to all nodes:**
|
||||
1. LND: `lnd.conf` rpcpass synced from `secrets/bitcoin-rpc-password` (was hardcoded `archipelago123`)
|
||||
2. MariaDB mempool: data dirs wiped + reinitialized (password mismatch unrecoverable)
|
||||
3. BTCPay Postgres: `ALTER USER` to sync password with secrets
|
||||
4. FileBrowser: `--user 0:0` instead of `--cap-add NET_BIND_SERVICE` (rootless port 80 fix)
|
||||
5. Nextcloud: same `--user 0:0` fix
|
||||
6. Tailscale container on .228: removed (2,685 restarts — unauthenticated, host already has TS)
|
||||
|
||||
**Deploy script fixes:**
|
||||
- `deploy-tailscale.sh`: LND config always synced before start, `eval "$DB_PASSWORDS"` → safe individual reads, MariaDB password sync step, filebrowser `--user 0:0`
|
||||
- `deploy-to-target.sh`: LND stale config check now compares passwords (not just cookie/localhost), filebrowser `--user 0:0`
|
||||
|
||||
**Rootless port 80 rule**: Containers binding port 80 MUST use `--user 0:0`. `NET_BIND_SERVICE` cap doesn't work in rootless (UID 0 → host 100000, unprivileged).
|
||||
|
||||
### Session 3 fixes (2026-03-22 to 2026-03-24)
|
||||
|
||||
**Additional container fixes applied live:**
|
||||
- PhotoPrism: recreated with proper `/photoprism/storage`, `/photoprism/originals`, `/photoprism/import` volume mounts (all 3 nodes)
|
||||
- Vaultwarden/Jellyfin: recreated with `--user 0:0` + health checks (Arch 1/2)
|
||||
- Nextcloud: downgraded image to v29 (data initialized with v28, can't skip to v30)
|
||||
- Fedimint: upgraded v0.5.1 → v0.10.0 on all Tailscale nodes
|
||||
- Fedimint-gateway: bcrypt hash passed via file mount (shell escaping workaround)
|
||||
- SearXNG: recreated with proper caps on Arch 2
|
||||
- Arch 3 right-sized: stopped immich (3), jellyfin, vaultwarden, nbxplorer (7.3GB RAM)
|
||||
|
||||
**Deploy script improvements (6 commits pushed):**
|
||||
1. `d37165ca` — Credential sync, health checks, rootless port binding
|
||||
2. `f5714a5b` — Fleet deploy falls back to Tailscale when LAN unreachable, `--all` alias
|
||||
3. `028248df` — Suppress tar xattr spam in AIUI deploy (`--no-xattrs`)
|
||||
4. `f5802f9e` — Fix LND config SSH escaping, Tailscale fallback for BUILD_SOURCE
|
||||
5. `06d85e1d` — Fix health check escaping for SSH heredoc (`--health-cmd 'cmd'` not `"cmd"`)
|
||||
6. `a7920de8` — Correct health check endpoints (fedimint→8175, nextcloud→`/`, filebrowser→`/`)
|
||||
|
||||
**Health checks added to deploy-tailscale.sh:**
|
||||
- 25 containers now have `--health-cmd` in deploy-tailscale.sh (was zero)
|
||||
- Key corrections: fedimint checks port 8175 (UI) not 8174 (websocket), nextcloud/filebrowser check `/` not custom endpoints
|
||||
|
||||
**Fleet status at end of session:**
|
||||
|
||||
| Node | Status | Notes |
|
||||
|------|--------|-------|
|
||||
| .228 | 36/36, 0 unhealthy, load 1.0 | Fully stable |
|
||||
| Arch 1 | 25/25, 0 unhealthy, load 0.5 | Fully stable |
|
||||
| Arch 2 | 25/25, 0 unhealthy, load 0.2 | Fully stable |
|
||||
| Arch 3 | 24/28, 0 unhealthy, load 7.7 | Right-sized for 7.3GB RAM, Bitcoin IBD at 97.8% |
|
||||
| .198 | Bitcoin chain data empty (4KB) | Needs full IBD — will take days. Not pruned. |
|
||||
|
||||
### Remaining for next session
|
||||
- **.198**: Bitcoin doing full IBD from scratch (chain data was lost/empty). No prune flag set. Will take days.
|
||||
- **Arch 3**: Bitcoin IBD was at 97.8% — check if complete, then start LND/nbxplorer
|
||||
- **Tor config Python syntax errors** in deploy-to-target.sh step 33 (cosmetic, falls back to system Tor)
|
||||
- **deploy-to-target.sh** still missing health checks (only deploy-tailscale.sh has them)
|
||||
- **first-boot-containers.sh** needs same rootless fixes (filebrowser `--user 0:0`, credential sync)
|
||||
- **Fedimint guardian setup** not done on any node — all in "Setup UI" mode
|
||||
- User needs to `git pull && ./scripts/deploy-to-target.sh --all` to deploy latest fixes to Tailscale nodes
|
||||
21
.claude/memory/project_environments.md
Normal file
21
.claude/memory/project_environments.md
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: Four Environments
|
||||
description: Clear distinction between dev mode (local mock), dev server (228), demo (Portainer), and prod (same as dev server)
|
||||
type: project
|
||||
---
|
||||
|
||||
Four distinct environments — use correct terminology:
|
||||
|
||||
| Name | What | Where | Backend | Deploy |
|
||||
|------|------|-------|---------|--------|
|
||||
| **Dev mode** | Local macOS, mock backend | `localhost:8100` | `mock-backend.js` on `:5959` | `npm run dev:mock` |
|
||||
| **Dev server / Prod** | Primary build/test/live server | `192.168.1.228` (+ fleet) | Real Rust backend + Podman | `deploy-to-target.sh --live` |
|
||||
| **Demo** | Public demo instance | Remote server | Mock Node.js via Docker | Portainer Stacks / `docker-compose.demo.yml` |
|
||||
|
||||
- Dev server and prod are the SAME machine (192.168.1.228) — "prod" just means "the live deployment"
|
||||
- Demo is completely separate — user deploys via Portainer UI, Claude has no SSH access
|
||||
- Dev mode is local-only, no containers needed, fastest iteration
|
||||
|
||||
**Why:** User corrected ambiguous usage of "dev servers (prod)" — these are the same thing, not two separate environments.
|
||||
|
||||
**How to apply:** Always say "dev mode" for local mock, "dev server" or "prod" for 228, "demo" for the Portainer instance. Never conflate them.
|
||||
44
.claude/memory/project_repo_cleanup_and_dev_env.md
Normal file
44
.claude/memory/project_repo_cleanup_and_dev_env.md
Normal file
@@ -0,0 +1,44 @@
|
||||
---
|
||||
name: v1.3.0 Session Status (March 20)
|
||||
description: Tor management system, bug fixes, federation name sync — cloud files working both ways
|
||||
type: project
|
||||
---
|
||||
|
||||
## Deployed to .228 + .198
|
||||
|
||||
### What's Live
|
||||
- Full Tor hidden service management (systemd path unit pattern — tor-helper.sh)
|
||||
- Container doctor: system Tor preferred, archy-tor container removed
|
||||
- Federation name sync: server rename pushes to peers
|
||||
- Cloud files working both ways over Tor
|
||||
- Arch channel local echo for sent messages
|
||||
- Web5 Message button → Mesh redirect
|
||||
- Node names in federation/peers
|
||||
- PeerFiles header shows name + DID (not onion)
|
||||
- Connected Nodes flex height
|
||||
- Server name persistence (root-owned file fixed)
|
||||
- Tor services UI: add from installed apps, delete, restart, auth/protocol badges
|
||||
- Layout: Network Interfaces + Tor Services stack on normal screens
|
||||
|
||||
### Architecture: Tor Management
|
||||
- Backend writes staged torrc + action file to /var/lib/archipelago/tor-config/
|
||||
- systemd path unit (archipelago-tor-helper.path) triggers root-level service
|
||||
- tor-helper.sh processes actions: write-torrc-and-restart, restart, delete-service, sync-hostnames
|
||||
- NoNewPrivileges=yes safe — no sudo from backend
|
||||
- Container doctor ensures system Tor stays running after deploys
|
||||
- Web apps: port 80 on .onion → local app port; Protocol services: direct port
|
||||
|
||||
### Onion Addresses (current)
|
||||
- .228 archipelago: r33p5uzk2vxhdte4a5pfqgeax44a7b2lx57q32dxmx5llzyfz42lwnyd.onion
|
||||
- .198 archipelago: mxn62m4odavwctlpsq2ozvhy3ibjpenlzemumwtkev7wviikttxvjhyd.onion
|
||||
|
||||
### Still TODO
|
||||
1. **Tor channel chat** — messages via Archipelago channel need testing/polish
|
||||
2. **ISO build** — update build-auto-installer-iso.sh with tor-helper, systemd units, container doctor changes
|
||||
3. **Better error messaging** — when nodes are down, addresses changed, all situations
|
||||
4. **File access permissions** — public (no auth), federated (full access), peer-set (specific files)
|
||||
5. **Auth on Tor app access** — login before accessing app via .onion (post-beta candidate)
|
||||
6. **.198 health check** — deploy health check times out on .198 (backend works, likely timing)
|
||||
|
||||
**Why:** Session continuity for v1.3.0 beta stabilization effort.
|
||||
**How to apply:** Read at start of next session. Work on TODO items in order.
|
||||
145
.claude/plans/memoized-plotting-sifakis.md
Normal file
145
.claude/plans/memoized-plotting-sifakis.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# Architecture Review — Fix Remaining Issues
|
||||
|
||||
## Context
|
||||
|
||||
The architecture review (`docs/architecture-review.html`) identified 4 P0, 6 P1, and 6 medium-priority issues across the codebase. After research, **all 4 P0s and 4 of 6 P1s are already fixed**. This plan addresses the remaining open items that improve reliability and security during the beta freeze.
|
||||
|
||||
**What's already fixed:** P0-1 (health RPC), P0-2 (health checks), P0-3 (backup rollback), P0-4 (nginx protections), P1-B (rate limiter cleanup), P1-C (systemd limits), P1-E (WS reconnect), P1-F (Vue error handler), Issue 11 (session async I/O).
|
||||
|
||||
**What we're fixing now (4 items):**
|
||||
|
||||
---
|
||||
|
||||
## Item 1: Add 10s timeout to 6 bare `client.connect()` calls — DONE
|
||||
|
||||
**Why:** A down Nostr relay hangs the async task indefinitely, blocking identity publishing, node discovery, and marketplace operations. Direct uptime impact.
|
||||
|
||||
### Files & locations
|
||||
|
||||
| File | Line | Function |
|
||||
|------|------|----------|
|
||||
| `core/archipelago/src/identity_manager.rs` | 409 | `publish_profile()` |
|
||||
| `core/archipelago/src/nostr_discovery.rs` | 113 | `publish_node_revocation()` |
|
||||
| `core/archipelago/src/nostr_discovery.rs` | 200 | `verify_revocation()` |
|
||||
| `core/archipelago/src/nostr_discovery.rs` | 264 | `discover_archipelago_nodes()` |
|
||||
| `core/archipelago/src/marketplace.rs` | 298 | `discover()` |
|
||||
| `core/archipelago/src/marketplace.rs` | 406 | `publish()` |
|
||||
|
||||
### Pattern (from `nostr_handshake.rs:126`)
|
||||
|
||||
Replace each `client.connect().await;` with:
|
||||
```rust
|
||||
if tokio::time::timeout(Duration::from_secs(10), client.connect()).await.is_err() {
|
||||
tracing::warn!("Nostr relay connection timed out after 10s, continuing anyway");
|
||||
}
|
||||
```
|
||||
|
||||
Ensure `use std::time::Duration;` is imported in each file. `tracing::warn!` is already available in all three files.
|
||||
|
||||
### Risk: LOW — Mechanical pattern replication, no logic changes.
|
||||
|
||||
---
|
||||
|
||||
## Item 2: Pin all crypto dependency versions exactly — DONE
|
||||
|
||||
**Why:** Floating versions (`"2.1"` instead of `"2.2.0"`) allow `cargo update` to silently change crypto libraries. Supply chain risk + project rules violation.
|
||||
|
||||
### Versions (verified from Cargo.lock)
|
||||
|
||||
**`core/archipelago/Cargo.toml`:**
|
||||
|
||||
| Line | Current | Pin to |
|
||||
|------|---------|--------|
|
||||
| 44 | `sha2 = "0.10"` | `"0.10.9"` |
|
||||
| 45 | `hmac = "0.12"` | `"0.12.1"` |
|
||||
| 50 | `ed25519-dalek = { version = "2.1", ... }` | `version = "2.2.0"` |
|
||||
| 51 | `curve25519-dalek = "4"` | `"4.1.3"` |
|
||||
| 52 | `rand = "0.8"` | `"0.8.5"` |
|
||||
| 69 | `argon2 = "0.5"` | `"0.5.3"` |
|
||||
| 70 | `chacha20poly1305 = "0.10"` | `"0.10.1"` |
|
||||
| 81 | `zeroize = { version = "1.7", ... }` | `version = "1.8.2"` |
|
||||
| 92 | `hkdf = "0.12"` | `"0.12.4"` |
|
||||
|
||||
**`core/security/Cargo.toml`:**
|
||||
|
||||
| Line | Current | Pin to |
|
||||
|------|---------|--------|
|
||||
| 16 | `aes-gcm = "0.10"` | `"0.10.3"` |
|
||||
| 17 | `rand = "0.8"` | `"0.8.5"` |
|
||||
| 19 | `zeroize = { version = "1", ... }` | `version = "1.8.2"` |
|
||||
|
||||
**Note:** `core/models/Cargo.toml` has `ed25519-dalek = "2.0.0"` but this crate is NOT in the workspace — it's dead code. Skip it.
|
||||
|
||||
### Risk: LOW — Pins to versions already resolved in Cargo.lock. No actual dependency changes.
|
||||
|
||||
---
|
||||
|
||||
## Item 3: Pin all floating container image tags — DONE
|
||||
|
||||
**Why:** Floating tags (`:1`, `:7`, `:alpine`, `:main`) mean two installs a week apart get different software. Supply chain risk and a support nightmare.
|
||||
|
||||
### File: `scripts/image-versions.sh`
|
||||
|
||||
| Line | Variable | Current Tag | Action |
|
||||
|------|----------|-------------|--------|
|
||||
| 16 | `MARIADB_IMAGE` | `:11.4` | SSH -> get exact patch version |
|
||||
| 21 | `POSTGRES_IMAGE` | `:15` | SSH -> get exact patch version |
|
||||
| 22 | `BTCPAY_POSTGRES_IMAGE` | `:15` | SSH -> get exact patch version |
|
||||
| 25 | `HOMEASSISTANT_IMAGE` | `:2024.12` | SSH -> get exact patch version |
|
||||
| 27 | `UPTIME_KUMA_IMAGE` | `:1` | SSH -> get exact patch version |
|
||||
| 32 | `NEXTCLOUD_IMAGE` | `:29` | SSH -> get exact patch version |
|
||||
| 34 | `ONLYOFFICE_IMAGE` | `:8.2` | SSH -> get exact patch version |
|
||||
| 35 | `FILEBROWSER_IMAGE` | `:v2` | SSH -> get exact patch version |
|
||||
| 36 | `NPM_IMAGE` | `:2` | SSH -> get exact patch version |
|
||||
| 49 | `REDIS_IMAGE` | `:7` | SSH -> get exact patch version |
|
||||
| 52 | `VALKEY_IMAGE` | `:8` | SSH -> get exact patch version |
|
||||
| 60 | `INDEEDHUB_POSTGRES_IMAGE` | `:16-alpine` | SSH -> get exact patch version |
|
||||
| 61 | `INDEEDHUB_REDIS_IMAGE` | `:7-alpine` | SSH -> get exact patch version |
|
||||
| 64 | `DWN_SERVER_IMAGE` | `:main` | SSH -> get image digest, pin by SHA or tag |
|
||||
| 68 | `NGINX_ALPINE_IMAGE` | `:alpine` | SSH -> get exact version |
|
||||
|
||||
### Pre-work required
|
||||
Run on 192.168.1.228: `podman images --format '{{.Repository}}:{{.Tag}}'` to get exact versions currently deployed. Pin to THOSE — don't upgrade.
|
||||
|
||||
### Risk: MEDIUM — Must match what's actually running. Wrong pin = containers fail on next creation.
|
||||
|
||||
---
|
||||
|
||||
## Item 4: Add CI pipeline for Rust + frontend checks — DONE
|
||||
|
||||
**Why:** No tests or linting run in CI. Regressions from Items 1-3 (and all future beta fixes) go undetected until they hit the server.
|
||||
|
||||
### File to create: `.github/workflows/ci.yml`
|
||||
|
||||
Two parallel jobs:
|
||||
1. **`rust`** (ubuntu-latest): `cargo fmt --check` -> `cargo clippy -D warnings` -> `cargo test`
|
||||
2. **`frontend`** (ubuntu-latest): `npm ci` -> `npm run type-check` -> `npm test`
|
||||
|
||||
Trigger: push to `main` + all PRs. Reference existing `build-macos.yml` for action versions (checkout@v4, setup-node@v4 with Node 18).
|
||||
|
||||
### Risk: LOW — Additive only, new file, doesn't affect existing workflows.
|
||||
|
||||
---
|
||||
|
||||
## Execution Order
|
||||
|
||||
1. **Item 1** (Nostr timeouts) — lowest risk, immediate reliability gain
|
||||
2. **Item 2** (crypto pins) — batch with Item 1 for single deploy
|
||||
3. **Item 3** (container image pins) — requires SSH query first
|
||||
4. **Item 4** (CI) — validates everything, no deploy needed
|
||||
|
||||
Items 1+2 deploy together. Item 3 deploys separately (script only). Item 4 is push-only.
|
||||
|
||||
## Verification
|
||||
|
||||
- Items 1+2: `cargo clippy --all-targets --all-features` on dev server (zero warnings), then deploy + test identity/discovery/marketplace features
|
||||
- Item 3: `source scripts/image-versions.sh` + verify all vars have exact patch versions
|
||||
- Item 4: Push to branch, verify both CI jobs pass green on GitHub Actions
|
||||
|
||||
## Deferred (post-beta)
|
||||
|
||||
- Issue 6: Generate TS types from Rust (ts-rs) — new dependency
|
||||
- Issue 7: Consolidate container metadata to single source — structural refactor
|
||||
- Issue 8: Split deploy/ISO scripts into modules — already planned in script comments
|
||||
- Issue 9: Single app manifest driving all 6+ locations — architectural change
|
||||
- Issue 12: useAsyncState composable — touches 14+ views, risky during freeze
|
||||
File diff suppressed because it is too large
Load Diff
119
.claude/plans/tailscale-migration.md
Normal file
119
.claude/plans/tailscale-migration.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# Plan: Seamless Tailscale Migration for Alpha Testers
|
||||
|
||||
## Context
|
||||
|
||||
Tailscale nodes (Arch 1/2/3) are alpha tester machines. They need full deployment — binary, frontend, infrastructure, and containers — with zero friction. Currently `deploy-tailscale.sh` only deploys binary + frontend (85 lines), missing ALL infrastructure that `deploy-to-target.sh --live` provides (rootless prereqs, UID mapping, containers, nginx, Tor, HTTPS, dev mode, UFW, etc.).
|
||||
|
||||
These nodes may also have old **rootful** containers that need migrating to rootless.
|
||||
|
||||
## Approach
|
||||
|
||||
**Don't refactor the 1615-line deploy-to-target.sh** — too risky during beta freeze. Instead:
|
||||
|
||||
1. **Rewrite `deploy-tailscale.sh`** as a full-deploy script with split-mode SSH resilience
|
||||
2. **Add `--tailscale` flag** to `deploy-to-target.sh` as a convenience wrapper
|
||||
3. **Add rootful→rootless migration** as an automatic pre-step
|
||||
4. **Fix `first-boot-containers.sh`** for rootless (separate concern, for ISO builds)
|
||||
|
||||
## Changes
|
||||
|
||||
### 1. Rewrite `scripts/deploy-tailscale.sh` (~400 lines)
|
||||
|
||||
Currently 85 lines doing only binary+frontend. Rewrite to be a full deploy for any node, using split-mode SSH (each step = separate short SSH session) for Tailscale stability.
|
||||
|
||||
**Steps the new script will run (each as its own SSH session):**
|
||||
|
||||
1. SSH connectivity check
|
||||
2. Install prerequisites (rsync, node, npm) if missing
|
||||
3. Rsync code to target
|
||||
4. **Rootful→rootless migration** (detect `sudo podman ps -a`, stop & remove old rootful containers)
|
||||
5. Build frontend (nohup + poll, or skip if copy-only node)
|
||||
6. Build backend (nohup + poll, or skip if copy-only node)
|
||||
7. Create rollback backup
|
||||
8. Deploy binary (build locally or copy from .228)
|
||||
9. Deploy frontend (build locally or copy from .228)
|
||||
10. Deploy AIUI
|
||||
11. Sync nginx config + HTTPS snippets
|
||||
12. Sync systemd service
|
||||
13. **Setup rootless prereqs** (sysctl, linger, podman.socket)
|
||||
14. **Create data dirs + UID mapping** (full chown table from deploy-to-target.sh:670-689)
|
||||
15. **Dev mode** (ARCHIPELAGO_DEV_MODE=true for HTTP cookies over Tailscale)
|
||||
16. Deploy nostr-provider.js
|
||||
17. Deploy Claude API proxy (if ANTHROPIC_API_KEY available)
|
||||
18. Setup NTP + swap
|
||||
19. Restart services
|
||||
20. **Setup HTTPS** (with node's own IP in SAN)
|
||||
21. **Read Bitcoin RPC credentials** from server secrets
|
||||
22. **Create all containers** (Bitcoin, Mempool, BTCPay, ElectrumX, LND, Fedimint, Immich, HA, Grafana, Jellyfin, Vaultwarden, SearXNG, FileBrowser)
|
||||
23. **Setup Tor** hidden services
|
||||
24. **Fix UFW** forward policy
|
||||
25. **Fix IndeedHub** NIP-07 (if running)
|
||||
26. **Transfer custom images** for copy-only nodes (individual tarballs, never combined)
|
||||
27. Run container doctor
|
||||
28. Write deploy manifest
|
||||
29. Post-deploy health check
|
||||
|
||||
**Copy-only mode**: When target can't build (Arch 1/3), script detects no `cargo`/`npm` on target and copies pre-built artifacts from .228 via SSH pipe.
|
||||
|
||||
**Key sections to port from deploy-to-target.sh:**
|
||||
- Lines 646-689 — rootless prereqs + UID mapping
|
||||
- Lines 629-641 — dev mode
|
||||
- Lines 839-1474 — all container creation
|
||||
- Lines 1143-1234 — Tor setup
|
||||
- Lines 1477-1485 — UFW fix
|
||||
- Lines 1487-1545 — IndeedHub NIP-07
|
||||
|
||||
### 2. Add `--tailscale` flag to `deploy-to-target.sh` (~30 lines)
|
||||
|
||||
Wrapper that calls `deploy-tailscale.sh` for each node sequentially. Also add `--tailscale-node=arch1|arch2|arch3` for single-node targeting.
|
||||
|
||||
### 3. Rootful→rootless migration (in deploy-tailscale.sh step 4)
|
||||
|
||||
Auto-detect and handle:
|
||||
```
|
||||
ssh TARGET 'ROOTFUL=$(sudo podman ps -a 2>/dev/null | wc -l); if [ $ROOTFUL -gt 1 ]; then sudo podman stop --all; sudo podman rm --all; fi'
|
||||
```
|
||||
Data safe — `/var/lib/archipelago/` never deleted, only ownership fixed by UID mapping step.
|
||||
|
||||
### 4. Fix `scripts/first-boot-containers.sh` (5 targeted edits)
|
||||
|
||||
- **Line 15**: Change root check → archipelago user check (UID 1000)
|
||||
- **Line 140**: Change `10.88.0.0/16` → `0.0.0.0/0` (match deploy-to-target.sh)
|
||||
- **After line 111**: Add rootless prereqs (sysctl, linger, podman.socket)
|
||||
- **After line 113**: Add full UID mapping block
|
||||
- **Pin `:latest` tags**: photoprism, ollama, searxng, nginx-proxy-manager, penpot
|
||||
|
||||
### 5. Update `scripts/setup-https-dev.sh`
|
||||
|
||||
Dynamic SAN — detect node's own IPs (including Tailscale interface) instead of hardcoding .228/.198.
|
||||
|
||||
## Files Modified
|
||||
|
||||
| File | Change | ~Lines |
|
||||
|------|--------|--------|
|
||||
| `scripts/deploy-tailscale.sh` | Full rewrite — complete deploy with split-mode SSH | ~400 |
|
||||
| `scripts/deploy-to-target.sh` | Add `--tailscale` / `--tailscale-node` flags | ~30 |
|
||||
| `scripts/first-boot-containers.sh` | Fix for rootless (subnet, UID mapping, prereqs) | ~40 |
|
||||
| `scripts/setup-https-dev.sh` | Dynamic SAN with Tailscale IPs | ~15 |
|
||||
| `docs/BETA-PROGRESS.md` | Update TASK-11 status | ~5 |
|
||||
|
||||
## Auth State Preservation
|
||||
|
||||
All user state in `/var/lib/archipelago/` is **never touched** by deploys:
|
||||
- `sessions.json`, `user.json`, `identities/`, `secrets/`, `federation/`
|
||||
|
||||
## Verification
|
||||
|
||||
1. Deploy to Arch 2 first (has build tools, safest test)
|
||||
2. Then Arch 1/3 (copy-only mode)
|
||||
3. For each node: `podman ps` shows containers, `curl /health` returns 200, UI loads, login works
|
||||
4. Run container doctor — 0 fixes needed
|
||||
|
||||
## Order
|
||||
|
||||
1. Rewrite `deploy-tailscale.sh` (main deliverable)
|
||||
2. Add `--tailscale` flags to `deploy-to-target.sh`
|
||||
3. Fix `first-boot-containers.sh`
|
||||
4. Update `setup-https-dev.sh`
|
||||
5. Test: Arch 2 → Arch 1 → Arch 3
|
||||
6. Update BETA-PROGRESS.md
|
||||
47
.gitea/workflows/build-iso.yml
Normal file
47
.gitea/workflows/build-iso.yml
Normal file
@@ -0,0 +1,47 @@
|
||||
name: Build Archipelago ISO
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-iso:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Build backend
|
||||
run: |
|
||||
source $HOME/.cargo/env 2>/dev/null || true
|
||||
cargo build --release --manifest-path core/Cargo.toml
|
||||
sudo rm -f /usr/local/bin/archipelago
|
||||
sudo cp core/target/release/archipelago /usr/local/bin/archipelago
|
||||
sudo systemctl restart archipelago 2>/dev/null || true
|
||||
|
||||
- name: Build frontend
|
||||
run: |
|
||||
echo "PWD: $(pwd)"
|
||||
ls -la neode-ui/package.json || echo "neode-ui/package.json NOT FOUND"
|
||||
cd neode-ui
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Build unbundled ISO
|
||||
run: |
|
||||
cd image-recipe
|
||||
sudo UNBUNDLED=1 DEV_SERVER=localhost BUILD_FROM_SOURCE=0 ./build-auto-installer-iso.sh
|
||||
|
||||
- name: Copy to Builds
|
||||
run: |
|
||||
ISO=$(ls image-recipe/results/archipelago-installer-unbundled-*.iso 2>/dev/null | head -1)
|
||||
if [ -n "$ISO" ]; then
|
||||
DATE=$(date +%Y%m%d-%H%M)
|
||||
sudo cp "$ISO" "/var/lib/archipelago/filebrowser/Builds/archipelago-unbundled-${DATE}.iso"
|
||||
sudo chown 1000:1000 "/var/lib/archipelago/filebrowser/Builds/archipelago-unbundled-${DATE}.iso"
|
||||
echo "ISO: archipelago-unbundled-${DATE}.iso"
|
||||
fi
|
||||
@@ -1,45 +0,0 @@
|
||||
name: Nightly Security Review
|
||||
on:
|
||||
schedule:
|
||||
- cron: '47 1 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
security-review:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Claude Code
|
||||
run: npm install -g @anthropic-ai/claude-code
|
||||
|
||||
- name: Run security review on recent changes
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
run: |
|
||||
CHANGED=$(git diff --name-only HEAD~1..HEAD 2>/dev/null || echo "")
|
||||
if [ -z "$CHANGED" ]; then
|
||||
echo "No recent changes to review"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
claude --print "Run a security review focused on these recently changed files:
|
||||
$CHANGED
|
||||
|
||||
Check for:
|
||||
- Constant-time comparison violations in crypto code
|
||||
- Private key material in logs or error messages
|
||||
- Floating-point Bitcoin amounts (must be integer sats)
|
||||
- eval() or unsafe blocks without SAFETY comments
|
||||
- Hardcoded credentials or secrets
|
||||
- Missing input validation at API boundaries
|
||||
|
||||
Output a structured report with severity levels.
|
||||
If any CRITICAL issues found, exit with code 1." > security-report.txt 2>&1
|
||||
|
||||
cat security-report.txt
|
||||
|
||||
if grep -qi "critical" security-report.txt; then
|
||||
echo "::error::Critical security issues found — review security-report.txt"
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,29 +0,0 @@
|
||||
name: Weekly Dependency Audit
|
||||
on:
|
||||
schedule:
|
||||
- cron: '13 2 * * 0'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Rust dependency audit
|
||||
run: |
|
||||
cargo install cargo-audit 2>/dev/null || true
|
||||
echo "=== Cargo Audit ==="
|
||||
cargo audit 2>&1 | tee cargo-audit.txt || true
|
||||
|
||||
echo ""
|
||||
echo "=== Version Pinning Check ==="
|
||||
grep -n '"\*"' Cargo.toml || echo "No wildcard versions found"
|
||||
|
||||
- name: Check for critical vulnerabilities
|
||||
run: |
|
||||
if grep -qi "RUSTSEC.*critical\|vulnerability found" cargo-audit.txt 2>/dev/null; then
|
||||
echo "::error::Critical Rust dependency vulnerabilities found"
|
||||
exit 1
|
||||
fi
|
||||
echo "No critical vulnerabilities detected"
|
||||
65
.github/workflows/ci.yml
vendored
Normal file
65
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
RUST_VERSION: stable
|
||||
NODE_VERSION: 18
|
||||
|
||||
jobs:
|
||||
rust:
|
||||
name: Rust (fmt + clippy + test)
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: core
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Rust
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: ${{ env.RUST_VERSION }}
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Check formatting
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Clippy
|
||||
run: cargo clippy --all-targets --all-features -- -D warnings
|
||||
|
||||
- name: Tests
|
||||
run: cargo test --all-features
|
||||
|
||||
frontend:
|
||||
name: Frontend (type-check + lint)
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: neode-ui
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
cache-dependency-path: neode-ui/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Type check
|
||||
run: npm run type-check
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
101
CHANGELOG.md
101
CHANGELOG.md
@@ -7,6 +7,107 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.3.1] - 2026-03-25
|
||||
|
||||
### Security
|
||||
- All crypto dependencies pinned to exact versions from Cargo.lock (supply chain hardening)
|
||||
- ed25519-dalek 2.1 → 2.2.0, sha2 → 0.10.9, hmac → 0.12.1, argon2 → 0.5.3, chacha20poly1305 → 0.10.1, zeroize → 1.8.2, hkdf → 0.12.4, aes-gcm → 0.10.3
|
||||
- All container images pinned to exact patch versions (no more floating tags)
|
||||
- postgres:15 → 15.17, redis:7 → 7.4.8, nginx:alpine → 1.29.6-alpine, uptime-kuma:1 → 1.23.17, nextcloud:29 → 29.0.16, valkey:8 → 8.1.6, mariadb:11.4 → 11.4.10, and 7 more
|
||||
- DWN server pinned by SHA256 digest (only has `:main` branch tag)
|
||||
|
||||
### Reliability
|
||||
- Nostr relay connections now have 10s timeout — prevents indefinite hangs blocking RPC calls
|
||||
- identity_manager.rs: publish_profile()
|
||||
- nostr_discovery.rs: publish_node_revocation(), verify_revocation(), discover_archipelago_nodes()
|
||||
- marketplace.rs: discover(), publish()
|
||||
|
||||
### Infrastructure
|
||||
- CI pipeline added (.github/workflows/ci.yml) — cargo fmt, clippy, tests + frontend type-check, build
|
||||
- Update system now fetches from git.tx1138.com Gitea instance (configurable via ARCHIPELAGO_UPDATE_URL)
|
||||
- Cleaned up stale git branches (app-store, overnight/2026-03-12, overnight/2026-03-13)
|
||||
|
||||
## [1.3.0] - 2026-03-19
|
||||
|
||||
### Security
|
||||
|
||||
#### Pentest Remediation (33 findings, all addressed)
|
||||
- **Critical**: Backend now binds to 127.0.0.1 only — no more direct LAN access to port 5678
|
||||
- **Critical**: Fixed path traversal in Tor service management that could allow `sudo rm -rf` on arbitrary directories
|
||||
- **Critical**: Fixed unauthenticated file read/delete via DWN recordId path traversal
|
||||
- **High**: Federation peers now require cryptographic signature — unsigned peers rejected
|
||||
- **High**: Login redirect XSS vulnerability fixed with proper URL validation
|
||||
- **High**: Viewer role restricted to read-only node methods (was granting sign/export access)
|
||||
- **High**: Backup restore/verify now validates IDs against path traversal
|
||||
- **High**: Tar archive extraction validates every entry path (prevents tar slip attacks)
|
||||
- **High**: S3 backup endpoints require HTTPS and reject private IP ranges
|
||||
- **Medium**: Remember-me token secret now uses cryptographic random (not machine-id)
|
||||
- **Medium**: Destructive operations (factory reset, onboarding reset) now require password re-verification
|
||||
- **Medium**: Session token rotated after TOTP verification (prevents interception reuse)
|
||||
- **Medium**: Webhook URL validation hardened against IPv6 bypass, DNS rebinding, redirect chains
|
||||
- **Low**: CORS localhost:8100 only included in dev mode
|
||||
- **Low**: CSP `unsafe-inline` removed from `script-src`
|
||||
- **Low**: Content filenames validated against path separators and hidden file prefixes
|
||||
- **Low**: Nostr relay URLs restricted to `wss://` with private IP rejection
|
||||
- **Low**: Onion address validation enforces v3 format (56 base32 chars)
|
||||
- **Low**: Router detection restricted to private IP ranges only
|
||||
|
||||
#### Nginx Authentication
|
||||
- Fixed session cookie name mismatch (`session_id` → `session`) across all nginx auth checks
|
||||
- LND Connect info endpoint now properly authenticated
|
||||
|
||||
### Container Reliability
|
||||
|
||||
#### Memory Limits (prevents OOM crashes)
|
||||
- All 37 containers in `first-boot-containers.sh` now have `--memory=` limits
|
||||
- Automatic RAM tier detection — reduced limits on 8GB machines
|
||||
- Prevents a single runaway container from crashing the entire system
|
||||
|
||||
#### Smart Container States
|
||||
- New `exited` state distinguishes crashed containers from intentionally stopped ones
|
||||
- Crashed containers show red "crashed" badge with restart button
|
||||
- Health-aware status: "healthy" (green), "starting up" (yellow spinner), "unhealthy" (orange pulse)
|
||||
- Restart button added next to Stop on running containers
|
||||
|
||||
#### Crash Recovery Improvements
|
||||
- Boot recovery and health monitor now coordinate via shared flag (no more restart cascade)
|
||||
- User-stopped containers tracked in `user-stopped.json` — survive reboots without auto-restart
|
||||
- Boot recovery uses tiered ordering: databases → core → services → apps → UIs
|
||||
- Health monitor waits for boot recovery to complete before starting checks
|
||||
|
||||
### UI Improvements
|
||||
|
||||
#### Home Dashboard
|
||||
- Wallet card now matches Web5 wallet display
|
||||
- New Transactions modal with full history (incoming/outgoing, amounts, confirmations)
|
||||
- Transactions button in header — switches to "Incoming" badge when pending transactions exist
|
||||
- Dev faucet button (dev mode only) with mutable wallet state
|
||||
- Fixed system stats crash (`cpu_usage_percent` field name mismatch)
|
||||
|
||||
#### Apps & App Details
|
||||
- Container restart button (icon) next to Stop on all running apps
|
||||
- Exited/crashed containers show "Restart" instead of "Start" with red styling
|
||||
- Removed broken sticky header from Apps page
|
||||
- Health-aware status badges throughout
|
||||
|
||||
#### Mesh, Cloud, Settings & More
|
||||
- Mesh view overhaul with improved layout
|
||||
- Glass button styling updates across components
|
||||
- New BaseModal and ToggleSwitch components
|
||||
- Updated translations (English + Spanish)
|
||||
- Spotlight search improvements
|
||||
|
||||
### Infrastructure
|
||||
|
||||
#### LND Connect
|
||||
- Tor hidden service now exposes LND REST port (8080) for remote wallet connections
|
||||
- Fixed in ISO build script, deploy script, and live servers
|
||||
|
||||
#### Dev Environment
|
||||
- Mock backend has mutable wallet state (faucet/send/receive actually change balances)
|
||||
- Testnet stack option auto-starts Podman machine on macOS
|
||||
- Boot mode simulation for testing startup screens
|
||||
|
||||
## [1.2.0] - 2026-03-14
|
||||
|
||||
### Fixed
|
||||
|
||||
44
CLAUDE.md
44
CLAUDE.md
@@ -261,6 +261,50 @@ sshpass -p 'EwPDR8q45l0Upx@' ssh -o StrictHostKeyChecking=no archipelago@192.168
|
||||
Single source of truth: `neode-ui/public/assets/img/app-icons/`
|
||||
Naming: `{app-id}.{png|webp|svg}` — do not duplicate elsewhere.
|
||||
|
||||
## Security Standards (Post-Pentest — Mandatory)
|
||||
|
||||
These rules come from a full penetration test (33 findings, all remediated). Follow them for ALL new code.
|
||||
|
||||
### Backend (Rust)
|
||||
|
||||
- **Backend binds to 127.0.0.1 ONLY** — never `0.0.0.0`. All external access goes through nginx.
|
||||
- **Validate ALL user input before path construction** — reject `..`, `/`, `\`, null bytes. Use the existing `validate_app_id()` pattern in `tor.rs`.
|
||||
- **Never pass user input to `sudo` commands** — if unavoidable, validate strictly against an allowlist of characters `[a-zA-Z0-9_-]`.
|
||||
- **Every HTTP endpoint that returns sensitive data MUST check authentication** — use `self.is_authenticated(&headers).await` or be in `UNAUTHENTICATED_METHODS` with justification.
|
||||
- **Rate-limit authentication endpoints** — `extract_client_ip()` must only trust `X-Real-IP` from the loopback interface (127.0.0.1).
|
||||
- **Federation messages require ed25519 signatures** — never accept unsigned peer-joined messages.
|
||||
- **RBAC: use explicit allowlists, not prefix matching** — `method.starts_with("node.")` is BANNED. List exact methods per role.
|
||||
- **Session cookies: `SameSite=Lax; HttpOnly; Path=/`** — `Strict` breaks iframe app fetches. `Lax` still prevents CSRF on POST.
|
||||
- **Destructive operations require password re-verification** — factory reset, onboarding reset, identity export.
|
||||
- **Remember-me secrets: use `OsRng` random bytes** — never derive from `/etc/machine-id` or other public data.
|
||||
- **Rotate session tokens after privilege escalation** — TOTP verification must issue a new token, invalidating the pending one.
|
||||
- **Tar archive extraction: validate every entry path** — never use `archive.unpack()`. Iterate entries and verify no `..` components or paths escaping the target directory.
|
||||
|
||||
### Frontend (Vue/TypeScript)
|
||||
|
||||
- **Validate redirect URLs** — use `isLocalRedirect()` from `router/index.ts` before any `window.location.href` assignment. Reject `javascript:`, protocol-relative (`//`), and external URLs.
|
||||
- **Never use `v-html` with user input** — if unavoidable, always sanitize with `DOMPurify.sanitize()`.
|
||||
- **CSP: no `unsafe-inline` in `script-src`** — Vite builds don't need it. Keep `unsafe-inline` only in `style-src` for Tailwind.
|
||||
|
||||
### Nginx
|
||||
|
||||
- **Session validation: `$cookie_session` (not `$cookie_session_id`)** — cookie name must match the Rust backend's `session=` cookie.
|
||||
- **Prefer `auth_request` over cookie-presence checks** — `if ($cookie_session = "")` only checks presence, not validity. For sensitive endpoints, use nginx `auth_request` to validate against the backend.
|
||||
- **All `/app/*` proxies are unauthenticated at nginx level** — each app must handle its own auth. Never expose apps with default credentials (change Grafana `admin/admin` on first boot, etc.).
|
||||
|
||||
### SSRF Prevention
|
||||
|
||||
- **Validate all user-supplied URLs** — require `https://` scheme, reject private IP ranges (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, ::1, fc00::/7).
|
||||
- **Disable redirect following** — use `redirect(Policy::none())` on reqwest clients that fetch user-supplied URLs.
|
||||
- **Onion addresses: validate v3 format** — exactly 56 base32 `[a-z2-7]` chars + `.onion`.
|
||||
- **Webhook URLs: parse with `Url::parse`** — don't split on `:` for host extraction (breaks IPv6).
|
||||
|
||||
### Container Security
|
||||
|
||||
- **Memory limits on every container** — use `--memory=$(mem_limit <name>)` pattern from `first-boot-containers.sh`. Prevents one container from OOM-killing the system.
|
||||
- **Health checks on every container** — define via `--health-cmd` in `podman run`.
|
||||
- **User-stopped tracking** — when a user stops a container via UI, record in `user-stopped.json` so crash recovery and health monitor don't auto-restart it.
|
||||
|
||||
## Code Quality
|
||||
|
||||
- Zero compiler warnings (Rust and TypeScript)
|
||||
|
||||
105
README.md
105
README.md
@@ -2,39 +2,60 @@
|
||||
|
||||
> Self-Sovereign Bitcoin Node OS
|
||||
|
||||
**Archipelago** is a bootable personal server OS. Flash it to a USB drive, install on any x86_64 or ARM64 machine, and manage Bitcoin infrastructure, self-hosted apps, and Web5 identity through a modern web interface.
|
||||
**Archipelago** is a bootable personal server OS. Flash it to a USB drive, install on any x86_64 or ARM64 machine, and manage Bitcoin infrastructure, self-hosted apps, and decentralized identity through a glassmorphism web UI.
|
||||
|
||||
[](https://www.debian.org/)
|
||||
[](LICENSE)
|
||||
[](https://www.rust-lang.org/)
|
||||
[](https://vuejs.org/)
|
||||
[]()
|
||||
[]()
|
||||
|
||||
## Features
|
||||
|
||||
### Bitcoin Infrastructure
|
||||
- **Bitcoin Knots** full node with pruning support
|
||||
- **LND** Lightning Network daemon with channel management
|
||||
- **Electrs** Electrum server for wallet connectivity
|
||||
- **ElectrumX** Electrum server for wallet connectivity
|
||||
- **BTCPay Server** for accepting Bitcoin payments
|
||||
- **Mempool** block explorer and fee estimator
|
||||
- **Fedimint** federation guardian and gateway
|
||||
|
||||
### Self-Hosted Apps (20+)
|
||||
Storage (File Browser, Immich, Nextcloud), Productivity (Penpot, OnlyOffice, Vaultwarden), Media (Jellyfin), Search (SearXNG), AI (Ollama), Network (Tailscale, Nginx Proxy Manager), Home (Home Assistant), and more.
|
||||
### Self-Hosted Apps (30)
|
||||
Bitcoin (ThunderHub), Storage (FileBrowser, Immich, Nextcloud), Productivity (Penpot, OnlyOffice, Vaultwarden), Media (Jellyfin, PhotoPrism), Search (SearXNG), AI (Ollama), Network (Tailscale, Nginx Proxy Manager), Home (Home Assistant), Nostr (nostr-rs-relay, Nostrudel), Dev (Grafana, Portainer), and more.
|
||||
|
||||
### Web5 Identity
|
||||
- DID-based digital identity (Ed25519 + secp256k1)
|
||||
- Verifiable Credentials issuance and verification
|
||||
- Decentralized Web Node (DWN) for data sync
|
||||
- Nostr relay integration for node discovery
|
||||
### Decentralized Identity
|
||||
- Ed25519 node identity with DID Documents (did:key)
|
||||
- Multi-identity management (Personal/Business/Anonymous)
|
||||
- W3C Verifiable Credentials issuance and verification
|
||||
- Decentralized Web Node (DWN) with bidirectional sync over Tor
|
||||
- Nostr relay integration and NIP-07 signing for iframe apps
|
||||
|
||||
### Multi-Node Federation
|
||||
- Invite-based node joining over Tor hidden services
|
||||
- Trust levels (Trusted/Verified/Untrusted) with DID-based auth
|
||||
- Bidirectional DWN state sync between federated nodes
|
||||
- File sharing with access controls (free/peers-only/paid)
|
||||
|
||||
### Mesh Networking
|
||||
- LoRa radio communication via Meshcore protocol
|
||||
- Device discovery and mesh routing
|
||||
- Off-grid Bitcoin balance checks (planned)
|
||||
|
||||
### System Updates
|
||||
- OTA updates from self-hosted Gitea (git.tx1138.com) with SHA256 verification
|
||||
- Three update modes: Manual, Daily Check, Auto Apply (3 AM window)
|
||||
- Rollback support with automatic backup before applying
|
||||
- Full UI for update management in Settings
|
||||
|
||||
### Security
|
||||
- AES-256-GCM encrypted secrets at rest
|
||||
- Container isolation: read-only root, capability dropping, non-root user
|
||||
- ChaCha20-Poly1305 encrypted secrets at rest, Argon2id password hashing
|
||||
- Rootless Podman: read-only root, cap-drop ALL, non-root user, no-new-privileges
|
||||
- TOTP two-factor authentication
|
||||
- Per-endpoint rate limiting and input validation
|
||||
- Per-endpoint rate limiting, CSRF protection, input validation
|
||||
- AppArmor profiles for container confinement
|
||||
- Tor hidden services for all inter-node communication
|
||||
- All crypto and container dependencies pinned to exact versions
|
||||
- Full penetration test completed (33 findings, all remediated)
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -59,26 +80,25 @@ Storage (File Browser, Immich, Nextcloud), Productivity (Penpot, OnlyOffice, Vau
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
- Rust stable toolchain
|
||||
- Node.js 20+
|
||||
- Linux dev server (Debian 12) for backend builds
|
||||
- macOS or Linux for frontend development
|
||||
- Linux dev server (Debian 12) for backend builds — **never build Rust on macOS for Linux**
|
||||
- Node.js 20+, Rust stable toolchain
|
||||
|
||||
### Frontend Development
|
||||
|
||||
```bash
|
||||
cd neode-ui
|
||||
npm install
|
||||
npm start # Dev server on http://localhost:8100
|
||||
npm start # Dev server on http://localhost:8100 (mock backend on :5959)
|
||||
npm run type-check # TypeScript validation
|
||||
npm test # Run 515+ tests
|
||||
npm run build # Production build
|
||||
npm run build # Production build → web/dist/neode-ui/
|
||||
```
|
||||
|
||||
### Deploy to Server
|
||||
|
||||
```bash
|
||||
./scripts/deploy-to-target.sh --live # Deploy to dev server
|
||||
./scripts/deploy-to-target.sh --both # Deploy to both servers
|
||||
./scripts/deploy-to-target.sh --live # Deploy to primary dev server
|
||||
./scripts/deploy-to-target.sh --both # Deploy to both LAN servers
|
||||
```
|
||||
|
||||
### Build ISO
|
||||
@@ -86,40 +106,47 @@ npm run build # Production build
|
||||
```bash
|
||||
ssh archipelago@<server>
|
||||
cd ~/archy/image-recipe
|
||||
sudo ./build-auto-installer-iso.sh # x86_64
|
||||
sudo ARCH=arm64 ./build-auto-installer-iso.sh # ARM64
|
||||
sudo ./build-auto-installer-iso.sh
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Debian 12 (Bookworm)
|
||||
├── Podman (rootless containers)
|
||||
├── Nginx (reverse proxy + security headers)
|
||||
├── Rust Backend (JSON-RPC API on port 5678)
|
||||
│ ├── core/archipelago/ — RPC endpoints, state, identity
|
||||
│ ├── core/container/ — Podman client, manifests, health
|
||||
│ └── core/security/ — AppArmor, secrets, image verification
|
||||
└── Vue 3 Frontend (Composition API + TypeScript + Pinia)
|
||||
├── Rootless Podman (30 containers, archy-net DNS)
|
||||
├── Nginx (reverse proxy, security headers, rate limiting)
|
||||
├── Rust Backend (JSON-RPC API on 127.0.0.1:5678)
|
||||
│ ├── core/archipelago/ — RPC endpoints, auth, identity, federation, mesh
|
||||
│ ├── core/container/ — PodmanClient (REST API socket), manifests, health
|
||||
│ ├── core/security/ — AppArmor, secrets, Cosign image verification
|
||||
│ └── 6 more crates — models, helpers, js-engine, performance, etc.
|
||||
├── Vue 3 Frontend (Composition API + TypeScript strict + Pinia + Tailwind)
|
||||
└── System Tor (hidden services, SOCKS5 proxy)
|
||||
```
|
||||
|
||||
~49,000 lines of Rust | ~47,000 lines of TypeScript/Vue | 78 shell scripts | 30 container apps
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Architecture](docs/architecture.md) — System design
|
||||
- [Developer Guide](docs/developer-guide.md) — Contributing guide
|
||||
- [App Developer Guide](docs/app-developer-guide.md) — Writing app manifests
|
||||
- [App Manifest Spec](docs/app-manifest-spec.md) — YAML manifest format
|
||||
- [User Guide](docs/user-guide.md) — End-user documentation
|
||||
- [Release Notes](RELEASE-NOTES-v1.0.0.md) — v1.0.0 release notes
|
||||
- [v1.1 Roadmap](docs/roadmap-v1.1.md) — Upcoming features
|
||||
- [v2.0 Roadmap](docs/roadmap-v2.0.md) — Long-term vision
|
||||
| Doc | Purpose |
|
||||
|-----|---------|
|
||||
| [Architecture](docs/architecture.md) | System design, codebase stats, data paths |
|
||||
| [Architecture Review (HTML)](docs/architecture-review.html) | Interactive guide with diagrams and learning path |
|
||||
| [Developer Guide](docs/developer-guide.md) | Dev setup, workflow, code conventions |
|
||||
| [API Reference](docs/api-reference.md) | Complete RPC endpoint reference |
|
||||
| [App Developer Guide](docs/app-developer-guide.md) | Building and publishing apps |
|
||||
| [User Walkthrough](docs/user-walkthrough.md) | End-user installation and usage guide |
|
||||
| [Troubleshooting](docs/troubleshooting.md) | Diagnostic scenarios and solutions |
|
||||
| [Operations Runbook](docs/operations-runbook.md) | Ops commands and emergency recovery |
|
||||
| [Security Audit](docs/security-code-audit-2026-03.md) | Penetration test findings |
|
||||
| [Master Plan](docs/MASTER_PLAN.md) | Phased roadmap and task tracking |
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`feature/description`)
|
||||
3. Follow the coding standards in [CLAUDE.md](CLAUDE.md)
|
||||
4. Submit a pull request with tests
|
||||
4. Submit a pull request
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -1,151 +1,85 @@
|
||||
# Archipelago Apps - Development Guide
|
||||
|
||||
This directory contains all prepackaged containerized applications for Archipelago NodeOS.
|
||||
# Archipelago Apps — Development Guide
|
||||
|
||||
## App Overview
|
||||
|
||||
### Bitcoin & Lightning
|
||||
- **bitcoin-core**: Full Bitcoin node (ports: 8332, 8333) - v24.0.0
|
||||
- **lnd**: Lightning Network Daemon (ports: 9735, 10009, 8080)
|
||||
- **core-lightning**: Core Lightning implementation (ports: 9736, 9835)
|
||||
- **lightning-stack**: Complete Lightning implementation (ports: 9737, 10010, 8087) - v0.12.0
|
||||
- **btcpay-server**: Bitcoin payment processor (ports: 80, 443) - v1.12.0
|
||||
- **mempool**: Blockchain explorer (port: 4080) - v2.5.0
|
||||
- **fedimint**: Federated Bitcoin minting (ports: 8173, 8174) - v0.3.0
|
||||
| App | Ports | Version |
|
||||
|-----|-------|---------|
|
||||
| bitcoin-knots | 8332 (RPC), 8333 (P2P) | v28.1 |
|
||||
| lnd | 9735 (P2P), 10009 (gRPC), 8080 (REST) | v0.17.4-beta |
|
||||
| btcpay-server | 23000 (HTTP) | v1.13.5 |
|
||||
| thunderhub | 3010 (HTTP) | v0.13.31 |
|
||||
| mempool | 4080 (HTTP) | v2.5.0 |
|
||||
| electrumx | 50001 (TCP), 50002 (SSL) | latest |
|
||||
| fedimint | 8173 (API), 8174 (Web) | v0.10.0 |
|
||||
|
||||
### Nostr Relays
|
||||
- **nostr-rs-relay**: High-performance Rust relay (port: 8081)
|
||||
- **strfry**: Lightweight C++ relay (port: 8082)
|
||||
### Nostr
|
||||
| App | Ports | Version |
|
||||
|-----|-------|---------|
|
||||
| nostr-rs-relay | 8081 (WebSocket) | v0.9.0 |
|
||||
| nostrudel | 8082 (HTTP) | v0.40.0 |
|
||||
|
||||
### Web5 & Decentralized Protocols
|
||||
- **web5-dwn**: Decentralized Web Node (port: 3000)
|
||||
- **did-wallet**: Web5 DID Wallet (port: 8083)
|
||||
|
||||
### Self-Hosted Services
|
||||
- **home-assistant**: Home automation (port: 8123) - v2024.1.0
|
||||
- **grafana**: Monitoring and dashboards (port: 3001) - v10.2.0
|
||||
- **ollama**: Local AI models (port: 11434) - v0.1.0
|
||||
- **searxng**: Privacy search engine (port: 8888) - v2024.1.0
|
||||
- **onlyoffice**: Office suite (port: 8088) - v7.5.0
|
||||
- **penpot**: Design platform (port: 8089) - v2.0.0
|
||||
|
||||
### Custom Applications
|
||||
- **endurain**: Application platform (port: 8085) - v1.0.0
|
||||
- **morphos-server**: MorphOS server (port: 8086) - v1.0.0
|
||||
|
||||
### Mesh Networking
|
||||
- **router**: Mesh routing and network management (ports: 8084, 5353, 1900)
|
||||
- **meshtastic**: LoRa mesh networking (ports: 4403, 1883)
|
||||
|
||||
## Port Assignments
|
||||
|
||||
All apps use unique base ports. In development mode, ports are offset by 10000 (configurable).
|
||||
|
||||
See [PORTS.md](./PORTS.md) for complete port mapping.
|
||||
|
||||
Key apps:
|
||||
- **bitcoin-core**: 8332, 8333 → 18332, 18333
|
||||
- **btcpay-server**: 80, 443 → 10080, 10443
|
||||
- **home-assistant**: 8123 → 18123
|
||||
- **grafana**: 3001 → 13001
|
||||
- **mempool**: 4080 → 14080
|
||||
- **ollama**: 11434 → 21434
|
||||
- **lightning-stack**: 9737, 10010, 8087 → 19737, 20010, 18087
|
||||
### Self-Hosted
|
||||
| App | Port | Version |
|
||||
|-----|------|---------|
|
||||
| nextcloud | 8084 | v28 |
|
||||
| jellyfin | 8096 | v10.8.13 |
|
||||
| immich | 2283 | release |
|
||||
| photoprism | 2342 | v240915 |
|
||||
| vaultwarden | 8222 | v1.30.0-alpine |
|
||||
| homeassistant | 8123 | v2024.1 |
|
||||
| filebrowser | 8083 | v2.27.0 |
|
||||
| searxng | 8888 | 2024.11.17 |
|
||||
| ollama | 11434 | v0.5.4 |
|
||||
| grafana | 3001 | v10.2.0 |
|
||||
| portainer | 9000 | v2.19.4 |
|
||||
| onlyoffice | 8088 | v7.5.1 |
|
||||
| penpot | 8089 | v2.4 |
|
||||
|
||||
## Building Apps
|
||||
|
||||
### Build All Apps
|
||||
|
||||
```bash
|
||||
./build.sh
|
||||
cd apps
|
||||
./build.sh # Build all custom apps
|
||||
./build.sh <app-id> # Build specific app
|
||||
```
|
||||
|
||||
### Build Specific App
|
||||
|
||||
```bash
|
||||
./build.sh <app-id>
|
||||
```
|
||||
|
||||
### Build for Development
|
||||
|
||||
```bash
|
||||
./build.sh <app-id> --dev
|
||||
```
|
||||
Custom apps with local source: `router`, `did-wallet`, `web5-dwn`. All other apps use official container images.
|
||||
|
||||
## App Structure
|
||||
|
||||
Each app directory contains:
|
||||
|
||||
- `manifest.yml` - App manifest defining container configuration
|
||||
- `Dockerfile` - Container image definition
|
||||
- `README.md` - App-specific documentation (for custom apps)
|
||||
- Source code (for custom apps: router, did-wallet, web5-dwn)
|
||||
|
||||
## Custom Apps
|
||||
|
||||
The following apps have custom implementations:
|
||||
|
||||
1. **router** - TypeScript/Node.js mesh router
|
||||
2. **did-wallet** - TypeScript/Node.js Web5 wallet
|
||||
3. **web5-dwn** - TypeScript/Node.js DWN server
|
||||
|
||||
These apps can be developed locally:
|
||||
|
||||
```bash
|
||||
cd apps/<app-id>
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Standard Apps
|
||||
|
||||
The following apps use official Docker images:
|
||||
|
||||
- bitcoin-core (bitcoin/bitcoin:26.0)
|
||||
- lnd (lightninglabs/lnd:v0.18.0)
|
||||
- core-lightning (elementsproject/lightningd:v23.08.2)
|
||||
- btcpay-server (btcpayserver/btcpayserver:1.12.0)
|
||||
- nostr-rs-relay (scsibug/nostr-rs-relay:latest)
|
||||
- strfry (strfry/strfry:latest)
|
||||
- meshtastic (meshtastic/meshtastic:latest)
|
||||
- `manifest.yml` — Container configuration
|
||||
- `Dockerfile` — Image definition (custom apps only)
|
||||
- `README.md` — App-specific docs (custom apps only)
|
||||
- `src/` — Source code (custom apps only)
|
||||
|
||||
## Running in Development
|
||||
|
||||
### Using Archipelago Backend
|
||||
|
||||
The Archipelago backend will automatically:
|
||||
1. Build local images if they don't exist
|
||||
2. Apply port offsets in dev mode
|
||||
3. Map volumes to `/tmp/archipelago-dev/<app-id>`
|
||||
4. Start containers with proper networking
|
||||
|
||||
### Manual Testing
|
||||
|
||||
You can test apps manually:
|
||||
The Archipelago backend manages containers via rootless Podman. Install and start apps through the web UI Marketplace or via RPC:
|
||||
|
||||
```bash
|
||||
# Build the app
|
||||
./build.sh <app-id>
|
||||
|
||||
# Run with Docker/Podman
|
||||
docker run -p <host-port>:<container-port> \
|
||||
-v /tmp/archipelago-dev/<app-id>:/data \
|
||||
archipelago/<app-id>:latest
|
||||
curl -X POST http://localhost:5959/rpc/v1 \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"method": "container-install", "params": {"manifest_path": "apps/router/manifest.yml"}}'
|
||||
```
|
||||
|
||||
## Integration with Archipelago
|
||||
### Manual Testing (Podman)
|
||||
|
||||
Apps are integrated via:
|
||||
```bash
|
||||
# Build
|
||||
./build.sh router
|
||||
|
||||
1. **Manifest files** - Define app configuration
|
||||
2. **Container runtime** - Podman/Docker for execution
|
||||
3. **Port manager** - Handles port allocation and offsets
|
||||
4. **Dev orchestrator** - Manages containers in development
|
||||
# Run directly with Podman
|
||||
podman run -p 18084:8080 \
|
||||
-v /tmp/archipelago-dev/router:/app/data \
|
||||
localhost/archipelago/router:latest
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
## Integration Checklist
|
||||
|
||||
When building the OS image, these apps will be:
|
||||
1. Built into container images
|
||||
2. Included in the OS image
|
||||
3. Available for installation via the UI
|
||||
4. Pre-configured with proper networking and security
|
||||
Adding a new app requires updates in multiple places. See the full checklist in [CLAUDE.md](../CLAUDE.md) under "App Integration Checklist".
|
||||
|
||||
## Port Assignments
|
||||
|
||||
See [PORTS.md](./PORTS.md) for complete mapping. Dev ports are offset by +10000.
|
||||
|
||||
@@ -1,70 +1,46 @@
|
||||
# Archipelago App Manifests
|
||||
|
||||
This directory contains app manifest definitions for containerized applications in the Archipelago Bitcoin Node OS.
|
||||
Containerized applications for the Archipelago Bitcoin Node OS. All apps run in rootless Podman with security hardening (cap-drop ALL, readonly root, non-root user, memory limits).
|
||||
|
||||
## App Categories
|
||||
|
||||
### Bitcoin & Lightning
|
||||
- `bitcoin-core/` - Bitcoin Core full node (v24.0.0)
|
||||
- `lnd/` - Lightning Network Daemon
|
||||
- `core-lightning/` - Core Lightning (CLN)
|
||||
- `lightning-stack/` - Complete Lightning implementation (v0.12.0)
|
||||
- `btcpay-server/` - BTCPay Server payment processor (v1.12.0)
|
||||
- `mempool/` - Mempool blockchain explorer (v2.5.0)
|
||||
- `fedimint/` - Federated Bitcoin minting (v0.3.0)
|
||||
- **bitcoin-knots** — Full Bitcoin node (v28.1)
|
||||
- **lnd** — Lightning Network Daemon (v0.17.4-beta)
|
||||
- **btcpay-server** — Payment processor (v1.13.5)
|
||||
- **thunderhub** — Lightning management UI (v0.13.31)
|
||||
- **mempool** — Block explorer and fee estimator (v2.5.0)
|
||||
- **electrumx** — Electrum server
|
||||
- **fedimint** — Federated Bitcoin minting (v0.10.0)
|
||||
|
||||
### Web5 & Decentralized Protocols
|
||||
- `nostr-rs-relay/` - High-performance Nostr relay (Rust)
|
||||
- `strfry/` - Nostr relay (C++)
|
||||
- `web5-dwn/` - Decentralized Web Node
|
||||
- `did-wallet/` - Web5 wallet with DID support
|
||||
### Nostr
|
||||
- **nostr-rs-relay** — High-performance Rust relay (v0.9.0)
|
||||
- **nostrudel** — Nostr web client (v0.40.0)
|
||||
|
||||
### Web5 & Identity
|
||||
- **web5-dwn** — Decentralized Web Node (v0.4.0)
|
||||
- **did-wallet** — Web5 DID Wallet
|
||||
|
||||
### Self-Hosted Services
|
||||
- `home-assistant/` - Home automation (v2024.1.0)
|
||||
- `grafana/` - Monitoring and dashboards (v10.2.0)
|
||||
- `ollama/` - Local AI models (v0.1.0)
|
||||
- `searxng/` - Privacy-respecting search engine (v2024.1.0)
|
||||
- `onlyoffice/` - Office suite (v7.5.0)
|
||||
- `penpot/` - Design platform (v2.0.0)
|
||||
- **nextcloud** (v28), **jellyfin** (v10.8.13), **immich** (release), **photoprism** (v240915)
|
||||
- **vaultwarden** (v1.30.0-alpine), **onlyoffice** (v7.5.1), **penpot** (v2.4)
|
||||
- **homeassistant** (v2024.1), **filebrowser** (v2.27.0), **searxng** (2024.11.17)
|
||||
- **ollama** (v0.5.4), **grafana** (v10.2.0), **portainer** (v2.19.4)
|
||||
|
||||
### Custom Applications
|
||||
- `endurain/` - Endurain application platform (v1.0.0)
|
||||
- `morphos-server/` - MorphOS server (v1.0.0)
|
||||
### Networking
|
||||
- **tailscale** (stable), **nginx-proxy-manager** (v2.12.1)
|
||||
|
||||
### Mesh Networking & Routing
|
||||
- `meshtastic/` - Meshtastic LoRa mesh networking
|
||||
- `router/` - Mesh routing and local network management
|
||||
### Custom & External
|
||||
- **indeedhub** — Bitcoin documentary streaming (custom build)
|
||||
- **router** — Mesh routing and network management
|
||||
- **botfights**, **nwnn**, **484-kitchen**, **call-the-operator**, **arch-presentation**, **syntropy-institute**, **t-zero** — External web apps
|
||||
|
||||
## Manifest Format
|
||||
|
||||
Each app has a `manifest.yml` file defining:
|
||||
- Container image and version
|
||||
- Resource requirements
|
||||
- Dependencies
|
||||
- Security policies
|
||||
- Health checks
|
||||
- Network configuration
|
||||
Each app has a `manifest.yml` defining container image, resources, dependencies, security policies, health checks, and network config. See [`docs/app-manifest-spec.md`](../docs/app-manifest-spec.md) for the spec.
|
||||
|
||||
See `docs/app-manifest-spec.md` for the complete specification.
|
||||
## Quick Reference
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Build All Apps
|
||||
|
||||
```bash
|
||||
./build.sh
|
||||
```
|
||||
|
||||
### Build Specific App
|
||||
|
||||
```bash
|
||||
./build.sh <app-id>
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
See [DEVELOPMENT.md](./DEVELOPMENT.md) for development guide and [QUICKSTART.md](./QUICKSTART.md) for quick start instructions.
|
||||
|
||||
## Port Assignments
|
||||
|
||||
See [PORTS.md](./PORTS.md) for complete port mapping. All apps use unique ports and are automatically offset in development mode.
|
||||
- [PORTS.md](./PORTS.md) — Complete port mapping
|
||||
- [QUICKSTART.md](./QUICKSTART.md) — Build and run apps
|
||||
- [DEVELOPMENT.md](./DEVELOPMENT.md) — Development workflow
|
||||
|
||||
1
core/Cargo.lock
generated
1
core/Cargo.lock
generated
@@ -147,6 +147,7 @@ dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"futures",
|
||||
"hyper 0.14.32",
|
||||
"indexmap",
|
||||
"log",
|
||||
"reqwest",
|
||||
|
||||
@@ -41,15 +41,15 @@ archipelago-parmanode = { path = "../parmanode" }
|
||||
|
||||
# Authentication
|
||||
bcrypt = "0.15"
|
||||
sha2 = "0.10"
|
||||
hmac = "0.12"
|
||||
sha2 = "0.10.9"
|
||||
hmac = "0.12.1"
|
||||
uuid = { version = "1.0", features = ["v4"] }
|
||||
regex = "1.10"
|
||||
|
||||
# Node identity (Ed25519 + X25519 key agreement)
|
||||
ed25519-dalek = { version = "2.1", features = ["rand_core"] }
|
||||
curve25519-dalek = "4"
|
||||
rand = "0.8"
|
||||
ed25519-dalek = { version = "2.2.0", features = ["rand_core"] }
|
||||
curve25519-dalek = "4.1.3"
|
||||
rand = "0.8.5"
|
||||
hex = "0.4"
|
||||
bs58 = "0.5"
|
||||
chrono = "0.4"
|
||||
@@ -66,8 +66,8 @@ reqwest = { version = "0.11", default-features = false, features = ["json", "soc
|
||||
nostr-sdk = { version = "0.44", features = ["nip04", "nip44"] }
|
||||
|
||||
# Backup encryption (DID identity export) + TOTP 2FA encryption
|
||||
argon2 = "0.5"
|
||||
chacha20poly1305 = "0.10"
|
||||
argon2 = "0.5.3"
|
||||
chacha20poly1305 = "0.10.1"
|
||||
base64 = "0.21"
|
||||
|
||||
# Full system backup (tar archive + gzip compression)
|
||||
@@ -78,7 +78,7 @@ flate2 = "1.0"
|
||||
totp-rs = { version = "5.7", features = ["otpauth", "gen_secret"] }
|
||||
qrcode = "0.14"
|
||||
data-encoding = "2.6"
|
||||
zeroize = { version = "1.7", features = ["derive"] }
|
||||
zeroize = { version = "1.8.2", features = ["derive"] }
|
||||
|
||||
# Mainline DHT (did:dht — BitTorrent DHT for decentralized identity)
|
||||
mainline = "2"
|
||||
@@ -89,7 +89,7 @@ bytes = "1"
|
||||
serial2-tokio = "0.1"
|
||||
|
||||
# Double Ratchet key derivation (Phase 3: encrypted mesh messaging)
|
||||
hkdf = "0.12"
|
||||
hkdf = "0.12.4"
|
||||
|
||||
# Transport abstraction (Phase 2: mesh as federation transport)
|
||||
ciborium = "0.2.2"
|
||||
|
||||
@@ -1,864 +0,0 @@
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use crate::content_server;
|
||||
use crate::electrs_status;
|
||||
use crate::monitoring::MetricsStore;
|
||||
use crate::network::dwn_store::DwnStore;
|
||||
use crate::node_message as node_msg;
|
||||
use crate::config::Config;
|
||||
use crate::session::{self, SessionStore};
|
||||
use crate::state::StateManager;
|
||||
use anyhow::Result;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use hyper::{Method, Request, Response, StatusCode};
|
||||
use hyper_ws_listener::WsStream;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use std::time::Instant;
|
||||
use tracing::{debug, info};
|
||||
|
||||
pub struct ApiHandler {
|
||||
config: Config,
|
||||
rpc_handler: Arc<RpcHandler>,
|
||||
state_manager: Arc<StateManager>,
|
||||
metrics_store: Arc<MetricsStore>,
|
||||
session_store: SessionStore,
|
||||
}
|
||||
|
||||
impl ApiHandler {
|
||||
pub async fn new(
|
||||
config: Config,
|
||||
state_manager: Arc<StateManager>,
|
||||
metrics_store: Arc<MetricsStore>,
|
||||
) -> Result<Self> {
|
||||
let session_store = SessionStore::new();
|
||||
let rpc_handler = Arc::new(
|
||||
RpcHandler::new(
|
||||
config.clone(),
|
||||
state_manager.clone(),
|
||||
metrics_store.clone(),
|
||||
session_store.clone(),
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
rpc_handler,
|
||||
state_manager,
|
||||
metrics_store,
|
||||
session_store,
|
||||
})
|
||||
}
|
||||
|
||||
/// Access the RPC handler (for service initialization after construction).
|
||||
pub fn rpc_handler(&self) -> &Arc<RpcHandler> {
|
||||
&self.rpc_handler
|
||||
}
|
||||
|
||||
/// Check if the request has a valid session cookie.
|
||||
async fn is_authenticated(&self, headers: &hyper::HeaderMap) -> bool {
|
||||
match session::extract_session_cookie(headers) {
|
||||
Some(token) => self.session_store.validate(&token).await,
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a 401 Unauthorized JSON response.
|
||||
fn unauthorized() -> Response<hyper::Body> {
|
||||
let body = serde_json::json!({ "error": "Unauthorized" });
|
||||
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
||||
Response::builder()
|
||||
.status(StatusCode::UNAUTHORIZED)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(body_bytes))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Allowed CORS origins derived from the config host IP.
|
||||
fn allowed_origins(&self) -> Vec<String> {
|
||||
vec![
|
||||
format!("http://{}", self.config.host_ip),
|
||||
format!("https://{}", self.config.host_ip),
|
||||
"http://localhost:8100".to_string(), // Vite dev server
|
||||
]
|
||||
}
|
||||
|
||||
/// Validate the Origin header against allowed origins.
|
||||
/// Returns the matched origin if valid, None if cross-origin is not allowed.
|
||||
fn validate_origin(&self, headers: &hyper::HeaderMap) -> Option<String> {
|
||||
let origin = headers
|
||||
.get("origin")
|
||||
.and_then(|v| v.to_str().ok())?;
|
||||
let allowed = self.allowed_origins();
|
||||
if allowed.iter().any(|a| a == origin) {
|
||||
Some(origin.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_request(
|
||||
&self,
|
||||
req: Request<hyper::Body>,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let path = req.uri().path().to_string();
|
||||
let method = req.method().clone();
|
||||
|
||||
// Handle CORS preflight for all routes
|
||||
if method == Method::OPTIONS {
|
||||
let mut builder = Response::builder()
|
||||
.status(StatusCode::NO_CONTENT)
|
||||
.header("Vary", "Origin");
|
||||
if let Some(origin) = self.validate_origin(req.headers()) {
|
||||
builder = builder
|
||||
.header("Access-Control-Allow-Origin", &origin)
|
||||
.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
.header("Access-Control-Allow-Headers", "Content-Type, X-CSRF-Token")
|
||||
.header("Access-Control-Allow-Credentials", "true");
|
||||
}
|
||||
return Ok(builder.body(hyper::Body::empty()).unwrap());
|
||||
}
|
||||
|
||||
// WebSocket upgrade — validate session before upgrading
|
||||
if method == Method::GET && path == "/ws/db" {
|
||||
if !self.is_authenticated(req.headers()).await {
|
||||
tracing::warn!("401 WebSocket /ws/db — session invalid or missing");
|
||||
return Ok(Self::unauthorized());
|
||||
}
|
||||
return Self::handle_websocket(req, self.state_manager.clone(), self.metrics_store.clone()).await;
|
||||
}
|
||||
|
||||
// Convert body to bytes for non-WS routes
|
||||
let headers = req.headers().clone();
|
||||
let (parts, body) = req.into_parts();
|
||||
let body_bytes = hyper::body::to_bytes(body).await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to read body: {}", e))?;
|
||||
let req_with_bytes = Request::from_parts(parts, hyper::Body::from(body_bytes.clone()));
|
||||
|
||||
debug!("{} {}", method, path);
|
||||
|
||||
match (method, path.as_str()) {
|
||||
// RPC — auth is handled inside rpc handler per-method
|
||||
(Method::POST, "/rpc/v1") => self.rpc_handler.handle(req_with_bytes).await,
|
||||
|
||||
// Health — unauthenticated
|
||||
(Method::GET, "/health") => Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.body(hyper::Body::from("OK"))
|
||||
.unwrap()),
|
||||
|
||||
// Node message — P2P endpoint (authenticated by source validation, not cookie)
|
||||
(Method::POST, "/archipelago/node-message") => {
|
||||
Self::handle_node_message(body_bytes).await
|
||||
}
|
||||
|
||||
// Content serving — peers access shared content over Tor (no session auth)
|
||||
(Method::GET, p) if p.starts_with("/content/") => {
|
||||
Self::handle_content_request(p, &headers, &self.config).await
|
||||
}
|
||||
|
||||
// Content catalog — list available content (no session auth, for peers)
|
||||
(Method::GET, "/content") => {
|
||||
Self::handle_content_catalog(&self.config).await
|
||||
}
|
||||
|
||||
// Electrs status — unauthenticated (read-only sync status)
|
||||
(Method::GET, "/electrs-status") => Self::handle_electrs_status().await,
|
||||
|
||||
// LND connect info — unauthenticated (read-only, localhost only)
|
||||
(Method::GET, "/lnd-connect-info") => {
|
||||
Self::handle_lnd_connect_info(self.rpc_handler.clone()).await
|
||||
}
|
||||
|
||||
// Container logs — requires session
|
||||
(Method::GET, path) if path.starts_with("/api/container/logs") => {
|
||||
if !self.is_authenticated(&headers).await {
|
||||
return Ok(Self::unauthorized());
|
||||
}
|
||||
let origin = self.validate_origin(&headers).unwrap_or_default();
|
||||
Self::handle_container_logs_http(self.rpc_handler.clone(), path, &origin).await
|
||||
}
|
||||
|
||||
// LND proxy — requires session
|
||||
(Method::GET, path) if path.starts_with("/proxy/lnd/") => {
|
||||
if !self.is_authenticated(&headers).await {
|
||||
return Ok(Self::unauthorized());
|
||||
}
|
||||
let origin = self.validate_origin(&headers).unwrap_or_default();
|
||||
Self::handle_lnd_proxy(path, &origin).await
|
||||
}
|
||||
|
||||
// DWN health — unauthenticated
|
||||
(Method::GET, "/dwn/health") => {
|
||||
Self::handle_dwn_health(&self.config).await
|
||||
}
|
||||
|
||||
// DWN message processing — peers access over Tor for sync (no session auth)
|
||||
(Method::POST, "/dwn") => {
|
||||
Self::handle_dwn_message(body_bytes, &self.config).await
|
||||
}
|
||||
|
||||
_ => Ok(Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(hyper::Body::from("Not Found"))
|
||||
.unwrap()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_container_logs_http(
|
||||
rpc: Arc<RpcHandler>,
|
||||
path: &str,
|
||||
cors_origin: &str,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let query = path
|
||||
.strip_prefix("/api/container/logs")
|
||||
.and_then(|s| s.strip_prefix('?'))
|
||||
.unwrap_or("");
|
||||
let params: std::collections::HashMap<String, String> =
|
||||
query
|
||||
.split('&')
|
||||
.filter_map(|p| {
|
||||
let mut it = p.splitn(2, '=');
|
||||
let k = it.next()?.to_string();
|
||||
let v = it.next()?.to_string();
|
||||
Some((k, v))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let app_id = params.get("app_id").map(|s| s.as_str()).unwrap_or("lnd");
|
||||
|
||||
// Validate app_id format
|
||||
if !is_valid_app_id(app_id) {
|
||||
let body = serde_json::json!({ "error": "Invalid app_id" });
|
||||
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::BAD_REQUEST)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(body_bytes))
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
let lines = params
|
||||
.get("lines")
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
.unwrap_or(200);
|
||||
|
||||
match rpc.get_container_logs_value(app_id, lines).await {
|
||||
Ok(value) => {
|
||||
let body = serde_json::json!({ "result": value });
|
||||
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Access-Control-Allow-Origin", cors_origin)
|
||||
.header("Access-Control-Allow-Credentials", "true")
|
||||
.header("Vary", "Origin")
|
||||
.body(hyper::Body::from(body_bytes))
|
||||
.unwrap())
|
||||
}
|
||||
Err(e) => {
|
||||
let body = serde_json::json!({ "error": e.to_string() });
|
||||
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Access-Control-Allow-Origin", cors_origin)
|
||||
.header("Access-Control-Allow-Credentials", "true")
|
||||
.header("Vary", "Origin")
|
||||
.body(hyper::Body::from(body_bytes))
|
||||
.unwrap())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_node_message(body: hyper::body::Bytes) -> Result<Response<hyper::Body>> {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Incoming {
|
||||
from_pubkey: Option<String>,
|
||||
message: Option<String>,
|
||||
signature: Option<String>,
|
||||
}
|
||||
let incoming: Incoming = serde_json::from_slice(&body).unwrap_or(Incoming {
|
||||
from_pubkey: None,
|
||||
message: None,
|
||||
signature: None,
|
||||
});
|
||||
if let (Some(from), Some(msg)) = (incoming.from_pubkey.as_ref(), incoming.message.as_ref()) {
|
||||
// Validate from_pubkey is a valid hex ed25519 pubkey
|
||||
if !is_valid_pubkey_hex(from) {
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::BAD_REQUEST)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(r#"{"error":"Invalid pubkey format"}"#))
|
||||
.unwrap());
|
||||
}
|
||||
// Verify ed25519 signature if provided (required for trusted messages)
|
||||
if let Some(sig_hex) = &incoming.signature {
|
||||
match crate::identity::NodeIdentity::verify(from, msg.as_bytes(), sig_hex) {
|
||||
Ok(true) => {}
|
||||
_ => {
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::FORBIDDEN)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(r#"{"error":"Invalid signature"}"#))
|
||||
.unwrap());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No signature — accept but mark as unverified
|
||||
tracing::warn!("Node message from {} has no signature — unverified", &from[..16.min(from.len())]);
|
||||
}
|
||||
// Sanitize log output to prevent log injection
|
||||
let safe_from = sanitize_log_string(from);
|
||||
let safe_msg = sanitize_log_string(msg);
|
||||
tracing::info!("Received message from {}: {}", safe_from, safe_msg);
|
||||
// Sanitize stored message content (strip HTML entities)
|
||||
let clean_from = sanitize_html(from);
|
||||
let clean_msg = sanitize_html(msg);
|
||||
node_msg::store_received(&clean_from, &clean_msg).await;
|
||||
}
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(r#"{"ok":true}"#))
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
async fn handle_electrs_status() -> Result<Response<hyper::Body>> {
|
||||
let status = electrs_status::get_electrs_sync_status().await;
|
||||
let body = serde_json::to_vec(&status).unwrap_or_default();
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(body))
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
async fn handle_lnd_connect_info(
|
||||
rpc: std::sync::Arc<super::rpc::RpcHandler>,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
match rpc.handle_lnd_connect_info().await {
|
||||
Ok(val) => {
|
||||
let body = serde_json::to_vec(&val).unwrap_or_default();
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(body))
|
||||
.unwrap())
|
||||
}
|
||||
Err(e) => Ok(Response::builder()
|
||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(
|
||||
serde_json::json!({"error": e.to_string()}).to_string(),
|
||||
))
|
||||
.unwrap()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_lnd_proxy(path: &str, cors_origin: &str) -> Result<Response<hyper::Body>> {
|
||||
let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/");
|
||||
let url = format!("http://127.0.0.1:8080{}", suffix);
|
||||
match reqwest::get(&url).await {
|
||||
Ok(resp) => {
|
||||
let status = resp.status().as_u16();
|
||||
let headers = resp.headers().clone();
|
||||
let body = resp.bytes().await.unwrap_or_default();
|
||||
let mut builder = Response::builder().status(status);
|
||||
if let Some(ct) = headers.get("content-type") {
|
||||
if let Ok(s) = ct.to_str() {
|
||||
builder = builder.header("Content-Type", s);
|
||||
}
|
||||
}
|
||||
builder
|
||||
.header("Access-Control-Allow-Origin", cors_origin)
|
||||
.header("Access-Control-Allow-Credentials", "true")
|
||||
.header("Vary", "Origin")
|
||||
.body(hyper::Body::from(body))
|
||||
.map_err(|e| anyhow::anyhow!("response build: {}", e))
|
||||
}
|
||||
Err(e) => {
|
||||
let body = serde_json::json!({ "error": e.to_string() });
|
||||
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::BAD_GATEWAY)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Access-Control-Allow-Origin", cors_origin)
|
||||
.header("Access-Control-Allow-Credentials", "true")
|
||||
.header("Vary", "Origin")
|
||||
.body(hyper::Body::from(body_bytes))
|
||||
.unwrap())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_content_catalog(config: &Config) -> Result<Response<hyper::Body>> {
|
||||
match content_server::load_catalog(&config.data_dir).await {
|
||||
Ok(catalog) => {
|
||||
// Only expose public metadata for available items
|
||||
let items: Vec<serde_json::Value> = catalog
|
||||
.items
|
||||
.iter()
|
||||
.filter(|i| !matches!(i.availability, content_server::Availability::Nobody))
|
||||
.map(|i| {
|
||||
serde_json::json!({
|
||||
"id": i.id,
|
||||
"filename": i.filename,
|
||||
"mime_type": i.mime_type,
|
||||
"size_bytes": i.size_bytes,
|
||||
"description": i.description,
|
||||
"access": i.access,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let body = serde_json::to_vec(&serde_json::json!({ "items": items }))
|
||||
.unwrap_or_default();
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(body))
|
||||
.unwrap())
|
||||
}
|
||||
Err(e) => {
|
||||
let body = serde_json::json!({ "error": e.to_string() });
|
||||
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(body_bytes))
|
||||
.unwrap())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_content_request(
|
||||
path: &str,
|
||||
headers: &hyper::HeaderMap,
|
||||
config: &Config,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let content_id = path.strip_prefix("/content/").unwrap_or("");
|
||||
if content_id.is_empty() || !is_valid_app_id(content_id) {
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::BAD_REQUEST)
|
||||
.body(hyper::Body::from("Invalid content ID"))
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
// Extract payment token from X-Payment-Token header
|
||||
let payment_token = headers
|
||||
.get("x-payment-token")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
// Extract federation peer DID from X-Federation-DID header
|
||||
let peer_did = headers
|
||||
.get("x-federation-did")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
// Parse Range header for streaming support
|
||||
let range = headers
|
||||
.get("range")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(content_server::parse_range_header);
|
||||
|
||||
match content_server::serve_content(
|
||||
&config.data_dir,
|
||||
content_id,
|
||||
payment_token.as_deref(),
|
||||
peer_did.as_deref(),
|
||||
range,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(content_server::ServeResult::Ok(bytes, mime_type)) => {
|
||||
let len = bytes.len();
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", mime_type)
|
||||
.header("Content-Length", len.to_string())
|
||||
.header("Accept-Ranges", "bytes")
|
||||
.body(hyper::Body::from(bytes))
|
||||
.unwrap())
|
||||
}
|
||||
Ok(content_server::ServeResult::Partial {
|
||||
bytes,
|
||||
mime_type,
|
||||
start,
|
||||
end,
|
||||
total,
|
||||
}) => {
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::PARTIAL_CONTENT)
|
||||
.header("Content-Type", mime_type)
|
||||
.header("Content-Length", bytes.len().to_string())
|
||||
.header("Content-Range", format!("bytes {}-{}/{}", start, end, total))
|
||||
.header("Accept-Ranges", "bytes")
|
||||
.body(hyper::Body::from(bytes))
|
||||
.unwrap())
|
||||
}
|
||||
Ok(content_server::ServeResult::PaymentRequired(price_sats)) => {
|
||||
let body = serde_json::json!({
|
||||
"error": "Payment required",
|
||||
"price_sats": price_sats,
|
||||
"payment_header": "X-Payment-Token",
|
||||
});
|
||||
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::PAYMENT_REQUIRED)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(body_bytes))
|
||||
.unwrap())
|
||||
}
|
||||
Ok(content_server::ServeResult::Forbidden) => {
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::FORBIDDEN)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(
|
||||
r#"{"error":"Access denied — federation peer required"}"#,
|
||||
))
|
||||
.unwrap())
|
||||
}
|
||||
Ok(content_server::ServeResult::NotFound) | Err(_) => {
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(hyper::Body::from("Content not found"))
|
||||
.unwrap())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_websocket(
|
||||
req: Request<hyper::Body>,
|
||||
state_manager: Arc<StateManager>,
|
||||
metrics_store: Arc<MetricsStore>,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let (response, ws_fut_opt) = hyper_ws_listener::create_ws(req)
|
||||
.map_err(|e| anyhow::anyhow!("WebSocket upgrade failed: {}", e))?;
|
||||
|
||||
if let Some(ws_fut) = ws_fut_opt {
|
||||
tokio::spawn(async move {
|
||||
let ws_stream: WsStream = match ws_fut.await {
|
||||
Ok(Ok(s)) => s,
|
||||
Ok(Err(e)) => {
|
||||
debug!("WebSocket handshake failed (hyper): {}", e);
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("WebSocket task join failed: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
metrics_store.increment_ws();
|
||||
info!("WebSocket /ws/db connected");
|
||||
|
||||
let (mut tx, mut rx) = ws_stream.split();
|
||||
|
||||
let initial_msg = state_manager.get_initial_message().await;
|
||||
if let Ok(json_msg) = serde_json::to_string(&initial_msg) {
|
||||
if let Err(e) = tx.send(Message::Text(json_msg)).await {
|
||||
debug!("Failed to send initial data: {}", e);
|
||||
return;
|
||||
}
|
||||
debug!("Sent initial data dump at revision {}", initial_msg.rev);
|
||||
}
|
||||
|
||||
let mut state_rx = state_manager.subscribe();
|
||||
let ping_interval = tokio::time::interval(tokio::time::Duration::from_secs(30));
|
||||
tokio::pin!(ping_interval);
|
||||
let mut last_client_activity = Instant::now();
|
||||
const INACTIVITY_TIMEOUT_SECS: u64 = 300; // 5 minutes
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = ping_interval.tick() => {
|
||||
// Check inactivity timeout
|
||||
if last_client_activity.elapsed().as_secs() >= INACTIVITY_TIMEOUT_SECS {
|
||||
info!("WebSocket client inactive for {}s, closing", INACTIVITY_TIMEOUT_SECS);
|
||||
let _ = tx.send(Message::Close(None)).await;
|
||||
break;
|
||||
}
|
||||
if tx.send(Message::Ping(vec![])).await.is_err() {
|
||||
debug!("Failed to send ping, connection likely closed");
|
||||
break;
|
||||
}
|
||||
}
|
||||
update = state_rx.recv() => {
|
||||
match update {
|
||||
Ok(msg) => {
|
||||
if let Ok(json_msg) = serde_json::to_string(&msg) {
|
||||
if let Err(e) = tx.send(Message::Text(json_msg)).await {
|
||||
debug!("Failed to send state update: {}", e);
|
||||
break;
|
||||
}
|
||||
debug!("Sent state update at revision {}", msg.rev);
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(skipped)) => {
|
||||
debug!("Client lagged behind, skipped {} messages", skipped);
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
debug!("Broadcast channel closed");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
msg = rx.next() => {
|
||||
match msg {
|
||||
Some(Ok(Message::Close(_))) => break,
|
||||
Some(Ok(Message::Pong(_))) => {
|
||||
last_client_activity = Instant::now();
|
||||
debug!("Received pong");
|
||||
}
|
||||
Some(Ok(Message::Ping(data))) => {
|
||||
last_client_activity = Instant::now();
|
||||
let _ = tx.send(Message::Pong(data)).await;
|
||||
}
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
last_client_activity = Instant::now();
|
||||
// Handle JSON ping from frontend
|
||||
if text.contains("\"type\":\"ping\"") || text.contains("\"type\": \"ping\"") {
|
||||
let _ = tx.send(Message::Text(r#"{"type":"pong"}"#.to_string())).await;
|
||||
}
|
||||
}
|
||||
Some(Ok(_)) => {
|
||||
last_client_activity = Instant::now();
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
debug!("WebSocket stream error: {}", e);
|
||||
break;
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
metrics_store.decrement_ws();
|
||||
info!("WebSocket /ws/db disconnected");
|
||||
});
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate that an app ID matches the safe pattern: lowercase alphanumeric + hyphens.
|
||||
fn is_valid_app_id(id: &str) -> bool {
|
||||
!id.is_empty()
|
||||
&& id.len() <= 64
|
||||
&& id.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
|
||||
&& id.as_bytes()[0] != b'-'
|
||||
}
|
||||
|
||||
/// Validate that a pubkey is a 64-char hex string.
|
||||
fn is_valid_pubkey_hex(s: &str) -> bool {
|
||||
s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
/// Strip newlines and ANSI escape sequences from strings before logging.
|
||||
fn sanitize_log_string(s: &str) -> String {
|
||||
s.replace('\n', "\\n")
|
||||
.replace('\r', "\\r")
|
||||
.replace('\x1b', "")
|
||||
}
|
||||
|
||||
/// Strip HTML-sensitive characters to prevent XSS when stored/rendered.
|
||||
fn sanitize_html(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
impl ApiHandler {
|
||||
/// DWN health endpoint — returns store stats.
|
||||
async fn handle_dwn_health(config: &Config) -> Result<Response<hyper::Body>> {
|
||||
match DwnStore::new(&config.data_dir).await {
|
||||
Ok(store) => {
|
||||
let stats = store.stats().await.unwrap_or(crate::network::dwn_store::StoreStats {
|
||||
message_count: 0,
|
||||
protocol_count: 0,
|
||||
total_bytes: 0,
|
||||
});
|
||||
let body = serde_json::json!({
|
||||
"status": "ok",
|
||||
"message_count": stats.message_count,
|
||||
"protocol_count": stats.protocol_count,
|
||||
"total_bytes": stats.total_bytes,
|
||||
});
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(body.to_string()))
|
||||
.unwrap())
|
||||
}
|
||||
Err(_) => Ok(Response::builder()
|
||||
.status(StatusCode::SERVICE_UNAVAILABLE)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(r#"{"status":"unavailable"}"#))
|
||||
.unwrap()),
|
||||
}
|
||||
}
|
||||
|
||||
/// DWN message processing endpoint — handles RecordsWrite, RecordsQuery, RecordsRead, RecordsDelete.
|
||||
/// Supports batch processing: all messages in the array are processed.
|
||||
async fn handle_dwn_message(
|
||||
body: hyper::body::Bytes,
|
||||
config: &Config,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let request: serde_json::Value = match serde_json::from_slice(&body) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
let err = serde_json::json!({"error": format!("Invalid JSON: {}", e)});
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::BAD_REQUEST)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(err.to_string()))
|
||||
.unwrap());
|
||||
}
|
||||
};
|
||||
|
||||
// Collect all messages to process
|
||||
let messages: Vec<serde_json::Value> = if request.get("message").is_some() {
|
||||
vec![request["message"].clone()]
|
||||
} else if let Some(msgs) = request["messages"].as_array() {
|
||||
msgs.clone()
|
||||
} else {
|
||||
vec![serde_json::Value::Null]
|
||||
};
|
||||
|
||||
let store = DwnStore::new(&config.data_dir).await?;
|
||||
let mut results = Vec::new();
|
||||
|
||||
for message in &messages {
|
||||
let interface = message["descriptor"]["interface"]
|
||||
.as_str()
|
||||
.unwrap_or("");
|
||||
let method = message["descriptor"]["method"]
|
||||
.as_str()
|
||||
.unwrap_or("");
|
||||
|
||||
let result = match (interface, method) {
|
||||
("Records", "Write") => {
|
||||
let author = message["author"].as_str().unwrap_or("unknown");
|
||||
let protocol = message["descriptor"]["protocol"].as_str();
|
||||
let schema = message["descriptor"]["schema"].as_str();
|
||||
let data_format = message["descriptor"]["dataFormat"].as_str();
|
||||
let data = message.get("data").cloned();
|
||||
// Deduplicate: check if recordId already exists
|
||||
if let Some(record_id) = message["recordId"].as_str() {
|
||||
if store.read_message(record_id).await.ok().flatten().is_some() {
|
||||
serde_json::json!({"status": {"code": 200, "detail": "Already exists"}})
|
||||
} else {
|
||||
match store
|
||||
.write_message(author, protocol, schema, data_format, data)
|
||||
.await
|
||||
{
|
||||
Ok(msg) => {
|
||||
serde_json::json!({"status": {"code": 202}, "entry": msg})
|
||||
}
|
||||
Err(e) => serde_json::json!({"status": {"code": 500, "detail": e.to_string()}}),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match store
|
||||
.write_message(author, protocol, schema, data_format, data)
|
||||
.await
|
||||
{
|
||||
Ok(msg) => serde_json::json!({"status": {"code": 202}, "entry": msg}),
|
||||
Err(e) => serde_json::json!({"status": {"code": 500, "detail": e.to_string()}}),
|
||||
}
|
||||
}
|
||||
}
|
||||
("Records", "Query") => {
|
||||
let query = crate::network::dwn_store::MessageQuery {
|
||||
protocol: message["descriptor"]["filter"]["protocol"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string()),
|
||||
schema: message["descriptor"]["filter"]["schema"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string()),
|
||||
author: message["descriptor"]["filter"]["author"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string()),
|
||||
date_from: message["descriptor"]["filter"]["dateFrom"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string()),
|
||||
date_to: message["descriptor"]["filter"]["dateTo"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string()),
|
||||
limit: message["descriptor"]["filter"]["limit"]
|
||||
.as_u64()
|
||||
.map(|n| n as usize),
|
||||
};
|
||||
match store.query_messages(&query).await {
|
||||
Ok(messages) => {
|
||||
serde_json::json!({"status": {"code": 200}, "entries": messages})
|
||||
}
|
||||
Err(e) => {
|
||||
serde_json::json!({"status": {"code": 500, "detail": e.to_string()}})
|
||||
}
|
||||
}
|
||||
}
|
||||
("Records", "Read") => {
|
||||
let record_id = message["descriptor"]["recordId"]
|
||||
.as_str()
|
||||
.unwrap_or("");
|
||||
match store.read_message(record_id).await {
|
||||
Ok(Some(msg)) => {
|
||||
serde_json::json!({"status": {"code": 200}, "entry": msg})
|
||||
}
|
||||
Ok(None) => serde_json::json!({"status": {"code": 404, "detail": "Record not found"}}),
|
||||
Err(e) => {
|
||||
serde_json::json!({"status": {"code": 500, "detail": e.to_string()}})
|
||||
}
|
||||
}
|
||||
}
|
||||
("Records", "Delete") => {
|
||||
let record_id = message["descriptor"]["recordId"]
|
||||
.as_str()
|
||||
.unwrap_or("");
|
||||
match store.delete_message(record_id).await {
|
||||
Ok(true) => serde_json::json!({"status": {"code": 200}}),
|
||||
Ok(false) => serde_json::json!({"status": {"code": 404, "detail": "Record not found"}}),
|
||||
Err(e) => {
|
||||
serde_json::json!({"status": {"code": 500, "detail": e.to_string()}})
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
serde_json::json!({"status": {"code": 400, "detail": format!("Unknown method: {}.{}", interface, method)}})
|
||||
}
|
||||
};
|
||||
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
// Return single result for single message, array for batch
|
||||
let (response_body, http_status) = if results.len() == 1 {
|
||||
let result = &results[0];
|
||||
let status_code = result["status"]["code"].as_u64().unwrap_or(200);
|
||||
let http_status = match status_code {
|
||||
202 => StatusCode::ACCEPTED,
|
||||
400 => StatusCode::BAD_REQUEST,
|
||||
404 => StatusCode::NOT_FOUND,
|
||||
500 => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
_ => StatusCode::OK,
|
||||
};
|
||||
(result.to_string(), http_status)
|
||||
} else {
|
||||
(
|
||||
serde_json::json!({"replies": results}).to_string(),
|
||||
StatusCode::OK,
|
||||
)
|
||||
};
|
||||
|
||||
Ok(Response::builder()
|
||||
.status(http_status)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(response_body))
|
||||
.unwrap())
|
||||
}
|
||||
}
|
||||
122
core/archipelago/src/api/handler/content.rs
Normal file
122
core/archipelago/src/api/handler/content.rs
Normal file
@@ -0,0 +1,122 @@
|
||||
use crate::config::Config;
|
||||
use super::build_response;use crate::content_server;
|
||||
use anyhow::Result;
|
||||
use hyper::{Response, StatusCode};
|
||||
|
||||
use super::{ApiHandler, is_valid_app_id};
|
||||
|
||||
impl ApiHandler {
|
||||
pub(super) async fn handle_content_catalog(config: &Config) -> Result<Response<hyper::Body>> {
|
||||
match content_server::load_catalog(&config.data_dir).await {
|
||||
Ok(catalog) => {
|
||||
// Only expose public metadata for available items
|
||||
let items: Vec<serde_json::Value> = catalog
|
||||
.items
|
||||
.iter()
|
||||
.filter(|i| !matches!(i.availability, content_server::Availability::Nobody))
|
||||
.map(|i| {
|
||||
serde_json::json!({
|
||||
"id": i.id,
|
||||
"filename": i.filename,
|
||||
"mime_type": i.mime_type,
|
||||
"size_bytes": i.size_bytes,
|
||||
"description": i.description,
|
||||
"access": i.access,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let body = serde_json::to_vec(&serde_json::json!({ "items": items }))
|
||||
.unwrap_or_default();
|
||||
Ok(build_response(StatusCode::OK, "application/json", hyper::Body::from(body)))
|
||||
}
|
||||
Err(e) => {
|
||||
let body = serde_json::json!({ "error": e.to_string() });
|
||||
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
||||
Ok(build_response(StatusCode::INTERNAL_SERVER_ERROR, "application/json", hyper::Body::from(body_bytes)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn handle_content_request(
|
||||
path: &str,
|
||||
headers: &hyper::HeaderMap,
|
||||
config: &Config,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let content_id = path.strip_prefix("/content/").unwrap_or("");
|
||||
if content_id.is_empty() || !is_valid_app_id(content_id) {
|
||||
return Ok(build_response(StatusCode::BAD_REQUEST, "text/plain", hyper::Body::from("Invalid content ID")));
|
||||
}
|
||||
|
||||
// Extract payment token from X-Payment-Token header
|
||||
let payment_token = headers
|
||||
.get("x-payment-token")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
// Extract federation peer DID from X-Federation-DID header
|
||||
let peer_did = headers
|
||||
.get("x-federation-did")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
// Parse Range header for streaming support
|
||||
let range = headers
|
||||
.get("range")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(content_server::parse_range_header);
|
||||
|
||||
match content_server::serve_content(
|
||||
&config.data_dir,
|
||||
content_id,
|
||||
payment_token.as_deref(),
|
||||
peer_did.as_deref(),
|
||||
range,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(content_server::ServeResult::Ok(bytes, mime_type)) => {
|
||||
let len = bytes.len();
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", mime_type)
|
||||
.header("Content-Length", len.to_string())
|
||||
.header("Accept-Ranges", "bytes")
|
||||
.body(hyper::Body::from(bytes))
|
||||
.unwrap())
|
||||
}
|
||||
Ok(content_server::ServeResult::Partial {
|
||||
bytes,
|
||||
mime_type,
|
||||
start,
|
||||
end,
|
||||
total,
|
||||
}) => {
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::PARTIAL_CONTENT)
|
||||
.header("Content-Type", mime_type)
|
||||
.header("Content-Length", bytes.len().to_string())
|
||||
.header("Content-Range", format!("bytes {}-{}/{}", start, end, total))
|
||||
.header("Accept-Ranges", "bytes")
|
||||
.body(hyper::Body::from(bytes))
|
||||
.unwrap())
|
||||
}
|
||||
Ok(content_server::ServeResult::PaymentRequired(price_sats)) => {
|
||||
let body = serde_json::json!({
|
||||
"error": "Payment required",
|
||||
"price_sats": price_sats,
|
||||
"payment_header": "X-Payment-Token",
|
||||
});
|
||||
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
||||
Ok(build_response(StatusCode::PAYMENT_REQUIRED, "application/json", hyper::Body::from(body_bytes)))
|
||||
}
|
||||
Ok(content_server::ServeResult::Forbidden) => {
|
||||
Ok(build_response(StatusCode::FORBIDDEN, "application/json", hyper::Body::from(
|
||||
r#"{"error":"Access denied — federation peer required"}"#,
|
||||
)))
|
||||
}
|
||||
Ok(content_server::ServeResult::NotFound) | Err(_) => {
|
||||
Ok(build_response(StatusCode::NOT_FOUND, "text/plain", hyper::Body::from("Content not found")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
189
core/archipelago/src/api/handler/dwn.rs
Normal file
189
core/archipelago/src/api/handler/dwn.rs
Normal file
@@ -0,0 +1,189 @@
|
||||
use crate::config::Config;
|
||||
use super::build_response;use crate::network::dwn_store::DwnStore;
|
||||
use anyhow::Result;
|
||||
use hyper::{Response, StatusCode};
|
||||
|
||||
use super::ApiHandler;
|
||||
|
||||
impl ApiHandler {
|
||||
/// DWN health endpoint — returns store stats.
|
||||
pub(super) async fn handle_dwn_health(config: &Config) -> Result<Response<hyper::Body>> {
|
||||
match DwnStore::new(&config.data_dir).await {
|
||||
Ok(store) => {
|
||||
let stats = store.stats().await.unwrap_or(crate::network::dwn_store::StoreStats {
|
||||
message_count: 0,
|
||||
protocol_count: 0,
|
||||
total_bytes: 0,
|
||||
});
|
||||
let body = serde_json::json!({
|
||||
"status": "ok",
|
||||
"message_count": stats.message_count,
|
||||
"protocol_count": stats.protocol_count,
|
||||
"total_bytes": stats.total_bytes,
|
||||
});
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(body.to_string()))
|
||||
.unwrap())
|
||||
}
|
||||
Err(_) => Ok(build_response(StatusCode::SERVICE_UNAVAILABLE, "application/json", hyper::Body::from(r#"{"status":"unavailable"}"#))),
|
||||
}
|
||||
}
|
||||
|
||||
/// DWN message processing endpoint — handles RecordsWrite, RecordsQuery, RecordsRead, RecordsDelete.
|
||||
/// Supports batch processing: all messages in the array are processed.
|
||||
pub(super) async fn handle_dwn_message(
|
||||
body: hyper::body::Bytes,
|
||||
config: &Config,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let request: serde_json::Value = match serde_json::from_slice(&body) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
let err = serde_json::json!({"error": format!("Invalid JSON: {}", e)});
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::BAD_REQUEST)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(err.to_string()))
|
||||
.unwrap());
|
||||
}
|
||||
};
|
||||
|
||||
// Collect all messages to process
|
||||
let messages: Vec<serde_json::Value> = if request.get("message").is_some() {
|
||||
vec![request["message"].clone()]
|
||||
} else if let Some(msgs) = request["messages"].as_array() {
|
||||
msgs.clone()
|
||||
} else {
|
||||
vec![serde_json::Value::Null]
|
||||
};
|
||||
|
||||
let store = DwnStore::new(&config.data_dir).await?;
|
||||
let mut results = Vec::new();
|
||||
|
||||
for message in &messages {
|
||||
let interface = message["descriptor"]["interface"]
|
||||
.as_str()
|
||||
.unwrap_or("");
|
||||
let method = message["descriptor"]["method"]
|
||||
.as_str()
|
||||
.unwrap_or("");
|
||||
|
||||
let result = match (interface, method) {
|
||||
("Records", "Write") => {
|
||||
let author = message["author"].as_str().unwrap_or("unknown");
|
||||
let protocol = message["descriptor"]["protocol"].as_str();
|
||||
let schema = message["descriptor"]["schema"].as_str();
|
||||
let data_format = message["descriptor"]["dataFormat"].as_str();
|
||||
let data = message.get("data").cloned();
|
||||
// Deduplicate: check if recordId already exists
|
||||
if let Some(record_id) = message["recordId"].as_str() {
|
||||
if store.read_message(record_id).await.ok().flatten().is_some() {
|
||||
serde_json::json!({"status": {"code": 200, "detail": "Already exists"}})
|
||||
} else {
|
||||
match store
|
||||
.write_message(author, protocol, schema, data_format, data)
|
||||
.await
|
||||
{
|
||||
Ok(msg) => {
|
||||
serde_json::json!({"status": {"code": 202}, "entry": msg})
|
||||
}
|
||||
Err(e) => serde_json::json!({"status": {"code": 500, "detail": e.to_string()}}),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match store
|
||||
.write_message(author, protocol, schema, data_format, data)
|
||||
.await
|
||||
{
|
||||
Ok(msg) => serde_json::json!({"status": {"code": 202}, "entry": msg}),
|
||||
Err(e) => serde_json::json!({"status": {"code": 500, "detail": e.to_string()}}),
|
||||
}
|
||||
}
|
||||
}
|
||||
("Records", "Query") => {
|
||||
let query = crate::network::dwn_store::MessageQuery {
|
||||
protocol: message["descriptor"]["filter"]["protocol"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string()),
|
||||
schema: message["descriptor"]["filter"]["schema"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string()),
|
||||
author: message["descriptor"]["filter"]["author"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string()),
|
||||
date_from: message["descriptor"]["filter"]["dateFrom"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string()),
|
||||
date_to: message["descriptor"]["filter"]["dateTo"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string()),
|
||||
limit: message["descriptor"]["filter"]["limit"]
|
||||
.as_u64()
|
||||
.map(|n| n as usize),
|
||||
};
|
||||
match store.query_messages(&query).await {
|
||||
Ok(messages) => {
|
||||
serde_json::json!({"status": {"code": 200}, "entries": messages})
|
||||
}
|
||||
Err(e) => {
|
||||
serde_json::json!({"status": {"code": 500, "detail": e.to_string()}})
|
||||
}
|
||||
}
|
||||
}
|
||||
("Records", "Read") => {
|
||||
let record_id = message["descriptor"]["recordId"]
|
||||
.as_str()
|
||||
.unwrap_or("");
|
||||
match store.read_message(record_id).await {
|
||||
Ok(Some(msg)) => {
|
||||
serde_json::json!({"status": {"code": 200}, "entry": msg})
|
||||
}
|
||||
Ok(None) => serde_json::json!({"status": {"code": 404, "detail": "Record not found"}}),
|
||||
Err(e) => {
|
||||
serde_json::json!({"status": {"code": 500, "detail": e.to_string()}})
|
||||
}
|
||||
}
|
||||
}
|
||||
("Records", "Delete") => {
|
||||
let record_id = message["descriptor"]["recordId"]
|
||||
.as_str()
|
||||
.unwrap_or("");
|
||||
match store.delete_message(record_id).await {
|
||||
Ok(true) => serde_json::json!({"status": {"code": 200}}),
|
||||
Ok(false) => serde_json::json!({"status": {"code": 404, "detail": "Record not found"}}),
|
||||
Err(e) => {
|
||||
serde_json::json!({"status": {"code": 500, "detail": e.to_string()}})
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
serde_json::json!({"status": {"code": 400, "detail": format!("Unknown method: {}.{}", interface, method)}})
|
||||
}
|
||||
};
|
||||
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
// Return single result for single message, array for batch
|
||||
let (response_body, http_status) = if results.len() == 1 {
|
||||
let result = &results[0];
|
||||
let status_code = result["status"]["code"].as_u64().unwrap_or(200);
|
||||
let http_status = match status_code {
|
||||
202 => StatusCode::ACCEPTED,
|
||||
400 => StatusCode::BAD_REQUEST,
|
||||
404 => StatusCode::NOT_FOUND,
|
||||
500 => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
_ => StatusCode::OK,
|
||||
};
|
||||
(result.to_string(), http_status)
|
||||
} else {
|
||||
(
|
||||
serde_json::json!({"replies": results}).to_string(),
|
||||
StatusCode::OK,
|
||||
)
|
||||
};
|
||||
|
||||
Ok(build_response(http_status, "application/json", hyper::Body::from(response_body)))
|
||||
}
|
||||
}
|
||||
267
core/archipelago/src/api/handler/mod.rs
Normal file
267
core/archipelago/src/api/handler/mod.rs
Normal file
@@ -0,0 +1,267 @@
|
||||
mod content;
|
||||
mod dwn;
|
||||
mod node_message;
|
||||
mod proxy;
|
||||
mod websocket;
|
||||
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use crate::config::Config;
|
||||
use crate::monitoring::MetricsStore;
|
||||
use crate::session::{self, SessionStore};
|
||||
use crate::state::StateManager;
|
||||
use anyhow::Result;
|
||||
use hyper::{Method, Request, Response, StatusCode};
|
||||
use std::sync::Arc;
|
||||
use tracing::debug;
|
||||
|
||||
/// Build an HTTP response without unwrap. Falls back to a plain 500 if builder fails.
|
||||
// Used by handler submodules after unwrap elimination
|
||||
#[allow(dead_code)]
|
||||
pub(super) fn build_response(status: StatusCode, content_type: &str, body: hyper::Body) -> Response<hyper::Body> {
|
||||
Response::builder()
|
||||
.status(status)
|
||||
.header("Content-Type", content_type)
|
||||
.body(body)
|
||||
.unwrap_or_else(|_| Response::new(hyper::Body::from("Internal error")))
|
||||
}
|
||||
|
||||
pub struct ApiHandler {
|
||||
config: Config,
|
||||
rpc_handler: Arc<RpcHandler>,
|
||||
state_manager: Arc<StateManager>,
|
||||
metrics_store: Arc<MetricsStore>,
|
||||
session_store: SessionStore,
|
||||
}
|
||||
|
||||
impl ApiHandler {
|
||||
pub async fn new(
|
||||
config: Config,
|
||||
state_manager: Arc<StateManager>,
|
||||
metrics_store: Arc<MetricsStore>,
|
||||
) -> Result<Self> {
|
||||
let session_store = SessionStore::new().await;
|
||||
let rpc_handler = Arc::new(
|
||||
RpcHandler::new(
|
||||
config.clone(),
|
||||
state_manager.clone(),
|
||||
metrics_store.clone(),
|
||||
session_store.clone(),
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
rpc_handler,
|
||||
state_manager,
|
||||
metrics_store,
|
||||
session_store,
|
||||
})
|
||||
}
|
||||
|
||||
/// Access the RPC handler (for service initialization after construction).
|
||||
pub fn rpc_handler(&self) -> &Arc<RpcHandler> {
|
||||
&self.rpc_handler
|
||||
}
|
||||
|
||||
/// Check if the request has a valid session cookie.
|
||||
async fn is_authenticated(&self, headers: &hyper::HeaderMap) -> bool {
|
||||
match session::extract_session_cookie(headers) {
|
||||
Some(token) => self.session_store.validate(&token).await,
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a 401 Unauthorized JSON response.
|
||||
fn unauthorized() -> Response<hyper::Body> {
|
||||
let body = serde_json::json!({ "error": "Unauthorized" });
|
||||
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
||||
Response::builder()
|
||||
.status(StatusCode::UNAUTHORIZED)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(body_bytes))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Allowed CORS origins derived from the config host IP.
|
||||
fn allowed_origins(&self) -> Vec<String> {
|
||||
let mut origins = vec![
|
||||
format!("http://{}", self.config.host_ip),
|
||||
format!("https://{}", self.config.host_ip),
|
||||
];
|
||||
if self.config.dev_mode {
|
||||
origins.push("http://localhost:8100".to_string()); // Vite dev server
|
||||
}
|
||||
origins
|
||||
}
|
||||
|
||||
/// Validate the Origin header against allowed origins.
|
||||
/// Returns the matched origin if valid, None if cross-origin is not allowed.
|
||||
fn validate_origin(&self, headers: &hyper::HeaderMap) -> Option<String> {
|
||||
let origin = headers
|
||||
.get("origin")
|
||||
.and_then(|v| v.to_str().ok())?;
|
||||
let allowed = self.allowed_origins();
|
||||
if allowed.iter().any(|a| a == origin) {
|
||||
Some(origin.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_request(
|
||||
&self,
|
||||
req: Request<hyper::Body>,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let path = req.uri().path().to_string();
|
||||
let method = req.method().clone();
|
||||
|
||||
// Handle CORS preflight for all routes
|
||||
if method == Method::OPTIONS {
|
||||
let mut builder = Response::builder()
|
||||
.status(StatusCode::NO_CONTENT)
|
||||
.header("Vary", "Origin");
|
||||
if let Some(origin) = self.validate_origin(req.headers()) {
|
||||
builder = builder
|
||||
.header("Access-Control-Allow-Origin", &origin)
|
||||
.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
.header("Access-Control-Allow-Headers", "Content-Type, X-CSRF-Token")
|
||||
.header("Access-Control-Allow-Credentials", "true");
|
||||
}
|
||||
return Ok(builder.body(hyper::Body::empty()).unwrap());
|
||||
}
|
||||
|
||||
// WebSocket upgrade — validate session before upgrading
|
||||
if method == Method::GET && path == "/ws/db" {
|
||||
if !self.is_authenticated(req.headers()).await {
|
||||
tracing::warn!("401 WebSocket /ws/db — session invalid or missing");
|
||||
return Ok(Self::unauthorized());
|
||||
}
|
||||
return Self::handle_websocket(req, self.state_manager.clone(), self.metrics_store.clone()).await;
|
||||
}
|
||||
|
||||
// Convert body to bytes for non-WS routes
|
||||
let headers = req.headers().clone();
|
||||
let (parts, body) = req.into_parts();
|
||||
let body_bytes = hyper::body::to_bytes(body).await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to read body: {}", e))?;
|
||||
let req_with_bytes = Request::from_parts(parts, hyper::Body::from(body_bytes.clone()));
|
||||
|
||||
debug!("{} {}", method, path);
|
||||
|
||||
match (method, path.as_str()) {
|
||||
// RPC — auth is handled inside rpc handler per-method
|
||||
(Method::POST, "/rpc/v1") => self.rpc_handler.handle(req_with_bytes).await,
|
||||
|
||||
// Health — unauthenticated, returns JSON with service status
|
||||
(Method::GET, "/health") => {
|
||||
let recovery_complete = crate::crash_recovery::is_recovery_complete();
|
||||
let uptime = crate::crash_recovery::uptime_seconds();
|
||||
let health_status = if recovery_complete { "ok" } else { "degraded" };
|
||||
let status = serde_json::json!({
|
||||
"status": health_status,
|
||||
"crash_recovery_complete": recovery_complete,
|
||||
"uptime_seconds": uptime,
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"services": {
|
||||
"rpc": true,
|
||||
"sessions": true,
|
||||
}
|
||||
});
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(serde_json::to_vec(&status).unwrap_or_default()))
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
// Node message — P2P endpoint (authenticated by source validation, not cookie)
|
||||
(Method::POST, "/archipelago/node-message") => {
|
||||
Self::handle_node_message(body_bytes).await
|
||||
}
|
||||
|
||||
// Content serving — peers access shared content over Tor (no session auth)
|
||||
(Method::GET, p) if p.starts_with("/content/") => {
|
||||
Self::handle_content_request(p, &headers, &self.config).await
|
||||
}
|
||||
|
||||
// Content catalog — list available content (no session auth, for peers)
|
||||
(Method::GET, "/content") => {
|
||||
Self::handle_content_catalog(&self.config).await
|
||||
}
|
||||
|
||||
// Electrs status — unauthenticated (read-only sync status)
|
||||
(Method::GET, "/electrs-status") => Self::handle_electrs_status().await,
|
||||
|
||||
// LND connect info — nginx validates session cookie (presence check),
|
||||
// backend is bound to 127.0.0.1 so only nginx can reach it.
|
||||
// No backend auth check here because the LND UI iframe fetches this
|
||||
// endpoint and the session cookie flow is validated at the nginx layer.
|
||||
(Method::GET, "/lnd-connect-info") => {
|
||||
Self::handle_lnd_connect_info(self.rpc_handler.clone()).await
|
||||
}
|
||||
|
||||
// Container logs — requires session
|
||||
(Method::GET, path) if path.starts_with("/api/container/logs") => {
|
||||
if !self.is_authenticated(&headers).await {
|
||||
return Ok(Self::unauthorized());
|
||||
}
|
||||
let origin = self.validate_origin(&headers).unwrap_or_default();
|
||||
Self::handle_container_logs_http(self.rpc_handler.clone(), path, &origin).await
|
||||
}
|
||||
|
||||
// LND proxy — requires session
|
||||
(Method::GET, path) if path.starts_with("/proxy/lnd/") => {
|
||||
if !self.is_authenticated(&headers).await {
|
||||
return Ok(Self::unauthorized());
|
||||
}
|
||||
let origin = self.validate_origin(&headers).unwrap_or_default();
|
||||
Self::handle_lnd_proxy(path, &origin).await
|
||||
}
|
||||
|
||||
// DWN health — unauthenticated
|
||||
(Method::GET, "/dwn/health") => {
|
||||
Self::handle_dwn_health(&self.config).await
|
||||
}
|
||||
|
||||
// DWN message processing — peers access over Tor for sync (no session auth)
|
||||
(Method::POST, "/dwn") => {
|
||||
Self::handle_dwn_message(body_bytes, &self.config).await
|
||||
}
|
||||
|
||||
_ => Ok(Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(hyper::Body::from("Not Found"))
|
||||
.unwrap()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate that an app ID matches the safe pattern: lowercase alphanumeric + hyphens.
|
||||
fn is_valid_app_id(id: &str) -> bool {
|
||||
!id.is_empty()
|
||||
&& id.len() <= 64
|
||||
&& id.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
|
||||
&& id.as_bytes()[0] != b'-'
|
||||
}
|
||||
|
||||
/// Validate that a pubkey is a 64-char hex string.
|
||||
fn is_valid_pubkey_hex(s: &str) -> bool {
|
||||
s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
/// Strip newlines and ANSI escape sequences from strings before logging.
|
||||
fn sanitize_log_string(s: &str) -> String {
|
||||
s.replace('\n', "\\n")
|
||||
.replace('\r', "\\r")
|
||||
.replace('\x1b', "")
|
||||
}
|
||||
|
||||
/// Strip HTML-sensitive characters to prevent XSS when stored/rendered.
|
||||
fn sanitize_html(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
74
core/archipelago/src/api/handler/node_message.rs
Normal file
74
core/archipelago/src/api/handler/node_message.rs
Normal file
@@ -0,0 +1,74 @@
|
||||
use crate::node_message as node_msg;
|
||||
use super::build_response;use anyhow::Result;
|
||||
use hyper::{Response, StatusCode};
|
||||
|
||||
use super::{ApiHandler, is_valid_pubkey_hex, sanitize_html, sanitize_log_string};
|
||||
|
||||
impl ApiHandler {
|
||||
pub(super) async fn handle_node_message(body: hyper::body::Bytes) -> Result<Response<hyper::Body>> {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Incoming {
|
||||
from_pubkey: Option<String>,
|
||||
message: Option<String>,
|
||||
signature: Option<String>,
|
||||
#[serde(default)]
|
||||
encrypted: bool,
|
||||
}
|
||||
let incoming: Incoming = serde_json::from_slice(&body).unwrap_or(Incoming {
|
||||
from_pubkey: None,
|
||||
message: None,
|
||||
signature: None,
|
||||
encrypted: false,
|
||||
});
|
||||
if let (Some(from), Some(msg)) = (incoming.from_pubkey.as_ref(), incoming.message.as_ref()) {
|
||||
// Validate from_pubkey is a valid hex ed25519 pubkey
|
||||
if !is_valid_pubkey_hex(from) {
|
||||
return Ok(build_response(StatusCode::BAD_REQUEST, "application/json", hyper::Body::from(r#"{"error":"Invalid pubkey format"}"#)));
|
||||
}
|
||||
// Verify ed25519 signature if provided (required for trusted messages)
|
||||
if let Some(sig_hex) = &incoming.signature {
|
||||
match crate::identity::NodeIdentity::verify(from, msg.as_bytes(), sig_hex) {
|
||||
Ok(true) => {}
|
||||
_ => {
|
||||
return Ok(build_response(StatusCode::FORBIDDEN, "application/json", hyper::Body::from(r#"{"error":"Invalid signature"}"#)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Decrypt if the message is E2E encrypted
|
||||
let plaintext = if incoming.encrypted {
|
||||
// Load our identity to derive shared secret
|
||||
let data_dir = std::path::Path::new("/var/lib/archipelago");
|
||||
let identity_dir = data_dir.join("identity");
|
||||
match crate::identity::NodeIdentity::load_or_create(&identity_dir).await {
|
||||
Ok(node_id) => {
|
||||
match node_msg::decrypt_from_peer(node_id.signing_key(), from, msg) {
|
||||
Ok(decrypted) => {
|
||||
tracing::info!("Decrypted E2E message from {}...", &from[..16.min(from.len())]);
|
||||
decrypted
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("E2E decryption failed from {}: {}", &from[..16.min(from.len())], e);
|
||||
return Ok(build_response(StatusCode::BAD_REQUEST, "application/json", hyper::Body::from(r#"{"error":"Decryption failed"}"#)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Cannot decrypt: identity load failed: {}", e);
|
||||
msg.clone()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
msg.clone()
|
||||
};
|
||||
|
||||
let safe_from = sanitize_log_string(from);
|
||||
let safe_msg = sanitize_log_string(&plaintext);
|
||||
tracing::info!("Received message from {}: {}", safe_from, safe_msg);
|
||||
let clean_from = sanitize_html(from);
|
||||
let clean_msg = sanitize_html(&plaintext);
|
||||
node_msg::store_received(&clean_from, &clean_msg).await;
|
||||
}
|
||||
Ok(build_response(StatusCode::OK, "application/json", hyper::Body::from(r#"{"ok":true}"#)))
|
||||
}
|
||||
}
|
||||
131
core/archipelago/src/api/handler/proxy.rs
Normal file
131
core/archipelago/src/api/handler/proxy.rs
Normal file
@@ -0,0 +1,131 @@
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use super::build_response;use crate::electrs_status;
|
||||
use anyhow::Result;
|
||||
use hyper::{Response, StatusCode};
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::{ApiHandler, is_valid_app_id};
|
||||
|
||||
impl ApiHandler {
|
||||
pub(super) async fn handle_container_logs_http(
|
||||
rpc: Arc<RpcHandler>,
|
||||
path: &str,
|
||||
cors_origin: &str,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let query = path
|
||||
.strip_prefix("/api/container/logs")
|
||||
.and_then(|s| s.strip_prefix('?'))
|
||||
.unwrap_or("");
|
||||
let params: std::collections::HashMap<String, String> =
|
||||
query
|
||||
.split('&')
|
||||
.filter_map(|p| {
|
||||
let mut it = p.splitn(2, '=');
|
||||
let k = it.next()?.to_string();
|
||||
let v = it.next()?.to_string();
|
||||
Some((k, v))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let app_id = params.get("app_id").map(|s| s.as_str()).unwrap_or("lnd");
|
||||
|
||||
// Validate app_id format
|
||||
if !is_valid_app_id(app_id) {
|
||||
let body = serde_json::json!({ "error": "Invalid app_id" });
|
||||
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
||||
return Ok(build_response(StatusCode::BAD_REQUEST, "application/json", hyper::Body::from(body_bytes)));
|
||||
}
|
||||
|
||||
let lines = params
|
||||
.get("lines")
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
.unwrap_or(200);
|
||||
|
||||
match rpc.get_container_logs_value(app_id, lines).await {
|
||||
Ok(value) => {
|
||||
let body = serde_json::json!({ "result": value });
|
||||
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Access-Control-Allow-Origin", cors_origin)
|
||||
.header("Access-Control-Allow-Credentials", "true")
|
||||
.header("Vary", "Origin")
|
||||
.body(hyper::Body::from(body_bytes))
|
||||
.unwrap())
|
||||
}
|
||||
Err(e) => {
|
||||
let body = serde_json::json!({ "error": e.to_string() });
|
||||
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Access-Control-Allow-Origin", cors_origin)
|
||||
.header("Access-Control-Allow-Credentials", "true")
|
||||
.header("Vary", "Origin")
|
||||
.body(hyper::Body::from(body_bytes))
|
||||
.unwrap())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn handle_electrs_status() -> Result<Response<hyper::Body>> {
|
||||
let status = electrs_status::get_electrs_sync_status().await;
|
||||
let body = serde_json::to_vec(&status).unwrap_or_default();
|
||||
Ok(build_response(StatusCode::OK, "application/json", hyper::Body::from(body)))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_lnd_connect_info(
|
||||
rpc: std::sync::Arc<super::super::rpc::RpcHandler>,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
match rpc.handle_lnd_connect_info().await {
|
||||
Ok(val) => {
|
||||
let body = serde_json::to_vec(&val).unwrap_or_default();
|
||||
Ok(build_response(StatusCode::OK, "application/json", hyper::Body::from(body)))
|
||||
}
|
||||
Err(e) => Ok(Response::builder()
|
||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(
|
||||
serde_json::json!({"error": e.to_string()}).to_string(),
|
||||
))
|
||||
.unwrap()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn handle_lnd_proxy(path: &str, cors_origin: &str) -> Result<Response<hyper::Body>> {
|
||||
let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/");
|
||||
let url = format!("http://127.0.0.1:8080{}", suffix);
|
||||
match reqwest::get(&url).await {
|
||||
Ok(resp) => {
|
||||
let status = resp.status().as_u16();
|
||||
let headers = resp.headers().clone();
|
||||
let body = resp.bytes().await.unwrap_or_default();
|
||||
let mut builder = Response::builder().status(status);
|
||||
if let Some(ct) = headers.get("content-type") {
|
||||
if let Ok(s) = ct.to_str() {
|
||||
builder = builder.header("Content-Type", s);
|
||||
}
|
||||
}
|
||||
builder
|
||||
.header("Access-Control-Allow-Origin", cors_origin)
|
||||
.header("Access-Control-Allow-Credentials", "true")
|
||||
.header("Vary", "Origin")
|
||||
.body(hyper::Body::from(body))
|
||||
.map_err(|e| anyhow::anyhow!("response build: {}", e))
|
||||
}
|
||||
Err(e) => {
|
||||
let body = serde_json::json!({ "error": e.to_string() });
|
||||
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::BAD_GATEWAY)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Access-Control-Allow-Origin", cors_origin)
|
||||
.header("Access-Control-Allow-Credentials", "true")
|
||||
.header("Vary", "Origin")
|
||||
.body(hyper::Body::from(body_bytes))
|
||||
.unwrap())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
128
core/archipelago/src/api/handler/websocket.rs
Normal file
128
core/archipelago/src/api/handler/websocket.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
use crate::monitoring::MetricsStore;
|
||||
use crate::state::StateManager;
|
||||
use anyhow::Result;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use hyper::{Request, Response};
|
||||
use hyper_ws_listener::WsStream;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tracing::{debug, info};
|
||||
|
||||
use super::ApiHandler;
|
||||
|
||||
impl ApiHandler {
|
||||
pub(super) async fn handle_websocket(
|
||||
req: Request<hyper::Body>,
|
||||
state_manager: Arc<StateManager>,
|
||||
metrics_store: Arc<MetricsStore>,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let (response, ws_fut_opt) = hyper_ws_listener::create_ws(req)
|
||||
.map_err(|e| anyhow::anyhow!("WebSocket upgrade failed: {}", e))?;
|
||||
|
||||
if let Some(ws_fut) = ws_fut_opt {
|
||||
tokio::spawn(async move {
|
||||
let ws_stream: WsStream = match ws_fut.await {
|
||||
Ok(Ok(s)) => s,
|
||||
Ok(Err(e)) => {
|
||||
debug!("WebSocket handshake failed (hyper): {}", e);
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("WebSocket task join failed: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
metrics_store.increment_ws();
|
||||
info!("WebSocket /ws/db connected");
|
||||
|
||||
let (mut tx, mut rx) = ws_stream.split();
|
||||
|
||||
let initial_msg = state_manager.get_initial_message().await;
|
||||
if let Ok(json_msg) = serde_json::to_string(&initial_msg) {
|
||||
if let Err(e) = tx.send(Message::Text(json_msg)).await {
|
||||
debug!("Failed to send initial data: {}", e);
|
||||
return;
|
||||
}
|
||||
debug!("Sent initial data dump at revision {}", initial_msg.rev);
|
||||
}
|
||||
|
||||
let mut state_rx = state_manager.subscribe();
|
||||
let ping_interval = tokio::time::interval(tokio::time::Duration::from_secs(30));
|
||||
tokio::pin!(ping_interval);
|
||||
let mut last_client_activity = Instant::now();
|
||||
const INACTIVITY_TIMEOUT_SECS: u64 = 300; // 5 minutes
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = ping_interval.tick() => {
|
||||
// Check inactivity timeout
|
||||
if last_client_activity.elapsed().as_secs() >= INACTIVITY_TIMEOUT_SECS {
|
||||
info!("WebSocket client inactive for {}s, closing", INACTIVITY_TIMEOUT_SECS);
|
||||
let _ = tx.send(Message::Close(None)).await;
|
||||
break;
|
||||
}
|
||||
if tx.send(Message::Ping(vec![])).await.is_err() {
|
||||
debug!("Failed to send ping, connection likely closed");
|
||||
break;
|
||||
}
|
||||
}
|
||||
update = state_rx.recv() => {
|
||||
match update {
|
||||
Ok(msg) => {
|
||||
if let Ok(json_msg) = serde_json::to_string(&msg) {
|
||||
if let Err(e) = tx.send(Message::Text(json_msg)).await {
|
||||
debug!("Failed to send state update: {}", e);
|
||||
break;
|
||||
}
|
||||
debug!("Sent state update at revision {}", msg.rev);
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(skipped)) => {
|
||||
debug!("Client lagged behind, skipped {} messages", skipped);
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
debug!("Broadcast channel closed");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
msg = rx.next() => {
|
||||
match msg {
|
||||
Some(Ok(Message::Close(_))) => break,
|
||||
Some(Ok(Message::Pong(_))) => {
|
||||
last_client_activity = Instant::now();
|
||||
debug!("Received pong");
|
||||
}
|
||||
Some(Ok(Message::Ping(data))) => {
|
||||
last_client_activity = Instant::now();
|
||||
let _ = tx.send(Message::Pong(data)).await;
|
||||
}
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
last_client_activity = Instant::now();
|
||||
// Handle JSON ping from frontend
|
||||
if text.contains("\"type\":\"ping\"") || text.contains("\"type\": \"ping\"") {
|
||||
let _ = tx.send(Message::Text(r#"{"type":"pong"}"#.to_string())).await;
|
||||
}
|
||||
}
|
||||
Some(Ok(_)) => {
|
||||
last_client_activity = Instant::now();
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
debug!("WebSocket stream error: {}", e);
|
||||
break;
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
metrics_store.decrement_ws();
|
||||
info!("WebSocket /ws/db disconnected");
|
||||
});
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,8 @@
|
||||
//! Data stays local until explicitly shared via future relay mechanism.
|
||||
|
||||
use super::RpcHandler;
|
||||
use anyhow::Result;
|
||||
use tracing::info;
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
const ANALYTICS_FILE: &str = "analytics-config.json";
|
||||
|
||||
@@ -117,4 +117,322 @@ impl RpcHandler {
|
||||
"collected_at": chrono::Utc::now().to_rfc3339(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Build a full telemetry report for the beta fleet monitoring.
|
||||
/// Includes health data, container states, errors, and uptime.
|
||||
/// No wallet data, no keys, no personal data — only system health.
|
||||
pub(super) async fn handle_telemetry_report(&self) -> Result<serde_json::Value> {
|
||||
// Check opt-in
|
||||
let config_path = self.config.data_dir.join(ANALYTICS_FILE);
|
||||
let enabled = if config_path.exists() {
|
||||
let data = tokio::fs::read_to_string(&config_path).await?;
|
||||
let config: serde_json::Value = serde_json::from_str(&data).unwrap_or_default();
|
||||
config["enabled"].as_bool().unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if !enabled {
|
||||
anyhow::bail!("Telemetry not enabled. Opt in via analytics.enable first.");
|
||||
}
|
||||
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
|
||||
// Anonymous node ID — SHA-256 hash of the DID (not the DID itself)
|
||||
let node_id = {
|
||||
use sha2::{Sha256, Digest};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(data.server_info.pubkey.as_bytes());
|
||||
hex::encode(hasher.finalize())[..16].to_string()
|
||||
};
|
||||
|
||||
// Container states
|
||||
let containers: Vec<serde_json::Value> = data.package_data.iter().map(|(id, pkg)| {
|
||||
serde_json::json!({
|
||||
"id": id,
|
||||
"state": format!("{:?}", pkg.state),
|
||||
"version": pkg.manifest.version,
|
||||
})
|
||||
}).collect();
|
||||
|
||||
// System stats
|
||||
let cpu_cores = std::thread::available_parallelism()
|
||||
.map(|n| n.get()).unwrap_or(0);
|
||||
let mem_output = tokio::process::Command::new("grep")
|
||||
.args(["MemTotal", "/proc/meminfo"])
|
||||
.output().await;
|
||||
let total_ram_mb = mem_output.ok()
|
||||
.and_then(|o| String::from_utf8_lossy(&o.stdout).split_whitespace().nth(1)?.parse::<u64>().ok())
|
||||
.map(|kb| kb / 1024).unwrap_or(0);
|
||||
|
||||
// Uptime
|
||||
let uptime_secs = tokio::fs::read_to_string("/proc/uptime").await
|
||||
.ok()
|
||||
.and_then(|s| s.split_whitespace().next()?.parse::<f64>().ok())
|
||||
.map(|f| f as u64)
|
||||
.unwrap_or(0);
|
||||
|
||||
// Recent alerts from metrics store
|
||||
let recent_alerts: Vec<serde_json::Value> = self.metrics_store.get_fired_alerts(10).await
|
||||
.into_iter()
|
||||
.map(|a| serde_json::json!({
|
||||
"rule": format!("{:?}", a.kind),
|
||||
"message": a.message,
|
||||
"timestamp": a.timestamp,
|
||||
}))
|
||||
.collect();
|
||||
|
||||
let report = serde_json::json!({
|
||||
"node_id": node_id,
|
||||
"version": data.server_info.version,
|
||||
"uptime_secs": uptime_secs,
|
||||
"cpu_cores": cpu_cores,
|
||||
"ram_mb": total_ram_mb,
|
||||
"containers": containers,
|
||||
"container_count": data.package_data.len(),
|
||||
"running_count": data.package_data.values()
|
||||
.filter(|p| matches!(p.state, crate::data_model::PackageState::Running)).count(),
|
||||
"federation_peers": data.peer_health.len(),
|
||||
"recent_alerts": recent_alerts,
|
||||
"reported_at": chrono::Utc::now().to_rfc3339(),
|
||||
});
|
||||
|
||||
// Save latest report to disk for debugging
|
||||
let report_path = self.config.data_dir.join("telemetry-latest.json");
|
||||
let _ = tokio::fs::write(&report_path, serde_json::to_string_pretty(&report)?).await;
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
// ── Fleet telemetry collector endpoints ──────────────────────────────
|
||||
|
||||
/// Receive a telemetry report from a fleet node.
|
||||
/// Stores it in telemetry-fleet/ directory, indexed by node_id.
|
||||
/// Does NOT require auth — called by remote nodes posting reports.
|
||||
pub(super) async fn handle_telemetry_ingest(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
||||
let report = params.context("Missing telemetry report payload")?;
|
||||
|
||||
// Validate required fields
|
||||
let node_id = report.get("node_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.context("Missing required field: node_id")?;
|
||||
if node_id.is_empty() || node_id.len() > 64 {
|
||||
anyhow::bail!("Invalid node_id: must be 1-64 characters");
|
||||
}
|
||||
// Sanitize node_id to prevent path traversal
|
||||
if node_id.contains('/') || node_id.contains('\\') || node_id.contains("..") {
|
||||
anyhow::bail!("Invalid node_id: contains disallowed characters");
|
||||
}
|
||||
let _version = report.get("version")
|
||||
.and_then(|v| v.as_str())
|
||||
.context("Missing required field: version")?;
|
||||
let _reported_at = report.get("reported_at")
|
||||
.and_then(|v| v.as_str())
|
||||
.context("Missing required field: reported_at")?;
|
||||
|
||||
let fleet_dir = self.config.data_dir.join("telemetry-fleet");
|
||||
tokio::fs::create_dir_all(&fleet_dir).await
|
||||
.context("Failed to create telemetry-fleet directory")?;
|
||||
|
||||
// Write latest report (overwrites previous)
|
||||
let latest_path = fleet_dir.join(format!("{}.json", node_id));
|
||||
let report_json = serde_json::to_string_pretty(&report)
|
||||
.context("Failed to serialize report")?;
|
||||
tokio::fs::write(&latest_path, &report_json).await
|
||||
.context("Failed to write latest fleet report")?;
|
||||
|
||||
// Append to history file (cap at 200 entries)
|
||||
let history_path = fleet_dir.join(format!("{}-history.json", node_id));
|
||||
let mut history: Vec<serde_json::Value> = match tokio::fs::read_to_string(&history_path).await {
|
||||
Ok(data) => serde_json::from_str(&data).unwrap_or_default(),
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
history.push(report.clone());
|
||||
// Keep only the last 200 entries
|
||||
if history.len() > 200 {
|
||||
let start = history.len() - 200;
|
||||
history = history.split_off(start);
|
||||
}
|
||||
let history_json = serde_json::to_string_pretty(&history)
|
||||
.context("Failed to serialize history")?;
|
||||
tokio::fs::write(&history_path, &history_json).await
|
||||
.context("Failed to write fleet history")?;
|
||||
|
||||
debug!(node_id = %node_id, "Ingested fleet telemetry report");
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"status": "ok",
|
||||
"node_id": node_id,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Get all fleet nodes' latest reports.
|
||||
/// Reads all {node_id}.json files from telemetry-fleet/ (excluding *-history.json).
|
||||
pub(super) async fn handle_telemetry_fleet_status(&self) -> Result<serde_json::Value> {
|
||||
let fleet_dir = self.config.data_dir.join("telemetry-fleet");
|
||||
if !fleet_dir.exists() {
|
||||
return Ok(serde_json::json!({ "nodes": [] }));
|
||||
}
|
||||
|
||||
let mut nodes: Vec<serde_json::Value> = Vec::new();
|
||||
let mut entries = tokio::fs::read_dir(&fleet_dir).await
|
||||
.context("Failed to read telemetry-fleet directory")?;
|
||||
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let file_name = entry.file_name();
|
||||
let name = file_name.to_string_lossy();
|
||||
// Skip history files and non-JSON files
|
||||
if name.ends_with("-history.json") || !name.ends_with(".json") {
|
||||
continue;
|
||||
}
|
||||
|
||||
match tokio::fs::read_to_string(entry.path()).await {
|
||||
Ok(data) => {
|
||||
match serde_json::from_str::<serde_json::Value>(&data) {
|
||||
Ok(mut report) => {
|
||||
// Compute online/offline status from reported_at
|
||||
let is_online = report.get("reported_at")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
|
||||
.map(|dt| {
|
||||
let age = chrono::Utc::now().signed_duration_since(dt);
|
||||
age.num_minutes() < 30
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
// Compute human-readable last_seen
|
||||
let last_seen = report.get("reported_at")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
|
||||
.map(|dt| {
|
||||
let age = chrono::Utc::now().signed_duration_since(dt);
|
||||
let mins = age.num_minutes();
|
||||
if mins < 1 {
|
||||
"just now".to_string()
|
||||
} else if mins < 60 {
|
||||
format!("{}m ago", mins)
|
||||
} else if mins < 1440 {
|
||||
format!("{}h ago", mins / 60)
|
||||
} else {
|
||||
format!("{}d ago", mins / 1440)
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
if let Some(obj) = report.as_object_mut() {
|
||||
obj.insert("online".to_string(), serde_json::json!(is_online));
|
||||
obj.insert("last_seen".to_string(), serde_json::json!(last_seen));
|
||||
}
|
||||
nodes.push(report);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(file = %name, error = %e, "Skipping corrupt fleet report");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(file = %name, error = %e, "Failed to read fleet report");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by node_id for stable ordering
|
||||
nodes.sort_by(|a, b| {
|
||||
let a_id = a.get("node_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let b_id = b.get("node_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
a_id.cmp(b_id)
|
||||
});
|
||||
|
||||
info!(count = nodes.len(), "Fleet status query");
|
||||
|
||||
Ok(serde_json::json!({ "nodes": nodes }))
|
||||
}
|
||||
|
||||
/// Get history for a specific fleet node.
|
||||
/// Reads telemetry-fleet/{node_id}-history.json.
|
||||
pub(super) async fn handle_telemetry_fleet_node_history(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
||||
let p = params.context("Missing params")?;
|
||||
let node_id = p.get("node_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.context("Missing required field: node_id")?;
|
||||
|
||||
// Sanitize node_id
|
||||
if node_id.is_empty() || node_id.len() > 64
|
||||
|| node_id.contains('/') || node_id.contains('\\') || node_id.contains("..")
|
||||
{
|
||||
anyhow::bail!("Invalid node_id");
|
||||
}
|
||||
|
||||
let history_path = self.config.data_dir
|
||||
.join("telemetry-fleet")
|
||||
.join(format!("{}-history.json", node_id));
|
||||
|
||||
let history: Vec<serde_json::Value> = match tokio::fs::read_to_string(&history_path).await {
|
||||
Ok(data) => serde_json::from_str(&data).unwrap_or_default(),
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"node_id": node_id,
|
||||
"entries": history,
|
||||
"count": history.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Get aggregated fleet alerts across all nodes.
|
||||
/// Reads all fleet reports, collects recent_alerts, sorts by timestamp descending.
|
||||
pub(super) async fn handle_telemetry_fleet_alerts(&self) -> Result<serde_json::Value> {
|
||||
let fleet_dir = self.config.data_dir.join("telemetry-fleet");
|
||||
if !fleet_dir.exists() {
|
||||
return Ok(serde_json::json!({ "alerts": [] }));
|
||||
}
|
||||
|
||||
let mut all_alerts: Vec<serde_json::Value> = Vec::new();
|
||||
let mut entries = tokio::fs::read_dir(&fleet_dir).await
|
||||
.context("Failed to read telemetry-fleet directory")?;
|
||||
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let file_name = entry.file_name();
|
||||
let name = file_name.to_string_lossy();
|
||||
// Only read latest reports, skip history files
|
||||
if name.ends_with("-history.json") || !name.ends_with(".json") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let data = match tokio::fs::read_to_string(entry.path()).await {
|
||||
Ok(d) => d,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let report: serde_json::Value = match serde_json::from_str(&data) {
|
||||
Ok(r) => r,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let node_id = report.get("node_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
if let Some(alerts) = report.get("recent_alerts").and_then(|v| v.as_array()) {
|
||||
for alert in alerts {
|
||||
let mut enriched = alert.clone();
|
||||
if let Some(obj) = enriched.as_object_mut() {
|
||||
obj.insert("node_id".to_string(), serde_json::json!(node_id));
|
||||
}
|
||||
all_alerts.push(enriched);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by timestamp descending (most recent first)
|
||||
all_alerts.sort_by(|a, b| {
|
||||
let a_ts = a.get("timestamp").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let b_ts = b.get("timestamp").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
b_ts.cmp(&a_ts)
|
||||
});
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"alerts": all_alerts,
|
||||
"count": all_alerts.len(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,35 @@ impl RpcHandler {
|
||||
Ok(serde_json::json!({ "success": true, "session_rotated": true }))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_auth_is_setup(&self) -> Result<serde_json::Value> {
|
||||
let is_setup = self.auth_manager.is_setup().await?;
|
||||
Ok(serde_json::json!(is_setup))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_auth_setup(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
// Prevent re-setup if already set up
|
||||
let is_setup = self.auth_manager.is_setup().await?;
|
||||
if is_setup {
|
||||
return Err(anyhow::anyhow!("Already set up. Use auth.changePassword to change."));
|
||||
}
|
||||
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let password = params
|
||||
.get("password")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing password"))?;
|
||||
|
||||
if password.len() < 8 {
|
||||
return Err(anyhow::anyhow!("Password must be at least 8 characters"));
|
||||
}
|
||||
|
||||
self.auth_manager.setup_user(password).await?;
|
||||
Ok(serde_json::json!(true))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_auth_onboarding_complete(&self) -> Result<serde_json::Value> {
|
||||
self.auth_manager.complete_onboarding().await?;
|
||||
Ok(serde_json::json!(true))
|
||||
@@ -76,7 +105,21 @@ impl RpcHandler {
|
||||
Ok(serde_json::json!(complete))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_auth_reset_onboarding(&self) -> Result<serde_json::Value> {
|
||||
pub(super) async fn handle_auth_reset_onboarding(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let password = params
|
||||
.get("password")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing password — re-authentication required"))?;
|
||||
|
||||
let valid = self.auth_manager.verify_password(password).await?;
|
||||
if !valid {
|
||||
return Err(anyhow::anyhow!("Password Incorrect"));
|
||||
}
|
||||
|
||||
self.auth_manager.reset_onboarding().await?;
|
||||
Ok(serde_json::json!(true))
|
||||
}
|
||||
|
||||
@@ -1,8 +1,61 @@
|
||||
use super::RpcHandler;
|
||||
use crate::backup::full;
|
||||
use anyhow::{Context, Result};
|
||||
use std::net::IpAddr;
|
||||
use tracing::info;
|
||||
|
||||
/// Validate an S3 endpoint URL: require https, reject private/loopback IPs and localhost.
|
||||
fn validate_s3_endpoint(endpoint: &str) -> Result<()> {
|
||||
// Require HTTPS scheme
|
||||
if !endpoint.starts_with("https://") {
|
||||
anyhow::bail!("S3 endpoint must use https://");
|
||||
}
|
||||
|
||||
// Extract host from URL (strip scheme, path, port)
|
||||
let after_scheme = &endpoint["https://".len()..];
|
||||
let host_port = after_scheme.split('/').next().unwrap_or("");
|
||||
// Strip port if present (handle IPv6 bracket notation)
|
||||
let host = if host_port.starts_with('[') {
|
||||
// IPv6: [::1]:443
|
||||
host_port.split(']').next().unwrap_or("").trim_start_matches('[')
|
||||
} else {
|
||||
host_port.split(':').next().unwrap_or("")
|
||||
};
|
||||
|
||||
if host.is_empty() {
|
||||
anyhow::bail!("S3 endpoint missing host");
|
||||
}
|
||||
|
||||
// Reject localhost
|
||||
if host == "localhost" || host.ends_with(".localhost") {
|
||||
anyhow::bail!("S3 endpoint must not point to localhost");
|
||||
}
|
||||
|
||||
// Parse as IP and reject private/reserved ranges
|
||||
if let Ok(ip) = host.parse::<IpAddr>() {
|
||||
let is_private = match ip {
|
||||
IpAddr::V4(v4) => {
|
||||
v4.is_loopback() // 127.0.0.0/8
|
||||
|| v4.octets()[0] == 10 // 10.0.0.0/8
|
||||
|| (v4.octets()[0] == 172 && (v4.octets()[1] & 0xf0) == 16) // 172.16.0.0/12
|
||||
|| (v4.octets()[0] == 192 && v4.octets()[1] == 168) // 192.168.0.0/16
|
||||
|| (v4.octets()[0] == 169 && v4.octets()[1] == 254) // 169.254.0.0/16
|
||||
|| v4.is_unspecified() // 0.0.0.0
|
||||
}
|
||||
IpAddr::V6(v6) => {
|
||||
v6.is_loopback() // ::1
|
||||
|| (v6.segments()[0] & 0xfe00) == 0xfc00 // fc00::/7
|
||||
|| v6.is_unspecified() // ::
|
||||
}
|
||||
};
|
||||
if is_private {
|
||||
anyhow::bail!("S3 endpoint must not point to a private or reserved IP address");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// Create a full encrypted backup. Params: { passphrase, description? }
|
||||
pub(super) async fn handle_backup_create(
|
||||
@@ -55,6 +108,11 @@ impl RpcHandler {
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'passphrase' parameter"))?;
|
||||
|
||||
// Validate backup ID to prevent path traversal
|
||||
if id.is_empty() || id.len() > 128 || id.contains('/') || id.contains('\\') || id.contains("..") || id.contains('\0') {
|
||||
anyhow::bail!("Invalid backup ID");
|
||||
}
|
||||
|
||||
let result = full::verify_backup(&self.config.data_dir, id, passphrase).await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
@@ -78,6 +136,11 @@ impl RpcHandler {
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'passphrase' parameter"))?;
|
||||
|
||||
// Validate backup ID to prevent path traversal
|
||||
if id.is_empty() || id.len() > 128 || id.contains('/') || id.contains('\\') || id.contains("..") || id.contains('\0') {
|
||||
anyhow::bail!("Invalid backup ID");
|
||||
}
|
||||
|
||||
full::restore_full_backup(&self.config.data_dir, id, passphrase).await?;
|
||||
|
||||
Ok(serde_json::json!({ "restored": true, "id": id }))
|
||||
@@ -183,6 +246,9 @@ impl RpcHandler {
|
||||
anyhow::bail!("Invalid backup ID");
|
||||
}
|
||||
|
||||
// Validate endpoint to prevent SSRF against internal services
|
||||
validate_s3_endpoint(endpoint)?;
|
||||
|
||||
let bak_path = full::backup_file_path(&self.config.data_dir, id);
|
||||
if !bak_path.exists() {
|
||||
anyhow::bail!("Backup not found: {}", id);
|
||||
@@ -255,6 +321,9 @@ impl RpcHandler {
|
||||
anyhow::bail!("Invalid backup ID");
|
||||
}
|
||||
|
||||
// Validate endpoint to prevent SSRF against internal services
|
||||
validate_s3_endpoint(endpoint)?;
|
||||
|
||||
let key = format!("archipelago-backups/{}.tar.gz.enc", id);
|
||||
let url = format!("{}/{}/{}", endpoint.trim_end_matches('/'), bucket, key);
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ impl RpcHandler {
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post("http://127.0.0.1:8332/")
|
||||
.post(crate::constants::BITCOIN_RPC_URL)
|
||||
.basic_auth(&rpc_user, Some(&rpc_pass))
|
||||
.json(&body)
|
||||
.send()
|
||||
|
||||
@@ -4,6 +4,16 @@ use crate::network::dwn_store::DwnStore;
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::debug;
|
||||
|
||||
/// Validate a v3 Tor onion address.
|
||||
/// Must be exactly 62 chars: 56 base32 characters (a-z, 2-7) followed by ".onion".
|
||||
fn is_valid_v3_onion(addr: &str) -> bool {
|
||||
if addr.len() != 62 || !addr.ends_with(".onion") {
|
||||
return false;
|
||||
}
|
||||
let prefix = &addr[..56];
|
||||
prefix.chars().all(|c| c.is_ascii_lowercase() || ('2'..='7').contains(&c))
|
||||
}
|
||||
|
||||
const FILE_CATALOG_PROTOCOL: &str = "https://archipelago.dev/protocols/file-catalog/v1";
|
||||
|
||||
impl RpcHandler {
|
||||
@@ -25,10 +35,22 @@ impl RpcHandler {
|
||||
.get("filename")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing filename"))?;
|
||||
// Prevent path traversal
|
||||
if filename.contains("..") || filename.contains('\0') {
|
||||
// Validate filename: prevent path traversal and null bytes
|
||||
// Allow forward slashes for subdirectories (e.g., "Music/song.mp3")
|
||||
if filename.contains("..") || filename.contains('\0') || filename.contains('\\') {
|
||||
anyhow::bail!("Invalid filename: path traversal not allowed");
|
||||
}
|
||||
// Reject paths starting with / (absolute) or . (hidden)
|
||||
if filename.starts_with('/') || filename.starts_with('.') {
|
||||
anyhow::bail!("Invalid filename: absolute paths and hidden files not allowed");
|
||||
}
|
||||
// Reject any path segment starting with . (hidden dirs)
|
||||
if filename.split('/').any(|seg| seg.starts_with('.') || seg.is_empty()) {
|
||||
anyhow::bail!("Invalid filename: hidden files/dirs or empty segments not allowed");
|
||||
}
|
||||
if filename.is_empty() || filename.len() > 512 {
|
||||
anyhow::bail!("Invalid filename: must be 1-512 characters");
|
||||
}
|
||||
let mime_type = params
|
||||
.get("mime_type")
|
||||
.and_then(|v| v.as_str())
|
||||
@@ -51,7 +73,7 @@ impl RpcHandler {
|
||||
|
||||
// Resolve actual file size from disk
|
||||
let file_path = content_server::content_file_path(&self.config.data_dir, &item);
|
||||
if let Ok(metadata) = std::fs::metadata(&file_path) {
|
||||
if let Ok(metadata) = tokio::fs::metadata(&file_path).await {
|
||||
item.size_bytes = metadata.len();
|
||||
}
|
||||
|
||||
@@ -191,11 +213,12 @@ impl RpcHandler {
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing content_id"))?;
|
||||
|
||||
if !onion.ends_with(".onion") || onion.len() < 10 {
|
||||
return Err(anyhow::anyhow!("Invalid onion address"));
|
||||
// Validate v3 onion address: 56 base32 chars + ".onion" = 62 chars total
|
||||
if !is_valid_v3_onion(onion) {
|
||||
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
||||
}
|
||||
|
||||
let socks_proxy = reqwest::Proxy::all("socks5h://127.0.0.1:9050")
|
||||
let socks_proxy = reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY)
|
||||
.context("Failed to create SOCKS proxy")?;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
@@ -252,13 +275,13 @@ impl RpcHandler {
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing onion address"))?;
|
||||
|
||||
// Validate onion address format
|
||||
if !onion.ends_with(".onion") || onion.len() < 10 {
|
||||
return Err(anyhow::anyhow!("Invalid onion address"));
|
||||
// Validate v3 onion address: 56 base32 chars + ".onion" = 62 chars total
|
||||
if !is_valid_v3_onion(onion) {
|
||||
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
||||
}
|
||||
|
||||
// Connect via Tor SOCKS proxy to the peer's content catalog endpoint
|
||||
let socks_proxy = reqwest::Proxy::all("socks5h://127.0.0.1:9050")
|
||||
let socks_proxy = reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY)
|
||||
.context("Failed to create SOCKS proxy")?;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
|
||||
398
core/archipelago/src/api/rpc/dispatcher.rs
Normal file
398
core/archipelago/src/api/rpc/dispatcher.rs
Normal file
@@ -0,0 +1,398 @@
|
||||
use super::RpcHandler;
|
||||
use anyhow::Result;
|
||||
|
||||
impl RpcHandler {
|
||||
/// Route an RPC method name to its handler, returning the result value.
|
||||
pub(super) async fn dispatch(
|
||||
&self,
|
||||
method: &str,
|
||||
params: Option<serde_json::Value>,
|
||||
session_token: &Option<String>,
|
||||
) -> Result<serde_json::Value> {
|
||||
match method {
|
||||
"echo" => self.handle_echo(params).await,
|
||||
"server.echo" => self.handle_echo(params).await,
|
||||
"health" => self.handle_health().await,
|
||||
"auth.login" => self.handle_auth_login(params).await,
|
||||
"auth.logout" => self.handle_auth_logout().await,
|
||||
"auth.changePassword" => self.handle_auth_change_password(params, session_token).await,
|
||||
"auth.isSetup" => self.handle_auth_is_setup().await,
|
||||
"auth.setup" => self.handle_auth_setup(params).await,
|
||||
"auth.onboardingComplete" => self.handle_auth_onboarding_complete().await,
|
||||
"auth.isOnboardingComplete" => self.handle_auth_is_onboarding_complete().await,
|
||||
"auth.resetOnboarding" => self.handle_auth_reset_onboarding(params).await,
|
||||
|
||||
// Container orchestration (for Archipelago-managed containers)
|
||||
"container-install" => self.handle_container_install(params).await,
|
||||
"container-start" => self.handle_container_start(params).await,
|
||||
"container-stop" => self.handle_container_stop(params).await,
|
||||
"container-remove" => self.handle_container_remove(params).await,
|
||||
"container-list" => self.handle_container_list().await,
|
||||
"container-status" => self.handle_container_status(params).await,
|
||||
"container-logs" => self.handle_container_logs(params).await,
|
||||
"container-health" => self.handle_container_health(params).await,
|
||||
|
||||
// Package management (for docker-compose apps)
|
||||
"package.install" => self.handle_package_install(params).await,
|
||||
"package.start" => self.handle_package_start(params).await,
|
||||
"package.stop" => self.handle_package_stop(params).await,
|
||||
"package.restart" => self.handle_package_restart(params).await,
|
||||
"package.uninstall" => self.handle_package_uninstall(params).await,
|
||||
|
||||
// Bundled app management (for pre-loaded container images)
|
||||
"bundled-app-start" => self.handle_bundled_app_start(params).await,
|
||||
"bundled-app-stop" => self.handle_bundled_app_stop(params).await,
|
||||
|
||||
// Node identity and P2P peers
|
||||
"node-add-peer" => self.handle_node_add_peer(params).await,
|
||||
"node-list-peers" => self.handle_node_list_peers().await,
|
||||
"node-remove-peer" => self.handle_node_remove_peer(params).await,
|
||||
"node-send-message" => self.handle_node_send_message(params).await,
|
||||
"node-check-peer" => self.handle_node_check_peer(params).await,
|
||||
"node-messages-received" => self.handle_node_messages_received().await,
|
||||
"node-store-sent" => self.handle_node_store_sent(params).await,
|
||||
"node-nostr-discover" => self.handle_node_nostr_discover().await,
|
||||
"node.did" => self.handle_node_did().await,
|
||||
"node.signChallenge" => self.handle_node_sign_challenge(params).await,
|
||||
"node.createBackup" => self.handle_node_create_backup(params).await,
|
||||
"node.tor-address" => self.handle_node_tor_address().await,
|
||||
"node.nostr-publish" => self.handle_node_nostr_publish().await,
|
||||
"node.nostr-pubkey" => self.handle_node_nostr_pubkey().await,
|
||||
"node.nostr-sign" => self.handle_node_nostr_sign(params).await,
|
||||
"node-nostr-verify-revoked" => self.handle_node_nostr_verify_revoked().await,
|
||||
"node.rotate-did" => self.handle_node_rotate_did(params).await,
|
||||
|
||||
// Encrypted peer handshake (NIP-44)
|
||||
"handshake.discover" => self.handle_handshake_discover().await,
|
||||
"handshake.connect" => self.handle_handshake_connect(params).await,
|
||||
"handshake.poll" => self.handle_handshake_poll().await,
|
||||
|
||||
// TOTP 2FA
|
||||
"auth.totp.setup.begin" => self.handle_totp_setup_begin(params).await,
|
||||
"auth.totp.setup.confirm" => self.handle_totp_setup_confirm(params).await,
|
||||
"auth.totp.disable" => self.handle_totp_disable(params).await,
|
||||
"auth.totp.status" => self.handle_totp_status().await,
|
||||
"auth.login.totp" => self.handle_login_totp(params, session_token).await,
|
||||
"auth.login.backup" => self.handle_login_backup(params, session_token).await,
|
||||
|
||||
// Bitcoin & Lightning deep data
|
||||
"bitcoin.getinfo" => self.handle_bitcoin_getinfo().await,
|
||||
"lnd.getinfo" => self.handle_lnd_getinfo().await,
|
||||
"lnd.listchannels" => self.handle_lnd_listchannels().await,
|
||||
"lnd.openchannel" => self.handle_lnd_openchannel(params).await,
|
||||
"lnd.closechannel" => self.handle_lnd_closechannel(params).await,
|
||||
"lnd.newaddress" => self.handle_lnd_newaddress().await,
|
||||
"lnd.sendcoins" => self.handle_lnd_sendcoins(params).await,
|
||||
"lnd.createinvoice" => self.handle_lnd_createinvoice(params).await,
|
||||
"lnd.payinvoice" => self.handle_lnd_payinvoice(params).await,
|
||||
"lnd.create-psbt" => self.handle_lnd_create_psbt(params).await,
|
||||
"lnd.finalize-psbt" => self.handle_lnd_finalize_psbt(params).await,
|
||||
"lnd.create-raw-tx" => self.handle_lnd_create_raw_tx(params).await,
|
||||
"lnd.gettransactions" => self.handle_lnd_gettransactions().await,
|
||||
"lnd.connect-info" => self.handle_lnd_connect_info().await,
|
||||
"lnd.export-channel-backup" => self.handle_lnd_export_channel_backup().await,
|
||||
|
||||
// Multi-identity management
|
||||
"identity.list" => self.handle_identity_list(params).await,
|
||||
"identity.create" => self.handle_identity_create(params).await,
|
||||
"identity.get" => self.handle_identity_get(params).await,
|
||||
"identity.delete" => self.handle_identity_delete(params).await,
|
||||
"identity.set-default" => self.handle_identity_set_default(params).await,
|
||||
"identity.sign" => self.handle_identity_sign(params).await,
|
||||
"identity.verify" => self.handle_identity_verify(params).await,
|
||||
"identity.resolve-did" => self.handle_identity_resolve_did(params).await,
|
||||
"identity.resolve-remote-did" => self.handle_identity_resolve_remote_did(params).await,
|
||||
"identity.verify-did-document" => self.handle_identity_verify_did_document(params).await,
|
||||
"identity.create-dht-did" => self.handle_identity_create_dht_did(params).await,
|
||||
"identity.resolve-dht-did" => self.handle_identity_resolve_dht_did(params).await,
|
||||
"identity.refresh-dht-did" => self.handle_identity_refresh_dht_did(params).await,
|
||||
"identity.dht-status" => self.handle_identity_dht_status(params).await,
|
||||
"identity.update-profile" => self.handle_identity_update_profile(params).await,
|
||||
"identity.publish-profile" => self.handle_identity_publish_profile(params).await,
|
||||
"identity.export-keys" => self.handle_identity_export_keys(params).await,
|
||||
"identity.create-nostr-key" => self.handle_identity_create_nostr_key(params).await,
|
||||
"identity.nostr-sign" => self.handle_identity_nostr_sign(params).await,
|
||||
"identity.nostr-encrypt-nip04" => self.handle_identity_nostr_encrypt_nip04(params).await,
|
||||
"identity.nostr-decrypt-nip04" => self.handle_identity_nostr_decrypt_nip04(params).await,
|
||||
"identity.nostr-encrypt-nip44" => self.handle_identity_nostr_encrypt_nip44(params).await,
|
||||
"identity.nostr-decrypt-nip44" => self.handle_identity_nostr_decrypt_nip44(params).await,
|
||||
|
||||
// Bitcoin domain names (NIP-05)
|
||||
"identity.register-name" => self.handle_identity_register_name(params).await,
|
||||
"identity.remove-name" => self.handle_identity_remove_name(params).await,
|
||||
"identity.resolve-name" => self.handle_identity_resolve_name(params).await,
|
||||
"identity.list-names" => self.handle_identity_list_names(params).await,
|
||||
"identity.link-name" => self.handle_identity_link_name(params).await,
|
||||
|
||||
// Verifiable Credentials
|
||||
"identity.issue-credential" => self.handle_identity_issue_credential(params).await,
|
||||
"identity.verify-credential" => self.handle_identity_verify_credential(params).await,
|
||||
"identity.list-credentials" => self.handle_identity_list_credentials(params).await,
|
||||
"identity.revoke-credential" => self.handle_identity_revoke_credential(params).await,
|
||||
"identity.create-presentation" => self.handle_identity_create_presentation(params).await,
|
||||
"identity.verify-presentation" => self.handle_identity_verify_presentation(params).await,
|
||||
|
||||
// Network overlay
|
||||
"network.get-visibility" => self.handle_network_get_visibility().await,
|
||||
"network.set-visibility" => self.handle_network_set_visibility(params).await,
|
||||
"network.request-connection" => self.handle_network_request_connection(params).await,
|
||||
"network.list-requests" => self.handle_network_list_requests().await,
|
||||
"network.accept-request" => self.handle_network_accept_request(params).await,
|
||||
"network.reject-request" => self.handle_network_reject_request(params).await,
|
||||
|
||||
// Tor hidden services
|
||||
"tor.list-services" => self.handle_tor_list_services().await,
|
||||
"tor.create-service" => self.handle_tor_create_service(params).await,
|
||||
"tor.delete-service" => self.handle_tor_delete_service(params).await,
|
||||
"tor.get-onion-address" => self.handle_tor_get_onion_address(params).await,
|
||||
"tor.rotate-service" => self.handle_tor_rotate_service(params).await,
|
||||
"tor.cleanup-rotated" => self.handle_tor_cleanup_rotated().await,
|
||||
"tor.toggle-app" => self.handle_tor_toggle_app(params).await,
|
||||
"tor.restart" => self.handle_tor_restart().await,
|
||||
|
||||
// Nostr relay management
|
||||
"nostr.list-relays" => self.handle_nostr_list_relays().await,
|
||||
"nostr.add-relay" => self.handle_nostr_add_relay(params).await,
|
||||
"nostr.remove-relay" => self.handle_nostr_remove_relay(params).await,
|
||||
"nostr.toggle-relay" => self.handle_nostr_toggle_relay(params).await,
|
||||
"nostr.get-stats" => self.handle_nostr_get_stats().await,
|
||||
|
||||
// Router / UPnP
|
||||
"router.discover" => self.handle_router_discover().await,
|
||||
"router.list-forwards" => self.handle_router_list_forwards().await,
|
||||
"router.add-forward" => self.handle_router_add_forward(params).await,
|
||||
"router.remove-forward" => self.handle_router_remove_forward(params).await,
|
||||
"network.diagnostics" => self.handle_network_diagnostics().await,
|
||||
"network.list-interfaces" => self.handle_network_list_interfaces().await,
|
||||
"network.scan-wifi" => self.handle_network_scan_wifi().await,
|
||||
"network.configure-wifi" => self.handle_network_configure_wifi(params).await,
|
||||
"network.configure-ethernet" => self.handle_network_configure_ethernet(params).await,
|
||||
"network.dns-status" => self.handle_network_dns_status().await,
|
||||
"network.configure-dns" => self.handle_network_configure_dns(params).await,
|
||||
"router.detect" => self.handle_router_detect(params).await,
|
||||
"router.info" => self.handle_router_info().await,
|
||||
"router.configure" => self.handle_router_configure(params).await,
|
||||
|
||||
// Ecash wallet
|
||||
"wallet.ecash-balance" => self.handle_wallet_ecash_balance().await,
|
||||
"wallet.ecash-mint" => self.handle_wallet_ecash_mint(params).await,
|
||||
"wallet.ecash-melt" => self.handle_wallet_ecash_melt(params).await,
|
||||
"wallet.ecash-send" => self.handle_wallet_ecash_send(params).await,
|
||||
"wallet.ecash-receive" => self.handle_wallet_ecash_receive(params).await,
|
||||
"wallet.ecash-history" => self.handle_wallet_ecash_history().await,
|
||||
"wallet.networking-profits" => self.handle_wallet_networking_profits().await,
|
||||
|
||||
// Content catalog management
|
||||
"content.list-mine" => self.handle_content_list_mine().await,
|
||||
"content.add" => self.handle_content_add(params).await,
|
||||
"content.remove" => self.handle_content_remove(params).await,
|
||||
"content.set-pricing" => self.handle_content_set_pricing(params).await,
|
||||
"content.set-availability" => self.handle_content_set_availability(params).await,
|
||||
"content.browse-peer" => self.handle_content_browse_peer(params).await,
|
||||
"content.download-peer" => self.handle_content_download_peer(params).await,
|
||||
|
||||
// DWN (Decentralized Web Node)
|
||||
"dwn.status" => self.handle_dwn_status().await,
|
||||
"dwn.sync" => self.handle_dwn_sync().await,
|
||||
"dwn.register-protocol" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_dwn_register_protocol(&p).await
|
||||
}
|
||||
"dwn.list-protocols" => self.handle_dwn_list_protocols().await,
|
||||
"dwn.remove-protocol" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_dwn_remove_protocol(&p).await
|
||||
}
|
||||
"dwn.query-messages" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_dwn_query_messages(&p).await
|
||||
}
|
||||
"dwn.write-message" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_dwn_write_message(&p).await
|
||||
}
|
||||
|
||||
// Federation
|
||||
"federation.invite" => self.handle_federation_invite().await,
|
||||
"federation.join" => self.handle_federation_join(params).await,
|
||||
"federation.list-nodes" => self.handle_federation_list_nodes().await,
|
||||
"federation.remove-node" => self.handle_federation_remove_node(params).await,
|
||||
"federation.set-trust" => self.handle_federation_set_trust(params).await,
|
||||
"federation.sync-state" => self.handle_federation_sync_state().await,
|
||||
"federation.get-state" => self.handle_federation_get_state().await,
|
||||
"federation.peer-joined" => self.handle_federation_peer_joined(params).await,
|
||||
"federation.deploy-app" => self.handle_federation_deploy_app(params).await,
|
||||
"federation.peer-address-changed" => self.handle_federation_peer_address_changed(params).await,
|
||||
"federation.notify-did-change" => self.handle_federation_notify_did_change(params).await,
|
||||
"federation.peer-did-changed" => self.handle_federation_peer_did_changed(params).await,
|
||||
|
||||
// VPN & Remote Access
|
||||
"vpn.status" => self.handle_vpn_status().await,
|
||||
"vpn.configure" => self.handle_vpn_configure(params).await,
|
||||
"vpn.disconnect" => self.handle_vpn_disconnect().await,
|
||||
"remote.setup" => self.handle_remote_setup(params).await,
|
||||
|
||||
// Marketplace
|
||||
"marketplace.discover" => self.handle_marketplace_discover().await,
|
||||
"marketplace.publish" => self.handle_marketplace_publish(params).await,
|
||||
"marketplace.get-manifest" => self.handle_marketplace_get_manifest(params).await,
|
||||
"marketplace.list-published" => self.handle_marketplace_list_published().await,
|
||||
"marketplace.verify" => self.handle_marketplace_verify(params).await,
|
||||
"marketplace.create-invoice" => self.handle_marketplace_create_invoice(params).await,
|
||||
"marketplace.check-payment" => self.handle_marketplace_check_payment(params).await,
|
||||
|
||||
// Mesh networking (Meshcore LoRa)
|
||||
"mesh.status" => self.handle_mesh_status().await,
|
||||
"mesh.peers" => self.handle_mesh_peers().await,
|
||||
"mesh.messages" => self.handle_mesh_messages(params).await,
|
||||
"mesh.send" => self.handle_mesh_send(params).await,
|
||||
"mesh.broadcast" => self.handle_mesh_broadcast().await,
|
||||
"mesh.configure" => self.handle_mesh_configure(params).await,
|
||||
"mesh.send-invoice" => self.handle_mesh_send_invoice(params).await,
|
||||
"mesh.send-coordinate" => self.handle_mesh_send_coordinate(params).await,
|
||||
"mesh.send-alert" => self.handle_mesh_send_alert(params).await,
|
||||
"mesh.outbox" => self.handle_mesh_outbox(params).await,
|
||||
"mesh.session-status" => self.handle_mesh_session_status(params).await,
|
||||
"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,
|
||||
"transport.peers" => self.handle_transport_peers().await,
|
||||
"transport.send" => self.handle_transport_send(params).await,
|
||||
"transport.set-mode" => self.handle_transport_set_mode(params).await,
|
||||
|
||||
// Server settings
|
||||
"server.set-name" => self.handle_server_set_name(params).await,
|
||||
|
||||
// System monitoring
|
||||
"system.stats" => self.handle_system_stats().await,
|
||||
"system.processes" => self.handle_system_processes().await,
|
||||
"system.temperature" => self.handle_system_temperature().await,
|
||||
"system.detect-usb-devices" => self.handle_system_detect_usb_devices().await,
|
||||
"system.disk-status" => self.handle_system_disk_status().await,
|
||||
"system.disk-cleanup" => self.handle_system_disk_cleanup().await,
|
||||
"system.reboot" => self.handle_system_reboot(params).await,
|
||||
"system.factory-reset" => self.handle_system_factory_reset(params).await,
|
||||
|
||||
// Opt-in anonymous analytics
|
||||
"analytics.get-status" => self.handle_analytics_get_status().await,
|
||||
"analytics.enable" => self.handle_analytics_enable().await,
|
||||
"analytics.disable" => self.handle_analytics_disable().await,
|
||||
"analytics.get-snapshot" => self.handle_analytics_get_snapshot().await,
|
||||
"telemetry.report" => self.handle_telemetry_report().await,
|
||||
"telemetry.ingest" => self.handle_telemetry_ingest(params).await,
|
||||
"telemetry.fleet-status" => self.handle_telemetry_fleet_status().await,
|
||||
"telemetry.fleet-node-history" => self.handle_telemetry_fleet_node_history(params).await,
|
||||
"telemetry.fleet-alerts" => self.handle_telemetry_fleet_alerts().await,
|
||||
|
||||
// Real-time metrics monitoring
|
||||
"monitoring.current" => self.handle_monitoring_current().await,
|
||||
"monitoring.history" => self.handle_monitoring_history(params).await,
|
||||
"monitoring.containers" => self.handle_monitoring_containers().await,
|
||||
"monitoring.alerts" => self.handle_monitoring_alerts(params).await,
|
||||
"monitoring.alert-rules" => self.handle_monitoring_alert_rules().await,
|
||||
"monitoring.configure-alert" => self.handle_monitoring_configure_alert(params).await,
|
||||
"monitoring.acknowledge-alert" => self.handle_monitoring_acknowledge_alert(params).await,
|
||||
"monitoring.export" => self.handle_monitoring_export(params).await,
|
||||
|
||||
// System updates
|
||||
"update.check" => self.handle_update_check().await,
|
||||
"update.status" => self.handle_update_status().await,
|
||||
"update.dismiss" => self.handle_update_dismiss().await,
|
||||
"update.download" => self.handle_update_download().await,
|
||||
"update.apply" => self.handle_update_apply().await,
|
||||
"update.git-apply" => self.handle_update_git_apply().await,
|
||||
"update.rollback" => self.handle_update_rollback().await,
|
||||
"update.get-schedule" => self.handle_update_get_schedule().await,
|
||||
"update.set-schedule" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_update_set_schedule(&p).await
|
||||
}
|
||||
|
||||
// Backup & Restore
|
||||
"backup.create" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_backup_create(&p).await
|
||||
}
|
||||
"backup.list" => self.handle_backup_list().await,
|
||||
"backup.verify" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_backup_verify(&p).await
|
||||
}
|
||||
"backup.restore" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_backup_restore(&p).await
|
||||
}
|
||||
"backup.restore-identity" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_backup_restore_identity(&p).await
|
||||
}
|
||||
"backup.delete" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_backup_delete(&p).await
|
||||
}
|
||||
"backup.list-drives" => self.handle_backup_list_drives().await,
|
||||
"backup.to-usb" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_backup_to_usb(&p).await
|
||||
}
|
||||
"backup.upload-s3" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_backup_upload_s3(&p).await
|
||||
}
|
||||
"backup.download-s3" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_backup_download_s3(&p).await
|
||||
}
|
||||
|
||||
// Security / secrets
|
||||
"security.rotate-secrets" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_security_rotate_secrets(&p).await
|
||||
}
|
||||
"security.list-expiring" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_security_list_expiring(&p).await
|
||||
}
|
||||
|
||||
// Webhooks
|
||||
"webhook.get-config" => self.handle_webhook_get_config().await,
|
||||
"webhook.configure" => self.handle_webhook_configure(params).await,
|
||||
"webhook.test" => self.handle_webhook_test().await,
|
||||
|
||||
_ => {
|
||||
Err(anyhow::anyhow!("Unknown method: {}", method))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn handle_echo(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
||||
if let Some(p) = params {
|
||||
if let Some(msg) = p.get("message").and_then(|v| v.as_str()) {
|
||||
return Ok(serde_json::json!({ "message": msg }));
|
||||
}
|
||||
}
|
||||
Ok(serde_json::json!({ "message": "Hello from Archipelago!" }))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_health(&self) -> Result<serde_json::Value> {
|
||||
let recovery_complete = crate::crash_recovery::is_recovery_complete();
|
||||
let uptime = crate::crash_recovery::uptime_seconds();
|
||||
let status = if recovery_complete { "ok" } else { "degraded" };
|
||||
Ok(serde_json::json!({
|
||||
"status": status,
|
||||
"crash_recovery_complete": recovery_complete,
|
||||
"uptime_seconds": uptime,
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,17 @@
|
||||
use super::RpcHandler;
|
||||
use super::*;
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use crate::credentials;
|
||||
use crate::federation::{self, FederatedNode, TrustLevel};
|
||||
use crate::identity;
|
||||
use crate::identity_manager::IdentityManager;
|
||||
use crate::network::dwn_store::DwnStore;
|
||||
use anyhow::Result;
|
||||
use tracing::{debug, info};
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
const FEDERATION_PROTOCOL: &str = "https://archipelago.dev/protocols/federation/v1";
|
||||
|
||||
/// Validate a DID parameter: must start with "did:", max 256 chars, no path traversal.
|
||||
fn validate_did(did: &str) -> Result<()> {
|
||||
if did.is_empty() || did.len() > 256 {
|
||||
anyhow::bail!("Invalid DID: must be 1-256 characters");
|
||||
}
|
||||
if !did.starts_with("did:") {
|
||||
anyhow::bail!("Invalid DID: must start with 'did:'");
|
||||
}
|
||||
if did.contains("..") || did.contains('/') || did.contains('\\') || did.contains('\0') {
|
||||
anyhow::bail!("Invalid DID: contains forbidden characters");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// federation.invite — Generate an invite code containing our DID + onion for a peer.
|
||||
pub(super) async fn handle_federation_invite(&self) -> Result<serde_json::Value> {
|
||||
pub(in crate::api::rpc) async fn handle_federation_invite(&self) -> Result<serde_json::Value> {
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
let onion = data
|
||||
@@ -50,7 +36,7 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
/// federation.join — Accept an invite code and establish federation with the remote node.
|
||||
pub(super) async fn handle_federation_join(
|
||||
pub(in crate::api::rpc) async fn handle_federation_join(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
@@ -65,12 +51,15 @@ impl RpcHandler {
|
||||
let local_onion = data.server_info.tor_address.clone().unwrap_or_default();
|
||||
let local_pubkey = data.server_info.pubkey.clone();
|
||||
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let node_identity = identity::NodeIdentity::load_or_create(&identity_dir).await?;
|
||||
let node = federation::accept_invite(
|
||||
&self.config.data_dir,
|
||||
code,
|
||||
&local_did,
|
||||
&local_onion,
|
||||
&local_pubkey,
|
||||
|data| node_identity.sign(data),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -147,7 +136,7 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
/// federation.list-nodes — List all federated nodes with their status, last state, and VC verification.
|
||||
pub(super) async fn handle_federation_list_nodes(&self) -> Result<serde_json::Value> {
|
||||
pub(in crate::api::rpc) async fn handle_federation_list_nodes(&self) -> Result<serde_json::Value> {
|
||||
let nodes = federation::load_nodes(&self.config.data_dir).await?;
|
||||
|
||||
// Load credentials to check for federation VCs
|
||||
@@ -194,7 +183,7 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
/// federation.remove-node — Remove a node from the federation by DID.
|
||||
pub(super) async fn handle_federation_remove_node(
|
||||
pub(in crate::api::rpc) async fn handle_federation_remove_node(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
@@ -215,7 +204,7 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
/// federation.set-trust — Change trust level for a federated node.
|
||||
pub(super) async fn handle_federation_set_trust(
|
||||
pub(in crate::api::rpc) async fn handle_federation_set_trust(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
@@ -247,7 +236,7 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
/// federation.sync-state — Manually trigger state sync with all federated peers.
|
||||
pub(super) async fn handle_federation_sync_state(&self) -> Result<serde_json::Value> {
|
||||
pub(in crate::api::rpc) async fn handle_federation_sync_state(&self) -> Result<serde_json::Value> {
|
||||
let nodes = federation::load_nodes(&self.config.data_dir).await?;
|
||||
|
||||
if nodes.is_empty() {
|
||||
@@ -309,7 +298,7 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
/// federation.get-state — Return this node's state snapshot (called by peers during sync).
|
||||
pub(super) async fn handle_federation_get_state(&self) -> Result<serde_json::Value> {
|
||||
pub(in crate::api::rpc) async fn handle_federation_get_state(&self) -> Result<serde_json::Value> {
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
|
||||
// Build app statuses from package_data
|
||||
@@ -325,15 +314,17 @@ impl RpcHandler {
|
||||
|
||||
let tor_active = data.server_info.tor_address.is_some();
|
||||
|
||||
let server_name = data.server_info.name.clone().filter(|n| !n.is_empty());
|
||||
let state = federation::build_local_state(
|
||||
apps, 0.0, 0, 0, 0, 0, 0, tor_active,
|
||||
apps, 0.0, 0, 0, 0, 0, 0, tor_active, server_name,
|
||||
);
|
||||
|
||||
Ok(serde_json::to_value(&state)?)
|
||||
}
|
||||
|
||||
/// federation.peer-joined — Called by a remote peer after they accept our invite.
|
||||
pub(super) async fn handle_federation_peer_joined(
|
||||
/// Requires ed25519 signature over "peer-joined:{did}:{onion}:{pubkey}" to prevent spoofing.
|
||||
pub(in crate::api::rpc) async fn handle_federation_peer_joined(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
@@ -351,6 +342,27 @@ impl RpcHandler {
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'pubkey'"))?;
|
||||
|
||||
// Verify ed25519 signature to prevent federation spoofing (H2 security fix)
|
||||
let signature = params
|
||||
.get("signature")
|
||||
.and_then(|v| v.as_str());
|
||||
match signature {
|
||||
Some(sig) => {
|
||||
let sign_data = format!("peer-joined:{}:{}:{}", did, onion, pubkey);
|
||||
match identity::NodeIdentity::verify(pubkey, sign_data.as_bytes(), sig) {
|
||||
Ok(true) => {}
|
||||
_ => {
|
||||
tracing::warn!(peer_did = %did, "Rejected peer-joined: invalid signature");
|
||||
anyhow::bail!("Invalid signature");
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
tracing::warn!(peer_did = %did, "Rejected peer-joined: missing signature");
|
||||
anyhow::bail!("Missing signature — all federation peers must be cryptographically verified");
|
||||
}
|
||||
}
|
||||
|
||||
let nodes = federation::load_nodes(&self.config.data_dir).await?;
|
||||
if nodes.iter().any(|n| n.did == did) {
|
||||
return Ok(serde_json::json!({ "accepted": true, "already_known": true }));
|
||||
@@ -374,7 +386,7 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
/// federation.deploy-app — Deploy an app to a remote federated node.
|
||||
pub(super) async fn handle_federation_deploy_app(
|
||||
pub(in crate::api::rpc) async fn handle_federation_deploy_app(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
@@ -423,7 +435,9 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
/// federation.peer-address-changed — A peer notifies us that their .onion changed.
|
||||
pub(super) async fn handle_federation_peer_address_changed(
|
||||
/// Requires ed25519 signature over "address-changed:{did}:{new_onion}" using the
|
||||
/// peer's known pubkey. This prevents attackers from redirecting federation traffic.
|
||||
pub(in crate::api::rpc) async fn handle_federation_peer_address_changed(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
@@ -436,17 +450,31 @@ impl RpcHandler {
|
||||
.get("new_onion")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing new_onion"))?;
|
||||
let signature = params
|
||||
.get("signature")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing signature — address changes must be signed"))?;
|
||||
|
||||
// Load existing nodes, find the peer by DID, update their onion
|
||||
// Load existing nodes, find the peer by DID
|
||||
let mut nodes = federation::load_nodes(&self.config.data_dir).await?;
|
||||
let found = nodes.iter_mut().find(|n| n.did == did);
|
||||
|
||||
match found {
|
||||
Some(node) => {
|
||||
// Verify signature using the peer's KNOWN pubkey (H3 security fix)
|
||||
let sign_data = format!("address-changed:{}:{}", did, new_onion);
|
||||
match identity::NodeIdentity::verify(&node.pubkey, sign_data.as_bytes(), signature) {
|
||||
Ok(true) => {}
|
||||
_ => {
|
||||
tracing::warn!(did = %did, "Rejected address change: invalid signature");
|
||||
anyhow::bail!("Invalid signature — address change rejected");
|
||||
}
|
||||
}
|
||||
|
||||
let old = node.onion.clone();
|
||||
node.onion = new_onion.to_string();
|
||||
federation::save_nodes(&self.config.data_dir, &nodes).await?;
|
||||
info!(did = %did, old_onion = %old, new_onion = %new_onion, "Updated federated peer address");
|
||||
info!(did = %did, old_onion = %old, new_onion = %new_onion, "Updated federated peer address (signature verified)");
|
||||
Ok(serde_json::json!({
|
||||
"updated": true,
|
||||
"did": did,
|
||||
@@ -463,4 +491,225 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// federation.notify-did-change — Notify all federated peers that our DID has rotated.
|
||||
/// Called after `node.rotate-did` to propagate the rotation proof to peers.
|
||||
pub(in crate::api::rpc) async fn handle_federation_notify_did_change(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let old_did = params
|
||||
.get("old_did")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'old_did'"))?;
|
||||
let new_did = params
|
||||
.get("new_did")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'new_did'"))?;
|
||||
let proof_signature = params
|
||||
.get("proof_signature")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'proof_signature'"))?;
|
||||
let proof_message = params
|
||||
.get("proof_message")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'proof_message'"))?;
|
||||
|
||||
validate_did(old_did)?;
|
||||
validate_did(new_did)?;
|
||||
|
||||
// Get the new pubkey to include in the notification
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let new_pubkey = data.server_info.pubkey.clone();
|
||||
|
||||
let nodes = federation::load_nodes(&self.config.data_dir).await?;
|
||||
|
||||
let proxy = reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY)
|
||||
.context("Invalid Tor proxy")?;
|
||||
let client = reqwest::Client::builder()
|
||||
.proxy(proxy)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.context("Failed to build HTTP client")?;
|
||||
|
||||
let mut notified = 0u32;
|
||||
let mut failed = 0u32;
|
||||
let mut results = Vec::new();
|
||||
|
||||
for node in &nodes {
|
||||
// Only notify trusted and observer peers
|
||||
if node.trust_level == TrustLevel::Untrusted {
|
||||
continue;
|
||||
}
|
||||
|
||||
let host = if node.onion.ends_with(".onion") {
|
||||
node.onion.clone()
|
||||
} else {
|
||||
format!("{}.onion", node.onion)
|
||||
};
|
||||
let url = format!("http://{}/rpc/v1", host);
|
||||
|
||||
let body = serde_json::json!({
|
||||
"method": "federation.peer-did-changed",
|
||||
"params": {
|
||||
"old_did": old_did,
|
||||
"new_did": new_did,
|
||||
"new_pubkey": new_pubkey,
|
||||
"signature": proof_signature,
|
||||
"proof_message": proof_message,
|
||||
}
|
||||
});
|
||||
|
||||
match client.post(&url).json(&body).send().await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
notified += 1;
|
||||
results.push(serde_json::json!({
|
||||
"did": node.did,
|
||||
"status": "ok",
|
||||
}));
|
||||
info!(peer_did = %node.did, "Notified peer of DID rotation");
|
||||
}
|
||||
Ok(resp) => {
|
||||
failed += 1;
|
||||
results.push(serde_json::json!({
|
||||
"did": node.did,
|
||||
"status": "error",
|
||||
"error": format!("Peer returned {}", resp.status()),
|
||||
}));
|
||||
warn!(peer_did = %node.did, status = %resp.status(), "Peer rejected DID rotation notification");
|
||||
}
|
||||
Err(e) => {
|
||||
failed += 1;
|
||||
results.push(serde_json::json!({
|
||||
"did": node.did,
|
||||
"status": "error",
|
||||
"error": e.to_string(),
|
||||
}));
|
||||
warn!(peer_did = %node.did, error = %e, "Failed to notify peer of DID rotation");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"notified": notified,
|
||||
"failed": failed,
|
||||
"results": results,
|
||||
}))
|
||||
}
|
||||
|
||||
/// federation.peer-did-changed — A peer notifies us that their DID has rotated.
|
||||
/// Verifies the rotation proof against the peer's KNOWN pubkey before accepting.
|
||||
pub(in crate::api::rpc) async fn handle_federation_peer_did_changed(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let old_did = params
|
||||
.get("old_did")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'old_did'"))?;
|
||||
let new_did = params
|
||||
.get("new_did")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'new_did'"))?;
|
||||
let new_pubkey = params
|
||||
.get("new_pubkey")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'new_pubkey'"))?;
|
||||
let signature = params
|
||||
.get("signature")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'signature'"))?;
|
||||
|
||||
validate_did(old_did)?;
|
||||
validate_did(new_did)?;
|
||||
|
||||
// Validate new_pubkey is a valid 32-byte hex-encoded Ed25519 public key
|
||||
let pubkey_bytes = hex::decode(new_pubkey)
|
||||
.map_err(|_| anyhow::anyhow!("Invalid new_pubkey: not valid hex"))?;
|
||||
if pubkey_bytes.len() != 32 {
|
||||
anyhow::bail!("Invalid new_pubkey: must be 32 bytes (64 hex chars)");
|
||||
}
|
||||
|
||||
// Validate signature is valid hex of correct length (64 bytes = 128 hex chars)
|
||||
let sig_bytes = hex::decode(signature)
|
||||
.map_err(|_| anyhow::anyhow!("Invalid signature: not valid hex"))?;
|
||||
if sig_bytes.len() != 64 {
|
||||
anyhow::bail!("Invalid signature: must be 64 bytes (128 hex chars)");
|
||||
}
|
||||
|
||||
// Verify the new_did matches the new_pubkey
|
||||
let expected_new_did = identity::did_key_from_pubkey_hex(new_pubkey)?;
|
||||
if expected_new_did != new_did {
|
||||
anyhow::bail!("new_did does not match new_pubkey");
|
||||
}
|
||||
|
||||
// Load existing nodes, find the peer by their OLD DID
|
||||
let mut nodes = federation::load_nodes(&self.config.data_dir).await?;
|
||||
let found = nodes.iter_mut().find(|n| n.did == old_did);
|
||||
|
||||
match found {
|
||||
Some(node) => {
|
||||
// Verify the rotation proof: the old key signed
|
||||
// "did-rotate:{old_did}:{new_did}:{timestamp}" and the sender
|
||||
// forwards both the signature and the full proof_message.
|
||||
let proof_message = params
|
||||
.get("proof_message")
|
||||
.and_then(|v| v.as_str());
|
||||
|
||||
let verified = if let Some(msg) = proof_message {
|
||||
// Verify the proof_message starts with the expected prefix
|
||||
let expected_prefix = format!("did-rotate:{}:{}:", old_did, new_did);
|
||||
if !msg.starts_with(&expected_prefix) {
|
||||
warn!(old_did = %old_did, "Rejected DID rotation: proof_message has wrong prefix");
|
||||
anyhow::bail!("Invalid proof_message format");
|
||||
}
|
||||
// Verify signature against the full proof_message using the KNOWN pubkey
|
||||
matches!(
|
||||
identity::NodeIdentity::verify(&node.pubkey, msg.as_bytes(), signature),
|
||||
Ok(true)
|
||||
)
|
||||
} else {
|
||||
// Fallback: verify without timestamp (backwards-compatible)
|
||||
let fallback_msg = format!("did-rotate:{}:{}", old_did, new_did);
|
||||
matches!(
|
||||
identity::NodeIdentity::verify(&node.pubkey, fallback_msg.as_bytes(), signature),
|
||||
Ok(true)
|
||||
)
|
||||
};
|
||||
|
||||
if !verified {
|
||||
warn!(old_did = %old_did, "Rejected DID rotation: invalid signature");
|
||||
anyhow::bail!("Invalid signature — DID rotation rejected");
|
||||
}
|
||||
|
||||
let old_pubkey = node.pubkey.clone();
|
||||
node.did = new_did.to_string();
|
||||
node.pubkey = new_pubkey.to_string();
|
||||
node.last_seen = Some(chrono::Utc::now().to_rfc3339());
|
||||
federation::save_nodes(&self.config.data_dir, &nodes).await?;
|
||||
|
||||
info!(
|
||||
old_did = %old_did,
|
||||
new_did = %new_did,
|
||||
old_pubkey = %old_pubkey,
|
||||
"Updated federated peer DID (rotation signature verified)"
|
||||
);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"updated": true,
|
||||
"old_did": old_did,
|
||||
"new_did": new_did,
|
||||
}))
|
||||
}
|
||||
None => {
|
||||
info!(old_did = %old_did, "Received DID rotation from unknown peer — ignoring");
|
||||
Ok(serde_json::json!({
|
||||
"updated": false,
|
||||
"reason": "Unknown peer DID",
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
17
core/archipelago/src/api/rpc/federation/mod.rs
Normal file
17
core/archipelago/src/api/rpc/federation/mod.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
mod handlers;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
pub(super) fn validate_did(did: &str) -> Result<()> {
|
||||
if did.is_empty() || did.len() > 256 {
|
||||
anyhow::bail!("Invalid DID: must be 1-256 characters");
|
||||
}
|
||||
if !did.starts_with("did:") {
|
||||
anyhow::bail!("Invalid DID: must start with 'did:'");
|
||||
}
|
||||
if did.contains("..") || did.contains('/') || did.contains('\\') || did.contains('\0') {
|
||||
anyhow::bail!("Invalid DID: contains forbidden characters");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ impl RpcHandler {
|
||||
&identity_dir,
|
||||
&self.config.nostr_relays,
|
||||
self.config.nostr_tor_proxy.as_deref(),
|
||||
None, // TODO: track last-seen timestamp to avoid re-processing
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -1,28 +1,13 @@
|
||||
//! RPC handlers for multi-identity management.
|
||||
|
||||
use super::RpcHandler;
|
||||
use super::*;
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use crate::identity_manager::{IdentityManager, IdentityProfile, IdentityPurpose};
|
||||
use crate::network::did_dht;
|
||||
use anyhow::{Context, Result};
|
||||
use nostr_sdk::ToBech32;
|
||||
|
||||
/// Validate an identity ID: alphanumeric, hyphens, underscores, 1-128 chars, no path traversal.
|
||||
fn validate_identity_id(id: &str) -> Result<()> {
|
||||
if id.is_empty() || id.len() > 128 {
|
||||
anyhow::bail!("Invalid identity id: must be 1-128 characters");
|
||||
}
|
||||
if id.contains("..") || id.contains('/') || id.contains('\\') || id.contains('\0') {
|
||||
anyhow::bail!("Invalid identity id: contains forbidden characters");
|
||||
}
|
||||
if !id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b':') {
|
||||
anyhow::bail!("Invalid identity id: must be alphanumeric, hyphens, underscores, or colons");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// List all identities with their default status.
|
||||
pub(super) async fn handle_identity_list(
|
||||
pub(in crate::api::rpc) async fn handle_identity_list(
|
||||
&self,
|
||||
_params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
@@ -52,7 +37,7 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
/// Create a new identity.
|
||||
pub(super) async fn handle_identity_create(
|
||||
pub(in crate::api::rpc) async fn handle_identity_create(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
@@ -60,8 +45,11 @@ impl RpcHandler {
|
||||
let name = params
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Personal")
|
||||
.to_string();
|
||||
.unwrap_or("Personal");
|
||||
if name.len() > 100 {
|
||||
anyhow::bail!("Identity name must be 100 characters or fewer");
|
||||
}
|
||||
let name = name.to_string();
|
||||
|
||||
let purpose_str = params
|
||||
.get("purpose")
|
||||
@@ -90,7 +78,7 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
/// Get a single identity by ID.
|
||||
pub(super) async fn handle_identity_get(
|
||||
pub(in crate::api::rpc) async fn handle_identity_get(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
@@ -120,7 +108,7 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
/// Delete an identity.
|
||||
pub(super) async fn handle_identity_delete(
|
||||
pub(in crate::api::rpc) async fn handle_identity_delete(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
@@ -138,7 +126,7 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
/// Set the default identity.
|
||||
pub(super) async fn handle_identity_set_default(
|
||||
pub(in crate::api::rpc) async fn handle_identity_set_default(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
@@ -156,7 +144,7 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
/// Sign a message with a specific identity.
|
||||
pub(super) async fn handle_identity_sign(
|
||||
pub(in crate::api::rpc) async fn handle_identity_sign(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
@@ -182,7 +170,7 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
/// Verify a signature against a DID.
|
||||
pub(super) async fn handle_identity_verify(
|
||||
pub(in crate::api::rpc) async fn handle_identity_verify(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
@@ -208,7 +196,7 @@ impl RpcHandler {
|
||||
|
||||
/// Resolve a DID to its W3C DID Document.
|
||||
/// If no DID is provided, returns the node's own DID Document.
|
||||
pub(super) async fn handle_identity_resolve_did(
|
||||
pub(in crate::api::rpc) async fn handle_identity_resolve_did(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
@@ -240,7 +228,7 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
/// Verify a DID Document: validate structure, check key material matches DID.
|
||||
pub(super) async fn handle_identity_verify_did_document(
|
||||
pub(in crate::api::rpc) async fn handle_identity_verify_did_document(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
@@ -312,7 +300,7 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
/// Create a Nostr keypair linked to an identity.
|
||||
pub(super) async fn handle_identity_create_nostr_key(
|
||||
pub(in crate::api::rpc) async fn handle_identity_create_nostr_key(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
@@ -342,7 +330,7 @@ impl RpcHandler {
|
||||
/// - `event_hash` (hex) + `id` — sign a pre-computed hash
|
||||
/// - `event` (full event object) — compute NIP-01 hash, fill pubkey, sign
|
||||
/// If `id` is omitted, uses the default identity.
|
||||
pub(super) async fn handle_identity_nostr_sign(
|
||||
pub(in crate::api::rpc) async fn handle_identity_nostr_sign(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
@@ -426,7 +414,7 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
/// NIP-04 encrypt plaintext for a peer.
|
||||
pub(super) async fn handle_identity_nostr_encrypt_nip04(
|
||||
pub(in crate::api::rpc) async fn handle_identity_nostr_encrypt_nip04(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
@@ -444,7 +432,7 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
/// NIP-04 decrypt ciphertext from a peer.
|
||||
pub(super) async fn handle_identity_nostr_decrypt_nip04(
|
||||
pub(in crate::api::rpc) async fn handle_identity_nostr_decrypt_nip04(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
@@ -462,7 +450,7 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
/// NIP-44 encrypt plaintext for a peer.
|
||||
pub(super) async fn handle_identity_nostr_encrypt_nip44(
|
||||
pub(in crate::api::rpc) async fn handle_identity_nostr_encrypt_nip44(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
@@ -480,7 +468,7 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
/// NIP-44 decrypt ciphertext from a peer.
|
||||
pub(super) async fn handle_identity_nostr_decrypt_nip44(
|
||||
pub(in crate::api::rpc) async fn handle_identity_nostr_decrypt_nip44(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
@@ -499,7 +487,7 @@ impl RpcHandler {
|
||||
|
||||
/// Resolve a remote peer's DID Document over Tor.
|
||||
/// Queries the peer's /rpc/ endpoint for identity.resolve-did.
|
||||
pub(super) async fn handle_identity_resolve_remote_did(
|
||||
pub(in crate::api::rpc) async fn handle_identity_resolve_remote_did(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
@@ -518,7 +506,7 @@ impl RpcHandler {
|
||||
let url = format!("http://{}/rpc/", host);
|
||||
|
||||
// Use SOCKS5 proxy to reach .onion address
|
||||
let proxy = reqwest::Proxy::all("socks5h://127.0.0.1:9050")
|
||||
let proxy = reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY)
|
||||
.context("Failed to create Tor proxy")?;
|
||||
let client = reqwest::Client::builder()
|
||||
.proxy(proxy)
|
||||
@@ -575,7 +563,7 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
/// identity.create-dht-did — Publish an identity's DID to the Mainline DHT.
|
||||
pub(super) async fn handle_identity_create_dht_did(
|
||||
pub(in crate::api::rpc) async fn handle_identity_create_dht_did(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
@@ -601,7 +589,7 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
/// identity.resolve-dht-did — Resolve a did:dht from the DHT.
|
||||
pub(super) async fn handle_identity_resolve_dht_did(
|
||||
pub(in crate::api::rpc) async fn handle_identity_resolve_dht_did(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
@@ -624,7 +612,7 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
/// identity.refresh-dht-did — Re-publish an identity's did:dht to keep it alive in the DHT.
|
||||
pub(super) async fn handle_identity_refresh_dht_did(
|
||||
pub(in crate::api::rpc) async fn handle_identity_refresh_dht_did(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
@@ -652,7 +640,7 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
/// Update profile metadata for an identity.
|
||||
pub(super) async fn handle_identity_update_profile(
|
||||
pub(in crate::api::rpc) async fn handle_identity_update_profile(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
@@ -678,7 +666,7 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
/// Publish kind 0 (metadata) profile to the local Nostr relay.
|
||||
pub(super) async fn handle_identity_publish_profile(
|
||||
pub(in crate::api::rpc) async fn handle_identity_publish_profile(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
@@ -702,7 +690,7 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
/// Export private keys for an identity — REQUIRES password verification.
|
||||
pub(super) async fn handle_identity_export_keys(
|
||||
pub(in crate::api::rpc) async fn handle_identity_export_keys(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
@@ -740,7 +728,7 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
/// identity.dht-status — Check if an identity's did:dht is published and resolvable.
|
||||
pub(super) async fn handle_identity_dht_status(
|
||||
pub(in crate::api::rpc) async fn handle_identity_dht_status(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
17
core/archipelago/src/api/rpc/identity/mod.rs
Normal file
17
core/archipelago/src/api/rpc/identity/mod.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
mod handlers;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
pub(super) fn validate_identity_id(id: &str) -> Result<()> {
|
||||
if id.is_empty() || id.len() > 128 {
|
||||
anyhow::bail!("Invalid identity id: must be 1-128 characters");
|
||||
}
|
||||
if id.contains("..") || id.contains('/') || id.contains('\\') || id.contains('\0') {
|
||||
anyhow::bail!("Invalid identity id: contains forbidden characters");
|
||||
}
|
||||
if !id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b':') {
|
||||
anyhow::bail!("Invalid identity id: must be alphanumeric, hyphens, underscores, or colons");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,996 +0,0 @@
|
||||
use super::RpcHandler;
|
||||
use anyhow::{Context, Result};
|
||||
use base64::Engine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct LndInfo {
|
||||
alias: String,
|
||||
num_active_channels: u32,
|
||||
num_peers: u32,
|
||||
synced_to_chain: bool,
|
||||
block_height: u64,
|
||||
balance_sats: i64,
|
||||
channel_balance_sats: i64,
|
||||
pending_open_balance: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LndGetInfoResponse {
|
||||
alias: Option<String>,
|
||||
num_active_channels: Option<u32>,
|
||||
num_peers: Option<u32>,
|
||||
synced_to_chain: Option<bool>,
|
||||
block_height: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LndChannelBalanceResponse {
|
||||
local_balance: Option<LndAmount>,
|
||||
pending_open_local_balance: Option<LndAmount>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LndBalanceResponse {
|
||||
total_balance: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
confirmed_balance: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LndAmount {
|
||||
sat: Option<String>,
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
pub(super) async fn handle_lnd_getinfo(&self) -> Result<serde_json::Value> {
|
||||
let macaroon_path =
|
||||
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||
|
||||
let macaroon_bytes = tokio::fs::read(macaroon_path)
|
||||
.await
|
||||
.context("Failed to read LND admin macaroon — is LND installed?")?;
|
||||
let macaroon_hex = hex::encode(&macaroon_bytes);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.context("Failed to create HTTP client")?;
|
||||
|
||||
let get_info: LndGetInfoResponse = client
|
||||
.get("https://127.0.0.1:8080/v1/getinfo")
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND REST connection failed")?
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse LND getinfo response")?;
|
||||
|
||||
let channel_balance: LndChannelBalanceResponse = match client
|
||||
.get("https://127.0.0.1:8080/v1/balance/channels")
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp.json().await.unwrap_or(LndChannelBalanceResponse {
|
||||
local_balance: None,
|
||||
pending_open_local_balance: None,
|
||||
}),
|
||||
Err(_) => LndChannelBalanceResponse {
|
||||
local_balance: None,
|
||||
pending_open_local_balance: None,
|
||||
},
|
||||
};
|
||||
|
||||
let wallet_balance: LndBalanceResponse = match client
|
||||
.get("https://127.0.0.1:8080/v1/balance/blockchain")
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp.json().await.unwrap_or(LndBalanceResponse {
|
||||
total_balance: None,
|
||||
confirmed_balance: None,
|
||||
}),
|
||||
Err(_) => LndBalanceResponse {
|
||||
total_balance: None,
|
||||
confirmed_balance: None,
|
||||
},
|
||||
};
|
||||
|
||||
let info = LndInfo {
|
||||
alias: get_info.alias.unwrap_or_default(),
|
||||
num_active_channels: get_info.num_active_channels.unwrap_or(0),
|
||||
num_peers: get_info.num_peers.unwrap_or(0),
|
||||
synced_to_chain: get_info.synced_to_chain.unwrap_or(false),
|
||||
block_height: get_info.block_height.unwrap_or(0),
|
||||
balance_sats: wallet_balance
|
||||
.total_balance
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0),
|
||||
channel_balance_sats: channel_balance
|
||||
.local_balance
|
||||
.and_then(|a| a.sat.and_then(|s| s.parse().ok()))
|
||||
.unwrap_or(0),
|
||||
pending_open_balance: channel_balance
|
||||
.pending_open_local_balance
|
||||
.and_then(|a| a.sat.and_then(|s| s.parse().ok()))
|
||||
.unwrap_or(0),
|
||||
};
|
||||
|
||||
Ok(serde_json::to_value(info)?)
|
||||
}
|
||||
|
||||
/// Helper: create an authenticated LND REST client
|
||||
async fn lnd_client(&self) -> Result<(reqwest::Client, String)> {
|
||||
let macaroon_path =
|
||||
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||
let macaroon_bytes = tokio::fs::read(macaroon_path)
|
||||
.await
|
||||
.context("Failed to read LND admin macaroon — is LND installed?")?;
|
||||
let macaroon_hex = hex::encode(&macaroon_bytes);
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.context("Failed to create HTTP client")?;
|
||||
Ok((client, macaroon_hex))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_lnd_listchannels(&self) -> Result<serde_json::Value> {
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let channels_resp: LndListChannelsResponse = client
|
||||
.get("https://127.0.0.1:8080/v1/channels")
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND REST connection failed")?
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse LND channels response")?;
|
||||
|
||||
let pending_resp: LndPendingChannelsResponse = match client
|
||||
.get("https://127.0.0.1:8080/v1/channels/pending")
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp.json().await.unwrap_or_default(),
|
||||
Err(_) => LndPendingChannelsResponse::default(),
|
||||
};
|
||||
|
||||
let channels: Vec<ChannelInfo> = channels_resp
|
||||
.channels
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|ch| {
|
||||
let capacity: i64 = ch.capacity.as_deref().and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
let local: i64 = ch.local_balance.as_deref().and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
let remote: i64 = ch.remote_balance.as_deref().and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
ChannelInfo {
|
||||
chan_id: ch.chan_id.unwrap_or_default(),
|
||||
remote_pubkey: ch.remote_pubkey.unwrap_or_default(),
|
||||
capacity,
|
||||
local_balance: local,
|
||||
remote_balance: remote,
|
||||
active: ch.active.unwrap_or(false),
|
||||
status: if ch.active.unwrap_or(false) { "active".into() } else { "inactive".into() },
|
||||
channel_point: ch.channel_point.unwrap_or_default(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut pending_channels: Vec<ChannelInfo> = Vec::new();
|
||||
for pch in pending_resp.pending_open_channels.unwrap_or_default() {
|
||||
if let Some(ch) = pch.channel {
|
||||
let capacity: i64 = ch.capacity.as_deref().and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
let local: i64 = ch.local_balance.as_deref().and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
let remote: i64 = ch.remote_balance.as_deref().and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
pending_channels.push(ChannelInfo {
|
||||
chan_id: String::new(),
|
||||
remote_pubkey: ch.remote_node_pub.unwrap_or_default(),
|
||||
capacity,
|
||||
local_balance: local,
|
||||
remote_balance: remote,
|
||||
active: false,
|
||||
status: "pending_open".into(),
|
||||
channel_point: ch.channel_point.unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let total_local: i64 = channels.iter().map(|c| c.local_balance).sum();
|
||||
let total_remote: i64 = channels.iter().map(|c| c.remote_balance).sum();
|
||||
|
||||
let mut all_channels = channels;
|
||||
all_channels.extend(pending_channels);
|
||||
|
||||
let result = ChannelListResult {
|
||||
channels: all_channels,
|
||||
total_inbound: total_remote,
|
||||
total_outbound: total_local,
|
||||
};
|
||||
|
||||
Ok(serde_json::to_value(result)?)
|
||||
}
|
||||
|
||||
pub(super) async fn handle_lnd_openchannel(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let pubkey = params.get("pubkey")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'pubkey' parameter"))?;
|
||||
let amount = params.get("amount")
|
||||
.and_then(|v| v.as_i64())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'amount' parameter (sats)"))?;
|
||||
|
||||
// Validate pubkey: must be 66-char hex (compressed secp256k1)
|
||||
if pubkey.len() != 66 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err(anyhow::anyhow!("Invalid pubkey: must be 66-character hex string"));
|
||||
}
|
||||
|
||||
if amount < 20000 {
|
||||
return Err(anyhow::anyhow!("Channel amount must be at least 20,000 sats"));
|
||||
}
|
||||
if amount > 16_777_215 {
|
||||
return Err(anyhow::anyhow!("Channel amount exceeds maximum (16,777,215 sats)"));
|
||||
}
|
||||
|
||||
info!(peer = pubkey, amount = amount, "Opening Lightning channel");
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
// First connect to the peer if an address is provided
|
||||
if let Some(addr) = params.get("address").and_then(|v| v.as_str()) {
|
||||
// Validate peer address format (host:port)
|
||||
if addr.len() > 256 || addr.contains('\0') || addr.contains(' ') {
|
||||
return Err(anyhow::anyhow!("Invalid peer address format"));
|
||||
}
|
||||
let connect_body = serde_json::json!({
|
||||
"addr": { "pubkey": pubkey, "host": addr },
|
||||
"perm": true
|
||||
});
|
||||
let _ = client
|
||||
.post("https://127.0.0.1:8080/v1/peers")
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&connect_body)
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
|
||||
let open_body = serde_json::json!({
|
||||
"node_pubkey_string": pubkey,
|
||||
"local_funding_amount": amount.to_string(),
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post("https://127.0.0.1:8080/v1/channels")
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&open_body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to open channel")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp.json().await.context("Failed to parse open channel response")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
||||
return Err(anyhow::anyhow!("Failed to open channel: {}", msg));
|
||||
}
|
||||
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
pub(super) async fn handle_lnd_closechannel(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let channel_point = params.get("channel_point")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'channel_point' parameter (txid:output_index)"))?;
|
||||
|
||||
let parts: Vec<&str> = channel_point.split(':').collect();
|
||||
if parts.len() != 2 {
|
||||
return Err(anyhow::anyhow!("Invalid channel_point format. Expected 'txid:output_index'"));
|
||||
}
|
||||
// Validate txid is 64-char hex and output_index is numeric
|
||||
if parts[0].len() != 64 || !parts[0].chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err(anyhow::anyhow!("Invalid txid in channel_point: must be 64-character hex"));
|
||||
}
|
||||
if parts[1].parse::<u32>().is_err() {
|
||||
return Err(anyhow::anyhow!("Invalid output_index in channel_point: must be a number"));
|
||||
}
|
||||
|
||||
let force = params.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
info!(channel_point = channel_point, force = force, "Closing Lightning channel");
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let url = format!(
|
||||
"https://127.0.0.1:8080/v1/channels/{}/{}?force={}",
|
||||
parts[0], parts[1], force
|
||||
);
|
||||
|
||||
let resp = client
|
||||
.delete(&url)
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to close channel")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp.json().await.context("Failed to parse close channel response")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
||||
return Err(anyhow::anyhow!("Failed to close channel: {}", msg));
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({ "success": true }))
|
||||
}
|
||||
|
||||
/// Generate a new on-chain Bitcoin address.
|
||||
pub(super) async fn handle_lnd_newaddress(&self) -> Result<serde_json::Value> {
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let resp = client
|
||||
.get("https://127.0.0.1:8080/v1/newaddress")
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND REST connection failed")?;
|
||||
|
||||
let body: serde_json::Value = resp.json().await
|
||||
.context("Failed to parse newaddress response")?;
|
||||
|
||||
let address = body.get("address")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
Ok(serde_json::json!({ "address": address }))
|
||||
}
|
||||
|
||||
/// Send on-chain Bitcoin to an address.
|
||||
pub(super) async fn handle_lnd_sendcoins(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let addr = params.get("addr")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'addr' parameter"))?;
|
||||
let amount = params.get("amount")
|
||||
.and_then(|v| v.as_i64())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'amount' parameter (sats)"))?;
|
||||
|
||||
if amount < 546 {
|
||||
return Err(anyhow::anyhow!("Amount must be at least 546 sats (dust limit)"));
|
||||
}
|
||||
if amount > 21_000_000 * 100_000_000 {
|
||||
return Err(anyhow::anyhow!("Amount exceeds maximum Bitcoin supply"));
|
||||
}
|
||||
|
||||
// Validate Bitcoin address format (basic: length and allowed chars)
|
||||
if addr.len() < 14 || addr.len() > 90 || !addr.chars().all(|c| c.is_ascii_alphanumeric()) {
|
||||
return Err(anyhow::anyhow!("Invalid Bitcoin address format"));
|
||||
}
|
||||
|
||||
info!(addr = addr, amount = amount, "Sending on-chain Bitcoin");
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let send_body = serde_json::json!({
|
||||
"addr": addr,
|
||||
"amount": amount.to_string(),
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post("https://127.0.0.1:8080/v1/transactions")
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&send_body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to send on-chain transaction")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp.json().await
|
||||
.context("Failed to parse send response")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
||||
return Err(anyhow::anyhow!("Failed to send: {}", msg));
|
||||
}
|
||||
|
||||
let txid = body.get("txid").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
Ok(serde_json::json!({ "txid": txid }))
|
||||
}
|
||||
|
||||
/// Create a Lightning invoice.
|
||||
pub(super) async fn handle_lnd_createinvoice(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let amount_sats = params.get("amount_sats")
|
||||
.and_then(|v| v.as_i64())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'amount_sats' parameter"))?;
|
||||
let memo = params.get("memo")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
if amount_sats < 0 {
|
||||
return Err(anyhow::anyhow!("Amount must be non-negative"));
|
||||
}
|
||||
if amount_sats > 21_000_000 * 100_000_000 {
|
||||
return Err(anyhow::anyhow!("Amount exceeds maximum Bitcoin supply"));
|
||||
}
|
||||
|
||||
// Limit memo length to prevent abuse
|
||||
if memo.len() > 639 {
|
||||
return Err(anyhow::anyhow!("Memo too long (max 639 bytes)"));
|
||||
}
|
||||
|
||||
info!(amount_sats = amount_sats, "Creating Lightning invoice");
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let invoice_body = serde_json::json!({
|
||||
"value": amount_sats.to_string(),
|
||||
"memo": memo,
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post("https://127.0.0.1:8080/v1/invoices")
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&invoice_body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to create invoice")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp.json().await
|
||||
.context("Failed to parse invoice response")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
||||
return Err(anyhow::anyhow!("Failed to create invoice: {}", msg));
|
||||
}
|
||||
|
||||
let payment_request = body.get("payment_request")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"payment_request": payment_request,
|
||||
"amount_sats": amount_sats,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Pay a Lightning invoice.
|
||||
pub(super) async fn handle_lnd_payinvoice(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let payment_request = params.get("payment_request")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'payment_request' parameter"))?;
|
||||
|
||||
// Basic validation: Lightning invoices start with lnbc/lntb/lnbcrt
|
||||
if payment_request.len() < 10 || payment_request.len() > 2048 {
|
||||
return Err(anyhow::anyhow!("Invalid payment request length"));
|
||||
}
|
||||
let lower = payment_request.to_lowercase();
|
||||
if !lower.starts_with("lnbc") && !lower.starts_with("lntb") && !lower.starts_with("lnbcrt") {
|
||||
return Err(anyhow::anyhow!("Invalid payment request: must be a Lightning invoice (lnbc...)"));
|
||||
}
|
||||
|
||||
info!("Paying Lightning invoice");
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let pay_body = serde_json::json!({
|
||||
"payment_request": payment_request,
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post("https://127.0.0.1:8080/v1/channels/transactions")
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&pay_body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to pay invoice")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp.json().await
|
||||
.context("Failed to parse payment response")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
||||
return Err(anyhow::anyhow!("Payment failed: {}", msg));
|
||||
}
|
||||
|
||||
let payment_error = body.get("payment_error").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if !payment_error.is_empty() {
|
||||
return Err(anyhow::anyhow!("Payment failed: {}", payment_error));
|
||||
}
|
||||
|
||||
let amount_sat = body.get("payment_route")
|
||||
.and_then(|r| r.get("total_amt"))
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
let payment_hash = body.get("payment_hash")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"payment_hash": payment_hash,
|
||||
"amount_sats": amount_sat,
|
||||
}))
|
||||
}
|
||||
/// Create an unsigned PSBT for hardware wallet signing.
|
||||
/// Uses LND's WalletKit.FundPsbt to select UTXOs and create a PSBT template.
|
||||
pub(super) async fn handle_lnd_create_psbt(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
|
||||
let outputs = params.get("outputs")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'outputs' array (each: address + amount_sats)"))?;
|
||||
|
||||
if outputs.is_empty() {
|
||||
return Err(anyhow::anyhow!("outputs must not be empty"));
|
||||
}
|
||||
|
||||
// Build the outputs map for LND: { "address": "amount_sats_as_string" }
|
||||
let mut lnd_outputs: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
|
||||
let mut total_amount: i64 = 0;
|
||||
for output in outputs {
|
||||
let addr = output.get("address")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Each output must have an 'address'"))?;
|
||||
// Validate Bitcoin address format
|
||||
if addr.len() < 14 || addr.len() > 90 || !addr.chars().all(|c| c.is_ascii_alphanumeric()) {
|
||||
return Err(anyhow::anyhow!("Invalid Bitcoin address format in output"));
|
||||
}
|
||||
let amount = output.get("amount_sats")
|
||||
.and_then(|v| v.as_i64())
|
||||
.ok_or_else(|| anyhow::anyhow!("Each output must have 'amount_sats'"))?;
|
||||
if amount < 546 {
|
||||
return Err(anyhow::anyhow!("Amount must be at least 546 sats (dust limit)"));
|
||||
}
|
||||
lnd_outputs.insert(addr.to_string(), serde_json::json!(amount));
|
||||
total_amount += amount;
|
||||
}
|
||||
|
||||
let sat_per_vbyte = params.get("fee_rate_sat_per_vbyte")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(10);
|
||||
|
||||
info!(total_amount = total_amount, fee_rate = sat_per_vbyte, "Creating PSBT for hardware wallet signing");
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let fund_body = serde_json::json!({
|
||||
"raw": {
|
||||
"outputs": lnd_outputs,
|
||||
},
|
||||
"sat_per_vbyte": sat_per_vbyte,
|
||||
"spend_unconfirmed": false,
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post("https://127.0.0.1:8080/v2/wallet/psbt/fund")
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&fund_body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to create PSBT via LND")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp.json().await
|
||||
.context("Failed to parse PSBT response")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
||||
return Err(anyhow::anyhow!("Failed to create PSBT: {}", msg));
|
||||
}
|
||||
|
||||
let funded_psbt = body.get("funded_psbt")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let change_output_index = body.get("change_output_index")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(-1);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"psbt_base64": funded_psbt,
|
||||
"change_output_index": change_output_index,
|
||||
"total_amount_sats": total_amount,
|
||||
"fee_rate_sat_per_vbyte": sat_per_vbyte,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Finalize a signed PSBT and broadcast the transaction.
|
||||
/// Takes a PSBT that has been signed by a hardware wallet.
|
||||
pub(super) async fn handle_lnd_finalize_psbt(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let signed_psbt = params.get("signed_psbt_base64")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'signed_psbt_base64'"))?;
|
||||
|
||||
info!("Finalizing signed PSBT from hardware wallet");
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let finalize_body = serde_json::json!({
|
||||
"funded_psbt": signed_psbt,
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post("https://127.0.0.1:8080/v2/wallet/psbt/finalize")
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&finalize_body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to finalize PSBT via LND")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp.json().await
|
||||
.context("Failed to parse finalize response")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
||||
return Err(anyhow::anyhow!("Failed to finalize PSBT: {}", msg));
|
||||
}
|
||||
|
||||
let raw_final_tx = body.get("raw_final_tx")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
// Broadcast the finalized transaction
|
||||
let publish_body = serde_json::json!({
|
||||
"tx_hex": raw_final_tx,
|
||||
});
|
||||
|
||||
let pub_resp = client
|
||||
.post("https://127.0.0.1:8080/v2/wallet/tx")
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&publish_body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to broadcast transaction")?;
|
||||
|
||||
let pub_status = pub_resp.status();
|
||||
let pub_body: serde_json::Value = pub_resp.json().await
|
||||
.context("Failed to parse broadcast response")?;
|
||||
|
||||
if !pub_status.is_success() {
|
||||
let msg = pub_body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
||||
return Err(anyhow::anyhow!("Transaction broadcast failed: {}", msg));
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"raw_final_tx": raw_final_tx,
|
||||
"broadcast": true,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Create a signed raw transaction WITHOUT broadcasting.
|
||||
/// Used for mesh relay: create the TX locally, then relay the hex to an
|
||||
/// internet-connected peer who broadcasts it.
|
||||
pub(super) async fn handle_lnd_create_raw_tx(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
|
||||
let addr = params.get("addr")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'addr'"))?;
|
||||
let amount_sats = params.get("amount_sats")
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'amount_sats'"))?;
|
||||
|
||||
if amount_sats < 546 {
|
||||
anyhow::bail!("Amount must be at least 546 sats (dust limit)");
|
||||
}
|
||||
if amount_sats > 2_100_000_000_000_000 {
|
||||
anyhow::bail!("Amount exceeds 21M BTC");
|
||||
}
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
// Step 1: Fund a PSBT with the desired output
|
||||
let fee_rate = params.get("fee_rate").and_then(|v| v.as_u64()).unwrap_or(5);
|
||||
let fund_body = serde_json::json!({
|
||||
"raw": {
|
||||
"outputs": { addr: amount_sats }
|
||||
},
|
||||
"sat_per_vbyte": fee_rate,
|
||||
"spend_unconfirmed": false,
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post("https://127.0.0.1:8080/v2/wallet/psbt/fund")
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&fund_body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to fund PSBT via LND")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp.json().await
|
||||
.context("Failed to parse fund response")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
||||
return Err(anyhow::anyhow!("Failed to create TX: {}", msg));
|
||||
}
|
||||
|
||||
let funded_psbt = body.get("funded_psbt")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("No funded_psbt in response"))?;
|
||||
|
||||
// Step 2: Finalize (LND auto-signs with hot wallet keys)
|
||||
let finalize_body = serde_json::json!({
|
||||
"funded_psbt": funded_psbt,
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post("https://127.0.0.1:8080/v2/wallet/psbt/finalize")
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&finalize_body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to finalize PSBT")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp.json().await
|
||||
.context("Failed to parse finalize response")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
||||
return Err(anyhow::anyhow!("Failed to sign TX: {}", msg));
|
||||
}
|
||||
|
||||
// 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"))?;
|
||||
|
||||
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_tx_hex,
|
||||
"amount_sats": amount_sats,
|
||||
"addr": addr,
|
||||
"broadcast": false,
|
||||
}))
|
||||
}
|
||||
|
||||
/// List on-chain transactions from LND.
|
||||
/// Returns all transactions, with incoming (amount > 0) flagged.
|
||||
pub(super) async fn handle_lnd_gettransactions(&self) -> Result<serde_json::Value> {
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let resp = client
|
||||
.get("https://127.0.0.1:8080/v1/transactions")
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND REST connection failed")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse transactions response")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
return Err(anyhow::anyhow!("Failed to list transactions: {}", msg));
|
||||
}
|
||||
|
||||
let empty_vec = vec![];
|
||||
let raw_txs = body
|
||||
.get("transactions")
|
||||
.and_then(|v| v.as_array())
|
||||
.unwrap_or(&empty_vec);
|
||||
|
||||
let mut transactions: Vec<serde_json::Value> = Vec::new();
|
||||
for tx in raw_txs {
|
||||
let amount: i64 = tx
|
||||
.get("amount")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.or_else(|| tx.get("amount").and_then(|v| v.as_i64()))
|
||||
.unwrap_or(0);
|
||||
|
||||
let num_confirmations: i64 = tx
|
||||
.get("num_confirmations")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0);
|
||||
|
||||
let tx_hash = tx
|
||||
.get("tx_hash")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let time_stamp: i64 = tx
|
||||
.get("time_stamp")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.or_else(|| tx.get("time_stamp").and_then(|v| v.as_i64()))
|
||||
.unwrap_or(0);
|
||||
|
||||
let total_fees: i64 = tx
|
||||
.get("total_fees")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.or_else(|| tx.get("total_fees").and_then(|v| v.as_i64()))
|
||||
.unwrap_or(0);
|
||||
|
||||
let dest_addresses: Vec<String> = tx
|
||||
.get("dest_addresses")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|a| a.as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let label = tx
|
||||
.get("label")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let block_height: i64 = tx
|
||||
.get("block_height")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0);
|
||||
|
||||
let direction = if amount > 0 { "incoming" } else { "outgoing" };
|
||||
|
||||
transactions.push(serde_json::json!({
|
||||
"tx_hash": tx_hash,
|
||||
"amount_sats": amount.abs(),
|
||||
"direction": direction,
|
||||
"num_confirmations": num_confirmations,
|
||||
"time_stamp": time_stamp,
|
||||
"total_fees": total_fees,
|
||||
"dest_addresses": dest_addresses,
|
||||
"label": label,
|
||||
"block_height": block_height,
|
||||
}));
|
||||
}
|
||||
|
||||
// Sort by timestamp descending (most recent first)
|
||||
transactions.sort_by(|a, b| {
|
||||
let ta = a.get("time_stamp").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let tb = b.get("time_stamp").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
tb.cmp(&ta)
|
||||
});
|
||||
|
||||
let incoming_pending: usize = transactions
|
||||
.iter()
|
||||
.filter(|t| {
|
||||
t.get("direction").and_then(|v| v.as_str()) == Some("incoming")
|
||||
&& t.get("num_confirmations").and_then(|v| v.as_i64()) == Some(0)
|
||||
})
|
||||
.count();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"transactions": transactions,
|
||||
"incoming_pending_count": incoming_pending,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Return LND connection info: base64url-encoded TLS cert and admin macaroon
|
||||
/// for building lndconnect:// URIs in the frontend.
|
||||
pub(crate) async fn handle_lnd_connect_info(&self) -> Result<serde_json::Value> {
|
||||
let cert_path = "/var/lib/archipelago/lnd/tls.cert";
|
||||
let macaroon_path =
|
||||
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||
|
||||
// Read and encode TLS cert (PEM → DER → base64url)
|
||||
let cert_pem = tokio::fs::read_to_string(cert_path)
|
||||
.await
|
||||
.context("Failed to read LND TLS certificate")?;
|
||||
let cert_der_b64: String = cert_pem
|
||||
.lines()
|
||||
.filter(|l| !l.starts_with("-----"))
|
||||
.collect();
|
||||
let cert_der = base64::engine::general_purpose::STANDARD
|
||||
.decode(&cert_der_b64)
|
||||
.context("Failed to decode PEM base64")?;
|
||||
let cert_b64url = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&cert_der);
|
||||
|
||||
// Read and encode macaroon (binary → base64url)
|
||||
let macaroon_bytes = tokio::fs::read(macaroon_path)
|
||||
.await
|
||||
.context("Failed to read LND admin macaroon")?;
|
||||
let macaroon_b64url =
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&macaroon_bytes);
|
||||
|
||||
// Read Tor onion address if available
|
||||
let tor_onion = tokio::fs::read_to_string(
|
||||
"/var/lib/archipelago/tor/hidden_service_lnd/hostname",
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string());
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"cert_base64url": cert_b64url,
|
||||
"macaroon_base64url": macaroon_b64url,
|
||||
"tor_onion": tor_onion,
|
||||
"rest_port": 8080,
|
||||
"grpc_port": 10009,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// Channel types
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ChannelInfo {
|
||||
chan_id: String,
|
||||
remote_pubkey: String,
|
||||
capacity: i64,
|
||||
local_balance: i64,
|
||||
remote_balance: i64,
|
||||
active: bool,
|
||||
status: String,
|
||||
channel_point: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ChannelListResult {
|
||||
channels: Vec<ChannelInfo>,
|
||||
total_inbound: i64,
|
||||
total_outbound: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LndListChannelsResponse {
|
||||
channels: Option<Vec<LndChannel>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LndChannel {
|
||||
chan_id: Option<String>,
|
||||
remote_pubkey: Option<String>,
|
||||
capacity: Option<String>,
|
||||
local_balance: Option<String>,
|
||||
remote_balance: Option<String>,
|
||||
active: Option<bool>,
|
||||
channel_point: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
struct LndPendingChannelsResponse {
|
||||
pending_open_channels: Option<Vec<LndPendingOpenChannel>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LndPendingOpenChannel {
|
||||
channel: Option<LndPendingChannel>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LndPendingChannel {
|
||||
remote_node_pub: Option<String>,
|
||||
capacity: Option<String>,
|
||||
local_balance: Option<String>,
|
||||
remote_balance: Option<String>,
|
||||
channel_point: Option<String>,
|
||||
}
|
||||
251
core/archipelago/src/api/rpc/lnd/channels.rs
Normal file
251
core/archipelago/src/api/rpc/lnd/channels.rs
Normal file
@@ -0,0 +1,251 @@
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ChannelInfo {
|
||||
chan_id: String,
|
||||
remote_pubkey: String,
|
||||
capacity: i64,
|
||||
local_balance: i64,
|
||||
remote_balance: i64,
|
||||
active: bool,
|
||||
status: String,
|
||||
channel_point: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ChannelListResult {
|
||||
channels: Vec<ChannelInfo>,
|
||||
total_inbound: i64,
|
||||
total_outbound: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LndListChannelsResponse {
|
||||
channels: Option<Vec<LndChannel>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LndChannel {
|
||||
chan_id: Option<String>,
|
||||
remote_pubkey: Option<String>,
|
||||
capacity: Option<String>,
|
||||
local_balance: Option<String>,
|
||||
remote_balance: Option<String>,
|
||||
active: Option<bool>,
|
||||
channel_point: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
struct LndPendingChannelsResponse {
|
||||
pending_open_channels: Option<Vec<LndPendingOpenChannel>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LndPendingOpenChannel {
|
||||
channel: Option<LndPendingChannel>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LndPendingChannel {
|
||||
remote_node_pub: Option<String>,
|
||||
capacity: Option<String>,
|
||||
local_balance: Option<String>,
|
||||
remote_balance: Option<String>,
|
||||
channel_point: Option<String>,
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
pub(in crate::api::rpc) async fn handle_lnd_listchannels(&self) -> Result<serde_json::Value> {
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let channels_resp: LndListChannelsResponse = client
|
||||
.get("https://127.0.0.1:8080/v1/channels")
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND REST connection failed")?
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse LND channels response")?;
|
||||
|
||||
let pending_resp: LndPendingChannelsResponse = match client
|
||||
.get("https://127.0.0.1:8080/v1/channels/pending")
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp.json().await.unwrap_or_default(),
|
||||
Err(_) => LndPendingChannelsResponse::default(),
|
||||
};
|
||||
|
||||
let channels: Vec<ChannelInfo> = channels_resp
|
||||
.channels
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|ch| {
|
||||
let capacity: i64 = ch.capacity.as_deref().and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
let local: i64 = ch.local_balance.as_deref().and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
let remote: i64 = ch.remote_balance.as_deref().and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
ChannelInfo {
|
||||
chan_id: ch.chan_id.unwrap_or_default(),
|
||||
remote_pubkey: ch.remote_pubkey.unwrap_or_default(),
|
||||
capacity,
|
||||
local_balance: local,
|
||||
remote_balance: remote,
|
||||
active: ch.active.unwrap_or(false),
|
||||
status: if ch.active.unwrap_or(false) { "active".into() } else { "inactive".into() },
|
||||
channel_point: ch.channel_point.unwrap_or_default(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut pending_channels: Vec<ChannelInfo> = Vec::new();
|
||||
for pch in pending_resp.pending_open_channels.unwrap_or_default() {
|
||||
if let Some(ch) = pch.channel {
|
||||
let capacity: i64 = ch.capacity.as_deref().and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
let local: i64 = ch.local_balance.as_deref().and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
let remote: i64 = ch.remote_balance.as_deref().and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
pending_channels.push(ChannelInfo {
|
||||
chan_id: String::new(),
|
||||
remote_pubkey: ch.remote_node_pub.unwrap_or_default(),
|
||||
capacity,
|
||||
local_balance: local,
|
||||
remote_balance: remote,
|
||||
active: false,
|
||||
status: "pending_open".into(),
|
||||
channel_point: ch.channel_point.unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let total_local: i64 = channels.iter().map(|c| c.local_balance).sum();
|
||||
let total_remote: i64 = channels.iter().map(|c| c.remote_balance).sum();
|
||||
|
||||
let mut all_channels = channels;
|
||||
all_channels.extend(pending_channels);
|
||||
|
||||
let result = ChannelListResult {
|
||||
channels: all_channels,
|
||||
total_inbound: total_remote,
|
||||
total_outbound: total_local,
|
||||
};
|
||||
|
||||
Ok(serde_json::to_value(result)?)
|
||||
}
|
||||
|
||||
pub(in crate::api::rpc) async fn handle_lnd_openchannel(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let pubkey = params.get("pubkey")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'pubkey' parameter"))?;
|
||||
let amount = params.get("amount")
|
||||
.and_then(|v| v.as_i64())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'amount' parameter (sats)"))?;
|
||||
|
||||
// Validate pubkey: must be 66-char hex (compressed secp256k1)
|
||||
if pubkey.len() != 66 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err(anyhow::anyhow!("Invalid pubkey: must be 66-character hex string"));
|
||||
}
|
||||
|
||||
if amount < 20000 {
|
||||
return Err(anyhow::anyhow!("Channel amount must be at least 20,000 sats"));
|
||||
}
|
||||
if amount > 16_777_215 {
|
||||
return Err(anyhow::anyhow!("Channel amount exceeds maximum (16,777,215 sats)"));
|
||||
}
|
||||
|
||||
info!(peer = pubkey, amount = amount, "Opening Lightning channel");
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
// First connect to the peer if an address is provided
|
||||
if let Some(addr) = params.get("address").and_then(|v| v.as_str()) {
|
||||
// Validate peer address format (host:port)
|
||||
if addr.len() > 256 || addr.contains('\0') || addr.contains(' ') {
|
||||
return Err(anyhow::anyhow!("Invalid peer address format"));
|
||||
}
|
||||
let connect_body = serde_json::json!({
|
||||
"addr": { "pubkey": pubkey, "host": addr },
|
||||
"perm": true
|
||||
});
|
||||
let _ = client
|
||||
.post("https://127.0.0.1:8080/v1/peers")
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&connect_body)
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
|
||||
let open_body = serde_json::json!({
|
||||
"node_pubkey_string": pubkey,
|
||||
"local_funding_amount": amount.to_string(),
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post("https://127.0.0.1:8080/v1/channels")
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&open_body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to open channel")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp.json().await.context("Failed to parse open channel response")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
||||
return Err(anyhow::anyhow!("Failed to open channel: {}", msg));
|
||||
}
|
||||
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
pub(in crate::api::rpc) async fn handle_lnd_closechannel(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let channel_point = params.get("channel_point")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'channel_point' parameter (txid:output_index)"))?;
|
||||
|
||||
let parts: Vec<&str> = channel_point.split(':').collect();
|
||||
if parts.len() != 2 {
|
||||
return Err(anyhow::anyhow!("Invalid channel_point format. Expected 'txid:output_index'"));
|
||||
}
|
||||
// Validate txid is 64-char hex and output_index is numeric
|
||||
if parts[0].len() != 64 || !parts[0].chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err(anyhow::anyhow!("Invalid txid in channel_point: must be 64-character hex"));
|
||||
}
|
||||
if parts[1].parse::<u32>().is_err() {
|
||||
return Err(anyhow::anyhow!("Invalid output_index in channel_point: must be a number"));
|
||||
}
|
||||
|
||||
let force = params.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
info!(channel_point = channel_point, force = force, "Closing Lightning channel");
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let url = format!(
|
||||
"https://127.0.0.1:8080/v1/channels/{}/{}?force={}",
|
||||
parts[0], parts[1], force
|
||||
);
|
||||
|
||||
let resp = client
|
||||
.delete(&url)
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to close channel")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp.json().await.context("Failed to parse close channel response")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
||||
return Err(anyhow::anyhow!("Failed to close channel: {}", msg));
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({ "success": true }))
|
||||
}
|
||||
}
|
||||
228
core/archipelago/src/api/rpc/lnd/info.rs
Normal file
228
core/archipelago/src/api/rpc/lnd/info.rs
Normal file
@@ -0,0 +1,228 @@
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use anyhow::{Context, Result};
|
||||
use base64::Engine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{LndAmount, LndBalanceResponse};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct LndInfo {
|
||||
alias: String,
|
||||
num_active_channels: u32,
|
||||
num_peers: u32,
|
||||
synced_to_chain: bool,
|
||||
block_height: u64,
|
||||
balance_sats: i64,
|
||||
channel_balance_sats: i64,
|
||||
pending_open_balance: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LndGetInfoResponse {
|
||||
alias: Option<String>,
|
||||
num_active_channels: Option<u32>,
|
||||
num_peers: Option<u32>,
|
||||
synced_to_chain: Option<bool>,
|
||||
block_height: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LndChannelBalanceResponse {
|
||||
local_balance: Option<LndAmount>,
|
||||
pending_open_local_balance: Option<LndAmount>,
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
pub(in crate::api::rpc) async fn handle_lnd_getinfo(&self) -> Result<serde_json::Value> {
|
||||
let macaroon_path =
|
||||
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||
|
||||
let macaroon_bytes = tokio::fs::read(macaroon_path)
|
||||
.await
|
||||
.context("Failed to read LND admin macaroon — is LND installed?")?;
|
||||
let macaroon_hex = hex::encode(&macaroon_bytes);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.context("Failed to create HTTP client")?;
|
||||
|
||||
let get_info: LndGetInfoResponse = client
|
||||
.get("https://127.0.0.1:8080/v1/getinfo")
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND REST connection failed")?
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse LND getinfo response")?;
|
||||
|
||||
let channel_balance: LndChannelBalanceResponse = match client
|
||||
.get("https://127.0.0.1:8080/v1/balance/channels")
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp.json().await.unwrap_or(LndChannelBalanceResponse {
|
||||
local_balance: None,
|
||||
pending_open_local_balance: None,
|
||||
}),
|
||||
Err(_) => LndChannelBalanceResponse {
|
||||
local_balance: None,
|
||||
pending_open_local_balance: None,
|
||||
},
|
||||
};
|
||||
|
||||
let wallet_balance: LndBalanceResponse = match client
|
||||
.get("https://127.0.0.1:8080/v1/balance/blockchain")
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp.json().await.unwrap_or(LndBalanceResponse {
|
||||
total_balance: None,
|
||||
}),
|
||||
Err(_) => LndBalanceResponse {
|
||||
total_balance: None,
|
||||
},
|
||||
};
|
||||
|
||||
let info = LndInfo {
|
||||
alias: get_info.alias.unwrap_or_default(),
|
||||
num_active_channels: get_info.num_active_channels.unwrap_or(0),
|
||||
num_peers: get_info.num_peers.unwrap_or(0),
|
||||
synced_to_chain: get_info.synced_to_chain.unwrap_or(false),
|
||||
block_height: get_info.block_height.unwrap_or(0),
|
||||
balance_sats: wallet_balance
|
||||
.total_balance
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0),
|
||||
channel_balance_sats: channel_balance
|
||||
.local_balance
|
||||
.and_then(|a| a.sat.and_then(|s| s.parse().ok()))
|
||||
.unwrap_or(0),
|
||||
pending_open_balance: channel_balance
|
||||
.pending_open_local_balance
|
||||
.and_then(|a| a.sat.and_then(|s| s.parse().ok()))
|
||||
.unwrap_or(0),
|
||||
};
|
||||
|
||||
Ok(serde_json::to_value(info)?)
|
||||
}
|
||||
|
||||
/// Return LND connection info: base64url-encoded TLS cert and admin macaroon
|
||||
/// for building lndconnect:// URIs in the frontend.
|
||||
pub(crate) async fn handle_lnd_connect_info(&self) -> Result<serde_json::Value> {
|
||||
let cert_path = "/var/lib/archipelago/lnd/tls.cert";
|
||||
let macaroon_path =
|
||||
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||
|
||||
// Read and encode TLS cert (PEM -> DER -> base64url)
|
||||
let cert_pem = tokio::fs::read_to_string(cert_path)
|
||||
.await
|
||||
.context("Failed to read LND TLS certificate")?;
|
||||
let cert_der_b64: String = cert_pem
|
||||
.lines()
|
||||
.filter(|l| !l.starts_with("-----"))
|
||||
.collect();
|
||||
let cert_der = base64::engine::general_purpose::STANDARD
|
||||
.decode(&cert_der_b64)
|
||||
.context("Failed to decode PEM base64")?;
|
||||
let cert_b64url = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&cert_der);
|
||||
|
||||
// Read and encode macaroon (binary -> base64url)
|
||||
let macaroon_bytes = tokio::fs::read(macaroon_path)
|
||||
.await
|
||||
.context("Failed to read LND admin macaroon")?;
|
||||
let macaroon_b64url =
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&macaroon_bytes);
|
||||
|
||||
// Read Tor onion address -- check system Tor path first, then legacy
|
||||
let tor_onion = {
|
||||
let mut onion = None;
|
||||
for path in &[
|
||||
"/var/lib/archipelago/tor-hostnames/lnd",
|
||||
"/var/lib/tor/hidden_service_lnd/hostname",
|
||||
"/var/lib/archipelago/tor/hidden_service_lnd/hostname",
|
||||
] {
|
||||
if let Ok(addr) = tokio::fs::read_to_string(path).await {
|
||||
let addr = addr.trim().to_string();
|
||||
if addr.ends_with(".onion") {
|
||||
onion = Some(addr);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Try sudo for system Tor dirs (owned by debian-tor, 0700)
|
||||
if let Ok(output) = tokio::process::Command::new("sudo")
|
||||
.args(["cat", path])
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
if output.status.success() {
|
||||
let addr = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if addr.ends_with(".onion") {
|
||||
onion = Some(addr);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
onion
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"cert_base64url": cert_b64url,
|
||||
"macaroon_base64url": macaroon_b64url,
|
||||
"tor_onion": tor_onion,
|
||||
"rest_port": 8080,
|
||||
"grpc_port": 10009,
|
||||
}))
|
||||
}
|
||||
|
||||
/// lnd.export-channel-backup -- Export all channel static backups (SCB).
|
||||
/// Returns base64-encoded multi-channel backup that can restore channels on a new node.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_export_channel_backup(&self) -> Result<serde_json::Value> {
|
||||
let macaroon_path = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||
let macaroon_bytes = tokio::fs::read(macaroon_path)
|
||||
.await
|
||||
.context("Failed to read LND admin macaroon")?;
|
||||
let macaroon_hex = hex::encode(&macaroon_bytes);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.context("Failed to build HTTP client")?;
|
||||
|
||||
let resp = client
|
||||
.get("https://127.0.0.1:8080/v1/channels/backup")
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to reach LND REST API")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("LND returned {}", resp.status());
|
||||
}
|
||||
|
||||
let data: serde_json::Value = resp.json().await.context("Invalid JSON from LND")?;
|
||||
|
||||
// Extract the multi_chan_backup bytes
|
||||
let backup_b64 = data
|
||||
.get("multi_chan_backup")
|
||||
.and_then(|m| m.get("multi_chan_backup"))
|
||||
.and_then(|b| b.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"backup": backup_b64,
|
||||
"channel_count": data.get("multi_chan_backup")
|
||||
.and_then(|m| m.get("chan_points"))
|
||||
.and_then(|c| c.as_array())
|
||||
.map(|a| a.len())
|
||||
.unwrap_or(0),
|
||||
"timestamp": chrono::Utc::now().to_rfc3339(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
38
core/archipelago/src/api/rpc/lnd/mod.rs
Normal file
38
core/archipelago/src/api/rpc/lnd/mod.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
mod channels;
|
||||
mod info;
|
||||
mod payments;
|
||||
mod wallet;
|
||||
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
// Shared LND response types used by multiple submodules
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub(super) struct LndBalanceResponse {
|
||||
pub total_balance: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub(super) struct LndAmount {
|
||||
pub sat: Option<String>,
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// Helper: create an authenticated LND REST client.
|
||||
/// Returns an HTTP client configured for LND's self-signed TLS and the
|
||||
/// hex-encoded admin macaroon for request headers.
|
||||
pub(crate) async fn lnd_client(&self) -> Result<(reqwest::Client, String)> {
|
||||
let macaroon_path =
|
||||
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||
let macaroon_bytes = tokio::fs::read(macaroon_path)
|
||||
.await
|
||||
.context("Failed to read LND admin macaroon — is LND installed?")?;
|
||||
let macaroon_hex = hex::encode(&macaroon_bytes);
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.context("Failed to create HTTP client")?;
|
||||
Ok((client, macaroon_hex))
|
||||
}
|
||||
}
|
||||
191
core/archipelago/src/api/rpc/lnd/payments.rs
Normal file
191
core/archipelago/src/api/rpc/lnd/payments.rs
Normal file
@@ -0,0 +1,191 @@
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::info;
|
||||
|
||||
impl RpcHandler {
|
||||
/// Pay a Lightning invoice.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_payinvoice(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let payment_request = params.get("payment_request")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'payment_request' parameter"))?;
|
||||
|
||||
// Basic validation: Lightning invoices start with lnbc/lntb/lnbcrt
|
||||
if payment_request.len() < 10 || payment_request.len() > 2048 {
|
||||
return Err(anyhow::anyhow!("Invalid payment request length"));
|
||||
}
|
||||
let lower = payment_request.to_lowercase();
|
||||
if !lower.starts_with("lnbc") && !lower.starts_with("lntb") && !lower.starts_with("lnbcrt") {
|
||||
return Err(anyhow::anyhow!("Invalid payment request: must be a Lightning invoice (lnbc...)"));
|
||||
}
|
||||
|
||||
info!("Paying Lightning invoice");
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let pay_body = serde_json::json!({
|
||||
"payment_request": payment_request,
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post("https://127.0.0.1:8080/v1/channels/transactions")
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&pay_body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to pay invoice")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp.json().await
|
||||
.context("Failed to parse payment response")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
||||
return Err(anyhow::anyhow!("Payment failed: {}", msg));
|
||||
}
|
||||
|
||||
let payment_error = body.get("payment_error").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if !payment_error.is_empty() {
|
||||
return Err(anyhow::anyhow!("Payment failed: {}", payment_error));
|
||||
}
|
||||
|
||||
let amount_sat = body.get("payment_route")
|
||||
.and_then(|r| r.get("total_amt"))
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
let payment_hash = body.get("payment_hash")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"payment_hash": payment_hash,
|
||||
"amount_sats": amount_sat,
|
||||
}))
|
||||
}
|
||||
|
||||
/// List on-chain transactions from LND.
|
||||
/// Returns all transactions, with incoming (amount > 0) flagged.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_gettransactions(&self) -> Result<serde_json::Value> {
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let resp = client
|
||||
.get("https://127.0.0.1:8080/v1/transactions")
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND REST connection failed")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse transactions response")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
return Err(anyhow::anyhow!("Failed to list transactions: {}", msg));
|
||||
}
|
||||
|
||||
let empty_vec = vec![];
|
||||
let raw_txs = body
|
||||
.get("transactions")
|
||||
.and_then(|v| v.as_array())
|
||||
.unwrap_or(&empty_vec);
|
||||
|
||||
let mut transactions: Vec<serde_json::Value> = Vec::new();
|
||||
for tx in raw_txs {
|
||||
let amount: i64 = tx
|
||||
.get("amount")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.or_else(|| tx.get("amount").and_then(|v| v.as_i64()))
|
||||
.unwrap_or(0);
|
||||
|
||||
let num_confirmations: i64 = tx
|
||||
.get("num_confirmations")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0);
|
||||
|
||||
let tx_hash = tx
|
||||
.get("tx_hash")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let time_stamp: i64 = tx
|
||||
.get("time_stamp")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.or_else(|| tx.get("time_stamp").and_then(|v| v.as_i64()))
|
||||
.unwrap_or(0);
|
||||
|
||||
let total_fees: i64 = tx
|
||||
.get("total_fees")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.or_else(|| tx.get("total_fees").and_then(|v| v.as_i64()))
|
||||
.unwrap_or(0);
|
||||
|
||||
let dest_addresses: Vec<String> = tx
|
||||
.get("dest_addresses")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|a| a.as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let label = tx
|
||||
.get("label")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let block_height: i64 = tx
|
||||
.get("block_height")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0);
|
||||
|
||||
let direction = if amount > 0 { "incoming" } else { "outgoing" };
|
||||
|
||||
transactions.push(serde_json::json!({
|
||||
"tx_hash": tx_hash,
|
||||
"amount_sats": amount.abs(),
|
||||
"direction": direction,
|
||||
"num_confirmations": num_confirmations,
|
||||
"time_stamp": time_stamp,
|
||||
"total_fees": total_fees,
|
||||
"dest_addresses": dest_addresses,
|
||||
"label": label,
|
||||
"block_height": block_height,
|
||||
}));
|
||||
}
|
||||
|
||||
// Sort by timestamp descending (most recent first)
|
||||
transactions.sort_by(|a, b| {
|
||||
let ta = a.get("time_stamp").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let tb = b.get("time_stamp").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
tb.cmp(&ta)
|
||||
});
|
||||
|
||||
let incoming_pending: usize = transactions
|
||||
.iter()
|
||||
.filter(|t| {
|
||||
t.get("direction").and_then(|v| v.as_str()) == Some("incoming")
|
||||
&& t.get("num_confirmations").and_then(|v| v.as_i64()) == Some(0)
|
||||
})
|
||||
.count();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"transactions": transactions,
|
||||
"incoming_pending_count": incoming_pending,
|
||||
}))
|
||||
}
|
||||
}
|
||||
384
core/archipelago/src/api/rpc/lnd/wallet.rs
Normal file
384
core/archipelago/src/api/rpc/lnd/wallet.rs
Normal file
@@ -0,0 +1,384 @@
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use anyhow::{Context, Result};
|
||||
use base64::Engine;
|
||||
use tracing::info;
|
||||
|
||||
impl RpcHandler {
|
||||
/// Generate a new on-chain Bitcoin address.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_newaddress(&self) -> Result<serde_json::Value> {
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let resp = client
|
||||
.get("https://127.0.0.1:8080/v1/newaddress")
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND REST connection failed")?;
|
||||
|
||||
let body: serde_json::Value = resp.json().await
|
||||
.context("Failed to parse newaddress response")?;
|
||||
|
||||
let address = body.get("address")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
Ok(serde_json::json!({ "address": address }))
|
||||
}
|
||||
|
||||
/// Send on-chain Bitcoin to an address.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_sendcoins(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let addr = params.get("addr")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'addr' parameter"))?;
|
||||
let amount = params.get("amount")
|
||||
.and_then(|v| v.as_i64())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'amount' parameter (sats)"))?;
|
||||
|
||||
if amount < 546 {
|
||||
return Err(anyhow::anyhow!("Amount must be at least 546 sats (dust limit)"));
|
||||
}
|
||||
if amount > 21_000_000 * 100_000_000 {
|
||||
return Err(anyhow::anyhow!("Amount exceeds maximum Bitcoin supply"));
|
||||
}
|
||||
|
||||
// Validate Bitcoin address format (basic: length and allowed chars)
|
||||
if addr.len() < 14 || addr.len() > 90 || !addr.chars().all(|c| c.is_ascii_alphanumeric()) {
|
||||
return Err(anyhow::anyhow!("Invalid Bitcoin address format"));
|
||||
}
|
||||
|
||||
info!(addr = addr, amount = amount, "Sending on-chain Bitcoin");
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let send_body = serde_json::json!({
|
||||
"addr": addr,
|
||||
"amount": amount.to_string(),
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post("https://127.0.0.1:8080/v1/transactions")
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&send_body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to send on-chain transaction")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp.json().await
|
||||
.context("Failed to parse send response")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
||||
return Err(anyhow::anyhow!("Failed to send: {}", msg));
|
||||
}
|
||||
|
||||
let txid = body.get("txid").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
Ok(serde_json::json!({ "txid": txid }))
|
||||
}
|
||||
|
||||
/// Create a Lightning invoice.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_createinvoice(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let amount_sats = params.get("amount_sats")
|
||||
.and_then(|v| v.as_i64())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'amount_sats' parameter"))?;
|
||||
let memo = params.get("memo")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
if amount_sats < 0 {
|
||||
return Err(anyhow::anyhow!("Amount must be non-negative"));
|
||||
}
|
||||
if amount_sats > 21_000_000 * 100_000_000 {
|
||||
return Err(anyhow::anyhow!("Amount exceeds maximum Bitcoin supply"));
|
||||
}
|
||||
|
||||
// Limit memo length to prevent abuse
|
||||
if memo.len() > 639 {
|
||||
return Err(anyhow::anyhow!("Memo too long (max 639 bytes)"));
|
||||
}
|
||||
|
||||
info!(amount_sats = amount_sats, "Creating Lightning invoice");
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let invoice_body = serde_json::json!({
|
||||
"value": amount_sats.to_string(),
|
||||
"memo": memo,
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post("https://127.0.0.1:8080/v1/invoices")
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&invoice_body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to create invoice")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp.json().await
|
||||
.context("Failed to parse invoice response")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
||||
return Err(anyhow::anyhow!("Failed to create invoice: {}", msg));
|
||||
}
|
||||
|
||||
let payment_request = body.get("payment_request")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"payment_request": payment_request,
|
||||
"amount_sats": amount_sats,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Create an unsigned PSBT for hardware wallet signing.
|
||||
/// Uses LND's WalletKit.FundPsbt to select UTXOs and create a PSBT template.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_create_psbt(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
|
||||
let outputs = params.get("outputs")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'outputs' array (each: address + amount_sats)"))?;
|
||||
|
||||
if outputs.is_empty() {
|
||||
return Err(anyhow::anyhow!("outputs must not be empty"));
|
||||
}
|
||||
|
||||
// Build the outputs map for LND: { "address": "amount_sats_as_string" }
|
||||
let mut lnd_outputs: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
|
||||
let mut total_amount: i64 = 0;
|
||||
for output in outputs {
|
||||
let addr = output.get("address")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Each output must have an 'address'"))?;
|
||||
// Validate Bitcoin address format
|
||||
if addr.len() < 14 || addr.len() > 90 || !addr.chars().all(|c| c.is_ascii_alphanumeric()) {
|
||||
return Err(anyhow::anyhow!("Invalid Bitcoin address format in output"));
|
||||
}
|
||||
let amount = output.get("amount_sats")
|
||||
.and_then(|v| v.as_i64())
|
||||
.ok_or_else(|| anyhow::anyhow!("Each output must have 'amount_sats'"))?;
|
||||
if amount < 546 {
|
||||
return Err(anyhow::anyhow!("Amount must be at least 546 sats (dust limit)"));
|
||||
}
|
||||
lnd_outputs.insert(addr.to_string(), serde_json::json!(amount));
|
||||
total_amount += amount;
|
||||
}
|
||||
|
||||
let sat_per_vbyte = params.get("fee_rate_sat_per_vbyte")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(10);
|
||||
|
||||
info!(total_amount = total_amount, fee_rate = sat_per_vbyte, "Creating PSBT for hardware wallet signing");
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let fund_body = serde_json::json!({
|
||||
"raw": {
|
||||
"outputs": lnd_outputs,
|
||||
},
|
||||
"sat_per_vbyte": sat_per_vbyte,
|
||||
"spend_unconfirmed": false,
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post("https://127.0.0.1:8080/v2/wallet/psbt/fund")
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&fund_body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to create PSBT via LND")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp.json().await
|
||||
.context("Failed to parse PSBT response")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
||||
return Err(anyhow::anyhow!("Failed to create PSBT: {}", msg));
|
||||
}
|
||||
|
||||
let funded_psbt = body.get("funded_psbt")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let change_output_index = body.get("change_output_index")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(-1);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"psbt_base64": funded_psbt,
|
||||
"change_output_index": change_output_index,
|
||||
"total_amount_sats": total_amount,
|
||||
"fee_rate_sat_per_vbyte": sat_per_vbyte,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Finalize a signed PSBT and broadcast the transaction.
|
||||
/// Takes a PSBT that has been signed by a hardware wallet.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_finalize_psbt(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let signed_psbt = params.get("signed_psbt_base64")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'signed_psbt_base64'"))?;
|
||||
|
||||
info!("Finalizing signed PSBT from hardware wallet");
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let finalize_body = serde_json::json!({
|
||||
"funded_psbt": signed_psbt,
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post("https://127.0.0.1:8080/v2/wallet/psbt/finalize")
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&finalize_body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to finalize PSBT via LND")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp.json().await
|
||||
.context("Failed to parse finalize response")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
||||
return Err(anyhow::anyhow!("Failed to finalize PSBT: {}", msg));
|
||||
}
|
||||
|
||||
let raw_final_tx = body.get("raw_final_tx")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
// Broadcast the finalized transaction
|
||||
let publish_body = serde_json::json!({
|
||||
"tx_hex": raw_final_tx,
|
||||
});
|
||||
|
||||
let pub_resp = client
|
||||
.post("https://127.0.0.1:8080/v2/wallet/tx")
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&publish_body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to broadcast transaction")?;
|
||||
|
||||
let pub_status = pub_resp.status();
|
||||
let pub_body: serde_json::Value = pub_resp.json().await
|
||||
.context("Failed to parse broadcast response")?;
|
||||
|
||||
if !pub_status.is_success() {
|
||||
let msg = pub_body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
||||
return Err(anyhow::anyhow!("Transaction broadcast failed: {}", msg));
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"raw_final_tx": raw_final_tx,
|
||||
"broadcast": true,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Create a signed raw transaction WITHOUT broadcasting.
|
||||
/// Used for mesh relay: create the TX locally, then relay the hex to an
|
||||
/// internet-connected peer who broadcasts it.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_create_raw_tx(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
|
||||
let addr = params.get("addr")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'addr'"))?;
|
||||
let amount_sats = params.get("amount_sats")
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'amount_sats'"))?;
|
||||
|
||||
if amount_sats < 546 {
|
||||
anyhow::bail!("Amount must be at least 546 sats (dust limit)");
|
||||
}
|
||||
if amount_sats > 2_100_000_000_000_000 {
|
||||
anyhow::bail!("Amount exceeds 21M BTC");
|
||||
}
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
// Step 1: Fund a PSBT with the desired output
|
||||
let fee_rate = params.get("fee_rate").and_then(|v| v.as_u64()).unwrap_or(5);
|
||||
let fund_body = serde_json::json!({
|
||||
"raw": {
|
||||
"outputs": { addr: amount_sats }
|
||||
},
|
||||
"sat_per_vbyte": fee_rate,
|
||||
"spend_unconfirmed": false,
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post("https://127.0.0.1:8080/v2/wallet/psbt/fund")
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&fund_body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to fund PSBT via LND")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp.json().await
|
||||
.context("Failed to parse fund response")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
||||
return Err(anyhow::anyhow!("Failed to create TX: {}", msg));
|
||||
}
|
||||
|
||||
let funded_psbt = body.get("funded_psbt")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("No funded_psbt in response"))?;
|
||||
|
||||
// Step 2: Finalize (LND auto-signs with hot wallet keys)
|
||||
let finalize_body = serde_json::json!({
|
||||
"funded_psbt": funded_psbt,
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post("https://127.0.0.1:8080/v2/wallet/psbt/finalize")
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&finalize_body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to finalize PSBT")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp.json().await
|
||||
.context("Failed to parse finalize response")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
||||
return Err(anyhow::anyhow!("Failed to sign TX: {}", msg));
|
||||
}
|
||||
|
||||
// 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"))?;
|
||||
|
||||
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_tx_hex,
|
||||
"amount_sats": amount_sats,
|
||||
"addr": addr,
|
||||
"broadcast": false,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -179,10 +179,29 @@ impl RpcHandler {
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing r_hash"))?;
|
||||
|
||||
// Check invoice status — stub until LND lookup is implemented
|
||||
// TODO: Add lnd.lookupinvoice RPC endpoint for real payment verification
|
||||
let paid = false; // Payment verification pending LND integration
|
||||
let _ = r_hash; // Used when LND lookup is available
|
||||
// Validate r_hash is hex-encoded (LND payment hashes are 32 bytes = 64 hex chars)
|
||||
if r_hash.len() != 64 || !r_hash.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err(anyhow::anyhow!("Invalid r_hash: must be 64-character hex string"));
|
||||
}
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let url = format!(
|
||||
"https://127.0.0.1:8080/v1/invoice/{}",
|
||||
r_hash
|
||||
);
|
||||
let paid = match client
|
||||
.get(&url)
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) if r.status().is_success() => {
|
||||
let body: serde_json::Value = r.json().await.unwrap_or_default();
|
||||
body.get("settled").and_then(|v| v.as_bool()).unwrap_or(false)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"r_hash": r_hash,
|
||||
|
||||
@@ -1,865 +0,0 @@
|
||||
use super::RpcHandler;
|
||||
use crate::mesh;
|
||||
use crate::mesh::message_types::{
|
||||
self, AlertPayload, AlertType, Coordinate, InvoicePayload, MeshMessageType, TypedEnvelope,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use tracing::info;
|
||||
|
||||
impl RpcHandler {
|
||||
/// mesh.status — Get mesh radio status, device info, and peer count.
|
||||
pub(super) async fn handle_mesh_status(&self) -> Result<serde_json::Value> {
|
||||
let service = self.mesh_service.read().await;
|
||||
if let Some(svc) = service.as_ref() {
|
||||
let status = svc.status().await;
|
||||
Ok(serde_json::to_value(status)?)
|
||||
} else {
|
||||
// No service running — return basic config + device detection
|
||||
let config = mesh::load_config(&self.config.data_dir).await?;
|
||||
let devices = mesh::detect_devices().await;
|
||||
Ok(serde_json::json!({
|
||||
"enabled": config.enabled,
|
||||
"device_connected": false,
|
||||
"device_type": "unknown",
|
||||
"device_path": config.device_path,
|
||||
"channel_name": config.channel_name.unwrap_or_else(|| "archipelago".to_string()),
|
||||
"detected_devices": devices,
|
||||
"peer_count": 0,
|
||||
"messages_sent": 0,
|
||||
"messages_received": 0,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// mesh.peers — List discovered mesh peers.
|
||||
pub(super) async fn handle_mesh_peers(&self) -> Result<serde_json::Value> {
|
||||
let service = self.mesh_service.read().await;
|
||||
if let Some(svc) = service.as_ref() {
|
||||
let peers = svc.peers().await;
|
||||
Ok(serde_json::json!({
|
||||
"peers": peers,
|
||||
"count": peers.len(),
|
||||
}))
|
||||
} else {
|
||||
Ok(serde_json::json!({
|
||||
"peers": [],
|
||||
"count": 0,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// mesh.messages — Get recent mesh message history.
|
||||
pub(super) async fn handle_mesh_messages(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let limit = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("limit"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|n| n as usize);
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
if let Some(svc) = service.as_ref() {
|
||||
let messages = svc.messages(limit).await;
|
||||
Ok(serde_json::json!({
|
||||
"messages": messages,
|
||||
"count": messages.len(),
|
||||
}))
|
||||
} else {
|
||||
Ok(serde_json::json!({
|
||||
"messages": [],
|
||||
"count": 0,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// mesh.send — Send an encrypted message to a mesh peer.
|
||||
pub(super) async fn handle_mesh_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
|
||||
.get("contact_id")
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing contact_id"))? as u32;
|
||||
|
||||
let message = params
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing message"))?;
|
||||
|
||||
if message.is_empty() {
|
||||
anyhow::bail!("Message cannot be empty");
|
||||
}
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running. Enable mesh first."))?;
|
||||
|
||||
let msg = svc.send_message(contact_id, message).await?;
|
||||
info!(contact_id, encrypted = msg.encrypted, "Sent mesh message");
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"sent": true,
|
||||
"message_id": msg.id,
|
||||
"encrypted": msg.encrypted,
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.broadcast — Broadcast our node identity over mesh.
|
||||
pub(super) async fn handle_mesh_broadcast(&self) -> Result<serde_json::Value> {
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running. Enable mesh first."))?;
|
||||
|
||||
svc.broadcast_identity().await?;
|
||||
info!("Broadcast identity over mesh");
|
||||
|
||||
Ok(serde_json::json!({ "broadcast": true }))
|
||||
}
|
||||
|
||||
/// mesh.configure — Enable/disable mesh and set device path.
|
||||
pub(super) async fn handle_mesh_configure(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
|
||||
let mut config = mesh::load_config(&self.config.data_dir).await?;
|
||||
|
||||
if let Some(enabled) = params.get("enabled").and_then(|v| v.as_bool()) {
|
||||
config.enabled = enabled;
|
||||
}
|
||||
if let Some(device) = params.get("device_path").and_then(|v| v.as_str()) {
|
||||
config.device_path = Some(device.to_string());
|
||||
}
|
||||
if let Some(channel) = params.get("channel_name").and_then(|v| v.as_str()) {
|
||||
config.channel_name = Some(channel.to_string());
|
||||
}
|
||||
if let Some(broadcast) = params.get("broadcast_identity").and_then(|v| v.as_bool()) {
|
||||
config.broadcast_identity = broadcast;
|
||||
}
|
||||
if let Some(name) = params.get("advert_name").and_then(|v| v.as_str()) {
|
||||
config.advert_name = Some(name.to_string());
|
||||
}
|
||||
|
||||
mesh::save_config(&self.config.data_dir, &config).await?;
|
||||
|
||||
// If we have a running service, update its config
|
||||
let mut service = self.mesh_service.write().await;
|
||||
if let Some(svc) = service.as_mut() {
|
||||
svc.configure(config.clone()).await?;
|
||||
}
|
||||
|
||||
info!("Mesh config updated");
|
||||
Ok(serde_json::json!({
|
||||
"configured": true,
|
||||
"enabled": config.enabled,
|
||||
"device_path": config.device_path,
|
||||
}))
|
||||
}
|
||||
|
||||
// ─── Phase 3: Typed Messages ────────────────────────────────────────
|
||||
|
||||
/// mesh.send-invoice — Create a Lightning invoice and send bolt11 to mesh peer.
|
||||
pub(super) async fn handle_mesh_send_invoice(
|
||||
&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;
|
||||
let amount_sats = params["amount_sats"]
|
||||
.as_u64()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing amount_sats"))?;
|
||||
let memo = params["memo"].as_str().map(|s| s.to_string());
|
||||
|
||||
// Build invoice payload
|
||||
let invoice = InvoicePayload {
|
||||
bolt11: format!("lnbc{}n1pjmesh...", amount_sats), // Placeholder — real LND call in Phase 4
|
||||
amount_sats,
|
||||
memo: memo.clone(),
|
||||
payment_hash: None,
|
||||
};
|
||||
|
||||
let payload = message_types::encode_payload(&invoice)?;
|
||||
let envelope = TypedEnvelope::new(MeshMessageType::Invoice, payload);
|
||||
let wire = envelope.to_wire()?;
|
||||
|
||||
// Send via mesh
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
let wire_str = String::from_utf8_lossy(&wire).to_string();
|
||||
let msg = svc.send_message(contact_id, &wire_str).await?;
|
||||
|
||||
info!(contact_id, amount_sats, "Sent invoice over mesh");
|
||||
Ok(serde_json::json!({
|
||||
"sent": true,
|
||||
"message_id": msg.id,
|
||||
"amount_sats": amount_sats,
|
||||
"bolt11": invoice.bolt11,
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.send-coordinate — Send GPS coordinates to a mesh peer.
|
||||
pub(super) async fn handle_mesh_send_coordinate(
|
||||
&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;
|
||||
let lat = params["lat"]
|
||||
.as_f64()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing lat"))?;
|
||||
let lng = params["lng"]
|
||||
.as_f64()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing lng"))?;
|
||||
let label = params["label"].as_str().map(|s| s.to_string());
|
||||
|
||||
let coord = Coordinate::from_degrees(lat, lng, label);
|
||||
let payload = message_types::encode_payload(&coord)?;
|
||||
let envelope = TypedEnvelope::new(MeshMessageType::Coordinate, payload);
|
||||
let wire = envelope.to_wire()?;
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
let wire_str = String::from_utf8_lossy(&wire).to_string();
|
||||
let msg = svc.send_message(contact_id, &wire_str).await?;
|
||||
|
||||
info!(contact_id, "Sent coordinate over mesh");
|
||||
Ok(serde_json::json!({
|
||||
"sent": true,
|
||||
"message_id": msg.id,
|
||||
"lat": coord.lat,
|
||||
"lng": coord.lng,
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.send-alert — Send a signed emergency alert over mesh.
|
||||
pub(super) async fn handle_mesh_send_alert(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let message = params["message"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing message"))?;
|
||||
let alert_type_str = params["alert_type"]
|
||||
.as_str()
|
||||
.unwrap_or("status");
|
||||
let broadcast = params["broadcast"].as_bool().unwrap_or(false);
|
||||
|
||||
let alert_type = match alert_type_str {
|
||||
"emergency" => AlertType::Emergency,
|
||||
"dead_man" => AlertType::DeadMan,
|
||||
_ => AlertType::Status,
|
||||
};
|
||||
|
||||
// Optional GPS
|
||||
let coordinate = if let (Some(lat), Some(lng)) = (
|
||||
params["lat"].as_f64(),
|
||||
params["lng"].as_f64(),
|
||||
) {
|
||||
Some(Coordinate::from_degrees(lat, lng, None))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let alert = AlertPayload {
|
||||
alert_type,
|
||||
message: message.to_string(),
|
||||
coordinate,
|
||||
};
|
||||
|
||||
let payload = message_types::encode_payload(&alert)?;
|
||||
|
||||
// Sign the alert with node identity
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let node_key_path = identity_dir.join("node_key");
|
||||
|
||||
let envelope = if node_key_path.exists() {
|
||||
let key_bytes = tokio::fs::read(&node_key_path).await?;
|
||||
if key_bytes.len() == 32 {
|
||||
let mut seed = [0u8; 32];
|
||||
seed.copy_from_slice(&key_bytes);
|
||||
let signing_key = ed25519_dalek::SigningKey::from_bytes(&seed);
|
||||
TypedEnvelope::new_signed(MeshMessageType::Alert, payload, &signing_key)
|
||||
} else {
|
||||
TypedEnvelope::new(MeshMessageType::Alert, payload)
|
||||
}
|
||||
} else {
|
||||
TypedEnvelope::new(MeshMessageType::Alert, payload)
|
||||
};
|
||||
|
||||
let wire = envelope.to_wire()?;
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
let wire_str = String::from_utf8_lossy(&wire).to_string();
|
||||
if broadcast {
|
||||
// Send on channel (all peers)
|
||||
svc.send_message(0, &wire_str).await?;
|
||||
info!(alert_type = alert_type_str, "Broadcast alert over mesh");
|
||||
} else if let Some(contact_id) = params["contact_id"].as_u64() {
|
||||
svc.send_message(contact_id as u32, &wire_str).await?;
|
||||
info!(contact_id, alert_type = alert_type_str, "Sent alert to peer");
|
||||
} else {
|
||||
anyhow::bail!("Must specify contact_id or broadcast: true");
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"sent": true,
|
||||
"alert_type": alert_type_str,
|
||||
"signed": envelope.sig.is_some(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.outbox — List pending store-and-forward messages.
|
||||
pub(super) async fn handle_mesh_outbox(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let limit = params
|
||||
.as_ref()
|
||||
.and_then(|p| p["limit"].as_u64())
|
||||
.map(|n| n as usize);
|
||||
|
||||
// Check if outbox file exists
|
||||
let outbox = mesh::outbox::MeshOutbox::load(&self.config.data_dir).await?;
|
||||
let messages = outbox.list(limit).await;
|
||||
let count = outbox.count().await;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"messages": messages.iter().map(|m| serde_json::json!({
|
||||
"id": m.id,
|
||||
"dest_did": m.dest_did,
|
||||
"from_did": m.from_did,
|
||||
"created_at": m.created_at,
|
||||
"ttl_secs": m.ttl_secs,
|
||||
"retry_count": m.retry_count,
|
||||
"relay_hops": m.relay_hops,
|
||||
"expired": m.is_expired(),
|
||||
})).collect::<Vec<_>>(),
|
||||
"count": count,
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.session-status — Get ratchet session info for a peer.
|
||||
pub(super) async fn handle_mesh_session_status(
|
||||
&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;
|
||||
|
||||
// Look up peer DID from mesh service
|
||||
let service = self.mesh_service.read().await;
|
||||
let peer_did = if let Some(svc) = service.as_ref() {
|
||||
let peers = svc.peers().await;
|
||||
peers.iter().find(|p| p.contact_id == contact_id).and_then(|p| p.did.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(did) = peer_did {
|
||||
let session_mgr = mesh::session::SessionManager::new(&self.config.data_dir);
|
||||
if let Some(info) = session_mgr.session_info(&did).await {
|
||||
Ok(serde_json::json!({
|
||||
"has_session": info.has_session,
|
||||
"forward_secrecy": info.forward_secrecy,
|
||||
"message_count": info.message_count,
|
||||
"ratchet_generation": info.ratchet_generation,
|
||||
"peer_did": did,
|
||||
}))
|
||||
} else {
|
||||
Ok(serde_json::json!({
|
||||
"has_session": false,
|
||||
"forward_secrecy": false,
|
||||
"message_count": 0,
|
||||
"ratchet_generation": 0,
|
||||
"peer_did": did,
|
||||
}))
|
||||
}
|
||||
} else {
|
||||
Ok(serde_json::json!({
|
||||
"has_session": false,
|
||||
"forward_secrecy": false,
|
||||
"message_count": 0,
|
||||
"ratchet_generation": 0,
|
||||
"peer_did": null,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Phase 4: Off-Grid Bitcoin Operations ────────────────────────────
|
||||
|
||||
/// mesh.relay-tx — Send a raw transaction for relay by an internet-connected mesh peer.
|
||||
pub(super) async fn handle_mesh_relay_tx(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let tx_hex = params["tx_hex"]
|
||||
.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");
|
||||
}
|
||||
// Validate hex
|
||||
if hex::decode(tx_hex).is_err() {
|
||||
anyhow::bail!("tx_hex is not valid hexadecimal");
|
||||
}
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
let request_id = chrono::Utc::now().timestamp() as u64;
|
||||
svc.relay_tracker.track_tx_relay(request_id, svc.our_did()).await;
|
||||
|
||||
let wire = crate::mesh::bitcoin_relay::build_tx_relay_request(tx_hex, request_id)?;
|
||||
|
||||
let mut sent_count = 0u32;
|
||||
|
||||
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)");
|
||||
}
|
||||
Ok(serde_json::json!({
|
||||
"request_id": request_id,
|
||||
"queued": true,
|
||||
"tx_hex_len": tx_hex.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// 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,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let count = params
|
||||
.as_ref()
|
||||
.and_then(|p| p["count"].as_u64())
|
||||
.unwrap_or(10) as usize;
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
let headers = svc.block_header_cache.recent_headers(count).await;
|
||||
let latest = svc.block_header_cache.latest_height().await;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"headers": headers.iter().map(|h| serde_json::json!({
|
||||
"height": h.height,
|
||||
"hash": h.hash,
|
||||
"prev_hash": h.prev_hash,
|
||||
"timestamp": h.timestamp,
|
||||
"announced_by": h.announced_by,
|
||||
})).collect::<Vec<_>>(),
|
||||
"latest_height": latest,
|
||||
"count": headers.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.relay-lightning — Send a Lightning invoice for payment by an internet-connected peer.
|
||||
pub(super) async fn handle_mesh_relay_lightning(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let bolt11 = params["bolt11"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing bolt11"))?;
|
||||
let amount_sats = params["amount_sats"]
|
||||
.as_u64()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing amount_sats"))?;
|
||||
|
||||
if !bolt11.starts_with("lnbc") && !bolt11.starts_with("lntb") {
|
||||
anyhow::bail!("Invalid bolt11 invoice — must start with lnbc or lntb");
|
||||
}
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
let request_id = chrono::Utc::now().timestamp() as u64;
|
||||
svc.relay_tracker.track_lightning_relay(request_id, svc.our_did()).await;
|
||||
|
||||
let wire = crate::mesh::bitcoin_relay::build_lightning_relay_request(
|
||||
bolt11, amount_sats, request_id,
|
||||
)?;
|
||||
|
||||
// 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; }
|
||||
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, amount_sats, archy_peers = sent_count, "Lightning relay sent (E2E encrypted)");
|
||||
Ok(serde_json::json!({
|
||||
"request_id": request_id,
|
||||
"queued": true,
|
||||
"amount_sats": amount_sats,
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.deadman-status — Get dead man's switch status.
|
||||
pub(super) async fn handle_mesh_deadman_status(&self) -> Result<serde_json::Value> {
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
let status = svc.dead_man_switch.status().await;
|
||||
Ok(serde_json::to_value(status)?)
|
||||
}
|
||||
|
||||
/// mesh.deadman-configure — Configure the dead man's switch.
|
||||
pub(super) async fn handle_mesh_deadman_configure(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
let mut config = svc.dead_man_switch.get_config().await;
|
||||
|
||||
if let Some(enabled) = params.get("enabled").and_then(|v| v.as_bool()) {
|
||||
config.dead_man_enabled = enabled;
|
||||
}
|
||||
if let Some(interval) = params.get("interval_secs").and_then(|v| v.as_u64()) {
|
||||
if interval < 60 {
|
||||
anyhow::bail!("Interval must be at least 60 seconds");
|
||||
}
|
||||
config.dead_man_interval_secs = interval;
|
||||
}
|
||||
if let (Some(lat), Some(lng)) = (
|
||||
params.get("lat").and_then(|v| v.as_f64()),
|
||||
params.get("lng").and_then(|v| v.as_f64()),
|
||||
) {
|
||||
let label = params.get("label").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
config.last_gps = Some(Coordinate::from_degrees(lat, lng, label));
|
||||
}
|
||||
if let Some(contacts) = params.get("contacts").and_then(|v| v.as_array()) {
|
||||
config.emergency_contacts = contacts
|
||||
.iter()
|
||||
.filter_map(|c| c.as_str().map(|s| s.to_string()))
|
||||
.collect();
|
||||
}
|
||||
if let Some(msg) = params.get("custom_message").and_then(|v| v.as_str()) {
|
||||
config.custom_message = Some(msg.to_string());
|
||||
}
|
||||
if let Some(auto_gps) = params.get("auto_gps").and_then(|v| v.as_bool()) {
|
||||
config.auto_include_gps = auto_gps;
|
||||
}
|
||||
|
||||
svc.dead_man_switch.configure(config).await?;
|
||||
// Reset timer on configure
|
||||
svc.dead_man_switch.check_in().await;
|
||||
|
||||
let status = svc.dead_man_switch.status().await;
|
||||
info!("Dead man's switch configured");
|
||||
Ok(serde_json::to_value(status)?)
|
||||
}
|
||||
|
||||
/// mesh.deadman-checkin — Heartbeat to reset the dead man's switch timer.
|
||||
pub(super) async fn handle_mesh_deadman_checkin(&self) -> Result<serde_json::Value> {
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
svc.dead_man_check_in().await;
|
||||
let remaining = svc.dead_man_switch.time_remaining_secs().await;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"checked_in": true,
|
||||
"time_remaining_secs": remaining,
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.rotate-prekeys — Force prekey rotation for X3DH.
|
||||
pub(super) async fn handle_mesh_rotate_prekeys(&self) -> Result<serde_json::Value> {
|
||||
// Load identity signing key
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let node_key_path = identity_dir.join("node_key");
|
||||
let key_bytes = tokio::fs::read(&node_key_path)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("Node identity not found"))?;
|
||||
if key_bytes.len() != 32 {
|
||||
anyhow::bail!("Invalid node key");
|
||||
}
|
||||
let mut seed = [0u8; 32];
|
||||
seed.copy_from_slice(&key_bytes);
|
||||
let signing_key = ed25519_dalek::SigningKey::from_bytes(&seed);
|
||||
|
||||
// Generate new prekey bundle
|
||||
let (bundle, _secrets) = mesh::x3dh::generate_prekey_bundle(&signing_key, 10)?;
|
||||
|
||||
// Save bundle for distribution
|
||||
let bundle_bytes = mesh::x3dh::encode_bundle(&bundle)?;
|
||||
let prekey_dir = self.config.data_dir.join("prekeys");
|
||||
tokio::fs::create_dir_all(&prekey_dir).await?;
|
||||
tokio::fs::write(prekey_dir.join("bundle.cbor"), &bundle_bytes).await?;
|
||||
|
||||
info!(
|
||||
one_time_keys = bundle.one_time_prekeys.len(),
|
||||
"Prekey bundle rotated"
|
||||
);
|
||||
Ok(serde_json::json!({
|
||||
"rotated": true,
|
||||
"signed_prekey_id": bundle.signed_prekey.id,
|
||||
"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,
|
||||
}))
|
||||
}
|
||||
}
|
||||
267
core/archipelago/src/api/rpc/mesh/bitcoin_ops.rs
Normal file
267
core/archipelago/src/api/rpc/mesh/bitcoin_ops.rs
Normal file
@@ -0,0 +1,267 @@
|
||||
use super::super::RpcHandler;
|
||||
use anyhow::Result;
|
||||
use tracing::info;
|
||||
|
||||
impl RpcHandler {
|
||||
/// mesh.relay-tx — Send a raw transaction for relay by an internet-connected mesh peer.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_relay_tx(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let tx_hex = params["tx_hex"]
|
||||
.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");
|
||||
}
|
||||
// Validate hex
|
||||
if hex::decode(tx_hex).is_err() {
|
||||
anyhow::bail!("tx_hex is not valid hexadecimal");
|
||||
}
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
let request_id = chrono::Utc::now().timestamp() as u64;
|
||||
svc.relay_tracker.track_tx_relay(request_id, svc.our_did()).await;
|
||||
|
||||
let wire = crate::mesh::bitcoin_relay::build_tx_relay_request(tx_hex, request_id)?;
|
||||
|
||||
let mut sent_count = 0u32;
|
||||
|
||||
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;
|
||||
}
|
||||
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)");
|
||||
}
|
||||
Ok(serde_json::json!({
|
||||
"request_id": request_id,
|
||||
"queued": true,
|
||||
"tx_hex_len": tx_hex.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.relay-status — Check the status of a pending or completed TX relay.
|
||||
pub(in crate::api::rpc) 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(in crate::api::rpc) async fn handle_mesh_block_headers(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let count = params
|
||||
.as_ref()
|
||||
.and_then(|p| p["count"].as_u64())
|
||||
.unwrap_or(10) as usize;
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
let headers = svc.block_header_cache.recent_headers(count).await;
|
||||
let latest = svc.block_header_cache.latest_height().await;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"headers": headers.iter().map(|h| serde_json::json!({
|
||||
"height": h.height,
|
||||
"hash": h.hash,
|
||||
"prev_hash": h.prev_hash,
|
||||
"timestamp": h.timestamp,
|
||||
"announced_by": h.announced_by,
|
||||
})).collect::<Vec<_>>(),
|
||||
"latest_height": latest,
|
||||
"count": headers.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.relay-lightning — Send a Lightning invoice for payment by an internet-connected peer.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_relay_lightning(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let bolt11 = params["bolt11"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing bolt11"))?;
|
||||
let amount_sats = params["amount_sats"]
|
||||
.as_u64()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing amount_sats"))?;
|
||||
|
||||
if !bolt11.starts_with("lnbc") && !bolt11.starts_with("lntb") {
|
||||
anyhow::bail!("Invalid bolt11 invoice — must start with lnbc or lntb");
|
||||
}
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
let request_id = chrono::Utc::now().timestamp() as u64;
|
||||
svc.relay_tracker.track_lightning_relay(request_id, svc.our_did()).await;
|
||||
|
||||
let wire = crate::mesh::bitcoin_relay::build_lightning_relay_request(
|
||||
bolt11, amount_sats, request_id,
|
||||
)?;
|
||||
|
||||
// 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; }
|
||||
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, amount_sats, archy_peers = sent_count, "Lightning relay sent (E2E encrypted)");
|
||||
Ok(serde_json::json!({
|
||||
"request_id": request_id,
|
||||
"queued": true,
|
||||
"amount_sats": amount_sats,
|
||||
}))
|
||||
}
|
||||
}
|
||||
96
core/archipelago/src/api/rpc/mesh/messaging.rs
Normal file
96
core/archipelago/src/api/rpc/mesh/messaging.rs
Normal file
@@ -0,0 +1,96 @@
|
||||
use super::super::RpcHandler;
|
||||
use crate::mesh;
|
||||
use anyhow::Result;
|
||||
use tracing::info;
|
||||
|
||||
impl RpcHandler {
|
||||
/// mesh.send — Send an encrypted message to a mesh peer.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_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
|
||||
.get("contact_id")
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing contact_id"))? as u32;
|
||||
|
||||
let message = params
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing message"))?;
|
||||
|
||||
if message.is_empty() {
|
||||
anyhow::bail!("Message cannot be empty");
|
||||
}
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running. Enable mesh first."))?;
|
||||
|
||||
let msg = svc.send_message(contact_id, message).await?;
|
||||
info!(contact_id, encrypted = msg.encrypted, "Sent mesh message");
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"sent": true,
|
||||
"message_id": msg.id,
|
||||
"encrypted": msg.encrypted,
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.broadcast — Broadcast our node identity over mesh.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_broadcast(&self) -> Result<serde_json::Value> {
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running. Enable mesh first."))?;
|
||||
|
||||
svc.broadcast_identity().await?;
|
||||
info!("Broadcast identity over mesh");
|
||||
|
||||
Ok(serde_json::json!({ "broadcast": true }))
|
||||
}
|
||||
|
||||
/// mesh.configure — Enable/disable mesh and set device path.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_configure(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
|
||||
let mut config = mesh::load_config(&self.config.data_dir).await?;
|
||||
|
||||
if let Some(enabled) = params.get("enabled").and_then(|v| v.as_bool()) {
|
||||
config.enabled = enabled;
|
||||
}
|
||||
if let Some(device) = params.get("device_path").and_then(|v| v.as_str()) {
|
||||
config.device_path = Some(device.to_string());
|
||||
}
|
||||
if let Some(channel) = params.get("channel_name").and_then(|v| v.as_str()) {
|
||||
config.channel_name = Some(channel.to_string());
|
||||
}
|
||||
if let Some(broadcast) = params.get("broadcast_identity").and_then(|v| v.as_bool()) {
|
||||
config.broadcast_identity = broadcast;
|
||||
}
|
||||
if let Some(name) = params.get("advert_name").and_then(|v| v.as_str()) {
|
||||
config.advert_name = Some(name.to_string());
|
||||
}
|
||||
|
||||
mesh::save_config(&self.config.data_dir, &config).await?;
|
||||
|
||||
// If we have a running service, update its config
|
||||
let mut service = self.mesh_service.write().await;
|
||||
if let Some(svc) = service.as_mut() {
|
||||
svc.configure(config.clone()).await?;
|
||||
}
|
||||
|
||||
info!("Mesh config updated");
|
||||
Ok(serde_json::json!({
|
||||
"configured": true,
|
||||
"enabled": config.enabled,
|
||||
"device_path": config.device_path,
|
||||
}))
|
||||
}
|
||||
}
|
||||
5
core/archipelago/src/api/rpc/mesh/mod.rs
Normal file
5
core/archipelago/src/api/rpc/mesh/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
mod bitcoin_ops;
|
||||
mod messaging;
|
||||
mod safety;
|
||||
mod status;
|
||||
mod typed_messages;
|
||||
222
core/archipelago/src/api/rpc/mesh/safety.rs
Normal file
222
core/archipelago/src/api/rpc/mesh/safety.rs
Normal file
@@ -0,0 +1,222 @@
|
||||
use super::super::RpcHandler;
|
||||
use crate::mesh;
|
||||
use crate::mesh::message_types::Coordinate;
|
||||
use anyhow::Result;
|
||||
use tracing::info;
|
||||
|
||||
impl RpcHandler {
|
||||
/// mesh.outbox — List pending store-and-forward messages.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_outbox(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let limit = params
|
||||
.as_ref()
|
||||
.and_then(|p| p["limit"].as_u64())
|
||||
.map(|n| n as usize);
|
||||
|
||||
// Check if outbox file exists
|
||||
let outbox = mesh::outbox::MeshOutbox::load(&self.config.data_dir).await?;
|
||||
let messages = outbox.list(limit).await;
|
||||
let count = outbox.count().await;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"messages": messages.iter().map(|m| serde_json::json!({
|
||||
"id": m.id,
|
||||
"dest_did": m.dest_did,
|
||||
"from_did": m.from_did,
|
||||
"created_at": m.created_at,
|
||||
"ttl_secs": m.ttl_secs,
|
||||
"retry_count": m.retry_count,
|
||||
"relay_hops": m.relay_hops,
|
||||
"expired": m.is_expired(),
|
||||
})).collect::<Vec<_>>(),
|
||||
"count": count,
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.deadman-status — Get dead man's switch status.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_deadman_status(&self) -> Result<serde_json::Value> {
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
let status = svc.dead_man_switch.status().await;
|
||||
Ok(serde_json::to_value(status)?)
|
||||
}
|
||||
|
||||
/// mesh.deadman-configure — Configure the dead man's switch.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_deadman_configure(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
let mut config = svc.dead_man_switch.get_config().await;
|
||||
|
||||
if let Some(enabled) = params.get("enabled").and_then(|v| v.as_bool()) {
|
||||
config.dead_man_enabled = enabled;
|
||||
}
|
||||
if let Some(interval) = params.get("interval_secs").and_then(|v| v.as_u64()) {
|
||||
if interval < 60 {
|
||||
anyhow::bail!("Interval must be at least 60 seconds");
|
||||
}
|
||||
config.dead_man_interval_secs = interval;
|
||||
}
|
||||
if let (Some(lat), Some(lng)) = (
|
||||
params.get("lat").and_then(|v| v.as_f64()),
|
||||
params.get("lng").and_then(|v| v.as_f64()),
|
||||
) {
|
||||
let label = params.get("label").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
config.last_gps = Some(Coordinate::from_degrees(lat, lng, label));
|
||||
}
|
||||
if let Some(contacts) = params.get("contacts").and_then(|v| v.as_array()) {
|
||||
config.emergency_contacts = contacts
|
||||
.iter()
|
||||
.filter_map(|c| c.as_str().map(|s| s.to_string()))
|
||||
.collect();
|
||||
}
|
||||
if let Some(msg) = params.get("custom_message").and_then(|v| v.as_str()) {
|
||||
config.custom_message = Some(msg.to_string());
|
||||
}
|
||||
if let Some(auto_gps) = params.get("auto_gps").and_then(|v| v.as_bool()) {
|
||||
config.auto_include_gps = auto_gps;
|
||||
}
|
||||
|
||||
svc.dead_man_switch.configure(config).await?;
|
||||
// Reset timer on configure
|
||||
svc.dead_man_switch.check_in().await;
|
||||
|
||||
let status = svc.dead_man_switch.status().await;
|
||||
info!("Dead man's switch configured");
|
||||
Ok(serde_json::to_value(status)?)
|
||||
}
|
||||
|
||||
/// mesh.deadman-checkin — Heartbeat to reset the dead man's switch timer.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_deadman_checkin(&self) -> Result<serde_json::Value> {
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
svc.dead_man_check_in().await;
|
||||
let remaining = svc.dead_man_switch.time_remaining_secs().await;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"checked_in": true,
|
||||
"time_remaining_secs": remaining,
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.rotate-prekeys — Force prekey rotation for X3DH.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_rotate_prekeys(&self) -> Result<serde_json::Value> {
|
||||
// Load identity signing key
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let node_key_path = identity_dir.join("node_key");
|
||||
let key_bytes = tokio::fs::read(&node_key_path)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("Node identity not found"))?;
|
||||
if key_bytes.len() != 32 {
|
||||
anyhow::bail!("Invalid node key");
|
||||
}
|
||||
let mut seed = [0u8; 32];
|
||||
seed.copy_from_slice(&key_bytes);
|
||||
let signing_key = ed25519_dalek::SigningKey::from_bytes(&seed);
|
||||
|
||||
// Generate new prekey bundle
|
||||
let (bundle, _secrets) = mesh::x3dh::generate_prekey_bundle(&signing_key, 10)?;
|
||||
|
||||
// Save bundle for distribution
|
||||
let bundle_bytes = mesh::x3dh::encode_bundle(&bundle)?;
|
||||
let prekey_dir = self.config.data_dir.join("prekeys");
|
||||
tokio::fs::create_dir_all(&prekey_dir).await?;
|
||||
tokio::fs::write(prekey_dir.join("bundle.cbor"), &bundle_bytes).await?;
|
||||
|
||||
info!(
|
||||
one_time_keys = bundle.one_time_prekeys.len(),
|
||||
"Prekey bundle rotated"
|
||||
);
|
||||
Ok(serde_json::json!({
|
||||
"rotated": true,
|
||||
"signed_prekey_id": bundle.signed_prekey.id,
|
||||
"one_time_prekeys": bundle.one_time_prekeys.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.test-send — Send test payloads of various sizes to diagnose radio link.
|
||||
/// Sends plain text markers that the receiver can count.
|
||||
pub(in crate::api::rpc) 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,
|
||||
}))
|
||||
}
|
||||
}
|
||||
121
core/archipelago/src/api/rpc/mesh/status.rs
Normal file
121
core/archipelago/src/api/rpc/mesh/status.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
use super::super::RpcHandler;
|
||||
use crate::mesh;
|
||||
use anyhow::Result;
|
||||
|
||||
impl RpcHandler {
|
||||
/// mesh.status — Get mesh radio status, device info, and peer count.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_status(&self) -> Result<serde_json::Value> {
|
||||
let service = self.mesh_service.read().await;
|
||||
if let Some(svc) = service.as_ref() {
|
||||
let status = svc.status().await;
|
||||
Ok(serde_json::to_value(status)?)
|
||||
} else {
|
||||
// No service running — return basic config + device detection
|
||||
let config = mesh::load_config(&self.config.data_dir).await?;
|
||||
let devices = mesh::detect_devices().await;
|
||||
Ok(serde_json::json!({
|
||||
"enabled": config.enabled,
|
||||
"device_connected": false,
|
||||
"device_type": "unknown",
|
||||
"device_path": config.device_path,
|
||||
"channel_name": config.channel_name.unwrap_or_else(|| "archipelago".to_string()),
|
||||
"detected_devices": devices,
|
||||
"peer_count": 0,
|
||||
"messages_sent": 0,
|
||||
"messages_received": 0,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// mesh.peers — List discovered mesh peers.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_peers(&self) -> Result<serde_json::Value> {
|
||||
let service = self.mesh_service.read().await;
|
||||
if let Some(svc) = service.as_ref() {
|
||||
let peers = svc.peers().await;
|
||||
Ok(serde_json::json!({
|
||||
"peers": peers,
|
||||
"count": peers.len(),
|
||||
}))
|
||||
} else {
|
||||
Ok(serde_json::json!({
|
||||
"peers": [],
|
||||
"count": 0,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// mesh.messages — Get recent mesh message history.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_messages(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let limit = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("limit"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|n| n as usize);
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
if let Some(svc) = service.as_ref() {
|
||||
let messages = svc.messages(limit).await;
|
||||
Ok(serde_json::json!({
|
||||
"messages": messages,
|
||||
"count": messages.len(),
|
||||
}))
|
||||
} else {
|
||||
Ok(serde_json::json!({
|
||||
"messages": [],
|
||||
"count": 0,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// mesh.session-status — Get ratchet session info for a peer.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_session_status(
|
||||
&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;
|
||||
|
||||
// Look up peer DID from mesh service
|
||||
let service = self.mesh_service.read().await;
|
||||
let peer_did = if let Some(svc) = service.as_ref() {
|
||||
let peers = svc.peers().await;
|
||||
peers.iter().find(|p| p.contact_id == contact_id).and_then(|p| p.did.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(did) = peer_did {
|
||||
let session_mgr = mesh::session::SessionManager::new(&self.config.data_dir);
|
||||
if let Some(info) = session_mgr.session_info(&did).await {
|
||||
Ok(serde_json::json!({
|
||||
"has_session": info.has_session,
|
||||
"forward_secrecy": info.forward_secrecy,
|
||||
"message_count": info.message_count,
|
||||
"ratchet_generation": info.ratchet_generation,
|
||||
"peer_did": did,
|
||||
}))
|
||||
} else {
|
||||
Ok(serde_json::json!({
|
||||
"has_session": false,
|
||||
"forward_secrecy": false,
|
||||
"message_count": 0,
|
||||
"ratchet_generation": 0,
|
||||
"peer_did": did,
|
||||
}))
|
||||
}
|
||||
} else {
|
||||
Ok(serde_json::json!({
|
||||
"has_session": false,
|
||||
"forward_secrecy": false,
|
||||
"message_count": 0,
|
||||
"ratchet_generation": 0,
|
||||
"peer_did": null,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
174
core/archipelago/src/api/rpc/mesh/typed_messages.rs
Normal file
174
core/archipelago/src/api/rpc/mesh/typed_messages.rs
Normal file
@@ -0,0 +1,174 @@
|
||||
use super::super::RpcHandler;
|
||||
use crate::mesh::message_types::{
|
||||
self, AlertPayload, AlertType, Coordinate, InvoicePayload, MeshMessageType, TypedEnvelope,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use tracing::info;
|
||||
|
||||
impl RpcHandler {
|
||||
/// mesh.send-invoice — Create a Lightning invoice and send bolt11 to mesh peer.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_send_invoice(
|
||||
&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;
|
||||
let amount_sats = params["amount_sats"]
|
||||
.as_u64()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing amount_sats"))?;
|
||||
let memo = params["memo"].as_str().map(|s| s.to_string());
|
||||
|
||||
// Build invoice payload
|
||||
let invoice = InvoicePayload {
|
||||
bolt11: format!("lnbc{}n1pjmesh...", amount_sats), // Placeholder — real LND call in Phase 4
|
||||
amount_sats,
|
||||
memo: memo.clone(),
|
||||
payment_hash: None,
|
||||
};
|
||||
|
||||
let payload = message_types::encode_payload(&invoice)?;
|
||||
let envelope = TypedEnvelope::new(MeshMessageType::Invoice, payload);
|
||||
let wire = envelope.to_wire()?;
|
||||
|
||||
// Send via mesh
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
let wire_str = String::from_utf8_lossy(&wire).to_string();
|
||||
let msg = svc.send_message(contact_id, &wire_str).await?;
|
||||
|
||||
info!(contact_id, amount_sats, "Sent invoice over mesh");
|
||||
Ok(serde_json::json!({
|
||||
"sent": true,
|
||||
"message_id": msg.id,
|
||||
"amount_sats": amount_sats,
|
||||
"bolt11": invoice.bolt11,
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.send-coordinate — Send GPS coordinates to a mesh peer.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_send_coordinate(
|
||||
&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;
|
||||
let lat = params["lat"]
|
||||
.as_f64()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing lat"))?;
|
||||
let lng = params["lng"]
|
||||
.as_f64()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing lng"))?;
|
||||
let label = params["label"].as_str().map(|s| s.to_string());
|
||||
|
||||
let coord = Coordinate::from_degrees(lat, lng, label);
|
||||
let payload = message_types::encode_payload(&coord)?;
|
||||
let envelope = TypedEnvelope::new(MeshMessageType::Coordinate, payload);
|
||||
let wire = envelope.to_wire()?;
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
let wire_str = String::from_utf8_lossy(&wire).to_string();
|
||||
let msg = svc.send_message(contact_id, &wire_str).await?;
|
||||
|
||||
info!(contact_id, "Sent coordinate over mesh");
|
||||
Ok(serde_json::json!({
|
||||
"sent": true,
|
||||
"message_id": msg.id,
|
||||
"lat": coord.lat,
|
||||
"lng": coord.lng,
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.send-alert — Send a signed emergency alert over mesh.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_send_alert(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let message = params["message"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing message"))?;
|
||||
let alert_type_str = params["alert_type"]
|
||||
.as_str()
|
||||
.unwrap_or("status");
|
||||
let broadcast = params["broadcast"].as_bool().unwrap_or(false);
|
||||
|
||||
let alert_type = match alert_type_str {
|
||||
"emergency" => AlertType::Emergency,
|
||||
"dead_man" => AlertType::DeadMan,
|
||||
_ => AlertType::Status,
|
||||
};
|
||||
|
||||
// Optional GPS
|
||||
let coordinate = if let (Some(lat), Some(lng)) = (
|
||||
params["lat"].as_f64(),
|
||||
params["lng"].as_f64(),
|
||||
) {
|
||||
Some(Coordinate::from_degrees(lat, lng, None))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let alert = AlertPayload {
|
||||
alert_type,
|
||||
message: message.to_string(),
|
||||
coordinate,
|
||||
};
|
||||
|
||||
let payload = message_types::encode_payload(&alert)?;
|
||||
|
||||
// Sign the alert with node identity
|
||||
let (_data, _) = self.state_manager.get_snapshot().await;
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let node_key_path = identity_dir.join("node_key");
|
||||
|
||||
let envelope = if node_key_path.exists() {
|
||||
let key_bytes = tokio::fs::read(&node_key_path).await?;
|
||||
if key_bytes.len() == 32 {
|
||||
let mut seed = [0u8; 32];
|
||||
seed.copy_from_slice(&key_bytes);
|
||||
let signing_key = ed25519_dalek::SigningKey::from_bytes(&seed);
|
||||
TypedEnvelope::new_signed(MeshMessageType::Alert, payload, &signing_key)
|
||||
} else {
|
||||
TypedEnvelope::new(MeshMessageType::Alert, payload)
|
||||
}
|
||||
} else {
|
||||
TypedEnvelope::new(MeshMessageType::Alert, payload)
|
||||
};
|
||||
|
||||
let wire = envelope.to_wire()?;
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
let wire_str = String::from_utf8_lossy(&wire).to_string();
|
||||
if broadcast {
|
||||
// Send on channel (all peers)
|
||||
svc.send_message(0, &wire_str).await?;
|
||||
info!(alert_type = alert_type_str, "Broadcast alert over mesh");
|
||||
} else if let Some(contact_id) = params["contact_id"].as_u64() {
|
||||
svc.send_message(contact_id as u32, &wire_str).await?;
|
||||
info!(contact_id, alert_type = alert_type_str, "Sent alert to peer");
|
||||
} else {
|
||||
anyhow::bail!("Must specify contact_id or broadcast: true");
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"sent": true,
|
||||
"alert_type": alert_type_str,
|
||||
"signed": envelope.sig.is_some(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
116
core/archipelago/src/api/rpc/middleware.rs
Normal file
116
core/archipelago/src/api/rpc/middleware.rs
Normal file
@@ -0,0 +1,116 @@
|
||||
use crate::session::SessionStore;
|
||||
use std::net::IpAddr;
|
||||
|
||||
/// Methods that do not require a valid session cookie.
|
||||
pub(super) const UNAUTHENTICATED_METHODS: &[&str] = &[
|
||||
"auth.login",
|
||||
"auth.login.totp",
|
||||
"auth.login.backup",
|
||||
"auth.isOnboardingComplete",
|
||||
"auth.isSetup",
|
||||
"auth.setup",
|
||||
"auth.onboardingComplete",
|
||||
"health",
|
||||
// Onboarding flow (before user has a session — DID creation, signing, backup)
|
||||
"node.did",
|
||||
"node.signChallenge",
|
||||
"node.nostr-pubkey",
|
||||
"node.createBackup",
|
||||
"identity.verify",
|
||||
"identity.resolve-did",
|
||||
// Onboarding restore (before user account exists)
|
||||
"backup.restore-identity",
|
||||
// Inter-node RPC: called by federated peers over Tor, no session cookies
|
||||
"federation.peer-joined",
|
||||
"federation.peer-address-changed",
|
||||
"federation.peer-did-changed",
|
||||
"federation.get-state",
|
||||
// Fleet telemetry ingest: called by remote nodes posting reports
|
||||
"telemetry.ingest",
|
||||
];
|
||||
|
||||
/// Methods whose responses can be cached for a few seconds.
|
||||
pub(super) const CACHEABLE_METHODS: &[&str] = &[
|
||||
"system.stats",
|
||||
"federation.list-nodes",
|
||||
];
|
||||
|
||||
/// Sanitize error messages before returning to clients.
|
||||
/// Keeps user-facing validation errors but strips internal system details.
|
||||
pub(super) fn sanitize_error_message(msg: &str) -> String {
|
||||
// Allow known validation errors through (these are user-actionable)
|
||||
let user_facing_prefixes = [
|
||||
"Invalid",
|
||||
"Missing",
|
||||
"Not found",
|
||||
"Already exists",
|
||||
"Rate limit",
|
||||
"Unauthorized",
|
||||
"Forbidden",
|
||||
"Not supported",
|
||||
"requires",
|
||||
"must be",
|
||||
"cannot",
|
||||
"Password",
|
||||
"Session",
|
||||
];
|
||||
for prefix in &user_facing_prefixes {
|
||||
if msg.starts_with(prefix) {
|
||||
// Truncate long messages and strip file paths
|
||||
let sanitized = msg.replace("/var/lib/archipelago/", "[data]/")
|
||||
.replace("/usr/local/bin/", "[bin]/")
|
||||
.replace("/etc/", "[config]/");
|
||||
return if sanitized.len() > 200 {
|
||||
format!("{}...", &sanitized[..200])
|
||||
} else {
|
||||
sanitized
|
||||
};
|
||||
}
|
||||
}
|
||||
// For all other errors, return a generic message
|
||||
"Operation failed. Check server logs for details.".to_string()
|
||||
}
|
||||
|
||||
/// Derive a CSRF token from the session token via HMAC.
|
||||
/// Deterministic: same session token always produces the same CSRF token.
|
||||
/// Survives backend restarts because it depends only on the session token
|
||||
/// and the on-disk remember secret (not ephemeral state).
|
||||
pub(super) async fn derive_csrf_token(session_token: &str) -> String {
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
let secret = SessionStore::load_or_create_remember_secret().await;
|
||||
let mut mac = HmacSha256::new_from_slice(&secret).expect("HMAC key");
|
||||
mac.update(format!("csrf:{}", session_token).as_bytes());
|
||||
hex::encode(mac.finalize().into_bytes())
|
||||
}
|
||||
|
||||
/// Extract a named cookie value from headers.
|
||||
pub(super) fn extract_cookie(headers: &hyper::HeaderMap, name: &str) -> Option<String> {
|
||||
let prefix = format!("{}=", name);
|
||||
for value in headers.get_all("cookie") {
|
||||
if let Ok(s) = value.to_str() {
|
||||
for part in s.split(';') {
|
||||
let part = part.trim();
|
||||
if let Some(val) = part.strip_prefix(&prefix) {
|
||||
let val = val.trim();
|
||||
if !val.is_empty() {
|
||||
return Some(val.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract the client IP from request headers (X-Real-IP or X-Forwarded-For).
|
||||
pub(super) fn extract_client_ip(headers: &hyper::HeaderMap) -> IpAddr {
|
||||
headers
|
||||
.get("x-real-ip")
|
||||
.or_else(|| headers.get("x-forwarded-for"))
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.split(',').next())
|
||||
.and_then(|s| s.trim().parse::<IpAddr>().ok())
|
||||
.unwrap_or(IpAddr::V4(std::net::Ipv4Addr::LOCALHOST))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -51,7 +51,7 @@ impl RpcHandler {
|
||||
/// Get the current node visibility setting.
|
||||
pub(super) async fn handle_network_get_visibility(&self) -> Result<serde_json::Value> {
|
||||
let vis = self.load_visibility().await;
|
||||
let tor_address = docker_packages::read_tor_address("archipelago");
|
||||
let tor_address = docker_packages::read_tor_address("archipelago").await;
|
||||
Ok(serde_json::json!({
|
||||
"visibility": vis.as_str(),
|
||||
"tor_address": tor_address,
|
||||
@@ -106,7 +106,7 @@ impl RpcHandler {
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let my_pubkey = &data.server_info.pubkey;
|
||||
let my_did = identity::did_key_from_pubkey_hex(my_pubkey)?;
|
||||
let my_onion = docker_packages::read_tor_address("archipelago")
|
||||
let my_onion = docker_packages::read_tor_address("archipelago").await
|
||||
.unwrap_or_default();
|
||||
|
||||
let req_msg = serde_json::json!({
|
||||
@@ -121,6 +121,8 @@ impl RpcHandler {
|
||||
to_onion,
|
||||
my_pubkey,
|
||||
&req_msg.to_string(),
|
||||
None,
|
||||
None,
|
||||
).await?;
|
||||
|
||||
// Also add them as a pending peer locally
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
use super::RpcHandler;
|
||||
use crate::{backup, identity, nostr_discovery};
|
||||
use crate::container::docker_packages;
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context, Result};
|
||||
use ed25519_dalek::SigningKey;
|
||||
use nostr_sdk::ToBech32;
|
||||
use rand::rngs::OsRng;
|
||||
use tokio::fs;
|
||||
|
||||
impl RpcHandler {
|
||||
pub(super) async fn handle_node_did(&self) -> Result<serde_json::Value> {
|
||||
@@ -68,7 +71,7 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
pub(super) async fn handle_node_tor_address(&self) -> Result<serde_json::Value> {
|
||||
let tor_address = docker_packages::read_tor_address("archipelago");
|
||||
let tor_address = docker_packages::read_tor_address("archipelago").await;
|
||||
Ok(serde_json::json!({ "tor_address": tor_address }))
|
||||
}
|
||||
|
||||
@@ -145,4 +148,81 @@ impl RpcHandler {
|
||||
"error": status.error,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Rotate the node's Ed25519 identity keypair.
|
||||
/// Requires password re-verification. Returns a signed proof that peers can
|
||||
/// use to verify the rotation was authorized by the holder of the old key.
|
||||
pub(super) async fn handle_node_rotate_did(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let password = params
|
||||
.get("password")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'password' parameter"))?;
|
||||
|
||||
// Re-verify password before allowing key rotation
|
||||
if !self.auth_manager.verify_password(password).await? {
|
||||
anyhow::bail!("Password verification failed");
|
||||
}
|
||||
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
|
||||
// Load the current identity to get old DID and signing key
|
||||
let old_identity = identity::NodeIdentity::load_or_create(&identity_dir).await?;
|
||||
let old_pubkey_hex = old_identity.pubkey_hex();
|
||||
let old_did = identity::did_key_from_pubkey_hex(&old_pubkey_hex)?;
|
||||
|
||||
// Generate a new Ed25519 keypair
|
||||
let new_signing_key = SigningKey::generate(&mut OsRng);
|
||||
let new_pubkey_hex = hex::encode(new_signing_key.verifying_key().as_bytes());
|
||||
let new_did = identity::did_key_from_pubkey_hex(&new_pubkey_hex)?;
|
||||
|
||||
// Create a rotation proof signed by the OLD key:
|
||||
// "did-rotate:{old_did}:{new_did}:{timestamp}"
|
||||
let timestamp = chrono::Utc::now().to_rfc3339();
|
||||
let proof_message = format!("did-rotate:{}:{}:{}", old_did, new_did, timestamp);
|
||||
let proof_signature = old_identity.sign(proof_message.as_bytes());
|
||||
|
||||
// Write the new key files, overwriting the old ones
|
||||
let key_path = identity_dir.join("node_key");
|
||||
let pub_path = identity_dir.join("node_key.pub");
|
||||
|
||||
fs::write(&key_path, new_signing_key.to_bytes())
|
||||
.await
|
||||
.context("Failed to write new node key")?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600))
|
||||
.await
|
||||
.context("Failed to set key permissions")?;
|
||||
}
|
||||
|
||||
fs::write(&pub_path, new_signing_key.verifying_key().as_bytes())
|
||||
.await
|
||||
.context("Failed to write new node public key")?;
|
||||
|
||||
// Update in-memory state so the new pubkey is reflected immediately
|
||||
let (mut data, _) = self.state_manager.get_snapshot().await;
|
||||
data.server_info.pubkey = new_pubkey_hex.clone();
|
||||
self.state_manager.update_data(data).await;
|
||||
|
||||
tracing::info!(
|
||||
old_did = %old_did,
|
||||
new_did = %new_did,
|
||||
"Node DID rotated successfully"
|
||||
);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"old_did": old_did,
|
||||
"new_did": new_did,
|
||||
"new_pubkey": new_pubkey_hex,
|
||||
"proof_signature": proof_signature,
|
||||
"proof_message": proof_message,
|
||||
"timestamp": timestamp,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
692
core/archipelago/src/api/rpc/package/config.rs
Normal file
692
core/archipelago/src/api/rpc/package/config.rs
Normal file
@@ -0,0 +1,692 @@
|
||||
use super::validation::validate_app_id;
|
||||
use crate::port_allocator::PortAllocator;
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
/// Trusted Docker registries. Only images from these sources are allowed.
|
||||
#[allow(dead_code)]
|
||||
pub(super) const TRUSTED_REGISTRIES: &[&str] = &["docker.io/", "ghcr.io/", "localhost/", "80.71.235.15:3000/"];
|
||||
|
||||
/// Detect which Bitcoin container is running on archy-net for DNS resolution.
|
||||
/// Returns the container name to use as the RPC host (e.g., "bitcoin-knots").
|
||||
pub(super) fn detect_bitcoin_container_name() -> String {
|
||||
// Synchronous check — called from get_app_config which is sync
|
||||
let output = std::process::Command::new("podman")
|
||||
.args(["ps", "--format", "{{.Names}}"])
|
||||
.output();
|
||||
if let Ok(out) = output {
|
||||
let names = String::from_utf8_lossy(&out.stdout);
|
||||
for candidate in &["bitcoin-knots", "bitcoin-core", "bitcoin"] {
|
||||
if names.lines().any(|l| l.trim() == *candidate) {
|
||||
return candidate.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
// Default to bitcoin-knots (most common)
|
||||
"bitcoin-knots".to_string()
|
||||
}
|
||||
|
||||
/// Validate Docker image against trusted registry allowlist.
|
||||
pub(super) fn is_valid_docker_image(image: &str) -> bool {
|
||||
if image.is_empty() || image.len() > 256 {
|
||||
return false;
|
||||
}
|
||||
// Reject shell metacharacters
|
||||
let dangerous_chars = ['&', '|', ';', '`', '$', '(', ')', '<', '>', '\n', '\r'];
|
||||
if image.chars().any(|c| dangerous_chars.contains(&c)) {
|
||||
return false;
|
||||
}
|
||||
// Must come from a trusted registry — match the exact domain, not just prefix
|
||||
let registry = match image.split('/').next() {
|
||||
Some(r) => r,
|
||||
None => return false,
|
||||
};
|
||||
matches!(registry, "docker.io" | "ghcr.io" | "localhost" | "80.71.235.15:3000")
|
||||
}
|
||||
|
||||
/// Per-app Linux capabilities needed beyond the default cap-drop=ALL.
|
||||
/// Most apps need CHOWN/SETUID/SETGID for internal user switching.
|
||||
pub(super) fn get_app_capabilities(app_id: &str) -> Vec<String> {
|
||||
match app_id {
|
||||
// Apps that need user switching and file ownership changes
|
||||
"nextcloud" | "homeassistant" | "home-assistant" | "btcpay-server" | "btcpayserver"
|
||||
| "jellyfin" | "onlyoffice" | "onlyoffice-documentserver" | "portainer" => vec![
|
||||
"--cap-add=CHOWN".to_string(),
|
||||
"--cap-add=SETUID".to_string(),
|
||||
"--cap-add=SETGID".to_string(),
|
||||
"--cap-add=DAC_OVERRIDE".to_string(),
|
||||
],
|
||||
// Nginx Proxy Manager needs to bind low ports
|
||||
"nginx-proxy-manager" => vec![
|
||||
"--cap-add=CHOWN".to_string(),
|
||||
"--cap-add=SETUID".to_string(),
|
||||
"--cap-add=SETGID".to_string(),
|
||||
"--cap-add=NET_BIND_SERVICE".to_string(),
|
||||
],
|
||||
// Bitcoin and Lightning need file ownership ops + DAC_OVERRIDE for data dir access
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" | "lnd" | "fedimint"
|
||||
| "fedimint-gateway" => vec![
|
||||
"--cap-add=CHOWN".to_string(),
|
||||
"--cap-add=FOWNER".to_string(),
|
||||
"--cap-add=SETUID".to_string(),
|
||||
"--cap-add=SETGID".to_string(),
|
||||
"--cap-add=DAC_OVERRIDE".to_string(),
|
||||
],
|
||||
// Vaultwarden needs file ownership + NET_BIND_SERVICE (binds port 80 internally)
|
||||
"vaultwarden" => vec![
|
||||
"--cap-add=CHOWN".to_string(),
|
||||
"--cap-add=SETUID".to_string(),
|
||||
"--cap-add=SETGID".to_string(),
|
||||
"--cap-add=NET_BIND_SERVICE".to_string(),
|
||||
],
|
||||
// PhotoPrism uses s6-overlay which needs privilege ops
|
||||
"photoprism" => vec![
|
||||
"--cap-add=CHOWN".to_string(),
|
||||
"--cap-add=SETUID".to_string(),
|
||||
"--cap-add=SETGID".to_string(),
|
||||
],
|
||||
// Grafana runs as specific UID (472)
|
||||
"grafana" => vec![
|
||||
"--cap-add=CHOWN".to_string(),
|
||||
"--cap-add=SETUID".to_string(),
|
||||
"--cap-add=SETGID".to_string(),
|
||||
],
|
||||
// Uptime-kuma startup script needs chown/fowner for /app/data ownership
|
||||
"uptime-kuma" => vec![
|
||||
"--cap-add=CHOWN".to_string(),
|
||||
"--cap-add=FOWNER".to_string(),
|
||||
"--cap-add=SETUID".to_string(),
|
||||
"--cap-add=SETGID".to_string(),
|
||||
],
|
||||
// Minimal apps (searxng, filebrowser, etc.) need no extra caps
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Apps safe to run with --read-only root filesystem.
|
||||
/// These work correctly with volume mounts + tmpfs for /tmp and /run.
|
||||
pub(super) fn is_readonly_compatible(app_id: &str) -> bool {
|
||||
matches!(
|
||||
app_id,
|
||||
"searxng"
|
||||
| "grafana"
|
||||
| "filebrowser"
|
||||
| "electrumx"
|
||||
| "mempool-electrs"
|
||||
| "electrs"
|
||||
| "nostr-rs-relay"
|
||||
| "ollama"
|
||||
| "indeedhub"
|
||||
)
|
||||
}
|
||||
|
||||
/// Get container health check arguments for podman run.
|
||||
/// Returns (health-cmd, interval, retries) args to append to run_args.
|
||||
pub(super) fn get_health_check_args(app_id: &str, rpc_pass: &str) -> Vec<String> {
|
||||
let btc_health = format!(
|
||||
"bitcoin-cli -rpcuser=archipelago -rpcpassword={} getblockchaininfo || exit 1",
|
||||
rpc_pass
|
||||
);
|
||||
let (cmd, interval, retries) = match app_id {
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => (btc_health.as_str(), "30s", "3"),
|
||||
"lnd" => ("lncli getinfo || exit 1", "30s", "3"),
|
||||
"btcpay-server" | "btcpayserver" => {
|
||||
("curl -sf http://localhost:49392/ || exit 1", "30s", "3")
|
||||
}
|
||||
"mempool-api" => (
|
||||
"curl -sf http://localhost:8999/api/v1/backend-info || exit 1",
|
||||
"30s",
|
||||
"3",
|
||||
),
|
||||
"mempool" | "mempool-web" | "archy-mempool-web" => {
|
||||
("curl -sf http://localhost:8080/ || exit 1", "30s", "3")
|
||||
}
|
||||
"electrumx" | "mempool-electrs" | "electrs" => {
|
||||
("curl -sf http://localhost:8000/ || exit 1", "60s", "3")
|
||||
}
|
||||
"nextcloud" => (
|
||||
"curl -sf http://localhost:80/status.php || exit 1",
|
||||
"30s",
|
||||
"3",
|
||||
),
|
||||
"homeassistant" | "home-assistant" => (
|
||||
"curl -sf http://localhost:8123/api/ || exit 1",
|
||||
"30s",
|
||||
"3",
|
||||
),
|
||||
"grafana" => (
|
||||
"curl -sf http://localhost:3000/api/health || exit 1",
|
||||
"30s",
|
||||
"3",
|
||||
),
|
||||
"jellyfin" => (
|
||||
"curl -sf http://localhost:8096/health || exit 1",
|
||||
"30s",
|
||||
"3",
|
||||
),
|
||||
"vaultwarden" => ("curl -sf http://localhost:80/alive || exit 1", "30s", "3"),
|
||||
"uptime-kuma" => ("curl -sf http://localhost:3001/ || exit 1", "30s", "3"),
|
||||
"filebrowser" => (
|
||||
"curl -sf http://localhost:80/health || exit 1",
|
||||
"30s",
|
||||
"3",
|
||||
),
|
||||
"searxng" => ("curl -sf http://localhost:8080/ || exit 1", "30s", "3"),
|
||||
"photoprism" => (
|
||||
"curl -sf http://localhost:2342/api/v1/status || exit 1",
|
||||
"60s",
|
||||
"3",
|
||||
),
|
||||
"immich_server" | "immich" => (
|
||||
"curl -sf http://localhost:2283/api/server/ping || exit 1",
|
||||
"30s",
|
||||
"3",
|
||||
),
|
||||
"dwn" => (
|
||||
"curl -sf http://localhost:3000/health || exit 1",
|
||||
"30s",
|
||||
"3",
|
||||
),
|
||||
"portainer" => (
|
||||
"curl -sf http://localhost:9000/api/status || exit 1",
|
||||
"30s",
|
||||
"3",
|
||||
),
|
||||
"ollama" => ("curl -sf http://localhost:11434/ || exit 1", "30s", "3"),
|
||||
"fedimint" => (
|
||||
"curl -sf http://localhost:8174/health || exit 1",
|
||||
"60s",
|
||||
"3",
|
||||
),
|
||||
"nostr-rs-relay" | "nostr-relay" => {
|
||||
("curl -sf http://localhost:8080/ || exit 1", "30s", "3")
|
||||
}
|
||||
"nginx-proxy-manager" => (
|
||||
"curl -sf http://localhost:81/api/ || exit 1",
|
||||
"30s",
|
||||
"3",
|
||||
),
|
||||
_ => return vec![],
|
||||
};
|
||||
|
||||
vec![
|
||||
format!("--health-cmd={}", cmd),
|
||||
format!("--health-interval={}", interval),
|
||||
format!("--health-retries={}", retries),
|
||||
"--health-start-period=60s".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
/// Get per-app memory limit.
|
||||
pub(super) fn get_memory_limit(app_id: &str) -> &'static str {
|
||||
match app_id {
|
||||
// Heavy apps
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => "2g",
|
||||
"onlyoffice" | "onlyoffice-documentserver" => "2g",
|
||||
"ollama" => "4g",
|
||||
// Medium apps
|
||||
"lnd" => "512m",
|
||||
"electrumx" | "mempool-electrs" | "electrs" => "1g",
|
||||
"nextcloud" => "1g",
|
||||
"immich_server" | "immich" => "1g",
|
||||
"btcpay-server" | "btcpayserver" => "1g",
|
||||
"homeassistant" | "home-assistant" => "512m",
|
||||
"fedimint" => "512m",
|
||||
"fedimint-gateway" => "512m",
|
||||
"photoprism" => "1g",
|
||||
// Light apps
|
||||
"mempool-api" => "512m",
|
||||
"mempool" | "mempool-web" | "archy-mempool-web" => "256m",
|
||||
"grafana" => "256m",
|
||||
"jellyfin" => "1g",
|
||||
"vaultwarden" => "256m",
|
||||
"uptime-kuma" => "256m",
|
||||
"filebrowser" => "256m",
|
||||
"searxng" => "512m",
|
||||
"dwn" => "256m",
|
||||
"portainer" => "256m",
|
||||
"nostr-rs-relay" | "nostr-relay" => "256m",
|
||||
"nginx-proxy-manager" => "256m",
|
||||
// Databases
|
||||
"archy-btcpay-db" | "archy-mempool-db" | "mysql-mempool" => "512m",
|
||||
"immich_postgres" | "penpot-postgres" => "256m",
|
||||
"immich_redis" | "penpot-valkey" => "128m",
|
||||
// Default
|
||||
_ => "512m",
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all container names for an app (handles multi-container apps like mempool)
|
||||
pub(super) async fn get_containers_for_app(package_id: &str) -> Result<Vec<String>> {
|
||||
validate_app_id(package_id)?;
|
||||
let output = tokio::process::Command::new("podman")
|
||||
.args(["ps", "-a", "--format", "{{.Names}}"])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to list containers")?;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let all: Vec<&str> = stdout.lines().filter(|s| !s.is_empty()).collect();
|
||||
|
||||
let patterns: Vec<String> = match package_id {
|
||||
"mempool" | "mempool-web" => {
|
||||
vec![
|
||||
"electrumx".into(),
|
||||
"mempool-electrs".into(),
|
||||
"mempool-api".into(),
|
||||
"archy-mempool-api".into(),
|
||||
"archy-mempool-web".into(),
|
||||
"mempool".into(),
|
||||
"archy-mempool-db".into(),
|
||||
"mysql-mempool".into(),
|
||||
]
|
||||
}
|
||||
"fedimint" => vec![
|
||||
"fedimint".into(),
|
||||
"fedimint-ui".into(),
|
||||
"archy-fedimint".into(),
|
||||
"fedimint-gateway".into(),
|
||||
],
|
||||
"fedimint-gateway" => vec!["fedimint-gateway".into()],
|
||||
"immich" => vec![
|
||||
"immich_postgres".into(),
|
||||
"immich_redis".into(),
|
||||
"immich_server".into(),
|
||||
],
|
||||
"penpot" | "penpot-frontend" => vec![
|
||||
"penpot-postgres".into(),
|
||||
"penpot-valkey".into(),
|
||||
"penpot-backend".into(),
|
||||
"penpot-exporter".into(),
|
||||
"penpot-frontend".into(),
|
||||
],
|
||||
_ => vec![package_id.to_string(), format!("archy-{}", package_id)],
|
||||
};
|
||||
|
||||
let mut result = Vec::new();
|
||||
for name in all {
|
||||
for pat in &patterns {
|
||||
if name == pat {
|
||||
result.push(name.to_string());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Get data directories to clean for an app.
|
||||
/// Caller must validate package_id before calling.
|
||||
pub(super) fn get_data_dirs_for_app(package_id: &str) -> Vec<String> {
|
||||
let base = "/var/lib/archipelago";
|
||||
match package_id {
|
||||
"mempool" | "mempool-web" => vec![
|
||||
format!("{}/mempool", base),
|
||||
format!("{}/mysql-mempool", base),
|
||||
format!("{}/electrumx", base),
|
||||
format!("{}/mempool-electrs", base),
|
||||
],
|
||||
"fedimint" => vec![
|
||||
format!("{}/fedimint", base),
|
||||
format!("{}/fedimint-gateway", base),
|
||||
],
|
||||
"fedimint-gateway" => vec![format!("{}/fedimint-gateway", base)],
|
||||
"immich" => vec![
|
||||
format!("{}/immich", base),
|
||||
format!("{}/immich-db", base),
|
||||
],
|
||||
"penpot" | "penpot-frontend" => vec![
|
||||
format!("{}/penpot-assets", base),
|
||||
format!("{}/penpot-postgres", base),
|
||||
],
|
||||
_ => vec![format!("{}/{}", base, package_id)],
|
||||
}
|
||||
}
|
||||
|
||||
/// Get app-specific configuration
|
||||
/// Returns: (ports, volumes, env_vars, custom_command, custom_args)
|
||||
pub(super) async fn get_app_config(
|
||||
app_id: &str,
|
||||
host_ip: &str,
|
||||
allocator: &mut PortAllocator,
|
||||
rpc_user: &str,
|
||||
rpc_pass: &str,
|
||||
) -> (
|
||||
Vec<String>,
|
||||
Vec<String>,
|
||||
Vec<String>,
|
||||
Option<String>,
|
||||
Option<Vec<String>>,
|
||||
) {
|
||||
match app_id {
|
||||
"homeassistant" | "home-assistant" => (
|
||||
vec!["8123:8123".to_string()],
|
||||
vec!["/var/lib/archipelago/home-assistant:/config".to_string()],
|
||||
vec!["TZ=UTC".to_string()],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => (
|
||||
vec!["8332:8332".to_string(), "8333:8333".to_string()],
|
||||
vec!["/var/lib/archipelago/bitcoin:/home/bitcoin/.bitcoin".to_string()],
|
||||
vec![],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"lnd" => (
|
||||
vec![
|
||||
"9735:9735".to_string(),
|
||||
"10009:10009".to_string(),
|
||||
"8080:8080".to_string(),
|
||||
],
|
||||
vec!["/var/lib/archipelago/lnd:/root/.lnd".to_string()],
|
||||
vec!["BITCOIN_ACTIVE=1".to_string()],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"btcpay-server" | "btcpayserver" => (
|
||||
vec!["23000:49392".to_string()],
|
||||
vec!["/var/lib/archipelago/btcpay:/datadir".to_string()],
|
||||
vec![
|
||||
"ASPNETCORE_URLS=http://0.0.0.0:49392".to_string(),
|
||||
"BTCPAY_PROTOCOL=http".to_string(),
|
||||
format!("BTCPAY_HOST={}:23000", host_ip),
|
||||
"BTCPAY_CHAINS=btc".to_string(),
|
||||
format!("BTCPAY_BTCRPCURL=http://{}:8332", host_ip),
|
||||
format!("BTCPAY_BTCRPCUSER={}", rpc_user),
|
||||
format!("BTCPAY_BTCRPCPASSWORD={}", rpc_pass),
|
||||
"BTCPAY_POSTGRES=User ID=btcpay;Password=btcpaypass;Host=archy-btcpay-db;Port=5432;Database=btcpay;Include Error Detail=true".to_string(),
|
||||
],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"mempool" | "mempool-web" => (
|
||||
vec!["4080:8080".to_string()],
|
||||
vec![],
|
||||
vec![format!("BACKEND_MAINNET_HTTP_HOST={}", host_ip)],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"mempool-api" => (
|
||||
vec!["8999:8999".to_string()],
|
||||
vec!["/var/lib/archipelago/mempool:/data".to_string()],
|
||||
vec![
|
||||
"MEMPOOL_BACKEND=electrum".to_string(),
|
||||
"ELECTRUM_HOST=electrumx".to_string(),
|
||||
"ELECTRUM_PORT=50001".to_string(),
|
||||
"ELECTRUM_TLS_ENABLED=false".to_string(),
|
||||
format!("CORE_RPC_HOST={}", host_ip),
|
||||
"CORE_RPC_PORT=8332".to_string(),
|
||||
format!("CORE_RPC_USERNAME={}", rpc_user),
|
||||
format!("CORE_RPC_PASSWORD={}", rpc_pass),
|
||||
"DATABASE_ENABLED=true".to_string(),
|
||||
"DATABASE_HOST=archy-mempool-db".to_string(),
|
||||
"DATABASE_DATABASE=mempool".to_string(),
|
||||
"DATABASE_USERNAME=mempool".to_string(),
|
||||
"DATABASE_PASSWORD=mempoolpass".to_string(),
|
||||
],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"electrumx" | "mempool-electrs" | "electrs" => {
|
||||
// Detect which bitcoin container is running for archy-net DNS resolution
|
||||
let bitcoin_host = detect_bitcoin_container_name();
|
||||
(
|
||||
vec!["50001:50001".to_string()],
|
||||
vec!["/var/lib/archipelago/electrumx:/data".to_string()],
|
||||
vec![
|
||||
format!(
|
||||
"DAEMON_URL=http://{}:{}@{}:8332/",
|
||||
rpc_user, rpc_pass, bitcoin_host
|
||||
),
|
||||
"COIN=Bitcoin".to_string(),
|
||||
"DB_DIRECTORY=/data".to_string(),
|
||||
"SERVICES=tcp://:50001,rpc://0.0.0.0:8000".to_string(),
|
||||
],
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
"mysql-mempool" => (
|
||||
vec![],
|
||||
vec!["/var/lib/archipelago/mysql-mempool:/var/lib/mysql".to_string()],
|
||||
vec![
|
||||
"MYSQL_DATABASE=mempool".to_string(),
|
||||
"MYSQL_USER=mempool".to_string(),
|
||||
"MYSQL_PASSWORD=mempoolpass".to_string(),
|
||||
"MYSQL_ROOT_PASSWORD=rootpass".to_string(),
|
||||
],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"grafana" => (
|
||||
vec!["3000:3000".to_string()],
|
||||
vec!["/var/lib/archipelago/grafana:/var/lib/grafana".to_string()],
|
||||
vec![
|
||||
"GF_PATHS_DATA=/var/lib/grafana".to_string(),
|
||||
"GF_USERS_ALLOW_SIGN_UP=false".to_string(),
|
||||
],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"searxng" => (
|
||||
vec!["8888:8080".to_string()],
|
||||
vec![],
|
||||
vec![],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"ollama" => (
|
||||
vec!["11434:11434".to_string()],
|
||||
vec!["/var/lib/archipelago/ollama:/root/.ollama".to_string()],
|
||||
vec![],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"onlyoffice" | "onlyoffice-documentserver" => (
|
||||
vec!["9980:80".to_string()],
|
||||
vec![],
|
||||
vec![],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"penpot" | "penpot-frontend" => (
|
||||
vec!["9001:80".to_string()],
|
||||
vec![],
|
||||
vec![],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"nextcloud" => {
|
||||
let host_port = allocator
|
||||
.allocate_or_get(app_id, 8085, 80)
|
||||
.await
|
||||
.unwrap_or(8085);
|
||||
(
|
||||
vec![format!("{}:80", host_port)],
|
||||
vec!["/var/lib/archipelago/nextcloud:/var/www/html".to_string()],
|
||||
vec![],
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
"vaultwarden" => {
|
||||
let host_port = allocator
|
||||
.allocate_or_get(app_id, 8082, 80)
|
||||
.await
|
||||
.unwrap_or(8082);
|
||||
(
|
||||
vec![format!("{}:80", host_port)],
|
||||
vec!["/var/lib/archipelago/vaultwarden:/data".to_string()],
|
||||
vec![],
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
"jellyfin" => (
|
||||
vec!["8096:8096".to_string()],
|
||||
vec![
|
||||
"/var/lib/archipelago/jellyfin/config:/config".to_string(),
|
||||
"/var/lib/archipelago/jellyfin/cache:/cache".to_string(),
|
||||
],
|
||||
vec![],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"photoprism" => (
|
||||
vec!["2342:2342".to_string()],
|
||||
vec!["/var/lib/archipelago/photoprism:/photoprism/storage".to_string()],
|
||||
vec![
|
||||
"PHOTOPRISM_ADMIN_PASSWORD=archipelago".to_string(),
|
||||
"PHOTOPRISM_DEFAULT_LOCALE=en".to_string(),
|
||||
],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"immich" => (
|
||||
vec!["2283:2283".to_string()],
|
||||
vec!["/var/lib/archipelago/immich:/usr/src/app/upload".to_string()],
|
||||
vec![
|
||||
"DB_HOSTNAME=immich_postgres".to_string(),
|
||||
"DB_USERNAME=postgres".to_string(),
|
||||
"DB_PASSWORD=immichpass".to_string(),
|
||||
"DB_DATABASE_NAME=immich".to_string(),
|
||||
"REDIS_HOSTNAME=immich_redis".to_string(),
|
||||
"UPLOAD_LOCATION=/usr/src/app/upload".to_string(),
|
||||
],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"filebrowser" => {
|
||||
let host_port = allocator
|
||||
.allocate_or_get(app_id, 8083, 80)
|
||||
.await
|
||||
.unwrap_or(8083);
|
||||
(
|
||||
vec![format!("{}:80", host_port)],
|
||||
vec!["/var/lib/archipelago/filebrowser:/srv".to_string()],
|
||||
vec![],
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
"nginx-proxy-manager" => (
|
||||
vec![
|
||||
"81:81".to_string(),
|
||||
"8084:80".to_string(),
|
||||
"8443:443".to_string(),
|
||||
],
|
||||
vec![
|
||||
"/var/lib/archipelago/nginx-proxy-manager/data:/data".to_string(),
|
||||
"/var/lib/archipelago/nginx-proxy-manager/letsencrypt:/etc/letsencrypt".to_string(),
|
||||
],
|
||||
vec![],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"portainer" => (
|
||||
vec!["9000:9000".to_string()],
|
||||
vec![
|
||||
"/var/lib/archipelago/portainer:/data".to_string(),
|
||||
"/var/run/podman/podman.sock:/var/run/docker.sock".to_string(),
|
||||
],
|
||||
vec![],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"uptime-kuma" => (
|
||||
vec!["3001:3001".to_string()],
|
||||
vec!["/var/lib/archipelago/uptime-kuma:/app/data".to_string()],
|
||||
vec!["TZ=UTC".to_string()],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"tailscale" => (
|
||||
vec!["8240:8240".to_string()],
|
||||
vec!["/var/lib/archipelago/tailscale:/var/lib/tailscale".to_string()],
|
||||
vec!["TS_STATE_DIR=/var/lib/tailscale".to_string()],
|
||||
Some(
|
||||
"sh -c 'tailscale web --listen 0.0.0.0:8240 & exec tailscaled'".to_string(),
|
||||
),
|
||||
None,
|
||||
),
|
||||
"fedimint" => (
|
||||
vec![
|
||||
"8173:8173".to_string(),
|
||||
"8174:8174".to_string(),
|
||||
"8175:8175".to_string(),
|
||||
],
|
||||
vec!["/var/lib/archipelago/fedimint:/data".to_string()],
|
||||
vec![
|
||||
"FM_DATA_DIR=/data".to_string(),
|
||||
format!("FM_BITCOIND_USERNAME={}", rpc_user),
|
||||
format!("FM_BITCOIND_PASSWORD={}", rpc_pass),
|
||||
"FM_BITCOIN_NETWORK=bitcoin".to_string(),
|
||||
"FM_BIND_P2P=0.0.0.0:8173".to_string(),
|
||||
"FM_BIND_API=0.0.0.0:8174".to_string(),
|
||||
"FM_BIND_UI=0.0.0.0:8175".to_string(),
|
||||
format!("FM_P2P_URL=fedimint://{}:8173", host_ip),
|
||||
format!("FM_API_URL=ws://{}:8174", host_ip),
|
||||
format!("FM_BITCOIND_URL=http://{}:8332", host_ip),
|
||||
],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"fedimint-gateway" => (
|
||||
vec!["8176:8176".to_string(), "9737:9737".to_string()],
|
||||
vec!["/var/lib/archipelago/fedimint-gateway:/data".to_string()],
|
||||
vec![],
|
||||
None,
|
||||
Some(vec![
|
||||
"gatewayd".to_string(),
|
||||
"--data-dir".to_string(),
|
||||
"/data".to_string(),
|
||||
"--listen".to_string(),
|
||||
"0.0.0.0:8176".to_string(),
|
||||
"--bcrypt-password-hash".to_string(),
|
||||
"$2y$10$t9YjjxkiktrlYvjajB/zgOMDnSNVg4HqrbDqh47u7Jf42whNdxNqC".to_string(),
|
||||
"--network".to_string(),
|
||||
"bitcoin".to_string(),
|
||||
"--bitcoind-url".to_string(),
|
||||
format!("http://{}:8332", host_ip),
|
||||
"--bitcoind-username".to_string(),
|
||||
rpc_user.to_string(),
|
||||
"--bitcoind-password".to_string(),
|
||||
rpc_pass.to_string(),
|
||||
"ldk".to_string(),
|
||||
"--ldk-lightning-port".to_string(),
|
||||
"9737".to_string(),
|
||||
"--ldk-alias".to_string(),
|
||||
"archipelago-gateway".to_string(),
|
||||
]),
|
||||
),
|
||||
"indeedhub" => (
|
||||
vec!["8190:3000".to_string()],
|
||||
vec![],
|
||||
vec![
|
||||
"NODE_ENV=production".to_string(),
|
||||
"NEXT_TELEMETRY_DISABLED=1".to_string(),
|
||||
],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"nostr-rs-relay" => (
|
||||
vec!["18081:8080".to_string()],
|
||||
vec!["/var/lib/archipelago/nostr-rs-relay:/usr/src/app/db".to_string()],
|
||||
vec![],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"dwn" => (
|
||||
vec!["3100:3000".to_string()],
|
||||
vec!["/var/lib/archipelago/dwn:/dwn/data".to_string()],
|
||||
vec![
|
||||
"DS_PORT=3000".to_string(),
|
||||
"DS_MESSAGES_STORE_URI=level://data/messages".to_string(),
|
||||
"DS_DATA_STORE_URI=level://data/data".to_string(),
|
||||
"DS_EVENT_LOG_URI=level://data/events".to_string(),
|
||||
],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
_ => (vec![], vec![], vec![], None, None),
|
||||
}
|
||||
}
|
||||
216
core/archipelago/src/api/rpc/package/dependencies.rs
Normal file
216
core/archipelago/src/api/rpc/package/dependencies.rs
Normal file
@@ -0,0 +1,216 @@
|
||||
use super::config::get_containers_for_app;
|
||||
use anyhow::Result;
|
||||
use tracing::info;
|
||||
|
||||
/// Names of container variants that represent a running Bitcoin node
|
||||
const BITCOIN_NAMES: &[&str] = &["bitcoin-knots", "bitcoin-core", "bitcoin"];
|
||||
|
||||
/// Names of container variants that represent a running Electrum indexer
|
||||
const ELECTRUM_NAMES: &[&str] = &["electrumx", "mempool-electrs", "electrs"];
|
||||
|
||||
/// Snapshot of which dependency services are currently running.
|
||||
pub(super) struct RunningDeps {
|
||||
pub has_bitcoin: bool,
|
||||
pub has_electrumx: bool,
|
||||
pub has_lnd: bool,
|
||||
}
|
||||
|
||||
/// Query podman for currently running containers and return dependency status.
|
||||
pub(super) async fn detect_running_deps() -> Result<RunningDeps> {
|
||||
let dep_check = tokio::process::Command::new("podman")
|
||||
.args(["ps", "--format", "{{.Names}}"])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to check running containers: {}", e))?;
|
||||
|
||||
let running = String::from_utf8_lossy(&dep_check.stdout);
|
||||
let is_running = |names: &[&str]| {
|
||||
running.lines().any(|l| {
|
||||
let name = l.trim();
|
||||
names.iter().any(|n| name == *n)
|
||||
})
|
||||
};
|
||||
|
||||
Ok(RunningDeps {
|
||||
has_bitcoin: is_running(BITCOIN_NAMES),
|
||||
has_electrumx: is_running(ELECTRUM_NAMES),
|
||||
has_lnd: is_running(&["lnd"]),
|
||||
})
|
||||
}
|
||||
|
||||
/// Verify that required dependency services are running before installing an app.
|
||||
/// Returns an error with a user-friendly message if dependencies are missing.
|
||||
pub(super) fn check_install_deps(package_id: &str, deps: &RunningDeps) -> Result<()> {
|
||||
match package_id {
|
||||
"electrumx" | "mempool-electrs" | "electrs" if !deps.has_bitcoin => {
|
||||
Err(anyhow::anyhow!(
|
||||
"ElectrumX requires a running Bitcoin node (Bitcoin Knots). \
|
||||
Please install and start Bitcoin Knots first."
|
||||
))
|
||||
}
|
||||
"lnd" if !deps.has_bitcoin => Err(anyhow::anyhow!(
|
||||
"LND requires a running Bitcoin node (Bitcoin Knots). \
|
||||
Please install and start Bitcoin Knots first."
|
||||
)),
|
||||
"btcpay-server" | "btcpayserver" if !deps.has_bitcoin => Err(anyhow::anyhow!(
|
||||
"BTCPay Server requires a running Bitcoin node (Bitcoin Knots). \
|
||||
Please install and start Bitcoin Knots first."
|
||||
)),
|
||||
"mempool" | "mempool-web" if !deps.has_bitcoin || !deps.has_electrumx => {
|
||||
let mut missing = vec![];
|
||||
if !deps.has_bitcoin {
|
||||
missing.push("Bitcoin Knots");
|
||||
}
|
||||
if !deps.has_electrumx {
|
||||
missing.push("ElectrumX");
|
||||
}
|
||||
Err(anyhow::anyhow!(
|
||||
"Mempool requires {} to be running. Please install and start {} first.",
|
||||
missing.join(" and "),
|
||||
missing.join(" and ")
|
||||
))
|
||||
}
|
||||
"fedimint" if !deps.has_bitcoin => Err(anyhow::anyhow!(
|
||||
"Fedimint requires a running Bitcoin node (Bitcoin Knots). \
|
||||
Please install and start Bitcoin Knots first."
|
||||
)),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Log informational messages about optional dependencies.
|
||||
pub(super) fn log_optional_dep_info(package_id: &str, deps: &RunningDeps) {
|
||||
if matches!(package_id, "btcpay-server" | "btcpayserver") && !deps.has_lnd {
|
||||
tracing::info!(
|
||||
"BTCPay Server installing without LND \
|
||||
— Lightning payments won't be available until LND is installed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an app requires the shared `archy-net` Podman network for
|
||||
/// inter-container DNS resolution.
|
||||
pub(super) fn needs_archy_net(package_id: &str) -> bool {
|
||||
matches!(
|
||||
package_id,
|
||||
"bitcoin-knots"
|
||||
| "bitcoin"
|
||||
| "bitcoin-core"
|
||||
| "lnd"
|
||||
| "mempool"
|
||||
| "mempool-web"
|
||||
| "mempool-api"
|
||||
| "electrumx"
|
||||
| "mempool-electrs"
|
||||
| "electrs"
|
||||
| "mysql-mempool"
|
||||
| "archy-mempool-db"
|
||||
| "archy-mempool-web"
|
||||
| "btcpay-server"
|
||||
| "btcpayserver"
|
||||
| "archy-btcpay-db"
|
||||
| "archy-nbxplorer"
|
||||
| "nbxplorer"
|
||||
| "fedimint"
|
||||
| "fedimint-gateway"
|
||||
)
|
||||
}
|
||||
|
||||
/// Return the correct startup order for a multi-container app stack.
|
||||
/// Containers are started in this order to satisfy dependency chains.
|
||||
pub(super) fn startup_order(package_id: &str) -> &'static [&'static str] {
|
||||
match package_id {
|
||||
"mempool" | "mempool-web" => &[
|
||||
"archy-mempool-db",
|
||||
"mysql-mempool",
|
||||
"electrumx",
|
||||
"mempool-electrs",
|
||||
"mempool-api",
|
||||
"archy-mempool-api",
|
||||
"archy-mempool-web",
|
||||
"mempool",
|
||||
],
|
||||
"immich" => &["immich_postgres", "immich_redis", "immich_server"],
|
||||
"penpot" | "penpot-frontend" => &[
|
||||
"penpot-postgres",
|
||||
"penpot-valkey",
|
||||
"penpot-backend",
|
||||
"penpot-exporter",
|
||||
"penpot-frontend",
|
||||
],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// Sort a list of container names according to the dependency-aware startup
|
||||
/// order for the given app. Unknown containers sort to the end.
|
||||
pub(super) async fn ordered_containers_for_start(
|
||||
package_id: &str,
|
||||
) -> Result<Vec<String>> {
|
||||
let containers = get_containers_for_app(package_id).await?;
|
||||
if containers.is_empty() {
|
||||
return Ok(vec![format!("archy-{}", package_id)]);
|
||||
}
|
||||
let order = startup_order(package_id);
|
||||
// If no special order defined, fall back to mempool order (legacy behavior)
|
||||
let effective_order: &[&str] = if order.is_empty() {
|
||||
startup_order("mempool")
|
||||
} else {
|
||||
order
|
||||
};
|
||||
let mut sorted = containers;
|
||||
sorted.sort_by_key(|c| {
|
||||
effective_order
|
||||
.iter()
|
||||
.position(|o| *o == c)
|
||||
.unwrap_or(99)
|
||||
});
|
||||
Ok(sorted)
|
||||
}
|
||||
|
||||
/// Configure Fedimint Gateway to use LND instead of LDK.
|
||||
/// Modifies ports, volumes, and command args in place when LND credentials exist.
|
||||
pub(super) fn configure_fedimint_lnd(
|
||||
host_ip: &str,
|
||||
ports: &mut Vec<String>,
|
||||
volumes: &mut Vec<String>,
|
||||
custom_args: &mut Option<Vec<String>>,
|
||||
rpc_user: &str,
|
||||
rpc_pass: &str,
|
||||
) {
|
||||
let lnd_cert = "/var/lib/archipelago/lnd/tls.cert";
|
||||
let lnd_macaroon =
|
||||
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||
if std::path::Path::new(lnd_cert).exists()
|
||||
&& std::path::Path::new(lnd_macaroon).exists()
|
||||
{
|
||||
info!("LND detected with credentials — configuring gateway in lnd mode");
|
||||
ports.retain(|p| p != "9737:9737");
|
||||
volumes.push(format!("{}:/lnd/tls.cert:ro", lnd_cert));
|
||||
volumes.push(format!("{}:/lnd/admin.macaroon:ro", lnd_macaroon));
|
||||
*custom_args = Some(vec![
|
||||
"gatewayd".to_string(),
|
||||
"--data-dir".to_string(),
|
||||
"/data".to_string(),
|
||||
"--listen".to_string(),
|
||||
"0.0.0.0:8176".to_string(),
|
||||
"--bcrypt-password-hash".to_string(),
|
||||
"$2y$10$t9YjjxkiktrlYvjajB/zgOMDnSNVg4HqrbDqh47u7Jf42whNdxNqC".to_string(),
|
||||
"--network".to_string(),
|
||||
"bitcoin".to_string(),
|
||||
"--bitcoind-url".to_string(),
|
||||
format!("http://{}:8332", host_ip),
|
||||
"--bitcoind-username".to_string(),
|
||||
rpc_user.to_string(),
|
||||
"--bitcoind-password".to_string(),
|
||||
rpc_pass.to_string(),
|
||||
"lnd".to_string(),
|
||||
"--lnd-rpc-host".to_string(),
|
||||
format!("{}:10009", host_ip),
|
||||
"--lnd-tls-cert".to_string(),
|
||||
"/lnd/tls.cert".to_string(),
|
||||
"--lnd-macaroon".to_string(),
|
||||
"/lnd/admin.macaroon".to_string(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
467
core/archipelago/src/api/rpc/package/install.rs
Normal file
467
core/archipelago/src/api/rpc/package/install.rs
Normal file
@@ -0,0 +1,467 @@
|
||||
use super::config::{
|
||||
get_app_capabilities, get_app_config, get_health_check_args, get_memory_limit,
|
||||
is_readonly_compatible, is_valid_docker_image,
|
||||
};
|
||||
use super::dependencies::{
|
||||
check_install_deps, configure_fedimint_lnd, detect_running_deps, log_optional_dep_info,
|
||||
needs_archy_net,
|
||||
};
|
||||
use super::progress::parse_pull_progress;
|
||||
use super::validation::validate_app_id;
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use anyhow::{Context, Result};
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tracing::{debug, info};
|
||||
|
||||
impl RpcHandler {
|
||||
/// Install a package from a Docker image.
|
||||
/// Security: Image verification, resource limits, network isolation.
|
||||
pub(in crate::api::rpc) async fn handle_package_install(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
|
||||
let package_id = params
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||
validate_app_id(package_id)?;
|
||||
|
||||
let docker_image = params
|
||||
.get("dockerImage")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing dockerImage"))?;
|
||||
|
||||
debug!(
|
||||
"Installing package {} from image {}",
|
||||
package_id, docker_image
|
||||
);
|
||||
|
||||
if !is_valid_docker_image(docker_image) {
|
||||
return Err(anyhow::anyhow!("Invalid Docker image format"));
|
||||
}
|
||||
|
||||
// Multi-container stacks get their own install path
|
||||
if package_id == "immich" {
|
||||
return self.install_immich_stack().await;
|
||||
}
|
||||
if package_id == "penpot" || package_id == "penpot-frontend" {
|
||||
return self.install_penpot_stack().await;
|
||||
}
|
||||
|
||||
// Dependency checks
|
||||
let deps = detect_running_deps().await?;
|
||||
check_install_deps(package_id, &deps)?;
|
||||
log_optional_dep_info(package_id, &deps);
|
||||
|
||||
// Check if container already exists
|
||||
let check_output = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"ps",
|
||||
"-a",
|
||||
"--format",
|
||||
"{{.Names}}",
|
||||
"--filter",
|
||||
&format!("name=^{}$", package_id),
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to check existing containers")?;
|
||||
|
||||
if !String::from_utf8_lossy(&check_output.stdout)
|
||||
.trim()
|
||||
.is_empty()
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"Container {} already exists. Stop and remove it first.",
|
||||
package_id
|
||||
));
|
||||
}
|
||||
|
||||
// Pull or verify image
|
||||
let has_local_fallback = self
|
||||
.pull_or_verify_image(package_id, docker_image)
|
||||
.await?;
|
||||
|
||||
// Normalize container name for legacy aliases
|
||||
let container_name = match package_id {
|
||||
"electrs" | "mempool-electrs" => "electrumx",
|
||||
_ => package_id,
|
||||
};
|
||||
|
||||
// Read Bitcoin RPC credentials for container configs
|
||||
let (rpc_user, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
|
||||
|
||||
// App-specific configuration
|
||||
let (mut ports, mut volumes, env_vars, custom_command, mut custom_args) = {
|
||||
let mut allocator = self.port_allocator.lock().await;
|
||||
get_app_config(
|
||||
package_id,
|
||||
&self.config.host_ip,
|
||||
&mut allocator,
|
||||
&rpc_user,
|
||||
&rpc_pass,
|
||||
)
|
||||
.await
|
||||
};
|
||||
|
||||
// Fedimint Gateway: auto-detect LND and switch to lnd mode
|
||||
if package_id == "fedimint-gateway" && deps.has_lnd {
|
||||
configure_fedimint_lnd(
|
||||
&self.config.host_ip,
|
||||
&mut ports,
|
||||
&mut volumes,
|
||||
&mut custom_args,
|
||||
&rpc_user,
|
||||
&rpc_pass,
|
||||
);
|
||||
}
|
||||
|
||||
// Build the podman run command
|
||||
let mut run_args = vec![
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
container_name,
|
||||
"--restart=unless-stopped",
|
||||
];
|
||||
|
||||
let is_tailscale = package_id == "tailscale";
|
||||
|
||||
// Network mode
|
||||
if is_tailscale {
|
||||
run_args.push("--network=host");
|
||||
run_args.push("--privileged");
|
||||
run_args.push("--cap-add=NET_ADMIN");
|
||||
run_args.push("--cap-add=NET_RAW");
|
||||
run_args.push("--device=/dev/net/tun");
|
||||
} else if needs_archy_net(package_id) {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["network", "create", "archy-net"])
|
||||
.output()
|
||||
.await;
|
||||
run_args.push("--network=archy-net");
|
||||
}
|
||||
|
||||
// Security hardening (skip for privileged containers)
|
||||
let security_caps: Vec<String> = if !is_tailscale {
|
||||
get_app_capabilities(package_id)
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
let readonly_compatible = !is_tailscale && is_readonly_compatible(package_id);
|
||||
|
||||
if !is_tailscale {
|
||||
run_args.push("--cap-drop=ALL");
|
||||
run_args.push("--security-opt=no-new-privileges:true");
|
||||
for cap in &security_caps {
|
||||
run_args.push(cap);
|
||||
}
|
||||
if readonly_compatible {
|
||||
run_args.push("--read-only");
|
||||
run_args.push("--tmpfs=/tmp:rw,noexec,nosuid,size=256m");
|
||||
run_args.push("--tmpfs=/run:rw,noexec,nosuid,size=64m");
|
||||
}
|
||||
}
|
||||
|
||||
// Create data directories
|
||||
self.create_data_dirs(package_id, &volumes).await;
|
||||
|
||||
// Pre-install: bitcoin.conf with rpcauth
|
||||
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
|
||||
self.write_bitcoin_conf(&rpc_user, &rpc_pass).await;
|
||||
}
|
||||
|
||||
// Port mappings (skip for host-network containers)
|
||||
if !is_tailscale {
|
||||
for port in &ports {
|
||||
run_args.push("-p");
|
||||
run_args.push(port);
|
||||
}
|
||||
}
|
||||
|
||||
// Volume mounts
|
||||
for volume in &volumes {
|
||||
run_args.push("-v");
|
||||
run_args.push(volume);
|
||||
}
|
||||
|
||||
// Environment variables
|
||||
for env in &env_vars {
|
||||
run_args.push("-e");
|
||||
run_args.push(env);
|
||||
}
|
||||
|
||||
// Resource limits
|
||||
let memory_limit = get_memory_limit(package_id);
|
||||
let mem_arg = format!("--memory={}", memory_limit);
|
||||
run_args.push(&mem_arg);
|
||||
run_args.push("--cpus=2");
|
||||
|
||||
// Health checks
|
||||
let health_args = get_health_check_args(package_id, &rpc_pass);
|
||||
for arg in &health_args {
|
||||
run_args.push(arg);
|
||||
}
|
||||
|
||||
// Image — prefer local build over registry
|
||||
let effective_image = if has_local_fallback {
|
||||
format!("localhost/{}:latest", package_id)
|
||||
} else {
|
||||
docker_image.to_string()
|
||||
};
|
||||
run_args.push(&effective_image);
|
||||
|
||||
debug!("Running container with args: {:?}", run_args);
|
||||
|
||||
// Build command with optional custom command/args
|
||||
let mut cmd = tokio::process::Command::new("podman");
|
||||
cmd.args(&run_args);
|
||||
if let Some(custom_cmd) = custom_command {
|
||||
cmd.arg(custom_cmd);
|
||||
} else if let Some(args) = custom_args {
|
||||
cmd.args(args);
|
||||
}
|
||||
|
||||
let run_output = cmd.output().await.context("Failed to run container")?;
|
||||
|
||||
if !run_output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&run_output.stderr);
|
||||
return Err(anyhow::anyhow!("Failed to start container: {}", stderr));
|
||||
}
|
||||
|
||||
let container_id = String::from_utf8_lossy(&run_output.stdout)
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
// Post-install hooks
|
||||
self.run_post_install_hooks(package_id).await;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"success": true,
|
||||
"package_id": package_id,
|
||||
"container_id": container_id,
|
||||
"message": format!("Package {} installed and started", package_id)
|
||||
}))
|
||||
}
|
||||
|
||||
// -- Private helpers for install --
|
||||
|
||||
/// Pull the image from a registry or verify a local image exists.
|
||||
/// Returns `true` if a local fallback image was found (registry pull skipped).
|
||||
async fn pull_or_verify_image(
|
||||
&self,
|
||||
package_id: &str,
|
||||
docker_image: &str,
|
||||
) -> Result<bool> {
|
||||
let is_local_image = docker_image.starts_with("localhost/");
|
||||
let has_local_fallback = if !is_local_image {
|
||||
let local_tag = format!("localhost/{}:latest", package_id);
|
||||
let check = tokio::process::Command::new("podman")
|
||||
.args(["images", "-q", &local_tag])
|
||||
.output()
|
||||
.await
|
||||
.ok();
|
||||
check.map_or(false, |o| {
|
||||
!String::from_utf8_lossy(&o.stdout).trim().is_empty()
|
||||
})
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if !is_local_image && !has_local_fallback {
|
||||
self.pull_image_with_progress(package_id, docker_image)
|
||||
.await?;
|
||||
} else if has_local_fallback {
|
||||
debug!(
|
||||
"Using local build for {} (skipping registry pull)",
|
||||
package_id
|
||||
);
|
||||
} else {
|
||||
// Local image — verify it exists
|
||||
let images_output = tokio::process::Command::new("podman")
|
||||
.args(["images", "-q", docker_image])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to check local image")?;
|
||||
if String::from_utf8_lossy(&images_output.stdout)
|
||||
.trim()
|
||||
.is_empty()
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"Local image {} not found. Build the image first \
|
||||
or ensure the registry is reachable.",
|
||||
docker_image
|
||||
));
|
||||
}
|
||||
debug!("Using local image: {}", docker_image);
|
||||
}
|
||||
|
||||
Ok(has_local_fallback)
|
||||
}
|
||||
|
||||
/// Stream `podman pull` while updating install progress state.
|
||||
async fn pull_image_with_progress(
|
||||
&self,
|
||||
package_id: &str,
|
||||
docker_image: &str,
|
||||
) -> Result<()> {
|
||||
debug!("Pulling image: {}", docker_image);
|
||||
self.set_install_progress(package_id, 0, 0).await;
|
||||
|
||||
let mut child = tokio::process::Command::new("podman")
|
||||
.args(["pull", docker_image])
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.context("Failed to start image pull")?;
|
||||
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
let reader = BufReader::new(stderr);
|
||||
let mut lines = reader.lines();
|
||||
let pkg_id = package_id.to_string();
|
||||
let state_mgr = self.state_manager.clone();
|
||||
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
if let Some((downloaded, total)) = parse_pull_progress(&line) {
|
||||
Self::update_install_progress(&state_mgr, &pkg_id, downloaded, total)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let status = child
|
||||
.wait()
|
||||
.await
|
||||
.context("Failed to wait for image pull")?;
|
||||
if !status.success() {
|
||||
self.clear_install_progress(package_id).await;
|
||||
return Err(anyhow::anyhow!("Failed to pull image"));
|
||||
}
|
||||
|
||||
self.set_install_progress(package_id, 100, 100).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create data directories for volume mounts under /var/lib/archipelago/.
|
||||
async fn create_data_dirs(&self, package_id: &str, volumes: &[String]) {
|
||||
for volume in volumes {
|
||||
if let Some(host_path) = volume.split(':').next() {
|
||||
if host_path.starts_with("/var/lib/archipelago/") {
|
||||
debug!("Creating directory: {}", host_path);
|
||||
let create_dir = tokio::process::Command::new("sudo")
|
||||
.args(["mkdir", "-p", host_path])
|
||||
.output()
|
||||
.await;
|
||||
if let Err(e) = create_dir {
|
||||
debug!("Failed to create directory {}: {}", host_path, e);
|
||||
}
|
||||
// Grafana runs as UID 472 — fix permissions
|
||||
if package_id == "grafana" && host_path.contains("grafana") {
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["chown", "-R", "472:472", host_path])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write bitcoin.conf with rpcauth (salted HMAC hash, no plaintext password).
|
||||
async fn write_bitcoin_conf(&self, rpc_user: &str, rpc_pass: &str) {
|
||||
let bitcoin_dir = "/var/lib/archipelago/bitcoin";
|
||||
let conf_path = format!("{}/bitcoin.conf", bitcoin_dir);
|
||||
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
let salt_bytes: [u8; 16] = rand::random();
|
||||
let salt_hex = hex::encode(salt_bytes);
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(salt_hex.as_bytes())
|
||||
.expect("HMAC accepts any key length");
|
||||
mac.update(rpc_pass.as_bytes());
|
||||
let hash_hex = hex::encode(mac.finalize().into_bytes());
|
||||
let rpcauth_line = format!("rpcauth={}:{}${}", rpc_user, salt_hex, hash_hex);
|
||||
|
||||
let bitcoin_conf = format!(
|
||||
"\
|
||||
# rpcauth: salted hash only — no plaintext password in config or CLI\n\
|
||||
{}\n\
|
||||
server=1\n\
|
||||
prune=550\n\
|
||||
rpcbind=0.0.0.0\n\
|
||||
rpcallowip=0.0.0.0/0\n\
|
||||
rpcport=8332\n\
|
||||
listen=1\n\
|
||||
printtoconsole=1\n",
|
||||
rpcauth_line
|
||||
);
|
||||
let _ = tokio::fs::create_dir_all(bitcoin_dir).await;
|
||||
let _ = tokio::fs::write(&conf_path, bitcoin_conf).await;
|
||||
info!("Created bitcoin.conf with rpcauth (no plaintext credentials)");
|
||||
}
|
||||
|
||||
/// Run post-install hooks (Nextcloud trusted domains, Bitcoin UI container).
|
||||
async fn run_post_install_hooks(&self, package_id: &str) {
|
||||
if package_id == "nextcloud" {
|
||||
let host_ip = self.config.host_ip.clone();
|
||||
tokio::spawn(async move {
|
||||
// Wait for Nextcloud to finish first-run initialization
|
||||
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
|
||||
for domain_idx in 1..=2u8 {
|
||||
let value = if domain_idx == 1 {
|
||||
host_ip.as_str()
|
||||
} else {
|
||||
"localhost"
|
||||
};
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"exec",
|
||||
"-u",
|
||||
"33",
|
||||
"nextcloud",
|
||||
"php",
|
||||
"occ",
|
||||
"config:system:set",
|
||||
"trusted_domains",
|
||||
&domain_idx.to_string(),
|
||||
"--value",
|
||||
value,
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
info!("Nextcloud trusted domains configured for {}", host_ip);
|
||||
});
|
||||
}
|
||||
|
||||
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
|
||||
tokio::spawn(async move {
|
||||
let ui_dir = "/opt/archipelago/docker/bitcoin-ui";
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["build", "-t", "localhost/bitcoin-ui", ui_dir])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["rm", "-f", "bitcoin-ui"])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
"bitcoin-ui",
|
||||
"--restart=unless-stopped",
|
||||
"-p",
|
||||
"8334:80",
|
||||
"localhost/bitcoin-ui:latest",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
info!("Bitcoin UI container started on port 8334");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
9
core/archipelago/src/api/rpc/package/lifecycle.rs
Normal file
9
core/archipelago/src/api/rpc/package/lifecycle.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
// Container lifecycle operations.
|
||||
//
|
||||
// Split into focused sub-modules:
|
||||
// - install.rs — Image pulling, container creation, volume setup, multi-container stacks
|
||||
// - runtime.rs — Start, stop, restart, uninstall operations
|
||||
// - dependencies.rs — Dependency resolution, startup ordering, network requirements
|
||||
//
|
||||
// All public handler methods (handle_package_*) are implemented on RpcHandler
|
||||
// in their respective sub-modules and remain callable from the RPC dispatcher.
|
||||
11
core/archipelago/src/api/rpc/package/mod.rs
Normal file
11
core/archipelago/src/api/rpc/package/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
mod config;
|
||||
mod dependencies;
|
||||
mod install;
|
||||
mod lifecycle;
|
||||
mod progress;
|
||||
mod runtime;
|
||||
mod stacks;
|
||||
mod validation;
|
||||
|
||||
// Re-export items needed by sibling modules (container.rs, security.rs)
|
||||
pub(super) use validation::validate_app_id;
|
||||
141
core/archipelago/src/api/rpc/package/progress.rs
Normal file
141
core/archipelago/src/api/rpc/package/progress.rs
Normal file
@@ -0,0 +1,141 @@
|
||||
//! Install progress tracking and podman pull output parsing.
|
||||
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use crate::data_model::{
|
||||
Description, InstallProgress, Manifest, PackageDataEntry, PackageState, StaticFiles,
|
||||
};
|
||||
|
||||
impl RpcHandler {
|
||||
/// Set install progress for a package and broadcast the update.
|
||||
/// Creates a minimal package entry if one doesn't exist yet.
|
||||
pub(super) async fn set_install_progress(
|
||||
&self,
|
||||
package_id: &str,
|
||||
downloaded: u64,
|
||||
size: u64,
|
||||
) {
|
||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
||||
let entry = data
|
||||
.package_data
|
||||
.entry(package_id.to_string())
|
||||
.or_insert_with(|| create_installing_entry(package_id));
|
||||
entry.state = PackageState::Installing;
|
||||
entry.install_progress = Some(InstallProgress { size, downloaded });
|
||||
self.state_manager.update_data(data).await;
|
||||
}
|
||||
|
||||
/// Clear install progress after pull completes or fails.
|
||||
pub(super) async fn clear_install_progress(&self, package_id: &str) {
|
||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||
entry.install_progress = None;
|
||||
}
|
||||
self.state_manager.update_data(data).await;
|
||||
}
|
||||
|
||||
/// Update install progress (static method for use in async closures).
|
||||
pub(super) async fn update_install_progress(
|
||||
state_manager: &crate::state::StateManager,
|
||||
package_id: &str,
|
||||
downloaded: u64,
|
||||
total: u64,
|
||||
) {
|
||||
let (mut data, _rev) = state_manager.get_snapshot().await;
|
||||
let entry = data
|
||||
.package_data
|
||||
.entry(package_id.to_string())
|
||||
.or_insert_with(|| create_installing_entry(package_id));
|
||||
entry.install_progress = Some(InstallProgress {
|
||||
size: total,
|
||||
downloaded,
|
||||
});
|
||||
state_manager.update_data(data).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a minimal PackageDataEntry for a package being installed.
|
||||
fn create_installing_entry(package_id: &str) -> PackageDataEntry {
|
||||
PackageDataEntry {
|
||||
state: PackageState::Installing,
|
||||
health: None,
|
||||
static_files: StaticFiles {
|
||||
license: String::new(),
|
||||
instructions: String::new(),
|
||||
icon: format!("/assets/img/app-icons/{}.png", package_id),
|
||||
},
|
||||
manifest: Manifest {
|
||||
id: package_id.to_string(),
|
||||
title: package_id.to_string(),
|
||||
version: String::new(),
|
||||
description: Description {
|
||||
short: "Installing...".to_string(),
|
||||
long: String::new(),
|
||||
},
|
||||
release_notes: String::new(),
|
||||
license: String::new(),
|
||||
wrapper_repo: String::new(),
|
||||
upstream_repo: String::new(),
|
||||
support_site: String::new(),
|
||||
marketing_site: String::new(),
|
||||
donation_url: None,
|
||||
author: None,
|
||||
website: None,
|
||||
interfaces: None,
|
||||
tier: None,
|
||||
},
|
||||
installed: None,
|
||||
install_progress: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse podman pull progress output.
|
||||
/// Podman outputs lines like: "Copying blob sha256:abc done | 50.0MiB / 100.0MiB"
|
||||
/// Returns (downloaded_bytes, total_bytes) if parseable.
|
||||
pub(super) fn parse_pull_progress(line: &str) -> Option<(u64, u64)> {
|
||||
let line = line.trim();
|
||||
let parts: Vec<&str> = line.split('/').collect();
|
||||
if parts.len() != 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let downloaded = parse_size_value(parts[0].trim())?;
|
||||
let total = parse_size_value(parts[1].trim())?;
|
||||
|
||||
if total > 0 {
|
||||
Some((downloaded, total))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a size value like "50.0MiB", "1.2GiB", "500KiB" into bytes.
|
||||
fn parse_size_value(s: &str) -> Option<u64> {
|
||||
let s = s.trim();
|
||||
|
||||
let (num_str, multiplier) = if let Some(pos) = s.rfind("GiB") {
|
||||
(
|
||||
s[..pos].trim().split_whitespace().last()?,
|
||||
1024 * 1024 * 1024,
|
||||
)
|
||||
} else if let Some(pos) = s.rfind("MiB") {
|
||||
(s[..pos].trim().split_whitespace().last()?, 1024 * 1024)
|
||||
} else if let Some(pos) = s.rfind("KiB") {
|
||||
(s[..pos].trim().split_whitespace().last()?, 1024)
|
||||
} else if let Some(pos) = s.rfind("GB") {
|
||||
(
|
||||
s[..pos].trim().split_whitespace().last()?,
|
||||
1_000_000_000,
|
||||
)
|
||||
} else if let Some(pos) = s.rfind("MB") {
|
||||
(s[..pos].trim().split_whitespace().last()?, 1_000_000)
|
||||
} else if let Some(pos) = s.rfind("KB") {
|
||||
(s[..pos].trim().split_whitespace().last()?, 1_000)
|
||||
} else if let Some(pos) = s.rfind('B') {
|
||||
(s[..pos].trim().split_whitespace().last()?, 1)
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let num: f64 = num_str.parse().ok()?;
|
||||
Some((num * multiplier as f64) as u64)
|
||||
}
|
||||
359
core/archipelago/src/api/rpc/package/runtime.rs
Normal file
359
core/archipelago/src/api/rpc/package/runtime.rs
Normal file
@@ -0,0 +1,359 @@
|
||||
use super::config::{get_containers_for_app, get_data_dirs_for_app, is_valid_docker_image};
|
||||
use super::dependencies::ordered_containers_for_start;
|
||||
use super::validation::validate_app_id;
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
impl RpcHandler {
|
||||
/// Start a package: start all containers in dependency order.
|
||||
pub(in crate::api::rpc) async fn handle_package_start(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let package_id = params
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||
validate_app_id(package_id)?;
|
||||
|
||||
let to_start = ordered_containers_for_start(package_id).await?;
|
||||
|
||||
// Clear user-stopped flag — user explicitly started this app
|
||||
crate::crash_recovery::clear_user_stopped(&self.config.data_dir, package_id).await;
|
||||
for name in &to_start {
|
||||
crate::crash_recovery::clear_user_stopped(&self.config.data_dir, name).await;
|
||||
}
|
||||
|
||||
for name in to_start {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["start", &name])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
|
||||
/// Stop a package: mark as user-stopped and stop all containers.
|
||||
pub(in crate::api::rpc) async fn handle_package_stop(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let package_id = params
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||
validate_app_id(package_id)?;
|
||||
|
||||
// Mark as user-stopped so health monitor and crash recovery don't auto-restart
|
||||
crate::crash_recovery::mark_user_stopped(&self.config.data_dir, package_id).await;
|
||||
|
||||
let containers = get_containers_for_app(package_id).await?;
|
||||
if containers.is_empty() {
|
||||
let container_name = format!("archy-{}", package_id);
|
||||
crate::crash_recovery::mark_user_stopped(&self.config.data_dir, &container_name)
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["stop", &container_name])
|
||||
.output()
|
||||
.await;
|
||||
return Ok(serde_json::Value::Null);
|
||||
}
|
||||
|
||||
for name in &containers {
|
||||
crate::crash_recovery::mark_user_stopped(&self.config.data_dir, name).await;
|
||||
}
|
||||
for name in containers {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["stop", &name])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
|
||||
/// Restart a package: restart all containers.
|
||||
pub(in crate::api::rpc) async fn handle_package_restart(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let package_id = params
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||
validate_app_id(package_id)?;
|
||||
|
||||
let containers = get_containers_for_app(package_id).await?;
|
||||
if containers.is_empty() {
|
||||
let container_name = format!("archy-{}", package_id);
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["restart", &container_name])
|
||||
.output()
|
||||
.await;
|
||||
return Ok(serde_json::Value::Null);
|
||||
}
|
||||
|
||||
for name in containers {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["restart", &name])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
|
||||
/// Uninstall a package: stop and remove all related containers, clean data.
|
||||
pub(in crate::api::rpc) async fn handle_package_uninstall(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let package_id = params
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||
validate_app_id(package_id)?;
|
||||
let preserve_data = params
|
||||
.get("preserve_data")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
let containers_to_remove = get_containers_for_app(package_id).await?;
|
||||
if containers_to_remove.is_empty() {
|
||||
tracing::warn!("Uninstall {}: no containers found", package_id);
|
||||
}
|
||||
|
||||
let mut stopped = 0u32;
|
||||
let mut removed = 0u32;
|
||||
let mut errors = Vec::new();
|
||||
|
||||
for name in &containers_to_remove {
|
||||
tracing::info!("Uninstall {}: stopping container {}", package_id, name);
|
||||
let stop_out = tokio::process::Command::new("podman")
|
||||
.args(["stop", "-t", "10", name])
|
||||
.output()
|
||||
.await;
|
||||
match stop_out {
|
||||
Ok(o) if o.status.success() => stopped += 1,
|
||||
Ok(o) => {
|
||||
let stderr = String::from_utf8_lossy(&o.stderr);
|
||||
tracing::warn!(
|
||||
"Uninstall {}: stop {} failed: {}",
|
||||
package_id,
|
||||
name,
|
||||
stderr.trim()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Uninstall {}: stop {} error: {}",
|
||||
package_id,
|
||||
name,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("Uninstall {}: removing container {}", package_id, name);
|
||||
let rm_out = tokio::process::Command::new("podman")
|
||||
.args(["rm", "-f", name])
|
||||
.output()
|
||||
.await;
|
||||
match rm_out {
|
||||
Ok(o) if o.status.success() => removed += 1,
|
||||
Ok(o) => {
|
||||
let stderr = String::from_utf8_lossy(&o.stderr);
|
||||
let msg = format!("Failed to remove {}: {}", name, stderr.trim());
|
||||
tracing::error!("Uninstall {}: {}", package_id, msg);
|
||||
errors.push(msg);
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = format!("Failed to remove {}: {}", name, e);
|
||||
tracing::error!("Uninstall {}: {}", package_id, msg);
|
||||
errors.push(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Release port allocation
|
||||
{
|
||||
let mut allocator = self.port_allocator.lock().await;
|
||||
let _ = allocator.release(package_id).await;
|
||||
}
|
||||
|
||||
// Clean data directories unless preserve_data
|
||||
if !preserve_data {
|
||||
let data_dirs = get_data_dirs_for_app(package_id);
|
||||
for dir in &data_dirs {
|
||||
tracing::info!("Uninstall {}: removing data {}", package_id, dir);
|
||||
let rm_out = tokio::process::Command::new("sudo")
|
||||
.args(["rm", "-rf", dir])
|
||||
.output()
|
||||
.await;
|
||||
if let Ok(o) = rm_out {
|
||||
if !o.status.success() {
|
||||
tracing::warn!("Uninstall {}: rm {} failed", package_id, dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !errors.is_empty() {
|
||||
tracing::error!(
|
||||
"Uninstall {} completed with errors: {:?}",
|
||||
package_id,
|
||||
errors
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
"Uninstall {} complete: stopped={}, removed={}",
|
||||
package_id,
|
||||
stopped,
|
||||
removed
|
||||
);
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"status": if errors.is_empty() { "uninstalled" } else { "partial" },
|
||||
"stopped": stopped,
|
||||
"removed": removed,
|
||||
"errors": errors,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Start a bundled app (create container from pre-loaded image if needed).
|
||||
pub(in crate::api::rpc) async fn handle_bundled_app_start(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let app_id = params
|
||||
.get("app_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
|
||||
validate_app_id(app_id)?;
|
||||
let image = params
|
||||
.get("image")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing image"))?;
|
||||
if !is_valid_docker_image(image) {
|
||||
return Err(anyhow::anyhow!("Invalid Docker image format"));
|
||||
}
|
||||
let ports = params
|
||||
.get("ports")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing ports"))?;
|
||||
let volumes = params
|
||||
.get("volumes")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing volumes"))?;
|
||||
|
||||
let check_output = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"ps",
|
||||
"-a",
|
||||
"--format",
|
||||
"{{.Names}}",
|
||||
"--filter",
|
||||
&format!("name={}", app_id),
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to check container")?;
|
||||
|
||||
let existing = String::from_utf8_lossy(&check_output.stdout);
|
||||
|
||||
if existing.trim().is_empty() {
|
||||
let mut cmd = tokio::process::Command::new("podman");
|
||||
cmd.args(["run", "-d", "--name", app_id]);
|
||||
|
||||
for port in ports {
|
||||
if let (Some(host), Some(container)) = (
|
||||
port.get("host").and_then(|v| v.as_u64()),
|
||||
port.get("container").and_then(|v| v.as_u64()),
|
||||
) {
|
||||
cmd.arg("-p").arg(format!("{}:{}", host, container));
|
||||
}
|
||||
}
|
||||
|
||||
for volume in volumes {
|
||||
if let (Some(host), Some(container)) = (
|
||||
volume.get("host").and_then(|v| v.as_str()),
|
||||
volume.get("container").and_then(|v| v.as_str()),
|
||||
) {
|
||||
// Validate host path: must be under /var/lib/archipelago/
|
||||
if !host.starts_with("/var/lib/archipelago/")
|
||||
|| host.contains("..")
|
||||
|| host.contains('\0')
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"Volume host path must be under /var/lib/archipelago/ \
|
||||
and cannot contain path traversal"
|
||||
));
|
||||
}
|
||||
if container.contains("..") || container.contains('\0') {
|
||||
return Err(anyhow::anyhow!("Invalid container mount path"));
|
||||
}
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["mkdir", "-p", host])
|
||||
.output()
|
||||
.await;
|
||||
cmd.arg("-v").arg(format!("{}:{}", host, container));
|
||||
}
|
||||
}
|
||||
|
||||
cmd.arg(image);
|
||||
|
||||
let output = cmd.output().await.context("Failed to create container")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow::anyhow!("Failed to create container: {}", stderr));
|
||||
}
|
||||
} else {
|
||||
let output = tokio::process::Command::new("podman")
|
||||
.args(["start", app_id])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to start container")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow::anyhow!("Failed to start container: {}", stderr));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({ "status": "started", "app_id": app_id }))
|
||||
}
|
||||
|
||||
/// Stop a bundled app.
|
||||
pub(in crate::api::rpc) async fn handle_bundled_app_stop(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let app_id = params
|
||||
.get("app_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
|
||||
validate_app_id(app_id)?;
|
||||
|
||||
let output = tokio::process::Command::new("podman")
|
||||
.args(["stop", app_id])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to stop container")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow::anyhow!("Failed to stop container: {}", stderr));
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({ "status": "stopped", "app_id": app_id }))
|
||||
}
|
||||
}
|
||||
335
core/archipelago/src/api/rpc/package/stacks.rs
Normal file
335
core/archipelago/src/api/rpc/package/stacks.rs
Normal file
@@ -0,0 +1,335 @@
|
||||
//! Multi-container app stack installers (Immich, Penpot).
|
||||
//!
|
||||
//! Each stack pulls multiple images, creates a private network, and starts
|
||||
//! containers in dependency order.
|
||||
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::info;
|
||||
|
||||
impl RpcHandler {
|
||||
/// Install Immich stack (postgres + redis + server).
|
||||
pub(super) async fn install_immich_stack(&self) -> Result<serde_json::Value> {
|
||||
let check = tokio::process::Command::new("podman")
|
||||
.args(["ps", "-a", "--format", "{{.Names}}"])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to list containers")?;
|
||||
let stdout = String::from_utf8_lossy(&check.stdout);
|
||||
if stdout.contains("immich_server") {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Immich already installed. Stop and remove it first."
|
||||
));
|
||||
}
|
||||
if stdout.contains("immich\n") || stdout.lines().any(|l| l.trim() == "immich") {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["stop", "immich"])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["rm", "-f", "immich"])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
|
||||
let images = [
|
||||
"80.71.235.15:3000/archipelago/immich-postgres:14-vectorchord0.4.3-pgvectors0.2.0",
|
||||
"80.71.235.15:3000/archipelago/valkey:7-alpine",
|
||||
"80.71.235.15:3000/archipelago/immich-server:release",
|
||||
];
|
||||
for img in &images {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["pull", img])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args([
|
||||
"mkdir",
|
||||
"-p",
|
||||
"/var/lib/archipelago/immich",
|
||||
"/var/lib/archipelago/immich-db",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["network", "create", "immich-net"])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
"immich_postgres",
|
||||
"--restart",
|
||||
"unless-stopped",
|
||||
"--network",
|
||||
"immich-net",
|
||||
"-v",
|
||||
"/var/lib/archipelago/immich-db:/var/lib/postgresql/data",
|
||||
"-e",
|
||||
"POSTGRES_PASSWORD=immichpass",
|
||||
"-e",
|
||||
"POSTGRES_USER=postgres",
|
||||
"-e",
|
||||
"POSTGRES_DB=immich",
|
||||
"80.71.235.15:3000/archipelago/immich-postgres:14-vectorchord0.4.3-pgvectors0.2.0",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
"immich_redis",
|
||||
"--restart",
|
||||
"unless-stopped",
|
||||
"--network",
|
||||
"immich-net",
|
||||
"80.71.235.15:3000/archipelago/valkey:7-alpine",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
|
||||
let run = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
"immich_server",
|
||||
"--restart",
|
||||
"unless-stopped",
|
||||
"--network",
|
||||
"immich-net",
|
||||
"-p",
|
||||
"2283:2283",
|
||||
"-v",
|
||||
"/var/lib/archipelago/immich:/usr/src/app/upload",
|
||||
"-e",
|
||||
"DB_HOSTNAME=immich_postgres",
|
||||
"-e",
|
||||
"DB_USERNAME=postgres",
|
||||
"-e",
|
||||
"DB_PASSWORD=immichpass",
|
||||
"-e",
|
||||
"DB_DATABASE_NAME=immich",
|
||||
"-e",
|
||||
"REDIS_HOSTNAME=immich_redis",
|
||||
"-e",
|
||||
"UPLOAD_LOCATION=/usr/src/app/upload",
|
||||
"80.71.235.15:3000/archipelago/immich-server:release",
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to start immich_server")?;
|
||||
|
||||
if !run.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&run.stderr);
|
||||
return Err(anyhow::anyhow!(
|
||||
"Failed to start Immich server: {}",
|
||||
stderr
|
||||
));
|
||||
}
|
||||
|
||||
info!("Immich stack installed and started");
|
||||
Ok(serde_json::json!({
|
||||
"success": true,
|
||||
"package_id": "immich",
|
||||
"message": "Immich stack installed and started"
|
||||
}))
|
||||
}
|
||||
|
||||
/// Install Penpot stack (postgres + valkey + backend + exporter + frontend).
|
||||
pub(super) async fn install_penpot_stack(&self) -> Result<serde_json::Value> {
|
||||
let check = tokio::process::Command::new("podman")
|
||||
.args(["ps", "-a", "--format", "{{.Names}}"])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to list containers")?;
|
||||
let stdout = String::from_utf8_lossy(&check.stdout);
|
||||
if stdout.contains("penpot-frontend") {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Penpot already installed. Stop and remove it first."
|
||||
));
|
||||
}
|
||||
|
||||
let images = [
|
||||
"80.71.235.15:3000/archipelago/postgres:15",
|
||||
"80.71.235.15:3000/archipelago/valkey:8.1",
|
||||
"80.71.235.15:3000/archipelago/penpot-backend:2.4",
|
||||
"80.71.235.15:3000/archipelago/penpot-exporter:2.4",
|
||||
"80.71.235.15:3000/archipelago/penpot-frontend:2.4",
|
||||
];
|
||||
for img in &images {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["pull", img])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["mkdir", "-p", "/var/lib/archipelago/penpot-assets"])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["network", "create", "penpot-net"])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
// Generate a stable secret key derived from the data directory
|
||||
let secret = {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(b"penpot-secret-");
|
||||
hasher.update(self.config.data_dir.to_string_lossy().as_bytes());
|
||||
hex::encode(hasher.finalize())
|
||||
};
|
||||
let host_ip = &self.config.host_ip;
|
||||
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
"penpot-postgres",
|
||||
"--restart",
|
||||
"unless-stopped",
|
||||
"--network",
|
||||
"penpot-net",
|
||||
"-v",
|
||||
"/var/lib/archipelago/penpot-postgres:/var/lib/postgresql/data",
|
||||
"-e",
|
||||
"POSTGRES_DB=penpot",
|
||||
"-e",
|
||||
"POSTGRES_USER=penpot",
|
||||
"-e",
|
||||
"POSTGRES_PASSWORD=penpot",
|
||||
"80.71.235.15:3000/archipelago/postgres:15",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
"penpot-valkey",
|
||||
"--restart",
|
||||
"unless-stopped",
|
||||
"--network",
|
||||
"penpot-net",
|
||||
"-e",
|
||||
"VALKEY_EXTRA_FLAGS=--maxmemory 128mb --maxmemory-policy volatile-lfu",
|
||||
"80.71.235.15:3000/archipelago/valkey:8.1",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
"penpot-backend",
|
||||
"--restart",
|
||||
"unless-stopped",
|
||||
"--network",
|
||||
"penpot-net",
|
||||
"-v",
|
||||
"/var/lib/archipelago/penpot-assets:/opt/data/assets",
|
||||
"-e",
|
||||
&format!("PENPOT_PUBLIC_URI=http://{}:9001", host_ip),
|
||||
"-e",
|
||||
&format!("PENPOT_SECRET_KEY={}", secret),
|
||||
"-e",
|
||||
"PENPOT_DATABASE_URI=postgresql://penpot-postgres/penpot",
|
||||
"-e",
|
||||
"PENPOT_DATABASE_USERNAME=penpot",
|
||||
"-e",
|
||||
"PENPOT_DATABASE_PASSWORD=penpot",
|
||||
"-e",
|
||||
"PENPOT_REDIS_URI=redis://penpot-valkey/0",
|
||||
"-e",
|
||||
"PENPOT_OBJECTS_STORAGE_BACKEND=fs",
|
||||
"-e",
|
||||
"PENPOT_OBJECTS_STORAGE_FS_DIRECTORY=/opt/data/assets",
|
||||
"-e",
|
||||
"PENPOT_FLAGS=disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies",
|
||||
"80.71.235.15:3000/archipelago/penpot-backend:2.4",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
"penpot-exporter",
|
||||
"--restart",
|
||||
"unless-stopped",
|
||||
"--network",
|
||||
"penpot-net",
|
||||
"-e",
|
||||
&format!("PENPOT_SECRET_KEY={}", secret),
|
||||
"-e",
|
||||
"PENPOT_PUBLIC_URI=http://penpot-frontend:8080",
|
||||
"-e",
|
||||
"PENPOT_REDIS_URI=redis://penpot-valkey/0",
|
||||
"80.71.235.15:3000/archipelago/penpot-exporter:2.4",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
|
||||
let run = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
"penpot-frontend",
|
||||
"--restart",
|
||||
"unless-stopped",
|
||||
"--network",
|
||||
"penpot-net",
|
||||
"-p",
|
||||
"9001:8080",
|
||||
"-v",
|
||||
"/var/lib/archipelago/penpot-assets:/opt/data/assets",
|
||||
"-e",
|
||||
&format!("PENPOT_PUBLIC_URI=http://{}:9001", host_ip),
|
||||
"-e",
|
||||
"PENPOT_FLAGS=disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies",
|
||||
"80.71.235.15:3000/archipelago/penpot-frontend:2.4",
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to start penpot-frontend")?;
|
||||
|
||||
if !run.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&run.stderr);
|
||||
return Err(anyhow::anyhow!(
|
||||
"Failed to start Penpot frontend: {}",
|
||||
stderr
|
||||
));
|
||||
}
|
||||
|
||||
info!("Penpot stack installed and started");
|
||||
Ok(serde_json::json!({
|
||||
"success": true,
|
||||
"package_id": "penpot",
|
||||
"message": "Penpot stack installed and started"
|
||||
}))
|
||||
}
|
||||
}
|
||||
18
core/archipelago/src/api/rpc/package/validation.rs
Normal file
18
core/archipelago/src/api/rpc/package/validation.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
use anyhow::Result;
|
||||
|
||||
/// Validate that a package/app ID is safe (lowercase alphanumeric + hyphens, 1-64 chars).
|
||||
pub(in crate::api::rpc) fn validate_app_id(id: &str) -> Result<()> {
|
||||
if id.is_empty() || id.len() > 64 {
|
||||
anyhow::bail!("Invalid app id: must be 1-64 characters");
|
||||
}
|
||||
if !id
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
|
||||
{
|
||||
anyhow::bail!("Invalid app id: only lowercase letters, digits, and hyphens allowed");
|
||||
}
|
||||
if id.starts_with('-') {
|
||||
anyhow::bail!("Invalid app id: must not start with a hyphen");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -89,7 +89,25 @@ impl RpcHandler {
|
||||
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let pubkey = data.server_info.pubkey.clone();
|
||||
node_message::send_to_peer(onion, &pubkey, message).await?;
|
||||
|
||||
// Load signing key for E2E encryption
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let node_id = crate::identity::NodeIdentity::load_or_create(&identity_dir).await?;
|
||||
|
||||
// Look up recipient's pubkey from federation nodes
|
||||
let fed_nodes = federation::load_nodes(&self.config.data_dir).await.unwrap_or_default();
|
||||
let recipient_pubkey = fed_nodes.iter()
|
||||
.find(|n| n.onion == onion || n.onion == format!("{}.onion", onion)
|
||||
|| format!("{}.onion", n.onion) == onion)
|
||||
.map(|n| n.pubkey.clone());
|
||||
|
||||
node_message::send_to_peer(
|
||||
onion,
|
||||
&pubkey,
|
||||
message,
|
||||
Some(node_id.signing_key()),
|
||||
recipient_pubkey.as_deref(),
|
||||
).await?;
|
||||
Ok(serde_json::json!({ "ok": true, "sent_to": onion }))
|
||||
}
|
||||
|
||||
@@ -111,6 +129,20 @@ impl RpcHandler {
|
||||
Ok(serde_json::json!({ "messages": messages }))
|
||||
}
|
||||
|
||||
/// Store a sent message for Archipelago channel history persistence.
|
||||
pub(super) async fn handle_node_store_sent(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let message = params
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing message"))?;
|
||||
node_message::store_sent(message);
|
||||
Ok(serde_json::json!({ "ok": true }))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_node_nostr_discover(&self) -> Result<serde_json::Value> {
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let nodes = nostr_discovery::discover_archipelago_nodes(
|
||||
|
||||
67
core/archipelago/src/api/rpc/response.rs
Normal file
67
core/archipelago/src/api/rpc/response.rs
Normal file
@@ -0,0 +1,67 @@
|
||||
use hyper::{Response, StatusCode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct RpcRequest {
|
||||
pub method: String,
|
||||
pub params: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct RpcResponse {
|
||||
pub result: Option<serde_json::Value>,
|
||||
pub error: Option<RpcError>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct RpcError {
|
||||
pub code: i32,
|
||||
pub message: String,
|
||||
pub data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Simple TTL cache for read-only RPC responses.
|
||||
pub(super) struct ResponseCache {
|
||||
entries: tokio::sync::RwLock<std::collections::HashMap<String, (std::time::Instant, serde_json::Value)>>,
|
||||
ttl: std::time::Duration,
|
||||
}
|
||||
|
||||
impl ResponseCache {
|
||||
pub fn new(ttl_secs: u64) -> Self {
|
||||
Self {
|
||||
entries: tokio::sync::RwLock::new(std::collections::HashMap::new()),
|
||||
ttl: std::time::Duration::from_secs(ttl_secs),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get(&self, key: &str) -> Option<serde_json::Value> {
|
||||
let entries = self.entries.read().await;
|
||||
if let Some((ts, value)) = entries.get(key) {
|
||||
if ts.elapsed() < self.ttl {
|
||||
return Some(value.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn set(&self, key: String, value: serde_json::Value) {
|
||||
let mut entries = self.entries.write().await;
|
||||
entries.insert(key, (std::time::Instant::now(), value));
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a JSON HTTP response without unwrap. Falls back to a plain 500 if builder fails.
|
||||
pub(super) fn json_response(status: StatusCode, body: &[u8]) -> Response<hyper::Body> {
|
||||
Response::builder()
|
||||
.status(status)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(body.to_vec()))
|
||||
.unwrap_or_else(|_| {
|
||||
Response::new(hyper::Body::from(r#"{"error":{"code":500,"message":"Internal error"}}"#))
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a Set-Cookie header value, returning a default if parsing fails.
|
||||
pub(super) fn cookie_header(value: &str) -> hyper::header::HeaderValue {
|
||||
value.parse().unwrap_or_else(|_| hyper::header::HeaderValue::from_static(""))
|
||||
}
|
||||
316
core/archipelago/src/api/rpc/system/handlers.rs
Normal file
316
core/archipelago/src/api/rpc/system/handlers.rs
Normal file
@@ -0,0 +1,316 @@
|
||||
use super::*;
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::{debug, info};
|
||||
|
||||
impl RpcHandler {
|
||||
/// server.set-name — Rename the server (persisted to data_dir/server-name)
|
||||
pub(in crate::api::rpc) async fn handle_server_set_name(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let name = params
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: name"))?
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
if name.is_empty() || name.len() > 64 {
|
||||
anyhow::bail!("Name must be 1-64 characters");
|
||||
}
|
||||
|
||||
// Persist to file
|
||||
let name_file = self.config.data_dir.join("server-name");
|
||||
tokio::fs::write(&name_file, &name)
|
||||
.await
|
||||
.context("Failed to write server name")?;
|
||||
|
||||
// Update live state
|
||||
let (mut data, _) = self.state_manager.get_snapshot().await;
|
||||
data.server_info.name = Some(name.clone());
|
||||
self.state_manager.update_data(data).await;
|
||||
|
||||
info!("Server name updated to: {}", name);
|
||||
|
||||
// Push the new name to federation peers in background
|
||||
let data_dir = self.config.data_dir.clone();
|
||||
let state_manager = self.state_manager.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = push_name_to_peers(&data_dir, &state_manager).await {
|
||||
debug!("Federation name push (non-fatal): {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(serde_json::json!({ "name": name }))
|
||||
}
|
||||
|
||||
/// system.stats — CPU usage, RAM used/total, disk used/total, uptime, load average
|
||||
pub(in crate::api::rpc) async fn handle_system_stats(&self) -> Result<serde_json::Value> {
|
||||
debug!("Getting system stats");
|
||||
|
||||
let uptime = read_uptime().await.unwrap_or(0.0);
|
||||
let load = read_loadavg().await.unwrap_or((0.0, 0.0, 0.0));
|
||||
let cpu = read_cpu_usage().await.unwrap_or(0.0);
|
||||
let (mem_used, mem_total) = read_meminfo().await.unwrap_or((0, 0));
|
||||
let (disk_used, disk_total) = read_disk_usage().await.unwrap_or((0, 0));
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"uptime_secs": uptime as u64,
|
||||
"load_avg_1": load.0,
|
||||
"load_avg_5": load.1,
|
||||
"load_avg_15": load.2,
|
||||
"cpu_usage_percent": cpu,
|
||||
"mem_used_bytes": mem_used,
|
||||
"mem_total_bytes": mem_total,
|
||||
"disk_used_bytes": disk_used,
|
||||
"disk_total_bytes": disk_total,
|
||||
}))
|
||||
}
|
||||
|
||||
/// system.processes — top 10 processes by CPU
|
||||
pub(in crate::api::rpc) async fn handle_system_processes(&self) -> Result<serde_json::Value> {
|
||||
debug!("Getting top processes");
|
||||
|
||||
let procs = read_top_processes().await.unwrap_or_default();
|
||||
|
||||
Ok(serde_json::json!({ "processes": procs }))
|
||||
}
|
||||
|
||||
/// system.temperature — thermal zone readings
|
||||
pub(in crate::api::rpc) async fn handle_system_temperature(&self) -> Result<serde_json::Value> {
|
||||
debug!("Getting system temperature");
|
||||
|
||||
let temps = read_temperatures().await.unwrap_or_default();
|
||||
|
||||
Ok(serde_json::json!({ "temperatures": temps }))
|
||||
}
|
||||
|
||||
/// system.detect-usb-devices — scan for known hardware wallet USB devices
|
||||
pub(in crate::api::rpc) async fn handle_system_detect_usb_devices(&self) -> Result<serde_json::Value> {
|
||||
debug!("Scanning for USB hardware wallets");
|
||||
|
||||
let devices = detect_usb_hardware_wallets().await.unwrap_or_default();
|
||||
|
||||
Ok(serde_json::json!({ "devices": devices }))
|
||||
}
|
||||
|
||||
/// system.disk-status — Disk usage with warning/critical thresholds.
|
||||
pub(in crate::api::rpc) async fn handle_system_disk_status(&self) -> Result<serde_json::Value> {
|
||||
let (used, total) = read_disk_usage().await.unwrap_or((0, 0));
|
||||
let percent = if total > 0 {
|
||||
(used as f64 / total as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let percent_rounded = (percent * 10.0).round() / 10.0;
|
||||
|
||||
let level = if percent >= 90.0 {
|
||||
"critical"
|
||||
} else if percent >= 85.0 {
|
||||
"warning"
|
||||
} else {
|
||||
"ok"
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"used_bytes": used,
|
||||
"total_bytes": total,
|
||||
"free_bytes": total.saturating_sub(used),
|
||||
"used_percent": percent_rounded,
|
||||
"level": level,
|
||||
}))
|
||||
}
|
||||
|
||||
/// system.disk-cleanup — Remove old container images, stale logs, and temp files.
|
||||
pub(in crate::api::rpc) async fn handle_system_disk_cleanup(&self) -> Result<serde_json::Value> {
|
||||
tracing::info!("Starting disk cleanup");
|
||||
let mut freed_bytes: u64 = 0;
|
||||
let mut actions: Vec<String> = Vec::new();
|
||||
|
||||
// 1. Prune dangling container images
|
||||
match prune_container_images().await {
|
||||
Ok(bytes) => {
|
||||
if bytes > 0 {
|
||||
freed_bytes += bytes;
|
||||
actions.push(format!("Pruned dangling images: {} freed", format_bytes(bytes)));
|
||||
}
|
||||
}
|
||||
Err(e) => actions.push(format!("Image prune failed: {}", e)),
|
||||
}
|
||||
|
||||
// 2. Clean old log files (> 30 days)
|
||||
match clean_old_logs(30).await {
|
||||
Ok(bytes) => {
|
||||
if bytes > 0 {
|
||||
freed_bytes += bytes;
|
||||
actions.push(format!("Cleaned old logs: {} freed", format_bytes(bytes)));
|
||||
}
|
||||
}
|
||||
Err(e) => actions.push(format!("Log cleanup failed: {}", e)),
|
||||
}
|
||||
|
||||
// 3. Remove stale temp files
|
||||
match clean_temp_files().await {
|
||||
Ok(bytes) => {
|
||||
if bytes > 0 {
|
||||
freed_bytes += bytes;
|
||||
actions.push(format!("Removed temp files: {} freed", format_bytes(bytes)));
|
||||
}
|
||||
}
|
||||
Err(e) => actions.push(format!("Temp cleanup failed: {}", e)),
|
||||
}
|
||||
|
||||
// 4. Prune container build cache
|
||||
match prune_build_cache().await {
|
||||
Ok(bytes) => {
|
||||
if bytes > 0 {
|
||||
freed_bytes += bytes;
|
||||
actions.push(format!("Pruned build cache: {} freed", format_bytes(bytes)));
|
||||
}
|
||||
}
|
||||
Err(e) => actions.push(format!("Build cache prune failed: {}", e)),
|
||||
}
|
||||
|
||||
tracing::info!("Disk cleanup complete: {} freed ({} actions)", format_bytes(freed_bytes), actions.len());
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"freed_bytes": freed_bytes,
|
||||
"freed_human": format_bytes(freed_bytes),
|
||||
"actions": actions,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// system.factory-reset — Wipe all user data, remove containers, and restart.
|
||||
/// Only preserves the data_dir itself (recreated empty on restart).
|
||||
/// system.reboot — Reboot the machine. Requires password re-verification.
|
||||
pub(in crate::api::rpc) async fn handle_system_reboot(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let password = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("password"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing password — re-authentication required"))?;
|
||||
|
||||
let valid = self.auth_manager.verify_password(password).await?;
|
||||
if !valid {
|
||||
return Err(anyhow::anyhow!("Password incorrect"));
|
||||
}
|
||||
|
||||
info!("System reboot initiated by user");
|
||||
|
||||
// Schedule reboot in 2 seconds (gives time for the RPC response to reach the client)
|
||||
// Uses the tor-helper path unit pattern (writes action file, systemd triggers root service)
|
||||
tokio::spawn(async {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
let action = serde_json::json!({"action": "reboot"});
|
||||
let _ = tokio::fs::write(
|
||||
"/var/lib/archipelago/tor-config/tor-action",
|
||||
serde_json::to_string(&action).unwrap_or_default(),
|
||||
).await;
|
||||
});
|
||||
|
||||
Ok(serde_json::json!({ "rebooting": true }))
|
||||
}
|
||||
|
||||
pub(in crate::api::rpc) async fn handle_system_factory_reset(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
// Safety check: require { confirm: true }
|
||||
let confirmed = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("confirm"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
if !confirmed {
|
||||
anyhow::bail!("Factory reset requires {{ \"confirm\": true }}");
|
||||
}
|
||||
|
||||
// Require password re-authentication for destructive operations
|
||||
let password = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("password"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing password — re-authentication required"))?;
|
||||
|
||||
let valid = self.auth_manager.verify_password(password).await?;
|
||||
if !valid {
|
||||
return Err(anyhow::anyhow!("Password Incorrect"));
|
||||
}
|
||||
|
||||
tracing::warn!("Factory reset initiated — wiping ALL user data and containers");
|
||||
|
||||
let data_dir = &self.config.data_dir;
|
||||
|
||||
// 1. Stop and remove ALL containers (force)
|
||||
let client = archipelago_container::PodmanClient::new("archipelago".to_string());
|
||||
if let Ok(containers) = client.list_containers().await {
|
||||
for c in &containers {
|
||||
tracing::info!("Factory reset: removing container {}", c.name);
|
||||
let _ = client.stop_container(&c.name).await;
|
||||
let _ = client.remove_container(&c.name).await;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Remove all container images
|
||||
tracing::info!("Factory reset: pruning all container images");
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["rmi", "--all", "--force"])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
// 3. Prune volumes and build cache
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["volume", "prune", "-f"])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["system", "prune", "-af"])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
// 4. Wipe the entire data directory contents
|
||||
// Delete everything inside data_dir, then recreate the empty dir.
|
||||
if let Ok(mut entries) = tokio::fs::read_dir(data_dir).await {
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
let path = entry.path();
|
||||
let name = entry.file_name();
|
||||
let name_str = name.to_string_lossy();
|
||||
|
||||
// Skip the tor directory (managed by system debian-tor user)
|
||||
if name_str == "tor" {
|
||||
continue;
|
||||
}
|
||||
|
||||
tracing::info!("Factory reset: removing {}", path.display());
|
||||
if path.is_dir() {
|
||||
let _ = tokio::fs::remove_dir_all(&path).await;
|
||||
} else {
|
||||
let _ = tokio::fs::remove_file(&path).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Clear all sessions
|
||||
self.session_store.invalidate_all_except("").await;
|
||||
|
||||
tracing::warn!("Factory reset complete — all data wiped, restarting service");
|
||||
|
||||
// Restart the service via systemd
|
||||
tokio::spawn(async {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
let _ = std::process::Command::new("sudo")
|
||||
.args(["systemctl", "restart", "archipelago"])
|
||||
.spawn();
|
||||
});
|
||||
|
||||
Ok(serde_json::json!({ "status": "resetting" }))
|
||||
}
|
||||
}
|
||||
@@ -1,179 +1,48 @@
|
||||
use super::RpcHandler;
|
||||
mod handlers;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::debug;
|
||||
use tracing::{debug, info};
|
||||
|
||||
impl RpcHandler {
|
||||
/// server.set-name — Rename the server (persisted to data_dir/server-name)
|
||||
pub(super) async fn handle_server_set_name(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let name = params
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: name"))?
|
||||
.trim()
|
||||
.to_string();
|
||||
/// Push the server name to all federation peers by syncing state.
|
||||
pub(super) async fn push_name_to_peers(
|
||||
data_dir: &std::path::Path,
|
||||
state_manager: &std::sync::Arc<crate::state::StateManager>,
|
||||
) -> Result<()> {
|
||||
use crate::{federation, identity};
|
||||
|
||||
if name.is_empty() || name.len() > 64 {
|
||||
anyhow::bail!("Name must be 1-64 characters");
|
||||
let nodes = federation::load_nodes(data_dir).await?;
|
||||
if nodes.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let (data, _) = state_manager.get_snapshot().await;
|
||||
let local_did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
let identity_dir = data_dir.join("identity");
|
||||
let node_identity = identity::NodeIdentity::load_or_create(&identity_dir).await?;
|
||||
|
||||
let mut synced = 0u32;
|
||||
for node in &nodes {
|
||||
if node.trust_level == federation::TrustLevel::Untrusted {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Persist to file
|
||||
let name_file = self.config.data_dir.join("server-name");
|
||||
tokio::fs::write(&name_file, &name)
|
||||
.await
|
||||
.context("Failed to write server name")?;
|
||||
|
||||
// Update live state
|
||||
let (mut data, _) = self.state_manager.get_snapshot().await;
|
||||
data.server_info.name = Some(name.clone());
|
||||
self.state_manager.update_data(data).await;
|
||||
|
||||
debug!("Server name updated to: {}", name);
|
||||
Ok(serde_json::json!({ "name": name }))
|
||||
}
|
||||
|
||||
/// system.stats — CPU usage, RAM used/total, disk used/total, uptime, load average
|
||||
pub(super) async fn handle_system_stats(&self) -> Result<serde_json::Value> {
|
||||
debug!("Getting system stats");
|
||||
|
||||
let uptime = read_uptime().await.unwrap_or(0.0);
|
||||
let load = read_loadavg().await.unwrap_or((0.0, 0.0, 0.0));
|
||||
let cpu = read_cpu_usage().await.unwrap_or(0.0);
|
||||
let (mem_used, mem_total) = read_meminfo().await.unwrap_or((0, 0));
|
||||
let (disk_used, disk_total) = read_disk_usage().await.unwrap_or((0, 0));
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"uptime_secs": uptime as u64,
|
||||
"load_avg_1": load.0,
|
||||
"load_avg_5": load.1,
|
||||
"load_avg_15": load.2,
|
||||
"cpu_usage_percent": cpu,
|
||||
"mem_used_bytes": mem_used,
|
||||
"mem_total_bytes": mem_total,
|
||||
"disk_used_bytes": disk_used,
|
||||
"disk_total_bytes": disk_total,
|
||||
}))
|
||||
}
|
||||
|
||||
/// system.processes — top 10 processes by CPU
|
||||
pub(super) async fn handle_system_processes(&self) -> Result<serde_json::Value> {
|
||||
debug!("Getting top processes");
|
||||
|
||||
let procs = read_top_processes().await.unwrap_or_default();
|
||||
|
||||
Ok(serde_json::json!({ "processes": procs }))
|
||||
}
|
||||
|
||||
/// system.temperature — thermal zone readings
|
||||
pub(super) async fn handle_system_temperature(&self) -> Result<serde_json::Value> {
|
||||
debug!("Getting system temperature");
|
||||
|
||||
let temps = read_temperatures().await.unwrap_or_default();
|
||||
|
||||
Ok(serde_json::json!({ "temperatures": temps }))
|
||||
}
|
||||
|
||||
/// system.detect-usb-devices — scan for known hardware wallet USB devices
|
||||
pub(super) async fn handle_system_detect_usb_devices(&self) -> Result<serde_json::Value> {
|
||||
debug!("Scanning for USB hardware wallets");
|
||||
|
||||
let devices = detect_usb_hardware_wallets().await.unwrap_or_default();
|
||||
|
||||
Ok(serde_json::json!({ "devices": devices }))
|
||||
}
|
||||
|
||||
/// system.disk-status — Disk usage with warning/critical thresholds.
|
||||
pub(super) async fn handle_system_disk_status(&self) -> Result<serde_json::Value> {
|
||||
let (used, total) = read_disk_usage().await.unwrap_or((0, 0));
|
||||
let percent = if total > 0 {
|
||||
(used as f64 / total as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let percent_rounded = (percent * 10.0).round() / 10.0;
|
||||
|
||||
let level = if percent >= 90.0 {
|
||||
"critical"
|
||||
} else if percent >= 85.0 {
|
||||
"warning"
|
||||
} else {
|
||||
"ok"
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"used_bytes": used,
|
||||
"total_bytes": total,
|
||||
"free_bytes": total.saturating_sub(used),
|
||||
"used_percent": percent_rounded,
|
||||
"level": level,
|
||||
}))
|
||||
}
|
||||
|
||||
/// system.disk-cleanup — Remove old container images, stale logs, and temp files.
|
||||
pub(super) async fn handle_system_disk_cleanup(&self) -> Result<serde_json::Value> {
|
||||
tracing::info!("Starting disk cleanup");
|
||||
let mut freed_bytes: u64 = 0;
|
||||
let mut actions: Vec<String> = Vec::new();
|
||||
|
||||
// 1. Prune dangling container images
|
||||
match prune_container_images().await {
|
||||
Ok(bytes) => {
|
||||
if bytes > 0 {
|
||||
freed_bytes += bytes;
|
||||
actions.push(format!("Pruned dangling images: {} freed", format_bytes(bytes)));
|
||||
}
|
||||
}
|
||||
Err(e) => actions.push(format!("Image prune failed: {}", e)),
|
||||
match federation::sync_with_peer(
|
||||
data_dir,
|
||||
node,
|
||||
&local_did,
|
||||
|bytes| node_identity.sign(bytes),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => synced += 1,
|
||||
Err(e) => debug!("Sync with {} after rename: {}", node.did.chars().take(20).collect::<String>(), e),
|
||||
}
|
||||
|
||||
// 2. Clean old log files (> 30 days)
|
||||
match clean_old_logs(30).await {
|
||||
Ok(bytes) => {
|
||||
if bytes > 0 {
|
||||
freed_bytes += bytes;
|
||||
actions.push(format!("Cleaned old logs: {} freed", format_bytes(bytes)));
|
||||
}
|
||||
}
|
||||
Err(e) => actions.push(format!("Log cleanup failed: {}", e)),
|
||||
}
|
||||
|
||||
// 3. Remove stale temp files
|
||||
match clean_temp_files().await {
|
||||
Ok(bytes) => {
|
||||
if bytes > 0 {
|
||||
freed_bytes += bytes;
|
||||
actions.push(format!("Removed temp files: {} freed", format_bytes(bytes)));
|
||||
}
|
||||
}
|
||||
Err(e) => actions.push(format!("Temp cleanup failed: {}", e)),
|
||||
}
|
||||
|
||||
// 4. Prune container build cache
|
||||
match prune_build_cache().await {
|
||||
Ok(bytes) => {
|
||||
if bytes > 0 {
|
||||
freed_bytes += bytes;
|
||||
actions.push(format!("Pruned build cache: {} freed", format_bytes(bytes)));
|
||||
}
|
||||
}
|
||||
Err(e) => actions.push(format!("Build cache prune failed: {}", e)),
|
||||
}
|
||||
|
||||
tracing::info!("Disk cleanup complete: {} freed ({} actions)", format_bytes(freed_bytes), actions.len());
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"freed_bytes": freed_bytes,
|
||||
"freed_human": format_bytes(freed_bytes),
|
||||
"actions": actions,
|
||||
}))
|
||||
}
|
||||
info!("Pushed server name to {}/{} peers", synced, nodes.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read system uptime from /proc/uptime (seconds since boot).
|
||||
async fn read_uptime() -> Result<f64> {
|
||||
pub(super) async fn read_uptime() -> Result<f64> {
|
||||
let content = tokio::fs::read_to_string("/proc/uptime")
|
||||
.await
|
||||
.context("Failed to read /proc/uptime")?;
|
||||
@@ -187,7 +56,7 @@ async fn read_uptime() -> Result<f64> {
|
||||
}
|
||||
|
||||
/// Read load averages from /proc/loadavg.
|
||||
async fn read_loadavg() -> Result<(f64, f64, f64)> {
|
||||
pub(super) async fn read_loadavg() -> Result<(f64, f64, f64)> {
|
||||
let content = tokio::fs::read_to_string("/proc/loadavg")
|
||||
.await
|
||||
.context("Failed to read /proc/loadavg")?;
|
||||
@@ -211,7 +80,7 @@ async fn read_loadavg() -> Result<(f64, f64, f64)> {
|
||||
}
|
||||
|
||||
/// Compute CPU usage by sampling /proc/stat twice with a 250ms gap.
|
||||
async fn read_cpu_usage() -> Result<f64> {
|
||||
pub(super) async fn read_cpu_usage() -> Result<f64> {
|
||||
let snap1 = read_cpu_jiffies().await?;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
|
||||
let snap2 = read_cpu_jiffies().await?;
|
||||
@@ -256,7 +125,7 @@ async fn read_cpu_jiffies() -> Result<CpuJiffies> {
|
||||
|
||||
/// Read memory info from /proc/meminfo.
|
||||
/// Returns (used_bytes, total_bytes).
|
||||
async fn read_meminfo() -> Result<(u64, u64)> {
|
||||
pub(super) async fn read_meminfo() -> Result<(u64, u64)> {
|
||||
let content = tokio::fs::read_to_string("/proc/meminfo")
|
||||
.await
|
||||
.context("Failed to read /proc/meminfo")?;
|
||||
@@ -277,7 +146,7 @@ async fn read_meminfo() -> Result<(u64, u64)> {
|
||||
Ok((used_bytes, total_bytes))
|
||||
}
|
||||
|
||||
fn parse_meminfo_kb(val: &str) -> Result<u64> {
|
||||
pub(super) fn parse_meminfo_kb(val: &str) -> Result<u64> {
|
||||
val.trim()
|
||||
.trim_end_matches("kB")
|
||||
.trim()
|
||||
@@ -287,7 +156,7 @@ fn parse_meminfo_kb(val: &str) -> Result<u64> {
|
||||
|
||||
/// Read disk usage via `df` for the root filesystem.
|
||||
/// Returns (used_bytes, total_bytes).
|
||||
async fn read_disk_usage() -> Result<(u64, u64)> {
|
||||
pub(super) async fn read_disk_usage() -> Result<(u64, u64)> {
|
||||
let output = tokio::process::Command::new("df")
|
||||
.args(["--block-size=1", "--output=used,size", "/"])
|
||||
.output()
|
||||
@@ -320,7 +189,7 @@ async fn read_disk_usage() -> Result<(u64, u64)> {
|
||||
}
|
||||
|
||||
/// Read top 10 processes by CPU from `ps`.
|
||||
async fn read_top_processes() -> Result<Vec<serde_json::Value>> {
|
||||
pub(super) async fn read_top_processes() -> Result<Vec<serde_json::Value>> {
|
||||
let output = tokio::process::Command::new("ps")
|
||||
.args(["--no-headers", "-eo", "pid,%cpu,%mem,comm", "--sort=-%cpu"])
|
||||
.output()
|
||||
@@ -362,7 +231,7 @@ const KNOWN_HW_WALLETS: &[(u16, &str)] = &[
|
||||
];
|
||||
|
||||
/// Scan /sys/bus/usb/devices/ for known hardware wallet vendor IDs.
|
||||
async fn detect_usb_hardware_wallets() -> Result<Vec<serde_json::Value>> {
|
||||
pub(super) async fn detect_usb_hardware_wallets() -> Result<Vec<serde_json::Value>> {
|
||||
let usb_dir = std::path::Path::new("/sys/bus/usb/devices");
|
||||
if !usb_dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
@@ -424,7 +293,7 @@ async fn detect_usb_hardware_wallets() -> Result<Vec<serde_json::Value>> {
|
||||
|
||||
/// Prune dangling container images via `podman image prune -f`.
|
||||
/// Returns estimated bytes freed.
|
||||
async fn prune_container_images() -> Result<u64> {
|
||||
pub(super) async fn prune_container_images() -> Result<u64> {
|
||||
let output = tokio::process::Command::new("podman")
|
||||
.args(["image", "prune", "-f"])
|
||||
.output()
|
||||
@@ -445,7 +314,7 @@ async fn prune_container_images() -> Result<u64> {
|
||||
}
|
||||
|
||||
/// Prune container build cache via `podman system prune -f`.
|
||||
async fn prune_build_cache() -> Result<u64> {
|
||||
pub(super) async fn prune_build_cache() -> Result<u64> {
|
||||
// Just prune volumes and build cache (not containers or images — those are handled above)
|
||||
let output = tokio::process::Command::new("podman")
|
||||
.args(["volume", "prune", "-f"])
|
||||
@@ -466,7 +335,7 @@ async fn prune_build_cache() -> Result<u64> {
|
||||
}
|
||||
|
||||
/// Clean log files older than `max_age_days` from common log directories.
|
||||
async fn clean_old_logs(max_age_days: u64) -> Result<u64> {
|
||||
pub(super) async fn clean_old_logs(max_age_days: u64) -> Result<u64> {
|
||||
let output = tokio::process::Command::new("sudo")
|
||||
.args([
|
||||
"find",
|
||||
@@ -506,7 +375,7 @@ async fn clean_old_logs(max_age_days: u64) -> Result<u64> {
|
||||
}
|
||||
|
||||
/// Remove stale temp files from /tmp and /var/tmp.
|
||||
async fn clean_temp_files() -> Result<u64> {
|
||||
pub(super) async fn clean_temp_files() -> Result<u64> {
|
||||
let mut freed = 0u64;
|
||||
|
||||
for dir in &["/tmp", "/var/tmp"] {
|
||||
@@ -534,7 +403,7 @@ async fn clean_temp_files() -> Result<u64> {
|
||||
Ok(freed)
|
||||
}
|
||||
|
||||
fn format_bytes(bytes: u64) -> String {
|
||||
pub(super) fn format_bytes(bytes: u64) -> String {
|
||||
const KB: u64 = 1024;
|
||||
const MB: u64 = KB * 1024;
|
||||
const GB: u64 = MB * 1024;
|
||||
@@ -551,7 +420,7 @@ fn format_bytes(bytes: u64) -> String {
|
||||
}
|
||||
|
||||
/// Read temperatures from /sys/class/thermal/thermal_zone*/temp.
|
||||
async fn read_temperatures() -> Result<Vec<serde_json::Value>> {
|
||||
pub(super) async fn read_temperatures() -> Result<Vec<serde_json::Value>> {
|
||||
let mut temps = Vec::new();
|
||||
let thermal_dir = std::path::Path::new("/sys/class/thermal");
|
||||
if !thermal_dir.exists() {
|
||||
@@ -590,91 +459,3 @@ async fn read_temperatures() -> Result<Vec<serde_json::Value>> {
|
||||
|
||||
Ok(temps)
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// system.factory-reset — Wipe all user data, remove containers, and restart.
|
||||
/// Only preserves the data_dir itself (recreated empty on restart).
|
||||
pub(super) async fn handle_system_factory_reset(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
// Safety check: require { confirm: true }
|
||||
let confirmed = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("confirm"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
if !confirmed {
|
||||
anyhow::bail!("Factory reset requires {{ \"confirm\": true }}");
|
||||
}
|
||||
|
||||
tracing::warn!("Factory reset initiated — wiping ALL user data and containers");
|
||||
|
||||
let data_dir = &self.config.data_dir;
|
||||
|
||||
// 1. Stop and remove ALL containers (force)
|
||||
let client = archipelago_container::PodmanClient::new("archipelago".to_string());
|
||||
if let Ok(containers) = client.list_containers().await {
|
||||
for c in &containers {
|
||||
tracing::info!("Factory reset: removing container {}", c.name);
|
||||
let _ = client.stop_container(&c.name).await;
|
||||
let _ = client.remove_container(&c.name).await;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Remove all container images
|
||||
tracing::info!("Factory reset: pruning all container images");
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["rmi", "--all", "--force"])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
// 3. Prune volumes and build cache
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["volume", "prune", "-f"])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["system", "prune", "-af"])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
// 4. Wipe the entire data directory contents
|
||||
// Delete everything inside data_dir, then recreate the empty dir.
|
||||
if let Ok(mut entries) = tokio::fs::read_dir(data_dir).await {
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
let path = entry.path();
|
||||
let name = entry.file_name();
|
||||
let name_str = name.to_string_lossy();
|
||||
|
||||
// Skip the tor directory (managed by system debian-tor user)
|
||||
if name_str == "tor" {
|
||||
continue;
|
||||
}
|
||||
|
||||
tracing::info!("Factory reset: removing {}", path.display());
|
||||
if path.is_dir() {
|
||||
let _ = tokio::fs::remove_dir_all(&path).await;
|
||||
} else {
|
||||
let _ = tokio::fs::remove_file(&path).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Clear all sessions
|
||||
self.session_store.invalidate_all_except("").await;
|
||||
|
||||
tracing::warn!("Factory reset complete — all data wiped, restarting service");
|
||||
|
||||
// Restart the service via systemd
|
||||
tokio::spawn(async {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
let _ = std::process::Command::new("sudo")
|
||||
.args(["systemctl", "restart", "archipelago"])
|
||||
.spawn();
|
||||
});
|
||||
|
||||
Ok(serde_json::json!({ "status": "resetting" }))
|
||||
}
|
||||
}
|
||||
@@ -1,559 +0,0 @@
|
||||
use super::RpcHandler;
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::{federation, identity};
|
||||
|
||||
const TOR_DATA_DIR: &str = "/var/lib/archipelago/tor";
|
||||
const SERVICES_CONFIG: &str = "services.json";
|
||||
/// How long old service directories are kept during transition (seconds).
|
||||
const ROTATION_TRANSITION_SECS: u64 = 86400; // 24 hours
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct TorService {
|
||||
name: String,
|
||||
local_port: u16,
|
||||
onion_address: Option<String>,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
struct ServicesConfig {
|
||||
services: Vec<TorServiceEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct TorServiceEntry {
|
||||
name: String,
|
||||
local_port: u16,
|
||||
#[serde(default = "default_true")]
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// List all configured hidden services with their .onion addresses.
|
||||
pub(super) async fn handle_tor_list_services(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
let config_dir = self.config.data_dir.join("tor-config");
|
||||
let services = list_services(&config_dir).await?;
|
||||
Ok(serde_json::json!({ "services": services }))
|
||||
}
|
||||
|
||||
/// Create a new hidden service for a given local port.
|
||||
pub(super) async fn handle_tor_create_service(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let name = params
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing name"))?;
|
||||
let local_port = params
|
||||
.get("local_port")
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing local_port"))? as u16;
|
||||
|
||||
// Validate name
|
||||
if name.is_empty() || name.len() > 64 || !name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
|
||||
return Err(anyhow::anyhow!("Invalid service name (alphanumeric, hyphens, underscores only)"));
|
||||
}
|
||||
|
||||
let config_dir = self.config.data_dir.join("tor-config");
|
||||
let mut config = load_services_config(&config_dir).await;
|
||||
if config.services.iter().any(|s| s.name == name) {
|
||||
return Err(anyhow::anyhow!("Service '{}' already exists", name));
|
||||
}
|
||||
|
||||
config.services.push(TorServiceEntry {
|
||||
name: name.to_string(),
|
||||
local_port,
|
||||
enabled: true,
|
||||
});
|
||||
save_services_config(&config_dir, &config).await?;
|
||||
|
||||
debug!("Tor service created: {} -> port {}", name, local_port);
|
||||
Ok(serde_json::json!({ "created": true, "name": name }))
|
||||
}
|
||||
|
||||
/// Delete a hidden service.
|
||||
pub(super) async fn handle_tor_delete_service(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let name = params
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing name"))?;
|
||||
|
||||
let config_dir = self.config.data_dir.join("tor-config");
|
||||
let mut config = load_services_config(&config_dir).await;
|
||||
let before = config.services.len();
|
||||
config.services.retain(|s| s.name != name);
|
||||
if config.services.len() == before {
|
||||
return Err(anyhow::anyhow!("Service '{}' not found", name));
|
||||
}
|
||||
save_services_config(&config_dir, &config).await?;
|
||||
|
||||
debug!("Tor service deleted: {}", name);
|
||||
Ok(serde_json::json!({ "deleted": true, "name": name }))
|
||||
}
|
||||
|
||||
/// Get the .onion address for a specific service.
|
||||
pub(super) async fn handle_tor_get_onion_address(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let name = params
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing name"))?;
|
||||
|
||||
let onion = read_onion_address(name);
|
||||
Ok(serde_json::json!({ "name": name, "onion_address": onion }))
|
||||
}
|
||||
|
||||
/// Rotate a hidden service's .onion address by generating a new keypair.
|
||||
/// The old service directory is renamed for a 24h transition period.
|
||||
pub(super) async fn handle_tor_rotate_service(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let name = params
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing name"))?;
|
||||
|
||||
let base = tor_data_dir();
|
||||
let service_dir = format!("{}/hidden_service_{}", base, name);
|
||||
|
||||
// Read old .onion address before rotation
|
||||
let old_onion = read_onion_address(name);
|
||||
if old_onion.is_none() {
|
||||
return Err(anyhow::anyhow!("Service '{}' has no .onion address to rotate", name));
|
||||
}
|
||||
|
||||
// Delete old service directory immediately — no transition period
|
||||
let delete_status = tokio::process::Command::new("sudo")
|
||||
.args(["rm", "-rf", &service_dir])
|
||||
.status()
|
||||
.await
|
||||
.context("Failed to delete hidden service directory")?;
|
||||
|
||||
if !delete_status.success() {
|
||||
return Err(anyhow::anyhow!("Failed to delete hidden service directory for rotation"));
|
||||
}
|
||||
|
||||
// Clear the readable tor-hostnames cache so wait_for_hostname reads the new key
|
||||
let hostnames_dir = std::path::Path::new(&base)
|
||||
.parent()
|
||||
.unwrap_or(std::path::Path::new("/var/lib/archipelago"))
|
||||
.join("tor-hostnames");
|
||||
let _ = tokio::fs::remove_file(hostnames_dir.join(name)).await;
|
||||
|
||||
info!(service = name, old_onion = ?old_onion, "Rotated Tor service — restarting Tor");
|
||||
|
||||
// Try system Tor first (hidden services may be in /etc/tor/torrc), then container
|
||||
let system_ok = tokio::process::Command::new("sudo")
|
||||
.args(["systemctl", "restart", "tor"])
|
||||
.status()
|
||||
.await
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
|
||||
if !system_ok {
|
||||
// Fall back to container restart
|
||||
let container_ok = tokio::process::Command::new("podman")
|
||||
.args(["restart", "archy-tor"])
|
||||
.status()
|
||||
.await
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
if !container_ok {
|
||||
warn!("Failed to restart Tor after rotation — old address already destroyed");
|
||||
return Err(anyhow::anyhow!("Failed to restart Tor — old address destroyed, Tor will generate new key on next restart"));
|
||||
}
|
||||
}
|
||||
|
||||
// Wait up to 60s for new hostname file to appear
|
||||
let new_onion = wait_for_hostname(name, 60).await;
|
||||
|
||||
// Update the readable tor-hostnames copy
|
||||
if let Some(ref new_addr) = new_onion {
|
||||
let hostnames_dir = std::path::Path::new(&base)
|
||||
.parent()
|
||||
.unwrap_or(std::path::Path::new("/var/lib/archipelago"))
|
||||
.join("tor-hostnames");
|
||||
if let Err(e) = tokio::fs::create_dir_all(&hostnames_dir).await {
|
||||
warn!("Failed to create tor-hostnames dir: {}", e);
|
||||
}
|
||||
if let Err(e) = tokio::fs::write(hostnames_dir.join(name), new_addr).await {
|
||||
warn!("Failed to update tor-hostnames copy: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Notify federation peers of address change (private peer-to-peer, no public relays)
|
||||
if let Some(ref new_addr) = new_onion {
|
||||
let data_dir = self.config.data_dir.clone();
|
||||
let tor_proxy = self.config.nostr_tor_proxy.clone();
|
||||
let new_addr_clone = new_addr.clone();
|
||||
let old_onion_clone = old_onion.clone();
|
||||
tokio::spawn(async move {
|
||||
notify_federation_peers_address_change(
|
||||
&data_dir,
|
||||
&new_addr_clone,
|
||||
old_onion_clone.as_deref(),
|
||||
tor_proxy.as_deref(),
|
||||
).await;
|
||||
});
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"rotated": true,
|
||||
"name": name,
|
||||
"old_onion": old_onion,
|
||||
"new_onion": new_onion,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Clean up expired rotated service directories past the transition period.
|
||||
pub(super) async fn handle_tor_cleanup_rotated(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
let base = tor_data_dir();
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
let mut cleaned = Vec::new();
|
||||
if let Ok(entries) = std::fs::read_dir(&base) {
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if !name.contains("_old_") {
|
||||
continue;
|
||||
}
|
||||
// Parse timestamp from suffix: hidden_service_NAME_old_TIMESTAMP
|
||||
if let Some(ts_str) = name.rsplit('_').next() {
|
||||
if let Ok(ts) = ts_str.parse::<u64>() {
|
||||
if now - ts > ROTATION_TRANSITION_SECS {
|
||||
let path = entry.path();
|
||||
let status = tokio::process::Command::new("sudo")
|
||||
.args(["rm", "-rf", &path.to_string_lossy()])
|
||||
.status()
|
||||
.await;
|
||||
if status.map(|s| s.success()).unwrap_or(false) {
|
||||
info!(dir = %name, "Cleaned up expired rotated Tor service");
|
||||
cleaned.push(name);
|
||||
} else {
|
||||
warn!(dir = %name, "Failed to clean up rotated Tor service");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({ "cleaned": cleaned, "count": cleaned.len() }))
|
||||
}
|
||||
|
||||
/// Toggle Tor access for a specific app (enable/disable).
|
||||
pub(super) async fn handle_tor_toggle_app(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let app_id = params
|
||||
.get("app_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
|
||||
let enabled = params
|
||||
.get("enabled")
|
||||
.and_then(|v| v.as_bool())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing enabled (bool)"))?;
|
||||
|
||||
let config_dir = self.config.data_dir.join("tor-config");
|
||||
let mut config = load_services_config(&config_dir).await;
|
||||
|
||||
// Find the service entry for this app
|
||||
let found = config.services.iter_mut().find(|s| s.name == app_id);
|
||||
match found {
|
||||
Some(entry) => {
|
||||
if entry.enabled == enabled {
|
||||
return Ok(serde_json::json!({
|
||||
"app_id": app_id,
|
||||
"enabled": enabled,
|
||||
"changed": false,
|
||||
}));
|
||||
}
|
||||
entry.enabled = enabled;
|
||||
}
|
||||
None => {
|
||||
if !enabled {
|
||||
// Nothing to disable — doesn't exist
|
||||
return Ok(serde_json::json!({
|
||||
"app_id": app_id,
|
||||
"enabled": false,
|
||||
"changed": false,
|
||||
}));
|
||||
}
|
||||
// Add new entry
|
||||
let port = known_service_port(app_id);
|
||||
config.services.push(TorServiceEntry {
|
||||
name: app_id.to_string(),
|
||||
local_port: port,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
save_services_config(&config_dir, &config).await?;
|
||||
|
||||
let base = tor_data_dir();
|
||||
let service_dir = format!("{}/hidden_service_{}", base, app_id);
|
||||
|
||||
if !enabled {
|
||||
// Remove the hidden service directory so Tor stops serving it
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["rm", "-rf", &service_dir])
|
||||
.status()
|
||||
.await;
|
||||
info!(app = app_id, "Disabled Tor access — removed hidden service dir");
|
||||
}
|
||||
|
||||
// Restart Tor to apply changes — try system service first, then container
|
||||
let system_ok = tokio::process::Command::new("sudo")
|
||||
.args(["systemctl", "restart", "tor"])
|
||||
.status()
|
||||
.await
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
|
||||
if !system_ok {
|
||||
let container_ok = tokio::process::Command::new("podman")
|
||||
.args(["restart", "archy-tor"])
|
||||
.status()
|
||||
.await
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
if !container_ok {
|
||||
warn!("Failed to restart Tor after toggle");
|
||||
}
|
||||
}
|
||||
|
||||
// If enabling, wait for hostname to appear
|
||||
let new_onion = if enabled {
|
||||
wait_for_hostname(app_id, 60).await
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"app_id": app_id,
|
||||
"enabled": enabled,
|
||||
"changed": true,
|
||||
"onion_address": new_onion,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// List all hidden services by scanning the filesystem and merging with config.
|
||||
async fn list_services(config_dir: &std::path::Path) -> Result<Vec<TorService>> {
|
||||
let base = tor_data_dir();
|
||||
let config = load_services_config(config_dir).await;
|
||||
let mut services = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
|
||||
// First, add services from config
|
||||
for entry in &config.services {
|
||||
let onion = read_onion_address(&entry.name);
|
||||
seen.insert(entry.name.clone());
|
||||
services.push(TorService {
|
||||
name: entry.name.clone(),
|
||||
local_port: entry.local_port,
|
||||
onion_address: onion,
|
||||
enabled: entry.enabled,
|
||||
});
|
||||
}
|
||||
|
||||
// Then, scan filesystem for any hidden_service_* dirs not in config
|
||||
// Check both /var/lib/tor/ and /var/lib/archipelago/tor/
|
||||
for scan_dir in ["/var/lib/tor", &base] {
|
||||
if let Ok(entries) = std::fs::read_dir(scan_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.starts_with("hidden_service_") && entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
|
||||
let service_name = name.strip_prefix("hidden_service_").unwrap_or(&name).to_string();
|
||||
if seen.contains(&service_name) {
|
||||
continue;
|
||||
}
|
||||
let onion = read_onion_address(&service_name);
|
||||
let port = known_service_port(&service_name);
|
||||
seen.insert(service_name.clone());
|
||||
services.push(TorService {
|
||||
name: service_name,
|
||||
local_port: port,
|
||||
onion_address: onion,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(services)
|
||||
}
|
||||
|
||||
/// Read .onion address from hostname file.
|
||||
/// Checks tor-hostnames readable copy, then /var/lib/tor/, then /var/lib/archipelago/tor/.
|
||||
fn read_onion_address(service_name: &str) -> Option<String> {
|
||||
let base = tor_data_dir();
|
||||
let base_path = std::path::Path::new(&base);
|
||||
|
||||
// Try readable hostname copy first (system Tor owns hidden_service dirs at 0700)
|
||||
let hostnames_dir = base_path
|
||||
.parent()
|
||||
.unwrap_or(std::path::Path::new("/var/lib/archipelago"))
|
||||
.join("tor-hostnames")
|
||||
.join(service_name);
|
||||
if let Some(addr) = std::fs::read_to_string(&hostnames_dir)
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| s.ends_with(".onion") && s.len() == 62 && !s.contains(':') && s.chars().take(56).all(|c| c.is_ascii_alphanumeric()))
|
||||
{
|
||||
return Some(addr);
|
||||
}
|
||||
|
||||
// Check both /var/lib/tor/ (AppArmor-safe default) and /var/lib/archipelago/tor/
|
||||
let search_bases = [
|
||||
std::path::PathBuf::from("/var/lib/tor"),
|
||||
base_path.to_path_buf(),
|
||||
];
|
||||
for search_base in &search_bases {
|
||||
let path = search_base
|
||||
.join(format!("hidden_service_{}", service_name))
|
||||
.join("hostname");
|
||||
if let Some(addr) = std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.or_else(|| {
|
||||
std::process::Command::new("sudo")
|
||||
.args(["cat", &path.to_string_lossy()])
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
})
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| s.ends_with(".onion") && s.len() == 62 && !s.contains(':') && s.chars().take(56).all(|c| c.is_ascii_alphanumeric()))
|
||||
{
|
||||
return Some(addr);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Known default ports for built-in services.
|
||||
fn known_service_port(name: &str) -> u16 {
|
||||
match name {
|
||||
"archipelago" => 80,
|
||||
"lnd" => 8081,
|
||||
"btcpay" => 23000,
|
||||
"mempool" => 4080,
|
||||
"fedimint" => 8175,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn tor_data_dir() -> String {
|
||||
std::env::var("TOR_DATA_DIR").unwrap_or_else(|_| TOR_DATA_DIR.to_string())
|
||||
}
|
||||
|
||||
async fn load_services_config(config_dir: &std::path::Path) -> ServicesConfig {
|
||||
let path = config_dir.join(SERVICES_CONFIG);
|
||||
match tokio::fs::read_to_string(&path).await {
|
||||
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
|
||||
Err(_) => ServicesConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn save_services_config(config_dir: &std::path::Path, config: &ServicesConfig) -> Result<()> {
|
||||
tokio::fs::create_dir_all(config_dir).await.context("Failed to create tor config dir")?;
|
||||
let path = config_dir.join(SERVICES_CONFIG);
|
||||
let content = serde_json::to_string_pretty(config).context("Failed to serialize services config")?;
|
||||
tokio::fs::write(&path, content).await.context("Failed to write services config")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Notify federation peers of address change (private peer-to-peer only, never public relays).
|
||||
async fn notify_federation_peers_address_change(
|
||||
data_dir: &std::path::Path,
|
||||
new_onion: &str,
|
||||
old_onion: Option<&str>,
|
||||
tor_proxy: Option<&str>,
|
||||
) {
|
||||
let identity_dir = data_dir.join("identity");
|
||||
match identity::NodeIdentity::load_or_create(&identity_dir).await {
|
||||
Ok(node_id) => {
|
||||
let did = node_id.did_key();
|
||||
let proxy = tor_proxy.unwrap_or("127.0.0.1:9050");
|
||||
match federation::load_nodes(data_dir).await {
|
||||
Ok(peers) => {
|
||||
for peer in peers {
|
||||
if peer.onion.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let payload = serde_json::json!({
|
||||
"method": "federation.peer-address-changed",
|
||||
"params": {
|
||||
"did": did,
|
||||
"new_onion": new_onion,
|
||||
"old_onion": old_onion,
|
||||
}
|
||||
});
|
||||
let url = format!("http://{}/rpc/v1", &peer.onion);
|
||||
let client = match reqwest::Client::builder()
|
||||
.proxy(match reqwest::Proxy::all(format!("socks5h://{}", proxy))
|
||||
.or_else(|_| reqwest::Proxy::all("socks5h://127.0.0.1:9050")) {
|
||||
Ok(p) => p,
|
||||
Err(_) => continue,
|
||||
})
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
match client.post(&url).json(&payload).send().await {
|
||||
Ok(_) => info!(peer_did = %peer.did, "Notified peer of address change"),
|
||||
Err(e) => warn!(peer_did = %peer.did, "Failed to notify peer: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("Failed to load federation peers: {}", e),
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("Failed to load node identity for propagation: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for a hostname file to appear after Tor restart (up to max_secs).
|
||||
async fn wait_for_hostname(service_name: &str, max_secs: u64) -> Option<String> {
|
||||
for _ in 0..max_secs {
|
||||
if let Some(addr) = read_onion_address(service_name) {
|
||||
return Some(addr);
|
||||
}
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
warn!(service = service_name, "Timed out waiting for new .onion hostname");
|
||||
None
|
||||
}
|
||||
339
core/archipelago/src/api/rpc/tor/handlers.rs
Normal file
339
core/archipelago/src/api/rpc/tor/handlers.rs
Normal file
@@ -0,0 +1,339 @@
|
||||
use super::*;
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
impl RpcHandler {
|
||||
/// List all configured hidden services with their .onion addresses.
|
||||
pub(in crate::api::rpc) async fn handle_tor_list_services(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
let config_dir = self.config.data_dir.join("tor-config");
|
||||
let services = list_services(&config_dir).await?;
|
||||
let tor_running = check_tor_running().await;
|
||||
Ok(serde_json::json!({ "services": services, "tor_running": tor_running }))
|
||||
}
|
||||
|
||||
/// Create a new hidden service for a given local port.
|
||||
pub(in crate::api::rpc) async fn handle_tor_create_service(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let name = params
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing name"))?;
|
||||
let raw_port = params
|
||||
.get("local_port")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u16;
|
||||
let remote_port = params
|
||||
.get("remote_port")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v as u16);
|
||||
|
||||
validate_service_name(name)?;
|
||||
|
||||
let local_port = if raw_port == 0 {
|
||||
let detected = known_service_port(name);
|
||||
if detected == 0 {
|
||||
return Err(anyhow::anyhow!("Unknown app '{}' — specify local_port manually", name));
|
||||
}
|
||||
detected
|
||||
} else {
|
||||
raw_port
|
||||
};
|
||||
|
||||
let config_dir = self.config.data_dir.join("tor-config");
|
||||
let mut config = load_services_config(&config_dir).await;
|
||||
if config.services.iter().any(|s| s.name == name) {
|
||||
return Err(anyhow::anyhow!("Service '{}' already exists", name));
|
||||
}
|
||||
|
||||
let is_proto = is_protocol_service(name);
|
||||
config.services.push(TorServiceEntry {
|
||||
name: name.to_string(),
|
||||
local_port,
|
||||
remote_port,
|
||||
unauthenticated: is_proto,
|
||||
enabled: true,
|
||||
});
|
||||
save_services_config(&config_dir, &config).await?;
|
||||
|
||||
regenerate_torrc(&config).await?;
|
||||
restart_tor().await?;
|
||||
|
||||
let onion = wait_for_hostname(name, 60).await;
|
||||
if let Some(ref addr) = onion {
|
||||
sync_single_hostname(name, addr).await;
|
||||
}
|
||||
|
||||
info!(service = name, port = local_port, "Created Tor hidden service");
|
||||
Ok(serde_json::json!({
|
||||
"created": true,
|
||||
"name": name,
|
||||
"onion_address": onion,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Delete a hidden service.
|
||||
pub(in crate::api::rpc) async fn handle_tor_delete_service(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let name = params
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing name"))?;
|
||||
|
||||
validate_service_name(name)?;
|
||||
|
||||
if name == "archipelago" {
|
||||
return Err(anyhow::anyhow!("Cannot delete the node's own Tor service"));
|
||||
}
|
||||
|
||||
let config_dir = self.config.data_dir.join("tor-config");
|
||||
let mut config = load_services_config(&config_dir).await;
|
||||
let before = config.services.len();
|
||||
config.services.retain(|s| s.name != name);
|
||||
if config.services.len() == before {
|
||||
return Err(anyhow::anyhow!("Service '{}' not found", name));
|
||||
}
|
||||
save_services_config(&config_dir, &config).await?;
|
||||
|
||||
delete_hidden_service_dir(name).await;
|
||||
|
||||
regenerate_torrc(&config).await?;
|
||||
restart_tor().await?;
|
||||
|
||||
info!(service = name, "Deleted Tor hidden service");
|
||||
Ok(serde_json::json!({ "deleted": true, "name": name }))
|
||||
}
|
||||
|
||||
/// Get the .onion address for a specific service.
|
||||
pub(in crate::api::rpc) async fn handle_tor_get_onion_address(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let name = params
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing name"))?;
|
||||
|
||||
validate_service_name(name)?;
|
||||
|
||||
let onion = read_onion_address(name).await;
|
||||
Ok(serde_json::json!({ "name": name, "onion_address": onion }))
|
||||
}
|
||||
|
||||
/// Rotate a hidden service's .onion address by generating a new keypair.
|
||||
pub(in crate::api::rpc) async fn handle_tor_rotate_service(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let name = params
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing name"))?;
|
||||
|
||||
validate_service_name(name)?;
|
||||
|
||||
let old_onion = read_onion_address(name).await;
|
||||
if old_onion.is_none() {
|
||||
return Err(anyhow::anyhow!("Service '{}' has no .onion address to rotate", name));
|
||||
}
|
||||
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
rename_hidden_service_dir(name, timestamp).await;
|
||||
|
||||
info!(
|
||||
service = name,
|
||||
old_onion = ?old_onion,
|
||||
"Renamed old Tor service dir — restarting Tor to generate new keypair"
|
||||
);
|
||||
|
||||
restart_tor().await?;
|
||||
|
||||
let new_onion = wait_for_hostname(name, 60).await;
|
||||
|
||||
if let Some(ref new_addr) = new_onion {
|
||||
sync_single_hostname(name, new_addr).await;
|
||||
}
|
||||
|
||||
let old_name = format!("{}_old_{}", name, timestamp);
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(3600)).await;
|
||||
info!(old_dir = %old_name, "Transition period elapsed — deleting old Tor service dir");
|
||||
delete_hidden_service_dir(&old_name).await;
|
||||
});
|
||||
|
||||
if let Some(ref new_addr) = new_onion {
|
||||
let data_dir = self.config.data_dir.clone();
|
||||
let tor_proxy = self.config.nostr_tor_proxy.clone();
|
||||
let new_addr_clone = new_addr.clone();
|
||||
let old_onion_clone = old_onion.clone();
|
||||
tokio::spawn(async move {
|
||||
notify_federation_peers_address_change(
|
||||
&data_dir,
|
||||
&new_addr_clone,
|
||||
old_onion_clone.as_deref(),
|
||||
tor_proxy.as_deref(),
|
||||
).await;
|
||||
});
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"rotated": true,
|
||||
"name": name,
|
||||
"old_onion": old_onion,
|
||||
"new_onion": new_onion,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Clean up expired rotated service directories past the transition period.
|
||||
pub(in crate::api::rpc) async fn handle_tor_cleanup_rotated(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
let base = detect_hidden_service_base();
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
let mut cleaned = Vec::new();
|
||||
if let Ok(mut entries) = tokio::fs::read_dir(&base).await {
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if !name.contains("_old_") {
|
||||
continue;
|
||||
}
|
||||
if let Some(ts_str) = name.rsplit('_').next() {
|
||||
if let Ok(ts) = ts_str.parse::<u64>() {
|
||||
if now - ts > ROTATION_TRANSITION_SECS {
|
||||
let path = entry.path();
|
||||
let status = tokio::process::Command::new("sudo")
|
||||
.args(["rm", "-rf", &path.to_string_lossy()])
|
||||
.status()
|
||||
.await;
|
||||
if status.map(|s| s.success()).unwrap_or(false) {
|
||||
info!(dir = %name, "Cleaned up expired rotated Tor service");
|
||||
cleaned.push(name);
|
||||
} else {
|
||||
warn!(dir = %name, "Failed to clean up rotated Tor service");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({ "cleaned": cleaned, "count": cleaned.len() }))
|
||||
}
|
||||
|
||||
/// Toggle Tor access for a specific app (enable/disable).
|
||||
pub(in crate::api::rpc) async fn handle_tor_toggle_app(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let app_id = params
|
||||
.get("app_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
|
||||
|
||||
validate_service_name(app_id)?;
|
||||
|
||||
let enabled = params
|
||||
.get("enabled")
|
||||
.and_then(|v| v.as_bool())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing enabled (bool)"))?;
|
||||
|
||||
let config_dir = self.config.data_dir.join("tor-config");
|
||||
let mut config = load_services_config(&config_dir).await;
|
||||
|
||||
let found = config.services.iter_mut().find(|s| s.name == app_id);
|
||||
match found {
|
||||
Some(entry) => {
|
||||
if entry.enabled == enabled {
|
||||
return Ok(serde_json::json!({
|
||||
"app_id": app_id,
|
||||
"enabled": enabled,
|
||||
"changed": false,
|
||||
}));
|
||||
}
|
||||
entry.enabled = enabled;
|
||||
}
|
||||
None => {
|
||||
if !enabled {
|
||||
return Ok(serde_json::json!({
|
||||
"app_id": app_id,
|
||||
"enabled": false,
|
||||
"changed": false,
|
||||
}));
|
||||
}
|
||||
let port = known_service_port(app_id);
|
||||
let is_proto = is_protocol_service(app_id);
|
||||
config.services.push(TorServiceEntry {
|
||||
name: app_id.to_string(),
|
||||
local_port: port,
|
||||
remote_port: None,
|
||||
unauthenticated: is_proto,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
save_services_config(&config_dir, &config).await?;
|
||||
|
||||
if !enabled {
|
||||
delete_hidden_service_dir(app_id).await;
|
||||
info!(app = app_id, "Disabled Tor access — removed hidden service dir");
|
||||
}
|
||||
|
||||
regenerate_torrc(&config).await?;
|
||||
restart_tor().await?;
|
||||
|
||||
let new_onion = if enabled {
|
||||
let onion = wait_for_hostname(app_id, 60).await;
|
||||
if let Some(ref addr) = onion {
|
||||
sync_single_hostname(app_id, addr).await;
|
||||
}
|
||||
onion
|
||||
} else {
|
||||
let hostnames_dir = self.config.data_dir.join("tor-hostnames");
|
||||
let _ = tokio::fs::remove_file(hostnames_dir.join(app_id)).await;
|
||||
None
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"app_id": app_id,
|
||||
"enabled": enabled,
|
||||
"changed": true,
|
||||
"onion_address": new_onion,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Restart Tor daemon (system or container).
|
||||
pub(in crate::api::rpc) async fn handle_tor_restart(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
info!("Manual Tor restart requested");
|
||||
|
||||
let config_dir = self.config.data_dir.join("tor-config");
|
||||
let config = load_services_config(&config_dir).await;
|
||||
regenerate_torrc(&config).await?;
|
||||
|
||||
restart_tor().await?;
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
|
||||
sync_all_hostname_copies(&config).await;
|
||||
|
||||
let running = check_tor_running().await;
|
||||
Ok(serde_json::json!({ "restarted": true, "tor_running": running }))
|
||||
}
|
||||
}
|
||||
430
core/archipelago/src/api/rpc/tor/mod.rs
Normal file
430
core/archipelago/src/api/rpc/tor/mod.rs
Normal file
@@ -0,0 +1,430 @@
|
||||
mod handlers;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::{federation, identity};
|
||||
|
||||
pub(super) const TOR_DATA_DIR: &str = "/var/lib/archipelago/tor";
|
||||
pub(super) const SERVICES_CONFIG: &str = "services.json";
|
||||
/// How long old service directories are kept during transition (seconds).
|
||||
pub(super) const ROTATION_TRANSITION_SECS: u64 = 86400; // 24 hours
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(super) struct TorService {
|
||||
pub name: String,
|
||||
pub local_port: u16,
|
||||
pub onion_address: Option<String>,
|
||||
pub enabled: bool,
|
||||
pub unauthenticated: bool,
|
||||
pub protocol: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub(super) struct ServicesConfig {
|
||||
pub services: Vec<TorServiceEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(super) struct TorServiceEntry {
|
||||
pub name: String,
|
||||
pub local_port: u16,
|
||||
#[serde(default)]
|
||||
pub remote_port: Option<u16>,
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub unauthenticated: bool,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
// ─── Validation ───────────────────────────────────────────────────
|
||||
|
||||
pub(super) fn validate_service_name(name: &str) -> Result<()> {
|
||||
if name.is_empty() || name.len() > 64
|
||||
|| !name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_')
|
||||
{
|
||||
return Err(anyhow::anyhow!("Invalid service name (alphanumeric, hyphens, underscores only)"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── Tor Daemon Control ──────────────────────────────────────────
|
||||
|
||||
const TOR_ACTION_FILE: &str = "/var/lib/archipelago/tor-config/tor-action";
|
||||
const TOR_RESULT_FILE: &str = "/var/lib/archipelago/tor-config/tor-result";
|
||||
|
||||
/// Write an action file and wait for the tor-helper service to process it.
|
||||
pub(super) async fn dispatch_tor_action(action: serde_json::Value) -> Result<()> {
|
||||
let _ = tokio::fs::remove_file(TOR_RESULT_FILE).await;
|
||||
|
||||
let content = serde_json::to_string(&action).context("Failed to serialize tor action")?;
|
||||
let config_dir = Path::new(TOR_ACTION_FILE).parent().unwrap_or_else(|| Path::new("/var/lib/archipelago/tor-config"));
|
||||
tokio::fs::create_dir_all(config_dir).await.ok();
|
||||
tokio::fs::write(TOR_ACTION_FILE, &content)
|
||||
.await
|
||||
.context("Failed to write tor-action file")?;
|
||||
|
||||
for _ in 0..90 {
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||
if let Ok(result_str) = tokio::fs::read_to_string(TOR_RESULT_FILE).await {
|
||||
let _ = tokio::fs::remove_file(TOR_RESULT_FILE).await;
|
||||
if let Ok(result) = serde_json::from_str::<serde_json::Value>(&result_str) {
|
||||
if result.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||
return Ok(());
|
||||
}
|
||||
let err = result.get("error").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
||||
return Err(anyhow::anyhow!("Tor helper: {}", err));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(anyhow::anyhow!("Tor helper timed out — is archipelago-tor-helper.path enabled?"))
|
||||
}
|
||||
|
||||
pub(super) async fn delete_hidden_service_dir(name: &str) {
|
||||
if let Err(e) = dispatch_tor_action(serde_json::json!({
|
||||
"action": "delete-service",
|
||||
"name": name,
|
||||
})).await {
|
||||
warn!("Failed to delete hidden service dir for {}: {}", name, e);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn rename_hidden_service_dir(name: &str, timestamp: u64) {
|
||||
if let Err(e) = dispatch_tor_action(serde_json::json!({
|
||||
"action": "rename-service",
|
||||
"name": name,
|
||||
"timestamp": timestamp,
|
||||
})).await {
|
||||
warn!("Failed to rename hidden service dir for {}: {}", name, e);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn restart_tor() -> Result<()> {
|
||||
dispatch_tor_action(serde_json::json!({
|
||||
"action": "write-torrc-and-restart",
|
||||
})).await
|
||||
}
|
||||
|
||||
pub(super) async fn check_tor_running() -> bool {
|
||||
tokio::net::TcpStream::connect("127.0.0.1:9050")
|
||||
.await
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
// ─── torrc Generation ────────────────────────────────────────────
|
||||
|
||||
pub(super) fn detect_hidden_service_base() -> String {
|
||||
if Path::new("/var/lib/tor/hidden_service_archipelago").exists() {
|
||||
return "/var/lib/tor".to_string();
|
||||
}
|
||||
let custom = tor_data_dir();
|
||||
if Path::new(&custom).join("hidden_service_archipelago").exists() {
|
||||
return custom;
|
||||
}
|
||||
"/var/lib/tor".to_string()
|
||||
}
|
||||
|
||||
pub(super) async fn regenerate_torrc(config: &ServicesConfig) -> Result<()> {
|
||||
let base = detect_hidden_service_base();
|
||||
let mut lines = Vec::new();
|
||||
|
||||
lines.push("# Auto-generated by Archipelago — do not edit manually".to_string());
|
||||
lines.push("SocksPort 9050".to_string());
|
||||
lines.push("# ControlPort disabled for security".to_string());
|
||||
lines.push(String::new());
|
||||
|
||||
for svc in &config.services {
|
||||
if !svc.enabled {
|
||||
continue;
|
||||
}
|
||||
let dir = format!("{}/hidden_service_{}", base, svc.name);
|
||||
lines.push(format!("HiddenServiceDir {}", dir));
|
||||
|
||||
if is_protocol_service(&svc.name) {
|
||||
let remote_port = svc.remote_port.unwrap_or(svc.local_port);
|
||||
lines.push(format!("HiddenServicePort {} 127.0.0.1:{}", remote_port, svc.local_port));
|
||||
if svc.name == "lnd" {
|
||||
lines.push("HiddenServicePort 9735 127.0.0.1:9735".to_string());
|
||||
lines.push("HiddenServicePort 10009 127.0.0.1:10009".to_string());
|
||||
}
|
||||
} else {
|
||||
lines.push(format!("HiddenServicePort 80 127.0.0.1:{}", svc.local_port));
|
||||
}
|
||||
|
||||
lines.push(String::new());
|
||||
}
|
||||
|
||||
let content = lines.join("\n");
|
||||
let staging = "/var/lib/archipelago/tor-config/torrc.staged";
|
||||
let config_dir = Path::new(staging).parent().unwrap_or_else(|| Path::new("/var/lib/archipelago/tor-config"));
|
||||
tokio::fs::create_dir_all(config_dir).await.ok();
|
||||
tokio::fs::write(staging, &content).await.context("Failed to write staged torrc")?;
|
||||
|
||||
debug!("Staged torrc with {} enabled services",
|
||||
config.services.iter().filter(|s| s.enabled).count());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── Hostname Sync ───────────────────────────────────────────────
|
||||
|
||||
pub(super) async fn sync_single_hostname(name: &str, address: &str) {
|
||||
let hostnames_dir = Path::new("/var/lib/archipelago/tor-hostnames");
|
||||
if let Err(e) = tokio::fs::create_dir_all(hostnames_dir).await {
|
||||
warn!("Failed to create tor-hostnames dir: {}", e);
|
||||
return;
|
||||
}
|
||||
if let Err(e) = tokio::fs::write(hostnames_dir.join(name), address).await {
|
||||
warn!("Failed to write tor-hostname copy for {}: {}", name, e);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn sync_all_hostname_copies(config: &ServicesConfig) {
|
||||
for svc in &config.services {
|
||||
if !svc.enabled {
|
||||
continue;
|
||||
}
|
||||
if let Some(addr) = read_onion_address(&svc.name).await {
|
||||
sync_single_hostname(&svc.name, &addr).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Service Listing ─────────────────────────────────────────────
|
||||
|
||||
pub(super) async fn list_services(config_dir: &std::path::Path) -> Result<Vec<TorService>> {
|
||||
let base = detect_hidden_service_base();
|
||||
let config = load_services_config(config_dir).await;
|
||||
let mut services = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
|
||||
for entry in &config.services {
|
||||
let onion = read_onion_address(&entry.name).await;
|
||||
seen.insert(entry.name.clone());
|
||||
services.push(TorService {
|
||||
name: entry.name.clone(),
|
||||
local_port: entry.local_port,
|
||||
onion_address: onion,
|
||||
enabled: entry.enabled,
|
||||
unauthenticated: entry.unauthenticated,
|
||||
protocol: is_protocol_service(&entry.name),
|
||||
});
|
||||
}
|
||||
|
||||
for scan_dir in ["/var/lib/tor", &base] {
|
||||
if let Ok(mut entries) = tokio::fs::read_dir(scan_dir).await {
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
let is_dir = entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false);
|
||||
if name.starts_with("hidden_service_")
|
||||
&& !name.contains("_old_")
|
||||
&& is_dir
|
||||
{
|
||||
let service_name = name.strip_prefix("hidden_service_").unwrap_or(&name).to_string();
|
||||
if seen.contains(&service_name) {
|
||||
continue;
|
||||
}
|
||||
let onion = read_onion_address(&service_name).await;
|
||||
let port = known_service_port(&service_name);
|
||||
seen.insert(service_name.clone());
|
||||
let is_proto = is_protocol_service(&service_name);
|
||||
services.push(TorService {
|
||||
name: service_name,
|
||||
local_port: port,
|
||||
onion_address: onion,
|
||||
enabled: true,
|
||||
unauthenticated: is_proto,
|
||||
protocol: is_proto,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(services)
|
||||
}
|
||||
|
||||
// ─── Onion Address Reading ───────────────────────────────────────
|
||||
|
||||
pub(super) async fn read_onion_address(service_name: &str) -> Option<String> {
|
||||
let hostnames_path = Path::new("/var/lib/archipelago/tor-hostnames").join(service_name);
|
||||
if let Some(addr) = read_and_validate_onion(&hostnames_path).await {
|
||||
return Some(addr);
|
||||
}
|
||||
|
||||
let base = tor_data_dir();
|
||||
for search_base in &["/var/lib/tor", base.as_str()] {
|
||||
let path = Path::new(search_base)
|
||||
.join(format!("hidden_service_{}", service_name))
|
||||
.join("hostname");
|
||||
|
||||
if let Some(addr) = read_and_validate_onion(&path).await {
|
||||
return Some(addr);
|
||||
}
|
||||
|
||||
if let Some(addr) = sudo_read_and_validate_onion(&path).await {
|
||||
return Some(addr);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
async fn read_and_validate_onion(path: &Path) -> Option<String> {
|
||||
tokio::fs::read_to_string(path)
|
||||
.await
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| is_valid_v3_onion(s))
|
||||
}
|
||||
|
||||
async fn sudo_read_and_validate_onion(path: &Path) -> Option<String> {
|
||||
tokio::process::Command::new("sudo")
|
||||
.args(["cat", &path.to_string_lossy()])
|
||||
.output()
|
||||
.await
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| is_valid_v3_onion(s))
|
||||
}
|
||||
|
||||
fn is_valid_v3_onion(s: &str) -> bool {
|
||||
s.len() == 62
|
||||
&& s.ends_with(".onion")
|
||||
&& !s.contains(':')
|
||||
&& s[..56].chars().all(|c| c.is_ascii_alphanumeric())
|
||||
}
|
||||
|
||||
// ─── Known Ports ─────────────────────────────────────────────────
|
||||
|
||||
pub(super) fn known_service_port(name: &str) -> u16 {
|
||||
match name {
|
||||
"archipelago" => 80,
|
||||
"bitcoin" | "bitcoin-knots" => 8333,
|
||||
"electrs" | "electrumx" => 50001,
|
||||
"lnd" => 8080,
|
||||
"btcpay" | "btcpay-server" | "btcpayserver" => 23000,
|
||||
"mempool" => 4080,
|
||||
"fedimint" => 8175,
|
||||
"nostr-relay" | "nostr-rs-relay" => 8080,
|
||||
"searxng" => 8888,
|
||||
"ollama" => 11434,
|
||||
"filebrowser" => 8083,
|
||||
"grafana" => 3000,
|
||||
"home-assistant" => 8123,
|
||||
"immich" => 2283,
|
||||
"photoprism" => 2342,
|
||||
"penpot" => 9001,
|
||||
"nginx-proxy-manager" => 81,
|
||||
"vaultwarden" => 8343,
|
||||
"indeedhub" => 7777,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn is_protocol_service(name: &str) -> bool {
|
||||
matches!(name, "bitcoin" | "bitcoin-knots" | "electrs" | "electrumx" | "lnd")
|
||||
}
|
||||
|
||||
// ─── Config I/O ──────────────────────────────────────────────────
|
||||
|
||||
fn tor_data_dir() -> String {
|
||||
std::env::var("TOR_DATA_DIR").unwrap_or_else(|_| TOR_DATA_DIR.to_string())
|
||||
}
|
||||
|
||||
pub(super) async fn load_services_config(config_dir: &std::path::Path) -> ServicesConfig {
|
||||
let path = config_dir.join(SERVICES_CONFIG);
|
||||
match tokio::fs::read_to_string(&path).await {
|
||||
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
|
||||
Err(_) => ServicesConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn save_services_config(config_dir: &std::path::Path, config: &ServicesConfig) -> Result<()> {
|
||||
tokio::fs::create_dir_all(config_dir).await.context("Failed to create tor config dir")?;
|
||||
let path = config_dir.join(SERVICES_CONFIG);
|
||||
let content = serde_json::to_string_pretty(config).context("Failed to serialize services config")?;
|
||||
tokio::fs::write(&path, content).await.context("Failed to write services config")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── Federation Notification ─────────────────────────────────────
|
||||
|
||||
pub(super) async fn notify_federation_peers_address_change(
|
||||
data_dir: &std::path::Path,
|
||||
new_onion: &str,
|
||||
old_onion: Option<&str>,
|
||||
tor_proxy: Option<&str>,
|
||||
) {
|
||||
let identity_dir = data_dir.join("identity");
|
||||
match identity::NodeIdentity::load_or_create(&identity_dir).await {
|
||||
Ok(node_id) => {
|
||||
let did = match node_id.did_key() {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to derive DID key: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let proxy = tor_proxy.unwrap_or("127.0.0.1:9050");
|
||||
match federation::load_nodes(data_dir).await {
|
||||
Ok(peers) => {
|
||||
for peer in peers {
|
||||
if peer.onion.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let payload = serde_json::json!({
|
||||
"method": "federation.peer-address-changed",
|
||||
"params": {
|
||||
"did": did,
|
||||
"new_onion": new_onion,
|
||||
"old_onion": old_onion,
|
||||
}
|
||||
});
|
||||
let url = format!("http://{}/rpc/v1", &peer.onion);
|
||||
let client = match reqwest::Client::builder()
|
||||
.proxy(match reqwest::Proxy::all(format!("socks5h://{}", proxy))
|
||||
.or_else(|_| reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY)) {
|
||||
Ok(p) => p,
|
||||
Err(_) => continue,
|
||||
})
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
match client.post(&url).json(&payload).send().await {
|
||||
Ok(_) => info!(peer_did = %peer.did, "Notified peer of address change"),
|
||||
Err(e) => warn!(peer_did = %peer.did, "Failed to notify peer: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("Failed to load federation peers: {}", e),
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("Failed to load node identity for propagation: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Hostname Waiting ────────────────────────────────────────────
|
||||
|
||||
pub(super) async fn wait_for_hostname(service_name: &str, max_secs: u64) -> Option<String> {
|
||||
for _ in 0..max_secs {
|
||||
if let Some(addr) = read_onion_address(service_name).await {
|
||||
return Some(addr);
|
||||
}
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
warn!(service = service_name, "Timed out waiting for new .onion hostname");
|
||||
None
|
||||
}
|
||||
@@ -192,10 +192,11 @@ impl RpcHandler {
|
||||
let _ = self.auth_manager.update_totp(data).await;
|
||||
}
|
||||
|
||||
// Upgrade pending session to full
|
||||
self.session_store.upgrade_to_full(token).await;
|
||||
// Upgrade pending session to full (rotates token)
|
||||
let new_token = self.session_store.upgrade_to_full(token).await
|
||||
.ok_or_else(|| anyhow::anyhow!("Session expired. Please log in again."))?;
|
||||
|
||||
Ok(serde_json::json!({ "success": true }))
|
||||
Ok(serde_json::json!({ "success": true, "new_session_token": new_token }))
|
||||
}
|
||||
None => {
|
||||
anyhow::bail!("Invalid code. Please try again.");
|
||||
@@ -241,13 +242,14 @@ impl RpcHandler {
|
||||
totp_data.backup_codes.remove(idx);
|
||||
self.auth_manager.update_totp(totp_data).await?;
|
||||
|
||||
// Upgrade pending session to full
|
||||
self.session_store.upgrade_to_full(token).await;
|
||||
// Upgrade pending session to full (rotates token)
|
||||
let new_token = self.session_store.upgrade_to_full(token).await
|
||||
.ok_or_else(|| anyhow::anyhow!("Session expired. Please log in again."))?;
|
||||
|
||||
tracing::info!("Login via backup code (codes remaining: {})",
|
||||
self.auth_manager.get_totp_data().await?.map(|d| d.backup_codes.len()).unwrap_or(0));
|
||||
|
||||
Ok(serde_json::json!({ "success": true }))
|
||||
Ok(serde_json::json!({ "success": true, "new_session_token": new_token }))
|
||||
}
|
||||
None => {
|
||||
anyhow::bail!("Invalid backup code");
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
use super::RpcHandler;
|
||||
use crate::update;
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
impl RpcHandler {
|
||||
/// Check for available system updates.
|
||||
/// Tries git-based check first (if repo exists), falls back to manifest-based.
|
||||
pub(super) async fn handle_update_check(&self) -> Result<serde_json::Value> {
|
||||
// Try git-based check first (preferred for beta nodes)
|
||||
let repo_dir = std::path::PathBuf::from(
|
||||
std::env::var("HOME").unwrap_or_else(|_| "/home/archipelago".to_string()),
|
||||
)
|
||||
.join("archy");
|
||||
if repo_dir.join(".git").exists() {
|
||||
if let Ok(git_status) = self.git_check_update(&repo_dir).await {
|
||||
return Ok(git_status);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to manifest-based check
|
||||
let state = update::check_for_updates(&self.config.data_dir).await?;
|
||||
|
||||
let update_info = state.available_update.as_ref().map(|u| {
|
||||
@@ -24,6 +37,108 @@ impl RpcHandler {
|
||||
}))
|
||||
}
|
||||
|
||||
/// Git-based update check: runs `git fetch` and compares HEAD to origin/main.
|
||||
async fn git_check_update(&self, repo_dir: &std::path::Path) -> Result<serde_json::Value> {
|
||||
let repo_str = repo_dir.to_string_lossy().to_string();
|
||||
|
||||
// git fetch origin main
|
||||
let fetch = tokio::process::Command::new("git")
|
||||
.args(["fetch", "origin", "main", "--quiet"])
|
||||
.current_dir(&repo_str)
|
||||
.output()
|
||||
.await
|
||||
.context("git fetch failed")?;
|
||||
|
||||
if !fetch.status.success() {
|
||||
anyhow::bail!("git fetch failed: {}", String::from_utf8_lossy(&fetch.stderr));
|
||||
}
|
||||
|
||||
// Get local and remote HEADs
|
||||
let local = tokio::process::Command::new("git")
|
||||
.args(["rev-parse", "--short", "HEAD"])
|
||||
.current_dir(&repo_str)
|
||||
.output()
|
||||
.await?;
|
||||
let local_hash = String::from_utf8_lossy(&local.stdout).trim().to_string();
|
||||
|
||||
let remote = tokio::process::Command::new("git")
|
||||
.args(["rev-parse", "--short", "origin/main"])
|
||||
.current_dir(&repo_str)
|
||||
.output()
|
||||
.await?;
|
||||
let remote_hash = String::from_utf8_lossy(&remote.stdout).trim().to_string();
|
||||
|
||||
let update_available = local_hash != remote_hash;
|
||||
|
||||
// Get commit count and changelog if update available
|
||||
let mut changelog = Vec::new();
|
||||
let mut commits_behind = 0u64;
|
||||
if update_available {
|
||||
let count = tokio::process::Command::new("git")
|
||||
.args(["rev-list", "HEAD..origin/main", "--count"])
|
||||
.current_dir(&repo_str)
|
||||
.output()
|
||||
.await?;
|
||||
commits_behind = String::from_utf8_lossy(&count.stdout)
|
||||
.trim()
|
||||
.parse()
|
||||
.unwrap_or(0);
|
||||
|
||||
let log = tokio::process::Command::new("git")
|
||||
.args(["log", "HEAD..origin/main", "--oneline", "--no-merges", "-20"])
|
||||
.current_dir(&repo_str)
|
||||
.output()
|
||||
.await?;
|
||||
changelog = String::from_utf8_lossy(&log.stdout)
|
||||
.lines()
|
||||
.map(|l| l.to_string())
|
||||
.collect();
|
||||
}
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"current_version": local_hash,
|
||||
"last_check": now,
|
||||
"update_available": update_available,
|
||||
"update_method": "git",
|
||||
"update": if update_available {
|
||||
Some(serde_json::json!({
|
||||
"version": remote_hash,
|
||||
"commits_behind": commits_behind,
|
||||
"changelog": changelog,
|
||||
}))
|
||||
} else { None },
|
||||
}))
|
||||
}
|
||||
|
||||
/// Apply git-based update: runs self-update.sh which pulls, builds, and restarts.
|
||||
pub(super) async fn handle_update_git_apply(&self) -> Result<serde_json::Value> {
|
||||
let script = std::path::PathBuf::from(
|
||||
std::env::var("HOME").unwrap_or_else(|_| "/home/archipelago".to_string()),
|
||||
)
|
||||
.join("archy/scripts/self-update.sh");
|
||||
|
||||
if !script.exists() {
|
||||
anyhow::bail!("self-update.sh not found at {}", script.display());
|
||||
}
|
||||
|
||||
// Spawn the update script in the background (it will restart the service)
|
||||
let child = tokio::process::Command::new("bash")
|
||||
.arg(&script)
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.context("Failed to spawn self-update.sh")?;
|
||||
|
||||
tracing::info!(pid = child.id(), "Self-update script spawned");
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"started": true,
|
||||
"message": "Update started. The service will restart when complete.",
|
||||
}))
|
||||
}
|
||||
|
||||
/// Get update status without checking remote.
|
||||
pub(super) async fn handle_update_status(&self) -> Result<serde_json::Value> {
|
||||
let state = update::get_status(&self.config.data_dir).await?;
|
||||
|
||||
@@ -3,6 +3,90 @@ use crate::webhooks;
|
||||
use anyhow::Result;
|
||||
use tracing::info;
|
||||
|
||||
/// Check if a hostname/IP points to a private or internal address.
|
||||
/// Handles: IPv4, IPv6 (including mapped IPv4 like ::ffff:127.0.0.1),
|
||||
/// decimal/octal IP representations, and well-known internal hostnames.
|
||||
fn is_webhook_host_private(host: &str) -> bool {
|
||||
// Strip IPv6 brackets if present
|
||||
let h = host.trim_start_matches('[').trim_end_matches(']');
|
||||
|
||||
// Check well-known internal hostnames
|
||||
let lower = h.to_lowercase();
|
||||
if lower == "localhost"
|
||||
|| lower == "localhost.localdomain"
|
||||
|| lower.ends_with(".local")
|
||||
|| lower.ends_with(".internal")
|
||||
|| lower == "metadata.google.internal"
|
||||
|| lower == "169.254.169.254"
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Try to parse as IP address
|
||||
if let Ok(ip) = h.parse::<std::net::IpAddr>() {
|
||||
return match ip {
|
||||
std::net::IpAddr::V4(v4) => {
|
||||
v4.is_loopback()
|
||||
|| v4.is_private()
|
||||
|| v4.is_link_local()
|
||||
|| v4.is_unspecified()
|
||||
|| v4.octets()[0] == 100 && (64..=127).contains(&v4.octets()[1]) // CGNAT
|
||||
}
|
||||
std::net::IpAddr::V6(v6) => {
|
||||
if v6.is_loopback() || v6.is_unspecified() {
|
||||
return true;
|
||||
}
|
||||
// Check IPv4-mapped IPv6 (::ffff:x.x.x.x)
|
||||
if let Some(v4) = v6.to_ipv4_mapped() {
|
||||
return v4.is_loopback()
|
||||
|| v4.is_private()
|
||||
|| v4.is_link_local()
|
||||
|| v4.is_unspecified();
|
||||
}
|
||||
// Unique local (fd00::/8, fc00::/7)
|
||||
let segments = v6.segments();
|
||||
(segments[0] & 0xfe00) == 0xfc00
|
||||
|| (segments[0] & 0xffc0) == 0xfe80 // link-local
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Detect decimal IP notation (e.g., "2130706433" = 127.0.0.1)
|
||||
if let Ok(decimal) = h.parse::<u32>() {
|
||||
let octets = decimal.to_be_bytes();
|
||||
let v4 = std::net::Ipv4Addr::new(octets[0], octets[1], octets[2], octets[3]);
|
||||
return v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified();
|
||||
}
|
||||
|
||||
// Detect octal IP notation (e.g., "0177.0.0.1" = 127.0.0.1)
|
||||
if h.contains('.') {
|
||||
let parts: Vec<&str> = h.split('.').collect();
|
||||
if parts.len() == 4 {
|
||||
let mut octets = [0u8; 4];
|
||||
let mut all_ok = true;
|
||||
for (i, part) in parts.iter().enumerate() {
|
||||
let val = if part.starts_with("0x") || part.starts_with("0X") {
|
||||
u64::from_str_radix(part.trim_start_matches("0x").trim_start_matches("0X"), 16).ok()
|
||||
} else if part.starts_with('0') && part.len() > 1 {
|
||||
u64::from_str_radix(part, 8).ok()
|
||||
} else {
|
||||
part.parse::<u64>().ok()
|
||||
};
|
||||
match val {
|
||||
Some(v) if v <= 255 => octets[i] = v as u8,
|
||||
_ => { all_ok = false; break; }
|
||||
}
|
||||
}
|
||||
if all_ok {
|
||||
let v4 = std::net::Ipv4Addr::new(octets[0], octets[1], octets[2], octets[3]);
|
||||
return v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// webhook.get-config — Get current webhook configuration.
|
||||
pub(super) async fn handle_webhook_get_config(&self) -> Result<serde_json::Value> {
|
||||
@@ -28,37 +112,35 @@ impl RpcHandler {
|
||||
config.enabled = enabled;
|
||||
}
|
||||
if let Some(url) = params.get("url").and_then(|v| v.as_str()) {
|
||||
// Validate webhook URL scheme and reject obviously dangerous targets
|
||||
// Validate webhook URL scheme and reject dangerous targets
|
||||
if !url.is_empty() {
|
||||
if !url.starts_with("https://") && !url.starts_with("http://") {
|
||||
anyhow::bail!("Webhook URL must use HTTP(S)");
|
||||
}
|
||||
if !self.config.dev_mode && !url.starts_with("https://") {
|
||||
anyhow::bail!("Webhook URL must use HTTPS in production");
|
||||
}
|
||||
// Extract host portion and reject private/internal addresses
|
||||
let host_part = url
|
||||
.trim_start_matches("https://")
|
||||
.trim_start_matches("http://")
|
||||
.split('/')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.split(':')
|
||||
.next()
|
||||
.unwrap_or("");
|
||||
let is_private = host_part == "localhost"
|
||||
|| host_part == "127.0.0.1"
|
||||
|| host_part == "::1"
|
||||
|| host_part.starts_with("10.")
|
||||
|| host_part.starts_with("172.")
|
||||
|| host_part.starts_with("192.168.")
|
||||
|| host_part.starts_with("169.254.");
|
||||
if is_private && !self.config.dev_mode {
|
||||
anyhow::bail!("Webhook URL must not point to private/local addresses");
|
||||
}
|
||||
if url.len() > 2048 {
|
||||
anyhow::bail!("Webhook URL too long");
|
||||
}
|
||||
// Parse URL properly to handle edge cases (IPv6, userinfo, etc.)
|
||||
let parsed = reqwest::Url::parse(url)
|
||||
.map_err(|_| anyhow::anyhow!("Invalid webhook URL"))?;
|
||||
// Require https:// in production
|
||||
if !self.config.dev_mode && parsed.scheme() != "https" {
|
||||
anyhow::bail!("Webhook URL must use HTTPS in production");
|
||||
}
|
||||
if parsed.scheme() != "https" && parsed.scheme() != "http" {
|
||||
anyhow::bail!("Webhook URL must use HTTP(S)");
|
||||
}
|
||||
// Reject URLs with userinfo (user:pass@host) — can be used for credential smuggling
|
||||
if parsed.username() != "" || parsed.password().is_some() {
|
||||
anyhow::bail!("Webhook URL must not contain credentials");
|
||||
}
|
||||
// Extract and validate the host
|
||||
let host = parsed.host_str().unwrap_or("");
|
||||
if host.is_empty() {
|
||||
anyhow::bail!("Webhook URL must have a valid host");
|
||||
}
|
||||
// Reject private/internal addresses (handle IPv4, IPv6, decimal/octal IPs, hostnames)
|
||||
let is_private = is_webhook_host_private(host);
|
||||
if is_private && !self.config.dev_mode {
|
||||
anyhow::bail!("Webhook URL must not point to private/local addresses");
|
||||
}
|
||||
}
|
||||
config.url = url.to_string();
|
||||
}
|
||||
|
||||
@@ -32,9 +32,16 @@ impl UserRole {
|
||||
match self {
|
||||
UserRole::Admin => true,
|
||||
UserRole::Viewer => {
|
||||
// Read-only methods
|
||||
method.starts_with("system.")
|
||||
|| method.starts_with("node.")
|
||||
// Read-only system methods (explicit allowlist — NOT prefix "system."
|
||||
// which would grant access to system.factory-reset, system.shutdown, etc.)
|
||||
method == "system.stats"
|
||||
|| method == "system.processes"
|
||||
|| method == "system.temperature"
|
||||
|| method == "system.disk-status"
|
||||
|| method == "system.detect-usb-devices"
|
||||
|| method == "node.did"
|
||||
|| method == "node.tor-address"
|
||||
|| method == "node.nostr-pubkey"
|
||||
|| method.starts_with("federation.list")
|
||||
|| method.starts_with("dwn.status")
|
||||
|| method.starts_with("dwn.list")
|
||||
@@ -105,9 +112,7 @@ impl AuthManager {
|
||||
}
|
||||
|
||||
pub async fn setup_user(&self, password: &str) -> Result<()> {
|
||||
use bcrypt::{hash, DEFAULT_COST};
|
||||
|
||||
let password_hash = hash(password, DEFAULT_COST)?;
|
||||
let password_hash = argon2id_hash(password)?;
|
||||
|
||||
// If onboarding was already completed (before setup), preserve that
|
||||
let onboarding_complete = self.is_onboarding_complete().await?;
|
||||
@@ -217,10 +222,25 @@ impl AuthManager {
|
||||
}
|
||||
|
||||
pub async fn verify_password(&self, password: &str) -> Result<bool> {
|
||||
use bcrypt::verify;
|
||||
|
||||
if let Some(user) = self.get_user().await? {
|
||||
Ok(verify(password, &user.password_hash)?)
|
||||
// Detect hash format and verify accordingly
|
||||
if user.password_hash.starts_with("$2") {
|
||||
// Legacy bcrypt hash — verify then auto-upgrade to Argon2id
|
||||
let valid = bcrypt::verify(password, &user.password_hash)?;
|
||||
if valid {
|
||||
// Transparent upgrade: re-hash with Argon2id on successful login
|
||||
let new_hash = argon2id_hash(password)?;
|
||||
let mut upgraded = user.clone();
|
||||
upgraded.password_hash = new_hash;
|
||||
let user_file = self.data_dir.join("user.json");
|
||||
fs::write(&user_file, serde_json::to_string_pretty(&upgraded)?).await?;
|
||||
tracing::info!("Upgraded password hash from bcrypt to Argon2id");
|
||||
}
|
||||
Ok(valid)
|
||||
} else {
|
||||
// Argon2id hash (PHC string format: $argon2id$...)
|
||||
Ok(argon2id_verify(password, &user.password_hash))
|
||||
}
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
@@ -234,15 +254,13 @@ impl AuthManager {
|
||||
new_password: &str,
|
||||
also_change_ssh: bool,
|
||||
) -> Result<()> {
|
||||
use bcrypt::{hash, DEFAULT_COST};
|
||||
|
||||
if !self.verify_password(current_password).await? {
|
||||
anyhow::bail!("Current password is incorrect");
|
||||
}
|
||||
|
||||
validate_password_strength(new_password)?;
|
||||
|
||||
let password_hash = hash(new_password, DEFAULT_COST)?;
|
||||
let password_hash = argon2id_hash(new_password)?;
|
||||
|
||||
let mut user = self
|
||||
.get_user()
|
||||
@@ -422,3 +440,32 @@ async fn change_ssh_password(new_password: &str) -> Result<()> {
|
||||
tracing::info!("SSH password updated for user {}", ssh_user);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Hash a password with Argon2id (memory-hard, GPU/ASIC resistant).
|
||||
/// Uses PHC string format ($argon2id$v=19$m=65536,t=3,p=4$...) for self-describing storage.
|
||||
fn argon2id_hash(password: &str) -> Result<String> {
|
||||
use argon2::{Argon2, Params, PasswordHasher};
|
||||
use argon2::password_hash::SaltString;
|
||||
use rand::rngs::OsRng;
|
||||
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let params = Params::new(65536, 3, 4, Some(32))
|
||||
.map_err(|e| anyhow::anyhow!("Invalid Argon2 params: {}", e))?;
|
||||
let hasher = Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
|
||||
let hash = hasher
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.map_err(|e| anyhow::anyhow!("Argon2id hash failed: {}", e))?;
|
||||
Ok(hash.to_string())
|
||||
}
|
||||
|
||||
/// Verify a password against an Argon2id PHC string hash.
|
||||
fn argon2id_verify(password: &str, hash: &str) -> bool {
|
||||
use argon2::{Argon2, PasswordVerifier};
|
||||
use argon2::password_hash::PasswordHash;
|
||||
|
||||
let parsed = match PasswordHash::new(hash) {
|
||||
Ok(h) => h,
|
||||
Err(_) => return false,
|
||||
};
|
||||
Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok()
|
||||
}
|
||||
|
||||
@@ -120,6 +120,9 @@ pub async fn create_full_backup(
|
||||
}
|
||||
|
||||
/// Restore a full backup from an encrypted archive.
|
||||
///
|
||||
/// Uses atomic staging: extracts to a temporary directory first, validates,
|
||||
/// then swaps into place with rollback on failure.
|
||||
pub async fn restore_full_backup(
|
||||
data_dir: &Path,
|
||||
backup_id: &str,
|
||||
@@ -134,20 +137,127 @@ pub async fn restore_full_backup(
|
||||
.await
|
||||
.context("Failed to read backup file")?;
|
||||
|
||||
// Check disk space: need at least 2x backup size free
|
||||
let backup_size = encrypted.len() as u64;
|
||||
if let Ok(output) = tokio::process::Command::new("df")
|
||||
.args(["--output=avail", "-B1"])
|
||||
.arg(data_dir)
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
if let Ok(stdout) = String::from_utf8(output.stdout) {
|
||||
if let Some(avail) = stdout.lines().nth(1).and_then(|l| l.trim().parse::<u64>().ok()) {
|
||||
if avail < backup_size * 2 {
|
||||
anyhow::bail!(
|
||||
"Insufficient disk space for restore: need {}MB, have {}MB",
|
||||
backup_size * 2 / (1024 * 1024),
|
||||
avail / (1024 * 1024),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let tar_gz_data = decrypt_data(&encrypted, passphrase)?;
|
||||
|
||||
// Extract to data_dir
|
||||
tokio::task::spawn_blocking({
|
||||
let data_dir = data_dir.to_path_buf();
|
||||
move || extract_tar_gz(&data_dir, &tar_gz_data)
|
||||
})
|
||||
.await?
|
||||
.context("Failed to extract backup")?;
|
||||
let staging_dir = data_dir.join(".restore-staging");
|
||||
let rollback_dir = data_dir.join(".restore-backup");
|
||||
|
||||
info!(id = %backup_id, "Backup restored");
|
||||
// Clean up any previous failed restore
|
||||
let _ = fs::remove_dir_all(&staging_dir).await;
|
||||
let _ = fs::remove_dir_all(&rollback_dir).await;
|
||||
|
||||
// Extract to staging directory
|
||||
fs::create_dir_all(&staging_dir)
|
||||
.await
|
||||
.context("Failed to create staging directory")?;
|
||||
|
||||
let staging_clone = staging_dir.clone();
|
||||
if let Err(e) = tokio::task::spawn_blocking(move || extract_tar_gz(&staging_clone, &tar_gz_data))
|
||||
.await?
|
||||
{
|
||||
let _ = fs::remove_dir_all(&staging_dir).await;
|
||||
return Err(e).context("Failed to extract backup to staging");
|
||||
}
|
||||
|
||||
// Validate staging has required files
|
||||
let has_identity = staging_dir.join("identity").exists();
|
||||
if !has_identity {
|
||||
let _ = fs::remove_dir_all(&staging_dir).await;
|
||||
anyhow::bail!("Invalid backup: missing identity directory");
|
||||
}
|
||||
|
||||
// Move current data to rollback directory
|
||||
fs::create_dir_all(&rollback_dir)
|
||||
.await
|
||||
.context("Failed to create rollback directory")?;
|
||||
|
||||
for dir_name in BACKUP_DIRS {
|
||||
let src = data_dir.join(dir_name);
|
||||
if src.exists() {
|
||||
let dst = rollback_dir.join(dir_name);
|
||||
if let Err(e) = fs::rename(&src, &dst).await {
|
||||
// Rollback: restore what we already moved
|
||||
info!("Restore failed during move, rolling back: {}", e);
|
||||
restore_from_rollback(data_dir, &rollback_dir).await;
|
||||
let _ = fs::remove_dir_all(&staging_dir).await;
|
||||
let _ = fs::remove_dir_all(&rollback_dir).await;
|
||||
return Err(e).context("Failed to move current data to rollback");
|
||||
}
|
||||
}
|
||||
}
|
||||
for file_name in BACKUP_FILES {
|
||||
let src = data_dir.join(file_name);
|
||||
if src.exists() {
|
||||
let dst = rollback_dir.join(file_name);
|
||||
let _ = fs::rename(&src, &dst).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Move staging contents to data_dir
|
||||
if let Err(e) = move_staging_to_data(data_dir, &staging_dir).await {
|
||||
info!("Restore failed during staging swap, rolling back: {}", e);
|
||||
restore_from_rollback(data_dir, &rollback_dir).await;
|
||||
let _ = fs::remove_dir_all(&staging_dir).await;
|
||||
let _ = fs::remove_dir_all(&rollback_dir).await;
|
||||
return Err(e).context("Failed to move staging data to data_dir");
|
||||
}
|
||||
|
||||
// Clean up
|
||||
let _ = fs::remove_dir_all(&staging_dir).await;
|
||||
let _ = fs::remove_dir_all(&rollback_dir).await;
|
||||
|
||||
info!(id = %backup_id, "Backup restored atomically");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Move staging directory contents into data_dir.
|
||||
async fn move_staging_to_data(data_dir: &Path, staging_dir: &Path) -> Result<()> {
|
||||
let mut entries = fs::read_dir(staging_dir)
|
||||
.await
|
||||
.context("Failed to read staging dir")?;
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let src = entry.path();
|
||||
let name = entry.file_name();
|
||||
let dst = data_dir.join(&name);
|
||||
fs::rename(&src, &dst)
|
||||
.await
|
||||
.with_context(|| format!("Failed to move {:?} from staging", name))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Restore data from rollback directory back to data_dir.
|
||||
async fn restore_from_rollback(data_dir: &Path, rollback_dir: &Path) {
|
||||
if let Ok(mut entries) = fs::read_dir(rollback_dir).await {
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
let src = entry.path();
|
||||
let dst = data_dir.join(entry.file_name());
|
||||
let _ = fs::rename(&src, &dst).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// List available backups by reading metadata files.
|
||||
pub async fn list_backups(data_dir: &Path) -> Result<Vec<BackupMetadata>> {
|
||||
let backups_dir = data_dir.join("backups");
|
||||
@@ -435,10 +545,46 @@ fn create_tar_gz(data_dir: &Path) -> Result<Vec<u8>> {
|
||||
fn extract_tar_gz(data_dir: &Path, tar_gz_data: &[u8]) -> Result<()> {
|
||||
let gz = GzDecoder::new(tar_gz_data);
|
||||
let mut archive = Archive::new(gz);
|
||||
let canonical_base = data_dir
|
||||
.canonicalize()
|
||||
.context("Failed to canonicalize data_dir")?;
|
||||
|
||||
archive
|
||||
.unpack(data_dir)
|
||||
.context("Failed to extract backup archive")?;
|
||||
for entry_result in archive.entries().context("Failed to read tar entries")? {
|
||||
let mut entry = entry_result.context("Failed to read tar entry")?;
|
||||
let entry_path = entry.path().context("Failed to get entry path")?.to_path_buf();
|
||||
|
||||
// Reject entries with path traversal components
|
||||
for component in entry_path.components() {
|
||||
if matches!(component, std::path::Component::ParentDir) {
|
||||
anyhow::bail!(
|
||||
"Tar entry contains path traversal: {}",
|
||||
entry_path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let target = data_dir.join(&entry_path);
|
||||
// Verify the resolved path stays within data_dir
|
||||
// For new files that don't exist yet, check the parent directory
|
||||
let check_path = if target.exists() {
|
||||
target.canonicalize()?
|
||||
} else if let Some(parent) = target.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
parent.canonicalize()?.join(target.file_name().unwrap_or_default())
|
||||
} else {
|
||||
target.clone()
|
||||
};
|
||||
if !check_path.starts_with(&canonical_base) {
|
||||
anyhow::bail!(
|
||||
"Tar entry escapes target directory: {}",
|
||||
entry_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
entry
|
||||
.unpack(&target)
|
||||
.with_context(|| format!("Failed to extract: {}", entry_path.display()))?;
|
||||
}
|
||||
|
||||
debug!("Backup extracted to {:?}", data_dir);
|
||||
Ok(())
|
||||
|
||||
@@ -117,7 +117,7 @@ pub async fn restore_encrypted_backup(
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let perms = std::fs::Permissions::from_mode(0o600);
|
||||
std::fs::set_permissions(&key_path, perms)?;
|
||||
tokio::fs::set_permissions(&key_path, perms).await?;
|
||||
}
|
||||
|
||||
// Derive DID and pubkey from the restored key
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
//! Shared Bitcoin RPC credential management.
|
||||
//! Reads credentials from the per-installation secrets file, falling back to
|
||||
//! environment variables, then a dev-only default.
|
||||
//! Bitcoin RPC credential management.
|
||||
//!
|
||||
//! Uses `rpcauth` in bitcoin.conf (salted hash — no plaintext in config or CLI).
|
||||
//! The actual password is stored in `/var/lib/archipelago/secrets/bitcoin-rpc-password`
|
||||
//! and stays stable across reboots, restarts, and deploys.
|
||||
|
||||
use tokio::sync::OnceCell;
|
||||
use tracing::debug;
|
||||
|
||||
const SECRETS_PATH: &str = "/var/lib/archipelago/secrets/bitcoin-rpc-password";
|
||||
const DEFAULT_USER: &str = "archipelago";
|
||||
const RPC_USER: &str = "archipelago";
|
||||
|
||||
static CACHED_PASSWORD: OnceCell<String> = OnceCell::const_new();
|
||||
|
||||
/// Read the Bitcoin RPC password from the secrets file, env var, or dev fallback.
|
||||
/// Read the Bitcoin RPC password from the secrets file.
|
||||
/// Falls back to env var (dev), then generates and persists a random password.
|
||||
async fn read_password() -> String {
|
||||
// 1. Try secrets file (production path)
|
||||
// 1. Secrets file (production)
|
||||
if let Ok(pass) = tokio::fs::read_to_string(SECRETS_PATH).await {
|
||||
let pass = pass.trim().to_string();
|
||||
if !pass.is_empty() {
|
||||
@@ -21,7 +24,7 @@ async fn read_password() -> String {
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try environment variable
|
||||
// 2. Environment variable (dev)
|
||||
if let Ok(pass) = std::env::var("BITCOIN_RPC_PASSWORD") {
|
||||
if !pass.is_empty() {
|
||||
debug!("Bitcoin RPC password loaded from env var");
|
||||
@@ -29,9 +32,35 @@ async fn read_password() -> String {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Dev fallback (will only work on dev machines with default config)
|
||||
debug!("Bitcoin RPC password: using dev fallback");
|
||||
"archipelago123".to_string()
|
||||
// 3. Generate and persist (first boot)
|
||||
let random_pass = generate_random_password();
|
||||
if let Some(parent) = std::path::Path::new(SECRETS_PATH).parent() {
|
||||
let _ = tokio::fs::create_dir_all(parent).await;
|
||||
}
|
||||
match tokio::fs::write(SECRETS_PATH, &random_pass).await {
|
||||
Ok(_) => {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = tokio::fs::set_permissions(
|
||||
SECRETS_PATH,
|
||||
std::fs::Permissions::from_mode(0o600),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
debug!("Bitcoin RPC password generated and saved");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to save Bitcoin RPC password: {}", e);
|
||||
}
|
||||
}
|
||||
random_pass
|
||||
}
|
||||
|
||||
/// Generate a cryptographically random password (32 hex chars).
|
||||
fn generate_random_password() -> String {
|
||||
let bytes: [u8; 16] = rand::random();
|
||||
hex::encode(bytes)
|
||||
}
|
||||
|
||||
/// Get Bitcoin RPC credentials (user, password). Cached after first call.
|
||||
@@ -39,11 +68,6 @@ pub async fn bitcoin_rpc_credentials() -> (String, String) {
|
||||
let pass = CACHED_PASSWORD
|
||||
.get_or_init(|| async { read_password().await })
|
||||
.await;
|
||||
(DEFAULT_USER.to_string(), pass.clone())
|
||||
(RPC_USER.to_string(), pass.clone())
|
||||
}
|
||||
|
||||
/// Get the Bitcoin RPC password as a plain string (for config generation).
|
||||
pub async fn bitcoin_rpc_password() -> String {
|
||||
let (_, pass) = bitcoin_rpc_credentials().await;
|
||||
pass
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
data_dir: PathBuf::from("/var/lib/archipelago"),
|
||||
bind_host: "0.0.0.0".to_string(),
|
||||
bind_host: "127.0.0.1".to_string(),
|
||||
bind_port: 5678,
|
||||
log_level: "info".to_string(),
|
||||
host_ip: "127.0.0.1".to_string(),
|
||||
@@ -217,7 +217,7 @@ mod tests {
|
||||
fn test_default_config_values() {
|
||||
let config = Config::default();
|
||||
assert_eq!(config.data_dir, PathBuf::from("/var/lib/archipelago"));
|
||||
assert_eq!(config.bind_host, "0.0.0.0");
|
||||
assert_eq!(config.bind_host, "127.0.0.1");
|
||||
assert_eq!(config.bind_port, 5678);
|
||||
assert_eq!(config.log_level, "info");
|
||||
assert_eq!(config.host_ip, "127.0.0.1");
|
||||
|
||||
12
core/archipelago/src/constants.rs
Normal file
12
core/archipelago/src/constants.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
/// Centralized constants for the Archipelago backend.
|
||||
/// Avoids hardcoded values scattered across the codebase.
|
||||
|
||||
/// Bitcoin Core RPC endpoint (localhost only).
|
||||
pub const BITCOIN_RPC_URL: &str = "http://127.0.0.1:8332/";
|
||||
|
||||
/// DWN (Decentralized Web Node) health check endpoint.
|
||||
pub const DWN_HEALTH_URL: &str = "http://127.0.0.1:3100/health";
|
||||
|
||||
/// Tor SOCKS5 proxy for outbound onion connections.
|
||||
pub const TOR_SOCKS_PROXY: &str = "socks5h://127.0.0.1:9050";
|
||||
|
||||
@@ -64,29 +64,6 @@ impl DevDataManager {
|
||||
// This is a no-op by default, but can be extended
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get all app data directories
|
||||
#[allow(dead_code)]
|
||||
pub async fn list_app_data_dirs(&self) -> Result<Vec<String>> {
|
||||
if !self.dev_data_dir.exists() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let mut entries = fs::read_dir(&self.dev_data_dir)
|
||||
.await
|
||||
.with_context(|| format!("Failed to read dev data directory: {:?}", self.dev_data_dir))?;
|
||||
|
||||
let mut app_ids = Vec::new();
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
if entry.file_type().await?.is_dir() {
|
||||
if let Some(name) = entry.file_name().to_str() {
|
||||
app_ids.push(name.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(app_ids)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -247,16 +247,4 @@ impl DevContainerOrchestrator {
|
||||
archipelago_container::ContainerState::Unknown(_) => Ok("unknown".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get port mapping for an app
|
||||
#[allow(dead_code)]
|
||||
pub fn get_port_mapping(&self, app_id: &str) -> Option<Vec<u16>> {
|
||||
self.port_manager.get_port_mapping(app_id).ok().flatten()
|
||||
}
|
||||
|
||||
/// Get Bitcoin simulator
|
||||
#[allow(dead_code)]
|
||||
pub fn bitcoin_simulator(&self) -> &Arc<BitcoinSimulator> {
|
||||
&self.bitcoin_simulator
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,10 +141,11 @@ impl DockerPackageScanner {
|
||||
// Convert container state to package/service state
|
||||
let (package_state, service_status) = convert_state(&container.state);
|
||||
|
||||
let tor_address = read_tor_address(&app_id);
|
||||
let tor_address = read_tor_address(&app_id).await;
|
||||
|
||||
let package = PackageDataEntry {
|
||||
state: package_state.clone(),
|
||||
health: container.health.clone(),
|
||||
static_files: StaticFiles {
|
||||
license: "MIT".to_string(),
|
||||
instructions: metadata.description.clone(),
|
||||
@@ -547,7 +548,7 @@ fn is_real_onion_address(s: &str) -> bool {
|
||||
/// Read real .onion address from Tor hidden service hostname file.
|
||||
/// Service name "archipelago" is for the main web UI (nginx port 80).
|
||||
/// Uses TOR_DATA_DIR env var if set, else /var/lib/archipelago/tor.
|
||||
pub fn read_tor_address(app_id: &str) -> Option<String> {
|
||||
pub async fn read_tor_address(app_id: &str) -> Option<String> {
|
||||
let service = tor_service_name(app_id)?;
|
||||
let base = std::env::var("TOR_DATA_DIR").unwrap_or_else(|_| "/var/lib/archipelago/tor".to_string());
|
||||
|
||||
@@ -557,7 +558,8 @@ pub fn read_tor_address(app_id: &str) -> Option<String> {
|
||||
.unwrap_or(std::path::Path::new("/var/lib/archipelago"))
|
||||
.join("tor-hostnames")
|
||||
.join(service);
|
||||
if let Some(addr) = std::fs::read_to_string(&hostnames_path)
|
||||
if let Some(addr) = tokio::fs::read_to_string(&hostnames_path)
|
||||
.await
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| s.ends_with(".onion") && !s.is_empty())
|
||||
@@ -569,7 +571,8 @@ pub fn read_tor_address(app_id: &str) -> Option<String> {
|
||||
let path = std::path::Path::new(&base)
|
||||
.join(format!("hidden_service_{}", service))
|
||||
.join("hostname");
|
||||
std::fs::read_to_string(&path)
|
||||
tokio::fs::read_to_string(&path)
|
||||
.await
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| s.ends_with(".onion") && !s.is_empty())
|
||||
@@ -592,9 +595,8 @@ fn extract_lan_address(ports: &[String]) -> Option<String> {
|
||||
fn convert_state(container_state: &ContainerState) -> (PackageState, ServiceStatus) {
|
||||
match container_state {
|
||||
ContainerState::Running => (PackageState::Running, ServiceStatus::Running),
|
||||
ContainerState::Stopped | ContainerState::Exited => {
|
||||
(PackageState::Stopped, ServiceStatus::Stopped)
|
||||
}
|
||||
ContainerState::Stopped => (PackageState::Stopped, ServiceStatus::Stopped),
|
||||
ContainerState::Exited => (PackageState::Exited, ServiceStatus::Stopped),
|
||||
ContainerState::Created => (PackageState::Stopped, ServiceStatus::Stopped),
|
||||
ContainerState::Paused => (PackageState::Stopped, ServiceStatus::Stopped),
|
||||
ContainerState::Unknown(_) => (PackageState::Stopped, ServiceStatus::Stopped),
|
||||
@@ -607,6 +609,7 @@ fn package_state_str(state: &PackageState) -> &str {
|
||||
PackageState::Installed => "installed",
|
||||
PackageState::Stopping => "stopping",
|
||||
PackageState::Stopped => "stopped",
|
||||
PackageState::Exited => "exited",
|
||||
PackageState::Starting => "starting",
|
||||
PackageState::Running => "running",
|
||||
PackageState::Restarting => "restarting",
|
||||
|
||||
@@ -11,11 +11,80 @@
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Instant;
|
||||
use tokio::fs;
|
||||
use tracing::{info, warn};
|
||||
|
||||
const PID_FILE: &str = "archipelago.pid";
|
||||
const CONTAINER_STATE_FILE: &str = "running-containers.json";
|
||||
const USER_STOPPED_FILE: &str = "user-stopped.json";
|
||||
|
||||
/// Shared flag: true once boot recovery is complete. Health monitor should wait for this.
|
||||
pub static RECOVERY_COMPLETE: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Process start time for uptime calculation.
|
||||
static START_TIME: std::sync::OnceLock<Instant> = std::sync::OnceLock::new();
|
||||
|
||||
/// Initialize the start time. Call once at startup.
|
||||
pub fn init_start_time() {
|
||||
START_TIME.get_or_init(Instant::now);
|
||||
}
|
||||
|
||||
/// Get uptime in seconds since process start.
|
||||
pub fn uptime_seconds() -> u64 {
|
||||
START_TIME
|
||||
.get()
|
||||
.map(|t| t.elapsed().as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Mark boot recovery as complete. Call after crash recovery + start_stopped_containers finish.
|
||||
pub fn mark_recovery_complete() {
|
||||
RECOVERY_COMPLETE.store(true, Ordering::SeqCst);
|
||||
info!("Boot recovery complete — health monitor may proceed");
|
||||
}
|
||||
|
||||
/// Check if boot recovery is done.
|
||||
pub fn is_recovery_complete() -> bool {
|
||||
RECOVERY_COMPLETE.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
// ── User-stopped tracking ───────────────────────────────────────────────
|
||||
// When a user explicitly stops a container via the UI, we record it here
|
||||
// so crash recovery and health monitor don't auto-restart it.
|
||||
|
||||
/// Load the set of user-stopped containers from disk.
|
||||
pub async fn load_user_stopped(data_dir: &Path) -> std::collections::HashSet<String> {
|
||||
let path = data_dir.join(USER_STOPPED_FILE);
|
||||
match fs::read_to_string(&path).await {
|
||||
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
|
||||
Err(_) => std::collections::HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Save the set of user-stopped containers to disk.
|
||||
pub async fn save_user_stopped(data_dir: &Path, stopped: &std::collections::HashSet<String>) {
|
||||
let path = data_dir.join(USER_STOPPED_FILE);
|
||||
if let Ok(json) = serde_json::to_string_pretty(stopped) {
|
||||
let _ = fs::write(&path, json).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark a container as user-stopped (won't be auto-restarted).
|
||||
pub async fn mark_user_stopped(data_dir: &Path, name: &str) {
|
||||
let mut stopped = load_user_stopped(data_dir).await;
|
||||
stopped.insert(name.to_string());
|
||||
save_user_stopped(data_dir, &stopped).await;
|
||||
}
|
||||
|
||||
/// Clear user-stopped flag (container was manually started by user).
|
||||
pub async fn clear_user_stopped(data_dir: &Path, name: &str) {
|
||||
let mut stopped = load_user_stopped(data_dir).await;
|
||||
if stopped.remove(name) {
|
||||
save_user_stopped(data_dir, &stopped).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RunningContainerRecord {
|
||||
@@ -241,7 +310,8 @@ fn is_process_running(pid: u32) -> bool {
|
||||
/// Start all stopped containers that were previously installed.
|
||||
/// Runs on every startup to ensure containers come back after clean reboots.
|
||||
/// The crash recovery (PID-based) handles dirty shutdowns; this handles clean ones.
|
||||
pub async fn start_stopped_containers() -> RecoveryReport {
|
||||
/// Skips containers that the user intentionally stopped via the UI.
|
||||
pub async fn start_stopped_containers(data_dir: &Path) -> RecoveryReport {
|
||||
let output = match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(30),
|
||||
tokio::process::Command::new("podman")
|
||||
@@ -257,7 +327,7 @@ pub async fn start_stopped_containers() -> RecoveryReport {
|
||||
}
|
||||
};
|
||||
|
||||
let names: Vec<String> = match output {
|
||||
let all_names: Vec<String> = match output {
|
||||
Ok(o) if o.status.success() => {
|
||||
String::from_utf8_lossy(&o.stdout)
|
||||
.lines()
|
||||
@@ -268,17 +338,52 @@ pub async fn start_stopped_containers() -> RecoveryReport {
|
||||
_ => Vec::new(),
|
||||
};
|
||||
|
||||
if all_names.is_empty() {
|
||||
return RecoveryReport { total: 0, recovered: 0, failed: Vec::new() };
|
||||
}
|
||||
|
||||
// Filter out user-stopped containers
|
||||
let user_stopped = load_user_stopped(data_dir).await;
|
||||
let names: Vec<String> = all_names.into_iter()
|
||||
.filter(|n| {
|
||||
if user_stopped.contains(n) {
|
||||
info!("Skipping user-stopped container: {}", n);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if names.is_empty() {
|
||||
return RecoveryReport { total: 0, recovered: 0, failed: Vec::new() };
|
||||
}
|
||||
|
||||
info!("Starting {} stopped containers after boot...", names.len());
|
||||
let records: Vec<RunningContainerRecord> = names.iter()
|
||||
// Sort by startup tier: databases first, then core, then dependent services, then apps
|
||||
let mut records: Vec<RunningContainerRecord> = names.iter()
|
||||
.map(|n| RunningContainerRecord { name: n.clone(), image: String::new() })
|
||||
.collect();
|
||||
records.sort_by_key(|r| container_boot_tier(&r.name));
|
||||
|
||||
info!("Starting {} stopped containers after boot (skipped {} user-stopped)...",
|
||||
records.len(), user_stopped.len());
|
||||
recover_containers(&records).await
|
||||
}
|
||||
|
||||
/// Simple tier ordering for boot recovery (mirrors health_monitor tiers).
|
||||
fn container_boot_tier(name: &str) -> u8 {
|
||||
let id = name.strip_prefix("archy-").unwrap_or(name);
|
||||
match id {
|
||||
"btcpay-db" | "mempool-db" | "penpot-postgres" | "immich_postgres"
|
||||
| "immich_redis" | "penpot-valkey" => 0,
|
||||
"bitcoin-knots" | "bitcoin-core" | "bitcoin" => 1,
|
||||
"lnd" | "electrumx" | "mempool-electrs" | "electrs" | "nbxplorer" => 2,
|
||||
"mempool-web" | "bitcoin-ui" | "lnd-ui" | "electrs-ui"
|
||||
| "penpot-frontend" | "penpot-exporter" => 4,
|
||||
_ => 3,
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a background task that periodically saves the container snapshot.
|
||||
pub fn spawn_snapshot_task(data_dir: PathBuf) {
|
||||
tokio::spawn(async move {
|
||||
|
||||
@@ -1,796 +0,0 @@
|
||||
//! Verifiable Credentials (VC) management following W3C VC Data Model 2.0.
|
||||
//! Implements JSON-LD @context, Ed25519Signature2020 proof format.
|
||||
//! See: https://www.w3.org/TR/vc-data-model-2.0/
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use tokio::fs;
|
||||
use tracing::debug;
|
||||
|
||||
const CREDENTIALS_DIR: &str = "credentials";
|
||||
|
||||
/// W3C VC Data Model 2.0 context URI
|
||||
const VC_CONTEXT_V2: &str = "https://www.w3.org/ns/credentials/v2";
|
||||
/// Ed25519 signature suite context
|
||||
const ED25519_CONTEXT: &str = "https://w3id.org/security/suites/ed25519-2020/v1";
|
||||
|
||||
/// A Verifiable Credential following W3C VC Data Model 2.0.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VerifiableCredential {
|
||||
#[serde(rename = "@context")]
|
||||
pub context: Vec<String>,
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub credential_type: Vec<String>,
|
||||
pub issuer: String,
|
||||
pub credential_subject: CredentialSubject,
|
||||
pub issuance_date: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expiration_date: Option<String>,
|
||||
pub proof: CredentialProof,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub credential_status: Option<CredentialStatusEntry>,
|
||||
}
|
||||
|
||||
/// The subject of a credential with their DID and claims.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CredentialSubject {
|
||||
pub id: String,
|
||||
#[serde(flatten)]
|
||||
pub claims: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Ed25519Signature2020 proof format.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CredentialProof {
|
||||
#[serde(rename = "type")]
|
||||
pub proof_type: String,
|
||||
pub created: String,
|
||||
pub verification_method: String,
|
||||
pub proof_purpose: String,
|
||||
pub proof_value: String,
|
||||
}
|
||||
|
||||
/// Credential status for revocation tracking.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CredentialStatusEntry {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub status_type: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CredentialStatus {
|
||||
Active,
|
||||
Revoked,
|
||||
Expired,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CredentialStatus {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
CredentialStatus::Active => write!(f, "active"),
|
||||
CredentialStatus::Revoked => write!(f, "revoked"),
|
||||
CredentialStatus::Expired => write!(f, "expired"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stored credentials index.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct CredentialStore {
|
||||
pub credentials: Vec<VerifiableCredential>,
|
||||
}
|
||||
|
||||
async fn ensure_dir(data_dir: &Path) -> Result<()> {
|
||||
let dir = data_dir.join(CREDENTIALS_DIR);
|
||||
if !dir.exists() {
|
||||
fs::create_dir_all(&dir).await.context("Creating credentials dir")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn store_path(data_dir: &Path) -> std::path::PathBuf {
|
||||
data_dir.join(CREDENTIALS_DIR).join("credentials.json")
|
||||
}
|
||||
|
||||
pub async fn load_credentials(data_dir: &Path) -> Result<CredentialStore> {
|
||||
ensure_dir(data_dir).await?;
|
||||
let path = store_path(data_dir);
|
||||
if !path.exists() {
|
||||
return Ok(CredentialStore::default());
|
||||
}
|
||||
let raw = fs::read(&path).await.context("Reading credentials")?;
|
||||
// Detect plaintext JSON (migration path) vs encrypted binary
|
||||
if raw.first().map_or(false, |b| *b == b'[' || *b == b'{') {
|
||||
let data = String::from_utf8(raw).context("UTF-8 credentials")?;
|
||||
return serde_json::from_str(&data).context("Parsing credentials");
|
||||
}
|
||||
// Encrypted: decrypt using node key
|
||||
let key = load_encryption_key(data_dir).await?;
|
||||
let plaintext = decrypt_credentials(&raw, &key)?;
|
||||
serde_json::from_slice(&plaintext).context("Parsing decrypted credentials")
|
||||
}
|
||||
|
||||
pub async fn save_credentials(data_dir: &Path, store: &CredentialStore) -> Result<()> {
|
||||
ensure_dir(data_dir).await?;
|
||||
let path = store_path(data_dir);
|
||||
let data = serde_json::to_vec(store)?;
|
||||
// Encrypt using node key
|
||||
let key = load_encryption_key(data_dir).await?;
|
||||
let encrypted = encrypt_credentials(&data, &key)?;
|
||||
fs::write(&path, encrypted).await.context("Writing credentials")
|
||||
}
|
||||
|
||||
/// Derive a 32-byte encryption key from the node's identity key via SHA-256.
|
||||
async fn load_encryption_key(data_dir: &Path) -> Result<[u8; 32]> {
|
||||
let node_key_path = data_dir.join("identity").join("node_key");
|
||||
let key_bytes = fs::read(&node_key_path).await.context("Reading node key for credential encryption")?;
|
||||
use sha2::{Sha256, Digest};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(b"archipelago-credential-store-v1");
|
||||
hasher.update(&key_bytes);
|
||||
let hash = hasher.finalize();
|
||||
let mut key = [0u8; 32];
|
||||
key.copy_from_slice(&hash);
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
fn encrypt_credentials(data: &[u8], key: &[u8; 32]) -> Result<Vec<u8>> {
|
||||
use chacha20poly1305::aead::{Aead, KeyInit};
|
||||
let nonce_bytes: [u8; 12] = rand::random();
|
||||
let cipher = chacha20poly1305::ChaCha20Poly1305::new_from_slice(key)
|
||||
.map_err(|e| anyhow::anyhow!("Cipher init: {}", e))?;
|
||||
let ciphertext = cipher
|
||||
.encrypt(
|
||||
chacha20poly1305::aead::generic_array::GenericArray::from_slice(&nonce_bytes),
|
||||
data,
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("Encryption failed: {}", e))?;
|
||||
let mut output = Vec::with_capacity(12 + ciphertext.len());
|
||||
output.extend_from_slice(&nonce_bytes);
|
||||
output.extend_from_slice(&ciphertext);
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn decrypt_credentials(data: &[u8], key: &[u8; 32]) -> Result<Vec<u8>> {
|
||||
use chacha20poly1305::aead::{Aead, KeyInit};
|
||||
if data.len() < 12 {
|
||||
anyhow::bail!("Encrypted credentials too short");
|
||||
}
|
||||
let nonce = &data[..12];
|
||||
let ciphertext = &data[12..];
|
||||
let cipher = chacha20poly1305::ChaCha20Poly1305::new_from_slice(key)
|
||||
.map_err(|e| anyhow::anyhow!("Cipher init: {}", e))?;
|
||||
cipher
|
||||
.decrypt(
|
||||
chacha20poly1305::aead::generic_array::GenericArray::from_slice(nonce),
|
||||
ciphertext,
|
||||
)
|
||||
.map_err(|_| anyhow::anyhow!("Credential decryption failed — key mismatch or corruption"))
|
||||
}
|
||||
|
||||
/// Issue a new Verifiable Credential following W3C VC Data Model 2.0.
|
||||
/// Uses Ed25519Signature2020 proof format.
|
||||
pub async fn issue_credential(
|
||||
data_dir: &Path,
|
||||
issuer_did: &str,
|
||||
subject_did: &str,
|
||||
credential_type: &str,
|
||||
claims: serde_json::Value,
|
||||
expires_at: Option<&str>,
|
||||
sign_fn: impl FnOnce(&[u8]) -> Result<String>,
|
||||
) -> Result<VerifiableCredential> {
|
||||
let id = format!("urn:uuid:{}", uuid::Uuid::new_v4());
|
||||
let issued_at = chrono::Utc::now().to_rfc3339();
|
||||
let key_id = format!("{}#key-1", issuer_did);
|
||||
|
||||
// Build the credential body for signing (without proof)
|
||||
let body = serde_json::json!({
|
||||
"@context": [VC_CONTEXT_V2, ED25519_CONTEXT],
|
||||
"id": id,
|
||||
"type": ["VerifiableCredential", credential_type],
|
||||
"issuer": issuer_did,
|
||||
"credentialSubject": {
|
||||
"id": subject_did,
|
||||
},
|
||||
"issuanceDate": issued_at,
|
||||
});
|
||||
let body_bytes = serde_json::to_vec(&body)?;
|
||||
let signature = sign_fn(&body_bytes)?;
|
||||
|
||||
let vc = VerifiableCredential {
|
||||
context: vec![VC_CONTEXT_V2.to_string(), ED25519_CONTEXT.to_string()],
|
||||
id: id.clone(),
|
||||
credential_type: vec!["VerifiableCredential".to_string(), credential_type.to_string()],
|
||||
issuer: issuer_did.to_string(),
|
||||
credential_subject: CredentialSubject {
|
||||
id: subject_did.to_string(),
|
||||
claims,
|
||||
},
|
||||
issuance_date: issued_at.clone(),
|
||||
expiration_date: expires_at.map(|s| s.to_string()),
|
||||
proof: CredentialProof {
|
||||
proof_type: "Ed25519Signature2020".to_string(),
|
||||
created: issued_at,
|
||||
verification_method: key_id,
|
||||
proof_purpose: "assertionMethod".to_string(),
|
||||
proof_value: signature,
|
||||
},
|
||||
credential_status: None,
|
||||
};
|
||||
|
||||
let mut store = load_credentials(data_dir).await?;
|
||||
debug!(id = %vc.id, "Issued W3C VC");
|
||||
store.credentials.push(vc.clone());
|
||||
save_credentials(data_dir, &store).await?;
|
||||
Ok(vc)
|
||||
}
|
||||
|
||||
/// Verify a credential's signature against the issuer DID.
|
||||
pub fn verify_credential(
|
||||
vc: &VerifiableCredential,
|
||||
verify_fn: impl FnOnce(&str, &[u8], &str) -> Result<bool>,
|
||||
) -> Result<bool> {
|
||||
// Reconstruct the body that was signed (without proof)
|
||||
let body = serde_json::json!({
|
||||
"@context": vc.context,
|
||||
"id": vc.id,
|
||||
"type": vc.credential_type,
|
||||
"issuer": vc.issuer,
|
||||
"credentialSubject": {
|
||||
"id": vc.credential_subject.id,
|
||||
},
|
||||
"issuanceDate": vc.issuance_date,
|
||||
});
|
||||
let body_bytes = serde_json::to_vec(&body)?;
|
||||
verify_fn(&vc.issuer, &body_bytes, &vc.proof.proof_value)
|
||||
}
|
||||
|
||||
/// Revoke a credential by ID.
|
||||
pub async fn revoke_credential(data_dir: &Path, credential_id: &str) -> Result<()> {
|
||||
let mut store = load_credentials(data_dir).await?;
|
||||
let vc = store
|
||||
.credentials
|
||||
.iter_mut()
|
||||
.find(|c| c.id == credential_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Credential not found: {}", credential_id))?;
|
||||
vc.credential_status = Some(CredentialStatusEntry {
|
||||
id: format!("{}#status", credential_id),
|
||||
status_type: "CredentialStatusList2021".to_string(),
|
||||
status: "revoked".to_string(),
|
||||
});
|
||||
save_credentials(data_dir, &store).await
|
||||
}
|
||||
|
||||
/// List all credentials, optionally filtering by issuer or subject DID.
|
||||
pub async fn list_credentials(
|
||||
data_dir: &Path,
|
||||
filter_did: Option<&str>,
|
||||
) -> Result<Vec<VerifiableCredential>> {
|
||||
let store = load_credentials(data_dir).await?;
|
||||
let creds = if let Some(did) = filter_did {
|
||||
store
|
||||
.credentials
|
||||
.into_iter()
|
||||
.filter(|c| c.issuer == did || c.credential_subject.id == did)
|
||||
.collect()
|
||||
} else {
|
||||
store.credentials
|
||||
};
|
||||
Ok(creds)
|
||||
}
|
||||
|
||||
/// Check if a credential is revoked.
|
||||
pub fn is_revoked(vc: &VerifiableCredential) -> bool {
|
||||
vc.credential_status
|
||||
.as_ref()
|
||||
.map_or(false, |s| s.status == "revoked")
|
||||
}
|
||||
|
||||
/// A Verifiable Presentation following W3C VC Data Model 2.0.
|
||||
/// Bundles one or more VCs with a holder proof.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VerifiablePresentation {
|
||||
#[serde(rename = "@context")]
|
||||
pub context: Vec<String>,
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub presentation_type: Vec<String>,
|
||||
pub holder: String,
|
||||
pub verifiable_credential: Vec<VerifiableCredential>,
|
||||
pub proof: CredentialProof,
|
||||
}
|
||||
|
||||
/// Create a Verifiable Presentation wrapping selected credentials.
|
||||
/// The holder signs the presentation to prove they possess the credentials.
|
||||
pub fn create_presentation(
|
||||
holder_did: &str,
|
||||
credential_ids: &[&str],
|
||||
credentials: &[VerifiableCredential],
|
||||
sign_fn: impl FnOnce(&[u8]) -> Result<String>,
|
||||
) -> Result<VerifiablePresentation> {
|
||||
let selected: Vec<VerifiableCredential> = credentials
|
||||
.iter()
|
||||
.filter(|c| credential_ids.contains(&c.id.as_str()))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
if selected.is_empty() {
|
||||
return Err(anyhow::anyhow!("No matching credentials found"));
|
||||
}
|
||||
|
||||
let id = format!("urn:uuid:{}", uuid::Uuid::new_v4());
|
||||
let created = chrono::Utc::now().to_rfc3339();
|
||||
let key_id = format!("{}#key-1", holder_did);
|
||||
|
||||
// Build the presentation body for signing (without proof)
|
||||
let body = serde_json::json!({
|
||||
"@context": [VC_CONTEXT_V2, ED25519_CONTEXT],
|
||||
"id": id,
|
||||
"type": ["VerifiablePresentation"],
|
||||
"holder": holder_did,
|
||||
"verifiableCredential": selected,
|
||||
});
|
||||
let body_bytes = serde_json::to_vec(&body)?;
|
||||
let signature = sign_fn(&body_bytes)?;
|
||||
|
||||
Ok(VerifiablePresentation {
|
||||
context: vec![VC_CONTEXT_V2.to_string(), ED25519_CONTEXT.to_string()],
|
||||
id,
|
||||
presentation_type: vec!["VerifiablePresentation".to_string()],
|
||||
holder: holder_did.to_string(),
|
||||
verifiable_credential: selected,
|
||||
proof: CredentialProof {
|
||||
proof_type: "Ed25519Signature2020".to_string(),
|
||||
created,
|
||||
verification_method: key_id,
|
||||
proof_purpose: "authentication".to_string(),
|
||||
proof_value: signature,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Verify a Verifiable Presentation: check holder's proof signature,
|
||||
/// then verify each embedded credential.
|
||||
pub fn verify_presentation(
|
||||
vp: &VerifiablePresentation,
|
||||
verify_fn: impl Fn(&str, &[u8], &str) -> Result<bool>,
|
||||
) -> Result<PresentationVerification> {
|
||||
// 1. Verify the holder's presentation proof
|
||||
let body = serde_json::json!({
|
||||
"@context": vp.context,
|
||||
"id": vp.id,
|
||||
"type": vp.presentation_type,
|
||||
"holder": vp.holder,
|
||||
"verifiableCredential": vp.verifiable_credential,
|
||||
});
|
||||
let body_bytes = serde_json::to_vec(&body)?;
|
||||
let holder_valid = verify_fn(&vp.holder, &body_bytes, &vp.proof.proof_value)?;
|
||||
|
||||
// 2. Verify each embedded credential
|
||||
let mut credential_results = Vec::new();
|
||||
for vc in &vp.verifiable_credential {
|
||||
let vc_valid = verify_credential(vc, |did, bytes, sig| verify_fn(did, bytes, sig))?;
|
||||
credential_results.push(CredentialVerificationResult {
|
||||
id: vc.id.clone(),
|
||||
valid: vc_valid,
|
||||
revoked: is_revoked(vc),
|
||||
});
|
||||
}
|
||||
|
||||
let all_valid = holder_valid && credential_results.iter().all(|r| r.valid && !r.revoked);
|
||||
|
||||
Ok(PresentationVerification {
|
||||
holder_valid,
|
||||
credentials: credential_results,
|
||||
valid: all_valid,
|
||||
})
|
||||
}
|
||||
|
||||
/// Result of verifying a Verifiable Presentation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PresentationVerification {
|
||||
pub holder_valid: bool,
|
||||
pub credentials: Vec<CredentialVerificationResult>,
|
||||
pub valid: bool,
|
||||
}
|
||||
|
||||
/// Result of verifying a single credential within a presentation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CredentialVerificationResult {
|
||||
pub id: String,
|
||||
pub valid: bool,
|
||||
pub revoked: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_credential_status_display() {
|
||||
assert_eq!(CredentialStatus::Active.to_string(), "active");
|
||||
assert_eq!(CredentialStatus::Revoked.to_string(), "revoked");
|
||||
assert_eq!(CredentialStatus::Expired.to_string(), "expired");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_credential_status_serde_roundtrip() {
|
||||
let json = serde_json::to_string(&CredentialStatus::Revoked).unwrap();
|
||||
assert_eq!(json, "\"revoked\"");
|
||||
let parsed: CredentialStatus = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed, CredentialStatus::Revoked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_credential_store_default_is_empty() {
|
||||
let store = CredentialStore::default();
|
||||
assert!(store.credentials.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_credentials_returns_empty_when_no_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = load_credentials(dir.path()).await.unwrap();
|
||||
assert!(store.credentials.is_empty());
|
||||
assert!(dir.path().join(CREDENTIALS_DIR).exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_issue_credential_w3c_format() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vc = issue_credential(
|
||||
dir.path(),
|
||||
"did:key:issuer",
|
||||
"did:key:subject",
|
||||
"NodeOperator",
|
||||
serde_json::json!({"role": "admin"}),
|
||||
Some("2027-12-31T23:59:59Z"),
|
||||
|_bytes| Ok("mock-signature".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// W3C structure checks
|
||||
assert!(vc.id.starts_with("urn:uuid:"));
|
||||
assert_eq!(vc.context[0], VC_CONTEXT_V2);
|
||||
assert_eq!(vc.context[1], ED25519_CONTEXT);
|
||||
assert_eq!(vc.credential_type, vec!["VerifiableCredential", "NodeOperator"]);
|
||||
assert_eq!(vc.issuer, "did:key:issuer");
|
||||
assert_eq!(vc.credential_subject.id, "did:key:subject");
|
||||
assert_eq!(vc.proof.proof_type, "Ed25519Signature2020");
|
||||
assert_eq!(vc.proof.proof_purpose, "assertionMethod");
|
||||
assert_eq!(vc.proof.verification_method, "did:key:issuer#key-1");
|
||||
assert_eq!(vc.proof.proof_value, "mock-signature");
|
||||
assert_eq!(vc.expiration_date, Some("2027-12-31T23:59:59Z".to_string()));
|
||||
assert!(vc.credential_status.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_issue_credential_serializes_as_jsonld() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vc = issue_credential(
|
||||
dir.path(),
|
||||
"did:key:issuer",
|
||||
"did:key:subject",
|
||||
"TestCred",
|
||||
serde_json::json!({"level": "gold"}),
|
||||
None,
|
||||
|_| Ok("sig".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let json = serde_json::to_value(&vc).unwrap();
|
||||
// Must have @context
|
||||
assert!(json["@context"].is_array());
|
||||
// Must have type array
|
||||
assert!(json["type"].is_array());
|
||||
// Must have credentialSubject
|
||||
assert!(json["credentialSubject"]["id"].is_string());
|
||||
// Must have proof
|
||||
assert_eq!(json["proof"]["type"], "Ed25519Signature2020");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_save_and_load_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
issue_credential(
|
||||
dir.path(),
|
||||
"did:key:a",
|
||||
"did:key:b",
|
||||
"Type1",
|
||||
serde_json::json!({"k": "v"}),
|
||||
None,
|
||||
|_| Ok("s1".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let loaded = load_credentials(dir.path()).await.unwrap();
|
||||
assert_eq!(loaded.credentials.len(), 1);
|
||||
assert_eq!(loaded.credentials[0].credential_type[1], "Type1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_issue_credential_sign_fn_failure_propagates() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let result = issue_credential(
|
||||
dir.path(),
|
||||
"did:key:issuer",
|
||||
"did:key:subject",
|
||||
"TestCredential",
|
||||
serde_json::json!({}),
|
||||
None,
|
||||
|_bytes| Err(anyhow::anyhow!("Signing failed")),
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("Signing failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_credential_calls_verify_fn() {
|
||||
let vc = VerifiableCredential {
|
||||
context: vec![VC_CONTEXT_V2.to_string()],
|
||||
id: "urn:uuid:test".to_string(),
|
||||
credential_type: vec!["VerifiableCredential".to_string(), "Test".to_string()],
|
||||
issuer: "did:key:issuer".to_string(),
|
||||
credential_subject: CredentialSubject {
|
||||
id: "did:key:subject".to_string(),
|
||||
claims: serde_json::json!({"foo": "bar"}),
|
||||
},
|
||||
issuance_date: "2025-06-01T00:00:00Z".to_string(),
|
||||
expiration_date: None,
|
||||
proof: CredentialProof {
|
||||
proof_type: "Ed25519Signature2020".to_string(),
|
||||
created: "2025-06-01T00:00:00Z".to_string(),
|
||||
verification_method: "did:key:issuer#key-1".to_string(),
|
||||
proof_purpose: "assertionMethod".to_string(),
|
||||
proof_value: "valid-sig".to_string(),
|
||||
},
|
||||
credential_status: None,
|
||||
};
|
||||
|
||||
let result = verify_credential(&vc, |issuer, _data, sig| {
|
||||
assert_eq!(issuer, "did:key:issuer");
|
||||
assert_eq!(sig, "valid-sig");
|
||||
Ok(true)
|
||||
})
|
||||
.unwrap();
|
||||
assert!(result);
|
||||
|
||||
let result = verify_credential(&vc, |_issuer, _data, _sig| Ok(false)).unwrap();
|
||||
assert!(!result);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_revoke_credential() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vc = issue_credential(
|
||||
dir.path(),
|
||||
"did:key:issuer",
|
||||
"did:key:subject",
|
||||
"Revocable",
|
||||
serde_json::json!({}),
|
||||
None,
|
||||
|_| Ok("sig".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!is_revoked(&vc));
|
||||
|
||||
revoke_credential(dir.path(), &vc.id).await.unwrap();
|
||||
|
||||
let store = load_credentials(dir.path()).await.unwrap();
|
||||
assert!(is_revoked(&store.credentials[0]));
|
||||
assert_eq!(
|
||||
store.credentials[0].credential_status.as_ref().unwrap().status,
|
||||
"revoked"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_revoke_nonexistent_credential_fails() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let result = revoke_credential(dir.path(), "urn:uuid:does-not-exist").await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("Credential not found"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_credentials_no_filter() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
issue_credential(
|
||||
dir.path(), "did:key:a", "did:key:b", "Type1",
|
||||
serde_json::json!({}), None, |_| Ok("s1".to_string()),
|
||||
).await.unwrap();
|
||||
issue_credential(
|
||||
dir.path(), "did:key:c", "did:key:d", "Type2",
|
||||
serde_json::json!({}), None, |_| Ok("s2".to_string()),
|
||||
).await.unwrap();
|
||||
|
||||
let all = list_credentials(dir.path(), None).await.unwrap();
|
||||
assert_eq!(all.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_credentials_filter_by_did() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
issue_credential(
|
||||
dir.path(), "did:key:alice", "did:key:bob", "Type1",
|
||||
serde_json::json!({}), None, |_| Ok("s1".to_string()),
|
||||
).await.unwrap();
|
||||
issue_credential(
|
||||
dir.path(), "did:key:carol", "did:key:alice", "Type2",
|
||||
serde_json::json!({}), None, |_| Ok("s2".to_string()),
|
||||
).await.unwrap();
|
||||
issue_credential(
|
||||
dir.path(), "did:key:carol", "did:key:dave", "Type3",
|
||||
serde_json::json!({}), None, |_| Ok("s3".to_string()),
|
||||
).await.unwrap();
|
||||
|
||||
let filtered = list_credentials(dir.path(), Some("did:key:alice")).await.unwrap();
|
||||
assert_eq!(filtered.len(), 2);
|
||||
}
|
||||
|
||||
fn make_test_vc(id: &str, issuer: &str, subject: &str) -> VerifiableCredential {
|
||||
VerifiableCredential {
|
||||
context: vec![VC_CONTEXT_V2.to_string(), ED25519_CONTEXT.to_string()],
|
||||
id: id.to_string(),
|
||||
credential_type: vec!["VerifiableCredential".to_string(), "Test".to_string()],
|
||||
issuer: issuer.to_string(),
|
||||
credential_subject: CredentialSubject {
|
||||
id: subject.to_string(),
|
||||
claims: serde_json::json!({"role": "tester"}),
|
||||
},
|
||||
issuance_date: "2026-01-01T00:00:00Z".to_string(),
|
||||
expiration_date: None,
|
||||
proof: CredentialProof {
|
||||
proof_type: "Ed25519Signature2020".to_string(),
|
||||
created: "2026-01-01T00:00:00Z".to_string(),
|
||||
verification_method: format!("{}#key-1", issuer),
|
||||
proof_purpose: "assertionMethod".to_string(),
|
||||
proof_value: "mock-sig".to_string(),
|
||||
},
|
||||
credential_status: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_presentation() {
|
||||
let creds = vec![
|
||||
make_test_vc("urn:uuid:cred1", "did:key:issuer1", "did:key:holder"),
|
||||
make_test_vc("urn:uuid:cred2", "did:key:issuer2", "did:key:holder"),
|
||||
];
|
||||
|
||||
let vp = create_presentation(
|
||||
"did:key:holder",
|
||||
&["urn:uuid:cred1"],
|
||||
&creds,
|
||||
|_bytes| Ok("presentation-sig".to_string()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(vp.id.starts_with("urn:uuid:"));
|
||||
assert_eq!(vp.presentation_type, vec!["VerifiablePresentation"]);
|
||||
assert_eq!(vp.holder, "did:key:holder");
|
||||
assert_eq!(vp.verifiable_credential.len(), 1);
|
||||
assert_eq!(vp.verifiable_credential[0].id, "urn:uuid:cred1");
|
||||
assert_eq!(vp.proof.proof_type, "Ed25519Signature2020");
|
||||
assert_eq!(vp.proof.proof_purpose, "authentication");
|
||||
assert_eq!(vp.proof.proof_value, "presentation-sig");
|
||||
assert_eq!(vp.context[0], VC_CONTEXT_V2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_presentation_multiple_credentials() {
|
||||
let creds = vec![
|
||||
make_test_vc("urn:uuid:c1", "did:key:i1", "did:key:holder"),
|
||||
make_test_vc("urn:uuid:c2", "did:key:i2", "did:key:holder"),
|
||||
make_test_vc("urn:uuid:c3", "did:key:i3", "did:key:other"),
|
||||
];
|
||||
|
||||
let vp = create_presentation(
|
||||
"did:key:holder",
|
||||
&["urn:uuid:c1", "urn:uuid:c2"],
|
||||
&creds,
|
||||
|_| Ok("sig".to_string()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(vp.verifiable_credential.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_presentation_no_matching_credentials() {
|
||||
let creds = vec![make_test_vc("urn:uuid:c1", "did:key:i", "did:key:h")];
|
||||
|
||||
let result = create_presentation(
|
||||
"did:key:holder",
|
||||
&["urn:uuid:nonexistent"],
|
||||
&creds,
|
||||
|_| Ok("sig".to_string()),
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("No matching credentials"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_presentation_all_valid() {
|
||||
let creds = vec![
|
||||
make_test_vc("urn:uuid:c1", "did:key:issuer", "did:key:holder"),
|
||||
];
|
||||
|
||||
let vp = create_presentation(
|
||||
"did:key:holder",
|
||||
&["urn:uuid:c1"],
|
||||
&creds,
|
||||
|_| Ok("vp-sig".to_string()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = verify_presentation(&vp, |_did, _bytes, _sig| Ok(true)).unwrap();
|
||||
assert!(result.holder_valid);
|
||||
assert!(result.valid);
|
||||
assert_eq!(result.credentials.len(), 1);
|
||||
assert!(result.credentials[0].valid);
|
||||
assert!(!result.credentials[0].revoked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_presentation_holder_invalid() {
|
||||
let creds = vec![
|
||||
make_test_vc("urn:uuid:c1", "did:key:issuer", "did:key:holder"),
|
||||
];
|
||||
|
||||
let vp = create_presentation(
|
||||
"did:key:holder",
|
||||
&["urn:uuid:c1"],
|
||||
&creds,
|
||||
|_| Ok("bad-sig".to_string()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Holder verification fails, credential verification succeeds
|
||||
let result = verify_presentation(&vp, |did, _bytes, _sig| {
|
||||
Ok(did != "did:key:holder")
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert!(!result.holder_valid);
|
||||
assert!(!result.valid); // Overall invalid because holder proof failed
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_presentation_serializes_as_jsonld() {
|
||||
let creds = vec![
|
||||
make_test_vc("urn:uuid:c1", "did:key:issuer", "did:key:holder"),
|
||||
];
|
||||
|
||||
let vp = create_presentation(
|
||||
"did:key:holder",
|
||||
&["urn:uuid:c1"],
|
||||
&creds,
|
||||
|_| Ok("sig".to_string()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let json = serde_json::to_value(&vp).unwrap();
|
||||
assert!(json["@context"].is_array());
|
||||
assert!(json["type"].is_array());
|
||||
assert_eq!(json["type"][0], "VerifiablePresentation");
|
||||
assert!(json["holder"].is_string());
|
||||
assert!(json["verifiableCredential"].is_array());
|
||||
assert!(json["proof"]["type"].is_string());
|
||||
}
|
||||
}
|
||||
12
core/archipelago/src/credentials/mod.rs
Normal file
12
core/archipelago/src/credentials/mod.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
//! Verifiable Credentials (VC) management following W3C VC Data Model 2.0.
|
||||
//! Implements JSON-LD @context, Ed25519Signature2020 proof format.
|
||||
//! See: https://www.w3.org/TR/vc-data-model-2.0/
|
||||
|
||||
mod types;
|
||||
mod store;
|
||||
mod operations;
|
||||
mod presentation;
|
||||
|
||||
pub use store::load_credentials;
|
||||
pub use operations::{issue_credential, verify_credential, revoke_credential, list_credentials, is_revoked};
|
||||
pub use presentation::{VerifiablePresentation, create_presentation, verify_presentation};
|
||||
322
core/archipelago/src/credentials/operations.rs
Normal file
322
core/archipelago/src/credentials/operations.rs
Normal file
@@ -0,0 +1,322 @@
|
||||
use anyhow::Result;
|
||||
use std::path::Path;
|
||||
use tracing::debug;
|
||||
|
||||
use super::types::*;
|
||||
use super::store::{load_credentials, save_credentials};
|
||||
|
||||
/// Issue a new Verifiable Credential following W3C VC Data Model 2.0.
|
||||
/// Uses Ed25519Signature2020 proof format.
|
||||
pub async fn issue_credential(
|
||||
data_dir: &Path,
|
||||
issuer_did: &str,
|
||||
subject_did: &str,
|
||||
credential_type: &str,
|
||||
claims: serde_json::Value,
|
||||
expires_at: Option<&str>,
|
||||
sign_fn: impl FnOnce(&[u8]) -> Result<String>,
|
||||
) -> Result<VerifiableCredential> {
|
||||
let id = format!("urn:uuid:{}", uuid::Uuid::new_v4());
|
||||
let issued_at = chrono::Utc::now().to_rfc3339();
|
||||
let key_id = format!("{}#key-1", issuer_did);
|
||||
|
||||
// Build the credential body for signing (without proof)
|
||||
let body = serde_json::json!({
|
||||
"@context": [VC_CONTEXT_V2, ED25519_CONTEXT],
|
||||
"id": id,
|
||||
"type": ["VerifiableCredential", credential_type],
|
||||
"issuer": issuer_did,
|
||||
"credentialSubject": {
|
||||
"id": subject_did,
|
||||
},
|
||||
"issuanceDate": issued_at,
|
||||
});
|
||||
let body_bytes = serde_json::to_vec(&body)?;
|
||||
let signature = sign_fn(&body_bytes)?;
|
||||
|
||||
let vc = VerifiableCredential {
|
||||
context: vec![VC_CONTEXT_V2.to_string(), ED25519_CONTEXT.to_string()],
|
||||
id: id.clone(),
|
||||
credential_type: vec!["VerifiableCredential".to_string(), credential_type.to_string()],
|
||||
issuer: issuer_did.to_string(),
|
||||
credential_subject: CredentialSubject {
|
||||
id: subject_did.to_string(),
|
||||
claims,
|
||||
},
|
||||
issuance_date: issued_at.clone(),
|
||||
expiration_date: expires_at.map(|s| s.to_string()),
|
||||
proof: CredentialProof {
|
||||
proof_type: "Ed25519Signature2020".to_string(),
|
||||
created: issued_at,
|
||||
verification_method: key_id,
|
||||
proof_purpose: "assertionMethod".to_string(),
|
||||
proof_value: signature,
|
||||
},
|
||||
credential_status: None,
|
||||
};
|
||||
|
||||
let mut store = load_credentials(data_dir).await?;
|
||||
debug!(id = %vc.id, "Issued W3C VC");
|
||||
store.credentials.push(vc.clone());
|
||||
save_credentials(data_dir, &store).await?;
|
||||
Ok(vc)
|
||||
}
|
||||
|
||||
/// Verify a credential's signature against the issuer DID.
|
||||
pub fn verify_credential(
|
||||
vc: &VerifiableCredential,
|
||||
verify_fn: impl FnOnce(&str, &[u8], &str) -> Result<bool>,
|
||||
) -> Result<bool> {
|
||||
let body = serde_json::json!({
|
||||
"@context": vc.context,
|
||||
"id": vc.id,
|
||||
"type": vc.credential_type,
|
||||
"issuer": vc.issuer,
|
||||
"credentialSubject": {
|
||||
"id": vc.credential_subject.id,
|
||||
},
|
||||
"issuanceDate": vc.issuance_date,
|
||||
});
|
||||
let body_bytes = serde_json::to_vec(&body)?;
|
||||
verify_fn(&vc.issuer, &body_bytes, &vc.proof.proof_value)
|
||||
}
|
||||
|
||||
/// Revoke a credential by ID.
|
||||
pub async fn revoke_credential(data_dir: &Path, credential_id: &str) -> Result<()> {
|
||||
let mut store = load_credentials(data_dir).await?;
|
||||
let vc = store
|
||||
.credentials
|
||||
.iter_mut()
|
||||
.find(|c| c.id == credential_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Credential not found: {}", credential_id))?;
|
||||
vc.credential_status = Some(CredentialStatusEntry {
|
||||
id: format!("{}#status", credential_id),
|
||||
status_type: "CredentialStatusList2021".to_string(),
|
||||
status: "revoked".to_string(),
|
||||
});
|
||||
save_credentials(data_dir, &store).await
|
||||
}
|
||||
|
||||
/// List all credentials, optionally filtering by issuer or subject DID.
|
||||
pub async fn list_credentials(
|
||||
data_dir: &Path,
|
||||
filter_did: Option<&str>,
|
||||
) -> Result<Vec<VerifiableCredential>> {
|
||||
let store = load_credentials(data_dir).await?;
|
||||
let creds = if let Some(did) = filter_did {
|
||||
store
|
||||
.credentials
|
||||
.into_iter()
|
||||
.filter(|c| c.issuer == did || c.credential_subject.id == did)
|
||||
.collect()
|
||||
} else {
|
||||
store.credentials
|
||||
};
|
||||
Ok(creds)
|
||||
}
|
||||
|
||||
/// Check if a credential is revoked.
|
||||
pub fn is_revoked(vc: &VerifiableCredential) -> bool {
|
||||
vc.credential_status
|
||||
.as_ref()
|
||||
.map_or(false, |s| s.status == "revoked")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_issue_credential_w3c_format() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vc = issue_credential(
|
||||
dir.path(),
|
||||
"did:key:issuer",
|
||||
"did:key:subject",
|
||||
"NodeOperator",
|
||||
serde_json::json!({"role": "admin"}),
|
||||
Some("2027-12-31T23:59:59Z"),
|
||||
|_bytes| Ok("mock-signature".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(vc.id.starts_with("urn:uuid:"));
|
||||
assert_eq!(vc.context[0], VC_CONTEXT_V2);
|
||||
assert_eq!(vc.context[1], ED25519_CONTEXT);
|
||||
assert_eq!(vc.credential_type, vec!["VerifiableCredential", "NodeOperator"]);
|
||||
assert_eq!(vc.issuer, "did:key:issuer");
|
||||
assert_eq!(vc.credential_subject.id, "did:key:subject");
|
||||
assert_eq!(vc.proof.proof_type, "Ed25519Signature2020");
|
||||
assert_eq!(vc.proof.proof_purpose, "assertionMethod");
|
||||
assert_eq!(vc.proof.verification_method, "did:key:issuer#key-1");
|
||||
assert_eq!(vc.proof.proof_value, "mock-signature");
|
||||
assert_eq!(vc.expiration_date, Some("2027-12-31T23:59:59Z".to_string()));
|
||||
assert!(vc.credential_status.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_issue_credential_serializes_as_jsonld() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vc = issue_credential(
|
||||
dir.path(),
|
||||
"did:key:issuer",
|
||||
"did:key:subject",
|
||||
"TestCred",
|
||||
serde_json::json!({"level": "gold"}),
|
||||
None,
|
||||
|_| Ok("sig".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let json = serde_json::to_value(&vc).unwrap();
|
||||
assert!(json["@context"].is_array());
|
||||
assert!(json["type"].is_array());
|
||||
assert!(json["credentialSubject"]["id"].is_string());
|
||||
assert_eq!(json["proof"]["type"], "Ed25519Signature2020");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_save_and_load_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
issue_credential(
|
||||
dir.path(),
|
||||
"did:key:a",
|
||||
"did:key:b",
|
||||
"Type1",
|
||||
serde_json::json!({"k": "v"}),
|
||||
None,
|
||||
|_| Ok("s1".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let loaded = load_credentials(dir.path()).await.unwrap();
|
||||
assert_eq!(loaded.credentials.len(), 1);
|
||||
assert_eq!(loaded.credentials[0].credential_type[1], "Type1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_issue_credential_sign_fn_failure_propagates() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let result = issue_credential(
|
||||
dir.path(),
|
||||
"did:key:issuer",
|
||||
"did:key:subject",
|
||||
"TestCredential",
|
||||
serde_json::json!({}),
|
||||
None,
|
||||
|_bytes| Err(anyhow::anyhow!("Signing failed")),
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("Signing failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_credential_calls_verify_fn() {
|
||||
let vc = VerifiableCredential {
|
||||
context: vec![VC_CONTEXT_V2.to_string()],
|
||||
id: "urn:uuid:test".to_string(),
|
||||
credential_type: vec!["VerifiableCredential".to_string(), "Test".to_string()],
|
||||
issuer: "did:key:issuer".to_string(),
|
||||
credential_subject: CredentialSubject {
|
||||
id: "did:key:subject".to_string(),
|
||||
claims: serde_json::json!({"foo": "bar"}),
|
||||
},
|
||||
issuance_date: "2025-06-01T00:00:00Z".to_string(),
|
||||
expiration_date: None,
|
||||
proof: CredentialProof {
|
||||
proof_type: "Ed25519Signature2020".to_string(),
|
||||
created: "2025-06-01T00:00:00Z".to_string(),
|
||||
verification_method: "did:key:issuer#key-1".to_string(),
|
||||
proof_purpose: "assertionMethod".to_string(),
|
||||
proof_value: "valid-sig".to_string(),
|
||||
},
|
||||
credential_status: None,
|
||||
};
|
||||
|
||||
let result = verify_credential(&vc, |issuer, _data, sig| {
|
||||
assert_eq!(issuer, "did:key:issuer");
|
||||
assert_eq!(sig, "valid-sig");
|
||||
Ok(true)
|
||||
})
|
||||
.unwrap();
|
||||
assert!(result);
|
||||
|
||||
let result = verify_credential(&vc, |_issuer, _data, _sig| Ok(false)).unwrap();
|
||||
assert!(!result);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_revoke_credential() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vc = issue_credential(
|
||||
dir.path(),
|
||||
"did:key:issuer",
|
||||
"did:key:subject",
|
||||
"Revocable",
|
||||
serde_json::json!({}),
|
||||
None,
|
||||
|_| Ok("sig".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!is_revoked(&vc));
|
||||
|
||||
revoke_credential(dir.path(), &vc.id).await.unwrap();
|
||||
|
||||
let store = load_credentials(dir.path()).await.unwrap();
|
||||
assert!(is_revoked(&store.credentials[0]));
|
||||
assert_eq!(
|
||||
store.credentials[0].credential_status.as_ref().unwrap().status,
|
||||
"revoked"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_revoke_nonexistent_credential_fails() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let result = revoke_credential(dir.path(), "urn:uuid:does-not-exist").await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("Credential not found"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_credentials_no_filter() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
issue_credential(
|
||||
dir.path(), "did:key:a", "did:key:b", "Type1",
|
||||
serde_json::json!({}), None, |_| Ok("s1".to_string()),
|
||||
).await.unwrap();
|
||||
issue_credential(
|
||||
dir.path(), "did:key:c", "did:key:d", "Type2",
|
||||
serde_json::json!({}), None, |_| Ok("s2".to_string()),
|
||||
).await.unwrap();
|
||||
|
||||
let all = list_credentials(dir.path(), None).await.unwrap();
|
||||
assert_eq!(all.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_credentials_filter_by_did() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
issue_credential(
|
||||
dir.path(), "did:key:alice", "did:key:bob", "Type1",
|
||||
serde_json::json!({}), None, |_| Ok("s1".to_string()),
|
||||
).await.unwrap();
|
||||
issue_credential(
|
||||
dir.path(), "did:key:carol", "did:key:alice", "Type2",
|
||||
serde_json::json!({}), None, |_| Ok("s2".to_string()),
|
||||
).await.unwrap();
|
||||
issue_credential(
|
||||
dir.path(), "did:key:carol", "did:key:dave", "Type3",
|
||||
serde_json::json!({}), None, |_| Ok("s3".to_string()),
|
||||
).await.unwrap();
|
||||
|
||||
let filtered = list_credentials(dir.path(), Some("did:key:alice")).await.unwrap();
|
||||
assert_eq!(filtered.len(), 2);
|
||||
}
|
||||
}
|
||||
274
core/archipelago/src/credentials/presentation.rs
Normal file
274
core/archipelago/src/credentials/presentation.rs
Normal file
@@ -0,0 +1,274 @@
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::types::*;
|
||||
use super::operations::{verify_credential, is_revoked};
|
||||
|
||||
/// A Verifiable Presentation following W3C VC Data Model 2.0.
|
||||
/// Bundles one or more VCs with a holder proof.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VerifiablePresentation {
|
||||
#[serde(rename = "@context")]
|
||||
pub context: Vec<String>,
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub presentation_type: Vec<String>,
|
||||
pub holder: String,
|
||||
pub verifiable_credential: Vec<VerifiableCredential>,
|
||||
pub proof: CredentialProof,
|
||||
}
|
||||
|
||||
/// Create a Verifiable Presentation wrapping selected credentials.
|
||||
/// The holder signs the presentation to prove they possess the credentials.
|
||||
pub fn create_presentation(
|
||||
holder_did: &str,
|
||||
credential_ids: &[&str],
|
||||
credentials: &[VerifiableCredential],
|
||||
sign_fn: impl FnOnce(&[u8]) -> Result<String>,
|
||||
) -> Result<VerifiablePresentation> {
|
||||
let selected: Vec<VerifiableCredential> = credentials
|
||||
.iter()
|
||||
.filter(|c| credential_ids.contains(&c.id.as_str()))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
if selected.is_empty() {
|
||||
return Err(anyhow::anyhow!("No matching credentials found"));
|
||||
}
|
||||
|
||||
let id = format!("urn:uuid:{}", uuid::Uuid::new_v4());
|
||||
let created = chrono::Utc::now().to_rfc3339();
|
||||
let key_id = format!("{}#key-1", holder_did);
|
||||
|
||||
let body = serde_json::json!({
|
||||
"@context": [VC_CONTEXT_V2, ED25519_CONTEXT],
|
||||
"id": id,
|
||||
"type": ["VerifiablePresentation"],
|
||||
"holder": holder_did,
|
||||
"verifiableCredential": selected,
|
||||
});
|
||||
let body_bytes = serde_json::to_vec(&body)?;
|
||||
let signature = sign_fn(&body_bytes)?;
|
||||
|
||||
Ok(VerifiablePresentation {
|
||||
context: vec![VC_CONTEXT_V2.to_string(), ED25519_CONTEXT.to_string()],
|
||||
id,
|
||||
presentation_type: vec!["VerifiablePresentation".to_string()],
|
||||
holder: holder_did.to_string(),
|
||||
verifiable_credential: selected,
|
||||
proof: CredentialProof {
|
||||
proof_type: "Ed25519Signature2020".to_string(),
|
||||
created,
|
||||
verification_method: key_id,
|
||||
proof_purpose: "authentication".to_string(),
|
||||
proof_value: signature,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Verify a Verifiable Presentation: check holder's proof signature,
|
||||
/// then verify each embedded credential.
|
||||
pub fn verify_presentation(
|
||||
vp: &VerifiablePresentation,
|
||||
verify_fn: impl Fn(&str, &[u8], &str) -> Result<bool>,
|
||||
) -> Result<PresentationVerification> {
|
||||
let body = serde_json::json!({
|
||||
"@context": vp.context,
|
||||
"id": vp.id,
|
||||
"type": vp.presentation_type,
|
||||
"holder": vp.holder,
|
||||
"verifiableCredential": vp.verifiable_credential,
|
||||
});
|
||||
let body_bytes = serde_json::to_vec(&body)?;
|
||||
let holder_valid = verify_fn(&vp.holder, &body_bytes, &vp.proof.proof_value)?;
|
||||
|
||||
let mut credential_results = Vec::new();
|
||||
for vc in &vp.verifiable_credential {
|
||||
let vc_valid = verify_credential(vc, |did, bytes, sig| verify_fn(did, bytes, sig))?;
|
||||
credential_results.push(CredentialVerificationResult {
|
||||
id: vc.id.clone(),
|
||||
valid: vc_valid,
|
||||
revoked: is_revoked(vc),
|
||||
});
|
||||
}
|
||||
|
||||
let all_valid = holder_valid && credential_results.iter().all(|r| r.valid && !r.revoked);
|
||||
|
||||
Ok(PresentationVerification {
|
||||
holder_valid,
|
||||
credentials: credential_results,
|
||||
valid: all_valid,
|
||||
})
|
||||
}
|
||||
|
||||
/// Result of verifying a Verifiable Presentation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PresentationVerification {
|
||||
pub holder_valid: bool,
|
||||
pub credentials: Vec<CredentialVerificationResult>,
|
||||
pub valid: bool,
|
||||
}
|
||||
|
||||
/// Result of verifying a single credential within a presentation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CredentialVerificationResult {
|
||||
pub id: String,
|
||||
pub valid: bool,
|
||||
pub revoked: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_test_vc(id: &str, issuer: &str, subject: &str) -> VerifiableCredential {
|
||||
VerifiableCredential {
|
||||
context: vec![VC_CONTEXT_V2.to_string(), ED25519_CONTEXT.to_string()],
|
||||
id: id.to_string(),
|
||||
credential_type: vec!["VerifiableCredential".to_string(), "Test".to_string()],
|
||||
issuer: issuer.to_string(),
|
||||
credential_subject: CredentialSubject {
|
||||
id: subject.to_string(),
|
||||
claims: serde_json::json!({"role": "tester"}),
|
||||
},
|
||||
issuance_date: "2026-01-01T00:00:00Z".to_string(),
|
||||
expiration_date: None,
|
||||
proof: CredentialProof {
|
||||
proof_type: "Ed25519Signature2020".to_string(),
|
||||
created: "2026-01-01T00:00:00Z".to_string(),
|
||||
verification_method: format!("{}#key-1", issuer),
|
||||
proof_purpose: "assertionMethod".to_string(),
|
||||
proof_value: "mock-sig".to_string(),
|
||||
},
|
||||
credential_status: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_presentation() {
|
||||
let creds = vec![
|
||||
make_test_vc("urn:uuid:cred1", "did:key:issuer1", "did:key:holder"),
|
||||
make_test_vc("urn:uuid:cred2", "did:key:issuer2", "did:key:holder"),
|
||||
];
|
||||
|
||||
let vp = create_presentation(
|
||||
"did:key:holder",
|
||||
&["urn:uuid:cred1"],
|
||||
&creds,
|
||||
|_bytes| Ok("presentation-sig".to_string()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(vp.id.starts_with("urn:uuid:"));
|
||||
assert_eq!(vp.presentation_type, vec!["VerifiablePresentation"]);
|
||||
assert_eq!(vp.holder, "did:key:holder");
|
||||
assert_eq!(vp.verifiable_credential.len(), 1);
|
||||
assert_eq!(vp.verifiable_credential[0].id, "urn:uuid:cred1");
|
||||
assert_eq!(vp.proof.proof_type, "Ed25519Signature2020");
|
||||
assert_eq!(vp.proof.proof_purpose, "authentication");
|
||||
assert_eq!(vp.proof.proof_value, "presentation-sig");
|
||||
assert_eq!(vp.context[0], VC_CONTEXT_V2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_presentation_multiple_credentials() {
|
||||
let creds = vec![
|
||||
make_test_vc("urn:uuid:c1", "did:key:i1", "did:key:holder"),
|
||||
make_test_vc("urn:uuid:c2", "did:key:i2", "did:key:holder"),
|
||||
make_test_vc("urn:uuid:c3", "did:key:i3", "did:key:other"),
|
||||
];
|
||||
|
||||
let vp = create_presentation(
|
||||
"did:key:holder",
|
||||
&["urn:uuid:c1", "urn:uuid:c2"],
|
||||
&creds,
|
||||
|_| Ok("sig".to_string()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(vp.verifiable_credential.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_presentation_no_matching_credentials() {
|
||||
let creds = vec![make_test_vc("urn:uuid:c1", "did:key:i", "did:key:h")];
|
||||
|
||||
let result = create_presentation(
|
||||
"did:key:holder",
|
||||
&["urn:uuid:nonexistent"],
|
||||
&creds,
|
||||
|_| Ok("sig".to_string()),
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("No matching credentials"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_presentation_all_valid() {
|
||||
let creds = vec![
|
||||
make_test_vc("urn:uuid:c1", "did:key:issuer", "did:key:holder"),
|
||||
];
|
||||
|
||||
let vp = create_presentation(
|
||||
"did:key:holder",
|
||||
&["urn:uuid:c1"],
|
||||
&creds,
|
||||
|_| Ok("vp-sig".to_string()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = verify_presentation(&vp, |_did, _bytes, _sig| Ok(true)).unwrap();
|
||||
assert!(result.holder_valid);
|
||||
assert!(result.valid);
|
||||
assert_eq!(result.credentials.len(), 1);
|
||||
assert!(result.credentials[0].valid);
|
||||
assert!(!result.credentials[0].revoked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_presentation_holder_invalid() {
|
||||
let creds = vec![
|
||||
make_test_vc("urn:uuid:c1", "did:key:issuer", "did:key:holder"),
|
||||
];
|
||||
|
||||
let vp = create_presentation(
|
||||
"did:key:holder",
|
||||
&["urn:uuid:c1"],
|
||||
&creds,
|
||||
|_| Ok("bad-sig".to_string()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = verify_presentation(&vp, |did, _bytes, _sig| {
|
||||
Ok(did != "did:key:holder")
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert!(!result.holder_valid);
|
||||
assert!(!result.valid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_presentation_serializes_as_jsonld() {
|
||||
let creds = vec![
|
||||
make_test_vc("urn:uuid:c1", "did:key:issuer", "did:key:holder"),
|
||||
];
|
||||
|
||||
let vp = create_presentation(
|
||||
"did:key:holder",
|
||||
&["urn:uuid:c1"],
|
||||
&creds,
|
||||
|_| Ok("sig".to_string()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let json = serde_json::to_value(&vp).unwrap();
|
||||
assert!(json["@context"].is_array());
|
||||
assert!(json["type"].is_array());
|
||||
assert_eq!(json["type"][0], "VerifiablePresentation");
|
||||
assert!(json["holder"].is_string());
|
||||
assert!(json["verifiableCredential"].is_array());
|
||||
assert!(json["proof"]["type"].is_string());
|
||||
}
|
||||
}
|
||||
106
core/archipelago/src/credentials/store.rs
Normal file
106
core/archipelago/src/credentials/store.rs
Normal file
@@ -0,0 +1,106 @@
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::Path;
|
||||
use tokio::fs;
|
||||
|
||||
use super::types::{CredentialStore, CREDENTIALS_DIR};
|
||||
|
||||
async fn ensure_dir(data_dir: &Path) -> Result<()> {
|
||||
let dir = data_dir.join(CREDENTIALS_DIR);
|
||||
if !dir.exists() {
|
||||
fs::create_dir_all(&dir).await.context("Creating credentials dir")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn store_path(data_dir: &Path) -> std::path::PathBuf {
|
||||
data_dir.join(CREDENTIALS_DIR).join("credentials.json")
|
||||
}
|
||||
|
||||
pub async fn load_credentials(data_dir: &Path) -> Result<CredentialStore> {
|
||||
ensure_dir(data_dir).await?;
|
||||
let path = store_path(data_dir);
|
||||
if !path.exists() {
|
||||
return Ok(CredentialStore::default());
|
||||
}
|
||||
let raw = fs::read(&path).await.context("Reading credentials")?;
|
||||
// Detect plaintext JSON (migration path) vs encrypted binary
|
||||
if raw.first().map_or(false, |b| *b == b'[' || *b == b'{') {
|
||||
let data = String::from_utf8(raw).context("UTF-8 credentials")?;
|
||||
return serde_json::from_str(&data).context("Parsing credentials");
|
||||
}
|
||||
// Encrypted: decrypt using node key
|
||||
let key = load_encryption_key(data_dir).await?;
|
||||
let plaintext = decrypt_credentials(&raw, &key)?;
|
||||
serde_json::from_slice(&plaintext).context("Parsing decrypted credentials")
|
||||
}
|
||||
|
||||
pub async fn save_credentials(data_dir: &Path, store: &CredentialStore) -> Result<()> {
|
||||
ensure_dir(data_dir).await?;
|
||||
let path = store_path(data_dir);
|
||||
let data = serde_json::to_vec(store)?;
|
||||
// Encrypt using node key
|
||||
let key = load_encryption_key(data_dir).await?;
|
||||
let encrypted = encrypt_credentials(&data, &key)?;
|
||||
fs::write(&path, encrypted).await.context("Writing credentials")
|
||||
}
|
||||
|
||||
/// Derive a 32-byte encryption key from the node's identity key via SHA-256.
|
||||
async fn load_encryption_key(data_dir: &Path) -> Result<[u8; 32]> {
|
||||
let node_key_path = data_dir.join("identity").join("node_key");
|
||||
let key_bytes = fs::read(&node_key_path).await.context("Reading node key for credential encryption")?;
|
||||
use sha2::{Sha256, Digest};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(b"archipelago-credential-store-v1");
|
||||
hasher.update(&key_bytes);
|
||||
let hash = hasher.finalize();
|
||||
let mut key = [0u8; 32];
|
||||
key.copy_from_slice(&hash);
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
fn encrypt_credentials(data: &[u8], key: &[u8; 32]) -> Result<Vec<u8>> {
|
||||
use chacha20poly1305::aead::{Aead, KeyInit};
|
||||
let nonce_bytes: [u8; 12] = rand::random();
|
||||
let cipher = chacha20poly1305::ChaCha20Poly1305::new_from_slice(key)
|
||||
.map_err(|e| anyhow::anyhow!("Cipher init: {}", e))?;
|
||||
let ciphertext = cipher
|
||||
.encrypt(
|
||||
chacha20poly1305::aead::generic_array::GenericArray::from_slice(&nonce_bytes),
|
||||
data,
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("Encryption failed: {}", e))?;
|
||||
let mut output = Vec::with_capacity(12 + ciphertext.len());
|
||||
output.extend_from_slice(&nonce_bytes);
|
||||
output.extend_from_slice(&ciphertext);
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn decrypt_credentials(data: &[u8], key: &[u8; 32]) -> Result<Vec<u8>> {
|
||||
use chacha20poly1305::aead::{Aead, KeyInit};
|
||||
if data.len() < 12 {
|
||||
anyhow::bail!("Encrypted credentials too short");
|
||||
}
|
||||
let nonce = &data[..12];
|
||||
let ciphertext = &data[12..];
|
||||
let cipher = chacha20poly1305::ChaCha20Poly1305::new_from_slice(key)
|
||||
.map_err(|e| anyhow::anyhow!("Cipher init: {}", e))?;
|
||||
cipher
|
||||
.decrypt(
|
||||
chacha20poly1305::aead::generic_array::GenericArray::from_slice(nonce),
|
||||
ciphertext,
|
||||
)
|
||||
.map_err(|_| anyhow::anyhow!("Credential decryption failed — key mismatch or corruption"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_credentials_returns_empty_when_no_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = load_credentials(dir.path()).await.unwrap();
|
||||
assert!(store.credentials.is_empty());
|
||||
assert!(dir.path().join(CREDENTIALS_DIR).exists());
|
||||
}
|
||||
}
|
||||
89
core/archipelago/src/credentials/types.rs
Normal file
89
core/archipelago/src/credentials/types.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub(super) const CREDENTIALS_DIR: &str = "credentials";
|
||||
|
||||
/// W3C VC Data Model 2.0 context URI
|
||||
pub(super) const VC_CONTEXT_V2: &str = "https://www.w3.org/ns/credentials/v2";
|
||||
/// Ed25519 signature suite context
|
||||
pub(super) const ED25519_CONTEXT: &str = "https://w3id.org/security/suites/ed25519-2020/v1";
|
||||
|
||||
/// A Verifiable Credential following W3C VC Data Model 2.0.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VerifiableCredential {
|
||||
#[serde(rename = "@context")]
|
||||
pub context: Vec<String>,
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub credential_type: Vec<String>,
|
||||
pub issuer: String,
|
||||
pub credential_subject: CredentialSubject,
|
||||
pub issuance_date: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expiration_date: Option<String>,
|
||||
pub proof: CredentialProof,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub credential_status: Option<CredentialStatusEntry>,
|
||||
}
|
||||
|
||||
/// The subject of a credential with their DID and claims.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CredentialSubject {
|
||||
pub id: String,
|
||||
#[serde(flatten)]
|
||||
pub claims: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Ed25519Signature2020 proof format.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CredentialProof {
|
||||
#[serde(rename = "type")]
|
||||
pub proof_type: String,
|
||||
pub created: String,
|
||||
pub verification_method: String,
|
||||
pub proof_purpose: String,
|
||||
pub proof_value: String,
|
||||
}
|
||||
|
||||
/// Credential status for revocation tracking.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CredentialStatusEntry {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub status_type: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
/// Stored credentials index.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct CredentialStore {
|
||||
pub credentials: Vec<VerifiableCredential>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_credential_status_display() {
|
||||
assert_eq!(CredentialStatus::Active.to_string(), "active");
|
||||
assert_eq!(CredentialStatus::Revoked.to_string(), "revoked");
|
||||
assert_eq!(CredentialStatus::Expired.to_string(), "expired");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_credential_status_serde_roundtrip() {
|
||||
let json = serde_json::to_string(&CredentialStatus::Revoked).unwrap();
|
||||
assert_eq!(json, "\"revoked\"");
|
||||
let parsed: CredentialStatus = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed, CredentialStatus::Revoked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_credential_store_default_is_empty() {
|
||||
let store = CredentialStore::default();
|
||||
assert!(store.credentials.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,7 @@ pub enum PackageState {
|
||||
Installed,
|
||||
Stopping,
|
||||
Stopped,
|
||||
Exited,
|
||||
Starting,
|
||||
Running,
|
||||
Restarting,
|
||||
@@ -117,6 +118,9 @@ pub enum PackageState {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct PackageDataEntry {
|
||||
pub state: PackageState,
|
||||
/// Container health: "healthy", "unhealthy", "starting", or null
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub health: Option<String>,
|
||||
#[serde(rename = "static-files")]
|
||||
pub static_files: StaticFiles,
|
||||
pub manifest: Manifest,
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Serialize;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::TcpStream;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
const ELECTRUMX_HOST: &str = "127.0.0.1";
|
||||
const ELECTRUMX_PORT: u16 = 50001;
|
||||
const BITCOIN_RPC_URL: &str = "http://127.0.0.1:8332/";
|
||||
const ELECTRUMX_DATA_DIR: &str = "/var/lib/archipelago/electrumx";
|
||||
// Approximate final index size in bytes for mainnet (~130GB for ElectrumX full index as of 2026)
|
||||
const ESTIMATED_FULL_INDEX_BYTES: f64 = 130_000_000_000.0;
|
||||
@@ -35,16 +34,18 @@ pub struct ElectrsSyncStatus {
|
||||
}
|
||||
|
||||
/// Get the total size of a directory in bytes.
|
||||
fn dir_size_bytes(path: &str) -> u64 {
|
||||
async fn dir_size_bytes(path: &str) -> u64 {
|
||||
let mut total: u64 = 0;
|
||||
if let Ok(entries) = std::fs::read_dir(path) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
total += dir_size_bytes(&path.to_string_lossy());
|
||||
} else if let Ok(meta) = entry.metadata() {
|
||||
total += meta.len();
|
||||
}
|
||||
let mut entries = match tokio::fs::read_dir(path).await {
|
||||
Ok(entries) => entries,
|
||||
Err(_) => return 0,
|
||||
};
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
let entry_path = entry.path();
|
||||
if entry_path.is_dir() {
|
||||
total += Box::pin(dir_size_bytes(&entry_path.to_string_lossy())).await;
|
||||
} else if let Ok(meta) = entry.metadata().await {
|
||||
total += meta.len();
|
||||
}
|
||||
}
|
||||
total
|
||||
@@ -62,25 +63,39 @@ fn format_bytes(bytes: u64) -> String {
|
||||
}
|
||||
|
||||
/// Fetch ElectrumX indexed height via Electrum protocol (TCP JSON-RPC).
|
||||
fn electrumx_indexed_height() -> Result<u64> {
|
||||
let mut stream = TcpStream::connect((ELECTRUMX_HOST, ELECTRUMX_PORT))
|
||||
.context("Failed to connect to ElectrumX")?;
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(5)))
|
||||
.context("set_read_timeout")?;
|
||||
stream
|
||||
.set_write_timeout(Some(Duration::from_secs(5)))
|
||||
.context("set_write_timeout")?;
|
||||
async fn electrumx_indexed_height() -> Result<u64> {
|
||||
let timeout_duration = Duration::from_secs(5);
|
||||
|
||||
// blockchain.numblocks.subscribe returns current block height directly
|
||||
let req = r#"{"id":1,"method":"blockchain.numblocks.subscribe","params":[]}
|
||||
let stream = tokio::time::timeout(
|
||||
timeout_duration,
|
||||
TcpStream::connect((ELECTRUMX_HOST, ELECTRUMX_PORT)),
|
||||
)
|
||||
.await
|
||||
.context("ElectrumX connection timed out")?
|
||||
.context("Failed to connect to ElectrumX")?;
|
||||
|
||||
let (reader_half, mut writer_half) = tokio::io::split(stream);
|
||||
|
||||
// blockchain.headers.subscribe returns {"height": N, "hex": "..."}
|
||||
let req = r#"{"id":1,"method":"blockchain.headers.subscribe","params":[]}
|
||||
"#;
|
||||
stream.write_all(req.as_bytes())?;
|
||||
stream.flush()?;
|
||||
tokio::time::timeout(timeout_duration, writer_half.write_all(req.as_bytes()))
|
||||
.await
|
||||
.context("ElectrumX write timed out")?
|
||||
.context("Failed to write to ElectrumX")?;
|
||||
|
||||
let mut reader = BufReader::new(stream);
|
||||
tokio::time::timeout(timeout_duration, writer_half.flush())
|
||||
.await
|
||||
.context("ElectrumX flush timed out")?
|
||||
.context("Failed to flush ElectrumX stream")?;
|
||||
|
||||
let mut reader = BufReader::new(reader_half);
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line)?;
|
||||
tokio::time::timeout(timeout_duration, reader.read_line(&mut line))
|
||||
.await
|
||||
.context("ElectrumX read timed out")?
|
||||
.context("Failed to read from ElectrumX")?;
|
||||
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
anyhow::bail!("Empty response from ElectrumX");
|
||||
@@ -116,7 +131,7 @@ async fn bitcoin_network_height() -> Result<u64> {
|
||||
"params": []
|
||||
});
|
||||
let resp = client
|
||||
.post(BITCOIN_RPC_URL)
|
||||
.post(crate::constants::BITCOIN_RPC_URL)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Authorization", bitcoin_rpc_auth().await)
|
||||
.body(body.to_string())
|
||||
@@ -136,23 +151,47 @@ async fn bitcoin_network_height() -> Result<u64> {
|
||||
Ok(height)
|
||||
}
|
||||
|
||||
/// Get ElectrumX sync status. Runs blocking ElectrumX call in spawn_blocking.
|
||||
/// Get ElectrumX sync status.
|
||||
pub async fn get_electrs_sync_status() -> ElectrsSyncStatus {
|
||||
// Get index data size (non-blocking, fast filesystem stat)
|
||||
let data_bytes = dir_size_bytes(ELECTRUMX_DATA_DIR);
|
||||
// Get index data size
|
||||
let data_bytes = dir_size_bytes(ELECTRUMX_DATA_DIR).await;
|
||||
let index_size = if data_bytes > 0 {
|
||||
Some(format_bytes(data_bytes))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Read Tor onion address if available
|
||||
let tor_onion = tokio::fs::read_to_string(
|
||||
"/var/lib/archipelago/tor/hidden_service_electrs/hostname",
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string());
|
||||
// Read Tor onion address — check system Tor path first, then legacy
|
||||
let tor_onion = {
|
||||
let mut onion = None;
|
||||
for path in &[
|
||||
"/var/lib/archipelago/tor-hostnames/electrs",
|
||||
"/var/lib/tor/hidden_service_electrs/hostname",
|
||||
"/var/lib/archipelago/tor/hidden_service_electrs/hostname",
|
||||
] {
|
||||
if let Ok(addr) = tokio::fs::read_to_string(path).await {
|
||||
let addr = addr.trim().to_string();
|
||||
if addr.ends_with(".onion") {
|
||||
onion = Some(addr);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Ok(output) = tokio::process::Command::new("sudo")
|
||||
.args(["cat", path])
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
if output.status.success() {
|
||||
let addr = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if addr.ends_with(".onion") {
|
||||
onion = Some(addr);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
onion
|
||||
};
|
||||
|
||||
let network_height = match bitcoin_network_height().await {
|
||||
Ok(h) => h,
|
||||
@@ -169,13 +208,13 @@ pub async fn get_electrs_sync_status() -> ElectrsSyncStatus {
|
||||
}
|
||||
};
|
||||
|
||||
let indexed_height = match tokio::task::spawn_blocking(electrumx_indexed_height).await {
|
||||
Ok(Ok(h)) => h,
|
||||
Ok(Err(e)) => {
|
||||
let indexed_height = match electrumx_indexed_height().await {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
// ElectrumX may not be ready on 50001 during initial sync
|
||||
let err_msg = e.to_string();
|
||||
let err_lower = err_msg.to_lowercase();
|
||||
let (status, error) = if err_lower.contains("connect") || err_lower.contains("reset") || err_lower.contains("refused") {
|
||||
let (status, error) = if err_lower.contains("connect") || err_lower.contains("reset") || err_lower.contains("refused") || err_lower.contains("timed out") {
|
||||
// Estimate progress from data directory size
|
||||
let _est_pct = if data_bytes > 0 {
|
||||
((data_bytes as f64 / ESTIMATED_FULL_INDEX_BYTES) * 100.0).min(99.0)
|
||||
@@ -186,7 +225,7 @@ pub async fn get_electrs_sync_status() -> ElectrsSyncStatus {
|
||||
(
|
||||
"indexing".to_string(),
|
||||
Some(format!(
|
||||
"Building index ({} / ~55 GB estimated). Electrum RPC will be available when complete.",
|
||||
"Building index ({} / ~130 GB estimated). Electrum RPC will be available when complete.",
|
||||
size_str
|
||||
)),
|
||||
)
|
||||
@@ -209,17 +248,6 @@ pub async fn get_electrs_sync_status() -> ElectrsSyncStatus {
|
||||
tor_onion: tor_onion.clone(),
|
||||
};
|
||||
}
|
||||
Err(e) => {
|
||||
return ElectrsSyncStatus {
|
||||
indexed_height: 0,
|
||||
network_height,
|
||||
progress_pct: 0.0,
|
||||
status: "error".to_string(),
|
||||
error: Some(format!("Task: {}", e)),
|
||||
index_size,
|
||||
tor_onion: tor_onion.clone(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let progress_pct = if network_height > 0 {
|
||||
|
||||
@@ -1,812 +0,0 @@
|
||||
//! Node federation: trusted multi-node clusters with state sync.
|
||||
//!
|
||||
//! Nodes federate by exchanging invite codes containing DID + onion address.
|
||||
//! Trust is bilateral — both sides must agree. Federated nodes periodically
|
||||
//! sync container status, health metrics, and availability.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use tokio::fs;
|
||||
|
||||
const FEDERATION_DIR: &str = "federation";
|
||||
const NODES_FILE: &str = "nodes.json";
|
||||
const INVITES_FILE: &str = "invites.json";
|
||||
|
||||
/// Trust level for a federated node.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TrustLevel {
|
||||
Trusted,
|
||||
Observer,
|
||||
Untrusted,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TrustLevel {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
TrustLevel::Trusted => write!(f, "trusted"),
|
||||
TrustLevel::Observer => write!(f, "observer"),
|
||||
TrustLevel::Untrusted => write!(f, "untrusted"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A federated node in our cluster.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FederatedNode {
|
||||
pub did: String,
|
||||
pub pubkey: String,
|
||||
pub onion: String,
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
pub trust_level: TrustLevel,
|
||||
pub added_at: String,
|
||||
#[serde(default)]
|
||||
pub last_seen: Option<String>,
|
||||
#[serde(default)]
|
||||
pub last_state: Option<NodeStateSnapshot>,
|
||||
}
|
||||
|
||||
/// State snapshot received from a federated peer during sync.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NodeStateSnapshot {
|
||||
pub timestamp: String,
|
||||
#[serde(default)]
|
||||
pub apps: Vec<AppStatus>,
|
||||
#[serde(default)]
|
||||
pub cpu_usage_percent: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub mem_used_bytes: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub mem_total_bytes: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub disk_used_bytes: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub disk_total_bytes: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub uptime_secs: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub tor_active: Option<bool>,
|
||||
}
|
||||
|
||||
/// Status of a single app/container on a remote node.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppStatus {
|
||||
pub id: String,
|
||||
pub status: String, // "running", "stopped", "installed"
|
||||
#[serde(default)]
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
/// A pending invite (outgoing or incoming).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FederationInvite {
|
||||
pub code: String,
|
||||
pub did: String,
|
||||
pub onion: String,
|
||||
pub pubkey: String,
|
||||
pub created_at: String,
|
||||
#[serde(default)]
|
||||
pub accepted: bool,
|
||||
}
|
||||
|
||||
/// Top-level file structures.
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
struct NodesFile {
|
||||
nodes: Vec<FederatedNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
struct InvitesFile {
|
||||
outgoing: Vec<FederationInvite>,
|
||||
incoming: Vec<FederationInvite>,
|
||||
}
|
||||
|
||||
/// Ensure federation directory exists.
|
||||
async fn ensure_dir(data_dir: &Path) -> Result<std::path::PathBuf> {
|
||||
let dir = data_dir.join(FEDERATION_DIR);
|
||||
fs::create_dir_all(&dir)
|
||||
.await
|
||||
.context("Failed to create federation directory")?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
// ──────────────────────────── Node Management ────────────────────────────
|
||||
|
||||
pub async fn load_nodes(data_dir: &Path) -> Result<Vec<FederatedNode>> {
|
||||
let dir = data_dir.join(FEDERATION_DIR);
|
||||
let path = dir.join(NODES_FILE);
|
||||
if !path.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let content = fs::read_to_string(&path)
|
||||
.await
|
||||
.context("Failed to read federation nodes")?;
|
||||
let file: NodesFile = serde_json::from_str(&content).unwrap_or_default();
|
||||
Ok(file.nodes)
|
||||
}
|
||||
|
||||
pub async fn save_nodes(data_dir: &Path, nodes: &[FederatedNode]) -> Result<()> {
|
||||
let dir = ensure_dir(data_dir).await?;
|
||||
let file = NodesFile {
|
||||
nodes: nodes.to_vec(),
|
||||
};
|
||||
let content = serde_json::to_string_pretty(&file).context("Failed to serialize nodes")?;
|
||||
fs::write(dir.join(NODES_FILE), content)
|
||||
.await
|
||||
.context("Failed to write federation nodes")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn add_node(data_dir: &Path, node: FederatedNode) -> Result<Vec<FederatedNode>> {
|
||||
let mut nodes = load_nodes(data_dir).await?;
|
||||
let exists = nodes.iter().any(|n| n.did == node.did);
|
||||
if exists {
|
||||
anyhow::bail!("Node with DID {} is already federated", node.did);
|
||||
}
|
||||
nodes.push(node);
|
||||
save_nodes(data_dir, &nodes).await?;
|
||||
Ok(nodes)
|
||||
}
|
||||
|
||||
pub async fn remove_node(data_dir: &Path, did: &str) -> Result<Vec<FederatedNode>> {
|
||||
let mut nodes = load_nodes(data_dir).await?;
|
||||
let before = nodes.len();
|
||||
nodes.retain(|n| n.did != did);
|
||||
if nodes.len() == before {
|
||||
anyhow::bail!("No federated node with DID {}", did);
|
||||
}
|
||||
save_nodes(data_dir, &nodes).await?;
|
||||
Ok(nodes)
|
||||
}
|
||||
|
||||
pub async fn set_trust_level(
|
||||
data_dir: &Path,
|
||||
did: &str,
|
||||
trust: TrustLevel,
|
||||
) -> Result<Vec<FederatedNode>> {
|
||||
let mut nodes = load_nodes(data_dir).await?;
|
||||
let node = nodes
|
||||
.iter_mut()
|
||||
.find(|n| n.did == did)
|
||||
.ok_or_else(|| anyhow::anyhow!("No federated node with DID {}", did))?;
|
||||
node.trust_level = trust;
|
||||
save_nodes(data_dir, &nodes).await?;
|
||||
Ok(nodes)
|
||||
}
|
||||
|
||||
pub async fn update_node_state(
|
||||
data_dir: &Path,
|
||||
did: &str,
|
||||
state: NodeStateSnapshot,
|
||||
) -> Result<()> {
|
||||
let mut nodes = load_nodes(data_dir).await?;
|
||||
if let Some(node) = nodes.iter_mut().find(|n| n.did == did) {
|
||||
node.last_seen = Some(state.timestamp.clone());
|
||||
node.last_state = Some(state);
|
||||
save_nodes(data_dir, &nodes).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ──────────────────────────── Invite Management ────────────────────────────
|
||||
|
||||
async fn load_invites(data_dir: &Path) -> Result<InvitesFile> {
|
||||
let dir = data_dir.join(FEDERATION_DIR);
|
||||
let path = dir.join(INVITES_FILE);
|
||||
if !path.exists() {
|
||||
return Ok(InvitesFile::default());
|
||||
}
|
||||
let content = fs::read_to_string(&path)
|
||||
.await
|
||||
.context("Failed to read invites")?;
|
||||
let file: InvitesFile = serde_json::from_str(&content).unwrap_or_default();
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
async fn save_invites(data_dir: &Path, invites: &InvitesFile) -> Result<()> {
|
||||
let dir = ensure_dir(data_dir).await?;
|
||||
let content = serde_json::to_string_pretty(invites).context("Failed to serialize invites")?;
|
||||
fs::write(dir.join(INVITES_FILE), content)
|
||||
.await
|
||||
.context("Failed to write invites")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generate an invite code. Format: `fed1:<base64(json{did, onion, pubkey, token})>`
|
||||
pub async fn create_invite(
|
||||
data_dir: &Path,
|
||||
did: &str,
|
||||
onion: &str,
|
||||
pubkey: &str,
|
||||
) -> Result<String> {
|
||||
use base64::Engine;
|
||||
use rand::Rng;
|
||||
|
||||
let mut token_bytes = [0u8; 16];
|
||||
rand::thread_rng().fill(&mut token_bytes);
|
||||
let token = hex::encode(token_bytes);
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"did": did,
|
||||
"onion": onion,
|
||||
"pubkey": pubkey,
|
||||
"token": token,
|
||||
});
|
||||
let json = serde_json::to_string(&payload).context("Failed to serialize invite")?;
|
||||
let code = format!(
|
||||
"fed1:{}",
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json.as_bytes())
|
||||
);
|
||||
|
||||
let invite = FederationInvite {
|
||||
code: code.clone(),
|
||||
did: did.to_string(),
|
||||
onion: onion.to_string(),
|
||||
pubkey: pubkey.to_string(),
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
accepted: false,
|
||||
};
|
||||
|
||||
let mut invites = load_invites(data_dir).await?;
|
||||
invites.outgoing.push(invite);
|
||||
save_invites(data_dir, &invites).await?;
|
||||
|
||||
Ok(code)
|
||||
}
|
||||
|
||||
/// Parse an invite code into its components.
|
||||
pub fn parse_invite(code: &str) -> Result<(String, String, String, String)> {
|
||||
use base64::Engine;
|
||||
|
||||
let encoded = code
|
||||
.strip_prefix("fed1:")
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid invite format: must start with fed1:"))?;
|
||||
|
||||
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(encoded)
|
||||
.context("Invalid base64 in invite code")?;
|
||||
|
||||
let payload: serde_json::Value =
|
||||
serde_json::from_slice(&bytes).context("Invalid JSON in invite")?;
|
||||
|
||||
let did = payload["did"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing did in invite"))?
|
||||
.to_string();
|
||||
let onion = payload["onion"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing onion in invite"))?
|
||||
.to_string();
|
||||
let pubkey = payload["pubkey"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing pubkey in invite"))?
|
||||
.to_string();
|
||||
let token = payload["token"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing token in invite"))?
|
||||
.to_string();
|
||||
|
||||
Ok((did, onion, pubkey, token))
|
||||
}
|
||||
|
||||
/// Accept an invite: parse code, verify the remote node, add to federation.
|
||||
pub async fn accept_invite(
|
||||
data_dir: &Path,
|
||||
code: &str,
|
||||
local_did: &str,
|
||||
local_onion: &str,
|
||||
local_pubkey: &str,
|
||||
) -> Result<FederatedNode> {
|
||||
let (did, onion, pubkey, _token) = parse_invite(code)?;
|
||||
|
||||
// Check not already federated
|
||||
let nodes = load_nodes(data_dir).await?;
|
||||
if nodes.iter().any(|n| n.did == did) {
|
||||
anyhow::bail!("Already federated with node {}", did);
|
||||
}
|
||||
|
||||
let node = FederatedNode {
|
||||
did: did.clone(),
|
||||
pubkey,
|
||||
onion,
|
||||
name: None,
|
||||
trust_level: TrustLevel::Trusted,
|
||||
added_at: chrono::Utc::now().to_rfc3339(),
|
||||
last_seen: None,
|
||||
last_state: None,
|
||||
};
|
||||
|
||||
add_node(data_dir, node.clone()).await?;
|
||||
|
||||
// Record as incoming accepted invite
|
||||
let mut invites = load_invites(data_dir).await?;
|
||||
invites.incoming.push(FederationInvite {
|
||||
code: code.to_string(),
|
||||
did: did.clone(),
|
||||
onion: node.onion.clone(),
|
||||
pubkey: node.pubkey.clone(),
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
accepted: true,
|
||||
});
|
||||
save_invites(data_dir, &invites).await?;
|
||||
|
||||
// Notify remote node (best-effort over Tor)
|
||||
let _ = notify_join(&node.onion, local_did, local_onion, local_pubkey).await;
|
||||
|
||||
Ok(node)
|
||||
}
|
||||
|
||||
/// Best-effort notification to the remote node that we joined their federation.
|
||||
async fn notify_join(
|
||||
remote_onion: &str,
|
||||
local_did: &str,
|
||||
local_onion: &str,
|
||||
local_pubkey: &str,
|
||||
) -> Result<()> {
|
||||
let host = if remote_onion.ends_with(".onion") {
|
||||
remote_onion.to_string()
|
||||
} else {
|
||||
format!("{}.onion", remote_onion)
|
||||
};
|
||||
let url = format!("http://{}/rpc/v1", host);
|
||||
let body = serde_json::json!({
|
||||
"method": "federation.peer-joined",
|
||||
"params": {
|
||||
"did": local_did,
|
||||
"onion": local_onion,
|
||||
"pubkey": local_pubkey,
|
||||
}
|
||||
});
|
||||
|
||||
let proxy = reqwest::Proxy::all("socks5h://127.0.0.1:9050").context("Invalid Tor proxy")?;
|
||||
let client = reqwest::Client::builder()
|
||||
.proxy(proxy)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.context("Failed to build HTTP client")?;
|
||||
|
||||
let _ = client.post(&url).json(&body).send().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sync state with a single federated peer over Tor.
|
||||
pub async fn sync_with_peer(
|
||||
data_dir: &Path,
|
||||
peer: &FederatedNode,
|
||||
local_did: &str,
|
||||
sign_fn: impl FnOnce(&[u8]) -> String,
|
||||
) -> Result<NodeStateSnapshot> {
|
||||
let host = if peer.onion.ends_with(".onion") {
|
||||
peer.onion.clone()
|
||||
} else {
|
||||
format!("{}.onion", peer.onion)
|
||||
};
|
||||
let url = format!("http://{}/rpc/v1", host);
|
||||
|
||||
// Sign current timestamp for authentication
|
||||
let timestamp = chrono::Utc::now().to_rfc3339();
|
||||
let signature = sign_fn(timestamp.as_bytes());
|
||||
|
||||
let body = serde_json::json!({
|
||||
"method": "federation.get-state",
|
||||
"params": {}
|
||||
});
|
||||
|
||||
let proxy = reqwest::Proxy::all("socks5h://127.0.0.1:9050").context("Invalid Tor proxy")?;
|
||||
let client = reqwest::Client::builder()
|
||||
.proxy(proxy)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.context("Failed to build HTTP client")?;
|
||||
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.header("X-Federation-DID", local_did)
|
||||
.header("X-Federation-Sig", &signature)
|
||||
.header("X-Federation-Timestamp", ×tamp)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to reach federated peer")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("Peer returned {}", resp.status());
|
||||
}
|
||||
|
||||
let result: serde_json::Value = resp.json().await.context("Invalid response from peer")?;
|
||||
let state_val = result
|
||||
.get("result")
|
||||
.ok_or_else(|| anyhow::anyhow!("No result in peer response"))?;
|
||||
|
||||
let state: NodeStateSnapshot =
|
||||
serde_json::from_value(state_val.clone()).context("Failed to parse peer state")?;
|
||||
|
||||
update_node_state(data_dir, &peer.did, state.clone()).await?;
|
||||
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
/// Sync with a peer using the transport router (Mesh > LAN > Tor).
|
||||
/// Uses CBOR delta encoding for compact payloads over constrained links.
|
||||
/// Falls back to `sync_with_peer()` if no transport router is available.
|
||||
pub async fn sync_with_peer_via_transport(
|
||||
data_dir: &Path,
|
||||
peer: &FederatedNode,
|
||||
local_did: &str,
|
||||
previous_state: Option<&NodeStateSnapshot>,
|
||||
router: &crate::transport::TransportRouter,
|
||||
) -> Result<()> {
|
||||
use crate::transport::{MessageType, TransportMessage};
|
||||
use crate::transport::delta;
|
||||
|
||||
// Build the sync request payload — if we have a previous state from this peer,
|
||||
// send a delta request (tells the peer we only need changes since timestamp).
|
||||
let payload = if let Some(prev) = previous_state {
|
||||
// Request delta since our last known state
|
||||
let request = serde_json::json!({
|
||||
"type": "state_sync_request",
|
||||
"since": prev.timestamp,
|
||||
});
|
||||
delta::encode_cbor(&delta::StateDelta {
|
||||
ts: prev.timestamp.clone(),
|
||||
v: 1,
|
||||
..Default::default()
|
||||
})?
|
||||
} else {
|
||||
// First sync — request full state
|
||||
let request = serde_json::json!({ "type": "state_sync_request" });
|
||||
serde_json::to_vec(&request)?
|
||||
};
|
||||
|
||||
let message = TransportMessage {
|
||||
from_did: local_did.to_string(),
|
||||
payload,
|
||||
message_type: MessageType::StateSync,
|
||||
};
|
||||
|
||||
let transport_used = router.send_to_peer(&peer.did, &message).await?;
|
||||
tracing::info!(
|
||||
peer = %peer.did,
|
||||
transport = %transport_used,
|
||||
"Federation sync sent via transport"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build the local node's state snapshot for sharing with peers.
|
||||
pub fn build_local_state(
|
||||
apps: Vec<AppStatus>,
|
||||
cpu: f64,
|
||||
mem_used: u64,
|
||||
mem_total: u64,
|
||||
disk_used: u64,
|
||||
disk_total: u64,
|
||||
uptime: u64,
|
||||
tor_active: bool,
|
||||
) -> NodeStateSnapshot {
|
||||
NodeStateSnapshot {
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
apps,
|
||||
cpu_usage_percent: Some(cpu),
|
||||
mem_used_bytes: Some(mem_used),
|
||||
mem_total_bytes: Some(mem_total),
|
||||
disk_used_bytes: Some(disk_used),
|
||||
disk_total_bytes: Some(disk_total),
|
||||
uptime_secs: Some(uptime),
|
||||
tor_active: Some(tor_active),
|
||||
}
|
||||
}
|
||||
|
||||
/// Deploy an app to a remote federated peer over Tor.
|
||||
/// Only works if the peer is trusted and the app exists in our marketplace.
|
||||
pub async fn deploy_to_peer(
|
||||
peer: &FederatedNode,
|
||||
app_id: &str,
|
||||
version: &str,
|
||||
marketplace_url: &str,
|
||||
local_did: &str,
|
||||
sign_fn: impl FnOnce(&[u8]) -> String,
|
||||
) -> Result<serde_json::Value> {
|
||||
if peer.trust_level != TrustLevel::Trusted {
|
||||
anyhow::bail!("Can only deploy to trusted peers (current: {})", peer.trust_level);
|
||||
}
|
||||
|
||||
let host = if peer.onion.ends_with(".onion") {
|
||||
peer.onion.clone()
|
||||
} else {
|
||||
format!("{}.onion", peer.onion)
|
||||
};
|
||||
let url = format!("http://{}/rpc/v1", host);
|
||||
|
||||
let timestamp = chrono::Utc::now().to_rfc3339();
|
||||
let signature = sign_fn(timestamp.as_bytes());
|
||||
|
||||
let body = serde_json::json!({
|
||||
"method": "package.install",
|
||||
"params": {
|
||||
"id": app_id,
|
||||
"version": version,
|
||||
"marketplace-url": marketplace_url,
|
||||
}
|
||||
});
|
||||
|
||||
let proxy = reqwest::Proxy::all("socks5h://127.0.0.1:9050").context("Invalid Tor proxy")?;
|
||||
let client = reqwest::Client::builder()
|
||||
.proxy(proxy)
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.build()
|
||||
.context("Failed to build HTTP client")?;
|
||||
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.header("X-Federation-DID", local_did)
|
||||
.header("X-Federation-Sig", &signature)
|
||||
.header("X-Federation-Timestamp", ×tamp)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to reach federated peer for deploy")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("Remote node returned HTTP {}", resp.status());
|
||||
}
|
||||
|
||||
let result: serde_json::Value = resp.json().await.context("Invalid response from peer")?;
|
||||
|
||||
if let Some(err) = result.get("error") {
|
||||
if !err.is_null() {
|
||||
let msg = err.get("message").and_then(|m| m.as_str()).unwrap_or("Unknown remote error");
|
||||
anyhow::bail!("Remote node refused deploy: {}", msg);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"deployed": true,
|
||||
"app_id": app_id,
|
||||
"peer_did": peer.did,
|
||||
"peer_onion": peer.onion,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_node(did: &str, onion: &str) -> FederatedNode {
|
||||
FederatedNode {
|
||||
did: did.to_string(),
|
||||
pubkey: "aabbccdd".to_string(),
|
||||
onion: onion.to_string(),
|
||||
name: None,
|
||||
trust_level: TrustLevel::Trusted,
|
||||
added_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
last_seen: None,
|
||||
last_state: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trust_level_serialization() {
|
||||
let json = serde_json::to_string(&TrustLevel::Trusted).unwrap();
|
||||
assert_eq!(json, "\"trusted\"");
|
||||
|
||||
let parsed: TrustLevel = serde_json::from_str("\"observer\"").unwrap();
|
||||
assert_eq!(parsed, TrustLevel::Observer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_federated_node_serialization_roundtrip() {
|
||||
let node = make_node("did:key:zABC", "test.onion");
|
||||
let json = serde_json::to_string(&node).unwrap();
|
||||
let parsed: FederatedNode = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.did, "did:key:zABC");
|
||||
assert_eq!(parsed.trust_level, TrustLevel::Trusted);
|
||||
assert!(parsed.last_state.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_node_state_snapshot_defaults() {
|
||||
let json = r#"{"timestamp": "2026-01-01T00:00:00Z"}"#;
|
||||
let state: NodeStateSnapshot = serde_json::from_str(json).unwrap();
|
||||
assert!(state.apps.is_empty());
|
||||
assert!(state.cpu_usage_percent.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_nodes_empty_when_no_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let nodes = load_nodes(dir.path()).await.unwrap();
|
||||
assert!(nodes.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_save_and_load_nodes_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let nodes = vec![
|
||||
make_node("did:key:z1", "a.onion"),
|
||||
make_node("did:key:z2", "b.onion"),
|
||||
];
|
||||
save_nodes(dir.path(), &nodes).await.unwrap();
|
||||
let loaded = load_nodes(dir.path()).await.unwrap();
|
||||
assert_eq!(loaded.len(), 2);
|
||||
assert_eq!(loaded[0].did, "did:key:z1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_add_node_deduplicates_by_did() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
|
||||
.await
|
||||
.unwrap();
|
||||
let result = add_node(dir.path(), make_node("did:key:z1", "b.onion")).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remove_node_by_did() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
|
||||
.await
|
||||
.unwrap();
|
||||
add_node(dir.path(), make_node("did:key:z2", "b.onion"))
|
||||
.await
|
||||
.unwrap();
|
||||
let result = remove_node(dir.path(), "did:key:z1").await.unwrap();
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].did, "did:key:z2");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remove_nonexistent_node_errors() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let result = remove_node(dir.path(), "did:key:nonexistent").await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_set_trust_level() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
|
||||
.await
|
||||
.unwrap();
|
||||
let nodes = set_trust_level(dir.path(), "did:key:z1", TrustLevel::Observer)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(nodes[0].trust_level, TrustLevel::Observer);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_node_state() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let state = NodeStateSnapshot {
|
||||
timestamp: "2026-03-10T12:00:00Z".to_string(),
|
||||
apps: vec![AppStatus {
|
||||
id: "bitcoin".to_string(),
|
||||
status: "running".to_string(),
|
||||
version: Some("27.0".to_string()),
|
||||
}],
|
||||
cpu_usage_percent: Some(45.2),
|
||||
mem_used_bytes: Some(4_000_000_000),
|
||||
mem_total_bytes: Some(8_000_000_000),
|
||||
disk_used_bytes: None,
|
||||
disk_total_bytes: None,
|
||||
uptime_secs: Some(86400),
|
||||
tor_active: Some(true),
|
||||
};
|
||||
|
||||
update_node_state(dir.path(), "did:key:z1", state)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let nodes = load_nodes(dir.path()).await.unwrap();
|
||||
assert!(nodes[0].last_seen.is_some());
|
||||
let ls = nodes[0].last_state.as_ref().unwrap();
|
||||
assert_eq!(ls.apps.len(), 1);
|
||||
assert_eq!(ls.cpu_usage_percent, Some(45.2));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_and_parse_invite() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let code = create_invite(dir.path(), "did:key:z1", "test.onion", "aabbcc")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(code.starts_with("fed1:"));
|
||||
|
||||
let (did, onion, pubkey, token) = parse_invite(&code).unwrap();
|
||||
assert_eq!(did, "did:key:z1");
|
||||
assert_eq!(onion, "test.onion");
|
||||
assert_eq!(pubkey, "aabbcc");
|
||||
assert_eq!(token.len(), 32); // 16 bytes = 32 hex chars
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_invalid_invite() {
|
||||
assert!(parse_invite("invalid").is_err());
|
||||
assert!(parse_invite("fed1:not-valid-base64!!!").is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_accept_invite_creates_node() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let code = create_invite(dir.path(), "did:key:zRemote", "remote.onion", "remotepub")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Accept from a different "local" perspective
|
||||
let dir2 = tempfile::tempdir().unwrap();
|
||||
let node = accept_invite(
|
||||
dir2.path(),
|
||||
&code,
|
||||
"did:key:zLocal",
|
||||
"local.onion",
|
||||
"localpub",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(node.did, "did:key:zRemote");
|
||||
assert_eq!(node.trust_level, TrustLevel::Trusted);
|
||||
|
||||
let nodes = load_nodes(dir2.path()).await.unwrap();
|
||||
assert_eq!(nodes.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_accept_invite_rejects_duplicate() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let code = create_invite(dir.path(), "did:key:zRemote", "remote.onion", "remotepub")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let dir2 = tempfile::tempdir().unwrap();
|
||||
accept_invite(
|
||||
dir2.path(),
|
||||
&code,
|
||||
"did:key:zLocal",
|
||||
"local.onion",
|
||||
"localpub",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Accepting the same invite again should fail
|
||||
let result = accept_invite(
|
||||
dir2.path(),
|
||||
&code,
|
||||
"did:key:zLocal",
|
||||
"local.onion",
|
||||
"localpub",
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_local_state() {
|
||||
let state = build_local_state(
|
||||
vec![AppStatus {
|
||||
id: "lnd".to_string(),
|
||||
status: "running".to_string(),
|
||||
version: Some("0.18".to_string()),
|
||||
}],
|
||||
25.5,
|
||||
2_000_000_000,
|
||||
8_000_000_000,
|
||||
100_000_000_000,
|
||||
500_000_000_000,
|
||||
3600,
|
||||
true,
|
||||
);
|
||||
assert_eq!(state.apps.len(), 1);
|
||||
assert_eq!(state.cpu_usage_percent, Some(25.5));
|
||||
assert_eq!(state.tor_active, Some(true));
|
||||
}
|
||||
}
|
||||
259
core/archipelago/src/federation/invites.rs
Normal file
259
core/archipelago/src/federation/invites.rs
Normal file
@@ -0,0 +1,259 @@
|
||||
//! Federation invite creation, parsing, and acceptance.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::Path;
|
||||
|
||||
use super::storage::{add_node, load_invites, load_nodes, save_invites};
|
||||
use super::types::{FederatedNode, FederationInvite, TrustLevel};
|
||||
|
||||
/// Generate an invite code. Format: `fed1:<base64(json{did, onion, pubkey, token})>`
|
||||
pub async fn create_invite(
|
||||
data_dir: &Path,
|
||||
did: &str,
|
||||
onion: &str,
|
||||
pubkey: &str,
|
||||
) -> Result<String> {
|
||||
use base64::Engine;
|
||||
use rand::Rng;
|
||||
|
||||
let mut token_bytes = [0u8; 16];
|
||||
rand::thread_rng().fill(&mut token_bytes);
|
||||
let token = hex::encode(token_bytes);
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"did": did,
|
||||
"onion": onion,
|
||||
"pubkey": pubkey,
|
||||
"token": token,
|
||||
});
|
||||
let json = serde_json::to_string(&payload).context("Failed to serialize invite")?;
|
||||
let code = format!(
|
||||
"fed1:{}",
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json.as_bytes())
|
||||
);
|
||||
|
||||
let invite = FederationInvite {
|
||||
code: code.clone(),
|
||||
did: did.to_string(),
|
||||
onion: onion.to_string(),
|
||||
pubkey: pubkey.to_string(),
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
accepted: false,
|
||||
};
|
||||
|
||||
let mut invites = load_invites(data_dir).await?;
|
||||
invites.outgoing.push(invite);
|
||||
save_invites(data_dir, &invites).await?;
|
||||
|
||||
Ok(code)
|
||||
}
|
||||
|
||||
/// Parse an invite code into its components.
|
||||
pub fn parse_invite(code: &str) -> Result<(String, String, String, String)> {
|
||||
use base64::Engine;
|
||||
|
||||
let encoded = code
|
||||
.strip_prefix("fed1:")
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid invite format: must start with fed1:"))?;
|
||||
|
||||
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(encoded)
|
||||
.context("Invalid base64 in invite code")?;
|
||||
|
||||
let payload: serde_json::Value =
|
||||
serde_json::from_slice(&bytes).context("Invalid JSON in invite")?;
|
||||
|
||||
let did = payload["did"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing did in invite"))?
|
||||
.to_string();
|
||||
let onion = payload["onion"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing onion in invite"))?
|
||||
.to_string();
|
||||
let pubkey = payload["pubkey"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing pubkey in invite"))?
|
||||
.to_string();
|
||||
let token = payload["token"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing token in invite"))?
|
||||
.to_string();
|
||||
|
||||
Ok((did, onion, pubkey, token))
|
||||
}
|
||||
|
||||
/// Accept an invite: parse code, verify the remote node, add to federation.
|
||||
pub async fn accept_invite(
|
||||
data_dir: &Path,
|
||||
code: &str,
|
||||
local_did: &str,
|
||||
local_onion: &str,
|
||||
local_pubkey: &str,
|
||||
sign_fn: impl FnOnce(&[u8]) -> String,
|
||||
) -> Result<FederatedNode> {
|
||||
let (did, onion, pubkey, _token) = parse_invite(code)?;
|
||||
|
||||
// Check not already federated
|
||||
let nodes = load_nodes(data_dir).await?;
|
||||
if nodes.iter().any(|n| n.did == did) {
|
||||
anyhow::bail!("Already federated with node {}", did);
|
||||
}
|
||||
|
||||
let node = FederatedNode {
|
||||
did: did.clone(),
|
||||
pubkey,
|
||||
onion,
|
||||
name: None,
|
||||
trust_level: TrustLevel::Trusted,
|
||||
added_at: chrono::Utc::now().to_rfc3339(),
|
||||
last_seen: None,
|
||||
last_state: None,
|
||||
};
|
||||
|
||||
add_node(data_dir, node.clone()).await?;
|
||||
|
||||
// Record as incoming accepted invite
|
||||
let mut invites = load_invites(data_dir).await?;
|
||||
invites.incoming.push(FederationInvite {
|
||||
code: code.to_string(),
|
||||
did: did.clone(),
|
||||
onion: node.onion.clone(),
|
||||
pubkey: node.pubkey.clone(),
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
accepted: true,
|
||||
});
|
||||
save_invites(data_dir, &invites).await?;
|
||||
|
||||
// Notify remote node (best-effort over Tor)
|
||||
let _ = notify_join(&node.onion, local_did, local_onion, local_pubkey, sign_fn).await;
|
||||
|
||||
Ok(node)
|
||||
}
|
||||
|
||||
/// Best-effort notification to the remote node that we joined their federation.
|
||||
/// Signs the message with our ed25519 key so the remote peer can verify authenticity.
|
||||
async fn notify_join(
|
||||
remote_onion: &str,
|
||||
local_did: &str,
|
||||
local_onion: &str,
|
||||
local_pubkey: &str,
|
||||
sign_fn: impl FnOnce(&[u8]) -> String,
|
||||
) -> Result<()> {
|
||||
let host = if remote_onion.ends_with(".onion") {
|
||||
remote_onion.to_string()
|
||||
} else {
|
||||
format!("{}.onion", remote_onion)
|
||||
};
|
||||
let url = format!("http://{}/rpc/v1", host);
|
||||
|
||||
// Sign the canonical message: "peer-joined:{did}:{onion}:{pubkey}"
|
||||
let sign_data = format!("peer-joined:{}:{}:{}", local_did, local_onion, local_pubkey);
|
||||
let signature = sign_fn(sign_data.as_bytes());
|
||||
|
||||
let body = serde_json::json!({
|
||||
"method": "federation.peer-joined",
|
||||
"params": {
|
||||
"did": local_did,
|
||||
"onion": local_onion,
|
||||
"pubkey": local_pubkey,
|
||||
"signature": signature,
|
||||
}
|
||||
});
|
||||
|
||||
let proxy = reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY).context("Invalid Tor proxy")?;
|
||||
let client = reqwest::Client::builder()
|
||||
.proxy(proxy)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.context("Failed to build HTTP client")?;
|
||||
|
||||
let _ = client.post(&url).json(&body).send().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::federation::storage::load_nodes;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_and_parse_invite() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let code = create_invite(dir.path(), "did:key:z1", "test.onion", "aabbcc")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(code.starts_with("fed1:"));
|
||||
|
||||
let (did, onion, pubkey, token) = parse_invite(&code).unwrap();
|
||||
assert_eq!(did, "did:key:z1");
|
||||
assert_eq!(onion, "test.onion");
|
||||
assert_eq!(pubkey, "aabbcc");
|
||||
assert_eq!(token.len(), 32); // 16 bytes = 32 hex chars
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_invalid_invite() {
|
||||
assert!(parse_invite("invalid").is_err());
|
||||
assert!(parse_invite("fed1:not-valid-base64!!!").is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_accept_invite_creates_node() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let code = create_invite(dir.path(), "did:key:zRemote", "remote.onion", "remotepub")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Accept from a different "local" perspective
|
||||
let dir2 = tempfile::tempdir().unwrap();
|
||||
let node = accept_invite(
|
||||
dir2.path(),
|
||||
&code,
|
||||
"did:key:zLocal",
|
||||
"local.onion",
|
||||
"localpub",
|
||||
|_| "test-sig".to_string(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(node.did, "did:key:zRemote");
|
||||
assert_eq!(node.trust_level, TrustLevel::Trusted);
|
||||
|
||||
let nodes = load_nodes(dir2.path()).await.unwrap();
|
||||
assert_eq!(nodes.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_accept_invite_rejects_duplicate() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let code = create_invite(dir.path(), "did:key:zRemote", "remote.onion", "remotepub")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let dir2 = tempfile::tempdir().unwrap();
|
||||
accept_invite(
|
||||
dir2.path(),
|
||||
&code,
|
||||
"did:key:zLocal",
|
||||
"local.onion",
|
||||
"localpub",
|
||||
|_| "test-sig".to_string(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Accepting the same invite again should fail
|
||||
let result = accept_invite(
|
||||
dir2.path(),
|
||||
&code,
|
||||
"did:key:zLocal",
|
||||
"local.onion",
|
||||
"localpub",
|
||||
|_| "test-sig".to_string(),
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
18
core/archipelago/src/federation/mod.rs
Normal file
18
core/archipelago/src/federation/mod.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
//! Node federation: trusted multi-node clusters with state sync.
|
||||
//!
|
||||
//! Nodes federate by exchanging invite codes containing DID + onion address.
|
||||
//! Trust is bilateral — both sides must agree. Federated nodes periodically
|
||||
//! sync container status, health metrics, and availability.
|
||||
|
||||
mod invites;
|
||||
mod storage;
|
||||
mod sync;
|
||||
mod types;
|
||||
|
||||
// Re-export all public items so `crate::federation::*` continues to work.
|
||||
pub use invites::{accept_invite, create_invite};
|
||||
pub use storage::{
|
||||
add_node, load_nodes, remove_node, save_nodes, set_trust_level,
|
||||
};
|
||||
pub use sync::{build_local_state, deploy_to_peer, sync_with_peer};
|
||||
pub use types::{AppStatus, FederatedNode, NodeStateSnapshot, TrustLevel};
|
||||
258
core/archipelago/src/federation/storage.rs
Normal file
258
core/archipelago/src/federation/storage.rs
Normal file
@@ -0,0 +1,258 @@
|
||||
//! Federation persistent storage: node list and invite management on disk.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use tokio::fs;
|
||||
|
||||
use super::types::{FederatedNode, FederationInvite, NodeStateSnapshot, TrustLevel};
|
||||
|
||||
pub(crate) const FEDERATION_DIR: &str = "federation";
|
||||
pub(crate) const NODES_FILE: &str = "nodes.json";
|
||||
pub(crate) const INVITES_FILE: &str = "invites.json";
|
||||
|
||||
/// Top-level file structures.
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub(crate) struct NodesFile {
|
||||
pub(crate) nodes: Vec<FederatedNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub(crate) struct InvitesFile {
|
||||
pub(crate) outgoing: Vec<FederationInvite>,
|
||||
pub(crate) incoming: Vec<FederationInvite>,
|
||||
}
|
||||
|
||||
/// Ensure federation directory exists.
|
||||
pub(crate) async fn ensure_dir(data_dir: &Path) -> Result<std::path::PathBuf> {
|
||||
let dir = data_dir.join(FEDERATION_DIR);
|
||||
fs::create_dir_all(&dir)
|
||||
.await
|
||||
.context("Failed to create federation directory")?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
// ──────────────────────────── Node Management ────────────────────────────
|
||||
|
||||
pub async fn load_nodes(data_dir: &Path) -> Result<Vec<FederatedNode>> {
|
||||
let dir = data_dir.join(FEDERATION_DIR);
|
||||
let path = dir.join(NODES_FILE);
|
||||
if !path.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let content = fs::read_to_string(&path)
|
||||
.await
|
||||
.context("Failed to read federation nodes")?;
|
||||
let file: NodesFile = serde_json::from_str(&content).unwrap_or_default();
|
||||
Ok(file.nodes)
|
||||
}
|
||||
|
||||
pub async fn save_nodes(data_dir: &Path, nodes: &[FederatedNode]) -> Result<()> {
|
||||
let dir = ensure_dir(data_dir).await?;
|
||||
let file = NodesFile {
|
||||
nodes: nodes.to_vec(),
|
||||
};
|
||||
let content = serde_json::to_string_pretty(&file).context("Failed to serialize nodes")?;
|
||||
fs::write(dir.join(NODES_FILE), content)
|
||||
.await
|
||||
.context("Failed to write federation nodes")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn add_node(data_dir: &Path, node: FederatedNode) -> Result<Vec<FederatedNode>> {
|
||||
let mut nodes = load_nodes(data_dir).await?;
|
||||
let exists = nodes.iter().any(|n| n.did == node.did);
|
||||
if exists {
|
||||
anyhow::bail!("Node with DID {} is already federated", node.did);
|
||||
}
|
||||
nodes.push(node);
|
||||
save_nodes(data_dir, &nodes).await?;
|
||||
Ok(nodes)
|
||||
}
|
||||
|
||||
pub async fn remove_node(data_dir: &Path, did: &str) -> Result<Vec<FederatedNode>> {
|
||||
let mut nodes = load_nodes(data_dir).await?;
|
||||
let before = nodes.len();
|
||||
nodes.retain(|n| n.did != did);
|
||||
if nodes.len() == before {
|
||||
anyhow::bail!("No federated node with DID {}", did);
|
||||
}
|
||||
save_nodes(data_dir, &nodes).await?;
|
||||
Ok(nodes)
|
||||
}
|
||||
|
||||
pub async fn set_trust_level(
|
||||
data_dir: &Path,
|
||||
did: &str,
|
||||
trust: TrustLevel,
|
||||
) -> Result<Vec<FederatedNode>> {
|
||||
let mut nodes = load_nodes(data_dir).await?;
|
||||
let node = nodes
|
||||
.iter_mut()
|
||||
.find(|n| n.did == did)
|
||||
.ok_or_else(|| anyhow::anyhow!("No federated node with DID {}", did))?;
|
||||
node.trust_level = trust;
|
||||
save_nodes(data_dir, &nodes).await?;
|
||||
Ok(nodes)
|
||||
}
|
||||
|
||||
pub async fn update_node_state(
|
||||
data_dir: &Path,
|
||||
did: &str,
|
||||
state: NodeStateSnapshot,
|
||||
) -> Result<()> {
|
||||
let mut nodes = load_nodes(data_dir).await?;
|
||||
if let Some(node) = nodes.iter_mut().find(|n| n.did == did) {
|
||||
node.last_seen = Some(state.timestamp.clone());
|
||||
// Update node name from sync if provided (peer announced their name)
|
||||
if let Some(ref name) = state.node_name {
|
||||
if !name.is_empty() {
|
||||
node.name = Some(name.clone());
|
||||
}
|
||||
}
|
||||
node.last_state = Some(state);
|
||||
save_nodes(data_dir, &nodes).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ──────────────────────────── Invite Storage ────────────────────────────
|
||||
|
||||
pub(crate) async fn load_invites(data_dir: &Path) -> Result<InvitesFile> {
|
||||
let dir = data_dir.join(FEDERATION_DIR);
|
||||
let path = dir.join(INVITES_FILE);
|
||||
if !path.exists() {
|
||||
return Ok(InvitesFile::default());
|
||||
}
|
||||
let content = fs::read_to_string(&path)
|
||||
.await
|
||||
.context("Failed to read invites")?;
|
||||
let file: InvitesFile = serde_json::from_str(&content).unwrap_or_default();
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
pub(crate) async fn save_invites(data_dir: &Path, invites: &InvitesFile) -> Result<()> {
|
||||
let dir = ensure_dir(data_dir).await?;
|
||||
let content = serde_json::to_string_pretty(invites).context("Failed to serialize invites")?;
|
||||
fs::write(dir.join(INVITES_FILE), content)
|
||||
.await
|
||||
.context("Failed to write invites")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::federation::types::AppStatus;
|
||||
|
||||
fn make_node(did: &str, onion: &str) -> FederatedNode {
|
||||
FederatedNode {
|
||||
did: did.to_string(),
|
||||
pubkey: "aabbccdd".to_string(),
|
||||
onion: onion.to_string(),
|
||||
name: None,
|
||||
trust_level: TrustLevel::Trusted,
|
||||
added_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
last_seen: None,
|
||||
last_state: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_nodes_empty_when_no_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let nodes = load_nodes(dir.path()).await.unwrap();
|
||||
assert!(nodes.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_save_and_load_nodes_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let nodes = vec![
|
||||
make_node("did:key:z1", "a.onion"),
|
||||
make_node("did:key:z2", "b.onion"),
|
||||
];
|
||||
save_nodes(dir.path(), &nodes).await.unwrap();
|
||||
let loaded = load_nodes(dir.path()).await.unwrap();
|
||||
assert_eq!(loaded.len(), 2);
|
||||
assert_eq!(loaded[0].did, "did:key:z1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_add_node_deduplicates_by_did() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
|
||||
.await
|
||||
.unwrap();
|
||||
let result = add_node(dir.path(), make_node("did:key:z1", "b.onion")).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remove_node_by_did() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
|
||||
.await
|
||||
.unwrap();
|
||||
add_node(dir.path(), make_node("did:key:z2", "b.onion"))
|
||||
.await
|
||||
.unwrap();
|
||||
let result = remove_node(dir.path(), "did:key:z1").await.unwrap();
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].did, "did:key:z2");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remove_nonexistent_node_errors() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let result = remove_node(dir.path(), "did:key:nonexistent").await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_set_trust_level() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
|
||||
.await
|
||||
.unwrap();
|
||||
let nodes = set_trust_level(dir.path(), "did:key:z1", TrustLevel::Observer)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(nodes[0].trust_level, TrustLevel::Observer);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_node_state() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let state = NodeStateSnapshot {
|
||||
timestamp: "2026-03-10T12:00:00Z".to_string(),
|
||||
node_name: None,
|
||||
apps: vec![AppStatus {
|
||||
id: "bitcoin".to_string(),
|
||||
status: "running".to_string(),
|
||||
version: Some("27.0".to_string()),
|
||||
}],
|
||||
cpu_usage_percent: Some(45.2),
|
||||
mem_used_bytes: Some(4_000_000_000),
|
||||
mem_total_bytes: Some(8_000_000_000),
|
||||
disk_used_bytes: None,
|
||||
disk_total_bytes: None,
|
||||
uptime_secs: Some(86400),
|
||||
tor_active: Some(true),
|
||||
};
|
||||
|
||||
update_node_state(dir.path(), "did:key:z1", state)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let nodes = load_nodes(dir.path()).await.unwrap();
|
||||
assert!(nodes[0].last_seen.is_some());
|
||||
let ls = nodes[0].last_state.as_ref().unwrap();
|
||||
assert_eq!(ls.apps.len(), 1);
|
||||
assert_eq!(ls.cpu_usage_percent, Some(45.2));
|
||||
}
|
||||
}
|
||||
189
core/archipelago/src/federation/sync.rs
Normal file
189
core/archipelago/src/federation/sync.rs
Normal file
@@ -0,0 +1,189 @@
|
||||
//! Federation state sync and remote deployment.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::Path;
|
||||
|
||||
use super::storage::update_node_state;
|
||||
use super::types::{AppStatus, FederatedNode, NodeStateSnapshot, TrustLevel};
|
||||
|
||||
/// Sync state with a single federated peer over Tor.
|
||||
pub async fn sync_with_peer(
|
||||
data_dir: &Path,
|
||||
peer: &FederatedNode,
|
||||
local_did: &str,
|
||||
sign_fn: impl FnOnce(&[u8]) -> String,
|
||||
) -> Result<NodeStateSnapshot> {
|
||||
let host = if peer.onion.ends_with(".onion") {
|
||||
peer.onion.clone()
|
||||
} else {
|
||||
format!("{}.onion", peer.onion)
|
||||
};
|
||||
let url = format!("http://{}/rpc/v1", host);
|
||||
|
||||
// Sign current timestamp for authentication
|
||||
let timestamp = chrono::Utc::now().to_rfc3339();
|
||||
let signature = sign_fn(timestamp.as_bytes());
|
||||
|
||||
let body = serde_json::json!({
|
||||
"method": "federation.get-state",
|
||||
"params": {}
|
||||
});
|
||||
|
||||
let proxy = reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY).context("Invalid Tor proxy")?;
|
||||
let client = reqwest::Client::builder()
|
||||
.proxy(proxy)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.context("Failed to build HTTP client")?;
|
||||
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.header("X-Federation-DID", local_did)
|
||||
.header("X-Federation-Sig", &signature)
|
||||
.header("X-Federation-Timestamp", ×tamp)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to reach federated peer")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("Peer returned {}", resp.status());
|
||||
}
|
||||
|
||||
let result: serde_json::Value = resp.json().await.context("Invalid response from peer")?;
|
||||
let state_val = result
|
||||
.get("result")
|
||||
.ok_or_else(|| anyhow::anyhow!("No result in peer response"))?;
|
||||
|
||||
let state: NodeStateSnapshot =
|
||||
serde_json::from_value(state_val.clone()).context("Failed to parse peer state")?;
|
||||
|
||||
update_node_state(data_dir, &peer.did, state.clone()).await?;
|
||||
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
/// Build the local node's state snapshot for sharing with peers.
|
||||
pub fn build_local_state(
|
||||
apps: Vec<AppStatus>,
|
||||
cpu: f64,
|
||||
mem_used: u64,
|
||||
mem_total: u64,
|
||||
disk_used: u64,
|
||||
disk_total: u64,
|
||||
uptime: u64,
|
||||
tor_active: bool,
|
||||
server_name: Option<String>,
|
||||
) -> NodeStateSnapshot {
|
||||
NodeStateSnapshot {
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
node_name: server_name,
|
||||
apps,
|
||||
cpu_usage_percent: Some(cpu),
|
||||
mem_used_bytes: Some(mem_used),
|
||||
mem_total_bytes: Some(mem_total),
|
||||
disk_used_bytes: Some(disk_used),
|
||||
disk_total_bytes: Some(disk_total),
|
||||
uptime_secs: Some(uptime),
|
||||
tor_active: Some(tor_active),
|
||||
}
|
||||
}
|
||||
|
||||
/// Deploy an app to a remote federated peer over Tor.
|
||||
/// Only works if the peer is trusted and the app exists in our marketplace.
|
||||
pub async fn deploy_to_peer(
|
||||
peer: &FederatedNode,
|
||||
app_id: &str,
|
||||
version: &str,
|
||||
marketplace_url: &str,
|
||||
local_did: &str,
|
||||
sign_fn: impl FnOnce(&[u8]) -> String,
|
||||
) -> Result<serde_json::Value> {
|
||||
if peer.trust_level != TrustLevel::Trusted {
|
||||
anyhow::bail!("Can only deploy to trusted peers (current: {})", peer.trust_level);
|
||||
}
|
||||
|
||||
let host = if peer.onion.ends_with(".onion") {
|
||||
peer.onion.clone()
|
||||
} else {
|
||||
format!("{}.onion", peer.onion)
|
||||
};
|
||||
let url = format!("http://{}/rpc/v1", host);
|
||||
|
||||
let timestamp = chrono::Utc::now().to_rfc3339();
|
||||
let signature = sign_fn(timestamp.as_bytes());
|
||||
|
||||
let body = serde_json::json!({
|
||||
"method": "package.install",
|
||||
"params": {
|
||||
"id": app_id,
|
||||
"version": version,
|
||||
"marketplace-url": marketplace_url,
|
||||
}
|
||||
});
|
||||
|
||||
let proxy = reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY).context("Invalid Tor proxy")?;
|
||||
let client = reqwest::Client::builder()
|
||||
.proxy(proxy)
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.build()
|
||||
.context("Failed to build HTTP client")?;
|
||||
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.header("X-Federation-DID", local_did)
|
||||
.header("X-Federation-Sig", &signature)
|
||||
.header("X-Federation-Timestamp", ×tamp)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to reach federated peer for deploy")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("Remote node returned HTTP {}", resp.status());
|
||||
}
|
||||
|
||||
let result: serde_json::Value = resp.json().await.context("Invalid response from peer")?;
|
||||
|
||||
if let Some(err) = result.get("error") {
|
||||
if !err.is_null() {
|
||||
let msg = err.get("message").and_then(|m| m.as_str()).unwrap_or("Unknown remote error");
|
||||
anyhow::bail!("Remote node refused deploy: {}", msg);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"deployed": true,
|
||||
"app_id": app_id,
|
||||
"peer_did": peer.did,
|
||||
"peer_onion": peer.onion,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_build_local_state() {
|
||||
let state = build_local_state(
|
||||
vec![AppStatus {
|
||||
id: "lnd".to_string(),
|
||||
status: "running".to_string(),
|
||||
version: Some("0.18".to_string()),
|
||||
}],
|
||||
25.5,
|
||||
2_000_000_000,
|
||||
8_000_000_000,
|
||||
100_000_000_000,
|
||||
500_000_000_000,
|
||||
3600,
|
||||
true,
|
||||
Some("Test Node".to_string()),
|
||||
);
|
||||
assert_eq!(state.apps.len(), 1);
|
||||
assert_eq!(state.cpu_usage_percent, Some(25.5));
|
||||
assert_eq!(state.tor_active, Some(true));
|
||||
assert_eq!(state.node_name, Some("Test Node".to_string()));
|
||||
}
|
||||
}
|
||||
124
core/archipelago/src/federation/types.rs
Normal file
124
core/archipelago/src/federation/types.rs
Normal file
@@ -0,0 +1,124 @@
|
||||
//! Federation type definitions.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Trust level for a federated node.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TrustLevel {
|
||||
Trusted,
|
||||
Observer,
|
||||
Untrusted,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TrustLevel {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
TrustLevel::Trusted => write!(f, "trusted"),
|
||||
TrustLevel::Observer => write!(f, "observer"),
|
||||
TrustLevel::Untrusted => write!(f, "untrusted"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A federated node in our cluster.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FederatedNode {
|
||||
pub did: String,
|
||||
pub pubkey: String,
|
||||
pub onion: String,
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
pub trust_level: TrustLevel,
|
||||
pub added_at: String,
|
||||
#[serde(default)]
|
||||
pub last_seen: Option<String>,
|
||||
#[serde(default)]
|
||||
pub last_state: Option<NodeStateSnapshot>,
|
||||
}
|
||||
|
||||
/// State snapshot received from a federated peer during sync.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NodeStateSnapshot {
|
||||
pub timestamp: String,
|
||||
#[serde(default)]
|
||||
pub node_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub apps: Vec<AppStatus>,
|
||||
#[serde(default)]
|
||||
pub cpu_usage_percent: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub mem_used_bytes: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub mem_total_bytes: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub disk_used_bytes: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub disk_total_bytes: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub uptime_secs: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub tor_active: Option<bool>,
|
||||
}
|
||||
|
||||
/// Status of a single app/container on a remote node.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppStatus {
|
||||
pub id: String,
|
||||
pub status: String, // "running", "stopped", "installed"
|
||||
#[serde(default)]
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
/// A pending invite (outgoing or incoming).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FederationInvite {
|
||||
pub code: String,
|
||||
pub did: String,
|
||||
pub onion: String,
|
||||
pub pubkey: String,
|
||||
pub created_at: String,
|
||||
#[serde(default)]
|
||||
pub accepted: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_trust_level_serialization() {
|
||||
let json = serde_json::to_string(&TrustLevel::Trusted).unwrap();
|
||||
assert_eq!(json, "\"trusted\"");
|
||||
|
||||
let parsed: TrustLevel = serde_json::from_str("\"observer\"").unwrap();
|
||||
assert_eq!(parsed, TrustLevel::Observer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_federated_node_serialization_roundtrip() {
|
||||
let node = FederatedNode {
|
||||
did: "did:key:zABC".to_string(),
|
||||
pubkey: "aabbccdd".to_string(),
|
||||
onion: "test.onion".to_string(),
|
||||
name: None,
|
||||
trust_level: TrustLevel::Trusted,
|
||||
added_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
last_seen: None,
|
||||
last_state: None,
|
||||
};
|
||||
let json = serde_json::to_string(&node).unwrap();
|
||||
let parsed: FederatedNode = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.did, "did:key:zABC");
|
||||
assert_eq!(parsed.trust_level, TrustLevel::Trusted);
|
||||
assert!(parsed.last_state.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_node_state_snapshot_defaults() {
|
||||
let json = r#"{"timestamp": "2026-01-01T00:00:00Z"}"#;
|
||||
let state: NodeStateSnapshot = serde_json::from_str(json).unwrap();
|
||||
assert!(state.apps.is_empty());
|
||||
assert!(state.cpu_usage_percent.is_none());
|
||||
}
|
||||
}
|
||||
@@ -175,9 +175,6 @@ impl MemoryTracker {
|
||||
}
|
||||
}
|
||||
|
||||
fn remove(&mut self, name: &str) {
|
||||
self.samples.remove(name);
|
||||
}
|
||||
}
|
||||
|
||||
/// Query container memory stats from podman.
|
||||
@@ -350,8 +347,26 @@ async fn restart_container(name: &str) -> bool {
|
||||
/// Spawn the health monitor background task.
|
||||
pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
|
||||
tokio::spawn(async move {
|
||||
// Wait 2 minutes for containers to start up
|
||||
tokio::time::sleep(std::time::Duration::from_secs(120)).await;
|
||||
// Wait for boot recovery to complete before starting health checks.
|
||||
// This prevents the health monitor from fighting with crash_recovery
|
||||
// which is starting containers in tier order.
|
||||
info!("Health monitor: waiting for boot recovery to complete...");
|
||||
let wait_start = std::time::Instant::now();
|
||||
loop {
|
||||
if crate::crash_recovery::is_recovery_complete() {
|
||||
break;
|
||||
}
|
||||
// Safety timeout: start anyway after 5 minutes even if recovery hangs
|
||||
if wait_start.elapsed().as_secs() > 300 {
|
||||
warn!("Health monitor: boot recovery did not complete within 5 minutes, starting anyway");
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
}
|
||||
// Additional cooldown after recovery to let containers stabilize
|
||||
info!("Health monitor: recovery done, waiting 60s for containers to stabilize...");
|
||||
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
|
||||
info!("Health monitor: starting health checks");
|
||||
|
||||
let mut tracker = RestartTracker::new();
|
||||
let mut mem_tracker = MemoryTracker::new();
|
||||
@@ -378,6 +393,9 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Load user-stopped list to skip intentionally stopped containers
|
||||
let user_stopped = crate::crash_recovery::load_user_stopped(&data_dir).await;
|
||||
|
||||
// Sort containers by startup tier so databases restart before dependent services
|
||||
let mut unhealthy: Vec<&ContainerHealth> = Vec::new();
|
||||
let mut state_changed = false;
|
||||
@@ -392,6 +410,11 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
|
||||
continue;
|
||||
}
|
||||
if container.state == "exited" || container.state == "stopped" {
|
||||
// Skip user-stopped containers
|
||||
if user_stopped.contains(&container.name) {
|
||||
debug!("Skipping user-stopped container: {}", container.name);
|
||||
continue;
|
||||
}
|
||||
unhealthy.push(container);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user