bug fixing and deploy and build diagnostics

This commit is contained in:
Dorian 2026-03-22 03:30:21 +00:00
parent 1f8287c4c3
commit e4e0ef4f11
198 changed files with 21703 additions and 19587 deletions

View File

@ -28,6 +28,9 @@
- [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
## 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

@ -1,49 +1,44 @@
---
name: v1.3.0 Session Status (March 19 late)
description: Massive session — 33 pentest fixes, container reliability, federation, mesh channel, 30+ commits
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
- All 33 pentest security fixes (backend + frontend + nginx)
- Container reliability: memory limits in scripts, crash recovery coordination, health badges
- Federation & Peers: DID persistence, rotation, node names, two-column layout, invite types
- Archipelago public channel in Mesh (Tor messaging)
- LND Connect with CORS fix (bulletproof)
- ElectrumX headers.subscribe fix
- FileBrowser auto-login
- Lightning channel backup export
- App iframe auto-retry
- Install progress persists across navigation
- 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
### Active Bugs (fix next session)
1. **Archipelago channel**: sent messages don't show to sender (no local echo), .228 says "no peers found"
2. **Web5 Send Message modal**: should redirect to Mesh chat, not show its own modal
3. **Cloud peer files**: "Operation failed" when browsing .198 files from .228 — Tor connection issue
4. **Server name save**: not persisting — no `server-name.txt` on server
5. **Node names**: still showing DIDs in some places (cloud peer header, some federation contexts)
6. **Tor**: ControlPort 0 fix applied manually but needs to be in deploy script/torrc generation
7. **Connected Nodes container**: not filling height, needs max-height fix in Web5 view
### 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
### Outstanding Tasks
- Tor restart button in Network UI
- Auto-restart Tor when features fail
- ISO build for alpha tester
- Deploy to Tailscale nodes (Arch 1/2/3)
- .198 stabilization (containers, memory limits)
- Container memory limits recreation on existing servers
- Meshcore public channel investigation (radio messages not showing)
- AIUI API key settings
- Message notification → open Mesh chat (not Web5)
- Loading state on Archipelago channel send ("Decentralization takes a sec")
### Onion Addresses (current)
- .228 archipelago: r33p5uzk2vxhdte4a5pfqgeax44a7b2lx57q32dxmx5llzyfz42lwnyd.onion
- .198 archipelago: mxn62m4odavwctlpsq2ozvhy3ibjpenlzemumwtkev7wviikttxvjhyd.onion
### Deploy Notes
- Backend binary: atomic swap via `cp -new` + `mv`
- Tor fix: remove `ControlPort 0` from torrc, chown debian-tor
- LND UI: rebuild with `--no-cache` for CORS credentials fix
- Always sync: frontend, nginx config, docker UIs, scripts, core source
### 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 the massive v1.3.0 effort.
**How to apply:** Read at start of next session. Fix active bugs first, then ISO build.
**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.

File diff suppressed because it is too large Load Diff

View File

@ -2,39 +2,53 @@
> 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-0.1.0--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)
### Security
- AES-256-GCM encrypted secrets at rest
- Container isolation: read-only root, capability dropping, non-root user
- ChaCha20-Poly1305 encrypted secrets at rest, Argon2 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
- Full penetration test completed (33 findings, all remediated)
## Quick Start
@ -59,26 +73,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 +99,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

@ -1,916 +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().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()),
}
}
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>,
#[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(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());
}
}
}
// 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(Response::builder()
.status(StatusCode::BAD_REQUEST)
.header("Content-Type", "application/json")
.body(hyper::Body::from(r#"{"error":"Decryption failed"}"#))
.unwrap());
}
}
}
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(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

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

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::{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> {
@ -150,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
@ -197,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> {
@ -218,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> {
@ -250,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() {
@ -312,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
@ -338,7 +324,7 @@ impl RpcHandler {
/// federation.peer-joined — Called by a remote peer after they accept our invite.
/// Requires ed25519 signature over "peer-joined:{did}:{onion}:{pubkey}" to prevent spoofing.
pub(super) async fn handle_federation_peer_joined(
pub(in crate::api::rpc) async fn handle_federation_peer_joined(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
@ -400,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> {
@ -451,7 +437,7 @@ impl RpcHandler {
/// federation.peer-address-changed — A peer notifies us that their .onion changed.
/// Requires ed25519 signature over "address-changed:{did}:{new_onion}" using the
/// peer's known pubkey. This prevents attackers from redirecting federation traffic.
pub(super) async fn handle_federation_peer_address_changed(
pub(in crate::api::rpc) async fn handle_federation_peer_address_changed(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
@ -508,7 +494,7 @@ 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(super) async fn handle_federation_notify_did_change(
pub(in crate::api::rpc) async fn handle_federation_notify_did_change(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
@ -614,7 +600,7 @@ impl RpcHandler {
/// 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(super) async fn handle_federation_peer_did_changed(
pub(in crate::api::rpc) async fn handle_federation_peer_did_changed(
&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_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

@ -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> {
@ -93,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> {
@ -123,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> {
@ -141,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> {
@ -159,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> {
@ -185,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> {
@ -211,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> {
@ -243,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> {
@ -315,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> {
@ -345,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> {
@ -429,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> {
@ -447,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> {
@ -465,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> {
@ -483,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> {
@ -502,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> {
@ -578,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> {
@ -604,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> {
@ -627,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> {
@ -655,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> {
@ -681,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> {
@ -705,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> {
@ -743,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,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

@ -39,7 +39,8 @@ use crate::config::Config;
use crate::container::DevContainerOrchestrator;
use crate::monitoring::MetricsStore;
use crate::port_allocator::PortAllocator;
use crate::session::{self, EndpointRateLimiter, LoginRateLimiter, SessionStore, REMEMBER_TTL};
use crate::rate_limit::{EndpointRateLimiter, LoginRateLimiter};
use crate::session::{self, SessionStore, REMEMBER_TTL};
use crate::state::StateManager;
use anyhow::{Context, Result};
use hyper::{Request, Response, StatusCode};
@ -50,7 +51,7 @@ use middleware::{
UNAUTHENTICATED_METHODS, CACHEABLE_METHODS,
derive_csrf_token, extract_client_ip, extract_cookie, sanitize_error_message,
};
use response::{RpcRequest, RpcResponse, RpcError, ResponseCache};
use response::{RpcRequest, RpcResponse, RpcError, ResponseCache, json_response, cookie_header};
/// Default dev password when no user is set up (matches mock-backend).
pub(crate) const DEV_DEFAULT_PASSWORD: &str = "password123";
@ -218,7 +219,7 @@ impl RpcHandler {
let secret = SessionStore::load_or_create_remember_secret().await;
let mut mac = match HmacSha256::new_from_slice(&secret) {
Ok(m) => m,
Err(_) => { return Ok(Response::builder().status(500).body(hyper::Body::empty()).unwrap()); }
Err(_) => { return Ok(json_response(StatusCode::INTERNAL_SERVER_ERROR, b"{}")); }
};
mac.update(format!("csrf:{}", token).as_bytes());
match hex::decode(header) {
@ -273,11 +274,8 @@ impl RpcHandler {
result: Some(cached),
error: None,
};
return Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.body(hyper::Body::from(serde_json::to_string(&rpc_resp)?))
.unwrap());
let body = serde_json::to_vec(&rpc_resp)?;
return Ok(json_response(StatusCode::OK, &body));
}
}
@ -317,11 +315,7 @@ impl RpcHandler {
let resp_body = serde_json::to_vec(&rpc_resp)
.context("Failed to serialize response")?;
let mut response = Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.body(hyper::Body::from(resp_body))
.unwrap();
let mut response = json_response(StatusCode::OK, &resp_body);
// Post-dispatch: set cookies for auth-related methods
let client_ip = extract_client_ip(&parts.headers);
@ -349,11 +343,7 @@ impl RpcHandler {
}),
};
let resp_body = serde_json::to_vec(&rpc_resp).unwrap_or_default();
Response::builder()
.status(status)
.header("Content-Type", "application/json")
.body(hyper::Body::from(resp_body))
.unwrap()
json_response(status, &resp_body)
}
/// Build a 429 Too Many Requests response.
@ -367,12 +357,9 @@ impl RpcHandler {
}),
};
let resp_body = serde_json::to_vec(&rpc_resp).unwrap_or_default();
Response::builder()
.status(StatusCode::TOO_MANY_REQUESTS)
.header("Content-Type", "application/json")
.header("Retry-After", "60")
.body(hyper::Body::from(resp_body))
.unwrap()
let mut resp = json_response(StatusCode::TOO_MANY_REQUESTS, &resp_body);
resp.headers_mut().insert("Retry-After", cookie_header("60"));
resp
}
/// Apply session/CSRF/remember-me cookies after dispatch for auth-related methods.
@ -471,15 +458,11 @@ impl RpcHandler {
let secure_suffix = if self.config.dev_mode { "" } else { "; Secure" };
response.headers_mut().append(
"Set-Cookie",
format!("session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0{}", secure_suffix)
.parse()
.unwrap(),
cookie_header(&format!("session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0{}", secure_suffix)),
);
response.headers_mut().append(
"Set-Cookie",
format!("csrf_token=; SameSite=Lax; Path=/; Max-Age=0{}", secure_suffix)
.parse()
.unwrap(),
cookie_header(&format!("csrf_token=; SameSite=Lax; Path=/; Max-Age=0{}", secure_suffix)),
);
}
@ -493,27 +476,21 @@ impl RpcHandler {
fn set_session_cookie(&self, response: &mut Response<hyper::Body>, token: &str) {
response.headers_mut().append(
"Set-Cookie",
format!("session={}; HttpOnly; SameSite=Lax; Path=/{}", token, self.cookie_suffix())
.parse()
.unwrap(),
cookie_header(&format!("session={}; HttpOnly; SameSite=Lax; Path=/{}", token, self.cookie_suffix())),
);
}
fn set_csrf_cookie(&self, response: &mut Response<hyper::Body>, csrf_token: &str) {
response.headers_mut().append(
"Set-Cookie",
format!("csrf_token={}; SameSite=Lax; Path=/{}", csrf_token, self.cookie_suffix())
.parse()
.unwrap(),
cookie_header(&format!("csrf_token={}; SameSite=Lax; Path=/{}", csrf_token, self.cookie_suffix())),
);
}
fn set_remember_cookie(&self, response: &mut Response<hyper::Body>, remember_token: &str) {
response.headers_mut().append(
"Set-Cookie",
format!("remember={}; HttpOnly; SameSite=Lax; Path=/; Max-Age={}{}", remember_token, REMEMBER_TTL, self.cookie_suffix())
.parse()
.unwrap(),
cookie_header(&format!("remember={}; HttpOnly; SameSite=Lax; Path=/; Max-Age={}{}", remember_token, REMEMBER_TTL, self.cookie_suffix())),
);
}
}

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

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,10 @@
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)

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 = [
"ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0",
"docker.io/valkey/valkey:7-alpine",
"ghcr.io/immich-app/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",
"ghcr.io/immich-app/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",
"docker.io/valkey/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",
"ghcr.io/immich-app/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 = [
"docker.io/postgres:15",
"docker.io/valkey/valkey:8.1",
"docker.io/penpotapp/backend:2.4",
"docker.io/penpotapp/exporter:2.4",
"docker.io/penpotapp/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",
"docker.io/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",
"docker.io/valkey/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",
"docker.io/penpotapp/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",
"docker.io/penpotapp/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",
"docker.io/penpotapp/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

@ -1,3 +1,4 @@
use hyper::{Response, StatusCode};
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize)]
@ -48,3 +49,19 @@ impl ResponseCache {
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,189 +1,10 @@
use super::RpcHandler;
mod handlers;
use anyhow::{Context, Result};
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();
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(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)),
}
// 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,
}))
}
}
/// Push the server name to all federation peers by syncing state.
async fn push_name_to_peers(
pub(super) async fn push_name_to_peers(
data_dir: &std::path::Path,
state_manager: &std::sync::Arc<crate::state::StateManager>,
) -> Result<()> {
@ -221,7 +42,7 @@ async fn push_name_to_peers(
}
/// 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")?;
@ -235,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")?;
@ -259,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?;
@ -304,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")?;
@ -325,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()
@ -335,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()
@ -368,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()
@ -410,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());
@ -472,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()
@ -493,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"])
@ -514,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",
@ -554,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"] {
@ -582,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;
@ -599,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() {
@ -638,135 +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).
/// system.reboot — Reboot the machine. Requires password re-verification.
pub(super) 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(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 }}");
}
// 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,833 +0,0 @@
use super::RpcHandler;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
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,
/// True = direct port access without auth. False = through nginx with login.
unauthenticated: bool,
/// True if this is a protocol service (not HTTP), auth not applicable.
protocol: 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)]
remote_port: Option<u16>,
#[serde(default = "default_true")]
enabled: bool,
/// If true, routes directly to app port (no nginx auth).
/// Default false = routes through nginx with session auth.
#[serde(default)]
unauthenticated: 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?;
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(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 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)?;
// Auto-detect port from known services if not specified
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));
}
// Protocol services are inherently unauthenticated (not HTTP).
// Web apps default to authenticated (routed through nginx).
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 and restart Tor so the new service is live
regenerate_torrc(&config).await?;
restart_tor().await?;
// Wait for the hostname to appear
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(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"))?;
validate_service_name(name)?;
// Don't allow deleting the node's own service
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?;
// Remove hidden service directory via helper
delete_hidden_service_dir(name).await;
// Regenerate torrc and restart Tor
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(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"))?;
validate_service_name(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.
/// Renames the old hidden service directory (preserving keys during transition),
/// lets Tor create a new one with fresh keys, then schedules cleanup of the old
/// directory after 1 hour.
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"))?;
validate_service_name(name)?;
let old_onion = read_onion_address(name);
if old_onion.is_none() {
return Err(anyhow::anyhow!("Service '{}' has no .onion address to rotate", name));
}
// Rename old service directory to a timestamped backup instead of deleting
// immediately. The cleanup handler removes these after ROTATION_TRANSITION_SECS.
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"
);
// Tor restart will create a fresh hidden_service_{name} directory with new keys
restart_tor().await?;
// Wait up to 60s for new hostname file to appear
let new_onion = wait_for_hostname(name, 60).await;
if let Some(ref new_addr) = new_onion {
sync_single_hostname(name, new_addr).await;
}
// Schedule deletion of old service directory after 1 hour transition period
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;
});
// 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 = 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(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;
}
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"))?;
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 and restart Tor
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(super) async fn handle_tor_restart(
&self,
) -> Result<serde_json::Value> {
info!("Manual Tor restart requested");
// Before restarting, sync the torrc from services.json
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?;
// Give Tor a moment to stabilize, then sync all hostnames
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 }))
}
}
// ─── Validation ───────────────────────────────────────────────────
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 ──────────────────────────────────────────
//
// The backend runs under NoNewPrivileges=yes (systemd hardening), so it
// cannot call sudo. Privileged Tor operations use an action-file pattern:
//
// 1. Backend writes JSON to /var/lib/archipelago/tor-config/tor-action
// 2. systemd path unit (archipelago-tor-helper.path) detects the file
// 3. systemd oneshot service runs tor-helper.sh as root
// 4. tor-helper.sh processes the action and writes a result file
// 5. Backend polls for the result file
//
// Install: deploy script creates the path unit, service unit, and helper script.
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.
async fn dispatch_tor_action(action: serde_json::Value) -> Result<()> {
// Remove stale result
let _ = tokio::fs::remove_file(TOR_RESULT_FILE).await;
// Write the action file (triggers the systemd path unit)
let content = serde_json::to_string(&action).context("Failed to serialize tor action")?;
let config_dir = Path::new(TOR_ACTION_FILE).parent().unwrap();
tokio::fs::create_dir_all(config_dir).await.ok();
tokio::fs::write(TOR_ACTION_FILE, &content)
.await
.context("Failed to write tor-action file")?;
// Poll for result (up to 90s — Tor restart + hostname generation can be slow)
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?"))
}
/// Delete a hidden service directory via tor-helper.
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);
}
}
/// Rename a hidden service directory to a timestamped backup via tor-helper.
/// The old directory becomes `hidden_service_{name}_old_{timestamp}`.
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);
}
}
/// Write staged torrc and restart Tor.
async fn restart_tor() -> Result<()> {
dispatch_tor_action(serde_json::json!({
"action": "write-torrc-and-restart",
})).await
}
/// Check if Tor SOCKS port is responding.
async fn check_tor_running() -> bool {
tokio::net::TcpStream::connect("127.0.0.1:9050")
.await
.is_ok()
}
// ─── torrc Generation ────────────────────────────────────────────
/// Detect which base directory has existing hidden services.
fn detect_hidden_service_base() -> String {
// Check /var/lib/tor first (system Tor default, AppArmor-safe)
if Path::new("/var/lib/tor/hidden_service_archipelago").exists() {
return "/var/lib/tor".to_string();
}
// Fall back to custom dir
let custom = tor_data_dir();
if Path::new(&custom).join("hidden_service_archipelago").exists() {
return custom;
}
// Default to system Tor
"/var/lib/tor".to_string()
}
/// Regenerate /etc/tor/torrc from services config.
/// Only enabled services get HiddenServiceDir entries.
/// Uses a helper script to write to root-owned paths (avoids NoNewPrivileges issues).
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) {
// Protocol: direct port mapping (Bitcoin P2P, LND, Electrumx)
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 {
// Web app: map port 80 → local port (access via app.onion without specifying port)
lines.push(format!("HiddenServicePort 80 127.0.0.1:{}", svc.local_port));
}
lines.push(String::new());
}
let content = lines.join("\n");
// Write to staging file (owned by archipelago user, readable by tor-helper)
let staging = "/var/lib/archipelago/tor-config/torrc.staged";
let config_dir = Path::new(staging).parent().unwrap();
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 ───────────────────────────────────────────────
/// Copy a single hostname to the readable tor-hostnames directory.
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);
}
}
/// Sync all hostname copies from hidden service dirs.
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) {
sync_single_hostname(&svc.name, &addr).await;
}
}
}
// ─── Service Listing ─────────────────────────────────────────────
/// 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 = 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);
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),
});
}
// Scan filesystem for any hidden_service_* dirs not in config
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_")
&& !name.contains("_old_")
&& 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());
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 services are inherently unauthenticated
protocol: is_proto,
});
}
}
}
}
Ok(services)
}
// ─── Onion Address Reading ───────────────────────────────────────
/// 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> {
// Try readable hostname copy first (system Tor owns hidden_service dirs at 0700)
let hostnames_path = Path::new("/var/lib/archipelago/tor-hostnames").join(service_name);
if let Some(addr) = read_and_validate_onion(&hostnames_path) {
return Some(addr);
}
// Check both /var/lib/tor/ and /var/lib/archipelago/tor/
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");
// Try direct read first
if let Some(addr) = read_and_validate_onion(&path) {
return Some(addr);
}
// Try sudo cat (hidden service dirs are 0700 owned by debian-tor)
if let Some(addr) = sudo_read_and_validate_onion(&path) {
return Some(addr);
}
}
None
}
fn read_and_validate_onion(path: &Path) -> Option<String> {
std::fs::read_to_string(path)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| is_valid_v3_onion(s))
}
fn sudo_read_and_validate_onion(path: &Path) -> Option<String> {
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| is_valid_v3_onion(s))
}
/// Validate v3 onion address: exactly 56 base32 chars + ".onion"
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 ─────────────────────────────────────────────────
/// Known default ports for built-in and common services.
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,
}
}
/// Returns true if the service is a non-HTTP protocol (auth not applicable).
/// These get direct port mapping in torrc. Web apps route through nginx.
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())
}
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(())
}
// ─── Federation Notification ─────────────────────────────────────
/// 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 = 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 ────────────────────────────────────────────
/// 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

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

