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:
Dorian 2026-03-12 00:19:30 +00:00
parent 2a867b32a8
commit 6fee6befed
347 changed files with 18703 additions and 46785 deletions

11
.claude/memory/MEMORY.md Normal file
View 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

View 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

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

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

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

View 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

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

View File

@ -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(),
claims,
issued_at,
expires_at: expires_at.map(|s| s.to_string()),
signature,
status: CredentialStatus::Active,
credential_subject: CredentialSubject {
id: subject_did.to_string(),
claims,
},
issuance_date: issued_at.clone(),
expiration_date: expires_at.map(|s| s.to_string()),
proof: CredentialProof {
proof_type: "Ed25519Signature2020".to_string(),
created: issued_at,
verification_method: key_id,
proof_purpose: "assertionMethod".to_string(),
proof_value: signature,
},
credential_status: None,
};
let mut store = load_credentials(data_dir).await?;
debug!(id = %vc.id, "Issued 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());
}
}

View File

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

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

View File

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

View 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", &timestamp)
.json(&body)
.send()
.await
.context("Failed to reach federated peer")?;
if !resp.status().is_success() {
anyhow::bail!("Peer returned {}", resp.status());
}
let result: serde_json::Value = resp.json().await.context("Invalid response from peer")?;
let state_val = result
.get("result")
.ok_or_else(|| anyhow::anyhow!("No result in peer response"))?;
let state: NodeStateSnapshot =
serde_json::from_value(state_val.clone()).context("Failed to parse peer state")?;
update_node_state(data_dir, &peer.did, state.clone()).await?;
Ok(state)
}
/// Build the local node's state snapshot for sharing with peers.
pub fn build_local_state(
apps: Vec<AppStatus>,
cpu: f64,
mem_used: u64,
mem_total: u64,
disk_used: u64,
disk_total: u64,
uptime: u64,
tor_active: bool,
) -> NodeStateSnapshot {
NodeStateSnapshot {
timestamp: chrono::Utc::now().to_rfc3339(),
apps,
cpu_usage_percent: Some(cpu),
mem_used_bytes: Some(mem_used),
mem_total_bytes: Some(mem_total),
disk_used_bytes: Some(disk_used),
disk_total_bytes: Some(disk_total),
uptime_secs: Some(uptime),
tor_active: Some(tor_active),
}
}
/// Deploy an app to a remote federated peer over Tor.
/// Only works if the peer is trusted and the app exists in our marketplace.
pub async fn deploy_to_peer(
peer: &FederatedNode,
app_id: &str,
version: &str,
marketplace_url: &str,
local_did: &str,
sign_fn: impl FnOnce(&[u8]) -> String,
) -> Result<serde_json::Value> {
if peer.trust_level != TrustLevel::Trusted {
anyhow::bail!("Can only deploy to trusted peers (current: {})", peer.trust_level);
}
let host = if peer.onion.ends_with(".onion") {
peer.onion.clone()
} else {
format!("{}.onion", peer.onion)
};
let url = format!("http://{}/rpc/v1", host);
let timestamp = chrono::Utc::now().to_rfc3339();
let signature = sign_fn(timestamp.as_bytes());
let body = serde_json::json!({
"method": "package.install",
"params": {
"id": app_id,
"version": version,
"marketplace-url": marketplace_url,
}
});
let proxy = reqwest::Proxy::all("socks5h://127.0.0.1:9050").context("Invalid Tor proxy")?;
let client = reqwest::Client::builder()
.proxy(proxy)
.timeout(std::time::Duration::from_secs(120))
.build()
.context("Failed to build HTTP client")?;
let resp = client
.post(&url)
.header("X-Federation-DID", local_did)
.header("X-Federation-Sig", &signature)
.header("X-Federation-Timestamp", &timestamp)
.json(&body)
.send()
.await
.context("Failed to reach federated peer for deploy")?;
if !resp.status().is_success() {
anyhow::bail!("Remote node returned HTTP {}", resp.status());
}
let result: serde_json::Value = resp.json().await.context("Invalid response from peer")?;
if let Some(err) = result.get("error") {
if !err.is_null() {
let msg = err.get("message").and_then(|m| m.as_str()).unwrap_or("Unknown remote error");
anyhow::bail!("Remote node refused deploy: {}", msg);
}
}
Ok(serde_json::json!({
"deployed": true,
"app_id": app_id,
"peer_did": peer.did,
"peer_onion": peer.onion,
}))
}
#[cfg(test)]
mod tests {
use super::*;
fn make_node(did: &str, onion: &str) -> FederatedNode {
FederatedNode {
did: did.to_string(),
pubkey: "aabbccdd".to_string(),
onion: onion.to_string(),
name: None,
trust_level: TrustLevel::Trusted,
added_at: "2026-01-01T00:00:00Z".to_string(),
last_seen: None,
last_state: None,
}
}
#[test]
fn test_trust_level_serialization() {
let json = serde_json::to_string(&TrustLevel::Trusted).unwrap();
assert_eq!(json, "\"trusted\"");
let parsed: TrustLevel = serde_json::from_str("\"observer\"").unwrap();
assert_eq!(parsed, TrustLevel::Observer);
}
#[test]
fn test_federated_node_serialization_roundtrip() {
let node = make_node("did:key:zABC", "test.onion");
let json = serde_json::to_string(&node).unwrap();
let parsed: FederatedNode = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.did, "did:key:zABC");
assert_eq!(parsed.trust_level, TrustLevel::Trusted);
assert!(parsed.last_state.is_none());
}
#[test]
fn test_node_state_snapshot_defaults() {
let json = r#"{"timestamp": "2026-01-01T00:00:00Z"}"#;
let state: NodeStateSnapshot = serde_json::from_str(json).unwrap();
assert!(state.apps.is_empty());
assert!(state.cpu_usage_percent.is_none());
}
#[tokio::test]
async fn test_load_nodes_empty_when_no_file() {
let dir = tempfile::tempdir().unwrap();
let nodes = load_nodes(dir.path()).await.unwrap();
assert!(nodes.is_empty());
}
#[tokio::test]
async fn test_save_and_load_nodes_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let nodes = vec![
make_node("did:key:z1", "a.onion"),
make_node("did:key:z2", "b.onion"),
];
save_nodes(dir.path(), &nodes).await.unwrap();
let loaded = load_nodes(dir.path()).await.unwrap();
assert_eq!(loaded.len(), 2);
assert_eq!(loaded[0].did, "did:key:z1");
}
#[tokio::test]
async fn test_add_node_deduplicates_by_did() {
let dir = tempfile::tempdir().unwrap();
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
.await
.unwrap();
let result = add_node(dir.path(), make_node("did:key:z1", "b.onion")).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_remove_node_by_did() {
let dir = tempfile::tempdir().unwrap();
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
.await
.unwrap();
add_node(dir.path(), make_node("did:key:z2", "b.onion"))
.await
.unwrap();
let result = remove_node(dir.path(), "did:key:z1").await.unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].did, "did:key:z2");
}
#[tokio::test]
async fn test_remove_nonexistent_node_errors() {
let dir = tempfile::tempdir().unwrap();
let result = remove_node(dir.path(), "did:key:nonexistent").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_set_trust_level() {
let dir = tempfile::tempdir().unwrap();
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
.await
.unwrap();
let nodes = set_trust_level(dir.path(), "did:key:z1", TrustLevel::Observer)
.await
.unwrap();
assert_eq!(nodes[0].trust_level, TrustLevel::Observer);
}
#[tokio::test]
async fn test_update_node_state() {
let dir = tempfile::tempdir().unwrap();
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
.await
.unwrap();
let state = NodeStateSnapshot {
timestamp: "2026-03-10T12:00:00Z".to_string(),
apps: vec![AppStatus {
id: "bitcoin".to_string(),
status: "running".to_string(),
version: Some("27.0".to_string()),
}],
cpu_usage_percent: Some(45.2),
mem_used_bytes: Some(4_000_000_000),
mem_total_bytes: Some(8_000_000_000),
disk_used_bytes: None,
disk_total_bytes: None,
uptime_secs: Some(86400),
tor_active: Some(true),
};
update_node_state(dir.path(), "did:key:z1", state)
.await
.unwrap();
let nodes = load_nodes(dir.path()).await.unwrap();
assert!(nodes[0].last_seen.is_some());
let ls = nodes[0].last_state.as_ref().unwrap();
assert_eq!(ls.apps.len(), 1);
assert_eq!(ls.cpu_usage_percent, Some(45.2));
}
#[tokio::test]
async fn test_create_and_parse_invite() {
let dir = tempfile::tempdir().unwrap();
let code = create_invite(dir.path(), "did:key:z1", "test.onion", "aabbcc")
.await
.unwrap();
assert!(code.starts_with("fed1:"));
let (did, onion, pubkey, token) = parse_invite(&code).unwrap();
assert_eq!(did, "did:key:z1");
assert_eq!(onion, "test.onion");
assert_eq!(pubkey, "aabbcc");
assert_eq!(token.len(), 32); // 16 bytes = 32 hex chars
}
#[test]
fn test_parse_invalid_invite() {
assert!(parse_invite("invalid").is_err());
assert!(parse_invite("fed1:not-valid-base64!!!").is_err());
}
#[tokio::test]
async fn test_accept_invite_creates_node() {
let dir = tempfile::tempdir().unwrap();
let code = create_invite(dir.path(), "did:key:zRemote", "remote.onion", "remotepub")
.await
.unwrap();
// Accept from a different "local" perspective
let dir2 = tempfile::tempdir().unwrap();
let node = accept_invite(
dir2.path(),
&code,
"did:key:zLocal",
"local.onion",
"localpub",
)
.await
.unwrap();
assert_eq!(node.did, "did:key:zRemote");
assert_eq!(node.trust_level, TrustLevel::Trusted);
let nodes = load_nodes(dir2.path()).await.unwrap();
assert_eq!(nodes.len(), 1);
}
#[tokio::test]
async fn test_accept_invite_rejects_duplicate() {
let dir = tempfile::tempdir().unwrap();
let code = create_invite(dir.path(), "did:key:zRemote", "remote.onion", "remotepub")
.await
.unwrap();
let dir2 = tempfile::tempdir().unwrap();
accept_invite(
dir2.path(),
&code,
"did:key:zLocal",
"local.onion",
"localpub",
)
.await
.unwrap();
// Accepting the same invite again should fail
let result = accept_invite(
dir2.path(),
&code,
"did:key:zLocal",
"local.onion",
"localpub",
)
.await;
assert!(result.is_err());
}
#[test]
fn test_build_local_state() {
let state = build_local_state(
vec![AppStatus {
id: "lnd".to_string(),
status: "running".to_string(),
version: Some("0.18".to_string()),
}],
25.5,
2_000_000_000,
8_000_000_000,
100_000_000_000,
500_000_000_000,
3600,
true,
);
assert_eq!(state.apps.len(), 1);
assert_eq!(state.cpu_usage_percent, Some(25.5));
assert_eq!(state.tor_active, Some(true));
}
}

