refactor: update dependencies and remove unused code
- Added new dependencies: `adler2`, `crc32fast`, `flate2`, `miniz_oxide`, and `libredox`. - Updated existing dependencies: `tokio-rustls` to version 0.26.4 and `filetime` to version 0.2.27. - Removed the `backup.rs` file as it is no longer needed. - Introduced tests for configuration and credential management. - Enhanced the `identity` module to generate W3C compliant DID documents. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2a867b32a8
commit
6fee6befed
11
.claude/memory/MEMORY.md
Normal file
11
.claude/memory/MEMORY.md
Normal file
@ -0,0 +1,11 @@
|
||||
# Archipelago Project Memory Index
|
||||
|
||||
- [pending-features.md](pending-features.md) — Feature requests: kiosk mode, sideloading, Nostr login, etc.
|
||||
- [second-server.md](second-server.md) — Second dev server (archipelago-2 via Tailscale)
|
||||
- [third-server.md](third-server.md) — Third dev server (archipelago-3 via Tailscale)
|
||||
- [deploy-automation.md](deploy-automation.md) — Deploy script automation TODOs
|
||||
- [claude-proxy-setup.md](claude-proxy-setup.md) — Claude proxy OAuth setup details
|
||||
- [project-plan.md](project-plan.md) — Overall project plan status
|
||||
- [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
|
||||
- [web-only-apps.md](web-only-apps.md) — Web-only apps (L484 category) and iframe compatibility
|
||||
9
.claude/memory/claude-proxy-setup.md
Normal file
9
.claude/memory/claude-proxy-setup.md
Normal file
@ -0,0 +1,9 @@
|
||||
---
|
||||
name: claude-proxy-oauth-primary-only
|
||||
description: Only the primary server (192.168.1.228) uses OAuth proxy for Claude. Second server uses standard API key.
|
||||
type: feedback
|
||||
---
|
||||
|
||||
- Only the primary dev server (192.168.1.228) uses the Claude Max OAuth proxy for AIUI
|
||||
- The second dev server and any other deployments should use a standard Anthropic API key (`ANTHROPIC_API_KEY` env var)
|
||||
- Do not replicate the OAuth proxy setup to other servers
|
||||
18
.claude/memory/deploy-automation.md
Normal file
18
.claude/memory/deploy-automation.md
Normal file
@ -0,0 +1,18 @@
|
||||
---
|
||||
name: deploy-automation-todos
|
||||
description: Items to incorporate into the deploy script for automated setup — Claude API key, AIUI nginx config, swap
|
||||
type: project
|
||||
---
|
||||
|
||||
## Deploy Script Automation TODOs
|
||||
|
||||
These items should be added to `scripts/deploy-to-target.sh` so new server deploys are self-contained:
|
||||
|
||||
1. **Claude API key setup**: After deploying the proxy, auto-configure `/opt/archipelago/claude-proxy/.env.local` with the `ANTHROPIC_API_KEY`. On non-primary servers, use the standard API key (not OAuth). The key is: `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`
|
||||
|
||||
2. **AIUI nginx location block**: Ensure nginx config includes a proper `/aiui/` location block so static JS/CSS files are served with correct MIME types. Without this, AIUI fails to load modules.
|
||||
|
||||
3. **Swap space**: Deploy script should check for swap and create 4GB if missing (`fallocate -l 4G /swapfile && mkswap && swapon + fstab entry`).
|
||||
|
||||
4. **Primary server (192.168.1.228)**: 4GB swap configured on 2026-03-11.
|
||||
5. **Second server (archipelago-2)**: 4GB swap configured on 2026-03-11.
|
||||
26
.claude/memory/pending-features.md
Normal file
26
.claude/memory/pending-features.md
Normal file
@ -0,0 +1,26 @@
|
||||
---
|
||||
name: pending-ui-features
|
||||
description: Feature requests — completed and pending items for the next deployment cycle
|
||||
type: project
|
||||
---
|
||||
|
||||
## Completed (2026-03-11)
|
||||
|
||||
1. **IndieHub in iframe** — Restored. Removed forced new-tab check in `mustOpenInNewTab()`.
|
||||
2. **App uninstall fix** — Backend now logs errors and returns structured response instead of silently swallowing.
|
||||
3. **Login music stops after auth** — Added `stopAllAudio()` + router afterEach guard.
|
||||
4. **Container scanner dev_mode gate removed** — Scanner runs always now.
|
||||
5. **BotFights app** — Added as web-only app with SVG icon. Opens in new tab (X-Frame-Options blocks iframe).
|
||||
6. **L484 web apps** — Added 6 web-only apps: NWNN, 484 Kitchen, Call the Operator, Arch Presentation, Syntropy Institute, T-0. L484 category in marketplace.
|
||||
7. **Kiosk mode** — `/kiosk` route added, `setup-kiosk.sh` installs systemd service, systemd units in image-recipe/configs/. No full-screen iframe overlay — uses standard appLauncher.
|
||||
8. **AIUI first-install fix** — nginx `try_files` changed to `=404`, Chat.vue probes AIUI availability before loading iframe.
|
||||
9. **Web-only apps in My Apps** — Injected synthetic PackageDataEntry objects in Apps.vue. Web-only apps sorted first (alphabetically before container apps). No uninstall/start/stop buttons. Launch uses appLauncher with correct URLs.
|
||||
|
||||
## Pending
|
||||
|
||||
1. **Nostr NIP-07 login for containers** — Sign into container apps using onboarding Nostr keys. Not started.
|
||||
2. **App sideloading** — Settings page to load apps via Docker/OCI image URL. Not started.
|
||||
3. **Encrypted Nostr peer handshake (NIP-04/NIP-44)** — Exchange Tor onion addresses via encrypted DMs instead of public relay events. Not started. Currently onion addresses are published in plaintext on relays.
|
||||
4. **Third server deploy** — archipelago-3.tail2b6225.ts.net needs SSH key setup and first deploy.
|
||||
5. **Kiosk auto-start on servers** — setup-kiosk.sh exists but needs to be run on each server that has a display attached. Not confirmed running on .228.
|
||||
6. **Deploy to .198** — Secondary server not yet deployed with latest changes.
|
||||
23
.claude/memory/second-server.md
Normal file
23
.claude/memory/second-server.md
Normal file
@ -0,0 +1,23 @@
|
||||
---
|
||||
name: second-dev-server
|
||||
description: Second dev server accessible via Tailscale at archipelago-2.tail2b6225.ts.net, Ryzen 7 7840U, 14GB RAM
|
||||
type: project
|
||||
---
|
||||
|
||||
- Hostname: archipelago-2.tail2b6225.ts.net (Tailscale)
|
||||
- SSH: `ssh -i ~/.ssh/archipelago-deploy archipelago@archipelago-2.tail2b6225.ts.net`
|
||||
- Password: ThunderDome6574839201!
|
||||
- CPU: AMD Ryzen 7 7840U (faster than primary i3-8100T)
|
||||
- RAM: 14GB
|
||||
- Disk: 916GB NVMe
|
||||
- OS: Debian 12 (Bookworm) x86_64
|
||||
- Has: Podman 4.3.1, Node.js v20.20.1, Rust 1.94.0, Nginx 1.22.1
|
||||
- Swap: 4GB configured
|
||||
- Deploy: `ARCHIPELAGO_TARGET="archipelago@archipelago-2.tail2b6225.ts.net" ./scripts/deploy-to-target.sh --live`
|
||||
- Does NOT use OAuth proxy — uses standard ANTHROPIC_API_KEY for Claude/AIUI
|
||||
- First-boot containers created on 2026-03-11 (Bitcoin Knots, LND, Fedimint, PhotoPrism, Ollama, etc.)
|
||||
|
||||
## Pending Fixes for Next Deploy
|
||||
- **AIUI MIME type error**: Nginx needs a `/aiui/` location block serving correct MIME types for JS files. Currently JS files get wrong content-type causing module load failures.
|
||||
- **Self-signed cert warnings**: Expected on fresh deploy, not a bug.
|
||||
- **Container connection errors in AIUI console**: Expected until all containers finish starting and syncing.
|
||||
12
.claude/memory/third-server.md
Normal file
12
.claude/memory/third-server.md
Normal file
@ -0,0 +1,12 @@
|
||||
---
|
||||
name: third-dev-server
|
||||
description: Third dev server accessible via Tailscale at archipelago-3.tail2b6225.ts.net, password ThisIsWeb54321@
|
||||
type: project
|
||||
---
|
||||
|
||||
- Hostname: archipelago-3.tail2b6225.ts.net (Tailscale)
|
||||
- SSH: `sshpass -p 'ThisIsWeb54321@' ssh -o StrictHostKeyChecking=no archipelago@archipelago-3.tail2b6225.ts.net`
|
||||
- Password: ThisIsWeb54321@
|
||||
- Deploy: `ARCHIPELAGO_TARGET="archipelago@archipelago-3.tail2b6225.ts.net" ./scripts/deploy-to-target.sh --live`
|
||||
- SSH key NOT yet installed — need to copy `~/.ssh/archipelago-deploy.pub` manually
|
||||
- Added 2026-03-11
|
||||
34
.claude/memory/web-only-apps.md
Normal file
34
.claude/memory/web-only-apps.md
Normal file
@ -0,0 +1,34 @@
|
||||
---
|
||||
name: web-only-apps
|
||||
description: Web-only apps (no container) — L484 category, BotFights, IndieHub. Iframe compatibility, nginx proxying, My Apps injection.
|
||||
type: project
|
||||
---
|
||||
|
||||
## Web-Only Apps (added 2026-03-11)
|
||||
|
||||
These apps are external websites embedded via iframe — no Docker container. They show as "installed" in both the marketplace and My Apps.
|
||||
|
||||
### L484 Category
|
||||
- **NWNN** (nwnn.l484.com) — News aggregator. No X-Frame-Options. Works in iframe directly.
|
||||
- **484 Kitchen** (484.kitchen) — K484 platform. X-Frame-Options: SAMEORIGIN. Proxied via `/ext/484-kitchen/`.
|
||||
- **Call the Operator** (cta.tx1138.com) — Decentralization portal. No X-Frame-Options. Works in iframe directly.
|
||||
- **Arch Presentation** (present.l484.com) — Archipelago presentation. X-Frame-Options: SAMEORIGIN. Proxied via `/ext/arch-presentation/`.
|
||||
- **Syntropy Institute** (syntropy.institute) — Medicine Reimagined. No X-Frame-Options. Works in iframe directly.
|
||||
- **T-0** (teeminuszero.net) — Decentralization documentary. No X-Frame-Options. Works in iframe directly.
|
||||
|
||||
### Other Web-Only Apps
|
||||
- **BotFights** (botfights.net) — X-Frame-Options: SAMEORIGIN + CSP + COEP/COOP/CORP. Proxied via `/ext/botfights/`. Nginx strips all blocking headers.
|
||||
- **IndeeHub** (archipelago.indeehub.studio) — No X-Frame-Options. Works in iframe directly.
|
||||
|
||||
### Nginx External Proxies
|
||||
Sites with X-Frame-Options get reverse-proxied through nginx at `/ext/{app-id}/`:
|
||||
- `proxy_hide_header X-Frame-Options` strips upstream header
|
||||
- `add_header X-Content-Type-Options "nosniff" always` prevents server-level X-Frame-Options inheritance
|
||||
- BotFights also strips `Cross-Origin-Embedder-Policy`, `Cross-Origin-Opener-Policy`, `Cross-Origin-Resource-Policy`
|
||||
- Proxy locations in both HTTP and HTTPS server blocks of nginx-archipelago.conf
|
||||
|
||||
### Frontend Implementation
|
||||
- **appLauncher.ts**: `EXTERNAL_PROXY` map rewrites external URLs to proxy paths in `toEmbeddableUrl()`
|
||||
- **Apps.vue**: `WEB_ONLY_APPS` constant with synthetic `PackageDataEntry` objects. Sorted first alphabetically. No uninstall/start/stop buttons.
|
||||
- **Marketplace.vue**: `dockerImage: ''` + `webUrl` in `getCuratedAppList()`. L484 category.
|
||||
- **Icons**: `neode-ui/public/assets/img/app-icons/{app-id}.png` (or .svg)
|
||||
84
RELEASE-NOTES-v0.5.0-beta.md
Normal file
84
RELEASE-NOTES-v0.5.0-beta.md
Normal file
@ -0,0 +1,84 @@
|
||||
# Archipelago v0.5.0-beta Release Notes
|
||||
|
||||
**Release Date**: March 2026
|
||||
**Target Platform**: Debian 12 (Bookworm) — x86_64 and ARM64
|
||||
|
||||
## Overview
|
||||
|
||||
This is the first public beta of Archipelago, a self-sovereign Bitcoin Node OS. Flash it to a USB, install on any x86_64 or ARM64 machine, and manage your personal server through a modern web interface.
|
||||
|
||||
## What's Included
|
||||
|
||||
### Core System
|
||||
- Rust backend with RPC API and WebSocket real-time updates
|
||||
- Vue 3 frontend with glassmorphism UI design
|
||||
- Automated Podman container management
|
||||
- Nginx reverse proxy with HTTPS (self-signed cert)
|
||||
- Tor hidden services for all apps
|
||||
|
||||
### App Store (16+ Apps)
|
||||
- **Bitcoin Stack**: Bitcoin Knots, Electrs, LND, BTCPay Server, Mempool, Fedimint
|
||||
- **Storage**: File Browser, Immich, PhotoPrism
|
||||
- **Productivity**: Penpot, SearXNG
|
||||
- **AI**: Ollama (local LLMs)
|
||||
- **Network**: Nostr Relay, Nginx Proxy Manager, Tailscale, Home Assistant
|
||||
- **Platform**: IndeedHub
|
||||
|
||||
### Security
|
||||
- AES-256-GCM encrypted secrets on disk
|
||||
- Session management: 24h inactivity expiry, max 5 concurrent sessions
|
||||
- TOTP two-factor authentication with backup codes
|
||||
- Container hardening: read-only root, no new privileges, dropped capabilities
|
||||
- Pinned container image versions (no `:latest` tags)
|
||||
- Login rate limiting (5 attempts per 60 seconds per IP)
|
||||
- Path traversal prevention (nginx + client-side)
|
||||
- Cookie-based auth (no tokens in URLs)
|
||||
|
||||
### Identity & Web5
|
||||
- Decentralized Identifier (DID) generation
|
||||
- Identity backup/restore
|
||||
- Nostr relay support
|
||||
|
||||
### Performance
|
||||
- Backend startup: ~100ms
|
||||
- Frontend bundle: ~105 KB gzipped
|
||||
- WebSocket heartbeat with 30s ping/pong
|
||||
- Exponential backoff reconnection (max 30s)
|
||||
- Real-time install progress via WebSocket
|
||||
- Server-side 5-minute inactivity timeout for stale connections
|
||||
|
||||
## Known Issues
|
||||
|
||||
1. **ARM64 ISO**: ARM64 builds may require manual testing — primary testing is on x86_64
|
||||
2. **Bitcoin Initial Sync**: First blockchain sync takes 1-7 days depending on hardware
|
||||
3. **Self-signed HTTPS**: Browser shows certificate warning on first visit (expected)
|
||||
4. **Restore from Backup**: Not yet implemented in onboarding flow
|
||||
5. **Connect to Existing Server**: Not yet implemented in onboarding flow
|
||||
6. **Immich**: Stack installation may take 5+ minutes due to multiple container images
|
||||
7. **Memory**: Running all apps simultaneously requires 16+ GB RAM
|
||||
8. **Disk Space**: Full Bitcoin node + all apps requires 800+ GB
|
||||
|
||||
## Upgrade Path
|
||||
|
||||
This is a beta release. No upgrade path from beta to stable is guaranteed. Back up your data before installing.
|
||||
|
||||
## System Requirements
|
||||
|
||||
| Component | Minimum | Recommended |
|
||||
|-----------|---------|-------------|
|
||||
| CPU | 4 cores | 8+ cores |
|
||||
| RAM | 16 GB | 32 GB |
|
||||
| Storage | 500 GB SSD | 2 TB NVMe |
|
||||
| Network | Ethernet | Gigabit Ethernet |
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Download the ISO for your architecture
|
||||
2. Flash to USB with balenaEtcher or `dd`
|
||||
3. Boot from USB on target hardware
|
||||
4. Auto-installer runs — follow on-screen prompts
|
||||
5. After reboot, navigate to `http://<server-ip>` in your browser
|
||||
6. Complete the onboarding wizard
|
||||
7. Start installing apps from the App Store
|
||||
|
||||
See [User Guide](docs/user-guide.md) for detailed instructions.
|
||||
22
core/.cargo/config.toml
Normal file
22
core/.cargo/config.toml
Normal file
@ -0,0 +1,22 @@
|
||||
# Cargo configuration for Archipelago cross-compilation
|
||||
#
|
||||
# Native builds (x86_64 on x86_64) work automatically.
|
||||
# ARM64 cross-compilation requires the aarch64-unknown-linux-gnu toolchain.
|
||||
#
|
||||
# Install the target:
|
||||
# rustup target add aarch64-unknown-linux-gnu
|
||||
#
|
||||
# Install the cross-linker (Debian/Ubuntu):
|
||||
# sudo apt install gcc-aarch64-linux-gnu
|
||||
#
|
||||
# Build for ARM64:
|
||||
# cargo build --release --target aarch64-unknown-linux-gnu
|
||||
|
||||
[target.aarch64-unknown-linux-gnu]
|
||||
linker = "aarch64-linux-gnu-gcc"
|
||||
|
||||
# OpenSSL cross-compilation environment (set before building)
|
||||
# These are automatically set by the build scripts but documented here:
|
||||
# OPENSSL_DIR=/usr/aarch64-linux-gnu
|
||||
# PKG_CONFIG_PATH=/usr/lib/aarch64-linux-gnu/pkgconfig
|
||||
# PKG_CONFIG_ALLOW_CROSS=1
|
||||
300
core/Cargo.lock
generated
300
core/Cargo.lock
generated
@ -2,6 +2,12 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aead"
|
||||
version = "0.5.2"
|
||||
@ -76,8 +82,10 @@ dependencies = [
|
||||
"bs58",
|
||||
"chacha20poly1305",
|
||||
"chrono",
|
||||
"curve25519-dalek",
|
||||
"data-encoding",
|
||||
"ed25519-dalek",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"http-body 1.0.1",
|
||||
@ -94,6 +102,8 @@ dependencies = [
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"sha2",
|
||||
"tar",
|
||||
"tempfile",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tokio-test",
|
||||
@ -161,9 +171,11 @@ dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"hex",
|
||||
"log",
|
||||
"rand 0.8.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
@ -223,7 +235,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"js-sys",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tokio-socks",
|
||||
"tokio-tungstenite 0.26.2",
|
||||
"url",
|
||||
@ -519,6 +531,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
@ -685,33 +706,39 @@ version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"libredox",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||
dependencies = [
|
||||
"foreign-types-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
@ -1062,16 +1089,17 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-tls"
|
||||
version = "0.5.0"
|
||||
name = "hyper-rustls"
|
||||
version = "0.24.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905"
|
||||
checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http 0.2.12",
|
||||
"hyper 0.14.32",
|
||||
"native-tls",
|
||||
"rustls 0.21.12",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls 0.24.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1335,6 +1363,18 @@ version = "0.2.180"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"libc",
|
||||
"plain",
|
||||
"redox_syscall 0.7.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.11.0"
|
||||
@ -1389,6 +1429,16 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.1.1"
|
||||
@ -1410,23 +1460,6 @@ dependencies = [
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"openssl",
|
||||
"openssl-probe",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "negentropy"
|
||||
version = "0.5.0"
|
||||
@ -1540,50 +1573,6 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.75"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"cfg-if",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"openssl-macros",
|
||||
"openssl-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.111"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
@ -1602,7 +1591,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
"redox_syscall 0.5.18",
|
||||
"smallvec",
|
||||
"windows-link",
|
||||
]
|
||||
@ -1657,10 +1646,10 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.32"
|
||||
name = "plain"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||
|
||||
[[package]]
|
||||
name = "poly1305"
|
||||
@ -1810,6 +1799,15 @@ dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.2"
|
||||
@ -1854,15 +1852,15 @@ dependencies = [
|
||||
"http 0.2.12",
|
||||
"http-body 0.4.6",
|
||||
"hyper 0.14.32",
|
||||
"hyper-tls",
|
||||
"hyper-rustls",
|
||||
"ipnet",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"native-tls",
|
||||
"once_cell",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustls 0.21.12",
|
||||
"rustls-pemfile",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@ -1870,13 +1868,14 @@ dependencies = [
|
||||
"sync_wrapper",
|
||||
"system-configuration 0.5.1",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls 0.24.1",
|
||||
"tokio-socks",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"webpki-roots 0.25.4",
|
||||
"winreg",
|
||||
]
|
||||
|
||||
@ -1916,6 +1915,18 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.21.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e"
|
||||
dependencies = [
|
||||
"log",
|
||||
"ring",
|
||||
"rustls-webpki 0.101.7",
|
||||
"sct",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.36"
|
||||
@ -1925,7 +1936,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"rustls-webpki 0.103.9",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
@ -1948,6 +1959,16 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.101.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.9"
|
||||
@ -1980,15 +2001,6 @@ dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
@ -2007,6 +2019,16 @@ dependencies = [
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sct"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "secp256k1"
|
||||
version = "0.29.1"
|
||||
@ -2027,29 +2049,6 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.27"
|
||||
@ -2200,6 +2199,12 @@ dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.11"
|
||||
@ -2324,6 +2329,17 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
"xattr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.24.0"
|
||||
@ -2449,12 +2465,12 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-native-tls"
|
||||
version = "0.3.1"
|
||||
name = "tokio-rustls"
|
||||
version = "0.24.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
||||
checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081"
|
||||
dependencies = [
|
||||
"native-tls",
|
||||
"rustls 0.21.12",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
@ -2464,7 +2480,7 @@ version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"rustls 0.23.36",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
@ -2522,10 +2538,10 @@ checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"rustls",
|
||||
"rustls 0.23.36",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tungstenite 0.26.2",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
@ -2736,7 +2752,7 @@ dependencies = [
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.9.2",
|
||||
"rustls",
|
||||
"rustls 0.23.36",
|
||||
"rustls-pki-types",
|
||||
"sha1",
|
||||
"thiserror 2.0.18",
|
||||
@ -2834,12 +2850,6 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
@ -2939,6 +2949,12 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.25.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1"
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
@ -3289,6 +3305,16 @@ version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.1"
|
||||
|
||||
@ -38,6 +38,7 @@ impl RpcHandler {
|
||||
pub(super) async fn handle_auth_change_password(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
session_token: &Option<String>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let current_password = params
|
||||
@ -57,7 +58,12 @@ impl RpcHandler {
|
||||
.change_password(current_password, new_password, also_change_ssh)
|
||||
.await?;
|
||||
|
||||
Ok(serde_json::json!({ "success": true }))
|
||||
// Session rotation: invalidate all other sessions, rotate the caller's session
|
||||
if let Some(token) = session_token {
|
||||
self.session_store.invalidate_all_except(token).await;
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({ "success": true, "session_rotated": true }))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_auth_onboarding_complete(&self) -> Result<serde_json::Value> {
|
||||
|
||||
@ -57,13 +57,15 @@ impl RpcHandler {
|
||||
)
|
||||
.await?;
|
||||
|
||||
let status = if credentials::is_revoked(&vc) { "revoked" } else { "active" };
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"id": vc.id,
|
||||
"issuer": vc.issuer,
|
||||
"subject": vc.subject,
|
||||
"subject": vc.credential_subject.id,
|
||||
"type": vc.credential_type,
|
||||
"issued_at": vc.issued_at,
|
||||
"status": vc.status,
|
||||
"issued_at": vc.issuance_date,
|
||||
"status": status,
|
||||
}))
|
||||
}
|
||||
|
||||
@ -97,10 +99,12 @@ impl RpcHandler {
|
||||
})
|
||||
})?;
|
||||
|
||||
let status = if credentials::is_revoked(vc) { "revoked" } else { "active" };
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"id": vc.id,
|
||||
"valid": valid,
|
||||
"status": vc.status,
|
||||
"status": status,
|
||||
}))
|
||||
}
|
||||
|
||||
@ -118,15 +122,17 @@ impl RpcHandler {
|
||||
let items: Vec<serde_json::Value> = creds
|
||||
.into_iter()
|
||||
.map(|c| {
|
||||
let status = if credentials::is_revoked(&c) { "revoked" } else { "active" };
|
||||
serde_json::json!({
|
||||
"@context": c.context,
|
||||
"id": c.id,
|
||||
"issuer": c.issuer,
|
||||
"subject": c.subject,
|
||||
"type": c.credential_type,
|
||||
"claims": c.claims,
|
||||
"issued_at": c.issued_at,
|
||||
"expires_at": c.expires_at,
|
||||
"status": c.status,
|
||||
"issuer": c.issuer,
|
||||
"credentialSubject": c.credential_subject,
|
||||
"issuanceDate": c.issuance_date,
|
||||
"expirationDate": c.expiration_date,
|
||||
"proof": c.proof,
|
||||
"status": status,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@ -147,4 +153,86 @@ impl RpcHandler {
|
||||
credentials::revoke_credential(&self.config.data_dir, id).await?;
|
||||
Ok(serde_json::json!({ "ok": true }))
|
||||
}
|
||||
|
||||
/// Create a Verifiable Presentation bundling selected credentials.
|
||||
pub(super) async fn handle_identity_create_presentation(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let holder_id = params
|
||||
.get("holder_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing holder_id"))?;
|
||||
let credential_ids: Vec<&str> = params
|
||||
.get("credential_ids")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing credential_ids array"))?
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.collect();
|
||||
|
||||
if credential_ids.is_empty() {
|
||||
return Err(anyhow::anyhow!("credential_ids must not be empty"));
|
||||
}
|
||||
|
||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||
let holder_record = manager.get(holder_id).await?;
|
||||
let holder_did = holder_record.did.clone();
|
||||
|
||||
let store = credentials::load_credentials(&self.config.data_dir).await?;
|
||||
|
||||
let data_dir = self.config.data_dir.clone();
|
||||
let sign_id = holder_id.to_string();
|
||||
|
||||
let vp = credentials::create_presentation(
|
||||
&holder_did,
|
||||
&credential_ids,
|
||||
&store.credentials,
|
||||
|bytes| {
|
||||
let hex_msg = hex::encode(bytes);
|
||||
tokio::task::block_in_place(|| {
|
||||
let rt = tokio::runtime::Handle::current();
|
||||
rt.block_on(async {
|
||||
let mgr = IdentityManager::new(&data_dir).await?;
|
||||
mgr.sign(&sign_id, hex_msg.as_bytes()).await
|
||||
})
|
||||
})
|
||||
},
|
||||
)?;
|
||||
|
||||
Ok(serde_json::to_value(&vp)?)
|
||||
}
|
||||
|
||||
/// Verify a Verifiable Presentation: check holder proof and all embedded credentials.
|
||||
pub(super) async fn handle_identity_verify_presentation(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let presentation = params
|
||||
.get("presentation")
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing presentation"))?;
|
||||
|
||||
let vp: credentials::VerifiablePresentation =
|
||||
serde_json::from_value(presentation.clone())?;
|
||||
|
||||
let data_dir = self.config.data_dir.clone();
|
||||
let result = credentials::verify_presentation(&vp, |did, bytes, signature| {
|
||||
let hex_msg = hex::encode(bytes);
|
||||
tokio::task::block_in_place(|| {
|
||||
let rt = tokio::runtime::Handle::current();
|
||||
rt.block_on(async {
|
||||
let mgr = IdentityManager::new(&data_dir).await?;
|
||||
mgr.verify(did, hex_msg.as_bytes(), signature).await
|
||||
})
|
||||
})
|
||||
})?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"valid": result.valid,
|
||||
"holder_valid": result.holder_valid,
|
||||
"credentials": result.credentials,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
use super::RpcHandler;
|
||||
use crate::network::dwn_store::{DwnStore, MessageQuery, ProtocolDefinition};
|
||||
use crate::network::dwn_sync;
|
||||
use crate::peers;
|
||||
use anyhow::Result;
|
||||
@ -12,13 +13,18 @@ impl RpcHandler {
|
||||
version: String::new(),
|
||||
});
|
||||
|
||||
let store = DwnStore::new(&self.config.data_dir).await?;
|
||||
let stats = store.stats().await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"running": server_status.running,
|
||||
"version": server_status.version,
|
||||
"sync_status": sync_state.status,
|
||||
"last_sync": sync_state.last_sync,
|
||||
"messages_synced": sync_state.messages_synced,
|
||||
"storage_bytes": sync_state.storage_bytes,
|
||||
"storage_bytes": stats.total_bytes,
|
||||
"message_count": stats.message_count,
|
||||
"protocol_count": stats.protocol_count,
|
||||
"registered_protocols": sync_state.registered_protocols,
|
||||
"peer_sync_targets": sync_state.peer_sync_targets,
|
||||
}))
|
||||
@ -26,7 +32,6 @@ impl RpcHandler {
|
||||
|
||||
/// Trigger DWN sync with connected peers.
|
||||
pub(super) async fn handle_dwn_sync(&self) -> Result<serde_json::Value> {
|
||||
// Get list of connected peers' onion addresses
|
||||
let peer_list = peers::load_peers(&self.config.data_dir).await?;
|
||||
let onions: Vec<String> = peer_list
|
||||
.iter()
|
||||
@ -42,4 +47,97 @@ impl RpcHandler {
|
||||
"messages_synced": state.messages_synced,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Register a DWN protocol.
|
||||
pub(super) async fn handle_dwn_register_protocol(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let protocol = params["protocol"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'protocol' parameter"))?;
|
||||
let published = params["published"].as_bool().unwrap_or(false);
|
||||
|
||||
let definition = ProtocolDefinition {
|
||||
protocol: protocol.to_string(),
|
||||
published,
|
||||
types: params
|
||||
.get("types")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or_default(),
|
||||
structure: params
|
||||
.get("structure")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or_default(),
|
||||
date_registered: chrono::Utc::now().to_rfc3339(),
|
||||
};
|
||||
|
||||
let store = DwnStore::new(&self.config.data_dir).await?;
|
||||
store.register_protocol(&definition).await?;
|
||||
|
||||
Ok(serde_json::json!({"registered": true, "protocol": protocol}))
|
||||
}
|
||||
|
||||
/// List registered DWN protocols.
|
||||
pub(super) async fn handle_dwn_list_protocols(&self) -> Result<serde_json::Value> {
|
||||
let store = DwnStore::new(&self.config.data_dir).await?;
|
||||
let protocols = store.list_protocols().await?;
|
||||
Ok(serde_json::json!({"protocols": protocols}))
|
||||
}
|
||||
|
||||
/// Remove a DWN protocol.
|
||||
pub(super) async fn handle_dwn_remove_protocol(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let protocol = params["protocol"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'protocol' parameter"))?;
|
||||
|
||||
let store = DwnStore::new(&self.config.data_dir).await?;
|
||||
let removed = store.remove_protocol(protocol).await?;
|
||||
|
||||
Ok(serde_json::json!({"removed": removed, "protocol": protocol}))
|
||||
}
|
||||
|
||||
/// Query DWN messages.
|
||||
pub(super) async fn handle_dwn_query_messages(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let query = MessageQuery {
|
||||
protocol: params["protocol"].as_str().map(|s| s.to_string()),
|
||||
schema: params["schema"].as_str().map(|s| s.to_string()),
|
||||
author: params["author"].as_str().map(|s| s.to_string()),
|
||||
date_from: params["dateFrom"].as_str().map(|s| s.to_string()),
|
||||
date_to: params["dateTo"].as_str().map(|s| s.to_string()),
|
||||
limit: params["limit"].as_u64().map(|n| n as usize),
|
||||
};
|
||||
|
||||
let store = DwnStore::new(&self.config.data_dir).await?;
|
||||
let messages = store.query_messages(&query).await?;
|
||||
|
||||
Ok(serde_json::json!({"messages": messages, "count": messages.len()}))
|
||||
}
|
||||
|
||||
/// Write a DWN message.
|
||||
pub(super) async fn handle_dwn_write_message(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let author = params["author"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'author' parameter"))?;
|
||||
let protocol = params["protocol"].as_str();
|
||||
let schema = params["schema"].as_str();
|
||||
let data_format = params["dataFormat"].as_str();
|
||||
let data = params.get("data").cloned();
|
||||
|
||||
let store = DwnStore::new(&self.config.data_dir).await?;
|
||||
let message = store
|
||||
.write_message(author, protocol, schema, data_format, data)
|
||||
.await?;
|
||||
|
||||
Ok(serde_json::json!({"written": true, "record_id": message.record_id}))
|
||||
}
|
||||
}
|
||||
|
||||
326
core/archipelago/src/api/rpc/federation.rs
Normal file
326
core/archipelago/src/api/rpc/federation.rs
Normal file
@ -0,0 +1,326 @@
|
||||
use super::RpcHandler;
|
||||
use crate::federation::{self, FederatedNode, TrustLevel};
|
||||
use crate::identity;
|
||||
use anyhow::Result;
|
||||
use tracing::info;
|
||||
|
||||
impl RpcHandler {
|
||||
/// federation.invite — Generate an invite code containing our DID + onion for a peer.
|
||||
pub(super) async fn handle_federation_invite(&self) -> Result<serde_json::Value> {
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
let onion = data
|
||||
.server_info
|
||||
.tor_address
|
||||
.clone()
|
||||
.unwrap_or_default();
|
||||
let pubkey = data.server_info.pubkey.clone();
|
||||
|
||||
if onion.is_empty() {
|
||||
anyhow::bail!("Tor address not available. Tor may not be running.");
|
||||
}
|
||||
|
||||
let code = federation::create_invite(&self.config.data_dir, &did, &onion, &pubkey).await?;
|
||||
|
||||
info!(did = %did, "Generated federation invite");
|
||||
Ok(serde_json::json!({
|
||||
"code": code,
|
||||
"did": did,
|
||||
"onion": onion,
|
||||
}))
|
||||
}
|
||||
|
||||
/// federation.join — Accept an invite code and establish federation with the remote node.
|
||||
pub(super) async fn handle_federation_join(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let code = params
|
||||
.get("code")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'code' parameter"))?;
|
||||
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let local_did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
let local_onion = data.server_info.tor_address.clone().unwrap_or_default();
|
||||
let local_pubkey = data.server_info.pubkey.clone();
|
||||
|
||||
let node = federation::accept_invite(
|
||||
&self.config.data_dir,
|
||||
code,
|
||||
&local_did,
|
||||
&local_onion,
|
||||
&local_pubkey,
|
||||
)
|
||||
.await?;
|
||||
|
||||
info!(peer_did = %node.did, "Joined federation with peer");
|
||||
Ok(serde_json::json!({
|
||||
"joined": true,
|
||||
"node": {
|
||||
"did": node.did,
|
||||
"onion": node.onion,
|
||||
"pubkey": node.pubkey,
|
||||
"trust_level": node.trust_level.to_string(),
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/// federation.list-nodes — List all federated nodes with their status and last state.
|
||||
pub(super) async fn handle_federation_list_nodes(&self) -> Result<serde_json::Value> {
|
||||
let nodes = federation::load_nodes(&self.config.data_dir).await?;
|
||||
|
||||
let nodes_json: Vec<serde_json::Value> = nodes
|
||||
.iter()
|
||||
.map(|n| {
|
||||
let mut obj = serde_json::json!({
|
||||
"did": n.did,
|
||||
"pubkey": n.pubkey,
|
||||
"onion": n.onion,
|
||||
"trust_level": n.trust_level.to_string(),
|
||||
"added_at": n.added_at,
|
||||
});
|
||||
if let Some(name) = &n.name {
|
||||
obj["name"] = serde_json::json!(name);
|
||||
}
|
||||
if let Some(last_seen) = &n.last_seen {
|
||||
obj["last_seen"] = serde_json::json!(last_seen);
|
||||
}
|
||||
if let Some(state) = &n.last_state {
|
||||
obj["last_state"] = serde_json::to_value(state).unwrap_or_default();
|
||||
}
|
||||
obj
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(serde_json::json!({ "nodes": nodes_json }))
|
||||
}
|
||||
|
||||
/// federation.remove-node — Remove a node from the federation by DID.
|
||||
pub(super) async fn handle_federation_remove_node(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let did = params
|
||||
.get("did")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'did' parameter"))?;
|
||||
|
||||
let nodes = federation::remove_node(&self.config.data_dir, did).await?;
|
||||
info!(did = %did, "Removed node from federation");
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"removed": true,
|
||||
"nodes_remaining": nodes.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// federation.set-trust — Change trust level for a federated node.
|
||||
pub(super) async fn handle_federation_set_trust(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let did = params
|
||||
.get("did")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'did' parameter"))?;
|
||||
let trust_str = params
|
||||
.get("trust_level")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'trust_level' parameter"))?;
|
||||
|
||||
let trust = match trust_str {
|
||||
"trusted" => TrustLevel::Trusted,
|
||||
"observer" => TrustLevel::Observer,
|
||||
"untrusted" => TrustLevel::Untrusted,
|
||||
_ => anyhow::bail!("Invalid trust level: {} (expected trusted/observer/untrusted)", trust_str),
|
||||
};
|
||||
|
||||
federation::set_trust_level(&self.config.data_dir, did, trust).await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"updated": true,
|
||||
"did": did,
|
||||
"trust_level": trust.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// federation.sync-state — Manually trigger state sync with all federated peers.
|
||||
pub(super) async fn handle_federation_sync_state(&self) -> Result<serde_json::Value> {
|
||||
let nodes = federation::load_nodes(&self.config.data_dir).await?;
|
||||
|
||||
if nodes.is_empty() {
|
||||
return Ok(serde_json::json!({
|
||||
"synced": 0,
|
||||
"failed": 0,
|
||||
"results": [],
|
||||
}));
|
||||
}
|
||||
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let local_did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let node_identity = identity::NodeIdentity::load_or_create(&identity_dir).await?;
|
||||
|
||||
let mut synced = 0u32;
|
||||
let mut failed = 0u32;
|
||||
let mut results = Vec::new();
|
||||
|
||||
for node in &nodes {
|
||||
if node.trust_level == TrustLevel::Untrusted {
|
||||
continue;
|
||||
}
|
||||
|
||||
let did_clone = local_did.clone();
|
||||
match federation::sync_with_peer(
|
||||
&self.config.data_dir,
|
||||
node,
|
||||
&did_clone,
|
||||
|bytes| node_identity.sign(bytes),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(state) => {
|
||||
synced += 1;
|
||||
results.push(serde_json::json!({
|
||||
"did": node.did,
|
||||
"status": "ok",
|
||||
"apps": state.apps.len(),
|
||||
}));
|
||||
}
|
||||
Err(e) => {
|
||||
failed += 1;
|
||||
results.push(serde_json::json!({
|
||||
"did": node.did,
|
||||
"status": "error",
|
||||
"error": e.to_string(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"synced": synced,
|
||||
"failed": failed,
|
||||
"results": results,
|
||||
}))
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
|
||||
// Build app statuses from package_data
|
||||
let apps: Vec<federation::AppStatus> = data
|
||||
.package_data
|
||||
.iter()
|
||||
.map(|(id, pkg)| federation::AppStatus {
|
||||
id: id.clone(),
|
||||
status: format!("{:?}", pkg.state).to_lowercase(),
|
||||
version: Some(pkg.manifest.version.clone()),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let tor_active = data.server_info.tor_address.is_some();
|
||||
|
||||
let state = federation::build_local_state(
|
||||
apps, 0.0, 0, 0, 0, 0, 0, tor_active,
|
||||
);
|
||||
|
||||
Ok(serde_json::to_value(&state)?)
|
||||
}
|
||||
|
||||
/// federation.peer-joined — Called by a remote peer after they accept our invite.
|
||||
pub(super) async fn handle_federation_peer_joined(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let did = params
|
||||
.get("did")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'did'"))?;
|
||||
let onion = params
|
||||
.get("onion")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'onion'"))?;
|
||||
let pubkey = params
|
||||
.get("pubkey")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'pubkey'"))?;
|
||||
|
||||
let nodes = federation::load_nodes(&self.config.data_dir).await?;
|
||||
if nodes.iter().any(|n| n.did == did) {
|
||||
return Ok(serde_json::json!({ "accepted": true, "already_known": true }));
|
||||
}
|
||||
|
||||
let node = FederatedNode {
|
||||
did: did.to_string(),
|
||||
pubkey: pubkey.to_string(),
|
||||
onion: onion.to_string(),
|
||||
name: None,
|
||||
trust_level: TrustLevel::Trusted,
|
||||
added_at: chrono::Utc::now().to_rfc3339(),
|
||||
last_seen: None,
|
||||
last_state: None,
|
||||
};
|
||||
|
||||
federation::add_node(&self.config.data_dir, node).await?;
|
||||
info!(peer_did = %did, "Peer joined our federation");
|
||||
|
||||
Ok(serde_json::json!({ "accepted": true }))
|
||||
}
|
||||
|
||||
/// federation.deploy-app — Deploy an app to a remote federated node.
|
||||
pub(super) async fn handle_federation_deploy_app(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let peer_did = params
|
||||
.get("did")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'did' (target node)"))?;
|
||||
let app_id = params
|
||||
.get("app_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'app_id'"))?;
|
||||
let version = params
|
||||
.get("version")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("latest");
|
||||
let marketplace_url = params
|
||||
.get("marketplace_url")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let nodes = federation::load_nodes(&self.config.data_dir).await?;
|
||||
let peer = nodes
|
||||
.iter()
|
||||
.find(|n| n.did == peer_did)
|
||||
.ok_or_else(|| anyhow::anyhow!("No federated node with DID {}", peer_did))?;
|
||||
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let local_did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let node_identity = identity::NodeIdentity::load_or_create(&identity_dir).await?;
|
||||
|
||||
let result = federation::deploy_to_peer(
|
||||
peer,
|
||||
app_id,
|
||||
version,
|
||||
marketplace_url,
|
||||
&local_did,
|
||||
|bytes| node_identity.sign(bytes),
|
||||
)
|
||||
.await?;
|
||||
|
||||
info!(app = %app_id, peer = %peer_did, "Deployed app to federated peer");
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
138
core/archipelago/src/api/rpc/handshake.rs
Normal file
138
core/archipelago/src/api/rpc/handshake.rs
Normal file
@ -0,0 +1,138 @@
|
||||
use super::RpcHandler;
|
||||
use crate::{nostr_handshake, peers};
|
||||
use anyhow::Result;
|
||||
|
||||
impl RpcHandler {
|
||||
/// Discover nodes (presence-only — returns Nostr pubkeys + DIDs, no onion addresses).
|
||||
pub(super) async fn handle_handshake_discover(&self) -> Result<serde_json::Value> {
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let nodes = nostr_handshake::discover_nodes(
|
||||
&identity_dir,
|
||||
&self.config.nostr_relays,
|
||||
self.config.nostr_tor_proxy.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
Ok(serde_json::json!({ "nodes": nodes }))
|
||||
}
|
||||
|
||||
/// Send encrypted connection request to a peer's Nostr pubkey.
|
||||
/// Params: { recipient_nostr_pubkey }
|
||||
pub(super) async fn handle_handshake_connect(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let recipient = params
|
||||
.get("recipient_nostr_pubkey")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing recipient_nostr_pubkey"))?;
|
||||
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let our_onion = data
|
||||
.server_info
|
||||
.tor_address
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("No Tor address available — is Tor running?"))?;
|
||||
let our_node_pubkey = &data.server_info.pubkey;
|
||||
let our_did = crate::identity::did_key_from_pubkey_hex(our_node_pubkey)
|
||||
.unwrap_or_default();
|
||||
let our_version = &data.server_info.version;
|
||||
let our_name = data.server_info.name.as_deref();
|
||||
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
nostr_handshake::send_connect_request(
|
||||
&identity_dir,
|
||||
recipient,
|
||||
our_onion,
|
||||
our_node_pubkey,
|
||||
&our_did,
|
||||
our_version,
|
||||
our_name,
|
||||
&self.config.nostr_relays,
|
||||
self.config.nostr_tor_proxy.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(serde_json::json!({ "ok": true, "sent_to": recipient }))
|
||||
}
|
||||
|
||||
/// Poll for incoming encrypted handshake messages (connect requests/responses).
|
||||
/// Auto-adds peers and auto-responds to requests.
|
||||
pub(super) async fn handle_handshake_poll(&self) -> Result<serde_json::Value> {
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let handshakes = nostr_handshake::poll_handshakes(
|
||||
&identity_dir,
|
||||
&self.config.nostr_relays,
|
||||
self.config.nostr_tor_proxy.as_deref(),
|
||||
None, // TODO: track last-seen timestamp to avoid re-processing
|
||||
)
|
||||
.await?;
|
||||
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let mut added_peers = Vec::new();
|
||||
|
||||
for hs in &handshakes {
|
||||
let (onion, node_pubkey, name) = match &hs.message {
|
||||
nostr_handshake::HandshakeMessage::ConnectRequest {
|
||||
onion,
|
||||
node_pubkey,
|
||||
name,
|
||||
..
|
||||
} => {
|
||||
// Auto-respond with our details
|
||||
if let Some(our_onion) = data.server_info.tor_address.as_deref() {
|
||||
let our_did = crate::identity::did_key_from_pubkey_hex(
|
||||
&data.server_info.pubkey,
|
||||
)
|
||||
.unwrap_or_default();
|
||||
let _ = nostr_handshake::send_connect_response(
|
||||
&identity_dir,
|
||||
&hs.from_nostr_pubkey,
|
||||
our_onion,
|
||||
&data.server_info.pubkey,
|
||||
&our_did,
|
||||
&data.server_info.version,
|
||||
data.server_info.name.as_deref(),
|
||||
&self.config.nostr_relays,
|
||||
self.config.nostr_tor_proxy.as_deref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
(onion.clone(), node_pubkey.clone(), name.clone())
|
||||
}
|
||||
nostr_handshake::HandshakeMessage::ConnectResponse {
|
||||
onion,
|
||||
node_pubkey,
|
||||
name,
|
||||
..
|
||||
} => (onion.clone(), node_pubkey.clone(), name.clone()),
|
||||
};
|
||||
|
||||
// Auto-add as peer
|
||||
let peer = peers::KnownPeer {
|
||||
onion,
|
||||
pubkey: node_pubkey.clone(),
|
||||
name,
|
||||
added_at: Some(chrono::Utc::now().to_rfc3339()),
|
||||
};
|
||||
let _ = peers::add_peer(&self.config.data_dir, peer).await;
|
||||
added_peers.push(node_pubkey);
|
||||
}
|
||||
|
||||
let serialized: Vec<serde_json::Value> = handshakes
|
||||
.iter()
|
||||
.map(|hs| {
|
||||
serde_json::json!({
|
||||
"from_nostr_pubkey": hs.from_nostr_pubkey,
|
||||
"message": hs.message,
|
||||
"timestamp": hs.timestamp,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"handshakes": serialized,
|
||||
"added_peers": added_peers,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
use super::RpcHandler;
|
||||
use crate::identity_manager::{IdentityManager, IdentityPurpose};
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
impl RpcHandler {
|
||||
/// List all identities with their default status.
|
||||
@ -180,6 +180,101 @@ impl RpcHandler {
|
||||
Ok(serde_json::json!({ "valid": valid }))
|
||||
}
|
||||
|
||||
/// Resolve a DID to its W3C DID Document.
|
||||
/// If no DID is provided, returns the node's own DID Document.
|
||||
pub(super) async fn handle_identity_resolve_did(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
|
||||
// If a DID is provided, resolve it; otherwise use the node's DID
|
||||
let pubkey_hex = if let Some(did) = params.get("did").and_then(|v| v.as_str()) {
|
||||
// Extract pubkey from did:key format
|
||||
let pubkey_bytes = crate::identity::pubkey_bytes_from_did_key(did)?;
|
||||
hex::encode(pubkey_bytes)
|
||||
} else {
|
||||
// Use node's own pubkey
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
data.server_info.pubkey.clone()
|
||||
};
|
||||
|
||||
let document = crate::identity::did_document_from_pubkey_hex(&pubkey_hex)?;
|
||||
Ok(document)
|
||||
}
|
||||
|
||||
/// Verify a DID Document: validate structure, check key material matches DID.
|
||||
pub(super) async fn handle_identity_verify_did_document(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let document = params
|
||||
.get("document")
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: document"))?;
|
||||
|
||||
// Validate required fields
|
||||
let did = document["id"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("DID Document missing 'id' field"))?;
|
||||
|
||||
let context = document["@context"]
|
||||
.as_array()
|
||||
.ok_or_else(|| anyhow::anyhow!("DID Document missing '@context' array"))?;
|
||||
|
||||
let has_did_context = context.iter().any(|c| c.as_str() == Some("https://www.w3.org/ns/did/v1"));
|
||||
if !has_did_context {
|
||||
return Ok(serde_json::json!({
|
||||
"valid": false,
|
||||
"errors": ["Missing required @context: https://www.w3.org/ns/did/v1"]
|
||||
}));
|
||||
}
|
||||
|
||||
let verification_methods = document["verificationMethod"]
|
||||
.as_array()
|
||||
.ok_or_else(|| anyhow::anyhow!("DID Document missing 'verificationMethod' array"))?;
|
||||
|
||||
if verification_methods.is_empty() {
|
||||
return Ok(serde_json::json!({
|
||||
"valid": false,
|
||||
"errors": ["verificationMethod array is empty"]
|
||||
}));
|
||||
}
|
||||
|
||||
// Verify the DID matches the key material (for did:key method)
|
||||
let mut errors: Vec<String> = Vec::new();
|
||||
|
||||
if did.starts_with("did:key:") {
|
||||
match crate::identity::pubkey_bytes_from_did_key(did) {
|
||||
Ok(pubkey_bytes) => {
|
||||
// Check that at least one verification method has matching key
|
||||
let pubkey_multibase = format!("z{}", bs58::encode(&pubkey_bytes).into_string());
|
||||
let has_matching_key = verification_methods.iter().any(|vm| {
|
||||
vm["publicKeyMultibase"].as_str() == Some(&pubkey_multibase)
|
||||
});
|
||||
if !has_matching_key {
|
||||
errors.push("No verificationMethod matches the DID's public key".to_string());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
errors.push(format!("Failed to extract pubkey from DID: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check authentication is present
|
||||
if document["authentication"].as_array().map_or(true, |a| a.is_empty()) {
|
||||
errors.push("Missing or empty 'authentication' field".to_string());
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"valid": errors.is_empty(),
|
||||
"did": did,
|
||||
"errors": errors,
|
||||
"verification_methods": verification_methods.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Create a Nostr keypair linked to an identity.
|
||||
pub(super) async fn handle_identity_create_nostr_key(
|
||||
&self,
|
||||
@ -221,4 +316,81 @@ impl RpcHandler {
|
||||
"signature": signature,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Resolve a remote peer's DID Document over Tor.
|
||||
/// Queries the peer's /rpc/ endpoint for identity.resolve-did.
|
||||
pub(super) async fn handle_identity_resolve_remote_did(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let onion = params
|
||||
.get("onion")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: onion"))?;
|
||||
|
||||
// Build URL for peer's RPC endpoint over Tor
|
||||
let host = if onion.ends_with(".onion") {
|
||||
onion.to_string()
|
||||
} else {
|
||||
format!("{}.onion", onion)
|
||||
};
|
||||
let url = format!("http://{}/rpc/", host);
|
||||
|
||||
// Use SOCKS5 proxy to reach .onion address
|
||||
let proxy = reqwest::Proxy::all("socks5h://127.0.0.1:9050")
|
||||
.context("Failed to create Tor proxy")?;
|
||||
let client = reqwest::Client::builder()
|
||||
.proxy(proxy)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.context("Failed to build HTTP client")?;
|
||||
|
||||
let rpc_body = serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "identity.resolve-did",
|
||||
"params": {}
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.json(&rpc_body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to connect to peer over Tor")?;
|
||||
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse peer response")?;
|
||||
|
||||
// Extract the DID Document from the RPC response
|
||||
let document = body
|
||||
.get("result")
|
||||
.ok_or_else(|| anyhow::anyhow!("Peer returned error or missing result"))?;
|
||||
|
||||
// Cache the resolved DID locally
|
||||
let did = document["id"]
|
||||
.as_str()
|
||||
.unwrap_or("unknown");
|
||||
let cache_dir = self.config.data_dir.join("did-cache");
|
||||
tokio::fs::create_dir_all(&cache_dir).await.ok();
|
||||
let cache_file = cache_dir.join(format!("{}.json", onion.replace('.', "_")));
|
||||
let cache_entry = serde_json::json!({
|
||||
"document": document,
|
||||
"resolved_at": chrono::Utc::now().to_rfc3339(),
|
||||
"onion": onion,
|
||||
});
|
||||
tokio::fs::write(&cache_file, serde_json::to_string_pretty(&cache_entry).unwrap_or_default())
|
||||
.await
|
||||
.ok();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"document": document,
|
||||
"did": did,
|
||||
"resolved_from": onion,
|
||||
"cached": true,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
128
core/archipelago/src/api/rpc/marketplace.rs
Normal file
128
core/archipelago/src/api/rpc/marketplace.rs
Normal file
@ -0,0 +1,128 @@
|
||||
use super::RpcHandler;
|
||||
use crate::federation;
|
||||
use crate::marketplace;
|
||||
use crate::nostr_relays;
|
||||
use anyhow::Result;
|
||||
use tracing::info;
|
||||
|
||||
impl RpcHandler {
|
||||
/// marketplace.discover — Query Nostr relays for community app manifests.
|
||||
pub(super) async fn handle_marketplace_discover(&self) -> Result<serde_json::Value> {
|
||||
// Load enabled relays
|
||||
let relay_store = nostr_relays::load_relays(&self.config.data_dir).await?;
|
||||
let relay_urls: Vec<String> = relay_store
|
||||
.relays
|
||||
.iter()
|
||||
.filter(|r| r.enabled)
|
||||
.map(|r| r.url.clone())
|
||||
.collect();
|
||||
|
||||
// Load federated DIDs for trust scoring
|
||||
let fed_nodes = federation::load_nodes(&self.config.data_dir).await.unwrap_or_default();
|
||||
let federated_dids: Vec<String> = fed_nodes.iter().map(|n| n.did.clone()).collect();
|
||||
|
||||
let tor_proxy = std::env::var("ARCHIPELAGO_NOSTR_TOR_PROXY").ok();
|
||||
let apps = marketplace::discover(
|
||||
&self.config.data_dir,
|
||||
&relay_urls,
|
||||
tor_proxy.as_deref(),
|
||||
&federated_dids,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"apps": apps,
|
||||
"relay_count": relay_urls.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// marketplace.publish — Publish an app manifest to Nostr relays.
|
||||
pub(super) async fn handle_marketplace_publish(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let manifest: marketplace::AppManifest =
|
||||
serde_json::from_value(params).map_err(|e| anyhow::anyhow!("Invalid manifest: {}", e))?;
|
||||
|
||||
// Validate before publishing
|
||||
let issues = marketplace::validate_manifest(&manifest);
|
||||
if !issues.is_empty() {
|
||||
return Ok(serde_json::json!({
|
||||
"ok": false,
|
||||
"errors": issues,
|
||||
}));
|
||||
}
|
||||
|
||||
let relay_store = nostr_relays::load_relays(&self.config.data_dir).await?;
|
||||
let relay_urls: Vec<String> = relay_store
|
||||
.relays
|
||||
.iter()
|
||||
.filter(|r| r.enabled)
|
||||
.map(|r| r.url.clone())
|
||||
.collect();
|
||||
|
||||
let tor_proxy = std::env::var("ARCHIPELAGO_NOSTR_TOR_PROXY").ok();
|
||||
let event_id = marketplace::publish(
|
||||
&self.config.data_dir,
|
||||
&manifest,
|
||||
&relay_urls,
|
||||
tor_proxy.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
info!(app_id = %manifest.app_id, "Published app manifest");
|
||||
Ok(serde_json::json!({
|
||||
"ok": true,
|
||||
"event_id": event_id,
|
||||
"app_id": manifest.app_id,
|
||||
"relays": relay_urls.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// marketplace.get-manifest — Get cached manifest for a specific app.
|
||||
pub(super) async fn handle_marketplace_get_manifest(
|
||||
&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 required parameter: app_id"))?;
|
||||
|
||||
let cache = marketplace::load_cache(&self.config.data_dir).await?;
|
||||
let app = cache.apps.iter().find(|a| a.manifest.app_id == app_id);
|
||||
|
||||
match app {
|
||||
Some(discovered) => Ok(serde_json::to_value(discovered)?),
|
||||
None => Ok(serde_json::json!({ "error": "App not found in cache", "app_id": app_id })),
|
||||
}
|
||||
}
|
||||
|
||||
/// marketplace.list-published — List manifests published by this node.
|
||||
pub(super) async fn handle_marketplace_list_published(&self) -> Result<serde_json::Value> {
|
||||
let manifests = marketplace::list_published(&self.config.data_dir).await?;
|
||||
Ok(serde_json::json!({ "manifests": manifests }))
|
||||
}
|
||||
|
||||
/// marketplace.verify — Verify a manifest's security compliance.
|
||||
pub(super) async fn handle_marketplace_verify(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let manifest: marketplace::AppManifest =
|
||||
serde_json::from_value(params).map_err(|e| anyhow::anyhow!("Invalid manifest: {}", e))?;
|
||||
|
||||
let issues = marketplace::validate_manifest(&manifest);
|
||||
let (trust_score, trust_tier) = marketplace::calculate_trust_score(&manifest, 0, &[]);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"valid": issues.is_empty(),
|
||||
"issues": issues,
|
||||
"trust_score": trust_score,
|
||||
"trust_tier": trust_tier,
|
||||
}))
|
||||
}
|
||||
}
|
||||
90
core/archipelago/src/api/rpc/mesh.rs
Normal file
90
core/archipelago/src/api/rpc/mesh.rs
Normal file
@ -0,0 +1,90 @@
|
||||
use super::RpcHandler;
|
||||
use crate::{identity, mesh};
|
||||
use anyhow::Result;
|
||||
use tracing::info;
|
||||
|
||||
impl RpcHandler {
|
||||
/// mesh.status — Get mesh radio status and detected devices.
|
||||
pub(super) async fn handle_mesh_status(&self) -> Result<serde_json::Value> {
|
||||
let config = mesh::load_config(&self.config.data_dir).await?;
|
||||
let devices = mesh::detect_meshtastic_devices().await;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"enabled": config.enabled,
|
||||
"device_path": config.device_path,
|
||||
"channel_name": config.channel_name,
|
||||
"broadcast_identity": config.broadcast_identity,
|
||||
"detected_devices": devices,
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.discover — Discover nodes via mesh radio.
|
||||
pub(super) async fn handle_mesh_discover(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let device_path = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("device_path"))
|
||||
.and_then(|v| v.as_str());
|
||||
|
||||
let config = mesh::load_config(&self.config.data_dir).await?;
|
||||
let effective_device = device_path.or(config.device_path.as_deref());
|
||||
|
||||
let nodes = mesh::discover_nodes(effective_device).await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"nodes": nodes,
|
||||
"count": nodes.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.broadcast — Broadcast our node identity over mesh.
|
||||
pub(super) async fn handle_mesh_broadcast(&self) -> Result<serde_json::Value> {
|
||||
let config = mesh::load_config(&self.config.data_dir).await?;
|
||||
if !config.enabled {
|
||||
anyhow::bail!("Mesh networking is not enabled. Configure it first.");
|
||||
}
|
||||
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
let pubkey = &data.server_info.pubkey;
|
||||
|
||||
mesh::broadcast_identity(&did, pubkey, config.device_path.as_deref()).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;
|
||||
}
|
||||
|
||||
mesh::save_config(&self.config.data_dir, &config).await?;
|
||||
|
||||
info!("Mesh config updated");
|
||||
Ok(serde_json::json!({
|
||||
"configured": true,
|
||||
"enabled": config.enabled,
|
||||
"device_path": config.device_path,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,7 @@ mod content;
|
||||
mod credentials;
|
||||
mod dwn;
|
||||
mod federation;
|
||||
mod handshake;
|
||||
mod identity;
|
||||
mod interfaces;
|
||||
mod marketplace;
|
||||
@ -297,6 +298,11 @@ impl RpcHandler {
|
||||
"node.nostr-pubkey" => self.handle_node_nostr_pubkey().await,
|
||||
"node-nostr-verify-revoked" => self.handle_node_nostr_verify_revoked().await,
|
||||
|
||||
// Encrypted peer handshake (NIP-44)
|
||||
"handshake.discover" => self.handle_handshake_discover().await,
|
||||
"handshake.connect" => self.handle_handshake_connect(params).await,
|
||||
"handshake.poll" => self.handle_handshake_poll().await,
|
||||
|
||||
// TOTP 2FA
|
||||
"auth.totp.setup.begin" => self.handle_totp_setup_begin(params).await,
|
||||
"auth.totp.setup.confirm" => self.handle_totp_setup_confirm(params).await,
|
||||
|
||||
@ -4,7 +4,6 @@ use crate::data_model::{
|
||||
};
|
||||
use crate::port_allocator::PortAllocator;
|
||||
use anyhow::{Context, Result};
|
||||
use std::collections::HashMap;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tracing::{debug, info};
|
||||
|
||||
@ -752,15 +751,50 @@ printtoconsole=1\n";
|
||||
|
||||
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 {
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "stop", name])
|
||||
tracing::info!("Uninstall {}: stopping container {}", package_id, name);
|
||||
let stop_out = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "stop", "-t", "10", name])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
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("sudo")
|
||||
.args(["podman", "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
|
||||
@ -772,14 +806,31 @@ printtoconsole=1\n";
|
||||
if !preserve_data {
|
||||
let data_dirs = get_data_dirs_for_app(package_id);
|
||||
for dir in &data_dirs {
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({ "status": "uninstalled" }))
|
||||
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, then start)
|
||||
|
||||
@ -43,6 +43,101 @@ impl RpcHandler {
|
||||
|
||||
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,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// Read system uptime from /proc/uptime (seconds since boot).
|
||||
@ -226,6 +321,203 @@ async fn read_top_processes() -> Result<Vec<serde_json::Value>> {
|
||||
Ok(procs)
|
||||
}
|
||||
|
||||
/// Known hardware wallet USB vendor IDs.
|
||||
const KNOWN_HW_WALLETS: &[(u16, &str)] = &[
|
||||
(0xd13e, "ColdCard"),
|
||||
(0x534c, "Trezor"),
|
||||
(0x2c97, "Ledger"),
|
||||
(0x1209, "BitBox02"),
|
||||
];
|
||||
|
||||
/// Scan /sys/bus/usb/devices/ for known hardware wallet vendor IDs.
|
||||
async fn detect_usb_hardware_wallets() -> Result<Vec<serde_json::Value>> {
|
||||
let usb_dir = std::path::Path::new("/sys/bus/usb/devices");
|
||||
if !usb_dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut devices = Vec::new();
|
||||
let mut entries = tokio::fs::read_dir(usb_dir)
|
||||
.await
|
||||
.context("Failed to read /sys/bus/usb/devices")?;
|
||||
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let path = entry.path();
|
||||
let vendor_path = path.join("idVendor");
|
||||
let product_path = path.join("idProduct");
|
||||
|
||||
if !vendor_path.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let vid_str = match tokio::fs::read_to_string(&vendor_path).await {
|
||||
Ok(s) => s.trim().to_string(),
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let vid = match u16::from_str_radix(&vid_str, 16) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if let Some((_, name)) = KNOWN_HW_WALLETS.iter().find(|(known_vid, _)| *known_vid == vid) {
|
||||
let pid_str = tokio::fs::read_to_string(&product_path)
|
||||
.await
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
let manufacturer = tokio::fs::read_to_string(path.join("manufacturer"))
|
||||
.await
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
let product = tokio::fs::read_to_string(path.join("product"))
|
||||
.await
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
devices.push(serde_json::json!({
|
||||
"type": name,
|
||||
"vendor_id": vid_str,
|
||||
"product_id": pid_str,
|
||||
"manufacturer": manufacturer,
|
||||
"product": product,
|
||||
"path": path.to_string_lossy(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(devices)
|
||||
}
|
||||
|
||||
/// Prune dangling container images via `sudo podman image prune -f`.
|
||||
/// Returns estimated bytes freed.
|
||||
async fn prune_container_images() -> Result<u64> {
|
||||
let output = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "image", "prune", "-f"])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to run podman image prune")?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"podman image prune failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
// Podman outputs image IDs, estimate ~100MB per pruned image
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let pruned_count = stdout.lines().filter(|l| !l.trim().is_empty()).count();
|
||||
Ok(pruned_count as u64 * 100_000_000) // rough estimate
|
||||
}
|
||||
|
||||
/// Prune container build cache via `sudo podman system prune -f`.
|
||||
async fn prune_build_cache() -> Result<u64> {
|
||||
// Just prune volumes and build cache (not containers or images — those are handled above)
|
||||
let output = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "volume", "prune", "-f"])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to run podman volume prune")?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"podman volume prune failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let pruned_count = stdout.lines().filter(|l| !l.trim().is_empty()).count();
|
||||
Ok(pruned_count as u64 * 10_000_000) // rough estimate per volume
|
||||
}
|
||||
|
||||
/// Clean log files older than `max_age_days` from common log directories.
|
||||
async fn clean_old_logs(max_age_days: u64) -> Result<u64> {
|
||||
let output = tokio::process::Command::new("sudo")
|
||||
.args([
|
||||
"find",
|
||||
"/var/log",
|
||||
"-type",
|
||||
"f",
|
||||
"-name",
|
||||
"*.log.*",
|
||||
"-mtime",
|
||||
&format!("+{}", max_age_days),
|
||||
"-delete",
|
||||
"-print",
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to clean old logs")?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let deleted_count = stdout.lines().filter(|l| !l.trim().is_empty()).count();
|
||||
// Also clean rotated/compressed logs
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args([
|
||||
"find",
|
||||
"/var/log",
|
||||
"-type",
|
||||
"f",
|
||||
"-name",
|
||||
"*.gz",
|
||||
"-mtime",
|
||||
&format!("+{}", max_age_days),
|
||||
"-delete",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
Ok(deleted_count as u64 * 500_000) // rough estimate per log file
|
||||
}
|
||||
|
||||
/// Remove stale temp files from /tmp and /var/tmp.
|
||||
async fn clean_temp_files() -> Result<u64> {
|
||||
let mut freed = 0u64;
|
||||
|
||||
for dir in &["/tmp", "/var/tmp"] {
|
||||
let output = tokio::process::Command::new("sudo")
|
||||
.args([
|
||||
"find",
|
||||
dir,
|
||||
"-type",
|
||||
"f",
|
||||
"-mtime",
|
||||
"+7",
|
||||
"-delete",
|
||||
"-print",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
if let Ok(out) = output {
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let count = stdout.lines().filter(|l| !l.trim().is_empty()).count();
|
||||
freed += count as u64 * 100_000; // rough estimate per temp file
|
||||
}
|
||||
}
|
||||
|
||||
Ok(freed)
|
||||
}
|
||||
|
||||
fn format_bytes(bytes: u64) -> String {
|
||||
const KB: u64 = 1024;
|
||||
const MB: u64 = KB * 1024;
|
||||
const GB: u64 = MB * 1024;
|
||||
|
||||
if bytes >= GB {
|
||||
format!("{:.1} GB", bytes as f64 / GB as f64)
|
||||
} else if bytes >= MB {
|
||||
format!("{:.1} MB", bytes as f64 / MB as f64)
|
||||
} else if bytes >= KB {
|
||||
format!("{:.0} KB", bytes as f64 / KB as f64)
|
||||
} else {
|
||||
format!("{} B", bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/// Read temperatures from /sys/class/thermal/thermal_zone*/temp.
|
||||
async fn read_temperatures() -> Result<Vec<serde_json::Value>> {
|
||||
let mut temps = Vec::new();
|
||||
|
||||
@ -42,4 +42,52 @@ impl RpcHandler {
|
||||
update::dismiss_update(&self.config.data_dir).await?;
|
||||
Ok(serde_json::json!({ "ok": true }))
|
||||
}
|
||||
|
||||
/// Download the available update to staging.
|
||||
pub(super) async fn handle_update_download(&self) -> Result<serde_json::Value> {
|
||||
let progress = update::download_update(&self.config.data_dir).await?;
|
||||
Ok(serde_json::json!({
|
||||
"total_bytes": progress.total_bytes,
|
||||
"downloaded_bytes": progress.downloaded_bytes,
|
||||
"components_downloaded": progress.components_downloaded,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Apply the staged update.
|
||||
pub(super) async fn handle_update_apply(&self) -> Result<serde_json::Value> {
|
||||
update::apply_update(&self.config.data_dir).await?;
|
||||
Ok(serde_json::json!({ "applied": true, "restart_required": true }))
|
||||
}
|
||||
|
||||
/// Rollback to the previous version.
|
||||
pub(super) async fn handle_update_rollback(&self) -> Result<serde_json::Value> {
|
||||
update::rollback_update(&self.config.data_dir).await?;
|
||||
Ok(serde_json::json!({ "rolled_back": true, "restart_required": true }))
|
||||
}
|
||||
|
||||
/// Get the current update schedule.
|
||||
pub(super) async fn handle_update_get_schedule(&self) -> Result<serde_json::Value> {
|
||||
let schedule = update::get_schedule(&self.config.data_dir).await?;
|
||||
Ok(serde_json::json!({ "schedule": schedule }))
|
||||
}
|
||||
|
||||
/// Set the update schedule. Params: { schedule: "manual" | "daily_check" | "auto_apply" }
|
||||
pub(super) async fn handle_update_set_schedule(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let schedule_str = params["schedule"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'schedule' parameter"))?;
|
||||
|
||||
let schedule = match schedule_str {
|
||||
"manual" => update::UpdateSchedule::Manual,
|
||||
"daily_check" => update::UpdateSchedule::DailyCheck,
|
||||
"auto_apply" => update::UpdateSchedule::AutoApply,
|
||||
_ => anyhow::bail!("Invalid schedule: '{}'. Use manual, daily_check, or auto_apply", schedule_str),
|
||||
};
|
||||
|
||||
update::set_schedule(&self.config.data_dir, schedule).await?;
|
||||
Ok(serde_json::json!({ "schedule": schedule }))
|
||||
}
|
||||
}
|
||||
|
||||
607
core/archipelago/src/backup/full.rs
Normal file
607
core/archipelago/src/backup/full.rs
Normal file
@ -0,0 +1,607 @@
|
||||
//! Full system backup — identity keys + app data + settings + DWN messages.
|
||||
//!
|
||||
//! Creates an encrypted tar.gz archive containing all critical node data.
|
||||
//! Encryption: Argon2 key derivation + ChaCha20-Poly1305.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use argon2::Argon2;
|
||||
use chacha20poly1305::aead::{Aead, KeyInit};
|
||||
use flate2::read::GzDecoder;
|
||||
use flate2::write::GzEncoder;
|
||||
use flate2::Compression;
|
||||
use rand::RngCore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tar::{Archive, Builder};
|
||||
use tokio::fs;
|
||||
use tracing::{debug, info};
|
||||
|
||||
const SALT_LEN: usize = 16;
|
||||
const NONCE_LEN: usize = 12;
|
||||
const KEY_LEN: usize = 32;
|
||||
|
||||
/// Directories within data_dir to include in a full backup.
|
||||
const BACKUP_DIRS: &[&str] = &[
|
||||
"identity",
|
||||
"identities",
|
||||
"dwn",
|
||||
"credentials",
|
||||
"tor-config",
|
||||
"content",
|
||||
];
|
||||
|
||||
/// Files within data_dir to include in a full backup.
|
||||
const BACKUP_FILES: &[&str] = &[
|
||||
"user.json",
|
||||
"peers.json",
|
||||
"names.json",
|
||||
"onboarding.json",
|
||||
"nostr_relays.json",
|
||||
"network_visibility",
|
||||
"port_allocations.json",
|
||||
"update_state.json",
|
||||
];
|
||||
|
||||
/// Backup metadata stored alongside the archive.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct BackupMetadata {
|
||||
pub id: String,
|
||||
pub version: u32,
|
||||
pub created_at: String,
|
||||
pub encrypted: bool,
|
||||
pub size_bytes: u64,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// Result of a backup verification check.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct VerifyResult {
|
||||
pub valid: bool,
|
||||
pub id: String,
|
||||
pub created_at: String,
|
||||
pub size_bytes: u64,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Create a full encrypted backup of all node data.
|
||||
/// Returns the backup metadata and the path to the backup file.
|
||||
pub async fn create_full_backup(
|
||||
data_dir: &Path,
|
||||
passphrase: &str,
|
||||
description: Option<&str>,
|
||||
) -> Result<BackupMetadata> {
|
||||
let backups_dir = data_dir.join("backups");
|
||||
fs::create_dir_all(&backups_dir)
|
||||
.await
|
||||
.context("Failed to create backups dir")?;
|
||||
|
||||
let backup_id = uuid::Uuid::new_v4().to_string();
|
||||
let timestamp = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
// Step 1: Create tar.gz archive in memory
|
||||
let tar_gz_data = tokio::task::spawn_blocking({
|
||||
let data_dir = data_dir.to_path_buf();
|
||||
move || create_tar_gz(&data_dir)
|
||||
})
|
||||
.await?
|
||||
.context("Failed to create tar archive")?;
|
||||
|
||||
info!(size = tar_gz_data.len(), "Backup archive created");
|
||||
|
||||
// Step 2: Encrypt the archive
|
||||
let encrypted = encrypt_data(&tar_gz_data, passphrase)?;
|
||||
|
||||
// Step 3: Write to disk
|
||||
let backup_path = backups_dir.join(format!("{}.bak", backup_id));
|
||||
fs::write(&backup_path, &encrypted)
|
||||
.await
|
||||
.context("Failed to write backup file")?;
|
||||
|
||||
// Step 4: Write metadata
|
||||
let metadata = BackupMetadata {
|
||||
id: backup_id,
|
||||
version: 2,
|
||||
created_at: timestamp,
|
||||
encrypted: true,
|
||||
size_bytes: encrypted.len() as u64,
|
||||
description: description.map(|s| s.to_string()),
|
||||
};
|
||||
|
||||
let meta_path = backups_dir.join(format!("{}.meta.json", metadata.id));
|
||||
let meta_json = serde_json::to_string_pretty(&metadata)
|
||||
.context("Failed to serialize metadata")?;
|
||||
fs::write(&meta_path, meta_json)
|
||||
.await
|
||||
.context("Failed to write metadata")?;
|
||||
|
||||
info!(id = %metadata.id, size = metadata.size_bytes, "Full backup created");
|
||||
Ok(metadata)
|
||||
}
|
||||
|
||||
/// Restore a full backup from an encrypted archive.
|
||||
pub async fn restore_full_backup(
|
||||
data_dir: &Path,
|
||||
backup_id: &str,
|
||||
passphrase: &str,
|
||||
) -> Result<()> {
|
||||
let backup_path = data_dir.join("backups").join(format!("{}.bak", backup_id));
|
||||
if !backup_path.exists() {
|
||||
anyhow::bail!("Backup not found: {}", backup_id);
|
||||
}
|
||||
|
||||
let encrypted = fs::read(&backup_path)
|
||||
.await
|
||||
.context("Failed to read backup file")?;
|
||||
|
||||
let tar_gz_data = decrypt_data(&encrypted, passphrase)?;
|
||||
|
||||
// Extract to data_dir
|
||||
tokio::task::spawn_blocking({
|
||||
let data_dir = data_dir.to_path_buf();
|
||||
move || extract_tar_gz(&data_dir, &tar_gz_data)
|
||||
})
|
||||
.await?
|
||||
.context("Failed to extract backup")?;
|
||||
|
||||
info!(id = %backup_id, "Backup restored");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List available backups by reading metadata files.
|
||||
pub async fn list_backups(data_dir: &Path) -> Result<Vec<BackupMetadata>> {
|
||||
let backups_dir = data_dir.join("backups");
|
||||
if !backups_dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut backups = Vec::new();
|
||||
let mut entries = fs::read_dir(&backups_dir)
|
||||
.await
|
||||
.context("Failed to read backups dir")?;
|
||||
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) == Some("json")
|
||||
&& path.to_str().map_or(false, |s| s.contains(".meta."))
|
||||
{
|
||||
let content = match fs::read_to_string(&path).await {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if let Ok(meta) = serde_json::from_str::<BackupMetadata>(&content) {
|
||||
backups.push(meta);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort newest first
|
||||
backups.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||
Ok(backups)
|
||||
}
|
||||
|
||||
/// Verify a backup's integrity by attempting decryption.
|
||||
pub async fn verify_backup(
|
||||
data_dir: &Path,
|
||||
backup_id: &str,
|
||||
passphrase: &str,
|
||||
) -> Result<VerifyResult> {
|
||||
let backup_path = data_dir.join("backups").join(format!("{}.bak", backup_id));
|
||||
let meta_path = data_dir
|
||||
.join("backups")
|
||||
.join(format!("{}.meta.json", backup_id));
|
||||
|
||||
if !backup_path.exists() {
|
||||
return Ok(VerifyResult {
|
||||
valid: false,
|
||||
id: backup_id.to_string(),
|
||||
created_at: String::new(),
|
||||
size_bytes: 0,
|
||||
error: Some("Backup file not found".to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
let encrypted = fs::read(&backup_path)
|
||||
.await
|
||||
.context("Failed to read backup")?;
|
||||
|
||||
let meta: BackupMetadata = if meta_path.exists() {
|
||||
let content = fs::read_to_string(&meta_path).await?;
|
||||
serde_json::from_str(&content)?
|
||||
} else {
|
||||
BackupMetadata {
|
||||
id: backup_id.to_string(),
|
||||
version: 0,
|
||||
created_at: String::new(),
|
||||
encrypted: true,
|
||||
size_bytes: encrypted.len() as u64,
|
||||
description: None,
|
||||
}
|
||||
};
|
||||
|
||||
match decrypt_data(&encrypted, passphrase) {
|
||||
Ok(data) => {
|
||||
// Verify it's a valid gzip
|
||||
let mut decoder = GzDecoder::new(data.as_slice());
|
||||
let mut buf = [0u8; 512];
|
||||
match decoder.read(&mut buf) {
|
||||
Ok(_) => Ok(VerifyResult {
|
||||
valid: true,
|
||||
id: meta.id,
|
||||
created_at: meta.created_at,
|
||||
size_bytes: meta.size_bytes,
|
||||
error: None,
|
||||
}),
|
||||
Err(e) => Ok(VerifyResult {
|
||||
valid: false,
|
||||
id: meta.id,
|
||||
created_at: meta.created_at,
|
||||
size_bytes: meta.size_bytes,
|
||||
error: Some(format!("Archive corrupted: {}", e)),
|
||||
}),
|
||||
}
|
||||
}
|
||||
Err(e) => Ok(VerifyResult {
|
||||
valid: false,
|
||||
id: meta.id,
|
||||
created_at: meta.created_at,
|
||||
size_bytes: meta.size_bytes,
|
||||
error: Some(format!("Decryption failed: {}", e)),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the backup file path for download.
|
||||
pub fn backup_file_path(data_dir: &Path, backup_id: &str) -> PathBuf {
|
||||
data_dir.join("backups").join(format!("{}.bak", backup_id))
|
||||
}
|
||||
|
||||
/// Info about a detected removable USB drive.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UsbDrive {
|
||||
pub device: String,
|
||||
pub mount_point: Option<String>,
|
||||
pub label: Option<String>,
|
||||
pub size_bytes: u64,
|
||||
pub removable: bool,
|
||||
}
|
||||
|
||||
/// List removable USB drives on the system.
|
||||
/// Scans /sys/block/sd* for removable devices.
|
||||
pub async fn list_usb_drives() -> Result<Vec<UsbDrive>> {
|
||||
let mut drives = Vec::new();
|
||||
|
||||
let mut entries = match fs::read_dir("/sys/block").await {
|
||||
Ok(e) => e,
|
||||
Err(_) => return Ok(drives),
|
||||
};
|
||||
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if !name.starts_with("sd") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if removable
|
||||
let removable_path = format!("/sys/block/{}/removable", name);
|
||||
let removable = match fs::read_to_string(&removable_path).await {
|
||||
Ok(v) => v.trim() == "1",
|
||||
Err(_) => false,
|
||||
};
|
||||
if !removable {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get size in 512-byte sectors
|
||||
let size_path = format!("/sys/block/{}/size", name);
|
||||
let size_bytes = match fs::read_to_string(&size_path).await {
|
||||
Ok(v) => v.trim().parse::<u64>().unwrap_or(0) * 512,
|
||||
Err(_) => 0,
|
||||
};
|
||||
|
||||
let device = format!("/dev/{}", name);
|
||||
|
||||
// Check mount point from /proc/mounts
|
||||
let mount_point = find_mount_point(&device).await;
|
||||
|
||||
// Try to get label from the first partition
|
||||
let partition = format!("{}1", device);
|
||||
let label = get_fs_label(&partition).await;
|
||||
|
||||
drives.push(UsbDrive {
|
||||
device,
|
||||
mount_point,
|
||||
label,
|
||||
size_bytes,
|
||||
removable: true,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(drives)
|
||||
}
|
||||
|
||||
/// Copy a backup file to a mounted USB drive.
|
||||
pub async fn backup_to_usb(
|
||||
data_dir: &Path,
|
||||
backup_id: &str,
|
||||
mount_point: &str,
|
||||
) -> Result<PathBuf> {
|
||||
let src = backup_file_path(data_dir, backup_id);
|
||||
if !src.exists() {
|
||||
anyhow::bail!("Backup not found: {}", backup_id);
|
||||
}
|
||||
|
||||
let mount_path = Path::new(mount_point);
|
||||
if !mount_path.exists() || !mount_path.is_dir() {
|
||||
anyhow::bail!("Mount point not accessible: {}", mount_point);
|
||||
}
|
||||
|
||||
let dest_dir = mount_path.join("archipelago-backups");
|
||||
fs::create_dir_all(&dest_dir)
|
||||
.await
|
||||
.context("Failed to create backup dir on USB")?;
|
||||
|
||||
let filename = format!("{}.bak", backup_id);
|
||||
let dest = dest_dir.join(&filename);
|
||||
|
||||
fs::copy(&src, &dest)
|
||||
.await
|
||||
.context("Failed to copy backup to USB")?;
|
||||
|
||||
// Also copy metadata
|
||||
let meta_src = data_dir
|
||||
.join("backups")
|
||||
.join(format!("{}.meta.json", backup_id));
|
||||
if meta_src.exists() {
|
||||
let meta_dest = dest_dir.join(format!("{}.meta.json", backup_id));
|
||||
let _ = fs::copy(&meta_src, &meta_dest).await;
|
||||
}
|
||||
|
||||
info!(id = %backup_id, dest = %dest.display(), "Backup copied to USB");
|
||||
Ok(dest)
|
||||
}
|
||||
|
||||
async fn find_mount_point(device: &str) -> Option<String> {
|
||||
let mounts = fs::read_to_string("/proc/mounts").await.ok()?;
|
||||
for line in mounts.lines() {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 2 && parts[0].starts_with(device) {
|
||||
return Some(parts[1].to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
async fn get_fs_label(partition: &str) -> Option<String> {
|
||||
let output = tokio::process::Command::new("blkid")
|
||||
.arg("-s")
|
||||
.arg("LABEL")
|
||||
.arg("-o")
|
||||
.arg("value")
|
||||
.arg(partition)
|
||||
.output()
|
||||
.await
|
||||
.ok()?;
|
||||
if output.status.success() {
|
||||
let label = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !label.is_empty() {
|
||||
return Some(label);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// --- Internal helpers ---
|
||||
|
||||
fn create_tar_gz(data_dir: &Path) -> Result<Vec<u8>> {
|
||||
let mut buf = Vec::new();
|
||||
let gz = GzEncoder::new(&mut buf, Compression::default());
|
||||
let mut tar = Builder::new(gz);
|
||||
|
||||
// Add directories
|
||||
for dir_name in BACKUP_DIRS {
|
||||
let dir_path = data_dir.join(dir_name);
|
||||
if dir_path.exists() && dir_path.is_dir() {
|
||||
tar.append_dir_all(*dir_name, &dir_path)
|
||||
.with_context(|| format!("Failed to add dir {} to backup", dir_name))?;
|
||||
debug!(dir = %dir_name, "Added directory to backup");
|
||||
}
|
||||
}
|
||||
|
||||
// Add individual files
|
||||
for file_name in BACKUP_FILES {
|
||||
let file_path = data_dir.join(file_name);
|
||||
if file_path.exists() && file_path.is_file() {
|
||||
let data = std::fs::read(&file_path)
|
||||
.with_context(|| format!("Failed to read {}", file_name))?;
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(data.len() as u64);
|
||||
header.set_mode(0o644);
|
||||
header.set_cksum();
|
||||
tar.append_data(&mut header, *file_name, data.as_slice())
|
||||
.with_context(|| format!("Failed to add {} to backup", file_name))?;
|
||||
debug!(file = %file_name, "Added file to backup");
|
||||
}
|
||||
}
|
||||
|
||||
tar.into_inner()
|
||||
.context("Failed to finalize tar")?
|
||||
.finish()
|
||||
.context("Failed to finalize gzip")?;
|
||||
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
fn extract_tar_gz(data_dir: &Path, tar_gz_data: &[u8]) -> Result<()> {
|
||||
let gz = GzDecoder::new(tar_gz_data);
|
||||
let mut archive = Archive::new(gz);
|
||||
|
||||
archive
|
||||
.unpack(data_dir)
|
||||
.context("Failed to extract backup archive")?;
|
||||
|
||||
debug!("Backup extracted to {:?}", data_dir);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn encrypt_data(data: &[u8], passphrase: &str) -> Result<Vec<u8>> {
|
||||
let mut salt = [0u8; SALT_LEN];
|
||||
let mut nonce = [0u8; NONCE_LEN];
|
||||
rand::rngs::OsRng.fill_bytes(&mut salt);
|
||||
rand::rngs::OsRng.fill_bytes(&mut nonce);
|
||||
|
||||
let argon2 = Argon2::default();
|
||||
let mut key = [0u8; KEY_LEN];
|
||||
argon2
|
||||
.hash_password_into(passphrase.as_bytes(), &salt, &mut key)
|
||||
.map_err(|e| anyhow::anyhow!("Key derivation failed: {}", e))?;
|
||||
|
||||
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),
|
||||
data,
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("Encryption failed: {}", e))?;
|
||||
|
||||
// Format: salt || nonce || ciphertext
|
||||
let mut output = Vec::with_capacity(SALT_LEN + NONCE_LEN + ciphertext.len());
|
||||
output.extend_from_slice(&salt);
|
||||
output.extend_from_slice(&nonce);
|
||||
output.extend_from_slice(&ciphertext);
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn decrypt_data(data: &[u8], passphrase: &str) -> Result<Vec<u8>> {
|
||||
if data.len() < SALT_LEN + NONCE_LEN {
|
||||
anyhow::bail!("Backup data too short");
|
||||
}
|
||||
|
||||
let salt = &data[..SALT_LEN];
|
||||
let nonce = &data[SALT_LEN..SALT_LEN + NONCE_LEN];
|
||||
let ciphertext = &data[SALT_LEN + NONCE_LEN..];
|
||||
|
||||
let argon2 = Argon2::default();
|
||||
let mut key = [0u8; KEY_LEN];
|
||||
argon2
|
||||
.hash_password_into(passphrase.as_bytes(), salt, &mut key)
|
||||
.map_err(|e| anyhow::anyhow!("Key derivation failed: {}", e))?;
|
||||
|
||||
let cipher = chacha20poly1305::ChaCha20Poly1305::new_from_slice(&key)
|
||||
.map_err(|e| anyhow::anyhow!("Cipher init: {}", e))?;
|
||||
let plaintext = cipher
|
||||
.decrypt(
|
||||
chacha20poly1305::aead::generic_array::GenericArray::from_slice(nonce),
|
||||
ciphertext,
|
||||
)
|
||||
.map_err(|_| anyhow::anyhow!("Decryption failed — wrong passphrase or corrupted data"))?;
|
||||
|
||||
Ok(plaintext)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup_data_dir(dir: &Path) {
|
||||
// Create some test data
|
||||
std::fs::create_dir_all(dir.join("identity")).unwrap();
|
||||
std::fs::write(dir.join("identity/node_key"), vec![0xAB; 32]).unwrap();
|
||||
std::fs::write(dir.join("user.json"), r#"{"user":"test"}"#).unwrap();
|
||||
std::fs::write(dir.join("peers.json"), "[]").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypt_decrypt_roundtrip() {
|
||||
let data = b"Hello, Archipelago backup!";
|
||||
let pass = "test-passphrase";
|
||||
let encrypted = encrypt_data(data, pass).unwrap();
|
||||
assert_ne!(&encrypted[..], data);
|
||||
let decrypted = decrypt_data(&encrypted, pass).unwrap();
|
||||
assert_eq!(&decrypted, data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_passphrase_fails() {
|
||||
let data = b"secret data";
|
||||
let encrypted = encrypt_data(data, "correct").unwrap();
|
||||
assert!(decrypt_data(&encrypted, "wrong").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tar_gz_roundtrip() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
setup_data_dir(dir.path());
|
||||
|
||||
let archive = create_tar_gz(dir.path()).unwrap();
|
||||
assert!(!archive.is_empty());
|
||||
|
||||
// Extract to a new dir
|
||||
let restore_dir = TempDir::new().unwrap();
|
||||
extract_tar_gz(restore_dir.path(), &archive).unwrap();
|
||||
|
||||
// Verify files exist
|
||||
assert!(restore_dir.path().join("identity/node_key").exists());
|
||||
assert!(restore_dir.path().join("user.json").exists());
|
||||
assert!(restore_dir.path().join("peers.json").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_backup_and_list() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
setup_data_dir(dir.path());
|
||||
|
||||
let meta = create_full_backup(dir.path(), "backup-pass", Some("Test backup"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!meta.id.is_empty());
|
||||
assert!(meta.size_bytes > 0);
|
||||
assert_eq!(meta.description, Some("Test backup".to_string()));
|
||||
|
||||
let backups = list_backups(dir.path()).await.unwrap();
|
||||
assert_eq!(backups.len(), 1);
|
||||
assert_eq!(backups[0].id, meta.id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backup_verify() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
setup_data_dir(dir.path());
|
||||
|
||||
let meta = create_full_backup(dir.path(), "my-pass", None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result = verify_backup(dir.path(), &meta.id, "my-pass").await.unwrap();
|
||||
assert!(result.valid);
|
||||
|
||||
let bad_result = verify_backup(dir.path(), &meta.id, "wrong-pass").await.unwrap();
|
||||
assert!(!bad_result.valid);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backup_and_restore() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
setup_data_dir(dir.path());
|
||||
|
||||
let meta = create_full_backup(dir.path(), "restore-pass", None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Delete the original data
|
||||
std::fs::remove_file(dir.path().join("user.json")).unwrap();
|
||||
assert!(!dir.path().join("user.json").exists());
|
||||
|
||||
// Restore
|
||||
restore_full_backup(dir.path(), &meta.id, "restore-pass")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Verify restored
|
||||
assert!(dir.path().join("user.json").exists());
|
||||
let content = std::fs::read_to_string(dir.path().join("user.json")).unwrap();
|
||||
assert_eq!(content, r#"{"user":"test"}"#);
|
||||
}
|
||||
}
|
||||
9
core/archipelago/src/backup/mod.rs
Normal file
9
core/archipelago/src/backup/mod.rs
Normal file
@ -0,0 +1,9 @@
|
||||
//! Backup and restore for Archipelago.
|
||||
//!
|
||||
//! - `identity`: Encrypted DID identity key backup (existing).
|
||||
//! - `full`: Full system backup — identity + app data + configs + settings.
|
||||
|
||||
mod identity;
|
||||
pub mod full;
|
||||
|
||||
pub use identity::create_encrypted_backup;
|
||||
@ -208,3 +208,141 @@ impl Default for Config {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_default_config_values() {
|
||||
let config = Config::default();
|
||||
assert_eq!(config.data_dir, PathBuf::from("/var/lib/archipelago"));
|
||||
assert_eq!(config.bind_host, "0.0.0.0");
|
||||
assert_eq!(config.bind_port, 5678);
|
||||
assert_eq!(config.log_level, "info");
|
||||
assert_eq!(config.host_ip, "127.0.0.1");
|
||||
assert!(!config.dev_mode);
|
||||
assert_eq!(config.port_offset, 10000);
|
||||
assert!(config.nostr_discovery_enabled);
|
||||
assert_eq!(config.nostr_relays.len(), 2);
|
||||
assert_eq!(config.nostr_tor_proxy, Some("127.0.0.1:9050".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_container_runtime_from_str_podman() {
|
||||
assert!(matches!(ContainerRuntime::from_str("podman"), ContainerRuntime::Podman));
|
||||
assert!(matches!(ContainerRuntime::from_str("Podman"), ContainerRuntime::Podman));
|
||||
assert!(matches!(ContainerRuntime::from_str("PODMAN"), ContainerRuntime::Podman));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_container_runtime_from_str_docker() {
|
||||
assert!(matches!(ContainerRuntime::from_str("docker"), ContainerRuntime::Docker));
|
||||
assert!(matches!(ContainerRuntime::from_str("Docker"), ContainerRuntime::Docker));
|
||||
assert!(matches!(ContainerRuntime::from_str("DOCKER"), ContainerRuntime::Docker));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_container_runtime_from_str_auto() {
|
||||
assert!(matches!(ContainerRuntime::from_str("auto"), ContainerRuntime::Auto));
|
||||
assert!(matches!(ContainerRuntime::from_str("Auto"), ContainerRuntime::Auto));
|
||||
// Unknown strings default to Auto
|
||||
assert!(matches!(ContainerRuntime::from_str("unknown"), ContainerRuntime::Auto));
|
||||
assert!(matches!(ContainerRuntime::from_str(""), ContainerRuntime::Auto));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bitcoin_simulation_from_str() {
|
||||
assert!(matches!(BitcoinSimulation::from_str("mock"), BitcoinSimulation::Mock));
|
||||
assert!(matches!(BitcoinSimulation::from_str("Mock"), BitcoinSimulation::Mock));
|
||||
assert!(matches!(BitcoinSimulation::from_str("testnet"), BitcoinSimulation::Testnet));
|
||||
assert!(matches!(BitcoinSimulation::from_str("Testnet"), BitcoinSimulation::Testnet));
|
||||
assert!(matches!(BitcoinSimulation::from_str("mainnet"), BitcoinSimulation::Mainnet));
|
||||
assert!(matches!(BitcoinSimulation::from_str("Mainnet"), BitcoinSimulation::Mainnet));
|
||||
assert!(matches!(BitcoinSimulation::from_str("none"), BitcoinSimulation::None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bitcoin_simulation_unknown_defaults_to_none() {
|
||||
assert!(matches!(BitcoinSimulation::from_str(""), BitcoinSimulation::None));
|
||||
assert!(matches!(BitcoinSimulation::from_str("signet"), BitcoinSimulation::None));
|
||||
assert!(matches!(BitcoinSimulation::from_str("garbage"), BitcoinSimulation::None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_serialization_roundtrip() {
|
||||
let config = Config::default();
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
let deserialized: Config = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.bind_host, config.bind_host);
|
||||
assert_eq!(deserialized.bind_port, config.bind_port);
|
||||
assert_eq!(deserialized.data_dir, config.data_dir);
|
||||
assert_eq!(deserialized.log_level, config.log_level);
|
||||
assert_eq!(deserialized.dev_mode, config.dev_mode);
|
||||
assert_eq!(deserialized.port_offset, config.port_offset);
|
||||
assert_eq!(deserialized.nostr_discovery_enabled, config.nostr_discovery_enabled);
|
||||
assert_eq!(deserialized.nostr_relays, config.nostr_relays);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_toml_parsing() {
|
||||
let toml_str = r#"
|
||||
data_dir = "/tmp/test-data"
|
||||
bind_host = "127.0.0.1"
|
||||
bind_port = 9999
|
||||
log_level = "debug"
|
||||
host_ip = "192.168.1.100"
|
||||
dev_mode = true
|
||||
container_runtime = "Podman"
|
||||
port_offset = 20000
|
||||
bitcoin_simulation = "Testnet"
|
||||
dev_data_dir = "/tmp/dev-test"
|
||||
nostr_discovery_enabled = false
|
||||
nostr_relays = ["wss://example.com"]
|
||||
"#;
|
||||
let config: Config = toml::de::from_str(toml_str).unwrap();
|
||||
assert_eq!(config.data_dir, PathBuf::from("/tmp/test-data"));
|
||||
assert_eq!(config.bind_host, "127.0.0.1");
|
||||
assert_eq!(config.bind_port, 9999);
|
||||
assert_eq!(config.log_level, "debug");
|
||||
assert_eq!(config.host_ip, "192.168.1.100");
|
||||
assert!(config.dev_mode);
|
||||
assert_eq!(config.port_offset, 20000);
|
||||
assert!(!config.nostr_discovery_enabled);
|
||||
assert_eq!(config.nostr_relays, vec!["wss://example.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_data_dir_is_pathbuf() {
|
||||
let config = Config::default();
|
||||
assert!(config.data_dir.is_absolute());
|
||||
assert_eq!(config.data_dir, PathBuf::from("/var/lib/archipelago"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_host_ip_default() {
|
||||
let config = Config::default();
|
||||
assert_eq!(config.host_ip, "127.0.0.1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_dev_mode_defaults_off() {
|
||||
let config = Config::default();
|
||||
assert!(!config.dev_mode);
|
||||
assert_eq!(config.dev_data_dir, PathBuf::from("/tmp/archipelago-dev"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_nostr_discovery_enabled_by_default() {
|
||||
let config = Config::default();
|
||||
assert!(config.nostr_discovery_enabled);
|
||||
assert!(config.nostr_tor_proxy.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_nostr_relays_default_not_empty() {
|
||||
let config = Config::default();
|
||||
assert!(!config.nostr_relays.is_empty());
|
||||
assert!(config.nostr_relays.iter().all(|r| r.starts_with("wss://")));
|
||||
}
|
||||
}
|
||||
|
||||
@ -464,6 +464,48 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
|
||||
icon: "/assets/img/app-icons/tor.svg".to_string(),
|
||||
repo: "https://gitlab.torproject.org/tpo/core/tor".to_string(),
|
||||
},
|
||||
"botfights" => AppMetadata {
|
||||
title: "BotFights".to_string(),
|
||||
description: "AI bot arena — build, train, and battle autonomous agents".to_string(),
|
||||
icon: "/assets/img/app-icons/botfights.svg".to_string(),
|
||||
repo: "https://botfights.net".to_string(),
|
||||
},
|
||||
"nwnn" => AppMetadata {
|
||||
title: "Next Web News Network".to_string(),
|
||||
description: "Decentralized news and link aggregator, synced from Telegram".to_string(),
|
||||
icon: "/assets/img/app-icons/nwnn.png".to_string(),
|
||||
repo: "https://nwnn.l484.com".to_string(),
|
||||
},
|
||||
"484-kitchen" => AppMetadata {
|
||||
title: "484 Kitchen".to_string(),
|
||||
description: "K484 application platform".to_string(),
|
||||
icon: "/assets/img/app-icons/484-kitchen.png".to_string(),
|
||||
repo: "https://484.kitchen".to_string(),
|
||||
},
|
||||
"call-the-operator" => AppMetadata {
|
||||
title: "Call the Operator".to_string(),
|
||||
description: "Escape the Matrix — explore decentralized alternatives".to_string(),
|
||||
icon: "/assets/img/app-icons/call-the-operator.png".to_string(),
|
||||
repo: "https://cta.tx1138.com".to_string(),
|
||||
},
|
||||
"arch-presentation" => AppMetadata {
|
||||
title: "Arch Presentation".to_string(),
|
||||
description: "Archipelago: The Future of Decentralized Infrastructure".to_string(),
|
||||
icon: "/assets/img/app-icons/arch-presentation.png".to_string(),
|
||||
repo: "https://present.l484.com".to_string(),
|
||||
},
|
||||
"syntropy-institute" => AppMetadata {
|
||||
title: "Syntropy Institute".to_string(),
|
||||
description: "Medicine Reimagined — frequency analysis-therapy and digital homeopathy".to_string(),
|
||||
icon: "/assets/img/app-icons/syntropy-institute.png".to_string(),
|
||||
repo: "https://syntropy.institute".to_string(),
|
||||
},
|
||||
"t-zero" => AppMetadata {
|
||||
title: "T-0".to_string(),
|
||||
description: "Documentary series on decentralization, Bitcoin, and the ungovernable future".to_string(),
|
||||
icon: "/assets/img/app-icons/t-zero.png".to_string(),
|
||||
repo: "https://teeminuszero.net".to_string(),
|
||||
},
|
||||
_ => AppMetadata {
|
||||
title: app_id.to_string(),
|
||||
description: format!("{} application", app_id),
|
||||
|
||||
335
core/archipelago/src/crash_recovery.rs
Normal file
335
core/archipelago/src/crash_recovery.rs
Normal file
@ -0,0 +1,335 @@
|
||||
// Crash Recovery Module
|
||||
// Detects unexpected shutdowns and restarts containers that were running before the crash.
|
||||
//
|
||||
// How it works:
|
||||
// 1. On startup, write a PID file as a "running" marker
|
||||
// 2. Periodically snapshot which containers are running to a state file
|
||||
// 3. On clean shutdown, remove the PID file
|
||||
// 4. On next startup, if the PID file exists → previous instance crashed
|
||||
// 5. On crash recovery: read the saved container list, restart them, log actions
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::fs;
|
||||
use tracing::{info, warn};
|
||||
|
||||
const PID_FILE: &str = "archipelago.pid";
|
||||
const CONTAINER_STATE_FILE: &str = "running-containers.json";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RunningContainerRecord {
|
||||
pub name: String,
|
||||
pub image: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct ContainerSnapshot {
|
||||
pub timestamp: u64,
|
||||
pub containers: Vec<RunningContainerRecord>,
|
||||
}
|
||||
|
||||
/// Check if the previous instance crashed (PID file exists without a clean shutdown).
|
||||
/// Returns the list of containers that were running before the crash, if any.
|
||||
pub async fn check_for_crash(data_dir: &Path) -> Result<Option<Vec<RunningContainerRecord>>> {
|
||||
let pid_path = data_dir.join(PID_FILE);
|
||||
|
||||
if !pid_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// PID file exists — previous instance didn't shut down cleanly
|
||||
let old_pid = fs::read_to_string(&pid_path)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
warn!("Crash detected: previous instance (PID {}) did not shut down cleanly", old_pid);
|
||||
|
||||
// Check if that PID is actually still running (zombie/stuck process)
|
||||
if !old_pid.is_empty() {
|
||||
if let Ok(pid) = old_pid.parse::<u32>() {
|
||||
if is_process_running(pid) {
|
||||
warn!("Previous process (PID {}) is still running — not a crash, skipping recovery", pid);
|
||||
// Remove stale PID file and skip recovery
|
||||
let _ = fs::remove_file(&pid_path).await;
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load the saved container snapshot
|
||||
let state_path = data_dir.join(CONTAINER_STATE_FILE);
|
||||
let containers = if state_path.exists() {
|
||||
match fs::read_to_string(&state_path).await {
|
||||
Ok(content) => {
|
||||
match serde_json::from_str::<ContainerSnapshot>(&content) {
|
||||
Ok(snapshot) => {
|
||||
info!(
|
||||
"Found {} containers from pre-crash snapshot (saved at {})",
|
||||
snapshot.containers.len(),
|
||||
snapshot.timestamp
|
||||
);
|
||||
snapshot.containers
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to parse container snapshot: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to read container snapshot: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
info!("No container snapshot found — cannot determine which containers were running");
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
// Clean up the stale PID file
|
||||
let _ = fs::remove_file(&pid_path).await;
|
||||
|
||||
if containers.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(containers))
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the PID file to mark the current instance as running.
|
||||
pub async fn write_pid_marker(data_dir: &Path) -> Result<()> {
|
||||
let pid = std::process::id();
|
||||
let pid_path = data_dir.join(PID_FILE);
|
||||
fs::write(&pid_path, pid.to_string())
|
||||
.await
|
||||
.context("Failed to write PID marker")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove the PID file on clean shutdown.
|
||||
pub async fn remove_pid_marker(data_dir: &Path) {
|
||||
let pid_path = data_dir.join(PID_FILE);
|
||||
if let Err(e) = fs::remove_file(&pid_path).await {
|
||||
warn!("Failed to remove PID marker: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Save a snapshot of currently running containers to disk.
|
||||
/// Called periodically so we know what to restart after a crash.
|
||||
pub async fn save_container_snapshot(data_dir: &Path) -> Result<()> {
|
||||
let output = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "ps", "--format", "json"])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to run podman ps")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!("podman ps failed: {}", stderr);
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let containers: Vec<serde_json::Value> =
|
||||
serde_json::from_str(&stdout).unwrap_or_default();
|
||||
|
||||
let records: Vec<RunningContainerRecord> = containers
|
||||
.iter()
|
||||
.filter_map(|c| {
|
||||
let name = c.get("Names")
|
||||
.and_then(|v| {
|
||||
// Podman returns Names as an array
|
||||
if let Some(arr) = v.as_array() {
|
||||
arr.first().and_then(|n| n.as_str()).map(|s| s.to_string())
|
||||
} else {
|
||||
v.as_str().map(|s| s.to_string())
|
||||
}
|
||||
})?;
|
||||
let image = c.get("Image")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
Some(RunningContainerRecord { name, image })
|
||||
})
|
||||
.collect();
|
||||
|
||||
let snapshot = ContainerSnapshot {
|
||||
timestamp: std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs(),
|
||||
containers: records,
|
||||
};
|
||||
|
||||
let state_path = data_dir.join(CONTAINER_STATE_FILE);
|
||||
let json = serde_json::to_string_pretty(&snapshot)
|
||||
.context("Failed to serialize container snapshot")?;
|
||||
fs::write(&state_path, json)
|
||||
.await
|
||||
.context("Failed to write container snapshot")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Recover containers that were running before a crash.
|
||||
/// Attempts to start each container, logging success/failure.
|
||||
pub async fn recover_containers(containers: &[RunningContainerRecord]) -> RecoveryReport {
|
||||
let mut report = RecoveryReport {
|
||||
total: containers.len(),
|
||||
recovered: 0,
|
||||
failed: Vec::new(),
|
||||
};
|
||||
|
||||
for record in containers {
|
||||
info!("Recovering container: {} (image: {})", record.name, record.image);
|
||||
|
||||
let result = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "start", &record.name])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(output) if output.status.success() => {
|
||||
info!("Successfully restarted container: {}", record.name);
|
||||
report.recovered += 1;
|
||||
}
|
||||
Ok(output) => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
warn!("Failed to restart container {}: {}", record.name, stderr.trim());
|
||||
report.failed.push(record.name.clone());
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to execute podman start for {}: {}", record.name, e);
|
||||
report.failed.push(record.name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
report
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct RecoveryReport {
|
||||
pub total: usize,
|
||||
pub recovered: usize,
|
||||
pub failed: Vec<String>,
|
||||
}
|
||||
|
||||
/// Check if a process with the given PID is still running.
|
||||
fn is_process_running(pid: u32) -> bool {
|
||||
// Check /proc/{pid} on Linux
|
||||
std::path::Path::new(&format!("/proc/{}", pid)).exists()
|
||||
}
|
||||
|
||||
/// Spawn a background task that periodically saves the container snapshot.
|
||||
pub fn spawn_snapshot_task(data_dir: PathBuf) {
|
||||
tokio::spawn(async move {
|
||||
// Wait 30s before first snapshot (let containers stabilize after startup)
|
||||
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
|
||||
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(60));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
if let Err(e) = save_container_snapshot(&data_dir).await {
|
||||
tracing::debug!("Container snapshot (non-fatal): {}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_no_crash_without_pid_file() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let result = check_for_crash(tmp.path()).await.unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_crash_detected_with_pid_file() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
// Write a PID file with a non-existent PID
|
||||
fs::write(tmp.path().join(PID_FILE), "999999999").await.unwrap();
|
||||
let result = check_for_crash(tmp.path()).await.unwrap();
|
||||
// No snapshot file → crash detected but no containers to recover
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_crash_with_snapshot() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
// Write PID file
|
||||
fs::write(tmp.path().join(PID_FILE), "999999999").await.unwrap();
|
||||
// Write container snapshot
|
||||
let snapshot = ContainerSnapshot {
|
||||
timestamp: 1000,
|
||||
containers: vec![
|
||||
RunningContainerRecord {
|
||||
name: "archy-bitcoin-knots".to_string(),
|
||||
image: "bitcoin-knots:27.1".to_string(),
|
||||
},
|
||||
RunningContainerRecord {
|
||||
name: "archy-mempool-web".to_string(),
|
||||
image: "mempool/frontend:3.0".to_string(),
|
||||
},
|
||||
],
|
||||
};
|
||||
let json = serde_json::to_string(&snapshot).unwrap();
|
||||
fs::write(tmp.path().join(CONTAINER_STATE_FILE), json).await.unwrap();
|
||||
|
||||
let result = check_for_crash(tmp.path()).await.unwrap();
|
||||
assert!(result.is_some());
|
||||
let containers = result.unwrap();
|
||||
assert_eq!(containers.len(), 2);
|
||||
assert_eq!(containers[0].name, "archy-bitcoin-knots");
|
||||
assert_eq!(containers[1].name, "archy-mempool-web");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_write_and_remove_pid_marker() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
write_pid_marker(tmp.path()).await.unwrap();
|
||||
assert!(tmp.path().join(PID_FILE).exists());
|
||||
|
||||
remove_pid_marker(tmp.path()).await;
|
||||
assert!(!tmp.path().join(PID_FILE).exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pid_marker_contains_current_pid() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
write_pid_marker(tmp.path()).await.unwrap();
|
||||
let content = fs::read_to_string(tmp.path().join(PID_FILE)).await.unwrap();
|
||||
let saved_pid: u32 = content.parse().unwrap();
|
||||
assert_eq!(saved_pid, std::process::id());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_clean_shutdown_no_crash_on_restart() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
// Simulate: startup → write PID → clean shutdown → remove PID
|
||||
write_pid_marker(tmp.path()).await.unwrap();
|
||||
remove_pid_marker(tmp.path()).await;
|
||||
|
||||
// Next startup should detect no crash
|
||||
let result = check_for_crash(tmp.path()).await.unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_corrupt_snapshot_handled() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
fs::write(tmp.path().join(PID_FILE), "999999999").await.unwrap();
|
||||
fs::write(tmp.path().join(CONTAINER_STATE_FILE), "not valid json").await.unwrap();
|
||||
|
||||
// Should not crash, returns None (no recoverable containers)
|
||||
let result = check_for_crash(tmp.path()).await.unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
//! Verifiable Credentials (VC) management following W3C VC Data Model.
|
||||
//! Allows issuing, verifying, and managing credentials tied to DIDs.
|
||||
//! 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};
|
||||
@ -9,18 +10,58 @@ use tracing::debug;
|
||||
|
||||
const CREDENTIALS_DIR: &str = "credentials";
|
||||
|
||||
/// A Verifiable Credential following W3C VC Data Model (simplified).
|
||||
/// 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 subject: String,
|
||||
pub credential_type: 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,
|
||||
pub issued_at: String,
|
||||
pub expires_at: Option<String>,
|
||||
pub signature: String,
|
||||
pub status: CredentialStatus,
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
@ -76,8 +117,8 @@ pub async fn save_credentials(data_dir: &Path, store: &CredentialStore) -> Resul
|
||||
fs::write(&path, data).await.context("Writing credentials")
|
||||
}
|
||||
|
||||
/// Issue a new Verifiable Credential.
|
||||
/// The issuer signs the credential claims with their identity key.
|
||||
/// 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,
|
||||
@ -87,35 +128,47 @@ pub async fn issue_credential(
|
||||
expires_at: Option<&str>,
|
||||
sign_fn: impl FnOnce(&[u8]) -> Result<String>,
|
||||
) -> Result<VerifiableCredential> {
|
||||
let id = format!("vc:{}", uuid::Uuid::new_v4());
|
||||
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
|
||||
// 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,
|
||||
"subject": subject_did,
|
||||
"type": credential_type,
|
||||
"claims": claims,
|
||||
"issued_at": issued_at,
|
||||
"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(),
|
||||
subject: subject_did.to_string(),
|
||||
credential_type: credential_type.to_string(),
|
||||
credential_subject: CredentialSubject {
|
||||
id: subject_did.to_string(),
|
||||
claims,
|
||||
issued_at,
|
||||
expires_at: expires_at.map(|s| s.to_string()),
|
||||
signature,
|
||||
status: CredentialStatus::Active,
|
||||
},
|
||||
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 credential");
|
||||
debug!(id = %vc.id, "Issued W3C VC");
|
||||
store.credentials.push(vc.clone());
|
||||
save_credentials(data_dir, &store).await?;
|
||||
Ok(vc)
|
||||
@ -126,16 +179,19 @@ 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,
|
||||
"issuer": vc.issuer,
|
||||
"subject": vc.subject,
|
||||
"type": vc.credential_type,
|
||||
"claims": vc.claims,
|
||||
"issued_at": vc.issued_at,
|
||||
"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.signature)
|
||||
verify_fn(&vc.issuer, &body_bytes, &vc.proof.proof_value)
|
||||
}
|
||||
|
||||
/// Revoke a credential by ID.
|
||||
@ -146,7 +202,11 @@ pub async fn revoke_credential(data_dir: &Path, credential_id: &str) -> Result<(
|
||||
.iter_mut()
|
||||
.find(|c| c.id == credential_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Credential not found: {}", credential_id))?;
|
||||
vc.status = CredentialStatus::Revoked;
|
||||
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
|
||||
}
|
||||
|
||||
@ -160,10 +220,518 @@ pub async fn list_credentials(
|
||||
store
|
||||
.credentials
|
||||
.into_iter()
|
||||
.filter(|c| c.issuer == did || c.subject == did)
|
||||
.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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,9 +11,30 @@ pub struct DataModel {
|
||||
pub package_data: HashMap<String, PackageDataEntry>,
|
||||
#[serde(rename = "peer-health", default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub peer_health: HashMap<String, bool>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub notifications: Vec<Notification>,
|
||||
pub ui: UIData,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Notification {
|
||||
pub id: String,
|
||||
pub level: NotificationLevel,
|
||||
pub title: String,
|
||||
pub message: String,
|
||||
pub timestamp: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub app_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum NotificationLevel {
|
||||
Info,
|
||||
Warning,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ServerInfo {
|
||||
pub id: String,
|
||||
@ -239,6 +260,7 @@ impl DataModel {
|
||||
},
|
||||
package_data: HashMap::new(),
|
||||
peer_health: HashMap::new(),
|
||||
notifications: Vec::new(),
|
||||
ui: UIData {
|
||||
name: None,
|
||||
ack_welcome: String::new(),
|
||||
|
||||
305
core/archipelago/src/disk_monitor.rs
Normal file
305
core/archipelago/src/disk_monitor.rs
Normal file
@ -0,0 +1,305 @@
|
||||
// Disk Space Monitor
|
||||
// Periodically checks disk usage and triggers automatic cleanup at 90%.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// Parse df output into (used_bytes, total_bytes, used_percent).
|
||||
/// Expects output from `df --block-size=1 --output=used,size /` which has a header line
|
||||
/// followed by a data line with two whitespace-separated numbers.
|
||||
fn parse_df_output(stdout: &str) -> Result<(u64, u64, f64)> {
|
||||
let data_line = stdout
|
||||
.lines()
|
||||
.nth(1)
|
||||
.ok_or_else(|| anyhow::anyhow!("No data line from df"))?;
|
||||
let mut parts = data_line.split_whitespace();
|
||||
let used: u64 = parts
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing used"))?
|
||||
.parse()
|
||||
.context("parse df used")?;
|
||||
let total: u64 = parts
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing total"))?
|
||||
.parse()
|
||||
.context("parse df total")?;
|
||||
|
||||
let percent = if total > 0 {
|
||||
(used as f64 / total as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
Ok((used, total, percent))
|
||||
}
|
||||
|
||||
/// Check disk usage percentage for the root filesystem.
|
||||
/// Returns (used_bytes, total_bytes, used_percent).
|
||||
pub async fn check_disk_usage() -> Result<(u64, u64, f64)> {
|
||||
let output = tokio::process::Command::new("df")
|
||||
.args(["--block-size=1", "--output=used,size", "/"])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to run df")?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!("df failed: {}", String::from_utf8_lossy(&output.stderr));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8(output.stdout).context("df output not utf8")?;
|
||||
parse_df_output(&stdout)
|
||||
}
|
||||
|
||||
/// Run automatic cleanup when disk usage exceeds 90%.
|
||||
async fn auto_cleanup() -> Result<u64> {
|
||||
let mut freed: u64 = 0;
|
||||
|
||||
// Prune dangling images
|
||||
let output = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "image", "prune", "-f"])
|
||||
.output()
|
||||
.await;
|
||||
if let Ok(out) = output {
|
||||
if out.status.success() {
|
||||
let count = String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.count();
|
||||
freed += count as u64 * 100_000_000;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean old rotated logs (> 14 days for auto-cleanup, more aggressive)
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args([
|
||||
"find", "/var/log", "-type", "f", "-name", "*.log.*",
|
||||
"-mtime", "+14", "-delete",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args([
|
||||
"find", "/var/log", "-type", "f", "-name", "*.gz",
|
||||
"-mtime", "+14", "-delete",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
// Truncate large journal logs
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["journalctl", "--vacuum-size=100M"])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
Ok(freed)
|
||||
}
|
||||
|
||||
/// Spawn a background task that monitors disk usage every 5 minutes.
|
||||
/// Triggers automatic cleanup at 90% and logs warnings at 85%.
|
||||
pub fn spawn_disk_monitor(data_dir: std::path::PathBuf) {
|
||||
tokio::spawn(async move {
|
||||
// Initial delay to let system stabilize
|
||||
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
|
||||
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(300));
|
||||
let mut last_warning_level: Option<&str> = None;
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
match check_disk_usage().await {
|
||||
Ok((_used, _total, percent)) => {
|
||||
if percent >= 90.0 {
|
||||
if last_warning_level != Some("critical") {
|
||||
warn!("Disk usage critical: {:.1}% — triggering automatic cleanup", percent);
|
||||
last_warning_level = Some("critical");
|
||||
}
|
||||
match auto_cleanup().await {
|
||||
Ok(freed) => {
|
||||
if freed > 0 {
|
||||
info!("Auto-cleanup freed approximately {} bytes", freed);
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("Auto-cleanup failed: {}", e),
|
||||
}
|
||||
// Write disk warning file for the frontend to poll
|
||||
let warning_path = data_dir.join("disk-warning.json");
|
||||
let _ = tokio::fs::write(
|
||||
&warning_path,
|
||||
serde_json::json!({
|
||||
"level": "critical",
|
||||
"percent": (percent * 10.0).round() / 10.0,
|
||||
"timestamp": chrono::Utc::now().to_rfc3339(),
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.await;
|
||||
} else if percent >= 85.0 {
|
||||
if last_warning_level != Some("warning") {
|
||||
warn!("Disk usage warning: {:.1}% — approaching critical threshold", percent);
|
||||
last_warning_level = Some("warning");
|
||||
}
|
||||
let warning_path = data_dir.join("disk-warning.json");
|
||||
let _ = tokio::fs::write(
|
||||
&warning_path,
|
||||
serde_json::json!({
|
||||
"level": "warning",
|
||||
"percent": (percent * 10.0).round() / 10.0,
|
||||
"timestamp": chrono::Utc::now().to_rfc3339(),
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
// Clear warning file if disk is healthy
|
||||
if last_warning_level.is_some() {
|
||||
let warning_path = data_dir.join("disk-warning.json");
|
||||
let _ = tokio::fs::remove_file(&warning_path).await;
|
||||
last_warning_level = None;
|
||||
info!("Disk usage back to normal: {:.1}%", percent);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("Disk usage check failed (non-fatal): {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_df_output_normal() {
|
||||
// Simulates typical df --block-size=1 --output=used,size / output
|
||||
let output = " Used Size\n 500000000000 1000000000000\n";
|
||||
let (used, total, percent) = parse_df_output(output).unwrap();
|
||||
assert_eq!(used, 500_000_000_000);
|
||||
assert_eq!(total, 1_000_000_000_000);
|
||||
assert!((percent - 50.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_df_output_high_usage() {
|
||||
let output = " Used Size\n 900000000000 1000000000000\n";
|
||||
let (used, total, percent) = parse_df_output(output).unwrap();
|
||||
assert_eq!(used, 900_000_000_000);
|
||||
assert_eq!(total, 1_000_000_000_000);
|
||||
assert!((percent - 90.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_df_output_almost_full() {
|
||||
let output = "Used Size\n999 1000\n";
|
||||
let (used, total, percent) = parse_df_output(output).unwrap();
|
||||
assert_eq!(used, 999);
|
||||
assert_eq!(total, 1000);
|
||||
assert!((percent - 99.9).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_df_output_empty_disk() {
|
||||
let output = "Used Size\n0 1000000000000\n";
|
||||
let (used, total, percent) = parse_df_output(output).unwrap();
|
||||
assert_eq!(used, 0);
|
||||
assert_eq!(total, 1_000_000_000_000);
|
||||
assert!((percent - 0.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_df_output_zero_total() {
|
||||
// Edge case: total is 0 (should not happen but should not panic/divide-by-zero)
|
||||
let output = "Used Size\n0 0\n";
|
||||
let (used, total, percent) = parse_df_output(output).unwrap();
|
||||
assert_eq!(used, 0);
|
||||
assert_eq!(total, 0);
|
||||
assert!((percent - 0.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_df_output_no_data_line() {
|
||||
let output = "Used Size\n";
|
||||
let result = parse_df_output(output);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_df_output_empty_string() {
|
||||
let result = parse_df_output("");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_df_output_single_header_only() {
|
||||
let output = "Header Only";
|
||||
let result = parse_df_output(output);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_df_output_non_numeric() {
|
||||
let output = "Used Size\nabc def\n";
|
||||
let result = parse_df_output(output);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_df_output_missing_second_field() {
|
||||
let output = "Used Size\n12345\n";
|
||||
let result = parse_df_output(output);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_df_output_extra_whitespace() {
|
||||
let output = " Used Size \n 123456 7890000 \n";
|
||||
let (used, total, _) = parse_df_output(output).unwrap();
|
||||
assert_eq!(used, 123456);
|
||||
assert_eq!(total, 7890000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_df_output_real_world_format() {
|
||||
// Closer to real df output with header padding
|
||||
let output = " Used Size\n 328000000000 1800000000000\n";
|
||||
let (used, total, percent) = parse_df_output(output).unwrap();
|
||||
assert_eq!(used, 328_000_000_000);
|
||||
assert_eq!(total, 1_800_000_000_000);
|
||||
// ~18.2%
|
||||
assert!(percent > 18.0 && percent < 19.0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_disk_warning_json_format() {
|
||||
// Verify that the JSON structure we write for disk warnings is valid
|
||||
let percent: f64 = 92.3;
|
||||
let json = serde_json::json!({
|
||||
"level": "critical",
|
||||
"percent": (percent * 10.0).round() / 10.0,
|
||||
"timestamp": chrono::Utc::now().to_rfc3339(),
|
||||
});
|
||||
let s = json.to_string();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
|
||||
assert_eq!(parsed["level"], "critical");
|
||||
assert_eq!(parsed["percent"], 92.3);
|
||||
assert!(parsed["timestamp"].is_string());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_disk_warning_json_warning_level() {
|
||||
let percent: f64 = 87.5;
|
||||
let json = serde_json::json!({
|
||||
"level": "warning",
|
||||
"percent": (percent * 10.0).round() / 10.0,
|
||||
"timestamp": chrono::Utc::now().to_rfc3339(),
|
||||
});
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json.to_string()).unwrap();
|
||||
assert_eq!(parsed["level"], "warning");
|
||||
// 87.5 rounded to 1 decimal = 87.5
|
||||
assert_eq!(parsed["percent"], 87.5);
|
||||
}
|
||||
}
|
||||
@ -161,7 +161,7 @@ pub async fn get_electrs_sync_status() -> ElectrsSyncStatus {
|
||||
let err_msg = e.to_string();
|
||||
let (status, error) = if err_msg.contains("connect") || err_msg.contains("Connection refused") {
|
||||
// Estimate progress from data directory size
|
||||
let est_pct = if data_bytes > 0 {
|
||||
let _est_pct = if data_bytes > 0 {
|
||||
((data_bytes as f64 / ESTIMATED_FULL_INDEX_BYTES) * 100.0).min(99.0)
|
||||
} else {
|
||||
0.0
|
||||
|
||||
764
core/archipelago/src/federation.rs
Normal file
764
core/archipelago/src/federation.rs
Normal file
@ -0,0 +1,764 @@
|
||||
//! Node federation: trusted multi-node clusters with state sync.
|
||||
//!
|
||||
//! Nodes federate by exchanging invite codes containing DID + onion address.
|
||||
//! Trust is bilateral — both sides must agree. Federated nodes periodically
|
||||
//! sync container status, health metrics, and availability.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use tokio::fs;
|
||||
|
||||
const FEDERATION_DIR: &str = "federation";
|
||||
const NODES_FILE: &str = "nodes.json";
|
||||
const INVITES_FILE: &str = "invites.json";
|
||||
|
||||
/// Trust level for a federated node.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TrustLevel {
|
||||
Trusted,
|
||||
Observer,
|
||||
Untrusted,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TrustLevel {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
TrustLevel::Trusted => write!(f, "trusted"),
|
||||
TrustLevel::Observer => write!(f, "observer"),
|
||||
TrustLevel::Untrusted => write!(f, "untrusted"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A federated node in our cluster.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FederatedNode {
|
||||
pub did: String,
|
||||
pub pubkey: String,
|
||||
pub onion: String,
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
pub trust_level: TrustLevel,
|
||||
pub added_at: String,
|
||||
#[serde(default)]
|
||||
pub last_seen: Option<String>,
|
||||
#[serde(default)]
|
||||
pub last_state: Option<NodeStateSnapshot>,
|
||||
}
|
||||
|
||||
/// State snapshot received from a federated peer during sync.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NodeStateSnapshot {
|
||||
pub timestamp: String,
|
||||
#[serde(default)]
|
||||
pub apps: Vec<AppStatus>,
|
||||
#[serde(default)]
|
||||
pub cpu_usage_percent: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub mem_used_bytes: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub mem_total_bytes: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub disk_used_bytes: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub disk_total_bytes: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub uptime_secs: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub tor_active: Option<bool>,
|
||||
}
|
||||
|
||||
/// Status of a single app/container on a remote node.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppStatus {
|
||||
pub id: String,
|
||||
pub status: String, // "running", "stopped", "installed"
|
||||
#[serde(default)]
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
/// A pending invite (outgoing or incoming).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FederationInvite {
|
||||
pub code: String,
|
||||
pub did: String,
|
||||
pub onion: String,
|
||||
pub pubkey: String,
|
||||
pub created_at: String,
|
||||
#[serde(default)]
|
||||
pub accepted: bool,
|
||||
}
|
||||
|
||||
/// Top-level file structures.
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
struct NodesFile {
|
||||
nodes: Vec<FederatedNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
struct InvitesFile {
|
||||
outgoing: Vec<FederationInvite>,
|
||||
incoming: Vec<FederationInvite>,
|
||||
}
|
||||
|
||||
/// Ensure federation directory exists.
|
||||
async fn ensure_dir(data_dir: &Path) -> Result<std::path::PathBuf> {
|
||||
let dir = data_dir.join(FEDERATION_DIR);
|
||||
fs::create_dir_all(&dir)
|
||||
.await
|
||||
.context("Failed to create federation directory")?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
// ──────────────────────────── Node Management ────────────────────────────
|
||||
|
||||
pub async fn load_nodes(data_dir: &Path) -> Result<Vec<FederatedNode>> {
|
||||
let dir = data_dir.join(FEDERATION_DIR);
|
||||
let path = dir.join(NODES_FILE);
|
||||
if !path.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let content = fs::read_to_string(&path)
|
||||
.await
|
||||
.context("Failed to read federation nodes")?;
|
||||
let file: NodesFile = serde_json::from_str(&content).unwrap_or_default();
|
||||
Ok(file.nodes)
|
||||
}
|
||||
|
||||
pub async fn save_nodes(data_dir: &Path, nodes: &[FederatedNode]) -> Result<()> {
|
||||
let dir = ensure_dir(data_dir).await?;
|
||||
let file = NodesFile {
|
||||
nodes: nodes.to_vec(),
|
||||
};
|
||||
let content = serde_json::to_string_pretty(&file).context("Failed to serialize nodes")?;
|
||||
fs::write(dir.join(NODES_FILE), content)
|
||||
.await
|
||||
.context("Failed to write federation nodes")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn add_node(data_dir: &Path, node: FederatedNode) -> Result<Vec<FederatedNode>> {
|
||||
let mut nodes = load_nodes(data_dir).await?;
|
||||
let exists = nodes.iter().any(|n| n.did == node.did);
|
||||
if exists {
|
||||
anyhow::bail!("Node with DID {} is already federated", node.did);
|
||||
}
|
||||
nodes.push(node);
|
||||
save_nodes(data_dir, &nodes).await?;
|
||||
Ok(nodes)
|
||||
}
|
||||
|
||||
pub async fn remove_node(data_dir: &Path, did: &str) -> Result<Vec<FederatedNode>> {
|
||||
let mut nodes = load_nodes(data_dir).await?;
|
||||
let before = nodes.len();
|
||||
nodes.retain(|n| n.did != did);
|
||||
if nodes.len() == before {
|
||||
anyhow::bail!("No federated node with DID {}", did);
|
||||
}
|
||||
save_nodes(data_dir, &nodes).await?;
|
||||
Ok(nodes)
|
||||
}
|
||||
|
||||
pub async fn set_trust_level(
|
||||
data_dir: &Path,
|
||||
did: &str,
|
||||
trust: TrustLevel,
|
||||
) -> Result<Vec<FederatedNode>> {
|
||||
let mut nodes = load_nodes(data_dir).await?;
|
||||
let node = nodes
|
||||
.iter_mut()
|
||||
.find(|n| n.did == did)
|
||||
.ok_or_else(|| anyhow::anyhow!("No federated node with DID {}", did))?;
|
||||
node.trust_level = trust;
|
||||
save_nodes(data_dir, &nodes).await?;
|
||||
Ok(nodes)
|
||||
}
|
||||
|
||||
pub async fn update_node_state(
|
||||
data_dir: &Path,
|
||||
did: &str,
|
||||
state: NodeStateSnapshot,
|
||||
) -> Result<()> {
|
||||
let mut nodes = load_nodes(data_dir).await?;
|
||||
if let Some(node) = nodes.iter_mut().find(|n| n.did == did) {
|
||||
node.last_seen = Some(state.timestamp.clone());
|
||||
node.last_state = Some(state);
|
||||
save_nodes(data_dir, &nodes).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ──────────────────────────── Invite Management ────────────────────────────
|
||||
|
||||
async fn load_invites(data_dir: &Path) -> Result<InvitesFile> {
|
||||
let dir = data_dir.join(FEDERATION_DIR);
|
||||
let path = dir.join(INVITES_FILE);
|
||||
if !path.exists() {
|
||||
return Ok(InvitesFile::default());
|
||||
}
|
||||
let content = fs::read_to_string(&path)
|
||||
.await
|
||||
.context("Failed to read invites")?;
|
||||
let file: InvitesFile = serde_json::from_str(&content).unwrap_or_default();
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
async fn save_invites(data_dir: &Path, invites: &InvitesFile) -> Result<()> {
|
||||
let dir = ensure_dir(data_dir).await?;
|
||||
let content = serde_json::to_string_pretty(invites).context("Failed to serialize invites")?;
|
||||
fs::write(dir.join(INVITES_FILE), content)
|
||||
.await
|
||||
.context("Failed to write invites")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generate an invite code. Format: `fed1:<base64(json{did, onion, pubkey, token})>`
|
||||
pub async fn create_invite(
|
||||
data_dir: &Path,
|
||||
did: &str,
|
||||
onion: &str,
|
||||
pubkey: &str,
|
||||
) -> Result<String> {
|
||||
use base64::Engine;
|
||||
use rand::Rng;
|
||||
|
||||
let mut token_bytes = [0u8; 16];
|
||||
rand::thread_rng().fill(&mut token_bytes);
|
||||
let token = hex::encode(token_bytes);
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"did": did,
|
||||
"onion": onion,
|
||||
"pubkey": pubkey,
|
||||
"token": token,
|
||||
});
|
||||
let json = serde_json::to_string(&payload).context("Failed to serialize invite")?;
|
||||
let code = format!(
|
||||
"fed1:{}",
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json.as_bytes())
|
||||
);
|
||||
|
||||
let invite = FederationInvite {
|
||||
code: code.clone(),
|
||||
did: did.to_string(),
|
||||
onion: onion.to_string(),
|
||||
pubkey: pubkey.to_string(),
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
accepted: false,
|
||||
};
|
||||
|
||||
let mut invites = load_invites(data_dir).await?;
|
||||
invites.outgoing.push(invite);
|
||||
save_invites(data_dir, &invites).await?;
|
||||
|
||||
Ok(code)
|
||||
}
|
||||
|
||||
/// Parse an invite code into its components.
|
||||
pub fn parse_invite(code: &str) -> Result<(String, String, String, String)> {
|
||||
use base64::Engine;
|
||||
|
||||
let encoded = code
|
||||
.strip_prefix("fed1:")
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid invite format: must start with fed1:"))?;
|
||||
|
||||
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(encoded)
|
||||
.context("Invalid base64 in invite code")?;
|
||||
|
||||
let payload: serde_json::Value =
|
||||
serde_json::from_slice(&bytes).context("Invalid JSON in invite")?;
|
||||
|
||||
let did = payload["did"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing did in invite"))?
|
||||
.to_string();
|
||||
let onion = payload["onion"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing onion in invite"))?
|
||||
.to_string();
|
||||
let pubkey = payload["pubkey"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing pubkey in invite"))?
|
||||
.to_string();
|
||||
let token = payload["token"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing token in invite"))?
|
||||
.to_string();
|
||||
|
||||
Ok((did, onion, pubkey, token))
|
||||
}
|
||||
|
||||
/// Accept an invite: parse code, verify the remote node, add to federation.
|
||||
pub async fn accept_invite(
|
||||
data_dir: &Path,
|
||||
code: &str,
|
||||
local_did: &str,
|
||||
local_onion: &str,
|
||||
local_pubkey: &str,
|
||||
) -> Result<FederatedNode> {
|
||||
let (did, onion, pubkey, _token) = parse_invite(code)?;
|
||||
|
||||
// Check not already federated
|
||||
let nodes = load_nodes(data_dir).await?;
|
||||
if nodes.iter().any(|n| n.did == did) {
|
||||
anyhow::bail!("Already federated with node {}", did);
|
||||
}
|
||||
|
||||
let node = FederatedNode {
|
||||
did: did.clone(),
|
||||
pubkey,
|
||||
onion,
|
||||
name: None,
|
||||
trust_level: TrustLevel::Trusted,
|
||||
added_at: chrono::Utc::now().to_rfc3339(),
|
||||
last_seen: None,
|
||||
last_state: None,
|
||||
};
|
||||
|
||||
add_node(data_dir, node.clone()).await?;
|
||||
|
||||
// Record as incoming accepted invite
|
||||
let mut invites = load_invites(data_dir).await?;
|
||||
invites.incoming.push(FederationInvite {
|
||||
code: code.to_string(),
|
||||
did: did.clone(),
|
||||
onion: node.onion.clone(),
|
||||
pubkey: node.pubkey.clone(),
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
accepted: true,
|
||||
});
|
||||
save_invites(data_dir, &invites).await?;
|
||||
|
||||
// Notify remote node (best-effort over Tor)
|
||||
let _ = notify_join(&node.onion, local_did, local_onion, local_pubkey).await;
|
||||
|
||||
Ok(node)
|
||||
}
|
||||
|
||||
/// Best-effort notification to the remote node that we joined their federation.
|
||||
async fn notify_join(
|
||||
remote_onion: &str,
|
||||
local_did: &str,
|
||||
local_onion: &str,
|
||||
local_pubkey: &str,
|
||||
) -> Result<()> {
|
||||
let host = if remote_onion.ends_with(".onion") {
|
||||
remote_onion.to_string()
|
||||
} else {
|
||||
format!("{}.onion", remote_onion)
|
||||
};
|
||||
let url = format!("http://{}/rpc/v1", host);
|
||||
let body = serde_json::json!({
|
||||
"method": "federation.peer-joined",
|
||||
"params": {
|
||||
"did": local_did,
|
||||
"onion": local_onion,
|
||||
"pubkey": local_pubkey,
|
||||
}
|
||||
});
|
||||
|
||||
let proxy = reqwest::Proxy::all("socks5h://127.0.0.1:9050").context("Invalid Tor proxy")?;
|
||||
let client = reqwest::Client::builder()
|
||||
.proxy(proxy)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.context("Failed to build HTTP client")?;
|
||||
|
||||
let _ = client.post(&url).json(&body).send().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sync state with a single federated peer over Tor.
|
||||
pub async fn sync_with_peer(
|
||||
data_dir: &Path,
|
||||
peer: &FederatedNode,
|
||||
local_did: &str,
|
||||
sign_fn: impl FnOnce(&[u8]) -> String,
|
||||
) -> Result<NodeStateSnapshot> {
|
||||
let host = if peer.onion.ends_with(".onion") {
|
||||
peer.onion.clone()
|
||||
} else {
|
||||
format!("{}.onion", peer.onion)
|
||||
};
|
||||
let url = format!("http://{}/rpc/v1", host);
|
||||
|
||||
// Sign current timestamp for authentication
|
||||
let timestamp = chrono::Utc::now().to_rfc3339();
|
||||
let signature = sign_fn(timestamp.as_bytes());
|
||||
|
||||
let body = serde_json::json!({
|
||||
"method": "federation.get-state",
|
||||
"params": {}
|
||||
});
|
||||
|
||||
let proxy = reqwest::Proxy::all("socks5h://127.0.0.1:9050").context("Invalid Tor proxy")?;
|
||||
let client = reqwest::Client::builder()
|
||||
.proxy(proxy)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.context("Failed to build HTTP client")?;
|
||||
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.header("X-Federation-DID", local_did)
|
||||
.header("X-Federation-Sig", &signature)
|
||||
.header("X-Federation-Timestamp", ×tamp)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to reach federated peer")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("Peer returned {}", resp.status());
|
||||
}
|
||||
|
||||
let result: serde_json::Value = resp.json().await.context("Invalid response from peer")?;
|
||||
let state_val = result
|
||||
.get("result")
|
||||
.ok_or_else(|| anyhow::anyhow!("No result in peer response"))?;
|
||||
|
||||
let state: NodeStateSnapshot =
|
||||
serde_json::from_value(state_val.clone()).context("Failed to parse peer state")?;
|
||||
|
||||
update_node_state(data_dir, &peer.did, state.clone()).await?;
|
||||
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
/// Build the local node's state snapshot for sharing with peers.
|
||||
pub fn build_local_state(
|
||||
apps: Vec<AppStatus>,
|
||||
cpu: f64,
|
||||
mem_used: u64,
|
||||
mem_total: u64,
|
||||
disk_used: u64,
|
||||
disk_total: u64,
|
||||
uptime: u64,
|
||||
tor_active: bool,
|
||||
) -> NodeStateSnapshot {
|
||||
NodeStateSnapshot {
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
apps,
|
||||
cpu_usage_percent: Some(cpu),
|
||||
mem_used_bytes: Some(mem_used),
|
||||
mem_total_bytes: Some(mem_total),
|
||||
disk_used_bytes: Some(disk_used),
|
||||
disk_total_bytes: Some(disk_total),
|
||||
uptime_secs: Some(uptime),
|
||||
tor_active: Some(tor_active),
|
||||
}
|
||||
}
|
||||
|
||||
/// Deploy an app to a remote federated peer over Tor.
|
||||
/// Only works if the peer is trusted and the app exists in our marketplace.
|
||||
pub async fn deploy_to_peer(
|
||||
peer: &FederatedNode,
|
||||
app_id: &str,
|
||||
version: &str,
|
||||
marketplace_url: &str,
|
||||
local_did: &str,
|
||||
sign_fn: impl FnOnce(&[u8]) -> String,
|
||||
) -> Result<serde_json::Value> {
|
||||
if peer.trust_level != TrustLevel::Trusted {
|
||||
anyhow::bail!("Can only deploy to trusted peers (current: {})", peer.trust_level);
|
||||
}
|
||||
|
||||
let host = if peer.onion.ends_with(".onion") {
|
||||
peer.onion.clone()
|
||||
} else {
|
||||
format!("{}.onion", peer.onion)
|
||||
};
|
||||
let url = format!("http://{}/rpc/v1", host);
|
||||
|
||||
let timestamp = chrono::Utc::now().to_rfc3339();
|
||||
let signature = sign_fn(timestamp.as_bytes());
|
||||
|
||||
let body = serde_json::json!({
|
||||
"method": "package.install",
|
||||
"params": {
|
||||
"id": app_id,
|
||||
"version": version,
|
||||
"marketplace-url": marketplace_url,
|
||||
}
|
||||
});
|
||||
|
||||
let proxy = reqwest::Proxy::all("socks5h://127.0.0.1:9050").context("Invalid Tor proxy")?;
|
||||
let client = reqwest::Client::builder()
|
||||
.proxy(proxy)
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.build()
|
||||
.context("Failed to build HTTP client")?;
|
||||
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.header("X-Federation-DID", local_did)
|
||||
.header("X-Federation-Sig", &signature)
|
||||
.header("X-Federation-Timestamp", ×tamp)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to reach federated peer for deploy")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("Remote node returned HTTP {}", resp.status());
|
||||
}
|
||||
|
||||
let result: serde_json::Value = resp.json().await.context("Invalid response from peer")?;
|
||||
|
||||
if let Some(err) = result.get("error") {
|
||||
if !err.is_null() {
|
||||
let msg = err.get("message").and_then(|m| m.as_str()).unwrap_or("Unknown remote error");
|
||||
anyhow::bail!("Remote node refused deploy: {}", msg);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"deployed": true,
|
||||
"app_id": app_id,
|
||||
"peer_did": peer.did,
|
||||
"peer_onion": peer.onion,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_node(did: &str, onion: &str) -> FederatedNode {
|
||||
FederatedNode {
|
||||
did: did.to_string(),
|
||||
pubkey: "aabbccdd".to_string(),
|
||||
onion: onion.to_string(),
|
||||
name: None,
|
||||
trust_level: TrustLevel::Trusted,
|
||||
added_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
last_seen: None,
|
||||
last_state: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trust_level_serialization() {
|
||||
let json = serde_json::to_string(&TrustLevel::Trusted).unwrap();
|
||||
assert_eq!(json, "\"trusted\"");
|
||||
|
||||
let parsed: TrustLevel = serde_json::from_str("\"observer\"").unwrap();
|
||||
assert_eq!(parsed, TrustLevel::Observer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_federated_node_serialization_roundtrip() {
|
||||
let node = make_node("did:key:zABC", "test.onion");
|
||||
let json = serde_json::to_string(&node).unwrap();
|
||||
let parsed: FederatedNode = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.did, "did:key:zABC");
|
||||
assert_eq!(parsed.trust_level, TrustLevel::Trusted);
|
||||
assert!(parsed.last_state.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_node_state_snapshot_defaults() {
|
||||
let json = r#"{"timestamp": "2026-01-01T00:00:00Z"}"#;
|
||||
let state: NodeStateSnapshot = serde_json::from_str(json).unwrap();
|
||||
assert!(state.apps.is_empty());
|
||||
assert!(state.cpu_usage_percent.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_nodes_empty_when_no_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let nodes = load_nodes(dir.path()).await.unwrap();
|
||||
assert!(nodes.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_save_and_load_nodes_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let nodes = vec![
|
||||
make_node("did:key:z1", "a.onion"),
|
||||
make_node("did:key:z2", "b.onion"),
|
||||
];
|
||||
save_nodes(dir.path(), &nodes).await.unwrap();
|
||||
let loaded = load_nodes(dir.path()).await.unwrap();
|
||||
assert_eq!(loaded.len(), 2);
|
||||
assert_eq!(loaded[0].did, "did:key:z1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_add_node_deduplicates_by_did() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
|
||||
.await
|
||||
.unwrap();
|
||||
let result = add_node(dir.path(), make_node("did:key:z1", "b.onion")).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remove_node_by_did() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
|
||||
.await
|
||||
.unwrap();
|
||||
add_node(dir.path(), make_node("did:key:z2", "b.onion"))
|
||||
.await
|
||||
.unwrap();
|
||||
let result = remove_node(dir.path(), "did:key:z1").await.unwrap();
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].did, "did:key:z2");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remove_nonexistent_node_errors() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let result = remove_node(dir.path(), "did:key:nonexistent").await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_set_trust_level() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
|
||||
.await
|
||||
.unwrap();
|
||||
let nodes = set_trust_level(dir.path(), "did:key:z1", TrustLevel::Observer)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(nodes[0].trust_level, TrustLevel::Observer);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_node_state() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let state = NodeStateSnapshot {
|
||||
timestamp: "2026-03-10T12:00:00Z".to_string(),
|
||||
apps: vec![AppStatus {
|
||||
id: "bitcoin".to_string(),
|
||||
status: "running".to_string(),
|
||||
version: Some("27.0".to_string()),
|
||||
}],
|
||||
cpu_usage_percent: Some(45.2),
|
||||
mem_used_bytes: Some(4_000_000_000),
|
||||
mem_total_bytes: Some(8_000_000_000),
|
||||
disk_used_bytes: None,
|
||||
disk_total_bytes: None,
|
||||
uptime_secs: Some(86400),
|
||||
tor_active: Some(true),
|
||||
};
|
||||
|
||||
update_node_state(dir.path(), "did:key:z1", state)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let nodes = load_nodes(dir.path()).await.unwrap();
|
||||
assert!(nodes[0].last_seen.is_some());
|
||||
let ls = nodes[0].last_state.as_ref().unwrap();
|
||||
assert_eq!(ls.apps.len(), 1);
|
||||
assert_eq!(ls.cpu_usage_percent, Some(45.2));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_and_parse_invite() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let code = create_invite(dir.path(), "did:key:z1", "test.onion", "aabbcc")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(code.starts_with("fed1:"));
|
||||
|
||||
let (did, onion, pubkey, token) = parse_invite(&code).unwrap();
|
||||
assert_eq!(did, "did:key:z1");
|
||||
assert_eq!(onion, "test.onion");
|
||||
assert_eq!(pubkey, "aabbcc");
|
||||
assert_eq!(token.len(), 32); // 16 bytes = 32 hex chars
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_invalid_invite() {
|
||||
assert!(parse_invite("invalid").is_err());
|
||||
assert!(parse_invite("fed1:not-valid-base64!!!").is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_accept_invite_creates_node() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let code = create_invite(dir.path(), "did:key:zRemote", "remote.onion", "remotepub")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Accept from a different "local" perspective
|
||||
let dir2 = tempfile::tempdir().unwrap();
|
||||
let node = accept_invite(
|
||||
dir2.path(),
|
||||
&code,
|
||||
"did:key:zLocal",
|
||||
"local.onion",
|
||||
"localpub",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(node.did, "did:key:zRemote");
|
||||
assert_eq!(node.trust_level, TrustLevel::Trusted);
|
||||
|
||||
let nodes = load_nodes(dir2.path()).await.unwrap();
|
||||
assert_eq!(nodes.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_accept_invite_rejects_duplicate() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let code = create_invite(dir.path(), "did:key:zRemote", "remote.onion", "remotepub")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let dir2 = tempfile::tempdir().unwrap();
|
||||
accept_invite(
|
||||
dir2.path(),
|
||||
&code,
|
||||
"did:key:zLocal",
|
||||
"local.onion",
|
||||
"localpub",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Accepting the same invite again should fail
|
||||
let result = accept_invite(
|
||||
dir2.path(),
|
||||
&code,
|
||||
"did:key:zLocal",
|
||||
"local.onion",
|
||||
"localpub",
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_local_state() {
|
||||
let state = build_local_state(
|
||||
vec![AppStatus {
|
||||
id: "lnd".to_string(),
|
||||
status: "running".to_string(),
|
||||
version: Some("0.18".to_string()),
|
||||
}],
|
||||
25.5,
|
||||
2_000_000_000,
|
||||
8_000_000_000,
|
||||
100_000_000_000,
|
||||
500_000_000_000,
|
||||
3600,
|
||||
true,
|
||||
);
|
||||
assert_eq!(state.apps.len(), 1);
|
||||
assert_eq!(state.cpu_usage_percent, Some(25.5));
|
||||
assert_eq!(state.tor_active, Some(true));
|
||||
}
|
||||
}
|
||||
344
core/archipelago/src/health_monitor.rs
Normal file
344
core/archipelago/src/health_monitor.rs
Normal file
@ -0,0 +1,344 @@
|
||||
// Container Health Monitor
|
||||
// Checks container health every 60s, auto-restarts unhealthy containers (max 3 times),
|
||||
// and sends WebSocket notifications to the UI on failure.
|
||||
|
||||
use crate::data_model::{Notification, NotificationLevel};
|
||||
use crate::state::StateManager;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
const MAX_RESTART_ATTEMPTS: u32 = 3;
|
||||
const CHECK_INTERVAL_SECS: u64 = 60;
|
||||
|
||||
/// Track restart attempts per container to avoid infinite restart loops.
|
||||
struct RestartTracker {
|
||||
attempts: HashMap<String, u32>,
|
||||
}
|
||||
|
||||
impl RestartTracker {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
attempts: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a restart attempt. Returns false if max attempts exceeded.
|
||||
fn record_attempt(&mut self, name: &str) -> bool {
|
||||
let count = self.attempts.entry(name.to_string()).or_insert(0);
|
||||
*count += 1;
|
||||
*count <= MAX_RESTART_ATTEMPTS
|
||||
}
|
||||
|
||||
/// Clear restart count when a container is healthy again.
|
||||
fn clear(&mut self, name: &str) {
|
||||
self.attempts.remove(name);
|
||||
}
|
||||
|
||||
fn attempt_count(&self, name: &str) -> u32 {
|
||||
*self.attempts.get(name).unwrap_or(&0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ContainerHealth {
|
||||
name: String,
|
||||
app_id: String,
|
||||
state: String,
|
||||
healthy: bool,
|
||||
}
|
||||
|
||||
/// Query all containers and their health status.
|
||||
async fn check_containers() -> Vec<ContainerHealth> {
|
||||
let output = match tokio::process::Command::new("sudo")
|
||||
.args(["podman", "ps", "-a", "--format", "json"])
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
Ok(o) if o.status.success() => o,
|
||||
_ => return Vec::new(),
|
||||
};
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let containers: Vec<serde_json::Value> =
|
||||
serde_json::from_str(&stdout).unwrap_or_default();
|
||||
|
||||
// Backend services to skip
|
||||
let skip = [
|
||||
"btcpay-db", "nbxplorer", "mempool-db", "mempool-api",
|
||||
"penpot-postgres", "penpot-backend", "penpot-exporter", "penpot-valkey",
|
||||
"penpot-mailcatch", "immich_postgres", "immich_redis",
|
||||
"endurain-db", "nextcloud-db",
|
||||
];
|
||||
|
||||
containers
|
||||
.iter()
|
||||
.filter_map(|c| {
|
||||
let name = c.get("Names").and_then(|v| {
|
||||
if let Some(arr) = v.as_array() {
|
||||
arr.first().and_then(|n| n.as_str()).map(|s| s.to_string())
|
||||
} else {
|
||||
v.as_str().map(|s| s.to_string())
|
||||
}
|
||||
})?;
|
||||
|
||||
let app_id = name
|
||||
.strip_prefix("archy-")
|
||||
.unwrap_or(&name)
|
||||
.to_string();
|
||||
|
||||
if skip.contains(&app_id.as_str()) || app_id.ends_with("-ui") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let state = c.get("State")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_lowercase();
|
||||
|
||||
let healthy = state == "running";
|
||||
|
||||
Some(ContainerHealth {
|
||||
name,
|
||||
app_id,
|
||||
state,
|
||||
healthy,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Try to restart a container.
|
||||
async fn restart_container(name: &str) -> bool {
|
||||
info!("Auto-restarting unhealthy container: {}", name);
|
||||
let result = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "start", name])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(output) if output.status.success() => {
|
||||
info!("Successfully restarted container: {}", name);
|
||||
true
|
||||
}
|
||||
Ok(output) => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
warn!("Failed to restart container {}: {}", name, stderr.trim());
|
||||
false
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to execute podman start for {}: {}", name, e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the health monitor background task.
|
||||
pub fn spawn_health_monitor(state: Arc<StateManager>) {
|
||||
tokio::spawn(async move {
|
||||
// Wait 2 minutes for containers to start up
|
||||
tokio::time::sleep(std::time::Duration::from_secs(120)).await;
|
||||
|
||||
let mut tracker = RestartTracker::new();
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(CHECK_INTERVAL_SECS));
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
let containers = check_containers().await;
|
||||
if containers.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut state_changed = false;
|
||||
let (mut data, _) = state.get_snapshot().await;
|
||||
|
||||
for container in &containers {
|
||||
if container.healthy {
|
||||
// Clear restart tracker if container recovered
|
||||
if tracker.attempt_count(&container.name) > 0 {
|
||||
info!("Container {} is healthy again after restart", container.name);
|
||||
tracker.clear(&container.name);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Container is unhealthy (exited/stopped)
|
||||
// Only try auto-restart if we haven't exceeded max attempts
|
||||
if container.state == "exited" || container.state == "stopped" {
|
||||
let attempts = tracker.attempt_count(&container.name);
|
||||
|
||||
if attempts >= MAX_RESTART_ATTEMPTS {
|
||||
// Already notified, skip
|
||||
debug!("Container {} exceeded max restart attempts ({})", container.name, MAX_RESTART_ATTEMPTS);
|
||||
continue;
|
||||
}
|
||||
|
||||
if tracker.record_attempt(&container.name) {
|
||||
let restarted = restart_container(&container.name).await;
|
||||
let attempt = tracker.attempt_count(&container.name);
|
||||
|
||||
if !restarted || attempt >= MAX_RESTART_ATTEMPTS {
|
||||
// Push notification to UI
|
||||
let notification = Notification {
|
||||
id: format!("health-{}-{}", container.app_id, chrono::Utc::now().timestamp()),
|
||||
level: NotificationLevel::Error,
|
||||
title: format!("{} is unhealthy", container.app_id),
|
||||
message: if restarted {
|
||||
format!(
|
||||
"Container restarted ({}/{} attempts). May need manual attention.",
|
||||
attempt, MAX_RESTART_ATTEMPTS
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"Auto-restart failed (attempt {}/{}). Container state: {}",
|
||||
attempt, MAX_RESTART_ATTEMPTS, container.state
|
||||
)
|
||||
},
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
app_id: Some(container.app_id.clone()),
|
||||
};
|
||||
|
||||
// Keep only the latest 20 notifications
|
||||
data.notifications.push(notification);
|
||||
if data.notifications.len() > 20 {
|
||||
data.notifications = data.notifications.split_off(data.notifications.len() - 20);
|
||||
}
|
||||
state_changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if state_changed {
|
||||
state.update_data(data).await;
|
||||
debug!("Health monitor: state updated with notifications");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_restart_tracker_new_is_empty() {
|
||||
let tracker = RestartTracker::new();
|
||||
assert_eq!(tracker.attempt_count("any-container"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_restart_tracker_record_attempt_increments() {
|
||||
let mut tracker = RestartTracker::new();
|
||||
assert!(tracker.record_attempt("test-container"));
|
||||
assert_eq!(tracker.attempt_count("test-container"), 1);
|
||||
|
||||
assert!(tracker.record_attempt("test-container"));
|
||||
assert_eq!(tracker.attempt_count("test-container"), 2);
|
||||
|
||||
assert!(tracker.record_attempt("test-container"));
|
||||
assert_eq!(tracker.attempt_count("test-container"), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_restart_tracker_max_attempts_exceeded() {
|
||||
let mut tracker = RestartTracker::new();
|
||||
// First MAX_RESTART_ATTEMPTS attempts should return true
|
||||
for i in 1..=MAX_RESTART_ATTEMPTS {
|
||||
assert!(
|
||||
tracker.record_attempt("container-a"),
|
||||
"Attempt {} should be allowed",
|
||||
i
|
||||
);
|
||||
}
|
||||
// Next attempt exceeds max, returns false
|
||||
assert!(!tracker.record_attempt("container-a"));
|
||||
assert_eq!(tracker.attempt_count("container-a"), MAX_RESTART_ATTEMPTS + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_restart_tracker_independent_containers() {
|
||||
let mut tracker = RestartTracker::new();
|
||||
tracker.record_attempt("container-a");
|
||||
tracker.record_attempt("container-a");
|
||||
tracker.record_attempt("container-b");
|
||||
|
||||
assert_eq!(tracker.attempt_count("container-a"), 2);
|
||||
assert_eq!(tracker.attempt_count("container-b"), 1);
|
||||
assert_eq!(tracker.attempt_count("container-c"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_restart_tracker_clear_resets_count() {
|
||||
let mut tracker = RestartTracker::new();
|
||||
tracker.record_attempt("container-x");
|
||||
tracker.record_attempt("container-x");
|
||||
assert_eq!(tracker.attempt_count("container-x"), 2);
|
||||
|
||||
tracker.clear("container-x");
|
||||
assert_eq!(tracker.attempt_count("container-x"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_restart_tracker_clear_allows_new_attempts() {
|
||||
let mut tracker = RestartTracker::new();
|
||||
// Exhaust attempts
|
||||
for _ in 0..=MAX_RESTART_ATTEMPTS {
|
||||
tracker.record_attempt("container-y");
|
||||
}
|
||||
assert!(!tracker.record_attempt("container-y"));
|
||||
|
||||
// Clear and try again
|
||||
tracker.clear("container-y");
|
||||
assert!(tracker.record_attempt("container-y"));
|
||||
assert_eq!(tracker.attempt_count("container-y"), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_restart_tracker_clear_nonexistent_is_safe() {
|
||||
let mut tracker = RestartTracker::new();
|
||||
// Should not panic
|
||||
tracker.clear("nonexistent");
|
||||
assert_eq!(tracker.attempt_count("nonexistent"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_container_health_struct() {
|
||||
let health = ContainerHealth {
|
||||
name: "archy-bitcoin-knots".to_string(),
|
||||
app_id: "bitcoin-knots".to_string(),
|
||||
state: "running".to_string(),
|
||||
healthy: true,
|
||||
};
|
||||
assert!(health.healthy);
|
||||
assert_eq!(health.name, "archy-bitcoin-knots");
|
||||
assert_eq!(health.app_id, "bitcoin-knots");
|
||||
assert_eq!(health.state, "running");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_container_health_unhealthy() {
|
||||
let health = ContainerHealth {
|
||||
name: "archy-mempool-web".to_string(),
|
||||
app_id: "mempool-web".to_string(),
|
||||
state: "exited".to_string(),
|
||||
healthy: false,
|
||||
};
|
||||
assert!(!health.healthy);
|
||||
assert_eq!(health.state, "exited");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_max_restart_attempts_constant() {
|
||||
// Ensure the constant is a reasonable value (not 0, not too high)
|
||||
assert!(MAX_RESTART_ATTEMPTS >= 1);
|
||||
assert!(MAX_RESTART_ATTEMPTS <= 10);
|
||||
assert_eq!(MAX_RESTART_ATTEMPTS, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_interval_constant() {
|
||||
assert_eq!(CHECK_INTERVAL_SECS, 60);
|
||||
}
|
||||
}
|
||||
@ -105,6 +105,11 @@ impl NodeIdentity {
|
||||
pub fn did_key(&self) -> String {
|
||||
did_key_from_pubkey_hex(&self.pubkey_hex()).expect("pubkey_hex is valid")
|
||||
}
|
||||
|
||||
/// Generate a W3C DID Core v1.0 compliant DID Document.
|
||||
pub fn did_document(&self) -> serde_json::Value {
|
||||
did_document_from_pubkey_hex(&self.pubkey_hex()).expect("pubkey_hex is valid")
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert Ed25519 pubkey (hex) to did:key format.
|
||||
@ -121,6 +126,77 @@ pub fn did_key_from_pubkey_hex(pubkey_hex: &str) -> Result<String> {
|
||||
Ok(format!("did:key:z{}", bs58::encode(multicodec_pubkey).into_string()))
|
||||
}
|
||||
|
||||
/// Generate a W3C DID Core v1.0 compliant DID Document from an Ed25519 public key.
|
||||
/// Follows: https://www.w3.org/TR/did-core/
|
||||
/// Includes: verificationMethod, authentication, assertionMethod, keyAgreement contexts.
|
||||
pub fn did_document_from_pubkey_hex(pubkey_hex: &str) -> Result<serde_json::Value> {
|
||||
let did = did_key_from_pubkey_hex(pubkey_hex)?;
|
||||
let pubkey_bytes = hex::decode(pubkey_hex).context("Invalid pubkey hex")?;
|
||||
let pubkey_multibase = format!("z{}", bs58::encode(&pubkey_bytes).into_string());
|
||||
let key_id = format!("{}#key-1", did);
|
||||
|
||||
// Build X25519 key agreement key from Ed25519 public key
|
||||
// Ed25519 -> X25519 conversion (Montgomery form)
|
||||
let ed_point = curve25519_dalek::edwards::CompressedEdwardsY(
|
||||
pubkey_bytes
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| anyhow::anyhow!("Invalid pubkey length"))?,
|
||||
);
|
||||
let x25519_key = if let Some(point) = ed_point.decompress() {
|
||||
let montgomery = point.to_montgomery();
|
||||
format!("z{}", bs58::encode(montgomery.as_bytes()).into_string())
|
||||
} else {
|
||||
// Fallback: use Ed25519 key if conversion fails
|
||||
pubkey_multibase.clone()
|
||||
};
|
||||
let x25519_key_id = format!("{}#key-x25519-1", did);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"@context": [
|
||||
"https://www.w3.org/ns/did/v1",
|
||||
"https://w3id.org/security/suites/ed25519-2020/v1",
|
||||
"https://w3id.org/security/suites/x25519-2020/v1"
|
||||
],
|
||||
"id": did,
|
||||
"verificationMethod": [
|
||||
{
|
||||
"id": key_id,
|
||||
"type": "Ed25519VerificationKey2020",
|
||||
"controller": did,
|
||||
"publicKeyMultibase": pubkey_multibase
|
||||
},
|
||||
{
|
||||
"id": x25519_key_id,
|
||||
"type": "X25519KeyAgreementKey2020",
|
||||
"controller": did,
|
||||
"publicKeyMultibase": x25519_key
|
||||
}
|
||||
],
|
||||
"authentication": [key_id],
|
||||
"assertionMethod": [key_id],
|
||||
"capabilityInvocation": [key_id],
|
||||
"capabilityDelegation": [key_id],
|
||||
"keyAgreement": [x25519_key_id]
|
||||
}))
|
||||
}
|
||||
|
||||
/// Extract the raw 32-byte Ed25519 public key from a did:key string.
|
||||
pub fn pubkey_bytes_from_did_key(did: &str) -> Result<[u8; 32]> {
|
||||
let multibase_str = did
|
||||
.strip_prefix("did:key:z")
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid did:key format"))?;
|
||||
let decoded = bs58::decode(multibase_str)
|
||||
.into_vec()
|
||||
.context("Invalid base58 in did:key")?;
|
||||
if decoded.len() != 34 || decoded[0] != 0xed || decoded[1] != 0x01 {
|
||||
return Err(anyhow::anyhow!("Invalid Ed25519 multicodec prefix"));
|
||||
}
|
||||
let mut pubkey = [0u8; 32];
|
||||
pubkey.copy_from_slice(&decoded[2..34]);
|
||||
Ok(pubkey)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@ -210,4 +286,60 @@ mod tests {
|
||||
assert!(addr.starts_with("archipelago://abc123.onion#"));
|
||||
assert!(addr.contains(&identity.pubkey_hex()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_did_document_w3c_structure() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let identity = NodeIdentity::load_or_create(&dir.path().join("id")).await.unwrap();
|
||||
|
||||
let doc = identity.did_document();
|
||||
let did = identity.did_key();
|
||||
|
||||
// Verify @context
|
||||
let context = doc["@context"].as_array().unwrap();
|
||||
assert_eq!(context[0], "https://www.w3.org/ns/did/v1");
|
||||
|
||||
// Verify id matches did:key
|
||||
assert_eq!(doc["id"], did);
|
||||
|
||||
// Verify verificationMethod has Ed25519 and X25519 keys
|
||||
let vms = doc["verificationMethod"].as_array().unwrap();
|
||||
assert_eq!(vms.len(), 2);
|
||||
assert_eq!(vms[0]["type"], "Ed25519VerificationKey2020");
|
||||
assert_eq!(vms[1]["type"], "X25519KeyAgreementKey2020");
|
||||
assert_eq!(vms[0]["controller"], did);
|
||||
|
||||
// Verify authentication references key-1
|
||||
let auth = doc["authentication"].as_array().unwrap();
|
||||
assert_eq!(auth[0], format!("{}#key-1", did));
|
||||
|
||||
// Verify assertionMethod
|
||||
assert!(doc["assertionMethod"].as_array().is_some());
|
||||
|
||||
// Verify keyAgreement references x25519 key
|
||||
let ka = doc["keyAgreement"].as_array().unwrap();
|
||||
assert_eq!(ka[0], format!("{}#key-x25519-1", did));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_did_document_from_pubkey_hex() {
|
||||
let hex = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa3f4a18446b7e21e7e2c33";
|
||||
let doc = did_document_from_pubkey_hex(hex).unwrap();
|
||||
assert_eq!(doc["@context"].as_array().unwrap().len(), 3);
|
||||
assert!(doc["id"].as_str().unwrap().starts_with("did:key:z"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pubkey_bytes_from_did_key_roundtrip() {
|
||||
let hex = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa3f4a18446b7e21e7e2c33";
|
||||
let did = did_key_from_pubkey_hex(hex).unwrap();
|
||||
let recovered = pubkey_bytes_from_did_key(&did).unwrap();
|
||||
assert_eq!(hex::encode(recovered), hex);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pubkey_bytes_from_invalid_did() {
|
||||
assert!(pubkey_bytes_from_did_key("did:web:example.com").is_err());
|
||||
assert!(pubkey_bytes_from_did_key("did:key:invalid").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@ -27,6 +27,7 @@ mod mesh;
|
||||
mod monitoring;
|
||||
mod node_message;
|
||||
mod nostr_discovery;
|
||||
mod nostr_handshake;
|
||||
mod peers;
|
||||
mod server;
|
||||
mod session;
|
||||
|
||||
657
core/archipelago/src/marketplace.rs
Normal file
657
core/archipelago/src/marketplace.rs
Normal file
@ -0,0 +1,657 @@
|
||||
//! Decentralized app marketplace: discover, verify, and publish app manifests
|
||||
//! via Nostr relays. Uses NIP-78 (kind 30078) with d-tag "archipelago-app:<id>".
|
||||
//!
|
||||
//! See docs/marketplace-protocol.md for the full protocol specification.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use tokio::fs;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
const MARKETPLACE_DIR: &str = "marketplace";
|
||||
const CACHE_FILE: &str = "manifests.json";
|
||||
const PUBLISHED_DIR: &str = "published";
|
||||
const ARCHIPELAGO_KIND: u64 = 30078;
|
||||
const D_TAG_PREFIX: &str = "archipelago-app:";
|
||||
const MARKETPLACE_TAG: &str = "archipelago-marketplace";
|
||||
|
||||
/// Categories for marketplace apps.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AppCategory {
|
||||
Money,
|
||||
Commerce,
|
||||
Data,
|
||||
Networking,
|
||||
Home,
|
||||
Community,
|
||||
Other,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AppCategory {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Money => write!(f, "money"),
|
||||
Self::Commerce => write!(f, "commerce"),
|
||||
Self::Data => write!(f, "data"),
|
||||
Self::Networking => write!(f, "networking"),
|
||||
Self::Home => write!(f, "home"),
|
||||
Self::Community => write!(f, "community"),
|
||||
Self::Other => write!(f, "other"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Author information in a marketplace manifest.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ManifestAuthor {
|
||||
pub name: String,
|
||||
pub did: String,
|
||||
#[serde(default)]
|
||||
pub nostr_pubkey: String,
|
||||
}
|
||||
|
||||
/// Container configuration in a marketplace manifest.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ManifestContainer {
|
||||
pub image: String,
|
||||
#[serde(default)]
|
||||
pub ports: Vec<PortMapping>,
|
||||
#[serde(default)]
|
||||
pub volumes: Vec<VolumeMapping>,
|
||||
#[serde(default)]
|
||||
pub env: HashMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub capabilities: Vec<String>,
|
||||
#[serde(default = "default_true")]
|
||||
pub readonly_root: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub no_new_privileges: bool,
|
||||
#[serde(default = "default_uid")]
|
||||
pub run_as_user: u32,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_uid() -> u32 {
|
||||
1000
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PortMapping {
|
||||
pub container: u16,
|
||||
pub host: u16,
|
||||
#[serde(default = "default_tcp")]
|
||||
pub protocol: String,
|
||||
}
|
||||
|
||||
fn default_tcp() -> String {
|
||||
"tcp".into()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VolumeMapping {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// App manifest signatures.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ManifestSignatures {
|
||||
pub manifest_hash: String,
|
||||
pub did_signature: String,
|
||||
}
|
||||
|
||||
/// A marketplace app manifest.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppManifest {
|
||||
pub app_id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub description: ManifestDescription,
|
||||
pub author: ManifestAuthor,
|
||||
pub container: ManifestContainer,
|
||||
pub category: AppCategory,
|
||||
#[serde(default)]
|
||||
pub icon_url: String,
|
||||
#[serde(default)]
|
||||
pub repo_url: String,
|
||||
#[serde(default)]
|
||||
pub license: String,
|
||||
#[serde(default)]
|
||||
pub min_archipelago_version: String,
|
||||
#[serde(default)]
|
||||
pub dependencies: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub signatures: Option<ManifestSignatures>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum ManifestDescription {
|
||||
Simple(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.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DiscoveredApp {
|
||||
pub manifest: AppManifest,
|
||||
pub trust_score: u32,
|
||||
pub trust_tier: String,
|
||||
pub relay_count: u32,
|
||||
pub first_seen: String,
|
||||
pub nostr_pubkey: String,
|
||||
}
|
||||
|
||||
/// Cache of discovered marketplace apps.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct MarketplaceCache {
|
||||
pub apps: Vec<DiscoveredApp>,
|
||||
pub last_updated: String,
|
||||
}
|
||||
|
||||
/// Ensure marketplace directories exist.
|
||||
async fn ensure_dirs(data_dir: &Path) -> Result<()> {
|
||||
let market_dir = data_dir.join(MARKETPLACE_DIR);
|
||||
fs::create_dir_all(market_dir.join("cache")).await.context("Creating marketplace cache dir")?;
|
||||
fs::create_dir_all(market_dir.join(PUBLISHED_DIR)).await.context("Creating marketplace published dir")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load cached marketplace data.
|
||||
pub async fn load_cache(data_dir: &Path) -> Result<MarketplaceCache> {
|
||||
let path = data_dir.join(MARKETPLACE_DIR).join("cache").join(CACHE_FILE);
|
||||
if !path.exists() {
|
||||
return Ok(MarketplaceCache::default());
|
||||
}
|
||||
let data = fs::read_to_string(&path).await.context("Reading marketplace cache")?;
|
||||
serde_json::from_str(&data).context("Parsing marketplace cache")
|
||||
}
|
||||
|
||||
/// Save marketplace cache.
|
||||
pub async fn save_cache(data_dir: &Path, cache: &MarketplaceCache) -> Result<()> {
|
||||
ensure_dirs(data_dir).await?;
|
||||
let path = data_dir.join(MARKETPLACE_DIR).join("cache").join(CACHE_FILE);
|
||||
let data = serde_json::to_string_pretty(cache)?;
|
||||
fs::write(&path, data).await.context("Writing marketplace cache")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate a manifest meets security requirements.
|
||||
pub fn validate_manifest(manifest: &AppManifest) -> Vec<String> {
|
||||
let mut issues = Vec::new();
|
||||
|
||||
// Required fields
|
||||
if manifest.app_id.is_empty() {
|
||||
issues.push("Missing app_id".into());
|
||||
}
|
||||
if manifest.name.is_empty() {
|
||||
issues.push("Missing name".into());
|
||||
}
|
||||
if manifest.version.is_empty() {
|
||||
issues.push("Missing version".into());
|
||||
}
|
||||
if manifest.container.image.is_empty() {
|
||||
issues.push("Missing container image".into());
|
||||
}
|
||||
|
||||
// Security checks
|
||||
if manifest.container.image.ends_with(":latest") {
|
||||
issues.push("Container image uses :latest tag (must pin specific version)".into());
|
||||
}
|
||||
if !manifest.container.readonly_root {
|
||||
issues.push("readonly_root is false (should be true)".into());
|
||||
}
|
||||
if !manifest.container.no_new_privileges {
|
||||
issues.push("no_new_privileges is false (should be true)".into());
|
||||
}
|
||||
if manifest.container.run_as_user < 1000 {
|
||||
issues.push(format!("run_as_user is {} (must be >= 1000)", manifest.container.run_as_user));
|
||||
}
|
||||
|
||||
// app_id format
|
||||
if !manifest.app_id.chars().all(|c| c.is_ascii_lowercase() || c == '-' || c.is_ascii_digit()) {
|
||||
issues.push("app_id must be lowercase kebab-case".into());
|
||||
}
|
||||
|
||||
issues
|
||||
}
|
||||
|
||||
/// Calculate trust score for a discovered app manifest.
|
||||
pub fn calculate_trust_score(
|
||||
manifest: &AppManifest,
|
||||
relay_count: u32,
|
||||
federated_dids: &[String],
|
||||
) -> (u32, String) {
|
||||
let mut score: u32 = 0;
|
||||
|
||||
// DID verification (30 points) — has a valid DID in author
|
||||
if !manifest.author.did.is_empty() && manifest.author.did.starts_with("did:") {
|
||||
score += 30;
|
||||
}
|
||||
|
||||
// Relay consensus (20 points) — found on multiple relays
|
||||
score += match relay_count {
|
||||
0..=1 => 5,
|
||||
2..=3 => 12,
|
||||
_ => 20,
|
||||
};
|
||||
|
||||
// Federation trust (20 points) — developer DID in federation
|
||||
if federated_dids.contains(&manifest.author.did) {
|
||||
score += 20;
|
||||
}
|
||||
|
||||
// Version history (15 points) — has a proper semver version
|
||||
if manifest.version.split('.').count() == 3 {
|
||||
score += 10;
|
||||
}
|
||||
if !manifest.repo_url.is_empty() {
|
||||
score += 5;
|
||||
}
|
||||
|
||||
// Security compliance (15 points)
|
||||
let issues = validate_manifest(manifest);
|
||||
if issues.is_empty() {
|
||||
score += 15;
|
||||
} else if issues.len() <= 2 {
|
||||
score += 5;
|
||||
}
|
||||
|
||||
let tier = match score {
|
||||
80..=100 => "verified",
|
||||
50..=79 => "community",
|
||||
20..=49 => "unverified",
|
||||
_ => "untrusted",
|
||||
};
|
||||
|
||||
(score, tier.to_string())
|
||||
}
|
||||
|
||||
/// Discover app manifests from Nostr relays.
|
||||
///
|
||||
/// Queries configured relays for kind 30078 events with the marketplace tag,
|
||||
/// parses manifests, validates, scores, and returns sorted by trust score.
|
||||
pub async fn discover(
|
||||
data_dir: &Path,
|
||||
relays: &[String],
|
||||
tor_proxy: Option<&str>,
|
||||
federated_dids: &[String],
|
||||
) -> Result<Vec<DiscoveredApp>> {
|
||||
if relays.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
info!(relay_count = relays.len(), "Discovering marketplace apps from Nostr relays");
|
||||
|
||||
let anon_keys = nostr_sdk::prelude::Keys::generate();
|
||||
let client = build_nostr_client(anon_keys, tor_proxy)?;
|
||||
for url in relays {
|
||||
let _ = client.add_relay(url).await;
|
||||
}
|
||||
client.connect().await;
|
||||
|
||||
let filter = nostr_sdk::prelude::Filter::new()
|
||||
.kind(nostr_sdk::prelude::Kind::Custom(ARCHIPELAGO_KIND as u16))
|
||||
.hashtag(MARKETPLACE_TAG)
|
||||
.limit(200);
|
||||
|
||||
let events = client
|
||||
.fetch_events(filter, std::time::Duration::from_secs(20))
|
||||
.await
|
||||
.map(|e| e.to_vec())
|
||||
.unwrap_or_default();
|
||||
client.disconnect().await;
|
||||
|
||||
debug!(event_count = events.len(), "Fetched marketplace events from relays");
|
||||
|
||||
// Deduplicate by app_id, keeping the latest version
|
||||
let mut app_map: HashMap<String, (DiscoveredApp, u32)> = HashMap::new();
|
||||
|
||||
for event in events {
|
||||
// Parse manifest from event content
|
||||
let manifest: AppManifest = match serde_json::from_str(&event.content) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
debug!(err = %e, "Skipping invalid marketplace manifest");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Validate
|
||||
let issues = validate_manifest(&manifest);
|
||||
if issues.iter().any(|i| i.contains("Missing app_id") || i.contains("Missing container image")) {
|
||||
debug!(issues = ?issues, "Skipping manifest with critical issues");
|
||||
continue;
|
||||
}
|
||||
|
||||
let app_id = manifest.app_id.clone();
|
||||
let entry = app_map.entry(app_id).or_insert_with(|| {
|
||||
let (trust_score, trust_tier) = calculate_trust_score(&manifest, 1, federated_dids);
|
||||
(
|
||||
DiscoveredApp {
|
||||
manifest,
|
||||
trust_score,
|
||||
trust_tier,
|
||||
relay_count: 0,
|
||||
first_seen: event.created_at.to_human_datetime(),
|
||||
nostr_pubkey: event.pubkey.to_hex(),
|
||||
},
|
||||
0,
|
||||
)
|
||||
});
|
||||
entry.1 += 1;
|
||||
}
|
||||
|
||||
// Update relay counts and recalculate scores
|
||||
let mut apps: Vec<DiscoveredApp> = app_map
|
||||
.into_values()
|
||||
.map(|(mut app, relay_count)| {
|
||||
app.relay_count = relay_count;
|
||||
let (score, tier) = calculate_trust_score(&app.manifest, relay_count, federated_dids);
|
||||
app.trust_score = score;
|
||||
app.trust_tier = tier;
|
||||
app
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Sort by trust score descending
|
||||
apps.sort_by(|a, b| b.trust_score.cmp(&a.trust_score));
|
||||
|
||||
// Cache results
|
||||
let cache = MarketplaceCache {
|
||||
apps: apps.clone(),
|
||||
last_updated: chrono::Utc::now().to_rfc3339(),
|
||||
};
|
||||
if let Err(e) = save_cache(data_dir, &cache).await {
|
||||
warn!(err = %e, "Failed to save marketplace cache");
|
||||
}
|
||||
|
||||
info!(app_count = apps.len(), "Marketplace discovery complete");
|
||||
Ok(apps)
|
||||
}
|
||||
|
||||
/// Publish an app manifest to Nostr relays.
|
||||
pub async fn publish(
|
||||
data_dir: &Path,
|
||||
manifest: &AppManifest,
|
||||
relays: &[String],
|
||||
tor_proxy: Option<&str>,
|
||||
) -> Result<String> {
|
||||
if relays.is_empty() {
|
||||
anyhow::bail!("No relays configured for publishing");
|
||||
}
|
||||
|
||||
let issues = validate_manifest(manifest);
|
||||
if !issues.is_empty() {
|
||||
anyhow::bail!("Manifest validation failed: {}", issues.join(", "));
|
||||
}
|
||||
|
||||
let identity_dir = data_dir.join("identity");
|
||||
let keys = load_or_create_keys(&identity_dir).await?;
|
||||
let client = build_nostr_client(keys, tor_proxy)?;
|
||||
|
||||
let content = serde_json::to_string(manifest).context("Serializing manifest")?;
|
||||
let d_tag = format!("{}{}", D_TAG_PREFIX, manifest.app_id);
|
||||
|
||||
for url in relays {
|
||||
let _ = client.add_relay(url).await;
|
||||
}
|
||||
client.connect().await;
|
||||
|
||||
let builder = nostr_sdk::prelude::EventBuilder::new(
|
||||
nostr_sdk::prelude::Kind::Custom(ARCHIPELAGO_KIND as u16),
|
||||
&content,
|
||||
)
|
||||
.tag(nostr_sdk::prelude::Tag::identifier(&d_tag))
|
||||
.tag(nostr_sdk::prelude::Tag::hashtag(MARKETPLACE_TAG))
|
||||
.tag(nostr_sdk::prelude::Tag::hashtag(format!("category:{}", manifest.category)))
|
||||
.tag(nostr_sdk::prelude::Tag::custom(
|
||||
nostr_sdk::prelude::TagKind::custom("version"),
|
||||
[&manifest.version],
|
||||
))
|
||||
.tag(nostr_sdk::prelude::Tag::custom(
|
||||
nostr_sdk::prelude::TagKind::custom("image"),
|
||||
[&manifest.container.image],
|
||||
));
|
||||
|
||||
let output = client.send_event_builder(builder).await?;
|
||||
client.disconnect().await;
|
||||
|
||||
// Save to published directory
|
||||
ensure_dirs(data_dir).await?;
|
||||
let pub_path = data_dir
|
||||
.join(MARKETPLACE_DIR)
|
||||
.join(PUBLISHED_DIR)
|
||||
.join(format!("{}.json", manifest.app_id));
|
||||
let pub_data = serde_json::to_string_pretty(manifest)?;
|
||||
fs::write(&pub_path, pub_data).await.context("Saving published manifest")?;
|
||||
|
||||
info!(app_id = %manifest.app_id, "Published app manifest to {} relays", relays.len());
|
||||
Ok(output.id().to_hex())
|
||||
}
|
||||
|
||||
/// List manifests published by this node.
|
||||
pub async fn list_published(data_dir: &Path) -> Result<Vec<AppManifest>> {
|
||||
let pub_dir = data_dir.join(MARKETPLACE_DIR).join(PUBLISHED_DIR);
|
||||
if !pub_dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut manifests = Vec::new();
|
||||
let mut entries = fs::read_dir(&pub_dir).await.context("Reading published dir")?;
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let path = entry.path();
|
||||
if path.extension().map(|e| e == "json").unwrap_or(false) {
|
||||
let data = fs::read_to_string(&path).await?;
|
||||
if let Ok(manifest) = serde_json::from_str::<AppManifest>(&data) {
|
||||
manifests.push(manifest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(manifests)
|
||||
}
|
||||
|
||||
// Re-use nostr client builder pattern from nostr_discovery
|
||||
fn build_nostr_client(
|
||||
keys: nostr_sdk::prelude::Keys,
|
||||
tor_proxy: Option<&str>,
|
||||
) -> Result<nostr_sdk::prelude::Client> {
|
||||
use nostr_sdk::prelude::*;
|
||||
let client = if let Some(proxy_str) = tor_proxy {
|
||||
let addr: std::net::SocketAddr = proxy_str
|
||||
.trim()
|
||||
.parse()
|
||||
.ok()
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid Tor proxy: {}", proxy_str))?;
|
||||
let connection = Connection::new()
|
||||
.proxy(addr)
|
||||
.target(ConnectionTarget::All);
|
||||
let opts = ClientOptions::new().connection(connection);
|
||||
Client::builder().signer(keys).opts(opts).build()
|
||||
} else {
|
||||
Client::new(keys)
|
||||
};
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
/// Load or create Nostr keys for marketplace publishing.
|
||||
async fn load_or_create_keys(identity_dir: &Path) -> Result<nostr_sdk::prelude::Keys> {
|
||||
use nostr_sdk::prelude::Keys;
|
||||
|
||||
let secret_path = identity_dir.join("nostr_secret");
|
||||
if secret_path.exists() {
|
||||
let hex_secret = fs::read_to_string(&secret_path)
|
||||
.await
|
||||
.context("Reading Nostr secret")?;
|
||||
Keys::parse(hex_secret.trim()).context("Invalid Nostr secret")
|
||||
} else {
|
||||
let keys = Keys::generate();
|
||||
fs::create_dir_all(identity_dir).await?;
|
||||
fs::write(&secret_path, keys.secret_key().to_secret_hex()).await?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let sp = secret_path.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
std::fs::set_permissions(sp, std::fs::Permissions::from_mode(0o600))
|
||||
})
|
||||
.await??;
|
||||
}
|
||||
Ok(keys)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_manifest() -> AppManifest {
|
||||
AppManifest {
|
||||
app_id: "test-app".into(),
|
||||
name: "Test App".into(),
|
||||
version: "1.0.0".into(),
|
||||
description: ManifestDescription::Detailed {
|
||||
short: "A test app".into(),
|
||||
long: "A longer description of the test app".into(),
|
||||
},
|
||||
author: ManifestAuthor {
|
||||
name: "Test Dev".into(),
|
||||
did: "did:key:z6MkTest123".into(),
|
||||
nostr_pubkey: String::new(),
|
||||
},
|
||||
container: ManifestContainer {
|
||||
image: "docker.io/test/app:1.0.0".into(),
|
||||
ports: vec![PortMapping {
|
||||
container: 8080,
|
||||
host: 8180,
|
||||
protocol: "tcp".into(),
|
||||
}],
|
||||
volumes: vec![],
|
||||
env: HashMap::new(),
|
||||
capabilities: vec![],
|
||||
readonly_root: true,
|
||||
no_new_privileges: true,
|
||||
run_as_user: 1000,
|
||||
},
|
||||
category: AppCategory::Other,
|
||||
icon_url: String::new(),
|
||||
repo_url: "https://github.com/test/app".into(),
|
||||
license: "MIT".into(),
|
||||
min_archipelago_version: "0.1.0".into(),
|
||||
dependencies: vec![],
|
||||
signatures: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_valid_manifest() {
|
||||
let manifest = sample_manifest();
|
||||
let issues = validate_manifest(&manifest);
|
||||
assert!(issues.is_empty(), "Expected no issues, got: {:?}", issues);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_latest_tag() {
|
||||
let mut manifest = sample_manifest();
|
||||
manifest.container.image = "docker.io/test/app:latest".into();
|
||||
let issues = validate_manifest(&manifest);
|
||||
assert!(issues.iter().any(|i| i.contains("latest")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_root_user() {
|
||||
let mut manifest = sample_manifest();
|
||||
manifest.container.run_as_user = 0;
|
||||
let issues = validate_manifest(&manifest);
|
||||
assert!(issues.iter().any(|i| i.contains("run_as_user")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_missing_fields() {
|
||||
let mut manifest = sample_manifest();
|
||||
manifest.app_id = String::new();
|
||||
manifest.container.image = String::new();
|
||||
let issues = validate_manifest(&manifest);
|
||||
assert!(issues.len() >= 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trust_score_full() {
|
||||
let manifest = sample_manifest();
|
||||
let (score, tier) = calculate_trust_score(&manifest, 3, &["did:key:z6MkTest123".to_string()]);
|
||||
// DID (30) + relay consensus 2-3 (12) + federation (20) + semver (10) + repo (5) + security clean (15) = 92
|
||||
assert!(score >= 80, "Expected verified, got score={}", score);
|
||||
assert_eq!(tier, "verified");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trust_score_no_federation() {
|
||||
let manifest = sample_manifest();
|
||||
let (score, tier) = calculate_trust_score(&manifest, 1, &[]);
|
||||
// DID (30) + 1 relay (5) + no federation (0) + semver (10) + repo (5) + security (15) = 65
|
||||
assert_eq!(tier, "community");
|
||||
assert!(score >= 50 && score < 80);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trust_score_untrusted() {
|
||||
let mut manifest = sample_manifest();
|
||||
manifest.author.did = String::new();
|
||||
manifest.repo_url = String::new();
|
||||
manifest.version = "1".into();
|
||||
manifest.container.readonly_root = false;
|
||||
let (score, _tier) = calculate_trust_score(&manifest, 1, &[]);
|
||||
assert!(score < 50, "Expected low score, got {}", score);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_manifest_serialization() {
|
||||
let manifest = sample_manifest();
|
||||
let json = serde_json::to_string(&manifest).unwrap();
|
||||
let parsed: AppManifest = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.app_id, "test-app");
|
||||
assert_eq!(parsed.description.short(), "A test app");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_category_display() {
|
||||
assert_eq!(AppCategory::Money.to_string(), "money");
|
||||
assert_eq!(AppCategory::Networking.to_string(), "networking");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cache_persistence() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cache = MarketplaceCache {
|
||||
apps: vec![DiscoveredApp {
|
||||
manifest: sample_manifest(),
|
||||
trust_score: 75,
|
||||
trust_tier: "community".into(),
|
||||
relay_count: 2,
|
||||
first_seen: "2026-03-10T00:00:00Z".into(),
|
||||
nostr_pubkey: "abc123".into(),
|
||||
}],
|
||||
last_updated: "2026-03-10T00:00:00Z".into(),
|
||||
};
|
||||
save_cache(dir.path(), &cache).await.unwrap();
|
||||
let loaded = load_cache(dir.path()).await.unwrap();
|
||||
assert_eq!(loaded.apps.len(), 1);
|
||||
assert_eq!(loaded.apps[0].manifest.app_id, "test-app");
|
||||
}
|
||||
}
|
||||
265
core/archipelago/src/mesh.rs
Normal file
265
core/archipelago/src/mesh.rs
Normal file
@ -0,0 +1,265 @@
|
||||
//! Mesh networking: local node discovery over LoRa (Meshtastic) and BLE.
|
||||
//!
|
||||
//! Broadcasts node identity over mesh radio networks for offline peer discovery.
|
||||
//! Uses Meshtastic serial protocol when a compatible radio is connected via USB.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use tokio::fs;
|
||||
|
||||
const MESH_CONFIG_FILE: &str = "mesh-config.json";
|
||||
|
||||
/// A node discovered via mesh radio.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MeshNode {
|
||||
pub node_id: String,
|
||||
pub did: Option<String>,
|
||||
pub pubkey: Option<String>,
|
||||
pub rssi: Option<i32>,
|
||||
pub snr: Option<f64>,
|
||||
pub last_heard: String,
|
||||
#[serde(default)]
|
||||
pub hops: u32,
|
||||
#[serde(default)]
|
||||
pub channel: Option<String>,
|
||||
}
|
||||
|
||||
/// Mesh configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MeshConfig {
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub device_path: Option<String>,
|
||||
#[serde(default)]
|
||||
pub channel_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub broadcast_identity: bool,
|
||||
}
|
||||
|
||||
impl Default for MeshConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
device_path: None,
|
||||
channel_name: Some("archipelago".to_string()),
|
||||
broadcast_identity: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn load_config(data_dir: &Path) -> Result<MeshConfig> {
|
||||
let path = data_dir.join(MESH_CONFIG_FILE);
|
||||
if !path.exists() {
|
||||
return Ok(MeshConfig::default());
|
||||
}
|
||||
let content = fs::read_to_string(&path)
|
||||
.await
|
||||
.context("Failed to read mesh config")?;
|
||||
let config: MeshConfig = serde_json::from_str(&content).unwrap_or_default();
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub async fn save_config(data_dir: &Path, config: &MeshConfig) -> Result<()> {
|
||||
fs::create_dir_all(data_dir).await.context("Failed to create data dir")?;
|
||||
let content =
|
||||
serde_json::to_string_pretty(config).context("Failed to serialize mesh config")?;
|
||||
fs::write(data_dir.join(MESH_CONFIG_FILE), content)
|
||||
.await
|
||||
.context("Failed to write mesh config")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Detect Meshtastic-compatible USB devices.
|
||||
/// Meshtastic radios typically appear as USB serial devices (CP210x, CH340, FTDI).
|
||||
pub async fn detect_meshtastic_devices() -> Vec<String> {
|
||||
let mut devices = Vec::new();
|
||||
|
||||
// Check for common serial device paths
|
||||
let candidates = [
|
||||
"/dev/ttyUSB0",
|
||||
"/dev/ttyUSB1",
|
||||
"/dev/ttyACM0",
|
||||
"/dev/ttyACM1",
|
||||
];
|
||||
|
||||
for path in &candidates {
|
||||
if tokio::fs::metadata(path).await.is_ok() {
|
||||
devices.push(path.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Also scan sysfs for Meshtastic-specific USB VIDs
|
||||
if let Ok(mut entries) = tokio::fs::read_dir("/sys/bus/usb/devices").await {
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
let vid_path = entry.path().join("idVendor");
|
||||
if let Ok(vid_str) = tokio::fs::read_to_string(&vid_path).await {
|
||||
let vid = vid_str.trim();
|
||||
// Silicon Labs CP210x (common Meshtastic radio)
|
||||
// CH340 USB-serial
|
||||
// FTDI FT232
|
||||
if vid == "10c4" || vid == "1a86" || vid == "0403" {
|
||||
let product = tokio::fs::read_to_string(entry.path().join("product"))
|
||||
.await
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|_| "Serial Device".to_string());
|
||||
devices.push(format!("{} ({})", entry.path().display(), product));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
devices
|
||||
}
|
||||
|
||||
/// Discover nodes via Meshtastic CLI (meshtastic --nodes).
|
||||
/// Returns nodes that have broadcast their Archipelago identity.
|
||||
pub async fn discover_nodes(device_path: Option<&str>) -> Result<Vec<MeshNode>> {
|
||||
let mut cmd = tokio::process::Command::new("meshtastic");
|
||||
cmd.arg("--nodes");
|
||||
|
||||
if let Some(dev) = device_path {
|
||||
cmd.arg("--port").arg(dev);
|
||||
}
|
||||
|
||||
let output = cmd
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to run meshtastic CLI — is it installed?")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if stderr.contains("No Meshtastic") || stderr.contains("not found") {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
anyhow::bail!("meshtastic --nodes failed: {}", stderr);
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let mut nodes = Vec::new();
|
||||
|
||||
// Parse the meshtastic CLI node list output
|
||||
// Format varies but typically: NodeNum | User | AKA | ...
|
||||
for line in stdout.lines().skip(2) {
|
||||
// Skip header lines
|
||||
let parts: Vec<&str> = line.split('|').map(|s| s.trim()).collect();
|
||||
if parts.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let node_id = parts.first().unwrap_or(&"").to_string();
|
||||
if node_id.is_empty() || node_id.starts_with('-') {
|
||||
continue;
|
||||
}
|
||||
|
||||
nodes.push(MeshNode {
|
||||
node_id: node_id.trim().to_string(),
|
||||
did: None,
|
||||
pubkey: None,
|
||||
rssi: None,
|
||||
snr: None,
|
||||
last_heard: chrono::Utc::now().to_rfc3339(),
|
||||
hops: 0,
|
||||
channel: None,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(nodes)
|
||||
}
|
||||
|
||||
/// Broadcast our node identity over mesh.
|
||||
pub async fn broadcast_identity(
|
||||
did: &str,
|
||||
pubkey: &str,
|
||||
device_path: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let message = format!("ARCHY:{}:{}", did, pubkey);
|
||||
|
||||
let mut cmd = tokio::process::Command::new("meshtastic");
|
||||
cmd.arg("--sendtext").arg(&message);
|
||||
|
||||
if let Some(dev) = device_path {
|
||||
cmd.arg("--port").arg(dev);
|
||||
}
|
||||
|
||||
let output = cmd
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to broadcast via meshtastic")?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"meshtastic broadcast failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_mesh_config_default() {
|
||||
let config = MeshConfig::default();
|
||||
assert!(!config.enabled);
|
||||
assert_eq!(config.channel_name, Some("archipelago".to_string()));
|
||||
assert!(config.broadcast_identity);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mesh_config_serialization() {
|
||||
let config = MeshConfig {
|
||||
enabled: true,
|
||||
device_path: Some("/dev/ttyUSB0".to_string()),
|
||||
channel_name: Some("test".to_string()),
|
||||
broadcast_identity: false,
|
||||
};
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
let parsed: MeshConfig = serde_json::from_str(&json).unwrap();
|
||||
assert!(parsed.enabled);
|
||||
assert_eq!(parsed.device_path, Some("/dev/ttyUSB0".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mesh_node_serialization() {
|
||||
let node = MeshNode {
|
||||
node_id: "!aabbccdd".to_string(),
|
||||
did: Some("did:key:z123".to_string()),
|
||||
pubkey: Some("pubhex".to_string()),
|
||||
rssi: Some(-85),
|
||||
snr: Some(7.5),
|
||||
last_heard: "2026-03-10T00:00:00Z".to_string(),
|
||||
hops: 1,
|
||||
channel: Some("archipelago".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&node).unwrap();
|
||||
let parsed: MeshNode = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.node_id, "!aabbccdd");
|
||||
assert_eq!(parsed.rssi, Some(-85));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_config_default_when_no_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let config = load_config(dir.path()).await.unwrap();
|
||||
assert!(!config.enabled);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_save_and_load_config_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let config = MeshConfig {
|
||||
enabled: true,
|
||||
device_path: Some("/dev/ttyUSB0".to_string()),
|
||||
channel_name: Some("archy".to_string()),
|
||||
broadcast_identity: true,
|
||||
};
|
||||
save_config(dir.path(), &config).await.unwrap();
|
||||
let loaded = load_config(dir.path()).await.unwrap();
|
||||
assert!(loaded.enabled);
|
||||
assert_eq!(loaded.device_path, Some("/dev/ttyUSB0".to_string()));
|
||||
}
|
||||
}
|
||||
@ -213,3 +213,172 @@ pub async fn link_name_to_did(
|
||||
save_names(data_dir, &store).await?;
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_name_status_display() {
|
||||
assert_eq!(NameStatus::Active.to_string(), "active");
|
||||
assert_eq!(NameStatus::Pending.to_string(), "pending");
|
||||
assert_eq!(NameStatus::Expired.to_string(), "expired");
|
||||
assert_eq!(NameStatus::Failed.to_string(), "failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_name_status_serde_roundtrip() {
|
||||
let json = serde_json::to_string(&NameStatus::Active).unwrap();
|
||||
assert_eq!(json, "\"active\"");
|
||||
let deserialized: NameStatus = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized, NameStatus::Active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_names_store_default_is_empty() {
|
||||
let store = NamesStore::default();
|
||||
assert!(store.names.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_names_returns_empty_when_no_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = load_names(dir.path()).await.unwrap();
|
||||
assert!(store.names.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_save_and_load_names_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = NamesStore {
|
||||
names: vec![RegisteredName {
|
||||
id: "test-id-1".to_string(),
|
||||
name: "satoshi".to_string(),
|
||||
domain: "example.com".to_string(),
|
||||
identity_id: "identity-1".to_string(),
|
||||
did: "did:key:z123".to_string(),
|
||||
nostr_pubkey: Some("npub1abc".to_string()),
|
||||
status: NameStatus::Active,
|
||||
registered_at: "2025-01-01T00:00:00Z".to_string(),
|
||||
expires_at: None,
|
||||
nip05: "satoshi@example.com".to_string(),
|
||||
}],
|
||||
};
|
||||
save_names(dir.path(), &store).await.unwrap();
|
||||
let loaded = load_names(dir.path()).await.unwrap();
|
||||
assert_eq!(loaded.names.len(), 1);
|
||||
assert_eq!(loaded.names[0].name, "satoshi");
|
||||
assert_eq!(loaded.names[0].nip05, "satoshi@example.com");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_register_name_creates_record() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let result = register_name(
|
||||
dir.path(),
|
||||
"alice",
|
||||
"bitcoin.org",
|
||||
"id-1",
|
||||
"did:key:zabc",
|
||||
Some("npub1xyz"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.name, "alice");
|
||||
assert_eq!(result.domain, "bitcoin.org");
|
||||
assert_eq!(result.nip05, "alice@bitcoin.org");
|
||||
assert_eq!(result.did, "did:key:zabc");
|
||||
assert_eq!(result.nostr_pubkey, Some("npub1xyz".to_string()));
|
||||
assert_eq!(result.status, NameStatus::Active);
|
||||
assert!(!result.id.is_empty());
|
||||
|
||||
// Verify persisted
|
||||
let store = load_names(dir.path()).await.unwrap();
|
||||
assert_eq!(store.names.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_register_duplicate_name_fails() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
register_name(dir.path(), "bob", "test.com", "id-1", "did:key:z1", None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result =
|
||||
register_name(dir.path(), "bob", "test.com", "id-2", "did:key:z2", None).await;
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(err_msg.contains("already registered"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_register_same_name_different_domain_succeeds() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
register_name(dir.path(), "alice", "domain1.com", "id-1", "did:key:z1", None)
|
||||
.await
|
||||
.unwrap();
|
||||
let result =
|
||||
register_name(dir.path(), "alice", "domain2.com", "id-1", "did:key:z1", None).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let store = load_names(dir.path()).await.unwrap();
|
||||
assert_eq!(store.names.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remove_name_by_id() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let registered = register_name(
|
||||
dir.path(),
|
||||
"charlie",
|
||||
"example.com",
|
||||
"id-1",
|
||||
"did:key:z1",
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
remove_name(dir.path(), ®istered.id).await.unwrap();
|
||||
let store = load_names(dir.path()).await.unwrap();
|
||||
assert!(store.names.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remove_nonexistent_name_fails() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let result = remove_name(dir.path(), "nonexistent-id").await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("Name not found"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_link_name_to_did() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let registered = register_name(
|
||||
dir.path(),
|
||||
"dave",
|
||||
"example.com",
|
||||
"old-identity",
|
||||
"did:key:old",
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let updated = link_name_to_did(
|
||||
dir.path(),
|
||||
®istered.id,
|
||||
"did:key:new",
|
||||
"new-identity",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(updated.did, "did:key:new");
|
||||
assert_eq!(updated.identity_id, "new-identity");
|
||||
// Name itself should be unchanged
|
||||
assert_eq!(updated.name, "dave");
|
||||
}
|
||||
}
|
||||
|
||||
382
core/archipelago/src/network/dns.rs
Normal file
382
core/archipelago/src/network/dns.rs
Normal file
@ -0,0 +1,382 @@
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use tokio::fs;
|
||||
use tracing::{debug, info};
|
||||
|
||||
const DNS_CONFIG_FILE: &str = "dns_config.json";
|
||||
|
||||
/// DNS provider presets with server addresses.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DnsProvider {
|
||||
/// Use system default (DHCP-assigned DNS)
|
||||
System,
|
||||
/// Cloudflare DNS-over-HTTPS (1.1.1.1)
|
||||
Cloudflare,
|
||||
/// Google DNS-over-HTTPS (8.8.8.8)
|
||||
Google,
|
||||
/// Quad9 DNS-over-HTTPS (9.9.9.9)
|
||||
Quad9,
|
||||
/// Mullvad DNS (no logging)
|
||||
Mullvad,
|
||||
/// Custom user-specified servers
|
||||
Custom,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DnsProvider {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::System => write!(f, "system"),
|
||||
Self::Cloudflare => write!(f, "cloudflare"),
|
||||
Self::Google => write!(f, "google"),
|
||||
Self::Quad9 => write!(f, "quad9"),
|
||||
Self::Mullvad => write!(f, "mullvad"),
|
||||
Self::Custom => write!(f, "custom"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Persisted DNS configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DnsConfig {
|
||||
pub provider: DnsProvider,
|
||||
pub servers: Vec<String>,
|
||||
pub doh_enabled: bool,
|
||||
pub doh_url: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for DnsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
provider: DnsProvider::System,
|
||||
servers: Vec::new(),
|
||||
doh_enabled: false,
|
||||
doh_url: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Current DNS status read from the system.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DnsStatus {
|
||||
pub provider: String,
|
||||
pub servers: Vec<String>,
|
||||
pub doh_enabled: bool,
|
||||
pub doh_url: Option<String>,
|
||||
pub resolv_conf_servers: Vec<String>,
|
||||
}
|
||||
|
||||
/// Load persisted DNS config from disk.
|
||||
pub async fn load_config(data_dir: &Path) -> Result<DnsConfig> {
|
||||
let path = data_dir.join(DNS_CONFIG_FILE);
|
||||
if !path.exists() {
|
||||
return Ok(DnsConfig::default());
|
||||
}
|
||||
let data = fs::read_to_string(&path)
|
||||
.await
|
||||
.context("Reading DNS config")?;
|
||||
serde_json::from_str(&data).context("Parsing DNS config")
|
||||
}
|
||||
|
||||
/// Save DNS config to disk.
|
||||
pub async fn save_config(data_dir: &Path, config: &DnsConfig) -> Result<()> {
|
||||
let path = data_dir.join(DNS_CONFIG_FILE);
|
||||
let data = serde_json::to_string_pretty(config)?;
|
||||
fs::write(&path, data)
|
||||
.await
|
||||
.context("Writing DNS config")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the DNS servers for a given provider preset.
|
||||
pub fn provider_servers(provider: &DnsProvider) -> (Vec<String>, Option<String>) {
|
||||
match provider {
|
||||
DnsProvider::System => (Vec::new(), None),
|
||||
DnsProvider::Cloudflare => (
|
||||
vec!["1.1.1.1".into(), "1.0.0.1".into()],
|
||||
Some("https://cloudflare-dns.com/dns-query".into()),
|
||||
),
|
||||
DnsProvider::Google => (
|
||||
vec!["8.8.8.8".into(), "8.8.4.4".into()],
|
||||
Some("https://dns.google/dns-query".into()),
|
||||
),
|
||||
DnsProvider::Quad9 => (
|
||||
vec!["9.9.9.9".into(), "149.112.112.112".into()],
|
||||
Some("https://dns.quad9.net/dns-query".into()),
|
||||
),
|
||||
DnsProvider::Mullvad => (
|
||||
vec!["194.242.2.2".into()],
|
||||
Some("https://dns.mullvad.net/dns-query".into()),
|
||||
),
|
||||
DnsProvider::Custom => (Vec::new(), None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read current DNS servers from /etc/resolv.conf.
|
||||
pub async fn read_resolv_conf() -> Result<Vec<String>> {
|
||||
let content = fs::read_to_string("/etc/resolv.conf")
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let servers: Vec<String> = content
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with("nameserver") {
|
||||
trimmed.split_whitespace().nth(1).map(String::from)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(servers)
|
||||
}
|
||||
|
||||
/// Get current DNS status combining config + system state.
|
||||
pub async fn get_status(data_dir: &Path) -> Result<DnsStatus> {
|
||||
let config = load_config(data_dir).await?;
|
||||
let resolv_servers = read_resolv_conf().await.unwrap_or_default();
|
||||
|
||||
Ok(DnsStatus {
|
||||
provider: config.provider.to_string(),
|
||||
servers: if config.servers.is_empty() {
|
||||
resolv_servers.clone()
|
||||
} else {
|
||||
config.servers.clone()
|
||||
},
|
||||
doh_enabled: config.doh_enabled,
|
||||
doh_url: config.doh_url.clone(),
|
||||
resolv_conf_servers: resolv_servers,
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply DNS configuration to the system via nmcli.
|
||||
///
|
||||
/// Sets DNS servers on the active NetworkManager connection(s).
|
||||
pub async fn apply_dns(config: &DnsConfig) -> Result<()> {
|
||||
if config.provider == DnsProvider::System {
|
||||
// Revert to DHCP-assigned DNS
|
||||
info!("Reverting to system (DHCP) DNS");
|
||||
apply_dns_via_nmcli(&[]).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let servers = &config.servers;
|
||||
if servers.is_empty() {
|
||||
anyhow::bail!("No DNS servers specified");
|
||||
}
|
||||
|
||||
// Validate all server IPs
|
||||
for s in servers {
|
||||
if s.parse::<std::net::IpAddr>().is_err() {
|
||||
anyhow::bail!("Invalid DNS server IP: {}", s);
|
||||
}
|
||||
}
|
||||
|
||||
info!(provider = %config.provider, servers = ?servers, "Applying DNS configuration");
|
||||
apply_dns_via_nmcli(servers).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply DNS servers to all active NetworkManager connections.
|
||||
async fn apply_dns_via_nmcli(servers: &[String]) -> Result<()> {
|
||||
// Get active connections
|
||||
let output = tokio::process::Command::new("nmcli")
|
||||
.args(["-t", "-f", "NAME,DEVICE,TYPE", "connection", "show", "--active"])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to list nmcli connections")?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"nmcli connection show failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8(output.stdout).context("nmcli output not utf8")?;
|
||||
let connections: Vec<&str> = stdout
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let parts: Vec<&str> = line.splitn(3, ':').collect();
|
||||
if parts.len() >= 3 {
|
||||
let conn_type = parts[2];
|
||||
// Only modify ethernet and wifi connections
|
||||
if conn_type.contains("ethernet") || conn_type.contains("wireless") || conn_type.contains("wifi") {
|
||||
return Some(parts[0]);
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
.collect();
|
||||
|
||||
if connections.is_empty() {
|
||||
debug!("No active ethernet/wifi connections found, skipping DNS apply");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let dns_value = if servers.is_empty() {
|
||||
String::new() // Empty clears custom DNS, reverts to DHCP
|
||||
} else {
|
||||
servers.join(" ")
|
||||
};
|
||||
|
||||
for conn_name in &connections {
|
||||
// Set DNS servers
|
||||
let dns_args = if dns_value.is_empty() {
|
||||
vec![
|
||||
"connection".to_string(),
|
||||
"modify".to_string(),
|
||||
conn_name.to_string(),
|
||||
"ipv4.dns".to_string(),
|
||||
String::new(),
|
||||
"ipv4.ignore-auto-dns".to_string(),
|
||||
"no".to_string(),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
"connection".to_string(),
|
||||
"modify".to_string(),
|
||||
conn_name.to_string(),
|
||||
"ipv4.dns".to_string(),
|
||||
dns_value.clone(),
|
||||
"ipv4.ignore-auto-dns".to_string(),
|
||||
"yes".to_string(),
|
||||
]
|
||||
};
|
||||
|
||||
let modify = tokio::process::Command::new("nmcli")
|
||||
.args(&dns_args)
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to modify DNS via nmcli")?;
|
||||
|
||||
if !modify.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&modify.stderr);
|
||||
tracing::warn!(conn = conn_name, err = %stderr, "Failed to set DNS on connection");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Reapply the connection to pick up changes
|
||||
let reapply = tokio::process::Command::new("nmcli")
|
||||
.args(["connection", "up", conn_name])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
match reapply {
|
||||
Ok(out) if out.status.success() => {
|
||||
info!(conn = conn_name, "DNS updated successfully");
|
||||
}
|
||||
Ok(out) => {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
tracing::warn!(conn = conn_name, err = %stderr, "Failed to reapply connection");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(conn = conn_name, err = %e, "Failed to reapply connection");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Configure DNS with a specific provider.
|
||||
pub async fn configure(data_dir: &Path, provider: DnsProvider, custom_servers: Vec<String>) -> Result<DnsConfig> {
|
||||
let (servers, doh_url) = if provider == DnsProvider::Custom {
|
||||
(custom_servers, None)
|
||||
} else {
|
||||
let (preset_servers, preset_doh) = provider_servers(&provider);
|
||||
(preset_servers, preset_doh)
|
||||
};
|
||||
|
||||
let doh_enabled = doh_url.is_some();
|
||||
|
||||
let config = DnsConfig {
|
||||
provider,
|
||||
servers,
|
||||
doh_enabled,
|
||||
doh_url,
|
||||
};
|
||||
|
||||
// Apply to system
|
||||
apply_dns(&config).await?;
|
||||
|
||||
// Persist config
|
||||
save_config(data_dir, &config).await?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn test_default_config() {
|
||||
let config = DnsConfig::default();
|
||||
assert_eq!(config.provider, DnsProvider::System);
|
||||
assert!(config.servers.is_empty());
|
||||
assert!(!config.doh_enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_servers() {
|
||||
let (servers, doh) = provider_servers(&DnsProvider::Cloudflare);
|
||||
assert_eq!(servers, vec!["1.1.1.1", "1.0.0.1"]);
|
||||
assert!(doh.unwrap().contains("cloudflare"));
|
||||
|
||||
let (servers, doh) = provider_servers(&DnsProvider::System);
|
||||
assert!(servers.is_empty());
|
||||
assert!(doh.is_none());
|
||||
|
||||
let (servers, doh) = provider_servers(&DnsProvider::Custom);
|
||||
assert!(servers.is_empty());
|
||||
assert!(doh.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_display() {
|
||||
assert_eq!(DnsProvider::Cloudflare.to_string(), "cloudflare");
|
||||
assert_eq!(DnsProvider::System.to_string(), "system");
|
||||
assert_eq!(DnsProvider::Quad9.to_string(), "quad9");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_config_persistence() {
|
||||
let dir = tempdir().unwrap();
|
||||
let config = DnsConfig {
|
||||
provider: DnsProvider::Cloudflare,
|
||||
servers: vec!["1.1.1.1".into(), "1.0.0.1".into()],
|
||||
doh_enabled: true,
|
||||
doh_url: Some("https://cloudflare-dns.com/dns-query".into()),
|
||||
};
|
||||
save_config(dir.path(), &config).await.unwrap();
|
||||
let loaded = load_config(dir.path()).await.unwrap();
|
||||
assert_eq!(loaded.provider, DnsProvider::Cloudflare);
|
||||
assert_eq!(loaded.servers.len(), 2);
|
||||
assert!(loaded.doh_enabled);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_missing_config_returns_default() {
|
||||
let dir = tempdir().unwrap();
|
||||
let config = load_config(dir.path()).await.unwrap();
|
||||
assert_eq!(config.provider, DnsProvider::System);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_serialization() {
|
||||
let config = DnsConfig {
|
||||
provider: DnsProvider::Google,
|
||||
servers: vec!["8.8.8.8".into()],
|
||||
doh_enabled: true,
|
||||
doh_url: Some("https://dns.google/dns-query".into()),
|
||||
};
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
let parsed: DnsConfig = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.provider, DnsProvider::Google);
|
||||
assert_eq!(parsed.servers, vec!["8.8.8.8"]);
|
||||
}
|
||||
}
|
||||
459
core/archipelago/src/network/dwn_store.rs
Normal file
459
core/archipelago/src/network/dwn_store.rs
Normal file
@ -0,0 +1,459 @@
|
||||
//! DWN message store — persists DWN messages as JSON files on disk.
|
||||
//!
|
||||
//! Implements core CRUD operations, protocol registration, and query interface
|
||||
//! for the Decentralized Web Node spec.
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::fs;
|
||||
use tracing::debug;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// A DWN message descriptor following the spec.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MessageDescriptor {
|
||||
pub interface: String,
|
||||
pub method: String,
|
||||
pub protocol: Option<String>,
|
||||
pub schema: Option<String>,
|
||||
#[serde(rename = "dateCreated")]
|
||||
pub date_created: String,
|
||||
#[serde(rename = "dateModified")]
|
||||
pub date_modified: Option<String>,
|
||||
#[serde(rename = "dataFormat")]
|
||||
pub data_format: Option<String>,
|
||||
}
|
||||
|
||||
/// A stored DWN message.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DwnMessage {
|
||||
pub record_id: String,
|
||||
pub descriptor: MessageDescriptor,
|
||||
pub author: String,
|
||||
pub data: Option<serde_json::Value>,
|
||||
#[serde(rename = "dateCreated")]
|
||||
pub date_created: String,
|
||||
}
|
||||
|
||||
/// A registered protocol definition.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProtocolDefinition {
|
||||
pub protocol: String,
|
||||
pub published: bool,
|
||||
pub types: HashMap<String, serde_json::Value>,
|
||||
pub structure: HashMap<String, serde_json::Value>,
|
||||
#[serde(rename = "dateRegistered")]
|
||||
pub date_registered: String,
|
||||
}
|
||||
|
||||
/// Query parameters for searching messages.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct MessageQuery {
|
||||
pub protocol: Option<String>,
|
||||
pub schema: Option<String>,
|
||||
pub author: Option<String>,
|
||||
pub date_from: Option<String>,
|
||||
pub date_to: Option<String>,
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
/// The DWN message store backed by the filesystem.
|
||||
pub struct DwnStore {
|
||||
messages_dir: PathBuf,
|
||||
protocols_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl DwnStore {
|
||||
/// Create a new DWN store at the given data directory.
|
||||
pub async fn new(data_dir: &Path) -> Result<Self> {
|
||||
let messages_dir = data_dir.join("dwn/messages");
|
||||
let protocols_dir = data_dir.join("dwn/protocols");
|
||||
fs::create_dir_all(&messages_dir)
|
||||
.await
|
||||
.context("Failed to create DWN messages dir")?;
|
||||
fs::create_dir_all(&protocols_dir)
|
||||
.await
|
||||
.context("Failed to create DWN protocols dir")?;
|
||||
Ok(Self {
|
||||
messages_dir,
|
||||
protocols_dir,
|
||||
})
|
||||
}
|
||||
|
||||
/// Write a new message or update an existing one.
|
||||
pub async fn write_message(
|
||||
&self,
|
||||
author: &str,
|
||||
protocol: Option<&str>,
|
||||
schema: Option<&str>,
|
||||
data_format: Option<&str>,
|
||||
data: Option<serde_json::Value>,
|
||||
) -> Result<DwnMessage> {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let record_id = Uuid::new_v4().to_string();
|
||||
|
||||
let message = DwnMessage {
|
||||
record_id: record_id.clone(),
|
||||
descriptor: MessageDescriptor {
|
||||
interface: "Records".to_string(),
|
||||
method: "Write".to_string(),
|
||||
protocol: protocol.map(|s| s.to_string()),
|
||||
schema: schema.map(|s| s.to_string()),
|
||||
date_created: now.clone(),
|
||||
date_modified: Some(now.clone()),
|
||||
data_format: data_format.map(|s| s.to_string()),
|
||||
},
|
||||
author: author.to_string(),
|
||||
data,
|
||||
date_created: now,
|
||||
};
|
||||
|
||||
let path = self.messages_dir.join(format!("{}.json", record_id));
|
||||
let content =
|
||||
serde_json::to_string_pretty(&message).context("Failed to serialize message")?;
|
||||
fs::write(&path, content)
|
||||
.await
|
||||
.context("Failed to write message file")?;
|
||||
|
||||
debug!(record_id = %message.record_id, "DWN message written");
|
||||
Ok(message)
|
||||
}
|
||||
|
||||
/// Read a message by record ID.
|
||||
pub async fn read_message(&self, record_id: &str) -> Result<Option<DwnMessage>> {
|
||||
let path = self.messages_dir.join(format!("{}.json", record_id));
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let content = fs::read_to_string(&path)
|
||||
.await
|
||||
.context("Failed to read message file")?;
|
||||
let message: DwnMessage =
|
||||
serde_json::from_str(&content).context("Failed to parse message")?;
|
||||
Ok(Some(message))
|
||||
}
|
||||
|
||||
/// Delete a message by record ID.
|
||||
pub async fn delete_message(&self, record_id: &str) -> Result<bool> {
|
||||
let path = self.messages_dir.join(format!("{}.json", record_id));
|
||||
if !path.exists() {
|
||||
return Ok(false);
|
||||
}
|
||||
fs::remove_file(&path)
|
||||
.await
|
||||
.context("Failed to delete message file")?;
|
||||
debug!(record_id = %record_id, "DWN message deleted");
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Query messages by various criteria.
|
||||
pub async fn query_messages(&self, query: &MessageQuery) -> Result<Vec<DwnMessage>> {
|
||||
let mut results = Vec::new();
|
||||
let mut entries = fs::read_dir(&self.messages_dir)
|
||||
.await
|
||||
.context("Failed to read messages dir")?;
|
||||
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let content = match fs::read_to_string(&path).await {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let message: DwnMessage = match serde_json::from_str(&content) {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if let Some(ref proto) = query.protocol {
|
||||
if message.descriptor.protocol.as_deref() != Some(proto) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Some(ref schema) = query.schema {
|
||||
if message.descriptor.schema.as_deref() != Some(schema) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Some(ref author) = query.author {
|
||||
if &message.author != author {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Some(ref from) = query.date_from {
|
||||
if message.date_created < *from {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Some(ref to) = query.date_to {
|
||||
if message.date_created > *to {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
results.push(message);
|
||||
}
|
||||
|
||||
// Sort by date descending (newest first)
|
||||
results.sort_by(|a, b| b.date_created.cmp(&a.date_created));
|
||||
|
||||
if let Some(limit) = query.limit {
|
||||
results.truncate(limit);
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Register a protocol definition.
|
||||
pub async fn register_protocol(&self, definition: &ProtocolDefinition) -> Result<()> {
|
||||
if definition.protocol.is_empty() {
|
||||
bail!("Protocol URI cannot be empty");
|
||||
}
|
||||
let safe_name = definition.protocol.replace(['/', ':', '.'], "_");
|
||||
let path = self.protocols_dir.join(format!("{}.json", safe_name));
|
||||
let content =
|
||||
serde_json::to_string_pretty(definition).context("Failed to serialize protocol")?;
|
||||
fs::write(&path, content)
|
||||
.await
|
||||
.context("Failed to write protocol file")?;
|
||||
debug!(protocol = %definition.protocol, "Protocol registered");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all registered protocols.
|
||||
pub async fn list_protocols(&self) -> Result<Vec<ProtocolDefinition>> {
|
||||
let mut protocols = Vec::new();
|
||||
let mut entries = fs::read_dir(&self.protocols_dir)
|
||||
.await
|
||||
.context("Failed to read protocols dir")?;
|
||||
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
let content = match fs::read_to_string(&path).await {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if let Ok(proto) = serde_json::from_str::<ProtocolDefinition>(&content) {
|
||||
protocols.push(proto);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(protocols)
|
||||
}
|
||||
|
||||
/// Remove a registered protocol.
|
||||
pub async fn remove_protocol(&self, protocol_uri: &str) -> Result<bool> {
|
||||
let safe_name = protocol_uri.replace(['/', ':', '.'], "_");
|
||||
let path = self.protocols_dir.join(format!("{}.json", safe_name));
|
||||
if !path.exists() {
|
||||
return Ok(false);
|
||||
}
|
||||
fs::remove_file(&path)
|
||||
.await
|
||||
.context("Failed to remove protocol file")?;
|
||||
debug!(protocol = %protocol_uri, "Protocol removed");
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Get storage statistics.
|
||||
pub async fn stats(&self) -> Result<StoreStats> {
|
||||
let mut message_count: u64 = 0;
|
||||
let mut total_bytes: u64 = 0;
|
||||
|
||||
let mut entries = fs::read_dir(&self.messages_dir)
|
||||
.await
|
||||
.context("Failed to read messages dir")?;
|
||||
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
if entry
|
||||
.path()
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
== Some("json")
|
||||
{
|
||||
message_count += 1;
|
||||
if let Ok(meta) = entry.metadata().await {
|
||||
total_bytes += meta.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let protocol_count = self.list_protocols().await?.len() as u64;
|
||||
|
||||
Ok(StoreStats {
|
||||
message_count,
|
||||
protocol_count,
|
||||
total_bytes,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Storage statistics.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct StoreStats {
|
||||
pub message_count: u64,
|
||||
pub protocol_count: u64,
|
||||
pub total_bytes: u64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
async fn setup() -> (TempDir, DwnStore) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let store = DwnStore::new(dir.path()).await.unwrap();
|
||||
(dir, store)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_and_read_message() {
|
||||
let (_dir, store) = setup().await;
|
||||
let msg = store
|
||||
.write_message("did:key:test", Some("proto://chat"), None, None, Some(serde_json::json!({"text": "hello"})))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!msg.record_id.is_empty());
|
||||
|
||||
let read = store.read_message(&msg.record_id).await.unwrap();
|
||||
assert!(read.is_some());
|
||||
let read = read.unwrap();
|
||||
assert_eq!(read.author, "did:key:test");
|
||||
assert_eq!(read.data, Some(serde_json::json!({"text": "hello"})));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_nonexistent_returns_none() {
|
||||
let (_dir, store) = setup().await;
|
||||
let read = store.read_message("nonexistent-id").await.unwrap();
|
||||
assert!(read.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_message() {
|
||||
let (_dir, store) = setup().await;
|
||||
let msg = store
|
||||
.write_message("did:key:test", None, None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(store.delete_message(&msg.record_id).await.unwrap());
|
||||
assert!(!store.delete_message(&msg.record_id).await.unwrap());
|
||||
assert!(store.read_message(&msg.record_id).await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn query_by_protocol() {
|
||||
let (_dir, store) = setup().await;
|
||||
store
|
||||
.write_message("did:key:a", Some("proto://chat"), None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.write_message("did:key:a", Some("proto://files"), None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.write_message("did:key:b", Some("proto://chat"), None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let results = store
|
||||
.query_messages(&MessageQuery {
|
||||
protocol: Some("proto://chat".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(results.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn query_by_author() {
|
||||
let (_dir, store) = setup().await;
|
||||
store.write_message("did:key:a", None, None, None, None).await.unwrap();
|
||||
store.write_message("did:key:b", None, None, None, None).await.unwrap();
|
||||
|
||||
let results = store
|
||||
.query_messages(&MessageQuery {
|
||||
author: Some("did:key:a".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].author, "did:key:a");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn query_with_limit() {
|
||||
let (_dir, store) = setup().await;
|
||||
for i in 0..5 {
|
||||
store
|
||||
.write_message(&format!("did:key:{}", i), None, None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let results = store
|
||||
.query_messages(&MessageQuery {
|
||||
limit: Some(3),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(results.len(), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_and_list_protocols() {
|
||||
let (_dir, store) = setup().await;
|
||||
let proto = ProtocolDefinition {
|
||||
protocol: "https://example.com/chat".to_string(),
|
||||
published: true,
|
||||
types: HashMap::new(),
|
||||
structure: HashMap::new(),
|
||||
date_registered: chrono::Utc::now().to_rfc3339(),
|
||||
};
|
||||
store.register_protocol(&proto).await.unwrap();
|
||||
|
||||
let list = store.list_protocols().await.unwrap();
|
||||
assert_eq!(list.len(), 1);
|
||||
assert_eq!(list[0].protocol, "https://example.com/chat");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remove_protocol() {
|
||||
let (_dir, store) = setup().await;
|
||||
let proto = ProtocolDefinition {
|
||||
protocol: "https://example.com/test".to_string(),
|
||||
published: false,
|
||||
types: HashMap::new(),
|
||||
structure: HashMap::new(),
|
||||
date_registered: chrono::Utc::now().to_rfc3339(),
|
||||
};
|
||||
store.register_protocol(&proto).await.unwrap();
|
||||
assert!(store.remove_protocol("https://example.com/test").await.unwrap());
|
||||
assert!(!store.remove_protocol("https://example.com/test").await.unwrap());
|
||||
assert!(store.list_protocols().await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn store_stats() {
|
||||
let (_dir, store) = setup().await;
|
||||
store.write_message("did:key:a", None, None, None, None).await.unwrap();
|
||||
store.write_message("did:key:b", None, None, None, None).await.unwrap();
|
||||
|
||||
let stats = store.stats().await.unwrap();
|
||||
assert_eq!(stats.message_count, 2);
|
||||
assert!(stats.total_bytes > 0);
|
||||
}
|
||||
}
|
||||
@ -98,9 +98,11 @@ pub struct DwnStatusResponse {
|
||||
}
|
||||
|
||||
/// Trigger a sync with connected peers.
|
||||
/// For each peer that has a DWN endpoint, we query their DWN
|
||||
/// and replicate relevant messages.
|
||||
/// For each peer that has a DWN endpoint, we pull their messages
|
||||
/// and push our local messages, deduplicating by record_id.
|
||||
pub async fn sync_with_peers(data_dir: &Path, peer_onions: &[String]) -> Result<DwnSyncState> {
|
||||
use crate::network::dwn_store::{DwnStore, MessageQuery};
|
||||
|
||||
let mut state = load_sync_state(data_dir).await?;
|
||||
state.status = SyncStatus::Syncing;
|
||||
save_sync_state(data_dir, &state).await?;
|
||||
@ -114,21 +116,25 @@ pub async fn sync_with_peers(data_dir: &Path, peer_onions: &[String]) -> Result<
|
||||
.build()
|
||||
.context("Failed to build Tor HTTP client")?;
|
||||
|
||||
let store = DwnStore::new(data_dir).await?;
|
||||
let mut synced_count = 0u64;
|
||||
|
||||
// Get local messages since last sync (or all if first sync)
|
||||
let local_messages = store
|
||||
.query_messages(&MessageQuery {
|
||||
date_from: state.last_sync.clone(),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
|
||||
for onion in peer_onions {
|
||||
// Try to reach the peer's DWN endpoint
|
||||
let url = format!("http://{}:3100/health", onion);
|
||||
match client.get(&url).send().await {
|
||||
Ok(res) if res.status().is_success() => {
|
||||
debug!("Peer {} has DWN running, syncing...", onion);
|
||||
synced_count += 1;
|
||||
}
|
||||
Ok(_) => {
|
||||
debug!("Peer {} DWN not available", onion);
|
||||
match sync_single_peer(&client, &store, onion, &local_messages, &state.last_sync).await {
|
||||
Ok(count) => {
|
||||
debug!(peer = %onion, messages = count, "Peer sync complete");
|
||||
synced_count += count;
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("Could not reach peer {} DWN: {}", onion, e);
|
||||
debug!(peer = %onion, error = %e, "Peer sync failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -138,10 +144,108 @@ pub async fn sync_with_peers(data_dir: &Path, peer_onions: &[String]) -> Result<
|
||||
state.messages_synced += synced_count;
|
||||
save_sync_state(data_dir, &state).await?;
|
||||
|
||||
debug!("DWN sync complete: {} peers synced", synced_count);
|
||||
debug!(count = synced_count, "DWN sync complete");
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
/// Sync with a single peer: pull their messages and push ours.
|
||||
async fn sync_single_peer(
|
||||
client: &reqwest::Client,
|
||||
store: &crate::network::dwn_store::DwnStore,
|
||||
onion: &str,
|
||||
local_messages: &[crate::network::dwn_store::DwnMessage],
|
||||
last_sync: &Option<String>,
|
||||
) -> Result<u64> {
|
||||
let base_url = format!("http://{}:5678", onion);
|
||||
let mut imported = 0u64;
|
||||
|
||||
// Step 1: Check peer health
|
||||
let health_url = format!("{}/dwn/health", base_url);
|
||||
let res = client
|
||||
.get(&health_url)
|
||||
.send()
|
||||
.await
|
||||
.context("Peer DWN unreachable")?;
|
||||
if !res.status().is_success() {
|
||||
return Err(anyhow::anyhow!("Peer DWN not healthy"));
|
||||
}
|
||||
|
||||
// Step 2: Pull — query peer for messages since our last sync
|
||||
let dwn_url = format!("{}/dwn", base_url);
|
||||
let mut query_filter = serde_json::json!({});
|
||||
if let Some(ref since) = last_sync {
|
||||
query_filter = serde_json::json!({ "dateSort": "createdAscending", "dateFrom": since });
|
||||
}
|
||||
let pull_body = serde_json::json!({
|
||||
"messages": [{
|
||||
"descriptor": {
|
||||
"interface": "Records",
|
||||
"method": "Query",
|
||||
"filter": query_filter,
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
||||
let pull_res = client
|
||||
.post(&dwn_url)
|
||||
.json(&pull_body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to query peer DWN")?;
|
||||
|
||||
if pull_res.status().is_success() {
|
||||
let pull_data: serde_json::Value = pull_res.json().await.unwrap_or_default();
|
||||
if let Some(entries) = pull_data["entries"].as_array() {
|
||||
for entry in entries {
|
||||
let record_id = entry["record_id"].as_str().unwrap_or_default();
|
||||
if record_id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Skip if we already have this message
|
||||
if store.read_message(record_id).await?.is_some() {
|
||||
continue;
|
||||
}
|
||||
// Import the message
|
||||
let author = entry["author"].as_str().unwrap_or("unknown");
|
||||
let protocol = entry["descriptor"]["protocol"].as_str();
|
||||
let schema = entry["descriptor"]["schema"].as_str();
|
||||
let data_format = entry["descriptor"]["dataFormat"].as_str();
|
||||
let data = entry.get("data").cloned();
|
||||
|
||||
store
|
||||
.write_message(author, protocol, schema, data_format, data)
|
||||
.await?;
|
||||
imported += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Push — send our local messages to the peer
|
||||
for msg in local_messages {
|
||||
let push_body = serde_json::json!({
|
||||
"messages": [{
|
||||
"descriptor": {
|
||||
"interface": "Records",
|
||||
"method": "Write",
|
||||
"protocol": msg.descriptor.protocol,
|
||||
"schema": msg.descriptor.schema,
|
||||
"dataFormat": msg.descriptor.data_format,
|
||||
},
|
||||
"recordId": msg.record_id,
|
||||
"author": msg.author,
|
||||
"data": msg.data,
|
||||
}]
|
||||
});
|
||||
|
||||
// Best-effort push — don't fail the whole sync if one push fails
|
||||
if let Err(e) = client.post(&dwn_url).json(&push_body).send().await {
|
||||
debug!(record_id = %msg.record_id, error = %e, "Failed to push message to peer");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(imported)
|
||||
}
|
||||
|
||||
/// Add a peer as a sync target.
|
||||
pub async fn add_sync_target(data_dir: &Path, onion: &str) -> Result<()> {
|
||||
let mut state = load_sync_state(data_dir).await?;
|
||||
|
||||
@ -1,2 +1,4 @@
|
||||
pub mod dns;
|
||||
pub mod dwn_store;
|
||||
pub mod dwn_sync;
|
||||
pub mod router;
|
||||
|
||||
@ -5,7 +5,7 @@ use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use tokio::fs;
|
||||
use tracing::{debug, info, warn};
|
||||
use tracing::debug;
|
||||
|
||||
const FORWARDS_FILE: &str = "port_forwards.json";
|
||||
|
||||
|
||||
386
core/archipelago/src/nostr_handshake.rs
Normal file
386
core/archipelago/src/nostr_handshake.rs
Normal file
@ -0,0 +1,386 @@
|
||||
//! Encrypted peer handshake via Nostr NIP-44.
|
||||
//!
|
||||
//! Instead of publishing onion addresses publicly on relays, nodes exchange
|
||||
//! them privately via NIP-44 encrypted DMs:
|
||||
//!
|
||||
//! 1. Node publishes presence-only event (DID + Nostr pubkey, NO onion address)
|
||||
//! 2. To connect, Node A sends NIP-44 encrypted DM to Node B's Nostr pubkey
|
||||
//! containing A's onion address + Ed25519 node pubkey
|
||||
//! 3. Node B auto-responds with its own onion address + pubkey
|
||||
//! 4. Both nodes add each other as known peers
|
||||
//!
|
||||
//! Uses NIP-44 (ChaCha20-Poly1305) for encryption, kind 4 for DMs.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::SocketAddr;
|
||||
use std::path::Path;
|
||||
use tokio::fs;
|
||||
|
||||
const NOSTR_SECRET_FILE: &str = "nostr_secret";
|
||||
|
||||
/// Message types for the encrypted handshake protocol
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum HandshakeMessage {
|
||||
#[serde(rename = "connect-request")]
|
||||
ConnectRequest {
|
||||
onion: String,
|
||||
node_pubkey: String,
|
||||
did: String,
|
||||
version: String,
|
||||
name: Option<String>,
|
||||
},
|
||||
#[serde(rename = "connect-response")]
|
||||
ConnectResponse {
|
||||
onion: String,
|
||||
node_pubkey: String,
|
||||
did: String,
|
||||
version: String,
|
||||
name: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Result of polling for incoming handshake messages
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct IncomingHandshake {
|
||||
pub from_nostr_pubkey: String,
|
||||
pub message: HandshakeMessage,
|
||||
pub timestamp: String,
|
||||
}
|
||||
|
||||
/// Parse "host:port" to SocketAddr.
|
||||
fn parse_proxy_addr(s: &str) -> Option<SocketAddr> {
|
||||
s.trim().parse().ok()
|
||||
}
|
||||
|
||||
/// Load existing Nostr keys (secp256k1). Returns None if no keys exist.
|
||||
async fn load_nostr_keys(identity_dir: &Path) -> Result<Option<Keys>> {
|
||||
let secret_path = identity_dir.join(NOSTR_SECRET_FILE);
|
||||
if !secret_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let hex_secret = fs::read_to_string(&secret_path)
|
||||
.await
|
||||
.context("Failed to read Nostr secret")?;
|
||||
let keys = Keys::parse(hex_secret.trim()).context("Invalid Nostr secret")?;
|
||||
Ok(Some(keys))
|
||||
}
|
||||
|
||||
/// Build a Nostr client with optional Tor proxy.
|
||||
fn build_client(keys: Keys, tor_proxy: Option<&str>) -> Result<Client> {
|
||||
let client = if let Some(proxy_str) = tor_proxy {
|
||||
let addr = parse_proxy_addr(proxy_str)
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid Nostr Tor proxy: {}", proxy_str))?;
|
||||
let connection = Connection::new()
|
||||
.proxy(addr)
|
||||
.target(ConnectionTarget::All);
|
||||
let opts = ClientOptions::new().connection(connection);
|
||||
Client::builder().signer(keys).opts(opts).build()
|
||||
} else {
|
||||
Client::new(keys)
|
||||
};
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
/// Publish a presence-only event to Nostr relays.
|
||||
/// Content: { did, nostr_pubkey, version } — NO onion address.
|
||||
/// Uses NIP-33 replaceable events (kind 30078) with d-tag "archipelago-node".
|
||||
pub async fn publish_presence(
|
||||
identity_dir: &Path,
|
||||
did: &str,
|
||||
version: &str,
|
||||
relays: &[String],
|
||||
tor_proxy: Option<&str>,
|
||||
) -> Result<()> {
|
||||
if relays.is_empty() {
|
||||
anyhow::bail!("No relays configured");
|
||||
}
|
||||
|
||||
let keys = load_nostr_keys(identity_dir)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("No Nostr keys — generate them first"))?;
|
||||
|
||||
let nostr_pubkey = keys.public_key().to_hex();
|
||||
let client = build_client(keys, tor_proxy)?;
|
||||
|
||||
let content = serde_json::json!({
|
||||
"did": did,
|
||||
"nostr_pubkey": nostr_pubkey,
|
||||
"version": version,
|
||||
// No onion address — exchanged only via encrypted DM
|
||||
})
|
||||
.to_string();
|
||||
|
||||
for url in relays {
|
||||
let _ = client.add_relay(url).await;
|
||||
}
|
||||
client.connect().await;
|
||||
|
||||
let builder = EventBuilder::new(Kind::Custom(30078), content)
|
||||
.tag(Tag::identifier("archipelago-node"));
|
||||
let _ = client.send_event_builder(builder).await;
|
||||
client.disconnect().await;
|
||||
|
||||
tracing::info!("📡 Published presence (no onion) to {} relays", relays.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Discover other Archipelago nodes (presence-only — no onion addresses).
|
||||
/// Returns Nostr pubkeys and DIDs of discoverable nodes.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DiscoverableNode {
|
||||
pub nostr_pubkey: String,
|
||||
pub did: String,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
pub async fn discover_nodes(
|
||||
identity_dir: &Path,
|
||||
relays: &[String],
|
||||
tor_proxy: Option<&str>,
|
||||
) -> Result<Vec<DiscoverableNode>> {
|
||||
if relays.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let anon_keys = Keys::generate();
|
||||
let client = build_client(anon_keys, tor_proxy)?;
|
||||
for url in relays {
|
||||
let _ = client.add_relay(url).await;
|
||||
}
|
||||
client.connect().await;
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(30078))
|
||||
.identifier("archipelago-node")
|
||||
.limit(50);
|
||||
let events = client
|
||||
.fetch_events(filter, std::time::Duration::from_secs(15))
|
||||
.await
|
||||
.map(|e| e.to_vec())
|
||||
.unwrap_or_default();
|
||||
client.disconnect().await;
|
||||
|
||||
let mut nodes = Vec::new();
|
||||
for event in events {
|
||||
if let Ok(content) = serde_json::from_str::<serde_json::Value>(&event.content) {
|
||||
let nostr_pubkey = content
|
||||
.get("nostr_pubkey")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let did = content
|
||||
.get("did")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let version = content
|
||||
.get("version")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("0.1")
|
||||
.to_string();
|
||||
|
||||
// Skip entries that still have node_address (legacy public format)
|
||||
if content.get("node_address").is_some() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !nostr_pubkey.is_empty() {
|
||||
nodes.push(DiscoverableNode {
|
||||
nostr_pubkey,
|
||||
did,
|
||||
version,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(nodes)
|
||||
}
|
||||
|
||||
/// Send an encrypted connection request to a peer's Nostr pubkey.
|
||||
/// Uses NIP-44 encrypted DM (kind 4) containing our onion address.
|
||||
pub async fn send_connect_request(
|
||||
identity_dir: &Path,
|
||||
recipient_nostr_pubkey: &str,
|
||||
our_onion: &str,
|
||||
our_node_pubkey: &str,
|
||||
our_did: &str,
|
||||
our_version: &str,
|
||||
our_name: Option<&str>,
|
||||
relays: &[String],
|
||||
tor_proxy: Option<&str>,
|
||||
) -> Result<()> {
|
||||
if relays.is_empty() {
|
||||
anyhow::bail!("No relays configured");
|
||||
}
|
||||
|
||||
let keys = load_nostr_keys(identity_dir)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("No Nostr keys"))?;
|
||||
|
||||
let recipient_pk = PublicKey::from_hex(recipient_nostr_pubkey)
|
||||
.context("Invalid recipient Nostr pubkey")?;
|
||||
|
||||
let msg = HandshakeMessage::ConnectRequest {
|
||||
onion: our_onion.to_string(),
|
||||
node_pubkey: our_node_pubkey.to_string(),
|
||||
did: our_did.to_string(),
|
||||
version: our_version.to_string(),
|
||||
name: our_name.map(String::from),
|
||||
};
|
||||
let plaintext = serde_json::to_string(&msg).context("Failed to serialize handshake")?;
|
||||
|
||||
// NIP-44 encrypt
|
||||
let encrypted = nip44::encrypt(
|
||||
keys.secret_key(),
|
||||
&recipient_pk,
|
||||
&plaintext,
|
||||
nip44::Version::V2,
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("NIP-44 encrypt failed: {}", e))?;
|
||||
|
||||
let client = build_client(keys, tor_proxy)?;
|
||||
for url in relays {
|
||||
let _ = client.add_relay(url).await;
|
||||
}
|
||||
client.connect().await;
|
||||
|
||||
// Kind 4 encrypted DM with p-tag for recipient
|
||||
let builder = EventBuilder::new(Kind::EncryptedDirectMessage, encrypted)
|
||||
.tag(Tag::public_key(recipient_pk));
|
||||
let _ = client.send_event_builder(builder).await;
|
||||
client.disconnect().await;
|
||||
|
||||
tracing::info!(
|
||||
"🤝 Sent encrypted connect request to {}...{}",
|
||||
&recipient_nostr_pubkey[..8.min(recipient_nostr_pubkey.len())],
|
||||
&recipient_nostr_pubkey[recipient_nostr_pubkey.len().saturating_sub(4)..]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send an encrypted connection response to a peer.
|
||||
pub async fn send_connect_response(
|
||||
identity_dir: &Path,
|
||||
recipient_nostr_pubkey: &str,
|
||||
our_onion: &str,
|
||||
our_node_pubkey: &str,
|
||||
our_did: &str,
|
||||
our_version: &str,
|
||||
our_name: Option<&str>,
|
||||
relays: &[String],
|
||||
tor_proxy: Option<&str>,
|
||||
) -> Result<()> {
|
||||
if relays.is_empty() {
|
||||
anyhow::bail!("No relays configured");
|
||||
}
|
||||
|
||||
let keys = load_nostr_keys(identity_dir)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("No Nostr keys"))?;
|
||||
|
||||
let recipient_pk = PublicKey::from_hex(recipient_nostr_pubkey)
|
||||
.context("Invalid recipient Nostr pubkey")?;
|
||||
|
||||
let msg = HandshakeMessage::ConnectResponse {
|
||||
onion: our_onion.to_string(),
|
||||
node_pubkey: our_node_pubkey.to_string(),
|
||||
did: our_did.to_string(),
|
||||
version: our_version.to_string(),
|
||||
name: our_name.map(String::from),
|
||||
};
|
||||
let plaintext = serde_json::to_string(&msg).context("Failed to serialize handshake")?;
|
||||
|
||||
let encrypted = nip44::encrypt(
|
||||
keys.secret_key(),
|
||||
&recipient_pk,
|
||||
&plaintext,
|
||||
nip44::Version::V2,
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("NIP-44 encrypt failed: {}", e))?;
|
||||
|
||||
let client = build_client(keys, tor_proxy)?;
|
||||
for url in relays {
|
||||
let _ = client.add_relay(url).await;
|
||||
}
|
||||
client.connect().await;
|
||||
|
||||
let builder = EventBuilder::new(Kind::EncryptedDirectMessage, encrypted)
|
||||
.tag(Tag::public_key(recipient_pk));
|
||||
let _ = client.send_event_builder(builder).await;
|
||||
client.disconnect().await;
|
||||
|
||||
tracing::info!(
|
||||
"🤝 Sent encrypted connect response to {}...{}",
|
||||
&recipient_nostr_pubkey[..8.min(recipient_nostr_pubkey.len())],
|
||||
&recipient_nostr_pubkey[recipient_nostr_pubkey.len().saturating_sub(4)..]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Poll relays for incoming encrypted handshake DMs addressed to us.
|
||||
/// Returns new handshake messages since `since` timestamp.
|
||||
pub async fn poll_handshakes(
|
||||
identity_dir: &Path,
|
||||
relays: &[String],
|
||||
tor_proxy: Option<&str>,
|
||||
since: Option<Timestamp>,
|
||||
) -> Result<Vec<IncomingHandshake>> {
|
||||
if relays.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let keys = load_nostr_keys(identity_dir)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("No Nostr keys"))?;
|
||||
|
||||
let our_pk = keys.public_key();
|
||||
let client = build_client(keys.clone(), tor_proxy)?;
|
||||
for url in relays {
|
||||
let _ = client.add_relay(url).await;
|
||||
}
|
||||
client.connect().await;
|
||||
|
||||
// Query for encrypted DMs addressed to us
|
||||
let mut filter = Filter::new()
|
||||
.kind(Kind::EncryptedDirectMessage)
|
||||
.pubkey(our_pk)
|
||||
.limit(50);
|
||||
if let Some(ts) = since {
|
||||
filter = filter.since(ts);
|
||||
}
|
||||
|
||||
let events = client
|
||||
.fetch_events(filter, std::time::Duration::from_secs(15))
|
||||
.await
|
||||
.map(|e| e.to_vec())
|
||||
.unwrap_or_default();
|
||||
client.disconnect().await;
|
||||
|
||||
let mut handshakes = Vec::new();
|
||||
for event in events {
|
||||
// Skip our own events
|
||||
if event.pubkey == our_pk {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try NIP-44 decryption
|
||||
let plaintext = match nip44::decrypt(keys.secret_key(), &event.pubkey, &event.content) {
|
||||
Ok(pt) => pt,
|
||||
Err(_) => continue, // Not a handshake message or wrong key
|
||||
};
|
||||
|
||||
// Try to parse as HandshakeMessage
|
||||
if let Ok(msg) = serde_json::from_str::<HandshakeMessage>(&plaintext) {
|
||||
handshakes.push(IncomingHandshake {
|
||||
from_nostr_pubkey: event.pubkey.to_hex(),
|
||||
message: msg,
|
||||
timestamp: event.created_at.to_human_datetime(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(handshakes)
|
||||
}
|
||||
@ -170,3 +170,257 @@ fn normalize_relay_url(url: &str) -> Result<String> {
|
||||
Ok(format!("wss://{}", trimmed))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_normalize_relay_url_with_wss() {
|
||||
let result = normalize_relay_url("wss://relay.damus.io").unwrap();
|
||||
assert_eq!(result, "wss://relay.damus.io");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_relay_url_with_ws() {
|
||||
let result = normalize_relay_url("ws://relay.example.com").unwrap();
|
||||
assert_eq!(result, "ws://relay.example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_relay_url_without_scheme() {
|
||||
let result = normalize_relay_url("relay.example.com").unwrap();
|
||||
assert_eq!(result, "wss://relay.example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_relay_url_trims_whitespace() {
|
||||
let result = normalize_relay_url(" wss://relay.example.com ").unwrap();
|
||||
assert_eq!(result, "wss://relay.example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_relay_url_empty_errors() {
|
||||
let result = normalize_relay_url("");
|
||||
assert!(result.is_err());
|
||||
|
||||
let result = normalize_relay_url(" ");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_defaults_has_expected_count() {
|
||||
let store = seed_defaults();
|
||||
assert_eq!(store.relays.len(), DEFAULT_RELAYS.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_defaults_all_enabled() {
|
||||
let store = seed_defaults();
|
||||
assert!(store.relays.iter().all(|r| r.enabled));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_defaults_urls_match() {
|
||||
let store = seed_defaults();
|
||||
let urls: Vec<&str> = store.relays.iter().map(|r| r.url.as_str()).collect();
|
||||
for expected in DEFAULT_RELAYS {
|
||||
assert!(urls.contains(expected), "Missing default relay: {}", expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_defaults_all_have_timestamps() {
|
||||
let store = seed_defaults();
|
||||
for relay in &store.relays {
|
||||
assert!(!relay.added_at.is_empty());
|
||||
// Should be a valid RFC3339 timestamp
|
||||
assert!(chrono::DateTime::parse_from_rfc3339(&relay.added_at).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_relays_seeds_defaults_on_first_load() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let store = load_relays(tmp.path()).await.unwrap();
|
||||
assert_eq!(store.relays.len(), DEFAULT_RELAYS.len());
|
||||
|
||||
// The file should now exist after seeding
|
||||
assert!(tmp.path().join(RELAYS_FILE).exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_save_and_load_relays_roundtrip() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let store = RelayStore {
|
||||
relays: vec![
|
||||
RelayConfig {
|
||||
url: "wss://test.relay.one".to_string(),
|
||||
enabled: true,
|
||||
added_at: "2025-01-01T00:00:00Z".to_string(),
|
||||
},
|
||||
RelayConfig {
|
||||
url: "wss://test.relay.two".to_string(),
|
||||
enabled: false,
|
||||
added_at: "2025-01-02T00:00:00Z".to_string(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
save_relays(tmp.path(), &store).await.unwrap();
|
||||
let loaded = load_relays(tmp.path()).await.unwrap();
|
||||
assert_eq!(loaded.relays.len(), 2);
|
||||
assert_eq!(loaded.relays[0].url, "wss://test.relay.one");
|
||||
assert!(loaded.relays[0].enabled);
|
||||
assert_eq!(loaded.relays[1].url, "wss://test.relay.two");
|
||||
assert!(!loaded.relays[1].enabled);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_add_relay() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
// Seed defaults first
|
||||
let _ = load_relays(tmp.path()).await.unwrap();
|
||||
|
||||
let config = add_relay(tmp.path(), "wss://new.relay.test").await.unwrap();
|
||||
assert_eq!(config.url, "wss://new.relay.test");
|
||||
assert!(config.enabled);
|
||||
|
||||
let store = load_relays(tmp.path()).await.unwrap();
|
||||
assert_eq!(store.relays.len(), DEFAULT_RELAYS.len() + 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_add_relay_normalizes_url() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let _ = load_relays(tmp.path()).await.unwrap();
|
||||
|
||||
let config = add_relay(tmp.path(), "bare.relay.test").await.unwrap();
|
||||
assert_eq!(config.url, "wss://bare.relay.test");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_add_duplicate_relay_errors() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let _ = load_relays(tmp.path()).await.unwrap();
|
||||
|
||||
// Adding a default relay again should fail
|
||||
let result = add_relay(tmp.path(), "wss://relay.damus.io").await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remove_relay() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let _ = load_relays(tmp.path()).await.unwrap();
|
||||
let initial_count = DEFAULT_RELAYS.len();
|
||||
|
||||
remove_relay(tmp.path(), "wss://relay.damus.io").await.unwrap();
|
||||
let store = load_relays(tmp.path()).await.unwrap();
|
||||
assert_eq!(store.relays.len(), initial_count - 1);
|
||||
assert!(!store.relays.iter().any(|r| r.url == "wss://relay.damus.io"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remove_nonexistent_relay_errors() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let _ = load_relays(tmp.path()).await.unwrap();
|
||||
|
||||
let result = remove_relay(tmp.path(), "wss://nonexistent.relay").await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_toggle_relay_disable() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let _ = load_relays(tmp.path()).await.unwrap();
|
||||
|
||||
toggle_relay(tmp.path(), "wss://relay.damus.io", false).await.unwrap();
|
||||
|
||||
let store = load_relays(tmp.path()).await.unwrap();
|
||||
let relay = store.relays.iter().find(|r| r.url == "wss://relay.damus.io").unwrap();
|
||||
assert!(!relay.enabled);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_toggle_relay_enable() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let _ = load_relays(tmp.path()).await.unwrap();
|
||||
|
||||
// Disable first, then re-enable
|
||||
toggle_relay(tmp.path(), "wss://relay.damus.io", false).await.unwrap();
|
||||
toggle_relay(tmp.path(), "wss://relay.damus.io", true).await.unwrap();
|
||||
|
||||
let store = load_relays(tmp.path()).await.unwrap();
|
||||
let relay = store.relays.iter().find(|r| r.url == "wss://relay.damus.io").unwrap();
|
||||
assert!(relay.enabled);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_toggle_nonexistent_relay_errors() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let _ = load_relays(tmp.path()).await.unwrap();
|
||||
|
||||
let result = toggle_relay(tmp.path(), "wss://no.such.relay", true).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_stats() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let _ = load_relays(tmp.path()).await.unwrap();
|
||||
|
||||
let stats = get_stats(tmp.path()).await.unwrap();
|
||||
assert_eq!(stats.total_relays, DEFAULT_RELAYS.len());
|
||||
assert_eq!(stats.enabled_count, DEFAULT_RELAYS.len());
|
||||
assert_eq!(stats.connected_count, stats.enabled_count);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_stats_after_disable() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let _ = load_relays(tmp.path()).await.unwrap();
|
||||
|
||||
toggle_relay(tmp.path(), "wss://relay.damus.io", false).await.unwrap();
|
||||
toggle_relay(tmp.path(), "wss://nos.lol", false).await.unwrap();
|
||||
|
||||
let stats = get_stats(tmp.path()).await.unwrap();
|
||||
assert_eq!(stats.total_relays, DEFAULT_RELAYS.len());
|
||||
assert_eq!(stats.enabled_count, DEFAULT_RELAYS.len() - 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_relays() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let statuses = list_relays(tmp.path()).await.unwrap();
|
||||
assert_eq!(statuses.len(), DEFAULT_RELAYS.len());
|
||||
|
||||
// All default relays should be enabled and "connected"
|
||||
for status in &statuses {
|
||||
assert!(status.enabled);
|
||||
assert!(status.connected);
|
||||
assert!(status.url.starts_with("wss://"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_relay_store_default_is_empty() {
|
||||
let store = RelayStore::default();
|
||||
assert!(store.relays.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_relay_config_serialization() {
|
||||
let config = RelayConfig {
|
||||
url: "wss://test.relay".to_string(),
|
||||
enabled: true,
|
||||
added_at: "2025-01-01T00:00:00Z".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
let parsed: RelayConfig = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.url, config.url);
|
||||
assert_eq!(parsed.enabled, config.enabled);
|
||||
assert_eq!(parsed.added_at, config.added_at);
|
||||
}
|
||||
}
|
||||
|
||||
@ -61,3 +61,119 @@ pub async fn remove_peer(data_dir: &Path, pubkey: &str) -> Result<Vec<KnownPeer>
|
||||
save_peers(data_dir, &peers).await?;
|
||||
Ok(peers)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_peer(pubkey: &str, onion: &str) -> KnownPeer {
|
||||
KnownPeer {
|
||||
onion: onion.to_string(),
|
||||
pubkey: pubkey.to_string(),
|
||||
name: None,
|
||||
added_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_peers_file_default_is_empty() {
|
||||
let pf = PeersFile::default();
|
||||
assert!(pf.peers.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_known_peer_serialization_roundtrip() {
|
||||
let peer = KnownPeer {
|
||||
onion: "abc123.onion".to_string(),
|
||||
pubkey: "02aabbcc".to_string(),
|
||||
name: Some("My Node".to_string()),
|
||||
added_at: Some("2025-01-01T00:00:00Z".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&peer).unwrap();
|
||||
let parsed: KnownPeer = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.onion, "abc123.onion");
|
||||
assert_eq!(parsed.pubkey, "02aabbcc");
|
||||
assert_eq!(parsed.name, Some("My Node".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_known_peer_optional_fields_default() {
|
||||
// name and added_at should default to None when missing
|
||||
let json = r#"{"onion": "test.onion", "pubkey": "deadbeef"}"#;
|
||||
let peer: KnownPeer = serde_json::from_str(json).unwrap();
|
||||
assert!(peer.name.is_none());
|
||||
assert!(peer.added_at.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_peers_returns_empty_when_no_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let peers = load_peers(dir.path()).await.unwrap();
|
||||
assert!(peers.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_save_and_load_peers_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let peers = vec![
|
||||
make_peer("pub1", "onion1.onion"),
|
||||
make_peer("pub2", "onion2.onion"),
|
||||
];
|
||||
save_peers(dir.path(), &peers).await.unwrap();
|
||||
let loaded = load_peers(dir.path()).await.unwrap();
|
||||
assert_eq!(loaded.len(), 2);
|
||||
assert_eq!(loaded[0].pubkey, "pub1");
|
||||
assert_eq!(loaded[1].onion, "onion2.onion");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_add_peer_appends_new() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let peer = make_peer("pubkey-a", "a.onion");
|
||||
let result = add_peer(dir.path(), peer).await.unwrap();
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].pubkey, "pubkey-a");
|
||||
|
||||
// Add a second peer
|
||||
let peer2 = make_peer("pubkey-b", "b.onion");
|
||||
let result = add_peer(dir.path(), peer2).await.unwrap();
|
||||
assert_eq!(result.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_add_peer_deduplicates_by_pubkey() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let peer = make_peer("same-key", "first.onion");
|
||||
add_peer(dir.path(), peer).await.unwrap();
|
||||
|
||||
// Adding a peer with the same pubkey should not duplicate
|
||||
let peer_dup = make_peer("same-key", "second.onion");
|
||||
let result = add_peer(dir.path(), peer_dup).await.unwrap();
|
||||
assert_eq!(result.len(), 1);
|
||||
// Original should be kept (not replaced)
|
||||
assert_eq!(result[0].onion, "first.onion");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remove_peer_by_pubkey() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
add_peer(dir.path(), make_peer("key-1", "a.onion")).await.unwrap();
|
||||
add_peer(dir.path(), make_peer("key-2", "b.onion")).await.unwrap();
|
||||
add_peer(dir.path(), make_peer("key-3", "c.onion")).await.unwrap();
|
||||
|
||||
let result = remove_peer(dir.path(), "key-2").await.unwrap();
|
||||
assert_eq!(result.len(), 2);
|
||||
assert!(result.iter().all(|p| p.pubkey != "key-2"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remove_nonexistent_peer_is_noop() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
add_peer(dir.path(), make_peer("key-1", "a.onion")).await.unwrap();
|
||||
|
||||
// Removing a pubkey that doesn't exist should succeed but not change the list
|
||||
let result = remove_peer(dir.path(), "nonexistent").await.unwrap();
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].pubkey, "key-1");
|
||||
}
|
||||
}
|
||||
|
||||
@ -146,3 +146,144 @@ impl PortAllocator {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_port_allocations_default_is_empty() {
|
||||
let allocs = PortAllocations::default();
|
||||
assert!(allocs.allocations.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_allocator_from_empty_dir() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let alloc = PortAllocator::new(dir.path()).unwrap();
|
||||
assert!(alloc.allocations.allocations.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allocate_preferred_port_when_available() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut alloc = PortAllocator::new(dir.path()).unwrap();
|
||||
let port = alloc.allocate("my-app", 8500, 80).unwrap();
|
||||
assert_eq!(port, 8500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allocate_fallback_when_preferred_is_reserved() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut alloc = PortAllocator::new(dir.path()).unwrap();
|
||||
// Port 80 is in RESERVED_PORTS, so it should allocate from the range instead
|
||||
let port = alloc.allocate("web-app", 80, 80).unwrap();
|
||||
assert_ne!(port, 80);
|
||||
assert!(port >= WEB_PORT_RANGE_START && port <= WEB_PORT_RANGE_END);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allocate_fallback_when_preferred_is_taken() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut alloc = PortAllocator::new(dir.path()).unwrap();
|
||||
let port1 = alloc.allocate("app-1", 8500, 80).unwrap();
|
||||
assert_eq!(port1, 8500);
|
||||
|
||||
// Second app requesting the same preferred port gets a different one
|
||||
let port2 = alloc.allocate("app-2", 8500, 80).unwrap();
|
||||
assert_ne!(port2, 8500);
|
||||
assert!(port2 >= WEB_PORT_RANGE_START && port2 <= WEB_PORT_RANGE_END);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_returns_existing_allocation() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut alloc = PortAllocator::new(dir.path()).unwrap();
|
||||
alloc.allocate("test-app", 8600, 3000).unwrap();
|
||||
|
||||
let result = alloc.get("test-app");
|
||||
assert!(result.is_some());
|
||||
let (host, container) = result.unwrap();
|
||||
assert_eq!(host, 8600);
|
||||
assert_eq!(container, 3000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_returns_none_for_unknown_app() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let alloc = PortAllocator::new(dir.path()).unwrap();
|
||||
assert!(alloc.get("nonexistent").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allocate_or_get_returns_existing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut alloc = PortAllocator::new(dir.path()).unwrap();
|
||||
let port1 = alloc.allocate("my-app", 8700, 80).unwrap();
|
||||
|
||||
// Calling allocate_or_get with a different preferred port should return the existing one
|
||||
let port2 = alloc.allocate_or_get("my-app", 9999, 80).unwrap();
|
||||
assert_eq!(port1, port2);
|
||||
assert_eq!(port2, 8700);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allocate_or_get_allocates_when_new() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut alloc = PortAllocator::new(dir.path()).unwrap();
|
||||
let port = alloc.allocate_or_get("new-app", 8800, 443).unwrap();
|
||||
assert_eq!(port, 8800);
|
||||
|
||||
// Verify it's now stored
|
||||
assert!(alloc.get("new-app").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_release_removes_allocation() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut alloc = PortAllocator::new(dir.path()).unwrap();
|
||||
alloc.allocate("removable", 8900, 80).unwrap();
|
||||
assert!(alloc.get("removable").is_some());
|
||||
|
||||
alloc.release("removable").unwrap();
|
||||
assert!(alloc.get("removable").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_released_port_becomes_available() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut alloc = PortAllocator::new(dir.path()).unwrap();
|
||||
alloc.allocate("app-a", 8500, 80).unwrap();
|
||||
alloc.release("app-a").unwrap();
|
||||
|
||||
// Port 8500 should now be available again
|
||||
let port = alloc.allocate("app-b", 8500, 80).unwrap();
|
||||
assert_eq!(port, 8500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reserved_ports_are_never_allocated() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let alloc = PortAllocator::new(dir.path()).unwrap();
|
||||
for &port in RESERVED_PORTS {
|
||||
assert!(alloc.is_reserved(port), "Port {} should be reserved", port);
|
||||
assert!(!alloc.is_available(port), "Port {} should not be available", port);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_persistence_across_instances() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
{
|
||||
let mut alloc = PortAllocator::new(dir.path()).unwrap();
|
||||
alloc.allocate("persistent-app", 8555, 80).unwrap();
|
||||
}
|
||||
// Create a new allocator from the same directory
|
||||
let alloc2 = PortAllocator::new(dir.path()).unwrap();
|
||||
let result = alloc2.get("persistent-app");
|
||||
assert!(result.is_some());
|
||||
let (host, container) = result.unwrap();
|
||||
assert_eq!(host, 8555);
|
||||
assert_eq!(container, 80);
|
||||
}
|
||||
}
|
||||
|
||||
@ -46,29 +46,27 @@ impl Server {
|
||||
tracing::debug!("Nostr revoke (non-fatal): {}", e);
|
||||
}
|
||||
|
||||
// Publish node identity to Nostr only when opt-in (nostr_discovery_enabled + relays)
|
||||
// Publish presence-only to Nostr (DID + Nostr pubkey, NO onion address).
|
||||
// Onion addresses are exchanged privately via NIP-44 encrypted DMs.
|
||||
if config.nostr_discovery_enabled
|
||||
&& !config.nostr_relays.is_empty()
|
||||
&& data.server_info.node_address.is_some()
|
||||
{
|
||||
let identity_dir = config.data_dir.join("identity");
|
||||
let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey).unwrap_or_default();
|
||||
let node_addr = data.server_info.node_address.clone().unwrap_or_default();
|
||||
let version = data.server_info.version.clone();
|
||||
let relays = config.nostr_relays.clone();
|
||||
let tor_proxy = config.nostr_tor_proxy.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = nostr_discovery::publish_node_identity(
|
||||
if let Err(e) = nostr_handshake::publish_presence(
|
||||
&identity_dir,
|
||||
&did,
|
||||
&node_addr,
|
||||
&version,
|
||||
&relays,
|
||||
tor_proxy.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::debug!("Nostr publish (non-fatal): {}", e);
|
||||
tracing::debug!("Nostr presence publish (non-fatal): {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -100,17 +98,17 @@ impl Server {
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize Docker scanner if in dev mode
|
||||
if config.dev_mode {
|
||||
// Initialize container scanner — discovers installed apps from Podman/Docker
|
||||
{
|
||||
let scanner = create_docker_scanner(&config).await?;
|
||||
let state = state_manager.clone();
|
||||
let identity_clone = identity.clone();
|
||||
|
||||
// Initial scan
|
||||
tokio::spawn(async move {
|
||||
info!("🐳 Scanning Docker containers...");
|
||||
info!("🐳 Scanning containers...");
|
||||
if let Err(e) = scan_and_update_packages(&scanner, &state, identity_clone.as_ref()).await {
|
||||
error!("Failed to scan Docker containers: {}", e);
|
||||
error!("Failed to scan containers: {}", e);
|
||||
}
|
||||
|
||||
// Periodic scan every 10 seconds (only broadcasts if state changed)
|
||||
@ -118,7 +116,7 @@ impl Server {
|
||||
loop {
|
||||
interval.tick().await;
|
||||
if let Err(e) = scan_and_update_packages(&scanner, &state, identity_clone.as_ref()).await {
|
||||
error!("Failed to update Docker containers: {}", e);
|
||||
error!("Failed to update containers: {}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -70,3 +70,117 @@ impl Default for StateManager {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::data_model::DataModel;
|
||||
|
||||
#[test]
|
||||
fn test_state_manager_new() {
|
||||
let sm = StateManager::new();
|
||||
// Should be constructible without panic
|
||||
let _ = sm.subscribe();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_state_manager_default() {
|
||||
let sm = StateManager::default();
|
||||
let _ = sm.subscribe();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_snapshot_initial_revision_zero() {
|
||||
let sm = StateManager::new();
|
||||
let (data, rev) = sm.get_snapshot().await;
|
||||
assert_eq!(rev, 0);
|
||||
// DataModel::new() sets version from CARGO_PKG_VERSION
|
||||
assert_eq!(data.server_info.version, env!("CARGO_PKG_VERSION"));
|
||||
assert!(data.package_data.is_empty());
|
||||
assert!(data.notifications.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_data_increments_revision() {
|
||||
let sm = StateManager::new();
|
||||
|
||||
let (_, rev0) = sm.get_snapshot().await;
|
||||
assert_eq!(rev0, 0);
|
||||
|
||||
sm.update_data(DataModel::new()).await;
|
||||
let (_, rev1) = sm.get_snapshot().await;
|
||||
assert_eq!(rev1, 1);
|
||||
|
||||
sm.update_data(DataModel::new()).await;
|
||||
let (_, rev2) = sm.get_snapshot().await;
|
||||
assert_eq!(rev2, 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_data_stores_new_data() {
|
||||
let sm = StateManager::new();
|
||||
|
||||
let mut new_data = DataModel::new();
|
||||
new_data.server_info.name = Some("TestNode".to_string());
|
||||
new_data.ui.theme = "light".to_string();
|
||||
sm.update_data(new_data).await;
|
||||
|
||||
let (snapshot, _) = sm.get_snapshot().await;
|
||||
assert_eq!(snapshot.server_info.name, Some("TestNode".to_string()));
|
||||
assert_eq!(snapshot.ui.theme, "light");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_subscribe_receives_updates() {
|
||||
let sm = StateManager::new();
|
||||
let mut rx = sm.subscribe();
|
||||
|
||||
let mut data = DataModel::new();
|
||||
data.server_info.name = Some("BroadcastTest".to_string());
|
||||
sm.update_data(data).await;
|
||||
|
||||
let msg = rx.recv().await.unwrap();
|
||||
assert_eq!(msg.rev, 1);
|
||||
assert!(msg.data.is_some());
|
||||
let received_data = msg.data.unwrap();
|
||||
assert_eq!(received_data.server_info.name, Some("BroadcastTest".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_multiple_subscribers_receive_same_update() {
|
||||
let sm = StateManager::new();
|
||||
let mut rx1 = sm.subscribe();
|
||||
let mut rx2 = sm.subscribe();
|
||||
|
||||
sm.update_data(DataModel::new()).await;
|
||||
|
||||
let msg1 = rx1.recv().await.unwrap();
|
||||
let msg2 = rx2.recv().await.unwrap();
|
||||
assert_eq!(msg1.rev, msg2.rev);
|
||||
assert_eq!(msg1.rev, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_initial_message() {
|
||||
let sm = StateManager::new();
|
||||
|
||||
let mut data = DataModel::new();
|
||||
data.server_info.name = Some("InitMsg".to_string());
|
||||
sm.update_data(data).await;
|
||||
|
||||
let msg = sm.get_initial_message().await;
|
||||
assert_eq!(msg.rev, 1);
|
||||
assert!(msg.data.is_some());
|
||||
assert!(msg.patch.is_none());
|
||||
assert_eq!(msg.data.unwrap().server_info.name, Some("InitMsg".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_without_subscribers_does_not_panic() {
|
||||
let sm = StateManager::new();
|
||||
// No subscribers — send should silently succeed
|
||||
sm.update_data(DataModel::new()).await;
|
||||
let (_, rev) = sm.get_snapshot().await;
|
||||
assert_eq!(rev, 1);
|
||||
}
|
||||
}
|
||||
|
||||
480
core/archipelago/src/vpn.rs
Normal file
480
core/archipelago/src/vpn.rs
Normal file
@ -0,0 +1,480 @@
|
||||
//! VPN integration: Tailscale and WireGuard management.
|
||||
//!
|
||||
//! Manages VPN connections, generates WireGuard configs, and monitors
|
||||
//! VPN interface status for remote access to the Archipelago node.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use tokio::fs;
|
||||
|
||||
const VPN_CONFIG_FILE: &str = "vpn-config.json";
|
||||
|
||||
/// VPN provider type.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum VpnProvider {
|
||||
Tailscale,
|
||||
Wireguard,
|
||||
}
|
||||
|
||||
/// Persisted VPN configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VpnConfig {
|
||||
pub provider: VpnProvider,
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub tailscale_auth_key: Option<String>,
|
||||
#[serde(default)]
|
||||
pub wireguard_config: Option<WireGuardConfig>,
|
||||
#[serde(default)]
|
||||
pub configured_at: Option<String>,
|
||||
}
|
||||
|
||||
/// WireGuard configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WireGuardConfig {
|
||||
pub private_key: String,
|
||||
pub public_key: String,
|
||||
pub address: String,
|
||||
pub dns: String,
|
||||
#[serde(default)]
|
||||
pub peers: Vec<WireGuardPeer>,
|
||||
}
|
||||
|
||||
/// A WireGuard peer.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WireGuardPeer {
|
||||
pub public_key: String,
|
||||
pub endpoint: String,
|
||||
pub allowed_ips: String,
|
||||
#[serde(default)]
|
||||
pub persistent_keepalive: Option<u16>,
|
||||
}
|
||||
|
||||
/// Current VPN status (gathered at runtime, not persisted).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VpnStatus {
|
||||
pub connected: bool,
|
||||
pub provider: Option<String>,
|
||||
pub interface: Option<String>,
|
||||
pub ip_address: Option<String>,
|
||||
pub hostname: Option<String>,
|
||||
#[serde(default)]
|
||||
pub peers_connected: u32,
|
||||
#[serde(default)]
|
||||
pub bytes_in: u64,
|
||||
#[serde(default)]
|
||||
pub bytes_out: u64,
|
||||
}
|
||||
|
||||
impl Default for VpnConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
provider: VpnProvider::Tailscale,
|
||||
enabled: false,
|
||||
tailscale_auth_key: None,
|
||||
wireguard_config: None,
|
||||
configured_at: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn load_config(data_dir: &Path) -> Result<VpnConfig> {
|
||||
let path = data_dir.join(VPN_CONFIG_FILE);
|
||||
if !path.exists() {
|
||||
return Ok(VpnConfig::default());
|
||||
}
|
||||
let content = fs::read_to_string(&path)
|
||||
.await
|
||||
.context("Failed to read VPN config")?;
|
||||
let config: VpnConfig = serde_json::from_str(&content).unwrap_or_default();
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub async fn save_config(data_dir: &Path, config: &VpnConfig) -> Result<()> {
|
||||
fs::create_dir_all(data_dir).await.context("Failed to create data dir")?;
|
||||
let content = serde_json::to_string_pretty(config).context("Failed to serialize VPN config")?;
|
||||
fs::write(data_dir.join(VPN_CONFIG_FILE), content)
|
||||
.await
|
||||
.context("Failed to write VPN config")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generate a WireGuard keypair using the `wg` command.
|
||||
pub async fn generate_wireguard_keypair() -> Result<(String, String)> {
|
||||
let privkey_output = tokio::process::Command::new("wg")
|
||||
.arg("genkey")
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to run wg genkey — is wireguard-tools installed?")?;
|
||||
|
||||
if !privkey_output.status.success() {
|
||||
anyhow::bail!(
|
||||
"wg genkey failed: {}",
|
||||
String::from_utf8_lossy(&privkey_output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
let private_key = String::from_utf8(privkey_output.stdout)
|
||||
.context("Invalid UTF-8 from wg genkey")?
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
let _pubkey_output = tokio::process::Command::new("wg")
|
||||
.arg("pubkey")
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.context("Failed to spawn wg pubkey")?;
|
||||
|
||||
// Use echo + pipe approach instead
|
||||
let pubkey_output = tokio::process::Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(format!("echo '{}' | wg pubkey", private_key))
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to derive public key")?;
|
||||
|
||||
if !pubkey_output.status.success() {
|
||||
anyhow::bail!(
|
||||
"wg pubkey failed: {}",
|
||||
String::from_utf8_lossy(&pubkey_output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
let public_key = String::from_utf8(pubkey_output.stdout)
|
||||
.context("Invalid UTF-8 from wg pubkey")?
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
Ok((private_key, public_key))
|
||||
}
|
||||
|
||||
/// Generate a WireGuard configuration file content.
|
||||
pub fn generate_wireguard_conf(config: &WireGuardConfig) -> String {
|
||||
let mut conf = format!(
|
||||
"[Interface]\nPrivateKey = {}\nAddress = {}\nDNS = {}\n",
|
||||
config.private_key, config.address, config.dns
|
||||
);
|
||||
|
||||
for peer in &config.peers {
|
||||
conf.push_str(&format!(
|
||||
"\n[Peer]\nPublicKey = {}\nEndpoint = {}\nAllowedIPs = {}\n",
|
||||
peer.public_key, peer.endpoint, peer.allowed_ips
|
||||
));
|
||||
if let Some(ka) = peer.persistent_keepalive {
|
||||
conf.push_str(&format!("PersistentKeepalive = {}\n", ka));
|
||||
}
|
||||
}
|
||||
|
||||
conf
|
||||
}
|
||||
|
||||
/// Get the current VPN status by checking network interfaces.
|
||||
pub async fn get_status() -> VpnStatus {
|
||||
// Check for Tailscale interface
|
||||
if let Ok(tailscale) = get_tailscale_status().await {
|
||||
return tailscale;
|
||||
}
|
||||
|
||||
// Check for WireGuard interface
|
||||
if let Ok(wg) = get_wireguard_status().await {
|
||||
return wg;
|
||||
}
|
||||
|
||||
VpnStatus {
|
||||
connected: false,
|
||||
provider: None,
|
||||
interface: None,
|
||||
ip_address: None,
|
||||
hostname: None,
|
||||
peers_connected: 0,
|
||||
bytes_in: 0,
|
||||
bytes_out: 0,
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_tailscale_status() -> Result<VpnStatus> {
|
||||
// Check if tailscale0 interface exists
|
||||
let output = tokio::process::Command::new("ip")
|
||||
.args(["addr", "show", "tailscale0"])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to check tailscale0")?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!("No tailscale0 interface");
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let ip = stdout
|
||||
.lines()
|
||||
.find(|l| l.contains("inet ") && !l.contains("inet6"))
|
||||
.and_then(|l| {
|
||||
l.split_whitespace()
|
||||
.nth(1)
|
||||
.map(|ip| ip.split('/').next().unwrap_or(ip).to_string())
|
||||
});
|
||||
|
||||
// Try to get hostname from tailscale status
|
||||
let hostname = tokio::process::Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg("podman exec tailscale tailscale status --self --json 2>/dev/null | grep -o '\"DNSName\":\"[^\"]*\"' | head -1 | cut -d'\"' -f4")
|
||||
.output()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|o| {
|
||||
let s = String::from_utf8_lossy(&o.stdout).trim().to_string();
|
||||
if s.is_empty() { None } else { Some(s.trim_end_matches('.').to_string()) }
|
||||
});
|
||||
|
||||
// Get peer count
|
||||
let peers = tokio::process::Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg("podman exec tailscale tailscale status 2>/dev/null | grep -c 'active' || echo 0")
|
||||
.output()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|o| {
|
||||
String::from_utf8_lossy(&o.stdout)
|
||||
.trim()
|
||||
.parse::<u32>()
|
||||
.ok()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
|
||||
Ok(VpnStatus {
|
||||
connected: ip.is_some(),
|
||||
provider: Some("tailscale".to_string()),
|
||||
interface: Some("tailscale0".to_string()),
|
||||
ip_address: ip,
|
||||
hostname,
|
||||
peers_connected: peers,
|
||||
bytes_in: 0,
|
||||
bytes_out: 0,
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_wireguard_status() -> Result<VpnStatus> {
|
||||
let output = tokio::process::Command::new("ip")
|
||||
.args(["addr", "show", "wg0"])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to check wg0")?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!("No wg0 interface");
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let ip = stdout
|
||||
.lines()
|
||||
.find(|l| l.contains("inet ") && !l.contains("inet6"))
|
||||
.and_then(|l| {
|
||||
l.split_whitespace()
|
||||
.nth(1)
|
||||
.map(|ip| ip.split('/').next().unwrap_or(ip).to_string())
|
||||
});
|
||||
|
||||
// Get peer count from wg show
|
||||
let peers = tokio::process::Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg("wg show wg0 peers 2>/dev/null | wc -l")
|
||||
.output()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|o| {
|
||||
String::from_utf8_lossy(&o.stdout)
|
||||
.trim()
|
||||
.parse::<u32>()
|
||||
.ok()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
|
||||
// Get transfer stats
|
||||
let (bytes_in, bytes_out) = tokio::process::Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg("wg show wg0 transfer 2>/dev/null | awk '{i+=$2; o+=$3} END {print i, o}'")
|
||||
.output()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|o| {
|
||||
let s = String::from_utf8_lossy(&o.stdout);
|
||||
let mut parts = s.trim().split_whitespace();
|
||||
let i = parts.next().and_then(|v| v.parse::<u64>().ok()).unwrap_or(0);
|
||||
let o = parts.next().and_then(|v| v.parse::<u64>().ok()).unwrap_or(0);
|
||||
Some((i, o))
|
||||
})
|
||||
.unwrap_or((0, 0));
|
||||
|
||||
Ok(VpnStatus {
|
||||
connected: ip.is_some(),
|
||||
provider: Some("wireguard".to_string()),
|
||||
interface: Some("wg0".to_string()),
|
||||
ip_address: ip,
|
||||
hostname: None,
|
||||
peers_connected: peers,
|
||||
bytes_in,
|
||||
bytes_out,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configure Tailscale with an auth key (triggers tailscale up).
|
||||
pub async fn configure_tailscale(auth_key: &str, data_dir: &Path) -> Result<()> {
|
||||
let mut config = load_config(data_dir).await?;
|
||||
config.provider = VpnProvider::Tailscale;
|
||||
config.enabled = true;
|
||||
config.tailscale_auth_key = Some(auth_key.to_string());
|
||||
config.configured_at = Some(chrono::Utc::now().to_rfc3339());
|
||||
save_config(data_dir, &config).await?;
|
||||
|
||||
// Run tailscale up with auth key in the container
|
||||
let output = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"exec", "tailscale", "tailscale", "up",
|
||||
"--authkey", auth_key,
|
||||
"--accept-routes",
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to run tailscale up")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!("tailscale up failed: {}", stderr);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Configure WireGuard with generated keys and optional peer.
|
||||
pub async fn configure_wireguard(
|
||||
data_dir: &Path,
|
||||
address: &str,
|
||||
dns: &str,
|
||||
peer: Option<WireGuardPeer>,
|
||||
) -> Result<WireGuardConfig> {
|
||||
let (private_key, public_key) = generate_wireguard_keypair().await?;
|
||||
|
||||
let wg_config = WireGuardConfig {
|
||||
private_key: private_key.clone(),
|
||||
public_key: public_key.clone(),
|
||||
address: address.to_string(),
|
||||
dns: dns.to_string(),
|
||||
peers: peer.map_or_else(Vec::new, |p| vec![p]),
|
||||
};
|
||||
|
||||
let mut config = load_config(data_dir).await?;
|
||||
config.provider = VpnProvider::Wireguard;
|
||||
config.enabled = true;
|
||||
config.wireguard_config = Some(wg_config.clone());
|
||||
config.configured_at = Some(chrono::Utc::now().to_rfc3339());
|
||||
save_config(data_dir, &config).await?;
|
||||
|
||||
// Write WireGuard config file
|
||||
let conf_content = generate_wireguard_conf(&wg_config);
|
||||
let wg_dir = data_dir.join("wireguard");
|
||||
fs::create_dir_all(&wg_dir).await.context("Failed to create wireguard dir")?;
|
||||
fs::write(wg_dir.join("wg0.conf"), &conf_content)
|
||||
.await
|
||||
.context("Failed to write wg0.conf")?;
|
||||
|
||||
Ok(wg_config)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_vpn_config_default() {
|
||||
let config = VpnConfig::default();
|
||||
assert!(!config.enabled);
|
||||
assert_eq!(config.provider, VpnProvider::Tailscale);
|
||||
assert!(config.tailscale_auth_key.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vpn_config_serialization() {
|
||||
let config = VpnConfig {
|
||||
provider: VpnProvider::Wireguard,
|
||||
enabled: true,
|
||||
tailscale_auth_key: None,
|
||||
wireguard_config: Some(WireGuardConfig {
|
||||
private_key: "privkey".to_string(),
|
||||
public_key: "pubkey".to_string(),
|
||||
address: "10.0.0.1/24".to_string(),
|
||||
dns: "1.1.1.1".to_string(),
|
||||
peers: vec![],
|
||||
}),
|
||||
configured_at: Some("2026-01-01T00:00:00Z".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
let parsed: VpnConfig = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.provider, VpnProvider::Wireguard);
|
||||
assert!(parsed.enabled);
|
||||
assert!(parsed.wireguard_config.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_wireguard_conf() {
|
||||
let config = WireGuardConfig {
|
||||
private_key: "test_privkey".to_string(),
|
||||
public_key: "test_pubkey".to_string(),
|
||||
address: "10.0.0.2/24".to_string(),
|
||||
dns: "1.1.1.1, 8.8.8.8".to_string(),
|
||||
peers: vec![WireGuardPeer {
|
||||
public_key: "peer_pubkey".to_string(),
|
||||
endpoint: "vpn.example.com:51820".to_string(),
|
||||
allowed_ips: "0.0.0.0/0".to_string(),
|
||||
persistent_keepalive: Some(25),
|
||||
}],
|
||||
};
|
||||
let conf = generate_wireguard_conf(&config);
|
||||
assert!(conf.contains("[Interface]"));
|
||||
assert!(conf.contains("PrivateKey = test_privkey"));
|
||||
assert!(conf.contains("Address = 10.0.0.2/24"));
|
||||
assert!(conf.contains("[Peer]"));
|
||||
assert!(conf.contains("PublicKey = peer_pubkey"));
|
||||
assert!(conf.contains("PersistentKeepalive = 25"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_config_default_when_no_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let config = load_config(dir.path()).await.unwrap();
|
||||
assert!(!config.enabled);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_save_and_load_config_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let config = VpnConfig {
|
||||
provider: VpnProvider::Tailscale,
|
||||
enabled: true,
|
||||
tailscale_auth_key: Some("tskey-auth-test".to_string()),
|
||||
wireguard_config: None,
|
||||
configured_at: Some("2026-03-10T00:00:00Z".to_string()),
|
||||
};
|
||||
save_config(dir.path(), &config).await.unwrap();
|
||||
let loaded = load_config(dir.path()).await.unwrap();
|
||||
assert!(loaded.enabled);
|
||||
assert_eq!(loaded.tailscale_auth_key, Some("tskey-auth-test".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vpn_status_default() {
|
||||
let status = VpnStatus {
|
||||
connected: false,
|
||||
provider: None,
|
||||
interface: None,
|
||||
ip_address: None,
|
||||
hostname: None,
|
||||
peers_connected: 0,
|
||||
bytes_in: 0,
|
||||
bytes_out: 0,
|
||||
};
|
||||
assert!(!status.connected);
|
||||
}
|
||||
}
|
||||
@ -276,3 +276,307 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
|
||||
fn default_mint_url() -> String {
|
||||
"http://127.0.0.1:8175".to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_wallet_state_balance_empty() {
|
||||
let wallet = WalletState::default();
|
||||
assert_eq!(wallet.balance(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wallet_state_balance_unspent_only() {
|
||||
let wallet = WalletState {
|
||||
tokens: vec![
|
||||
EcashToken {
|
||||
id: "t1".into(),
|
||||
amount_sats: 100,
|
||||
token: "tok1".into(),
|
||||
mint_url: "http://mint".into(),
|
||||
spent: false,
|
||||
created_at: "2025-01-01T00:00:00Z".into(),
|
||||
},
|
||||
EcashToken {
|
||||
id: "t2".into(),
|
||||
amount_sats: 200,
|
||||
token: "tok2".into(),
|
||||
mint_url: "http://mint".into(),
|
||||
spent: true,
|
||||
created_at: "2025-01-01T00:00:00Z".into(),
|
||||
},
|
||||
EcashToken {
|
||||
id: "t3".into(),
|
||||
amount_sats: 50,
|
||||
token: "tok3".into(),
|
||||
mint_url: "http://mint".into(),
|
||||
spent: false,
|
||||
created_at: "2025-01-01T00:00:00Z".into(),
|
||||
},
|
||||
],
|
||||
transactions: vec![],
|
||||
mint_url: String::new(),
|
||||
};
|
||||
// Only t1 (100) and t3 (50) are unspent
|
||||
assert_eq!(wallet.balance(), 150);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wallet_state_balance_all_spent() {
|
||||
let wallet = WalletState {
|
||||
tokens: vec![EcashToken {
|
||||
id: "t1".into(),
|
||||
amount_sats: 500,
|
||||
token: "tok1".into(),
|
||||
mint_url: "http://mint".into(),
|
||||
spent: true,
|
||||
created_at: "2025-01-01T00:00:00Z".into(),
|
||||
}],
|
||||
transactions: vec![],
|
||||
mint_url: String::new(),
|
||||
};
|
||||
assert_eq!(wallet.balance(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_mint_url() {
|
||||
let url = default_mint_url();
|
||||
assert_eq!(url, "http://127.0.0.1:8175");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_wallet_creates_default_when_missing() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let wallet = load_wallet(tmp.path()).await.unwrap();
|
||||
assert_eq!(wallet.balance(), 0);
|
||||
assert!(wallet.tokens.is_empty());
|
||||
assert!(wallet.transactions.is_empty());
|
||||
assert_eq!(wallet.mint_url, default_mint_url());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_save_and_load_wallet_roundtrip() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let wallet = WalletState {
|
||||
tokens: vec![EcashToken {
|
||||
id: "round-trip-token".into(),
|
||||
amount_sats: 42,
|
||||
token: "cashuA_test".into(),
|
||||
mint_url: "http://127.0.0.1:8175".into(),
|
||||
spent: false,
|
||||
created_at: "2025-06-01T12:00:00Z".into(),
|
||||
}],
|
||||
transactions: vec![EcashTransaction {
|
||||
id: "tx1".into(),
|
||||
tx_type: TransactionType::Mint,
|
||||
amount_sats: 42,
|
||||
timestamp: "2025-06-01T12:00:00Z".into(),
|
||||
description: "Test mint".into(),
|
||||
}],
|
||||
mint_url: "http://127.0.0.1:8175".into(),
|
||||
};
|
||||
|
||||
save_wallet(tmp.path(), &wallet).await.unwrap();
|
||||
let loaded = load_wallet(tmp.path()).await.unwrap();
|
||||
assert_eq!(loaded.tokens.len(), 1);
|
||||
assert_eq!(loaded.tokens[0].id, "round-trip-token");
|
||||
assert_eq!(loaded.tokens[0].amount_sats, 42);
|
||||
assert!(!loaded.tokens[0].spent);
|
||||
assert_eq!(loaded.transactions.len(), 1);
|
||||
assert_eq!(loaded.balance(), 42);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_save_wallet_creates_directory() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let wallet_dir = tmp.path().join("wallet");
|
||||
assert!(!wallet_dir.exists());
|
||||
|
||||
let wallet = WalletState::default();
|
||||
save_wallet(tmp.path(), &wallet).await.unwrap();
|
||||
assert!(wallet_dir.exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_receive_token_valid() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let amount = receive_token(tmp.path(), "cashuSend_500_abc123_1700000000")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(amount, 500);
|
||||
|
||||
let wallet = load_wallet(tmp.path()).await.unwrap();
|
||||
assert_eq!(wallet.balance(), 500);
|
||||
assert_eq!(wallet.tokens.len(), 1);
|
||||
assert!(!wallet.tokens[0].spent);
|
||||
assert_eq!(wallet.transactions.len(), 1);
|
||||
assert!(matches!(
|
||||
wallet.transactions[0].tx_type,
|
||||
TransactionType::Receive
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_receive_token_invalid_format() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let result = receive_token(tmp.path(), "invalid_token_string").await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_receive_token_zero_amount() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let result = receive_token(tmp.path(), "cashuSend_0_abc_1700000000").await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_melt_tokens_marks_spent() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
// Pre-populate a wallet with a token
|
||||
let wallet = WalletState {
|
||||
tokens: vec![EcashToken {
|
||||
id: "melt-me".into(),
|
||||
amount_sats: 1000,
|
||||
token: "cashuA_test".into(),
|
||||
mint_url: default_mint_url(),
|
||||
spent: false,
|
||||
created_at: "2025-01-01T00:00:00Z".into(),
|
||||
}],
|
||||
transactions: vec![],
|
||||
mint_url: default_mint_url(),
|
||||
};
|
||||
save_wallet(tmp.path(), &wallet).await.unwrap();
|
||||
|
||||
let amount = melt_tokens(tmp.path(), "melt-me").await.unwrap();
|
||||
assert_eq!(amount, 1000);
|
||||
|
||||
let loaded = load_wallet(tmp.path()).await.unwrap();
|
||||
assert!(loaded.tokens[0].spent);
|
||||
assert_eq!(loaded.balance(), 0);
|
||||
assert_eq!(loaded.transactions.len(), 1);
|
||||
assert!(matches!(
|
||||
loaded.transactions[0].tx_type,
|
||||
TransactionType::Melt
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_melt_tokens_not_found() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let result = melt_tokens(tmp.path(), "nonexistent").await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_melt_already_spent_token_errors() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let wallet = WalletState {
|
||||
tokens: vec![EcashToken {
|
||||
id: "already-spent".into(),
|
||||
amount_sats: 100,
|
||||
token: "cashuA_x".into(),
|
||||
mint_url: default_mint_url(),
|
||||
spent: true,
|
||||
created_at: "2025-01-01T00:00:00Z".into(),
|
||||
}],
|
||||
transactions: vec![],
|
||||
mint_url: default_mint_url(),
|
||||
};
|
||||
save_wallet(tmp.path(), &wallet).await.unwrap();
|
||||
|
||||
let result = melt_tokens(tmp.path(), "already-spent").await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_send_token_sufficient_balance() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let wallet = WalletState {
|
||||
tokens: vec![
|
||||
EcashToken {
|
||||
id: "s1".into(),
|
||||
amount_sats: 300,
|
||||
token: "tok1".into(),
|
||||
mint_url: default_mint_url(),
|
||||
spent: false,
|
||||
created_at: "2025-01-01T00:00:00Z".into(),
|
||||
},
|
||||
EcashToken {
|
||||
id: "s2".into(),
|
||||
amount_sats: 200,
|
||||
token: "tok2".into(),
|
||||
mint_url: default_mint_url(),
|
||||
spent: false,
|
||||
created_at: "2025-01-01T00:00:00Z".into(),
|
||||
},
|
||||
],
|
||||
transactions: vec![],
|
||||
mint_url: default_mint_url(),
|
||||
};
|
||||
save_wallet(tmp.path(), &wallet).await.unwrap();
|
||||
|
||||
let token_str = send_token(tmp.path(), 400).await.unwrap();
|
||||
assert!(token_str.starts_with("cashuSend_400_"));
|
||||
|
||||
let loaded = load_wallet(tmp.path()).await.unwrap();
|
||||
// Both tokens should be marked spent (300 + 200 = 500 >= 400)
|
||||
assert!(loaded.tokens.iter().all(|t| t.spent));
|
||||
assert_eq!(loaded.balance(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_send_token_insufficient_balance() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let wallet = WalletState {
|
||||
tokens: vec![EcashToken {
|
||||
id: "small".into(),
|
||||
amount_sats: 10,
|
||||
token: "tok".into(),
|
||||
mint_url: default_mint_url(),
|
||||
spent: false,
|
||||
created_at: "2025-01-01T00:00:00Z".into(),
|
||||
}],
|
||||
transactions: vec![],
|
||||
mint_url: default_mint_url(),
|
||||
};
|
||||
save_wallet(tmp.path(), &wallet).await.unwrap();
|
||||
|
||||
let result = send_token(tmp.path(), 1000).await;
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(err_msg.contains("Insufficient balance"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wallet_state_serialization() {
|
||||
let wallet = WalletState {
|
||||
tokens: vec![],
|
||||
transactions: vec![],
|
||||
mint_url: "http://localhost:8175".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&wallet).unwrap();
|
||||
let parsed: WalletState = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.mint_url, wallet.mint_url);
|
||||
assert!(parsed.tokens.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transaction_type_serialization() {
|
||||
let tx = EcashTransaction {
|
||||
id: "tx-test".into(),
|
||||
tx_type: TransactionType::Send,
|
||||
amount_sats: 100,
|
||||
timestamp: "2025-01-01T00:00:00Z".into(),
|
||||
description: "test".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&tx).unwrap();
|
||||
assert!(json.contains("\"send\""));
|
||||
|
||||
let parsed: EcashTransaction = serde_json::from_str(&json).unwrap();
|
||||
assert!(matches!(parsed.tx_type, TransactionType::Send));
|
||||
}
|
||||
}
|
||||
|
||||
@ -112,3 +112,167 @@ pub async fn get_networking_profits(data_dir: &Path) -> Result<ProfitsSummary> {
|
||||
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_profits_summary_default() {
|
||||
let summary = ProfitsSummary::default();
|
||||
assert_eq!(summary.total_sats, 0);
|
||||
assert_eq!(summary.content_sales_sats, 0);
|
||||
assert_eq!(summary.routing_fees_sats, 0);
|
||||
assert!(summary.recent.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_profits_returns_default_when_missing() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let summary = load_profits(tmp.path()).await.unwrap();
|
||||
assert_eq!(summary.total_sats, 0);
|
||||
assert_eq!(summary.content_sales_sats, 0);
|
||||
assert_eq!(summary.routing_fees_sats, 0);
|
||||
assert!(summary.recent.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_save_and_load_profits_roundtrip() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let summary = ProfitsSummary {
|
||||
total_sats: 5000,
|
||||
content_sales_sats: 3000,
|
||||
routing_fees_sats: 2000,
|
||||
recent: vec![ProfitEntry {
|
||||
source: ProfitSource::ContentSale,
|
||||
amount_sats: 3000,
|
||||
timestamp: "2025-06-01T00:00:00Z".to_string(),
|
||||
description: "Test sale".to_string(),
|
||||
}],
|
||||
};
|
||||
|
||||
save_profits(tmp.path(), &summary).await.unwrap();
|
||||
let loaded = load_profits(tmp.path()).await.unwrap();
|
||||
assert_eq!(loaded.total_sats, 5000);
|
||||
assert_eq!(loaded.content_sales_sats, 3000);
|
||||
assert_eq!(loaded.routing_fees_sats, 2000);
|
||||
assert_eq!(loaded.recent.len(), 1);
|
||||
assert_eq!(loaded.recent[0].amount_sats, 3000);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_save_profits_creates_wallet_dir() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let wallet_dir = tmp.path().join("wallet");
|
||||
assert!(!wallet_dir.exists());
|
||||
|
||||
save_profits(tmp.path(), &ProfitsSummary::default()).await.unwrap();
|
||||
assert!(wallet_dir.exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_record_content_sale() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
record_content_sale(tmp.path(), 500, "First sale").await.unwrap();
|
||||
|
||||
let summary = load_profits(tmp.path()).await.unwrap();
|
||||
assert_eq!(summary.total_sats, 500);
|
||||
assert_eq!(summary.content_sales_sats, 500);
|
||||
assert_eq!(summary.routing_fees_sats, 0);
|
||||
assert_eq!(summary.recent.len(), 1);
|
||||
assert_eq!(summary.recent[0].amount_sats, 500);
|
||||
assert_eq!(summary.recent[0].description, "First sale");
|
||||
assert!(matches!(summary.recent[0].source, ProfitSource::ContentSale));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_record_multiple_content_sales() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
record_content_sale(tmp.path(), 100, "Sale 1").await.unwrap();
|
||||
record_content_sale(tmp.path(), 200, "Sale 2").await.unwrap();
|
||||
record_content_sale(tmp.path(), 300, "Sale 3").await.unwrap();
|
||||
|
||||
let summary = load_profits(tmp.path()).await.unwrap();
|
||||
assert_eq!(summary.total_sats, 600);
|
||||
assert_eq!(summary.content_sales_sats, 600);
|
||||
assert_eq!(summary.recent.len(), 3);
|
||||
// Newest first (inserted at index 0)
|
||||
assert_eq!(summary.recent[0].description, "Sale 3");
|
||||
assert_eq!(summary.recent[1].description, "Sale 2");
|
||||
assert_eq!(summary.recent[2].description, "Sale 1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_record_content_sale_truncates_at_100() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
for i in 0..110 {
|
||||
record_content_sale(tmp.path(), 1, &format!("Sale {}", i))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let summary = load_profits(tmp.path()).await.unwrap();
|
||||
assert_eq!(summary.recent.len(), 100);
|
||||
assert_eq!(summary.total_sats, 110);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_networking_profits_empty() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let summary = get_networking_profits(tmp.path()).await.unwrap();
|
||||
assert_eq!(summary.total_sats, 0);
|
||||
assert_eq!(summary.content_sales_sats, 0);
|
||||
assert_eq!(summary.routing_fees_sats, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_networking_profits_includes_ecash_receives() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
// Simulate receiving ecash tokens
|
||||
ecash::receive_token(tmp.path(), "cashuSend_500_uuid1_1700000000")
|
||||
.await
|
||||
.unwrap();
|
||||
ecash::receive_token(tmp.path(), "cashuSend_300_uuid2_1700000001")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let summary = get_networking_profits(tmp.path()).await.unwrap();
|
||||
// ecash receives (800) should be reflected as content sales
|
||||
assert_eq!(summary.content_sales_sats, 800);
|
||||
assert_eq!(summary.total_sats, 800);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_networking_profits_uses_higher_of_tracked_or_ecash() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
// Record a larger tracked profit
|
||||
record_content_sale(tmp.path(), 2000, "Big sale").await.unwrap();
|
||||
// Receive a smaller ecash amount
|
||||
ecash::receive_token(tmp.path(), "cashuSend_100_uuid_170")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let summary = get_networking_profits(tmp.path()).await.unwrap();
|
||||
// Should use tracked (2000) since it's larger than ecash receives (100)
|
||||
assert_eq!(summary.content_sales_sats, 2000);
|
||||
assert_eq!(summary.total_sats, 2000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_profit_source_serialization() {
|
||||
let entry = ProfitEntry {
|
||||
source: ProfitSource::RoutingFee,
|
||||
amount_sats: 42,
|
||||
timestamp: "2025-01-01T00:00:00Z".to_string(),
|
||||
description: "routing".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&entry).unwrap();
|
||||
assert!(json.contains("\"routing_fee\""));
|
||||
|
||||
let parsed: ProfitEntry = serde_json::from_str(&json).unwrap();
|
||||
assert!(matches!(parsed.source, ProfitSource::RoutingFee));
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,7 +8,7 @@ serde = { version = "1.0", features = ["derive"] }
|
||||
serde_yaml = "0.9"
|
||||
serde_json = "1.0"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
reqwest = { version = "0.11", features = ["json"] }
|
||||
reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls"] }
|
||||
thiserror = "1.0"
|
||||
anyhow = "1.0"
|
||||
async-trait = "0.1"
|
||||
|
||||
@ -35,8 +35,7 @@ impl DependencyResolver {
|
||||
|
||||
self.resolve_recursive(app_id, &mut visited, &mut visiting, &mut result)?;
|
||||
|
||||
// Reverse to get installation order (dependencies first)
|
||||
result.reverse();
|
||||
// Result is already in installation order (dependencies first)
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
|
||||
@ -12,8 +12,10 @@ log = "0.4"
|
||||
tracing = "0.1"
|
||||
uuid = { version = "1.0", features = ["v4"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
serde_json = "1.0"
|
||||
aes-gcm = "0.10"
|
||||
rand = "0.8"
|
||||
hex = "0.4"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@ -4,7 +4,9 @@
|
||||
use aes_gcm::aead::{Aead, KeyInit};
|
||||
use aes_gcm::{Aes256Gcm, Nonce};
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use rand::RngCore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use tokio::fs;
|
||||
use uuid::Uuid;
|
||||
@ -12,6 +14,27 @@ use uuid::Uuid;
|
||||
/// Prefix to identify encrypted files (magic bytes)
|
||||
const ENCRYPTED_MAGIC: &[u8] = b"ARCHI_ENC1";
|
||||
|
||||
/// Metadata for a stored secret (stored alongside the encrypted data).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SecretMetadata {
|
||||
pub secret_id: String,
|
||||
pub key: String,
|
||||
pub app_id: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub rotated_at: Option<DateTime<Utc>>,
|
||||
pub rotation_count: u32,
|
||||
}
|
||||
|
||||
/// Info about a secret that may need rotation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ExpiringSecret {
|
||||
pub secret_id: String,
|
||||
pub key: String,
|
||||
pub app_id: String,
|
||||
pub age_days: i64,
|
||||
pub last_rotated: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
pub struct SecretsManager {
|
||||
secrets_dir: PathBuf,
|
||||
cipher: Aes256Gcm,
|
||||
@ -77,7 +100,7 @@ impl SecretsManager {
|
||||
pub async fn store_secret(
|
||||
&self,
|
||||
app_id: &str,
|
||||
_key: &str,
|
||||
key: &str,
|
||||
value: &str,
|
||||
) -> Result<String> {
|
||||
let secret_id = Uuid::new_v4().to_string();
|
||||
@ -95,6 +118,25 @@ impl SecretsManager {
|
||||
.await
|
||||
.context("Failed to write secret")?;
|
||||
|
||||
// Save metadata
|
||||
let metadata = SecretMetadata {
|
||||
secret_id: secret_id.clone(),
|
||||
key: key.to_string(),
|
||||
app_id: app_id.to_string(),
|
||||
created_at: Utc::now(),
|
||||
rotated_at: None,
|
||||
rotation_count: 0,
|
||||
};
|
||||
let meta_path = self
|
||||
.secrets_dir
|
||||
.join(app_id)
|
||||
.join(format!("{}.meta.json", secret_id));
|
||||
let meta_json = serde_json::to_string(&metadata)
|
||||
.context("Failed to serialize metadata")?;
|
||||
fs::write(&meta_path, meta_json.as_bytes())
|
||||
.await
|
||||
.context("Failed to write metadata")?;
|
||||
|
||||
// Set restrictive permissions
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@ -102,6 +144,9 @@ impl SecretsManager {
|
||||
let mut perms = fs::metadata(&secret_path).await?.permissions();
|
||||
perms.set_mode(0o600);
|
||||
fs::set_permissions(&secret_path, perms).await?;
|
||||
let mut meta_perms = fs::metadata(&meta_path).await?.permissions();
|
||||
meta_perms.set_mode(0o600);
|
||||
fs::set_permissions(&meta_path, meta_perms).await?;
|
||||
}
|
||||
|
||||
Ok(secret_id)
|
||||
@ -164,16 +209,152 @@ impl SecretsManager {
|
||||
Ok(secrets)
|
||||
}
|
||||
|
||||
/// Delete a secret
|
||||
pub async fn delete_secret(&self, app_id: &str, secret_id: &str) -> Result<()> {
|
||||
/// Rotate a secret: generate a new random value, re-encrypt, update metadata.
|
||||
/// Returns the new plaintext secret value.
|
||||
pub async fn rotate_secret(
|
||||
&self,
|
||||
app_id: &str,
|
||||
secret_id: &str,
|
||||
) -> Result<String> {
|
||||
// Generate a new random secret (32 bytes, hex-encoded = 64 chars)
|
||||
let mut new_secret_bytes = [0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut new_secret_bytes);
|
||||
let new_value = hex::encode(new_secret_bytes);
|
||||
|
||||
let secret_path = self
|
||||
.secrets_dir
|
||||
.join(app_id)
|
||||
.join(format!("{}.secret", secret_id));
|
||||
|
||||
anyhow::ensure!(
|
||||
secret_path.exists(),
|
||||
"Secret {} not found for app {}",
|
||||
secret_id,
|
||||
app_id
|
||||
);
|
||||
|
||||
// Re-encrypt with new value
|
||||
let encrypted = self
|
||||
.encrypt(new_value.as_bytes())
|
||||
.context("Failed to encrypt rotated secret")?;
|
||||
fs::write(&secret_path, &encrypted)
|
||||
.await
|
||||
.context("Failed to write rotated secret")?;
|
||||
|
||||
// Update metadata
|
||||
let meta_path = self
|
||||
.secrets_dir
|
||||
.join(app_id)
|
||||
.join(format!("{}.meta.json", secret_id));
|
||||
if meta_path.exists() {
|
||||
if let Ok(data) = fs::read_to_string(&meta_path).await {
|
||||
if let Ok(mut metadata) = serde_json::from_str::<SecretMetadata>(&data) {
|
||||
metadata.rotated_at = Some(Utc::now());
|
||||
metadata.rotation_count += 1;
|
||||
if let Ok(json) = serde_json::to_string(&metadata) {
|
||||
let _ = fs::write(&meta_path, json.as_bytes()).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(new_value)
|
||||
}
|
||||
|
||||
/// List secrets older than `max_age_days` that may need rotation.
|
||||
pub async fn list_expiring(
|
||||
&self,
|
||||
max_age_days: i64,
|
||||
) -> Result<Vec<ExpiringSecret>> {
|
||||
let mut expiring = Vec::new();
|
||||
let now = Utc::now();
|
||||
|
||||
if !self.secrets_dir.exists() {
|
||||
return Ok(expiring);
|
||||
}
|
||||
|
||||
let mut app_dirs = fs::read_dir(&self.secrets_dir).await?;
|
||||
while let Some(app_entry) = app_dirs.next_entry().await? {
|
||||
let app_path = app_entry.path();
|
||||
if !app_path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let app_id = app_path
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let mut entries = fs::read_dir(&app_path).await?;
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let path = entry.path();
|
||||
let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
|
||||
if !name.ends_with(".meta.json") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(data) = fs::read_to_string(&path).await {
|
||||
if let Ok(metadata) = serde_json::from_str::<SecretMetadata>(&data) {
|
||||
let reference_time =
|
||||
metadata.rotated_at.unwrap_or(metadata.created_at);
|
||||
let age = now.signed_duration_since(reference_time);
|
||||
if age.num_days() >= max_age_days {
|
||||
expiring.push(ExpiringSecret {
|
||||
secret_id: metadata.secret_id,
|
||||
key: metadata.key,
|
||||
app_id: metadata.app_id,
|
||||
age_days: age.num_days(),
|
||||
last_rotated: metadata.rotated_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(expiring)
|
||||
}
|
||||
|
||||
/// Read metadata for a specific secret.
|
||||
pub async fn get_metadata(
|
||||
&self,
|
||||
app_id: &str,
|
||||
secret_id: &str,
|
||||
) -> Result<Option<SecretMetadata>> {
|
||||
let meta_path = self
|
||||
.secrets_dir
|
||||
.join(app_id)
|
||||
.join(format!("{}.meta.json", secret_id));
|
||||
|
||||
if !meta_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let data = fs::read_to_string(&meta_path)
|
||||
.await
|
||||
.context("Failed to read metadata")?;
|
||||
let metadata: SecretMetadata =
|
||||
serde_json::from_str(&data).context("Failed to parse metadata")?;
|
||||
Ok(Some(metadata))
|
||||
}
|
||||
|
||||
/// Delete a secret and its metadata
|
||||
pub async fn delete_secret(&self, app_id: &str, secret_id: &str) -> Result<()> {
|
||||
let secret_path = self
|
||||
.secrets_dir
|
||||
.join(app_id)
|
||||
.join(format!("{}.secret", secret_id));
|
||||
let meta_path = self
|
||||
.secrets_dir
|
||||
.join(app_id)
|
||||
.join(format!("{}.meta.json", secret_id));
|
||||
|
||||
if secret_path.exists() {
|
||||
fs::remove_file(&secret_path).await?;
|
||||
}
|
||||
if meta_path.exists() {
|
||||
fs::remove_file(&meta_path).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@ -256,4 +437,113 @@ mod tests {
|
||||
// File must start with our magic prefix
|
||||
assert_eq!(&raw[..ENCRYPTED_MAGIC.len()], ENCRYPTED_MAGIC);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_rotate_secret() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mgr = SecretsManager::new(dir.path().to_path_buf(), test_key()).unwrap();
|
||||
|
||||
let secret_id = mgr
|
||||
.store_secret("test-app", "api-key", "original_value")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let original = mgr.read_secret("test-app", &secret_id).await.unwrap();
|
||||
assert_eq!(original, "original_value");
|
||||
|
||||
let new_value = mgr.rotate_secret("test-app", &secret_id).await.unwrap();
|
||||
assert_ne!(new_value, "original_value");
|
||||
assert_eq!(new_value.len(), 64); // 32 bytes hex-encoded
|
||||
|
||||
let read_back = mgr.read_secret("test-app", &secret_id).await.unwrap();
|
||||
assert_eq!(read_back, new_value);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_rotate_updates_metadata() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mgr = SecretsManager::new(dir.path().to_path_buf(), test_key()).unwrap();
|
||||
|
||||
let secret_id = mgr
|
||||
.store_secret("test-app", "db-pass", "initial")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let meta_before = mgr.get_metadata("test-app", &secret_id).await.unwrap().unwrap();
|
||||
assert_eq!(meta_before.rotation_count, 0);
|
||||
assert!(meta_before.rotated_at.is_none());
|
||||
|
||||
mgr.rotate_secret("test-app", &secret_id).await.unwrap();
|
||||
|
||||
let meta_after = mgr.get_metadata("test-app", &secret_id).await.unwrap().unwrap();
|
||||
assert_eq!(meta_after.rotation_count, 1);
|
||||
assert!(meta_after.rotated_at.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_rotate_nonexistent_fails() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mgr = SecretsManager::new(dir.path().to_path_buf(), test_key()).unwrap();
|
||||
|
||||
let result = mgr.rotate_secret("test-app", "nonexistent").await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_expiring_empty() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mgr = SecretsManager::new(dir.path().to_path_buf(), test_key()).unwrap();
|
||||
|
||||
let expiring = mgr.list_expiring(90).await.unwrap();
|
||||
assert!(expiring.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_expiring_fresh_secrets_not_listed() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mgr = SecretsManager::new(dir.path().to_path_buf(), test_key()).unwrap();
|
||||
|
||||
mgr.store_secret("test-app", "key1", "val1").await.unwrap();
|
||||
|
||||
// Fresh secret (0 days old) should not be listed for 90-day expiry
|
||||
let expiring = mgr.list_expiring(90).await.unwrap();
|
||||
assert!(expiring.is_empty());
|
||||
|
||||
// But it should appear for 0-day threshold
|
||||
let expiring = mgr.list_expiring(0).await.unwrap();
|
||||
assert_eq!(expiring.len(), 1);
|
||||
assert_eq!(expiring[0].key, "key1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_metadata_stored_and_read() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mgr = SecretsManager::new(dir.path().to_path_buf(), test_key()).unwrap();
|
||||
|
||||
let secret_id = mgr
|
||||
.store_secret("myapp", "connection-string", "postgres://...")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let meta = mgr.get_metadata("myapp", &secret_id).await.unwrap().unwrap();
|
||||
assert_eq!(meta.key, "connection-string");
|
||||
assert_eq!(meta.app_id, "myapp");
|
||||
assert_eq!(meta.rotation_count, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete_removes_metadata() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mgr = SecretsManager::new(dir.path().to_path_buf(), test_key()).unwrap();
|
||||
|
||||
let secret_id = mgr
|
||||
.store_secret("test-app", "key", "val")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
mgr.delete_secret("test-app", &secret_id).await.unwrap();
|
||||
|
||||
let meta = mgr.get_metadata("test-app", &secret_id).await.unwrap();
|
||||
assert!(meta.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,16 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO network_keys (package, interface, key) VALUES ($1, $2, $3) ON CONFLICT (package, interface) DO NOTHING",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Bytea"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "1ce5254f27de971fd87f5ab66d300f2b22433c86617a0dbf796bf2170186dd2e"
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM ssh_keys WHERE fingerprint = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "21471490cdc3adb206274cc68e1ea745ffa5da4479478c1fd2158a45324b1930"
|
||||
}
|
||||
@ -1,40 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT hostname, path, username, password FROM cifs_shares WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "hostname",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "path",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "username",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "password",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "28ea34bbde836e0618c5fc9bb7c36e463c20c841a7d6a0eb15be0f24f4a928ec"
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM tor WHERE package = $1 AND interface = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "350ab82048fb4a049042e4fdbe1b8c606ca400e43e31b9a05d2937217e0f6962"
|
||||
}
|
||||
@ -1,34 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT * FROM ssh_keys WHERE fingerprint = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "fingerprint",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "openssh_pubkey",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "created_at",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "4099028a5c0de578255bf54a67cef6cb0f1e9a4e158260700f1639dd4b438997"
|
||||
}
|
||||
@ -1,50 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT * FROM session WHERE logged_out IS NULL OR logged_out > CURRENT_TIMESTAMP",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "logged_in",
|
||||
"type_info": "Timestamp"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "logged_out",
|
||||
"type_info": "Timestamp"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "last_active",
|
||||
"type_info": "Timestamp"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "user_agent",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "metadata",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "4691e3a2ce80b59009ac17124f54f925f61dc5ea371903e62cdffa5d7b67ca96"
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE session SET logged_out = CURRENT_TIMESTAMP WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "4bcfbefb1eb3181343871a1cd7fc3afb81c2be5c681cfa8b4be0ce70610e9c3a"
|
||||
}
|
||||
@ -1,20 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT password FROM account",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "password",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "629be61c3c341c131ddbbff0293a83dbc6afd07cae69d246987f62cf0cc35c2a"
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT key FROM tor WHERE package = $1 AND interface = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "key",
|
||||
"type_info": "Bytea"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "687688055e63d27123cdc89a5bbbd8361776290a9411d527eaf1fdb40bef399d"
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE session SET last_active = CURRENT_TIMESTAMP WHERE id = $1 AND logged_out IS NULL OR logged_out > CURRENT_TIMESTAMP",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "6d35ccf780fb2bb62586dd1d3df9c1550a41ee580dad3f49d35cb843ebef10ca"
|
||||
}
|
||||
@ -1,24 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO network_keys (package, interface, key) VALUES ($1, $2, $3) ON CONFLICT (package, interface) DO UPDATE SET package = EXCLUDED.package RETURNING key",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "key",
|
||||
"type_info": "Bytea"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Bytea"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "770c1017734720453dc87b58c385b987c5af5807151ff71a59000014586752e0"
|
||||
}
|
||||
@ -1,65 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id, package_id, created_at, code, level, title, message, data FROM notifications WHERE id < $1 ORDER BY id DESC LIMIT $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "package_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamp"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "code",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "level",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "title",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "message",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "data",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "7b64f032d507e8ffe37c41f4c7ad514a66c421a11ab04c26d89a7aa8f6b67210"
|
||||
}
|
||||
@ -1,19 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO account (\n id,\n server_id,\n hostname,\n password,\n network_key,\n root_ca_key_pem,\n root_ca_cert_pem\n ) VALUES (\n 0, $1, $2, $3, $4, $5, $6\n ) ON CONFLICT (id) DO UPDATE SET\n server_id = EXCLUDED.server_id,\n hostname = EXCLUDED.hostname,\n password = EXCLUDED.password,\n network_key = EXCLUDED.network_key,\n root_ca_key_pem = EXCLUDED.root_ca_key_pem,\n root_ca_cert_pem = EXCLUDED.root_ca_cert_pem\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Bytea",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "7c7a3549c997eb75bf964ea65fbb98a73045adf618696cd838d79203ef5383fb"
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM tor WHERE package = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "7e0649d839927e57fa03ee51a2c9f96a8bdb0fc97ee8a3c6df1069e1e2b98576"
|
||||
}
|
||||
@ -1,16 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO tor (package, interface, key) VALUES ($1, $2, $3) ON CONFLICT (package, interface) DO NOTHING",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Bytea"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "8951b9126fbf60dbb5997241e11e3526b70bccf3e407327917294a993bc17ed5"
|
||||
}
|
||||
@ -1,64 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id, package_id, created_at, code, level, title, message, data FROM notifications ORDER BY id DESC LIMIT $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "package_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamp"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "code",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "level",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "title",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "message",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "data",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "94d471bb374b4965c6cbedf8c17bbf6bea226d38efaf6559923c79a36d5ca08c"
|
||||
}
|
||||
@ -1,44 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id, hostname, path, username, password FROM cifs_shares",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "hostname",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "path",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "username",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "password",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "95c4ab4c645f3302568c6ff13d85ab58252362694cf0f56999bf60194d20583a"
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM cifs_shares WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a60d6e66719325b08dc4ecfacaf337527233c84eee758ac9be967906e5841d27"
|
||||
}
|
||||
@ -1,32 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT fingerprint, openssh_pubkey, created_at FROM ssh_keys",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "fingerprint",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "openssh_pubkey",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "created_at",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "a6b0c8909a3a5d6d9156aebfb359424e6b5a1d1402e028219e21726f1ebd282e"
|
||||
}
|
||||
@ -1,18 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE cifs_shares SET hostname = $1, path = $2, username = $3, password = $4 WHERE id = $5",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Int4"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "b1147beaaabbed89f2ab8c1e13ec4393a9a8fde2833cf096af766a979d94dee6"
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM network_keys WHERE package = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "b203820ee1c553a4b246eac74b79bd10d5717b2a0ddecf22330b7d531aac7c5d"
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE account SET tor_key = NULL, network_key = gen_random_bytes(32)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "b81592b3a74940ab56d41537484090d45cfa4c85168a587b1a41dc5393cccea1"
|
||||
}
|
||||
@ -1,20 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT openssh_pubkey FROM ssh_keys",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "openssh_pubkey",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "d5117054072476377f3c4f040ea429d4c9b2cf534e76f35c80a2bf60e8599cca"
|
||||
}
|
||||
@ -1,19 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO notifications (package_id, code, level, title, message, data) VALUES ($1, $2, $3, $4, $5, $6)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int4",
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "da71f94b29798d1738d2b10b9a721ea72db8cfb362e7181c8226d9297507c62b"
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM network_keys WHERE package = $1 AND interface = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "dfc23b7e966c3853284753a7e934351ba0cae3825988b3e0ecd3b6781bcff524"
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM notifications WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e185203cf84e43b801dfb23b4159e34aeaef1154dcd3d6811ab504915497ccf7"
|
||||
}
|
||||
@ -1,20 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT tor_key FROM account WHERE id = 0",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "tor_key",
|
||||
"type_info": "Bytea"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "e545696735f202f9d13cf22a561f3ff3f9aed7f90027a9ba97634bcb47d772f0"
|
||||
}
|
||||
@ -1,16 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO session (id, user_agent, metadata) VALUES ($1, $2, $3)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e5843c5b0e7819b29aa1abf2266799bd4f82e761837b526a0972c3d4439a264d"
|
||||
}
|
||||
@ -1,40 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n network_keys.package,\n network_keys.interface,\n network_keys.key,\n tor.key AS \"tor_key?\"\n FROM\n network_keys\n LEFT JOIN\n tor\n ON\n network_keys.package = tor.package\n AND\n network_keys.interface = tor.interface\n WHERE\n network_keys.package = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "package",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "interface",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "key",
|
||||
"type_info": "Bytea"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "tor_key?",
|
||||
"type_info": "Bytea"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "e95322a8e2ae3b93f1e974b24c0b81803f1e9ec9e8ebbf15cafddfc1c5a028ed"
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM notifications WHERE id < $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "eb750adaa305bdbf3c5b70aaf59139c7b7569602adb58f2d6b3a94da4f167b0a"
|
||||
}
|
||||
@ -1,25 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO cifs_shares (hostname, path, username, password) VALUES ($1, $2, $3, $4) RETURNING id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "ecc765d8205c0876956f95f76944ac6a5f34dd820c4073b7728c7067aab9fded"
|
||||
}
|
||||
@ -1,16 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO ssh_keys (fingerprint, openssh_pubkey, created_at) VALUES ($1, $2, $3)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f6d1c5ef0f9d9577bea8382318967b9deb46da75788c7fe6082b43821c22d556"
|
||||
}
|
||||
@ -1,20 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT network_key FROM account WHERE id = 0",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "network_key",
|
||||
"type_info": "Bytea"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "f7d2dae84613bcef330f7403352cc96547f3f6dbec11bf2eadfaf53ad8ab51b5"
|
||||
}
|
||||
@ -1,62 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT * FROM account WHERE id = 0",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "password",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "tor_key",
|
||||
"type_info": "Bytea"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "server_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "hostname",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "network_key",
|
||||
"type_info": "Bytea"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "root_ca_key_pem",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "root_ca_cert_pem",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "fe6e4f09f3028e5b6b6259e86cbad285680ce157aae9d7837ac020c8b2945e7f"
|
||||
}
|
||||
@ -1,169 +0,0 @@
|
||||
[package]
|
||||
authors = ["Aiden McClelland <me@drbonez.dev>"]
|
||||
description = "The core of StartOS"
|
||||
documentation = "https://docs.rs/start-os"
|
||||
edition = "2021"
|
||||
keywords = [
|
||||
"self-hosted",
|
||||
"raspberry-pi",
|
||||
"privacy",
|
||||
"bitcoin",
|
||||
"full-node",
|
||||
"lightning",
|
||||
]
|
||||
name = "start-os"
|
||||
readme = "README.md"
|
||||
repository = "https://github.com/Start9Labs/start-os"
|
||||
version = "0.3.5-rev.1"
|
||||
license = "MIT"
|
||||
|
||||
[lib]
|
||||
name = "startos"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "startbox"
|
||||
path = "src/main.rs"
|
||||
|
||||
[features]
|
||||
avahi = ["avahi-sys"]
|
||||
avahi-alias = ["avahi"]
|
||||
cli = []
|
||||
daemon = []
|
||||
default = ["cli", "sdk", "daemon", "js-engine"]
|
||||
dev = []
|
||||
docker = []
|
||||
sdk = []
|
||||
unstable = ["console-subscriber", "tokio/tracing"]
|
||||
|
||||
[dependencies]
|
||||
aes = { version = "0.7.5", features = ["ctr"] }
|
||||
async-compression = { version = "0.4.4", features = [
|
||||
"gzip",
|
||||
"brotli",
|
||||
"tokio",
|
||||
] }
|
||||
async-stream = "0.3.5"
|
||||
async-trait = "0.1.74"
|
||||
avahi-sys = { git = "https://github.com/Start9Labs/avahi-sys", version = "0.10.0", branch = "feature/dynamic-linking", features = [
|
||||
"dynamic",
|
||||
], optional = true }
|
||||
base32 = "0.4.0"
|
||||
base64 = "0.21.4"
|
||||
base64ct = "1.6.0"
|
||||
basic-cookies = "0.1.4"
|
||||
bytes = "1"
|
||||
chrono = { version = "0.4.31", features = ["serde"] }
|
||||
clap = "3.2.25"
|
||||
color-eyre = "0.6.2"
|
||||
console = "0.15.7"
|
||||
console-subscriber = { version = "0.2", optional = true }
|
||||
cookie = "0.18.0"
|
||||
cookie_store = "0.20.0"
|
||||
current_platform = "0.2.0"
|
||||
digest = "0.10.7"
|
||||
divrem = "1.0.0"
|
||||
ed25519 = { version = "2.2.3", features = ["pkcs8", "pem", "alloc"] }
|
||||
ed25519-dalek = { version = "2.0.0", features = [
|
||||
"serde",
|
||||
"zeroize",
|
||||
"rand_core",
|
||||
"digest",
|
||||
] }
|
||||
ed25519-dalek-v1 = { package = "ed25519-dalek", version = "1" }
|
||||
container-init = { path = "../container-init" }
|
||||
emver = { git = "https://github.com/Start9Labs/emver-rs.git", rev = "61cf0bc96711b4d6f3f30df8efef025e0cc02bad", features = [
|
||||
"serde",
|
||||
] }
|
||||
fd-lock-rs = "0.1.4"
|
||||
futures = "0.3.28"
|
||||
gpt = "3.1.0"
|
||||
helpers = { path = "../helpers" }
|
||||
hex = "0.4.3"
|
||||
hmac = "0.12.1"
|
||||
http = "0.2.9"
|
||||
hyper = { version = "0.14.27", features = ["full"] }
|
||||
hyper-ws-listener = "0.3.0"
|
||||
imbl = "2.0.2"
|
||||
imbl-value = { git = "https://github.com/Start9Labs/imbl-value.git" }
|
||||
include_dir = "0.7.3"
|
||||
indexmap = { version = "2.0.2", features = ["serde"] }
|
||||
indicatif = { version = "0.17.7", features = ["tokio"] }
|
||||
ipnet = { version = "2.8.0", features = ["serde"] }
|
||||
iprange = { version = "0.6.7", features = ["serde"] }
|
||||
isocountry = "0.3.2"
|
||||
itertools = "0.11.0"
|
||||
jaq-core = "0.10.1"
|
||||
jaq-std = "0.10.0"
|
||||
josekit = "0.8.4"
|
||||
js-engine = { path = '../js-engine', optional = true }
|
||||
jsonpath_lib = { git = "https://github.com/Start9Labs/jsonpath.git" }
|
||||
lazy_static = "1.4.0"
|
||||
libc = "0.2.149"
|
||||
log = "0.4.20"
|
||||
mbrman = "0.5.2"
|
||||
models = { version = "*", path = "../models" }
|
||||
new_mime_guess = "4"
|
||||
nix = { version = "0.27.1", features = ["user", "process", "signal", "fs"] }
|
||||
nom = "7.1.3"
|
||||
num = "0.4.1"
|
||||
num_enum = "0.7.0"
|
||||
openssh-keys = "0.6.2"
|
||||
openssl = { version = "0.10.57", features = ["vendored"] }
|
||||
p256 = { version = "0.13.2", features = ["pem"] }
|
||||
patch-db = { version = "*", path = "../../patch-db/patch-db", features = [
|
||||
"trace",
|
||||
] }
|
||||
pbkdf2 = "0.12.2"
|
||||
pin-project = "1.1.3"
|
||||
pkcs8 = { version = "0.10.2", features = ["std"] }
|
||||
prettytable-rs = "0.10.0"
|
||||
proptest = "1.3.1"
|
||||
proptest-derive = "0.4.0"
|
||||
rand = { version = "0.8.5", features = ["std"] }
|
||||
regex = "1.10.2"
|
||||
reqwest = { version = "0.11.22", features = ["stream", "json", "socks"] }
|
||||
reqwest_cookie_store = "0.6.0"
|
||||
rpassword = "7.2.0"
|
||||
rpc-toolkit = "0.2.2"
|
||||
rust-argon2 = "2.0.0"
|
||||
scopeguard = "1.1" # because avahi-sys fucks your shit up
|
||||
semver = { version = "1.0.20", features = ["serde"] }
|
||||
serde = { version = "1.0", features = ["derive", "rc"] }
|
||||
serde_cbor = { package = "ciborium", version = "0.2.1" }
|
||||
serde_json = "1.0"
|
||||
serde_toml = { package = "toml", version = "0.8.2" }
|
||||
serde_with = { version = "3.4.0", features = ["macros", "json"] }
|
||||
serde_yaml = "0.9.25"
|
||||
sha2 = "0.10.2"
|
||||
simple-logging = "2.0.2"
|
||||
sqlx = { version = "0.7.2", features = [
|
||||
"chrono",
|
||||
"runtime-tokio-rustls",
|
||||
"postgres",
|
||||
] }
|
||||
sscanf = "0.4.1"
|
||||
ssh-key = { version = "0.6.2", features = ["ed25519"] }
|
||||
stderrlog = "0.5.4"
|
||||
tar = "0.4.40"
|
||||
thiserror = "1.0.49"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-rustls = "0.24.1"
|
||||
tokio-socks = "0.5.1"
|
||||
tokio-stream = { version = "0.1.14", features = ["io-util", "sync", "net"] }
|
||||
tokio-tar = { git = "https://github.com/dr-bonez/tokio-tar.git" }
|
||||
tokio-tungstenite = { version = "0.20.1", features = ["native-tls"] }
|
||||
tokio-util = { version = "0.7.9", features = ["io"] }
|
||||
torut = "0.2.1"
|
||||
tracing = "0.1.39"
|
||||
tracing-error = "0.2.0"
|
||||
tracing-futures = "0.2.5"
|
||||
tracing-journald = "0.3.0"
|
||||
tracing-subscriber = { version = "0.3.17", features = ["env-filter"] }
|
||||
trust-dns-server = "0.23.1"
|
||||
typed-builder = "0.17.0"
|
||||
url = { version = "2.4.1", features = ["serde"] }
|
||||
urlencoding = "2.1.3"
|
||||
uuid = { version = "1.4.1", features = ["v4"] }
|
||||
zeroize = "1.6.0"
|
||||
|
||||
@ -1,22 +0,0 @@
|
||||
[licenses]
|
||||
unlicensed = "warn"
|
||||
allow-osi-fsf-free = "neither"
|
||||
copyleft = "deny"
|
||||
confidence-threshold = 0.93
|
||||
allow = [
|
||||
"Apache-2.0",
|
||||
"Apache-2.0 WITH LLVM-exception",
|
||||
"MIT",
|
||||
"ISC",
|
||||
"MPL-2.0",
|
||||
"CC0-1.0",
|
||||
"BSD-2-Clause",
|
||||
"BSD-3-Clause",
|
||||
"LGPL-3.0",
|
||||
"OpenSSL",
|
||||
]
|
||||
|
||||
clarify = [
|
||||
{ name = "webpki", expression = "ISC", license-files = [ { path = "LICENSE", hash = 0x001c7e6c } ] },
|
||||
{ name = "ring", expression = "OpenSSL", license-files = [ { path = "LICENSE", hash = 0xbd0eed23 } ] },
|
||||
]
|
||||
@ -1,60 +0,0 @@
|
||||
-- Add migration script here
|
||||
CREATE TABLE IF NOT EXISTS tor (
|
||||
package TEXT NOT NULL,
|
||||
interface TEXT NOT NULL,
|
||||
key BYTEA NOT NULL CHECK (length(key) = 64),
|
||||
PRIMARY KEY (package, interface)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS session (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
logged_in TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
logged_out TIMESTAMP,
|
||||
last_active TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
user_agent TEXT,
|
||||
metadata TEXT NOT NULL DEFAULT 'null'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS account (
|
||||
id SERIAL PRIMARY KEY CHECK (id = 0),
|
||||
password TEXT NOT NULL,
|
||||
tor_key BYTEA NOT NULL CHECK (length(tor_key) = 64)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ssh_keys (
|
||||
fingerprint TEXT NOT NULL,
|
||||
openssh_pubkey TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (fingerprint)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS certificates (
|
||||
id SERIAL PRIMARY KEY,
|
||||
-- Root = 0, Int = 1, Other = 2..
|
||||
priv_key_pem TEXT NOT NULL,
|
||||
certificate_pem TEXT NOT NULL,
|
||||
lookup_string TEXT UNIQUE,
|
||||
created_at TEXT,
|
||||
updated_at TEXT
|
||||
);
|
||||
|
||||
ALTER SEQUENCE certificates_id_seq START 2 RESTART 2;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id SERIAL PRIMARY KEY,
|
||||
package_id TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
code INTEGER NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
data TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cifs_shares (
|
||||
id SERIAL PRIMARY KEY,
|
||||
hostname TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
password TEXT
|
||||
);
|
||||
@ -1,62 +0,0 @@
|
||||
-- Add migration script here
|
||||
CREATE EXTENSION pgcrypto;
|
||||
|
||||
ALTER TABLE
|
||||
account
|
||||
ADD
|
||||
COLUMN server_id TEXT,
|
||||
ADD
|
||||
COLUMN hostname TEXT,
|
||||
ADD
|
||||
COLUMN network_key BYTEA CHECK (length(network_key) = 32),
|
||||
ADD
|
||||
COLUMN root_ca_key_pem TEXT,
|
||||
ADD
|
||||
COLUMN root_ca_cert_pem TEXT;
|
||||
|
||||
UPDATE
|
||||
account
|
||||
SET
|
||||
network_key = gen_random_bytes(32),
|
||||
root_ca_key_pem = (
|
||||
SELECT
|
||||
priv_key_pem
|
||||
FROM
|
||||
certificates
|
||||
WHERE
|
||||
id = 0
|
||||
),
|
||||
root_ca_cert_pem = (
|
||||
SELECT
|
||||
certificate_pem
|
||||
FROM
|
||||
certificates
|
||||
WHERE
|
||||
id = 0
|
||||
)
|
||||
WHERE
|
||||
id = 0;
|
||||
|
||||
ALTER TABLE
|
||||
account
|
||||
ALTER COLUMN
|
||||
tor_key DROP NOT NULL,
|
||||
ALTER COLUMN
|
||||
network_key
|
||||
SET
|
||||
NOT NULL,
|
||||
ALTER COLUMN
|
||||
root_ca_key_pem
|
||||
SET
|
||||
NOT NULL,
|
||||
ALTER COLUMN
|
||||
root_ca_cert_pem
|
||||
SET
|
||||
NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS network_keys (
|
||||
package TEXT NOT NULL,
|
||||
interface TEXT NOT NULL,
|
||||
key BYTEA NOT NULL CHECK (length(key) = 32),
|
||||
PRIMARY KEY (package, interface)
|
||||
);
|
||||
@ -1,132 +0,0 @@
|
||||
use std::time::SystemTime;
|
||||
|
||||
use ed25519_dalek::SecretKey;
|
||||
use openssl::pkey::{PKey, Private};
|
||||
use openssl::x509::X509;
|
||||
use sqlx::PgExecutor;
|
||||
|
||||
use crate::hostname::{generate_hostname, generate_id, Hostname};
|
||||
use crate::net::keys::Key;
|
||||
use crate::net::ssl::{generate_key, make_root_cert};
|
||||
use crate::prelude::*;
|
||||
use crate::util::crypto::ed25519_expand_key;
|
||||
|
||||
fn hash_password(password: &str) -> Result<String, Error> {
|
||||
argon2::hash_encoded(
|
||||
password.as_bytes(),
|
||||
&rand::random::<[u8; 16]>()[..],
|
||||
&argon2::Config::rfc9106_low_mem(),
|
||||
)
|
||||
.with_kind(crate::ErrorKind::PasswordHashGeneration)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AccountInfo {
|
||||
pub server_id: String,
|
||||
pub hostname: Hostname,
|
||||
pub password: String,
|
||||
pub key: Key,
|
||||
pub root_ca_key: PKey<Private>,
|
||||
pub root_ca_cert: X509,
|
||||
}
|
||||
impl AccountInfo {
|
||||
pub fn new(password: &str, start_time: SystemTime) -> Result<Self, Error> {
|
||||
let server_id = generate_id();
|
||||
let hostname = generate_hostname();
|
||||
let root_ca_key = generate_key()?;
|
||||
let root_ca_cert = make_root_cert(&root_ca_key, &hostname, start_time)?;
|
||||
Ok(Self {
|
||||
server_id,
|
||||
hostname,
|
||||
password: hash_password(password)?,
|
||||
key: Key::new(None),
|
||||
root_ca_key,
|
||||
root_ca_cert,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn load(secrets: impl PgExecutor<'_>) -> Result<Self, Error> {
|
||||
let r = sqlx::query!("SELECT * FROM account WHERE id = 0")
|
||||
.fetch_one(secrets)
|
||||
.await?;
|
||||
|
||||
let server_id = r.server_id.unwrap_or_else(generate_id);
|
||||
let hostname = r.hostname.map(Hostname).unwrap_or_else(generate_hostname);
|
||||
let password = r.password;
|
||||
let network_key = SecretKey::try_from(r.network_key).map_err(|e| {
|
||||
Error::new(
|
||||
eyre!("expected vec of len 32, got len {}", e.len()),
|
||||
ErrorKind::ParseDbField,
|
||||
)
|
||||
})?;
|
||||
let tor_key = if let Some(k) = &r.tor_key {
|
||||
<[u8; 64]>::try_from(&k[..]).map_err(|_| {
|
||||
Error::new(
|
||||
eyre!("expected vec of len 64, got len {}", k.len()),
|
||||
ErrorKind::ParseDbField,
|
||||
)
|
||||
})?
|
||||
} else {
|
||||
ed25519_expand_key(&network_key)
|
||||
};
|
||||
let key = Key::from_pair(None, network_key, tor_key);
|
||||
let root_ca_key = PKey::private_key_from_pem(r.root_ca_key_pem.as_bytes())?;
|
||||
let root_ca_cert = X509::from_pem(r.root_ca_cert_pem.as_bytes())?;
|
||||
|
||||
Ok(Self {
|
||||
server_id,
|
||||
hostname,
|
||||
password,
|
||||
key,
|
||||
root_ca_key,
|
||||
root_ca_cert,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn save(&self, secrets: impl PgExecutor<'_>) -> Result<(), Error> {
|
||||
let server_id = self.server_id.as_str();
|
||||
let hostname = self.hostname.0.as_str();
|
||||
let password = self.password.as_str();
|
||||
let network_key = self.key.as_bytes();
|
||||
let network_key = network_key.as_slice();
|
||||
let root_ca_key = String::from_utf8(self.root_ca_key.private_key_to_pem_pkcs8()?)?;
|
||||
let root_ca_cert = String::from_utf8(self.root_ca_cert.to_pem()?)?;
|
||||
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO account (
|
||||
id,
|
||||
server_id,
|
||||
hostname,
|
||||
password,
|
||||
network_key,
|
||||
root_ca_key_pem,
|
||||
root_ca_cert_pem
|
||||
) VALUES (
|
||||
0, $1, $2, $3, $4, $5, $6
|
||||
) ON CONFLICT (id) DO UPDATE SET
|
||||
server_id = EXCLUDED.server_id,
|
||||
hostname = EXCLUDED.hostname,
|
||||
password = EXCLUDED.password,
|
||||
network_key = EXCLUDED.network_key,
|
||||
root_ca_key_pem = EXCLUDED.root_ca_key_pem,
|
||||
root_ca_cert_pem = EXCLUDED.root_ca_cert_pem
|
||||
"#,
|
||||
server_id,
|
||||
hostname,
|
||||
password,
|
||||
network_key,
|
||||
root_ca_key,
|
||||
root_ca_cert,
|
||||
)
|
||||
.execute(secrets)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_password(&mut self, password: &str) -> Result<(), Error> {
|
||||
self.password = hash_password(password)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@ -1,163 +0,0 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use clap::ArgMatches;
|
||||
use color_eyre::eyre::eyre;
|
||||
use indexmap::IndexSet;
|
||||
pub use models::ActionId;
|
||||
use models::ImageId;
|
||||
use rpc_toolkit::command;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::instrument;
|
||||
|
||||
use crate::config::{Config, ConfigSpec};
|
||||
use crate::context::RpcContext;
|
||||
use crate::prelude::*;
|
||||
use crate::procedure::docker::DockerContainers;
|
||||
use crate::procedure::{PackageProcedure, ProcedureName};
|
||||
use crate::s9pk::manifest::PackageId;
|
||||
use crate::util::serde::{display_serializable, parse_stdin_deserializable, IoFormat};
|
||||
use crate::util::Version;
|
||||
use crate::volume::Volumes;
|
||||
use crate::{Error, ResultExt};
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct Actions(pub BTreeMap<ActionId, Action>);
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "version")]
|
||||
pub enum ActionResult {
|
||||
#[serde(rename = "0")]
|
||||
V0(ActionResultV0),
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ActionResultV0 {
|
||||
pub message: String,
|
||||
pub value: Option<String>,
|
||||
pub copyable: bool,
|
||||
pub qr: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum DockerStatus {
|
||||
Running,
|
||||
Stopped,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct Action {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub warning: Option<String>,
|
||||
pub implementation: PackageProcedure,
|
||||
pub allowed_statuses: IndexSet<DockerStatus>,
|
||||
#[serde(default)]
|
||||
pub input_spec: ConfigSpec,
|
||||
}
|
||||
impl Action {
|
||||
#[instrument(skip_all)]
|
||||
pub fn validate(
|
||||
&self,
|
||||
_container: &Option<DockerContainers>,
|
||||
eos_version: &Version,
|
||||
volumes: &Volumes,
|
||||
image_ids: &BTreeSet<ImageId>,
|
||||
) -> Result<(), Error> {
|
||||
self.implementation
|
||||
.validate(eos_version, volumes, image_ids, true)
|
||||
.with_ctx(|_| {
|
||||
(
|
||||
crate::ErrorKind::ValidateS9pk,
|
||||
format!("Action {}", self.name),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[instrument(skip_all)]
|
||||
pub async fn execute(
|
||||
&self,
|
||||
ctx: &RpcContext,
|
||||
pkg_id: &PackageId,
|
||||
pkg_version: &Version,
|
||||
action_id: &ActionId,
|
||||
volumes: &Volumes,
|
||||
input: Option<Config>,
|
||||
) -> Result<ActionResult, Error> {
|
||||
if let Some(ref input) = input {
|
||||
self.input_spec
|
||||
.matches(&input)
|
||||
.with_kind(crate::ErrorKind::ConfigSpecViolation)?;
|
||||
}
|
||||
self.implementation
|
||||
.execute(
|
||||
ctx,
|
||||
pkg_id,
|
||||
pkg_version,
|
||||
ProcedureName::Action(action_id.clone()),
|
||||
volumes,
|
||||
input,
|
||||
None,
|
||||
)
|
||||
.await?
|
||||
.map_err(|e| Error::new(eyre!("{}", e.1), crate::ErrorKind::Action))
|
||||
}
|
||||
}
|
||||
|
||||
fn display_action_result(action_result: ActionResult, matches: &ArgMatches) {
|
||||
if matches.is_present("format") {
|
||||
return display_serializable(action_result, matches);
|
||||
}
|
||||
match action_result {
|
||||
ActionResult::V0(ar) => {
|
||||
println!(
|
||||
"{}: {}",
|
||||
ar.message,
|
||||
serde_json::to_string(&ar.value).unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[command(about = "Executes an action", display(display_action_result))]
|
||||
#[instrument(skip_all)]
|
||||
pub async fn action(
|
||||
#[context] ctx: RpcContext,
|
||||
#[arg(rename = "id")] pkg_id: PackageId,
|
||||
#[arg(rename = "action-id")] action_id: ActionId,
|
||||
#[arg(stdin, parse(parse_stdin_deserializable))] input: Option<Config>,
|
||||
#[allow(unused_variables)]
|
||||
#[arg(long = "format")]
|
||||
format: Option<IoFormat>,
|
||||
) -> Result<ActionResult, Error> {
|
||||
let manifest = ctx
|
||||
.db
|
||||
.peek()
|
||||
.await
|
||||
.as_package_data()
|
||||
.as_idx(&pkg_id)
|
||||
.or_not_found(&pkg_id)?
|
||||
.as_installed()
|
||||
.or_not_found(&pkg_id)?
|
||||
.as_manifest()
|
||||
.de()?;
|
||||
|
||||
if let Some(action) = manifest.actions.0.get(&action_id) {
|
||||
action
|
||||
.execute(
|
||||
&ctx,
|
||||
&manifest.id,
|
||||
&manifest.version,
|
||||
&action_id,
|
||||
&manifest.volumes,
|
||||
input,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
Err(Error::new(
|
||||
eyre!("Action not found in manifest"),
|
||||
crate::ErrorKind::NotFound,
|
||||
))
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,391 +0,0 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use clap::ArgMatches;
|
||||
use color_eyre::eyre::eyre;
|
||||
use josekit::jwk::Jwk;
|
||||
use rpc_toolkit::command;
|
||||
use rpc_toolkit::command_helpers::prelude::{RequestParts, ResponseParts};
|
||||
use rpc_toolkit::yajrc::RpcError;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use sqlx::{Executor, Postgres};
|
||||
use tracing::instrument;
|
||||
|
||||
use crate::context::{CliContext, RpcContext};
|
||||
use crate::middleware::auth::{AsLogoutSessionId, HasLoggedOutSessions, HashSessionToken};
|
||||
use crate::middleware::encrypt::EncryptedWire;
|
||||
use crate::prelude::*;
|
||||
use crate::util::display_none;
|
||||
use crate::util::serde::{display_serializable, IoFormat};
|
||||
use crate::{ensure_code, Error, ResultExt};
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum PasswordType {
|
||||
EncryptedWire(EncryptedWire),
|
||||
String(String),
|
||||
}
|
||||
impl PasswordType {
|
||||
pub fn decrypt(self, current_secret: impl AsRef<Jwk>) -> Result<String, Error> {
|
||||
match self {
|
||||
PasswordType::String(x) => Ok(x),
|
||||
PasswordType::EncryptedWire(x) => x.decrypt(current_secret).ok_or_else(|| {
|
||||
Error::new(
|
||||
color_eyre::eyre::eyre!("Couldn't decode password"),
|
||||
crate::ErrorKind::Unknown,
|
||||
)
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Default for PasswordType {
|
||||
fn default() -> Self {
|
||||
PasswordType::String(String::default())
|
||||
}
|
||||
}
|
||||
impl std::fmt::Debug for PasswordType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "<REDACTED_PASSWORD>")?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for PasswordType {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Ok(match serde_json::from_str(s) {
|
||||
Ok(a) => a,
|
||||
Err(_) => PasswordType::String(s.to_string()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[command(subcommands(login, logout, session, reset_password, get_pubkey))]
|
||||
pub fn auth() -> Result<(), Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cli_metadata() -> Value {
|
||||
serde_json::json!({
|
||||
"platforms": ["cli"],
|
||||
})
|
||||
}
|
||||
|
||||
pub fn parse_metadata(_: &str, _: &ArgMatches) -> Result<Value, Error> {
|
||||
Ok(cli_metadata())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gen_pwd() {
|
||||
println!(
|
||||
"{:?}",
|
||||
argon2::hash_encoded(
|
||||
b"testing1234",
|
||||
&rand::random::<[u8; 16]>()[..],
|
||||
&argon2::Config::rfc9106_low_mem()
|
||||
)
|
||||
.unwrap()
|
||||
)
|
||||
}
|
||||
|
||||
#[instrument(skip_all)]
|
||||
async fn cli_login(
|
||||
ctx: CliContext,
|
||||
password: Option<PasswordType>,
|
||||
metadata: Value,
|
||||
) -> Result<(), RpcError> {
|
||||
let password = if let Some(password) = password {
|
||||
password.decrypt(&ctx)?
|
||||
} else {
|
||||
rpassword::prompt_password("Password: ")?
|
||||
};
|
||||
|
||||
rpc_toolkit::command_helpers::call_remote(
|
||||
ctx,
|
||||
"auth.login",
|
||||
serde_json::json!({ "password": password, "metadata": metadata }),
|
||||
PhantomData::<()>,
|
||||
)
|
||||
.await?
|
||||
.result?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn check_password(hash: &str, password: &str) -> Result<(), Error> {
|
||||
ensure_code!(
|
||||
argon2::verify_encoded(&hash, password.as_bytes()).map_err(|_| {
|
||||
Error::new(
|
||||
eyre!("Password Incorrect"),
|
||||
crate::ErrorKind::IncorrectPassword,
|
||||
)
|
||||
})?,
|
||||
crate::ErrorKind::IncorrectPassword,
|
||||
"Password Incorrect"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn check_password_against_db<Ex>(secrets: &mut Ex, password: &str) -> Result<(), Error>
|
||||
where
|
||||
for<'a> &'a mut Ex: Executor<'a, Database = Postgres>,
|
||||
{
|
||||
let pw_hash = sqlx::query!("SELECT password FROM account")
|
||||
.fetch_one(secrets)
|
||||
.await?
|
||||
.password;
|
||||
check_password(&pw_hash, password)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[command(
|
||||
custom_cli(cli_login(async, context(CliContext))),
|
||||
display(display_none),
|
||||
metadata(authenticated = false)
|
||||
)]
|
||||
#[instrument(skip_all)]
|
||||
pub async fn login(
|
||||
#[context] ctx: RpcContext,
|
||||
#[request] req: &RequestParts,
|
||||
#[response] res: &mut ResponseParts,
|
||||
#[arg] password: Option<PasswordType>,
|
||||
#[arg(
|
||||
parse(parse_metadata),
|
||||
default = "cli_metadata",
|
||||
help = "RPC Only: This value cannot be overidden from the cli"
|
||||
)]
|
||||
metadata: Value,
|
||||
) -> Result<(), Error> {
|
||||
let password = password.unwrap_or_default().decrypt(&ctx)?;
|
||||
let mut handle = ctx.secret_store.acquire().await?;
|
||||
check_password_against_db(handle.as_mut(), &password).await?;
|
||||
|
||||
let hash_token = HashSessionToken::new();
|
||||
let user_agent = req.headers.get("user-agent").and_then(|h| h.to_str().ok());
|
||||
let metadata = serde_json::to_string(&metadata).with_kind(crate::ErrorKind::Database)?;
|
||||
let hash_token_hashed = hash_token.hashed();
|
||||
sqlx::query!(
|
||||
"INSERT INTO session (id, user_agent, metadata) VALUES ($1, $2, $3)",
|
||||
hash_token_hashed,
|
||||
user_agent,
|
||||
metadata,
|
||||
)
|
||||
.execute(handle.as_mut())
|
||||
.await?;
|
||||
res.headers.insert(
|
||||
"set-cookie",
|
||||
hash_token.header_value()?, // Should be impossible, but don't want to panic
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[command(display(display_none), metadata(authenticated = false))]
|
||||
#[instrument(skip_all)]
|
||||
pub async fn logout(
|
||||
#[context] ctx: RpcContext,
|
||||
#[request] req: &RequestParts,
|
||||
) -> Result<Option<HasLoggedOutSessions>, Error> {
|
||||
let auth = match HashSessionToken::from_request_parts(req) {
|
||||
Err(_) => return Ok(None),
|
||||
Ok(a) => a,
|
||||
};
|
||||
Ok(Some(HasLoggedOutSessions::new(vec![auth], &ctx).await?))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct Session {
|
||||
logged_in: DateTime<Utc>,
|
||||
last_active: DateTime<Utc>,
|
||||
user_agent: Option<String>,
|
||||
metadata: Value,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct SessionList {
|
||||
current: String,
|
||||
sessions: BTreeMap<String, Session>,
|
||||
}
|
||||
|
||||
#[command(subcommands(list, kill))]
|
||||
pub async fn session() -> Result<(), Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn display_sessions(arg: SessionList, matches: &ArgMatches) {
|
||||
use prettytable::*;
|
||||
|
||||
if matches.is_present("format") {
|
||||
return display_serializable(arg, matches);
|
||||
}
|
||||
|
||||
let mut table = Table::new();
|
||||
table.add_row(row![bc =>
|
||||
"ID",
|
||||
"LOGGED IN",
|
||||
"LAST ACTIVE",
|
||||
"USER AGENT",
|
||||
"METADATA",
|
||||
]);
|
||||
for (id, session) in arg.sessions {
|
||||
let mut row = row![
|
||||
&id,
|
||||
&format!("{}", session.logged_in),
|
||||
&format!("{}", session.last_active),
|
||||
session.user_agent.as_deref().unwrap_or("N/A"),
|
||||
&format!("{}", session.metadata),
|
||||
];
|
||||
if id == arg.current {
|
||||
row.iter_mut()
|
||||
.map(|c| c.style(Attr::ForegroundColor(color::GREEN)))
|
||||
.collect::<()>()
|
||||
}
|
||||
table.add_row(row);
|
||||
}
|
||||
table.print_tty(false).unwrap();
|
||||
}
|
||||
|
||||
#[command(display(display_sessions))]
|
||||
#[instrument(skip_all)]
|
||||
pub async fn list(
|
||||
#[context] ctx: RpcContext,
|
||||
#[request] req: &RequestParts,
|
||||
#[allow(unused_variables)]
|
||||
#[arg(long = "format")]
|
||||
format: Option<IoFormat>,
|
||||
) -> Result<SessionList, Error> {
|
||||
Ok(SessionList {
|
||||
current: HashSessionToken::from_request_parts(req)?.as_hash(),
|
||||
sessions: sqlx::query!(
|
||||
"SELECT * FROM session WHERE logged_out IS NULL OR logged_out > CURRENT_TIMESTAMP"
|
||||
)
|
||||
.fetch_all(ctx.secret_store.acquire().await?.as_mut())
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
Ok((
|
||||
row.id,
|
||||
Session {
|
||||
logged_in: DateTime::from_utc(row.logged_in, Utc),
|
||||
last_active: DateTime::from_utc(row.last_active, Utc),
|
||||
user_agent: row.user_agent,
|
||||
metadata: serde_json::from_str(&row.metadata)
|
||||
.with_kind(crate::ErrorKind::Database)?,
|
||||
},
|
||||
))
|
||||
})
|
||||
.collect::<Result<_, Error>>()?,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_comma_separated(arg: &str, _: &ArgMatches) -> Result<Vec<String>, RpcError> {
|
||||
Ok(arg.split(",").map(|s| s.trim().to_owned()).collect())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct KillSessionId(String);
|
||||
|
||||
impl AsLogoutSessionId for KillSessionId {
|
||||
fn as_logout_session_id(self) -> String {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[command(display(display_none))]
|
||||
#[instrument(skip_all)]
|
||||
pub async fn kill(
|
||||
#[context] ctx: RpcContext,
|
||||
#[arg(parse(parse_comma_separated))] ids: Vec<String>,
|
||||
) -> Result<(), Error> {
|
||||
HasLoggedOutSessions::new(ids.into_iter().map(KillSessionId), &ctx).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[instrument(skip_all)]
|
||||
async fn cli_reset_password(
|
||||
ctx: CliContext,
|
||||
old_password: Option<PasswordType>,
|
||||
new_password: Option<PasswordType>,
|
||||
) -> Result<(), RpcError> {
|
||||
let old_password = if let Some(old_password) = old_password {
|
||||
old_password.decrypt(&ctx)?
|
||||
} else {
|
||||
rpassword::prompt_password("Current Password: ")?
|
||||
};
|
||||
|
||||
let new_password = if let Some(new_password) = new_password {
|
||||
new_password.decrypt(&ctx)?
|
||||
} else {
|
||||
let new_password = rpassword::prompt_password("New Password: ")?;
|
||||
if new_password != rpassword::prompt_password("Confirm: ")? {
|
||||
return Err(Error::new(
|
||||
eyre!("Passwords do not match"),
|
||||
crate::ErrorKind::IncorrectPassword,
|
||||
)
|
||||
.into());
|
||||
}
|
||||
new_password
|
||||
};
|
||||
|
||||
rpc_toolkit::command_helpers::call_remote(
|
||||
ctx,
|
||||
"auth.reset-password",
|
||||
serde_json::json!({ "old-password": old_password, "new-password": new_password }),
|
||||
PhantomData::<()>,
|
||||
)
|
||||
.await?
|
||||
.result?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[command(
|
||||
rename = "reset-password",
|
||||
custom_cli(cli_reset_password(async, context(CliContext))),
|
||||
display(display_none)
|
||||
)]
|
||||
#[instrument(skip_all)]
|
||||
pub async fn reset_password(
|
||||
#[context] ctx: RpcContext,
|
||||
#[arg(rename = "old-password")] old_password: Option<PasswordType>,
|
||||
#[arg(rename = "new-password")] new_password: Option<PasswordType>,
|
||||
) -> Result<(), Error> {
|
||||
let old_password = old_password.unwrap_or_default().decrypt(&ctx)?;
|
||||
let new_password = new_password.unwrap_or_default().decrypt(&ctx)?;
|
||||
|
||||
let mut account = ctx.account.write().await;
|
||||
if !argon2::verify_encoded(&account.password, old_password.as_bytes())
|
||||
.with_kind(crate::ErrorKind::IncorrectPassword)?
|
||||
{
|
||||
return Err(Error::new(
|
||||
eyre!("Incorrect Password"),
|
||||
crate::ErrorKind::IncorrectPassword,
|
||||
));
|
||||
}
|
||||
account.set_password(&new_password)?;
|
||||
account.save(&ctx.secret_store).await?;
|
||||
let account_password = &account.password;
|
||||
ctx.db
|
||||
.mutate(|d| {
|
||||
d.as_server_info_mut()
|
||||
.as_password_hash_mut()
|
||||
.ser(account_password)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[command(
|
||||
rename = "get-pubkey",
|
||||
display(display_none),
|
||||
metadata(authenticated = false)
|
||||
)]
|
||||
#[instrument(skip_all)]
|
||||
pub async fn get_pubkey(#[context] ctx: RpcContext) -> Result<Jwk, RpcError> {
|
||||
let secret = ctx.as_ref().clone();
|
||||
let pub_key = secret.to_public_key()?;
|
||||
Ok(pub_key)
|
||||
}
|
||||
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