@ -42,10 +42,11 @@ async fn read_password() -> String {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(
let _ = tokio::fs::set_permissions(
SECRETS_PATH,
std::fs::Permissions::from_mode(0o600),
);
)
.await;
}
debug!("Bitcoin RPC password generated and saved");
}
@ -70,13 +71,3 @@ pub async fn bitcoin_rpc_credentials() -> (String, String) {
(RPC_USER.to_string(), pass.clone())
}
/// Get the Bitcoin RPC password (for container config generation).
pub async fn bitcoin_rpc_password() -> String {
let (_, pass) = bitcoin_rpc_credentials().await;
pass
}
/// Get the Bitcoin RPC username.
pub async fn bitcoin_rpc_username() -> String {
RPC_USER.to_string()
}

View File

@ -10,10 +10,3 @@ 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";
/// DNS-over-HTTPS providers for secure DNS resolution.
pub const DNS_PROVIDERS: &[&str] = &[
"https://cloudflare-dns.com/dns-query",
"https://dns.google/dns-query",
"https://dns.quad9.net/dns-query",
"https://dns.mullvad.net/dns-query",
];

View File

@ -12,7 +12,6 @@ use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Instant;
use tokio::fs;
use tracing::{info, warn};

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

@ -1,833 +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 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,
}
/// 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());
// 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 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,
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(())
}
/// 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)
}
/// 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,
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::*;
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,
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,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.

View File

@ -15,7 +15,7 @@ const NODE_KEY_PUB_FILE: &str = "node_key.pub";
/// Survives reboots; used for signing, verification, and node address.
pub struct NodeIdentity {
signing_key: SigningKey,
identity_dir: PathBuf,
_identity_dir: PathBuf,
}
impl NodeIdentity {
@ -60,7 +60,7 @@ impl NodeIdentity {
Ok(Self {
signing_key,
identity_dir: identity_dir.to_path_buf(),
_identity_dir: identity_dir.to_path_buf(),
})
}
@ -115,11 +115,6 @@ impl NodeIdentity {
.map_err(|e| anyhow::anyhow!("Invalid pubkey hex: {}", e))
}
/// Generate a W3C DID Core v1.0 compliant DID Document.
pub fn did_document(&self) -> Result<serde_json::Value> {
did_document_from_pubkey_hex(&self.pubkey_hex())
.map_err(|e| anyhow::anyhow!("Invalid pubkey hex: {}", e))
}
}
/// Convert Ed25519 pubkey (hex) to did:key format.

View File

@ -14,7 +14,7 @@ mod bitcoin_rpc;
mod config;
mod content_server;
mod crash_recovery;
mod cluster;mod credentials;
mod credentials;
mod disk_monitor;
mod health_monitor;
mod electrs_status;
@ -33,6 +33,7 @@ mod nostr_discovery;
mod nostr_handshake;
mod peers;
mod server;
mod rate_limit;
mod session;
mod state;
mod totp;
@ -41,7 +42,7 @@ mod names;
mod network;
mod nostr_relays;
mod update;
mod tpm;mod vpn;
mod vpn;
mod webhooks;
use auth::AuthManager;

View File

@ -137,14 +137,6 @@ pub enum ManifestDescription {
Detailed { short: String, long: String },
}
impl ManifestDescription {
pub fn short(&self) -> &str {
match self {
Self::Simple(s) => s,
Self::Detailed { short, .. } => short,
}
}
}
/// A discovered marketplace app with trust scoring.
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -507,11 +499,11 @@ async fn load_or_create_keys(identity_dir: &Path) -> Result<nostr_sdk::prelude::
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let sp = secret_path.clone();
tokio::task::spawn_blocking(move || {
std::fs::set_permissions(sp, std::fs::Permissions::from_mode(0o600))
})
.await??;
tokio::fs::set_permissions(
&secret_path,
std::fs::Permissions::from_mode(0o600),
)
.await?;
}
Ok(keys)
}

View File

