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
|
- [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
|
- [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
|
## Completed Work
|
||||||
- [project_mesh_198_issue.md](project_mesh_198_issue.md) — Mesh .198: 3 bugs fixed and deployed
|
- [project_mesh_198_issue.md](project_mesh_198_issue.md) — Mesh .198: 3 bugs fixed and deployed
|
||||||
- [project_indeedhub_arch3_fix.md](project_indeedhub_arch3_fix.md) — IndeedHub Arch 3: corrupted combined tarball fixed
|
- [project_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)
|
name: v1.3.0 Session Status (March 20)
|
||||||
description: Massive session — 33 pentest fixes, container reliability, federation, mesh channel, 30+ commits
|
description: Tor management system, bug fixes, federation name sync — cloud files working both ways
|
||||||
type: project
|
type: project
|
||||||
---
|
---
|
||||||
|
|
||||||
## Deployed to .228 + .198
|
## Deployed to .228 + .198
|
||||||
|
|
||||||
### What's Live
|
### What's Live
|
||||||
- All 33 pentest security fixes (backend + frontend + nginx)
|
- Full Tor hidden service management (systemd path unit pattern — tor-helper.sh)
|
||||||
- Container reliability: memory limits in scripts, crash recovery coordination, health badges
|
- Container doctor: system Tor preferred, archy-tor container removed
|
||||||
- Federation & Peers: DID persistence, rotation, node names, two-column layout, invite types
|
- Federation name sync: server rename pushes to peers
|
||||||
- Archipelago public channel in Mesh (Tor messaging)
|
- Cloud files working both ways over Tor
|
||||||
- LND Connect with CORS fix (bulletproof)
|
- Arch channel local echo for sent messages
|
||||||
- ElectrumX headers.subscribe fix
|
- Web5 Message button → Mesh redirect
|
||||||
- FileBrowser auto-login
|
- Node names in federation/peers
|
||||||
- Lightning channel backup export
|
- PeerFiles header shows name + DID (not onion)
|
||||||
- App iframe auto-retry
|
- Connected Nodes flex height
|
||||||
- Install progress persists across navigation
|
- 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)
|
### Architecture: Tor Management
|
||||||
1. **Archipelago channel**: sent messages don't show to sender (no local echo), .228 says "no peers found"
|
- Backend writes staged torrc + action file to /var/lib/archipelago/tor-config/
|
||||||
2. **Web5 Send Message modal**: should redirect to Mesh chat, not show its own modal
|
- systemd path unit (archipelago-tor-helper.path) triggers root-level service
|
||||||
3. **Cloud peer files**: "Operation failed" when browsing .198 files from .228 — Tor connection issue
|
- tor-helper.sh processes actions: write-torrc-and-restart, restart, delete-service, sync-hostnames
|
||||||
4. **Server name save**: not persisting — no `server-name.txt` on server
|
- NoNewPrivileges=yes safe — no sudo from backend
|
||||||
5. **Node names**: still showing DIDs in some places (cloud peer header, some federation contexts)
|
- Container doctor ensures system Tor stays running after deploys
|
||||||
6. **Tor**: ControlPort 0 fix applied manually but needs to be in deploy script/torrc generation
|
- Web apps: port 80 on .onion → local app port; Protocol services: direct port
|
||||||
7. **Connected Nodes container**: not filling height, needs max-height fix in Web5 view
|
|
||||||
|
|
||||||
### Outstanding Tasks
|
### Onion Addresses (current)
|
||||||
- Tor restart button in Network UI
|
- .228 archipelago: r33p5uzk2vxhdte4a5pfqgeax44a7b2lx57q32dxmx5llzyfz42lwnyd.onion
|
||||||
- Auto-restart Tor when features fail
|
- .198 archipelago: mxn62m4odavwctlpsq2ozvhy3ibjpenlzemumwtkev7wviikttxvjhyd.onion
|
||||||
- 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")
|
|
||||||
|
|
||||||
### Deploy Notes
|
### Still TODO
|
||||||
- Backend binary: atomic swap via `cp -new` + `mv`
|
1. **Tor channel chat** — messages via Archipelago channel need testing/polish
|
||||||
- Tor fix: remove `ControlPort 0` from torrc, chown debian-tor
|
2. **ISO build** — update build-auto-installer-iso.sh with tor-helper, systemd units, container doctor changes
|
||||||
- LND UI: rebuild with `--no-cache` for CORS credentials fix
|
3. **Better error messaging** — when nodes are down, addresses changed, all situations
|
||||||
- Always sync: frontend, nginx config, docker UIs, scripts, core source
|
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.
|
**Why:** Session continuity for v1.3.0 beta stabilization effort.
|
||||||
**How to apply:** Read at start of next session. Fix active bugs first, then ISO build.
|
**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
|
> 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/)
|
[](https://www.debian.org/)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://www.rust-lang.org/)
|
[](https://www.rust-lang.org/)
|
||||||
[](https://vuejs.org/)
|
[](https://vuejs.org/)
|
||||||
[]()
|
[]()
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Bitcoin Infrastructure
|
### Bitcoin Infrastructure
|
||||||
- **Bitcoin Knots** full node with pruning support
|
- **Bitcoin Knots** full node with pruning support
|
||||||
- **LND** Lightning Network daemon with channel management
|
- **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
|
- **BTCPay Server** for accepting Bitcoin payments
|
||||||
- **Mempool** block explorer and fee estimator
|
- **Mempool** block explorer and fee estimator
|
||||||
- **Fedimint** federation guardian and gateway
|
- **Fedimint** federation guardian and gateway
|
||||||
|
|
||||||
### Self-Hosted Apps (20+)
|
### Self-Hosted Apps (30)
|
||||||
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.
|
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
|
### Decentralized Identity
|
||||||
- DID-based digital identity (Ed25519 + secp256k1)
|
- Ed25519 node identity with DID Documents (did:key)
|
||||||
- Verifiable Credentials issuance and verification
|
- Multi-identity management (Personal/Business/Anonymous)
|
||||||
- Decentralized Web Node (DWN) for data sync
|
- W3C Verifiable Credentials issuance and verification
|
||||||
- Nostr relay integration for node discovery
|
- 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
|
### Security
|
||||||
- AES-256-GCM encrypted secrets at rest
|
- ChaCha20-Poly1305 encrypted secrets at rest, Argon2 password hashing
|
||||||
- Container isolation: read-only root, capability dropping, non-root user
|
- Rootless Podman: read-only root, cap-drop ALL, non-root user, no-new-privileges
|
||||||
- TOTP two-factor authentication
|
- TOTP two-factor authentication
|
||||||
- Per-endpoint rate limiting and input validation
|
- Per-endpoint rate limiting, CSRF protection, input validation
|
||||||
- AppArmor profiles for container confinement
|
- AppArmor profiles for container confinement
|
||||||
|
- Tor hidden services for all inter-node communication
|
||||||
|
- Full penetration test completed (33 findings, all remediated)
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@ -59,26 +73,25 @@ Storage (File Browser, Immich, Nextcloud), Productivity (Penpot, OnlyOffice, Vau
|
|||||||
## Development
|
## Development
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
- Rust stable toolchain
|
- macOS or Linux for frontend development
|
||||||
- Node.js 20+
|
- Linux dev server (Debian 12) for backend builds — **never build Rust on macOS for Linux**
|
||||||
- Linux dev server (Debian 12) for backend builds
|
- Node.js 20+, Rust stable toolchain
|
||||||
|
|
||||||
### Frontend Development
|
### Frontend Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd neode-ui
|
cd neode-ui
|
||||||
npm install
|
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 run type-check # TypeScript validation
|
||||||
npm test # Run 515+ tests
|
npm run build # Production build → web/dist/neode-ui/
|
||||||
npm run build # Production build
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Deploy to Server
|
### Deploy to Server
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/deploy-to-target.sh --live # Deploy to dev server
|
./scripts/deploy-to-target.sh --live # Deploy to primary dev server
|
||||||
./scripts/deploy-to-target.sh --both # Deploy to both servers
|
./scripts/deploy-to-target.sh --both # Deploy to both LAN servers
|
||||||
```
|
```
|
||||||
|
|
||||||
### Build ISO
|
### Build ISO
|
||||||
@ -86,40 +99,47 @@ npm run build # Production build
|
|||||||
```bash
|
```bash
|
||||||
ssh archipelago@<server>
|
ssh archipelago@<server>
|
||||||
cd ~/archy/image-recipe
|
cd ~/archy/image-recipe
|
||||||
sudo ./build-auto-installer-iso.sh # x86_64
|
sudo ./build-auto-installer-iso.sh
|
||||||
sudo ARCH=arm64 ./build-auto-installer-iso.sh # ARM64
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
Debian 12 (Bookworm)
|
Debian 12 (Bookworm)
|
||||||
├── Podman (rootless containers)
|
├── Rootless Podman (30 containers, archy-net DNS)
|
||||||
├── Nginx (reverse proxy + security headers)
|
├── Nginx (reverse proxy, security headers, rate limiting)
|
||||||
├── Rust Backend (JSON-RPC API on port 5678)
|
├── Rust Backend (JSON-RPC API on 127.0.0.1:5678)
|
||||||
│ ├── core/archipelago/ — RPC endpoints, state, identity
|
│ ├── core/archipelago/ — RPC endpoints, auth, identity, federation, mesh
|
||||||
│ ├── core/container/ — Podman client, manifests, health
|
│ ├── core/container/ — PodmanClient (REST API socket), manifests, health
|
||||||
│ └── core/security/ — AppArmor, secrets, image verification
|
│ ├── core/security/ — AppArmor, secrets, Cosign image verification
|
||||||
└── Vue 3 Frontend (Composition API + TypeScript + Pinia)
|
│ └── 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
|
## Documentation
|
||||||
|
|
||||||
- [Architecture](docs/architecture.md) — System design
|
| Doc | Purpose |
|
||||||
- [Developer Guide](docs/developer-guide.md) — Contributing guide
|
|-----|---------|
|
||||||
- [App Developer Guide](docs/app-developer-guide.md) — Writing app manifests
|
| [Architecture](docs/architecture.md) | System design, codebase stats, data paths |
|
||||||
- [App Manifest Spec](docs/app-manifest-spec.md) — YAML manifest format
|
| [Architecture Review (HTML)](docs/architecture-review.html) | Interactive guide with diagrams and learning path |
|
||||||
- [User Guide](docs/user-guide.md) — End-user documentation
|
| [Developer Guide](docs/developer-guide.md) | Dev setup, workflow, code conventions |
|
||||||
- [Release Notes](RELEASE-NOTES-v1.0.0.md) — v1.0.0 release notes
|
| [API Reference](docs/api-reference.md) | Complete RPC endpoint reference |
|
||||||
- [v1.1 Roadmap](docs/roadmap-v1.1.md) — Upcoming features
|
| [App Developer Guide](docs/app-developer-guide.md) | Building and publishing apps |
|
||||||
- [v2.0 Roadmap](docs/roadmap-v2.0.md) — Long-term vision
|
| [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
|
## Contributing
|
||||||
|
|
||||||
1. Fork the repository
|
1. Fork the repository
|
||||||
2. Create a feature branch (`feature/description`)
|
2. Create a feature branch (`feature/description`)
|
||||||
3. Follow the coding standards in [CLAUDE.md](CLAUDE.md)
|
3. Follow the coding standards in [CLAUDE.md](CLAUDE.md)
|
||||||
4. Submit a pull request with tests
|
4. Submit a pull request
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@ -1,151 +1,85 @@
|
|||||||
# Archipelago Apps - Development Guide
|
# Archipelago Apps — Development Guide
|
||||||
|
|
||||||
This directory contains all prepackaged containerized applications for Archipelago NodeOS.
|
|
||||||
|
|
||||||
## App Overview
|
## App Overview
|
||||||
|
|
||||||
### Bitcoin & Lightning
|
### Bitcoin & Lightning
|
||||||
- **bitcoin-core**: Full Bitcoin node (ports: 8332, 8333) - v24.0.0
|
| App | Ports | Version |
|
||||||
- **lnd**: Lightning Network Daemon (ports: 9735, 10009, 8080)
|
|-----|-------|---------|
|
||||||
- **core-lightning**: Core Lightning implementation (ports: 9736, 9835)
|
| bitcoin-knots | 8332 (RPC), 8333 (P2P) | v28.1 |
|
||||||
- **lightning-stack**: Complete Lightning implementation (ports: 9737, 10010, 8087) - v0.12.0
|
| lnd | 9735 (P2P), 10009 (gRPC), 8080 (REST) | v0.17.4-beta |
|
||||||
- **btcpay-server**: Bitcoin payment processor (ports: 80, 443) - v1.12.0
|
| btcpay-server | 23000 (HTTP) | v1.13.5 |
|
||||||
- **mempool**: Blockchain explorer (port: 4080) - v2.5.0
|
| thunderhub | 3010 (HTTP) | v0.13.31 |
|
||||||
- **fedimint**: Federated Bitcoin minting (ports: 8173, 8174) - v0.3.0
|
| mempool | 4080 (HTTP) | v2.5.0 |
|
||||||
|
| electrumx | 50001 (TCP), 50002 (SSL) | latest |
|
||||||
|
| fedimint | 8173 (API), 8174 (Web) | v0.10.0 |
|
||||||
|
|
||||||
### Nostr Relays
|
### Nostr
|
||||||
- **nostr-rs-relay**: High-performance Rust relay (port: 8081)
|
| App | Ports | Version |
|
||||||
- **strfry**: Lightweight C++ relay (port: 8082)
|
|-----|-------|---------|
|
||||||
|
| nostr-rs-relay | 8081 (WebSocket) | v0.9.0 |
|
||||||
|
| nostrudel | 8082 (HTTP) | v0.40.0 |
|
||||||
|
|
||||||
### Web5 & Decentralized Protocols
|
### Self-Hosted
|
||||||
- **web5-dwn**: Decentralized Web Node (port: 3000)
|
| App | Port | Version |
|
||||||
- **did-wallet**: Web5 DID Wallet (port: 8083)
|
|-----|------|---------|
|
||||||
|
| nextcloud | 8084 | v28 |
|
||||||
### Self-Hosted Services
|
| jellyfin | 8096 | v10.8.13 |
|
||||||
- **home-assistant**: Home automation (port: 8123) - v2024.1.0
|
| immich | 2283 | release |
|
||||||
- **grafana**: Monitoring and dashboards (port: 3001) - v10.2.0
|
| photoprism | 2342 | v240915 |
|
||||||
- **ollama**: Local AI models (port: 11434) - v0.1.0
|
| vaultwarden | 8222 | v1.30.0-alpine |
|
||||||
- **searxng**: Privacy search engine (port: 8888) - v2024.1.0
|
| homeassistant | 8123 | v2024.1 |
|
||||||
- **onlyoffice**: Office suite (port: 8088) - v7.5.0
|
| filebrowser | 8083 | v2.27.0 |
|
||||||
- **penpot**: Design platform (port: 8089) - v2.0.0
|
| searxng | 8888 | 2024.11.17 |
|
||||||
|
| ollama | 11434 | v0.5.4 |
|
||||||
### Custom Applications
|
| grafana | 3001 | v10.2.0 |
|
||||||
- **endurain**: Application platform (port: 8085) - v1.0.0
|
| portainer | 9000 | v2.19.4 |
|
||||||
- **morphos-server**: MorphOS server (port: 8086) - v1.0.0
|
| onlyoffice | 8088 | v7.5.1 |
|
||||||
|
| penpot | 8089 | v2.4 |
|
||||||
### 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
|
|
||||||
|
|
||||||
## Building Apps
|
## Building Apps
|
||||||
|
|
||||||
### Build All Apps
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./build.sh
|
cd apps
|
||||||
|
./build.sh # Build all custom apps
|
||||||
|
./build.sh <app-id> # Build specific app
|
||||||
```
|
```
|
||||||
|
|
||||||
### Build Specific App
|
Custom apps with local source: `router`, `did-wallet`, `web5-dwn`. All other apps use official container images.
|
||||||
|
|
||||||
```bash
|
|
||||||
./build.sh <app-id>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Build for Development
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./build.sh <app-id> --dev
|
|
||||||
```
|
|
||||||
|
|
||||||
## App Structure
|
## App Structure
|
||||||
|
|
||||||
Each app directory contains:
|
Each app directory contains:
|
||||||
|
- `manifest.yml` — Container configuration
|
||||||
- `manifest.yml` - App manifest defining container configuration
|
- `Dockerfile` — Image definition (custom apps only)
|
||||||
- `Dockerfile` - Container image definition
|
- `README.md` — App-specific docs (custom apps only)
|
||||||
- `README.md` - App-specific documentation (for custom apps)
|
- `src/` — Source code (custom apps only)
|
||||||
- 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)
|
|
||||||
|
|
||||||
## Running in Development
|
## Running in Development
|
||||||
|
|
||||||
### Using Archipelago Backend
|
The Archipelago backend manages containers via rootless Podman. Install and start apps through the web UI Marketplace or via RPC:
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build the app
|
curl -X POST http://localhost:5959/rpc/v1 \
|
||||||
./build.sh <app-id>
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"method": "container-install", "params": {"manifest_path": "apps/router/manifest.yml"}}'
|
||||||
# Run with Docker/Podman
|
|
||||||
docker run -p <host-port>:<container-port> \
|
|
||||||
-v /tmp/archipelago-dev/<app-id>:/data \
|
|
||||||
archipelago/<app-id>:latest
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Integration with Archipelago
|
### Manual Testing (Podman)
|
||||||
|
|
||||||
Apps are integrated via:
|
```bash
|
||||||
|
# Build
|
||||||
|
./build.sh router
|
||||||
|
|
||||||
1. **Manifest files** - Define app configuration
|
# Run directly with Podman
|
||||||
2. **Container runtime** - Podman/Docker for execution
|
podman run -p 18084:8080 \
|
||||||
3. **Port manager** - Handles port allocation and offsets
|
-v /tmp/archipelago-dev/router:/app/data \
|
||||||
4. **Dev orchestrator** - Manages containers in development
|
localhost/archipelago/router:latest
|
||||||
|
```
|
||||||
|
|
||||||
## Next Steps
|
## Integration Checklist
|
||||||
|
|
||||||
When building the OS image, these apps will be:
|
Adding a new app requires updates in multiple places. See the full checklist in [CLAUDE.md](../CLAUDE.md) under "App Integration Checklist".
|
||||||
1. Built into container images
|
|
||||||
2. Included in the OS image
|
## Port Assignments
|
||||||
3. Available for installation via the UI
|
|
||||||
4. Pre-configured with proper networking and security
|
See [PORTS.md](./PORTS.md) for complete mapping. Dev ports are offset by +10000.
|
||||||
|
|||||||
@ -1,70 +1,46 @@
|
|||||||
# Archipelago App Manifests
|
# 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
|
## App Categories
|
||||||
|
|
||||||
### Bitcoin & Lightning
|
### Bitcoin & Lightning
|
||||||
- `bitcoin-core/` - Bitcoin Core full node (v24.0.0)
|
- **bitcoin-knots** — Full Bitcoin node (v28.1)
|
||||||
- `lnd/` - Lightning Network Daemon
|
- **lnd** — Lightning Network Daemon (v0.17.4-beta)
|
||||||
- `core-lightning/` - Core Lightning (CLN)
|
- **btcpay-server** — Payment processor (v1.13.5)
|
||||||
- `lightning-stack/` - Complete Lightning implementation (v0.12.0)
|
- **thunderhub** — Lightning management UI (v0.13.31)
|
||||||
- `btcpay-server/` - BTCPay Server payment processor (v1.12.0)
|
- **mempool** — Block explorer and fee estimator (v2.5.0)
|
||||||
- `mempool/` - Mempool blockchain explorer (v2.5.0)
|
- **electrumx** — Electrum server
|
||||||
- `fedimint/` - Federated Bitcoin minting (v0.3.0)
|
- **fedimint** — Federated Bitcoin minting (v0.10.0)
|
||||||
|
|
||||||
### Web5 & Decentralized Protocols
|
### Nostr
|
||||||
- `nostr-rs-relay/` - High-performance Nostr relay (Rust)
|
- **nostr-rs-relay** — High-performance Rust relay (v0.9.0)
|
||||||
- `strfry/` - Nostr relay (C++)
|
- **nostrudel** — Nostr web client (v0.40.0)
|
||||||
- `web5-dwn/` - Decentralized Web Node
|
|
||||||
- `did-wallet/` - Web5 wallet with DID support
|
### Web5 & Identity
|
||||||
|
- **web5-dwn** — Decentralized Web Node (v0.4.0)
|
||||||
|
- **did-wallet** — Web5 DID Wallet
|
||||||
|
|
||||||
### Self-Hosted Services
|
### Self-Hosted Services
|
||||||
- `home-assistant/` - Home automation (v2024.1.0)
|
- **nextcloud** (v28), **jellyfin** (v10.8.13), **immich** (release), **photoprism** (v240915)
|
||||||
- `grafana/` - Monitoring and dashboards (v10.2.0)
|
- **vaultwarden** (v1.30.0-alpine), **onlyoffice** (v7.5.1), **penpot** (v2.4)
|
||||||
- `ollama/` - Local AI models (v0.1.0)
|
- **homeassistant** (v2024.1), **filebrowser** (v2.27.0), **searxng** (2024.11.17)
|
||||||
- `searxng/` - Privacy-respecting search engine (v2024.1.0)
|
- **ollama** (v0.5.4), **grafana** (v10.2.0), **portainer** (v2.19.4)
|
||||||
- `onlyoffice/` - Office suite (v7.5.0)
|
|
||||||
- `penpot/` - Design platform (v2.0.0)
|
|
||||||
|
|
||||||
### Custom Applications
|
### Networking
|
||||||
- `endurain/` - Endurain application platform (v1.0.0)
|
- **tailscale** (stable), **nginx-proxy-manager** (v2.12.1)
|
||||||
- `morphos-server/` - MorphOS server (v1.0.0)
|
|
||||||
|
|
||||||
### Mesh Networking & Routing
|
### Custom & External
|
||||||
- `meshtastic/` - Meshtastic LoRa mesh networking
|
- **indeedhub** — Bitcoin documentary streaming (custom build)
|
||||||
- `router/` - Mesh routing and local network management
|
- **router** — Mesh routing and network management
|
||||||
|
- **botfights**, **nwnn**, **484-kitchen**, **call-the-operator**, **arch-presentation**, **syntropy-institute**, **t-zero** — External web apps
|
||||||
|
|
||||||
## Manifest Format
|
## Manifest Format
|
||||||
|
|
||||||
Each app has a `manifest.yml` file defining:
|
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.
|
||||||
- Container image and version
|
|
||||||
- Resource requirements
|
|
||||||
- Dependencies
|
|
||||||
- Security policies
|
|
||||||
- Health checks
|
|
||||||
- Network configuration
|
|
||||||
|
|
||||||
See `docs/app-manifest-spec.md` for the complete specification.
|
## Quick Reference
|
||||||
|
|
||||||
## Quick Start
|
- [PORTS.md](./PORTS.md) — Complete port mapping
|
||||||
|
- [QUICKSTART.md](./QUICKSTART.md) — Build and run apps
|
||||||
### Build All Apps
|
- [DEVELOPMENT.md](./DEVELOPMENT.md) — Development workflow
|
||||||
|
|
||||||
```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.
|
|
||||||
|
|||||||
1
core/Cargo.lock
generated
1
core/Cargo.lock
generated
@ -147,6 +147,7 @@ dependencies = [
|
|||||||
"async-trait",
|
"async-trait",
|
||||||
"chrono",
|
"chrono",
|
||||||
"futures",
|
"futures",
|
||||||
|
"hyper 0.14.32",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"log",
|
"log",
|
||||||
"reqwest",
|
"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
|
// Resolve actual file size from disk
|
||||||
let file_path = content_server::content_file_path(&self.config.data_dir, &item);
|
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();
|
item.size_bytes = metadata.len();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,31 +1,17 @@
|
|||||||
use super::RpcHandler;
|
use super::*;
|
||||||
|
use crate::api::rpc::RpcHandler;
|
||||||
use crate::credentials;
|
use crate::credentials;
|
||||||
use crate::federation::{self, FederatedNode, TrustLevel};
|
use crate::federation::{self, FederatedNode, TrustLevel};
|
||||||
use crate::identity;
|
use crate::identity;
|
||||||
use crate::identity_manager::IdentityManager;
|
|
||||||
use crate::network::dwn_store::DwnStore;
|
use crate::network::dwn_store::DwnStore;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
const FEDERATION_PROTOCOL: &str = "https://archipelago.dev/protocols/federation/v1";
|
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 {
|
impl RpcHandler {
|
||||||
/// federation.invite — Generate an invite code containing our DID + onion for a peer.
|
/// 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 (data, _) = self.state_manager.get_snapshot().await;
|
||||||
let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||||
let onion = data
|
let onion = data
|
||||||
@ -50,7 +36,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// federation.join — Accept an invite code and establish federation with the remote node.
|
/// 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,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<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.
|
/// 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?;
|
let nodes = federation::load_nodes(&self.config.data_dir).await?;
|
||||||
|
|
||||||
// Load credentials to check for federation VCs
|
// Load credentials to check for federation VCs
|
||||||
@ -197,7 +183,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// federation.remove-node — Remove a node from the federation by DID.
|
/// 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,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
@ -218,7 +204,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// federation.set-trust — Change trust level for a federated node.
|
/// 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,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
@ -250,7 +236,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// federation.sync-state — Manually trigger state sync with all federated peers.
|
/// 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?;
|
let nodes = federation::load_nodes(&self.config.data_dir).await?;
|
||||||
|
|
||||||
if nodes.is_empty() {
|
if nodes.is_empty() {
|
||||||
@ -312,7 +298,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// federation.get-state — Return this node's state snapshot (called by peers during sync).
|
/// 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;
|
let (data, _) = self.state_manager.get_snapshot().await;
|
||||||
|
|
||||||
// Build app statuses from package_data
|
// 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.
|
/// 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.
|
/// 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,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
@ -400,7 +386,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// federation.deploy-app — Deploy an app to a remote federated node.
|
/// 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,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<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.
|
/// federation.peer-address-changed — A peer notifies us that their .onion changed.
|
||||||
/// Requires ed25519 signature over "address-changed:{did}:{new_onion}" using the
|
/// Requires ed25519 signature over "address-changed:{did}:{new_onion}" using the
|
||||||
/// peer's known pubkey. This prevents attackers from redirecting federation traffic.
|
/// 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,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<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.
|
/// 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.
|
/// 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,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<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.
|
/// 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.
|
/// 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,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<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::*;
|
||||||
|
use crate::api::rpc::RpcHandler;
|
||||||
use super::RpcHandler;
|
|
||||||
use crate::identity_manager::{IdentityManager, IdentityProfile, IdentityPurpose};
|
use crate::identity_manager::{IdentityManager, IdentityProfile, IdentityPurpose};
|
||||||
use crate::network::did_dht;
|
use crate::network::did_dht;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use nostr_sdk::ToBech32;
|
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 {
|
impl RpcHandler {
|
||||||
/// List all identities with their default status.
|
/// 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,
|
&self,
|
||||||
_params: Option<serde_json::Value>,
|
_params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
@ -52,7 +37,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new identity.
|
/// Create a new identity.
|
||||||
pub(super) async fn handle_identity_create(
|
pub(in crate::api::rpc) async fn handle_identity_create(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
@ -93,7 +78,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get a single identity by ID.
|
/// Get a single identity by ID.
|
||||||
pub(super) async fn handle_identity_get(
|
pub(in crate::api::rpc) async fn handle_identity_get(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
@ -123,7 +108,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Delete an identity.
|
/// Delete an identity.
|
||||||
pub(super) async fn handle_identity_delete(
|
pub(in crate::api::rpc) async fn handle_identity_delete(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
@ -141,7 +126,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Set the default identity.
|
/// Set the default identity.
|
||||||
pub(super) async fn handle_identity_set_default(
|
pub(in crate::api::rpc) async fn handle_identity_set_default(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
@ -159,7 +144,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Sign a message with a specific identity.
|
/// 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,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
@ -185,7 +170,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Verify a signature against a DID.
|
/// Verify a signature against a DID.
|
||||||
pub(super) async fn handle_identity_verify(
|
pub(in crate::api::rpc) async fn handle_identity_verify(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
@ -211,7 +196,7 @@ impl RpcHandler {
|
|||||||
|
|
||||||
/// Resolve a DID to its W3C DID Document.
|
/// Resolve a DID to its W3C DID Document.
|
||||||
/// If no DID is provided, returns the node's own 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,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
@ -243,7 +228,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Verify a DID Document: validate structure, check key material matches DID.
|
/// 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,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
@ -315,7 +300,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create a Nostr keypair linked to an identity.
|
/// 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,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
@ -345,7 +330,7 @@ impl RpcHandler {
|
|||||||
/// - `event_hash` (hex) + `id` — sign a pre-computed hash
|
/// - `event_hash` (hex) + `id` — sign a pre-computed hash
|
||||||
/// - `event` (full event object) — compute NIP-01 hash, fill pubkey, sign
|
/// - `event` (full event object) — compute NIP-01 hash, fill pubkey, sign
|
||||||
/// If `id` is omitted, uses the default identity.
|
/// 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,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
@ -429,7 +414,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// NIP-04 encrypt plaintext for a peer.
|
/// 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,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
@ -447,7 +432,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// NIP-04 decrypt ciphertext from a peer.
|
/// 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,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
@ -465,7 +450,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// NIP-44 encrypt plaintext for a peer.
|
/// 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,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
@ -483,7 +468,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// NIP-44 decrypt ciphertext from a peer.
|
/// 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,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
@ -502,7 +487,7 @@ impl RpcHandler {
|
|||||||
|
|
||||||
/// Resolve a remote peer's DID Document over Tor.
|
/// Resolve a remote peer's DID Document over Tor.
|
||||||
/// Queries the peer's /rpc/ endpoint for identity.resolve-did.
|
/// 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,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<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.
|
/// 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,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
@ -604,7 +589,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// identity.resolve-dht-did — Resolve a did:dht from the DHT.
|
/// 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,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<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.
|
/// 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,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
@ -655,7 +640,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Update profile metadata for an identity.
|
/// 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,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
@ -681,7 +666,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Publish kind 0 (metadata) profile to the local Nostr relay.
|
/// 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,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
@ -705,7 +690,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Export private keys for an identity — REQUIRES password verification.
|
/// 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,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<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.
|
/// 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,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<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::container::DevContainerOrchestrator;
|
||||||
use crate::monitoring::MetricsStore;
|
use crate::monitoring::MetricsStore;
|
||||||
use crate::port_allocator::PortAllocator;
|
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 crate::state::StateManager;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use hyper::{Request, Response, StatusCode};
|
use hyper::{Request, Response, StatusCode};
|
||||||
@ -50,7 +51,7 @@ use middleware::{
|
|||||||
UNAUTHENTICATED_METHODS, CACHEABLE_METHODS,
|
UNAUTHENTICATED_METHODS, CACHEABLE_METHODS,
|
||||||
derive_csrf_token, extract_client_ip, extract_cookie, sanitize_error_message,
|
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).
|
/// Default dev password when no user is set up (matches mock-backend).
|
||||||
pub(crate) const DEV_DEFAULT_PASSWORD: &str = "password123";
|
pub(crate) const DEV_DEFAULT_PASSWORD: &str = "password123";
|
||||||
@ -218,7 +219,7 @@ impl RpcHandler {
|
|||||||
let secret = SessionStore::load_or_create_remember_secret().await;
|
let secret = SessionStore::load_or_create_remember_secret().await;
|
||||||
let mut mac = match HmacSha256::new_from_slice(&secret) {
|
let mut mac = match HmacSha256::new_from_slice(&secret) {
|
||||||
Ok(m) => m,
|
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());
|
mac.update(format!("csrf:{}", token).as_bytes());
|
||||||
match hex::decode(header) {
|
match hex::decode(header) {
|
||||||
@ -273,11 +274,8 @@ impl RpcHandler {
|
|||||||
result: Some(cached),
|
result: Some(cached),
|
||||||
error: None,
|
error: None,
|
||||||
};
|
};
|
||||||
return Ok(Response::builder()
|
let body = serde_json::to_vec(&rpc_resp)?;
|
||||||
.status(StatusCode::OK)
|
return Ok(json_response(StatusCode::OK, &body));
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.body(hyper::Body::from(serde_json::to_string(&rpc_resp)?))
|
|
||||||
.unwrap());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -317,11 +315,7 @@ impl RpcHandler {
|
|||||||
let resp_body = serde_json::to_vec(&rpc_resp)
|
let resp_body = serde_json::to_vec(&rpc_resp)
|
||||||
.context("Failed to serialize response")?;
|
.context("Failed to serialize response")?;
|
||||||
|
|
||||||
let mut response = Response::builder()
|
let mut response = json_response(StatusCode::OK, &resp_body);
|
||||||
.status(StatusCode::OK)
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.body(hyper::Body::from(resp_body))
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Post-dispatch: set cookies for auth-related methods
|
// Post-dispatch: set cookies for auth-related methods
|
||||||
let client_ip = extract_client_ip(&parts.headers);
|
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();
|
let resp_body = serde_json::to_vec(&rpc_resp).unwrap_or_default();
|
||||||
Response::builder()
|
json_response(status, &resp_body)
|
||||||
.status(status)
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.body(hyper::Body::from(resp_body))
|
|
||||||
.unwrap()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a 429 Too Many Requests response.
|
/// 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();
|
let resp_body = serde_json::to_vec(&rpc_resp).unwrap_or_default();
|
||||||
Response::builder()
|
let mut resp = json_response(StatusCode::TOO_MANY_REQUESTS, &resp_body);
|
||||||
.status(StatusCode::TOO_MANY_REQUESTS)
|
resp.headers_mut().insert("Retry-After", cookie_header("60"));
|
||||||
.header("Content-Type", "application/json")
|
resp
|
||||||
.header("Retry-After", "60")
|
|
||||||
.body(hyper::Body::from(resp_body))
|
|
||||||
.unwrap()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply session/CSRF/remember-me cookies after dispatch for auth-related methods.
|
/// 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" };
|
let secure_suffix = if self.config.dev_mode { "" } else { "; Secure" };
|
||||||
response.headers_mut().append(
|
response.headers_mut().append(
|
||||||
"Set-Cookie",
|
"Set-Cookie",
|
||||||
format!("session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0{}", secure_suffix)
|
cookie_header(&format!("session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0{}", secure_suffix)),
|
||||||
.parse()
|
|
||||||
.unwrap(),
|
|
||||||
);
|
);
|
||||||
response.headers_mut().append(
|
response.headers_mut().append(
|
||||||
"Set-Cookie",
|
"Set-Cookie",
|
||||||
format!("csrf_token=; SameSite=Lax; Path=/; Max-Age=0{}", secure_suffix)
|
cookie_header(&format!("csrf_token=; SameSite=Lax; Path=/; Max-Age=0{}", secure_suffix)),
|
||||||
.parse()
|
|
||||||
.unwrap(),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -493,27 +476,21 @@ impl RpcHandler {
|
|||||||
fn set_session_cookie(&self, response: &mut Response<hyper::Body>, token: &str) {
|
fn set_session_cookie(&self, response: &mut Response<hyper::Body>, token: &str) {
|
||||||
response.headers_mut().append(
|
response.headers_mut().append(
|
||||||
"Set-Cookie",
|
"Set-Cookie",
|
||||||
format!("session={}; HttpOnly; SameSite=Lax; Path=/{}", token, self.cookie_suffix())
|
cookie_header(&format!("session={}; HttpOnly; SameSite=Lax; Path=/{}", token, self.cookie_suffix())),
|
||||||
.parse()
|
|
||||||
.unwrap(),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_csrf_cookie(&self, response: &mut Response<hyper::Body>, csrf_token: &str) {
|
fn set_csrf_cookie(&self, response: &mut Response<hyper::Body>, csrf_token: &str) {
|
||||||
response.headers_mut().append(
|
response.headers_mut().append(
|
||||||
"Set-Cookie",
|
"Set-Cookie",
|
||||||
format!("csrf_token={}; SameSite=Lax; Path=/{}", csrf_token, self.cookie_suffix())
|
cookie_header(&format!("csrf_token={}; SameSite=Lax; Path=/{}", csrf_token, self.cookie_suffix())),
|
||||||
.parse()
|
|
||||||
.unwrap(),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_remember_cookie(&self, response: &mut Response<hyper::Body>, remember_token: &str) {
|
fn set_remember_cookie(&self, response: &mut Response<hyper::Body>, remember_token: &str) {
|
||||||
response.headers_mut().append(
|
response.headers_mut().append(
|
||||||
"Set-Cookie",
|
"Set-Cookie",
|
||||||
format!("remember={}; HttpOnly; SameSite=Lax; Path=/; Max-Age={}{}", remember_token, REMEMBER_TTL, self.cookie_suffix())
|
cookie_header(&format!("remember={}; HttpOnly; SameSite=Lax; Path=/; Max-Age={}{}", remember_token, REMEMBER_TTL, self.cookie_suffix())),
|
||||||
.parse()
|
|
||||||
.unwrap(),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 config;
|
||||||
|
mod dependencies;
|
||||||
|
mod install;
|
||||||
mod lifecycle;
|
mod lifecycle;
|
||||||
|
mod progress;
|
||||||
|
mod runtime;
|
||||||
|
mod stacks;
|
||||||
mod validation;
|
mod validation;
|
||||||
|
|
||||||
// Re-export items needed by sibling modules (container.rs, security.rs)
|
// 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};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@ -48,3 +49,19 @@ impl ResponseCache {
|
|||||||
entries.insert(key, (std::time::Instant::now(), value));
|
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 anyhow::{Context, Result};
|
||||||
use tracing::{debug, info};
|
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.
|
/// 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,
|
data_dir: &std::path::Path,
|
||||||
state_manager: &std::sync::Arc<crate::state::StateManager>,
|
state_manager: &std::sync::Arc<crate::state::StateManager>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
@ -221,7 +42,7 @@ async fn push_name_to_peers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Read system uptime from /proc/uptime (seconds since boot).
|
/// 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")
|
let content = tokio::fs::read_to_string("/proc/uptime")
|
||||||
.await
|
.await
|
||||||
.context("Failed to read /proc/uptime")?;
|
.context("Failed to read /proc/uptime")?;
|
||||||
@ -235,7 +56,7 @@ async fn read_uptime() -> Result<f64> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Read load averages from /proc/loadavg.
|
/// 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")
|
let content = tokio::fs::read_to_string("/proc/loadavg")
|
||||||
.await
|
.await
|
||||||
.context("Failed to read /proc/loadavg")?;
|
.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.
|
/// 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?;
|
let snap1 = read_cpu_jiffies().await?;
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
|
||||||
let snap2 = read_cpu_jiffies().await?;
|
let snap2 = read_cpu_jiffies().await?;
|
||||||
@ -304,7 +125,7 @@ async fn read_cpu_jiffies() -> Result<CpuJiffies> {
|
|||||||
|
|
||||||
/// Read memory info from /proc/meminfo.
|
/// Read memory info from /proc/meminfo.
|
||||||
/// Returns (used_bytes, total_bytes).
|
/// 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")
|
let content = tokio::fs::read_to_string("/proc/meminfo")
|
||||||
.await
|
.await
|
||||||
.context("Failed to read /proc/meminfo")?;
|
.context("Failed to read /proc/meminfo")?;
|
||||||
@ -325,7 +146,7 @@ async fn read_meminfo() -> Result<(u64, u64)> {
|
|||||||
Ok((used_bytes, total_bytes))
|
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()
|
val.trim()
|
||||||
.trim_end_matches("kB")
|
.trim_end_matches("kB")
|
||||||
.trim()
|
.trim()
|
||||||
@ -335,7 +156,7 @@ fn parse_meminfo_kb(val: &str) -> Result<u64> {
|
|||||||
|
|
||||||
/// Read disk usage via `df` for the root filesystem.
|
/// Read disk usage via `df` for the root filesystem.
|
||||||
/// Returns (used_bytes, total_bytes).
|
/// 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")
|
let output = tokio::process::Command::new("df")
|
||||||
.args(["--block-size=1", "--output=used,size", "/"])
|
.args(["--block-size=1", "--output=used,size", "/"])
|
||||||
.output()
|
.output()
|
||||||
@ -368,7 +189,7 @@ async fn read_disk_usage() -> Result<(u64, u64)> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Read top 10 processes by CPU from `ps`.
|
/// 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")
|
let output = tokio::process::Command::new("ps")
|
||||||
.args(["--no-headers", "-eo", "pid,%cpu,%mem,comm", "--sort=-%cpu"])
|
.args(["--no-headers", "-eo", "pid,%cpu,%mem,comm", "--sort=-%cpu"])
|
||||||
.output()
|
.output()
|
||||||
@ -410,7 +231,7 @@ const KNOWN_HW_WALLETS: &[(u16, &str)] = &[
|
|||||||
];
|
];
|
||||||
|
|
||||||
/// Scan /sys/bus/usb/devices/ for known hardware wallet vendor IDs.
|
/// 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");
|
let usb_dir = std::path::Path::new("/sys/bus/usb/devices");
|
||||||
if !usb_dir.exists() {
|
if !usb_dir.exists() {
|
||||||
return Ok(Vec::new());
|
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`.
|
/// Prune dangling container images via `podman image prune -f`.
|
||||||
/// Returns estimated bytes freed.
|
/// 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")
|
let output = tokio::process::Command::new("podman")
|
||||||
.args(["image", "prune", "-f"])
|
.args(["image", "prune", "-f"])
|
||||||
.output()
|
.output()
|
||||||
@ -493,7 +314,7 @@ async fn prune_container_images() -> Result<u64> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Prune container build cache via `podman system prune -f`.
|
/// 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)
|
// Just prune volumes and build cache (not containers or images — those are handled above)
|
||||||
let output = tokio::process::Command::new("podman")
|
let output = tokio::process::Command::new("podman")
|
||||||
.args(["volume", "prune", "-f"])
|
.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.
|
/// 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")
|
let output = tokio::process::Command::new("sudo")
|
||||||
.args([
|
.args([
|
||||||
"find",
|
"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.
|
/// 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;
|
let mut freed = 0u64;
|
||||||
|
|
||||||
for dir in &["/tmp", "/var/tmp"] {
|
for dir in &["/tmp", "/var/tmp"] {
|
||||||
@ -582,7 +403,7 @@ async fn clean_temp_files() -> Result<u64> {
|
|||||||
Ok(freed)
|
Ok(freed)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_bytes(bytes: u64) -> String {
|
pub(super) fn format_bytes(bytes: u64) -> String {
|
||||||
const KB: u64 = 1024;
|
const KB: u64 = 1024;
|
||||||
const MB: u64 = KB * 1024;
|
const MB: u64 = KB * 1024;
|
||||||
const GB: u64 = MB * 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.
|
/// 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 mut temps = Vec::new();
|
||||||
let thermal_dir = std::path::Path::new("/sys/class/thermal");
|
let thermal_dir = std::path::Path::new("/sys/class/thermal");
|
||||||
if !thermal_dir.exists() {
|
if !thermal_dir.exists() {
|
||||||
@ -638,135 +459,3 @@ async fn read_temperatures() -> Result<Vec<serde_json::Value>> {
|
|||||||
|
|
||||||
Ok(temps)
|
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;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
let perms = std::fs::Permissions::from_mode(0o600);
|
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
|
// Derive DID and pubkey from the restored key
|
||||||
|
|||||||
@ -42,10 +42,11 @@ async fn read_password() -> String {
|
|||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
let _ = std::fs::set_permissions(
|
let _ = tokio::fs::set_permissions(
|
||||||
SECRETS_PATH,
|
SECRETS_PATH,
|
||||||
std::fs::Permissions::from_mode(0o600),
|
std::fs::Permissions::from_mode(0o600),
|
||||||
);
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
debug!("Bitcoin RPC password generated and saved");
|
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())
|
(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.
|
/// Tor SOCKS5 proxy for outbound onion connections.
|
||||||
pub const TOR_SOCKS_PROXY: &str = "socks5h://127.0.0.1:9050";
|
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 serde::{Deserialize, Serialize};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
use tracing::{info, warn};
|
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.
|
/// 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.
|
/// Survives reboots; used for signing, verification, and node address.
|
||||||
pub struct NodeIdentity {
|
pub struct NodeIdentity {
|
||||||
signing_key: SigningKey,
|
signing_key: SigningKey,
|
||||||
identity_dir: PathBuf,
|
_identity_dir: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NodeIdentity {
|
impl NodeIdentity {
|
||||||
@ -60,7 +60,7 @@ impl NodeIdentity {
|
|||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
signing_key,
|
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))
|
.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.
|
/// Convert Ed25519 pubkey (hex) to did:key format.
|
||||||
|
|||||||
@ -14,7 +14,7 @@ mod bitcoin_rpc;
|
|||||||
mod config;
|
mod config;
|
||||||
mod content_server;
|
mod content_server;
|
||||||
mod crash_recovery;
|
mod crash_recovery;
|
||||||
mod cluster;mod credentials;
|
mod credentials;
|
||||||
mod disk_monitor;
|
mod disk_monitor;
|
||||||
mod health_monitor;
|
mod health_monitor;
|
||||||
mod electrs_status;
|
mod electrs_status;
|
||||||
@ -33,6 +33,7 @@ mod nostr_discovery;
|
|||||||
mod nostr_handshake;
|
mod nostr_handshake;
|
||||||
mod peers;
|
mod peers;
|
||||||
mod server;
|
mod server;
|
||||||
|
mod rate_limit;
|
||||||
mod session;
|
mod session;
|
||||||
mod state;
|
mod state;
|
||||||
mod totp;
|
mod totp;
|
||||||
@ -41,7 +42,7 @@ mod names;
|
|||||||
mod network;
|
mod network;
|
||||||
mod nostr_relays;
|
mod nostr_relays;
|
||||||
mod update;
|
mod update;
|
||||||
mod tpm;mod vpn;
|
mod vpn;
|
||||||
mod webhooks;
|
mod webhooks;
|
||||||
|
|
||||||
use auth::AuthManager;
|
use auth::AuthManager;
|
||||||
|
|||||||
@ -137,14 +137,6 @@ pub enum ManifestDescription {
|
|||||||
Detailed { short: String, long: String },
|
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.
|
/// A discovered marketplace app with trust scoring.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@ -507,11 +499,11 @@ async fn load_or_create_keys(identity_dir: &Path) -> Result<nostr_sdk::prelude::
|
|||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
let sp = secret_path.clone();
|
tokio::fs::set_permissions(
|
||||||
tokio::task::spawn_blocking(move || {
|
&secret_path,
|
||||||
std::fs::set_permissions(sp, std::fs::Permissions::from_mode(0o600))
|
std::fs::Permissions::from_mode(0o600),
|
||||||
})
|
)
|
||||||
.await??;
|
.await?;
|
||||||
}
|
}
|
||||||
Ok(keys)
|
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.
|
//! Emergency alert system and dead man's switch for mesh networking.
|
||||||
//!
|
//!
|
||||||
//! The dead man's switch automatically broadcasts a signed alert with GPS
|
//! 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 anyhow::{Context, Result};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use tracing::{info, warn};
|
|
||||||
|
|
||||||
/// Default dead man's switch interval: 6 hours.
|
/// Default dead man's switch interval: 6 hours.
|
||||||
const DEFAULT_INTERVAL_SECS: u64 = 21600;
|
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.
|
//! Off-grid Bitcoin operations over mesh radio.
|
||||||
//!
|
//!
|
||||||
//! Enables mesh-only nodes (no internet) to:
|
//! Enables mesh-only nodes (no internet) to:
|
||||||
@ -12,11 +14,9 @@ use super::message_types::{
|
|||||||
MeshMessageType, TxRelayPayload, TxRelayResponsePayload, TypedEnvelope,
|
MeshMessageType, TxRelayPayload, TxRelayResponsePayload, TypedEnvelope,
|
||||||
};
|
};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use tracing::{debug, info, warn};
|
use tracing::warn;
|
||||||
|
|
||||||
// ─── Block Header Cache ─────────────────────────────────────────────────
|
// ─── 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.
|
//! Mesh message encryption: X25519 ECDH key agreement + ChaCha20-Poly1305.
|
||||||
//!
|
//!
|
||||||
//! Reuses Archipelago's existing Ed25519 identity infrastructure.
|
//! 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.
|
//! Typed message envelope for mesh communication.
|
||||||
//!
|
//!
|
||||||
//! Wraps all mesh messages in a CBOR envelope with type discrimination,
|
//! 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::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
use tokio::sync::{broadcast, watch};
|
use tokio::sync::watch;
|
||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
const MESH_CONFIG_FILE: &str = "mesh-config.json";
|
const MESH_CONFIG_FILE: &str = "mesh-config.json";
|
||||||
@ -559,11 +559,6 @@ impl MeshService {
|
|||||||
Ok(())
|
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).
|
/// Get a reference to shared state (for RPC handlers).
|
||||||
pub fn shared_state(&self) -> Arc<MeshState> {
|
pub fn shared_state(&self) -> Arc<MeshState> {
|
||||||
Arc::clone(&self.state)
|
Arc::clone(&self.state)
|
||||||
@ -574,11 +569,6 @@ impl MeshService {
|
|||||||
self.dead_man_switch.check_in().await;
|
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.
|
/// Get our DID.
|
||||||
pub fn our_did(&self) -> &str {
|
pub fn our_did(&self) -> &str {
|
||||||
&self.our_did
|
&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.
|
//! Store-and-forward message queue for mesh networking.
|
||||||
//!
|
//!
|
||||||
//! When a destination peer is offline or unreachable, messages are queued
|
//! 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::collections::VecDeque;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info};
|
||||||
|
|
||||||
/// Default time-to-live for queued messages (24 hours).
|
/// Default time-to-live for queued messages (24 hours).
|
||||||
const DEFAULT_TTL_SECS: u64 = 86400;
|
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.
|
//! Meshcore binary frame protocol: constants, encoding, decoding, command builders.
|
||||||
//!
|
//!
|
||||||
//! Frame format (USB serial):
|
//! 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.
|
//! Double Ratchet protocol for forward-secret mesh messaging.
|
||||||
//!
|
//!
|
||||||
//! Implements the Signal protocol's Double Ratchet algorithm:
|
//! Implements the Signal protocol's Double Ratchet algorithm:
|
||||||
@ -13,10 +15,10 @@
|
|||||||
//! Reference: Signal Technical Documentation — Double Ratchet Algorithm
|
//! Reference: Signal Technical Documentation — Double Ratchet Algorithm
|
||||||
|
|
||||||
use super::crypto;
|
use super::crypto;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::Result;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use zeroize::{Zeroize, ZeroizeOnDrop};
|
use zeroize::Zeroize;
|
||||||
|
|
||||||
/// HKDF info string for root key + chain key derivation.
|
/// HKDF info string for root key + chain key derivation.
|
||||||
const KDF_RK_INFO: &[u8] = b"ArchyRatchetRK";
|
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.
|
//! Async serial driver for Meshcore devices.
|
||||||
//!
|
//!
|
||||||
//! Handles opening the serial port, reading/writing frames,
|
//! Handles opening the serial port, reading/writing frames,
|
||||||
|
|||||||
@ -10,7 +10,7 @@ use sha2::{Digest, Sha256};
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, warn};
|
||||||
|
|
||||||
const RATCHET_DIR: &str = "ratchet";
|
const RATCHET_DIR: &str = "ratchet";
|
||||||
|
|
||||||
@ -87,14 +87,6 @@ impl SessionManager {
|
|||||||
self.session_path(did).exists()
|
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.
|
/// Encrypt a message for a peer using their ratchet session.
|
||||||
/// Loads the session from disk if not in memory.
|
/// Loads the session from disk if not in memory.
|
||||||
pub async fn encrypt_for_peer(
|
pub async fn encrypt_for_peer(
|
||||||
@ -166,19 +158,6 @@ impl SessionManager {
|
|||||||
Ok(plaintext)
|
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).
|
/// Get session info for a peer (for RPC status endpoint).
|
||||||
pub async fn session_info(&self, did: &str) -> Option<SessionInfo> {
|
pub async fn session_info(&self, did: &str) -> Option<SessionInfo> {
|
||||||
let sessions = self.sessions.read().await;
|
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).
|
/// 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.
|
//! Steganographic encoding for mesh messages.
|
||||||
//!
|
//!
|
||||||
//! Transforms typed message envelopes into formats that resemble innocuous
|
//! 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.
|
//! Shared types for mesh networking subsystem.
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
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.
|
//! X3DH (Extended Triple Diffie-Hellman) key agreement for mesh sessions.
|
||||||
//!
|
//!
|
||||||
//! Implements the Signal protocol's X3DH using existing Ed25519/X25519 identity
|
//! 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 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::path::PathBuf;
|
||||||
use std::sync::atomic::{AtomicU32, Ordering};
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::RwLock;
|
use tracing::{debug, warn};
|
||||||
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(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Spawn the background metrics collector (runs every 60 seconds).
|
/// 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(
|
pub fn spawn_metrics_collector(
|
||||||
store: Arc<MetricsStore>,
|
store: Arc<MetricsStore>,
|
||||||
state: Option<Arc<crate::state::StateManager>>,
|
state: Option<Arc<crate::state::StateManager>>,
|
||||||
@ -523,71 +32,16 @@ pub fn spawn_metrics_collector(
|
|||||||
|
|
||||||
match collector::collect_snapshot().await {
|
match collector::collect_snapshot().await {
|
||||||
Ok(snapshot) => {
|
Ok(snapshot) => {
|
||||||
// Check alert rules before pushing (needs snapshot data)
|
|
||||||
let alerts = store.check_alerts(&snapshot).await;
|
let alerts = store.check_alerts(&snapshot).await;
|
||||||
|
|
||||||
store.push(snapshot).await;
|
store.push(snapshot).await;
|
||||||
debug!("Metrics snapshot collected");
|
debug!("Metrics snapshot collected");
|
||||||
|
|
||||||
// Push alert notifications via WebSocket
|
|
||||||
if !alerts.is_empty() {
|
if !alerts.is_empty() {
|
||||||
if let Some(ref state_mgr) = state {
|
if let Some(ref state_mgr) = state {
|
||||||
let (mut data, _rev) = state_mgr.get_snapshot().await;
|
notifications::push_alert_notifications(state_mgr, &alerts).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());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fire-and-forget webhook delivery for mapped alert types
|
|
||||||
if let Some(ref dir) = data_dir {
|
if let Some(ref dir) = data_dir {
|
||||||
for alert in &alerts {
|
notifications::deliver_alert_webhooks(dir, &alerts).await;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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 {
|
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> {
|
pub async fn get(&self, did: &str) -> Option<serde_json::Value> {
|
||||||
let entries = self.entries.read().await;
|
let entries = self.entries.read().await;
|
||||||
if let Some((ts, doc)) = entries.get(did) {
|
if let Some((ts, doc)) = entries.get(did) {
|
||||||
|
|||||||
@ -281,20 +281,3 @@ async fn sync_single_peer(
|
|||||||
Ok(imported)
|
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 anyhow::{Context, Result};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use nostr_sdk::pool;
|
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
@ -146,51 +145,6 @@ pub async fn revoke_if_needed(identity_dir: &Path, tor_proxy: Option<&str>) -> R
|
|||||||
Ok(())
|
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).
|
/// Get Nostr public key for this node (hex).
|
||||||
pub async fn get_nostr_pubkey(identity_dir: &Path) -> Result<String> {
|
pub async fn get_nostr_pubkey(identity_dir: &Path) -> Result<String> {
|
||||||
let keys = load_or_create_nostr_keys(identity_dir).await?;
|
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);
|
let identity = Arc::new(identity);
|
||||||
|
|
||||||
// Create metrics store and spawn background collector
|
// 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();
|
let metrics_for_telemetry = metrics_store.clone();
|
||||||
crate::monitoring::spawn_metrics_collector(metrics_store.clone(), Some(state_manager.clone()), Some(config.data_dir.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.
|
/// Serve with a graceful shutdown signal.
|
||||||
/// When the shutdown future completes, stop accepting new connections and drain in-flight requests.
|
/// When the shutdown future completes, stop accepting new connections and drain in-flight requests.
|
||||||
pub async fn serve_with_shutdown(
|
pub async fn serve_with_shutdown(
|
||||||
|
|||||||
@ -2,10 +2,9 @@ use hmac::{Hmac, Mac};
|
|||||||
use rand::RngCore;
|
use rand::RngCore;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::net::IpAddr;
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use zeroize::Zeroize;
|
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.
|
/// Create a full (authenticated) session. Returns the plaintext token.
|
||||||
/// Enforces max concurrent sessions by evicting the oldest if limit reached.
|
/// Enforces max concurrent sessions by evicting the oldest if limit reached.
|
||||||
pub async fn create(&self) -> String {
|
pub async fn create(&self) -> String {
|
||||||
@ -303,6 +283,7 @@ impl SessionStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Remove all expired sessions (cleanup).
|
/// Remove all expired sessions (cleanup).
|
||||||
|
#[cfg(test)]
|
||||||
pub async fn cleanup_expired(&self) {
|
pub async fn cleanup_expired(&self) {
|
||||||
let mut sessions = self.sessions.write().await;
|
let mut sessions = self.sessions.write().await;
|
||||||
sessions.retain(|_, session| {
|
sessions.retain(|_, session| {
|
||||||
@ -336,6 +317,7 @@ impl SessionStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get the number of active full sessions.
|
/// Get the number of active full sessions.
|
||||||
|
#[cfg(test)]
|
||||||
pub async fn active_session_count(&self) -> usize {
|
pub async fn active_session_count(&self) -> usize {
|
||||||
let sessions = self.sessions.read().await;
|
let sessions = self.sessions.read().await;
|
||||||
sessions
|
sessions
|
||||||
@ -447,147 +429,6 @@ pub fn extract_session_cookie(headers: &hyper::HeaderMap) -> Option<String> {
|
|||||||
.filter(|v| !v.is_empty())
|
.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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
@ -669,44 +510,6 @@ mod tests {
|
|||||||
assert_eq!(extract_session_cookie(&headers), None);
|
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]
|
#[tokio::test]
|
||||||
async fn test_session_activity_updates_on_validate() {
|
async fn test_session_activity_updates_on_validate() {
|
||||||
|
|||||||
@ -82,7 +82,7 @@ pub fn setup(password: &str) -> Result<SetupResult> {
|
|||||||
6,
|
6,
|
||||||
1, // skew
|
1, // skew
|
||||||
30,
|
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()),
|
Some("Archipelago".to_string()),
|
||||||
"node".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.
|
//! Chunked message protocol with Reed-Solomon FEC for LoRa transport.
|
||||||
//!
|
//!
|
||||||
//! Splits payloads larger than a single LoRa frame (160 bytes) into
|
//! 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.
|
//! CBOR delta encoding for federation state sync.
|
||||||
//!
|
//!
|
||||||
//! Instead of sending a full NodeStateSnapshot (~500-2000 bytes JSON) on every
|
//! 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.
|
//! LAN transport — peer discovery via mDNS and direct HTTP messaging.
|
||||||
//!
|
//!
|
||||||
//! Advertises this node as `_archipelago._tcp.local.` with TXT records
|
//! 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::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::sync::RwLock;
|
use tracing::{debug, info};
|
||||||
use tracing::{debug, info, warn};
|
|
||||||
|
|
||||||
const SERVICE_TYPE: &str = "_archipelago._tcp.local.";
|
const SERVICE_TYPE: &str = "_archipelago._tcp.local.";
|
||||||
const DEFAULT_PORT: u16 = 5678;
|
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.
|
//! Mesh transport — sends messages via LoRa radio through the MeshService.
|
||||||
//!
|
//!
|
||||||
//! Bridges the transport abstraction to the existing mesh serial listener.
|
//! Bridges the transport abstraction to the existing mesh serial listener.
|
||||||
//! For payloads exceeding the LoRa frame limit (160 bytes), uses the chunking
|
//! For payloads exceeding the LoRa frame limit (160 bytes), uses the chunking
|
||||||
//! protocol with Reed-Solomon FEC for reliable delivery.
|
//! 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 super::{NodeTransport, TransportKind, TransportMessage};
|
||||||
use crate::mesh::MeshService;
|
use crate::mesh::MeshService;
|
||||||
use anyhow::{Context, Result};
|
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.
|
//! Transport abstraction layer for Archipelago node-to-node communication.
|
||||||
//!
|
//!
|
||||||
//! Unifies mesh radio (LoRa), LAN (mDNS), and Tor under a common trait.
|
//! 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.
|
//! Tor transport — sends messages via HTTP POST through SOCKS5 proxy.
|
||||||
//!
|
//!
|
||||||
//! Wraps the existing `node_message.rs` Tor messaging logic behind
|
//! Wraps the existing `node_message.rs` Tor messaging logic behind
|
||||||
//! the `NodeTransport` trait.
|
//! the `NodeTransport` trait.
|
||||||
|
|
||||||
use super::{MessageType, NodeTransport, TransportKind, TransportMessage};
|
use super::{NodeTransport, TransportKind, TransportMessage};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|||||||
@ -5,7 +5,7 @@ use chrono::Timelike;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info};
|
||||||
|
|
||||||
const DEFAULT_UPDATE_MANIFEST_URL: &str =
|
const DEFAULT_UPDATE_MANIFEST_URL: &str =
|
||||||
"https://raw.githubusercontent.com/archipelago-os/releases/main/manifest.json";
|
"https://raw.githubusercontent.com/archipelago-os/releases/main/manifest.json";
|
||||||
@ -301,83 +301,6 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
|
|||||||
Ok(())
|
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.
|
/// Rollback to the previous version from backup.
|
||||||
pub async fn rollback_update(data_dir: &Path) -> Result<()> {
|
pub async fn rollback_update(data_dir: &Path) -> Result<()> {
|
||||||
let backup_dir = data_dir.join("update-backup");
|
let backup_dir = data_dir.join("update-backup");
|
||||||
|
|||||||
@ -6,8 +6,6 @@ use anyhow::{Context, Result};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
use tracing::debug;
|
|
||||||
|
|
||||||
use super::ecash;
|
use super::ecash;
|
||||||
|
|
||||||
const PROFITS_FILE: &str = "wallet/profits.json";
|
const PROFITS_FILE: &str = "wallet/profits.json";
|
||||||
@ -55,42 +53,6 @@ pub async fn load_profits(data_dir: &Path) -> Result<ProfitsSummary> {
|
|||||||
Ok(summary)
|
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.
|
/// Compute a full profits summary including ecash receive transactions.
|
||||||
pub async fn get_networking_profits(data_dir: &Path) -> Result<ProfitsSummary> {
|
pub async fn get_networking_profits(data_dir: &Path) -> Result<ProfitsSummary> {
|
||||||
let mut summary = load_profits(data_dir).await?;
|
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 cap_drop = vec!["ALL".to_string()];
|
||||||
|
|
||||||
let body = serde_json::json!({
|
let body = serde_json::json!({
|
||||||
|
|||||||
@ -38,7 +38,7 @@ pub struct ExpiringSecret {
|
|||||||
pub struct SecretsManager {
|
pub struct SecretsManager {
|
||||||
secrets_dir: PathBuf,
|
secrets_dir: PathBuf,
|
||||||
cipher: Aes256Gcm,
|
cipher: Aes256Gcm,
|
||||||
raw_key: zeroize::Zeroizing<[u8; 32]>,
|
_raw_key: zeroize::Zeroizing<[u8; 32]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SecretsManager {
|
impl SecretsManager {
|
||||||
@ -57,7 +57,7 @@ impl SecretsManager {
|
|||||||
Ok(Self {
|
Ok(Self {
|
||||||
secrets_dir,
|
secrets_dir,
|
||||||
cipher,
|
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() {
|
if !app_path.is_dir() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let app_id = app_path
|
let _app_id = app_path
|
||||||
.file_name()
|
.file_name()
|
||||||
.and_then(|s| s.to_str())
|
.and_then(|s| s.to_str())
|
||||||
.unwrap_or("")
|
.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