Compare commits

...

109 Commits

Author SHA1 Message Date
Dorian
9aaf8d4b95 fix: CI rm binary before cp to avoid 'Text file busy'
Some checks failed
Build Archipelago ISO / build-iso (push) Has been cancelled
On Linux, rm on a running binary works (process keeps its fd).
Then cp creates a new inode. Restart service after.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 15:26:18 +00:00
Dorian
ea222895be fix: CI add debug output for frontend build step
Some checks failed
Build Archipelago ISO / build-iso (push) Failing after 12m39s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 15:04:22 +00:00
Dorian
27f1b8d21b fix: CI stop archipelago service before replacing binary
Some checks failed
Build Archipelago ISO / build-iso (push) Failing after 13m58s
The running binary locks the file, causing 'Text file busy' on cp.
Stop the service, copy, then restart.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 14:44:32 +00:00
Dorian
d71eae1815 fix: CI increase timeout, cleared stale git lock on runner
Some checks failed
Build Archipelago ISO / build-iso (push) Failing after 12m14s
Stale shallow.lock was blocking checkout. Removed it on the runner.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 14:30:21 +00:00
Dorian
3daf889f74 fix: CI use actions/checkout@v4 (Gitea proxies to GitHub)
Some checks failed
Build Archipelago ISO / build-iso (push) Has been cancelled
The full URL form was 404. The short form lets Gitea resolve from
its configured action sources (GitHub proxy). This worked for build #7.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 14:26:57 +00:00
Dorian
e96acc9023 fix: CI checkout cd to home before cleanup to avoid cwd error
Some checks failed
Build Archipelago ISO / build-iso (push) Has been cancelled
The runner cwd is the workspace itself, so deleting it removes the
shell's cwd. cd to home first, then clean workspace before clone.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 14:24:24 +00:00
Dorian
2d47fd800e fix: CI checkout with token auth for private repo
Some checks failed
Build Archipelago ISO / build-iso (push) Failing after 0s
Manual git clone needs GITHUB_TOKEN injected for private repos.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 14:21:48 +00:00
Dorian
008573b6ac fix: CI checkout uses manual git clone instead of missing action
Some checks failed
Build Archipelago ISO / build-iso (push) Has been cancelled
The actions/checkout@v4 action was 404 on git.tx1138.com causing
instant build failures. Use manual git clone for reliability with
host-mode runner.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 14:16:13 +00:00
Dorian
ae13c0dad2 feat: migrate all container images to Archipelago app registry
Some checks failed
Build Archipelago ISO / build-iso (push) Failing after 0s
All container image references now pull from 80.71.235.15:3000/archipelago/
instead of Docker Hub and ghcr.io. image-versions.sh is the single source
of truth; all scripts use $*_IMAGE variables instead of hardcoded refs.