@ -1,3 +1,5 @@
// WIP mesh/transport protocol — suppress dead code warnings
#![allow(dead_code)]
//! Emergency alert system and dead man's switch for mesh networking.
//!
//! The dead man's switch automatically broadcasts a signed alert with GPS
@ -11,10 +13,8 @@ use super::message_types::{
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
use tracing::{info, warn};
/// Default dead man's switch interval: 6 hours.
const DEFAULT_INTERVAL_SECS: u64 = 21600;

View File

@ -1,3 +1,5 @@
// WIP mesh/transport protocol — suppress dead code warnings
#![allow(dead_code)]
//! Off-grid Bitcoin operations over mesh radio.
//!
//! Enables mesh-only nodes (no internet) to:
@ -12,11 +14,9 @@ use super::message_types::{
MeshMessageType, TxRelayPayload, TxRelayResponsePayload, TypedEnvelope,
};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tokio::sync::RwLock;
use tracing::{debug, info, warn};
use tracing::warn;
// ─── Block Header Cache ─────────────────────────────────────────────────

View File

@ -1,3 +1,5 @@
// WIP mesh/transport protocol — suppress dead code warnings
#![allow(dead_code)]
//! Mesh message encryption: X25519 ECDH key agreement + ChaCha20-Poly1305.
//!
//! Reuses Archipelago's existing Ed25519 identity infrastructure.

View File

@ -1,3 +1,5 @@
// WIP mesh/transport protocol — suppress dead code warnings
#![allow(dead_code)]
//! Typed message envelope for mesh communication.
//!
//! Wraps all mesh messages in a CBOR envelope with type discrimination,

View File

@ -30,7 +30,7 @@ use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use tokio::fs;
use tokio::sync::{broadcast, watch};
use tokio::sync::watch;
use tracing::{error, info, warn};
const MESH_CONFIG_FILE: &str = "mesh-config.json";
@ -559,11 +559,6 @@ impl MeshService {
Ok(())
}
/// Subscribe to mesh events.
pub fn subscribe(&self) -> broadcast::Receiver<MeshEvent> {
self.state.event_tx.subscribe()
}
/// Get a reference to shared state (for RPC handlers).
pub fn shared_state(&self) -> Arc<MeshState> {
Arc::clone(&self.state)
@ -574,11 +569,6 @@ impl MeshService {
self.dead_man_switch.check_in().await;
}
/// Get the node's signing key (for signed messages).
pub fn signing_key(&self) -> &SigningKey {
&self.signing_key
}
/// Get our DID.
pub fn our_did(&self) -> &str {
&self.our_did

View File

@ -1,3 +1,5 @@
// WIP mesh/transport protocol — suppress dead code warnings
#![allow(dead_code)]
//! Store-and-forward message queue for mesh networking.
//!
//! When a destination peer is offline or unreachable, messages are queued
@ -9,7 +11,7 @@ use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
use std::path::{Path, PathBuf};
use tokio::sync::RwLock;
use tracing::{debug, info, warn};
use tracing::{debug, info};
/// Default time-to-live for queued messages (24 hours).
const DEFAULT_TTL_SECS: u64 = 86400;

View File

@ -1,3 +1,5 @@
// WIP mesh/transport protocol — suppress dead code warnings
#![allow(dead_code)]
//! Meshcore binary frame protocol: constants, encoding, decoding, command builders.
//!
//! Frame format (USB serial):

View File

@ -1,3 +1,5 @@
// WIP mesh/transport protocol — suppress dead code warnings
#![allow(dead_code)]
//! Double Ratchet protocol for forward-secret mesh messaging.
//!
//! Implements the Signal protocol's Double Ratchet algorithm:
@ -13,10 +15,10 @@
//! Reference: Signal Technical Documentation — Double Ratchet Algorithm
use super::crypto;
use anyhow::{Context, Result};
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use zeroize::{Zeroize, ZeroizeOnDrop};
use zeroize::Zeroize;
/// HKDF info string for root key + chain key derivation.
const KDF_RK_INFO: &[u8] = b"ArchyRatchetRK";

View File

@ -1,3 +1,5 @@
// WIP mesh/transport protocol — suppress dead code warnings
#![allow(dead_code)]
//! Async serial driver for Meshcore devices.
//!
//! Handles opening the serial port, reading/writing frames,

View File

@ -10,7 +10,7 @@ use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tokio::sync::RwLock;
use tracing::{debug, info, warn};
use tracing::{debug, warn};
const RATCHET_DIR: &str = "ratchet";
@ -87,14 +87,6 @@ impl SessionManager {
self.session_path(did).exists()
}
/// Store a new session (after X3DH completes).
pub async fn store_session(&self, did: &str, state: RatchetState) -> Result<()> {
self.save_session_to_disk(did, &state).await?;
self.sessions.write().await.insert(did.to_string(), state);
info!(did = %did, "Ratchet session established");
Ok(())
}
/// Encrypt a message for a peer using their ratchet session.
/// Loads the session from disk if not in memory.
pub async fn encrypt_for_peer(
@ -166,19 +158,6 @@ impl SessionManager {
Ok(plaintext)
}
/// Remove a session (e.g., on reset or peer removal).
pub async fn remove_session(&self, did: &str) -> Result<()> {
self.sessions.write().await.remove(did);
let path = self.session_path(did);
if path.exists() {
tokio::fs::remove_file(&path)
.await
.context("Failed to remove ratchet session file")?;
}
info!(did = %did, "Ratchet session removed");
Ok(())
}
/// Get session info for a peer (for RPC status endpoint).
pub async fn session_info(&self, did: &str) -> Option<SessionInfo> {
let sessions = self.sessions.read().await;
@ -203,10 +182,6 @@ impl SessionManager {
}
}
/// List all peers with active sessions.
pub async fn list_sessions(&self) -> Vec<String> {
self.sessions.read().await.keys().cloned().collect()
}
}
/// Summary info about a ratchet session (returned via RPC).

View File

@ -1,3 +1,5 @@
// WIP mesh/transport protocol — suppress dead code warnings
#![allow(dead_code)]
//! Steganographic encoding for mesh messages.
//!
//! Transforms typed message envelopes into formats that resemble innocuous

View File

@ -1,3 +1,5 @@
// WIP mesh/transport protocol — suppress dead code warnings
#![allow(dead_code)]
//! Shared types for mesh networking subsystem.
use serde::{Deserialize, Serialize};

View File

@ -1,3 +1,5 @@
// WIP mesh/transport protocol — suppress dead code warnings
#![allow(dead_code)]
//! X3DH (Extended Triple Diffie-Hellman) key agreement for mesh sessions.
//!
//! Implements the Signal protocol's X3DH using existing Ed25519/X25519 identity

View File

@ -0,0 +1,239 @@
use crate::monitoring::store::MetricsStore;
use crate::monitoring::types::*;
use tracing::{info, warn};
const ALERT_RULES_FILE: &str = "alert-rules.json";
impl AlertRule {
pub(crate) fn default_rules() -> Vec<AlertRule> {
vec![
AlertRule {
kind: AlertRuleKind::DiskUsage,
threshold: 80.0,
enabled: true,
description: "Disk usage exceeds threshold".to_string(),
},
AlertRule {
kind: AlertRuleKind::RamUsage,
threshold: 80.0,
enabled: true,
description: "Total memory usage exceeds threshold".to_string(),
},
AlertRule {
kind: AlertRuleKind::CpuLoad,
threshold: 4.0,
enabled: true,
description: "CPU load exceeds 4x core count for 5 minutes".to_string(),
},
AlertRule {
kind: AlertRuleKind::ContainerCrash,
threshold: 1.0,
enabled: true,
description: "Container stopped unexpectedly".to_string(),
},
AlertRule {
kind: AlertRuleKind::BackendErrorSpike,
threshold: 500.0,
enabled: true,
description: "RPC latency exceeds threshold (ms)".to_string(),
},
AlertRule {
kind: AlertRuleKind::SslCertExpiry,
threshold: 30.0,
enabled: true,
description: "SSL certificate expires within N days".to_string(),
},
]
}
}
/// Alert-related methods on MetricsStore.
impl MetricsStore {
/// Get the current alert rules.
pub async fn get_alert_rules(&self) -> Vec<AlertRule> {
self.alert_rules.read().await.clone()
}
/// Update an alert rule by kind and persist to disk.
pub async fn update_alert_rule(&self, kind: &AlertRuleKind, enabled: Option<bool>, threshold: Option<f64>) {
let mut rules = self.alert_rules.write().await;
if let Some(rule) = rules.iter_mut().find(|r| &r.kind == kind) {
if let Some(e) = enabled {
rule.enabled = e;
}
if let Some(t) = threshold {
rule.threshold = t;
}
}
// Persist to disk so changes survive restarts
if let Some(ref dir) = self.data_dir {
if let Err(e) = save_alert_rules(dir, &rules).await {
warn!("Failed to persist alert rules: {}", e);
}
}
}
/// Get fired alert history.
pub async fn get_fired_alerts(&self, last_n: usize) -> Vec<FiredAlert> {
let buf = self.fired_alerts.read().await;
let start = buf.len().saturating_sub(last_n);
buf.iter().skip(start).cloned().collect()
}
/// Acknowledge a fired alert by id.
pub async fn acknowledge_alert(&self, alert_id: &str) -> bool {
let mut buf = self.fired_alerts.write().await;
if let Some(alert) = buf.iter_mut().find(|a| a.id == alert_id) {
alert.acknowledged = true;
true
} else {
false
}
}
/// Evaluate alert rules against a snapshot and return any new alerts.
pub async fn check_alerts(&self, snapshot: &MetricSnapshot) -> Vec<FiredAlert> {
let rules = self.alert_rules.read().await;
let mut new_alerts = Vec::new();
let ts = snapshot.timestamp;
for rule in rules.iter() {
if !rule.enabled {
continue;
}
match rule.kind {
AlertRuleKind::DiskUsage => {
if snapshot.system.disk_total_bytes > 0 {
let pct = (snapshot.system.disk_used_bytes as f64
/ snapshot.system.disk_total_bytes as f64)
* 100.0;
if pct > rule.threshold {
new_alerts.push(FiredAlert {
id: format!("disk-{}", ts),
kind: AlertRuleKind::DiskUsage,
message: format!("Disk usage at {:.1}% (threshold: {:.0}%)", pct, rule.threshold),
value: pct,
threshold: rule.threshold,
timestamp: ts,
acknowledged: false,
});
}
}
}
AlertRuleKind::RamUsage => {
if snapshot.system.mem_total_bytes > 0 {
let pct = (snapshot.system.mem_used_bytes as f64
/ snapshot.system.mem_total_bytes as f64)
* 100.0;
if pct > rule.threshold {
new_alerts.push(FiredAlert {
id: format!("ram-{}", ts),
kind: AlertRuleKind::RamUsage,
message: format!("RAM usage at {:.1}% (threshold: {:.0}%)", pct, rule.threshold),
value: pct,
threshold: rule.threshold,
timestamp: ts,
acknowledged: false,
});
}
}
}
AlertRuleKind::CpuLoad => {
// Alert if 5-min load average exceeds threshold * core count
let cores = std::thread::available_parallelism()
.map(|n| n.get() as f64)
.unwrap_or(4.0);
let max_load = rule.threshold * cores;
if snapshot.system.load_avg_5 > max_load {
new_alerts.push(FiredAlert {
id: format!("cpu-{}", ts),
kind: AlertRuleKind::CpuLoad,
message: format!(
"CPU load at {:.1} (threshold: {:.0} = {:.0}x {} cores)",
snapshot.system.load_avg_5, max_load, rule.threshold, cores as u32
),
value: snapshot.system.load_avg_5,
threshold: max_load,
timestamp: ts,
acknowledged: false,
});
}
}
AlertRuleKind::BackendErrorSpike => {
if snapshot.rpc_latency_ms > rule.threshold {
new_alerts.push(FiredAlert {
id: format!("latency-{}", ts),
kind: AlertRuleKind::BackendErrorSpike,
message: format!(
"RPC latency at {:.0}ms (threshold: {:.0}ms)",
snapshot.rpc_latency_ms, rule.threshold
),
value: snapshot.rpc_latency_ms,
threshold: rule.threshold,
timestamp: ts,
acknowledged: false,
});
}
}
// ContainerCrash and SslCertExpiry are checked via dedicated paths
_ => {}
}
}
// Store fired alerts
if !new_alerts.is_empty() {
let mut buf = self.fired_alerts.write().await;
for alert in &new_alerts {
if buf.len() >= MAX_ALERT_HISTORY {
buf.pop_front();
}
buf.push_back(alert.clone());
}
}
new_alerts
}
}
/// Load alert rules from disk, falling back to defaults if file missing or corrupt.
pub(crate) async fn load_alert_rules(data_dir: &std::path::Path) -> Vec<AlertRule> {
let path = data_dir.join(ALERT_RULES_FILE);
match tokio::fs::read_to_string(&path).await {
Ok(content) => match serde_json::from_str::<Vec<AlertRule>>(&content) {
Ok(saved) => {
// Merge with defaults: use saved enabled/threshold, add any new rule kinds
let defaults = AlertRule::default_rules();
let mut merged = Vec::new();
for default in &defaults {
if let Some(saved_rule) = saved.iter().find(|r| r.kind == default.kind) {
merged.push(AlertRule {
kind: default.kind.clone(),
threshold: saved_rule.threshold,
enabled: saved_rule.enabled,
description: default.description.clone(),
});
} else {
merged.push(default.clone());
}
}
info!("Loaded alert rules from {}", path.display());
merged
}
Err(e) => {
warn!("Failed to parse alert rules ({}), using defaults", e);
AlertRule::default_rules()
}
},
Err(_) => AlertRule::default_rules(),
}
}
/// Save alert rules to disk.
pub(crate) async fn save_alert_rules(data_dir: &std::path::Path, rules: &[AlertRule]) -> anyhow::Result<()> {
tokio::fs::create_dir_all(data_dir).await?;
let content = serde_json::to_string_pretty(rules)?;
tokio::fs::write(data_dir.join(ALERT_RULES_FILE), content).await?;
Ok(())
}

View File

@ -1,512 +1,21 @@
pub mod collector;
pub(crate) mod alerts;
mod notifications;
pub mod store;
mod telemetry;
pub mod types;
// Re-export public types for external consumers
pub use store::MetricsStore;
pub use telemetry::spawn_telemetry_reporter;
pub use types::*;
use crate::webhooks::{self, WebhookEvent, WebhookPayload};
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use tokio::sync::RwLock;
use tracing::{debug, info, warn};
const ALERT_RULES_FILE: &str = "alert-rules.json";
/// Maximum entries at 1-minute resolution (24 hours = 1440 minutes)
const MAX_1MIN_ENTRIES: usize = 1440;
/// Maximum entries at 15-minute resolution (7 days = 672 quarter-hours)
const MAX_15MIN_ENTRIES: usize = 672;
/// A single metrics snapshot collected at a point in time.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetricSnapshot {
pub timestamp: i64,
pub system: SystemMetrics,
pub containers: Vec<ContainerMetrics>,
pub rpc_latency_ms: f64,
pub ws_connections: u32,
}
/// System-wide resource metrics.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SystemMetrics {
pub cpu_percent: f64,
pub mem_used_bytes: u64,
pub mem_total_bytes: u64,
pub disk_used_bytes: u64,
pub disk_total_bytes: u64,
pub net_rx_bytes: u64,
pub net_tx_bytes: u64,
pub load_avg_1: f64,
pub load_avg_5: f64,
pub load_avg_15: f64,
}
/// Per-container resource metrics.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContainerMetrics {
pub name: String,
pub cpu_percent: f64,
pub mem_used_bytes: u64,
pub mem_limit_bytes: u64,
pub net_rx_bytes: u64,
pub net_tx_bytes: u64,
pub block_read_bytes: u64,
pub block_write_bytes: u64,
}
/// Maximum number of fired alerts to keep in history.
const MAX_ALERT_HISTORY: usize = 100;
/// Types of alert rules the system can evaluate.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum AlertRuleKind {
DiskUsage,
RamUsage,
CpuLoad,
ContainerCrash,
BackendErrorSpike,
SslCertExpiry,
}
/// A configured alert rule with threshold and enabled state.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AlertRule {
pub kind: AlertRuleKind,
pub threshold: f64,
pub enabled: bool,
pub description: String,
}
/// A fired alert instance.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FiredAlert {
pub id: String,
pub kind: AlertRuleKind,
pub message: String,
pub value: f64,
pub threshold: f64,
pub timestamp: i64,
pub acknowledged: bool,
}
impl AlertRule {
fn default_rules() -> Vec<AlertRule> {
vec![
AlertRule {
kind: AlertRuleKind::DiskUsage,
threshold: 80.0,
enabled: true,
description: "Disk usage exceeds threshold".to_string(),
},
AlertRule {
kind: AlertRuleKind::RamUsage,
threshold: 80.0,
enabled: true,
description: "Total memory usage exceeds threshold".to_string(),
},
AlertRule {
kind: AlertRuleKind::CpuLoad,
threshold: 4.0,
enabled: true,
description: "CPU load exceeds 4x core count for 5 minutes".to_string(),
},
AlertRule {
kind: AlertRuleKind::ContainerCrash,
threshold: 1.0,
enabled: true,
description: "Container stopped unexpectedly".to_string(),
},
AlertRule {
kind: AlertRuleKind::BackendErrorSpike,
threshold: 500.0,
enabled: true,
description: "RPC latency exceeds threshold (ms)".to_string(),
},
AlertRule {
kind: AlertRuleKind::SslCertExpiry,
threshold: 30.0,
enabled: true,
description: "SSL certificate expires within N days".to_string(),
},
]
}
}
/// Thread-safe metrics store with ring buffers at two resolutions.
pub struct MetricsStore {
minute_data: RwLock<VecDeque<MetricSnapshot>>,
quarter_hour_data: RwLock<VecDeque<MetricSnapshot>>,
minute_count: RwLock<u32>,
rpc_latency: RwLock<(f64, u64)>,
ws_connections: AtomicU32,
alert_rules: RwLock<Vec<AlertRule>>,
fired_alerts: RwLock<VecDeque<FiredAlert>>,
data_dir: Option<PathBuf>,
}
impl MetricsStore {
pub fn new() -> Self {
Self {
minute_data: RwLock::new(VecDeque::with_capacity(MAX_1MIN_ENTRIES)),
quarter_hour_data: RwLock::new(VecDeque::with_capacity(MAX_15MIN_ENTRIES)),
minute_count: RwLock::new(0),
rpc_latency: RwLock::new((0.0, 0)),
ws_connections: AtomicU32::new(0),
alert_rules: RwLock::new(AlertRule::default_rules()),
fired_alerts: RwLock::new(VecDeque::with_capacity(MAX_ALERT_HISTORY)),
data_dir: None,
}
}
/// Create a MetricsStore that persists alert rules to disk.
pub fn with_data_dir(data_dir: PathBuf) -> Self {
let rules = load_alert_rules_sync(&data_dir);
Self {
minute_data: RwLock::new(VecDeque::with_capacity(MAX_1MIN_ENTRIES)),
quarter_hour_data: RwLock::new(VecDeque::with_capacity(MAX_15MIN_ENTRIES)),
minute_count: RwLock::new(0),
rpc_latency: RwLock::new((0.0, 0)),
ws_connections: AtomicU32::new(0),
alert_rules: RwLock::new(rules),
fired_alerts: RwLock::new(VecDeque::with_capacity(MAX_ALERT_HISTORY)),
data_dir: Some(data_dir),
}
}
/// Record a new metric snapshot (called every minute by collector).
pub async fn push(&self, mut snapshot: MetricSnapshot) {
// Fill in RPC latency from accumulated samples
{
let mut latency = self.rpc_latency.write().await;
if latency.1 > 0 {
snapshot.rpc_latency_ms = (latency.0 / latency.1 as f64 * 10.0).round() / 10.0;
*latency = (0.0, 0);
}
}
// Fill in current WS connection count
snapshot.ws_connections = self.ws_connections.load(Ordering::Relaxed);
// Push to 1-minute ring buffer
{
let mut buf = self.minute_data.write().await;
if buf.len() >= MAX_1MIN_ENTRIES {
buf.pop_front();
}
buf.push_back(snapshot.clone());
}
// Every 15 minutes, push to quarter-hour ring buffer
{
let mut count = self.minute_count.write().await;
*count += 1;
if *count >= 15 {
*count = 0;
let mut buf = self.quarter_hour_data.write().await;
if buf.len() >= MAX_15MIN_ENTRIES {
buf.pop_front();
}
buf.push_back(snapshot);
}
}
}
/// Record an RPC request latency sample (milliseconds).
pub async fn record_rpc_latency(&self, latency_ms: f64) {
let mut data = self.rpc_latency.write().await;
data.0 += latency_ms;
data.1 += 1;
}
/// Increment WebSocket connection count (called on connect).
pub fn increment_ws(&self) {
self.ws_connections.fetch_add(1, Ordering::Relaxed);
}
/// Decrement WebSocket connection count (called on disconnect).
pub fn decrement_ws(&self) {
// Use saturating semantics to avoid underflow
let _ = self.ws_connections.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| {
if v > 0 { Some(v - 1) } else { Some(0) }
});
}
/// Get the latest snapshot.
pub async fn latest(&self) -> Option<MetricSnapshot> {
self.minute_data.read().await.back().cloned()
}
/// Get minute-resolution data for the last N minutes.
pub async fn history_minutes(&self, last_n: usize) -> Vec<MetricSnapshot> {
let buf = self.minute_data.read().await;
let start = buf.len().saturating_sub(last_n);
buf.iter().skip(start).cloned().collect()
}
/// Get quarter-hour-resolution data for the last N entries.
pub async fn history_quarter_hours(&self, last_n: usize) -> Vec<MetricSnapshot> {
let buf = self.quarter_hour_data.read().await;
let start = buf.len().saturating_sub(last_n);
buf.iter().skip(start).cloned().collect()
}
/// Get the current alert rules.
pub async fn get_alert_rules(&self) -> Vec<AlertRule> {
self.alert_rules.read().await.clone()
}
/// Update an alert rule by kind and persist to disk.
pub async fn update_alert_rule(&self, kind: &AlertRuleKind, enabled: Option<bool>, threshold: Option<f64>) {
let mut rules = self.alert_rules.write().await;
if let Some(rule) = rules.iter_mut().find(|r| &r.kind == kind) {
if let Some(e) = enabled {
rule.enabled = e;
}
if let Some(t) = threshold {
rule.threshold = t;
}
}
// Persist to disk so changes survive restarts
if let Some(ref dir) = self.data_dir {
if let Err(e) = save_alert_rules(dir, &rules).await {
warn!("Failed to persist alert rules: {}", e);
}
}
}
/// Get fired alert history.
pub async fn get_fired_alerts(&self, last_n: usize) -> Vec<FiredAlert> {
let buf = self.fired_alerts.read().await;
let start = buf.len().saturating_sub(last_n);
buf.iter().skip(start).cloned().collect()
}
/// Acknowledge a fired alert by id.
pub async fn acknowledge_alert(&self, alert_id: &str) -> bool {
let mut buf = self.fired_alerts.write().await;
if let Some(alert) = buf.iter_mut().find(|a| a.id == alert_id) {
alert.acknowledged = true;
true
} else {
false
}
}
/// Evaluate alert rules against a snapshot and return any new alerts.
pub async fn check_alerts(&self, snapshot: &MetricSnapshot) -> Vec<FiredAlert> {
let rules = self.alert_rules.read().await;
let mut new_alerts = Vec::new();
let ts = snapshot.timestamp;
for rule in rules.iter() {
if !rule.enabled {
continue;
}
match rule.kind {
AlertRuleKind::DiskUsage => {
if snapshot.system.disk_total_bytes > 0 {
let pct = (snapshot.system.disk_used_bytes as f64
/ snapshot.system.disk_total_bytes as f64)
* 100.0;
if pct > rule.threshold {
new_alerts.push(FiredAlert {
id: format!("disk-{}", ts),
kind: AlertRuleKind::DiskUsage,
message: format!("Disk usage at {:.1}% (threshold: {:.0}%)", pct, rule.threshold),
value: pct,
threshold: rule.threshold,
timestamp: ts,
acknowledged: false,
});
}
}
}
AlertRuleKind::RamUsage => {
if snapshot.system.mem_total_bytes > 0 {
let pct = (snapshot.system.mem_used_bytes as f64
/ snapshot.system.mem_total_bytes as f64)
* 100.0;
if pct > rule.threshold {
new_alerts.push(FiredAlert {
id: format!("ram-{}", ts),
kind: AlertRuleKind::RamUsage,
message: format!("RAM usage at {:.1}% (threshold: {:.0}%)", pct, rule.threshold),
value: pct,
threshold: rule.threshold,
timestamp: ts,
acknowledged: false,
});
}
}
}
AlertRuleKind::CpuLoad => {
// Alert if 5-min load average exceeds threshold * core count
let cores = std::thread::available_parallelism()
.map(|n| n.get() as f64)
.unwrap_or(4.0);
let max_load = rule.threshold * cores;
if snapshot.system.load_avg_5 > max_load {
new_alerts.push(FiredAlert {
id: format!("cpu-{}", ts),
kind: AlertRuleKind::CpuLoad,
message: format!(
"CPU load at {:.1} (threshold: {:.0} = {:.0}x {} cores)",
snapshot.system.load_avg_5, max_load, rule.threshold, cores as u32
),
value: snapshot.system.load_avg_5,
threshold: max_load,
timestamp: ts,
acknowledged: false,
});
}
}
AlertRuleKind::BackendErrorSpike => {
if snapshot.rpc_latency_ms > rule.threshold {
new_alerts.push(FiredAlert {
id: format!("latency-{}", ts),
kind: AlertRuleKind::BackendErrorSpike,
message: format!(
"RPC latency at {:.0}ms (threshold: {:.0}ms)",
snapshot.rpc_latency_ms, rule.threshold
),
value: snapshot.rpc_latency_ms,
threshold: rule.threshold,
timestamp: ts,
acknowledged: false,
});
}
}
// ContainerCrash and SslCertExpiry are checked via dedicated paths
_ => {}
}
}
// Store fired alerts
if !new_alerts.is_empty() {
let mut buf = self.fired_alerts.write().await;
for alert in &new_alerts {
if buf.len() >= MAX_ALERT_HISTORY {
buf.pop_front();
}
buf.push_back(alert.clone());
}
}
new_alerts
}
/// Fire a container crash alert (called by health monitor).
pub async fn fire_container_alert(&self, container_name: &str, state: &str) {
let rules = self.alert_rules.read().await;
let enabled = rules
.iter()
.any(|r| r.kind == AlertRuleKind::ContainerCrash && r.enabled);
drop(rules);
if !enabled {
return;
}
let ts = chrono::Utc::now().timestamp();
let alert = FiredAlert {
id: format!("container-{}-{}", container_name, ts),
kind: AlertRuleKind::ContainerCrash,
message: format!("Container '{}' is {} — may need attention", container_name, state),
value: 1.0,
threshold: 1.0,
timestamp: ts,
acknowledged: false,
};
let mut buf = self.fired_alerts.write().await;
if buf.len() >= MAX_ALERT_HISTORY {
buf.pop_front();
}
buf.push_back(alert);
}
/// Fire an SSL cert expiry alert.
pub async fn fire_ssl_alert(&self, days_remaining: f64) {
let rules = self.alert_rules.read().await;
let threshold = rules
.iter()
.find(|r| r.kind == AlertRuleKind::SslCertExpiry && r.enabled)
.map(|r| r.threshold);
drop(rules);
let threshold = match threshold {
Some(t) if days_remaining < t => t,
_ => return,
};
let ts = chrono::Utc::now().timestamp();
let alert = FiredAlert {
id: format!("ssl-{}", ts),
kind: AlertRuleKind::SslCertExpiry,
message: format!(
"SSL certificate expires in {:.0} days (threshold: {:.0} days)",
days_remaining, threshold
),
value: days_remaining,
threshold,
timestamp: ts,
acknowledged: false,
};
let mut buf = self.fired_alerts.write().await;
if buf.len() >= MAX_ALERT_HISTORY {
buf.pop_front();
}
buf.push_back(alert);
}
}
/// Load alert rules from disk, falling back to defaults if file missing or corrupt.
fn load_alert_rules_sync(data_dir: &std::path::Path) -> Vec<AlertRule> {
let path = data_dir.join(ALERT_RULES_FILE);
match std::fs::read_to_string(&path) {
Ok(content) => match serde_json::from_str::<Vec<AlertRule>>(&content) {
Ok(saved) => {
// Merge with defaults: use saved enabled/threshold, add any new rule kinds
let defaults = AlertRule::default_rules();
let mut merged = Vec::new();
for default in &defaults {
if let Some(saved_rule) = saved.iter().find(|r| r.kind == default.kind) {
merged.push(AlertRule {
kind: default.kind.clone(),
threshold: saved_rule.threshold,
enabled: saved_rule.enabled,
description: default.description.clone(),
});
} else {
merged.push(default.clone());
}
}
info!("Loaded alert rules from {}", path.display());
merged
}
Err(e) => {
warn!("Failed to parse alert rules ({}), using defaults", e);
AlertRule::default_rules()
}
},
Err(_) => AlertRule::default_rules(),
}
}
/// Save alert rules to disk.
async fn save_alert_rules(data_dir: &std::path::Path, rules: &[AlertRule]) -> anyhow::Result<()> {
tokio::fs::create_dir_all(data_dir).await?;
let content = serde_json::to_string_pretty(rules)?;
tokio::fs::write(data_dir.join(ALERT_RULES_FILE), content).await?;
Ok(())
}
use tracing::{debug, warn};
/// Spawn the background metrics collector (runs every 60 seconds).
/// Also evaluates alert rules on each snapshot and pushes notifications.
/// Evaluates alert rules on each snapshot and dispatches notifications.
pub fn spawn_metrics_collector(
store: Arc<MetricsStore>,
state: Option<Arc<crate::state::StateManager>>,
@ -523,71 +32,16 @@ pub fn spawn_metrics_collector(
match collector::collect_snapshot().await {
Ok(snapshot) => {
// Check alert rules before pushing (needs snapshot data)
let alerts = store.check_alerts(&snapshot).await;
store.push(snapshot).await;
debug!("Metrics snapshot collected");
// Push alert notifications via WebSocket
if !alerts.is_empty() {
if let Some(ref state_mgr) = state {
let (mut data, _rev) = state_mgr.get_snapshot().await;
for alert in &alerts {
let level = match alert.kind {
AlertRuleKind::DiskUsage | AlertRuleKind::RamUsage => {
if alert.value > 95.0 {
crate::data_model::NotificationLevel::Error
} else {
crate::data_model::NotificationLevel::Warning
notifications::push_alert_notifications(state_mgr, &alerts).await;
}
}
AlertRuleKind::ContainerCrash => {
crate::data_model::NotificationLevel::Error
}
_ => crate::data_model::NotificationLevel::Warning,
};
let notification = crate::data_model::Notification {
id: alert.id.clone(),
level,
title: format!("{:?} Alert", alert.kind),
message: alert.message.clone(),
timestamp: chrono::Utc::now().to_rfc3339(),
app_id: None,
};
data.notifications.push(notification);
}
// Keep max 20 notifications
while data.notifications.len() > 20 {
data.notifications.remove(0);
}
state_mgr.update_data(data).await;
info!("Fired {} alert(s)", alerts.len());
}
// Fire-and-forget webhook delivery for mapped alert types
if let Some(ref dir) = data_dir {
for alert in &alerts {
let event = match alert.kind {
AlertRuleKind::DiskUsage => Some(WebhookEvent::DiskWarning),
AlertRuleKind::ContainerCrash => Some(WebhookEvent::ContainerCrash),
_ => None,
};
if let Some(event) = event {
let payload = WebhookPayload {
event,
title: format!("{:?} Alert", alert.kind),
message: alert.message.clone(),
timestamp: chrono::Utc::now().to_rfc3339(),
node_id: String::new(),
details: Some(serde_json::json!({
"value": alert.value,
"threshold": alert.threshold,
})),
};
webhooks::send_webhook(dir, payload).await;
}
}
notifications::deliver_alert_webhooks(dir, &alerts).await;
}
}
}
@ -598,396 +52,3 @@ pub fn spawn_metrics_collector(
}
});
}
/// Spawn the periodic telemetry reporter (runs every 15 minutes when opt-in enabled).
/// Collects anonymous health data and saves to disk. Posts to central collector if configured.
pub fn spawn_telemetry_reporter(
store: Arc<MetricsStore>,
state: Option<Arc<crate::state::StateManager>>,
data_dir: PathBuf,
) {
tokio::spawn(async move {
// Wait 60s for system to fully stabilize
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
let mut interval = tokio::time::interval(std::time::Duration::from_secs(900)); // 15 min
loop {
interval.tick().await;
// Check if telemetry is opted in
let config_path = data_dir.join("analytics-config.json");
let enabled = match tokio::fs::read_to_string(&config_path).await {
Ok(data) => serde_json::from_str::<serde_json::Value>(&data)
.ok()
.and_then(|c| c["enabled"].as_bool())
.unwrap_or(false),
Err(_) => false,
};
if !enabled {
debug!("Telemetry disabled — skipping report");
continue;
}
match build_telemetry_report(&store, &state, &data_dir).await {
Ok(report) => {
// Save latest report to disk
let report_path = data_dir.join("telemetry-latest.json");
if let Ok(json) = serde_json::to_string_pretty(&report) {
let _ = tokio::fs::write(&report_path, &json).await;
}
// Always save to local fleet directory so this node appears
// in its own fleet view
save_report_to_fleet_dir(&data_dir, &report).await;
// POST to central collector if configured
let collector_url = std::env::var("TELEMETRY_COLLECTOR_URL").ok();
if let Some(url) = collector_url {
match post_telemetry_report(&url, &report).await {
Ok(_) => info!("Telemetry report sent to collector"),
Err(e) => warn!("Failed to send telemetry report: {}", e),
}
} else {
debug!("Telemetry report saved locally (no collector URL configured)");
}
}
Err(e) => {
warn!("Failed to build telemetry report: {}", e);
}
}
}
});
}
/// Build an anonymous telemetry report from current system state.
async fn build_telemetry_report(
store: &Arc<MetricsStore>,
state: &Option<Arc<crate::state::StateManager>>,
data_dir: &std::path::Path,
) -> anyhow::Result<serde_json::Value> {
// Anonymous node ID — truncated SHA-256 hash of pubkey
let (node_id, version, container_count, running_count, peer_count) = if let Some(ref sm) = state {
let (data, _) = sm.get_snapshot().await;
let id = {
use sha2::{Sha256, Digest};
let mut h = Sha256::new();
h.update(data.server_info.pubkey.as_bytes());
hex::encode(h.finalize())[..16].to_string()
};
let running = data.package_data.values()
.filter(|p| matches!(p.state, crate::data_model::PackageState::Running))
.count();
(id, data.server_info.version.clone(), data.package_data.len(), running, data.peer_health.len())
} else {
("unknown".to_string(), "unknown".to_string(), 0, 0, 0)
};
// System info
let cpu_cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(0);
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);
// Latest metrics snapshot
let latest = store.latest().await;
let (cpu_pct, mem_pct, disk_pct): (f64, f64, f64) = latest.map(|s| {
let mem_total = s.system.mem_total_bytes as f64;
let disk_total = s.system.disk_total_bytes as f64;
(
s.system.cpu_percent,
if mem_total > 0.0 { (s.system.mem_used_bytes as f64 / mem_total) * 100.0 } else { 0.0 },
if disk_total > 0.0 { (s.system.disk_used_bytes as f64 / disk_total) * 100.0 } else { 0.0 },
)
}).unwrap_or((0.0, 0.0, 0.0));
// Recent alerts
let recent_alerts: Vec<serde_json::Value> = store.get_fired_alerts(10).await
.into_iter()
.map(|a| serde_json::json!({
"rule": format!("{:?}", a.kind),
"message": a.message,
"timestamp": a.timestamp,
}))
.collect();
Ok(serde_json::json!({
"node_id": node_id,
"version": version,
"uptime_secs": uptime_secs,
"cpu_cores": cpu_cores,
"cpu_pct": (cpu_pct * 10.0).round() / 10.0,
"mem_pct": (mem_pct * 10.0).round() / 10.0,
"disk_pct": (disk_pct * 10.0).round() / 10.0,
"container_count": container_count,
"running_count": running_count,
"federation_peers": peer_count,
"recent_alerts": recent_alerts,
"reported_at": chrono::Utc::now().to_rfc3339(),
}))
}
/// POST a telemetry report to the central collector.
async fn post_telemetry_report(url: &str, report: &serde_json::Value) -> anyhow::Result<()> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()?;
let response = client.post(url)
.header("Content-Type", "application/json")
.header("User-Agent", "Archipelago-Telemetry/1.0")
.json(report)
.send()
.await?;
if !response.status().is_success() {
anyhow::bail!("Collector returned {}", response.status());
}
Ok(())
}
/// Save a telemetry report into the local fleet directory.
/// This makes the node's own report visible in the fleet dashboard.
async fn save_report_to_fleet_dir(data_dir: &std::path::Path, report: &serde_json::Value) {
let node_id = match report.get("node_id").and_then(|v| v.as_str()) {
Some(id) if !id.is_empty() => id,
_ => {
warn!("Telemetry report missing node_id — skipping fleet save");
return;
}
};
let fleet_dir = data_dir.join("telemetry-fleet");
if let Err(e) = tokio::fs::create_dir_all(&fleet_dir).await {
warn!("Failed to create telemetry-fleet directory: {}", e);
return;
}
// Write latest report (overwrites previous)
let latest_path = fleet_dir.join(format!("{}.json", node_id));
match serde_json::to_string_pretty(report) {
Ok(json) => {
if let Err(e) = tokio::fs::write(&latest_path, &json).await {
warn!("Failed to write fleet report for {}: {}", node_id, e);
}
}
Err(e) => {
warn!("Failed to serialize fleet report: {}", e);
return;
}
}
// 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());
if history.len() > 200 {
let start = history.len() - 200;
history = history.split_off(start);
}
match serde_json::to_string_pretty(&history) {
Ok(json) => {
if let Err(e) = tokio::fs::write(&history_path, &json).await {
warn!("Failed to write fleet history for {}: {}", node_id, e);
}
}
Err(e) => {
warn!("Failed to serialize fleet history: {}", e);
}
}
debug!("Saved own telemetry report to fleet directory (node_id={})", node_id);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_metrics_store_new() {
let store = MetricsStore::new();
assert_eq!(store.ws_connections.load(Ordering::Relaxed), 0);
}
#[test]
fn test_ws_connection_tracking() {
let store = MetricsStore::new();
store.increment_ws();
store.increment_ws();
assert_eq!(store.ws_connections.load(Ordering::Relaxed), 2);
store.decrement_ws();
assert_eq!(store.ws_connections.load(Ordering::Relaxed), 1);
store.decrement_ws();
assert_eq!(store.ws_connections.load(Ordering::Relaxed), 0);
// Decrement below zero should stay at 0
store.decrement_ws();
assert_eq!(store.ws_connections.load(Ordering::Relaxed), 0);
}
#[tokio::test]
async fn test_push_and_latest() {
let store = MetricsStore::new();
assert!(store.latest().await.is_none());
let snapshot = MetricSnapshot {
timestamp: 1000,
system: SystemMetrics {
cpu_percent: 25.0,
mem_used_bytes: 1_000_000,
mem_total_bytes: 4_000_000,
disk_used_bytes: 500_000,
disk_total_bytes: 1_000_000,
net_rx_bytes: 100,
net_tx_bytes: 200,
load_avg_1: 1.0,
load_avg_5: 0.5,
load_avg_15: 0.3,
},
containers: vec![],
rpc_latency_ms: 0.0,
ws_connections: 0,
};
store.push(snapshot).await;
let latest = store.latest().await.unwrap();
assert_eq!(latest.timestamp, 1000);
assert_eq!(latest.system.cpu_percent, 25.0);
}
#[tokio::test]
async fn test_rpc_latency_recording() {
let store = MetricsStore::new();
store.record_rpc_latency(10.0).await;
store.record_rpc_latency(20.0).await;
store.record_rpc_latency(30.0).await;
let snapshot = MetricSnapshot {
timestamp: 2000,
system: SystemMetrics {
cpu_percent: 0.0,
mem_used_bytes: 0,
mem_total_bytes: 0,
disk_used_bytes: 0,
disk_total_bytes: 0,
net_rx_bytes: 0,
net_tx_bytes: 0,
load_avg_1: 0.0,
load_avg_5: 0.0,
load_avg_15: 0.0,
},
containers: vec![],
rpc_latency_ms: 0.0,
ws_connections: 0,
};
store.push(snapshot).await;
let latest = store.latest().await.unwrap();
assert_eq!(latest.rpc_latency_ms, 20.0); // average of 10+20+30 = 20
}
#[tokio::test]
async fn test_history_minutes() {
let store = MetricsStore::new();
for i in 0..5 {
let snapshot = MetricSnapshot {
timestamp: i * 60,
system: SystemMetrics {
cpu_percent: i as f64,
mem_used_bytes: 0,
mem_total_bytes: 0,
disk_used_bytes: 0,
disk_total_bytes: 0,
net_rx_bytes: 0,
net_tx_bytes: 0,
load_avg_1: 0.0,
load_avg_5: 0.0,
load_avg_15: 0.0,
},
containers: vec![],
rpc_latency_ms: 0.0,
ws_connections: 0,
};
store.push(snapshot).await;
}
let history = store.history_minutes(3).await;
assert_eq!(history.len(), 3);
assert_eq!(history[0].timestamp, 120);
assert_eq!(history[2].timestamp, 240);
}
#[tokio::test]
async fn test_ring_buffer_eviction() {
let store = MetricsStore::new();
// Push more than MAX_1MIN_ENTRIES
for i in 0..(MAX_1MIN_ENTRIES + 10) {
let snapshot = MetricSnapshot {
timestamp: i as i64,
system: SystemMetrics {
cpu_percent: 0.0,
mem_used_bytes: 0,
mem_total_bytes: 0,
disk_used_bytes: 0,
disk_total_bytes: 0,
net_rx_bytes: 0,
net_tx_bytes: 0,
load_avg_1: 0.0,
load_avg_5: 0.0,
load_avg_15: 0.0,
},
containers: vec![],
rpc_latency_ms: 0.0,
ws_connections: 0,
};
store.push(snapshot).await;
}
let all = store.history_minutes(MAX_1MIN_ENTRIES + 100).await;
assert_eq!(all.len(), MAX_1MIN_ENTRIES);
// Oldest entry should be 10 (first 10 were evicted)
assert_eq!(all[0].timestamp, 10);
}
#[tokio::test]
async fn test_quarter_hour_downsampling() {
let store = MetricsStore::new();
// Push exactly 15 entries to trigger one quarter-hour sample
for i in 0..15 {
let snapshot = MetricSnapshot {
timestamp: i * 60,
system: SystemMetrics {
cpu_percent: 50.0,
mem_used_bytes: 0,
mem_total_bytes: 0,
disk_used_bytes: 0,
disk_total_bytes: 0,
net_rx_bytes: 0,
net_tx_bytes: 0,
load_avg_1: 0.0,
load_avg_5: 0.0,
load_avg_15: 0.0,
},
containers: vec![],
rpc_latency_ms: 0.0,
ws_connections: 0,
};
store.push(snapshot).await;
}
let qh = store.history_quarter_hours(10).await;
assert_eq!(qh.len(), 1);
assert_eq!(qh[0].timestamp, 14 * 60); // The 15th entry (index 14)
}
#[test]
fn test_constants() {
assert_eq!(MAX_1MIN_ENTRIES, 1440);
assert_eq!(MAX_15MIN_ENTRIES, 672);
}
}