View File

@ -0,0 +1,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);
}
}

View File

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

View File

@ -27,6 +27,7 @@ mod mesh;
mod monitoring;
mod node_message;
mod nostr_discovery;
mod nostr_handshake;
mod peers;
mod server;
mod session;

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

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

View File

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

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

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

View File

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

View File

@ -1,2 +1,4 @@
pub mod dns;
pub mod dwn_store;
pub mod dwn_sync;
pub mod router;

View File

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

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

View File

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

View File

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

View File

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

View File

@ -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,25 +98,25 @@ 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)
let mut interval = tokio::time::interval(Duration::from_secs(10));
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);
}
}
});

View File

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

View File

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

View File

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

View File

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

View File

@ -34,9 +34,8 @@ impl DependencyResolver {
let mut result = Vec::new();
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)
}

View File

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

View File

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

View File

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

View File

@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM ssh_keys WHERE fingerprint = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "21471490cdc3adb206274cc68e1ea745ffa5da4479478c1fd2158a45324b1930"
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM tor WHERE package = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "7e0649d839927e57fa03ee51a2c9f96a8bdb0fc97ee8a3c6df1069e1e2b98576"
}

View File

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

View File

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

View File

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

View File

@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM cifs_shares WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int4"
]
},
"nullable": []
},
"hash": "a60d6e66719325b08dc4ecfacaf337527233c84eee758ac9be967906e5841d27"
}

View File

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

View File

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

View File

@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM network_keys WHERE package = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "b203820ee1c553a4b246eac74b79bd10d5717b2a0ddecf22330b7d531aac7c5d"
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM notifications WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int4"
]
},
"nullable": []
},
"hash": "e185203cf84e43b801dfb23b4159e34aeaef1154dcd3d6811ab504915497ccf7"
}

View File

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

View File

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

View File

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

View File

@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM notifications WHERE id < $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int4"
]
},
"nullable": []
},
"hash": "eb750adaa305bdbf3c5b70aaf59139c7b7569602adb58f2d6b3a94da4f167b0a"
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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