bug fixing and deploy and build diagnostics
This commit is contained in:
parent
1f8287c4c3
commit
e4e0ef4f11
@ -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
|
||||
|
||||
21
.claude/memory/project_bitcoin_rpc_auth.md
Normal file
21
.claude/memory/project_bitcoin_rpc_auth.md
Normal file
@ -0,0 +1,21 @@
|
||||
---
|
||||
name: Bitcoin RPC rpcauth architecture
|
||||
description: Bitcoin uses rpcauth (salted hash in config, password in secrets file), system Tor for containers, reboot survival
|
||||
type: project
|
||||
---
|
||||
|
||||
Bitcoin RPC uses `rpcauth` — salted HMAC-SHA256 hash in bitcoin.conf, plaintext password in `/var/lib/archipelago/secrets/bitcoin-rpc-password`. Credentials are STABLE across reboots, restarts, deploys.
|
||||
|
||||
**Why:** Cookie auth rotates on every Bitcoin restart, breaking all dependent containers with env-var-only credentials. The `rpcauth` approach keeps the password stable while never exposing plaintext in config files or CLI args.
|
||||
|
||||
**How to apply:**
|
||||
- Bitcoin: reads rpcauth from bitcoin.conf (no CLI credential flags, config generated by first-boot or deploy)
|
||||
- LND: `bitcoind.rpcuser/rpcpass` in lnd.conf (NOT rpccookie — LND v0.18.4 doesn't support it)
|
||||
- All containers: read password from secrets file at creation time, passed via env vars
|
||||
- Rust backend `bitcoin_rpc.rs`: reads from secrets file, cached with OnceCell
|
||||
- bitcoin-ui: mounts `/var/lib/archipelago/secrets:/secrets:ro`, start.sh reads password and injects nginx auth header
|
||||
- System Tor: `SocksPort 0.0.0.0:9050` + SocksPolicy, containers use `host.containers.internal:9050`
|
||||
- `podman-restart.service` enabled for container auto-start after reboot
|
||||
- Tor hidden service hostnames copied to `/var/lib/archipelago/tor-hostnames/` for readable access
|
||||
- .198 ElectrumX points at .228's full Bitcoin node (pruned node can't run ElectrumX locally)
|
||||
- Health monitor interval: 60 seconds — UI may briefly show "crashed" during restarts
|
||||
@ -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
98
README.md
98
README.md
@ -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.
|
||||
|
||||
[](https://www.debian.org/)
|
||||
[](LICENSE)
|
||||
[](https://www.rust-lang.org/)
|
||||
[](https://vuejs.org/)
|
||||
[]()
|
||||
[]()
|
||||
|
||||
## Features
|
||||
|
||||
### Bitcoin Infrastructure
|
||||
- **Bitcoin Knots** full node with pruning support
|
||||
- **LND** Lightning Network daemon with channel management
|
||||
- **Electrs** Electrum server for wallet connectivity
|
||||
- **ElectrumX** Electrum server for wallet connectivity
|
||||
- **BTCPay Server** for accepting Bitcoin payments
|
||||
- **Mempool** block explorer and fee estimator
|
||||
- **Fedimint** federation guardian and gateway
|
||||
|
||||
### Self-Hosted Apps (20+)
|
||||
Storage (File Browser, Immich, Nextcloud), Productivity (Penpot, OnlyOffice, Vaultwarden), Media (Jellyfin), Search (SearXNG), AI (Ollama), Network (Tailscale, Nginx Proxy Manager), Home (Home Assistant), and more.
|
||||
### Self-Hosted Apps (30)
|
||||
Bitcoin (ThunderHub), Storage (FileBrowser, Immich, Nextcloud), Productivity (Penpot, OnlyOffice, Vaultwarden), Media (Jellyfin, PhotoPrism), Search (SearXNG), AI (Ollama), Network (Tailscale, Nginx Proxy Manager), Home (Home Assistant), Nostr (nostr-rs-relay, Nostrudel), Dev (Grafana, Portainer), and more.
|
||||
|
||||
### Web5 Identity
|
||||
- DID-based digital identity (Ed25519 + secp256k1)
|
||||
- Verifiable Credentials issuance and verification
|
||||
- Decentralized Web Node (DWN) for data sync
|
||||
- Nostr relay integration for node discovery
|
||||
### Decentralized Identity
|
||||
- Ed25519 node identity with DID Documents (did:key)
|
||||
- Multi-identity management (Personal/Business/Anonymous)
|
||||
- W3C Verifiable Credentials issuance and verification
|
||||
- Decentralized Web Node (DWN) with bidirectional sync over Tor
|
||||
- Nostr relay integration and NIP-07 signing for iframe apps
|
||||
|
||||
### Multi-Node Federation
|
||||
- Invite-based node joining over Tor hidden services
|
||||
- Trust levels (Trusted/Verified/Untrusted) with DID-based auth
|
||||
- Bidirectional DWN state sync between federated nodes
|
||||
- File sharing with access controls (free/peers-only/paid)
|
||||
|
||||
### Mesh Networking
|
||||
- LoRa radio communication via Meshcore protocol
|
||||
- Device discovery and mesh routing
|
||||
- Off-grid Bitcoin balance checks (planned)
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
@ -1,151 +1,85 @@
|
||||
# Archipelago Apps - Development Guide
|
||||
|
||||
This directory contains all prepackaged containerized applications for Archipelago NodeOS.
|
||||
# Archipelago Apps — Development Guide
|
||||
|
||||
## App Overview
|
||||
|
||||
### Bitcoin & Lightning
|
||||
- **bitcoin-core**: Full Bitcoin node (ports: 8332, 8333) - v24.0.0
|
||||
- **lnd**: Lightning Network Daemon (ports: 9735, 10009, 8080)
|
||||
- **core-lightning**: Core Lightning implementation (ports: 9736, 9835)
|
||||
- **lightning-stack**: Complete Lightning implementation (ports: 9737, 10010, 8087) - v0.12.0
|
||||
- **btcpay-server**: Bitcoin payment processor (ports: 80, 443) - v1.12.0
|
||||
- **mempool**: Blockchain explorer (port: 4080) - v2.5.0
|
||||
- **fedimint**: Federated Bitcoin minting (ports: 8173, 8174) - v0.3.0
|
||||
| App | Ports | Version |
|
||||
|-----|-------|---------|
|
||||
| bitcoin-knots | 8332 (RPC), 8333 (P2P) | v28.1 |
|
||||
| lnd | 9735 (P2P), 10009 (gRPC), 8080 (REST) | v0.17.4-beta |
|
||||
| btcpay-server | 23000 (HTTP) | v1.13.5 |
|
||||
| thunderhub | 3010 (HTTP) | v0.13.31 |
|
||||
| mempool | 4080 (HTTP) | v2.5.0 |
|
||||
| electrumx | 50001 (TCP), 50002 (SSL) | latest |
|
||||
| fedimint | 8173 (API), 8174 (Web) | v0.10.0 |
|
||||
|
||||
### Nostr Relays
|
||||
- **nostr-rs-relay**: High-performance Rust relay (port: 8081)
|
||||
- **strfry**: Lightweight C++ relay (port: 8082)
|
||||
### Nostr
|
||||
| App | Ports | Version |
|
||||
|-----|-------|---------|
|
||||
| nostr-rs-relay | 8081 (WebSocket) | v0.9.0 |
|
||||
| nostrudel | 8082 (HTTP) | v0.40.0 |
|
||||
|
||||
### Web5 & Decentralized Protocols
|
||||
- **web5-dwn**: Decentralized Web Node (port: 3000)
|
||||
- **did-wallet**: Web5 DID Wallet (port: 8083)
|
||||
|
||||
### Self-Hosted Services
|
||||
- **home-assistant**: Home automation (port: 8123) - v2024.1.0
|
||||
- **grafana**: Monitoring and dashboards (port: 3001) - v10.2.0
|
||||
- **ollama**: Local AI models (port: 11434) - v0.1.0
|
||||
- **searxng**: Privacy search engine (port: 8888) - v2024.1.0
|
||||
- **onlyoffice**: Office suite (port: 8088) - v7.5.0
|
||||
- **penpot**: Design platform (port: 8089) - v2.0.0
|
||||
|
||||
### Custom Applications
|
||||
- **endurain**: Application platform (port: 8085) - v1.0.0
|
||||
- **morphos-server**: MorphOS server (port: 8086) - v1.0.0
|
||||
|
||||
### Mesh Networking
|
||||
- **router**: Mesh routing and network management (ports: 8084, 5353, 1900)
|
||||
- **meshtastic**: LoRa mesh networking (ports: 4403, 1883)
|
||||
|
||||
## Port Assignments
|
||||
|
||||
All apps use unique base ports. In development mode, ports are offset by 10000 (configurable).
|
||||
|
||||
See [PORTS.md](./PORTS.md) for complete port mapping.
|
||||
|
||||
Key apps:
|
||||
- **bitcoin-core**: 8332, 8333 → 18332, 18333
|
||||
- **btcpay-server**: 80, 443 → 10080, 10443
|
||||
- **home-assistant**: 8123 → 18123
|
||||
- **grafana**: 3001 → 13001
|
||||
- **mempool**: 4080 → 14080
|
||||
- **ollama**: 11434 → 21434
|
||||
- **lightning-stack**: 9737, 10010, 8087 → 19737, 20010, 18087
|
||||
### Self-Hosted
|
||||
| App | Port | Version |
|
||||
|-----|------|---------|
|
||||
| nextcloud | 8084 | v28 |
|
||||
| jellyfin | 8096 | v10.8.13 |
|
||||
| immich | 2283 | release |
|
||||
| photoprism | 2342 | v240915 |
|
||||
| vaultwarden | 8222 | v1.30.0-alpine |
|
||||
| homeassistant | 8123 | v2024.1 |
|
||||
| filebrowser | 8083 | v2.27.0 |
|
||||
| searxng | 8888 | 2024.11.17 |
|
||||
| ollama | 11434 | v0.5.4 |
|
||||
| grafana | 3001 | v10.2.0 |
|
||||
| portainer | 9000 | v2.19.4 |
|
||||
| onlyoffice | 8088 | v7.5.1 |
|
||||
| penpot | 8089 | v2.4 |
|
||||
|
||||
## Building Apps
|
||||
|
||||
### Build All Apps
|
||||
|
||||
```bash
|
||||
./build.sh
|
||||
cd apps
|
||||
./build.sh # Build all custom apps
|
||||
./build.sh <app-id> # Build specific app
|
||||
```
|
||||
|
||||
### Build Specific App
|
||||
|
||||
```bash
|
||||
./build.sh <app-id>
|
||||
```
|
||||
|
||||
### Build for Development
|
||||
|
||||
```bash
|
||||
./build.sh <app-id> --dev
|
||||
```
|
||||
Custom apps with local source: `router`, `did-wallet`, `web5-dwn`. All other apps use official container images.
|
||||
|
||||
## App Structure
|
||||
|
||||
Each app directory contains:
|
||||
|
||||
- `manifest.yml` - App manifest defining container configuration
|
||||
- `Dockerfile` - Container image definition
|
||||
- `README.md` - App-specific documentation (for custom apps)
|
||||
- Source code (for custom apps: router, did-wallet, web5-dwn)
|
||||
|
||||
## Custom Apps
|
||||
|
||||
The following apps have custom implementations:
|
||||
|
||||
1. **router** - TypeScript/Node.js mesh router
|
||||
2. **did-wallet** - TypeScript/Node.js Web5 wallet
|
||||
3. **web5-dwn** - TypeScript/Node.js DWN server
|
||||
|
||||
These apps can be developed locally:
|
||||
|
||||
```bash
|
||||
cd apps/<app-id>
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Standard Apps
|
||||
|
||||
The following apps use official Docker images:
|
||||
|
||||
- bitcoin-core (bitcoin/bitcoin:26.0)
|
||||
- lnd (lightninglabs/lnd:v0.18.0)
|
||||
- core-lightning (elementsproject/lightningd:v23.08.2)
|
||||
- btcpay-server (btcpayserver/btcpayserver:1.12.0)
|
||||
- nostr-rs-relay (scsibug/nostr-rs-relay:latest)
|
||||
- strfry (strfry/strfry:latest)
|
||||
- meshtastic (meshtastic/meshtastic:latest)
|
||||
- `manifest.yml` — Container configuration
|
||||
- `Dockerfile` — Image definition (custom apps only)
|
||||
- `README.md` — App-specific docs (custom apps only)
|
||||
- `src/` — Source code (custom apps only)
|
||||
|
||||
## Running in Development
|
||||
|
||||
### Using Archipelago Backend
|
||||
|
||||
The Archipelago backend will automatically:
|
||||
1. Build local images if they don't exist
|
||||
2. Apply port offsets in dev mode
|
||||
3. Map volumes to `/tmp/archipelago-dev/<app-id>`
|
||||
4. Start containers with proper networking
|
||||
|
||||
### Manual Testing
|
||||
|
||||
You can test apps manually:
|
||||
The Archipelago backend manages containers via rootless Podman. Install and start apps through the web UI Marketplace or via RPC:
|
||||
|
||||
```bash
|
||||
# Build the app
|
||||
./build.sh <app-id>
|
||||
|
||||
# Run with Docker/Podman
|
||||
docker run -p <host-port>:<container-port> \
|
||||
-v /tmp/archipelago-dev/<app-id>:/data \
|
||||
archipelago/<app-id>:latest
|
||||
curl -X POST http://localhost:5959/rpc/v1 \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"method": "container-install", "params": {"manifest_path": "apps/router/manifest.yml"}}'
|
||||
```
|
||||
|
||||
## Integration with Archipelago
|
||||
### Manual Testing (Podman)
|
||||
|
||||
Apps are integrated via:
|
||||
```bash
|
||||
# Build
|
||||
./build.sh router
|
||||
|
||||
1. **Manifest files** - Define app configuration
|
||||
2. **Container runtime** - Podman/Docker for execution
|
||||
3. **Port manager** - Handles port allocation and offsets
|
||||
4. **Dev orchestrator** - Manages containers in development
|
||||
# Run directly with Podman
|
||||
podman run -p 18084:8080 \
|
||||
-v /tmp/archipelago-dev/router:/app/data \
|
||||
localhost/archipelago/router:latest
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
## Integration Checklist
|
||||
|
||||
When building the OS image, these apps will be:
|
||||
1. Built into container images
|
||||
2. Included in the OS image
|
||||
3. Available for installation via the UI
|
||||
4. Pre-configured with proper networking and security
|
||||
Adding a new app requires updates in multiple places. See the full checklist in [CLAUDE.md](../CLAUDE.md) under "App Integration Checklist".
|
||||
|
||||
## Port Assignments
|
||||
|
||||
See [PORTS.md](./PORTS.md) for complete mapping. Dev ports are offset by +10000.
|
||||
|
||||
@ -1,70 +1,46 @@
|
||||
# Archipelago App Manifests
|
||||
|
||||
This directory contains app manifest definitions for containerized applications in the Archipelago Bitcoin Node OS.
|
||||
Containerized applications for the Archipelago Bitcoin Node OS. All apps run in rootless Podman with security hardening (cap-drop ALL, readonly root, non-root user, memory limits).
|
||||
|
||||
## App Categories
|
||||
|
||||
### Bitcoin & Lightning
|
||||
- `bitcoin-core/` - Bitcoin Core full node (v24.0.0)
|
||||
- `lnd/` - Lightning Network Daemon
|
||||
- `core-lightning/` - Core Lightning (CLN)
|
||||
- `lightning-stack/` - Complete Lightning implementation (v0.12.0)
|
||||
- `btcpay-server/` - BTCPay Server payment processor (v1.12.0)
|
||||
- `mempool/` - Mempool blockchain explorer (v2.5.0)
|
||||
- `fedimint/` - Federated Bitcoin minting (v0.3.0)
|
||||
- **bitcoin-knots** — Full Bitcoin node (v28.1)
|
||||
- **lnd** — Lightning Network Daemon (v0.17.4-beta)
|
||||
- **btcpay-server** — Payment processor (v1.13.5)
|
||||
- **thunderhub** — Lightning management UI (v0.13.31)
|
||||
- **mempool** — Block explorer and fee estimator (v2.5.0)
|
||||
- **electrumx** — Electrum server
|
||||
- **fedimint** — Federated Bitcoin minting (v0.10.0)
|
||||
|
||||
### Web5 & Decentralized Protocols
|
||||
- `nostr-rs-relay/` - High-performance Nostr relay (Rust)
|
||||
- `strfry/` - Nostr relay (C++)
|
||||
- `web5-dwn/` - Decentralized Web Node
|
||||
- `did-wallet/` - Web5 wallet with DID support
|
||||
### Nostr
|
||||
- **nostr-rs-relay** — High-performance Rust relay (v0.9.0)
|
||||
- **nostrudel** — Nostr web client (v0.40.0)
|
||||
|
||||
### Web5 & Identity
|
||||
- **web5-dwn** — Decentralized Web Node (v0.4.0)
|
||||
- **did-wallet** — Web5 DID Wallet
|
||||
|
||||
### Self-Hosted Services
|
||||
- `home-assistant/` - Home automation (v2024.1.0)
|
||||
- `grafana/` - Monitoring and dashboards (v10.2.0)
|
||||
- `ollama/` - Local AI models (v0.1.0)
|
||||
- `searxng/` - Privacy-respecting search engine (v2024.1.0)
|
||||
- `onlyoffice/` - Office suite (v7.5.0)
|
||||
- `penpot/` - Design platform (v2.0.0)
|
||||
- **nextcloud** (v28), **jellyfin** (v10.8.13), **immich** (release), **photoprism** (v240915)
|
||||
- **vaultwarden** (v1.30.0-alpine), **onlyoffice** (v7.5.1), **penpot** (v2.4)
|
||||
- **homeassistant** (v2024.1), **filebrowser** (v2.27.0), **searxng** (2024.11.17)
|
||||
- **ollama** (v0.5.4), **grafana** (v10.2.0), **portainer** (v2.19.4)
|
||||
|
||||
### Custom Applications
|
||||
- `endurain/` - Endurain application platform (v1.0.0)
|
||||
- `morphos-server/` - MorphOS server (v1.0.0)
|
||||
### Networking
|
||||
- **tailscale** (stable), **nginx-proxy-manager** (v2.12.1)
|
||||
|
||||
### Mesh Networking & Routing
|
||||
- `meshtastic/` - Meshtastic LoRa mesh networking
|
||||
- `router/` - Mesh routing and local network management
|
||||
### Custom & External
|
||||
- **indeedhub** — Bitcoin documentary streaming (custom build)
|
||||
- **router** — Mesh routing and network management
|
||||
- **botfights**, **nwnn**, **484-kitchen**, **call-the-operator**, **arch-presentation**, **syntropy-institute**, **t-zero** — External web apps
|
||||
|
||||
## Manifest Format
|
||||
|
||||
Each app has a `manifest.yml` file defining:
|
||||
- Container image and version
|
||||
- Resource requirements
|
||||
- Dependencies
|
||||
- Security policies
|
||||
- Health checks
|
||||
- Network configuration
|
||||
Each app has a `manifest.yml` defining container image, resources, dependencies, security policies, health checks, and network config. See [`docs/app-manifest-spec.md`](../docs/app-manifest-spec.md) for the spec.
|
||||
|
||||
See `docs/app-manifest-spec.md` for the complete specification.
|
||||
## Quick Reference
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Build All Apps
|
||||
|
||||
```bash
|
||||
./build.sh
|
||||
```
|
||||
|
||||
### Build Specific App
|
||||
|
||||
```bash
|
||||
./build.sh <app-id>
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
See [DEVELOPMENT.md](./DEVELOPMENT.md) for development guide and [QUICKSTART.md](./QUICKSTART.md) for quick start instructions.
|
||||
|
||||
## Port Assignments
|
||||
|
||||
See [PORTS.md](./PORTS.md) for complete port mapping. All apps use unique ports and are automatically offset in development mode.
|
||||
- [PORTS.md](./PORTS.md) — Complete port mapping
|
||||
- [QUICKSTART.md](./QUICKSTART.md) — Build and run apps
|
||||
- [DEVELOPMENT.md](./DEVELOPMENT.md) — Development workflow
|
||||
|
||||
1
core/Cargo.lock
generated
1
core/Cargo.lock
generated
@ -147,6 +147,7 @@ dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"futures",
|
||||
"hyper 0.14.32",
|
||||
"indexmap",
|
||||
"log",
|
||||
"reqwest",
|
||||
|
||||
@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
impl ApiHandler {
|
||||
/// DWN health endpoint — returns store stats.
|
||||
async fn handle_dwn_health(config: &Config) -> Result<Response<hyper::Body>> {
|
||||
match DwnStore::new(&config.data_dir).await {
|
||||
Ok(store) => {
|
||||
let stats = store.stats().await.unwrap_or(crate::network::dwn_store::StoreStats {
|
||||
message_count: 0,
|
||||
protocol_count: 0,
|
||||
total_bytes: 0,
|
||||
});
|
||||
let body = serde_json::json!({
|
||||
"status": "ok",
|
||||
"message_count": stats.message_count,
|
||||
"protocol_count": stats.protocol_count,
|
||||
"total_bytes": stats.total_bytes,
|
||||
});
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(body.to_string()))
|
||||
.unwrap())
|
||||
}
|
||||
Err(_) => Ok(Response::builder()
|
||||
.status(StatusCode::SERVICE_UNAVAILABLE)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(r#"{"status":"unavailable"}"#))
|
||||
.unwrap()),
|
||||
}
|
||||
}
|
||||
|
||||
/// DWN message processing endpoint — handles RecordsWrite, RecordsQuery, RecordsRead, RecordsDelete.
|
||||
/// Supports batch processing: all messages in the array are processed.
|
||||
async fn handle_dwn_message(
|
||||
body: hyper::body::Bytes,
|
||||
config: &Config,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let request: serde_json::Value = match serde_json::from_slice(&body) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
let err = serde_json::json!({"error": format!("Invalid JSON: {}", e)});
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::BAD_REQUEST)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(err.to_string()))
|
||||
.unwrap());
|
||||
}
|
||||
};
|
||||
|
||||
// Collect all messages to process
|
||||
let messages: Vec<serde_json::Value> = if request.get("message").is_some() {
|
||||
vec![request["message"].clone()]
|
||||
} else if let Some(msgs) = request["messages"].as_array() {
|
||||
msgs.clone()
|
||||
} else {
|
||||
vec![serde_json::Value::Null]
|
||||
};
|
||||
|
||||
let store = DwnStore::new(&config.data_dir).await?;
|
||||
let mut results = Vec::new();
|
||||
|
||||
for message in &messages {
|
||||
let interface = message["descriptor"]["interface"]
|
||||
.as_str()
|
||||
.unwrap_or("");
|
||||
let method = message["descriptor"]["method"]
|
||||
.as_str()
|
||||
.unwrap_or("");
|
||||
|
||||
let result = match (interface, method) {
|
||||
("Records", "Write") => {
|
||||
let author = message["author"].as_str().unwrap_or("unknown");
|
||||
let protocol = message["descriptor"]["protocol"].as_str();
|
||||
let schema = message["descriptor"]["schema"].as_str();
|
||||
let data_format = message["descriptor"]["dataFormat"].as_str();
|
||||
let data = message.get("data").cloned();
|
||||
// Deduplicate: check if recordId already exists
|
||||
if let Some(record_id) = message["recordId"].as_str() {
|
||||
if store.read_message(record_id).await.ok().flatten().is_some() {
|
||||
serde_json::json!({"status": {"code": 200, "detail": "Already exists"}})
|
||||
} else {
|
||||
match store
|
||||
.write_message(author, protocol, schema, data_format, data)
|
||||
.await
|
||||
{
|
||||
Ok(msg) => {
|
||||
serde_json::json!({"status": {"code": 202}, "entry": msg})
|
||||
}
|
||||
Err(e) => serde_json::json!({"status": {"code": 500, "detail": e.to_string()}}),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match store
|
||||
.write_message(author, protocol, schema, data_format, data)
|
||||
.await
|
||||
{
|
||||
Ok(msg) => serde_json::json!({"status": {"code": 202}, "entry": msg}),
|
||||
Err(e) => serde_json::json!({"status": {"code": 500, "detail": e.to_string()}}),
|
||||
}
|
||||
}
|
||||
}
|
||||
("Records", "Query") => {
|
||||
let query = crate::network::dwn_store::MessageQuery {
|
||||
protocol: message["descriptor"]["filter"]["protocol"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string()),
|
||||
schema: message["descriptor"]["filter"]["schema"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string()),
|
||||
author: message["descriptor"]["filter"]["author"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string()),
|
||||
date_from: message["descriptor"]["filter"]["dateFrom"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string()),
|
||||
date_to: message["descriptor"]["filter"]["dateTo"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string()),
|
||||
limit: message["descriptor"]["filter"]["limit"]
|
||||
.as_u64()
|
||||
.map(|n| n as usize),
|
||||
};
|
||||
match store.query_messages(&query).await {
|
||||
Ok(messages) => {
|
||||
serde_json::json!({"status": {"code": 200}, "entries": messages})
|
||||
}
|
||||
Err(e) => {
|
||||
serde_json::json!({"status": {"code": 500, "detail": e.to_string()}})
|
||||
}
|
||||
}
|
||||
}
|
||||
("Records", "Read") => {
|
||||
let record_id = message["descriptor"]["recordId"]
|
||||
.as_str()
|
||||
.unwrap_or("");
|
||||
match store.read_message(record_id).await {
|
||||
Ok(Some(msg)) => {
|
||||
serde_json::json!({"status": {"code": 200}, "entry": msg})
|
||||
}
|
||||
Ok(None) => serde_json::json!({"status": {"code": 404, "detail": "Record not found"}}),
|
||||
Err(e) => {
|
||||
serde_json::json!({"status": {"code": 500, "detail": e.to_string()}})
|
||||
}
|
||||
}
|
||||
}
|
||||
("Records", "Delete") => {
|
||||
let record_id = message["descriptor"]["recordId"]
|
||||
.as_str()
|
||||
.unwrap_or("");
|
||||
match store.delete_message(record_id).await {
|
||||
Ok(true) => serde_json::json!({"status": {"code": 200}}),
|
||||
Ok(false) => serde_json::json!({"status": {"code": 404, "detail": "Record not found"}}),
|
||||
Err(e) => {
|
||||
serde_json::json!({"status": {"code": 500, "detail": e.to_string()}})
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
serde_json::json!({"status": {"code": 400, "detail": format!("Unknown method: {}.{}", interface, method)}})
|
||||
}
|
||||
};
|
||||
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
// Return single result for single message, array for batch
|
||||
let (response_body, http_status) = if results.len() == 1 {
|
||||
let result = &results[0];
|
||||
let status_code = result["status"]["code"].as_u64().unwrap_or(200);
|
||||
let http_status = match status_code {
|
||||
202 => StatusCode::ACCEPTED,
|
||||
400 => StatusCode::BAD_REQUEST,
|
||||
404 => StatusCode::NOT_FOUND,
|
||||
500 => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
_ => StatusCode::OK,
|
||||
};
|
||||
(result.to_string(), http_status)
|
||||
} else {
|
||||
(
|
||||
serde_json::json!({"replies": results}).to_string(),
|
||||
StatusCode::OK,
|
||||
)
|
||||
};
|
||||
|
||||
Ok(Response::builder()
|
||||
.status(http_status)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(response_body))
|
||||
.unwrap())
|
||||
}
|
||||
}
|
||||
122
core/archipelago/src/api/handler/content.rs
Normal file
122
core/archipelago/src/api/handler/content.rs
Normal file
@ -0,0 +1,122 @@
|
||||
use crate::config::Config;
|
||||
use super::build_response;use crate::content_server;
|
||||
use anyhow::Result;
|
||||
use hyper::{Response, StatusCode};
|
||||
|
||||
use super::{ApiHandler, is_valid_app_id};
|
||||
|
||||
impl ApiHandler {
|
||||
pub(super) async fn handle_content_catalog(config: &Config) -> Result<Response<hyper::Body>> {
|
||||
match content_server::load_catalog(&config.data_dir).await {
|
||||
Ok(catalog) => {
|
||||
// Only expose public metadata for available items
|
||||
let items: Vec<serde_json::Value> = catalog
|
||||
.items
|
||||
.iter()
|
||||
.filter(|i| !matches!(i.availability, content_server::Availability::Nobody))
|
||||
.map(|i| {
|
||||
serde_json::json!({
|
||||
"id": i.id,
|
||||
"filename": i.filename,
|
||||
"mime_type": i.mime_type,
|
||||
"size_bytes": i.size_bytes,
|
||||
"description": i.description,
|
||||
"access": i.access,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let body = serde_json::to_vec(&serde_json::json!({ "items": items }))
|
||||
.unwrap_or_default();
|
||||
Ok(build_response(StatusCode::OK, "application/json", hyper::Body::from(body)))
|
||||
}
|
||||
Err(e) => {
|
||||
let body = serde_json::json!({ "error": e.to_string() });
|
||||
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
||||
Ok(build_response(StatusCode::INTERNAL_SERVER_ERROR, "application/json", hyper::Body::from(body_bytes)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn handle_content_request(
|
||||
path: &str,
|
||||
headers: &hyper::HeaderMap,
|
||||
config: &Config,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let content_id = path.strip_prefix("/content/").unwrap_or("");
|
||||
if content_id.is_empty() || !is_valid_app_id(content_id) {
|
||||
return Ok(build_response(StatusCode::BAD_REQUEST, "text/plain", hyper::Body::from("Invalid content ID")));
|
||||
}
|
||||
|
||||
// Extract payment token from X-Payment-Token header
|
||||
let payment_token = headers
|
||||
.get("x-payment-token")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
// Extract federation peer DID from X-Federation-DID header
|
||||
let peer_did = headers
|
||||
.get("x-federation-did")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
// Parse Range header for streaming support
|
||||
let range = headers
|
||||
.get("range")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(content_server::parse_range_header);
|
||||
|
||||
match content_server::serve_content(
|
||||
&config.data_dir,
|
||||
content_id,
|
||||
payment_token.as_deref(),
|
||||
peer_did.as_deref(),
|
||||
range,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(content_server::ServeResult::Ok(bytes, mime_type)) => {
|
||||
let len = bytes.len();
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", mime_type)
|
||||
.header("Content-Length", len.to_string())
|
||||
.header("Accept-Ranges", "bytes")
|
||||
.body(hyper::Body::from(bytes))
|
||||
.unwrap())
|
||||
}
|
||||
Ok(content_server::ServeResult::Partial {
|
||||
bytes,
|
||||
mime_type,
|
||||
start,
|
||||
end,
|
||||
total,
|
||||
}) => {
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::PARTIAL_CONTENT)
|
||||
.header("Content-Type", mime_type)
|
||||
.header("Content-Length", bytes.len().to_string())
|
||||
.header("Content-Range", format!("bytes {}-{}/{}", start, end, total))
|
||||
.header("Accept-Ranges", "bytes")
|
||||
.body(hyper::Body::from(bytes))
|
||||
.unwrap())
|
||||
}
|
||||
Ok(content_server::ServeResult::PaymentRequired(price_sats)) => {
|
||||
let body = serde_json::json!({
|
||||
"error": "Payment required",
|
||||
"price_sats": price_sats,
|
||||
"payment_header": "X-Payment-Token",
|
||||
});
|
||||
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
||||
Ok(build_response(StatusCode::PAYMENT_REQUIRED, "application/json", hyper::Body::from(body_bytes)))
|
||||
}
|
||||
Ok(content_server::ServeResult::Forbidden) => {
|
||||
Ok(build_response(StatusCode::FORBIDDEN, "application/json", hyper::Body::from(
|
||||
r#"{"error":"Access denied — federation peer required"}"#,
|
||||
)))
|
||||
}
|
||||
Ok(content_server::ServeResult::NotFound) | Err(_) => {
|
||||
Ok(build_response(StatusCode::NOT_FOUND, "text/plain", hyper::Body::from("Content not found")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
189
core/archipelago/src/api/handler/dwn.rs
Normal file
189
core/archipelago/src/api/handler/dwn.rs
Normal file
@ -0,0 +1,189 @@
|
||||
use crate::config::Config;
|
||||
use super::build_response;use crate::network::dwn_store::DwnStore;
|
||||
use anyhow::Result;
|
||||
use hyper::{Response, StatusCode};
|
||||
|
||||
use super::ApiHandler;
|
||||
|
||||
impl ApiHandler {
|
||||
/// DWN health endpoint — returns store stats.
|
||||
pub(super) async fn handle_dwn_health(config: &Config) -> Result<Response<hyper::Body>> {
|
||||
match DwnStore::new(&config.data_dir).await {
|
||||
Ok(store) => {
|
||||
let stats = store.stats().await.unwrap_or(crate::network::dwn_store::StoreStats {
|
||||
message_count: 0,
|
||||
protocol_count: 0,
|
||||
total_bytes: 0,
|
||||
});
|
||||
let body = serde_json::json!({
|
||||
"status": "ok",
|
||||
"message_count": stats.message_count,
|
||||
"protocol_count": stats.protocol_count,
|
||||
"total_bytes": stats.total_bytes,
|
||||
});
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(body.to_string()))
|
||||
.unwrap())
|
||||
}
|
||||
Err(_) => Ok(build_response(StatusCode::SERVICE_UNAVAILABLE, "application/json", hyper::Body::from(r#"{"status":"unavailable"}"#))),
|
||||
}
|
||||
}
|
||||
|
||||
/// DWN message processing endpoint — handles RecordsWrite, RecordsQuery, RecordsRead, RecordsDelete.
|
||||
/// Supports batch processing: all messages in the array are processed.
|
||||
pub(super) async fn handle_dwn_message(
|
||||
body: hyper::body::Bytes,
|
||||
config: &Config,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let request: serde_json::Value = match serde_json::from_slice(&body) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
let err = serde_json::json!({"error": format!("Invalid JSON: {}", e)});
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::BAD_REQUEST)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(err.to_string()))
|
||||
.unwrap());
|
||||
}
|
||||
};
|
||||
|
||||
// Collect all messages to process
|
||||
let messages: Vec<serde_json::Value> = if request.get("message").is_some() {
|
||||
vec![request["message"].clone()]
|
||||
} else if let Some(msgs) = request["messages"].as_array() {
|
||||
msgs.clone()
|
||||
} else {
|
||||
vec![serde_json::Value::Null]
|
||||
};
|
||||
|
||||
let store = DwnStore::new(&config.data_dir).await?;
|
||||
let mut results = Vec::new();
|
||||
|
||||
for message in &messages {
|
||||
let interface = message["descriptor"]["interface"]
|
||||
.as_str()
|
||||
.unwrap_or("");
|
||||
let method = message["descriptor"]["method"]
|
||||
.as_str()
|
||||
.unwrap_or("");
|
||||
|
||||
let result = match (interface, method) {
|
||||
("Records", "Write") => {
|
||||
let author = message["author"].as_str().unwrap_or("unknown");
|
||||
let protocol = message["descriptor"]["protocol"].as_str();
|
||||
let schema = message["descriptor"]["schema"].as_str();
|
||||
let data_format = message["descriptor"]["dataFormat"].as_str();
|
||||
let data = message.get("data").cloned();
|
||||
// Deduplicate: check if recordId already exists
|
||||
if let Some(record_id) = message["recordId"].as_str() {
|
||||
if store.read_message(record_id).await.ok().flatten().is_some() {
|
||||
serde_json::json!({"status": {"code": 200, "detail": "Already exists"}})
|
||||
} else {
|
||||
match store
|
||||
.write_message(author, protocol, schema, data_format, data)
|
||||
.await
|
||||
{
|
||||
Ok(msg) => {
|
||||
serde_json::json!({"status": {"code": 202}, "entry": msg})
|
||||
}
|
||||
Err(e) => serde_json::json!({"status": {"code": 500, "detail": e.to_string()}}),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match store
|
||||
.write_message(author, protocol, schema, data_format, data)
|
||||
.await
|
||||
{
|
||||
Ok(msg) => serde_json::json!({"status": {"code": 202}, "entry": msg}),
|
||||
Err(e) => serde_json::json!({"status": {"code": 500, "detail": e.to_string()}}),
|
||||
}
|
||||
}
|
||||
}
|
||||
("Records", "Query") => {
|
||||
let query = crate::network::dwn_store::MessageQuery {
|
||||
protocol: message["descriptor"]["filter"]["protocol"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string()),
|
||||
schema: message["descriptor"]["filter"]["schema"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string()),
|
||||
author: message["descriptor"]["filter"]["author"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string()),
|
||||
date_from: message["descriptor"]["filter"]["dateFrom"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string()),
|
||||
date_to: message["descriptor"]["filter"]["dateTo"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string()),
|
||||
limit: message["descriptor"]["filter"]["limit"]
|
||||
.as_u64()
|
||||
.map(|n| n as usize),
|
||||
};
|
||||
match store.query_messages(&query).await {
|
||||
Ok(messages) => {
|
||||
serde_json::json!({"status": {"code": 200}, "entries": messages})
|
||||
}
|
||||
Err(e) => {
|
||||
serde_json::json!({"status": {"code": 500, "detail": e.to_string()}})
|
||||
}
|
||||
}
|
||||
}
|
||||
("Records", "Read") => {
|
||||
let record_id = message["descriptor"]["recordId"]
|
||||
.as_str()
|
||||
.unwrap_or("");
|
||||
match store.read_message(record_id).await {
|
||||
Ok(Some(msg)) => {
|
||||
serde_json::json!({"status": {"code": 200}, "entry": msg})
|
||||
}
|
||||
Ok(None) => serde_json::json!({"status": {"code": 404, "detail": "Record not found"}}),
|
||||
Err(e) => {
|
||||
serde_json::json!({"status": {"code": 500, "detail": e.to_string()}})
|
||||
}
|
||||
}
|
||||
}
|
||||
("Records", "Delete") => {
|
||||
let record_id = message["descriptor"]["recordId"]
|
||||
.as_str()
|
||||
.unwrap_or("");
|
||||
match store.delete_message(record_id).await {
|
||||
Ok(true) => serde_json::json!({"status": {"code": 200}}),
|
||||
Ok(false) => serde_json::json!({"status": {"code": 404, "detail": "Record not found"}}),
|
||||
Err(e) => {
|
||||
serde_json::json!({"status": {"code": 500, "detail": e.to_string()}})
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
serde_json::json!({"status": {"code": 400, "detail": format!("Unknown method: {}.{}", interface, method)}})
|
||||
}
|
||||
};
|
||||
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
// Return single result for single message, array for batch
|
||||
let (response_body, http_status) = if results.len() == 1 {
|
||||
let result = &results[0];
|
||||
let status_code = result["status"]["code"].as_u64().unwrap_or(200);
|
||||
let http_status = match status_code {
|
||||
202 => StatusCode::ACCEPTED,
|
||||
400 => StatusCode::BAD_REQUEST,
|
||||
404 => StatusCode::NOT_FOUND,
|
||||
500 => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
_ => StatusCode::OK,
|
||||
};
|
||||
(result.to_string(), http_status)
|
||||
} else {
|
||||
(
|
||||
serde_json::json!({"replies": results}).to_string(),
|
||||
StatusCode::OK,
|
||||
)
|
||||
};
|
||||
|
||||
Ok(build_response(http_status, "application/json", hyper::Body::from(response_body)))
|
||||
}
|
||||
}
|
||||
267
core/archipelago/src/api/handler/mod.rs
Normal file
267
core/archipelago/src/api/handler/mod.rs
Normal file
@ -0,0 +1,267 @@
|
||||
mod content;
|
||||
mod dwn;
|
||||
mod node_message;
|
||||
mod proxy;
|
||||
mod websocket;
|
||||
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use crate::config::Config;
|
||||
use crate::monitoring::MetricsStore;
|
||||
use crate::session::{self, SessionStore};
|
||||
use crate::state::StateManager;
|
||||
use anyhow::Result;
|
||||
use hyper::{Method, Request, Response, StatusCode};
|
||||
use std::sync::Arc;
|
||||
use tracing::debug;
|
||||
|
||||
/// Build an HTTP response without unwrap. Falls back to a plain 500 if builder fails.
|
||||
// Used by handler submodules after unwrap elimination
|
||||
#[allow(dead_code)]
|
||||
pub(super) fn build_response(status: StatusCode, content_type: &str, body: hyper::Body) -> Response<hyper::Body> {
|
||||
Response::builder()
|
||||
.status(status)
|
||||
.header("Content-Type", content_type)
|
||||
.body(body)
|
||||
.unwrap_or_else(|_| Response::new(hyper::Body::from("Internal error")))
|
||||
}
|
||||
|
||||
pub struct ApiHandler {
|
||||
config: Config,
|
||||
rpc_handler: Arc<RpcHandler>,
|
||||
state_manager: Arc<StateManager>,
|
||||
metrics_store: Arc<MetricsStore>,
|
||||
session_store: SessionStore,
|
||||
}
|
||||
|
||||
impl ApiHandler {
|
||||
pub async fn new(
|
||||
config: Config,
|
||||
state_manager: Arc<StateManager>,
|
||||
metrics_store: Arc<MetricsStore>,
|
||||
) -> Result<Self> {
|
||||
let session_store = SessionStore::new().await;
|
||||
let rpc_handler = Arc::new(
|
||||
RpcHandler::new(
|
||||
config.clone(),
|
||||
state_manager.clone(),
|
||||
metrics_store.clone(),
|
||||
session_store.clone(),
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
rpc_handler,
|
||||
state_manager,
|
||||
metrics_store,
|
||||
session_store,
|
||||
})
|
||||
}
|
||||
|
||||
/// Access the RPC handler (for service initialization after construction).
|
||||
pub fn rpc_handler(&self) -> &Arc<RpcHandler> {
|
||||
&self.rpc_handler
|
||||
}
|
||||
|
||||
/// Check if the request has a valid session cookie.
|
||||
async fn is_authenticated(&self, headers: &hyper::HeaderMap) -> bool {
|
||||
match session::extract_session_cookie(headers) {
|
||||
Some(token) => self.session_store.validate(&token).await,
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a 401 Unauthorized JSON response.
|
||||
fn unauthorized() -> Response<hyper::Body> {
|
||||
let body = serde_json::json!({ "error": "Unauthorized" });
|
||||
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
||||
Response::builder()
|
||||
.status(StatusCode::UNAUTHORIZED)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(body_bytes))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Allowed CORS origins derived from the config host IP.
|
||||
fn allowed_origins(&self) -> Vec<String> {
|
||||
let mut origins = vec![
|
||||
format!("http://{}", self.config.host_ip),
|
||||
format!("https://{}", self.config.host_ip),
|
||||
];
|
||||
if self.config.dev_mode {
|
||||
origins.push("http://localhost:8100".to_string()); // Vite dev server
|
||||
}
|
||||
origins
|
||||
}
|
||||
|
||||
/// Validate the Origin header against allowed origins.
|
||||
/// Returns the matched origin if valid, None if cross-origin is not allowed.
|
||||
fn validate_origin(&self, headers: &hyper::HeaderMap) -> Option<String> {
|
||||
let origin = headers
|
||||
.get("origin")
|
||||
.and_then(|v| v.to_str().ok())?;
|
||||
let allowed = self.allowed_origins();
|
||||
if allowed.iter().any(|a| a == origin) {
|
||||
Some(origin.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_request(
|
||||
&self,
|
||||
req: Request<hyper::Body>,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let path = req.uri().path().to_string();
|
||||
let method = req.method().clone();
|
||||
|
||||
// Handle CORS preflight for all routes
|
||||
if method == Method::OPTIONS {
|
||||
let mut builder = Response::builder()
|
||||
.status(StatusCode::NO_CONTENT)
|
||||
.header("Vary", "Origin");
|
||||
if let Some(origin) = self.validate_origin(req.headers()) {
|
||||
builder = builder
|
||||
.header("Access-Control-Allow-Origin", &origin)
|
||||
.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
.header("Access-Control-Allow-Headers", "Content-Type, X-CSRF-Token")
|
||||
.header("Access-Control-Allow-Credentials", "true");
|
||||
}
|
||||
return Ok(builder.body(hyper::Body::empty()).unwrap());
|
||||
}
|
||||
|
||||
// WebSocket upgrade — validate session before upgrading
|
||||
if method == Method::GET && path == "/ws/db" {
|
||||
if !self.is_authenticated(req.headers()).await {
|
||||
tracing::warn!("401 WebSocket /ws/db — session invalid or missing");
|
||||
return Ok(Self::unauthorized());
|
||||
}
|
||||
return Self::handle_websocket(req, self.state_manager.clone(), self.metrics_store.clone()).await;
|
||||
}
|
||||
|
||||
// Convert body to bytes for non-WS routes
|
||||
let headers = req.headers().clone();
|
||||
let (parts, body) = req.into_parts();
|
||||
let body_bytes = hyper::body::to_bytes(body).await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to read body: {}", e))?;
|
||||
let req_with_bytes = Request::from_parts(parts, hyper::Body::from(body_bytes.clone()));
|
||||
|
||||
debug!("{} {}", method, path);
|
||||
|
||||
match (method, path.as_str()) {
|
||||
// RPC — auth is handled inside rpc handler per-method
|
||||
(Method::POST, "/rpc/v1") => self.rpc_handler.handle(req_with_bytes).await,
|
||||
|
||||
// Health — unauthenticated, returns JSON with service status
|
||||
(Method::GET, "/health") => {
|
||||
let recovery_complete = crate::crash_recovery::is_recovery_complete();
|
||||
let uptime = crate::crash_recovery::uptime_seconds();
|
||||
let health_status = if recovery_complete { "ok" } else { "degraded" };
|
||||
let status = serde_json::json!({
|
||||
"status": health_status,
|
||||
"crash_recovery_complete": recovery_complete,
|
||||
"uptime_seconds": uptime,
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"services": {
|
||||
"rpc": true,
|
||||
"sessions": true,
|
||||
}
|
||||
});
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(serde_json::to_vec(&status).unwrap_or_default()))
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
// Node message — P2P endpoint (authenticated by source validation, not cookie)
|
||||
(Method::POST, "/archipelago/node-message") => {
|
||||
Self::handle_node_message(body_bytes).await
|
||||
}
|
||||
|
||||
// Content serving — peers access shared content over Tor (no session auth)
|
||||
(Method::GET, p) if p.starts_with("/content/") => {
|
||||
Self::handle_content_request(p, &headers, &self.config).await
|
||||
}
|
||||
|
||||
// Content catalog — list available content (no session auth, for peers)
|
||||
(Method::GET, "/content") => {
|
||||
Self::handle_content_catalog(&self.config).await
|
||||
}
|
||||
|
||||
// Electrs status — unauthenticated (read-only sync status)
|
||||
(Method::GET, "/electrs-status") => Self::handle_electrs_status().await,
|
||||
|
||||
// LND connect info — nginx validates session cookie (presence check),
|
||||
// backend is bound to 127.0.0.1 so only nginx can reach it.
|
||||
// No backend auth check here because the LND UI iframe fetches this
|
||||
// endpoint and the session cookie flow is validated at the nginx layer.
|
||||
(Method::GET, "/lnd-connect-info") => {
|
||||
Self::handle_lnd_connect_info(self.rpc_handler.clone()).await
|
||||
}
|
||||
|
||||
// Container logs — requires session
|
||||
(Method::GET, path) if path.starts_with("/api/container/logs") => {
|
||||
if !self.is_authenticated(&headers).await {
|
||||
return Ok(Self::unauthorized());
|
||||
}
|
||||
let origin = self.validate_origin(&headers).unwrap_or_default();
|
||||
Self::handle_container_logs_http(self.rpc_handler.clone(), path, &origin).await
|
||||
}
|
||||
|
||||
// LND proxy — requires session
|
||||
(Method::GET, path) if path.starts_with("/proxy/lnd/") => {
|
||||
if !self.is_authenticated(&headers).await {
|
||||
return Ok(Self::unauthorized());
|
||||
}
|
||||
let origin = self.validate_origin(&headers).unwrap_or_default();
|
||||
Self::handle_lnd_proxy(path, &origin).await
|
||||
}
|
||||
|
||||
// DWN health — unauthenticated
|
||||
(Method::GET, "/dwn/health") => {
|
||||
Self::handle_dwn_health(&self.config).await
|
||||
}
|
||||
|
||||
// DWN message processing — peers access over Tor for sync (no session auth)
|
||||
(Method::POST, "/dwn") => {
|
||||
Self::handle_dwn_message(body_bytes, &self.config).await
|
||||
}
|
||||
|
||||
_ => Ok(Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(hyper::Body::from("Not Found"))
|
||||
.unwrap()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate that an app ID matches the safe pattern: lowercase alphanumeric + hyphens.
|
||||
fn is_valid_app_id(id: &str) -> bool {
|
||||
!id.is_empty()
|
||||
&& id.len() <= 64
|
||||
&& id.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
|
||||
&& id.as_bytes()[0] != b'-'
|
||||
}
|
||||
|
||||
/// Validate that a pubkey is a 64-char hex string.
|
||||
fn is_valid_pubkey_hex(s: &str) -> bool {
|
||||
s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
/// Strip newlines and ANSI escape sequences from strings before logging.
|
||||
fn sanitize_log_string(s: &str) -> String {
|
||||
s.replace('\n', "\\n")
|
||||
.replace('\r', "\\r")
|
||||
.replace('\x1b', "")
|
||||
}
|
||||
|
||||
/// Strip HTML-sensitive characters to prevent XSS when stored/rendered.
|
||||
fn sanitize_html(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
74
core/archipelago/src/api/handler/node_message.rs
Normal file
74
core/archipelago/src/api/handler/node_message.rs
Normal file
@ -0,0 +1,74 @@
|
||||
use crate::node_message as node_msg;
|
||||
use super::build_response;use anyhow::Result;
|
||||
use hyper::{Response, StatusCode};
|
||||
|
||||
use super::{ApiHandler, is_valid_pubkey_hex, sanitize_html, sanitize_log_string};
|
||||
|
||||
impl ApiHandler {
|
||||
pub(super) async fn handle_node_message(body: hyper::body::Bytes) -> Result<Response<hyper::Body>> {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Incoming {
|
||||
from_pubkey: Option<String>,
|
||||
message: Option<String>,
|
||||
signature: Option<String>,
|
||||
#[serde(default)]
|
||||
encrypted: bool,
|
||||
}
|
||||
let incoming: Incoming = serde_json::from_slice(&body).unwrap_or(Incoming {
|
||||
from_pubkey: None,
|
||||
message: None,
|
||||
signature: None,
|
||||
encrypted: false,
|
||||
});
|
||||
if let (Some(from), Some(msg)) = (incoming.from_pubkey.as_ref(), incoming.message.as_ref()) {
|
||||
// Validate from_pubkey is a valid hex ed25519 pubkey
|
||||
if !is_valid_pubkey_hex(from) {
|
||||
return Ok(build_response(StatusCode::BAD_REQUEST, "application/json", hyper::Body::from(r#"{"error":"Invalid pubkey format"}"#)));
|
||||
}
|
||||
// Verify ed25519 signature if provided (required for trusted messages)
|
||||
if let Some(sig_hex) = &incoming.signature {
|
||||
match crate::identity::NodeIdentity::verify(from, msg.as_bytes(), sig_hex) {
|
||||
Ok(true) => {}
|
||||
_ => {
|
||||
return Ok(build_response(StatusCode::FORBIDDEN, "application/json", hyper::Body::from(r#"{"error":"Invalid signature"}"#)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Decrypt if the message is E2E encrypted
|
||||
let plaintext = if incoming.encrypted {
|
||||
// Load our identity to derive shared secret
|
||||
let data_dir = std::path::Path::new("/var/lib/archipelago");
|
||||
let identity_dir = data_dir.join("identity");
|
||||
match crate::identity::NodeIdentity::load_or_create(&identity_dir).await {
|
||||
Ok(node_id) => {
|
||||
match node_msg::decrypt_from_peer(node_id.signing_key(), from, msg) {
|
||||
Ok(decrypted) => {
|
||||
tracing::info!("Decrypted E2E message from {}...", &from[..16.min(from.len())]);
|
||||
decrypted
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("E2E decryption failed from {}: {}", &from[..16.min(from.len())], e);
|
||||
return Ok(build_response(StatusCode::BAD_REQUEST, "application/json", hyper::Body::from(r#"{"error":"Decryption failed"}"#)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Cannot decrypt: identity load failed: {}", e);
|
||||
msg.clone()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
msg.clone()
|
||||
};
|
||||
|
||||
let safe_from = sanitize_log_string(from);
|
||||
let safe_msg = sanitize_log_string(&plaintext);
|
||||
tracing::info!("Received message from {}: {}", safe_from, safe_msg);
|
||||
let clean_from = sanitize_html(from);
|
||||
let clean_msg = sanitize_html(&plaintext);
|
||||
node_msg::store_received(&clean_from, &clean_msg).await;
|
||||
}
|
||||
Ok(build_response(StatusCode::OK, "application/json", hyper::Body::from(r#"{"ok":true}"#)))
|
||||
}
|
||||
}
|
||||
131
core/archipelago/src/api/handler/proxy.rs
Normal file
131
core/archipelago/src/api/handler/proxy.rs
Normal file
@ -0,0 +1,131 @@
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use super::build_response;use crate::electrs_status;
|
||||
use anyhow::Result;
|
||||
use hyper::{Response, StatusCode};
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::{ApiHandler, is_valid_app_id};
|
||||
|
||||
impl ApiHandler {
|
||||
pub(super) async fn handle_container_logs_http(
|
||||
rpc: Arc<RpcHandler>,
|
||||
path: &str,
|
||||
cors_origin: &str,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let query = path
|
||||
.strip_prefix("/api/container/logs")
|
||||
.and_then(|s| s.strip_prefix('?'))
|
||||
.unwrap_or("");
|
||||
let params: std::collections::HashMap<String, String> =
|
||||
query
|
||||
.split('&')
|
||||
.filter_map(|p| {
|
||||
let mut it = p.splitn(2, '=');
|
||||
let k = it.next()?.to_string();
|
||||
let v = it.next()?.to_string();
|
||||
Some((k, v))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let app_id = params.get("app_id").map(|s| s.as_str()).unwrap_or("lnd");
|
||||
|
||||
// Validate app_id format
|
||||
if !is_valid_app_id(app_id) {
|
||||
let body = serde_json::json!({ "error": "Invalid app_id" });
|
||||
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
||||
return Ok(build_response(StatusCode::BAD_REQUEST, "application/json", hyper::Body::from(body_bytes)));
|
||||
}
|
||||
|
||||
let lines = params
|
||||
.get("lines")
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
.unwrap_or(200);
|
||||
|
||||
match rpc.get_container_logs_value(app_id, lines).await {
|
||||
Ok(value) => {
|
||||
let body = serde_json::json!({ "result": value });
|
||||
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Access-Control-Allow-Origin", cors_origin)
|
||||
.header("Access-Control-Allow-Credentials", "true")
|
||||
.header("Vary", "Origin")
|
||||
.body(hyper::Body::from(body_bytes))
|
||||
.unwrap())
|
||||
}
|
||||
Err(e) => {
|
||||
let body = serde_json::json!({ "error": e.to_string() });
|
||||
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Access-Control-Allow-Origin", cors_origin)
|
||||
.header("Access-Control-Allow-Credentials", "true")
|
||||
.header("Vary", "Origin")
|
||||
.body(hyper::Body::from(body_bytes))
|
||||
.unwrap())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn handle_electrs_status() -> Result<Response<hyper::Body>> {
|
||||
let status = electrs_status::get_electrs_sync_status().await;
|
||||
let body = serde_json::to_vec(&status).unwrap_or_default();
|
||||
Ok(build_response(StatusCode::OK, "application/json", hyper::Body::from(body)))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_lnd_connect_info(
|
||||
rpc: std::sync::Arc<super::super::rpc::RpcHandler>,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
match rpc.handle_lnd_connect_info().await {
|
||||
Ok(val) => {
|
||||
let body = serde_json::to_vec(&val).unwrap_or_default();
|
||||
Ok(build_response(StatusCode::OK, "application/json", hyper::Body::from(body)))
|
||||
}
|
||||
Err(e) => Ok(Response::builder()
|
||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(
|
||||
serde_json::json!({"error": e.to_string()}).to_string(),
|
||||
))
|
||||
.unwrap()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn handle_lnd_proxy(path: &str, cors_origin: &str) -> Result<Response<hyper::Body>> {
|
||||
let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/");
|
||||
let url = format!("http://127.0.0.1:8080{}", suffix);
|
||||
match reqwest::get(&url).await {
|
||||
Ok(resp) => {
|
||||
let status = resp.status().as_u16();
|
||||
let headers = resp.headers().clone();
|
||||
let body = resp.bytes().await.unwrap_or_default();
|
||||
let mut builder = Response::builder().status(status);
|
||||
if let Some(ct) = headers.get("content-type") {
|
||||
if let Ok(s) = ct.to_str() {
|
||||
builder = builder.header("Content-Type", s);
|
||||
}
|
||||
}
|
||||
builder
|
||||
.header("Access-Control-Allow-Origin", cors_origin)
|
||||
.header("Access-Control-Allow-Credentials", "true")
|
||||
.header("Vary", "Origin")
|
||||
.body(hyper::Body::from(body))
|
||||
.map_err(|e| anyhow::anyhow!("response build: {}", e))
|
||||
}
|
||||
Err(e) => {
|
||||
let body = serde_json::json!({ "error": e.to_string() });
|
||||
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::BAD_GATEWAY)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Access-Control-Allow-Origin", cors_origin)
|
||||
.header("Access-Control-Allow-Credentials", "true")
|
||||
.header("Vary", "Origin")
|
||||
.body(hyper::Body::from(body_bytes))
|
||||
.unwrap())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
128
core/archipelago/src/api/handler/websocket.rs
Normal file
128
core/archipelago/src/api/handler/websocket.rs
Normal file
@ -0,0 +1,128 @@
|
||||
use crate::monitoring::MetricsStore;
|
||||
use crate::state::StateManager;
|
||||
use anyhow::Result;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use hyper::{Request, Response};
|
||||
use hyper_ws_listener::WsStream;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tracing::{debug, info};
|
||||
|
||||
use super::ApiHandler;
|
||||
|
||||
impl ApiHandler {
|
||||
pub(super) async fn handle_websocket(
|
||||
req: Request<hyper::Body>,
|
||||
state_manager: Arc<StateManager>,
|
||||
metrics_store: Arc<MetricsStore>,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let (response, ws_fut_opt) = hyper_ws_listener::create_ws(req)
|
||||
.map_err(|e| anyhow::anyhow!("WebSocket upgrade failed: {}", e))?;
|
||||
|
||||
if let Some(ws_fut) = ws_fut_opt {
|
||||
tokio::spawn(async move {
|
||||
let ws_stream: WsStream = match ws_fut.await {
|
||||
Ok(Ok(s)) => s,
|
||||
Ok(Err(e)) => {
|
||||
debug!("WebSocket handshake failed (hyper): {}", e);
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("WebSocket task join failed: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
metrics_store.increment_ws();
|
||||
info!("WebSocket /ws/db connected");
|
||||
|
||||
let (mut tx, mut rx) = ws_stream.split();
|
||||
|
||||
let initial_msg = state_manager.get_initial_message().await;
|
||||
if let Ok(json_msg) = serde_json::to_string(&initial_msg) {
|
||||
if let Err(e) = tx.send(Message::Text(json_msg)).await {
|
||||
debug!("Failed to send initial data: {}", e);
|
||||
return;
|
||||
}
|
||||
debug!("Sent initial data dump at revision {}", initial_msg.rev);
|
||||
}
|
||||
|
||||
let mut state_rx = state_manager.subscribe();
|
||||
let ping_interval = tokio::time::interval(tokio::time::Duration::from_secs(30));
|
||||
tokio::pin!(ping_interval);
|
||||
let mut last_client_activity = Instant::now();
|
||||
const INACTIVITY_TIMEOUT_SECS: u64 = 300; // 5 minutes
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = ping_interval.tick() => {
|
||||
// Check inactivity timeout
|
||||
if last_client_activity.elapsed().as_secs() >= INACTIVITY_TIMEOUT_SECS {
|
||||
info!("WebSocket client inactive for {}s, closing", INACTIVITY_TIMEOUT_SECS);
|
||||
let _ = tx.send(Message::Close(None)).await;
|
||||
break;
|
||||
}
|
||||
if tx.send(Message::Ping(vec![])).await.is_err() {
|
||||
debug!("Failed to send ping, connection likely closed");
|
||||
break;
|
||||
}
|
||||
}
|
||||
update = state_rx.recv() => {
|
||||
match update {
|
||||
Ok(msg) => {
|
||||
if let Ok(json_msg) = serde_json::to_string(&msg) {
|
||||
if let Err(e) = tx.send(Message::Text(json_msg)).await {
|
||||
debug!("Failed to send state update: {}", e);
|
||||
break;
|
||||
}
|
||||
debug!("Sent state update at revision {}", msg.rev);
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(skipped)) => {
|
||||
debug!("Client lagged behind, skipped {} messages", skipped);
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
debug!("Broadcast channel closed");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
msg = rx.next() => {
|
||||
match msg {
|
||||
Some(Ok(Message::Close(_))) => break,
|
||||
Some(Ok(Message::Pong(_))) => {
|
||||
last_client_activity = Instant::now();
|
||||
debug!("Received pong");
|
||||
}
|
||||
Some(Ok(Message::Ping(data))) => {
|
||||
last_client_activity = Instant::now();
|
||||
let _ = tx.send(Message::Pong(data)).await;
|
||||
}
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
last_client_activity = Instant::now();
|
||||
// Handle JSON ping from frontend
|
||||
if text.contains("\"type\":\"ping\"") || text.contains("\"type\": \"ping\"") {
|
||||
let _ = tx.send(Message::Text(r#"{"type":"pong"}"#.to_string())).await;
|
||||
}
|
||||
}
|
||||
Some(Ok(_)) => {
|
||||
last_client_activity = Instant::now();
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
debug!("WebSocket stream error: {}", e);
|
||||
break;
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
metrics_store.decrement_ws();
|
||||
info!("WebSocket /ws/db disconnected");
|
||||
});
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -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> {
|
||||
17
core/archipelago/src/api/rpc/federation/mod.rs
Normal file
17
core/archipelago/src/api/rpc/federation/mod.rs
Normal file
@ -0,0 +1,17 @@
|
||||
mod handlers;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
pub(super) fn validate_did(did: &str) -> Result<()> {
|
||||
if did.is_empty() || did.len() > 256 {
|
||||
anyhow::bail!("Invalid DID: must be 1-256 characters");
|
||||
}
|
||||
if !did.starts_with("did:") {
|
||||
anyhow::bail!("Invalid DID: must start with 'did:'");
|
||||
}
|
||||
if did.contains("..") || did.contains('/') || did.contains('\\') || did.contains('\0') {
|
||||
anyhow::bail!("Invalid DID: contains forbidden characters");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -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> {
|
||||
17
core/archipelago/src/api/rpc/identity/mod.rs
Normal file
17
core/archipelago/src/api/rpc/identity/mod.rs
Normal file
@ -0,0 +1,17 @@
|
||||
mod handlers;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
pub(super) fn validate_identity_id(id: &str) -> Result<()> {
|
||||
if id.is_empty() || id.len() > 128 {
|
||||
anyhow::bail!("Invalid identity id: must be 1-128 characters");
|
||||
}
|
||||
if id.contains("..") || id.contains('/') || id.contains('\\') || id.contains('\0') {
|
||||
anyhow::bail!("Invalid identity id: contains forbidden characters");
|
||||
}
|
||||
if !id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b':') {
|
||||
anyhow::bail!("Invalid identity id: must be alphanumeric, hyphens, underscores, or colons");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -1,865 +0,0 @@
|
||||
use super::RpcHandler;
|
||||
use crate::mesh;
|
||||
use crate::mesh::message_types::{
|
||||
self, AlertPayload, AlertType, Coordinate, InvoicePayload, MeshMessageType, TypedEnvelope,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use tracing::info;
|
||||
|
||||
impl RpcHandler {
|
||||
/// mesh.status — Get mesh radio status, device info, and peer count.
|
||||
pub(super) async fn handle_mesh_status(&self) -> Result<serde_json::Value> {
|
||||
let service = self.mesh_service.read().await;
|
||||
if let Some(svc) = service.as_ref() {
|
||||
let status = svc.status().await;
|
||||
Ok(serde_json::to_value(status)?)
|
||||
} else {
|
||||
// No service running — return basic config + device detection
|
||||
let config = mesh::load_config(&self.config.data_dir).await?;
|
||||
let devices = mesh::detect_devices().await;
|
||||
Ok(serde_json::json!({
|
||||
"enabled": config.enabled,
|
||||
"device_connected": false,
|
||||
"device_type": "unknown",
|
||||
"device_path": config.device_path,
|
||||
"channel_name": config.channel_name.unwrap_or_else(|| "archipelago".to_string()),
|
||||
"detected_devices": devices,
|
||||
"peer_count": 0,
|
||||
"messages_sent": 0,
|
||||
"messages_received": 0,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// mesh.peers — List discovered mesh peers.
|
||||
pub(super) async fn handle_mesh_peers(&self) -> Result<serde_json::Value> {
|
||||
let service = self.mesh_service.read().await;
|
||||
if let Some(svc) = service.as_ref() {
|
||||
let peers = svc.peers().await;
|
||||
Ok(serde_json::json!({
|
||||
"peers": peers,
|
||||
"count": peers.len(),
|
||||
}))
|
||||
} else {
|
||||
Ok(serde_json::json!({
|
||||
"peers": [],
|
||||
"count": 0,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// mesh.messages — Get recent mesh message history.
|
||||
pub(super) async fn handle_mesh_messages(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let limit = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("limit"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|n| n as usize);
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
if let Some(svc) = service.as_ref() {
|
||||
let messages = svc.messages(limit).await;
|
||||
Ok(serde_json::json!({
|
||||
"messages": messages,
|
||||
"count": messages.len(),
|
||||
}))
|
||||
} else {
|
||||
Ok(serde_json::json!({
|
||||
"messages": [],
|
||||
"count": 0,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// mesh.send — Send an encrypted message to a mesh peer.
|
||||
pub(super) async fn handle_mesh_send(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
|
||||
let contact_id = params
|
||||
.get("contact_id")
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing contact_id"))? as u32;
|
||||
|
||||
let message = params
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing message"))?;
|
||||
|
||||
if message.is_empty() {
|
||||
anyhow::bail!("Message cannot be empty");
|
||||
}
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running. Enable mesh first."))?;
|
||||
|
||||
let msg = svc.send_message(contact_id, message).await?;
|
||||
info!(contact_id, encrypted = msg.encrypted, "Sent mesh message");
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"sent": true,
|
||||
"message_id": msg.id,
|
||||
"encrypted": msg.encrypted,
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.broadcast — Broadcast our node identity over mesh.
|
||||
pub(super) async fn handle_mesh_broadcast(&self) -> Result<serde_json::Value> {
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running. Enable mesh first."))?;
|
||||
|
||||
svc.broadcast_identity().await?;
|
||||
info!("Broadcast identity over mesh");
|
||||
|
||||
Ok(serde_json::json!({ "broadcast": true }))
|
||||
}
|
||||
|
||||
/// mesh.configure — Enable/disable mesh and set device path.
|
||||
pub(super) async fn handle_mesh_configure(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
|
||||
let mut config = mesh::load_config(&self.config.data_dir).await?;
|
||||
|
||||
if let Some(enabled) = params.get("enabled").and_then(|v| v.as_bool()) {
|
||||
config.enabled = enabled;
|
||||
}
|
||||
if let Some(device) = params.get("device_path").and_then(|v| v.as_str()) {
|
||||
config.device_path = Some(device.to_string());
|
||||
}
|
||||
if let Some(channel) = params.get("channel_name").and_then(|v| v.as_str()) {
|
||||
config.channel_name = Some(channel.to_string());
|
||||
}
|
||||
if let Some(broadcast) = params.get("broadcast_identity").and_then(|v| v.as_bool()) {
|
||||
config.broadcast_identity = broadcast;
|
||||
}
|
||||
if let Some(name) = params.get("advert_name").and_then(|v| v.as_str()) {
|
||||
config.advert_name = Some(name.to_string());
|
||||
}
|
||||
|
||||
mesh::save_config(&self.config.data_dir, &config).await?;
|
||||
|
||||
// If we have a running service, update its config
|
||||
let mut service = self.mesh_service.write().await;
|
||||
if let Some(svc) = service.as_mut() {
|
||||
svc.configure(config.clone()).await?;
|
||||
}
|
||||
|
||||
info!("Mesh config updated");
|
||||
Ok(serde_json::json!({
|
||||
"configured": true,
|
||||
"enabled": config.enabled,
|
||||
"device_path": config.device_path,
|
||||
}))
|
||||
}
|
||||
|
||||
// ─── Phase 3: Typed Messages ────────────────────────────────────────
|
||||
|
||||
/// mesh.send-invoice — Create a Lightning invoice and send bolt11 to mesh peer.
|
||||
pub(super) async fn handle_mesh_send_invoice(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let contact_id = params["contact_id"]
|
||||
.as_u64()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing contact_id"))? as u32;
|
||||
let amount_sats = params["amount_sats"]
|
||||
.as_u64()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing amount_sats"))?;
|
||||
let memo = params["memo"].as_str().map(|s| s.to_string());
|
||||
|
||||
// Build invoice payload
|
||||
let invoice = InvoicePayload {
|
||||
bolt11: format!("lnbc{}n1pjmesh...", amount_sats), // Placeholder — real LND call in Phase 4
|
||||
amount_sats,
|
||||
memo: memo.clone(),
|
||||
payment_hash: None,
|
||||
};
|
||||
|
||||
let payload = message_types::encode_payload(&invoice)?;
|
||||
let envelope = TypedEnvelope::new(MeshMessageType::Invoice, payload);
|
||||
let wire = envelope.to_wire()?;
|
||||
|
||||
// Send via mesh
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
let wire_str = String::from_utf8_lossy(&wire).to_string();
|
||||
let msg = svc.send_message(contact_id, &wire_str).await?;
|
||||
|
||||
info!(contact_id, amount_sats, "Sent invoice over mesh");
|
||||
Ok(serde_json::json!({
|
||||
"sent": true,
|
||||
"message_id": msg.id,
|
||||
"amount_sats": amount_sats,
|
||||
"bolt11": invoice.bolt11,
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.send-coordinate — Send GPS coordinates to a mesh peer.
|
||||
pub(super) async fn handle_mesh_send_coordinate(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let contact_id = params["contact_id"]
|
||||
.as_u64()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing contact_id"))? as u32;
|
||||
let lat = params["lat"]
|
||||
.as_f64()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing lat"))?;
|
||||
let lng = params["lng"]
|
||||
.as_f64()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing lng"))?;
|
||||
let label = params["label"].as_str().map(|s| s.to_string());
|
||||
|
||||
let coord = Coordinate::from_degrees(lat, lng, label);
|
||||
let payload = message_types::encode_payload(&coord)?;
|
||||
let envelope = TypedEnvelope::new(MeshMessageType::Coordinate, payload);
|
||||
let wire = envelope.to_wire()?;
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
let wire_str = String::from_utf8_lossy(&wire).to_string();
|
||||
let msg = svc.send_message(contact_id, &wire_str).await?;
|
||||
|
||||
info!(contact_id, "Sent coordinate over mesh");
|
||||
Ok(serde_json::json!({
|
||||
"sent": true,
|
||||
"message_id": msg.id,
|
||||
"lat": coord.lat,
|
||||
"lng": coord.lng,
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.send-alert — Send a signed emergency alert over mesh.
|
||||
pub(super) async fn handle_mesh_send_alert(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let message = params["message"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing message"))?;
|
||||
let alert_type_str = params["alert_type"]
|
||||
.as_str()
|
||||
.unwrap_or("status");
|
||||
let broadcast = params["broadcast"].as_bool().unwrap_or(false);
|
||||
|
||||
let alert_type = match alert_type_str {
|
||||
"emergency" => AlertType::Emergency,
|
||||
"dead_man" => AlertType::DeadMan,
|
||||
_ => AlertType::Status,
|
||||
};
|
||||
|
||||
// Optional GPS
|
||||
let coordinate = if let (Some(lat), Some(lng)) = (
|
||||
params["lat"].as_f64(),
|
||||
params["lng"].as_f64(),
|
||||
) {
|
||||
Some(Coordinate::from_degrees(lat, lng, None))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let alert = AlertPayload {
|
||||
alert_type,
|
||||
message: message.to_string(),
|
||||
coordinate,
|
||||
};
|
||||
|
||||
let payload = message_types::encode_payload(&alert)?;
|
||||
|
||||
// Sign the alert with node identity
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let node_key_path = identity_dir.join("node_key");
|
||||
|
||||
let envelope = if node_key_path.exists() {
|
||||
let key_bytes = tokio::fs::read(&node_key_path).await?;
|
||||
if key_bytes.len() == 32 {
|
||||
let mut seed = [0u8; 32];
|
||||
seed.copy_from_slice(&key_bytes);
|
||||
let signing_key = ed25519_dalek::SigningKey::from_bytes(&seed);
|
||||
TypedEnvelope::new_signed(MeshMessageType::Alert, payload, &signing_key)
|
||||
} else {
|
||||
TypedEnvelope::new(MeshMessageType::Alert, payload)
|
||||
}
|
||||
} else {
|
||||
TypedEnvelope::new(MeshMessageType::Alert, payload)
|
||||
};
|
||||
|
||||
let wire = envelope.to_wire()?;
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
let wire_str = String::from_utf8_lossy(&wire).to_string();
|
||||
if broadcast {
|
||||
// Send on channel (all peers)
|
||||
svc.send_message(0, &wire_str).await?;
|
||||
info!(alert_type = alert_type_str, "Broadcast alert over mesh");
|
||||
} else if let Some(contact_id) = params["contact_id"].as_u64() {
|
||||
svc.send_message(contact_id as u32, &wire_str).await?;
|
||||
info!(contact_id, alert_type = alert_type_str, "Sent alert to peer");
|
||||
} else {
|
||||
anyhow::bail!("Must specify contact_id or broadcast: true");
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"sent": true,
|
||||
"alert_type": alert_type_str,
|
||||
"signed": envelope.sig.is_some(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.outbox — List pending store-and-forward messages.
|
||||
pub(super) async fn handle_mesh_outbox(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let limit = params
|
||||
.as_ref()
|
||||
.and_then(|p| p["limit"].as_u64())
|
||||
.map(|n| n as usize);
|
||||
|
||||
// Check if outbox file exists
|
||||
let outbox = mesh::outbox::MeshOutbox::load(&self.config.data_dir).await?;
|
||||
let messages = outbox.list(limit).await;
|
||||
let count = outbox.count().await;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"messages": messages.iter().map(|m| serde_json::json!({
|
||||
"id": m.id,
|
||||
"dest_did": m.dest_did,
|
||||
"from_did": m.from_did,
|
||||
"created_at": m.created_at,
|
||||
"ttl_secs": m.ttl_secs,
|
||||
"retry_count": m.retry_count,
|
||||
"relay_hops": m.relay_hops,
|
||||
"expired": m.is_expired(),
|
||||
})).collect::<Vec<_>>(),
|
||||
"count": count,
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.session-status — Get ratchet session info for a peer.
|
||||
pub(super) async fn handle_mesh_session_status(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let contact_id = params["contact_id"]
|
||||
.as_u64()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing contact_id"))? as u32;
|
||||
|
||||
// Look up peer DID from mesh service
|
||||
let service = self.mesh_service.read().await;
|
||||
let peer_did = if let Some(svc) = service.as_ref() {
|
||||
let peers = svc.peers().await;
|
||||
peers.iter().find(|p| p.contact_id == contact_id).and_then(|p| p.did.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(did) = peer_did {
|
||||
let session_mgr = mesh::session::SessionManager::new(&self.config.data_dir);
|
||||
if let Some(info) = session_mgr.session_info(&did).await {
|
||||
Ok(serde_json::json!({
|
||||
"has_session": info.has_session,
|
||||
"forward_secrecy": info.forward_secrecy,
|
||||
"message_count": info.message_count,
|
||||
"ratchet_generation": info.ratchet_generation,
|
||||
"peer_did": did,
|
||||
}))
|
||||
} else {
|
||||
Ok(serde_json::json!({
|
||||
"has_session": false,
|
||||
"forward_secrecy": false,
|
||||
"message_count": 0,
|
||||
"ratchet_generation": 0,
|
||||
"peer_did": did,
|
||||
}))
|
||||
}
|
||||
} else {
|
||||
Ok(serde_json::json!({
|
||||
"has_session": false,
|
||||
"forward_secrecy": false,
|
||||
"message_count": 0,
|
||||
"ratchet_generation": 0,
|
||||
"peer_did": null,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Phase 4: Off-Grid Bitcoin Operations ────────────────────────────
|
||||
|
||||
/// mesh.relay-tx — Send a raw transaction for relay by an internet-connected mesh peer.
|
||||
pub(super) async fn handle_mesh_relay_tx(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let tx_hex = params["tx_hex"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing tx_hex"))?;
|
||||
|
||||
let relay_mode = params["relay_mode"]
|
||||
.as_str()
|
||||
.unwrap_or("archy");
|
||||
|
||||
if tx_hex.len() < 20 || tx_hex.len() > 200_000 {
|
||||
anyhow::bail!("Invalid tx_hex length");
|
||||
}
|
||||
// Validate hex
|
||||
if hex::decode(tx_hex).is_err() {
|
||||
anyhow::bail!("tx_hex is not valid hexadecimal");
|
||||
}
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
let request_id = chrono::Utc::now().timestamp() as u64;
|
||||
svc.relay_tracker.track_tx_relay(request_id, svc.our_did()).await;
|
||||
|
||||
let wire = crate::mesh::bitcoin_relay::build_tx_relay_request(tx_hex, request_id)?;
|
||||
|
||||
let mut sent_count = 0u32;
|
||||
|
||||
if relay_mode == "broadcast" {
|
||||
// Broadcast mode: send on channel 0 (all mesh nodes relay)
|
||||
// Still encrypted — only Archy nodes can decrypt and broadcast the TX
|
||||
let shared_state = svc.shared_state();
|
||||
let shared_secrets = shared_state.shared_secrets.read().await;
|
||||
|
||||
// Encrypt with first available Archy peer's shared secret
|
||||
// (any Archy node that receives it can try decrypting)
|
||||
let payload = shared_secrets.values().next()
|
||||
.and_then(|secret| {
|
||||
crate::mesh::crypto::encrypt(secret, &wire).ok().map(|ct| {
|
||||
let mut encrypted = Vec::with_capacity(1 + ct.len());
|
||||
encrypted.push(crate::mesh::message_types::ENCRYPTED_TYPED_MARKER);
|
||||
encrypted.extend_from_slice(&ct);
|
||||
encrypted
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| wire.clone());
|
||||
drop(shared_secrets);
|
||||
|
||||
{
|
||||
use base64::Engine;
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(&payload);
|
||||
let _ = shared_state
|
||||
.cmd_tx
|
||||
.send(crate::mesh::listener::MeshCommand::BroadcastChannel {
|
||||
channel: 0,
|
||||
payload: b64.into_bytes(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
sent_count = 1;
|
||||
info!(request_id, tx_len = tx_hex.len(), "TX relay broadcast on mesh channel 0 (encrypted)");
|
||||
} else {
|
||||
// Archy mode: E2E encrypted per-peer, direct to known Archy nodes
|
||||
let peers = svc.peers().await;
|
||||
let shared_state = svc.shared_state();
|
||||
let shared_secrets = shared_state.shared_secrets.read().await;
|
||||
for peer in &peers {
|
||||
if !peer.advert_name.starts_with("Archy-") { continue; }
|
||||
if let Some(ref pk) = peer.pubkey_hex {
|
||||
if let Ok(pk_bytes) = hex::decode(pk) {
|
||||
if pk_bytes.len() >= 6 {
|
||||
let mut prefix = [0u8; 6];
|
||||
prefix.copy_from_slice(&pk_bytes[..6]);
|
||||
|
||||
let payload = if let Some(secret) = shared_secrets.get(&peer.contact_id) {
|
||||
match crate::mesh::crypto::encrypt(secret, &wire) {
|
||||
Ok(ciphertext) => {
|
||||
let mut encrypted = Vec::with_capacity(1 + ciphertext.len());
|
||||
encrypted.push(crate::mesh::message_types::ENCRYPTED_TYPED_MARKER);
|
||||
encrypted.extend_from_slice(&ciphertext);
|
||||
encrypted
|
||||
}
|
||||
Err(_) => wire.clone(),
|
||||
}
|
||||
} else {
|
||||
wire.clone()
|
||||
};
|
||||
|
||||
let _ = svc.shared_state()
|
||||
.cmd_tx
|
||||
.send(crate::mesh::listener::MeshCommand::SendRaw {
|
||||
dest_pubkey_prefix: prefix,
|
||||
payload,
|
||||
})
|
||||
.await;
|
||||
sent_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(shared_secrets);
|
||||
info!(request_id, tx_len = tx_hex.len(), archy_peers = sent_count, "TX relay sent to Archy peers (E2E encrypted)");
|
||||
}
|
||||
Ok(serde_json::json!({
|
||||
"request_id": request_id,
|
||||
"queued": true,
|
||||
"tx_hex_len": tx_hex.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.relay-status — Check the status of a pending or completed TX relay.
|
||||
pub(super) async fn handle_mesh_relay_status(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let request_id = params["request_id"]
|
||||
.as_u64()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing request_id"))?;
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
// Check completed results first
|
||||
if let Some(result) = svc.relay_tracker.get_result(request_id).await {
|
||||
return Ok(serde_json::json!({
|
||||
"status": if result.txid.is_some() { "confirmed" } else { "failed" },
|
||||
"request_id": result.request_id,
|
||||
"txid": result.txid,
|
||||
"error": result.error,
|
||||
"error_code": result.error_code,
|
||||
"completed_at": result.completed_at,
|
||||
}));
|
||||
}
|
||||
|
||||
// Check if still pending
|
||||
if svc.relay_tracker.is_pending(request_id).await {
|
||||
return Ok(serde_json::json!({
|
||||
"status": "pending",
|
||||
"request_id": request_id,
|
||||
}));
|
||||
}
|
||||
|
||||
// Unknown — either expired or never existed
|
||||
Ok(serde_json::json!({
|
||||
"status": "unknown",
|
||||
"request_id": request_id,
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.block-headers — Get cached block headers received from mesh peers.
|
||||
pub(super) async fn handle_mesh_block_headers(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let count = params
|
||||
.as_ref()
|
||||
.and_then(|p| p["count"].as_u64())
|
||||
.unwrap_or(10) as usize;
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
let headers = svc.block_header_cache.recent_headers(count).await;
|
||||
let latest = svc.block_header_cache.latest_height().await;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"headers": headers.iter().map(|h| serde_json::json!({
|
||||
"height": h.height,
|
||||
"hash": h.hash,
|
||||
"prev_hash": h.prev_hash,
|
||||
"timestamp": h.timestamp,
|
||||
"announced_by": h.announced_by,
|
||||
})).collect::<Vec<_>>(),
|
||||
"latest_height": latest,
|
||||
"count": headers.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.relay-lightning — Send a Lightning invoice for payment by an internet-connected peer.
|
||||
pub(super) async fn handle_mesh_relay_lightning(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let bolt11 = params["bolt11"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing bolt11"))?;
|
||||
let amount_sats = params["amount_sats"]
|
||||
.as_u64()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing amount_sats"))?;
|
||||
|
||||
if !bolt11.starts_with("lnbc") && !bolt11.starts_with("lntb") {
|
||||
anyhow::bail!("Invalid bolt11 invoice — must start with lnbc or lntb");
|
||||
}
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
let request_id = chrono::Utc::now().timestamp() as u64;
|
||||
svc.relay_tracker.track_lightning_relay(request_id, svc.our_did()).await;
|
||||
|
||||
let wire = crate::mesh::bitcoin_relay::build_lightning_relay_request(
|
||||
bolt11, amount_sats, request_id,
|
||||
)?;
|
||||
|
||||
// Send to Archipelago peers — E2E encrypted per-peer
|
||||
let peers = svc.peers().await;
|
||||
let shared_state = svc.shared_state();
|
||||
let shared_secrets = shared_state.shared_secrets.read().await;
|
||||
let mut sent_count = 0u32;
|
||||
for peer in &peers {
|
||||
if !peer.advert_name.starts_with("Archy-") { continue; }
|
||||
if let Some(ref pk) = peer.pubkey_hex {
|
||||
if let Ok(pk_bytes) = hex::decode(pk) {
|
||||
if pk_bytes.len() >= 6 {
|
||||
let mut prefix = [0u8; 6];
|
||||
prefix.copy_from_slice(&pk_bytes[..6]);
|
||||
|
||||
let payload = if let Some(secret) = shared_secrets.get(&peer.contact_id) {
|
||||
match crate::mesh::crypto::encrypt(secret, &wire) {
|
||||
Ok(ciphertext) => {
|
||||
let mut encrypted = Vec::with_capacity(1 + ciphertext.len());
|
||||
encrypted.push(crate::mesh::message_types::ENCRYPTED_TYPED_MARKER);
|
||||
encrypted.extend_from_slice(&ciphertext);
|
||||
encrypted
|
||||
}
|
||||
Err(_) => wire.clone(),
|
||||
}
|
||||
} else {
|
||||
wire.clone()
|
||||
};
|
||||
|
||||
let _ = svc.shared_state()
|
||||
.cmd_tx
|
||||
.send(crate::mesh::listener::MeshCommand::SendRaw {
|
||||
dest_pubkey_prefix: prefix,
|
||||
payload,
|
||||
})
|
||||
.await;
|
||||
sent_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(shared_secrets);
|
||||
|
||||
info!(request_id, amount_sats, archy_peers = sent_count, "Lightning relay sent (E2E encrypted)");
|
||||
Ok(serde_json::json!({
|
||||
"request_id": request_id,
|
||||
"queued": true,
|
||||
"amount_sats": amount_sats,
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.deadman-status — Get dead man's switch status.
|
||||
pub(super) async fn handle_mesh_deadman_status(&self) -> Result<serde_json::Value> {
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
let status = svc.dead_man_switch.status().await;
|
||||
Ok(serde_json::to_value(status)?)
|
||||
}
|
||||
|
||||
/// mesh.deadman-configure — Configure the dead man's switch.
|
||||
pub(super) async fn handle_mesh_deadman_configure(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
let mut config = svc.dead_man_switch.get_config().await;
|
||||
|
||||
if let Some(enabled) = params.get("enabled").and_then(|v| v.as_bool()) {
|
||||
config.dead_man_enabled = enabled;
|
||||
}
|
||||
if let Some(interval) = params.get("interval_secs").and_then(|v| v.as_u64()) {
|
||||
if interval < 60 {
|
||||
anyhow::bail!("Interval must be at least 60 seconds");
|
||||
}
|
||||
config.dead_man_interval_secs = interval;
|
||||
}
|
||||
if let (Some(lat), Some(lng)) = (
|
||||
params.get("lat").and_then(|v| v.as_f64()),
|
||||
params.get("lng").and_then(|v| v.as_f64()),
|
||||
) {
|
||||
let label = params.get("label").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
config.last_gps = Some(Coordinate::from_degrees(lat, lng, label));
|
||||
}
|
||||
if let Some(contacts) = params.get("contacts").and_then(|v| v.as_array()) {
|
||||
config.emergency_contacts = contacts
|
||||
.iter()
|
||||
.filter_map(|c| c.as_str().map(|s| s.to_string()))
|
||||
.collect();
|
||||
}
|
||||
if let Some(msg) = params.get("custom_message").and_then(|v| v.as_str()) {
|
||||
config.custom_message = Some(msg.to_string());
|
||||
}
|
||||
if let Some(auto_gps) = params.get("auto_gps").and_then(|v| v.as_bool()) {
|
||||
config.auto_include_gps = auto_gps;
|
||||
}
|
||||
|
||||
svc.dead_man_switch.configure(config).await?;
|
||||
// Reset timer on configure
|
||||
svc.dead_man_switch.check_in().await;
|
||||
|
||||
let status = svc.dead_man_switch.status().await;
|
||||
info!("Dead man's switch configured");
|
||||
Ok(serde_json::to_value(status)?)
|
||||
}
|
||||
|
||||
/// mesh.deadman-checkin — Heartbeat to reset the dead man's switch timer.
|
||||
pub(super) async fn handle_mesh_deadman_checkin(&self) -> Result<serde_json::Value> {
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
svc.dead_man_check_in().await;
|
||||
let remaining = svc.dead_man_switch.time_remaining_secs().await;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"checked_in": true,
|
||||
"time_remaining_secs": remaining,
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.rotate-prekeys — Force prekey rotation for X3DH.
|
||||
pub(super) async fn handle_mesh_rotate_prekeys(&self) -> Result<serde_json::Value> {
|
||||
// Load identity signing key
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let node_key_path = identity_dir.join("node_key");
|
||||
let key_bytes = tokio::fs::read(&node_key_path)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("Node identity not found"))?;
|
||||
if key_bytes.len() != 32 {
|
||||
anyhow::bail!("Invalid node key");
|
||||
}
|
||||
let mut seed = [0u8; 32];
|
||||
seed.copy_from_slice(&key_bytes);
|
||||
let signing_key = ed25519_dalek::SigningKey::from_bytes(&seed);
|
||||
|
||||
// Generate new prekey bundle
|
||||
let (bundle, _secrets) = mesh::x3dh::generate_prekey_bundle(&signing_key, 10)?;
|
||||
|
||||
// Save bundle for distribution
|
||||
let bundle_bytes = mesh::x3dh::encode_bundle(&bundle)?;
|
||||
let prekey_dir = self.config.data_dir.join("prekeys");
|
||||
tokio::fs::create_dir_all(&prekey_dir).await?;
|
||||
tokio::fs::write(prekey_dir.join("bundle.cbor"), &bundle_bytes).await?;
|
||||
|
||||
info!(
|
||||
one_time_keys = bundle.one_time_prekeys.len(),
|
||||
"Prekey bundle rotated"
|
||||
);
|
||||
Ok(serde_json::json!({
|
||||
"rotated": true,
|
||||
"signed_prekey_id": bundle.signed_prekey.id,
|
||||
"one_time_prekeys": bundle.one_time_prekeys.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
// ─── Radio Diagnostics ─────────────────────────────────────────────
|
||||
|
||||
/// mesh.test-send — Send test payloads of various sizes to diagnose radio link.
|
||||
/// Sends plain text markers that the receiver can count.
|
||||
pub(super) async fn handle_mesh_test_send(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let contact_id = params["contact_id"]
|
||||
.as_u64()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing contact_id"))? as u32;
|
||||
|
||||
// Test modes: "ping" (small), "medium" (80 bytes), "large" (150 bytes), "chunked" (400 bytes)
|
||||
let mode = params["mode"].as_str().unwrap_or("ping");
|
||||
let count = params["count"].as_u64().unwrap_or(3) as usize;
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
let mut sent = 0usize;
|
||||
let test_id = chrono::Utc::now().timestamp() as u32;
|
||||
|
||||
for i in 0..count {
|
||||
let payload = match mode {
|
||||
"ping" => format!("MESHTEST:{}:{}:PING", test_id, i),
|
||||
"medium" => format!("MESHTEST:{}:{}:{}", test_id, i, "X".repeat(60)),
|
||||
"large" => format!("MESHTEST:{}:{}:{}", test_id, i, "X".repeat(130)),
|
||||
"chunked" => {
|
||||
// Send a TypedEnvelope that requires chunking (>140 base64 chars)
|
||||
let fake_tx = "0".repeat(400); // simulates TX hex
|
||||
let wire = crate::mesh::bitcoin_relay::build_tx_relay_request(&fake_tx, test_id as u64 + i as u64)?;
|
||||
// Send via SendRaw which handles base64 + chunking
|
||||
let peers = svc.peers().await;
|
||||
if let Some(peer) = peers.iter().find(|p| p.contact_id == contact_id) {
|
||||
if let Some(ref pk) = peer.pubkey_hex {
|
||||
if let Ok(pk_bytes) = hex::decode(pk) {
|
||||
if pk_bytes.len() >= 6 {
|
||||
let mut prefix = [0u8; 6];
|
||||
prefix.copy_from_slice(&pk_bytes[..6]);
|
||||
let _ = svc.shared_state().cmd_tx.send(
|
||||
crate::mesh::listener::MeshCommand::SendRaw {
|
||||
dest_pubkey_prefix: prefix,
|
||||
payload: wire,
|
||||
},
|
||||
).await;
|
||||
sent += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Delay between chunked sends
|
||||
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||
continue;
|
||||
}
|
||||
_ => format!("MESHTEST:{}:{}:UNKNOWN", test_id, i),
|
||||
};
|
||||
|
||||
// Send as plain text for ping/medium/large
|
||||
let msg = svc.send_message(contact_id, &payload).await?;
|
||||
sent += 1;
|
||||
info!(test_id, seq = i, mode, len = payload.len(), "Test message sent");
|
||||
|
||||
// Small delay between sends
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"test_id": test_id,
|
||||
"mode": mode,
|
||||
"sent": sent,
|
||||
"count": count,
|
||||
}))
|
||||
}
|
||||
}
|
||||
267
core/archipelago/src/api/rpc/mesh/bitcoin_ops.rs
Normal file
267
core/archipelago/src/api/rpc/mesh/bitcoin_ops.rs
Normal file
@ -0,0 +1,267 @@
|
||||
use super::super::RpcHandler;
|
||||
use anyhow::Result;
|
||||
use tracing::info;
|
||||
|
||||
impl RpcHandler {
|
||||
/// mesh.relay-tx — Send a raw transaction for relay by an internet-connected mesh peer.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_relay_tx(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let tx_hex = params["tx_hex"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing tx_hex"))?;
|
||||
|
||||
let relay_mode = params["relay_mode"]
|
||||
.as_str()
|
||||
.unwrap_or("archy");
|
||||
|
||||
if tx_hex.len() < 20 || tx_hex.len() > 200_000 {
|
||||
anyhow::bail!("Invalid tx_hex length");
|
||||
}
|
||||
// Validate hex
|
||||
if hex::decode(tx_hex).is_err() {
|
||||
anyhow::bail!("tx_hex is not valid hexadecimal");
|
||||
}
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
let request_id = chrono::Utc::now().timestamp() as u64;
|
||||
svc.relay_tracker.track_tx_relay(request_id, svc.our_did()).await;
|
||||
|
||||
let wire = crate::mesh::bitcoin_relay::build_tx_relay_request(tx_hex, request_id)?;
|
||||
|
||||
let mut sent_count = 0u32;
|
||||
|
||||
if relay_mode == "broadcast" {
|
||||
// Broadcast mode: send on channel 0 (all mesh nodes relay)
|
||||
// Still encrypted — only Archy nodes can decrypt and broadcast the TX
|
||||
let shared_state = svc.shared_state();
|
||||
let shared_secrets = shared_state.shared_secrets.read().await;
|
||||
|
||||
// Encrypt with first available Archy peer's shared secret
|
||||
// (any Archy node that receives it can try decrypting)
|
||||
let payload = shared_secrets.values().next()
|
||||
.and_then(|secret| {
|
||||
crate::mesh::crypto::encrypt(secret, &wire).ok().map(|ct| {
|
||||
let mut encrypted = Vec::with_capacity(1 + ct.len());
|
||||
encrypted.push(crate::mesh::message_types::ENCRYPTED_TYPED_MARKER);
|
||||
encrypted.extend_from_slice(&ct);
|
||||
encrypted
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| wire.clone());
|
||||
drop(shared_secrets);
|
||||
|
||||
{
|
||||
use base64::Engine;
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(&payload);
|
||||
let _ = shared_state
|
||||
.cmd_tx
|
||||
.send(crate::mesh::listener::MeshCommand::BroadcastChannel {
|
||||
channel: 0,
|
||||
payload: b64.into_bytes(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
info!(request_id, tx_len = tx_hex.len(), "TX relay broadcast on mesh channel 0 (encrypted)");
|
||||
} else {
|
||||
// Archy mode: E2E encrypted per-peer, direct to known Archy nodes
|
||||
let peers = svc.peers().await;
|
||||
let shared_state = svc.shared_state();
|
||||
let shared_secrets = shared_state.shared_secrets.read().await;
|
||||
for peer in &peers {
|
||||
if !peer.advert_name.starts_with("Archy-") { continue; }
|
||||
if let Some(ref pk) = peer.pubkey_hex {
|
||||
if let Ok(pk_bytes) = hex::decode(pk) {
|
||||
if pk_bytes.len() >= 6 {
|
||||
let mut prefix = [0u8; 6];
|
||||
prefix.copy_from_slice(&pk_bytes[..6]);
|
||||
|
||||
let payload = if let Some(secret) = shared_secrets.get(&peer.contact_id) {
|
||||
match crate::mesh::crypto::encrypt(secret, &wire) {
|
||||
Ok(ciphertext) => {
|
||||
let mut encrypted = Vec::with_capacity(1 + ciphertext.len());
|
||||
encrypted.push(crate::mesh::message_types::ENCRYPTED_TYPED_MARKER);
|
||||
encrypted.extend_from_slice(&ciphertext);
|
||||
encrypted
|
||||
}
|
||||
Err(_) => wire.clone(),
|
||||
}
|
||||
} else {
|
||||
wire.clone()
|
||||
};
|
||||
|
||||
let _ = svc.shared_state()
|
||||
.cmd_tx
|
||||
.send(crate::mesh::listener::MeshCommand::SendRaw {
|
||||
dest_pubkey_prefix: prefix,
|
||||
payload,
|
||||
})
|
||||
.await;
|
||||
sent_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(shared_secrets);
|
||||
info!(request_id, tx_len = tx_hex.len(), archy_peers = sent_count, "TX relay sent to Archy peers (E2E encrypted)");
|
||||
}
|
||||
Ok(serde_json::json!({
|
||||
"request_id": request_id,
|
||||
"queued": true,
|
||||
"tx_hex_len": tx_hex.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.relay-status — Check the status of a pending or completed TX relay.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_relay_status(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let request_id = params["request_id"]
|
||||
.as_u64()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing request_id"))?;
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
// Check completed results first
|
||||
if let Some(result) = svc.relay_tracker.get_result(request_id).await {
|
||||
return Ok(serde_json::json!({
|
||||
"status": if result.txid.is_some() { "confirmed" } else { "failed" },
|
||||
"request_id": result.request_id,
|
||||
"txid": result.txid,
|
||||
"error": result.error,
|
||||
"error_code": result.error_code,
|
||||
"completed_at": result.completed_at,
|
||||
}));
|
||||
}
|
||||
|
||||
// Check if still pending
|
||||
if svc.relay_tracker.is_pending(request_id).await {
|
||||
return Ok(serde_json::json!({
|
||||
"status": "pending",
|
||||
"request_id": request_id,
|
||||
}));
|
||||
}
|
||||
|
||||
// Unknown — either expired or never existed
|
||||
Ok(serde_json::json!({
|
||||
"status": "unknown",
|
||||
"request_id": request_id,
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.block-headers — Get cached block headers received from mesh peers.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_block_headers(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let count = params
|
||||
.as_ref()
|
||||
.and_then(|p| p["count"].as_u64())
|
||||
.unwrap_or(10) as usize;
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
let headers = svc.block_header_cache.recent_headers(count).await;
|
||||
let latest = svc.block_header_cache.latest_height().await;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"headers": headers.iter().map(|h| serde_json::json!({
|
||||
"height": h.height,
|
||||
"hash": h.hash,
|
||||
"prev_hash": h.prev_hash,
|
||||
"timestamp": h.timestamp,
|
||||
"announced_by": h.announced_by,
|
||||
})).collect::<Vec<_>>(),
|
||||
"latest_height": latest,
|
||||
"count": headers.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.relay-lightning — Send a Lightning invoice for payment by an internet-connected peer.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_relay_lightning(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let bolt11 = params["bolt11"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing bolt11"))?;
|
||||
let amount_sats = params["amount_sats"]
|
||||
.as_u64()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing amount_sats"))?;
|
||||
|
||||
if !bolt11.starts_with("lnbc") && !bolt11.starts_with("lntb") {
|
||||
anyhow::bail!("Invalid bolt11 invoice — must start with lnbc or lntb");
|
||||
}
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
let request_id = chrono::Utc::now().timestamp() as u64;
|
||||
svc.relay_tracker.track_lightning_relay(request_id, svc.our_did()).await;
|
||||
|
||||
let wire = crate::mesh::bitcoin_relay::build_lightning_relay_request(
|
||||
bolt11, amount_sats, request_id,
|
||||
)?;
|
||||
|
||||
// Send to Archipelago peers — E2E encrypted per-peer
|
||||
let peers = svc.peers().await;
|
||||
let shared_state = svc.shared_state();
|
||||
let shared_secrets = shared_state.shared_secrets.read().await;
|
||||
let mut sent_count = 0u32;
|
||||
for peer in &peers {
|
||||
if !peer.advert_name.starts_with("Archy-") { continue; }
|
||||
if let Some(ref pk) = peer.pubkey_hex {
|
||||
if let Ok(pk_bytes) = hex::decode(pk) {
|
||||
if pk_bytes.len() >= 6 {
|
||||
let mut prefix = [0u8; 6];
|
||||
prefix.copy_from_slice(&pk_bytes[..6]);
|
||||
|
||||
let payload = if let Some(secret) = shared_secrets.get(&peer.contact_id) {
|
||||
match crate::mesh::crypto::encrypt(secret, &wire) {
|
||||
Ok(ciphertext) => {
|
||||
let mut encrypted = Vec::with_capacity(1 + ciphertext.len());
|
||||
encrypted.push(crate::mesh::message_types::ENCRYPTED_TYPED_MARKER);
|
||||
encrypted.extend_from_slice(&ciphertext);
|
||||
encrypted
|
||||
}
|
||||
Err(_) => wire.clone(),
|
||||
}
|
||||
} else {
|
||||
wire.clone()
|
||||
};
|
||||
|
||||
let _ = svc.shared_state()
|
||||
.cmd_tx
|
||||
.send(crate::mesh::listener::MeshCommand::SendRaw {
|
||||
dest_pubkey_prefix: prefix,
|
||||
payload,
|
||||
})
|
||||
.await;
|
||||
sent_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(shared_secrets);
|
||||
|
||||
info!(request_id, amount_sats, archy_peers = sent_count, "Lightning relay sent (E2E encrypted)");
|
||||
Ok(serde_json::json!({
|
||||
"request_id": request_id,
|
||||
"queued": true,
|
||||
"amount_sats": amount_sats,
|
||||
}))
|
||||
}
|
||||
}
|
||||
96
core/archipelago/src/api/rpc/mesh/messaging.rs
Normal file
96
core/archipelago/src/api/rpc/mesh/messaging.rs
Normal file
@ -0,0 +1,96 @@
|
||||
use super::super::RpcHandler;
|
||||
use crate::mesh;
|
||||
use anyhow::Result;
|
||||
use tracing::info;
|
||||
|
||||
impl RpcHandler {
|
||||
/// mesh.send — Send an encrypted message to a mesh peer.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_send(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
|
||||
let contact_id = params
|
||||
.get("contact_id")
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing contact_id"))? as u32;
|
||||
|
||||
let message = params
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing message"))?;
|
||||
|
||||
if message.is_empty() {
|
||||
anyhow::bail!("Message cannot be empty");
|
||||
}
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running. Enable mesh first."))?;
|
||||
|
||||
let msg = svc.send_message(contact_id, message).await?;
|
||||
info!(contact_id, encrypted = msg.encrypted, "Sent mesh message");
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"sent": true,
|
||||
"message_id": msg.id,
|
||||
"encrypted": msg.encrypted,
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.broadcast — Broadcast our node identity over mesh.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_broadcast(&self) -> Result<serde_json::Value> {
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running. Enable mesh first."))?;
|
||||
|
||||
svc.broadcast_identity().await?;
|
||||
info!("Broadcast identity over mesh");
|
||||
|
||||
Ok(serde_json::json!({ "broadcast": true }))
|
||||
}
|
||||
|
||||
/// mesh.configure — Enable/disable mesh and set device path.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_configure(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
|
||||
let mut config = mesh::load_config(&self.config.data_dir).await?;
|
||||
|
||||
if let Some(enabled) = params.get("enabled").and_then(|v| v.as_bool()) {
|
||||
config.enabled = enabled;
|
||||
}
|
||||
if let Some(device) = params.get("device_path").and_then(|v| v.as_str()) {
|
||||
config.device_path = Some(device.to_string());
|
||||
}
|
||||
if let Some(channel) = params.get("channel_name").and_then(|v| v.as_str()) {
|
||||
config.channel_name = Some(channel.to_string());
|
||||
}
|
||||
if let Some(broadcast) = params.get("broadcast_identity").and_then(|v| v.as_bool()) {
|
||||
config.broadcast_identity = broadcast;
|
||||
}
|
||||
if let Some(name) = params.get("advert_name").and_then(|v| v.as_str()) {
|
||||
config.advert_name = Some(name.to_string());
|
||||
}
|
||||
|
||||
mesh::save_config(&self.config.data_dir, &config).await?;
|
||||
|
||||
// If we have a running service, update its config
|
||||
let mut service = self.mesh_service.write().await;
|
||||
if let Some(svc) = service.as_mut() {
|
||||
svc.configure(config.clone()).await?;
|
||||
}
|
||||
|
||||
info!("Mesh config updated");
|
||||
Ok(serde_json::json!({
|
||||
"configured": true,
|
||||
"enabled": config.enabled,
|
||||
"device_path": config.device_path,
|
||||
}))
|
||||
}
|
||||
}
|
||||
5
core/archipelago/src/api/rpc/mesh/mod.rs
Normal file
5
core/archipelago/src/api/rpc/mesh/mod.rs
Normal file
@ -0,0 +1,5 @@
|
||||
mod bitcoin_ops;
|
||||
mod messaging;
|
||||
mod safety;
|
||||
mod status;
|
||||
mod typed_messages;
|
||||
222
core/archipelago/src/api/rpc/mesh/safety.rs
Normal file
222
core/archipelago/src/api/rpc/mesh/safety.rs
Normal file
@ -0,0 +1,222 @@
|
||||
use super::super::RpcHandler;
|
||||
use crate::mesh;
|
||||
use crate::mesh::message_types::Coordinate;
|
||||
use anyhow::Result;
|
||||
use tracing::info;
|
||||
|
||||
impl RpcHandler {
|
||||
/// mesh.outbox — List pending store-and-forward messages.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_outbox(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let limit = params
|
||||
.as_ref()
|
||||
.and_then(|p| p["limit"].as_u64())
|
||||
.map(|n| n as usize);
|
||||
|
||||
// Check if outbox file exists
|
||||
let outbox = mesh::outbox::MeshOutbox::load(&self.config.data_dir).await?;
|
||||
let messages = outbox.list(limit).await;
|
||||
let count = outbox.count().await;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"messages": messages.iter().map(|m| serde_json::json!({
|
||||
"id": m.id,
|
||||
"dest_did": m.dest_did,
|
||||
"from_did": m.from_did,
|
||||
"created_at": m.created_at,
|
||||
"ttl_secs": m.ttl_secs,
|
||||
"retry_count": m.retry_count,
|
||||
"relay_hops": m.relay_hops,
|
||||
"expired": m.is_expired(),
|
||||
})).collect::<Vec<_>>(),
|
||||
"count": count,
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.deadman-status — Get dead man's switch status.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_deadman_status(&self) -> Result<serde_json::Value> {
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
let status = svc.dead_man_switch.status().await;
|
||||
Ok(serde_json::to_value(status)?)
|
||||
}
|
||||
|
||||
/// mesh.deadman-configure — Configure the dead man's switch.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_deadman_configure(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
let mut config = svc.dead_man_switch.get_config().await;
|
||||
|
||||
if let Some(enabled) = params.get("enabled").and_then(|v| v.as_bool()) {
|
||||
config.dead_man_enabled = enabled;
|
||||
}
|
||||
if let Some(interval) = params.get("interval_secs").and_then(|v| v.as_u64()) {
|
||||
if interval < 60 {
|
||||
anyhow::bail!("Interval must be at least 60 seconds");
|
||||
}
|
||||
config.dead_man_interval_secs = interval;
|
||||
}
|
||||
if let (Some(lat), Some(lng)) = (
|
||||
params.get("lat").and_then(|v| v.as_f64()),
|
||||
params.get("lng").and_then(|v| v.as_f64()),
|
||||
) {
|
||||
let label = params.get("label").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
config.last_gps = Some(Coordinate::from_degrees(lat, lng, label));
|
||||
}
|
||||
if let Some(contacts) = params.get("contacts").and_then(|v| v.as_array()) {
|
||||
config.emergency_contacts = contacts
|
||||
.iter()
|
||||
.filter_map(|c| c.as_str().map(|s| s.to_string()))
|
||||
.collect();
|
||||
}
|
||||
if let Some(msg) = params.get("custom_message").and_then(|v| v.as_str()) {
|
||||
config.custom_message = Some(msg.to_string());
|
||||
}
|
||||
if let Some(auto_gps) = params.get("auto_gps").and_then(|v| v.as_bool()) {
|
||||
config.auto_include_gps = auto_gps;
|
||||
}
|
||||
|
||||
svc.dead_man_switch.configure(config).await?;
|
||||
// Reset timer on configure
|
||||
svc.dead_man_switch.check_in().await;
|
||||
|
||||
let status = svc.dead_man_switch.status().await;
|
||||
info!("Dead man's switch configured");
|
||||
Ok(serde_json::to_value(status)?)
|
||||
}
|
||||
|
||||
/// mesh.deadman-checkin — Heartbeat to reset the dead man's switch timer.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_deadman_checkin(&self) -> Result<serde_json::Value> {
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
svc.dead_man_check_in().await;
|
||||
let remaining = svc.dead_man_switch.time_remaining_secs().await;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"checked_in": true,
|
||||
"time_remaining_secs": remaining,
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.rotate-prekeys — Force prekey rotation for X3DH.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_rotate_prekeys(&self) -> Result<serde_json::Value> {
|
||||
// Load identity signing key
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let node_key_path = identity_dir.join("node_key");
|
||||
let key_bytes = tokio::fs::read(&node_key_path)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("Node identity not found"))?;
|
||||
if key_bytes.len() != 32 {
|
||||
anyhow::bail!("Invalid node key");
|
||||
}
|
||||
let mut seed = [0u8; 32];
|
||||
seed.copy_from_slice(&key_bytes);
|
||||
let signing_key = ed25519_dalek::SigningKey::from_bytes(&seed);
|
||||
|
||||
// Generate new prekey bundle
|
||||
let (bundle, _secrets) = mesh::x3dh::generate_prekey_bundle(&signing_key, 10)?;
|
||||
|
||||
// Save bundle for distribution
|
||||
let bundle_bytes = mesh::x3dh::encode_bundle(&bundle)?;
|
||||
let prekey_dir = self.config.data_dir.join("prekeys");
|
||||
tokio::fs::create_dir_all(&prekey_dir).await?;
|
||||
tokio::fs::write(prekey_dir.join("bundle.cbor"), &bundle_bytes).await?;
|
||||
|
||||
info!(
|
||||
one_time_keys = bundle.one_time_prekeys.len(),
|
||||
"Prekey bundle rotated"
|
||||
);
|
||||
Ok(serde_json::json!({
|
||||
"rotated": true,
|
||||
"signed_prekey_id": bundle.signed_prekey.id,
|
||||
"one_time_prekeys": bundle.one_time_prekeys.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.test-send — Send test payloads of various sizes to diagnose radio link.
|
||||
/// Sends plain text markers that the receiver can count.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_test_send(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let contact_id = params["contact_id"]
|
||||
.as_u64()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing contact_id"))? as u32;
|
||||
|
||||
// Test modes: "ping" (small), "medium" (80 bytes), "large" (150 bytes), "chunked" (400 bytes)
|
||||
let mode = params["mode"].as_str().unwrap_or("ping");
|
||||
let count = params["count"].as_u64().unwrap_or(3) as usize;
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
let mut sent = 0usize;
|
||||
let test_id = chrono::Utc::now().timestamp() as u32;
|
||||
|
||||
for i in 0..count {
|
||||
let payload = match mode {
|
||||
"ping" => format!("MESHTEST:{}:{}:PING", test_id, i),
|
||||
"medium" => format!("MESHTEST:{}:{}:{}", test_id, i, "X".repeat(60)),
|
||||
"large" => format!("MESHTEST:{}:{}:{}", test_id, i, "X".repeat(130)),
|
||||
"chunked" => {
|
||||
// Send a TypedEnvelope that requires chunking (>140 base64 chars)
|
||||
let fake_tx = "0".repeat(400); // simulates TX hex
|
||||
let wire = crate::mesh::bitcoin_relay::build_tx_relay_request(&fake_tx, test_id as u64 + i as u64)?;
|
||||
// Send via SendRaw which handles base64 + chunking
|
||||
let peers = svc.peers().await;
|
||||
if let Some(peer) = peers.iter().find(|p| p.contact_id == contact_id) {
|
||||
if let Some(ref pk) = peer.pubkey_hex {
|
||||
if let Ok(pk_bytes) = hex::decode(pk) {
|
||||
if pk_bytes.len() >= 6 {
|
||||
let mut prefix = [0u8; 6];
|
||||
prefix.copy_from_slice(&pk_bytes[..6]);
|
||||
let _ = svc.shared_state().cmd_tx.send(
|
||||
crate::mesh::listener::MeshCommand::SendRaw {
|
||||
dest_pubkey_prefix: prefix,
|
||||
payload: wire,
|
||||
},
|
||||
).await;
|
||||
sent += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Delay between chunked sends
|
||||
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||
continue;
|
||||
}
|
||||
_ => format!("MESHTEST:{}:{}:UNKNOWN", test_id, i),
|
||||
};
|
||||
|
||||
// Send as plain text for ping/medium/large
|
||||
let _msg = svc.send_message(contact_id, &payload).await?;
|
||||
sent += 1;
|
||||
info!(test_id, seq = i, mode, len = payload.len(), "Test message sent");
|
||||
|
||||
// Small delay between sends
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"test_id": test_id,
|
||||
"mode": mode,
|
||||
"sent": sent,
|
||||
"count": count,
|
||||
}))
|
||||
}
|
||||
}
|
||||
121
core/archipelago/src/api/rpc/mesh/status.rs
Normal file
121
core/archipelago/src/api/rpc/mesh/status.rs
Normal file
@ -0,0 +1,121 @@
|
||||
use super::super::RpcHandler;
|
||||
use crate::mesh;
|
||||
use anyhow::Result;
|
||||
|
||||
impl RpcHandler {
|
||||
/// mesh.status — Get mesh radio status, device info, and peer count.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_status(&self) -> Result<serde_json::Value> {
|
||||
let service = self.mesh_service.read().await;
|
||||
if let Some(svc) = service.as_ref() {
|
||||
let status = svc.status().await;
|
||||
Ok(serde_json::to_value(status)?)
|
||||
} else {
|
||||
// No service running — return basic config + device detection
|
||||
let config = mesh::load_config(&self.config.data_dir).await?;
|
||||
let devices = mesh::detect_devices().await;
|
||||
Ok(serde_json::json!({
|
||||
"enabled": config.enabled,
|
||||
"device_connected": false,
|
||||
"device_type": "unknown",
|
||||
"device_path": config.device_path,
|
||||
"channel_name": config.channel_name.unwrap_or_else(|| "archipelago".to_string()),
|
||||
"detected_devices": devices,
|
||||
"peer_count": 0,
|
||||
"messages_sent": 0,
|
||||
"messages_received": 0,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// mesh.peers — List discovered mesh peers.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_peers(&self) -> Result<serde_json::Value> {
|
||||
let service = self.mesh_service.read().await;
|
||||
if let Some(svc) = service.as_ref() {
|
||||
let peers = svc.peers().await;
|
||||
Ok(serde_json::json!({
|
||||
"peers": peers,
|
||||
"count": peers.len(),
|
||||
}))
|
||||
} else {
|
||||
Ok(serde_json::json!({
|
||||
"peers": [],
|
||||
"count": 0,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// mesh.messages — Get recent mesh message history.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_messages(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let limit = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("limit"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|n| n as usize);
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
if let Some(svc) = service.as_ref() {
|
||||
let messages = svc.messages(limit).await;
|
||||
Ok(serde_json::json!({
|
||||
"messages": messages,
|
||||
"count": messages.len(),
|
||||
}))
|
||||
} else {
|
||||
Ok(serde_json::json!({
|
||||
"messages": [],
|
||||
"count": 0,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// mesh.session-status — Get ratchet session info for a peer.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_session_status(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let contact_id = params["contact_id"]
|
||||
.as_u64()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing contact_id"))? as u32;
|
||||
|
||||
// Look up peer DID from mesh service
|
||||
let service = self.mesh_service.read().await;
|
||||
let peer_did = if let Some(svc) = service.as_ref() {
|
||||
let peers = svc.peers().await;
|
||||
peers.iter().find(|p| p.contact_id == contact_id).and_then(|p| p.did.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(did) = peer_did {
|
||||
let session_mgr = mesh::session::SessionManager::new(&self.config.data_dir);
|
||||
if let Some(info) = session_mgr.session_info(&did).await {
|
||||
Ok(serde_json::json!({
|
||||
"has_session": info.has_session,
|
||||
"forward_secrecy": info.forward_secrecy,
|
||||
"message_count": info.message_count,
|
||||
"ratchet_generation": info.ratchet_generation,
|
||||
"peer_did": did,
|
||||
}))
|
||||
} else {
|
||||
Ok(serde_json::json!({
|
||||
"has_session": false,
|
||||
"forward_secrecy": false,
|
||||
"message_count": 0,
|
||||
"ratchet_generation": 0,
|
||||
"peer_did": did,
|
||||
}))
|
||||
}
|
||||
} else {
|
||||
Ok(serde_json::json!({
|
||||
"has_session": false,
|
||||
"forward_secrecy": false,
|
||||
"message_count": 0,
|
||||
"ratchet_generation": 0,
|
||||
"peer_did": null,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
174
core/archipelago/src/api/rpc/mesh/typed_messages.rs
Normal file
174
core/archipelago/src/api/rpc/mesh/typed_messages.rs
Normal file
@ -0,0 +1,174 @@
|
||||
use super::super::RpcHandler;
|
||||
use crate::mesh::message_types::{
|
||||
self, AlertPayload, AlertType, Coordinate, InvoicePayload, MeshMessageType, TypedEnvelope,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use tracing::info;
|
||||
|
||||
impl RpcHandler {
|
||||
/// mesh.send-invoice — Create a Lightning invoice and send bolt11 to mesh peer.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_send_invoice(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let contact_id = params["contact_id"]
|
||||
.as_u64()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing contact_id"))? as u32;
|
||||
let amount_sats = params["amount_sats"]
|
||||
.as_u64()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing amount_sats"))?;
|
||||
let memo = params["memo"].as_str().map(|s| s.to_string());
|
||||
|
||||
// Build invoice payload
|
||||
let invoice = InvoicePayload {
|
||||
bolt11: format!("lnbc{}n1pjmesh...", amount_sats), // Placeholder — real LND call in Phase 4
|
||||
amount_sats,
|
||||
memo: memo.clone(),
|
||||
payment_hash: None,
|
||||
};
|
||||
|
||||
let payload = message_types::encode_payload(&invoice)?;
|
||||
let envelope = TypedEnvelope::new(MeshMessageType::Invoice, payload);
|
||||
let wire = envelope.to_wire()?;
|
||||
|
||||
// Send via mesh
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
let wire_str = String::from_utf8_lossy(&wire).to_string();
|
||||
let msg = svc.send_message(contact_id, &wire_str).await?;
|
||||
|
||||
info!(contact_id, amount_sats, "Sent invoice over mesh");
|
||||
Ok(serde_json::json!({
|
||||
"sent": true,
|
||||
"message_id": msg.id,
|
||||
"amount_sats": amount_sats,
|
||||
"bolt11": invoice.bolt11,
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.send-coordinate — Send GPS coordinates to a mesh peer.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_send_coordinate(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let contact_id = params["contact_id"]
|
||||
.as_u64()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing contact_id"))? as u32;
|
||||
let lat = params["lat"]
|
||||
.as_f64()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing lat"))?;
|
||||
let lng = params["lng"]
|
||||
.as_f64()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing lng"))?;
|
||||
let label = params["label"].as_str().map(|s| s.to_string());
|
||||
|
||||
let coord = Coordinate::from_degrees(lat, lng, label);
|
||||
let payload = message_types::encode_payload(&coord)?;
|
||||
let envelope = TypedEnvelope::new(MeshMessageType::Coordinate, payload);
|
||||
let wire = envelope.to_wire()?;
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
let wire_str = String::from_utf8_lossy(&wire).to_string();
|
||||
let msg = svc.send_message(contact_id, &wire_str).await?;
|
||||
|
||||
info!(contact_id, "Sent coordinate over mesh");
|
||||
Ok(serde_json::json!({
|
||||
"sent": true,
|
||||
"message_id": msg.id,
|
||||
"lat": coord.lat,
|
||||
"lng": coord.lng,
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.send-alert — Send a signed emergency alert over mesh.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_send_alert(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let message = params["message"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing message"))?;
|
||||
let alert_type_str = params["alert_type"]
|
||||
.as_str()
|
||||
.unwrap_or("status");
|
||||
let broadcast = params["broadcast"].as_bool().unwrap_or(false);
|
||||
|
||||
let alert_type = match alert_type_str {
|
||||
"emergency" => AlertType::Emergency,
|
||||
"dead_man" => AlertType::DeadMan,
|
||||
_ => AlertType::Status,
|
||||
};
|
||||
|
||||
// Optional GPS
|
||||
let coordinate = if let (Some(lat), Some(lng)) = (
|
||||
params["lat"].as_f64(),
|
||||
params["lng"].as_f64(),
|
||||
) {
|
||||
Some(Coordinate::from_degrees(lat, lng, None))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let alert = AlertPayload {
|
||||
alert_type,
|
||||
message: message.to_string(),
|
||||
coordinate,
|
||||
};
|
||||
|
||||
let payload = message_types::encode_payload(&alert)?;
|
||||
|
||||
// Sign the alert with node identity
|
||||
let (_data, _) = self.state_manager.get_snapshot().await;
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let node_key_path = identity_dir.join("node_key");
|
||||
|
||||
let envelope = if node_key_path.exists() {
|
||||
let key_bytes = tokio::fs::read(&node_key_path).await?;
|
||||
if key_bytes.len() == 32 {
|
||||
let mut seed = [0u8; 32];
|
||||
seed.copy_from_slice(&key_bytes);
|
||||
let signing_key = ed25519_dalek::SigningKey::from_bytes(&seed);
|
||||
TypedEnvelope::new_signed(MeshMessageType::Alert, payload, &signing_key)
|
||||
} else {
|
||||
TypedEnvelope::new(MeshMessageType::Alert, payload)
|
||||
}
|
||||
} else {
|
||||
TypedEnvelope::new(MeshMessageType::Alert, payload)
|
||||
};
|
||||
|
||||
let wire = envelope.to_wire()?;
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
let wire_str = String::from_utf8_lossy(&wire).to_string();
|
||||
if broadcast {
|
||||
// Send on channel (all peers)
|
||||
svc.send_message(0, &wire_str).await?;
|
||||
info!(alert_type = alert_type_str, "Broadcast alert over mesh");
|
||||
} else if let Some(contact_id) = params["contact_id"].as_u64() {
|
||||
svc.send_message(contact_id as u32, &wire_str).await?;
|
||||
info!(contact_id, alert_type = alert_type_str, "Sent alert to peer");
|
||||
} else {
|
||||
anyhow::bail!("Must specify contact_id or broadcast: true");
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"sent": true,
|
||||
"alert_type": alert_type_str,
|
||||
"signed": envelope.sig.is_some(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
@ -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())),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
216
core/archipelago/src/api/rpc/package/dependencies.rs
Normal file
216
core/archipelago/src/api/rpc/package/dependencies.rs
Normal file
@ -0,0 +1,216 @@
|
||||
use super::config::get_containers_for_app;
|
||||
use anyhow::Result;
|
||||
use tracing::info;
|
||||
|
||||
/// Names of container variants that represent a running Bitcoin node
|
||||
const BITCOIN_NAMES: &[&str] = &["bitcoin-knots", "bitcoin-core", "bitcoin"];
|
||||
|
||||
/// Names of container variants that represent a running Electrum indexer
|
||||
const ELECTRUM_NAMES: &[&str] = &["electrumx", "mempool-electrs", "electrs"];
|
||||
|
||||
/// Snapshot of which dependency services are currently running.
|
||||
pub(super) struct RunningDeps {
|
||||
pub has_bitcoin: bool,
|
||||
pub has_electrumx: bool,
|
||||
pub has_lnd: bool,
|
||||
}
|
||||
|
||||
/// Query podman for currently running containers and return dependency status.
|
||||
pub(super) async fn detect_running_deps() -> Result<RunningDeps> {
|
||||
let dep_check = tokio::process::Command::new("podman")
|
||||
.args(["ps", "--format", "{{.Names}}"])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to check running containers: {}", e))?;
|
||||
|
||||
let running = String::from_utf8_lossy(&dep_check.stdout);
|
||||
let is_running = |names: &[&str]| {
|
||||
running.lines().any(|l| {
|
||||
let name = l.trim();
|
||||
names.iter().any(|n| name == *n)
|
||||
})
|
||||
};
|
||||
|
||||
Ok(RunningDeps {
|
||||
has_bitcoin: is_running(BITCOIN_NAMES),
|
||||
has_electrumx: is_running(ELECTRUM_NAMES),
|
||||
has_lnd: is_running(&["lnd"]),
|
||||
})
|
||||
}
|
||||
|
||||
/// Verify that required dependency services are running before installing an app.
|
||||
/// Returns an error with a user-friendly message if dependencies are missing.
|
||||
pub(super) fn check_install_deps(package_id: &str, deps: &RunningDeps) -> Result<()> {
|
||||
match package_id {
|
||||
"electrumx" | "mempool-electrs" | "electrs" if !deps.has_bitcoin => {
|
||||
Err(anyhow::anyhow!(
|
||||
"ElectrumX requires a running Bitcoin node (Bitcoin Knots). \
|
||||
Please install and start Bitcoin Knots first."
|
||||
))
|
||||
}
|
||||
"lnd" if !deps.has_bitcoin => Err(anyhow::anyhow!(
|
||||
"LND requires a running Bitcoin node (Bitcoin Knots). \
|
||||
Please install and start Bitcoin Knots first."
|
||||
)),
|
||||
"btcpay-server" | "btcpayserver" if !deps.has_bitcoin => Err(anyhow::anyhow!(
|
||||
"BTCPay Server requires a running Bitcoin node (Bitcoin Knots). \
|
||||
Please install and start Bitcoin Knots first."
|
||||
)),
|
||||
"mempool" | "mempool-web" if !deps.has_bitcoin || !deps.has_electrumx => {
|
||||
let mut missing = vec![];
|
||||
if !deps.has_bitcoin {
|
||||
missing.push("Bitcoin Knots");
|
||||
}
|
||||
if !deps.has_electrumx {
|
||||
missing.push("ElectrumX");
|
||||
}
|
||||
Err(anyhow::anyhow!(
|
||||
"Mempool requires {} to be running. Please install and start {} first.",
|
||||
missing.join(" and "),
|
||||
missing.join(" and ")
|
||||
))
|
||||
}
|
||||
"fedimint" if !deps.has_bitcoin => Err(anyhow::anyhow!(
|
||||
"Fedimint requires a running Bitcoin node (Bitcoin Knots). \
|
||||
Please install and start Bitcoin Knots first."
|
||||
)),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Log informational messages about optional dependencies.
|
||||
pub(super) fn log_optional_dep_info(package_id: &str, deps: &RunningDeps) {
|
||||
if matches!(package_id, "btcpay-server" | "btcpayserver") && !deps.has_lnd {
|
||||
tracing::info!(
|
||||
"BTCPay Server installing without LND \
|
||||
— Lightning payments won't be available until LND is installed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an app requires the shared `archy-net` Podman network for
|
||||
/// inter-container DNS resolution.
|
||||
pub(super) fn needs_archy_net(package_id: &str) -> bool {
|
||||
matches!(
|
||||
package_id,
|
||||
"bitcoin-knots"
|
||||
| "bitcoin"
|
||||
| "bitcoin-core"
|
||||
| "lnd"
|
||||
| "mempool"
|
||||
| "mempool-web"
|
||||
| "mempool-api"
|
||||
| "electrumx"
|
||||
| "mempool-electrs"
|
||||
| "electrs"
|
||||
| "mysql-mempool"
|
||||
| "archy-mempool-db"
|
||||
| "archy-mempool-web"
|
||||
| "btcpay-server"
|
||||
| "btcpayserver"
|
||||
| "archy-btcpay-db"
|
||||
| "archy-nbxplorer"
|
||||
| "nbxplorer"
|
||||
| "fedimint"
|
||||
| "fedimint-gateway"
|
||||
)
|
||||
}
|
||||
|
||||
/// Return the correct startup order for a multi-container app stack.
|
||||
/// Containers are started in this order to satisfy dependency chains.
|
||||
pub(super) fn startup_order(package_id: &str) -> &'static [&'static str] {
|
||||
match package_id {
|
||||
"mempool" | "mempool-web" => &[
|
||||
"archy-mempool-db",
|
||||
"mysql-mempool",
|
||||
"electrumx",
|
||||
"mempool-electrs",
|
||||
"mempool-api",
|
||||
"archy-mempool-api",
|
||||
"archy-mempool-web",
|
||||
"mempool",
|
||||
],
|
||||
"immich" => &["immich_postgres", "immich_redis", "immich_server"],
|
||||
"penpot" | "penpot-frontend" => &[
|
||||
"penpot-postgres",
|
||||
"penpot-valkey",
|
||||
"penpot-backend",
|
||||
"penpot-exporter",
|
||||
"penpot-frontend",
|
||||
],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// Sort a list of container names according to the dependency-aware startup
|
||||
/// order for the given app. Unknown containers sort to the end.
|
||||
pub(super) async fn ordered_containers_for_start(
|
||||
package_id: &str,
|
||||
) -> Result<Vec<String>> {
|
||||
let containers = get_containers_for_app(package_id).await?;
|
||||
if containers.is_empty() {
|
||||
return Ok(vec![format!("archy-{}", package_id)]);
|
||||
}
|
||||
let order = startup_order(package_id);
|
||||
// If no special order defined, fall back to mempool order (legacy behavior)
|
||||
let effective_order: &[&str] = if order.is_empty() {
|
||||
startup_order("mempool")
|
||||
} else {
|
||||
order
|
||||
};
|
||||
let mut sorted = containers;
|
||||
sorted.sort_by_key(|c| {
|
||||
effective_order
|
||||
.iter()
|
||||
.position(|o| *o == c)
|
||||
.unwrap_or(99)
|
||||
});
|
||||
Ok(sorted)
|
||||
}
|
||||
|
||||
/// Configure Fedimint Gateway to use LND instead of LDK.
|
||||
/// Modifies ports, volumes, and command args in place when LND credentials exist.
|
||||
pub(super) fn configure_fedimint_lnd(
|
||||
host_ip: &str,
|
||||
ports: &mut Vec<String>,
|
||||
volumes: &mut Vec<String>,
|
||||
custom_args: &mut Option<Vec<String>>,
|
||||
rpc_user: &str,
|
||||
rpc_pass: &str,
|
||||
) {
|
||||
let lnd_cert = "/var/lib/archipelago/lnd/tls.cert";
|
||||
let lnd_macaroon =
|
||||
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||
if std::path::Path::new(lnd_cert).exists()
|
||||
&& std::path::Path::new(lnd_macaroon).exists()
|
||||
{
|
||||
info!("LND detected with credentials — configuring gateway in lnd mode");
|
||||
ports.retain(|p| p != "9737:9737");
|
||||
volumes.push(format!("{}:/lnd/tls.cert:ro", lnd_cert));
|
||||
volumes.push(format!("{}:/lnd/admin.macaroon:ro", lnd_macaroon));
|
||||
*custom_args = Some(vec![
|
||||
"gatewayd".to_string(),
|
||||
"--data-dir".to_string(),
|
||||
"/data".to_string(),
|
||||
"--listen".to_string(),
|
||||
"0.0.0.0:8176".to_string(),
|
||||
"--bcrypt-password-hash".to_string(),
|
||||
"$2y$10$t9YjjxkiktrlYvjajB/zgOMDnSNVg4HqrbDqh47u7Jf42whNdxNqC".to_string(),
|
||||
"--network".to_string(),
|
||||
"bitcoin".to_string(),
|
||||
"--bitcoind-url".to_string(),
|
||||
format!("http://{}:8332", host_ip),
|
||||
"--bitcoind-username".to_string(),
|
||||
rpc_user.to_string(),
|
||||
"--bitcoind-password".to_string(),
|
||||
rpc_pass.to_string(),
|
||||
"lnd".to_string(),
|
||||
"--lnd-rpc-host".to_string(),
|
||||
format!("{}:10009", host_ip),
|
||||
"--lnd-tls-cert".to_string(),
|
||||
"/lnd/tls.cert".to_string(),
|
||||
"--lnd-macaroon".to_string(),
|
||||
"/lnd/admin.macaroon".to_string(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
467
core/archipelago/src/api/rpc/package/install.rs
Normal file
467
core/archipelago/src/api/rpc/package/install.rs
Normal file
@ -0,0 +1,467 @@
|
||||
use super::config::{
|
||||
get_app_capabilities, get_app_config, get_health_check_args, get_memory_limit,
|
||||
is_readonly_compatible, is_valid_docker_image,
|
||||
};
|
||||
use super::dependencies::{
|
||||
check_install_deps, configure_fedimint_lnd, detect_running_deps, log_optional_dep_info,
|
||||
needs_archy_net,
|
||||
};
|
||||
use super::progress::parse_pull_progress;
|
||||
use super::validation::validate_app_id;
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use anyhow::{Context, Result};
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tracing::{debug, info};
|
||||
|
||||
impl RpcHandler {
|
||||
/// Install a package from a Docker image.
|
||||
/// Security: Image verification, resource limits, network isolation.
|
||||
pub(in crate::api::rpc) async fn handle_package_install(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
|
||||
let package_id = params
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||
validate_app_id(package_id)?;
|
||||
|
||||
let docker_image = params
|
||||
.get("dockerImage")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing dockerImage"))?;
|
||||
|
||||
debug!(
|
||||
"Installing package {} from image {}",
|
||||
package_id, docker_image
|
||||
);
|
||||
|
||||
if !is_valid_docker_image(docker_image) {
|
||||
return Err(anyhow::anyhow!("Invalid Docker image format"));
|
||||
}
|
||||
|
||||
// Multi-container stacks get their own install path
|
||||
if package_id == "immich" {
|
||||
return self.install_immich_stack().await;
|
||||
}
|
||||
if package_id == "penpot" || package_id == "penpot-frontend" {
|
||||
return self.install_penpot_stack().await;
|
||||
}
|
||||
|
||||
// Dependency checks
|
||||
let deps = detect_running_deps().await?;
|
||||
check_install_deps(package_id, &deps)?;
|
||||
log_optional_dep_info(package_id, &deps);
|
||||
|
||||
// Check if container already exists
|
||||
let check_output = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"ps",
|
||||
"-a",
|
||||
"--format",
|
||||
"{{.Names}}",
|
||||
"--filter",
|
||||
&format!("name=^{}$", package_id),
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to check existing containers")?;
|
||||
|
||||
if !String::from_utf8_lossy(&check_output.stdout)
|
||||
.trim()
|
||||
.is_empty()
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"Container {} already exists. Stop and remove it first.",
|
||||
package_id
|
||||
));
|
||||
}
|
||||
|
||||
// Pull or verify image
|
||||
let has_local_fallback = self
|
||||
.pull_or_verify_image(package_id, docker_image)
|
||||
.await?;
|
||||
|
||||
// Normalize container name for legacy aliases
|
||||
let container_name = match package_id {
|
||||
"electrs" | "mempool-electrs" => "electrumx",
|
||||
_ => package_id,
|
||||
};
|
||||
|
||||
// Read Bitcoin RPC credentials for container configs
|
||||
let (rpc_user, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
|
||||
|
||||
// App-specific configuration
|
||||
let (mut ports, mut volumes, env_vars, custom_command, mut custom_args) = {
|
||||
let mut allocator = self.port_allocator.lock().await;
|
||||
get_app_config(
|
||||
package_id,
|
||||
&self.config.host_ip,
|
||||
&mut allocator,
|
||||
&rpc_user,
|
||||
&rpc_pass,
|
||||
)
|
||||
.await
|
||||
};
|
||||
|
||||
// Fedimint Gateway: auto-detect LND and switch to lnd mode
|
||||
if package_id == "fedimint-gateway" && deps.has_lnd {
|
||||
configure_fedimint_lnd(
|
||||
&self.config.host_ip,
|
||||
&mut ports,
|
||||
&mut volumes,
|
||||
&mut custom_args,
|
||||
&rpc_user,
|
||||
&rpc_pass,
|
||||
);
|
||||
}
|
||||
|
||||
// Build the podman run command
|
||||
let mut run_args = vec![
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
container_name,
|
||||
"--restart=unless-stopped",
|
||||
];
|
||||
|
||||
let is_tailscale = package_id == "tailscale";
|
||||
|
||||
// Network mode
|
||||
if is_tailscale {
|
||||
run_args.push("--network=host");
|
||||
run_args.push("--privileged");
|
||||
run_args.push("--cap-add=NET_ADMIN");
|
||||
run_args.push("--cap-add=NET_RAW");
|
||||
run_args.push("--device=/dev/net/tun");
|
||||
} else if needs_archy_net(package_id) {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["network", "create", "archy-net"])
|
||||
.output()
|
||||
.await;
|
||||
run_args.push("--network=archy-net");
|
||||
}
|
||||
|
||||
// Security hardening (skip for privileged containers)
|
||||
let security_caps: Vec<String> = if !is_tailscale {
|
||||
get_app_capabilities(package_id)
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
let readonly_compatible = !is_tailscale && is_readonly_compatible(package_id);
|
||||
|
||||
if !is_tailscale {
|
||||
run_args.push("--cap-drop=ALL");
|
||||
run_args.push("--security-opt=no-new-privileges:true");
|
||||
for cap in &security_caps {
|
||||
run_args.push(cap);
|
||||
}
|
||||
if readonly_compatible {
|
||||
run_args.push("--read-only");
|
||||
run_args.push("--tmpfs=/tmp:rw,noexec,nosuid,size=256m");
|
||||
run_args.push("--tmpfs=/run:rw,noexec,nosuid,size=64m");
|
||||
}
|
||||
}
|
||||
|
||||
// Create data directories
|
||||
self.create_data_dirs(package_id, &volumes).await;
|
||||
|
||||
// Pre-install: bitcoin.conf with rpcauth
|
||||
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
|
||||
self.write_bitcoin_conf(&rpc_user, &rpc_pass).await;
|
||||
}
|
||||
|
||||
// Port mappings (skip for host-network containers)
|
||||
if !is_tailscale {
|
||||
for port in &ports {
|
||||
run_args.push("-p");
|
||||
run_args.push(port);
|
||||
}
|
||||
}
|
||||
|
||||
// Volume mounts
|
||||
for volume in &volumes {
|
||||
run_args.push("-v");
|
||||
run_args.push(volume);
|
||||
}
|
||||
|
||||
// Environment variables
|
||||
for env in &env_vars {
|
||||
run_args.push("-e");
|
||||
run_args.push(env);
|
||||
}
|
||||
|
||||
// Resource limits
|
||||
let memory_limit = get_memory_limit(package_id);
|
||||
let mem_arg = format!("--memory={}", memory_limit);
|
||||
run_args.push(&mem_arg);
|
||||
run_args.push("--cpus=2");
|
||||
|
||||
// Health checks
|
||||
let health_args = get_health_check_args(package_id, &rpc_pass);
|
||||
for arg in &health_args {
|
||||
run_args.push(arg);
|
||||
}
|
||||
|
||||
// Image — prefer local build over registry
|
||||
let effective_image = if has_local_fallback {
|
||||
format!("localhost/{}:latest", package_id)
|
||||
} else {
|
||||
docker_image.to_string()
|
||||
};
|
||||
run_args.push(&effective_image);
|
||||
|
||||
debug!("Running container with args: {:?}", run_args);
|
||||
|
||||
// Build command with optional custom command/args
|
||||
let mut cmd = tokio::process::Command::new("podman");
|
||||
cmd.args(&run_args);
|
||||
if let Some(custom_cmd) = custom_command {
|
||||
cmd.arg(custom_cmd);
|
||||
} else if let Some(args) = custom_args {
|
||||
cmd.args(args);
|
||||
}
|
||||
|
||||
let run_output = cmd.output().await.context("Failed to run container")?;
|
||||
|
||||
if !run_output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&run_output.stderr);
|
||||
return Err(anyhow::anyhow!("Failed to start container: {}", stderr));
|
||||
}
|
||||
|
||||
let container_id = String::from_utf8_lossy(&run_output.stdout)
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
// Post-install hooks
|
||||
self.run_post_install_hooks(package_id).await;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"success": true,
|
||||
"package_id": package_id,
|
||||
"container_id": container_id,
|
||||
"message": format!("Package {} installed and started", package_id)
|
||||
}))
|
||||
}
|
||||
|
||||
// -- Private helpers for install --
|
||||
|
||||
/// Pull the image from a registry or verify a local image exists.
|
||||
/// Returns `true` if a local fallback image was found (registry pull skipped).
|
||||
async fn pull_or_verify_image(
|
||||
&self,
|
||||
package_id: &str,
|
||||
docker_image: &str,
|
||||
) -> Result<bool> {
|
||||
let is_local_image = docker_image.starts_with("localhost/");
|
||||
let has_local_fallback = if !is_local_image {
|
||||
let local_tag = format!("localhost/{}:latest", package_id);
|
||||
let check = tokio::process::Command::new("podman")
|
||||
.args(["images", "-q", &local_tag])
|
||||
.output()
|
||||
.await
|
||||
.ok();
|
||||
check.map_or(false, |o| {
|
||||
!String::from_utf8_lossy(&o.stdout).trim().is_empty()
|
||||
})
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if !is_local_image && !has_local_fallback {
|
||||
self.pull_image_with_progress(package_id, docker_image)
|
||||
.await?;
|
||||
} else if has_local_fallback {
|
||||
debug!(
|
||||
"Using local build for {} (skipping registry pull)",
|
||||
package_id
|
||||
);
|
||||
} else {
|
||||
// Local image — verify it exists
|
||||
let images_output = tokio::process::Command::new("podman")
|
||||
.args(["images", "-q", docker_image])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to check local image")?;
|
||||
if String::from_utf8_lossy(&images_output.stdout)
|
||||
.trim()
|
||||
.is_empty()
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"Local image {} not found. Build the image first \
|
||||
or ensure the registry is reachable.",
|
||||
docker_image
|
||||
));
|
||||
}
|
||||
debug!("Using local image: {}", docker_image);
|
||||
}
|
||||
|
||||
Ok(has_local_fallback)
|
||||
}
|
||||
|
||||
/// Stream `podman pull` while updating install progress state.
|
||||
async fn pull_image_with_progress(
|
||||
&self,
|
||||
package_id: &str,
|
||||
docker_image: &str,
|
||||
) -> Result<()> {
|
||||
debug!("Pulling image: {}", docker_image);
|
||||
self.set_install_progress(package_id, 0, 0).await;
|
||||
|
||||
let mut child = tokio::process::Command::new("podman")
|
||||
.args(["pull", docker_image])
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.context("Failed to start image pull")?;
|
||||
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
let reader = BufReader::new(stderr);
|
||||
let mut lines = reader.lines();
|
||||
let pkg_id = package_id.to_string();
|
||||
let state_mgr = self.state_manager.clone();
|
||||
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
if let Some((downloaded, total)) = parse_pull_progress(&line) {
|
||||
Self::update_install_progress(&state_mgr, &pkg_id, downloaded, total)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let status = child
|
||||
.wait()
|
||||
.await
|
||||
.context("Failed to wait for image pull")?;
|
||||
if !status.success() {
|
||||
self.clear_install_progress(package_id).await;
|
||||
return Err(anyhow::anyhow!("Failed to pull image"));
|
||||
}
|
||||
|
||||
self.set_install_progress(package_id, 100, 100).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create data directories for volume mounts under /var/lib/archipelago/.
|
||||
async fn create_data_dirs(&self, package_id: &str, volumes: &[String]) {
|
||||
for volume in volumes {
|
||||
if let Some(host_path) = volume.split(':').next() {
|
||||
if host_path.starts_with("/var/lib/archipelago/") {
|
||||
debug!("Creating directory: {}", host_path);
|
||||
let create_dir = tokio::process::Command::new("sudo")
|
||||
.args(["mkdir", "-p", host_path])
|
||||
.output()
|
||||
.await;
|
||||
if let Err(e) = create_dir {
|
||||
debug!("Failed to create directory {}: {}", host_path, e);
|
||||
}
|
||||
// Grafana runs as UID 472 — fix permissions
|
||||
if package_id == "grafana" && host_path.contains("grafana") {
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["chown", "-R", "472:472", host_path])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write bitcoin.conf with rpcauth (salted HMAC hash, no plaintext password).
|
||||
async fn write_bitcoin_conf(&self, rpc_user: &str, rpc_pass: &str) {
|
||||
let bitcoin_dir = "/var/lib/archipelago/bitcoin";
|
||||
let conf_path = format!("{}/bitcoin.conf", bitcoin_dir);
|
||||
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
let salt_bytes: [u8; 16] = rand::random();
|
||||
let salt_hex = hex::encode(salt_bytes);
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(salt_hex.as_bytes())
|
||||
.expect("HMAC accepts any key length");
|
||||
mac.update(rpc_pass.as_bytes());
|
||||
let hash_hex = hex::encode(mac.finalize().into_bytes());
|
||||
let rpcauth_line = format!("rpcauth={}:{}${}", rpc_user, salt_hex, hash_hex);
|
||||
|
||||
let bitcoin_conf = format!(
|
||||
"\
|
||||
# rpcauth: salted hash only — no plaintext password in config or CLI\n\
|
||||
{}\n\
|
||||
server=1\n\
|
||||
prune=550\n\
|
||||
rpcbind=0.0.0.0\n\
|
||||
rpcallowip=0.0.0.0/0\n\
|
||||
rpcport=8332\n\
|
||||
listen=1\n\
|
||||
printtoconsole=1\n",
|
||||
rpcauth_line
|
||||
);
|
||||
let _ = tokio::fs::create_dir_all(bitcoin_dir).await;
|
||||
let _ = tokio::fs::write(&conf_path, bitcoin_conf).await;
|
||||
info!("Created bitcoin.conf with rpcauth (no plaintext credentials)");
|
||||
}
|
||||
|
||||
/// Run post-install hooks (Nextcloud trusted domains, Bitcoin UI container).
|
||||
async fn run_post_install_hooks(&self, package_id: &str) {
|
||||
if package_id == "nextcloud" {
|
||||
let host_ip = self.config.host_ip.clone();
|
||||
tokio::spawn(async move {
|
||||
// Wait for Nextcloud to finish first-run initialization
|
||||
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
|
||||
for domain_idx in 1..=2u8 {
|
||||
let value = if domain_idx == 1 {
|
||||
host_ip.as_str()
|
||||
} else {
|
||||
"localhost"
|
||||
};
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"exec",
|
||||
"-u",
|
||||
"33",
|
||||
"nextcloud",
|
||||
"php",
|
||||
"occ",
|
||||
"config:system:set",
|
||||
"trusted_domains",
|
||||
&domain_idx.to_string(),
|
||||
"--value",
|
||||
value,
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
info!("Nextcloud trusted domains configured for {}", host_ip);
|
||||
});
|
||||
}
|
||||
|
||||
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
|
||||
tokio::spawn(async move {
|
||||
let ui_dir = "/opt/archipelago/docker/bitcoin-ui";
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["build", "-t", "localhost/bitcoin-ui", ui_dir])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["rm", "-f", "bitcoin-ui"])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
"bitcoin-ui",
|
||||
"--restart=unless-stopped",
|
||||
"-p",
|
||||
"8334:80",
|
||||
"localhost/bitcoin-ui:latest",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
info!("Bitcoin UI container started on port 8334");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -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)
|
||||
|
||||
141
core/archipelago/src/api/rpc/package/progress.rs
Normal file
141
core/archipelago/src/api/rpc/package/progress.rs
Normal file
@ -0,0 +1,141 @@
|
||||
//! Install progress tracking and podman pull output parsing.
|
||||
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use crate::data_model::{
|
||||
Description, InstallProgress, Manifest, PackageDataEntry, PackageState, StaticFiles,
|
||||
};
|
||||
|
||||
impl RpcHandler {
|
||||
/// Set install progress for a package and broadcast the update.
|
||||
/// Creates a minimal package entry if one doesn't exist yet.
|
||||
pub(super) async fn set_install_progress(
|
||||
&self,
|
||||
package_id: &str,
|
||||
downloaded: u64,
|
||||
size: u64,
|
||||
) {
|
||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
||||
let entry = data
|
||||
.package_data
|
||||
.entry(package_id.to_string())
|
||||
.or_insert_with(|| create_installing_entry(package_id));
|
||||
entry.state = PackageState::Installing;
|
||||
entry.install_progress = Some(InstallProgress { size, downloaded });
|
||||
self.state_manager.update_data(data).await;
|
||||
}
|
||||
|
||||
/// Clear install progress after pull completes or fails.
|
||||
pub(super) async fn clear_install_progress(&self, package_id: &str) {
|
||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||
entry.install_progress = None;
|
||||
}
|
||||
self.state_manager.update_data(data).await;
|
||||
}
|
||||
|
||||
/// Update install progress (static method for use in async closures).
|
||||
pub(super) async fn update_install_progress(
|
||||
state_manager: &crate::state::StateManager,
|
||||
package_id: &str,
|
||||
downloaded: u64,
|
||||
total: u64,
|
||||
) {
|
||||
let (mut data, _rev) = state_manager.get_snapshot().await;
|
||||
let entry = data
|
||||
.package_data
|
||||
.entry(package_id.to_string())
|
||||
.or_insert_with(|| create_installing_entry(package_id));
|
||||
entry.install_progress = Some(InstallProgress {
|
||||
size: total,
|
||||
downloaded,
|
||||
});
|
||||
state_manager.update_data(data).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a minimal PackageDataEntry for a package being installed.
|
||||
fn create_installing_entry(package_id: &str) -> PackageDataEntry {
|
||||
PackageDataEntry {
|
||||
state: PackageState::Installing,
|
||||
health: None,
|
||||
static_files: StaticFiles {
|
||||
license: String::new(),
|
||||
instructions: String::new(),
|
||||
icon: format!("/assets/img/app-icons/{}.png", package_id),
|
||||
},
|
||||
manifest: Manifest {
|
||||
id: package_id.to_string(),
|
||||
title: package_id.to_string(),
|
||||
version: String::new(),
|
||||
description: Description {
|
||||
short: "Installing...".to_string(),
|
||||
long: String::new(),
|
||||
},
|
||||
release_notes: String::new(),
|
||||
license: String::new(),
|
||||
wrapper_repo: String::new(),
|
||||
upstream_repo: String::new(),
|
||||
support_site: String::new(),
|
||||
marketing_site: String::new(),
|
||||
donation_url: None,
|
||||
author: None,
|
||||
website: None,
|
||||
interfaces: None,
|
||||
tier: None,
|
||||
},
|
||||
installed: None,
|
||||
install_progress: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse podman pull progress output.
|
||||
/// Podman outputs lines like: "Copying blob sha256:abc done | 50.0MiB / 100.0MiB"
|
||||
/// Returns (downloaded_bytes, total_bytes) if parseable.
|
||||
pub(super) fn parse_pull_progress(line: &str) -> Option<(u64, u64)> {
|
||||
let line = line.trim();
|
||||
let parts: Vec<&str> = line.split('/').collect();
|
||||
if parts.len() != 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let downloaded = parse_size_value(parts[0].trim())?;
|
||||
let total = parse_size_value(parts[1].trim())?;
|
||||
|
||||
if total > 0 {
|
||||
Some((downloaded, total))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a size value like "50.0MiB", "1.2GiB", "500KiB" into bytes.
|
||||
fn parse_size_value(s: &str) -> Option<u64> {
|
||||
let s = s.trim();
|
||||
|
||||
let (num_str, multiplier) = if let Some(pos) = s.rfind("GiB") {
|
||||
(
|
||||
s[..pos].trim().split_whitespace().last()?,
|
||||
1024 * 1024 * 1024,
|
||||
)
|
||||
} else if let Some(pos) = s.rfind("MiB") {
|
||||
(s[..pos].trim().split_whitespace().last()?, 1024 * 1024)
|
||||
} else if let Some(pos) = s.rfind("KiB") {
|
||||
(s[..pos].trim().split_whitespace().last()?, 1024)
|
||||
} else if let Some(pos) = s.rfind("GB") {
|
||||
(
|
||||
s[..pos].trim().split_whitespace().last()?,
|
||||
1_000_000_000,
|
||||
)
|
||||
} else if let Some(pos) = s.rfind("MB") {
|
||||
(s[..pos].trim().split_whitespace().last()?, 1_000_000)
|
||||
} else if let Some(pos) = s.rfind("KB") {
|
||||
(s[..pos].trim().split_whitespace().last()?, 1_000)
|
||||
} else if let Some(pos) = s.rfind('B') {
|
||||
(s[..pos].trim().split_whitespace().last()?, 1)
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let num: f64 = num_str.parse().ok()?;
|
||||
Some((num * multiplier as f64) as u64)
|
||||
}
|
||||
359
core/archipelago/src/api/rpc/package/runtime.rs
Normal file
359
core/archipelago/src/api/rpc/package/runtime.rs
Normal file
@ -0,0 +1,359 @@
|
||||
use super::config::{get_containers_for_app, get_data_dirs_for_app, is_valid_docker_image};
|
||||
use super::dependencies::ordered_containers_for_start;
|
||||
use super::validation::validate_app_id;
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
impl RpcHandler {
|
||||
/// Start a package: start all containers in dependency order.
|
||||
pub(in crate::api::rpc) async fn handle_package_start(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let package_id = params
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||
validate_app_id(package_id)?;
|
||||
|
||||
let to_start = ordered_containers_for_start(package_id).await?;
|
||||
|
||||
// Clear user-stopped flag — user explicitly started this app
|
||||
crate::crash_recovery::clear_user_stopped(&self.config.data_dir, package_id).await;
|
||||
for name in &to_start {
|
||||
crate::crash_recovery::clear_user_stopped(&self.config.data_dir, name).await;
|
||||
}
|
||||
|
||||
for name in to_start {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["start", &name])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
|
||||
/// Stop a package: mark as user-stopped and stop all containers.
|
||||
pub(in crate::api::rpc) async fn handle_package_stop(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let package_id = params
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||
validate_app_id(package_id)?;
|
||||
|
||||
// Mark as user-stopped so health monitor and crash recovery don't auto-restart
|
||||
crate::crash_recovery::mark_user_stopped(&self.config.data_dir, package_id).await;
|
||||
|
||||
let containers = get_containers_for_app(package_id).await?;
|
||||
if containers.is_empty() {
|
||||
let container_name = format!("archy-{}", package_id);
|
||||
crate::crash_recovery::mark_user_stopped(&self.config.data_dir, &container_name)
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["stop", &container_name])
|
||||
.output()
|
||||
.await;
|
||||
return Ok(serde_json::Value::Null);
|
||||
}
|
||||
|
||||
for name in &containers {
|
||||
crate::crash_recovery::mark_user_stopped(&self.config.data_dir, name).await;
|
||||
}
|
||||
for name in containers {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["stop", &name])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
|
||||
/// Restart a package: restart all containers.
|
||||
pub(in crate::api::rpc) async fn handle_package_restart(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let package_id = params
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||
validate_app_id(package_id)?;
|
||||
|
||||
let containers = get_containers_for_app(package_id).await?;
|
||||
if containers.is_empty() {
|
||||
let container_name = format!("archy-{}", package_id);
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["restart", &container_name])
|
||||
.output()
|
||||
.await;
|
||||
return Ok(serde_json::Value::Null);
|
||||
}
|
||||
|
||||
for name in containers {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["restart", &name])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
|
||||
/// Uninstall a package: stop and remove all related containers, clean data.
|
||||
pub(in crate::api::rpc) async fn handle_package_uninstall(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let package_id = params
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||
validate_app_id(package_id)?;
|
||||
let preserve_data = params
|
||||
.get("preserve_data")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
let containers_to_remove = get_containers_for_app(package_id).await?;
|
||||
if containers_to_remove.is_empty() {
|
||||
tracing::warn!("Uninstall {}: no containers found", package_id);
|
||||
}
|
||||
|
||||
let mut stopped = 0u32;
|
||||
let mut removed = 0u32;
|
||||
let mut errors = Vec::new();
|
||||
|
||||
for name in &containers_to_remove {
|
||||
tracing::info!("Uninstall {}: stopping container {}", package_id, name);
|
||||
let stop_out = tokio::process::Command::new("podman")
|
||||
.args(["stop", "-t", "10", name])
|
||||
.output()
|
||||
.await;
|
||||
match stop_out {
|
||||
Ok(o) if o.status.success() => stopped += 1,
|
||||
Ok(o) => {
|
||||
let stderr = String::from_utf8_lossy(&o.stderr);
|
||||
tracing::warn!(
|
||||
"Uninstall {}: stop {} failed: {}",
|
||||
package_id,
|
||||
name,
|
||||
stderr.trim()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Uninstall {}: stop {} error: {}",
|
||||
package_id,
|
||||
name,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("Uninstall {}: removing container {}", package_id, name);
|
||||
let rm_out = tokio::process::Command::new("podman")
|
||||
.args(["rm", "-f", name])
|
||||
.output()
|
||||
.await;
|
||||
match rm_out {
|
||||
Ok(o) if o.status.success() => removed += 1,
|
||||
Ok(o) => {
|
||||
let stderr = String::from_utf8_lossy(&o.stderr);
|
||||
let msg = format!("Failed to remove {}: {}", name, stderr.trim());
|
||||
tracing::error!("Uninstall {}: {}", package_id, msg);
|
||||
errors.push(msg);
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = format!("Failed to remove {}: {}", name, e);
|
||||
tracing::error!("Uninstall {}: {}", package_id, msg);
|
||||
errors.push(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Release port allocation
|
||||
{
|
||||
let mut allocator = self.port_allocator.lock().await;
|
||||
let _ = allocator.release(package_id).await;
|
||||
}
|
||||
|
||||
// Clean data directories unless preserve_data
|
||||
if !preserve_data {
|
||||
let data_dirs = get_data_dirs_for_app(package_id);
|
||||
for dir in &data_dirs {
|
||||
tracing::info!("Uninstall {}: removing data {}", package_id, dir);
|
||||
let rm_out = tokio::process::Command::new("sudo")
|
||||
.args(["rm", "-rf", dir])
|
||||
.output()
|
||||
.await;
|
||||
if let Ok(o) = rm_out {
|
||||
if !o.status.success() {
|
||||
tracing::warn!("Uninstall {}: rm {} failed", package_id, dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !errors.is_empty() {
|
||||
tracing::error!(
|
||||
"Uninstall {} completed with errors: {:?}",
|
||||
package_id,
|
||||
errors
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
"Uninstall {} complete: stopped={}, removed={}",
|
||||
package_id,
|
||||
stopped,
|
||||
removed
|
||||
);
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"status": if errors.is_empty() { "uninstalled" } else { "partial" },
|
||||
"stopped": stopped,
|
||||
"removed": removed,
|
||||
"errors": errors,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Start a bundled app (create container from pre-loaded image if needed).
|
||||
pub(in crate::api::rpc) async fn handle_bundled_app_start(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let app_id = params
|
||||
.get("app_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
|
||||
validate_app_id(app_id)?;
|
||||
let image = params
|
||||
.get("image")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing image"))?;
|
||||
if !is_valid_docker_image(image) {
|
||||
return Err(anyhow::anyhow!("Invalid Docker image format"));
|
||||
}
|
||||
let ports = params
|
||||
.get("ports")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing ports"))?;
|
||||
let volumes = params
|
||||
.get("volumes")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing volumes"))?;
|
||||
|
||||
let check_output = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"ps",
|
||||
"-a",
|
||||
"--format",
|
||||
"{{.Names}}",
|
||||
"--filter",
|
||||
&format!("name={}", app_id),
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to check container")?;
|
||||
|
||||
let existing = String::from_utf8_lossy(&check_output.stdout);
|
||||
|
||||
if existing.trim().is_empty() {
|
||||
let mut cmd = tokio::process::Command::new("podman");
|
||||
cmd.args(["run", "-d", "--name", app_id]);
|
||||
|
||||
for port in ports {
|
||||
if let (Some(host), Some(container)) = (
|
||||
port.get("host").and_then(|v| v.as_u64()),
|
||||
port.get("container").and_then(|v| v.as_u64()),
|
||||
) {
|
||||
cmd.arg("-p").arg(format!("{}:{}", host, container));
|
||||
}
|
||||
}
|
||||
|
||||
for volume in volumes {
|
||||
if let (Some(host), Some(container)) = (
|
||||
volume.get("host").and_then(|v| v.as_str()),
|
||||
volume.get("container").and_then(|v| v.as_str()),
|
||||
) {
|
||||
// Validate host path: must be under /var/lib/archipelago/
|
||||
if !host.starts_with("/var/lib/archipelago/")
|
||||
|| host.contains("..")
|
||||
|| host.contains('\0')
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"Volume host path must be under /var/lib/archipelago/ \
|
||||
and cannot contain path traversal"
|
||||
));
|
||||
}
|
||||
if container.contains("..") || container.contains('\0') {
|
||||
return Err(anyhow::anyhow!("Invalid container mount path"));
|
||||
}
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["mkdir", "-p", host])
|
||||
.output()
|
||||
.await;
|
||||
cmd.arg("-v").arg(format!("{}:{}", host, container));
|
||||
}
|
||||
}
|
||||
|
||||
cmd.arg(image);
|
||||
|
||||
let output = cmd.output().await.context("Failed to create container")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow::anyhow!("Failed to create container: {}", stderr));
|
||||
}
|
||||
} else {
|
||||
let output = tokio::process::Command::new("podman")
|
||||
.args(["start", app_id])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to start container")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow::anyhow!("Failed to start container: {}", stderr));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({ "status": "started", "app_id": app_id }))
|
||||
}
|
||||
|
||||
/// Stop a bundled app.
|
||||
pub(in crate::api::rpc) async fn handle_bundled_app_stop(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let app_id = params
|
||||
.get("app_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
|
||||
validate_app_id(app_id)?;
|
||||
|
||||
let output = tokio::process::Command::new("podman")
|
||||
.args(["stop", app_id])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to stop container")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow::anyhow!("Failed to stop container: {}", stderr));
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({ "status": "stopped", "app_id": app_id }))
|
||||
}
|
||||
}
|
||||
335
core/archipelago/src/api/rpc/package/stacks.rs
Normal file
335
core/archipelago/src/api/rpc/package/stacks.rs
Normal file
@ -0,0 +1,335 @@
|
||||
//! Multi-container app stack installers (Immich, Penpot).
|
||||
//!
|
||||
//! Each stack pulls multiple images, creates a private network, and starts
|
||||
//! containers in dependency order.
|
||||
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::info;
|
||||
|
||||
impl RpcHandler {
|
||||
/// Install Immich stack (postgres + redis + server).
|
||||
pub(super) async fn install_immich_stack(&self) -> Result<serde_json::Value> {
|
||||
let check = tokio::process::Command::new("podman")
|
||||
.args(["ps", "-a", "--format", "{{.Names}}"])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to list containers")?;
|
||||
let stdout = String::from_utf8_lossy(&check.stdout);
|
||||
if stdout.contains("immich_server") {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Immich already installed. Stop and remove it first."
|
||||
));
|
||||
}
|
||||
if stdout.contains("immich\n") || stdout.lines().any(|l| l.trim() == "immich") {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["stop", "immich"])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["rm", "-f", "immich"])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
|
||||
let images = [
|
||||
"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"
|
||||
}))
|
||||
}
|
||||
}
|
||||
@ -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(""))
|
||||
}
|
||||
|
||||
316
core/archipelago/src/api/rpc/system/handlers.rs
Normal file
316
core/archipelago/src/api/rpc/system/handlers.rs
Normal file
@ -0,0 +1,316 @@
|
||||
use super::*;
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::{debug, info};
|
||||
|
||||
impl RpcHandler {
|
||||
/// server.set-name — Rename the server (persisted to data_dir/server-name)
|
||||
pub(in crate::api::rpc) async fn handle_server_set_name(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let name = params
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: name"))?
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
if name.is_empty() || name.len() > 64 {
|
||||
anyhow::bail!("Name must be 1-64 characters");
|
||||
}
|
||||
|
||||
// Persist to file
|
||||
let name_file = self.config.data_dir.join("server-name");
|
||||
tokio::fs::write(&name_file, &name)
|
||||
.await
|
||||
.context("Failed to write server name")?;
|
||||
|
||||
// Update live state
|
||||
let (mut data, _) = self.state_manager.get_snapshot().await;
|
||||
data.server_info.name = Some(name.clone());
|
||||
self.state_manager.update_data(data).await;
|
||||
|
||||
info!("Server name updated to: {}", name);
|
||||
|
||||
// Push the new name to federation peers in background
|
||||
let data_dir = self.config.data_dir.clone();
|
||||
let state_manager = self.state_manager.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = push_name_to_peers(&data_dir, &state_manager).await {
|
||||
debug!("Federation name push (non-fatal): {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(serde_json::json!({ "name": name }))
|
||||
}
|
||||
|
||||
/// system.stats — CPU usage, RAM used/total, disk used/total, uptime, load average
|
||||
pub(in crate::api::rpc) async fn handle_system_stats(&self) -> Result<serde_json::Value> {
|
||||
debug!("Getting system stats");
|
||||
|
||||
let uptime = read_uptime().await.unwrap_or(0.0);
|
||||
let load = read_loadavg().await.unwrap_or((0.0, 0.0, 0.0));
|
||||
let cpu = read_cpu_usage().await.unwrap_or(0.0);
|
||||
let (mem_used, mem_total) = read_meminfo().await.unwrap_or((0, 0));
|
||||
let (disk_used, disk_total) = read_disk_usage().await.unwrap_or((0, 0));
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"uptime_secs": uptime as u64,
|
||||
"load_avg_1": load.0,
|
||||
"load_avg_5": load.1,
|
||||
"load_avg_15": load.2,
|
||||
"cpu_usage_percent": cpu,
|
||||
"mem_used_bytes": mem_used,
|
||||
"mem_total_bytes": mem_total,
|
||||
"disk_used_bytes": disk_used,
|
||||
"disk_total_bytes": disk_total,
|
||||
}))
|
||||
}
|
||||
|
||||
/// system.processes — top 10 processes by CPU
|
||||
pub(in crate::api::rpc) async fn handle_system_processes(&self) -> Result<serde_json::Value> {
|
||||
debug!("Getting top processes");
|
||||
|
||||
let procs = read_top_processes().await.unwrap_or_default();
|
||||
|
||||
Ok(serde_json::json!({ "processes": procs }))
|
||||
}
|
||||
|
||||
/// system.temperature — thermal zone readings
|
||||
pub(in crate::api::rpc) async fn handle_system_temperature(&self) -> Result<serde_json::Value> {
|
||||
debug!("Getting system temperature");
|
||||
|
||||
let temps = read_temperatures().await.unwrap_or_default();
|
||||
|
||||
Ok(serde_json::json!({ "temperatures": temps }))
|
||||
}
|
||||
|
||||
/// system.detect-usb-devices — scan for known hardware wallet USB devices
|
||||
pub(in crate::api::rpc) async fn handle_system_detect_usb_devices(&self) -> Result<serde_json::Value> {
|
||||
debug!("Scanning for USB hardware wallets");
|
||||
|
||||
let devices = detect_usb_hardware_wallets().await.unwrap_or_default();
|
||||
|
||||
Ok(serde_json::json!({ "devices": devices }))
|
||||
}
|
||||
|
||||
/// system.disk-status — Disk usage with warning/critical thresholds.
|
||||
pub(in crate::api::rpc) async fn handle_system_disk_status(&self) -> Result<serde_json::Value> {
|
||||
let (used, total) = read_disk_usage().await.unwrap_or((0, 0));
|
||||
let percent = if total > 0 {
|
||||
(used as f64 / total as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let percent_rounded = (percent * 10.0).round() / 10.0;
|
||||
|
||||
let level = if percent >= 90.0 {
|
||||
"critical"
|
||||
} else if percent >= 85.0 {
|
||||
"warning"
|
||||
} else {
|
||||
"ok"
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"used_bytes": used,
|
||||
"total_bytes": total,
|
||||
"free_bytes": total.saturating_sub(used),
|
||||
"used_percent": percent_rounded,
|
||||
"level": level,
|
||||
}))
|
||||
}
|
||||
|
||||
/// system.disk-cleanup — Remove old container images, stale logs, and temp files.
|
||||
pub(in crate::api::rpc) async fn handle_system_disk_cleanup(&self) -> Result<serde_json::Value> {
|
||||
tracing::info!("Starting disk cleanup");
|
||||
let mut freed_bytes: u64 = 0;
|
||||
let mut actions: Vec<String> = Vec::new();
|
||||
|
||||
// 1. Prune dangling container images
|
||||
match prune_container_images().await {
|
||||
Ok(bytes) => {
|
||||
if bytes > 0 {
|
||||
freed_bytes += bytes;
|
||||
actions.push(format!("Pruned dangling images: {} freed", format_bytes(bytes)));
|
||||
}
|
||||
}
|
||||
Err(e) => actions.push(format!("Image prune failed: {}", e)),
|
||||
}
|
||||
|
||||
// 2. Clean old log files (> 30 days)
|
||||
match clean_old_logs(30).await {
|
||||
Ok(bytes) => {
|
||||
if bytes > 0 {
|
||||
freed_bytes += bytes;
|
||||
actions.push(format!("Cleaned old logs: {} freed", format_bytes(bytes)));
|
||||
}
|
||||
}
|
||||
Err(e) => actions.push(format!("Log cleanup failed: {}", e)),
|
||||
}
|
||||
|
||||
// 3. Remove stale temp files
|
||||
match clean_temp_files().await {
|
||||
Ok(bytes) => {
|
||||
if bytes > 0 {
|
||||
freed_bytes += bytes;
|
||||
actions.push(format!("Removed temp files: {} freed", format_bytes(bytes)));
|
||||
}
|
||||
}
|
||||
Err(e) => actions.push(format!("Temp cleanup failed: {}", e)),
|
||||
}
|
||||
|
||||
// 4. Prune container build cache
|
||||
match prune_build_cache().await {
|
||||
Ok(bytes) => {
|
||||
if bytes > 0 {
|
||||
freed_bytes += bytes;
|
||||
actions.push(format!("Pruned build cache: {} freed", format_bytes(bytes)));
|
||||
}
|
||||
}
|
||||
Err(e) => actions.push(format!("Build cache prune failed: {}", e)),
|
||||
}
|
||||
|
||||
tracing::info!("Disk cleanup complete: {} freed ({} actions)", format_bytes(freed_bytes), actions.len());
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"freed_bytes": freed_bytes,
|
||||
"freed_human": format_bytes(freed_bytes),
|
||||
"actions": actions,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// system.factory-reset — Wipe all user data, remove containers, and restart.
|
||||
/// Only preserves the data_dir itself (recreated empty on restart).
|
||||
/// system.reboot — Reboot the machine. Requires password re-verification.
|
||||
pub(in crate::api::rpc) async fn handle_system_reboot(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let password = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("password"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing password — re-authentication required"))?;
|
||||
|
||||
let valid = self.auth_manager.verify_password(password).await?;
|
||||
if !valid {
|
||||
return Err(anyhow::anyhow!("Password incorrect"));
|
||||
}
|
||||
|
||||
info!("System reboot initiated by user");
|
||||
|
||||
// Schedule reboot in 2 seconds (gives time for the RPC response to reach the client)
|
||||
// Uses the tor-helper path unit pattern (writes action file, systemd triggers root service)
|
||||
tokio::spawn(async {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
let action = serde_json::json!({"action": "reboot"});
|
||||
let _ = tokio::fs::write(
|
||||
"/var/lib/archipelago/tor-config/tor-action",
|
||||
serde_json::to_string(&action).unwrap_or_default(),
|
||||
).await;
|
||||
});
|
||||
|
||||
Ok(serde_json::json!({ "rebooting": true }))
|
||||
}
|
||||
|
||||
pub(in crate::api::rpc) async fn handle_system_factory_reset(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
// Safety check: require { confirm: true }
|
||||
let confirmed = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("confirm"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
if !confirmed {
|
||||
anyhow::bail!("Factory reset requires {{ \"confirm\": true }}");
|
||||
}
|
||||
|
||||
// Require password re-authentication for destructive operations
|
||||
let password = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("password"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing password — re-authentication required"))?;
|
||||
|
||||
let valid = self.auth_manager.verify_password(password).await?;
|
||||
if !valid {
|
||||
return Err(anyhow::anyhow!("Password Incorrect"));
|
||||
}
|
||||
|
||||
tracing::warn!("Factory reset initiated — wiping ALL user data and containers");
|
||||
|
||||
let data_dir = &self.config.data_dir;
|
||||
|
||||
// 1. Stop and remove ALL containers (force)
|
||||
let client = archipelago_container::PodmanClient::new("archipelago".to_string());
|
||||
if let Ok(containers) = client.list_containers().await {
|
||||
for c in &containers {
|
||||
tracing::info!("Factory reset: removing container {}", c.name);
|
||||
let _ = client.stop_container(&c.name).await;
|
||||
let _ = client.remove_container(&c.name).await;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Remove all container images
|
||||
tracing::info!("Factory reset: pruning all container images");
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["rmi", "--all", "--force"])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
// 3. Prune volumes and build cache
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["volume", "prune", "-f"])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["system", "prune", "-af"])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
// 4. Wipe the entire data directory contents
|
||||
// Delete everything inside data_dir, then recreate the empty dir.
|
||||
if let Ok(mut entries) = tokio::fs::read_dir(data_dir).await {
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
let path = entry.path();
|
||||
let name = entry.file_name();
|
||||
let name_str = name.to_string_lossy();
|
||||
|
||||
// Skip the tor directory (managed by system debian-tor user)
|
||||
if name_str == "tor" {
|
||||
continue;
|
||||
}
|
||||
|
||||
tracing::info!("Factory reset: removing {}", path.display());
|
||||
if path.is_dir() {
|
||||
let _ = tokio::fs::remove_dir_all(&path).await;
|
||||
} else {
|
||||
let _ = tokio::fs::remove_file(&path).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Clear all sessions
|
||||
self.session_store.invalidate_all_except("").await;
|
||||
|
||||
tracing::warn!("Factory reset complete — all data wiped, restarting service");
|
||||
|
||||
// Restart the service via systemd
|
||||
tokio::spawn(async {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
let _ = std::process::Command::new("sudo")
|
||||
.args(["systemctl", "restart", "archipelago"])
|
||||
.spawn();
|
||||
});
|
||||
|
||||
Ok(serde_json::json!({ "status": "resetting" }))
|
||||
}
|
||||
}
|
||||
@ -1,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" }))
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
339
core/archipelago/src/api/rpc/tor/handlers.rs
Normal file
339
core/archipelago/src/api/rpc/tor/handlers.rs
Normal file
@ -0,0 +1,339 @@
|
||||
use super::*;
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
impl RpcHandler {
|
||||
/// List all configured hidden services with their .onion addresses.
|
||||
pub(in crate::api::rpc) async fn handle_tor_list_services(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
let config_dir = self.config.data_dir.join("tor-config");
|
||||
let services = list_services(&config_dir).await?;
|
||||
let tor_running = check_tor_running().await;
|
||||
Ok(serde_json::json!({ "services": services, "tor_running": tor_running }))
|
||||
}
|
||||
|
||||
/// Create a new hidden service for a given local port.
|
||||
pub(in crate::api::rpc) async fn handle_tor_create_service(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let name = params
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing name"))?;
|
||||
let raw_port = params
|
||||
.get("local_port")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u16;
|
||||
let remote_port = params
|
||||
.get("remote_port")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v as u16);
|
||||
|
||||
validate_service_name(name)?;
|
||||
|
||||
let local_port = if raw_port == 0 {
|
||||
let detected = known_service_port(name);
|
||||
if detected == 0 {
|
||||
return Err(anyhow::anyhow!("Unknown app '{}' — specify local_port manually", name));
|
||||
}
|
||||
detected
|
||||
} else {
|
||||
raw_port
|
||||
};
|
||||
|
||||
let config_dir = self.config.data_dir.join("tor-config");
|
||||
let mut config = load_services_config(&config_dir).await;
|
||||
if config.services.iter().any(|s| s.name == name) {
|
||||
return Err(anyhow::anyhow!("Service '{}' already exists", name));
|
||||
}
|
||||
|
||||
let is_proto = is_protocol_service(name);
|
||||
config.services.push(TorServiceEntry {
|
||||
name: name.to_string(),
|
||||
local_port,
|
||||
remote_port,
|
||||
unauthenticated: is_proto,
|
||||
enabled: true,
|
||||
});
|
||||
save_services_config(&config_dir, &config).await?;
|
||||
|
||||
regenerate_torrc(&config).await?;
|
||||
restart_tor().await?;
|
||||
|
||||
let onion = wait_for_hostname(name, 60).await;
|
||||
if let Some(ref addr) = onion {
|
||||
sync_single_hostname(name, addr).await;
|
||||
}
|
||||
|
||||
info!(service = name, port = local_port, "Created Tor hidden service");
|
||||
Ok(serde_json::json!({
|
||||
"created": true,
|
||||
"name": name,
|
||||
"onion_address": onion,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Delete a hidden service.
|
||||
pub(in crate::api::rpc) async fn handle_tor_delete_service(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let name = params
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing name"))?;
|
||||
|
||||
validate_service_name(name)?;
|
||||
|
||||
if name == "archipelago" {
|
||||
return Err(anyhow::anyhow!("Cannot delete the node's own Tor service"));
|
||||
}
|
||||
|
||||
let config_dir = self.config.data_dir.join("tor-config");
|
||||
let mut config = load_services_config(&config_dir).await;
|
||||
let before = config.services.len();
|
||||
config.services.retain(|s| s.name != name);
|
||||
if config.services.len() == before {
|
||||
return Err(anyhow::anyhow!("Service '{}' not found", name));
|
||||
}
|
||||
save_services_config(&config_dir, &config).await?;
|
||||
|
||||
delete_hidden_service_dir(name).await;
|
||||
|
||||
regenerate_torrc(&config).await?;
|
||||
restart_tor().await?;
|
||||
|
||||
info!(service = name, "Deleted Tor hidden service");
|
||||
Ok(serde_json::json!({ "deleted": true, "name": name }))
|
||||
}
|
||||
|
||||
/// Get the .onion address for a specific service.
|
||||
pub(in crate::api::rpc) async fn handle_tor_get_onion_address(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let name = params
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing name"))?;
|
||||
|
||||
validate_service_name(name)?;
|
||||
|
||||
let onion = read_onion_address(name).await;
|
||||
Ok(serde_json::json!({ "name": name, "onion_address": onion }))
|
||||
}
|
||||
|
||||
/// Rotate a hidden service's .onion address by generating a new keypair.
|
||||
pub(in crate::api::rpc) async fn handle_tor_rotate_service(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let name = params
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing name"))?;
|
||||
|
||||
validate_service_name(name)?;
|
||||
|
||||
let old_onion = read_onion_address(name).await;
|
||||
if old_onion.is_none() {
|
||||
return Err(anyhow::anyhow!("Service '{}' has no .onion address to rotate", name));
|
||||
}
|
||||
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
rename_hidden_service_dir(name, timestamp).await;
|
||||
|
||||
info!(
|
||||
service = name,
|
||||
old_onion = ?old_onion,
|
||||
"Renamed old Tor service dir — restarting Tor to generate new keypair"
|
||||
);
|
||||
|
||||
restart_tor().await?;
|
||||
|
||||
let new_onion = wait_for_hostname(name, 60).await;
|
||||
|
||||
if let Some(ref new_addr) = new_onion {
|
||||
sync_single_hostname(name, new_addr).await;
|
||||
}
|
||||
|
||||
let old_name = format!("{}_old_{}", name, timestamp);
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(3600)).await;
|
||||
info!(old_dir = %old_name, "Transition period elapsed — deleting old Tor service dir");
|
||||
delete_hidden_service_dir(&old_name).await;
|
||||
});
|
||||
|
||||
if let Some(ref new_addr) = new_onion {
|
||||
let data_dir = self.config.data_dir.clone();
|
||||
let tor_proxy = self.config.nostr_tor_proxy.clone();
|
||||
let new_addr_clone = new_addr.clone();
|
||||
let old_onion_clone = old_onion.clone();
|
||||
tokio::spawn(async move {
|
||||
notify_federation_peers_address_change(
|
||||
&data_dir,
|
||||
&new_addr_clone,
|
||||
old_onion_clone.as_deref(),
|
||||
tor_proxy.as_deref(),
|
||||
).await;
|
||||
});
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"rotated": true,
|
||||
"name": name,
|
||||
"old_onion": old_onion,
|
||||
"new_onion": new_onion,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Clean up expired rotated service directories past the transition period.
|
||||
pub(in crate::api::rpc) async fn handle_tor_cleanup_rotated(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
let base = detect_hidden_service_base();
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
let mut cleaned = Vec::new();
|
||||
if let Ok(mut entries) = tokio::fs::read_dir(&base).await {
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if !name.contains("_old_") {
|
||||
continue;
|
||||
}
|
||||
if let Some(ts_str) = name.rsplit('_').next() {
|
||||
if let Ok(ts) = ts_str.parse::<u64>() {
|
||||
if now - ts > ROTATION_TRANSITION_SECS {
|
||||
let path = entry.path();
|
||||
let status = tokio::process::Command::new("sudo")
|
||||
.args(["rm", "-rf", &path.to_string_lossy()])
|
||||
.status()
|
||||
.await;
|
||||
if status.map(|s| s.success()).unwrap_or(false) {
|
||||
info!(dir = %name, "Cleaned up expired rotated Tor service");
|
||||
cleaned.push(name);
|
||||
} else {
|
||||
warn!(dir = %name, "Failed to clean up rotated Tor service");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({ "cleaned": cleaned, "count": cleaned.len() }))
|
||||
}
|
||||
|
||||
/// Toggle Tor access for a specific app (enable/disable).
|
||||
pub(in crate::api::rpc) async fn handle_tor_toggle_app(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let app_id = params
|
||||
.get("app_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
|
||||
|
||||
validate_service_name(app_id)?;
|
||||
|
||||
let enabled = params
|
||||
.get("enabled")
|
||||
.and_then(|v| v.as_bool())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing enabled (bool)"))?;
|
||||
|
||||
let config_dir = self.config.data_dir.join("tor-config");
|
||||
let mut config = load_services_config(&config_dir).await;
|
||||
|
||||
let found = config.services.iter_mut().find(|s| s.name == app_id);
|
||||
match found {
|
||||
Some(entry) => {
|
||||
if entry.enabled == enabled {
|
||||
return Ok(serde_json::json!({
|
||||
"app_id": app_id,
|
||||
"enabled": enabled,
|
||||
"changed": false,
|
||||
}));
|
||||
}
|
||||
entry.enabled = enabled;
|
||||
}
|
||||
None => {
|
||||
if !enabled {
|
||||
return Ok(serde_json::json!({
|
||||
"app_id": app_id,
|
||||
"enabled": false,
|
||||
"changed": false,
|
||||
}));
|
||||
}
|
||||
let port = known_service_port(app_id);
|
||||
let is_proto = is_protocol_service(app_id);
|
||||
config.services.push(TorServiceEntry {
|
||||
name: app_id.to_string(),
|
||||
local_port: port,
|
||||
remote_port: None,
|
||||
unauthenticated: is_proto,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
save_services_config(&config_dir, &config).await?;
|
||||
|
||||
if !enabled {
|
||||
delete_hidden_service_dir(app_id).await;
|
||||
info!(app = app_id, "Disabled Tor access — removed hidden service dir");
|
||||
}
|
||||
|
||||
regenerate_torrc(&config).await?;
|
||||
restart_tor().await?;
|
||||
|
||||
let new_onion = if enabled {
|
||||
let onion = wait_for_hostname(app_id, 60).await;
|
||||
if let Some(ref addr) = onion {
|
||||
sync_single_hostname(app_id, addr).await;
|
||||
}
|
||||
onion
|
||||
} else {
|
||||
let hostnames_dir = self.config.data_dir.join("tor-hostnames");
|
||||
let _ = tokio::fs::remove_file(hostnames_dir.join(app_id)).await;
|
||||
None
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"app_id": app_id,
|
||||
"enabled": enabled,
|
||||
"changed": true,
|
||||
"onion_address": new_onion,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Restart Tor daemon (system or container).
|
||||
pub(in crate::api::rpc) async fn handle_tor_restart(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
info!("Manual Tor restart requested");
|
||||
|
||||
let config_dir = self.config.data_dir.join("tor-config");
|
||||
let config = load_services_config(&config_dir).await;
|
||||
regenerate_torrc(&config).await?;
|
||||
|
||||
restart_tor().await?;
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
|
||||
sync_all_hostname_copies(&config).await;
|
||||
|
||||
let running = check_tor_running().await;
|
||||
Ok(serde_json::json!({ "restarted": true, "tor_running": running }))
|
||||
}
|
||||
}
|
||||
430
core/archipelago/src/api/rpc/tor/mod.rs
Normal file
430
core/archipelago/src/api/rpc/tor/mod.rs
Normal file
@ -0,0 +1,430 @@
|
||||
mod handlers;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::{federation, identity};
|
||||
|
||||
pub(super) const TOR_DATA_DIR: &str = "/var/lib/archipelago/tor";
|
||||
pub(super) const SERVICES_CONFIG: &str = "services.json";
|
||||
/// How long old service directories are kept during transition (seconds).
|
||||
pub(super) const ROTATION_TRANSITION_SECS: u64 = 86400; // 24 hours
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(super) struct TorService {
|
||||
pub name: String,
|
||||
pub local_port: u16,
|
||||
pub onion_address: Option<String>,
|
||||
pub enabled: bool,
|
||||
pub unauthenticated: bool,
|
||||
pub protocol: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub(super) struct ServicesConfig {
|
||||
pub services: Vec<TorServiceEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(super) struct TorServiceEntry {
|
||||
pub name: String,
|
||||
pub local_port: u16,
|
||||
#[serde(default)]
|
||||
pub remote_port: Option<u16>,
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub unauthenticated: bool,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
// ─── Validation ───────────────────────────────────────────────────
|
||||
|
||||
pub(super) fn validate_service_name(name: &str) -> Result<()> {
|
||||
if name.is_empty() || name.len() > 64
|
||||
|| !name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_')
|
||||
{
|
||||
return Err(anyhow::anyhow!("Invalid service name (alphanumeric, hyphens, underscores only)"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── Tor Daemon Control ──────────────────────────────────────────
|
||||
|
||||
const TOR_ACTION_FILE: &str = "/var/lib/archipelago/tor-config/tor-action";
|
||||
const TOR_RESULT_FILE: &str = "/var/lib/archipelago/tor-config/tor-result";
|
||||
|
||||
/// Write an action file and wait for the tor-helper service to process it.
|
||||
pub(super) async fn dispatch_tor_action(action: serde_json::Value) -> Result<()> {
|
||||
let _ = tokio::fs::remove_file(TOR_RESULT_FILE).await;
|
||||
|
||||
let content = serde_json::to_string(&action).context("Failed to serialize tor action")?;
|
||||
let config_dir = Path::new(TOR_ACTION_FILE).parent().unwrap_or_else(|| Path::new("/var/lib/archipelago/tor-config"));
|
||||
tokio::fs::create_dir_all(config_dir).await.ok();
|
||||
tokio::fs::write(TOR_ACTION_FILE, &content)
|
||||
.await
|
||||
.context("Failed to write tor-action file")?;
|
||||
|
||||
for _ in 0..90 {
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||
if let Ok(result_str) = tokio::fs::read_to_string(TOR_RESULT_FILE).await {
|
||||
let _ = tokio::fs::remove_file(TOR_RESULT_FILE).await;
|
||||
if let Ok(result) = serde_json::from_str::<serde_json::Value>(&result_str) {
|
||||
if result.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||
return Ok(());
|
||||
}
|
||||
let err = result.get("error").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
||||
return Err(anyhow::anyhow!("Tor helper: {}", err));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(anyhow::anyhow!("Tor helper timed out — is archipelago-tor-helper.path enabled?"))
|
||||
}
|
||||
|
||||
pub(super) async fn delete_hidden_service_dir(name: &str) {
|
||||
if let Err(e) = dispatch_tor_action(serde_json::json!({
|
||||
"action": "delete-service",
|
||||
"name": name,
|
||||
})).await {
|
||||
warn!("Failed to delete hidden service dir for {}: {}", name, e);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn rename_hidden_service_dir(name: &str, timestamp: u64) {
|
||||
if let Err(e) = dispatch_tor_action(serde_json::json!({
|
||||
"action": "rename-service",
|
||||
"name": name,
|
||||
"timestamp": timestamp,
|
||||
})).await {
|
||||
warn!("Failed to rename hidden service dir for {}: {}", name, e);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn restart_tor() -> Result<()> {
|
||||
dispatch_tor_action(serde_json::json!({
|
||||
"action": "write-torrc-and-restart",
|
||||
})).await
|
||||
}
|
||||
|
||||
pub(super) async fn check_tor_running() -> bool {
|
||||
tokio::net::TcpStream::connect("127.0.0.1:9050")
|
||||
.await
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
// ─── torrc Generation ────────────────────────────────────────────
|
||||
|
||||
pub(super) fn detect_hidden_service_base() -> String {
|
||||
if Path::new("/var/lib/tor/hidden_service_archipelago").exists() {
|
||||
return "/var/lib/tor".to_string();
|
||||
}
|
||||
let custom = tor_data_dir();
|
||||
if Path::new(&custom).join("hidden_service_archipelago").exists() {
|
||||
return custom;
|
||||
}
|
||||
"/var/lib/tor".to_string()
|
||||
}
|
||||
|
||||
pub(super) async fn regenerate_torrc(config: &ServicesConfig) -> Result<()> {
|
||||
let base = detect_hidden_service_base();
|
||||
let mut lines = Vec::new();
|
||||
|
||||
lines.push("# Auto-generated by Archipelago — do not edit manually".to_string());
|
||||
lines.push("SocksPort 9050".to_string());
|
||||
lines.push("# ControlPort disabled for security".to_string());
|
||||
lines.push(String::new());
|
||||
|
||||
for svc in &config.services {
|
||||
if !svc.enabled {
|
||||
continue;
|
||||
}
|
||||
let dir = format!("{}/hidden_service_{}", base, svc.name);
|
||||
lines.push(format!("HiddenServiceDir {}", dir));
|
||||
|
||||
if is_protocol_service(&svc.name) {
|
||||
let remote_port = svc.remote_port.unwrap_or(svc.local_port);
|
||||
lines.push(format!("HiddenServicePort {} 127.0.0.1:{}", remote_port, svc.local_port));
|
||||
if svc.name == "lnd" {
|
||||
lines.push("HiddenServicePort 9735 127.0.0.1:9735".to_string());
|
||||
lines.push("HiddenServicePort 10009 127.0.0.1:10009".to_string());
|
||||
}
|
||||
} else {
|
||||
lines.push(format!("HiddenServicePort 80 127.0.0.1:{}", svc.local_port));
|
||||
}
|
||||
|
||||
lines.push(String::new());
|
||||
}
|
||||
|
||||
let content = lines.join("\n");
|
||||
let staging = "/var/lib/archipelago/tor-config/torrc.staged";
|
||||
let config_dir = Path::new(staging).parent().unwrap_or_else(|| Path::new("/var/lib/archipelago/tor-config"));
|
||||
tokio::fs::create_dir_all(config_dir).await.ok();
|
||||
tokio::fs::write(staging, &content).await.context("Failed to write staged torrc")?;
|
||||
|
||||
debug!("Staged torrc with {} enabled services",
|
||||
config.services.iter().filter(|s| s.enabled).count());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── Hostname Sync ───────────────────────────────────────────────
|
||||
|
||||
pub(super) async fn sync_single_hostname(name: &str, address: &str) {
|
||||
let hostnames_dir = Path::new("/var/lib/archipelago/tor-hostnames");
|
||||
if let Err(e) = tokio::fs::create_dir_all(hostnames_dir).await {
|
||||
warn!("Failed to create tor-hostnames dir: {}", e);
|
||||
return;
|
||||
}
|
||||
if let Err(e) = tokio::fs::write(hostnames_dir.join(name), address).await {
|
||||
warn!("Failed to write tor-hostname copy for {}: {}", name, e);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn sync_all_hostname_copies(config: &ServicesConfig) {
|
||||
for svc in &config.services {
|
||||
if !svc.enabled {
|
||||
continue;
|
||||
}
|
||||
if let Some(addr) = read_onion_address(&svc.name).await {
|
||||
sync_single_hostname(&svc.name, &addr).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Service Listing ─────────────────────────────────────────────
|
||||
|
||||
pub(super) async fn list_services(config_dir: &std::path::Path) -> Result<Vec<TorService>> {
|
||||
let base = detect_hidden_service_base();
|
||||
let config = load_services_config(config_dir).await;
|
||||
let mut services = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
|
||||
for entry in &config.services {
|
||||
let onion = read_onion_address(&entry.name).await;
|
||||
seen.insert(entry.name.clone());
|
||||
services.push(TorService {
|
||||
name: entry.name.clone(),
|
||||
local_port: entry.local_port,
|
||||
onion_address: onion,
|
||||
enabled: entry.enabled,
|
||||
unauthenticated: entry.unauthenticated,
|
||||
protocol: is_protocol_service(&entry.name),
|
||||
});
|
||||
}
|
||||
|
||||
for scan_dir in ["/var/lib/tor", &base] {
|
||||
if let Ok(mut entries) = tokio::fs::read_dir(scan_dir).await {
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
let is_dir = entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false);
|
||||
if name.starts_with("hidden_service_")
|
||||
&& !name.contains("_old_")
|
||||
&& is_dir
|
||||
{
|
||||
let service_name = name.strip_prefix("hidden_service_").unwrap_or(&name).to_string();
|
||||
if seen.contains(&service_name) {
|
||||
continue;
|
||||
}
|
||||
let onion = read_onion_address(&service_name).await;
|
||||
let port = known_service_port(&service_name);
|
||||
seen.insert(service_name.clone());
|
||||
let is_proto = is_protocol_service(&service_name);
|
||||
services.push(TorService {
|
||||
name: service_name,
|
||||
local_port: port,
|
||||
onion_address: onion,
|
||||
enabled: true,
|
||||
unauthenticated: is_proto,
|
||||
protocol: is_proto,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(services)
|
||||
}
|
||||
|
||||
// ─── Onion Address Reading ───────────────────────────────────────
|
||||
|
||||
pub(super) async fn read_onion_address(service_name: &str) -> Option<String> {
|
||||
let hostnames_path = Path::new("/var/lib/archipelago/tor-hostnames").join(service_name);
|
||||
if let Some(addr) = read_and_validate_onion(&hostnames_path).await {
|
||||
return Some(addr);
|
||||
}
|
||||
|
||||
let base = tor_data_dir();
|
||||
for search_base in &["/var/lib/tor", base.as_str()] {
|
||||
let path = Path::new(search_base)
|
||||
.join(format!("hidden_service_{}", service_name))
|
||||
.join("hostname");
|
||||
|
||||
if let Some(addr) = read_and_validate_onion(&path).await {
|
||||
return Some(addr);
|
||||
}
|
||||
|
||||
if let Some(addr) = sudo_read_and_validate_onion(&path).await {
|
||||
return Some(addr);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
async fn read_and_validate_onion(path: &Path) -> Option<String> {
|
||||
tokio::fs::read_to_string(path)
|
||||
.await
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| is_valid_v3_onion(s))
|
||||
}
|
||||
|
||||
async fn sudo_read_and_validate_onion(path: &Path) -> Option<String> {
|
||||
tokio::process::Command::new("sudo")
|
||||
.args(["cat", &path.to_string_lossy()])
|
||||
.output()
|
||||
.await
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| is_valid_v3_onion(s))
|
||||
}
|
||||
|
||||
fn is_valid_v3_onion(s: &str) -> bool {
|
||||
s.len() == 62
|
||||
&& s.ends_with(".onion")
|
||||
&& !s.contains(':')
|
||||
&& s[..56].chars().all(|c| c.is_ascii_alphanumeric())
|
||||
}
|
||||
|
||||
// ─── Known Ports ─────────────────────────────────────────────────
|
||||
|
||||
pub(super) fn known_service_port(name: &str) -> u16 {
|
||||
match name {
|
||||
"archipelago" => 80,
|
||||
"bitcoin" | "bitcoin-knots" => 8333,
|
||||
"electrs" | "electrumx" => 50001,
|
||||
"lnd" => 8080,
|
||||
"btcpay" | "btcpay-server" | "btcpayserver" => 23000,
|
||||
"mempool" => 4080,
|
||||
"fedimint" => 8175,
|
||||
"nostr-relay" | "nostr-rs-relay" => 8080,
|
||||
"searxng" => 8888,
|
||||
"ollama" => 11434,
|
||||
"filebrowser" => 8083,
|
||||
"grafana" => 3000,
|
||||
"home-assistant" => 8123,
|
||||
"immich" => 2283,
|
||||
"photoprism" => 2342,
|
||||
"penpot" => 9001,
|
||||
"nginx-proxy-manager" => 81,
|
||||
"vaultwarden" => 8343,
|
||||
"indeedhub" => 7777,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn is_protocol_service(name: &str) -> bool {
|
||||
matches!(name, "bitcoin" | "bitcoin-knots" | "electrs" | "electrumx" | "lnd")
|
||||
}
|
||||
|
||||
// ─── Config I/O ──────────────────────────────────────────────────
|
||||
|
||||
fn tor_data_dir() -> String {
|
||||
std::env::var("TOR_DATA_DIR").unwrap_or_else(|_| TOR_DATA_DIR.to_string())
|
||||
}
|
||||
|
||||
pub(super) async fn load_services_config(config_dir: &std::path::Path) -> ServicesConfig {
|
||||
let path = config_dir.join(SERVICES_CONFIG);
|
||||
match tokio::fs::read_to_string(&path).await {
|
||||
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
|
||||
Err(_) => ServicesConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn save_services_config(config_dir: &std::path::Path, config: &ServicesConfig) -> Result<()> {
|
||||
tokio::fs::create_dir_all(config_dir).await.context("Failed to create tor config dir")?;
|
||||
let path = config_dir.join(SERVICES_CONFIG);
|
||||
let content = serde_json::to_string_pretty(config).context("Failed to serialize services config")?;
|
||||
tokio::fs::write(&path, content).await.context("Failed to write services config")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── Federation Notification ─────────────────────────────────────
|
||||
|
||||
pub(super) async fn notify_federation_peers_address_change(
|
||||
data_dir: &std::path::Path,
|
||||
new_onion: &str,
|
||||
old_onion: Option<&str>,
|
||||
tor_proxy: Option<&str>,
|
||||
) {
|
||||
let identity_dir = data_dir.join("identity");
|
||||
match identity::NodeIdentity::load_or_create(&identity_dir).await {
|
||||
Ok(node_id) => {
|
||||
let did = match node_id.did_key() {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to derive DID key: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let proxy = tor_proxy.unwrap_or("127.0.0.1:9050");
|
||||
match federation::load_nodes(data_dir).await {
|
||||
Ok(peers) => {
|
||||
for peer in peers {
|
||||
if peer.onion.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let payload = serde_json::json!({
|
||||
"method": "federation.peer-address-changed",
|
||||
"params": {
|
||||
"did": did,
|
||||
"new_onion": new_onion,
|
||||
"old_onion": old_onion,
|
||||
}
|
||||
});
|
||||
let url = format!("http://{}/rpc/v1", &peer.onion);
|
||||
let client = match reqwest::Client::builder()
|
||||
.proxy(match reqwest::Proxy::all(format!("socks5h://{}", proxy))
|
||||
.or_else(|_| reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY)) {
|
||||
Ok(p) => p,
|
||||
Err(_) => continue,
|
||||
})
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
match client.post(&url).json(&payload).send().await {
|
||||
Ok(_) => info!(peer_did = %peer.did, "Notified peer of address change"),
|
||||
Err(e) => warn!(peer_did = %peer.did, "Failed to notify peer: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("Failed to load federation peers: {}", e),
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("Failed to load node identity for propagation: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Hostname Waiting ────────────────────────────────────────────
|
||||
|
||||
pub(super) async fn wait_for_hostname(service_name: &str, max_secs: u64) -> Option<String> {
|
||||
for _ in 0..max_secs {
|
||||
if let Some(addr) = read_onion_address(service_name).await {
|
||||
return Some(addr);
|
||||
}
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
warn!(service = service_name, "Timed out waiting for new .onion hostname");
|
||||
None
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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",
|
||||
];
|
||||
|
||||
@ -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};
|
||||
|
||||
@ -1,796 +0,0 @@
|
||||
//! Verifiable Credentials (VC) management following W3C VC Data Model 2.0.
|
||||
//! Implements JSON-LD @context, Ed25519Signature2020 proof format.
|
||||
//! See: https://www.w3.org/TR/vc-data-model-2.0/
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use tokio::fs;
|
||||
use tracing::debug;
|
||||
|
||||
const CREDENTIALS_DIR: &str = "credentials";
|
||||
|
||||
/// W3C VC Data Model 2.0 context URI
|
||||
const VC_CONTEXT_V2: &str = "https://www.w3.org/ns/credentials/v2";
|
||||
/// Ed25519 signature suite context
|
||||
const ED25519_CONTEXT: &str = "https://w3id.org/security/suites/ed25519-2020/v1";
|
||||
|
||||
/// A Verifiable Credential following W3C VC Data Model 2.0.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VerifiableCredential {
|
||||
#[serde(rename = "@context")]
|
||||
pub context: Vec<String>,
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub credential_type: Vec<String>,
|
||||
pub issuer: String,
|
||||
pub credential_subject: CredentialSubject,
|
||||
pub issuance_date: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expiration_date: Option<String>,
|
||||
pub proof: CredentialProof,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub credential_status: Option<CredentialStatusEntry>,
|
||||
}
|
||||
|
||||
/// The subject of a credential with their DID and claims.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CredentialSubject {
|
||||
pub id: String,
|
||||
#[serde(flatten)]
|
||||
pub claims: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Ed25519Signature2020 proof format.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CredentialProof {
|
||||
#[serde(rename = "type")]
|
||||
pub proof_type: String,
|
||||
pub created: String,
|
||||
pub verification_method: String,
|
||||
pub proof_purpose: String,
|
||||
pub proof_value: String,
|
||||
}
|
||||
|
||||
/// Credential status for revocation tracking.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CredentialStatusEntry {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub status_type: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CredentialStatus {
|
||||
Active,
|
||||
Revoked,
|
||||
Expired,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CredentialStatus {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
CredentialStatus::Active => write!(f, "active"),
|
||||
CredentialStatus::Revoked => write!(f, "revoked"),
|
||||
CredentialStatus::Expired => write!(f, "expired"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stored credentials index.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct CredentialStore {
|
||||
pub credentials: Vec<VerifiableCredential>,
|
||||
}
|
||||
|
||||
async fn ensure_dir(data_dir: &Path) -> Result<()> {
|
||||
let dir = data_dir.join(CREDENTIALS_DIR);
|
||||
if !dir.exists() {
|
||||
fs::create_dir_all(&dir).await.context("Creating credentials dir")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn store_path(data_dir: &Path) -> std::path::PathBuf {
|
||||
data_dir.join(CREDENTIALS_DIR).join("credentials.json")
|
||||
}
|
||||
|
||||
pub async fn load_credentials(data_dir: &Path) -> Result<CredentialStore> {
|
||||
ensure_dir(data_dir).await?;
|
||||
let path = store_path(data_dir);
|
||||
if !path.exists() {
|
||||
return Ok(CredentialStore::default());
|
||||
}
|
||||
let raw = fs::read(&path).await.context("Reading credentials")?;
|
||||
// Detect plaintext JSON (migration path) vs encrypted binary
|
||||
if raw.first().map_or(false, |b| *b == b'[' || *b == b'{') {
|
||||
let data = String::from_utf8(raw).context("UTF-8 credentials")?;
|
||||
return serde_json::from_str(&data).context("Parsing credentials");
|
||||
}
|
||||
// Encrypted: decrypt using node key
|
||||
let key = load_encryption_key(data_dir).await?;
|
||||
let plaintext = decrypt_credentials(&raw, &key)?;
|
||||
serde_json::from_slice(&plaintext).context("Parsing decrypted credentials")
|
||||
}
|
||||
|
||||
pub async fn save_credentials(data_dir: &Path, store: &CredentialStore) -> Result<()> {
|
||||
ensure_dir(data_dir).await?;
|
||||
let path = store_path(data_dir);
|
||||
let data = serde_json::to_vec(store)?;
|
||||
// Encrypt using node key
|
||||
let key = load_encryption_key(data_dir).await?;
|
||||
let encrypted = encrypt_credentials(&data, &key)?;
|
||||
fs::write(&path, encrypted).await.context("Writing credentials")
|
||||
}
|
||||
|
||||
/// Derive a 32-byte encryption key from the node's identity key via SHA-256.
|
||||
async fn load_encryption_key(data_dir: &Path) -> Result<[u8; 32]> {
|
||||
let node_key_path = data_dir.join("identity").join("node_key");
|
||||
let key_bytes = fs::read(&node_key_path).await.context("Reading node key for credential encryption")?;
|
||||
use sha2::{Sha256, Digest};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(b"archipelago-credential-store-v1");
|
||||
hasher.update(&key_bytes);
|
||||
let hash = hasher.finalize();
|
||||
let mut key = [0u8; 32];
|
||||
key.copy_from_slice(&hash);
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
fn encrypt_credentials(data: &[u8], key: &[u8; 32]) -> Result<Vec<u8>> {
|
||||
use chacha20poly1305::aead::{Aead, KeyInit};
|
||||
let nonce_bytes: [u8; 12] = rand::random();
|
||||
let cipher = chacha20poly1305::ChaCha20Poly1305::new_from_slice(key)
|
||||
.map_err(|e| anyhow::anyhow!("Cipher init: {}", e))?;
|
||||
let ciphertext = cipher
|
||||
.encrypt(
|
||||
chacha20poly1305::aead::generic_array::GenericArray::from_slice(&nonce_bytes),
|
||||
data,
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("Encryption failed: {}", e))?;
|
||||
let mut output = Vec::with_capacity(12 + ciphertext.len());
|
||||
output.extend_from_slice(&nonce_bytes);
|
||||
output.extend_from_slice(&ciphertext);
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn decrypt_credentials(data: &[u8], key: &[u8; 32]) -> Result<Vec<u8>> {
|
||||
use chacha20poly1305::aead::{Aead, KeyInit};
|
||||
if data.len() < 12 {
|
||||
anyhow::bail!("Encrypted credentials too short");
|
||||
}
|
||||
let nonce = &data[..12];
|
||||
let ciphertext = &data[12..];
|
||||
let cipher = chacha20poly1305::ChaCha20Poly1305::new_from_slice(key)
|
||||
.map_err(|e| anyhow::anyhow!("Cipher init: {}", e))?;
|
||||
cipher
|
||||
.decrypt(
|
||||
chacha20poly1305::aead::generic_array::GenericArray::from_slice(nonce),
|
||||
ciphertext,
|
||||
)
|
||||
.map_err(|_| anyhow::anyhow!("Credential decryption failed — key mismatch or corruption"))
|
||||
}
|
||||
|
||||
/// Issue a new Verifiable Credential following W3C VC Data Model 2.0.
|
||||
/// Uses Ed25519Signature2020 proof format.
|
||||
pub async fn issue_credential(
|
||||
data_dir: &Path,
|
||||
issuer_did: &str,
|
||||
subject_did: &str,
|
||||
credential_type: &str,
|
||||
claims: serde_json::Value,
|
||||
expires_at: Option<&str>,
|
||||
sign_fn: impl FnOnce(&[u8]) -> Result<String>,
|
||||
) -> Result<VerifiableCredential> {
|
||||
let id = format!("urn:uuid:{}", uuid::Uuid::new_v4());
|
||||
let issued_at = chrono::Utc::now().to_rfc3339();
|
||||
let key_id = format!("{}#key-1", issuer_did);
|
||||
|
||||
// Build the credential body for signing (without proof)
|
||||
let body = serde_json::json!({
|
||||
"@context": [VC_CONTEXT_V2, ED25519_CONTEXT],
|
||||
"id": id,
|
||||
"type": ["VerifiableCredential", credential_type],
|
||||
"issuer": issuer_did,
|
||||
"credentialSubject": {
|
||||
"id": subject_did,
|
||||
},
|
||||
"issuanceDate": issued_at,
|
||||
});
|
||||
let body_bytes = serde_json::to_vec(&body)?;
|
||||
let signature = sign_fn(&body_bytes)?;
|
||||
|
||||
let vc = VerifiableCredential {
|
||||
context: vec![VC_CONTEXT_V2.to_string(), ED25519_CONTEXT.to_string()],
|
||||
id: id.clone(),
|
||||
credential_type: vec!["VerifiableCredential".to_string(), credential_type.to_string()],
|
||||
issuer: issuer_did.to_string(),
|
||||
credential_subject: CredentialSubject {
|
||||
id: subject_did.to_string(),
|
||||
claims,
|
||||
},
|
||||
issuance_date: issued_at.clone(),
|
||||
expiration_date: expires_at.map(|s| s.to_string()),
|
||||
proof: CredentialProof {
|
||||
proof_type: "Ed25519Signature2020".to_string(),
|
||||
created: issued_at,
|
||||
verification_method: key_id,
|
||||
proof_purpose: "assertionMethod".to_string(),
|
||||
proof_value: signature,
|
||||
},
|
||||
credential_status: None,
|
||||
};
|
||||
|
||||
let mut store = load_credentials(data_dir).await?;
|
||||
debug!(id = %vc.id, "Issued W3C VC");
|
||||
store.credentials.push(vc.clone());
|
||||
save_credentials(data_dir, &store).await?;
|
||||
Ok(vc)
|
||||
}
|
||||
|
||||
/// Verify a credential's signature against the issuer DID.
|
||||
pub fn verify_credential(
|
||||
vc: &VerifiableCredential,
|
||||
verify_fn: impl FnOnce(&str, &[u8], &str) -> Result<bool>,
|
||||
) -> Result<bool> {
|
||||
// Reconstruct the body that was signed (without proof)
|
||||
let body = serde_json::json!({
|
||||
"@context": vc.context,
|
||||
"id": vc.id,
|
||||
"type": vc.credential_type,
|
||||
"issuer": vc.issuer,
|
||||
"credentialSubject": {
|
||||
"id": vc.credential_subject.id,
|
||||
},
|
||||
"issuanceDate": vc.issuance_date,
|
||||
});
|
||||
let body_bytes = serde_json::to_vec(&body)?;
|
||||
verify_fn(&vc.issuer, &body_bytes, &vc.proof.proof_value)
|
||||
}
|
||||
|
||||
/// Revoke a credential by ID.
|
||||
pub async fn revoke_credential(data_dir: &Path, credential_id: &str) -> Result<()> {
|
||||
let mut store = load_credentials(data_dir).await?;
|
||||
let vc = store
|
||||
.credentials
|
||||
.iter_mut()
|
||||
.find(|c| c.id == credential_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Credential not found: {}", credential_id))?;
|
||||
vc.credential_status = Some(CredentialStatusEntry {
|
||||
id: format!("{}#status", credential_id),
|
||||
status_type: "CredentialStatusList2021".to_string(),
|
||||
status: "revoked".to_string(),
|
||||
});
|
||||
save_credentials(data_dir, &store).await
|
||||
}
|
||||
|
||||
/// List all credentials, optionally filtering by issuer or subject DID.
|
||||
pub async fn list_credentials(
|
||||
data_dir: &Path,
|
||||
filter_did: Option<&str>,
|
||||
) -> Result<Vec<VerifiableCredential>> {
|
||||
let store = load_credentials(data_dir).await?;
|
||||
let creds = if let Some(did) = filter_did {
|
||||
store
|
||||
.credentials
|
||||
.into_iter()
|
||||
.filter(|c| c.issuer == did || c.credential_subject.id == did)
|
||||
.collect()
|
||||
} else {
|
||||
store.credentials
|
||||
};
|
||||
Ok(creds)
|
||||
}
|
||||
|
||||
/// Check if a credential is revoked.
|
||||
pub fn is_revoked(vc: &VerifiableCredential) -> bool {
|
||||
vc.credential_status
|
||||
.as_ref()
|
||||
.map_or(false, |s| s.status == "revoked")
|
||||
}
|
||||
|
||||
/// A Verifiable Presentation following W3C VC Data Model 2.0.
|
||||
/// Bundles one or more VCs with a holder proof.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VerifiablePresentation {
|
||||
#[serde(rename = "@context")]
|
||||
pub context: Vec<String>,
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub presentation_type: Vec<String>,
|
||||
pub holder: String,
|
||||
pub verifiable_credential: Vec<VerifiableCredential>,
|
||||
pub proof: CredentialProof,
|
||||
}
|
||||
|
||||
/// Create a Verifiable Presentation wrapping selected credentials.
|
||||
/// The holder signs the presentation to prove they possess the credentials.
|
||||
pub fn create_presentation(
|
||||
holder_did: &str,
|
||||
credential_ids: &[&str],
|
||||
credentials: &[VerifiableCredential],
|
||||
sign_fn: impl FnOnce(&[u8]) -> Result<String>,
|
||||
) -> Result<VerifiablePresentation> {
|
||||
let selected: Vec<VerifiableCredential> = credentials
|
||||
.iter()
|
||||
.filter(|c| credential_ids.contains(&c.id.as_str()))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
if selected.is_empty() {
|
||||
return Err(anyhow::anyhow!("No matching credentials found"));
|
||||
}
|
||||
|
||||
let id = format!("urn:uuid:{}", uuid::Uuid::new_v4());
|
||||
let created = chrono::Utc::now().to_rfc3339();
|
||||
let key_id = format!("{}#key-1", holder_did);
|
||||
|
||||
// Build the presentation body for signing (without proof)
|
||||
let body = serde_json::json!({
|
||||
"@context": [VC_CONTEXT_V2, ED25519_CONTEXT],
|
||||
"id": id,
|
||||
"type": ["VerifiablePresentation"],
|
||||
"holder": holder_did,
|
||||
"verifiableCredential": selected,
|
||||
});
|
||||
let body_bytes = serde_json::to_vec(&body)?;
|
||||
let signature = sign_fn(&body_bytes)?;
|
||||
|
||||
Ok(VerifiablePresentation {
|
||||
context: vec![VC_CONTEXT_V2.to_string(), ED25519_CONTEXT.to_string()],
|
||||
id,
|
||||
presentation_type: vec!["VerifiablePresentation".to_string()],
|
||||
holder: holder_did.to_string(),
|
||||
verifiable_credential: selected,
|
||||
proof: CredentialProof {
|
||||
proof_type: "Ed25519Signature2020".to_string(),
|
||||
created,
|
||||
verification_method: key_id,
|
||||
proof_purpose: "authentication".to_string(),
|
||||
proof_value: signature,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Verify a Verifiable Presentation: check holder's proof signature,
|
||||
/// then verify each embedded credential.
|
||||
pub fn verify_presentation(
|
||||
vp: &VerifiablePresentation,
|
||||
verify_fn: impl Fn(&str, &[u8], &str) -> Result<bool>,
|
||||
) -> Result<PresentationVerification> {
|
||||
// 1. Verify the holder's presentation proof
|
||||
let body = serde_json::json!({
|
||||
"@context": vp.context,
|
||||
"id": vp.id,
|
||||
"type": vp.presentation_type,
|
||||
"holder": vp.holder,
|
||||
"verifiableCredential": vp.verifiable_credential,
|
||||
});
|
||||
let body_bytes = serde_json::to_vec(&body)?;
|
||||
let holder_valid = verify_fn(&vp.holder, &body_bytes, &vp.proof.proof_value)?;
|
||||
|
||||
// 2. Verify each embedded credential
|
||||
let mut credential_results = Vec::new();
|
||||
for vc in &vp.verifiable_credential {
|
||||
let vc_valid = verify_credential(vc, |did, bytes, sig| verify_fn(did, bytes, sig))?;
|
||||
credential_results.push(CredentialVerificationResult {
|
||||
id: vc.id.clone(),
|
||||
valid: vc_valid,
|
||||
revoked: is_revoked(vc),
|
||||
});
|
||||
}
|
||||
|
||||
let all_valid = holder_valid && credential_results.iter().all(|r| r.valid && !r.revoked);
|
||||
|
||||
Ok(PresentationVerification {
|
||||
holder_valid,
|
||||
credentials: credential_results,
|
||||
valid: all_valid,
|
||||
})
|
||||
}
|
||||
|
||||
/// Result of verifying a Verifiable Presentation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PresentationVerification {
|
||||
pub holder_valid: bool,
|
||||
pub credentials: Vec<CredentialVerificationResult>,
|
||||
pub valid: bool,
|
||||
}
|
||||
|
||||
/// Result of verifying a single credential within a presentation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CredentialVerificationResult {
|
||||
pub id: String,
|
||||
pub valid: bool,
|
||||
pub revoked: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_credential_status_display() {
|
||||
assert_eq!(CredentialStatus::Active.to_string(), "active");
|
||||
assert_eq!(CredentialStatus::Revoked.to_string(), "revoked");
|
||||
assert_eq!(CredentialStatus::Expired.to_string(), "expired");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_credential_status_serde_roundtrip() {
|
||||
let json = serde_json::to_string(&CredentialStatus::Revoked).unwrap();
|
||||
assert_eq!(json, "\"revoked\"");
|
||||
let parsed: CredentialStatus = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed, CredentialStatus::Revoked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_credential_store_default_is_empty() {
|
||||
let store = CredentialStore::default();
|
||||
assert!(store.credentials.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_credentials_returns_empty_when_no_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = load_credentials(dir.path()).await.unwrap();
|
||||
assert!(store.credentials.is_empty());
|
||||
assert!(dir.path().join(CREDENTIALS_DIR).exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_issue_credential_w3c_format() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vc = issue_credential(
|
||||
dir.path(),
|
||||
"did:key:issuer",
|
||||
"did:key:subject",
|
||||
"NodeOperator",
|
||||
serde_json::json!({"role": "admin"}),
|
||||
Some("2027-12-31T23:59:59Z"),
|
||||
|_bytes| Ok("mock-signature".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// W3C structure checks
|
||||
assert!(vc.id.starts_with("urn:uuid:"));
|
||||
assert_eq!(vc.context[0], VC_CONTEXT_V2);
|
||||
assert_eq!(vc.context[1], ED25519_CONTEXT);
|
||||
assert_eq!(vc.credential_type, vec!["VerifiableCredential", "NodeOperator"]);
|
||||
assert_eq!(vc.issuer, "did:key:issuer");
|
||||
assert_eq!(vc.credential_subject.id, "did:key:subject");
|
||||
assert_eq!(vc.proof.proof_type, "Ed25519Signature2020");
|
||||
assert_eq!(vc.proof.proof_purpose, "assertionMethod");
|
||||
assert_eq!(vc.proof.verification_method, "did:key:issuer#key-1");
|
||||
assert_eq!(vc.proof.proof_value, "mock-signature");
|
||||
assert_eq!(vc.expiration_date, Some("2027-12-31T23:59:59Z".to_string()));
|
||||
assert!(vc.credential_status.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_issue_credential_serializes_as_jsonld() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vc = issue_credential(
|
||||
dir.path(),
|
||||
"did:key:issuer",
|
||||
"did:key:subject",
|
||||
"TestCred",
|
||||
serde_json::json!({"level": "gold"}),
|
||||
None,
|
||||
|_| Ok("sig".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let json = serde_json::to_value(&vc).unwrap();
|
||||
// Must have @context
|
||||
assert!(json["@context"].is_array());
|
||||
// Must have type array
|
||||
assert!(json["type"].is_array());
|
||||
// Must have credentialSubject
|
||||
assert!(json["credentialSubject"]["id"].is_string());
|
||||
// Must have proof
|
||||
assert_eq!(json["proof"]["type"], "Ed25519Signature2020");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_save_and_load_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
issue_credential(
|
||||
dir.path(),
|
||||
"did:key:a",
|
||||
"did:key:b",
|
||||
"Type1",
|
||||
serde_json::json!({"k": "v"}),
|
||||
None,
|
||||
|_| Ok("s1".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let loaded = load_credentials(dir.path()).await.unwrap();
|
||||
assert_eq!(loaded.credentials.len(), 1);
|
||||
assert_eq!(loaded.credentials[0].credential_type[1], "Type1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_issue_credential_sign_fn_failure_propagates() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let result = issue_credential(
|
||||
dir.path(),
|
||||
"did:key:issuer",
|
||||
"did:key:subject",
|
||||
"TestCredential",
|
||||
serde_json::json!({}),
|
||||
None,
|
||||
|_bytes| Err(anyhow::anyhow!("Signing failed")),
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("Signing failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_credential_calls_verify_fn() {
|
||||
let vc = VerifiableCredential {
|
||||
context: vec![VC_CONTEXT_V2.to_string()],
|
||||
id: "urn:uuid:test".to_string(),
|
||||
credential_type: vec!["VerifiableCredential".to_string(), "Test".to_string()],
|
||||
issuer: "did:key:issuer".to_string(),
|
||||
credential_subject: CredentialSubject {
|
||||
id: "did:key:subject".to_string(),
|
||||
claims: serde_json::json!({"foo": "bar"}),
|
||||
},
|
||||
issuance_date: "2025-06-01T00:00:00Z".to_string(),
|
||||
expiration_date: None,
|
||||
proof: CredentialProof {
|
||||
proof_type: "Ed25519Signature2020".to_string(),
|
||||
created: "2025-06-01T00:00:00Z".to_string(),
|
||||
verification_method: "did:key:issuer#key-1".to_string(),
|
||||
proof_purpose: "assertionMethod".to_string(),
|
||||
proof_value: "valid-sig".to_string(),
|
||||
},
|
||||
credential_status: None,
|
||||
};
|
||||
|
||||
let result = verify_credential(&vc, |issuer, _data, sig| {
|
||||
assert_eq!(issuer, "did:key:issuer");
|
||||
assert_eq!(sig, "valid-sig");
|
||||
Ok(true)
|
||||
})
|
||||
.unwrap();
|
||||
assert!(result);
|
||||
|
||||
let result = verify_credential(&vc, |_issuer, _data, _sig| Ok(false)).unwrap();
|
||||
assert!(!result);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_revoke_credential() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vc = issue_credential(
|
||||
dir.path(),
|
||||
"did:key:issuer",
|
||||
"did:key:subject",
|
||||
"Revocable",
|
||||
serde_json::json!({}),
|
||||
None,
|
||||
|_| Ok("sig".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!is_revoked(&vc));
|
||||
|
||||
revoke_credential(dir.path(), &vc.id).await.unwrap();
|
||||
|
||||
let store = load_credentials(dir.path()).await.unwrap();
|
||||
assert!(is_revoked(&store.credentials[0]));
|
||||
assert_eq!(
|
||||
store.credentials[0].credential_status.as_ref().unwrap().status,
|
||||
"revoked"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_revoke_nonexistent_credential_fails() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let result = revoke_credential(dir.path(), "urn:uuid:does-not-exist").await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("Credential not found"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_credentials_no_filter() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
issue_credential(
|
||||
dir.path(), "did:key:a", "did:key:b", "Type1",
|
||||
serde_json::json!({}), None, |_| Ok("s1".to_string()),
|
||||
).await.unwrap();
|
||||
issue_credential(
|
||||
dir.path(), "did:key:c", "did:key:d", "Type2",
|
||||
serde_json::json!({}), None, |_| Ok("s2".to_string()),
|
||||
).await.unwrap();
|
||||
|
||||
let all = list_credentials(dir.path(), None).await.unwrap();
|
||||
assert_eq!(all.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_credentials_filter_by_did() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
issue_credential(
|
||||
dir.path(), "did:key:alice", "did:key:bob", "Type1",
|
||||
serde_json::json!({}), None, |_| Ok("s1".to_string()),
|
||||
).await.unwrap();
|
||||
issue_credential(
|
||||
dir.path(), "did:key:carol", "did:key:alice", "Type2",
|
||||
serde_json::json!({}), None, |_| Ok("s2".to_string()),
|
||||
).await.unwrap();
|
||||
issue_credential(
|
||||
dir.path(), "did:key:carol", "did:key:dave", "Type3",
|
||||
serde_json::json!({}), None, |_| Ok("s3".to_string()),
|
||||
).await.unwrap();
|
||||
|
||||
let filtered = list_credentials(dir.path(), Some("did:key:alice")).await.unwrap();
|
||||
assert_eq!(filtered.len(), 2);
|
||||
}
|
||||
|
||||
fn make_test_vc(id: &str, issuer: &str, subject: &str) -> VerifiableCredential {
|
||||
VerifiableCredential {
|
||||
context: vec![VC_CONTEXT_V2.to_string(), ED25519_CONTEXT.to_string()],
|
||||
id: id.to_string(),
|
||||
credential_type: vec!["VerifiableCredential".to_string(), "Test".to_string()],
|
||||
issuer: issuer.to_string(),
|
||||
credential_subject: CredentialSubject {
|
||||
id: subject.to_string(),
|
||||
claims: serde_json::json!({"role": "tester"}),
|
||||
},
|
||||
issuance_date: "2026-01-01T00:00:00Z".to_string(),
|
||||
expiration_date: None,
|
||||
proof: CredentialProof {
|
||||
proof_type: "Ed25519Signature2020".to_string(),
|
||||
created: "2026-01-01T00:00:00Z".to_string(),
|
||||
verification_method: format!("{}#key-1", issuer),
|
||||
proof_purpose: "assertionMethod".to_string(),
|
||||
proof_value: "mock-sig".to_string(),
|
||||
},
|
||||
credential_status: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_presentation() {
|
||||
let creds = vec![
|
||||
make_test_vc("urn:uuid:cred1", "did:key:issuer1", "did:key:holder"),
|
||||
make_test_vc("urn:uuid:cred2", "did:key:issuer2", "did:key:holder"),
|
||||
];
|
||||
|
||||
let vp = create_presentation(
|
||||
"did:key:holder",
|
||||
&["urn:uuid:cred1"],
|
||||
&creds,
|
||||
|_bytes| Ok("presentation-sig".to_string()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(vp.id.starts_with("urn:uuid:"));
|
||||
assert_eq!(vp.presentation_type, vec!["VerifiablePresentation"]);
|
||||
assert_eq!(vp.holder, "did:key:holder");
|
||||
assert_eq!(vp.verifiable_credential.len(), 1);
|
||||
assert_eq!(vp.verifiable_credential[0].id, "urn:uuid:cred1");
|
||||
assert_eq!(vp.proof.proof_type, "Ed25519Signature2020");
|
||||
assert_eq!(vp.proof.proof_purpose, "authentication");
|
||||
assert_eq!(vp.proof.proof_value, "presentation-sig");
|
||||
assert_eq!(vp.context[0], VC_CONTEXT_V2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_presentation_multiple_credentials() {
|
||||
let creds = vec![
|
||||
make_test_vc("urn:uuid:c1", "did:key:i1", "did:key:holder"),
|
||||
make_test_vc("urn:uuid:c2", "did:key:i2", "did:key:holder"),
|
||||
make_test_vc("urn:uuid:c3", "did:key:i3", "did:key:other"),
|
||||
];
|
||||
|
||||
let vp = create_presentation(
|
||||
"did:key:holder",
|
||||
&["urn:uuid:c1", "urn:uuid:c2"],
|
||||
&creds,
|
||||
|_| Ok("sig".to_string()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(vp.verifiable_credential.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_presentation_no_matching_credentials() {
|
||||
let creds = vec![make_test_vc("urn:uuid:c1", "did:key:i", "did:key:h")];
|
||||
|
||||
let result = create_presentation(
|
||||
"did:key:holder",
|
||||
&["urn:uuid:nonexistent"],
|
||||
&creds,
|
||||
|_| Ok("sig".to_string()),
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("No matching credentials"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_presentation_all_valid() {
|
||||
let creds = vec![
|
||||
make_test_vc("urn:uuid:c1", "did:key:issuer", "did:key:holder"),
|
||||
];
|
||||
|
||||
let vp = create_presentation(
|
||||
"did:key:holder",
|
||||
&["urn:uuid:c1"],
|
||||
&creds,
|
||||
|_| Ok("vp-sig".to_string()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = verify_presentation(&vp, |_did, _bytes, _sig| Ok(true)).unwrap();
|
||||
assert!(result.holder_valid);
|
||||
assert!(result.valid);
|
||||
assert_eq!(result.credentials.len(), 1);
|
||||
assert!(result.credentials[0].valid);
|
||||
assert!(!result.credentials[0].revoked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_presentation_holder_invalid() {
|
||||
let creds = vec![
|
||||
make_test_vc("urn:uuid:c1", "did:key:issuer", "did:key:holder"),
|
||||
];
|
||||
|
||||
let vp = create_presentation(
|
||||
"did:key:holder",
|
||||
&["urn:uuid:c1"],
|
||||
&creds,
|
||||
|_| Ok("bad-sig".to_string()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Holder verification fails, credential verification succeeds
|
||||
let result = verify_presentation(&vp, |did, _bytes, _sig| {
|
||||
Ok(did != "did:key:holder")
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert!(!result.holder_valid);
|
||||
assert!(!result.valid); // Overall invalid because holder proof failed
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_presentation_serializes_as_jsonld() {
|
||||
let creds = vec![
|
||||
make_test_vc("urn:uuid:c1", "did:key:issuer", "did:key:holder"),
|
||||
];
|
||||
|
||||
let vp = create_presentation(
|
||||
"did:key:holder",
|
||||
&["urn:uuid:c1"],
|
||||
&creds,
|
||||
|_| Ok("sig".to_string()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let json = serde_json::to_value(&vp).unwrap();
|
||||
assert!(json["@context"].is_array());
|
||||
assert!(json["type"].is_array());
|
||||
assert_eq!(json["type"][0], "VerifiablePresentation");
|
||||
assert!(json["holder"].is_string());
|
||||
assert!(json["verifiableCredential"].is_array());
|
||||
assert!(json["proof"]["type"].is_string());
|
||||
}
|
||||
}
|
||||
12
core/archipelago/src/credentials/mod.rs
Normal file
12
core/archipelago/src/credentials/mod.rs
Normal file
@ -0,0 +1,12 @@
|
||||
//! Verifiable Credentials (VC) management following W3C VC Data Model 2.0.
|
||||
//! Implements JSON-LD @context, Ed25519Signature2020 proof format.
|
||||
//! See: https://www.w3.org/TR/vc-data-model-2.0/
|
||||
|
||||
mod types;
|
||||
mod store;
|
||||
mod operations;
|
||||
mod presentation;
|
||||
|
||||
pub use store::load_credentials;
|
||||
pub use operations::{issue_credential, verify_credential, revoke_credential, list_credentials, is_revoked};
|
||||
pub use presentation::{VerifiablePresentation, create_presentation, verify_presentation};
|
||||
322
core/archipelago/src/credentials/operations.rs
Normal file
322
core/archipelago/src/credentials/operations.rs
Normal file
@ -0,0 +1,322 @@
|
||||
use anyhow::Result;
|
||||
use std::path::Path;
|
||||
use tracing::debug;
|
||||
|
||||
use super::types::*;
|
||||
use super::store::{load_credentials, save_credentials};
|
||||
|
||||
/// Issue a new Verifiable Credential following W3C VC Data Model 2.0.
|
||||
/// Uses Ed25519Signature2020 proof format.
|
||||
pub async fn issue_credential(
|
||||
data_dir: &Path,
|
||||
issuer_did: &str,
|
||||
subject_did: &str,
|
||||
credential_type: &str,
|
||||
claims: serde_json::Value,
|
||||
expires_at: Option<&str>,
|
||||
sign_fn: impl FnOnce(&[u8]) -> Result<String>,
|
||||
) -> Result<VerifiableCredential> {
|
||||
let id = format!("urn:uuid:{}", uuid::Uuid::new_v4());
|
||||
let issued_at = chrono::Utc::now().to_rfc3339();
|
||||
let key_id = format!("{}#key-1", issuer_did);
|
||||
|
||||
// Build the credential body for signing (without proof)
|
||||
let body = serde_json::json!({
|
||||
"@context": [VC_CONTEXT_V2, ED25519_CONTEXT],
|
||||
"id": id,
|
||||
"type": ["VerifiableCredential", credential_type],
|
||||
"issuer": issuer_did,
|
||||
"credentialSubject": {
|
||||
"id": subject_did,
|
||||
},
|
||||
"issuanceDate": issued_at,
|
||||
});
|
||||
let body_bytes = serde_json::to_vec(&body)?;
|
||||
let signature = sign_fn(&body_bytes)?;
|
||||
|
||||
let vc = VerifiableCredential {
|
||||
context: vec![VC_CONTEXT_V2.to_string(), ED25519_CONTEXT.to_string()],
|
||||
id: id.clone(),
|
||||
credential_type: vec!["VerifiableCredential".to_string(), credential_type.to_string()],
|
||||
issuer: issuer_did.to_string(),
|
||||
credential_subject: CredentialSubject {
|
||||
id: subject_did.to_string(),
|
||||
claims,
|
||||
},
|
||||
issuance_date: issued_at.clone(),
|
||||
expiration_date: expires_at.map(|s| s.to_string()),
|
||||
proof: CredentialProof {
|
||||
proof_type: "Ed25519Signature2020".to_string(),
|
||||
created: issued_at,
|
||||
verification_method: key_id,
|
||||
proof_purpose: "assertionMethod".to_string(),
|
||||
proof_value: signature,
|
||||
},
|
||||
credential_status: None,
|
||||
};
|
||||
|
||||
let mut store = load_credentials(data_dir).await?;
|
||||
debug!(id = %vc.id, "Issued W3C VC");
|
||||
store.credentials.push(vc.clone());
|
||||
save_credentials(data_dir, &store).await?;
|
||||
Ok(vc)
|
||||
}
|
||||
|
||||
/// Verify a credential's signature against the issuer DID.
|
||||
pub fn verify_credential(
|
||||
vc: &VerifiableCredential,
|
||||
verify_fn: impl FnOnce(&str, &[u8], &str) -> Result<bool>,
|
||||
) -> Result<bool> {
|
||||
let body = serde_json::json!({
|
||||
"@context": vc.context,
|
||||
"id": vc.id,
|
||||
"type": vc.credential_type,
|
||||
"issuer": vc.issuer,
|
||||
"credentialSubject": {
|
||||
"id": vc.credential_subject.id,
|
||||
},
|
||||
"issuanceDate": vc.issuance_date,
|
||||
});
|
||||
let body_bytes = serde_json::to_vec(&body)?;
|
||||
verify_fn(&vc.issuer, &body_bytes, &vc.proof.proof_value)
|
||||
}
|
||||
|
||||
/// Revoke a credential by ID.
|
||||
pub async fn revoke_credential(data_dir: &Path, credential_id: &str) -> Result<()> {
|
||||
let mut store = load_credentials(data_dir).await?;
|
||||
let vc = store
|
||||
.credentials
|
||||
.iter_mut()
|
||||
.find(|c| c.id == credential_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Credential not found: {}", credential_id))?;
|
||||
vc.credential_status = Some(CredentialStatusEntry {
|
||||
id: format!("{}#status", credential_id),
|
||||
status_type: "CredentialStatusList2021".to_string(),
|
||||
status: "revoked".to_string(),
|
||||
});
|
||||
save_credentials(data_dir, &store).await
|
||||
}
|
||||
|
||||
/// List all credentials, optionally filtering by issuer or subject DID.
|
||||
pub async fn list_credentials(
|
||||
data_dir: &Path,
|
||||
filter_did: Option<&str>,
|
||||
) -> Result<Vec<VerifiableCredential>> {
|
||||
let store = load_credentials(data_dir).await?;
|
||||
let creds = if let Some(did) = filter_did {
|
||||
store
|
||||
.credentials
|
||||
.into_iter()
|
||||
.filter(|c| c.issuer == did || c.credential_subject.id == did)
|
||||
.collect()
|
||||
} else {
|
||||
store.credentials
|
||||
};
|
||||
Ok(creds)
|
||||
}
|
||||
|
||||
/// Check if a credential is revoked.
|
||||
pub fn is_revoked(vc: &VerifiableCredential) -> bool {
|
||||
vc.credential_status
|
||||
.as_ref()
|
||||
.map_or(false, |s| s.status == "revoked")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_issue_credential_w3c_format() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vc = issue_credential(
|
||||
dir.path(),
|
||||
"did:key:issuer",
|
||||
"did:key:subject",
|
||||
"NodeOperator",
|
||||
serde_json::json!({"role": "admin"}),
|
||||
Some("2027-12-31T23:59:59Z"),
|
||||
|_bytes| Ok("mock-signature".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(vc.id.starts_with("urn:uuid:"));
|
||||
assert_eq!(vc.context[0], VC_CONTEXT_V2);
|
||||
assert_eq!(vc.context[1], ED25519_CONTEXT);
|
||||
assert_eq!(vc.credential_type, vec!["VerifiableCredential", "NodeOperator"]);
|
||||
assert_eq!(vc.issuer, "did:key:issuer");
|
||||
assert_eq!(vc.credential_subject.id, "did:key:subject");
|
||||
assert_eq!(vc.proof.proof_type, "Ed25519Signature2020");
|
||||
assert_eq!(vc.proof.proof_purpose, "assertionMethod");
|
||||
assert_eq!(vc.proof.verification_method, "did:key:issuer#key-1");
|
||||
assert_eq!(vc.proof.proof_value, "mock-signature");
|
||||
assert_eq!(vc.expiration_date, Some("2027-12-31T23:59:59Z".to_string()));
|
||||
assert!(vc.credential_status.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_issue_credential_serializes_as_jsonld() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vc = issue_credential(
|
||||
dir.path(),
|
||||
"did:key:issuer",
|
||||
"did:key:subject",
|
||||
"TestCred",
|
||||
serde_json::json!({"level": "gold"}),
|
||||
None,
|
||||
|_| Ok("sig".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let json = serde_json::to_value(&vc).unwrap();
|
||||
assert!(json["@context"].is_array());
|
||||
assert!(json["type"].is_array());
|
||||
assert!(json["credentialSubject"]["id"].is_string());
|
||||
assert_eq!(json["proof"]["type"], "Ed25519Signature2020");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_save_and_load_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
issue_credential(
|
||||
dir.path(),
|
||||
"did:key:a",
|
||||
"did:key:b",
|
||||
"Type1",
|
||||
serde_json::json!({"k": "v"}),
|
||||
None,
|
||||
|_| Ok("s1".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let loaded = load_credentials(dir.path()).await.unwrap();
|
||||
assert_eq!(loaded.credentials.len(), 1);
|
||||
assert_eq!(loaded.credentials[0].credential_type[1], "Type1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_issue_credential_sign_fn_failure_propagates() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let result = issue_credential(
|
||||
dir.path(),
|
||||
"did:key:issuer",
|
||||
"did:key:subject",
|
||||
"TestCredential",
|
||||
serde_json::json!({}),
|
||||
None,
|
||||
|_bytes| Err(anyhow::anyhow!("Signing failed")),
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("Signing failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_credential_calls_verify_fn() {
|
||||
let vc = VerifiableCredential {
|
||||
context: vec![VC_CONTEXT_V2.to_string()],
|
||||
id: "urn:uuid:test".to_string(),
|
||||
credential_type: vec!["VerifiableCredential".to_string(), "Test".to_string()],
|
||||
issuer: "did:key:issuer".to_string(),
|
||||
credential_subject: CredentialSubject {
|
||||
id: "did:key:subject".to_string(),
|
||||
claims: serde_json::json!({"foo": "bar"}),
|
||||
},
|
||||
issuance_date: "2025-06-01T00:00:00Z".to_string(),
|
||||
expiration_date: None,
|
||||
proof: CredentialProof {
|
||||
proof_type: "Ed25519Signature2020".to_string(),
|
||||
created: "2025-06-01T00:00:00Z".to_string(),
|
||||
verification_method: "did:key:issuer#key-1".to_string(),
|
||||
proof_purpose: "assertionMethod".to_string(),
|
||||
proof_value: "valid-sig".to_string(),
|
||||
},
|
||||
credential_status: None,
|
||||
};
|
||||
|
||||
let result = verify_credential(&vc, |issuer, _data, sig| {
|
||||
assert_eq!(issuer, "did:key:issuer");
|
||||
assert_eq!(sig, "valid-sig");
|
||||
Ok(true)
|
||||
})
|
||||
.unwrap();
|
||||
assert!(result);
|
||||
|
||||
let result = verify_credential(&vc, |_issuer, _data, _sig| Ok(false)).unwrap();
|
||||
assert!(!result);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_revoke_credential() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vc = issue_credential(
|
||||
dir.path(),
|
||||
"did:key:issuer",
|
||||
"did:key:subject",
|
||||
"Revocable",
|
||||
serde_json::json!({}),
|
||||
None,
|
||||
|_| Ok("sig".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!is_revoked(&vc));
|
||||
|
||||
revoke_credential(dir.path(), &vc.id).await.unwrap();
|
||||
|
||||
let store = load_credentials(dir.path()).await.unwrap();
|
||||
assert!(is_revoked(&store.credentials[0]));
|
||||
assert_eq!(
|
||||
store.credentials[0].credential_status.as_ref().unwrap().status,
|
||||
"revoked"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_revoke_nonexistent_credential_fails() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let result = revoke_credential(dir.path(), "urn:uuid:does-not-exist").await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("Credential not found"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_credentials_no_filter() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
issue_credential(
|
||||
dir.path(), "did:key:a", "did:key:b", "Type1",
|
||||
serde_json::json!({}), None, |_| Ok("s1".to_string()),
|
||||
).await.unwrap();
|
||||
issue_credential(
|
||||
dir.path(), "did:key:c", "did:key:d", "Type2",
|
||||
serde_json::json!({}), None, |_| Ok("s2".to_string()),
|
||||
).await.unwrap();
|
||||
|
||||
let all = list_credentials(dir.path(), None).await.unwrap();
|
||||
assert_eq!(all.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_credentials_filter_by_did() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
issue_credential(
|
||||
dir.path(), "did:key:alice", "did:key:bob", "Type1",
|
||||
serde_json::json!({}), None, |_| Ok("s1".to_string()),
|
||||
).await.unwrap();
|
||||
issue_credential(
|
||||
dir.path(), "did:key:carol", "did:key:alice", "Type2",
|
||||
serde_json::json!({}), None, |_| Ok("s2".to_string()),
|
||||
).await.unwrap();
|
||||
issue_credential(
|
||||
dir.path(), "did:key:carol", "did:key:dave", "Type3",
|
||||
serde_json::json!({}), None, |_| Ok("s3".to_string()),
|
||||
).await.unwrap();
|
||||
|
||||
let filtered = list_credentials(dir.path(), Some("did:key:alice")).await.unwrap();
|
||||
assert_eq!(filtered.len(), 2);
|
||||
}
|
||||
}
|
||||
274
core/archipelago/src/credentials/presentation.rs
Normal file
274
core/archipelago/src/credentials/presentation.rs
Normal file
@ -0,0 +1,274 @@
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::types::*;
|
||||
use super::operations::{verify_credential, is_revoked};
|
||||
|
||||
/// A Verifiable Presentation following W3C VC Data Model 2.0.
|
||||
/// Bundles one or more VCs with a holder proof.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VerifiablePresentation {
|
||||
#[serde(rename = "@context")]
|
||||
pub context: Vec<String>,
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub presentation_type: Vec<String>,
|
||||
pub holder: String,
|
||||
pub verifiable_credential: Vec<VerifiableCredential>,
|
||||
pub proof: CredentialProof,
|
||||
}
|
||||
|
||||
/// Create a Verifiable Presentation wrapping selected credentials.
|
||||
/// The holder signs the presentation to prove they possess the credentials.
|
||||
pub fn create_presentation(
|
||||
holder_did: &str,
|
||||
credential_ids: &[&str],
|
||||
credentials: &[VerifiableCredential],
|
||||
sign_fn: impl FnOnce(&[u8]) -> Result<String>,
|
||||
) -> Result<VerifiablePresentation> {
|
||||
let selected: Vec<VerifiableCredential> = credentials
|
||||
.iter()
|
||||
.filter(|c| credential_ids.contains(&c.id.as_str()))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
if selected.is_empty() {
|
||||
return Err(anyhow::anyhow!("No matching credentials found"));
|
||||
}
|
||||
|
||||
let id = format!("urn:uuid:{}", uuid::Uuid::new_v4());
|
||||
let created = chrono::Utc::now().to_rfc3339();
|
||||
let key_id = format!("{}#key-1", holder_did);
|
||||
|
||||
let body = serde_json::json!({
|
||||
"@context": [VC_CONTEXT_V2, ED25519_CONTEXT],
|
||||
"id": id,
|
||||
"type": ["VerifiablePresentation"],
|
||||
"holder": holder_did,
|
||||
"verifiableCredential": selected,
|
||||
});
|
||||
let body_bytes = serde_json::to_vec(&body)?;
|
||||
let signature = sign_fn(&body_bytes)?;
|
||||
|
||||
Ok(VerifiablePresentation {
|
||||
context: vec![VC_CONTEXT_V2.to_string(), ED25519_CONTEXT.to_string()],
|
||||
id,
|
||||
presentation_type: vec!["VerifiablePresentation".to_string()],
|
||||
holder: holder_did.to_string(),
|
||||
verifiable_credential: selected,
|
||||
proof: CredentialProof {
|
||||
proof_type: "Ed25519Signature2020".to_string(),
|
||||
created,
|
||||
verification_method: key_id,
|
||||
proof_purpose: "authentication".to_string(),
|
||||
proof_value: signature,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Verify a Verifiable Presentation: check holder's proof signature,
|
||||
/// then verify each embedded credential.
|
||||
pub fn verify_presentation(
|
||||
vp: &VerifiablePresentation,
|
||||
verify_fn: impl Fn(&str, &[u8], &str) -> Result<bool>,
|
||||
) -> Result<PresentationVerification> {
|
||||
let body = serde_json::json!({
|
||||
"@context": vp.context,
|
||||
"id": vp.id,
|
||||
"type": vp.presentation_type,
|
||||
"holder": vp.holder,
|
||||
"verifiableCredential": vp.verifiable_credential,
|
||||
});
|
||||
let body_bytes = serde_json::to_vec(&body)?;
|
||||
let holder_valid = verify_fn(&vp.holder, &body_bytes, &vp.proof.proof_value)?;
|
||||
|
||||
let mut credential_results = Vec::new();
|
||||
for vc in &vp.verifiable_credential {
|
||||
let vc_valid = verify_credential(vc, |did, bytes, sig| verify_fn(did, bytes, sig))?;
|
||||
credential_results.push(CredentialVerificationResult {
|
||||
id: vc.id.clone(),
|
||||
valid: vc_valid,
|
||||
revoked: is_revoked(vc),
|
||||
});
|
||||
}
|
||||
|
||||
let all_valid = holder_valid && credential_results.iter().all(|r| r.valid && !r.revoked);
|
||||
|
||||
Ok(PresentationVerification {
|
||||
holder_valid,
|
||||
credentials: credential_results,
|
||||
valid: all_valid,
|
||||
})
|
||||
}
|
||||
|
||||
/// Result of verifying a Verifiable Presentation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PresentationVerification {
|
||||
pub holder_valid: bool,
|
||||
pub credentials: Vec<CredentialVerificationResult>,
|
||||
pub valid: bool,
|
||||
}
|
||||
|
||||
/// Result of verifying a single credential within a presentation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CredentialVerificationResult {
|
||||
pub id: String,
|
||||
pub valid: bool,
|
||||
pub revoked: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_test_vc(id: &str, issuer: &str, subject: &str) -> VerifiableCredential {
|
||||
VerifiableCredential {
|
||||
context: vec![VC_CONTEXT_V2.to_string(), ED25519_CONTEXT.to_string()],
|
||||
id: id.to_string(),
|
||||
credential_type: vec!["VerifiableCredential".to_string(), "Test".to_string()],
|
||||
issuer: issuer.to_string(),
|
||||
credential_subject: CredentialSubject {
|
||||
id: subject.to_string(),
|
||||
claims: serde_json::json!({"role": "tester"}),
|
||||
},
|
||||
issuance_date: "2026-01-01T00:00:00Z".to_string(),
|
||||
expiration_date: None,
|
||||
proof: CredentialProof {
|
||||
proof_type: "Ed25519Signature2020".to_string(),
|
||||
created: "2026-01-01T00:00:00Z".to_string(),
|
||||
verification_method: format!("{}#key-1", issuer),
|
||||
proof_purpose: "assertionMethod".to_string(),
|
||||
proof_value: "mock-sig".to_string(),
|
||||
},
|
||||
credential_status: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_presentation() {
|
||||
let creds = vec![
|
||||
make_test_vc("urn:uuid:cred1", "did:key:issuer1", "did:key:holder"),
|
||||
make_test_vc("urn:uuid:cred2", "did:key:issuer2", "did:key:holder"),
|
||||
];
|
||||
|
||||
let vp = create_presentation(
|
||||
"did:key:holder",
|
||||
&["urn:uuid:cred1"],
|
||||
&creds,
|
||||
|_bytes| Ok("presentation-sig".to_string()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(vp.id.starts_with("urn:uuid:"));
|
||||
assert_eq!(vp.presentation_type, vec!["VerifiablePresentation"]);
|
||||
assert_eq!(vp.holder, "did:key:holder");
|
||||
assert_eq!(vp.verifiable_credential.len(), 1);
|
||||
assert_eq!(vp.verifiable_credential[0].id, "urn:uuid:cred1");
|
||||
assert_eq!(vp.proof.proof_type, "Ed25519Signature2020");
|
||||
assert_eq!(vp.proof.proof_purpose, "authentication");
|
||||
assert_eq!(vp.proof.proof_value, "presentation-sig");
|
||||
assert_eq!(vp.context[0], VC_CONTEXT_V2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_presentation_multiple_credentials() {
|
||||
let creds = vec![
|
||||
make_test_vc("urn:uuid:c1", "did:key:i1", "did:key:holder"),
|
||||
make_test_vc("urn:uuid:c2", "did:key:i2", "did:key:holder"),
|
||||
make_test_vc("urn:uuid:c3", "did:key:i3", "did:key:other"),
|
||||
];
|
||||
|
||||
let vp = create_presentation(
|
||||
"did:key:holder",
|
||||
&["urn:uuid:c1", "urn:uuid:c2"],
|
||||
&creds,
|
||||
|_| Ok("sig".to_string()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(vp.verifiable_credential.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_presentation_no_matching_credentials() {
|
||||
let creds = vec![make_test_vc("urn:uuid:c1", "did:key:i", "did:key:h")];
|
||||
|
||||
let result = create_presentation(
|
||||
"did:key:holder",
|
||||
&["urn:uuid:nonexistent"],
|
||||
&creds,
|
||||
|_| Ok("sig".to_string()),
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("No matching credentials"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_presentation_all_valid() {
|
||||
let creds = vec![
|
||||
make_test_vc("urn:uuid:c1", "did:key:issuer", "did:key:holder"),
|
||||
];
|
||||
|
||||
let vp = create_presentation(
|
||||
"did:key:holder",
|
||||
&["urn:uuid:c1"],
|
||||
&creds,
|
||||
|_| Ok("vp-sig".to_string()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = verify_presentation(&vp, |_did, _bytes, _sig| Ok(true)).unwrap();
|
||||
assert!(result.holder_valid);
|
||||
assert!(result.valid);
|
||||
assert_eq!(result.credentials.len(), 1);
|
||||
assert!(result.credentials[0].valid);
|
||||
assert!(!result.credentials[0].revoked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_presentation_holder_invalid() {
|
||||
let creds = vec![
|
||||
make_test_vc("urn:uuid:c1", "did:key:issuer", "did:key:holder"),
|
||||
];
|
||||
|
||||
let vp = create_presentation(
|
||||
"did:key:holder",
|
||||
&["urn:uuid:c1"],
|
||||
&creds,
|
||||
|_| Ok("bad-sig".to_string()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = verify_presentation(&vp, |did, _bytes, _sig| {
|
||||
Ok(did != "did:key:holder")
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert!(!result.holder_valid);
|
||||
assert!(!result.valid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_presentation_serializes_as_jsonld() {
|
||||
let creds = vec![
|
||||
make_test_vc("urn:uuid:c1", "did:key:issuer", "did:key:holder"),
|
||||
];
|
||||
|
||||
let vp = create_presentation(
|
||||
"did:key:holder",
|
||||
&["urn:uuid:c1"],
|
||||
&creds,
|
||||
|_| Ok("sig".to_string()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let json = serde_json::to_value(&vp).unwrap();
|
||||
assert!(json["@context"].is_array());
|
||||
assert!(json["type"].is_array());
|
||||
assert_eq!(json["type"][0], "VerifiablePresentation");
|
||||
assert!(json["holder"].is_string());
|
||||
assert!(json["verifiableCredential"].is_array());
|
||||
assert!(json["proof"]["type"].is_string());
|
||||
}
|
||||
}
|
||||
106
core/archipelago/src/credentials/store.rs
Normal file
106
core/archipelago/src/credentials/store.rs
Normal file
@ -0,0 +1,106 @@
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::Path;
|
||||
use tokio::fs;
|
||||
|
||||
use super::types::{CredentialStore, CREDENTIALS_DIR};
|
||||
|
||||
async fn ensure_dir(data_dir: &Path) -> Result<()> {
|
||||
let dir = data_dir.join(CREDENTIALS_DIR);
|
||||
if !dir.exists() {
|
||||
fs::create_dir_all(&dir).await.context("Creating credentials dir")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn store_path(data_dir: &Path) -> std::path::PathBuf {
|
||||
data_dir.join(CREDENTIALS_DIR).join("credentials.json")
|
||||
}
|
||||
|
||||
pub async fn load_credentials(data_dir: &Path) -> Result<CredentialStore> {
|
||||
ensure_dir(data_dir).await?;
|
||||
let path = store_path(data_dir);
|
||||
if !path.exists() {
|
||||
return Ok(CredentialStore::default());
|
||||
}
|
||||
let raw = fs::read(&path).await.context("Reading credentials")?;
|
||||
// Detect plaintext JSON (migration path) vs encrypted binary
|
||||
if raw.first().map_or(false, |b| *b == b'[' || *b == b'{') {
|
||||
let data = String::from_utf8(raw).context("UTF-8 credentials")?;
|
||||
return serde_json::from_str(&data).context("Parsing credentials");
|
||||
}
|
||||
// Encrypted: decrypt using node key
|
||||
let key = load_encryption_key(data_dir).await?;
|
||||
let plaintext = decrypt_credentials(&raw, &key)?;
|
||||
serde_json::from_slice(&plaintext).context("Parsing decrypted credentials")
|
||||
}
|
||||
|
||||
pub async fn save_credentials(data_dir: &Path, store: &CredentialStore) -> Result<()> {
|
||||
ensure_dir(data_dir).await?;
|
||||
let path = store_path(data_dir);
|
||||
let data = serde_json::to_vec(store)?;
|
||||
// Encrypt using node key
|
||||
let key = load_encryption_key(data_dir).await?;
|
||||
let encrypted = encrypt_credentials(&data, &key)?;
|
||||
fs::write(&path, encrypted).await.context("Writing credentials")
|
||||
}
|
||||
|
||||
/// Derive a 32-byte encryption key from the node's identity key via SHA-256.
|
||||
async fn load_encryption_key(data_dir: &Path) -> Result<[u8; 32]> {
|
||||
let node_key_path = data_dir.join("identity").join("node_key");
|
||||
let key_bytes = fs::read(&node_key_path).await.context("Reading node key for credential encryption")?;
|
||||
use sha2::{Sha256, Digest};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(b"archipelago-credential-store-v1");
|
||||
hasher.update(&key_bytes);
|
||||
let hash = hasher.finalize();
|
||||
let mut key = [0u8; 32];
|
||||
key.copy_from_slice(&hash);
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
fn encrypt_credentials(data: &[u8], key: &[u8; 32]) -> Result<Vec<u8>> {
|
||||
use chacha20poly1305::aead::{Aead, KeyInit};
|
||||
let nonce_bytes: [u8; 12] = rand::random();
|
||||
let cipher = chacha20poly1305::ChaCha20Poly1305::new_from_slice(key)
|
||||
.map_err(|e| anyhow::anyhow!("Cipher init: {}", e))?;
|
||||
let ciphertext = cipher
|
||||
.encrypt(
|
||||
chacha20poly1305::aead::generic_array::GenericArray::from_slice(&nonce_bytes),
|
||||
data,
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("Encryption failed: {}", e))?;
|
||||
let mut output = Vec::with_capacity(12 + ciphertext.len());
|
||||
output.extend_from_slice(&nonce_bytes);
|
||||
output.extend_from_slice(&ciphertext);
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn decrypt_credentials(data: &[u8], key: &[u8; 32]) -> Result<Vec<u8>> {
|
||||
use chacha20poly1305::aead::{Aead, KeyInit};
|
||||
if data.len() < 12 {
|
||||
anyhow::bail!("Encrypted credentials too short");
|
||||
}
|
||||
let nonce = &data[..12];
|
||||
let ciphertext = &data[12..];
|
||||
let cipher = chacha20poly1305::ChaCha20Poly1305::new_from_slice(key)
|
||||
.map_err(|e| anyhow::anyhow!("Cipher init: {}", e))?;
|
||||
cipher
|
||||
.decrypt(
|
||||
chacha20poly1305::aead::generic_array::GenericArray::from_slice(nonce),
|
||||
ciphertext,
|
||||
)
|
||||
.map_err(|_| anyhow::anyhow!("Credential decryption failed — key mismatch or corruption"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_credentials_returns_empty_when_no_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = load_credentials(dir.path()).await.unwrap();
|
||||
assert!(store.credentials.is_empty());
|
||||
assert!(dir.path().join(CREDENTIALS_DIR).exists());
|
||||
}
|
||||
}
|
||||
89
core/archipelago/src/credentials/types.rs
Normal file
89
core/archipelago/src/credentials/types.rs
Normal file
@ -0,0 +1,89 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub(super) const CREDENTIALS_DIR: &str = "credentials";
|
||||
|
||||
/// W3C VC Data Model 2.0 context URI
|
||||
pub(super) const VC_CONTEXT_V2: &str = "https://www.w3.org/ns/credentials/v2";
|
||||
/// Ed25519 signature suite context
|
||||
pub(super) const ED25519_CONTEXT: &str = "https://w3id.org/security/suites/ed25519-2020/v1";
|
||||
|
||||
/// A Verifiable Credential following W3C VC Data Model 2.0.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VerifiableCredential {
|
||||
#[serde(rename = "@context")]
|
||||
pub context: Vec<String>,
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub credential_type: Vec<String>,
|
||||
pub issuer: String,
|
||||
pub credential_subject: CredentialSubject,
|
||||
pub issuance_date: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expiration_date: Option<String>,
|
||||
pub proof: CredentialProof,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub credential_status: Option<CredentialStatusEntry>,
|
||||
}
|
||||
|
||||
/// The subject of a credential with their DID and claims.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CredentialSubject {
|
||||
pub id: String,
|
||||
#[serde(flatten)]
|
||||
pub claims: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Ed25519Signature2020 proof format.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CredentialProof {
|
||||
#[serde(rename = "type")]
|
||||
pub proof_type: String,
|
||||
pub created: String,
|
||||
pub verification_method: String,
|
||||
pub proof_purpose: String,
|
||||
pub proof_value: String,
|
||||
}
|
||||
|
||||
/// Credential status for revocation tracking.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CredentialStatusEntry {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub status_type: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
/// Stored credentials index.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct CredentialStore {
|
||||
pub credentials: Vec<VerifiableCredential>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_credential_status_display() {
|
||||
assert_eq!(CredentialStatus::Active.to_string(), "active");
|
||||
assert_eq!(CredentialStatus::Revoked.to_string(), "revoked");
|
||||
assert_eq!(CredentialStatus::Expired.to_string(), "expired");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_credential_status_serde_roundtrip() {
|
||||
let json = serde_json::to_string(&CredentialStatus::Revoked).unwrap();
|
||||
assert_eq!(json, "\"revoked\"");
|
||||
let parsed: CredentialStatus = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed, CredentialStatus::Revoked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_credential_store_default_is_empty() {
|
||||
let store = CredentialStore::default();
|
||||
assert!(store.credentials.is_empty());
|
||||
}
|
||||
}
|
||||
@ -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", ×tamp)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to reach federated peer")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("Peer returned {}", resp.status());
|
||||
}
|
||||
|
||||
let result: serde_json::Value = resp.json().await.context("Invalid response from peer")?;
|
||||
let state_val = result
|
||||
.get("result")
|
||||
.ok_or_else(|| anyhow::anyhow!("No result in peer response"))?;
|
||||
|
||||
let state: NodeStateSnapshot =
|
||||
serde_json::from_value(state_val.clone()).context("Failed to parse peer state")?;
|
||||
|
||||
update_node_state(data_dir, &peer.did, state.clone()).await?;
|
||||
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
/// Sync with a peer using the transport router (Mesh > LAN > Tor).
|
||||
/// Uses CBOR delta encoding for compact payloads over constrained links.
|
||||
/// Falls back to `sync_with_peer()` if no transport router is available.
|
||||
pub async fn sync_with_peer_via_transport(
|
||||
data_dir: &Path,
|
||||
peer: &FederatedNode,
|
||||
local_did: &str,
|
||||
previous_state: Option<&NodeStateSnapshot>,
|
||||
router: &crate::transport::TransportRouter,
|
||||
) -> Result<()> {
|
||||
use crate::transport::{MessageType, TransportMessage};
|
||||
use crate::transport::delta;
|
||||
|
||||
// Build the sync request payload — if we have a previous state from this peer,
|
||||
// send a delta request (tells the peer we only need changes since timestamp).
|
||||
let payload = if let Some(prev) = previous_state {
|
||||
// Request delta since our last known state
|
||||
let request = serde_json::json!({
|
||||
"type": "state_sync_request",
|
||||
"since": prev.timestamp,
|
||||
});
|
||||
delta::encode_cbor(&delta::StateDelta {
|
||||
ts: prev.timestamp.clone(),
|
||||
v: 1,
|
||||
..Default::default()
|
||||
})?
|
||||
} else {
|
||||
// First sync — request full state
|
||||
let request = serde_json::json!({ "type": "state_sync_request" });
|
||||
serde_json::to_vec(&request)?
|
||||
};
|
||||
|
||||
let message = TransportMessage {
|
||||
from_did: local_did.to_string(),
|
||||
payload,
|
||||
message_type: MessageType::StateSync,
|
||||
};
|
||||
|
||||
let transport_used = router.send_to_peer(&peer.did, &message).await?;
|
||||
tracing::info!(
|
||||
peer = %peer.did,
|
||||
transport = %transport_used,
|
||||
"Federation sync sent via transport"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build the local node's state snapshot for sharing with peers.
|
||||
pub fn build_local_state(
|
||||
apps: Vec<AppStatus>,
|
||||
cpu: f64,
|
||||
mem_used: u64,
|
||||
mem_total: u64,
|
||||
disk_used: u64,
|
||||
disk_total: u64,
|
||||
uptime: u64,
|
||||
tor_active: bool,
|
||||
server_name: Option<String>,
|
||||
) -> NodeStateSnapshot {
|
||||
NodeStateSnapshot {
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
node_name: server_name,
|
||||
apps,
|
||||
cpu_usage_percent: Some(cpu),
|
||||
mem_used_bytes: Some(mem_used),
|
||||
mem_total_bytes: Some(mem_total),
|
||||
disk_used_bytes: Some(disk_used),
|
||||
disk_total_bytes: Some(disk_total),
|
||||
uptime_secs: Some(uptime),
|
||||
tor_active: Some(tor_active),
|
||||
}
|
||||
}
|
||||
|
||||
/// Deploy an app to a remote federated peer over Tor.
|
||||
/// Only works if the peer is trusted and the app exists in our marketplace.
|
||||
pub async fn deploy_to_peer(
|
||||
peer: &FederatedNode,
|
||||
app_id: &str,
|
||||
version: &str,
|
||||
marketplace_url: &str,
|
||||
local_did: &str,
|
||||
sign_fn: impl FnOnce(&[u8]) -> String,
|
||||
) -> Result<serde_json::Value> {
|
||||
if peer.trust_level != TrustLevel::Trusted {
|
||||
anyhow::bail!("Can only deploy to trusted peers (current: {})", peer.trust_level);
|
||||
}
|
||||
|
||||
let host = if peer.onion.ends_with(".onion") {
|
||||
peer.onion.clone()
|
||||
} else {
|
||||
format!("{}.onion", peer.onion)
|
||||
};
|
||||
let url = format!("http://{}/rpc/v1", host);
|
||||
|
||||
let timestamp = chrono::Utc::now().to_rfc3339();
|
||||
let signature = sign_fn(timestamp.as_bytes());
|
||||
|
||||
let body = serde_json::json!({
|
||||
"method": "package.install",
|
||||
"params": {
|
||||
"id": app_id,
|
||||
"version": version,
|
||||
"marketplace-url": marketplace_url,
|
||||
}
|
||||
});
|
||||
|
||||
let proxy = reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY).context("Invalid Tor proxy")?;
|
||||
let client = reqwest::Client::builder()
|
||||
.proxy(proxy)
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.build()
|
||||
.context("Failed to build HTTP client")?;
|
||||
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.header("X-Federation-DID", local_did)
|
||||
.header("X-Federation-Sig", &signature)
|
||||
.header("X-Federation-Timestamp", ×tamp)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to reach federated peer for deploy")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("Remote node returned HTTP {}", resp.status());
|
||||
}
|
||||
|
||||
let result: serde_json::Value = resp.json().await.context("Invalid response from peer")?;
|
||||
|
||||
if let Some(err) = result.get("error") {
|
||||
if !err.is_null() {
|
||||
let msg = err.get("message").and_then(|m| m.as_str()).unwrap_or("Unknown remote error");
|
||||
anyhow::bail!("Remote node refused deploy: {}", msg);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"deployed": true,
|
||||
"app_id": app_id,
|
||||
"peer_did": peer.did,
|
||||
"peer_onion": peer.onion,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
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()));
|
||||
}
|
||||
}
|
||||
259
core/archipelago/src/federation/invites.rs
Normal file
259
core/archipelago/src/federation/invites.rs
Normal file
@ -0,0 +1,259 @@
|
||||
//! Federation invite creation, parsing, and acceptance.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::Path;
|
||||
|
||||
use super::storage::{add_node, load_invites, load_nodes, save_invites};
|
||||
use super::types::{FederatedNode, FederationInvite, TrustLevel};
|
||||
|
||||
/// Generate an invite code. Format: `fed1:<base64(json{did, onion, pubkey, token})>`
|
||||
pub async fn create_invite(
|
||||
data_dir: &Path,
|
||||
did: &str,
|
||||
onion: &str,
|
||||
pubkey: &str,
|
||||
) -> Result<String> {
|
||||
use base64::Engine;
|
||||
use rand::Rng;
|
||||
|
||||
let mut token_bytes = [0u8; 16];
|
||||
rand::thread_rng().fill(&mut token_bytes);
|
||||
let token = hex::encode(token_bytes);
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"did": did,
|
||||
"onion": onion,
|
||||
"pubkey": pubkey,
|
||||
"token": token,
|
||||
});
|
||||
let json = serde_json::to_string(&payload).context("Failed to serialize invite")?;
|
||||
let code = format!(
|
||||
"fed1:{}",
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json.as_bytes())
|
||||
);
|
||||
|
||||
let invite = FederationInvite {
|
||||
code: code.clone(),
|
||||
did: did.to_string(),
|
||||
onion: onion.to_string(),
|
||||
pubkey: pubkey.to_string(),
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
accepted: false,
|
||||
};
|
||||
|
||||
let mut invites = load_invites(data_dir).await?;
|
||||
invites.outgoing.push(invite);
|
||||
save_invites(data_dir, &invites).await?;
|
||||
|
||||
Ok(code)
|
||||
}
|
||||
|
||||
/// Parse an invite code into its components.
|
||||
pub fn parse_invite(code: &str) -> Result<(String, String, String, String)> {
|
||||
use base64::Engine;
|
||||
|
||||
let encoded = code
|
||||
.strip_prefix("fed1:")
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid invite format: must start with fed1:"))?;
|
||||
|
||||
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(encoded)
|
||||
.context("Invalid base64 in invite code")?;
|
||||
|
||||
let payload: serde_json::Value =
|
||||
serde_json::from_slice(&bytes).context("Invalid JSON in invite")?;
|
||||
|
||||
let did = payload["did"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing did in invite"))?
|
||||
.to_string();
|
||||
let onion = payload["onion"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing onion in invite"))?
|
||||
.to_string();
|
||||
let pubkey = payload["pubkey"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing pubkey in invite"))?
|
||||
.to_string();
|
||||
let token = payload["token"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing token in invite"))?
|
||||
.to_string();
|
||||
|
||||
Ok((did, onion, pubkey, token))
|
||||
}
|
||||
|
||||
/// Accept an invite: parse code, verify the remote node, add to federation.
|
||||
pub async fn accept_invite(
|
||||
data_dir: &Path,
|
||||
code: &str,
|
||||
local_did: &str,
|
||||
local_onion: &str,
|
||||
local_pubkey: &str,
|
||||
sign_fn: impl FnOnce(&[u8]) -> String,
|
||||
) -> Result<FederatedNode> {
|
||||
let (did, onion, pubkey, _token) = parse_invite(code)?;
|
||||
|
||||
// Check not already federated
|
||||
let nodes = load_nodes(data_dir).await?;
|
||||
if nodes.iter().any(|n| n.did == did) {
|
||||
anyhow::bail!("Already federated with node {}", did);
|
||||
}
|
||||
|
||||
let node = FederatedNode {
|
||||
did: did.clone(),
|
||||
pubkey,
|
||||
onion,
|
||||
name: None,
|
||||
trust_level: TrustLevel::Trusted,
|
||||
added_at: chrono::Utc::now().to_rfc3339(),
|
||||
last_seen: None,
|
||||
last_state: None,
|
||||
};
|
||||
|
||||
add_node(data_dir, node.clone()).await?;
|
||||
|
||||
// Record as incoming accepted invite
|
||||
let mut invites = load_invites(data_dir).await?;
|
||||
invites.incoming.push(FederationInvite {
|
||||
code: code.to_string(),
|
||||
did: did.clone(),
|
||||
onion: node.onion.clone(),
|
||||
pubkey: node.pubkey.clone(),
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
accepted: true,
|
||||
});
|
||||
save_invites(data_dir, &invites).await?;
|
||||
|
||||
// Notify remote node (best-effort over Tor)
|
||||
let _ = notify_join(&node.onion, local_did, local_onion, local_pubkey, sign_fn).await;
|
||||
|
||||
Ok(node)
|
||||
}
|
||||
|
||||
/// Best-effort notification to the remote node that we joined their federation.
|
||||
/// Signs the message with our ed25519 key so the remote peer can verify authenticity.
|
||||
async fn notify_join(
|
||||
remote_onion: &str,
|
||||
local_did: &str,
|
||||
local_onion: &str,
|
||||
local_pubkey: &str,
|
||||
sign_fn: impl FnOnce(&[u8]) -> String,
|
||||
) -> Result<()> {
|
||||
let host = if remote_onion.ends_with(".onion") {
|
||||
remote_onion.to_string()
|
||||
} else {
|
||||
format!("{}.onion", remote_onion)
|
||||
};
|
||||
let url = format!("http://{}/rpc/v1", host);
|
||||
|
||||
// Sign the canonical message: "peer-joined:{did}:{onion}:{pubkey}"
|
||||
let sign_data = format!("peer-joined:{}:{}:{}", local_did, local_onion, local_pubkey);
|
||||
let signature = sign_fn(sign_data.as_bytes());
|
||||
|
||||
let body = serde_json::json!({
|
||||
"method": "federation.peer-joined",
|
||||
"params": {
|
||||
"did": local_did,
|
||||
"onion": local_onion,
|
||||
"pubkey": local_pubkey,
|
||||
"signature": signature,
|
||||
}
|
||||
});
|
||||
|
||||
let proxy = reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY).context("Invalid Tor proxy")?;
|
||||
let client = reqwest::Client::builder()
|
||||
.proxy(proxy)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.context("Failed to build HTTP client")?;
|
||||
|
||||
let _ = client.post(&url).json(&body).send().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::federation::storage::load_nodes;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_and_parse_invite() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let code = create_invite(dir.path(), "did:key:z1", "test.onion", "aabbcc")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(code.starts_with("fed1:"));
|
||||
|
||||
let (did, onion, pubkey, token) = parse_invite(&code).unwrap();
|
||||
assert_eq!(did, "did:key:z1");
|
||||
assert_eq!(onion, "test.onion");
|
||||
assert_eq!(pubkey, "aabbcc");
|
||||
assert_eq!(token.len(), 32); // 16 bytes = 32 hex chars
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_invalid_invite() {
|
||||
assert!(parse_invite("invalid").is_err());
|
||||
assert!(parse_invite("fed1:not-valid-base64!!!").is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_accept_invite_creates_node() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let code = create_invite(dir.path(), "did:key:zRemote", "remote.onion", "remotepub")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Accept from a different "local" perspective
|
||||
let dir2 = tempfile::tempdir().unwrap();
|
||||
let node = accept_invite(
|
||||
dir2.path(),
|
||||
&code,
|
||||
"did:key:zLocal",
|
||||
"local.onion",
|
||||
"localpub",
|
||||
|_| "test-sig".to_string(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(node.did, "did:key:zRemote");
|
||||
assert_eq!(node.trust_level, TrustLevel::Trusted);
|
||||
|
||||
let nodes = load_nodes(dir2.path()).await.unwrap();
|
||||
assert_eq!(nodes.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_accept_invite_rejects_duplicate() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let code = create_invite(dir.path(), "did:key:zRemote", "remote.onion", "remotepub")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let dir2 = tempfile::tempdir().unwrap();
|
||||
accept_invite(
|
||||
dir2.path(),
|
||||
&code,
|
||||
"did:key:zLocal",
|
||||
"local.onion",
|
||||
"localpub",
|
||||
|_| "test-sig".to_string(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Accepting the same invite again should fail
|
||||
let result = accept_invite(
|
||||
dir2.path(),
|
||||
&code,
|
||||
"did:key:zLocal",
|
||||
"local.onion",
|
||||
"localpub",
|
||||
|_| "test-sig".to_string(),
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
18
core/archipelago/src/federation/mod.rs
Normal file
18
core/archipelago/src/federation/mod.rs
Normal file
@ -0,0 +1,18 @@
|
||||
//! Node federation: trusted multi-node clusters with state sync.
|
||||
//!
|
||||
//! Nodes federate by exchanging invite codes containing DID + onion address.
|
||||
//! Trust is bilateral — both sides must agree. Federated nodes periodically
|
||||
//! sync container status, health metrics, and availability.
|
||||
|
||||
mod invites;
|
||||
mod storage;
|
||||
mod sync;
|
||||
mod types;
|
||||
|
||||
// Re-export all public items so `crate::federation::*` continues to work.
|
||||
pub use invites::{accept_invite, create_invite};
|
||||
pub use storage::{
|
||||
add_node, load_nodes, remove_node, save_nodes, set_trust_level,
|
||||
};
|
||||
pub use sync::{build_local_state, deploy_to_peer, sync_with_peer};
|
||||
pub use types::{AppStatus, FederatedNode, NodeStateSnapshot, TrustLevel};
|
||||
258
core/archipelago/src/federation/storage.rs
Normal file
258
core/archipelago/src/federation/storage.rs
Normal file
@ -0,0 +1,258 @@
|
||||
//! Federation persistent storage: node list and invite management on disk.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use tokio::fs;
|
||||
|
||||
use super::types::{FederatedNode, FederationInvite, NodeStateSnapshot, TrustLevel};
|
||||
|
||||
pub(crate) const FEDERATION_DIR: &str = "federation";
|
||||
pub(crate) const NODES_FILE: &str = "nodes.json";
|
||||
pub(crate) const INVITES_FILE: &str = "invites.json";
|
||||
|
||||
/// Top-level file structures.
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub(crate) struct NodesFile {
|
||||
pub(crate) nodes: Vec<FederatedNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub(crate) struct InvitesFile {
|
||||
pub(crate) outgoing: Vec<FederationInvite>,
|
||||
pub(crate) incoming: Vec<FederationInvite>,
|
||||
}
|
||||
|
||||
/// Ensure federation directory exists.
|
||||
pub(crate) async fn ensure_dir(data_dir: &Path) -> Result<std::path::PathBuf> {
|
||||
let dir = data_dir.join(FEDERATION_DIR);
|
||||
fs::create_dir_all(&dir)
|
||||
.await
|
||||
.context("Failed to create federation directory")?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
// ──────────────────────────── Node Management ────────────────────────────
|
||||
|
||||
pub async fn load_nodes(data_dir: &Path) -> Result<Vec<FederatedNode>> {
|
||||
let dir = data_dir.join(FEDERATION_DIR);
|
||||
let path = dir.join(NODES_FILE);
|
||||
if !path.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let content = fs::read_to_string(&path)
|
||||
.await
|
||||
.context("Failed to read federation nodes")?;
|
||||
let file: NodesFile = serde_json::from_str(&content).unwrap_or_default();
|
||||
Ok(file.nodes)
|
||||
}
|
||||
|
||||
pub async fn save_nodes(data_dir: &Path, nodes: &[FederatedNode]) -> Result<()> {
|
||||
let dir = ensure_dir(data_dir).await?;
|
||||
let file = NodesFile {
|
||||
nodes: nodes.to_vec(),
|
||||
};
|
||||
let content = serde_json::to_string_pretty(&file).context("Failed to serialize nodes")?;
|
||||
fs::write(dir.join(NODES_FILE), content)
|
||||
.await
|
||||
.context("Failed to write federation nodes")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn add_node(data_dir: &Path, node: FederatedNode) -> Result<Vec<FederatedNode>> {
|
||||
let mut nodes = load_nodes(data_dir).await?;
|
||||
let exists = nodes.iter().any(|n| n.did == node.did);
|
||||
if exists {
|
||||
anyhow::bail!("Node with DID {} is already federated", node.did);
|
||||
}
|
||||
nodes.push(node);
|
||||
save_nodes(data_dir, &nodes).await?;
|
||||
Ok(nodes)
|
||||
}
|
||||
|
||||
pub async fn remove_node(data_dir: &Path, did: &str) -> Result<Vec<FederatedNode>> {
|
||||
let mut nodes = load_nodes(data_dir).await?;
|
||||
let before = nodes.len();
|
||||
nodes.retain(|n| n.did != did);
|
||||
if nodes.len() == before {
|
||||
anyhow::bail!("No federated node with DID {}", did);
|
||||
}
|
||||
save_nodes(data_dir, &nodes).await?;
|
||||
Ok(nodes)
|
||||
}
|
||||
|
||||
pub async fn set_trust_level(
|
||||
data_dir: &Path,
|
||||
did: &str,
|
||||
trust: TrustLevel,
|
||||
) -> Result<Vec<FederatedNode>> {
|
||||
let mut nodes = load_nodes(data_dir).await?;
|
||||
let node = nodes
|
||||
.iter_mut()
|
||||
.find(|n| n.did == did)
|
||||
.ok_or_else(|| anyhow::anyhow!("No federated node with DID {}", did))?;
|
||||
node.trust_level = trust;
|
||||
save_nodes(data_dir, &nodes).await?;
|
||||
Ok(nodes)
|
||||
}
|
||||
|
||||
pub async fn update_node_state(
|
||||
data_dir: &Path,
|
||||
did: &str,
|
||||
state: NodeStateSnapshot,
|
||||
) -> Result<()> {
|
||||
let mut nodes = load_nodes(data_dir).await?;
|
||||
if let Some(node) = nodes.iter_mut().find(|n| n.did == did) {
|
||||
node.last_seen = Some(state.timestamp.clone());
|
||||
// Update node name from sync if provided (peer announced their name)
|
||||
if let Some(ref name) = state.node_name {
|
||||
if !name.is_empty() {
|
||||
node.name = Some(name.clone());
|
||||
}
|
||||
}
|
||||
node.last_state = Some(state);
|
||||
save_nodes(data_dir, &nodes).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ──────────────────────────── Invite Storage ────────────────────────────
|
||||
|
||||
pub(crate) async fn load_invites(data_dir: &Path) -> Result<InvitesFile> {
|
||||
let dir = data_dir.join(FEDERATION_DIR);
|
||||
let path = dir.join(INVITES_FILE);
|
||||
if !path.exists() {
|
||||
return Ok(InvitesFile::default());
|
||||
}
|
||||
let content = fs::read_to_string(&path)
|
||||
.await
|
||||
.context("Failed to read invites")?;
|
||||
let file: InvitesFile = serde_json::from_str(&content).unwrap_or_default();
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
pub(crate) async fn save_invites(data_dir: &Path, invites: &InvitesFile) -> Result<()> {
|
||||
let dir = ensure_dir(data_dir).await?;
|
||||
let content = serde_json::to_string_pretty(invites).context("Failed to serialize invites")?;
|
||||
fs::write(dir.join(INVITES_FILE), content)
|
||||
.await
|
||||
.context("Failed to write invites")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::federation::types::AppStatus;
|
||||
|
||||
fn make_node(did: &str, onion: &str) -> FederatedNode {
|
||||
FederatedNode {
|
||||
did: did.to_string(),
|
||||
pubkey: "aabbccdd".to_string(),
|
||||
onion: onion.to_string(),
|
||||
name: None,
|
||||
trust_level: TrustLevel::Trusted,
|
||||
added_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
last_seen: None,
|
||||
last_state: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_nodes_empty_when_no_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let nodes = load_nodes(dir.path()).await.unwrap();
|
||||
assert!(nodes.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_save_and_load_nodes_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let nodes = vec![
|
||||
make_node("did:key:z1", "a.onion"),
|
||||
make_node("did:key:z2", "b.onion"),
|
||||
];
|
||||
save_nodes(dir.path(), &nodes).await.unwrap();
|
||||
let loaded = load_nodes(dir.path()).await.unwrap();
|
||||
assert_eq!(loaded.len(), 2);
|
||||
assert_eq!(loaded[0].did, "did:key:z1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_add_node_deduplicates_by_did() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
|
||||
.await
|
||||
.unwrap();
|
||||
let result = add_node(dir.path(), make_node("did:key:z1", "b.onion")).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remove_node_by_did() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
|
||||
.await
|
||||
.unwrap();
|
||||
add_node(dir.path(), make_node("did:key:z2", "b.onion"))
|
||||
.await
|
||||
.unwrap();
|
||||
let result = remove_node(dir.path(), "did:key:z1").await.unwrap();
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].did, "did:key:z2");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remove_nonexistent_node_errors() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let result = remove_node(dir.path(), "did:key:nonexistent").await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_set_trust_level() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
|
||||
.await
|
||||
.unwrap();
|
||||
let nodes = set_trust_level(dir.path(), "did:key:z1", TrustLevel::Observer)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(nodes[0].trust_level, TrustLevel::Observer);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_node_state() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let state = NodeStateSnapshot {
|
||||
timestamp: "2026-03-10T12:00:00Z".to_string(),
|
||||
node_name: None,
|
||||
apps: vec![AppStatus {
|
||||
id: "bitcoin".to_string(),
|
||||
status: "running".to_string(),
|
||||
version: Some("27.0".to_string()),
|
||||
}],
|
||||
cpu_usage_percent: Some(45.2),
|
||||
mem_used_bytes: Some(4_000_000_000),
|
||||
mem_total_bytes: Some(8_000_000_000),
|
||||
disk_used_bytes: None,
|
||||
disk_total_bytes: None,
|
||||
uptime_secs: Some(86400),
|
||||
tor_active: Some(true),
|
||||
};
|
||||
|
||||
update_node_state(dir.path(), "did:key:z1", state)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let nodes = load_nodes(dir.path()).await.unwrap();
|
||||
assert!(nodes[0].last_seen.is_some());
|
||||
let ls = nodes[0].last_state.as_ref().unwrap();
|
||||
assert_eq!(ls.apps.len(), 1);
|
||||
assert_eq!(ls.cpu_usage_percent, Some(45.2));
|
||||
}
|
||||
}
|
||||
189
core/archipelago/src/federation/sync.rs
Normal file
189
core/archipelago/src/federation/sync.rs
Normal file
@ -0,0 +1,189 @@
|
||||
//! Federation state sync and remote deployment.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::Path;
|
||||
|
||||
use super::storage::update_node_state;
|
||||
use super::types::{AppStatus, FederatedNode, NodeStateSnapshot, TrustLevel};
|
||||
|
||||
/// Sync state with a single federated peer over Tor.
|
||||
pub async fn sync_with_peer(
|
||||
data_dir: &Path,
|
||||
peer: &FederatedNode,
|
||||
local_did: &str,
|
||||
sign_fn: impl FnOnce(&[u8]) -> String,
|
||||
) -> Result<NodeStateSnapshot> {
|
||||
let host = if peer.onion.ends_with(".onion") {
|
||||
peer.onion.clone()
|
||||
} else {
|
||||
format!("{}.onion", peer.onion)
|
||||
};
|
||||
let url = format!("http://{}/rpc/v1", host);
|
||||
|
||||
// Sign current timestamp for authentication
|
||||
let timestamp = chrono::Utc::now().to_rfc3339();
|
||||
let signature = sign_fn(timestamp.as_bytes());
|
||||
|
||||
let body = serde_json::json!({
|
||||
"method": "federation.get-state",
|
||||
"params": {}
|
||||
});
|
||||
|
||||
let proxy = reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY).context("Invalid Tor proxy")?;
|
||||
let client = reqwest::Client::builder()
|
||||
.proxy(proxy)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.context("Failed to build HTTP client")?;
|
||||
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.header("X-Federation-DID", local_did)
|
||||
.header("X-Federation-Sig", &signature)
|
||||
.header("X-Federation-Timestamp", ×tamp)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to reach federated peer")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("Peer returned {}", resp.status());
|
||||
}
|
||||
|
||||
let result: serde_json::Value = resp.json().await.context("Invalid response from peer")?;
|
||||
let state_val = result
|
||||
.get("result")
|
||||
.ok_or_else(|| anyhow::anyhow!("No result in peer response"))?;
|
||||
|
||||
let state: NodeStateSnapshot =
|
||||
serde_json::from_value(state_val.clone()).context("Failed to parse peer state")?;
|
||||
|
||||
update_node_state(data_dir, &peer.did, state.clone()).await?;
|
||||
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
/// Build the local node's state snapshot for sharing with peers.
|
||||
pub fn build_local_state(
|
||||
apps: Vec<AppStatus>,
|
||||
cpu: f64,
|
||||
mem_used: u64,
|
||||
mem_total: u64,
|
||||
disk_used: u64,
|
||||
disk_total: u64,
|
||||
uptime: u64,
|
||||
tor_active: bool,
|
||||
server_name: Option<String>,
|
||||
) -> NodeStateSnapshot {
|
||||
NodeStateSnapshot {
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
node_name: server_name,
|
||||
apps,
|
||||
cpu_usage_percent: Some(cpu),
|
||||
mem_used_bytes: Some(mem_used),
|
||||
mem_total_bytes: Some(mem_total),
|
||||
disk_used_bytes: Some(disk_used),
|
||||
disk_total_bytes: Some(disk_total),
|
||||
uptime_secs: Some(uptime),
|
||||
tor_active: Some(tor_active),
|
||||
}
|
||||
}
|
||||
|
||||
/// Deploy an app to a remote federated peer over Tor.
|
||||
/// Only works if the peer is trusted and the app exists in our marketplace.
|
||||
pub async fn deploy_to_peer(
|
||||
peer: &FederatedNode,
|
||||
app_id: &str,
|
||||
version: &str,
|
||||
marketplace_url: &str,
|
||||
local_did: &str,
|
||||
sign_fn: impl FnOnce(&[u8]) -> String,
|
||||
) -> Result<serde_json::Value> {
|
||||
if peer.trust_level != TrustLevel::Trusted {
|
||||
anyhow::bail!("Can only deploy to trusted peers (current: {})", peer.trust_level);
|
||||
}
|
||||
|
||||
let host = if peer.onion.ends_with(".onion") {
|
||||
peer.onion.clone()
|
||||
} else {
|
||||
format!("{}.onion", peer.onion)
|
||||
};
|
||||
let url = format!("http://{}/rpc/v1", host);
|
||||
|
||||
let timestamp = chrono::Utc::now().to_rfc3339();
|
||||
let signature = sign_fn(timestamp.as_bytes());
|
||||
|
||||
let body = serde_json::json!({
|
||||
"method": "package.install",
|
||||
"params": {
|
||||
"id": app_id,
|
||||
"version": version,
|
||||
"marketplace-url": marketplace_url,
|
||||
}
|
||||
});
|
||||
|
||||
let proxy = reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY).context("Invalid Tor proxy")?;
|
||||
let client = reqwest::Client::builder()
|
||||
.proxy(proxy)
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.build()
|
||||
.context("Failed to build HTTP client")?;
|
||||
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.header("X-Federation-DID", local_did)
|
||||
.header("X-Federation-Sig", &signature)
|
||||
.header("X-Federation-Timestamp", ×tamp)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to reach federated peer for deploy")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("Remote node returned HTTP {}", resp.status());
|
||||
}
|
||||
|
||||
let result: serde_json::Value = resp.json().await.context("Invalid response from peer")?;
|
||||
|
||||
if let Some(err) = result.get("error") {
|
||||
if !err.is_null() {
|
||||
let msg = err.get("message").and_then(|m| m.as_str()).unwrap_or("Unknown remote error");
|
||||
anyhow::bail!("Remote node refused deploy: {}", msg);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"deployed": true,
|
||||
"app_id": app_id,
|
||||
"peer_did": peer.did,
|
||||
"peer_onion": peer.onion,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_build_local_state() {
|
||||
let state = build_local_state(
|
||||
vec![AppStatus {
|
||||
id: "lnd".to_string(),
|
||||
status: "running".to_string(),
|
||||
version: Some("0.18".to_string()),
|
||||
}],
|
||||
25.5,
|
||||
2_000_000_000,
|
||||
8_000_000_000,
|
||||
100_000_000_000,
|
||||
500_000_000_000,
|
||||
3600,
|
||||
true,
|
||||
Some("Test Node".to_string()),
|
||||
);
|
||||
assert_eq!(state.apps.len(), 1);
|
||||
assert_eq!(state.cpu_usage_percent, Some(25.5));
|
||||
assert_eq!(state.tor_active, Some(true));
|
||||
assert_eq!(state.node_name, Some("Test Node".to_string()));
|
||||
}
|
||||
}
|
||||
124
core/archipelago/src/federation/types.rs
Normal file
124
core/archipelago/src/federation/types.rs
Normal file
@ -0,0 +1,124 @@
|
||||
//! Federation type definitions.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Trust level for a federated node.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TrustLevel {
|
||||
Trusted,
|
||||
Observer,
|
||||
Untrusted,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TrustLevel {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
TrustLevel::Trusted => write!(f, "trusted"),
|
||||
TrustLevel::Observer => write!(f, "observer"),
|
||||
TrustLevel::Untrusted => write!(f, "untrusted"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A federated node in our cluster.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FederatedNode {
|
||||
pub did: String,
|
||||
pub pubkey: String,
|
||||
pub onion: String,
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
pub trust_level: TrustLevel,
|
||||
pub added_at: String,
|
||||
#[serde(default)]
|
||||
pub last_seen: Option<String>,
|
||||
#[serde(default)]
|
||||
pub last_state: Option<NodeStateSnapshot>,
|
||||
}
|
||||
|
||||
/// State snapshot received from a federated peer during sync.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NodeStateSnapshot {
|
||||
pub timestamp: String,
|
||||
#[serde(default)]
|
||||
pub node_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub apps: Vec<AppStatus>,
|
||||
#[serde(default)]
|
||||
pub cpu_usage_percent: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub mem_used_bytes: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub mem_total_bytes: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub disk_used_bytes: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub disk_total_bytes: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub uptime_secs: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub tor_active: Option<bool>,
|
||||
}
|
||||
|
||||
/// Status of a single app/container on a remote node.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppStatus {
|
||||
pub id: String,
|
||||
pub status: String, // "running", "stopped", "installed"
|
||||
#[serde(default)]
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
/// A pending invite (outgoing or incoming).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FederationInvite {
|
||||
pub code: String,
|
||||
pub did: String,
|
||||
pub onion: String,
|
||||
pub pubkey: String,
|
||||
pub created_at: String,
|
||||
#[serde(default)]
|
||||
pub accepted: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_trust_level_serialization() {
|
||||
let json = serde_json::to_string(&TrustLevel::Trusted).unwrap();
|
||||
assert_eq!(json, "\"trusted\"");
|
||||
|
||||
let parsed: TrustLevel = serde_json::from_str("\"observer\"").unwrap();
|
||||
assert_eq!(parsed, TrustLevel::Observer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_federated_node_serialization_roundtrip() {
|
||||
let node = FederatedNode {
|
||||
did: "did:key:zABC".to_string(),
|
||||
pubkey: "aabbccdd".to_string(),
|
||||
onion: "test.onion".to_string(),
|
||||
name: None,
|
||||
trust_level: TrustLevel::Trusted,
|
||||
added_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
last_seen: None,
|
||||
last_state: None,
|
||||
};
|
||||
let json = serde_json::to_string(&node).unwrap();
|
||||
let parsed: FederatedNode = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.did, "did:key:zABC");
|
||||
assert_eq!(parsed.trust_level, TrustLevel::Trusted);
|
||||
assert!(parsed.last_state.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_node_state_snapshot_defaults() {
|
||||
let json = r#"{"timestamp": "2026-01-01T00:00:00Z"}"#;
|
||||
let state: NodeStateSnapshot = serde_json::from_str(json).unwrap();
|
||||
assert!(state.apps.is_empty());
|
||||
assert!(state.cpu_usage_percent.is_none());
|
||||
}
|
||||
}
|
||||
@ -175,9 +175,6 @@ impl MemoryTracker {
|
||||
}
|
||||
}
|
||||
|
||||
fn remove(&mut self, name: &str) {
|
||||
self.samples.remove(name);
|
||||
}
|
||||
}
|
||||
|
||||
/// Query container memory stats from podman.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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};
|
||||
|
||||
@ -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
|
||||
|
||||
239
core/archipelago/src/monitoring/alerts.rs
Normal file
239
core/archipelago/src/monitoring/alerts.rs
Normal 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(())
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
68
core/archipelago/src/monitoring/notifications.rs
Normal file
68
core/archipelago/src/monitoring/notifications.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
316
core/archipelago/src/monitoring/store.rs
Normal file
316
core/archipelago/src/monitoring/store.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
209
core/archipelago/src/monitoring/telemetry.rs
Normal file
209
core/archipelago/src/monitoring/telemetry.rs
Normal 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);
|
||||
}
|
||||
81
core/archipelago/src/monitoring/types.rs
Normal file
81
core/archipelago/src/monitoring/types.rs
Normal 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,
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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(())
|
||||
}
|
||||
|
||||
@ -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?;
|
||||
|
||||
191
core/archipelago/src/rate_limit.rs
Normal file
191
core/archipelago/src/rate_limit.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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(),
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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};
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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?;
|
||||
|
||||
@ -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!({
|
||||
|
||||
@ -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("")
|
||||
|
||||
19
docker/bitcoin-ui/50x.html
Normal file
19
docker/bitcoin-ui/50x.html
Normal 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>
|
||||
7
docker/bitcoin-ui/Dockerfile
Normal file
7
docker/bitcoin-ui/Dockerfile
Normal 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;"]
|
||||
818
docker/bitcoin-ui/index.html
Normal file
818
docker/bitcoin-ui/index.html
Normal 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
Loading…
x
Reference in New Issue
Block a user