View File

@ -0,0 +1,68 @@
use crate::monitoring::types::{AlertRuleKind, FiredAlert};
use crate::webhooks::{self, WebhookEvent, WebhookPayload};
use std::path::Path;
use std::sync::Arc;
use tracing::info;
/// Push fired alerts as notifications to the state manager (broadcast via WebSocket).
pub(crate) async fn push_alert_notifications(
state_mgr: &Arc<crate::state::StateManager>,
alerts: &[FiredAlert],
) {
let (mut data, _rev) = state_mgr.get_snapshot().await;
for alert in alerts {
let level = match alert.kind {
AlertRuleKind::DiskUsage | AlertRuleKind::RamUsage => {
if alert.value > 95.0 {
crate::data_model::NotificationLevel::Error
} else {
crate::data_model::NotificationLevel::Warning
}
}
AlertRuleKind::ContainerCrash => {
crate::data_model::NotificationLevel::Error
}
_ => crate::data_model::NotificationLevel::Warning,
};
let notification = crate::data_model::Notification {
id: alert.id.clone(),
level,
title: format!("{:?} Alert", alert.kind),
message: alert.message.clone(),
timestamp: chrono::Utc::now().to_rfc3339(),
app_id: None,
};
data.notifications.push(notification);
}
// Keep max 20 notifications
while data.notifications.len() > 20 {
data.notifications.remove(0);
}
state_mgr.update_data(data).await;
info!("Fired {} alert(s)", alerts.len());
}
/// Deliver webhook notifications for alerts that map to webhook events.
pub(crate) async fn deliver_alert_webhooks(data_dir: &Path, alerts: &[FiredAlert]) {
for alert in alerts {
let event = match alert.kind {
AlertRuleKind::DiskUsage => Some(WebhookEvent::DiskWarning),
AlertRuleKind::ContainerCrash => Some(WebhookEvent::ContainerCrash),
_ => None,
};
if let Some(event) = event {
let payload = WebhookPayload {
event,
title: format!("{:?} Alert", alert.kind),
message: alert.message.clone(),
timestamp: chrono::Utc::now().to_rfc3339(),
node_id: String::new(),
details: Some(serde_json::json!({
"value": alert.value,
"threshold": alert.threshold,
})),
};
webhooks::send_webhook(data_dir, payload).await;
}
}
}