Files updated:
- scripts/image-versions.sh: central ARCHY_REGISTRY variable
- core/*/config.rs: registry whitelist includes app registry
- core/*/stacks.rs: Immich + Penpot stack images
- scripts/{first-boot,deploy-to-target,container-specs}.sh: use variables
- docker/*/Dockerfile: nginx base image from registry
- image-recipe/: ISO build, podman config, menu script
- scripts/{container-doctor,deploy-bitcoin-knots,fix-indeedhub,validate-app-manifest}.sh

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 14:06:21 +00:00
Dorian
fc1e763cff feat: switch marketplace to Archipelago app registry
Some checks failed
Build Archipelago ISO / build-iso (push) Failing after 0s
All app images now pull from 80.71.235.15:3000/archipelago/
instead of Docker Hub / ghcr.io. Insecure registry config
baked into ISO for fresh installs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 10:46:26 +00:00
Dorian
1f9124789f fix: CI workflow use Gitea checkout action, unbundled only
Some checks failed
Build Archipelago ISO / build-iso (push) Failing after 11m44s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 09:34:11 +00:00
Dorian
99e32b877f chore: CI builds unbundled ISO only (with FileBrowser)
Some checks failed
Build Archipelago ISO / build-iso (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 09:26:40 +00:00
Dorian
5af4c71ab7 chore: remove disabled workflows, keep only build-iso
Some checks failed
Build Archipelago ISO / build-frontend (push) Has been cancelled
Build Archipelago ISO / build-bundled-iso (push) Has been cancelled
Build Archipelago ISO / build-backend (push) Has been cancelled
Build Archipelago ISO / build-unbundled-iso (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 09:20:12 +00:00
Dorian
059913d3dd feat: CI/CD builds both bundled and unbundled ISOs
Some checks failed
Build Archipelago ISO / build-backend (push) Has been cancelled
Build Archipelago ISO / build-bundled-iso (push) Has been cancelled
Build Archipelago ISO / build-unbundled-iso (push) Has been cancelled
Build Archipelago ISO / build-frontend (push) Has been cancelled
Workflow builds both variants on push to main. Manual trigger
lets you choose bundled, unbundled, or both. ISOs auto-copied
to FileBrowser /Builds/ folder for easy download.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 09:13:31 +00:00
Dorian
08bb2c80d4 feat: LUKS2 encryption, boot sequence fixes, onboarding auth, CI/CD
Some checks failed
Build Archipelago ISO / build-iso (push) Has been cancelled
- LUKS2 full-partition encryption for /var/lib/archipelago/ (TASK-42)
  4-partition layout: BIOS + EFI + root (30GB) + encrypted data
  AES-256-XTS with AES-NI detection, ChaCha20 fallback for ARM
  Auto-unlock via crypttab + random key file

- Fix EFI boot errors: remove shim-signed, clean shim artifacts
- Fix first-boot sequence: always show boot animation before onboarding
- Fix stale localStorage causing login instead of onboarding (BUG-47)

- Add auth.setup + auth.isSetup RPC handlers for password on clean install
- Add onboarding methods to UNAUTHENTICATED_METHODS (DID sign 403 fix)

- FileBrowser bundled in unbundled ISO, fix auto-login Secure cookie (BUG-46)
- Kiosk mode: xorg/chromium in rootfs, toggle script, MOTD instructions

- Add Gitea Actions CI/CD workflow for automatic ISO builds

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 09:12:16 +00:00
Dorian
5c15c52113 fix: add --no-cache to rootfs Docker build to prevent stale layer caching
Podman was caching the rootfs Docker layers, meaning firmware packages
and sources.list changes were never picked up on rebuild. Force fresh
build every time since the rootfs tar is the real cache.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:31:51 +00:00
Dorian
aa78d92f7f fix: replace DEB822 sources with traditional sources.list for non-free-firmware
The sed commands to modify debian.sources DEB822 format were silently
failing — firmware packages never got installed. Replace the entire
sources config with traditional sources.list that explicitly includes
non-free-firmware component.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:21:27 +00:00
Dorian
997d9d36ff fix: remove Secure Boot shim chain — causes EFI boot failure on most hardware
The shim (shimx64.efi.signed) was being installed as BOOTX64.EFI but it
tries to load a second-stage binary with a garbled name, causing
"Failed to open \EFI\BOOT\" errors on machines with Secure Boot disabled.

Fix: use grub-install --removable directly (unsigned GRUB as BOOTX64.EFI).
This works on all UEFI hardware. Users with Secure Boot must disable it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 20:47:14 +00:00
Dorian
809e471e2b fix: EFI Secure Boot chain with grub.cfg, fix non-free-firmware repo
EFI boot fix:
- Shim needs grub.cfg in same directory to find the root partition
- Create minimal grub.cfg in /EFI/BOOT/ that chains to /boot/grub/grub.cfg
- Preserve unsigned GRUB as fallback for non-Secure-Boot systems
- Copy full chain to both /EFI/BOOT/ and /EFI/archipelago/ paths
- Log EFI directory contents for debugging

Firmware fix:
- DEB822 format sed was wrong — fix Components line replacement
- Add fallback sources.list entry to guarantee non-free-firmware repo
- Ensures firmware-realtek, intel-microcode actually get installed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 19:25:55 +00:00
Dorian
54451103f3 fix: zero BIOS boot partition to prevent FAT-fs errors, add CPU microcode
- dd zero the 1MB BIOS boot partition before formatting to prevent
  kernel FAT-fs bread() errors during boot (sda1 had stale data)
- Add intel-microcode and amd64-microcode packages to suppress
  TSC_DEADLINE and similar CPU firmware bug warnings on boot

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 18:25:01 +00:00
Dorian
35f1aa2e13 fix: move mobile nav outside main for fixed positioning, add container scripts
- Dashboard.vue: move DashboardMobileNav outside <main> so position:fixed
  isn't broken by will-change:transform on the perspective container
- Add container-specs.sh and reconcile-containers.sh utility scripts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 18:13:22 +00:00
Dorian
74abbef00d fix: robust ISO download detection, fix color escape codes in installer
- Use find instead of hardcoded filename for downloaded ISO detection
  (wget may save with redirect filename or partial name)
- Fix color escape codes: use $'\033' syntax instead of '\033' for
  reliable ANSI color rendering in installer output

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 17:03:21 +00:00
Dorian
5d8365f001 fix: add hardware firmware, suppress GRUB warning, eject USB after install
- Add firmware-realtek, firmware-iwlwifi, firmware-misc-nonfree to rootfs
  (fixes missing r8169 NIC firmware on Dell and other common hardware)
- Enable non-free-firmware repo in rootfs Dockerfile
- Suppress os-prober GRUB warning (GRUB_DISABLE_OS_PROBER=true)
- Auto-eject USB boot media before reboot to prevent re-entering installer

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 16:56:02 +00:00
Dorian
c16fa8013a fix: use Debian 12 (Bookworm) live ISO base, remove squashfs boot artifacts
The ISO build was using Debian 13 (Trixie) as the live installer base
while the rootfs was built from Debian 12 (Bookworm). This caused:
- Debian 13 kernel/hostname/user in the live environment
- Squashfs errors on reboot from live-boot initramfs hooks

Fixes:
- Pin live ISO to Debian 12.10.0 (archive URL)
- Remove live-boot/live-config packages before initramfs regeneration
- Clean out any live-boot initramfs hooks/scripts from installed rootfs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 16:51:14 +00:00
Dorian
0e0c97c203 feat: architecture review fixes, self-update system, CI pipeline, supply chain hardening
Architecture review (all P0+P1 issues now fixed):
- Add 10s timeout to 6 bare Nostr client.connect() calls
- Pin all 12 crypto deps to exact versions from Cargo.lock
- Pin all 15 floating container image tags to exact patch versions
- Add CI pipeline (cargo fmt + clippy + tests, frontend type-check + build)

Self-update system (git.tx1138.com):
- scripts/self-update.sh: pull, build, install, restart with rollback
- systemd timer checks daily at 3 AM
- update.check RPC does git-based checks when repo is present
- update.git-apply RPC triggers self-update from UI
- Default update URL changed from GitHub to git.tx1138.com
- Git added to ISO package list for fresh installs

Documentation:
- CHANGELOG v1.3.1 with all changes
- README updated (version, update system section)
- BETA-PROGRESS session #6 logged
- architecture-review.html: 4 issues marked FIXED, 8/12 refactoring done

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 15:52:26 +00:00
Dorian
0fe4ebc7d5 docs: update deploy session memory with session 3 fixes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 18:06:57 +00:00
Dorian
a7920de824 fix: correct health check endpoints for fedimint, nextcloud, filebrowser
- Fedimint: check port 8175 (UI) not 8174 (websocket API)
- Nextcloud: check / not /status.php (returns 302 during setup)
- FileBrowser: check / not /health (endpoint doesn't exist)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 00:47:49 +00:00
Dorian
06d85e1d6f fix: health check escaping for SSH heredoc context
- Remove || exit 1 from health-cmd (redundant, breaks SSH heredoc)
- Use --health-cmd 'cmd' format (space, not equals) for proper quoting
- Simplify bitcoin health check to bitcoin-cli getnetworkinfo (no creds needed)
- Fix MariaDB health check nested quote issue

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 23:45:32 +00:00
Dorian
f5802f9ed0 fix: LND config escaping in SSH heredoc, Tailscale fallback for build source
- Fix shell escaping in LND config sync block (single-quoted SSH context
  doesn't need backslash-escaped dollars)
- deploy-tailscale.sh BUILD_SOURCE auto-detects Tailscale IP when LAN
  unreachable (fixes "No binary on .228" error)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 17:01:02 +00:00
Dorian
028248dfd7 fix: suppress tar xattr spam in AIUI deploy
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 16:54:54 +00:00
Dorian
f5714a5b2e fix: fleet deploy falls back to Tailscale when LAN unreachable
- Add --all as alias for --fleet
- Fleet deploy auto-detects Tailscale IP when LAN SSH fails
- Skip .198 gracefully when unreachable instead of failing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 16:51:49 +00:00
Dorian
d37165ca52 fix: deploy credential sync, health checks, rootless port binding
- LND config always synced with secrets/bitcoin-rpc-password before
  starting (both deploy scripts) — fixes 401 auth errors on all nodes
- Replace eval "$DB_PASSWORDS" with safe individual SSH reads in
  deploy-tailscale.sh (eliminates command injection risk)
- Add MariaDB password sync step after container start (ALTER USER)
- Add --health-cmd to all 25 containers in deploy-tailscale.sh
- FileBrowser uses --user 0:0 for rootless port 80 binding (both scripts)
- Fedimint env var fixed: FM_REL_NOTES_ACK=0_4_xyz

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 14:16:11 +00:00
Dorian
13e4a738be bug fixing and deploy and build diagnostics 2026-03-22 03:30:21 +00:00
Dorian
01942cea95 docs: mark all overnight plan tasks complete
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 03:08:52 +00:00
Dorian
24f86632d0 feat: add E2E smoke test script and CI/CD pipeline plan
- Create scripts/smoke-test.sh for live server verification (7 checks)
- Document planned GitHub Actions CI/CD pipeline in docs/ci-cd-plan.md
- Integration tests deferred to future task (require test harness setup)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 03:08:00 +00:00
Dorian
5099f6f763 refactor: create shared script library, fix ISO image pinning, document planned splits
- S21: Create scripts/lib/common.sh with shared logging, SSH, health check, mem_limit functions
- S18: Source common.sh from deploy-to-target.sh, deploy-tailscale.sh, first-boot-containers.sh
- S16: Fix 2 hardcoded images in ISO build, add missing image variables
- S19: Document planned 7-module split of build-auto-installer-iso.sh
- S20: Document planned 8-module split of first-boot-containers.sh

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 03:06:29 +00:00
Dorian
bfbaa36709 refactor: split Marketplace, Server, Home, AppDetails views; minor frontend quality fixes
- F29-F32: Split 4 views (Marketplace 1293→505, Server 1132→486, Home 1059→394, AppDetails 1036→386)
- F20: Add aria-current="page" to Dashboard nav links
- F21: Add 150ms search debounce in Marketplace and Apps views
- F22: Reduce backdrop-filter blur to 8px on mobile for GPU performance
- F23: Track and clear WebSocket connect check interval in all paths

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 03:01:38 +00:00
Dorian
ea1b1f826b refactor: split Web5.vue, Settings.vue, and Mesh.vue into focused subcomponents
- F25: Split Web5.vue (3940 lines) into 14 files under views/web5/
- F26: Split Mesh.vue (2106→840 lines) extracting Bitcoin and Deadman panels
- F27: Dashboard.vue assessed — layout shell, no split needed
- F28: Split Settings.vue (1792 lines) into AccountSection + SystemSection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 02:43:28 +00:00
Dorian
77f550fb5e refactor: split package.rs, mod.rs, listener.rs, and lnd.rs into focused submodules
- R35: Split package.rs (1794 lines) into package/{mod,config,validation,lifecycle}.rs
- R36: Split mesh/listener.rs (1799 lines) into listener/{mod,session,frames,decode,dispatch,bitcoin}.rs
- R37: Split rpc/mod.rs into mod.rs + dispatcher.rs, middleware.rs, response.rs (54% reduction)
- R38: Split lnd.rs (1064 lines) into lnd/{mod,info,channels,wallet,payments}.rs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 02:26:28 +00:00
Dorian
8e4d352393 fix: deploy error visibility, trap cleanup, variable quoting, frontend resilience
- S10: Add warnings to silent health check failures in deploy scripts
- S11: Add trap cleanup for temp dirs in deploy and tailscale scripts
- S12: Quote 20+ critical unquoted variables across deploy scripts
- S13: Extract hardcoded IPs to deploy-config-defaults.sh
- S15: Add --memory=256m to UI container runs
- F16: Remove in-memory JWT, use cookie-only auth in filebrowser client
- F17: Add meta tag fallback for CSRF token in RPC client
- F19: Track and clear setTimeout in AppSession on unmount

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 02:06:08 +00:00
Dorian
3b35b1bee0 fix: WebSocket reconnect race, parse error tracking, RPC timeout reduction, vendor chunk split
- F8: Add isReconnecting flag to prevent parallel reconnection attempts
- F9: Track JSON parse errors, force reconnect after 3 consecutive failures
- F11: Reduce RPC timeout to 15s, add jitter to retry backoff
- F12: Add vendor chunk splitting for vue/router/pinia
- F13: DOMPurify already applied to QR SVGs — verified
- F14: Replace O(n) goals alias lookup with Map-based O(1)
- F15: Wrap 7 localStorage.setItem calls in try/catch across 5 stores

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 01:57:05 +00:00
Dorian
f3976ba03a refactor: centralize constants, eliminate unwraps, remove dead code, resolve TODOs
- R13+R16: Replace .expect() with .context()? in main.rs and identity.rs
- R17+R18+R19: Fix unwrap() calls in helpers and js-engine
- R20+R21: Remove #[allow(dead_code)] annotations and delete truly dead code
- R22-R26: Create constants.rs module, replace 21 hardcoded values across 12 files
- R28+R29: LND/DWN timeouts already present — verified
- R30-R33: Remove TODO comments, implement marketplace payment check

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 01:54:35 +00:00
Dorian
5c3a3ffa8e fix: systemd resource limits, Tor rotation transition, unwrap elimination, RPC timeouts
- I2: Add MemoryMax=4G, LimitNOFILE=65535, TasksMax=2048 to systemd service
- I3: Tor rotation keeps old service for 1h transition before cleanup
- R14: Replace .parse().unwrap() with .unwrap_or(localhost) in rate limiter
- R15: Replace 7 unwrap/expect in mesh protocol with proper error propagation
- R27: Add 10s timeouts to mesh Bitcoin RPC calls

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 01:46:40 +00:00
Dorian
2f60ef44ea fix: deploy locking, safe eval replacement, first-boot error handling, script hardening
- S4: Add Bitcoin readiness gate and container tracking with final summary
- S5: Replace eval "$DB_PASSWORDS" with safe case-based variable parsing
- S6: Add deploy locking with stale lock detection (30min timeout)
- S7: Deploy rollback already implemented — verified existing mechanism
- S8: Switch trust-archipelago-cert.sh to SSH key auth, sshpass as fallback
- S9: Pipe MariaDB SQL via stdin to avoid password in ps output
- S17: Add disk space pre-flight check (abort if >85% full)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 01:39:22 +00:00
Dorian
3b7d541224 fix: WebSocket reconnect state refresh, listener leak fixes, pin container images
- F4: Fetch fresh server state after WebSocket reconnect
- F5: Guard message polling timer with auth check, stop on logout
- F6: Remove NIP-07 listener in appLauncher close()
- F7: Initialize audio player once to prevent listener stacking
- S3: Pin all container images to specific versions, create image-versions.sh

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 01:32:28 +00:00
Dorian
4d17c60da7 refactor: replace blocking std::fs and TCP I/O with async tokio equivalents
- R6: Convert 6 std::fs calls in session.rs to tokio::fs async
- R7: Convert std::fs::read_to_string in docker_packages.rs to async
- R8: Convert 3 std::fs calls in port_allocator.rs to async, switch to tokio::sync::Mutex
- R9+R10+R11: Fix blocking I/O in node_message.rs and nostr_discovery.rs
- R12: Convert electrs_status.rs from sync TCP to async tokio::net with 5s timeouts
- R4+R5: Spawn periodic cleanup tasks for endpoint and login rate limiters

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 01:21:08 +00:00
Dorian
38dc845f57 fix: WebSocket race conditions, Vue error handler, remove sudo podman, add container health checks
- F1: Guard connectWebSocket against concurrent calls with isWsConnecting flag
- F2: Serialize mesh send operations with sendQueue to prevent fetchMessages races
- F3: Add global Vue error handler with toast notification
- S1: Replace sudo podman with podman across all scripts (rootless Podman)
- S2: Add health-cmd to all 40 container run commands in first-boot-containers.sh

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 01:11:05 +00:00
Dorian
c299199d37 fix: add health RPC handler, Nostr connect timeouts, atomic backup restore, nginx rate limits
- R1: Add health RPC endpoint with crash recovery status, uptime, and version
- R2: Wrap all 5 Nostr client.connect() calls in 10s timeout
- R3: Make backup restore atomic with staging dir and rollback on failure
- I1: Add rate limiting, body size, and proxy timeouts to unauthenticated nginx endpoints

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 01:02:16 +00:00
Dorian
b5024c29df fix: sync-aware UI for Bitcoin-dependent apps
AppDetails.vue now checks Bitcoin sync progress for LND, ElectrumX,
BTCPay, and Mempool. Shows orange warning banner with sync progress
bar and block height when Bitcoin is still syncing. Users see clear
feedback instead of broken wallet connect pages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 14:26:05 +00:00
Dorian
196682f2f2 fix: LND and ElectrumX Tor onion address resolution
- lnd.rs: check tor-hostnames readable copy, then /var/lib/tor/, then
  legacy /var/lib/archipelago/tor/ with sudo fallback for each
- electrs_status.rs: same multi-path resolution for ElectrumX onion
- Both servers: created /var/lib/archipelago/tor-hostnames/ with readable
  copies of onion addresses (avoids sudo on every API call)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 12:31:30 +00:00
Dorian
b31148a8b7 fix: rpcauth credentials, reboot survival, system Tor for all containers
- Bitcoin RPC: switch to rpcauth (salted hash in bitcoin.conf, no plaintext
  in config or CLI). Password stable across reboots/restarts/deploys.
- Remove daily-reboot-test.sh cron on both servers
- Enable podman-restart.service for container auto-start after reboot
- System Tor: SocksPort 0.0.0.0:9050 with SocksPolicy for container access
- LND: tor.socks=host.containers.internal:9050 (system Tor, not container)
- Bitcoin: -proxy=host.containers.internal:9050 for Tor outbound
- bitcoin_rpc.rs: reads from secrets file, cached, stable credentials
- package.rs: dynamic rpc_user/rpc_pass, rpcauth hash generation
- network.rs: fix missing send_to_peer args (mesh encryption update)
- first-boot-containers.sh: rpcauth generation, system Tor config
- deploy-to-target.sh: rpcauth credentials, LND config migration
- Mesh: encrypted channel message support (ChaCha20-Poly1305 updates)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 11:56:20 +00:00
Dorian
b4d204d1d6 feat: reboot button in Settings with password confirmation
- system.reboot RPC endpoint requires password re-verification
- Uses systemd path unit pattern (tor-helper.sh) for privilege escalation
- 2-second delay before reboot to allow RPC response to reach client
- Clean UI: password input modal, loading state, error feedback

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 10:48:06 +00:00
Dorian
c82158c7c8 refactor: PodmanClient uses REST API socket instead of CLI
Replace all `podman` CLI shell-outs with HTTP requests to the rootless
Podman API unix socket (/run/user/{UID}/podman/podman.sock).

Benefits:
- No process spawning overhead — direct HTTP over unix socket
- Structured JSON responses — no string parsing fragility
- Proper timeouts on all operations (5s connect, 30s default, 120s create)
- Health check method to verify socket availability
- Restart container as first-class operation

Still uses CLI for:
- Image pulls (streaming operation better suited to CLI)
- Container logs (raw text stream, not JSON)

The Podman socket is rootless (runs as archipelago user), local-only
(unix socket), and already behind our session auth in the backend.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 10:13:49 +00:00
Dorian
9b6adfc42d feat: E2E encrypted Tor channel messages (ChaCha20-Poly1305)
Messages between federated nodes are now end-to-end encrypted:
- X25519 ECDH key agreement from existing ed25519 node identities
- HKDF-SHA256 key derivation with domain separation
- ChaCha20-Poly1305 authenticated encryption per message
- Random 12-byte nonce per message via OsRng (CSPRNG)
- Graceful fallback to plaintext if encryption fails
- Receiver auto-detects encrypted vs plaintext messages

The Tor transport was already encrypted (onion routing), this adds
application-layer E2E encryption so even a compromised receiving
backend can't read messages without the node's private key.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 09:04:43 +00:00
Dorian
f0a403b224 fix: persistent Tor channel messages, bulletproof Tor after deploys
- Messages persisted to disk (messages.json) — survive restarts
- Sent messages stored on backend via node-store-sent RPC
- Message deduplication (same pubkey + message within 30s)
- Max 200 messages in circular buffer
- Direction field (sent/received) for proper UI display
- Container doctor: prefer system Tor, remove archy-tor container
- Deploy torrc generator: read from tor-config/services.json,
  web apps map port 80→local port for clean .onion URLs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 08:26:40 +00:00
Dorian
fc1120338d fix: Tor management system, bug fixes, federation name sync
Major changes:
- Full Tor hidden service management via systemd path unit pattern
  (tor-helper.sh + archipelago-tor-helper.path/service) — respects
  NoNewPrivileges=yes, no sudo needed from backend
- Container doctor: prefer system Tor over container, remove archy-tor
- Deploy script: fix torrc generation (read correct services.json path),
  web apps map port 80→local port, enable both tor and tor@default
- Federation: server rename pushes name to peers via background sync
- Server name: fix root-owned file, optimistic store update
- Mesh: local echo for sent messages, sendingArch loading state
- Web5: Message button → Mesh redirect, node name lookup in messages
- PeerFiles: show DID not onion in header
- Connected Nodes: flex-1 instead of fixed max-h
- Toast notifications route to Mesh
- Deploy script: fix single-quote syntax in SSH block

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 02:59:29 +00:00
Dorian
4c0c8a83a9 chore: session state save — active bugs and outstanding tasks documented
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 23:03:11 +00:00
Dorian
b3949fdcf7 fix: file sharing path, Tor status consistency, Archipelago channel fixes
- ShareModal: strip leading / from filepath (was causing "absolute paths not allowed")
- Server.vue: Tor status in Local Network section now uses same source as header
- Both fixes needed for file sharing and Tor to work consistently

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 22:56:37 +00:00
Dorian
c4853fe746 feat: Archipelago public channel (Tor), FileBrowser auto-login
Public Channel:
- "Archipelago" channel in Mesh — broadcasts to all federation peers over Tor
- Shows received messages from all peers with pubkey label
- Auto-polls every 15s for new messages
- Orange-branded channel icon with unread badge
- Send handler routes to Tor broadcast when arch channel is active

FileBrowser Auto-Login:
- All filebrowser-client methods now call ensureAuth() before requests
- Auto-authenticates with default credentials if not logged in
- Fixes "files don't work when FileBrowser hasn't been logged into"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 22:24:27 +00:00
Dorian
c5417640a2 feat: Lightning channel backup, Web5 mobile tab active, file path fix
Task 14: Lightning Channel Backup
- New lnd.export-channel-backup RPC — exports SCB (Static Channel Backup)
- Settings UI: "Lightning Channel Backup" section with export + copy
- Returns base64 backup data, channel count, timestamp

Web5 mobile tab active state
- Fixed combined tab matching for Web5: includes /web5, /federation, /mesh routes
- Previously only matched /cloud and /server (wrong branch)

Content file path fix
- Allow forward slashes in filenames for subdirectories (Music/song.mp3)
- Still block .., \, null bytes, hidden files, absolute paths

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 21:47:18 +00:00
Dorian
1f732d8d08 fix: persist install progress across page navigation (Task 11)
Marketplace picks up in-progress installs from WebSocket store even
if install was started before page was opened. Removed nested .git.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 21:24:04 +00:00
Dorian
867e56cb84 feat: Federation UI polish — modals, backgrounds, scroll, names, blocked
- Federation page uses bg-web5.jpg background
- Invite code in full-screen modal with type label (Link/Peer)
- Join modal upgraded to full-screen with backdrop blur
- "Untrusted" renamed to "Blocked" in trust selector
- Your Nodes / Peers containers: max-h-[60vh] with inner scroll
- Server name from Settings shown on DID card + network map
- DID sync between Web5 and Federation on rotation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 21:11:11 +00:00
Dorian
203b044646 fix: DID sync between Web5 and Federation, cloud peer names
- Web5 loads node DID from backend on mount (authoritative, survives rotation)
- Federation rotation updates localStorage so Web5 picks up new DID
- Cloud peer names: peerDisplayName() "Node-XXXX" instead of raw DID
- Cloud hides onion addresses from peer cards
- Sync timeout increased to 180s with better error message

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 20:59:42 +00:00
Dorian
d98a2512b7 fix: node names everywhere, cloud peer names, sync timeout 180s
- Federation: nodeName() with Node-XXXX fallback for all views + map + sync results
- Cloud: peerDisplayName() replaces raw DIDs, hides onion addresses
- Sync timeout increased to 180s for Tor-connected nodes
- Better error message when sync fails

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 20:52:39 +00:00
Dorian
93aaeb4abe fix: node names not DIDs, file sharing path validation, sync results
- nodeName() shows friendly "Node-XXXX" instead of truncated DID
- nodeNameFromDid() for sync results lookup
- Map labels use node names
- Content filename validation: allow / for subdirectories (Music/song.mp3)
  but still block .., \, null bytes, hidden files, absolute paths
- Increased filename max length to 512 for paths with subdirectories

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 20:35:41 +00:00
Dorian
12679b77b7 security: observer peers can't see onion address, resources, apps, deploy
- Onion address shows "Not visible to peers" for non-trusted nodes
- Resource usage and app list only shown for trusted nodes
- Deploy app already gated to trusted only
- Backend should also strip data in get-state (future: TASK)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 20:11:09 +00:00
Dorian
781cbf3263 fix: Federation layout — DID card, two-column nodes/peers grid
- DID in glass-card top-right (desktop) / below title (mobile)
- Your Nodes + Peers in two-column grid (lg breakpoint)
- "Remove Dead Nodes" button for unreachable peers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 20:00:59 +00:00
Dorian
f1d9ecc392 feat: Federation & Peers — split nodes/peers, invite types, cleanup dead nodes
- Page title: "Federation & Peers"
- "Link Your Nodes" generates trusted invite, "Invite a Peer" generates observer invite
- "Your Nodes" section shows trusted nodes, "Peers" section shows observer/untrusted
- "Remove Dead Nodes" button cleans up unreachable nodes with no last_seen
- DID in header with "Copied!" feedback
- Node count in section headers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 19:56:24 +00:00
Dorian
973beb887a fix: Federation UI — title, DID in header, copy feedback, node count
- Title: "Federation & Peers"
- Your Node DID moved to top-right header row (desktop), below title (mobile)
- Copy button shows "Copied!" feedback for 2 seconds
- Removed "X federated nodes" from description, added count to section header
- Rotate button compact in header

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 19:44:54 +00:00
Dorian
cf184661d9 feat: DID management UI in Federation — rotate DID + notify peers
- "My Node Identity" card shows DID with copy button
- "Rotate DID" button opens modal with password confirmation
- Rotation generates new keypair, then auto-notifies all federation peers
- Shows success/failure count after notification

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 19:31:03 +00:00
Dorian
1a138c0409 feat: DID rotation + federation peer notification (Part 3)
- node.rotate-did: generates new Ed25519 keypair, signs rotation proof
  with old key, overwrites identity files, requires password
- federation.notify-did-change: broadcasts rotation proof to all
  trusted/observer peers over Tor
- federation.peer-did-changed: receiving side verifies rotation proof
  against known pubkey before updating peer's DID
- Rate-limited: 3/600s for rotation, 5/60s for peer notification
- Signature verification uses ed25519_dalek (constant-time)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 19:27:16 +00:00
Dorian
f8794791f3 feat: DID persistence + federation node names in sync
Part 1 — DID Persistence:
- Deploy script creates /var/lib/archipelago/identity/ directory
- First-boot script creates identity dir with proper ownership
- Identity load now logs pubkey to confirm persistence across restarts

Part 2 — Node Names:
- NodeStateSnapshot includes node_name field
- build_local_state() passes server name to sync responses
- update_node_state() stores peer's announced name on the FederatedNode
- Names propagate automatically during federation.sync-state

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 19:19:13 +00:00
Dorian
f8eefa87d2 fix: AIUI chat page uses bg-aiui.jpg background
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:21:15 +00:00
Dorian
96d722ed0f fix: hide dwn from My Apps (backend service, not user app)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:05:22 +00:00
Dorian
42a1526b70 fix: hide infrastructure containers from My Apps, orange glass hover on App Store cards
- Task 13: added archy-* prefix containers, mempool-api, UI containers
  to SERVICE_NAMES filter — removes empty squares from My Apps grid
- Task 12: App Store card hover changed from white/10 to orange-500/5
  with orange border glow (subtle, not severe)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 17:54:26 +00:00
Dorian
86df0bcaf2 fix: LND Connect bulletproof — CORS, credentials, memory limits, restart policy
Ensures LND Connect works through every deployment path:
- Nginx: CORS $http_origin on /lnd-connect-info (both HTTP+HTTPS)
- Nginx: no cookie gate (backend is 127.0.0.1-only)
- LND UI source: fetch with credentials: 'include'
- Deploy: rebuilds LND UI with --no-cache every deploy
- First-boot: --restart unless-stopped + memory limits on UI containers
- Backend: bound to 127.0.0.1:5678 in systemd service

Root cause was CORS: LND UI on :8081 fetching :80 is cross-origin.
Browser blocked reading the 200 response without CORS headers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 17:17:14 +00:00
Dorian
9fe680def1 fix: CORS headers on /lnd-connect-info for cross-origin LND UI fetch
The LND UI runs on port 8081 (separate nginx container) but fetches
/lnd-connect-info from port 80. This is cross-origin, so browsers
block reading the response without CORS headers. Added dynamic
Access-Control-Allow-Origin from $http_origin.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 17:11:40 +00:00
Dorian
9e15444228 fix: LND Connect — remove nginx cookie gate, rebuild LND UI with credentials
- Nginx cookie check removed for /lnd-connect-info — backend is
  localhost-only so no external access possible. Browsers (especially
  Brave) don't reliably send SameSite=Lax cookies from iframe fetches.
- LND UI source restored from archive with credentials: 'include'
- Discover.vue install banner removed (inline card progress only)
- Server.vue: Connectivity → Tor Status, using tor.list-services

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 17:02:17 +00:00
Dorian
c78a123e9c fix: Tor Status label (was Connectivity), remove Discover install banner
- Server.vue: "Connectivity" → "Tor Status" with tor.list-services check
- Discover.vue: removed full-width install progress banner (progress shown inline on cards)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 16:44:46 +00:00
Dorian
ca65a8172c feat: Tor status + cleanup, Tailscale admin, marketplace install UX
- Task 0: Tor status dot (green/red) + "Cleanup Old" rotated services button
- Task 2: BTCPay already handled (opens new tab)
- Task 3: Tailscale launches https://login.tailscale.com/admin/machines in new tab
- Task 8: Marketplace install shows inline progress on card (removed banner)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 16:38:11 +00:00
Dorian
f20f0650cf feat: Discover view, Fleet dashboard, MeshMap, type fixes
- New Discover.vue (app store redesign)
- Fleet.vue dashboard for .228
- MeshMap.vue component
- Fixed Discover.vue type errors (unused var, type predicate)
- Various UI updates (Apps, Dashboard, Marketplace, Mesh, Web5)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 16:12:01 +00:00
Dorian
9b4aa712f2 docs: add post-pentest security standards to CLAUDE.md
Mandatory rules for all new code based on 33 pentest findings.
Covers: input validation, auth checks, SSRF prevention, session
management, CSP, nginx config, container security, RBAC.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 16:04:21 +00:00
Dorian
e574b6dd18 fix: SameSite=Strict → Lax for session cookies (fixes iframe fetch)
SameSite=Strict prevents cookies from being sent when iframe content
(like the LND UI at /app/lnd/) fetches endpoints on the parent origin
(/lnd-connect-info). Lax still protects against CSRF on POST requests
but allows same-site GET navigations and fetches from iframes.

This was the root cause of "Failed to fetch" on LND Connect.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 15:30:58 +00:00
Dorian
6033199864 fix: remove backend auth check on /lnd-connect-info (nginx validates session)
Backend is bound to 127.0.0.1 — only nginx can reach it.
Nginx checks cookie_session presence. Adding backend auth broke
the LND UI iframe fetch because the session validation was too
strict for the cross-proxy cookie flow. The nginx layer is the
correct auth gate for this endpoint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 15:20:44 +00:00
Dorian
5e19a80f9d feat: add Discover page — cypherpunk app store with sovereignty messaging
- New Discover.vue with hero banner, featured sovereignty stack apps,
  principle cards, manifesto footer, and full app grid
- Featured apps (Bitcoin Knots, LND, BTCPay, Vaultwarden) with
  expanded privacy/sovereignty descriptions
- Discover is first tab in categories bar on App Store pages
- Smart back navigation: detail pages return to Discover when navigated from there
- Category clicks from Discover navigate to Marketplace with category pre-selected
- Cypherpunk aesthetic: terminal tags, scanline overlays, gradient accents,
  animated Bitcoin orange headings
- Global CSS classes: discover-hero, discover-terminal-tag, discover-featured-card,
  discover-principle-card, discover-manifesto
- Route added: /dashboard/discover

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 15:14:12 +00:00
Dorian
aabeb2e679 security: add is_authenticated check to /lnd-connect-info backend handler (AUTH-011)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 14:58:16 +00:00
Dorian
e8674a3801 fix: iframe auto-retry for apps still starting + retry button
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 14:52:16 +00:00
Dorian
ba6a0e6fe6 fix: deploy fixes secrets dir ownership (was root-only, backend couldn't read)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 14:07:13 +00:00
Dorian
f292ebf63e fix: ElectrumX status uses headers.subscribe (returns height correctly)
The previous blockchain.numblocks.subscribe call returned data in a
format the parser couldn't extract height from. headers.subscribe
returns {height: N, hex: "..."} which is properly parsed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 13:51:03 +00:00
Dorian
1dfceeb957 fix: deploy auto-fixes root-owned config files + dead man's switch permissions
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 13:04:50 +00:00
Dorian
c037db9d42 fix: What's New v1.3.0, backend bind 127.0.0.1 in deploy + systemd, dead man's switch permissions
- Added v1.3.0 release notes to Settings "What's New" modal
- Deploy script now auto-fixes backend bind address (0.0.0.0 → 127.0.0.1)
- All image-recipe systemd/service files updated to 127.0.0.1
- Fixed dead man's switch: alert-config.json owned by root, now chown'd
- Removed unused toggleAutoSync function (build error)
- Deploy script adds LND REST port 8080 to Tor config generation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 12:55:31 +00:00
Dorian
1a74a930f7 security+feat: v1.3.0 — pentest remediation, container reliability, UI overhaul
Security (33 pentest findings addressed):
- CRITICAL: backend binds 127.0.0.1, path traversal in tor.rs/dwn fixed
- HIGH: federation requires signatures, XSS login redirect, RBAC viewer restricted
- HIGH: tar slip prevention, S3 SSRF validation, backup ID validation
- MEDIUM: remember-me random secret, TOTP session rotation, password re-auth
- LOW: CSP unsafe-inline removed, CORS dev-only, onion/webhook validation

Container reliability:
- Memory limits on all 37 containers (OOM prevention)
- Exited vs stopped state distinction with health-aware status badges
- Crash recovery coordination (no more restart cascade)
- User-stopped tracking survives reboots
- Tiered boot recovery (databases → core → services → apps)

UI:
- Wallet TransactionsModal, health-aware app status badges
- Restart button on containers, exited/crashed red state
- Mesh view overhaul, glass button updates, BaseModal/ToggleSwitch
- Apps sticky header removed, dev faucet, mutable mock wallet

Infrastructure:
- LND REST port 8080 exposed over Tor (LND Connect fix)
- Nginx cookie_session fix, deploy script Tor config updated
- Dev environment: podman auto-start, boot mode simulation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 12:44:31 +00:00
Dorian
d1b48388fb fix: add QR codes to Home wallet receive modal
ReceiveBitcoinModal was missing QR code generation that Web5.vue has.
Added canvas refs + qrcode rendering for both on-chain (bitcoin: URI)
and lightning (lightning: URI) receive flows. Matches Web5 pattern.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 00:18:41 +00:00
Dorian
8c800525c0 fix: deploy auto-fixes stale LND config (rpchost + rpcpass)
LND was crash-looping because lnd.conf had 127.0.0.1:8332 (container
loopback, not reachable) and the old hardcoded password. Deploy script
now detects stale values and patches them to bitcoin-knots:8332 with
the current secrets file password. Fixes address generation failure.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 00:09:15 +00:00
Dorian
aad98dec08 fix: telemetry reporter field name cpu_percent, add type annotation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 23:53:17 +00:00
Dorian
a9bb5a28ce chore: mark TASK-17 and BUG-3 done in MASTER_PLAN
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 23:50:49 +00:00
Dorian
7cb4fd6812 feat(TASK-17): deploy auto-tag + BUG-3 IndeedHub WS fix
TASK-17: Deploy script auto-tags successful clean deploys with next
alpha version number. Skips if commit already tagged or working tree
is dirty.

BUG-3: Updated IndeedHub submodule — removed dead nostrConfig with
hardcoded ws://localhost:7777 that caused WebSocket reconnection spam
in browser console. Relay detection via relay.ts (auto-detect /relay
proxy) is the active path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 23:46:51 +00:00
Dorian
75018da1da chore: update TASK-12 status in MASTER_PLAN
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 23:38:47 +00:00
Dorian
41ab499698 feat(TASK-12): periodic telemetry reporter — 15min interval, collector POST
Background task spawned on server startup: every 15 minutes, checks opt-in
status, builds anonymous health report (node ID hash, version, uptime,
CPU/RAM/disk %, container states, recent alerts), saves to disk, and POSTs
to TELEMETRY_COLLECTOR_URL env var if configured. Non-fatal on failure.

Fixed FiredAlert field references (kind not rule_type, timestamp not
fired_at) in both monitoring and analytics modules.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 23:36:57 +00:00
Dorian
b8afb10ec6 test: fix 5 appLauncher tests for panel mode, 515/515 passing
Tests expected router.push but panel mode (now default) uses panelAppId
store state instead. Updated assertions to check panelAppId. Fixed
BTCPay app ID from 'btcpay' to 'btcpay-server'. All 515 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 23:27:26 +00:00
Dorian
165972e75c feat(TASK-12): beta telemetry — report endpoint + settings toggle
Backend: telemetry.report RPC builds anonymous health report with node ID
(SHA-256 hash of pubkey, truncated), version, uptime, container states,
CPU/RAM, federation peers, and recent alerts. Saves latest report to disk.
Requires analytics opt-in (existing analytics.enable/disable flow).

Frontend: "Beta Telemetry" section in Settings with enable/disable toggle.
Shows what data is and isn't collected. Mock backend handles all analytics
and telemetry RPCs.

Privacy: No wallet data, no private keys, no DIDs, no IP addresses.
Node identified by truncated hash only.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 23:14:47 +00:00
Dorian
b7edada7fe chore: health endpoint JSON, BETA-PROGRESS updated to ~55%
Health endpoint now returns JSON with version and service status instead
of plain "OK". Updated BETA-PROGRESS.md: BUG-1 done, TASK-8 done (12/12
+ code audit), FEATURE-4 at ~80%, overall at ~55%. Added session #5 log.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:57:29 +00:00
Dorian
a2bf51615f feat: What's New modal with full alpha release history
Replaced single hardcoded release note with scrollable history of all
alpha releases (alpha.1 through alpha.9). Each release has version badge,
date, and categorized highlights. Inner container scrolls independently
with max-height 85vh. Current release highlighted with orange badge,
older releases in muted style with left border timeline.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:53:58 +00:00
Dorian
adcc3fddc7 security: migrate bcrypt→Argon2id, random Bitcoin RPC password
Password hashing migrated from bcrypt to Argon2id (m=64MiB, t=3, p=4).
Transparent upgrade: on successful bcrypt login, re-hashes with Argon2id
and persists. New signups and password changes use Argon2id directly.
Unifies crypto stack — Argon2id was already used for TOTP and backup KDF.

Bitcoin RPC password: no longer falls back to hardcoded "archipelago123".
On first boot, generates a random 32-char hex password from CSPRNG,
saves to /var/lib/archipelago/secrets/bitcoin-rpc-password with 0600
permissions. Existing installs with secrets file are unaffected.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:41:23 +00:00
Dorian
7bbd8f889a security: RBAC viewer role, identity label length, error sanitization
- RBAC: Viewer role changed from prefix "system." to explicit allowlist
  of safe read-only methods. Prevents Viewer access to system.factory-reset,
  system.shutdown, system.reboot, system.disk-cleanup.
- identity.create: Name/label param now enforces max 100 chars.
- sanitize_error_message: Changed from contains() to starts_with() for
  prefix matching, preventing internal errors that happen to contain
  user-facing keywords from leaking through.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:37:08 +00:00
Dorian
12412c70db feat: TASK-31 nav header cleanup, TASK-38 Bitcoin sync gauge on homepage
TASK-31: Cleaned up Apps page nav header structure (tabs + categories + search).
TASK-38: Added Bitcoin Core sync progress gauge to homepage System Stats card —
shows sync percentage, block height, and green/orange color coding. Only
appears when Bitcoin is running. Grid expands to 4 columns when visible.

Updated MASTER_PLAN.md — cleaned up completed sections, moved done items.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:22:39 +00:00
Dorian
41ff1021ad fix: BUG-1 CSRF, TASK-8 H2/H3/H4, BUG-20/37/40/41 — 7 bugs fixed
BUG-1 (P0): CSRF tokens now HMAC-derived from session token instead of
random — survives backend restarts, eliminates cookie/header race conditions.
Frontend retries 403s as belt-and-suspenders.

TASK-8 H2: federation.peer-joined verifies ed25519 signature on join messages.
TASK-8 H3: federation.peer-address-changed requires signed proof from known peer.
TASK-8 H4: Rust backend default bind 0.0.0.0 → 127.0.0.1 (nginx proxies all).

BUG-20: ElectrumX index estimate string fixed from ~55GB to ~130GB.
BUG-37: App card Start/Stop buttons split into loading vs interactive states
        to prevent WebSocket state flicker during container scans.
BUG-40: Uninstall modal uses Teleport to body with z-[3000] for full overlay.
BUG-41: Uninstalling overlay on card + optimistic store removal.

Updated MASTER_PLAN.md and BETA-PROGRESS.md to reflect all completed work.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:05:21 +00:00
Dorian
00bfd62393 chore: dev environment — signet testnet stack, mock LND RPCs, faucet button
Switch docker-compose from regtest to signet, add standalone testnet stack
(docker-compose.testnet.yml) with Bitcoin+LND+ThunderHub+Fedimint. Mock
backend now auto-detects Podman/Docker sockets and includes full LND/Lightning
RPC mocks. Dev scripts refactored with boot mode, testnet option, and macOS
EAGAIN fix for port cleanup. Added dev faucet button to Home.vue.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 21:06:14 +00:00
Dorian
a6f1ab8d53 docs: session resume guide for 2026-03-18
Full context for resuming: rootless podman migration, security
hardening, .198 container creation needed, remaining tasks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 20:42:18 +00:00
372 changed files with 48465 additions and 26935 deletions

View File

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

View 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

View 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

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

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

View 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

View 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

View 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

View File

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

View File

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

View File

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

View File

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

@@ -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.
[![Debian 12](https://img.shields.io/badge/Debian-12%20Bookworm-a80030)](https://www.debian.org/)
[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
[![Rust](https://img.shields.io/badge/rust-stable-orange)](https://www.rust-lang.org/)
[![Vue.js](https://img.shields.io/badge/vue.js-3.5-brightgreen)](https://vuejs.org/)
[![Version](https://img.shields.io/badge/version-1.0.0-blue)]()
[![Version](https://img.shields.io/badge/version-1.3.1--beta-blue)]()
## 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

View File

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

View File

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

@@ -147,6 +147,7 @@ dependencies = [
"async-trait",
"chrono",
"futures",
"hyper 0.14.32",
"indexmap",
"log",
"reqwest",

View File

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

View File

@@ -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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#x27;")
}
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())
}
}

View 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")))
}
}
}
}

View 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)))
}
}

View 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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#x27;")
}

View 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}"#)))
}
}

View 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())
}
}
}
}

View 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)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View 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"),
}))
}
}

View File

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

View 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(())
}

View File

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

View File

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

View 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(())
}

View File

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

View 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 }))
}
}

View 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(),
}))
}
}

View 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))
}
}

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

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

View File

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

View File

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

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

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

View File

@@ -0,0 +1,5 @@
mod bitcoin_ops;
mod messaging;
mod safety;
mod status;
mod typed_messages;

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

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

View 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(),
}))
}
}

View 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

View File

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

View File

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

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

View 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(),
]);
}
}

View 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");
});
}
}
}

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

View 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;

View 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)
}

View 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 }))
}
}

View 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"
}))
}
}

View 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(())
}

View File

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

View 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(""))
}

View 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" }))
}
}

View File

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

View File

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

View 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 }))
}
}

View 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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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";

View File

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

View File

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

View File

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

View File

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

View File

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

View 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};

View 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);
}
}

View 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());
}
}

View 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());
}
}

View 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());
}
}

View File

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

View File

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

View File

@@ -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", &timestamp)
.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", &timestamp)
.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));
}
}

View 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());
}
}

View 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};

View 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));
}
}

View 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", &timestamp)
.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", &timestamp)
.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()));
}
}

View 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());
}
}

View File

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