View File

@ -0,0 +1,316 @@
use crate::monitoring::alerts::load_alert_rules;
use crate::monitoring::types::*;
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
use tokio::sync::RwLock;
/// Thread-safe metrics store with ring buffers at two resolutions.
pub struct MetricsStore {
minute_data: RwLock<VecDeque<MetricSnapshot>>,
quarter_hour_data: RwLock<VecDeque<MetricSnapshot>>,
minute_count: RwLock<u32>,
rpc_latency: RwLock<(f64, u64)>,
ws_connections: AtomicU32,
pub(crate) alert_rules: RwLock<Vec<AlertRule>>,
pub(crate) fired_alerts: RwLock<VecDeque<FiredAlert>>,
pub(crate) data_dir: Option<PathBuf>,
}
impl MetricsStore {
#[cfg(test)]
pub fn new() -> Self {
Self {
minute_data: RwLock::new(VecDeque::with_capacity(MAX_1MIN_ENTRIES)),
quarter_hour_data: RwLock::new(VecDeque::with_capacity(MAX_15MIN_ENTRIES)),
minute_count: RwLock::new(0),
rpc_latency: RwLock::new((0.0, 0)),
ws_connections: AtomicU32::new(0),
alert_rules: RwLock::new(AlertRule::default_rules()),
fired_alerts: RwLock::new(VecDeque::with_capacity(MAX_ALERT_HISTORY)),
data_dir: None,
}
}
/// Create a MetricsStore that persists alert rules to disk.
pub async fn with_data_dir(data_dir: PathBuf) -> Self {
let rules = load_alert_rules(&data_dir).await;
Self {
minute_data: RwLock::new(VecDeque::with_capacity(MAX_1MIN_ENTRIES)),
quarter_hour_data: RwLock::new(VecDeque::with_capacity(MAX_15MIN_ENTRIES)),
minute_count: RwLock::new(0),
rpc_latency: RwLock::new((0.0, 0)),
ws_connections: AtomicU32::new(0),
alert_rules: RwLock::new(rules),
fired_alerts: RwLock::new(VecDeque::with_capacity(MAX_ALERT_HISTORY)),
data_dir: Some(data_dir),
}
}
/// Record a new metric snapshot (called every minute by collector).
pub async fn push(&self, mut snapshot: MetricSnapshot) {
// Fill in RPC latency from accumulated samples
{
let mut latency = self.rpc_latency.write().await;
if latency.1 > 0 {
snapshot.rpc_latency_ms = (latency.0 / latency.1 as f64 * 10.0).round() / 10.0;
*latency = (0.0, 0);
}
}
// Fill in current WS connection count
snapshot.ws_connections = self.ws_connections.load(Ordering::Relaxed);
// Push to 1-minute ring buffer
{
let mut buf = self.minute_data.write().await;
if buf.len() >= MAX_1MIN_ENTRIES {
buf.pop_front();
}
buf.push_back(snapshot.clone());
}
// Every 15 minutes, push to quarter-hour ring buffer
{
let mut count = self.minute_count.write().await;
*count += 1;
if *count >= 15 {
*count = 0;
let mut buf = self.quarter_hour_data.write().await;
if buf.len() >= MAX_15MIN_ENTRIES {
buf.pop_front();
}
buf.push_back(snapshot);
}
}
}
/// Record an RPC request latency sample (milliseconds).
pub async fn record_rpc_latency(&self, latency_ms: f64) {
let mut data = self.rpc_latency.write().await;
data.0 += latency_ms;
data.1 += 1;
}
/// Increment WebSocket connection count (called on connect).
pub fn increment_ws(&self) {
self.ws_connections.fetch_add(1, Ordering::Relaxed);
}
/// Decrement WebSocket connection count (called on disconnect).
pub fn decrement_ws(&self) {
// Use saturating semantics to avoid underflow
let _ = self.ws_connections.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| {
if v > 0 { Some(v - 1) } else { Some(0) }
});
}
/// Get the latest snapshot.
pub async fn latest(&self) -> Option<MetricSnapshot> {
self.minute_data.read().await.back().cloned()
}
/// Get minute-resolution data for the last N minutes.
pub async fn history_minutes(&self, last_n: usize) -> Vec<MetricSnapshot> {
let buf = self.minute_data.read().await;
let start = buf.len().saturating_sub(last_n);
buf.iter().skip(start).cloned().collect()
}
/// Get quarter-hour-resolution data for the last N entries.
pub async fn history_quarter_hours(&self, last_n: usize) -> Vec<MetricSnapshot> {
let buf = self.quarter_hour_data.read().await;
let start = buf.len().saturating_sub(last_n);
buf.iter().skip(start).cloned().collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_metrics_store_new() {
let store = MetricsStore::new();
assert_eq!(store.ws_connections.load(Ordering::Relaxed), 0);
}
#[test]
fn test_ws_connection_tracking() {
let store = MetricsStore::new();
store.increment_ws();
store.increment_ws();
assert_eq!(store.ws_connections.load(Ordering::Relaxed), 2);
store.decrement_ws();
assert_eq!(store.ws_connections.load(Ordering::Relaxed), 1);
store.decrement_ws();
assert_eq!(store.ws_connections.load(Ordering::Relaxed), 0);
// Decrement below zero should stay at 0
store.decrement_ws();
assert_eq!(store.ws_connections.load(Ordering::Relaxed), 0);
}
#[tokio::test]
async fn test_push_and_latest() {
let store = MetricsStore::new();
assert!(store.latest().await.is_none());
let snapshot = MetricSnapshot {
timestamp: 1000,
system: SystemMetrics {
cpu_percent: 25.0,
mem_used_bytes: 1_000_000,
mem_total_bytes: 4_000_000,
disk_used_bytes: 500_000,
disk_total_bytes: 1_000_000,
net_rx_bytes: 100,
net_tx_bytes: 200,
load_avg_1: 1.0,
load_avg_5: 0.5,
load_avg_15: 0.3,
},
containers: vec![],
rpc_latency_ms: 0.0,
ws_connections: 0,
};
store.push(snapshot).await;
let latest = store.latest().await.unwrap();
assert_eq!(latest.timestamp, 1000);
assert_eq!(latest.system.cpu_percent, 25.0);
}
#[tokio::test]
async fn test_rpc_latency_recording() {
let store = MetricsStore::new();
store.record_rpc_latency(10.0).await;
store.record_rpc_latency(20.0).await;
store.record_rpc_latency(30.0).await;
let snapshot = MetricSnapshot {
timestamp: 2000,
system: SystemMetrics {
cpu_percent: 0.0,
mem_used_bytes: 0,
mem_total_bytes: 0,
disk_used_bytes: 0,
disk_total_bytes: 0,
net_rx_bytes: 0,
net_tx_bytes: 0,
load_avg_1: 0.0,
load_avg_5: 0.0,
load_avg_15: 0.0,
},
containers: vec![],
rpc_latency_ms: 0.0,
ws_connections: 0,
};
store.push(snapshot).await;
let latest = store.latest().await.unwrap();
assert_eq!(latest.rpc_latency_ms, 20.0); // average of 10+20+30 = 20
}
#[tokio::test]
async fn test_history_minutes() {
let store = MetricsStore::new();
for i in 0..5 {
let snapshot = MetricSnapshot {
timestamp: i * 60,
system: SystemMetrics {
cpu_percent: i as f64,
mem_used_bytes: 0,
mem_total_bytes: 0,
disk_used_bytes: 0,
disk_total_bytes: 0,
net_rx_bytes: 0,
net_tx_bytes: 0,
load_avg_1: 0.0,
load_avg_5: 0.0,
load_avg_15: 0.0,
},
containers: vec![],
rpc_latency_ms: 0.0,
ws_connections: 0,
};
store.push(snapshot).await;
}
let history = store.history_minutes(3).await;
assert_eq!(history.len(), 3);
assert_eq!(history[0].timestamp, 120);
assert_eq!(history[2].timestamp, 240);
}
#[tokio::test]
async fn test_ring_buffer_eviction() {
let store = MetricsStore::new();
// Push more than MAX_1MIN_ENTRIES
for i in 0..(MAX_1MIN_ENTRIES + 10) {
let snapshot = MetricSnapshot {
timestamp: i as i64,
system: SystemMetrics {
cpu_percent: 0.0,
mem_used_bytes: 0,
mem_total_bytes: 0,
disk_used_bytes: 0,
disk_total_bytes: 0,
net_rx_bytes: 0,
net_tx_bytes: 0,
load_avg_1: 0.0,
load_avg_5: 0.0,
load_avg_15: 0.0,
},
containers: vec![],
rpc_latency_ms: 0.0,
ws_connections: 0,
};
store.push(snapshot).await;
}
let all = store.history_minutes(MAX_1MIN_ENTRIES + 100).await;
assert_eq!(all.len(), MAX_1MIN_ENTRIES);
// Oldest entry should be 10 (first 10 were evicted)
assert_eq!(all[0].timestamp, 10);
}
#[tokio::test]
async fn test_quarter_hour_downsampling() {
let store = MetricsStore::new();
// Push exactly 15 entries to trigger one quarter-hour sample
for i in 0..15 {
let snapshot = MetricSnapshot {
timestamp: i * 60,
system: SystemMetrics {
cpu_percent: 50.0,
mem_used_bytes: 0,
mem_total_bytes: 0,
disk_used_bytes: 0,
disk_total_bytes: 0,
net_rx_bytes: 0,
net_tx_bytes: 0,
load_avg_1: 0.0,
load_avg_5: 0.0,
load_avg_15: 0.0,
},
containers: vec![],
rpc_latency_ms: 0.0,
ws_connections: 0,
};
store.push(snapshot).await;
}
let qh = store.history_quarter_hours(10).await;
assert_eq!(qh.len(), 1);
assert_eq!(qh[0].timestamp, 14 * 60); // The 15th entry (index 14)
}
#[test]
fn test_constants() {
assert_eq!(MAX_1MIN_ENTRIES, 1440);
assert_eq!(MAX_15MIN_ENTRIES, 672);
}
}

View File

@ -0,0 +1,209 @@
use crate::monitoring::store::MetricsStore;
use std::path::PathBuf;
use std::sync::Arc;
use tracing::{debug, info, warn};
/// Spawn the periodic telemetry reporter (runs every 15 minutes when opt-in enabled).
/// Collects anonymous health data and saves to disk. Posts to central collector if configured.
pub fn spawn_telemetry_reporter(
store: Arc<MetricsStore>,
state: Option<Arc<crate::state::StateManager>>,
data_dir: PathBuf,
) {
tokio::spawn(async move {
// Wait 60s for system to fully stabilize
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
let mut interval = tokio::time::interval(std::time::Duration::from_secs(900)); // 15 min
loop {
interval.tick().await;
// Check if telemetry is opted in
let config_path = data_dir.join("analytics-config.json");
let enabled = match tokio::fs::read_to_string(&config_path).await {
Ok(data) => serde_json::from_str::<serde_json::Value>(&data)
.ok()
.and_then(|c| c["enabled"].as_bool())
.unwrap_or(false),
Err(_) => false,
};
if !enabled {
debug!("Telemetry disabled — skipping report");
continue;
}
match build_telemetry_report(&store, &state, &data_dir).await {
Ok(report) => {
// Save latest report to disk
let report_path = data_dir.join("telemetry-latest.json");
if let Ok(json) = serde_json::to_string_pretty(&report) {
let _ = tokio::fs::write(&report_path, &json).await;
}
// Always save to local fleet directory so this node appears
// in its own fleet view
save_report_to_fleet_dir(&data_dir, &report).await;
// POST to central collector if configured
let collector_url = std::env::var("TELEMETRY_COLLECTOR_URL").ok();
if let Some(url) = collector_url {
match post_telemetry_report(&url, &report).await {
Ok(_) => info!("Telemetry report sent to collector"),
Err(e) => warn!("Failed to send telemetry report: {}", e),
}
} else {
debug!("Telemetry report saved locally (no collector URL configured)");
}
}
Err(e) => {
warn!("Failed to build telemetry report: {}", e);
}
}
}
});
}
/// Build an anonymous telemetry report from current system state.
async fn build_telemetry_report(
store: &Arc<MetricsStore>,
state: &Option<Arc<crate::state::StateManager>>,
data_dir: &std::path::Path,
) -> anyhow::Result<serde_json::Value> {
// Anonymous node ID — truncated SHA-256 hash of pubkey
let (node_id, version, container_count, running_count, peer_count) = if let Some(ref sm) = state {
let (data, _) = sm.get_snapshot().await;
let id = {
use sha2::{Sha256, Digest};
let mut h = Sha256::new();
h.update(data.server_info.pubkey.as_bytes());
hex::encode(h.finalize())[..16].to_string()
};
let running = data.package_data.values()
.filter(|p| matches!(p.state, crate::data_model::PackageState::Running))
.count();
(id, data.server_info.version.clone(), data.package_data.len(), running, data.peer_health.len())
} else {
("unknown".to_string(), "unknown".to_string(), 0, 0, 0)
};
// System info
let cpu_cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(0);
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);
// Latest metrics snapshot
let latest = store.latest().await;
let (cpu_pct, mem_pct, disk_pct): (f64, f64, f64) = latest.map(|s| {
let mem_total = s.system.mem_total_bytes as f64;
let disk_total = s.system.disk_total_bytes as f64;
(
s.system.cpu_percent,
if mem_total > 0.0 { (s.system.mem_used_bytes as f64 / mem_total) * 100.0 } else { 0.0 },
if disk_total > 0.0 { (s.system.disk_used_bytes as f64 / disk_total) * 100.0 } else { 0.0 },
)
}).unwrap_or((0.0, 0.0, 0.0));
// Recent alerts
let recent_alerts: Vec<serde_json::Value> = 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 _ = data_dir; // used for future per-app telemetry
Ok(serde_json::json!({
"node_id": node_id,
"version": version,
"uptime_secs": uptime_secs,
"cpu_cores": cpu_cores,
"cpu_pct": (cpu_pct * 10.0).round() / 10.0,
"mem_pct": (mem_pct * 10.0).round() / 10.0,
"disk_pct": (disk_pct * 10.0).round() / 10.0,
"container_count": container_count,
"running_count": running_count,
"federation_peers": peer_count,
"recent_alerts": recent_alerts,
"reported_at": chrono::Utc::now().to_rfc3339(),
}))
}
/// POST a telemetry report to the central collector.
async fn post_telemetry_report(url: &str, report: &serde_json::Value) -> anyhow::Result<()> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()?;
let response = client.post(url)
.header("Content-Type", "application/json")
.header("User-Agent", "Archipelago-Telemetry/1.0")
.json(report)
.send()
.await?;
if !response.status().is_success() {
anyhow::bail!("Collector returned {}", response.status());
}
Ok(())
}
/// Save a telemetry report into the local fleet directory.
/// This makes the node's own report visible in the fleet dashboard.
async fn save_report_to_fleet_dir(data_dir: &std::path::Path, report: &serde_json::Value) {
let node_id = match report.get("node_id").and_then(|v| v.as_str()) {
Some(id) if !id.is_empty() => id,
_ => {
warn!("Telemetry report missing node_id — skipping fleet save");
return;
}
};
let fleet_dir = data_dir.join("telemetry-fleet");
if let Err(e) = tokio::fs::create_dir_all(&fleet_dir).await {
warn!("Failed to create telemetry-fleet directory: {}", e);
return;
}
// Write latest report (overwrites previous)
let latest_path = fleet_dir.join(format!("{}.json", node_id));
match serde_json::to_string_pretty(report) {
Ok(json) => {
if let Err(e) = tokio::fs::write(&latest_path, &json).await {
warn!("Failed to write fleet report for {}: {}", node_id, e);
}
}
Err(e) => {
warn!("Failed to serialize fleet report: {}", e);
return;
}
}
// 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());
if history.len() > 200 {
let start = history.len() - 200;
history = history.split_off(start);
}
match serde_json::to_string_pretty(&history) {
Ok(json) => {
if let Err(e) = tokio::fs::write(&history_path, &json).await {
warn!("Failed to write fleet history for {}: {}", node_id, e);
}
}
Err(e) => {
warn!("Failed to serialize fleet history: {}", e);
}
}
debug!("Saved own telemetry report to fleet directory (node_id={})", node_id);
}

View File

@ -0,0 +1,81 @@
use serde::{Deserialize, Serialize};
/// Maximum entries at 1-minute resolution (24 hours = 1440 minutes)
pub const MAX_1MIN_ENTRIES: usize = 1440;
/// Maximum entries at 15-minute resolution (7 days = 672 quarter-hours)
pub const MAX_15MIN_ENTRIES: usize = 672;
/// Maximum number of fired alerts to keep in history.
pub const MAX_ALERT_HISTORY: usize = 100;
/// A single metrics snapshot collected at a point in time.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetricSnapshot {
pub timestamp: i64,
pub system: SystemMetrics,
pub containers: Vec<ContainerMetrics>,
pub rpc_latency_ms: f64,
pub ws_connections: u32,
}
/// System-wide resource metrics.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SystemMetrics {
pub cpu_percent: f64,
pub mem_used_bytes: u64,
pub mem_total_bytes: u64,
pub disk_used_bytes: u64,
pub disk_total_bytes: u64,
pub net_rx_bytes: u64,
pub net_tx_bytes: u64,
pub load_avg_1: f64,
pub load_avg_5: f64,
pub load_avg_15: f64,
}
/// Per-container resource metrics.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContainerMetrics {
pub name: String,
pub cpu_percent: f64,
pub mem_used_bytes: u64,
pub mem_limit_bytes: u64,
pub net_rx_bytes: u64,
pub net_tx_bytes: u64,
pub block_read_bytes: u64,
pub block_write_bytes: u64,
}
/// Types of alert rules the system can evaluate.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum AlertRuleKind {
DiskUsage,
RamUsage,
CpuLoad,
ContainerCrash,
BackendErrorSpike,
SslCertExpiry,
}
/// A configured alert rule with threshold and enabled state.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AlertRule {
pub kind: AlertRuleKind,
pub threshold: f64,
pub enabled: bool,
pub description: String,
}
/// A fired alert instance.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FiredAlert {
pub id: String,
pub kind: AlertRuleKind,
pub message: String,
pub value: f64,
pub threshold: f64,
pub timestamp: i64,
pub acknowledged: bool,
}

View File

@ -19,13 +19,6 @@ pub struct DhtDidCache {
}
impl DhtDidCache {
pub fn new() -> Self {
Self {
entries: RwLock::new(HashMap::new()),
ttl: std::time::Duration::from_secs(3600),
}
}
pub async fn get(&self, did: &str) -> Option<serde_json::Value> {
let entries = self.entries.read().await;
if let Some((ts, doc)) = entries.get(did) {

View File

@ -281,20 +281,3 @@ async fn sync_single_peer(
Ok(imported)
}
/// Add a peer as a sync target.
pub async fn add_sync_target(data_dir: &Path, onion: &str) -> Result<()> {
let mut state = load_sync_state(data_dir).await?;
if !state.peer_sync_targets.contains(&onion.to_string()) {
state.peer_sync_targets.push(onion.to_string());
save_sync_state(data_dir, &state).await?;
}
Ok(())
}
/// Remove a peer sync target.
pub async fn remove_sync_target(data_dir: &Path, onion: &str) -> Result<()> {
let mut state = load_sync_state(data_dir).await?;
state.peer_sync_targets.retain(|o| o != onion);
save_sync_state(data_dir, &state).await?;
Ok(())
}

View File

@ -7,7 +7,6 @@
use anyhow::{Context, Result};
use nostr_sdk::prelude::*;
use nostr_sdk::pool;
use std::net::SocketAddr;
use std::path::Path;
use tokio::fs;
@ -146,51 +145,6 @@ pub async fn revoke_if_needed(identity_dir: &Path, tor_proxy: Option<&str>) -> R
Ok(())
}
/// Publish node identity to Nostr relays for discovery.
/// Content: { did, node_address, version }
/// Only call when relays are non-empty (opt-in).
/// When tor_proxy is set, routes through Tor to prevent IP exposure.
/// Skips if nostr_revoked sentinel exists (revocation must not be overwritten).
pub async fn publish_node_identity(
identity_dir: &Path,
did: &str,
node_address: &str,
version: &str,
relays: &[String],
tor_proxy: Option<&str>,
) -> Result<pool::Output<EventId>> {
if relays.is_empty() {
anyhow::bail!("No relays configured for Nostr discovery");
}
if identity_dir.join(NOSTR_REVOKED_FILE).exists() {
tracing::debug!("Nostr discovery: skipping publish (revoked)");
return Err(anyhow::anyhow!("Nostr discovery revoked"));
}
let keys = load_or_create_nostr_keys(identity_dir).await?;
let client = build_nostr_client(keys, tor_proxy)?;
let content = serde_json::json!({
"did": did,
"node_address": node_address,
"version": version,
})
.to_string();
for url in relays {
let _ = client.add_relay(url).await;
}
client.connect().await;
let builder = EventBuilder::new(Kind::Custom(ARCHIPELAGO_KIND as u16), content)
.tag(Tag::identifier(D_TAG));
let output = client.send_event_builder(builder).await?;
client.disconnect().await;
Ok(output)
}
/// Get Nostr public key for this node (hex).
pub async fn get_nostr_pubkey(identity_dir: &Path) -> Result<String> {
let keys = load_or_create_nostr_keys(identity_dir).await?;

View File

@ -0,0 +1,191 @@
use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::RwLock;
/// Rate limiter for login attempts: max 5 failures per 60 seconds per IP.
#[derive(Clone)]
pub struct LoginRateLimiter {
attempts: Arc<RwLock<HashMap<IpAddr, Vec<Instant>>>>,
}
const MAX_ATTEMPTS: usize = 5;
const WINDOW_SECS: u64 = 60;
impl LoginRateLimiter {
pub fn new() -> Self {
Self {
attempts: Arc::new(RwLock::new(HashMap::new())),
}
}
pub async fn check(&self, ip: IpAddr) -> bool {
let mut attempts = self.attempts.write().await;
let now = Instant::now();
let entry = attempts.entry(ip).or_default();
entry.retain(|t| now.duration_since(*t).as_secs() < WINDOW_SECS);
entry.len() < MAX_ATTEMPTS
}
pub async fn record_failure(&self, ip: IpAddr) {
let mut attempts = self.attempts.write().await;
let entry = attempts.entry(ip).or_default();
entry.push(Instant::now());
}
/// Periodic cleanup of expired entries for IPs that are no longer active.
pub async fn cleanup(&self) {
let mut attempts = self.attempts.write().await;
let now = Instant::now();
attempts.retain(|_, timestamps| {
timestamps.retain(|t| now.duration_since(*t).as_secs() < WINDOW_SECS);
!timestamps.is_empty()
});
}
}
/// General-purpose rate limiter for sensitive endpoints.
/// Tracks request counts per (method, IP) with configurable limits and windows.
#[derive(Clone)]
pub struct EndpointRateLimiter {
/// Map of (method, ip) -> list of request timestamps
requests: Arc<RwLock<HashMap<(String, IpAddr), Vec<Instant>>>>, // Instant for monotonic rate limiting
/// Per-method configuration: (max_requests, window_secs)
limits: Arc<HashMap<String, (usize, u64)>>,
}
impl EndpointRateLimiter {
pub fn new() -> Self {
let mut limits = HashMap::new();
// Financial operations: strict limits
limits.insert("wallet.send".to_string(), (5usize, 300u64));
limits.insert("wallet.ecash-send".to_string(), (10, 300));
limits.insert("lnd.sendcoins".to_string(), (5, 300));
limits.insert("lnd.payinvoice".to_string(), (10, 300));
limits.insert("lnd.openchannel".to_string(), (3, 300));
limits.insert("lnd.closechannel".to_string(), (3, 300));
limits.insert("lnd.create-psbt".to_string(), (5, 300));
limits.insert("lnd.finalize-psbt".to_string(), (5, 300));
// Identity/credential operations
limits.insert("identity.create".to_string(), (10, 300));
limits.insert("identity.issue-credential".to_string(), (20, 300));
// Backup operations (resource-intensive)
limits.insert("backup.create".to_string(), (10, 600));
limits.insert("backup.restore".to_string(), (5, 600));
// Container operations
limits.insert("container-install".to_string(), (5, 300));
limits.insert("package.install".to_string(), (5, 300));
// S3 backup operations (resource-intensive)
limits.insert("backup.upload-s3".to_string(), (3, 600));
limits.insert("backup.download-s3".to_string(), (3, 600));
// System operations
limits.insert("update.apply".to_string(), (2, 600));
limits.insert("system.reboot".to_string(), (2, 300));
limits.insert("system.shutdown".to_string(), (2, 300));
// Password and TOTP changes
limits.insert("auth.changePassword".to_string(), (3, 300));
limits.insert("auth.totp.setup".to_string(), (3, 300));
limits.insert("auth.totp.confirm".to_string(), (5, 300));
// Federation join: prevent invite-code brute force
limits.insert("federation.join".to_string(), (5, 60));
limits.insert("federation.invite".to_string(), (10, 300));
// Inter-node federation RPCs (unauthenticated, need stricter limits)
limits.insert("federation.peer-joined".to_string(), (10, 60));
limits.insert("federation.peer-address-changed".to_string(), (10, 60));
limits.insert("federation.peer-did-changed".to_string(), (5, 60));
limits.insert("federation.get-state".to_string(), (30, 60));
// DID rotation: sensitive identity operation
limits.insert("node.rotate-did".to_string(), (3, 600));
Self {
requests: Arc::new(RwLock::new(HashMap::new())),
limits: Arc::new(limits),
}
}
/// Check if a request is allowed. Returns true if within limits.
pub async fn check(&self, method: &str, ip: IpAddr) -> bool {
let (max_req, window) = match self.limits.get(method) {
Some(config) => *config,
None => return true, // Not rate-limited
};
let key = (method.to_string(), ip);
let mut requests = self.requests.write().await;
let now = Instant::now();
let entry = requests.entry(key).or_default();
entry.retain(|t| now.duration_since(*t).as_secs() < window);
entry.len() < max_req
}
/// Record a request for rate limiting purposes.
pub async fn record(&self, method: &str, ip: IpAddr) {
if !self.limits.contains_key(method) {
return; // Not rate-limited, skip tracking
}
let key = (method.to_string(), ip);
let mut requests = self.requests.write().await;
let entry = requests.entry(key).or_default();
entry.push(Instant::now());
}
/// Periodic cleanup of expired entries.
pub async fn cleanup(&self) {
let mut requests = self.requests.write().await;
let now = Instant::now();
requests.retain(|(method, _), timestamps| {
let window = self
.limits
.get(method)
.map(|(_, w)| *w)
.unwrap_or(300);
timestamps.retain(|t| now.duration_since(*t).as_secs() < window);
!timestamps.is_empty()
});
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_rate_limiter_allows_under_limit() {
let limiter = LoginRateLimiter::new();
let ip: IpAddr = "127.0.0.1".parse().unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST));
for _ in 0..MAX_ATTEMPTS {
assert!(limiter.check(ip).await);
limiter.record_failure(ip).await;
}
}
#[tokio::test]
async fn test_rate_limiter_blocks_over_limit() {
let limiter = LoginRateLimiter::new();
let ip: IpAddr = "127.0.0.1".parse().unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST));
for _ in 0..MAX_ATTEMPTS {
limiter.record_failure(ip).await;
}
assert!(!limiter.check(ip).await);
}
#[tokio::test]
async fn test_rate_limiter_different_ips() {
let limiter = LoginRateLimiter::new();
let ip1: IpAddr = "127.0.0.1".parse().unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST));
let ip2: IpAddr = "192.168.1.1".parse().unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST));
for _ in 0..MAX_ATTEMPTS {
limiter.record_failure(ip1).await;
}
// ip1 should be blocked
assert!(!limiter.check(ip1).await);
// ip2 should still be allowed
assert!(limiter.check(ip2).await);
}
}

View File

@ -105,7 +105,7 @@ impl Server {
let identity = Arc::new(identity);
// Create metrics store and spawn background collector
let metrics_store = Arc::new(MetricsStore::with_data_dir(config.data_dir.clone()));
let metrics_store = Arc::new(MetricsStore::with_data_dir(config.data_dir.clone()).await);
let metrics_for_telemetry = metrics_store.clone();
crate::monitoring::spawn_metrics_collector(metrics_store.clone(), Some(state_manager.clone()), Some(config.data_dir.clone()));
@ -311,10 +311,6 @@ impl Server {
})
}
pub async fn serve(&self, addr: SocketAddr) -> Result<()> {
self.serve_with_shutdown(addr, std::future::pending()).await
}
/// Serve with a graceful shutdown signal.
/// When the shutdown future completes, stop accepting new connections and drain in-flight requests.
pub async fn serve_with_shutdown(

View File

@ -2,10 +2,9 @@ use hmac::{Hmac, Mac};
use rand::RngCore;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::net::IpAddr;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::sync::RwLock;
use zeroize::Zeroize;
@ -129,25 +128,6 @@ impl SessionStore {
}
}
/// Async wrapper for save — spawns to avoid blocking RPC.
fn schedule_save(&self, sessions: &HashMap<[u8; 32], Session>) {
let persisted: Vec<PersistedSession> = sessions
.iter()
.filter(|(_, s)| matches!(s.session_type, SessionType::Full))
.map(|(hash, s)| PersistedSession {
hash_hex: hex::encode(hash),
created_at: s.created_at.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(),
last_activity: s.last_activity.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(),
})
.collect();
let path = self.persist_path.clone();
tokio::spawn(async move {
if let Ok(json) = serde_json::to_string(&persisted) {
let _ = tokio::fs::write(path, json).await;
}
});
}
/// Create a full (authenticated) session. Returns the plaintext token.
/// Enforces max concurrent sessions by evicting the oldest if limit reached.
pub async fn create(&self) -> String {
@ -303,6 +283,7 @@ impl SessionStore {
}
/// Remove all expired sessions (cleanup).
#[cfg(test)]
pub async fn cleanup_expired(&self) {
let mut sessions = self.sessions.write().await;
sessions.retain(|_, session| {
@ -336,6 +317,7 @@ impl SessionStore {
}
/// Get the number of active full sessions.
#[cfg(test)]
pub async fn active_session_count(&self) -> usize {
let sessions = self.sessions.read().await;
sessions
@ -447,147 +429,6 @@ pub fn extract_session_cookie(headers: &hyper::HeaderMap) -> Option<String> {
.filter(|v| !v.is_empty())
}
/// Rate limiter for login attempts: max 5 failures per 60 seconds per IP.
#[derive(Clone)]
pub struct LoginRateLimiter {
attempts: Arc<RwLock<HashMap<IpAddr, Vec<Instant>>>>,
}
const MAX_ATTEMPTS: usize = 5;
const WINDOW_SECS: u64 = 60;
impl LoginRateLimiter {
pub fn new() -> Self {
Self {
attempts: Arc::new(RwLock::new(HashMap::new())),
}
}
pub async fn check(&self, ip: IpAddr) -> bool {
let mut attempts = self.attempts.write().await;
let now = Instant::now();
let entry = attempts.entry(ip).or_default();
entry.retain(|t| now.duration_since(*t).as_secs() < WINDOW_SECS);
entry.len() < MAX_ATTEMPTS
}
pub async fn record_failure(&self, ip: IpAddr) {
let mut attempts = self.attempts.write().await;
let entry = attempts.entry(ip).or_default();
entry.push(Instant::now());
}
/// Periodic cleanup of expired entries for IPs that are no longer active.
pub async fn cleanup(&self) {
let mut attempts = self.attempts.write().await;
let now = Instant::now();
attempts.retain(|_, timestamps| {
timestamps.retain(|t| now.duration_since(*t).as_secs() < WINDOW_SECS);
!timestamps.is_empty()
});
}
}
/// General-purpose rate limiter for sensitive endpoints.
/// Tracks request counts per (method, IP) with configurable limits and windows.
#[derive(Clone)]
pub struct EndpointRateLimiter {
/// Map of (method, ip) -> list of request timestamps
requests: Arc<RwLock<HashMap<(String, IpAddr), Vec<Instant>>>>, // Instant for monotonic rate limiting
/// Per-method configuration: (max_requests, window_secs)
limits: Arc<HashMap<String, (usize, u64)>>,
}
impl EndpointRateLimiter {
pub fn new() -> Self {
let mut limits = HashMap::new();
// Financial operations: strict limits
limits.insert("wallet.send".to_string(), (5usize, 300u64));
limits.insert("wallet.ecash-send".to_string(), (10, 300));
limits.insert("lnd.sendcoins".to_string(), (5, 300));
limits.insert("lnd.payinvoice".to_string(), (10, 300));
limits.insert("lnd.openchannel".to_string(), (3, 300));
limits.insert("lnd.closechannel".to_string(), (3, 300));
limits.insert("lnd.create-psbt".to_string(), (5, 300));
limits.insert("lnd.finalize-psbt".to_string(), (5, 300));
// Identity/credential operations
limits.insert("identity.create".to_string(), (10, 300));
limits.insert("identity.issue-credential".to_string(), (20, 300));
// Backup operations (resource-intensive)
limits.insert("backup.create".to_string(), (10, 600));
limits.insert("backup.restore".to_string(), (5, 600));
// Container operations
limits.insert("container-install".to_string(), (5, 300));
limits.insert("package.install".to_string(), (5, 300));
// S3 backup operations (resource-intensive)
limits.insert("backup.upload-s3".to_string(), (3, 600));
limits.insert("backup.download-s3".to_string(), (3, 600));
// System operations
limits.insert("update.apply".to_string(), (2, 600));
limits.insert("system.reboot".to_string(), (2, 300));
limits.insert("system.shutdown".to_string(), (2, 300));
// Password and TOTP changes
limits.insert("auth.changePassword".to_string(), (3, 300));
limits.insert("auth.totp.setup".to_string(), (3, 300));
limits.insert("auth.totp.confirm".to_string(), (5, 300));
// Federation join: prevent invite-code brute force
limits.insert("federation.join".to_string(), (5, 60));
limits.insert("federation.invite".to_string(), (10, 300));
// Inter-node federation RPCs (unauthenticated, need stricter limits)
limits.insert("federation.peer-joined".to_string(), (10, 60));
limits.insert("federation.peer-address-changed".to_string(), (10, 60));
limits.insert("federation.peer-did-changed".to_string(), (5, 60));
limits.insert("federation.get-state".to_string(), (30, 60));
// DID rotation: sensitive identity operation
limits.insert("node.rotate-did".to_string(), (3, 600));
Self {
requests: Arc::new(RwLock::new(HashMap::new())),
limits: Arc::new(limits),
}
}
/// Check if a request is allowed. Returns true if within limits.
pub async fn check(&self, method: &str, ip: IpAddr) -> bool {
let (max_req, window) = match self.limits.get(method) {
Some(config) => *config,
None => return true, // Not rate-limited
};
let key = (method.to_string(), ip);
let mut requests = self.requests.write().await;
let now = Instant::now();
let entry = requests.entry(key).or_default();
entry.retain(|t| now.duration_since(*t).as_secs() < window);
entry.len() < max_req
}
/// Record a request for rate limiting purposes.
pub async fn record(&self, method: &str, ip: IpAddr) {
if !self.limits.contains_key(method) {
return; // Not rate-limited, skip tracking
}
let key = (method.to_string(), ip);
let mut requests = self.requests.write().await;
let entry = requests.entry(key).or_default();
entry.push(Instant::now());
}
/// Periodic cleanup of expired entries.
pub async fn cleanup(&self) {
let mut requests = self.requests.write().await;
let now = Instant::now();
requests.retain(|(method, _), timestamps| {
let window = self
.limits
.get(method)
.map(|(_, w)| *w)
.unwrap_or(300);
timestamps.retain(|t| now.duration_since(*t).as_secs() < window);
!timestamps.is_empty()
});
}
}
#[cfg(test)]
mod tests {
@ -669,44 +510,6 @@ mod tests {
assert_eq!(extract_session_cookie(&headers), None);
}
#[tokio::test]
async fn test_rate_limiter_allows_under_limit() {
let limiter = LoginRateLimiter::new();
let ip: IpAddr = "127.0.0.1".parse().unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST));
for _ in 0..MAX_ATTEMPTS {
assert!(limiter.check(ip).await);
limiter.record_failure(ip).await;
}
}
#[tokio::test]
async fn test_rate_limiter_blocks_over_limit() {
let limiter = LoginRateLimiter::new();
let ip: IpAddr = "127.0.0.1".parse().unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST));
for _ in 0..MAX_ATTEMPTS {
limiter.record_failure(ip).await;
}
assert!(!limiter.check(ip).await);
}
#[tokio::test]
async fn test_rate_limiter_different_ips() {
let limiter = LoginRateLimiter::new();
let ip1: IpAddr = "127.0.0.1".parse().unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST));
let ip2: IpAddr = "192.168.1.1".parse().unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST));
for _ in 0..MAX_ATTEMPTS {
limiter.record_failure(ip1).await;
}
// ip1 should be blocked
assert!(!limiter.check(ip1).await);
// ip2 should still be allowed
assert!(limiter.check(ip2).await);
}
#[tokio::test]
async fn test_session_activity_updates_on_validate() {

View File

@ -82,7 +82,7 @@ pub fn setup(password: &str) -> Result<SetupResult> {
6,
1, // skew
30,
Secret::Raw(totp_secret.clone()).to_bytes().unwrap(),
Secret::Raw(totp_secret.clone()).to_bytes().map_err(|_| anyhow::anyhow!("Invalid TOTP secret"))?,
Some("Archipelago".to_string()),
"node".to_string(),
)

View File

@ -1,3 +1,5 @@
// WIP mesh/transport protocol — suppress dead code warnings
#![allow(dead_code)]
//! Chunked message protocol with Reed-Solomon FEC for LoRa transport.
//!
//! Splits payloads larger than a single LoRa frame (160 bytes) into

View File

@ -1,3 +1,5 @@
// WIP mesh/transport protocol — suppress dead code warnings
#![allow(dead_code)]
//! CBOR delta encoding for federation state sync.
//!
//! Instead of sending a full NodeStateSnapshot (~500-2000 bytes JSON) on every

View File

@ -1,3 +1,5 @@
// WIP mesh/transport protocol — suppress dead code warnings
#![allow(dead_code)]
//! LAN transport — peer discovery via mDNS and direct HTTP messaging.
//!
//! Advertises this node as `_archipelago._tcp.local.` with TXT records
@ -12,8 +14,7 @@ use mdns_sd::{ServiceDaemon, ServiceEvent, ServiceInfo};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
use tracing::{debug, info, warn};
use tracing::{debug, info};
const SERVICE_TYPE: &str = "_archipelago._tcp.local.";
const DEFAULT_PORT: u16 = 5678;

View File

@ -1,10 +1,12 @@
// WIP mesh/transport protocol — suppress dead code warnings
#![allow(dead_code)]
//! Mesh transport — sends messages via LoRa radio through the MeshService.
//!
//! Bridges the transport abstraction to the existing mesh serial listener.
//! For payloads exceeding the LoRa frame limit (160 bytes), uses the chunking
//! protocol with Reed-Solomon FEC for reliable delivery.
use super::chunking::{self, ChunkReassembler, MAX_CHUNK_PAYLOAD};
use super::chunking::{self, ChunkReassembler};
use super::{NodeTransport, TransportKind, TransportMessage};
use crate::mesh::MeshService;
use anyhow::{Context, Result};

View File

@ -1,3 +1,5 @@
// WIP mesh/transport protocol — suppress dead code warnings
#![allow(dead_code)]
//! Transport abstraction layer for Archipelago node-to-node communication.
//!
//! Unifies mesh radio (LoRa), LAN (mDNS), and Tor under a common trait.

View File

@ -1,9 +1,11 @@
// WIP mesh/transport protocol — suppress dead code warnings
#![allow(dead_code)]
//! Tor transport — sends messages via HTTP POST through SOCKS5 proxy.
//!
//! Wraps the existing `node_message.rs` Tor messaging logic behind
//! the `NodeTransport` trait.
use super::{MessageType, NodeTransport, TransportKind, TransportMessage};
use super::{NodeTransport, TransportKind, TransportMessage};
use anyhow::{Context, Result};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;

View File

@ -5,7 +5,7 @@ use chrono::Timelike;
use serde::{Deserialize, Serialize};
use std::path::Path;
use tokio::fs;
use tracing::{debug, info, warn};
use tracing::{debug, info};
const DEFAULT_UPDATE_MANIFEST_URL: &str =
"https://raw.githubusercontent.com/archipelago-os/releases/main/manifest.json";
@ -301,83 +301,6 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
Ok(())
}
/// Rolling container restart — restarts containers one at a time with health checks.
/// This enables zero-downtime updates for containerized apps.
pub async fn rolling_container_restart() -> Result<RollingRestartReport> {
use std::process::Command;
let output = Command::new("podman")
.args(["ps", "--format", "{{.Names}}"])
.output()
.context("Failed to list containers")?;
let names: Vec<String> = String::from_utf8_lossy(&output.stdout)
.lines()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
let total = names.len();
let mut restarted = 0;
let mut failed = Vec::new();
info!(total = total, "Starting rolling container restart");
for name in &names {
debug!(container = %name, "Restarting container");
let restart = Command::new("podman")
.args(["restart", "--time", "30", name])
.output();
match restart {
Ok(out) if out.status.success() => {
// Wait for container to be healthy
let mut healthy = false;
for _ in 0..12 {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
let check = Command::new("podman")
.args(["inspect", name, "--format", "{{.State.Status}}"])
.output();
if let Ok(out) = check {
let status = String::from_utf8_lossy(&out.stdout).trim().to_string();
if status == "running" {
healthy = true;
break;
}
}
}
if healthy {
restarted += 1;
debug!(container = %name, "Container restarted successfully");
} else {
failed.push(name.clone());
warn!(container = %name, "Container not healthy after restart");
}
}
_ => {
failed.push(name.clone());
warn!(container = %name, "Container restart command failed");
}
}
}
info!(restarted = restarted, failed = failed.len(), "Rolling restart complete");
Ok(RollingRestartReport {
total,
restarted,
failed,
})
}
/// Report from a rolling container restart.
#[derive(Debug, Serialize, Deserialize)]
pub struct RollingRestartReport {
pub total: usize,
pub restarted: usize,
pub failed: Vec<String>,
}
/// Rollback to the previous version from backup.
pub async fn rollback_update(data_dir: &Path) -> Result<()> {
let backup_dir = data_dir.join("update-backup");

View File

@ -6,8 +6,6 @@ use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
use tokio::fs;
use tracing::debug;
use super::ecash;
const PROFITS_FILE: &str = "wallet/profits.json";
@ -55,42 +53,6 @@ pub async fn load_profits(data_dir: &Path) -> Result<ProfitsSummary> {
Ok(summary)
}
/// Save profits summary to disk.
pub async fn save_profits(data_dir: &Path, summary: &ProfitsSummary) -> Result<()> {
let dir = data_dir.join("wallet");
fs::create_dir_all(&dir)
.await
.context("Failed to create wallet dir")?;
let path = data_dir.join(PROFITS_FILE);
let content =
serde_json::to_string_pretty(summary).context("Failed to serialize profits")?;
fs::write(&path, content)
.await
.context("Failed to write profits file")?;
Ok(())
}
/// Record a content sale profit.
pub async fn record_content_sale(data_dir: &Path, amount_sats: u64, description: &str) -> Result<()> {
let mut summary = load_profits(data_dir).await?;
summary.total_sats += amount_sats;
summary.content_sales_sats += amount_sats;
summary.recent.insert(
0,
ProfitEntry {
source: ProfitSource::ContentSale,
amount_sats,
timestamp: chrono::Utc::now().to_rfc3339(),
description: description.to_string(),
},
);
// Keep only the last 100 entries
summary.recent.truncate(100);
save_profits(data_dir, &summary).await?;
debug!("Recorded content sale: {} sats", amount_sats);
Ok(())
}
/// Compute a full profits summary including ecash receive transactions.
pub async fn get_networking_profits(data_dir: &Path) -> Result<ProfitsSummary> {
let mut summary = load_profits(data_dir).await?;

View File

@ -297,7 +297,7 @@ impl PodmanClient {
}
}
let mut cap_add: Vec<String> = manifest.app.security.capabilities.clone();
let cap_add: Vec<String> = manifest.app.security.capabilities.clone();
let cap_drop = vec!["ALL".to_string()];
let body = serde_json::json!({

View File

@ -38,7 +38,7 @@ pub struct ExpiringSecret {
pub struct SecretsManager {
secrets_dir: PathBuf,
cipher: Aes256Gcm,
raw_key: zeroize::Zeroizing<[u8; 32]>,
_raw_key: zeroize::Zeroizing<[u8; 32]>,
}
impl SecretsManager {
@ -57,7 +57,7 @@ impl SecretsManager {
Ok(Self {
secrets_dir,
cipher,
raw_key: zeroize::Zeroizing::new(key_array),
_raw_key: zeroize::Zeroizing::new(key_array),
})
}
@ -285,7 +285,7 @@ impl SecretsManager {
if !app_path.is_dir() {
continue;
}
let app_id = app_path
let _app_id = app_path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("")

View File

@ -0,0 +1,19 @@
<!DOCTYPE html>
<html>
<head>
<title>Error</title>
<style>
html { color-scheme: light dark; }
body { width: 35em; margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif; }
</style>
</head>
<body>
<h1>An error occurred.</h1>
<p>Sorry, the page you are looking for is currently unavailable.<br/>
Please try again later.</p>
<p>If you are the system administrator of this resource then you should check
the error log for details.</p>
<p><em>Faithfully yours, nginx.</em></p>
</body>
</html>

View File

@ -0,0 +1,7 @@
FROM docker.io/library/nginx:alpine
COPY index.html /usr/share/nginx/html/
COPY 50x.html /usr/share/nginx/html/
COPY assets/ /usr/share/nginx/html/assets/
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 8334
CMD ["nginx", "-g", "daemon off;"]

View File

@ -0,0 +1,818 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>Bitcoin Knots - Archipelago</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', sans-serif;
min-height: 100vh;
color: white;
overflow-x: hidden;
}
/* Background - Web5 style */
.bg-perspective-container {
position: fixed;
inset: 0;
z-index: -10;
perspective: 1000px;
perspective-origin: 50% 50%;
overflow: hidden;
}
.bg-layer {
position: absolute;
inset: 0;
background-image: url('/assets/img/bg-network.jpg');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
transition: all 0.45s cubic-bezier(0.68, -0.55, 0.265, 1.55);
transform-style: preserve-3d;
opacity: 1;
transform: translateZ(0) scale(1);
}
/* Dark overlay - Web5 style (0.8 opacity) */
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.8);
z-index: -5;
pointer-events: none;
}
/* Glass card - Archipelago standard with gradient border */
.glass-card {
position: relative;
background: rgba(0, 0, 0, 0.60);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.45),
inset 0 1px 0 rgba(255, 255, 255, 0.22);
border-radius: 1rem;
overflow-x: hidden;
overflow-y: visible;
border: none;
}
.glass-card::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
padding: 2px;
background: linear-gradient(135deg, rgba(0, 0, 0, 0.8), transparent);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
z-index: 1;
}
.glass-card > * {
position: relative;
z-index: 2;
}
/* Glass button - Archipelago standard (secondary actions) */
.glass-button {
background-color: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
border: 1px solid rgba(255, 255, 255, 0.18);
color: rgba(255, 255, 255, 0.9);
transition: all 0.3s ease;
}
.glass-button:hover {
color: white;
background-color: rgba(0, 0, 0, 0.7);
}
/* Gradient button - Archipelago standard (primary actions) */
.gradient-button {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.15) 0%, rgba(0, 0, 0, 0.8) 100%);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
color: rgba(255, 255, 255, 0.95);
transition: all 0.3s ease;
}
.gradient-button:hover {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.2) 0%, rgba(0, 0, 0, 0.9) 100%);
border-color: rgba(255, 255, 255, 0.3);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6);
transform: translateY(-1px);
}
.gradient-button:active {
transform: translateY(1px);
}
/* Interactive card - Archipelago standard (display only, no hover) */
.info-card {
position: relative;
background: rgba(0, 0, 0, 0.60);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.45),
inset 0 1px 0 rgba(255, 255, 255, 0.22);
border-radius: 16px;
padding: 12px;
border: none;
}
.info-card::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
padding: 2px;
background: linear-gradient(135deg, rgba(0, 0, 0, 0.8), transparent);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
/* Interactive button - Same as info-card but with hover effects */
.info-card-button {
position: relative;
background: rgba(0, 0, 0, 0.60);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.45),
inset 0 1px 0 rgba(255, 255, 255, 0.22);
border-radius: 16px;
padding: 12px;
transition: all 0.3s ease;
border: none;
cursor: pointer;
color: rgba(255, 255, 255, 0.9);
}
.info-card-button::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
padding: 2px;
background: linear-gradient(135deg, rgba(0, 0, 0, 0.8), transparent);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
transition: all 0.3s ease;
}
.info-card-button:hover {
transform: translateY(-2px);
background: rgba(0, 0, 0, 0.35);
box-shadow:
0 12px 32px rgba(0, 0, 0, 0.6),
inset 0 1px 0 rgba(255, 255, 255, 0.25);
color: rgba(255, 255, 255, 1);
}
.info-card-button:hover::before {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.3), transparent);
}
.info-card-button:active {
transform: translateY(1px);
}
/* Container */
.container {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
padding-bottom: 4rem;
}
/* Logo gradient border */
.logo-gradient-border {
position: relative;
border-radius: 16px;
padding: 3px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.6) 0%, rgba(0, 0, 0, 0.8) 100%);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
display: inline-block;
}
.logo-gradient-border::after {
content: '';
position: absolute;
inset: 3px;
border-radius: 13px;
background: #fff;
z-index: 0;
}
.logo-gradient-border img {
border-radius: 13px;
display: block;
position: relative;
z-index: 1;
width: 64px;
height: 64px;
}
/* Ping animation for status dots */
@keyframes ping {
75%, 100% {
transform: scale(2);
opacity: 0;
}
}
.animate-ping {
animation: ping 1s cubic-bezier(0, 0, 0.2, 1) infinite;
}
/* Pulsing glow for progress bar */
@keyframes progressGlow {
0%, 100% {
box-shadow: 0 0 10px rgba(251, 146, 60, 0.5),
0 0 20px rgba(251, 146, 60, 0.3),
0 0 30px rgba(251, 146, 60, 0.1);
}
50% {
box-shadow: 0 0 20px rgba(251, 146, 60, 0.8),
0 0 30px rgba(251, 146, 60, 0.5),
0 0 40px rgba(251, 146, 60, 0.3);
}
}
.progress-glow {
animation: progressGlow 2s ease-in-out infinite;
}
/* Spinning animation */
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin-slow {
animation: spin 3s linear infinite;
}
/* Shimmer effect */
@keyframes shimmer {
0% {
background-position: -1000px 0;
}
100% {
background-position: 1000px 0;
}
}
.shimmer {
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.1) 50%,
rgba(255, 255, 255, 0) 100%
);
background-size: 1000px 100%;
animation: shimmer 3s infinite;
}
/* Number increment animation */
@keyframes numberPulse {
0%, 100% {
transform: scale(1);
color: rgba(255, 255, 255, 0.9);
}
50% {
transform: scale(1.05);
color: rgba(251, 146, 60, 1);
}
}
.number-update {
animation: numberPulse 0.5s ease-in-out;
}
</style>
</head>
<body>
<div class="bg-perspective-container">
<div class="bg-layer"></div>
</div>
<div class="overlay"></div>
<div class="container">
<!-- Header - Glass card with logo and node info -->
<div class="glass-card p-6 mb-6">
<div class="flex flex-col md:flex-row items-center md:items-center gap-4 md:gap-6">
<!-- Logo - Top Left -->
<div class="flex-shrink-0">
<div class="logo-gradient-border">
<img
src="/assets/img/app-icons/bitcoin-knots.webp"
alt="Bitcoin Knots"
class="w-16 h-16"
style="object-fit: contain;"
onerror="this.style.display='none'"
/>
</div>
</div>
<!-- Title and Description -->
<div class="flex-1 min-w-0">
<h1 class="text-3xl font-bold text-white mb-2">Bitcoin Knots</h1>
<p class="text-white/70">Enhanced Bitcoin node implementation</p>
</div>
<!-- Node Status Info - Compact on Desktop -->
<div class="w-full md:w-auto flex flex-col md:flex-row gap-3 md:gap-4 mt-4 md:mt-0">
<div class="info-card flex items-center gap-3">
<div class="relative">
<div class="w-3 h-3 rounded-full bg-green-400"></div>
<div class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-ping opacity-75"></div>
</div>
<div>
<p class="text-xs text-white/60">Status</p>
<p class="text-sm font-medium text-white">Running</p>
</div>
</div>
<div class="info-card flex items-center gap-3">
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<div>
<p class="text-xs text-white/60">Version</p>
<p class="text-sm font-medium text-white" id="nodeVersion">Loading...</p>
</div>
</div>
<div class="info-card flex items-center gap-3">
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
<div>
<p class="text-xs text-white/60">Network</p>
<p class="text-sm font-medium text-white" id="networkType">Loading...</p>
</div>
</div>
<button
onclick="openSettings()"
class="px-4 py-3 glass-button rounded-lg text-sm font-medium"
>
Settings
</button>
</div>
</div>
</div>
<!-- Blockchain Sync Status Card - NEW -->
<div class="glass-card p-6 mb-6" id="syncStatusCard">
<div class="flex items-start gap-4 mb-4">
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-orange-500/20 flex items-center justify-center">
<svg class="w-6 h-6 text-orange-500 animate-spin-slow" fill="none" stroke="currentColor" viewBox="0 0 24 24" id="syncIcon">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</div>
<div class="flex-1">
<h2 class="text-xl font-semibold text-white mb-1">Blockchain Sync</h2>
<p class="text-white/70 text-sm" id="syncStatusText">Checking sync status...</p>
</div>
</div>
<!-- Progress Bar -->
<div class="mb-4">
<div class="flex justify-between text-sm text-white/60 mb-2">
<span id="currentBlock">Block 0</span>
<span id="syncPercentage">0%</span>
</div>
<div class="w-full bg-white/10 rounded-full h-3 overflow-hidden relative shimmer">
<div class="h-full bg-gradient-to-r from-orange-500 to-yellow-400 rounded-full transition-all duration-500 progress-glow" id="syncProgressBar" style="width: 0%"></div>
</div>
</div>
<!-- Sync Stats Grid -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<div class="info-card">
<p class="text-xs text-white/60 mb-1">Current Height</p>
<p class="text-lg font-semibold text-white transition-all" id="currentHeight">-</p>
</div>
<div class="info-card">
<p class="text-xs text-white/60 mb-1">Network Height</p>
<p class="text-lg font-semibold text-white" id="networkHeight">-</p>
</div>
<div class="info-card">
<p class="text-xs text-white/60 mb-1">Headers</p>
<p class="text-lg font-semibold text-white" id="headers">-</p>
</div>
<div class="info-card">
<p class="text-xs text-white/60 mb-1">Verification</p>
<p class="text-lg font-semibold text-white" id="verificationProgress">-</p>
</div>
</div>
</div>
<!-- Core Services Overview Cards - Web5 style -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<div class="glass-card p-6">
<div class="flex items-start gap-4 mb-4">
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
</svg>
</div>
<div class="flex-1">
<h2 class="text-xl font-semibold text-white mb-2">RPC Connection</h2>
<p class="text-white/70 text-sm mb-4">JSON-RPC API access</p>
</div>
</div>
<div class="space-y-3">
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
<span class="text-white/80 text-sm">RPC Host</span>
</div>
<span class="text-white/60 text-sm font-mono" id="rpcHost">localhost:8332</span>
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<span class="text-white/80 text-sm">RPC User</span>
</div>
<span class="text-white/60 text-sm font-mono">archipelago</span>
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<span class="text-white/80 text-sm">RPC Status</span>
</div>
<span class="text-green-400 text-sm font-medium">Connected</span>
</div>
</div>
<button class="mt-4 w-full info-card-button text-sm font-medium" onclick="copyRPCInfo()">
Copy RPC Info
</button>
</div>
<!-- ZMQ Notifications -->
<div class="glass-card p-6">
<div class="flex items-start gap-4 mb-4">
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
</div>
<div class="flex-1">
<h2 class="text-xl font-semibold text-white mb-2">ZMQ Notifications</h2>
<p class="text-white/70 text-sm mb-4">Real-time block and transaction updates</p>
</div>
</div>
<div class="space-y-3">
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
</svg>
<span class="text-white/80 text-sm">Block Notifications</span>
</div>
<span class="text-white/60 text-sm font-mono">tcp://localhost:28332</span>
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<span class="text-white/80 text-sm">TX Notifications</span>
</div>
<span class="text-white/60 text-sm font-mono">tcp://localhost:28333</span>
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<span class="text-white/80 text-sm">ZMQ Status</span>
</div>
<span class="text-green-400 text-sm font-medium">Active</span>
</div>
</div>
<button class="mt-4 w-full info-card-button text-sm font-medium" onclick="openLogs()">
View Logs
</button>
</div>
</div>
</div>
<!-- Settings Modal -->
<div class="modal hidden fixed inset-0 bg-black/80 backdrop-blur-sm z-50 items-center justify-center p-4" id="settingsModal">
<div class="glass-card p-6 max-w-2xl w-full max-h-[80vh] overflow-y-auto">
<div class="flex justify-between items-center mb-4">
<h2 class="text-2xl font-bold text-white">Node Settings</h2>
<button onclick="closeSettings()" class="glass-button px-3 py-2 rounded-lg text-xl font-medium">×</button>
</div>
<div class="space-y-3">
<div class="p-3 bg-white/5 rounded-lg">
<div class="font-semibold text-white mb-1">Network Mode</div>
<div class="text-white/70 text-sm">Regtest (Development)</div>
</div>
<div class="p-3 bg-white/5 rounded-lg">
<div class="font-semibold text-white mb-1">Transaction Index</div>
<div class="text-white/70 text-sm">Enabled (txindex=1)</div>
</div>
<div class="p-3 bg-white/5 rounded-lg">
<div class="font-semibold text-white mb-1">ZMQ Publishing</div>
<div class="text-white/70 text-sm">Block & TX notifications enabled</div>
</div>
<div class="p-3 bg-white/5 rounded-lg">
<div class="font-semibold text-white mb-1">RPC Access</div>
<div class="text-white/70 text-sm">Enabled on 0.0.0.0:18443</div>
</div>
</div>
</div>
</div>
<!-- Logs Modal -->
<div class="modal hidden fixed inset-0 bg-black/80 backdrop-blur-sm z-50 items-center justify-center p-4" id="logsModal">
<div class="glass-card p-6 max-w-4xl w-full max-h-[80vh] overflow-y-auto">
<div class="flex justify-between items-center mb-4">
<h2 class="text-2xl font-bold text-white">Node Logs</h2>
<button onclick="closeLogs()" class="glass-button px-3 py-2 rounded-lg text-xl font-medium">×</button>
</div>
<div class="bg-black/40 rounded-lg p-4 font-mono text-xs text-white/80 whitespace-pre-wrap break-all" id="logsContent">
Loading logs...
</div>
</div>
</div>
<script>
console.log('[Bitcoin UI] Script loaded, initializing...');
// RPC Configuration - Use local Nginx proxy within container
const RPC_ENDPOINT = '/bitcoin-rpc/';
console.log('[Bitcoin UI] RPC Endpoint:', RPC_ENDPOINT);
// Make RPC call to Bitcoin node via local proxy
async function callRPC(method, params = []) {
try {
console.log(`[Bitcoin UI] Calling RPC method: ${method}`);
const response = await fetch(RPC_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
jsonrpc: '1.0',
id: 'bitcoin-ui',
method: method,
params: params
})
});
console.log(`[Bitcoin UI] RPC response status: ${response.status}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
console.log(`[Bitcoin UI] RPC ${method} success:`, data.result ? 'OK' : 'Error');
if (data.error) {
throw new Error(data.error.message);
}
return data.result;
} catch (error) {
console.error(`[Bitcoin UI] RPC call failed: ${method}`, error);
return null;
}
}
// Track last block count for animations
let lastBlockCount = 0;
// Update blockchain info
async function updateBlockchainInfo() {
console.log('[Bitcoin UI] updateBlockchainInfo() called');
try {
const blockchainInfo = await callRPC('getblockchaininfo');
console.log('[Bitcoin UI] blockchainInfo:', blockchainInfo);
if (!blockchainInfo) {
console.error('[Bitcoin UI] No blockchain info received');
document.getElementById('syncStatusText').textContent = 'Unable to connect to Bitcoin node';
document.getElementById('syncStatusText').className = 'text-red-400 text-sm';
return;
}
const networkInfo = await callRPC('getnetworkinfo');
// Update network mode
const chain = blockchainInfo.chain || 'unknown';
const networkType = document.getElementById('networkType');
let networkShort = '';
if (chain === 'regtest') {
networkShort = 'Regtest';
} else if (chain === 'test') {
networkShort = 'Testnet';
} else if (chain === 'main') {
networkShort = 'Mainnet';
} else {
networkShort = chain;
}
if (networkType) networkType.textContent = networkShort;
// Update sync status
const blocks = blockchainInfo.blocks || 0;
const headers = blockchainInfo.headers || 0;
const verificationProgress = blockchainInfo.verificationprogress || 0;
const isSynced = blocks >= headers - 1;
// Calculate actual sync percentage based on blocks/headers
const actualSyncPercentage = headers > 0 ? ((blocks / headers) * 100).toFixed(2) : '0.00';
const verificationPercentage = (verificationProgress * 100).toFixed(2);
// Animate block count if it changed
const currentHeightElem = document.getElementById('currentHeight');
if (blocks !== lastBlockCount && lastBlockCount > 0) {
currentHeightElem.classList.add('number-update');
setTimeout(() => currentHeightElem.classList.remove('number-update'), 500);
}
lastBlockCount = blocks;
currentHeightElem.textContent = blocks.toLocaleString();
document.getElementById('networkHeight').textContent = headers.toLocaleString();
document.getElementById('headers').textContent = headers.toLocaleString();
document.getElementById('verificationProgress').textContent = `${verificationPercentage}%`;
document.getElementById('syncPercentage').textContent = `${actualSyncPercentage}%`;
document.getElementById('currentBlock').textContent = `Block ${blocks.toLocaleString()}`;
document.getElementById('syncProgressBar').style.width = `${actualSyncPercentage}%`;
// Update sync status text and icon
const syncStatusText = document.getElementById('syncStatusText');
const syncIcon = document.getElementById('syncIcon');
if (isSynced) {
syncStatusText.textContent = '✓ Fully synchronized with the network';
syncStatusText.className = 'text-green-400 text-sm font-medium';
// Stop spinning when synced
if (syncIcon) {
syncIcon.classList.remove('animate-spin-slow');
syncIcon.classList.add('text-green-500');
}
} else {
const remaining = headers - blocks;
syncStatusText.textContent = `Syncing... ${remaining.toLocaleString()} blocks remaining`;
syncStatusText.className = 'text-orange-400 text-sm font-medium';
// Keep spinning while syncing
if (syncIcon) {
syncIcon.classList.add('animate-spin-slow');
syncIcon.classList.remove('text-green-500');
}
}
// Update block height in quick actions (removed section)
// document.getElementById('blockHeight').textContent = blocks.toLocaleString();
// Update version
if (networkInfo && networkInfo.version) {
const version = networkInfo.version;
const versionStr = `v${Math.floor(version / 10000)}.${Math.floor((version % 10000) / 100)}.${version % 100}`;
const versionElem = document.getElementById('nodeVersion');
if (versionElem) versionElem.textContent = versionStr;
}
} catch (error) {
console.error('Failed to update blockchain info:', error);
document.getElementById('syncStatusText').textContent = 'Unable to fetch blockchain data';
document.getElementById('syncStatusText').className = 'text-red-400 text-sm';
}
}
// Initial update
console.log('[Bitcoin UI] Starting initial blockchain info update...');
updateBlockchainInfo();
// Update every 5 seconds
console.log('[Bitcoin UI] Setting up 5-second update interval');
setInterval(updateBlockchainInfo, 5000);
function copyRPCInfo() {
const info = `RPC Host: ${window.location.hostname}:8332\nRPC User: archipelago\nRPC Password: archipelago123\nRPC Endpoint: ${RPC_ENDPOINT}`;
navigator.clipboard.writeText(info).then(() => {
alert('RPC info copied to clipboard!');
});
}
function openSettings() {
document.getElementById('settingsModal').classList.remove('hidden');
document.getElementById('settingsModal').classList.add('flex');
}
function closeSettings() {
document.getElementById('settingsModal').classList.add('hidden');
document.getElementById('settingsModal').classList.remove('flex');
}
function openLogs() {
document.getElementById('logsModal').classList.remove('hidden');
document.getElementById('logsModal').classList.add('flex');
loadLogs();
}
function closeLogs() {
document.getElementById('logsModal').classList.add('hidden');
document.getElementById('logsModal').classList.remove('flex');
}
async function loadLogs() {
const logsContent = document.getElementById('logsContent');
logsContent.textContent = 'Loading logs from node...';
try {
const networkInfo = await callRPC('getnetworkinfo');
const blockchainInfo = await callRPC('getblockchaininfo');
const peerInfo = await callRPC('getpeerinfo');
if (networkInfo && blockchainInfo) {
logsContent.textContent = `Bitcoin Knots version ${networkInfo.subversion || 'unknown'}
Network: ${blockchainInfo.chain}
Blocks: ${blockchainInfo.blocks}
Headers: ${blockchainInfo.headers}
Verification Progress: ${(blockchainInfo.verificationprogress * 100).toFixed(2)}%
Connected Peers: ${peerInfo ? peerInfo.length : 0}
Difficulty: ${blockchainInfo.difficulty}
Chain Work: ${blockchainInfo.chainwork || 'N/A'}
Node is running and accepting connections.
RPC server active on port 8332`;
} else {
logsContent.textContent = 'Unable to fetch node logs. Please check your RPC connection.';
}
} catch (error) {
logsContent.textContent = `Error loading logs: ${error.message}`;
}
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeSettings();
closeLogs();
}
});
document.querySelectorAll('.modal').forEach(modal => {
modal.addEventListener('click', (e) => {
if (e.target === modal) {
closeSettings();
closeLogs();
}
});
});
</script>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More