Archipelago — open-source initial import
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.116-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
|
||||
[[bin]]
|
||||
name = "archipelago"
|
||||
path = "src/main.rs"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# DHT Phase 2: iroh-blobs peer swarm engine. OFF by default — it pulls a heavy
|
||||
# QUIC dependency tree, so it ships behind a flag for PoC/measurement on a
|
||||
# scratch node before any fleet rollout. With the flag off, swarm::providers()
|
||||
# is empty and every fetch goes straight to the origin HTTP path (today's
|
||||
# behaviour). Attach the optional iroh / iroh-blobs deps to this feature when
|
||||
# wiring the IrohProvider.
|
||||
iroh-swarm = ["dep:iroh", "dep:iroh-blobs"]
|
||||
|
||||
[dependencies]
|
||||
# Core dependencies
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
# Mesh port mirror: needs IPV6_V6ONLY on [::] listeners so they coexist with
|
||||
# the containers' own 0.0.0.0 binds (std/tokio don't expose the sockopt).
|
||||
socket2 = "0.5"
|
||||
libc = "0.2" # process-group signalling for the supervised reticulum daemon
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
anyhow = "1.0"
|
||||
thiserror = "1.0"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
# HTTP and WebSocket
|
||||
hyper = { version = "0.14", features = ["full", "http1"] }
|
||||
hyper-util = { version = "0.1", features = ["full", "http1"] }
|
||||
http-body-util = "0.1"
|
||||
http-body = "1.0"
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["cors", "trace"] }
|
||||
hyper-ws-listener = "0.3.0"
|
||||
tokio-tungstenite = "0.20"
|
||||
futures-util = "0.3"
|
||||
|
||||
# Our modules
|
||||
archipelago-container = { path = "../container" }
|
||||
archipelago-openwrt = { path = "../openwrt" }
|
||||
archipelago-security = { path = "../security" }
|
||||
archipelago-performance = { path = "../performance" }
|
||||
|
||||
|
||||
# Database (optional for now - can use SQLite or skip)
|
||||
# sqlx = { version = "0.7", features = ["sqlite", "runtime-tokio-rustls"] }
|
||||
|
||||
# Authentication
|
||||
bcrypt = "0.15"
|
||||
sha2 = "0.10.9"
|
||||
blake3 = "1"
|
||||
hmac = "0.12.1"
|
||||
uuid = { version = "1.0", features = ["v4"] }
|
||||
regex = "1.10"
|
||||
|
||||
# Node identity (Ed25519 + X25519 key agreement)
|
||||
ed25519-dalek = { version = "2.2.0", features = ["rand_core"] }
|
||||
curve25519-dalek = "4.1.3"
|
||||
rand = "0.8.5"
|
||||
hex = "0.4"
|
||||
bs58 = "0.5"
|
||||
chrono = "0.4"
|
||||
|
||||
# BIP-39 mnemonic seed generation + BIP-32 HD key derivation
|
||||
bip39 = { version = "=2.1.0", features = ["rand"] }
|
||||
bitcoin = { version = "=0.32.5", features = ["rand-std"] }
|
||||
|
||||
# Configuration
|
||||
toml = "0.8"
|
||||
serde_yaml = "0.9"
|
||||
|
||||
# HTTP client (for LND REST proxy, Tor SOCKS for peer messaging)
|
||||
# Uses rustls-tls for cross-compilation (no OpenSSL dependency)
|
||||
reqwest = { version = "0.11", default-features = false, features = ["json", "socks", "rustls-tls", "stream"] }
|
||||
|
||||
# Nostr (node discovery + NIP-44 encrypted peer handshake)
|
||||
nostr-sdk = { version = "0.44", features = ["nip04", "nip44"] }
|
||||
|
||||
# Backup encryption (DID identity export) + TOTP 2FA encryption
|
||||
argon2 = "0.5.3"
|
||||
chacha20poly1305 = "0.10.1"
|
||||
base64 = "0.21"
|
||||
|
||||
# Full system backup (tar archive + gzip compression)
|
||||
tar = "0.4"
|
||||
flate2 = "1.0"
|
||||
|
||||
# TOTP 2FA
|
||||
totp-rs = { version = "5.7", features = ["otpauth", "gen_secret"] }
|
||||
qrcode = "0.14"
|
||||
data-encoding = "2.6"
|
||||
zeroize = { version = "1.8.2", features = ["derive"] }
|
||||
|
||||
# Mainline DHT (did:dht — BitTorrent DHT for decentralized identity)
|
||||
mainline = "2"
|
||||
zbase32 = "0.1"
|
||||
bytes = "1"
|
||||
|
||||
# Mesh networking (Meshcore serial protocol over USB LoRa radios)
|
||||
serial2-tokio = "0.1"
|
||||
|
||||
# Double Ratchet key derivation (Phase 3: encrypted mesh messaging)
|
||||
hkdf = "0.12.4"
|
||||
|
||||
# Transport abstraction (Phase 2: mesh as federation transport)
|
||||
ciborium = "0.2.2"
|
||||
serde_bytes = "0.11"
|
||||
reed-solomon-erasure = "6.0"
|
||||
mdns-sd = "0.18"
|
||||
|
||||
# Systemd watchdog notification
|
||||
sd-notify = "0.4"
|
||||
|
||||
# Trait objects for async methods (container orchestrator trait, Step 4)
|
||||
async-trait = "0.1"
|
||||
|
||||
# DHT Phase 2: iroh-blobs peer swarm engine. OPTIONAL — only pulled in by the
|
||||
# `iroh-swarm` feature (off by default). Heavy QUIC dep tree; kept behind the
|
||||
# flag so the default fleet build is unaffected until the PoC is measured.
|
||||
iroh = { version = "1", optional = true }
|
||||
iroh-blobs = { version = "0.103", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4"
|
||||
tempfile = "3.10"
|
||||
@@ -0,0 +1,234 @@
|
||||
//! HTTP handlers for the content-addressed blob store.
|
||||
//!
|
||||
//! - `POST /api/blob` — session-authenticated. Raw body is the blob;
|
||||
//! headers set mime/filename. Returns `{cid, size, mime}`.
|
||||
//! - `GET /blob/<cid>?cap=<hex>&exp=<epoch>&peer=<pubkey>` — peer-facing.
|
||||
//! Capability verified against the stored HMAC key; bytes streamed back.
|
||||
|
||||
use super::{build_response, ApiHandler};
|
||||
use crate::blobs::BlobStore;
|
||||
use anyhow::Result;
|
||||
use hyper::{Body, HeaderMap, Response, StatusCode};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Read the archipelago .onion address if Tor has published one, so uploads
|
||||
/// that need to be publicly reachable (profile pictures, banners) can return
|
||||
/// a URL a peer outside the LAN can actually fetch. Returns `None` before
|
||||
/// onboarding or when Tor isn't running — callers fall back to the local
|
||||
/// self-test URL.
|
||||
async fn read_self_onion(data_dir: &Path) -> Option<String> {
|
||||
let hostnames = data_dir.join("tor-hostnames").join("archipelago");
|
||||
let legacy = Path::new("/var/lib/archipelago/tor-hostnames/archipelago");
|
||||
for p in [hostnames.as_path(), legacy] {
|
||||
if let Ok(s) = tokio::fs::read_to_string(p).await {
|
||||
let trimmed = s.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
impl ApiHandler {
|
||||
pub(super) async fn handle_blob_upload(
|
||||
store: &Arc<BlobStore>,
|
||||
self_pubkey_hex: &str,
|
||||
data_dir: &Path,
|
||||
headers: &HeaderMap,
|
||||
body: hyper::body::Bytes,
|
||||
) -> Result<Response<Body>> {
|
||||
let mime = headers
|
||||
.get("x-blob-mime")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
let filename = headers
|
||||
.get("x-blob-filename")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
// Optional caller-supplied thumbnail (small, base64) — e.g. the mesh
|
||||
// chat's image-quality picker generates a tiny client-side preview so
|
||||
// a ContentRef receiver can render something before fetching the full
|
||||
// blob. Best-effort: a malformed header is just ignored, not fatal.
|
||||
let thumb_bytes = headers
|
||||
.get("x-blob-thumb")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|b64| {
|
||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||
STANDARD.decode(b64).ok()
|
||||
});
|
||||
|
||||
let bytes = body.to_vec();
|
||||
// Uploads through /api/blob come from the node owner's session and
|
||||
// are almost always intended for external consumption (profile
|
||||
// pictures, banners). Store them public so `/blob/<cid>` serves
|
||||
// without a capability check — external Nostr clients fetching a
|
||||
// kind-0 `picture` URL have no cap and can't get one.
|
||||
match store.put(&bytes, &mime, filename, thumb_bytes, true).await {
|
||||
Ok(meta) => {
|
||||
let exp =
|
||||
(chrono::Utc::now().timestamp() as u64) + crate::blobs::DEFAULT_CAP_TTL_SECS;
|
||||
let cap = store.issue_capability(&meta.cid, self_pubkey_hex, exp);
|
||||
let self_test_url = format!(
|
||||
"/blob/{}?cap={}&exp={}&peer={}",
|
||||
meta.cid, cap, exp, self_pubkey_hex
|
||||
);
|
||||
let public_url = match read_self_onion(data_dir).await {
|
||||
Some(onion) => format!("http://{}/blob/{}", onion, meta.cid),
|
||||
// Pre-onboarding / Tor-not-up: surface the local path so
|
||||
// the UI doesn't break; publishing to Nostr should wait
|
||||
// until Tor is live anyway.
|
||||
None => format!("/blob/{}", meta.cid),
|
||||
};
|
||||
let resp = serde_json::json!({
|
||||
"cid": meta.cid,
|
||||
"size": meta.size,
|
||||
"mime": meta.mime,
|
||||
"filename": meta.filename,
|
||||
"public_url": public_url,
|
||||
"self_test_url": self_test_url,
|
||||
});
|
||||
Ok(build_response(
|
||||
StatusCode::OK,
|
||||
"application/json",
|
||||
Body::from(serde_json::to_vec(&resp).unwrap_or_default()),
|
||||
))
|
||||
}
|
||||
Err(e) => Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"text/plain",
|
||||
Body::from(format!("blob upload failed: {}", e)),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Share-to-mesh iframe intent. Mirrors `handle_blob_upload` but adds
|
||||
/// CORS headers for the requesting app origin and returns a small JSON
|
||||
/// payload the app forwards to its parent via postMessage:
|
||||
/// `{ type: "share-to-mesh", cid, size, mime, filename }`.
|
||||
pub(super) async fn handle_share_to_mesh(
|
||||
store: &Arc<BlobStore>,
|
||||
self_pubkey_hex: &str,
|
||||
headers: &HeaderMap,
|
||||
body: hyper::body::Bytes,
|
||||
origin: &str,
|
||||
) -> Result<Response<Body>> {
|
||||
let mime = headers
|
||||
.get("x-blob-mime")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
let filename = headers
|
||||
.get("x-blob-filename")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let bytes = body.to_vec();
|
||||
let meta = match store.put(&bytes, &mime, filename, None, false).await {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"text/plain",
|
||||
Body::from(format!("share-to-mesh failed: {}", e)),
|
||||
));
|
||||
}
|
||||
};
|
||||
// Self-signed capability so the app can preview/download its own
|
||||
// upload before the user has picked a peer.
|
||||
let exp = (chrono::Utc::now().timestamp() as u64) + crate::blobs::DEFAULT_CAP_TTL_SECS;
|
||||
let cap = store.issue_capability(&meta.cid, self_pubkey_hex, exp);
|
||||
let self_url = format!(
|
||||
"/blob/{}?cap={}&exp={}&peer={}",
|
||||
meta.cid, cap, exp, self_pubkey_hex
|
||||
);
|
||||
let resp = serde_json::json!({
|
||||
"type": "share-to-mesh",
|
||||
"cid": meta.cid,
|
||||
"size": meta.size,
|
||||
"mime": meta.mime,
|
||||
"filename": meta.filename,
|
||||
"self_url": self_url,
|
||||
});
|
||||
let body_vec = serde_json::to_vec(&resp).unwrap_or_default();
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Access-Control-Allow-Origin", origin)
|
||||
.header("Access-Control-Allow-Credentials", "true")
|
||||
.header("Vary", "Origin")
|
||||
.body(Body::from(body_vec))
|
||||
.unwrap_or_else(|_| Response::new(Body::from("internal error"))))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_blob_download(
|
||||
store: &Arc<BlobStore>,
|
||||
path: &str,
|
||||
query: &str,
|
||||
) -> Result<Response<Body>> {
|
||||
let cid = path.strip_prefix("/blob/").unwrap_or("");
|
||||
if cid.is_empty() || !cid.chars().all(|c| c.is_ascii_hexdigit()) || cid.len() != 64 {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"text/plain",
|
||||
Body::from("invalid cid"),
|
||||
));
|
||||
}
|
||||
|
||||
// Public blobs (profile pictures, banners) bypass the capability
|
||||
// check — their CID is published on Nostr relays where any reader
|
||||
// can see it, and external readers have no way to obtain a cap.
|
||||
// Only blobs explicitly marked public at upload time qualify.
|
||||
let is_public = store.meta(cid).await.map(|m| m.public).unwrap_or(false);
|
||||
|
||||
if !is_public {
|
||||
let mut cap = None;
|
||||
let mut exp: Option<u64> = None;
|
||||
let mut peer = None;
|
||||
for pair in query.split('&') {
|
||||
let mut it = pair.splitn(2, '=');
|
||||
match (it.next(), it.next()) {
|
||||
(Some("cap"), Some(v)) => cap = Some(v.to_string()),
|
||||
(Some("exp"), Some(v)) => exp = v.parse().ok(),
|
||||
(Some("peer"), Some(v)) => peer = Some(v.to_string()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let (Some(cap), Some(exp), Some(peer)) = (cap, exp, peer) else {
|
||||
return Ok(build_response(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"text/plain",
|
||||
Body::from("missing cap/exp/peer"),
|
||||
));
|
||||
};
|
||||
|
||||
if let Err(e) = store.verify_capability(cid, &peer, exp, &cap) {
|
||||
tracing::warn!("blob cap rejected: cid={} peer={} reason={}", cid, peer, e);
|
||||
return Ok(build_response(
|
||||
StatusCode::FORBIDDEN,
|
||||
"text/plain",
|
||||
Body::from(format!("capability rejected: {}", e)),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let bytes = match store.get(cid).await {
|
||||
Ok(b) => b,
|
||||
Err(_) => {
|
||||
return Ok(build_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"text/plain",
|
||||
Body::from("blob not found"),
|
||||
))
|
||||
}
|
||||
};
|
||||
let mime = store
|
||||
.meta(cid)
|
||||
.await
|
||||
.map(|m| m.mime)
|
||||
.unwrap_or_else(|_| "application/octet-stream".to_string());
|
||||
Ok(build_response(StatusCode::OK, &mime, Body::from(bytes)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
use super::build_response;
|
||||
use crate::config::Config;
|
||||
use crate::content_server;
|
||||
use anyhow::Result;
|
||||
use hyper::{Response, StatusCode};
|
||||
|
||||
use super::{is_valid_app_id, ApiHandler};
|
||||
|
||||
impl ApiHandler {
|
||||
pub(super) async fn handle_content_catalog(config: &Config) -> Result<Response<hyper::Body>> {
|
||||
match content_server::load_catalog(&config.data_dir).await {
|
||||
Ok(catalog) => {
|
||||
// Only expose public metadata for available items
|
||||
let items: Vec<serde_json::Value> = catalog
|
||||
.items
|
||||
.iter()
|
||||
.filter(|i| !matches!(i.availability, content_server::Availability::Nobody))
|
||||
.map(|i| {
|
||||
serde_json::json!({
|
||||
"id": i.id,
|
||||
"filename": i.filename,
|
||||
"mime_type": i.mime_type,
|
||||
"size_bytes": i.size_bytes,
|
||||
"description": i.description,
|
||||
"access": i.access,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let body =
|
||||
serde_json::to_vec(&serde_json::json!({ "items": items })).unwrap_or_default();
|
||||
Ok(build_response(
|
||||
StatusCode::OK,
|
||||
"application/json",
|
||||
hyper::Body::from(body),
|
||||
))
|
||||
}
|
||||
Err(e) => {
|
||||
let body = serde_json::json!({ "error": e.to_string() });
|
||||
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
||||
Ok(build_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"application/json",
|
||||
hyper::Body::from(body_bytes),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn handle_content_request(
|
||||
path: &str,
|
||||
headers: &hyper::HeaderMap,
|
||||
config: &Config,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let content_id = path.strip_prefix("/content/").unwrap_or("");
|
||||
if content_id.is_empty() || !is_valid_app_id(content_id) {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"text/plain",
|
||||
hyper::Body::from("Invalid content ID"),
|
||||
));
|
||||
}
|
||||
|
||||
// Extract payment token from X-Payment-Token header
|
||||
let payment_token = headers
|
||||
.get("x-payment-token")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
// Extract a paid-entitlement gate token from X-Invoice-Hash (Lightning)
|
||||
// or X-Onchain-Address (on-chain) — both authorize the download if this
|
||||
// node issued+settled them, and both resolve against the same shared
|
||||
// entitlement store keyed by the token string (#46).
|
||||
let invoice_hash = headers
|
||||
.get("x-invoice-hash")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| {
|
||||
headers
|
||||
.get("x-onchain-address")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string())
|
||||
});
|
||||
|
||||
// Extract federation peer DID from X-Federation-DID header
|
||||
let peer_did = headers
|
||||
.get("x-federation-did")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
// Parse Range header for streaming support
|
||||
let range = headers
|
||||
.get("range")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(content_server::parse_range_header);
|
||||
|
||||
match content_server::serve_content(
|
||||
&config.data_dir,
|
||||
content_id,
|
||||
payment_token.as_deref(),
|
||||
invoice_hash.as_deref(),
|
||||
peer_did.as_deref(),
|
||||
range,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(content_server::ServeResult::Ok(bytes, mime_type)) => {
|
||||
let len = bytes.len();
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", mime_type)
|
||||
.header("Content-Length", len.to_string())
|
||||
.header("Accept-Ranges", "bytes")
|
||||
.body(hyper::Body::from(bytes))
|
||||
.unwrap())
|
||||
}
|
||||
Ok(content_server::ServeResult::Partial {
|
||||
bytes,
|
||||
mime_type,
|
||||
start,
|
||||
end,
|
||||
total,
|
||||
}) => Ok(Response::builder()
|
||||
.status(StatusCode::PARTIAL_CONTENT)
|
||||
.header("Content-Type", mime_type)
|
||||
.header("Content-Length", bytes.len().to_string())
|
||||
.header(
|
||||
"Content-Range",
|
||||
format!("bytes {}-{}/{}", start, end, total),
|
||||
)
|
||||
.header("Accept-Ranges", "bytes")
|
||||
.body(hyper::Body::from(bytes))
|
||||
.unwrap()),
|
||||
Ok(content_server::ServeResult::PaymentRequired(price_sats)) => {
|
||||
let body = serde_json::json!({
|
||||
"error": "Payment required",
|
||||
"price_sats": price_sats,
|
||||
"payment_header": "X-Payment-Token",
|
||||
});
|
||||
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
||||
Ok(build_response(
|
||||
StatusCode::PAYMENT_REQUIRED,
|
||||
"application/json",
|
||||
hyper::Body::from(body_bytes),
|
||||
))
|
||||
}
|
||||
Ok(content_server::ServeResult::Forbidden) => Ok(build_response(
|
||||
StatusCode::FORBIDDEN,
|
||||
"application/json",
|
||||
hyper::Body::from(
|
||||
r#"{"error":"This file is shared with the host's federation peers only. Federate with that node (exchange invites) so it recognizes you, then try again."}"#,
|
||||
),
|
||||
)),
|
||||
Ok(content_server::ServeResult::NotFound) | Err(_) => Ok(build_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"text/plain",
|
||||
hyper::Body::from("Content not found"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Seller side (#46): mint a Lightning invoice for a paid catalog item so a
|
||||
/// buyer can pay from any external wallet. Path: GET /content/{id}/invoice.
|
||||
/// Records a pending entitlement keyed by the invoice's payment hash.
|
||||
pub(super) async fn handle_content_invoice(&self, path: &str) -> Result<Response<hyper::Body>> {
|
||||
let content_id = path
|
||||
.strip_prefix("/content/")
|
||||
.and_then(|s| s.strip_suffix("/invoice"))
|
||||
.unwrap_or("");
|
||||
if content_id.is_empty() || !is_valid_app_id(content_id) {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"text/plain",
|
||||
hyper::Body::from("Invalid content ID"),
|
||||
));
|
||||
}
|
||||
|
||||
let catalog = content_server::load_catalog(&self.config.data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let item = match catalog.items.iter().find(|i| i.id == content_id) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
return Ok(build_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"text/plain",
|
||||
hyper::Body::from("Content not found"),
|
||||
))
|
||||
}
|
||||
};
|
||||
let price_sats = match &item.access {
|
||||
content_server::AccessControl::Paid { price_sats, .. } => *price_sats,
|
||||
_ => {
|
||||
// Not a paid item — no invoice to issue.
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"Item is not paid"}"#),
|
||||
));
|
||||
}
|
||||
};
|
||||
if !content_server::method_accepted(&item.access, "lightning") {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"application/json",
|
||||
hyper::Body::from(
|
||||
r#"{"error":"The seller does not accept Lightning for this item"}"#,
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
let memo = format!("Archipelago peer file {content_id}");
|
||||
match self
|
||||
.rpc_handler
|
||||
.create_invoice(price_sats as i64, &memo)
|
||||
.await
|
||||
{
|
||||
Ok((bolt11, payment_hash)) if !payment_hash.is_empty() => {
|
||||
crate::content_invoice::record_pending(&payment_hash, content_id, price_sats).await;
|
||||
let body = serde_json::json!({
|
||||
"bolt11": bolt11,
|
||||
"payment_hash": payment_hash,
|
||||
"price_sats": price_sats,
|
||||
});
|
||||
Ok(build_response(
|
||||
StatusCode::OK,
|
||||
"application/json",
|
||||
hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()),
|
||||
))
|
||||
}
|
||||
Ok(_) => Ok(build_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"Invoice missing payment hash"}"#),
|
||||
)),
|
||||
Err(e) => {
|
||||
// Surface the FULL error chain ({:#}) — the generic top-level
|
||||
// message hid the real cause (e.g. the LND REST connection
|
||||
// failing), which made this 503 undiagnosable.
|
||||
tracing::warn!("content invoice creation failed: {e:#}");
|
||||
let body = serde_json::json!({
|
||||
"error": format!("Could not create invoice: {e:#}")
|
||||
});
|
||||
Ok(build_response(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"application/json",
|
||||
hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Seller side (#46): report whether a previously-issued invoice has settled.
|
||||
/// Path: GET /content/{id}/invoice-status/{payment_hash}. On settlement the
|
||||
/// entitlement is marked paid so the buyer can then download the file.
|
||||
pub(super) async fn handle_content_invoice_status(
|
||||
&self,
|
||||
path: &str,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let rest = path.strip_prefix("/content/").unwrap_or("");
|
||||
let (content_id, payment_hash) = match rest.split_once("/invoice-status/") {
|
||||
Some((id, hash)) => (id, hash),
|
||||
None => {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"text/plain",
|
||||
hyper::Body::from("Invalid request"),
|
||||
))
|
||||
}
|
||||
};
|
||||
if content_id.is_empty() || !is_valid_app_id(content_id) || payment_hash.is_empty() {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"text/plain",
|
||||
hyper::Body::from("Invalid request"),
|
||||
));
|
||||
}
|
||||
|
||||
// The hash must be one we issued for exactly this content item.
|
||||
match crate::content_invoice::lookup(payment_hash).await {
|
||||
Some((cid, _)) if cid == content_id => {}
|
||||
_ => {
|
||||
return Ok(build_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"Unknown invoice"}"#),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// Already paid? Otherwise ask our LND and persist the result.
|
||||
let mut paid = crate::content_invoice::is_paid_for(payment_hash, content_id).await;
|
||||
if !paid {
|
||||
if let Ok(true) = self.rpc_handler.invoice_is_settled(payment_hash).await {
|
||||
crate::content_invoice::mark_paid(payment_hash).await;
|
||||
paid = true;
|
||||
}
|
||||
}
|
||||
|
||||
let body = serde_json::json!({ "paid": paid });
|
||||
Ok(build_response(
|
||||
StatusCode::OK,
|
||||
"application/json",
|
||||
hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()),
|
||||
))
|
||||
}
|
||||
|
||||
/// Seller side (#46): issue a fresh on-chain address for a paid catalog item
|
||||
/// so a buyer can pay on-chain. Path: GET /content/{id}/onchain. Records a
|
||||
/// pending entitlement keyed by the address; price doubles as expected amount.
|
||||
pub(super) async fn handle_content_onchain(&self, path: &str) -> Result<Response<hyper::Body>> {
|
||||
let content_id = path
|
||||
.strip_prefix("/content/")
|
||||
.and_then(|s| s.strip_suffix("/onchain"))
|
||||
.unwrap_or("");
|
||||
if content_id.is_empty() || !is_valid_app_id(content_id) {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"text/plain",
|
||||
hyper::Body::from("Invalid content ID"),
|
||||
));
|
||||
}
|
||||
let catalog = content_server::load_catalog(&self.config.data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let price_sats = match catalog.items.iter().find(|i| i.id == content_id) {
|
||||
Some(i) => match &i.access {
|
||||
content_server::AccessControl::Paid { price_sats, .. } => {
|
||||
if !content_server::method_accepted(&i.access, "onchain") {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"application/json",
|
||||
hyper::Body::from(
|
||||
r#"{"error":"The seller does not accept on-chain payment for this item"}"#,
|
||||
),
|
||||
));
|
||||
}
|
||||
*price_sats
|
||||
}
|
||||
_ => {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"Item is not paid"}"#),
|
||||
))
|
||||
}
|
||||
},
|
||||
None => {
|
||||
return Ok(build_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"text/plain",
|
||||
hyper::Body::from("Content not found"),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
match self.rpc_handler.new_onchain_address().await {
|
||||
Ok(address) if !address.is_empty() => {
|
||||
crate::content_invoice::record_pending(&address, content_id, price_sats).await;
|
||||
let body = serde_json::json!({
|
||||
"address": address,
|
||||
"amount_sats": price_sats,
|
||||
});
|
||||
Ok(build_response(
|
||||
StatusCode::OK,
|
||||
"application/json",
|
||||
hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()),
|
||||
))
|
||||
}
|
||||
_ => {
|
||||
let body = serde_json::json!({
|
||||
"error": "Could not generate an on-chain address (is the wallet ready?)"
|
||||
});
|
||||
Ok(build_response(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"application/json",
|
||||
hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Seller side (#46): report whether an on-chain payment to a previously-
|
||||
/// issued address has arrived (>= price, >= 1 conf). Path:
|
||||
/// GET /content/{id}/onchain-status/{address}. Marks the entitlement paid.
|
||||
pub(super) async fn handle_content_onchain_status(
|
||||
&self,
|
||||
path: &str,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let rest = path.strip_prefix("/content/").unwrap_or("");
|
||||
let (content_id, address) = match rest.split_once("/onchain-status/") {
|
||||
Some((id, addr)) => (id, addr),
|
||||
None => {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"text/plain",
|
||||
hyper::Body::from("Invalid request"),
|
||||
))
|
||||
}
|
||||
};
|
||||
if content_id.is_empty() || !is_valid_app_id(content_id) || address.is_empty() {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"text/plain",
|
||||
hyper::Body::from("Invalid request"),
|
||||
));
|
||||
}
|
||||
// The address must be one we issued for exactly this content item.
|
||||
let price = match crate::content_invoice::lookup(address).await {
|
||||
Some((cid, price)) if cid == content_id => price,
|
||||
_ => {
|
||||
return Ok(build_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"Unknown address"}"#),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let mut paid = crate::content_invoice::is_paid_for(address, content_id).await;
|
||||
if !paid {
|
||||
if let Ok(true) = self.rpc_handler.onchain_received(address, price).await {
|
||||
crate::content_invoice::mark_paid(address).await;
|
||||
paid = true;
|
||||
}
|
||||
}
|
||||
let body = serde_json::json!({ "paid": paid });
|
||||
Ok(build_response(
|
||||
StatusCode::OK,
|
||||
"application/json",
|
||||
hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()),
|
||||
))
|
||||
}
|
||||
|
||||
/// Serve a degraded preview of paid content (blurred image or first 2% of video).
|
||||
pub(super) async fn handle_content_preview(
|
||||
path: &str,
|
||||
config: &Config,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
// Path format: /content/{id}/preview
|
||||
let content_id = path
|
||||
.strip_prefix("/content/")
|
||||
.and_then(|s| s.strip_suffix("/preview"))
|
||||
.unwrap_or("");
|
||||
|
||||
if content_id.is_empty() || !is_valid_app_id(content_id) {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"text/plain",
|
||||
hyper::Body::from("Invalid content ID"),
|
||||
));
|
||||
}
|
||||
|
||||
match content_server::serve_content_preview(&config.data_dir, content_id).await {
|
||||
Ok(content_server::PreviewResult::FullContent(bytes, mime_type)) => {
|
||||
let len = bytes.len();
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", mime_type)
|
||||
.header("Content-Length", len.to_string())
|
||||
.body(hyper::Body::from(bytes))
|
||||
.unwrap())
|
||||
}
|
||||
Ok(content_server::PreviewResult::BlurPreview(bytes, mime_type)) => {
|
||||
let len = bytes.len();
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", mime_type)
|
||||
.header("Content-Length", len.to_string())
|
||||
.header("X-Content-Preview", "blur")
|
||||
.body(hyper::Body::from(bytes))
|
||||
.unwrap())
|
||||
}
|
||||
Ok(content_server::PreviewResult::TruncatedPreview(bytes, mime_type, total_size)) => {
|
||||
let len = bytes.len();
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", mime_type)
|
||||
.header("Content-Length", len.to_string())
|
||||
.header("X-Content-Preview", "truncated")
|
||||
.header("X-Content-Total-Size", total_size.to_string())
|
||||
.body(hyper::Body::from(bytes))
|
||||
.unwrap())
|
||||
}
|
||||
Ok(content_server::PreviewResult::PreviewUnavailable) => Ok(Response::builder()
|
||||
.status(StatusCode::UNSUPPORTED_MEDIA_TYPE)
|
||||
.header("Content-Type", "text/plain")
|
||||
.header("X-Content-Preview", "unavailable")
|
||||
.body(hyper::Body::from(
|
||||
"Preview unavailable for this media (needs re-encoding)",
|
||||
))
|
||||
.unwrap()),
|
||||
Ok(content_server::PreviewResult::NotFound) | Err(_) => Ok(build_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"text/plain",
|
||||
hyper::Body::from("Preview not available"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
use super::build_response;
|
||||
use crate::config::Config;
|
||||
use crate::network::dwn_store::DwnStore;
|
||||
use anyhow::Result;
|
||||
use hyper::{Response, StatusCode};
|
||||
|
||||
use super::ApiHandler;
|
||||
|
||||
impl ApiHandler {
|
||||
/// DWN health endpoint — returns store stats.
|
||||
pub(super) async fn handle_dwn_health(config: &Config) -> Result<Response<hyper::Body>> {
|
||||
match DwnStore::new(&config.data_dir).await {
|
||||
Ok(store) => {
|
||||
let stats = store
|
||||
.stats()
|
||||
.await
|
||||
.unwrap_or(crate::network::dwn_store::StoreStats {
|
||||
message_count: 0,
|
||||
protocol_count: 0,
|
||||
total_bytes: 0,
|
||||
});
|
||||
let body = serde_json::json!({
|
||||
"status": "ok",
|
||||
"message_count": stats.message_count,
|
||||
"protocol_count": stats.protocol_count,
|
||||
"total_bytes": stats.total_bytes,
|
||||
});
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(body.to_string()))
|
||||
.unwrap())
|
||||
}
|
||||
Err(_) => Ok(build_response(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"status":"unavailable"}"#),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// DWN message processing endpoint — handles RecordsWrite, RecordsQuery, RecordsRead, RecordsDelete.
|
||||
/// Supports batch processing: all messages in the array are processed.
|
||||
pub(super) async fn handle_dwn_message(
|
||||
body: hyper::body::Bytes,
|
||||
config: &Config,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let request: serde_json::Value = match serde_json::from_slice(&body) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
let err = serde_json::json!({"error": format!("Invalid JSON: {}", e)});
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::BAD_REQUEST)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(err.to_string()))
|
||||
.unwrap());
|
||||
}
|
||||
};
|
||||
|
||||
// Collect all messages to process
|
||||
let messages: Vec<serde_json::Value> = if request.get("message").is_some() {
|
||||
vec![request["message"].clone()]
|
||||
} else if let Some(msgs) = request["messages"].as_array() {
|
||||
msgs.clone()
|
||||
} else {
|
||||
vec![serde_json::Value::Null]
|
||||
};
|
||||
|
||||
let store = DwnStore::new(&config.data_dir).await?;
|
||||
let mut results = Vec::new();
|
||||
|
||||
for message in &messages {
|
||||
let interface = message["descriptor"]["interface"].as_str().unwrap_or("");
|
||||
let method = message["descriptor"]["method"].as_str().unwrap_or("");
|
||||
|
||||
let result = match (interface, method) {
|
||||
("Records", "Write") => {
|
||||
let author = message["author"].as_str().unwrap_or("unknown");
|
||||
let protocol = message["descriptor"]["protocol"].as_str();
|
||||
let schema = message["descriptor"]["schema"].as_str();
|
||||
let data_format = message["descriptor"]["dataFormat"].as_str();
|
||||
let data = message.get("data").cloned();
|
||||
// Deduplicate: check if recordId already exists
|
||||
if let Some(record_id) = message["recordId"].as_str() {
|
||||
if store.read_message(record_id).await.ok().flatten().is_some() {
|
||||
serde_json::json!({"status": {"code": 200, "detail": "Already exists"}})
|
||||
} else {
|
||||
match store
|
||||
.write_message(author, protocol, schema, data_format, data)
|
||||
.await
|
||||
{
|
||||
Ok(msg) => {
|
||||
serde_json::json!({"status": {"code": 202}, "entry": msg})
|
||||
}
|
||||
Err(e) => {
|
||||
serde_json::json!({"status": {"code": 500, "detail": e.to_string()}})
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match store
|
||||
.write_message(author, protocol, schema, data_format, data)
|
||||
.await
|
||||
{
|
||||
Ok(msg) => serde_json::json!({"status": {"code": 202}, "entry": msg}),
|
||||
Err(e) => {
|
||||
serde_json::json!({"status": {"code": 500, "detail": e.to_string()}})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
("Records", "Query") => {
|
||||
let query = crate::network::dwn_store::MessageQuery {
|
||||
protocol: message["descriptor"]["filter"]["protocol"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string()),
|
||||
schema: message["descriptor"]["filter"]["schema"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string()),
|
||||
author: message["descriptor"]["filter"]["author"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string()),
|
||||
date_from: message["descriptor"]["filter"]["dateFrom"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string()),
|
||||
date_to: message["descriptor"]["filter"]["dateTo"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string()),
|
||||
limit: message["descriptor"]["filter"]["limit"]
|
||||
.as_u64()
|
||||
.map(|n| n as usize),
|
||||
};
|
||||
match store.query_messages(&query).await {
|
||||
Ok(messages) => {
|
||||
serde_json::json!({"status": {"code": 200}, "entries": messages})
|
||||
}
|
||||
Err(e) => {
|
||||
serde_json::json!({"status": {"code": 500, "detail": e.to_string()}})
|
||||
}
|
||||
}
|
||||
}
|
||||
("Records", "Read") => {
|
||||
let record_id = message["descriptor"]["recordId"].as_str().unwrap_or("");
|
||||
match store.read_message(record_id).await {
|
||||
Ok(Some(msg)) => {
|
||||
serde_json::json!({"status": {"code": 200}, "entry": msg})
|
||||
}
|
||||
Ok(None) => {
|
||||
serde_json::json!({"status": {"code": 404, "detail": "Record not found"}})
|
||||
}
|
||||
Err(e) => {
|
||||
serde_json::json!({"status": {"code": 500, "detail": e.to_string()}})
|
||||
}
|
||||
}
|
||||
}
|
||||
("Records", "Delete") => {
|
||||
let record_id = message["descriptor"]["recordId"].as_str().unwrap_or("");
|
||||
match store.delete_message(record_id).await {
|
||||
Ok(true) => serde_json::json!({"status": {"code": 200}}),
|
||||
Ok(false) => {
|
||||
serde_json::json!({"status": {"code": 404, "detail": "Record not found"}})
|
||||
}
|
||||
Err(e) => {
|
||||
serde_json::json!({"status": {"code": 500, "detail": e.to_string()}})
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
serde_json::json!({"status": {"code": 400, "detail": format!("Unknown method: {}.{}", interface, method)}})
|
||||
}
|
||||
};
|
||||
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
// Return single result for single message, array for batch
|
||||
let (response_body, http_status) = if results.len() == 1 {
|
||||
let result = &results[0];
|
||||
let status_code = result["status"]["code"].as_u64().unwrap_or(200);
|
||||
let http_status = match status_code {
|
||||
202 => StatusCode::ACCEPTED,
|
||||
400 => StatusCode::BAD_REQUEST,
|
||||
404 => StatusCode::NOT_FOUND,
|
||||
500 => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
_ => StatusCode::OK,
|
||||
};
|
||||
(result.to_string(), http_status)
|
||||
} else {
|
||||
(
|
||||
serde_json::json!({"replies": results}).to_string(),
|
||||
StatusCode::OK,
|
||||
)
|
||||
};
|
||||
|
||||
Ok(build_response(
|
||||
http_status,
|
||||
"application/json",
|
||||
hyper::Body::from(response_body),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,686 @@
|
||||
mod blob;
|
||||
mod content;
|
||||
mod dwn;
|
||||
mod node_message;
|
||||
mod proxy;
|
||||
mod remote_input;
|
||||
mod remote_relay;
|
||||
mod websocket;
|
||||
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use crate::blobs::BlobStore;
|
||||
use crate::config::Config;
|
||||
use crate::container::{ContainerOrchestrator, DevContainerOrchestrator};
|
||||
use crate::monitoring::MetricsStore;
|
||||
use crate::session::{self, SessionStore};
|
||||
use crate::state::StateManager;
|
||||
use anyhow::Result;
|
||||
use hyper::{Method, Request, Response, StatusCode};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::broadcast;
|
||||
use tracing::debug;
|
||||
|
||||
/// Build an HTTP response without unwrap. Falls back to a plain 500 if builder fails.
|
||||
// Used by handler submodules after unwrap elimination
|
||||
#[allow(dead_code)]
|
||||
pub(super) fn build_response(
|
||||
status: StatusCode,
|
||||
content_type: &str,
|
||||
body: hyper::Body,
|
||||
) -> Response<hyper::Body> {
|
||||
Response::builder()
|
||||
.status(status)
|
||||
.header("Content-Type", content_type)
|
||||
.body(body)
|
||||
.unwrap_or_else(|_| Response::new(hyper::Body::from("Internal error")))
|
||||
}
|
||||
|
||||
pub struct ApiHandler {
|
||||
config: Config,
|
||||
rpc_handler: Arc<RpcHandler>,
|
||||
state_manager: Arc<StateManager>,
|
||||
metrics_store: Arc<MetricsStore>,
|
||||
session_store: SessionStore,
|
||||
/// Broadcast channel for relaying companion app input to remote browsers.
|
||||
input_relay_tx: broadcast::Sender<String>,
|
||||
/// Reverse broadcast channel: the kiosk browser publishes "open this URL
|
||||
/// externally" requests here, and the companion (phone) socket forwards them
|
||||
/// to the phone's default browser. Lets "open in external browser" apps —
|
||||
/// which the kiosk can't usefully open itself — launch on the controller.
|
||||
external_open_tx: broadcast::Sender<String>,
|
||||
/// Content-addressed blob store for attachments shared over mesh/federation.
|
||||
blob_store: Arc<BlobStore>,
|
||||
/// Our own node pubkey (hex) — used to self-sign debug/test capabilities.
|
||||
self_pubkey_hex: String,
|
||||
}
|
||||
|
||||
impl ApiHandler {
|
||||
pub async fn new(
|
||||
config: Config,
|
||||
state_manager: Arc<StateManager>,
|
||||
metrics_store: Arc<MetricsStore>,
|
||||
orchestrator: Option<Arc<dyn ContainerOrchestrator>>,
|
||||
dev_orchestrator: Option<Arc<DevContainerOrchestrator>>,
|
||||
) -> Result<Self> {
|
||||
let session_store = SessionStore::new().await;
|
||||
let rpc_handler = Arc::new(
|
||||
RpcHandler::new(
|
||||
config.clone(),
|
||||
state_manager.clone(),
|
||||
metrics_store.clone(),
|
||||
session_store.clone(),
|
||||
orchestrator,
|
||||
dev_orchestrator,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
let (input_relay_tx, _) = broadcast::channel(64);
|
||||
let (external_open_tx, _) = broadcast::channel(16);
|
||||
|
||||
// Derive a blob-store capability key from the node's Ed25519 signing
|
||||
// key. SHA-256 domain-separated so rotating the identity rotates
|
||||
// every outstanding capability token (intentional — prevents a
|
||||
// replaced node from honouring old caps).
|
||||
let identity_dir = config.data_dir.join("identity");
|
||||
let identity = crate::identity::NodeIdentity::load_or_create(&identity_dir).await?;
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(identity.signing_key().to_bytes());
|
||||
hasher.update(b"|archipelago-blob-cap-v1");
|
||||
let mut cap_key = [0u8; 32];
|
||||
cap_key.copy_from_slice(&hasher.finalize());
|
||||
let blob_store = Arc::new(BlobStore::open(&config.data_dir, cap_key).await?);
|
||||
let self_pubkey_hex = hex::encode(identity.signing_key().verifying_key().as_bytes());
|
||||
|
||||
// Share blob store with the RPC layer so mesh.send-content /
|
||||
// mesh.fetch-content can reach the same instance (single cap_key,
|
||||
// single on-disk root) without re-opening it.
|
||||
rpc_handler
|
||||
.set_blob_store(blob_store.clone(), self_pubkey_hex.clone())
|
||||
.await;
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
rpc_handler,
|
||||
state_manager,
|
||||
metrics_store,
|
||||
session_store,
|
||||
input_relay_tx,
|
||||
external_open_tx,
|
||||
blob_store,
|
||||
self_pubkey_hex,
|
||||
})
|
||||
}
|
||||
|
||||
/// Access the RPC handler (for service initialization after construction).
|
||||
pub fn rpc_handler(&self) -> &Arc<RpcHandler> {
|
||||
&self.rpc_handler
|
||||
}
|
||||
|
||||
/// Check if the request has a valid session cookie.
|
||||
async fn is_authenticated(&self, headers: &hyper::HeaderMap) -> bool {
|
||||
match session::extract_session_cookie(headers) {
|
||||
Some(token) => self.session_store.validate(&token).await,
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Server-side fetch of the upstream app catalog so the browser can
|
||||
/// load it without fighting CORS (upstream Gitea emits no ACAO) or
|
||||
/// CSP (the fallback IP-port URL isn't in `connect-src`). The upstream
|
||||
/// list is derived from the operator's configured container registries
|
||||
/// so switching mirrors in Settings changes the App Store source too —
|
||||
/// each active registry contributes one Gitea `raw/branch/main/catalog.json`
|
||||
/// URL (http or https per `tls_verify`), tried in priority order.
|
||||
/// If registry config can't be loaded, falls back to the hardcoded OVH
|
||||
/// URL so the App Store still renders on nodes that haven't persisted
|
||||
/// a registry config yet. 15s total timeout.
|
||||
async fn handle_app_catalog_proxy(&self) -> Result<Response<hyper::Body>> {
|
||||
let mut upstreams: Vec<String> = Vec::new();
|
||||
if let Ok(config) = crate::container::registry::load_registries(&self.config.data_dir).await
|
||||
{
|
||||
for reg in config.active_registries() {
|
||||
let scheme = if reg.tls_verify { "https" } else { "http" };
|
||||
// Gitea raw URL: <scheme>://<host>/<namespace>/app-catalog/raw/branch/main/catalog.json.
|
||||
// reg.url already includes the namespace (e.g. "host/lfg2025"),
|
||||
// so we just tack on the repo + raw path.
|
||||
upstreams.push(format!(
|
||||
"{}://{}/app-catalog/raw/branch/main/catalog.json",
|
||||
scheme, reg.url
|
||||
));
|
||||
}
|
||||
}
|
||||
if upstreams.is_empty() {
|
||||
upstreams.push(
|
||||
"http://146.59.87.168:3000/lfg2025/app-catalog/raw/branch/main/catalog.json"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return Ok(build_response(
|
||||
hyper::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"text/plain",
|
||||
hyper::Body::from(format!("client build failed: {}", e)),
|
||||
));
|
||||
}
|
||||
};
|
||||
for url in &upstreams {
|
||||
match client.get(url).send().await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
if let Ok(bytes) = resp.bytes().await {
|
||||
return Ok(Response::builder()
|
||||
.status(hyper::StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Cache-Control", "public, max-age=3600")
|
||||
.body(hyper::Body::from(bytes))
|
||||
.unwrap_or_else(|_| {
|
||||
Response::new(hyper::Body::from("proxy response build failed"))
|
||||
}));
|
||||
}
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
Ok(build_response(
|
||||
hyper::StatusCode::BAD_GATEWAY,
|
||||
"text/plain",
|
||||
hyper::Body::from("all upstream catalog URLs failed"),
|
||||
))
|
||||
}
|
||||
|
||||
/// Serve an encrypted backup archive (`<data_dir>/backups/<id>.bak`) as a
|
||||
/// browser download. The archive is passphrase-encrypted at rest; the
|
||||
/// session gate at the route controls who can fetch it.
|
||||
async fn handle_backup_download(&self, path: &str) -> Result<Response<hyper::Body>> {
|
||||
let id = path.strip_prefix("/api/blob/backup/").unwrap_or("");
|
||||
// Backup ids are UUIDs — reject anything that could traverse paths.
|
||||
if id.is_empty() || !id.chars().all(|c| c.is_ascii_hexdigit() || c == '-') {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"invalid backup id"}"#),
|
||||
));
|
||||
}
|
||||
let file = self
|
||||
.config
|
||||
.data_dir
|
||||
.join("backups")
|
||||
.join(format!("{id}.bak"));
|
||||
match tokio::fs::read(&file).await {
|
||||
Ok(bytes) => Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/octet-stream")
|
||||
.header(
|
||||
"Content-Disposition",
|
||||
format!("attachment; filename=\"archipelago-backup-{id}.bak\""),
|
||||
)
|
||||
.header("Content-Length", bytes.len())
|
||||
.body(hyper::Body::from(bytes))
|
||||
.unwrap_or_else(|_| Response::new(hyper::Body::from("Internal error")))),
|
||||
Err(_) => Ok(build_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"backup not found"}"#),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a 401 Unauthorized JSON response.
|
||||
fn unauthorized() -> Response<hyper::Body> {
|
||||
let body = serde_json::json!({ "error": "Unauthorized" });
|
||||
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
||||
Response::builder()
|
||||
.status(StatusCode::UNAUTHORIZED)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(body_bytes))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// A 401 that still carries CORS headers, for endpoints fetched
|
||||
/// cross-origin by same-node app UIs (e.g. the LND wallet UI on its own
|
||||
/// port). Without the ACAO header the browser surfaces an opaque CORS
|
||||
/// error instead of the 401, so the app can't tell it just needs auth.
|
||||
/// `origin` is the already-validated reflect value from `app_cors_origin`
|
||||
/// (empty string when the origin isn't allowed → no CORS header added).
|
||||
fn unauthorized_cors(origin: &str) -> Response<hyper::Body> {
|
||||
let body = serde_json::json!({ "error": "Unauthorized" });
|
||||
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
||||
let mut builder = Response::builder()
|
||||
.status(StatusCode::UNAUTHORIZED)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Vary", "Origin");
|
||||
if !origin.is_empty() {
|
||||
builder = builder
|
||||
.header("Access-Control-Allow-Origin", origin)
|
||||
.header("Access-Control-Allow-Credentials", "true");
|
||||
}
|
||||
builder.body(hyper::Body::from(body_bytes)).unwrap()
|
||||
}
|
||||
|
||||
/// Allowed CORS origins derived from the config host IP.
|
||||
fn allowed_origins(&self) -> Vec<String> {
|
||||
let mut origins = vec![
|
||||
format!("http://{}", self.config.host_ip),
|
||||
format!("https://{}", self.config.host_ip),
|
||||
];
|
||||
if self.config.dev_mode {
|
||||
origins.push("http://localhost:8100".to_string()); // Vite dev server
|
||||
}
|
||||
origins
|
||||
}
|
||||
|
||||
/// Validate the Origin header against allowed origins.
|
||||
/// Returns the matched origin if valid, None if cross-origin is not allowed.
|
||||
fn validate_origin(&self, headers: &hyper::HeaderMap) -> Option<String> {
|
||||
let origin = headers.get("origin").and_then(|v| v.to_str().ok())?;
|
||||
let allowed = self.allowed_origins();
|
||||
if allowed.iter().any(|a| a == origin) {
|
||||
Some(origin.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Permissive origin check for the share-to-mesh iframe intent: any scheme
|
||||
/// http(s):// followed by the configured host_ip, optionally `:port`. Apps
|
||||
/// proxied under other ports (APP_PORTS) call this from within the same
|
||||
/// node, so they share host_ip but not port. The session cookie still has
|
||||
/// to be valid — this is a sanity check, not the primary auth.
|
||||
fn validate_app_origin(&self, headers: &hyper::HeaderMap) -> Option<String> {
|
||||
let origin = headers.get("origin").and_then(|v| v.to_str().ok())?;
|
||||
// Allow localhost dev server too so the Vite frontend can exercise it.
|
||||
if self.config.dev_mode && origin == "http://localhost:8100" {
|
||||
return Some(origin.to_string());
|
||||
}
|
||||
let host_ip = &self.config.host_ip;
|
||||
let matches = |scheme: &str| -> bool {
|
||||
let prefix = format!("{}{}", scheme, host_ip);
|
||||
if origin == prefix {
|
||||
return true;
|
||||
}
|
||||
let with_port = format!("{}:", prefix);
|
||||
origin.starts_with(&with_port)
|
||||
&& origin[with_port.len()..]
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_digit())
|
||||
};
|
||||
if matches("http://") || matches("https://") {
|
||||
Some(origin.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// CORS origin to echo for same-node app → backend calls (e.g. the LND
|
||||
/// wallet UI, served on its own APP_PORTS port). Such apps share the node's
|
||||
/// host but use a different port, so the strict allowlist (`host_ip`, no
|
||||
/// port) rejects them and the browser gets no `Access-Control-Allow-Origin`
|
||||
/// header ("blocked by CORS policy"). Reflect the Origin when its host
|
||||
/// matches the request's own `Host` header — i.e. the app lives on the same
|
||||
/// address the node is being reached by, which transparently covers the LAN
|
||||
/// IP, the Tailscale IP, localhost, and the `.onion` address without needing
|
||||
/// to enumerate them. Auth is still enforced by the session cookie; this
|
||||
/// only authorizes the browser to *read* the reply. Returns "" (no echoed
|
||||
/// origin) when there is no match.
|
||||
fn app_cors_origin(&self, headers: &hyper::HeaderMap) -> String {
|
||||
if let Some(origin) = self.validate_origin(headers) {
|
||||
return origin;
|
||||
}
|
||||
let Some(origin) = headers.get("origin").and_then(|v| v.to_str().ok()) else {
|
||||
return String::new();
|
||||
};
|
||||
// host portion (no scheme, no port) of an `scheme://host[:port]` value
|
||||
let host_of = |s: &str| -> Option<String> {
|
||||
let after_scheme = s.split_once("://").map(|(_, r)| r).unwrap_or(s);
|
||||
let host_port = after_scheme.split('/').next().unwrap_or(after_scheme);
|
||||
let host = host_port
|
||||
.rsplit_once(':')
|
||||
.map(|(h, _)| h)
|
||||
.unwrap_or(host_port);
|
||||
(!host.is_empty()).then(|| host.to_string())
|
||||
};
|
||||
let origin_host = host_of(origin);
|
||||
let req_host = headers
|
||||
.get(hyper::header::HOST)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(host_of);
|
||||
match (origin_host, req_host) {
|
||||
(Some(o), Some(r)) if o == r => origin.to_string(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_request(&self, req: Request<hyper::Body>) -> Result<Response<hyper::Body>> {
|
||||
let path = req.uri().path().to_string();
|
||||
let method = req.method().clone();
|
||||
|
||||
// Handle CORS preflight for all routes
|
||||
if method == Method::OPTIONS {
|
||||
let mut builder = Response::builder()
|
||||
.status(StatusCode::NO_CONTENT)
|
||||
.header("Vary", "Origin");
|
||||
let preflight_origin = self.app_cors_origin(req.headers());
|
||||
if !preflight_origin.is_empty() {
|
||||
builder = builder
|
||||
.header("Access-Control-Allow-Origin", &preflight_origin)
|
||||
.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
.header("Access-Control-Allow-Headers", "Content-Type, X-CSRF-Token")
|
||||
.header("Access-Control-Allow-Credentials", "true");
|
||||
}
|
||||
return Ok(builder.body(hyper::Body::empty()).unwrap());
|
||||
}
|
||||
|
||||
// WebSocket upgrade — validate session before upgrading
|
||||
if method == Method::GET && path == "/ws/db" {
|
||||
if !self.is_authenticated(req.headers()).await {
|
||||
tracing::warn!("401 WebSocket /ws/db — session invalid or missing");
|
||||
return Ok(Self::unauthorized());
|
||||
}
|
||||
return Self::handle_websocket(
|
||||
req,
|
||||
self.state_manager.clone(),
|
||||
self.metrics_store.clone(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Remote input WebSocket — companion app sends keyboard/mouse events
|
||||
if method == Method::GET && path == "/ws/remote-input" {
|
||||
if !self.is_authenticated(req.headers()).await {
|
||||
tracing::warn!("401 WebSocket /ws/remote-input — session invalid or missing");
|
||||
return Ok(Self::unauthorized());
|
||||
}
|
||||
return Self::handle_remote_input(
|
||||
req,
|
||||
self.input_relay_tx.clone(),
|
||||
self.external_open_tx.subscribe(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Remote relay WebSocket — browser receives companion input events
|
||||
if method == Method::GET && path == "/ws/remote-relay" {
|
||||
if !self.is_authenticated(req.headers()).await {
|
||||
tracing::warn!("401 WebSocket /ws/remote-relay — session invalid or missing");
|
||||
return Ok(Self::unauthorized());
|
||||
}
|
||||
return Self::handle_remote_relay(
|
||||
req,
|
||||
self.input_relay_tx.subscribe(),
|
||||
self.external_open_tx.clone(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Convert body to bytes for non-WS routes
|
||||
let headers = req.headers().clone();
|
||||
let query_string = req.uri().query().map(|s| s.to_string()).unwrap_or_default();
|
||||
let (parts, body) = req.into_parts();
|
||||
let body_bytes = hyper::body::to_bytes(body)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to read body: {}", e))?;
|
||||
let req_with_bytes = Request::from_parts(parts, hyper::Body::from(body_bytes.clone()));
|
||||
|
||||
debug!("{} {}", method, path);
|
||||
|
||||
match (method, path.as_str()) {
|
||||
// RPC — auth is handled inside rpc handler per-method
|
||||
(Method::POST, "/rpc/v1") => self.rpc_handler.clone().handle(req_with_bytes).await,
|
||||
|
||||
// Health — unauthenticated, returns JSON with service status
|
||||
(Method::GET, "/health") => {
|
||||
let recovery_complete = crate::crash_recovery::is_recovery_complete();
|
||||
let uptime = crate::crash_recovery::uptime_seconds();
|
||||
let health_status = if recovery_complete { "ok" } else { "degraded" };
|
||||
let status = serde_json::json!({
|
||||
"status": health_status,
|
||||
"crash_recovery_complete": recovery_complete,
|
||||
"uptime_seconds": uptime,
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"services": {
|
||||
"rpc": true,
|
||||
"sessions": true,
|
||||
}
|
||||
});
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(
|
||||
serde_json::to_vec(&status).unwrap_or_default(),
|
||||
))
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
// Node message — P2P endpoint (authenticated by source validation, not cookie)
|
||||
(Method::POST, "/archipelago/node-message") => {
|
||||
Self::handle_node_message(body_bytes).await
|
||||
}
|
||||
|
||||
// Mesh typed envelope relay over federation — peers POST
|
||||
// pre-encoded TypedEnvelope wire bytes here when the envelope is
|
||||
// too large for a single LoRa frame (primarily ContentRef). No
|
||||
// session auth: the body carries a pubkey + ed25519 signature
|
||||
// over the wire bytes which we verify before dispatching.
|
||||
(Method::POST, "/archipelago/mesh-typed") => {
|
||||
Self::handle_mesh_typed_relay(self.rpc_handler.clone(), body_bytes).await
|
||||
}
|
||||
|
||||
// Backup archive download — session-gated. Lives under /api/blob/
|
||||
// so the existing nginx `location /api/blob` prefix proxies it on
|
||||
// every fleet node without a config change.
|
||||
(Method::GET, p) if p.starts_with("/api/blob/backup/") => {
|
||||
if !self.is_authenticated(&headers).await {
|
||||
return Ok(Self::unauthorized());
|
||||
}
|
||||
self.handle_backup_download(p).await
|
||||
}
|
||||
|
||||
// Blob upload — local/session use only. Session-authenticated so
|
||||
// only the node owner can push attachments into the blob store.
|
||||
(Method::POST, "/api/blob") => {
|
||||
if !self.is_authenticated(&headers).await {
|
||||
return Ok(Self::unauthorized());
|
||||
}
|
||||
Self::handle_blob_upload(
|
||||
&self.blob_store,
|
||||
&self.self_pubkey_hex,
|
||||
&self.config.data_dir,
|
||||
&headers,
|
||||
body_bytes,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// Share-to-mesh intent — marketplace app iframes POST a file here
|
||||
// to stage it as a mesh attachment. Same body format as /api/blob
|
||||
// (raw bytes + X-Blob-Mime/X-Blob-Filename headers). The app is
|
||||
// expected to postMessage `{type:'share-to-mesh', cid, ...}` to
|
||||
// its parent window afterwards so the Mesh view can pick it up.
|
||||
// Authenticated by session cookie + a relaxed Origin check (any
|
||||
// port on the archipelago host is allowed, so proxied apps on
|
||||
// their own ports can reach it with credentials:'include').
|
||||
(Method::POST, "/api/share-to-mesh") => {
|
||||
if !self.is_authenticated(&headers).await {
|
||||
return Ok(Self::unauthorized());
|
||||
}
|
||||
let origin = match self.validate_app_origin(&headers) {
|
||||
Some(o) => o,
|
||||
None => {
|
||||
return Ok(build_response(
|
||||
StatusCode::FORBIDDEN,
|
||||
"text/plain",
|
||||
hyper::Body::from("origin not allowed"),
|
||||
))
|
||||
}
|
||||
};
|
||||
Self::handle_share_to_mesh(
|
||||
&self.blob_store,
|
||||
&self.self_pubkey_hex,
|
||||
&headers,
|
||||
body_bytes,
|
||||
&origin,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// Blob download — peer-facing. No session required; authenticated
|
||||
// by HMAC capability token signed when the blob ref was shared.
|
||||
(Method::GET, p) if p.starts_with("/blob/") => {
|
||||
Self::handle_blob_download(&self.blob_store, p, &query_string).await
|
||||
}
|
||||
|
||||
// Content preview — degraded previews for paid content (no auth, no payment)
|
||||
(Method::GET, p) if p.starts_with("/content/") && p.ends_with("/preview") => {
|
||||
Self::handle_content_preview(p, &self.config).await
|
||||
}
|
||||
|
||||
// Lightning-invoice peer-file sale (#46): mint invoice / poll settlement
|
||||
(Method::GET, p) if p.starts_with("/content/") && p.ends_with("/invoice") => {
|
||||
self.handle_content_invoice(p).await
|
||||
}
|
||||
(Method::GET, p) if p.starts_with("/content/") && p.contains("/invoice-status/") => {
|
||||
self.handle_content_invoice_status(p).await
|
||||
}
|
||||
|
||||
// On-chain peer-file sale (#46): issue address / poll for payment
|
||||
(Method::GET, p) if p.starts_with("/content/") && p.contains("/onchain-status/") => {
|
||||
self.handle_content_onchain_status(p).await
|
||||
}
|
||||
(Method::GET, p) if p.starts_with("/content/") && p.ends_with("/onchain") => {
|
||||
self.handle_content_onchain(p).await
|
||||
}
|
||||
|
||||
// Content serving — peers access shared content over Tor (no session auth)
|
||||
(Method::GET, p) if p.starts_with("/content/") => {
|
||||
Self::handle_content_request(p, &headers, &self.config).await
|
||||
}
|
||||
|
||||
// Content catalog — list available content (no session auth, for peers)
|
||||
(Method::GET, "/content") => Self::handle_content_catalog(&self.config).await,
|
||||
|
||||
// Electrs status — unauthenticated (read-only sync status)
|
||||
(Method::GET, "/electrs-status") => Self::handle_electrs_status().await,
|
||||
(Method::GET, "/bitcoin-status") => Self::handle_bitcoin_status().await,
|
||||
|
||||
// App-catalog proxy — fetches catalog.json from the configured
|
||||
// upstream URLs server-side so the browser doesn't hit CORS
|
||||
// (upstream Gitea has no ACAO header) or CSP (IP-port upstream
|
||||
// falls outside `connect-src`). Session-authenticated so only
|
||||
// the logged-in node owner can spin up fetches.
|
||||
(Method::GET, "/api/app-catalog") => {
|
||||
if !self.is_authenticated(&headers).await {
|
||||
return Ok(Self::unauthorized());
|
||||
}
|
||||
self.handle_app_catalog_proxy().await
|
||||
}
|
||||
|
||||
// Pine node status — public tier (version/uptime/height/sync/peer
|
||||
// counts) is unauthenticated like /bitcoin-status; Lightning
|
||||
// balances + latest mesh message additionally require the bearer
|
||||
// token the pine/HA seeder minted (or a valid session).
|
||||
(Method::GET, "/api/pine/status") => {
|
||||
let bearer = headers
|
||||
.get(hyper::header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.unwrap_or("");
|
||||
let authorized = self.rpc_handler.pine_status_token_ok(bearer).await
|
||||
|| self.is_authenticated(&headers).await;
|
||||
let body = self.rpc_handler.pine_status_json(authorized).await;
|
||||
Ok(build_response(
|
||||
StatusCode::OK,
|
||||
"application/json",
|
||||
hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()),
|
||||
))
|
||||
}
|
||||
|
||||
// LND connect info — nginx validates session cookie (presence check),
|
||||
// backend is bound to 127.0.0.1 so only nginx can reach it.
|
||||
// No backend auth check here because the LND UI iframe fetches this
|
||||
// endpoint and the session cookie flow is validated at the nginx layer.
|
||||
(Method::GET, "/lnd-connect-info") => {
|
||||
let origin = self.app_cors_origin(&headers);
|
||||
Self::handle_lnd_connect_info(self.rpc_handler.clone(), &origin).await
|
||||
}
|
||||
|
||||
// Container logs — requires session
|
||||
(Method::GET, path) if path.starts_with("/api/container/logs") => {
|
||||
if !self.is_authenticated(&headers).await {
|
||||
return Ok(Self::unauthorized());
|
||||
}
|
||||
let origin = self.validate_origin(&headers).unwrap_or_default();
|
||||
Self::handle_container_logs_http(self.rpc_handler.clone(), path, &origin).await
|
||||
}
|
||||
|
||||
// Peer content streaming proxy — Range-streams a peer's media file
|
||||
// so <video>/<audio> can seek/play (B3). Same-origin, session-gated.
|
||||
(Method::GET, p) if p.starts_with("/api/peer-content/") => {
|
||||
if !self.is_authenticated(&headers).await {
|
||||
return Ok(Self::unauthorized());
|
||||
}
|
||||
self.handle_peer_content_stream(p, &headers).await
|
||||
}
|
||||
|
||||
// LND proxy — requires session. The LND wallet UI calls this
|
||||
// cross-origin from its own app port, so even the 401 must carry
|
||||
// CORS headers; otherwise the browser reports a bare CORS failure
|
||||
// ("No 'Access-Control-Allow-Origin' header") instead of a
|
||||
// readable 401 the UI can act on.
|
||||
(Method::GET, path) if path.starts_with("/proxy/lnd/") => {
|
||||
let origin = self.app_cors_origin(&headers);
|
||||
if !self.is_authenticated(&headers).await {
|
||||
return Ok(Self::unauthorized_cors(&origin));
|
||||
}
|
||||
Self::handle_lnd_proxy(self.rpc_handler.clone(), path, &origin).await
|
||||
}
|
||||
|
||||
// DWN health — unauthenticated
|
||||
(Method::GET, "/dwn/health") => Self::handle_dwn_health(&self.config).await,
|
||||
|
||||
// DWN message processing — peers access over Tor for sync (no session auth)
|
||||
(Method::POST, "/dwn") => Self::handle_dwn_message(body_bytes, &self.config).await,
|
||||
|
||||
_ => Ok(Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(hyper::Body::from("Not Found"))
|
||||
.unwrap()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate that an app ID matches the safe pattern: lowercase alphanumeric + hyphens.
|
||||
fn is_valid_app_id(id: &str) -> bool {
|
||||
!id.is_empty()
|
||||
&& id.len() <= 64
|
||||
&& id
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
|
||||
&& id.as_bytes()[0] != b'-'
|
||||
}
|
||||
|
||||
/// Validate that a pubkey is a 64-char hex string.
|
||||
fn is_valid_pubkey_hex(s: &str) -> bool {
|
||||
s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
/// Strip newlines and ANSI escape sequences from strings before logging.
|
||||
fn sanitize_log_string(s: &str) -> String {
|
||||
s.replace('\n', "\\n")
|
||||
.replace('\r', "\\r")
|
||||
.replace('\x1b', "")
|
||||
}
|
||||
|
||||
/// Strip HTML-sensitive characters to prevent XSS when stored/rendered.
|
||||
fn sanitize_html(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
use super::build_response;
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use crate::node_message as node_msg;
|
||||
use anyhow::Result;
|
||||
use hyper::{Response, StatusCode};
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::{is_valid_pubkey_hex, sanitize_html, sanitize_log_string, ApiHandler};
|
||||
|
||||
impl ApiHandler {
|
||||
pub(super) async fn handle_node_message(
|
||||
body: hyper::body::Bytes,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Incoming {
|
||||
from_pubkey: Option<String>,
|
||||
from_name: Option<String>,
|
||||
message: Option<String>,
|
||||
signature: Option<String>,
|
||||
#[serde(default)]
|
||||
encrypted: bool,
|
||||
#[serde(default)]
|
||||
msg_id: Option<String>,
|
||||
}
|
||||
let incoming: Incoming = serde_json::from_slice(&body).unwrap_or(Incoming {
|
||||
from_pubkey: None,
|
||||
from_name: None,
|
||||
message: None,
|
||||
signature: None,
|
||||
encrypted: false,
|
||||
msg_id: None,
|
||||
});
|
||||
if let (Some(from), Some(msg)) = (incoming.from_pubkey.as_ref(), incoming.message.as_ref())
|
||||
{
|
||||
// Validate from_pubkey is a valid hex ed25519 pubkey
|
||||
if !is_valid_pubkey_hex(from) {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"Invalid pubkey format"}"#),
|
||||
));
|
||||
}
|
||||
// Verify ed25519 signature if provided (required for trusted messages)
|
||||
if let Some(sig_hex) = &incoming.signature {
|
||||
match crate::identity::NodeIdentity::verify(from, msg.as_bytes(), sig_hex) {
|
||||
Ok(true) => {}
|
||||
_ => {
|
||||
return Ok(build_response(
|
||||
StatusCode::FORBIDDEN,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"Invalid signature"}"#),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Decrypt if the message is E2E encrypted
|
||||
let plaintext = if incoming.encrypted {
|
||||
// Load our identity to derive shared secret
|
||||
let data_dir = std::path::Path::new("/var/lib/archipelago");
|
||||
let identity_dir = data_dir.join("identity");
|
||||
match crate::identity::NodeIdentity::load_or_create(&identity_dir).await {
|
||||
Ok(node_id) => {
|
||||
match node_msg::decrypt_from_peer(node_id.signing_key(), from, msg) {
|
||||
Ok(decrypted) => {
|
||||
tracing::info!(
|
||||
"Decrypted E2E message from {}...",
|
||||
&from[..16.min(from.len())]
|
||||
);
|
||||
decrypted
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"E2E decryption failed from {}: {}",
|
||||
&from[..16.min(from.len())],
|
||||
e
|
||||
);
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"Decryption failed"}"#),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Cannot decrypt: identity load failed: {}", e);
|
||||
msg.clone()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
msg.clone()
|
||||
};
|
||||
|
||||
// Detect a `connection_accepted` reply: the remote peer just
|
||||
// approved an outbound request we sent, so mirror their add on
|
||||
// our side (bidirectional peering without a manual second
|
||||
// click). JSON-shape only — any non-matching payload stays in
|
||||
// the normal received-messages store below.
|
||||
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&plaintext) {
|
||||
if val.get("type").and_then(|v| v.as_str()) == Some("connection_accepted") {
|
||||
if let (Some(their_onion), Some(their_pubkey)) = (
|
||||
val.get("from_onion").and_then(|v| v.as_str()),
|
||||
val.get("from_pubkey").and_then(|v| v.as_str()),
|
||||
) {
|
||||
let data_dir = std::path::Path::new("/var/lib/archipelago");
|
||||
let peer = crate::peers::KnownPeer {
|
||||
onion: their_onion.to_string(),
|
||||
pubkey: their_pubkey.to_string(),
|
||||
name: val
|
||||
.get("from_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
added_at: Some(chrono::Utc::now().to_rfc3339()),
|
||||
};
|
||||
match crate::peers::add_peer(data_dir, peer).await {
|
||||
Ok(_) => tracing::info!(
|
||||
from = %sanitize_log_string(from),
|
||||
"Auto-added peer after connection_accepted"
|
||||
),
|
||||
Err(e) => tracing::warn!(
|
||||
from = %sanitize_log_string(from),
|
||||
error = %e,
|
||||
"Failed to auto-add peer on connection_accepted"
|
||||
),
|
||||
}
|
||||
}
|
||||
return Ok(build_response(
|
||||
StatusCode::OK,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"ok":true,"handled":"connection_accepted"}"#),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(handled) =
|
||||
crate::api::rpc::bitcoin_relay::record_incoming_relay_message(
|
||||
std::path::Path::new("/var/lib/archipelago"),
|
||||
from,
|
||||
incoming.from_name.as_deref(),
|
||||
&val,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(build_response(
|
||||
StatusCode::OK,
|
||||
"application/json",
|
||||
hyper::Body::from(format!(r#"{{"ok":true,"handled":"{}"}}"#, handled)),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let safe_from = sanitize_log_string(from);
|
||||
let safe_msg = sanitize_log_string(&plaintext);
|
||||
tracing::info!("Received message from {}: {}", safe_from, safe_msg);
|
||||
let clean_from = sanitize_html(from);
|
||||
let clean_msg = sanitize_html(&plaintext);
|
||||
let clean_name = incoming.from_name.as_deref().map(sanitize_html);
|
||||
node_msg::store_received(
|
||||
&clean_from,
|
||||
&clean_msg,
|
||||
clean_name.as_deref(),
|
||||
incoming.msg_id.as_deref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Ok(build_response(
|
||||
StatusCode::OK,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"ok":true}"#),
|
||||
))
|
||||
}
|
||||
|
||||
/// Federation-routed mesh typed envelope. Body:
|
||||
/// `{from_pubkey, from_name?, typed_envelope_b64, signature}`
|
||||
/// Signature is ed25519 over the raw wire bytes, verified against
|
||||
/// from_pubkey before dispatch.
|
||||
pub(super) async fn handle_mesh_typed_relay(
|
||||
rpc_handler: Arc<RpcHandler>,
|
||||
body: hyper::body::Bytes,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Incoming {
|
||||
from_pubkey: String,
|
||||
#[serde(default)]
|
||||
from_name: Option<String>,
|
||||
typed_envelope_b64: String,
|
||||
signature: String,
|
||||
}
|
||||
let incoming: Incoming = match serde_json::from_slice(&body) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"application/json",
|
||||
hyper::Body::from(format!(r#"{{"error":"bad json: {}"}}"#, e)),
|
||||
));
|
||||
}
|
||||
};
|
||||
if !is_valid_pubkey_hex(&incoming.from_pubkey) {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"invalid pubkey"}"#),
|
||||
));
|
||||
}
|
||||
let wire = match BASE64.decode(incoming.typed_envelope_b64.as_bytes()) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"bad base64"}"#),
|
||||
));
|
||||
}
|
||||
};
|
||||
match crate::identity::NodeIdentity::verify(
|
||||
&incoming.from_pubkey,
|
||||
&wire,
|
||||
&incoming.signature,
|
||||
) {
|
||||
Ok(true) => {}
|
||||
_ => {
|
||||
return Ok(build_response(
|
||||
StatusCode::FORBIDDEN,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"signature rejected"}"#),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Inject into mesh state via the shared MeshService. Mirrors a radio
|
||||
// receive, so the message lands in the same chat stream as LoRa-
|
||||
// delivered messages from the same peer.
|
||||
let service = rpc_handler.mesh_service_arc();
|
||||
let svc_guard = service.read().await;
|
||||
let Some(svc) = svc_guard.as_ref() else {
|
||||
return Ok(build_response(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"mesh not running"}"#),
|
||||
));
|
||||
};
|
||||
if let Err(e) = svc
|
||||
.inject_typed_from_federation(
|
||||
&incoming.from_pubkey,
|
||||
incoming.from_name.as_deref(),
|
||||
wire,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("mesh-typed relay inject failed: {}", e);
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"application/json",
|
||||
hyper::Body::from(format!(r#"{{"error":"{}"}}"#, e)),
|
||||
));
|
||||
}
|
||||
Ok(build_response(
|
||||
StatusCode::OK,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"ok":true}"#),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
use super::build_response;
|
||||
use crate::api::rpc::lnd::LND_REST_BASE_URL;
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use crate::bitcoin_status;
|
||||
use crate::electrs_status;
|
||||
use anyhow::Result;
|
||||
use hyper::{Response, StatusCode};
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::{is_valid_app_id, ApiHandler};
|
||||
|
||||
impl ApiHandler {
|
||||
pub(super) async fn handle_container_logs_http(
|
||||
rpc: Arc<RpcHandler>,
|
||||
path: &str,
|
||||
cors_origin: &str,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let query = path
|
||||
.strip_prefix("/api/container/logs")
|
||||
.and_then(|s| s.strip_prefix('?'))
|
||||
.unwrap_or("");
|
||||
let params: std::collections::HashMap<String, String> = query
|
||||
.split('&')
|
||||
.filter_map(|p| {
|
||||
let mut it = p.splitn(2, '=');
|
||||
let k = it.next()?.to_string();
|
||||
let v = it.next()?.to_string();
|
||||
Some((k, v))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let app_id = params.get("app_id").map(|s| s.as_str()).unwrap_or("lnd");
|
||||
|
||||
// Validate app_id format
|
||||
if !is_valid_app_id(app_id) {
|
||||
let body = serde_json::json!({ "error": "Invalid app_id" });
|
||||
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"application/json",
|
||||
hyper::Body::from(body_bytes),
|
||||
));
|
||||
}
|
||||
|
||||
let lines = params
|
||||
.get("lines")
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
.unwrap_or(200);
|
||||
|
||||
match rpc.get_container_logs_value(app_id, lines).await {
|
||||
Ok(value) => {
|
||||
let body = serde_json::json!({ "result": value });
|
||||
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Access-Control-Allow-Origin", cors_origin)
|
||||
.header("Access-Control-Allow-Credentials", "true")
|
||||
.header("Vary", "Origin")
|
||||
.body(hyper::Body::from(body_bytes))
|
||||
.unwrap())
|
||||
}
|
||||
Err(e) => {
|
||||
let body = serde_json::json!({ "error": e.to_string() });
|
||||
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Access-Control-Allow-Origin", cors_origin)
|
||||
.header("Access-Control-Allow-Credentials", "true")
|
||||
.header("Vary", "Origin")
|
||||
.body(hyper::Body::from(body_bytes))
|
||||
.unwrap())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn handle_electrs_status() -> Result<Response<hyper::Body>> {
|
||||
let status = electrs_status::get_electrs_sync_status().await;
|
||||
let body = serde_json::to_vec(&status).unwrap_or_default();
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Cache-Control", "no-store")
|
||||
.body(hyper::Body::from(body))
|
||||
.unwrap_or_else(|_| Response::new(hyper::Body::from("{}"))))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_bitcoin_status() -> Result<Response<hyper::Body>> {
|
||||
let status = bitcoin_status::get_bitcoin_status().await;
|
||||
let body = serde_json::to_vec(&status).unwrap_or_default();
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Cache-Control", "no-store")
|
||||
.body(hyper::Body::from(body))
|
||||
.unwrap_or_else(|_| Response::new(hyper::Body::from("{}"))))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_lnd_connect_info(
|
||||
rpc: std::sync::Arc<super::super::rpc::RpcHandler>,
|
||||
cors_origin: &str,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
// The LND wallet UI is served on its own APP_PORTS origin and fetches
|
||||
// this cross-origin, so it needs the CORS headers echoed back.
|
||||
let cors = |builder: hyper::http::response::Builder| {
|
||||
builder
|
||||
.header("Access-Control-Allow-Origin", cors_origin)
|
||||
.header("Access-Control-Allow-Credentials", "true")
|
||||
.header("Vary", "Origin")
|
||||
};
|
||||
match rpc.handle_lnd_connect_info().await {
|
||||
Ok(val) => {
|
||||
let body = serde_json::to_vec(&val).unwrap_or_default();
|
||||
Ok(cors(
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json"),
|
||||
)
|
||||
.body(hyper::Body::from(body))
|
||||
.unwrap_or_else(|_| Response::new(hyper::Body::from("{}"))))
|
||||
}
|
||||
Err(e) => Ok(cors(
|
||||
Response::builder()
|
||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.header("Content-Type", "application/json"),
|
||||
)
|
||||
.body(hyper::Body::from(
|
||||
serde_json::json!({"error": e.to_string()}).to_string(),
|
||||
))
|
||||
.unwrap()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn handle_lnd_proxy(
|
||||
rpc: Arc<RpcHandler>,
|
||||
path: &str,
|
||||
cors_origin: &str,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/");
|
||||
let url = format!("{LND_REST_BASE_URL}{suffix}");
|
||||
// LND REST serves a self-signed cert and requires the admin macaroon.
|
||||
// A bare reqwest::get() uses the default client, which rejects the
|
||||
// self-signed cert (TLS verify error -> 502 "failing to fetch") and
|
||||
// sends no macaroon. Use the shared authenticated client instead — the
|
||||
// same one lnd.getinfo and the wallet RPCs use.
|
||||
let request = match rpc.lnd_client().await {
|
||||
Ok((client, macaroon_hex)) => client
|
||||
.get(&url)
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.map_err(anyhow::Error::from),
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
match request {
|
||||
Ok(resp) => {
|
||||
let status = resp.status().as_u16();
|
||||
let headers = resp.headers().clone();
|
||||
let body = resp.bytes().await.unwrap_or_default();
|
||||
let mut builder = Response::builder().status(status);
|
||||
if let Some(ct) = headers.get("content-type") {
|
||||
if let Ok(s) = ct.to_str() {
|
||||
builder = builder.header("Content-Type", s);
|
||||
}
|
||||
}
|
||||
builder
|
||||
.header("Access-Control-Allow-Origin", cors_origin)
|
||||
.header("Access-Control-Allow-Credentials", "true")
|
||||
.header("Vary", "Origin")
|
||||
.body(hyper::Body::from(body))
|
||||
.map_err(|e| anyhow::anyhow!("response build: {}", e))
|
||||
}
|
||||
Err(e) => {
|
||||
let body = serde_json::json!({ "error": e.to_string() });
|
||||
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::BAD_GATEWAY)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Access-Control-Allow-Origin", cors_origin)
|
||||
.header("Access-Control-Allow-Credentials", "true")
|
||||
.header("Vary", "Origin")
|
||||
.body(hyper::Body::from(body_bytes))
|
||||
.unwrap())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Range-streaming proxy for a peer's content file (B3). The browser's
|
||||
/// `<video>`/`<audio>` element makes Range requests; we forward the Range
|
||||
/// header to the peer's `/content/<id>` (which already returns 206 Partial
|
||||
/// Content) and pass the bytes + Content-Range/Content-Type straight back.
|
||||
/// This replaces the old path of downloading the whole file as base64 into
|
||||
/// a non-seekable Blob URL, which broke playback/seeking for video and
|
||||
/// large audio. Same-origin + session-authenticated (checked by caller).
|
||||
/// Path: `/api/peer-content/<onion>/<content_id>`.
|
||||
pub(super) async fn handle_peer_content_stream(
|
||||
&self,
|
||||
path: &str,
|
||||
headers: &hyper::HeaderMap,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let bad = |msg: &str| {
|
||||
Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"application/json",
|
||||
hyper::Body::from(serde_json::json!({ "error": msg }).to_string()),
|
||||
))
|
||||
};
|
||||
let rest = path.strip_prefix("/api/peer-content/").unwrap_or("");
|
||||
let (onion, content_id) = match rest.split_once('/') {
|
||||
Some((o, c)) if !o.is_empty() && !c.is_empty() => (o, c),
|
||||
_ => return bad("expected /api/peer-content/<onion>/<content_id>"),
|
||||
};
|
||||
// Validate to prevent SSRF / path traversal.
|
||||
let onion_norm = onion.trim_end_matches(".onion");
|
||||
let onion_ok = onion_norm.len() == 56
|
||||
&& onion_norm
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit());
|
||||
let id_ok = !content_id.contains("..")
|
||||
&& content_id
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'));
|
||||
if !onion_ok || !id_ok {
|
||||
return bad("invalid onion or content id");
|
||||
}
|
||||
|
||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
let peer_path = format!("/content/{}", content_id);
|
||||
// Generous overall timeout: this endpoint serves both seek/Range
|
||||
// playback (small, finishes fast) and full-file downloads of large
|
||||
// media (#38). 60s was too tight for a multi-hundred-MB transfer over
|
||||
// Tor and aborted the download mid-stream.
|
||||
let mut req = crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &peer_path)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.timeout(std::time::Duration::from_secs(900));
|
||||
if let Some(r) = headers.get("range").and_then(|v| v.to_str().ok()) {
|
||||
req = req.header("Range", r.to_string());
|
||||
}
|
||||
match req.send_get().await {
|
||||
Ok((resp, _transport)) => {
|
||||
let status = resp.status().as_u16();
|
||||
let rh = resp.headers().clone();
|
||||
let mut builder = Response::builder()
|
||||
.status(status)
|
||||
.header("Accept-Ranges", "bytes");
|
||||
for h in ["content-type", "content-range", "content-length"] {
|
||||
if let Some(v) = rh.get(h).and_then(|v| v.to_str().ok()) {
|
||||
builder = builder.header(h, v);
|
||||
}
|
||||
}
|
||||
// Stream the peer's body straight through instead of buffering
|
||||
// the whole file into memory (#38). For a 178MB download the old
|
||||
// `resp.bytes().await` allocated the entire file on the node
|
||||
// before sending a byte; `wrap_stream` forwards chunks as they
|
||||
// arrive, with constant memory.
|
||||
Ok(builder
|
||||
.body(hyper::Body::wrap_stream(resp.bytes_stream()))
|
||||
.unwrap_or_else(|_| Response::new(hyper::Body::empty())))
|
||||
}
|
||||
Err(e) => Ok(build_response(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"application/json",
|
||||
hyper::Body::from(serde_json::json!({ "error": e.to_string() }).to_string()),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
use anyhow::{Context, Result};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use hyper::{Request, Response};
|
||||
use hyper_ws_listener::WsStream;
|
||||
use serde::Deserialize;
|
||||
use std::time::Instant;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use super::ApiHandler;
|
||||
|
||||
/// Allowed xdotool key names. Only these pass validation.
|
||||
const ALLOWED_KEYS: &[&str] = &[
|
||||
// Letters
|
||||
"a",
|
||||
"b",
|
||||
"c",
|
||||
"d",
|
||||
"e",
|
||||
"f",
|
||||
"g",
|
||||
"h",
|
||||
"i",
|
||||
"j",
|
||||
"k",
|
||||
"l",
|
||||
"m",
|
||||
"n",
|
||||
"o",
|
||||
"p",
|
||||
"q",
|
||||
"r",
|
||||
"s",
|
||||
"t",
|
||||
"u",
|
||||
"v",
|
||||
"w",
|
||||
"x",
|
||||
"y",
|
||||
"z",
|
||||
"A",
|
||||
"B",
|
||||
"C",
|
||||
"D",
|
||||
"E",
|
||||
"F",
|
||||
"G",
|
||||
"H",
|
||||
"I",
|
||||
"J",
|
||||
"K",
|
||||
"L",
|
||||
"M",
|
||||
"N",
|
||||
"O",
|
||||
"P",
|
||||
"Q",
|
||||
"R",
|
||||
"S",
|
||||
"T",
|
||||
"U",
|
||||
"V",
|
||||
"W",
|
||||
"X",
|
||||
"Y",
|
||||
"Z",
|
||||
// Numbers
|
||||
"0",
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"6",
|
||||
"7",
|
||||
"8",
|
||||
"9",
|
||||
// Navigation
|
||||
"Up",
|
||||
"Down",
|
||||
"Left",
|
||||
"Right",
|
||||
"Return",
|
||||
"Escape",
|
||||
"Tab",
|
||||
"BackSpace",
|
||||
"Delete",
|
||||
"Home",
|
||||
"End",
|
||||
"Prior",
|
||||
"Next", // Prior=PageUp, Next=PageDown
|
||||
// Modifiers (for combos like shift+a)
|
||||
"space",
|
||||
"minus",
|
||||
"equal",
|
||||
"bracketleft",
|
||||
"bracketright",
|
||||
"backslash",
|
||||
"semicolon",
|
||||
"apostrophe",
|
||||
"grave",
|
||||
"comma",
|
||||
"period",
|
||||
"slash",
|
||||
// Function keys
|
||||
"F1",
|
||||
"F2",
|
||||
"F3",
|
||||
"F4",
|
||||
"F5",
|
||||
"F6",
|
||||
"F7",
|
||||
"F8",
|
||||
"F9",
|
||||
"F10",
|
||||
"F11",
|
||||
"F12",
|
||||
// Symbols — xdotool names
|
||||
"exclam",
|
||||
"at",
|
||||
"numbersign",
|
||||
"dollar",
|
||||
"percent",
|
||||
"asciicircum",
|
||||
"ampersand",
|
||||
"asterisk",
|
||||
"parenleft",
|
||||
"parenright",
|
||||
"underscore",
|
||||
"plus",
|
||||
"braceleft",
|
||||
"braceright",
|
||||
"bar",
|
||||
"colon",
|
||||
"quotedbl",
|
||||
"less",
|
||||
"greater",
|
||||
"question",
|
||||
"asciitilde",
|
||||
];
|
||||
|
||||
/// Validate a key name against the whitelist.
|
||||
/// Also allows "shift+X" combos where X is in the whitelist.
|
||||
fn validate_key(key: &str) -> bool {
|
||||
if ALLOWED_KEYS.contains(&key) {
|
||||
return true;
|
||||
}
|
||||
// Allow modifier combos: "shift+a", "ctrl+c", etc.
|
||||
if let Some((modifier, base)) = key.split_once('+') {
|
||||
let valid_modifiers = ["shift", "ctrl", "alt", "super"];
|
||||
return valid_modifiers.contains(&modifier) && ALLOWED_KEYS.contains(&base);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "t")]
|
||||
enum InputCommand {
|
||||
#[serde(rename = "k")]
|
||||
Key {
|
||||
k: String,
|
||||
/// Optional player ID (1 or 2) for multi-player arcade games.
|
||||
/// When absent, input is broadcast without player tagging.
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
p: Option<u8>,
|
||||
},
|
||||
#[serde(rename = "m")]
|
||||
MouseMove { x: i32, y: i32 },
|
||||
#[serde(rename = "c")]
|
||||
Click { b: u8 },
|
||||
#[serde(rename = "s")]
|
||||
Scroll { y: i32 },
|
||||
#[serde(rename = "p")]
|
||||
Ping,
|
||||
}
|
||||
|
||||
/// Validate and acknowledge input — relay-only, no xdotool.
|
||||
/// All input is forwarded to browser clients via the broadcast channel;
|
||||
/// the browser's remote-relay.ts dispatches DOM events from there.
|
||||
async fn handle_input(msg: &str) -> Result<Option<String>> {
|
||||
let cmd: InputCommand = serde_json::from_str(msg).context("invalid input command")?;
|
||||
|
||||
match cmd {
|
||||
InputCommand::Key { ref k, .. } => {
|
||||
if !validate_key(k) {
|
||||
warn!("rejected key: {}", k);
|
||||
return Ok(Some(r#"{"t":"e","m":"invalid key"}"#.to_string()));
|
||||
}
|
||||
}
|
||||
InputCommand::MouseMove { x, y } => {
|
||||
let _x = x.clamp(-50, 50);
|
||||
let _y = y.clamp(-50, 50);
|
||||
}
|
||||
InputCommand::Click { b } => {
|
||||
let _b = b.clamp(1, 3);
|
||||
}
|
||||
InputCommand::Scroll { y } => {
|
||||
let _y = y.clamp(-10, 10);
|
||||
}
|
||||
InputCommand::Ping => {
|
||||
return Ok(Some(r#"{"t":"p"}"#.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
impl ApiHandler {
|
||||
pub(super) async fn handle_remote_input(
|
||||
req: Request<hyper::Body>,
|
||||
relay_tx: broadcast::Sender<String>,
|
||||
mut external_open_rx: broadcast::Receiver<String>,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
// Extract optional player ID from query string: /ws/remote-input?p=1
|
||||
let player_id: Option<u8> = req
|
||||
.uri()
|
||||
.query()
|
||||
.and_then(|q| q.split('&').find(|s| s.starts_with("p=")))
|
||||
.and_then(|s| s.get(2..))
|
||||
.and_then(|v| v.parse().ok())
|
||||
.filter(|&p: &u8| p == 1 || p == 2);
|
||||
|
||||
let (response, ws_fut_opt) = hyper_ws_listener::create_ws(req)
|
||||
.map_err(|e| anyhow::anyhow!("WebSocket upgrade failed: {}", e))?;
|
||||
|
||||
if let Some(ws_fut) = ws_fut_opt {
|
||||
tokio::spawn(async move {
|
||||
let ws_stream: WsStream = match ws_fut.await {
|
||||
Ok(Ok(s)) => s,
|
||||
Ok(Err(e)) => {
|
||||
debug!("Remote input WS handshake failed: {}", e);
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("Remote input WS task join failed: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
info!("Remote input connected");
|
||||
|
||||
let (mut tx, mut rx) = ws_stream.split();
|
||||
|
||||
// Send ready message
|
||||
let _ = tx.send(Message::Text(r#"{"t":"ok"}"#.to_string())).await;
|
||||
|
||||
let ping_interval = tokio::time::interval(tokio::time::Duration::from_secs(30));
|
||||
tokio::pin!(ping_interval);
|
||||
let mut last_activity = Instant::now();
|
||||
let mut msg_count: u64 = 0;
|
||||
let mut rate_window_start = Instant::now();
|
||||
let mut rate_count: u32 = 0;
|
||||
const MAX_RATE: u32 = 120; // messages per second
|
||||
const INACTIVITY_TIMEOUT: u64 = 300;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = ping_interval.tick() => {
|
||||
if last_activity.elapsed().as_secs() >= INACTIVITY_TIMEOUT {
|
||||
info!("Remote input inactive, closing");
|
||||
let _ = tx.send(Message::Close(None)).await;
|
||||
break;
|
||||
}
|
||||
if tx.send(Message::Ping(vec![])).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Forward kiosk "open this URL externally" requests down to
|
||||
// the companion so the link opens in the phone's browser.
|
||||
ext = external_open_rx.recv() => {
|
||||
match ext {
|
||||
Ok(text) => {
|
||||
if tx.send(Message::Text(text)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(_)) => {}
|
||||
Err(broadcast::error::RecvError::Closed) => {}
|
||||
}
|
||||
}
|
||||
msg = rx.next() => {
|
||||
match msg {
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
last_activity = Instant::now();
|
||||
msg_count += 1;
|
||||
|
||||
// Rate limiting
|
||||
if rate_window_start.elapsed().as_millis() >= 1000 {
|
||||
rate_window_start = Instant::now();
|
||||
rate_count = 0;
|
||||
}
|
||||
rate_count += 1;
|
||||
if rate_count > MAX_RATE {
|
||||
continue; // silently drop
|
||||
}
|
||||
|
||||
// Relay to browser clients. If this connection has a
|
||||
// player ID from query string and the message is a key
|
||||
// event without a player field, inject it so the browser
|
||||
// can route input to the correct player.
|
||||
let relay_text = if let Some(pid) = player_id {
|
||||
if text.contains(r#""t":"k""#) && !text.contains(r#""p":"#) {
|
||||
// Insert "p":N before the closing brace
|
||||
if let Some(pos) = text.rfind('}') {
|
||||
let mut tagged = text[..pos].to_string();
|
||||
tagged.push_str(&format!(r#","p":{}"#, pid));
|
||||
tagged.push('}');
|
||||
tagged
|
||||
} else {
|
||||
text.clone()
|
||||
}
|
||||
} else {
|
||||
text.clone()
|
||||
}
|
||||
} else {
|
||||
text.clone()
|
||||
};
|
||||
let _ = relay_tx.send(relay_text);
|
||||
|
||||
match handle_input(&text).await {
|
||||
Ok(Some(reply)) => {
|
||||
let _ = tx.send(Message::Text(reply)).await;
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
debug!("Input error: {}", e);
|
||||
let err = format!(r#"{{"t":"e","m":"{}"}}"#,
|
||||
e.to_string().replace('"', "'"));
|
||||
let _ = tx.send(Message::Text(err)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Pong(_))) => {
|
||||
last_activity = Instant::now();
|
||||
}
|
||||
Some(Ok(Message::Ping(data))) => {
|
||||
last_activity = Instant::now();
|
||||
let _ = tx.send(Message::Pong(data)).await;
|
||||
}
|
||||
Some(Ok(Message::Close(_))) | None => break,
|
||||
Some(Ok(_)) => { last_activity = Instant::now(); }
|
||||
Some(Err(e)) => {
|
||||
debug!("Remote input stream error: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
"Remote input disconnected ({} messages processed)",
|
||||
msg_count
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
use anyhow::Result;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use hyper::{Request, Response};
|
||||
use hyper_ws_listener::WsStream;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tracing::{debug, info};
|
||||
|
||||
use super::ApiHandler;
|
||||
|
||||
impl ApiHandler {
|
||||
/// WebSocket endpoint for browser clients to receive relayed companion input.
|
||||
/// The browser's remote-relay.ts dispatches these as DOM keyboard/mouse events.
|
||||
///
|
||||
/// The kiosk also uses this socket in the *reverse* direction: when an "open
|
||||
/// in external browser" app is launched, the kiosk can't usefully open it
|
||||
/// itself, so it sends `{"t":"o","url":"https://…"}` here. We validate the
|
||||
/// URL and publish it on `external_open_tx`, which the companion (phone)
|
||||
/// socket forwards so the link opens in the phone's default browser.
|
||||
pub(super) async fn handle_remote_relay(
|
||||
req: Request<hyper::Body>,
|
||||
mut relay_rx: broadcast::Receiver<String>,
|
||||
external_open_tx: broadcast::Sender<String>,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let (response, ws_fut_opt) = hyper_ws_listener::create_ws(req)
|
||||
.map_err(|e| anyhow::anyhow!("WebSocket upgrade failed: {}", e))?;
|
||||
|
||||
if let Some(ws_fut) = ws_fut_opt {
|
||||
tokio::spawn(async move {
|
||||
let ws_stream: WsStream = match ws_fut.await {
|
||||
Ok(Ok(s)) => s,
|
||||
Ok(Err(e)) => {
|
||||
debug!("Remote relay WS handshake failed: {}", e);
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("Remote relay WS task join failed: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
info!("Remote relay client connected");
|
||||
|
||||
let (mut tx, mut rx) = ws_stream.split();
|
||||
|
||||
// Send ready message
|
||||
let _ = tx.send(Message::Text(r#"{"t":"ok"}"#.to_string())).await;
|
||||
|
||||
let ping_interval = tokio::time::interval(tokio::time::Duration::from_secs(30));
|
||||
tokio::pin!(ping_interval);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = ping_interval.tick() => {
|
||||
if tx.send(Message::Ping(vec![])).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Forward relayed input from companion app
|
||||
msg = relay_rx.recv() => {
|
||||
match msg {
|
||||
Ok(text) => {
|
||||
if tx.send(Message::Text(text)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
debug!("Remote relay lagged, dropped {} messages", n);
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
// Handle client-side messages (pong, close, open-url requests)
|
||||
client_msg = rx.next() => {
|
||||
match client_msg {
|
||||
Some(Ok(Message::Pong(_))) | Some(Ok(Message::Ping(_))) => {}
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
// The only kiosk→server message we accept is an
|
||||
// external-open request: {"t":"o","url":"https://…"}.
|
||||
if let Some(url) = parse_open_url(&text) {
|
||||
debug!("Relaying external-open to companion: {}", url);
|
||||
let _ = external_open_tx.send(
|
||||
format!(r#"{{"t":"o","url":{}}}"#, json_string(&url))
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Close(_))) | None => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("Remote relay client disconnected");
|
||||
});
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a kiosk `{"t":"o","url":"…"}` external-open request, returning the URL
|
||||
/// only if it's a well-formed http(s) URL. Anything else (other message tags,
|
||||
/// non-http schemes like `javascript:`/`file:`, malformed JSON) is rejected so a
|
||||
/// compromised kiosk page can't push arbitrary URIs to the phone.
|
||||
fn parse_open_url(text: &str) -> Option<String> {
|
||||
let v: serde_json::Value = serde_json::from_str(text).ok()?;
|
||||
if v.get("t").and_then(|t| t.as_str()) != Some("o") {
|
||||
return None;
|
||||
}
|
||||
let url = v.get("url").and_then(|u| u.as_str())?.trim();
|
||||
if url.len() > 2048 {
|
||||
return None;
|
||||
}
|
||||
let lower = url.to_ascii_lowercase();
|
||||
if lower.starts_with("http://") || lower.starts_with("https://") {
|
||||
Some(url.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize a string as a JSON string literal (with surrounding quotes).
|
||||
fn json_string(s: &str) -> String {
|
||||
serde_json::Value::String(s.to_string()).to_string()
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
use crate::monitoring::MetricsStore;
|
||||
use crate::state::StateManager;
|
||||
use anyhow::Result;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use hyper::{Request, Response};
|
||||
use hyper_ws_listener::WsStream;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tracing::{debug, info};
|
||||
|
||||
use super::ApiHandler;
|
||||
|
||||
impl ApiHandler {
|
||||
pub(super) async fn handle_websocket(
|
||||
req: Request<hyper::Body>,
|
||||
state_manager: Arc<StateManager>,
|
||||
metrics_store: Arc<MetricsStore>,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let (response, ws_fut_opt) = hyper_ws_listener::create_ws(req)
|
||||
.map_err(|e| anyhow::anyhow!("WebSocket upgrade failed: {}", e))?;
|
||||
|
||||
if let Some(ws_fut) = ws_fut_opt {
|
||||
tokio::spawn(async move {
|
||||
let ws_stream: WsStream = match ws_fut.await {
|
||||
Ok(Ok(s)) => s,
|
||||
Ok(Err(e)) => {
|
||||
debug!("WebSocket handshake failed (hyper): {}", e);
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("WebSocket task join failed: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
metrics_store.increment_ws();
|
||||
info!("WebSocket /ws/db connected");
|
||||
|
||||
let (mut tx, mut rx) = ws_stream.split();
|
||||
|
||||
// Subscribe BEFORE taking the initial snapshot. Messages are full
|
||||
// data dumps keyed by a monotonic revision, so a broadcast that
|
||||
// races the snapshot is at worst a harmless duplicate/newer dump
|
||||
// delivered right after — but subscribing after the snapshot send
|
||||
// (the old order) let any update in that window vanish forever,
|
||||
// since a tokio broadcast channel never delivers sends that
|
||||
// predate subscribe(). That silently stuck clients (e.g. a fresh
|
||||
// install's post-boot container scan) on a stale initial snapshot
|
||||
// until a full page reload opened a new connection past the race.
|
||||
let mut state_rx = state_manager.subscribe();
|
||||
|
||||
let initial_msg = state_manager.get_initial_message().await;
|
||||
if let Ok(json_msg) = serde_json::to_string(&initial_msg) {
|
||||
if let Err(e) = tx.send(Message::Text(json_msg)).await {
|
||||
debug!("Failed to send initial data: {}", e);
|
||||
return;
|
||||
}
|
||||
debug!("Sent initial data dump at revision {}", initial_msg.rev);
|
||||
}
|
||||
let ping_interval = tokio::time::interval(tokio::time::Duration::from_secs(30));
|
||||
tokio::pin!(ping_interval);
|
||||
let mut last_client_activity = Instant::now();
|
||||
const INACTIVITY_TIMEOUT_SECS: u64 = 300; // 5 minutes
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = ping_interval.tick() => {
|
||||
// Check inactivity timeout
|
||||
if last_client_activity.elapsed().as_secs() >= INACTIVITY_TIMEOUT_SECS {
|
||||
info!("WebSocket client inactive for {}s, closing", INACTIVITY_TIMEOUT_SECS);
|
||||
let _ = tx.send(Message::Close(None)).await;
|
||||
break;
|
||||
}
|
||||
if tx.send(Message::Ping(vec![])).await.is_err() {
|
||||
debug!("Failed to send ping, connection likely closed");
|
||||
break;
|
||||
}
|
||||
}
|
||||
update = state_rx.recv() => {
|
||||
match update {
|
||||
Ok(msg) => {
|
||||
if let Ok(json_msg) = serde_json::to_string(&msg) {
|
||||
if let Err(e) = tx.send(Message::Text(json_msg)).await {
|
||||
debug!("Failed to send state update: {}", e);
|
||||
break;
|
||||
}
|
||||
debug!("Sent state update at revision {}", msg.rev);
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(skipped)) => {
|
||||
debug!("Client lagged behind, skipped {} messages", skipped);
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
debug!("Broadcast channel closed");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
msg = rx.next() => {
|
||||
match msg {
|
||||
Some(Ok(Message::Close(_))) => break,
|
||||
Some(Ok(Message::Pong(_))) => {
|
||||
last_client_activity = Instant::now();
|
||||
debug!("Received pong");
|
||||
}
|
||||
Some(Ok(Message::Ping(data))) => {
|
||||
last_client_activity = Instant::now();
|
||||
let _ = tx.send(Message::Pong(data)).await;
|
||||
}
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
last_client_activity = Instant::now();
|
||||
// Handle JSON ping from frontend
|
||||
if text.contains("\"type\":\"ping\"") || text.contains("\"type\": \"ping\"") {
|
||||
let _ = tx.send(Message::Text(r#"{"type":"pong"}"#.to_string())).await;
|
||||
}
|
||||
}
|
||||
Some(Ok(_)) => {
|
||||
last_client_activity = Instant::now();
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
debug!("WebSocket stream error: {}", e);
|
||||
break;
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
metrics_store.decrement_ws();
|
||||
info!("WebSocket /ws/db disconnected");
|
||||
});
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
mod handler;
|
||||
pub(crate) mod rpc;
|
||||
|
||||
pub use handler::ApiHandler;
|
||||
@@ -0,0 +1,588 @@
|
||||
//! Opt-in anonymous node analytics.
|
||||
//! When enabled, collects aggregate stats (app install counts, uptime, hardware tier).
|
||||
//! No personally identifiable information. No IP addresses. No DIDs.
|
||||
//! Data stays local until explicitly shared via future relay mechanism.
|
||||
|
||||
use super::RpcHandler;
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
const ANALYTICS_FILE: &str = "analytics-config.json";
|
||||
|
||||
impl RpcHandler {
|
||||
/// Check if analytics are enabled.
|
||||
pub(super) async fn handle_analytics_get_status(&self) -> Result<serde_json::Value> {
|
||||
let config_path = self.config.data_dir.join(ANALYTICS_FILE);
|
||||
let enabled = if config_path.exists() {
|
||||
let data = tokio::fs::read_to_string(&config_path).await?;
|
||||
let config: serde_json::Value = serde_json::from_str(&data).unwrap_or_default();
|
||||
config["enabled"].as_bool().unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"enabled": enabled,
|
||||
"description": "Anonymous aggregate statistics. No personal data collected.",
|
||||
}))
|
||||
}
|
||||
|
||||
/// Enable opt-in analytics.
|
||||
pub(super) async fn handle_analytics_enable(&self) -> Result<serde_json::Value> {
|
||||
let config_path = self.config.data_dir.join(ANALYTICS_FILE);
|
||||
let config = serde_json::json!({
|
||||
"enabled": true,
|
||||
"opted_in_at": chrono::Utc::now().to_rfc3339(),
|
||||
});
|
||||
tokio::fs::write(&config_path, serde_json::to_string_pretty(&config)?).await?;
|
||||
info!("Analytics opted in");
|
||||
Ok(serde_json::json!({ "enabled": true }))
|
||||
}
|
||||
|
||||
/// Disable analytics.
|
||||
pub(super) async fn handle_analytics_disable(&self) -> Result<serde_json::Value> {
|
||||
let config_path = self.config.data_dir.join(ANALYTICS_FILE);
|
||||
let config = serde_json::json!({
|
||||
"enabled": false,
|
||||
"opted_out_at": chrono::Utc::now().to_rfc3339(),
|
||||
});
|
||||
tokio::fs::write(&config_path, serde_json::to_string_pretty(&config)?).await?;
|
||||
info!("Analytics opted out");
|
||||
Ok(serde_json::json!({ "enabled": false }))
|
||||
}
|
||||
|
||||
/// Get an anonymous analytics snapshot of this node.
|
||||
/// Only returns aggregate data — no DIDs, no IPs, no secrets.
|
||||
pub(super) async fn handle_analytics_get_snapshot(&self) -> Result<serde_json::Value> {
|
||||
// Check if opted in
|
||||
let config_path = self.config.data_dir.join(ANALYTICS_FILE);
|
||||
let enabled = if config_path.exists() {
|
||||
let data = tokio::fs::read_to_string(&config_path).await?;
|
||||
let config: serde_json::Value = serde_json::from_str(&data).unwrap_or_default();
|
||||
config["enabled"].as_bool().unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if !enabled {
|
||||
return Ok(serde_json::json!({
|
||||
"error": "Analytics not enabled. Opt in via analytics.enable first.",
|
||||
"enabled": false,
|
||||
}));
|
||||
}
|
||||
|
||||
// Collect anonymous aggregate data
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
|
||||
let app_count = data.package_data.len();
|
||||
let running_count = data
|
||||
.package_data
|
||||
.values()
|
||||
.filter(|p| matches!(p.state, crate::data_model::PackageState::Running))
|
||||
.count();
|
||||
|
||||
// Hardware tier (anonymous)
|
||||
let cpu_cores = std::thread::available_parallelism()
|
||||
.map(|n| n.get())
|
||||
.unwrap_or(0);
|
||||
|
||||
let mem_output = tokio::process::Command::new("grep")
|
||||
.args(["MemTotal", "/proc/meminfo"])
|
||||
.output()
|
||||
.await;
|
||||
let total_ram_mb = mem_output
|
||||
.ok()
|
||||
.and_then(|o| {
|
||||
let s = String::from_utf8_lossy(&o.stdout);
|
||||
s.split_whitespace().nth(1)?.parse::<u64>().ok()
|
||||
})
|
||||
.map(|kb| kb / 1024)
|
||||
.unwrap_or(0);
|
||||
|
||||
let hardware_tier = match total_ram_mb {
|
||||
0..=3999 => "minimal",
|
||||
4000..=7999 => "standard",
|
||||
8000..=15999 => "power",
|
||||
_ => "heavy",
|
||||
};
|
||||
|
||||
let version = &data.server_info.version;
|
||||
let federation_peers = data.peer_health.len();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"version": version,
|
||||
"app_count": app_count,
|
||||
"running_count": running_count,
|
||||
"hardware_tier": hardware_tier,
|
||||
"cpu_cores": cpu_cores,
|
||||
"ram_mb": total_ram_mb,
|
||||
"federation_peers": federation_peers,
|
||||
"collected_at": chrono::Utc::now().to_rfc3339(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Build a full telemetry report for the beta fleet monitoring.
|
||||
/// Includes health data, container states, errors, and uptime.
|
||||
/// No wallet data, no keys, no personal data — only system health.
|
||||
pub(super) async fn handle_telemetry_report(&self) -> Result<serde_json::Value> {
|
||||
// Check opt-in
|
||||
let config_path = self.config.data_dir.join(ANALYTICS_FILE);
|
||||
let enabled = if config_path.exists() {
|
||||
let data = tokio::fs::read_to_string(&config_path).await?;
|
||||
let config: serde_json::Value = serde_json::from_str(&data).unwrap_or_default();
|
||||
config["enabled"].as_bool().unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if !enabled {
|
||||
anyhow::bail!("Telemetry not enabled. Opt in via analytics.enable first.");
|
||||
}
|
||||
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
|
||||
// Anonymous node ID — SHA-256 hash of the DID (not the DID itself)
|
||||
let node_id = {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(data.server_info.pubkey.as_bytes());
|
||||
hex::encode(hasher.finalize())[..16].to_string()
|
||||
};
|
||||
|
||||
// Container states
|
||||
let containers: Vec<serde_json::Value> = data
|
||||
.package_data
|
||||
.iter()
|
||||
.map(|(id, pkg)| {
|
||||
serde_json::json!({
|
||||
"id": id,
|
||||
"state": format!("{:?}", pkg.state),
|
||||
"version": pkg.manifest.version,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
// System stats
|
||||
let cpu_cores = std::thread::available_parallelism()
|
||||
.map(|n| n.get())
|
||||
.unwrap_or(0);
|
||||
let mem_output = tokio::process::Command::new("grep")
|
||||
.args(["MemTotal", "/proc/meminfo"])
|
||||
.output()
|
||||
.await;
|
||||
let total_ram_mb = mem_output
|
||||
.ok()
|
||||
.and_then(|o| {
|
||||
String::from_utf8_lossy(&o.stdout)
|
||||
.split_whitespace()
|
||||
.nth(1)?
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
})
|
||||
.map(|kb| kb / 1024)
|
||||
.unwrap_or(0);
|
||||
|
||||
// Uptime
|
||||
let uptime_secs = tokio::fs::read_to_string("/proc/uptime")
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|s| s.split_whitespace().next()?.parse::<f64>().ok())
|
||||
.map(|f| f as u64)
|
||||
.unwrap_or(0);
|
||||
|
||||
let latest = self.metrics_store.latest().await;
|
||||
let (cpu_pct, mem_pct, disk_pct): (f64, f64, f64) = latest
|
||||
.map(|s| {
|
||||
let mem_total = s.system.mem_total_bytes as f64;
|
||||
let disk_total = s.system.disk_total_bytes as f64;
|
||||
(
|
||||
s.system.cpu_percent,
|
||||
if mem_total > 0.0 {
|
||||
(s.system.mem_used_bytes as f64 / mem_total) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
if disk_total > 0.0 {
|
||||
(s.system.disk_used_bytes as f64 / disk_total) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
)
|
||||
})
|
||||
.unwrap_or((0.0, 0.0, 0.0));
|
||||
|
||||
// Recent alerts from metrics store
|
||||
let recent_alerts: Vec<serde_json::Value> = self
|
||||
.metrics_store
|
||||
.get_fired_alerts(10)
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|a| {
|
||||
serde_json::json!({
|
||||
"rule": format!("{:?}", a.kind),
|
||||
"message": a.message,
|
||||
"timestamp": a.timestamp,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let report = serde_json::json!({
|
||||
"node_id": node_id,
|
||||
"node_name": data.server_info.name.clone().filter(|n| !n.trim().is_empty()),
|
||||
"hostname": system_hostname().await,
|
||||
"server_url": local_server_url(&self.config.host_ip),
|
||||
"version": data.server_info.version,
|
||||
"uptime_secs": uptime_secs,
|
||||
"cpu_cores": cpu_cores,
|
||||
"ram_mb": total_ram_mb,
|
||||
"cpu_pct": (cpu_pct * 10.0).round() / 10.0,
|
||||
"mem_pct": (mem_pct * 10.0).round() / 10.0,
|
||||
"disk_pct": (disk_pct * 10.0).round() / 10.0,
|
||||
"containers": containers,
|
||||
"container_count": data.package_data.len(),
|
||||
"running_count": data.package_data.values()
|
||||
.filter(|p| matches!(p.state, crate::data_model::PackageState::Running)).count(),
|
||||
"federation_peers": data.peer_health.len(),
|
||||
"recent_alerts": recent_alerts,
|
||||
"reported_at": chrono::Utc::now().to_rfc3339(),
|
||||
});
|
||||
|
||||
// Save latest report to disk for debugging
|
||||
let report_path = self.config.data_dir.join("telemetry-latest.json");
|
||||
let _ = tokio::fs::write(&report_path, serde_json::to_string_pretty(&report)?).await;
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
// ── Fleet telemetry collector endpoints ──────────────────────────────
|
||||
|
||||
/// Receive a telemetry report from a fleet node.
|
||||
/// Stores it in telemetry-fleet/ directory, indexed by node_id.
|
||||
/// Does NOT require auth — called by remote nodes posting reports.
|
||||
pub(super) async fn handle_telemetry_ingest(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let report = params.context("Missing telemetry report payload")?;
|
||||
|
||||
// Validate required fields
|
||||
let node_id = report
|
||||
.get("node_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.context("Missing required field: node_id")?;
|
||||
if node_id.is_empty() || node_id.len() > 64 {
|
||||
anyhow::bail!("Invalid node_id: must be 1-64 characters");
|
||||
}
|
||||
// Sanitize node_id to prevent path traversal
|
||||
if node_id.contains('/') || node_id.contains('\\') || node_id.contains("..") {
|
||||
anyhow::bail!("Invalid node_id: contains disallowed characters");
|
||||
}
|
||||
let _version = report
|
||||
.get("version")
|
||||
.and_then(|v| v.as_str())
|
||||
.context("Missing required field: version")?;
|
||||
let _reported_at = report
|
||||
.get("reported_at")
|
||||
.and_then(|v| v.as_str())
|
||||
.context("Missing required field: reported_at")?;
|
||||
|
||||
let fleet_dir = self.config.data_dir.join("telemetry-fleet");
|
||||
tokio::fs::create_dir_all(&fleet_dir)
|
||||
.await
|
||||
.context("Failed to create telemetry-fleet directory")?;
|
||||
|
||||
// Write latest report (overwrites previous)
|
||||
let latest_path = fleet_dir.join(format!("{}.json", node_id));
|
||||
let report_json =
|
||||
serde_json::to_string_pretty(&report).context("Failed to serialize report")?;
|
||||
tokio::fs::write(&latest_path, &report_json)
|
||||
.await
|
||||
.context("Failed to write latest fleet report")?;
|
||||
|
||||
// Append to history file (cap at 200 entries)
|
||||
let history_path = fleet_dir.join(format!("{}-history.json", node_id));
|
||||
let mut history: Vec<serde_json::Value> =
|
||||
match tokio::fs::read_to_string(&history_path).await {
|
||||
Ok(data) => serde_json::from_str(&data).unwrap_or_default(),
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
history.push(report.clone());
|
||||
// Keep only the last 200 entries
|
||||
if history.len() > 200 {
|
||||
let start = history.len() - 200;
|
||||
history = history.split_off(start);
|
||||
}
|
||||
let history_json =
|
||||
serde_json::to_string_pretty(&history).context("Failed to serialize history")?;
|
||||
tokio::fs::write(&history_path, &history_json)
|
||||
.await
|
||||
.context("Failed to write fleet history")?;
|
||||
|
||||
debug!(node_id = %node_id, "Ingested fleet telemetry report");
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"status": "ok",
|
||||
"node_id": node_id,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Get all fleet nodes' latest reports.
|
||||
///
|
||||
/// Primary source: TRUSTED federated nodes from nodes.json — their
|
||||
/// `last_state` snapshot (kept fresh by federation state-sync) already
|
||||
/// carries everything the Fleet UI renders. Observer ("peer") and
|
||||
/// Untrusted nodes are deliberately excluded from Fleet.
|
||||
///
|
||||
/// Secondary source: telemetry-fleet/*.json collector reports (opt-in
|
||||
/// anonymous telemetry, includes this node's own report) — merged in for
|
||||
/// back-compat with nodes that push telemetry but aren't federated.
|
||||
pub(super) async fn handle_telemetry_fleet_status(&self) -> Result<serde_json::Value> {
|
||||
let mut nodes: Vec<serde_json::Value> = Vec::new();
|
||||
|
||||
// ── Trusted federation nodes ─────────────────────────────────────
|
||||
let fed_nodes = crate::federation::load_nodes(&self.config.data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
for n in fed_nodes
|
||||
.iter()
|
||||
.filter(|n| n.trust_level == crate::federation::TrustLevel::Trusted)
|
||||
{
|
||||
let state = n.last_state.as_ref();
|
||||
let pct = |used: Option<u64>, total: Option<u64>| -> serde_json::Value {
|
||||
match (used, total) {
|
||||
(Some(u), Some(t)) if t > 0 => {
|
||||
serde_json::json!((u as f64 / t as f64 * 100.0).round())
|
||||
}
|
||||
_ => serde_json::json!(0),
|
||||
}
|
||||
};
|
||||
let apps = state.map(|s| s.apps.as_slice()).unwrap_or(&[]);
|
||||
let reported_at = state
|
||||
.map(|s| s.timestamp.clone())
|
||||
.or_else(|| n.last_seen.clone())
|
||||
.unwrap_or_else(|| n.added_at.clone());
|
||||
|
||||
let mut report = serde_json::json!({
|
||||
"node_id": n.did,
|
||||
"node_name": state.and_then(|s| s.node_name.clone()).or_else(|| n.name.clone()),
|
||||
"uptime_secs": state.and_then(|s| s.uptime_secs).unwrap_or(0),
|
||||
"cpu_pct": state.and_then(|s| s.cpu_usage_percent).map(|v| v.round()).unwrap_or(0.0),
|
||||
"mem_pct": pct(state.and_then(|s| s.mem_used_bytes), state.and_then(|s| s.mem_total_bytes)),
|
||||
"disk_pct": pct(state.and_then(|s| s.disk_used_bytes), state.and_then(|s| s.disk_total_bytes)),
|
||||
"container_count": apps.len(),
|
||||
"running_count": apps.iter().filter(|a| a.status == "running").count(),
|
||||
"federation_peers": state.map(|s| s.federated_peers.len()).unwrap_or(0),
|
||||
"containers": apps.iter().map(|a| serde_json::json!({
|
||||
"id": a.id,
|
||||
"state": a.status,
|
||||
"version": a.version.clone().unwrap_or_default(),
|
||||
})).collect::<Vec<_>>(),
|
||||
"reported_at": reported_at,
|
||||
"trust_level": n.trust_level.to_string(),
|
||||
"source": "federation",
|
||||
});
|
||||
annotate_fleet_report(&mut report);
|
||||
nodes.push(report);
|
||||
}
|
||||
|
||||
// ── Opt-in telemetry collector reports ───────────────────────────
|
||||
let fleet_dir = self.config.data_dir.join("telemetry-fleet");
|
||||
if fleet_dir.exists() {
|
||||
let mut entries = tokio::fs::read_dir(&fleet_dir)
|
||||
.await
|
||||
.context("Failed to read telemetry-fleet directory")?;
|
||||
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let file_name = entry.file_name();
|
||||
let name = file_name.to_string_lossy();
|
||||
// Skip history files and non-JSON files
|
||||
if name.ends_with("-history.json") || !name.ends_with(".json") {
|
||||
continue;
|
||||
}
|
||||
|
||||
match tokio::fs::read_to_string(entry.path()).await {
|
||||
Ok(data) => match serde_json::from_str::<serde_json::Value>(&data) {
|
||||
Ok(mut report) => {
|
||||
annotate_fleet_report(&mut report);
|
||||
nodes.push(report);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(file = %name, error = %e, "Skipping corrupt fleet report");
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
warn!(file = %name, error = %e, "Failed to read fleet report");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by node_id for stable ordering
|
||||
nodes.sort_by(|a, b| {
|
||||
let a_id = a.get("node_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let b_id = b.get("node_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
a_id.cmp(b_id)
|
||||
});
|
||||
|
||||
info!(count = nodes.len(), "Fleet status query");
|
||||
|
||||
Ok(serde_json::json!({ "nodes": nodes }))
|
||||
}
|
||||
|
||||
/// Get history for a specific fleet node.
|
||||
/// Reads telemetry-fleet/{node_id}-history.json.
|
||||
pub(super) async fn handle_telemetry_fleet_node_history(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let p = params.context("Missing params")?;
|
||||
let node_id = p
|
||||
.get("node_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.context("Missing required field: node_id")?;
|
||||
|
||||
// Sanitize node_id
|
||||
if node_id.is_empty()
|
||||
|| node_id.len() > 64
|
||||
|| node_id.contains('/')
|
||||
|| node_id.contains('\\')
|
||||
|| node_id.contains("..")
|
||||
{
|
||||
anyhow::bail!("Invalid node_id");
|
||||
}
|
||||
|
||||
let history_path = self
|
||||
.config
|
||||
.data_dir
|
||||
.join("telemetry-fleet")
|
||||
.join(format!("{}-history.json", node_id));
|
||||
|
||||
let history: Vec<serde_json::Value> = match tokio::fs::read_to_string(&history_path).await {
|
||||
Ok(data) => serde_json::from_str(&data).unwrap_or_default(),
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"node_id": node_id,
|
||||
"entries": history,
|
||||
"count": history.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Get aggregated fleet alerts across all nodes.
|
||||
/// Reads all fleet reports, collects recent_alerts, sorts by timestamp descending.
|
||||
pub(super) async fn handle_telemetry_fleet_alerts(&self) -> Result<serde_json::Value> {
|
||||
let fleet_dir = self.config.data_dir.join("telemetry-fleet");
|
||||
if !fleet_dir.exists() {
|
||||
return Ok(serde_json::json!({ "alerts": [] }));
|
||||
}
|
||||
|
||||
let mut all_alerts: Vec<serde_json::Value> = Vec::new();
|
||||
let mut entries = tokio::fs::read_dir(&fleet_dir)
|
||||
.await
|
||||
.context("Failed to read telemetry-fleet directory")?;
|
||||
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let file_name = entry.file_name();
|
||||
let name = file_name.to_string_lossy();
|
||||
// Only read latest reports, skip history files
|
||||
if name.ends_with("-history.json") || !name.ends_with(".json") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let data = match tokio::fs::read_to_string(entry.path()).await {
|
||||
Ok(d) => d,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let report: serde_json::Value = match serde_json::from_str(&data) {
|
||||
Ok(r) => r,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let node_id = report
|
||||
.get("node_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
if let Some(alerts) = report.get("recent_alerts").and_then(|v| v.as_array()) {
|
||||
for alert in alerts {
|
||||
let mut enriched = alert.clone();
|
||||
if let Some(obj) = enriched.as_object_mut() {
|
||||
obj.insert("node_id".to_string(), serde_json::json!(node_id));
|
||||
}
|
||||
all_alerts.push(enriched);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by timestamp descending (most recent first)
|
||||
all_alerts.sort_by(|a, b| {
|
||||
let a_ts = a.get("timestamp").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let b_ts = b.get("timestamp").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
b_ts.cmp(&a_ts)
|
||||
});
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"alerts": all_alerts,
|
||||
"count": all_alerts.len(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
async fn system_hostname() -> Option<String> {
|
||||
let output = tokio::process::Command::new("hostname")
|
||||
.output()
|
||||
.await
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let hostname = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
(!hostname.is_empty()).then_some(hostname)
|
||||
}
|
||||
|
||||
fn local_server_url(host_ip: &str) -> Option<String> {
|
||||
let host_ip = host_ip.trim();
|
||||
if host_ip.is_empty() || host_ip == "127.0.0.1" {
|
||||
None
|
||||
} else {
|
||||
Some(format!("https://{host_ip}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Stamp a fleet report with computed `online` and human-readable `last_seen`
|
||||
/// derived from its `reported_at` timestamp (online = reported <30min ago).
|
||||
fn annotate_fleet_report(report: &mut serde_json::Value) {
|
||||
let reported = report
|
||||
.get("reported_at")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok());
|
||||
|
||||
let is_online = reported
|
||||
.map(|dt| {
|
||||
let age = chrono::Utc::now().signed_duration_since(dt);
|
||||
age.num_minutes() < 30
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
let last_seen = reported
|
||||
.map(|dt| {
|
||||
let age = chrono::Utc::now().signed_duration_since(dt);
|
||||
let mins = age.num_minutes();
|
||||
if mins < 1 {
|
||||
"just now".to_string()
|
||||
} else if mins < 60 {
|
||||
format!("{}m ago", mins)
|
||||
} else if mins < 1440 {
|
||||
format!("{}h ago", mins / 60)
|
||||
} else {
|
||||
format!("{}d ago", mins / 1440)
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
if let Some(obj) = report.as_object_mut() {
|
||||
obj.insert("online".to_string(), serde_json::json!(is_online));
|
||||
obj.insert("last_seen".to_string(), serde_json::json!(last_seen));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
//! Ark protocol RPCs — bridge to the `barkd` sidecar.
|
||||
//!
|
||||
//! Companion to the Cashu RPCs in [`super::wallet`] and the Fedimint RPCs in
|
||||
//! [`super::fedimint`]. Holding VTXOs, joining rounds and unilateral exits are
|
||||
//! delegated to the barkd container via [`crate::wallet::ark_client::ArkClient`];
|
||||
//! here we expose the node's JSON-RPC surface. barkd keeps its own movement
|
||||
//! history, so unlike Fedimint there is no local transaction log.
|
||||
|
||||
use super::RpcHandler;
|
||||
use crate::wallet::ark_client::{self, ArkClient};
|
||||
use anyhow::Result;
|
||||
|
||||
impl RpcHandler {
|
||||
/// `wallet.ark-status` — sidecar reachability, wallet fingerprint, network
|
||||
/// and Ark server parameters. Soft-fails into `available: false` so the
|
||||
/// settings UI can render an install/enable hint instead of an error.
|
||||
pub(super) async fn handle_wallet_ark_status(&self) -> Result<serde_json::Value> {
|
||||
let config = ark_client::load_config(&self.config.data_dir).await;
|
||||
let client = match ArkClient::from_node(&self.config.data_dir).await {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
return Ok(serde_json::json!({
|
||||
"available": false,
|
||||
"wallet_ready": false,
|
||||
"config": config,
|
||||
}))
|
||||
}
|
||||
};
|
||||
// Make sure the wallet exists before reporting (idempotent, cheap once
|
||||
// created).
|
||||
let _ = ark_client::ensure_wallet(&self.config.data_dir).await;
|
||||
|
||||
let wallet = client.wallet_info().await.ok();
|
||||
let info = client.ark_info().await.ok();
|
||||
Ok(serde_json::json!({
|
||||
"available": true,
|
||||
"wallet_ready": wallet.is_some(),
|
||||
"wallet": wallet,
|
||||
"ark_info": info,
|
||||
"config": config,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `wallet.ark-balance` — off-chain (spendable + pending) and on-chain
|
||||
/// sats. Soft-fails to zeros so unified balances still render.
|
||||
pub(super) async fn handle_wallet_ark_balance(&self) -> Result<serde_json::Value> {
|
||||
let client = match ArkClient::from_node(&self.config.data_dir).await {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
return Ok(serde_json::json!({
|
||||
"balance_sats": 0,
|
||||
"spendable_sats": 0,
|
||||
"pending_sats": 0,
|
||||
"onchain_sats": 0,
|
||||
}))
|
||||
}
|
||||
};
|
||||
let bal = client
|
||||
.balance()
|
||||
.await
|
||||
.unwrap_or_else(|_| serde_json::json!({}));
|
||||
let sat = |key: &str| bal.get(key).and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let spendable = sat("spendable_sat");
|
||||
let pending = sat("pending_in_round_sat")
|
||||
+ sat("pending_board_sat")
|
||||
+ sat("pending_lightning_send_sat")
|
||||
+ sat("claimable_lightning_receive_sat")
|
||||
+ bal
|
||||
.get("pending_exit_sat")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
let onchain = client
|
||||
.onchain_balance()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|b| {
|
||||
b.get("total_sat")
|
||||
.or_else(|| b.get("confirmed_sat"))
|
||||
.and_then(|v| v.as_u64())
|
||||
})
|
||||
.unwrap_or(0);
|
||||
Ok(serde_json::json!({
|
||||
"balance_sats": spendable,
|
||||
"spendable_sats": spendable,
|
||||
"pending_sats": pending,
|
||||
"onchain_sats": onchain,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `wallet.ark-address` — fresh Ark (`tark1…`) receive address; pass
|
||||
/// `{"onchain": true}` for an on-chain boarding address instead.
|
||||
pub(super) async fn handle_wallet_ark_address(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let _ = ark_client::ensure_wallet(&self.config.data_dir).await;
|
||||
let client = ArkClient::from_node(&self.config.data_dir).await?;
|
||||
let onchain = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("onchain"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let address = if onchain {
|
||||
client.onchain_address().await?
|
||||
} else {
|
||||
client.ark_address().await?
|
||||
};
|
||||
Ok(serde_json::json!({ "address": address, "onchain": onchain }))
|
||||
}
|
||||
|
||||
/// `wallet.ark-send` — pay an Ark address, BOLT11 invoice, LNURL or
|
||||
/// lightning address from Ark funds.
|
||||
pub(super) async fn handle_wallet_ark_send(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let destination = params
|
||||
.get("destination")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing destination"))?;
|
||||
// Optional for BOLT11 invoices that carry their own amount.
|
||||
let amount_sats = params.get("amount_sats").and_then(|v| v.as_u64());
|
||||
if amount_sats == Some(0) {
|
||||
return Err(anyhow::anyhow!("Amount must be greater than zero"));
|
||||
}
|
||||
let comment = params.get("comment").and_then(|v| v.as_str());
|
||||
|
||||
let client = ArkClient::from_node(&self.config.data_dir).await?;
|
||||
let movement = client.send(destination, amount_sats, comment).await?;
|
||||
Ok(serde_json::json!({
|
||||
"sent": true,
|
||||
"movement": movement,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `wallet.ark-invoice` — BOLT11 invoice that lands as Ark funds when paid.
|
||||
pub(super) async fn handle_wallet_ark_invoice(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let amount_sats = params
|
||||
.get("amount_sats")
|
||||
.and_then(|v| v.as_u64())
|
||||
.filter(|&v| v > 0)
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing amount_sats"))?;
|
||||
|
||||
let _ = ark_client::ensure_wallet(&self.config.data_dir).await;
|
||||
let client = ArkClient::from_node(&self.config.data_dir).await?;
|
||||
let res = client.lightning_invoice(amount_sats).await?;
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
/// `wallet.ark-board` — lift on-chain funds into Ark VTXOs. Omitting
|
||||
/// `amount_sats` boards everything.
|
||||
pub(super) async fn handle_wallet_ark_board(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let amount_sats = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("amount_sats"))
|
||||
.and_then(|v| v.as_u64());
|
||||
if amount_sats == Some(0) {
|
||||
return Err(anyhow::anyhow!("Amount must be greater than zero"));
|
||||
}
|
||||
let client = ArkClient::from_node(&self.config.data_dir).await?;
|
||||
let res = client.board(amount_sats).await?;
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
/// `wallet.ark-offboard` — collaboratively move all VTXOs back on-chain,
|
||||
/// optionally to a provided address (defaults to the wallet's own).
|
||||
pub(super) async fn handle_wallet_ark_offboard(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let address = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("address"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty());
|
||||
let client = ArkClient::from_node(&self.config.data_dir).await?;
|
||||
let res = client.offboard_all(address).await?;
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
/// `wallet.ark-history` — barkd movements mapped to the unified
|
||||
/// transaction shape (kind = "ark"), newest first.
|
||||
pub(super) async fn handle_wallet_ark_history(&self) -> Result<serde_json::Value> {
|
||||
let mut transactions = ark_client::load_ark_txs(&self.config.data_dir).await;
|
||||
transactions.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
|
||||
Ok(serde_json::json!({ "transactions": transactions }))
|
||||
}
|
||||
|
||||
/// `wallet.ark-configure` — set the Ark server / esplora / network used
|
||||
/// when the barkd wallet is (re)created. Does NOT migrate an existing
|
||||
/// wallet: barkd binds a wallet to its Ark server at creation.
|
||||
pub(super) async fn handle_wallet_ark_configure(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let mut config = ark_client::load_config(&self.config.data_dir).await;
|
||||
for (key, field) in [
|
||||
("network", &mut config.network as &mut String),
|
||||
("ark_server", &mut config.ark_server),
|
||||
("esplora", &mut config.esplora),
|
||||
] {
|
||||
if let Some(v) = params.get(key).and_then(|v| v.as_str()) {
|
||||
let v = v.trim();
|
||||
if !v.is_empty() {
|
||||
*field = v.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
if !matches!(config.network.as_str(), "signet" | "mainnet" | "regtest") {
|
||||
return Err(anyhow::anyhow!(
|
||||
"network must be one of: signet, mainnet, regtest"
|
||||
));
|
||||
}
|
||||
ark_client::save_config(&self.config.data_dir, &config).await?;
|
||||
Ok(serde_json::json!({ "config": config }))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
use super::RpcHandler;
|
||||
#[cfg(debug_assertions)]
|
||||
use super::DEV_DEFAULT_PASSWORD;
|
||||
use anyhow::Result;
|
||||
|
||||
impl RpcHandler {
|
||||
pub(super) async fn handle_auth_login(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
|
||||
// Companion device-token login: minted via auth.createDeviceToken and
|
||||
// carried by the pairing QR. Verified here so it shares the login rate
|
||||
// limiter with password attempts.
|
||||
if let Some(token) = params.get("token").and_then(|v| v.as_str()) {
|
||||
return match crate::device_tokens::verify(&self.config.data_dir, token).await {
|
||||
Some(device) => {
|
||||
tracing::info!("[onboarding] device-token login ({device})");
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
None => {
|
||||
tracing::warn!("[onboarding] device-token login failed");
|
||||
Err(anyhow::anyhow!("Invalid device token"))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let password = params
|
||||
.get("password")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing password"))?;
|
||||
|
||||
let is_setup = self.auth_manager.is_setup().await?;
|
||||
if !is_setup {
|
||||
// Dev BUILDS only: allow the default password so the UI can log
|
||||
// in without running setup. cfg-gated so no release binary can
|
||||
// carry the bypass, whatever its runtime config says.
|
||||
#[cfg(debug_assertions)]
|
||||
if self.config.dev_mode && password == DEV_DEFAULT_PASSWORD {
|
||||
tracing::info!("[onboarding] login via dev default password");
|
||||
return Ok(serde_json::Value::Null);
|
||||
}
|
||||
tracing::warn!("[onboarding] login attempt before setup complete");
|
||||
return Err(anyhow::anyhow!(
|
||||
"User not set up. Please complete setup first."
|
||||
));
|
||||
}
|
||||
|
||||
let valid = self.auth_manager.verify_password(password).await?;
|
||||
if !valid {
|
||||
// The companion app sends its device token through the password
|
||||
// field (it reuses the whole password auto-login path, including
|
||||
// the WebView form). Accept a valid token here so that path works.
|
||||
if let Some(device) =
|
||||
crate::device_tokens::verify(&self.config.data_dir, password).await
|
||||
{
|
||||
tracing::info!("[onboarding] device-token login via password field ({device})");
|
||||
return Ok(serde_json::Value::Null);
|
||||
}
|
||||
tracing::warn!("[onboarding] login failed — wrong password");
|
||||
return Err(anyhow::anyhow!("Password Incorrect"));
|
||||
}
|
||||
|
||||
tracing::info!("[onboarding] login successful");
|
||||
|
||||
// Best-effort: heal a LOCKED LND wallet created with an unknown/legacy
|
||||
// password by rotating it onto the per-node secret, using the password
|
||||
// the user just authenticated with as a candidate. Non-blocking so login
|
||||
// is never slowed or broken when LND isn't installed / already unlocked.
|
||||
let candidate = password.to_string();
|
||||
tokio::spawn(async move {
|
||||
match crate::container::lnd::migrate_locked_wallet(&[candidate]).await {
|
||||
Ok(true) => tracing::info!("[login] LND wallet healed / auto-unlocked"),
|
||||
Ok(false) => {} // not locked, or seed-recovery required
|
||||
Err(e) => tracing::debug!("[login] LND wallet migration skipped: {e}"),
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure NostrVPN config exists — covers the case where onboardingComplete
|
||||
// was never called (e.g., user took the "already set up" shortcut).
|
||||
let data_dir = self.config.data_dir.clone();
|
||||
tokio::spawn(async move {
|
||||
// Quick check: if config.toml already exists, skip
|
||||
let config_path = data_dir.join("nostr-vpn/.config/nvpn/config.toml");
|
||||
if config_path.exists() {
|
||||
return;
|
||||
}
|
||||
// Identity must exist for VPN config
|
||||
if !data_dir.join("identity/nostr_pubkey").exists() {
|
||||
return;
|
||||
}
|
||||
match crate::vpn::configure_nostr_vpn(&data_dir).await {
|
||||
Ok(()) => tracing::info!("[login] NostrVPN auto-configured on first login"),
|
||||
Err(e) => tracing::debug!("[login] NostrVPN auto-config skipped: {}", e),
|
||||
}
|
||||
});
|
||||
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
|
||||
/// Mint a device token for the companion pairing QR. Session-gated by the
|
||||
/// dispatcher (not in UNAUTHENTICATED_METHODS), so only a logged-in web UI
|
||||
/// can mint one. The plaintext token is returned exactly once.
|
||||
pub(super) async fn handle_auth_create_device_token(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let mut name = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("name"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("companion")
|
||||
.trim()
|
||||
.to_string();
|
||||
if name.is_empty() || name.len() > 64 {
|
||||
return Err(anyhow::anyhow!("Device name must be 1-64 characters"));
|
||||
}
|
||||
// The default name was a single shared slot: every pairing popup
|
||||
// replaced the previous phone's token, silently logging out the
|
||||
// first phone the moment a second one paired. Default-named mints
|
||||
// get a unique suffix so each device keeps its own credential;
|
||||
// explicitly named devices keep replace-in-place semantics.
|
||||
if name == "companion" {
|
||||
name = format!("companion-{}", hex::encode(rand::random::<[u8; 2]>()));
|
||||
}
|
||||
let token = crate::device_tokens::create(&self.config.data_dir, &name).await?;
|
||||
Ok(serde_json::json!({ "name": name, "token": token }))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_auth_list_device_tokens(&self) -> Result<serde_json::Value> {
|
||||
let tokens = crate::device_tokens::list(&self.config.data_dir).await;
|
||||
Ok(serde_json::json!(tokens
|
||||
.iter()
|
||||
.map(|t| serde_json::json!({ "name": t.name, "created": t.created }))
|
||||
.collect::<Vec<_>>()))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_auth_revoke_device_token(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let name = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("name"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing name"))?;
|
||||
let removed = crate::device_tokens::remove(&self.config.data_dir, name).await?;
|
||||
Ok(serde_json::json!({ "removed": removed }))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_auth_logout(&self) -> Result<serde_json::Value> {
|
||||
tracing::info!("[onboarding] logout");
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
|
||||
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
|
||||
.get("currentPassword")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing currentPassword"))?;
|
||||
let new_password = params
|
||||
.get("newPassword")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing newPassword"))?;
|
||||
let also_change_ssh = params
|
||||
.get("alsoChangeSsh")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(true);
|
||||
|
||||
let outcome = self
|
||||
.auth_manager
|
||||
.change_password(current_password, new_password, also_change_ssh)
|
||||
.await?;
|
||||
|
||||
// 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,
|
||||
"ssh_updated": outcome.ssh_updated,
|
||||
"ssh_error": outcome.ssh_error,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_auth_is_setup(&self) -> Result<serde_json::Value> {
|
||||
let is_setup = self.auth_manager.is_setup().await?;
|
||||
Ok(serde_json::json!(is_setup))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_auth_setup(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
// Prevent re-setup if already set up
|
||||
let is_setup = self.auth_manager.is_setup().await?;
|
||||
if is_setup {
|
||||
tracing::warn!("[onboarding] setup rejected — already set up");
|
||||
return Err(anyhow::anyhow!(
|
||||
"Already set up. Use auth.changePassword to change."
|
||||
));
|
||||
}
|
||||
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let password = params
|
||||
.get("password")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing password"))?;
|
||||
|
||||
if password.len() < 8 {
|
||||
tracing::warn!("[onboarding] setup rejected — password too short");
|
||||
return Err(anyhow::anyhow!("Password must be at least 8 characters"));
|
||||
}
|
||||
|
||||
self.auth_manager.setup_user(password).await?;
|
||||
tracing::info!("[onboarding] user setup complete");
|
||||
|
||||
// The install-time password must also become the OS login for the
|
||||
// archipelago user — otherwise the console/SSH keeps the image default
|
||||
// ("archipelago") after the user has picked a real password (#97).
|
||||
// Best-effort: a failure here must not break onboarding.
|
||||
match crate::auth::change_ssh_password(password).await {
|
||||
Ok(()) => tracing::info!("[onboarding] system login password synced"),
|
||||
Err(e) => tracing::warn!("[onboarding] system login password sync failed: {e}"),
|
||||
}
|
||||
|
||||
// Persist the pending onboarding seed as the encrypted backup now that
|
||||
// a passphrase (the login password) finally exists — otherwise "Reveal
|
||||
// recovery phrase" has nothing to decrypt on this node, ever.
|
||||
// Best-effort: a failure here must not break password setup.
|
||||
match super::seed_rpc::save_pending_seed_encrypted(&self.config.data_dir, password).await {
|
||||
Ok(true) => tracing::info!("[onboarding] encrypted seed backup saved"),
|
||||
Ok(false) => tracing::info!(
|
||||
"[onboarding] no pending mnemonic to back up (restored earlier or legacy node)"
|
||||
),
|
||||
Err(e) => tracing::warn!("[onboarding] encrypted seed backup failed: {e:#}"),
|
||||
}
|
||||
|
||||
Ok(serde_json::json!(true))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_auth_onboarding_complete(&self) -> Result<serde_json::Value> {
|
||||
self.auth_manager.complete_onboarding().await?;
|
||||
tracing::info!("[onboarding] onboarding marked complete");
|
||||
|
||||
// Auto-configure NostrVPN with the node's Nostr identity
|
||||
let data_dir = self.config.data_dir.clone();
|
||||
tokio::spawn(async move {
|
||||
match crate::vpn::configure_nostr_vpn(&data_dir).await {
|
||||
Ok(()) => tracing::info!("[onboarding] NostrVPN configured and started"),
|
||||
Err(e) => tracing::warn!("[onboarding] NostrVPN setup (non-fatal): {}", e),
|
||||
}
|
||||
});
|
||||
|
||||
Ok(serde_json::json!(true))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_auth_is_onboarding_complete(&self) -> Result<serde_json::Value> {
|
||||
let complete = self.auth_manager.is_onboarding_complete().await?;
|
||||
tracing::debug!("[onboarding] isOnboardingComplete={}", complete);
|
||||
Ok(serde_json::json!(complete))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_auth_reset_onboarding(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let password = params
|
||||
.get("password")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing password — re-authentication required"))?;
|
||||
|
||||
let valid = self.auth_manager.verify_password(password).await?;
|
||||
if !valid {
|
||||
tracing::warn!("[onboarding] reset rejected — wrong password");
|
||||
return Err(anyhow::anyhow!("Password Incorrect"));
|
||||
}
|
||||
|
||||
self.auth_manager.reset_onboarding().await?;
|
||||
tracing::info!("[onboarding] onboarding reset");
|
||||
Ok(serde_json::json!(true))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
use super::RpcHandler;
|
||||
use crate::backup::full;
|
||||
use anyhow::{Context, Result};
|
||||
use std::net::IpAddr;
|
||||
use tracing::info;
|
||||
|
||||
/// Validate an S3 endpoint URL: require https, reject private/loopback IPs and localhost.
|
||||
fn validate_s3_endpoint(endpoint: &str) -> Result<()> {
|
||||
// Require HTTPS scheme
|
||||
if !endpoint.starts_with("https://") {
|
||||
anyhow::bail!("S3 endpoint must use https://");
|
||||
}
|
||||
|
||||
// Extract host from URL (strip scheme, path, port)
|
||||
let after_scheme = &endpoint["https://".len()..];
|
||||
let host_port = after_scheme.split('/').next().unwrap_or("");
|
||||
// Strip port if present (handle IPv6 bracket notation)
|
||||
let host = if host_port.starts_with('[') {
|
||||
// IPv6: [::1]:443
|
||||
host_port
|
||||
.split(']')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim_start_matches('[')
|
||||
} else {
|
||||
host_port.split(':').next().unwrap_or("")
|
||||
};
|
||||
|
||||
if host.is_empty() {
|
||||
anyhow::bail!("S3 endpoint missing host");
|
||||
}
|
||||
|
||||
// Reject localhost
|
||||
if host == "localhost" || host.ends_with(".localhost") {
|
||||
anyhow::bail!("S3 endpoint must not point to localhost");
|
||||
}
|
||||
|
||||
// Parse as IP and reject private/reserved ranges
|
||||
if let Ok(ip) = host.parse::<IpAddr>() {
|
||||
let is_private = match ip {
|
||||
IpAddr::V4(v4) => {
|
||||
v4.is_loopback() // 127.0.0.0/8
|
||||
|| v4.octets()[0] == 10 // 10.0.0.0/8
|
||||
|| (v4.octets()[0] == 172 && (v4.octets()[1] & 0xf0) == 16) // 172.16.0.0/12
|
||||
|| (v4.octets()[0] == 192 && v4.octets()[1] == 168) // 192.168.0.0/16
|
||||
|| (v4.octets()[0] == 169 && v4.octets()[1] == 254) // 169.254.0.0/16
|
||||
|| v4.is_unspecified() // 0.0.0.0
|
||||
}
|
||||
IpAddr::V6(v6) => {
|
||||
v6.is_loopback() // ::1
|
||||
|| (v6.segments()[0] & 0xfe00) == 0xfc00 // fc00::/7
|
||||
|| v6.is_unspecified() // ::
|
||||
}
|
||||
};
|
||||
if is_private {
|
||||
anyhow::bail!("S3 endpoint must not point to a private or reserved IP address");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// Create a full encrypted backup. Params: { passphrase, description? }
|
||||
pub(super) async fn handle_backup_create(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let passphrase = params["passphrase"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'passphrase' parameter"))?;
|
||||
let description = params["description"].as_str();
|
||||
|
||||
let meta = full::create_full_backup(&self.config.data_dir, passphrase, description).await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"id": meta.id,
|
||||
"created_at": meta.created_at,
|
||||
"size_bytes": meta.size_bytes,
|
||||
"encrypted": meta.encrypted,
|
||||
"description": meta.description,
|
||||
}))
|
||||
}
|
||||
|
||||
/// List available backups.
|
||||
pub(super) async fn handle_backup_list(&self) -> Result<serde_json::Value> {
|
||||
let backups = full::list_backups(&self.config.data_dir).await?;
|
||||
let list: Vec<serde_json::Value> = backups
|
||||
.iter()
|
||||
.map(|b| {
|
||||
serde_json::json!({
|
||||
"id": b.id,
|
||||
"created_at": b.created_at,
|
||||
"size_bytes": b.size_bytes,
|
||||
"encrypted": b.encrypted,
|
||||
"description": b.description,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(serde_json::json!({ "backups": list }))
|
||||
}
|
||||
|
||||
/// Verify a backup's integrity. Params: { id, passphrase }
|
||||
pub(super) async fn handle_backup_verify(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let id = params["id"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'id' parameter"))?;
|
||||
let passphrase = params["passphrase"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'passphrase' parameter"))?;
|
||||
|
||||
// Validate backup ID to prevent path traversal
|
||||
if id.is_empty()
|
||||
|| id.len() > 128
|
||||
|| id.contains('/')
|
||||
|| id.contains('\\')
|
||||
|| id.contains("..")
|
||||
|| id.contains('\0')
|
||||
{
|
||||
anyhow::bail!("Invalid backup ID");
|
||||
}
|
||||
|
||||
let result = full::verify_backup(&self.config.data_dir, id, passphrase).await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"valid": result.valid,
|
||||
"id": result.id,
|
||||
"created_at": result.created_at,
|
||||
"size_bytes": result.size_bytes,
|
||||
"error": result.error,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Restore from a backup. Params: { id, passphrase }
|
||||
pub(super) async fn handle_backup_restore(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let id = params["id"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'id' parameter"))?;
|
||||
let passphrase = params["passphrase"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'passphrase' parameter"))?;
|
||||
|
||||
// Validate backup ID to prevent path traversal
|
||||
if id.is_empty()
|
||||
|| id.len() > 128
|
||||
|| id.contains('/')
|
||||
|| id.contains('\\')
|
||||
|| id.contains("..")
|
||||
|| id.contains('\0')
|
||||
{
|
||||
anyhow::bail!("Invalid backup ID");
|
||||
}
|
||||
|
||||
full::restore_full_backup(&self.config.data_dir, id, passphrase).await?;
|
||||
|
||||
Ok(serde_json::json!({ "restored": true, "id": id }))
|
||||
}
|
||||
|
||||
/// Delete a backup. Params: { id }
|
||||
pub(super) async fn handle_backup_delete(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let id = params["id"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'id' parameter"))?;
|
||||
|
||||
// Validate backup ID to prevent path traversal
|
||||
if id.is_empty()
|
||||
|| id.len() > 128
|
||||
|| id.contains('/')
|
||||
|| id.contains('\\')
|
||||
|| id.contains("..")
|
||||
|| id.contains('\0')
|
||||
{
|
||||
anyhow::bail!("Invalid backup ID");
|
||||
}
|
||||
|
||||
let bak_path = full::backup_file_path(&self.config.data_dir, id);
|
||||
let meta_path = self
|
||||
.config
|
||||
.data_dir
|
||||
.join("backups")
|
||||
.join(format!("{}.meta.json", id));
|
||||
|
||||
let mut deleted = false;
|
||||
if bak_path.exists() {
|
||||
tokio::fs::remove_file(&bak_path).await?;
|
||||
deleted = true;
|
||||
}
|
||||
if meta_path.exists() {
|
||||
tokio::fs::remove_file(&meta_path).await?;
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({ "deleted": deleted, "id": id }))
|
||||
}
|
||||
|
||||
/// List removable USB drives.
|
||||
pub(super) async fn handle_backup_list_drives(&self) -> Result<serde_json::Value> {
|
||||
let drives = full::list_usb_drives().await?;
|
||||
let list: Vec<serde_json::Value> = drives
|
||||
.iter()
|
||||
.map(|d| {
|
||||
serde_json::json!({
|
||||
"device": d.device,
|
||||
"mount_point": d.mount_point,
|
||||
"label": d.label,
|
||||
"size_bytes": d.size_bytes,
|
||||
"removable": d.removable,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(serde_json::json!({ "drives": list }))
|
||||
}
|
||||
|
||||
/// Copy a backup to a mounted USB drive. Params: { id, mount_point }
|
||||
pub(super) async fn handle_backup_to_usb(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let id = params["id"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'id' parameter"))?;
|
||||
let mount_point = params["mount_point"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'mount_point' parameter"))?;
|
||||
|
||||
let dest = full::backup_to_usb(&self.config.data_dir, id, mount_point).await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"copied": true,
|
||||
"id": id,
|
||||
"destination": dest.to_string_lossy(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Upload a backup to S3-compatible storage.
|
||||
/// Params: { id, endpoint, bucket, access_key, secret_key, region? }
|
||||
pub(super) async fn handle_backup_upload_s3(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let id = params["id"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'id' parameter"))?;
|
||||
let endpoint = params["endpoint"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'endpoint' parameter"))?;
|
||||
let bucket = params["bucket"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'bucket' parameter"))?;
|
||||
let access_key = params["access_key"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'access_key' parameter"))?;
|
||||
let secret_key = params["secret_key"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'secret_key' parameter"))?;
|
||||
let _region = params["region"].as_str().unwrap_or("us-east-1");
|
||||
|
||||
// Validate backup ID
|
||||
if id.is_empty()
|
||||
|| id.len() > 128
|
||||
|| id.contains('/')
|
||||
|| id.contains('\\')
|
||||
|| id.contains("..")
|
||||
|| id.contains('\0')
|
||||
{
|
||||
anyhow::bail!("Invalid backup ID");
|
||||
}
|
||||
|
||||
// Validate endpoint to prevent SSRF against internal services
|
||||
validate_s3_endpoint(endpoint)?;
|
||||
|
||||
let bak_path = full::backup_file_path(&self.config.data_dir, id);
|
||||
if !bak_path.exists() {
|
||||
anyhow::bail!("Backup not found: {}", id);
|
||||
}
|
||||
|
||||
let file_bytes = tokio::fs::read(&bak_path)
|
||||
.await
|
||||
.context("Failed to read backup file")?;
|
||||
let key = format!("archipelago-backups/{}.tar.gz.enc", id);
|
||||
let size = file_bytes.len();
|
||||
|
||||
// Upload via HTTP PUT to S3-compatible endpoint
|
||||
let url = format!("{}/{}/{}", endpoint.trim_end_matches('/'), bucket, key);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(300))
|
||||
.build()?;
|
||||
|
||||
// Simple S3 PUT (works with MinIO, Backblaze B2 S3-compatible, Wasabi)
|
||||
// For full AWS S3, proper SigV4 signing would be needed
|
||||
let response = client
|
||||
.put(&url)
|
||||
.basic_auth(access_key, Some(secret_key))
|
||||
.header("Content-Type", "application/octet-stream")
|
||||
.body(file_bytes)
|
||||
.send()
|
||||
.await
|
||||
.context("S3 upload failed")?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
anyhow::bail!(
|
||||
"S3 upload failed ({}): {}",
|
||||
status,
|
||||
&body[..200.min(body.len())]
|
||||
);
|
||||
}
|
||||
|
||||
info!(id = %id, bucket = %bucket, size = %size, "Backup uploaded to S3");
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"uploaded": true,
|
||||
"id": id,
|
||||
"bucket": bucket,
|
||||
"key": key,
|
||||
"size_bytes": size,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Download a backup from S3-compatible storage.
|
||||
/// Params: { id, endpoint, bucket, access_key, secret_key, region? }
|
||||
pub(super) async fn handle_backup_download_s3(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let id = params["id"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'id' parameter"))?;
|
||||
let endpoint = params["endpoint"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'endpoint' parameter"))?;
|
||||
let bucket = params["bucket"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'bucket' parameter"))?;
|
||||
let access_key = params["access_key"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'access_key' parameter"))?;
|
||||
let secret_key = params["secret_key"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'secret_key' parameter"))?;
|
||||
|
||||
if id.is_empty()
|
||||
|| id.len() > 128
|
||||
|| id.contains('/')
|
||||
|| id.contains('\\')
|
||||
|| id.contains("..")
|
||||
|| id.contains('\0')
|
||||
{
|
||||
anyhow::bail!("Invalid backup ID");
|
||||
}
|
||||
|
||||
// Validate endpoint to prevent SSRF against internal services
|
||||
validate_s3_endpoint(endpoint)?;
|
||||
|
||||
let key = format!("archipelago-backups/{}.tar.gz.enc", id);
|
||||
let url = format!("{}/{}/{}", endpoint.trim_end_matches('/'), bucket, key);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(300))
|
||||
.build()?;
|
||||
|
||||
let response = client
|
||||
.get(&url)
|
||||
.basic_auth(access_key, Some(secret_key))
|
||||
.send()
|
||||
.await
|
||||
.context("S3 download failed")?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
anyhow::bail!("S3 download failed ({})", status);
|
||||
}
|
||||
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.context("Failed to read S3 response")?;
|
||||
let size = bytes.len();
|
||||
|
||||
// Save to backups directory
|
||||
let bak_dir = self.config.data_dir.join("backups");
|
||||
tokio::fs::create_dir_all(&bak_dir).await?;
|
||||
let bak_path = full::backup_file_path(&self.config.data_dir, id);
|
||||
tokio::fs::write(&bak_path, &bytes)
|
||||
.await
|
||||
.context("Failed to write backup file")?;
|
||||
|
||||
info!(id = %id, bucket = %bucket, size = %size, "Backup downloaded from S3");
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"downloaded": true,
|
||||
"id": id,
|
||||
"size_bytes": size,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Restore identity from an encrypted DID backup JSON.
|
||||
/// Params: { backup: { version, blob, ... }, passphrase }
|
||||
pub(super) async fn handle_backup_restore_identity(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let backup = params
|
||||
.get("backup")
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'backup' parameter"))?;
|
||||
let passphrase = params
|
||||
.get("passphrase")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'passphrase' parameter"))?;
|
||||
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let (did, pubkey) =
|
||||
crate::backup::restore_encrypted_backup(&identity_dir, backup, passphrase)
|
||||
.await
|
||||
.context("Identity restore failed")?;
|
||||
|
||||
info!(did = %did, "Identity restored from backup");
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"did": did,
|
||||
"pubkey": pubkey,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,644 @@
|
||||
use super::RpcHandler;
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use zeroize::Zeroize;
|
||||
|
||||
/// Retry configuration for [`bitcoin_rpc_post_with_retry`].
|
||||
///
|
||||
/// Exposed as a struct (rather than hard-coded constants inside the function)
|
||||
/// so tests can dial down timeouts to keep the suite fast while still
|
||||
/// exercising real retry/backoff behavior.
|
||||
#[derive(Debug, Clone)]
|
||||
struct RetryConfig {
|
||||
max_attempts: u32,
|
||||
attempt_timeout: std::time::Duration,
|
||||
/// Length must equal `max_attempts - 1` (one backoff between each
|
||||
/// successive attempt). The last attempt is not followed by a backoff.
|
||||
backoffs: Vec<std::time::Duration>,
|
||||
}
|
||||
|
||||
impl RetryConfig {
|
||||
/// Production retry policy: 3 attempts, 15s each, 500ms + 1500ms backoffs.
|
||||
/// Total worst-case wall time: 3 * 15 + 0.5 + 1.5 = 47s.
|
||||
fn production() -> Self {
|
||||
Self {
|
||||
max_attempts: BITCOIN_RPC_MAX_ATTEMPTS,
|
||||
attempt_timeout: BITCOIN_RPC_ATTEMPT_TIMEOUT,
|
||||
backoffs: BITCOIN_RPC_BACKOFFS.to_vec(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Max retry attempts for a single bitcoin_rpc_call invocation.
|
||||
/// First attempt + 2 retries = 3 total.
|
||||
const BITCOIN_RPC_MAX_ATTEMPTS: u32 = 3;
|
||||
|
||||
/// Per-attempt deadline. Must be >= the reqwest client's own timeout (we
|
||||
/// build it at 15s in handle_bitcoin_getinfo) — this is the outer safety net.
|
||||
const BITCOIN_RPC_ATTEMPT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
|
||||
|
||||
/// Backoff between attempts. Index 0 = after first failure, 1 = after second, etc.
|
||||
/// Chosen to absorb bitcoind's typical block-validation stall (2-5s) without
|
||||
/// adding noticeable latency on the happy path (first attempt succeeds in ~30ms).
|
||||
const BITCOIN_RPC_BACKOFFS: [std::time::Duration; 2] = [
|
||||
std::time::Duration::from_millis(500),
|
||||
std::time::Duration::from_millis(1500),
|
||||
];
|
||||
|
||||
/// Classify a reqwest error as transient (retryable) or fatal.
|
||||
/// Transient: timeout, connect refused, request/response body IO errors.
|
||||
/// Fatal: TLS errors, URL parse errors, redirect loops, builder errors.
|
||||
fn is_transient_transport_error(e: &reqwest::Error) -> bool {
|
||||
e.is_timeout() || e.is_connect() || e.is_request() || e.is_body()
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct BitcoinInfo {
|
||||
block_height: u64,
|
||||
sync_progress: f64,
|
||||
chain: String,
|
||||
difficulty: f64,
|
||||
mempool_size: u64,
|
||||
mempool_tx_count: u64,
|
||||
verification_progress: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct BitcoinRpcResponse<T> {
|
||||
result: Option<T>,
|
||||
error: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct BlockchainInfo {
|
||||
chain: Option<String>,
|
||||
blocks: Option<u64>,
|
||||
difficulty: Option<f64>,
|
||||
#[serde(rename = "verificationprogress")]
|
||||
verification_progress: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct MempoolInfo {
|
||||
size: Option<u64>,
|
||||
bytes: Option<u64>,
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
pub(super) async fn handle_bitcoin_getinfo(&self) -> Result<serde_json::Value> {
|
||||
// Per-attempt timeout (see bitcoin_rpc_call for retry semantics).
|
||||
// 15s is enough room for bitcoind to answer getblockchaininfo even
|
||||
// during block validation; bitcoin_rpc_call wraps each attempt in a
|
||||
// separate tokio::time::timeout too, so this is belt-and-suspenders.
|
||||
// connect_timeout is tighter so a dead bitcoind doesn't steal the
|
||||
// whole attempt budget on TCP connect alone.
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.connect_timeout(std::time::Duration::from_secs(3))
|
||||
.build()
|
||||
.context("Failed to create HTTP client")?;
|
||||
|
||||
let blockchain_info = self
|
||||
.bitcoin_rpc_call::<BlockchainInfo>(&client, "getblockchaininfo", &[])
|
||||
.await
|
||||
.context("Failed to query getblockchaininfo")?;
|
||||
|
||||
let mempool_info = self
|
||||
.bitcoin_rpc_call::<MempoolInfo>(&client, "getmempoolinfo", &[])
|
||||
.await
|
||||
.unwrap_or(MempoolInfo {
|
||||
size: Some(0),
|
||||
bytes: Some(0),
|
||||
});
|
||||
|
||||
let info = BitcoinInfo {
|
||||
block_height: blockchain_info.blocks.unwrap_or(0),
|
||||
sync_progress: blockchain_info.verification_progress.unwrap_or(0.0),
|
||||
chain: blockchain_info.chain.unwrap_or_else(|| "unknown".into()),
|
||||
difficulty: blockchain_info.difficulty.unwrap_or(0.0),
|
||||
mempool_size: mempool_info.bytes.unwrap_or(0),
|
||||
mempool_tx_count: mempool_info.size.unwrap_or(0),
|
||||
verification_progress: blockchain_info.verification_progress.unwrap_or(0.0),
|
||||
};
|
||||
|
||||
Ok(serde_json::to_value(info)?)
|
||||
}
|
||||
|
||||
/// Call a Bitcoin Core JSON-RPC method.
|
||||
///
|
||||
/// Retries up to [`BITCOIN_RPC_MAX_ATTEMPTS`] times on transient
|
||||
/// transport errors (timeout / connection refused / send/recv IO).
|
||||
/// Does **not** retry when bitcoind responds with a well-formed
|
||||
/// `{"error": ...}` body — those are real RPC errors and surfacing
|
||||
/// them quickly is the right behavior.
|
||||
///
|
||||
/// Motivation: on a syncing pruned node, bitcoind's RPC thread can block
|
||||
/// for 5-10 seconds during block validation. A single 10s timeout means
|
||||
/// ~30% of UI calls error out even though the node is perfectly healthy.
|
||||
/// With retry + backoff, the UI sees a uniform slow-but-successful
|
||||
/// response instead of intermittent failures.
|
||||
async fn bitcoin_rpc_call<T: serde::de::DeserializeOwned>(
|
||||
&self,
|
||||
client: &reqwest::Client,
|
||||
method: &str,
|
||||
params: &[serde_json::Value],
|
||||
) -> Result<T> {
|
||||
let (rpc_user, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
|
||||
bitcoin_rpc_post_with_retry(
|
||||
client,
|
||||
crate::constants::BITCOIN_RPC_URL,
|
||||
&rpc_user,
|
||||
&rpc_pass,
|
||||
method,
|
||||
params,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Initialize a Bitcoin Core descriptor wallet with keys derived from the master seed.
|
||||
/// Creates a blank wallet and imports BIP-84 (native segwit) descriptors.
|
||||
/// Requires: password re-verification, encrypted seed on disk.
|
||||
pub(super) async fn handle_bitcoin_init_wallet_from_seed(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let password = params
|
||||
.get("password")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'password' for seed access"))?;
|
||||
let wallet_name = params
|
||||
.get("wallet_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("archipelago");
|
||||
|
||||
// Verify user password.
|
||||
self.auth_manager
|
||||
.verify_password(password)
|
||||
.await
|
||||
.context("Password verification failed")?;
|
||||
|
||||
// Load encrypted seed.
|
||||
let mnemonic = crate::seed::load_seed_encrypted(&self.config.data_dir, password)
|
||||
.await
|
||||
.context("Failed to load encrypted seed")?;
|
||||
let seed = crate::seed::MasterSeed::from_mnemonic(&mnemonic);
|
||||
|
||||
// Derive BIP-84 account xprv.
|
||||
let xprv = crate::seed::derive_bitcoin_xprv(&seed)?;
|
||||
let mut xprv_str = xprv.to_string();
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.context("Failed to create HTTP client")?;
|
||||
|
||||
// Step 1: Create a blank descriptor wallet.
|
||||
let create_result = self
|
||||
.bitcoin_rpc_call::<serde_json::Value>(
|
||||
&client,
|
||||
"createwallet",
|
||||
&[
|
||||
serde_json::json!(wallet_name), // wallet_name
|
||||
serde_json::json!(false), // disable_private_keys
|
||||
serde_json::json!(true), // blank
|
||||
serde_json::json!(""), // passphrase
|
||||
serde_json::json!(false), // avoid_reuse
|
||||
serde_json::json!(true), // descriptors
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
match create_result {
|
||||
Ok(_) => tracing::info!("Created blank descriptor wallet '{}'", wallet_name),
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
if msg.contains("already exists") {
|
||||
tracing::info!(
|
||||
"Wallet '{}' already exists, importing descriptors",
|
||||
wallet_name
|
||||
);
|
||||
} else {
|
||||
xprv_str.zeroize();
|
||||
return Err(e.context("Failed to create wallet"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Import BIP-84 descriptors (external + internal/change).
|
||||
// Format: wpkh(xprv/0/*) for receive, wpkh(xprv/1/*) for change.
|
||||
let external_desc = format!("wpkh({}/0/*)", xprv_str);
|
||||
let internal_desc = format!("wpkh({}/1/*)", xprv_str);
|
||||
|
||||
// Get checksums from Bitcoin Core.
|
||||
let ext_info: serde_json::Value = self
|
||||
.bitcoin_rpc_call(
|
||||
&client,
|
||||
"getdescriptorinfo",
|
||||
&[serde_json::json!(external_desc)],
|
||||
)
|
||||
.await
|
||||
.context("getdescriptorinfo failed for external descriptor")?;
|
||||
|
||||
let int_info: serde_json::Value = self
|
||||
.bitcoin_rpc_call(
|
||||
&client,
|
||||
"getdescriptorinfo",
|
||||
&[serde_json::json!(internal_desc)],
|
||||
)
|
||||
.await
|
||||
.context("getdescriptorinfo failed for internal descriptor")?;
|
||||
|
||||
let ext_desc_with_checksum = ext_info
|
||||
.get("descriptor")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("No descriptor in getdescriptorinfo response"))?;
|
||||
let int_desc_with_checksum = int_info
|
||||
.get("descriptor")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("No descriptor in getdescriptorinfo response"))?;
|
||||
|
||||
let import_params = serde_json::json!([
|
||||
{
|
||||
"desc": ext_desc_with_checksum,
|
||||
"timestamp": "now",
|
||||
"active": true,
|
||||
"internal": false,
|
||||
"range": [0, 1000],
|
||||
},
|
||||
{
|
||||
"desc": int_desc_with_checksum,
|
||||
"timestamp": "now",
|
||||
"active": true,
|
||||
"internal": true,
|
||||
"range": [0, 1000],
|
||||
}
|
||||
]);
|
||||
|
||||
let _import_result: serde_json::Value = self
|
||||
.bitcoin_rpc_call(&client, "importdescriptors", &[import_params])
|
||||
.await
|
||||
.context("importdescriptors failed")?;
|
||||
|
||||
// Zeroize the xprv string from memory.
|
||||
xprv_str.zeroize();
|
||||
|
||||
tracing::info!(
|
||||
"Bitcoin Core wallet '{}' initialized from master seed (BIP-84)",
|
||||
wallet_name
|
||||
);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"initialized": true,
|
||||
"wallet_name": wallet_name,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// Free-function counterpart to `RpcHandler::bitcoin_rpc_call`.
|
||||
///
|
||||
/// Takes the URL + credentials as parameters so it can be exercised by unit
|
||||
/// tests against a mock HTTP server without constructing a full `RpcHandler`.
|
||||
///
|
||||
/// Production callers go through `RpcHandler::bitcoin_rpc_call`, which loads
|
||||
/// credentials from the secrets file and points at `BITCOIN_RPC_URL`.
|
||||
async fn bitcoin_rpc_post_with_retry<T: serde::de::DeserializeOwned>(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
rpc_user: &str,
|
||||
rpc_pass: &str,
|
||||
method: &str,
|
||||
params: &[serde_json::Value],
|
||||
) -> Result<T> {
|
||||
bitcoin_rpc_post_with_retry_cfg(
|
||||
client,
|
||||
url,
|
||||
rpc_user,
|
||||
rpc_pass,
|
||||
method,
|
||||
params,
|
||||
&RetryConfig::production(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Inner implementation with configurable retry policy (for tests).
|
||||
async fn bitcoin_rpc_post_with_retry_cfg<T: serde::de::DeserializeOwned>(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
rpc_user: &str,
|
||||
rpc_pass: &str,
|
||||
method: &str,
|
||||
params: &[serde_json::Value],
|
||||
cfg: &RetryConfig,
|
||||
) -> Result<T> {
|
||||
debug_assert_eq!(
|
||||
cfg.backoffs.len(),
|
||||
(cfg.max_attempts - 1) as usize,
|
||||
"RetryConfig: backoffs.len() must equal max_attempts - 1"
|
||||
);
|
||||
|
||||
let body = serde_json::json!({
|
||||
"jsonrpc": "1.0",
|
||||
"id": "archy",
|
||||
"method": method,
|
||||
"params": params,
|
||||
});
|
||||
|
||||
let mut last_err: Option<anyhow::Error> = None;
|
||||
for attempt in 0..cfg.max_attempts {
|
||||
if attempt > 0 {
|
||||
let backoff = cfg
|
||||
.backoffs
|
||||
.get(attempt as usize - 1)
|
||||
.copied()
|
||||
.unwrap_or_else(|| std::time::Duration::from_secs(2));
|
||||
tracing::warn!(
|
||||
"bitcoin_rpc({}): attempt {} failed, backing off {:?}",
|
||||
method,
|
||||
attempt,
|
||||
backoff
|
||||
);
|
||||
tokio::time::sleep(backoff).await;
|
||||
}
|
||||
|
||||
// Per-attempt hard deadline. Independent of reqwest's built-in timeout
|
||||
// so we always cap total time even if reqwest blocks on something
|
||||
// weird (e.g., DNS starvation).
|
||||
let fut = client
|
||||
.post(url)
|
||||
.basic_auth(rpc_user, Some(rpc_pass))
|
||||
.json(&body)
|
||||
.send();
|
||||
|
||||
let send_result = match tokio::time::timeout(cfg.attempt_timeout, fut).await {
|
||||
Err(_elapsed) => {
|
||||
last_err = Some(anyhow::anyhow!(
|
||||
"Bitcoin RPC send timed out after {:?}",
|
||||
cfg.attempt_timeout
|
||||
));
|
||||
continue; // transient: retry
|
||||
}
|
||||
Ok(r) => r,
|
||||
};
|
||||
|
||||
let resp = match send_result {
|
||||
Ok(r) => r,
|
||||
Err(e) if is_transient_transport_error(&e) => {
|
||||
last_err = Some(anyhow::Error::from(e).context("Bitcoin RPC connection failed"));
|
||||
continue; // transient: retry
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(anyhow::Error::from(e).context("Bitcoin RPC connection failed"));
|
||||
}
|
||||
};
|
||||
|
||||
let rpc_resp: BitcoinRpcResponse<T> = resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse Bitcoin RPC response")?;
|
||||
|
||||
if let Some(err) = rpc_resp.error {
|
||||
// RPC-level error: this is a real bitcoind response, not transient.
|
||||
anyhow::bail!("Bitcoin RPC error: {}", err);
|
||||
}
|
||||
|
||||
return rpc_resp
|
||||
.result
|
||||
.ok_or_else(|| anyhow::anyhow!("Bitcoin RPC returned null result"));
|
||||
}
|
||||
|
||||
Err(last_err
|
||||
.unwrap_or_else(|| anyhow::anyhow!("Bitcoin RPC exhausted retries with no error captured")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use hyper::service::{make_service_fn, service_fn};
|
||||
use hyper::{Body, Request, Response, Server, StatusCode};
|
||||
use std::convert::Infallible;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Spin up a mock bitcoind HTTP server that behaves according to `handler`.
|
||||
/// Returns the bound URL and a JoinHandle (dropped = server shutdown via the
|
||||
/// oneshot cancel channel).
|
||||
async fn spawn_mock<F, Fut>(
|
||||
handler: F,
|
||||
) -> (
|
||||
String,
|
||||
tokio::task::JoinHandle<()>,
|
||||
tokio::sync::oneshot::Sender<()>,
|
||||
)
|
||||
where
|
||||
F: Fn(Request<Body>) -> Fut + Send + Sync + Clone + 'static,
|
||||
Fut: std::future::Future<Output = Response<Body>> + Send + 'static,
|
||||
{
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], 0));
|
||||
let make_svc = make_service_fn(move |_| {
|
||||
let handler = handler.clone();
|
||||
async move {
|
||||
Ok::<_, Infallible>(service_fn(move |req| {
|
||||
let handler = handler.clone();
|
||||
async move { Ok::<_, Infallible>(handler(req).await) }
|
||||
}))
|
||||
}
|
||||
});
|
||||
let server = Server::bind(&addr).serve(make_svc);
|
||||
let url = format!("http://{}", server.local_addr());
|
||||
let (tx, rx) = tokio::sync::oneshot::channel::<()>();
|
||||
let handle = tokio::spawn(async move {
|
||||
let graceful = server.with_graceful_shutdown(async {
|
||||
let _ = rx.await;
|
||||
});
|
||||
let _ = graceful.await;
|
||||
});
|
||||
(url, handle, tx)
|
||||
}
|
||||
|
||||
/// Reply body bitcoind would send for a successful getblockcount.
|
||||
fn ok_reply() -> Body {
|
||||
Body::from(r#"{"result":42,"error":null,"id":"archy"}"#)
|
||||
}
|
||||
|
||||
fn err_reply() -> Body {
|
||||
Body::from(r#"{"result":null,"error":{"code":-8,"message":"nope"},"id":"archy"}"#)
|
||||
}
|
||||
|
||||
/// Succeeds on first attempt — should not retry.
|
||||
#[tokio::test]
|
||||
async fn happy_path_first_attempt() {
|
||||
let count = Arc::new(AtomicU32::new(0));
|
||||
let c = count.clone();
|
||||
let (url, _h, _tx) = spawn_mock(move |_req| {
|
||||
let c = c.clone();
|
||||
async move {
|
||||
c.fetch_add(1, Ordering::SeqCst);
|
||||
Response::new(ok_reply())
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
let client = reqwest::Client::builder().build().unwrap();
|
||||
let v: u64 =
|
||||
bitcoin_rpc_post_with_retry(&client, &url, "user", "pass", "getblockcount", &[])
|
||||
.await
|
||||
.expect("should succeed");
|
||||
assert_eq!(v, 42);
|
||||
assert_eq!(count.load(Ordering::SeqCst), 1, "should not have retried");
|
||||
}
|
||||
|
||||
/// HTTP 503 with non-JSON body: produces a JSON-parse error which is NOT
|
||||
/// classified as transient. Must fail after first attempt.
|
||||
/// This guards against the tempting mistake of blanket-retrying every
|
||||
/// non-2xx response — which would mask real bitcoind misconfig.
|
||||
#[tokio::test]
|
||||
async fn does_not_retry_parse_errors() {
|
||||
let count = Arc::new(AtomicU32::new(0));
|
||||
let c = count.clone();
|
||||
let (url, _h, _tx) = spawn_mock(move |_req| {
|
||||
let c = c.clone();
|
||||
async move {
|
||||
c.fetch_add(1, Ordering::SeqCst);
|
||||
Response::builder()
|
||||
.status(StatusCode::SERVICE_UNAVAILABLE)
|
||||
.body(Body::from("busy"))
|
||||
.unwrap()
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
let client = reqwest::Client::builder().build().unwrap();
|
||||
let result: Result<u64> =
|
||||
bitcoin_rpc_post_with_retry(&client, &url, "user", "pass", "getblockcount", &[]).await;
|
||||
assert!(result.is_err(), "non-JSON response should error out");
|
||||
assert_eq!(
|
||||
count.load(Ordering::SeqCst),
|
||||
1,
|
||||
"parse errors are not retryable"
|
||||
);
|
||||
}
|
||||
|
||||
/// Connect-refused (port closed) is the canonical transient transport
|
||||
/// error. Must exhaust BITCOIN_RPC_MAX_ATTEMPTS and the total elapsed
|
||||
/// time must include at least the sum of the backoffs.
|
||||
#[tokio::test]
|
||||
async fn retries_exhausted_on_persistent_connect_refused() {
|
||||
// Bind a port then immediately drop the listener so the port is closed.
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let closed_url = format!("http://{}", listener.local_addr().unwrap());
|
||||
drop(listener);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.connect_timeout(std::time::Duration::from_millis(500))
|
||||
.build()
|
||||
.unwrap();
|
||||
let start = std::time::Instant::now();
|
||||
let result: Result<u64> =
|
||||
bitcoin_rpc_post_with_retry(&client, &closed_url, "user", "pass", "getblockcount", &[])
|
||||
.await;
|
||||
let elapsed = start.elapsed();
|
||||
assert!(result.is_err(), "connect-refused should exhaust retries");
|
||||
let min_backoff: std::time::Duration = BITCOIN_RPC_BACKOFFS.iter().sum();
|
||||
assert!(
|
||||
elapsed >= min_backoff,
|
||||
"should have backed off between retries (elapsed={:?}, expected at least {:?})",
|
||||
elapsed,
|
||||
min_backoff
|
||||
);
|
||||
}
|
||||
|
||||
/// The motivating scenario: first attempt times out (bitcoind busy),
|
||||
/// subsequent attempt succeeds. Uses a short test-only RetryConfig so
|
||||
/// the test runs in <1s instead of 15s.
|
||||
#[tokio::test]
|
||||
async fn retries_on_timeout_then_succeeds() {
|
||||
let count = Arc::new(AtomicU32::new(0));
|
||||
let c = count.clone();
|
||||
// Mock server: first request hangs for 500ms, subsequent requests reply OK.
|
||||
let (url, _h, _tx) = spawn_mock(move |_req| {
|
||||
let c = c.clone();
|
||||
async move {
|
||||
let n = c.fetch_add(1, Ordering::SeqCst);
|
||||
if n == 0 {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
}
|
||||
Response::new(ok_reply())
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
let client = reqwest::Client::builder().build().unwrap();
|
||||
// Attempt timeout 100ms < server's 500ms sleep => first attempt times out.
|
||||
// Backoff 20ms between attempts.
|
||||
let cfg = RetryConfig {
|
||||
max_attempts: 3,
|
||||
attempt_timeout: std::time::Duration::from_millis(100),
|
||||
backoffs: vec![
|
||||
std::time::Duration::from_millis(20),
|
||||
std::time::Duration::from_millis(20),
|
||||
],
|
||||
};
|
||||
let v: u64 = bitcoin_rpc_post_with_retry_cfg(
|
||||
&client,
|
||||
&url,
|
||||
"user",
|
||||
"pass",
|
||||
"getblockcount",
|
||||
&[],
|
||||
&cfg,
|
||||
)
|
||||
.await
|
||||
.expect("second attempt should succeed");
|
||||
assert_eq!(v, 42);
|
||||
assert!(
|
||||
count.load(Ordering::SeqCst) >= 2,
|
||||
"expected at least 2 attempts (got {})",
|
||||
count.load(Ordering::SeqCst)
|
||||
);
|
||||
}
|
||||
|
||||
/// bitcoind returned a well-formed `{"error": ...}` body. Must NOT retry.
|
||||
#[tokio::test]
|
||||
async fn does_not_retry_on_rpc_level_error() {
|
||||
let count = Arc::new(AtomicU32::new(0));
|
||||
let c = count.clone();
|
||||
let (url, _h, _tx) = spawn_mock(move |_req| {
|
||||
let c = c.clone();
|
||||
async move {
|
||||
c.fetch_add(1, Ordering::SeqCst);
|
||||
Response::new(err_reply())
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
let client = reqwest::Client::builder().build().unwrap();
|
||||
let result: Result<u64> =
|
||||
bitcoin_rpc_post_with_retry(&client, &url, "user", "pass", "getblockcount", &[]).await;
|
||||
assert!(result.is_err());
|
||||
assert_eq!(
|
||||
count.load(Ordering::SeqCst),
|
||||
1,
|
||||
"RPC-level errors are not transient"
|
||||
);
|
||||
}
|
||||
|
||||
/// Sanity: retry budget invariants. Chosen to catch regressions where
|
||||
/// someone bumps these constants without realizing the total worst-case
|
||||
/// wall time implications.
|
||||
#[test]
|
||||
fn retry_budget_invariants() {
|
||||
assert_eq!(BITCOIN_RPC_MAX_ATTEMPTS, 3);
|
||||
assert_eq!(
|
||||
BITCOIN_RPC_BACKOFFS.len(),
|
||||
(BITCOIN_RPC_MAX_ATTEMPTS - 1) as usize
|
||||
);
|
||||
// Total wall-time ceiling:
|
||||
// 3 attempts * 15s + (0.5s + 1.5s) backoff = 47s
|
||||
let total: std::time::Duration = BITCOIN_RPC_ATTEMPT_TIMEOUT * BITCOIN_RPC_MAX_ATTEMPTS
|
||||
+ BITCOIN_RPC_BACKOFFS.iter().sum::<std::time::Duration>();
|
||||
assert!(total < std::time::Duration::from_secs(60));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,971 @@
|
||||
use super::RpcHandler;
|
||||
use crate::container::docker_packages;
|
||||
use crate::data_model::{Notification, NotificationLevel};
|
||||
use crate::{bitcoin_status, identity, peers};
|
||||
use anyhow::{Context, Result};
|
||||
use archipelago_container::ContainerState;
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
|
||||
use hmac::{Hmac, Mac};
|
||||
use rand::RngCore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use sha2::Sha256;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::fs;
|
||||
|
||||
const RELAY_DIR: &str = "bitcoin-relay";
|
||||
const RELAY_STATE_FILE: &str = "state.json";
|
||||
const TXRELAY_USER: &str = "txrelay";
|
||||
const TXRELAY_PASSWORD_FILE: &str = "bitcoin-rpc-txrelay-password";
|
||||
const TXRELAY_RPCAUTH_FILE: &str = "bitcoin-rpc-txrelay-rpcauth";
|
||||
const TXRELAY_CLIENT_ENV_FILE: &str = "bitcoin-rpc-txrelay-client.env";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
struct BitcoinRelayState {
|
||||
settings: BitcoinRelaySettings,
|
||||
requests: Vec<BitcoinRelayRequest>,
|
||||
updated_at: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for BitcoinRelayState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
settings: BitcoinRelaySettings::default(),
|
||||
requests: Vec::new(),
|
||||
updated_at: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
struct BitcoinRelaySettings {
|
||||
enabled_for_peers: bool,
|
||||
allow_peer_requests: bool,
|
||||
allow_http: bool,
|
||||
allow_https: bool,
|
||||
allow_tor: bool,
|
||||
selected_peer_pubkey: Option<String>,
|
||||
http_endpoint: Option<String>,
|
||||
https_endpoint: Option<String>,
|
||||
tor_endpoint: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for BitcoinRelaySettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled_for_peers: false,
|
||||
allow_peer_requests: false,
|
||||
allow_http: false,
|
||||
allow_https: true,
|
||||
allow_tor: false,
|
||||
selected_peer_pubkey: None,
|
||||
http_endpoint: None,
|
||||
https_endpoint: None,
|
||||
tor_endpoint: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct BitcoinRelayRequest {
|
||||
id: String,
|
||||
direction: RelayRequestDirection,
|
||||
status: RelayRequestStatus,
|
||||
peer_pubkey: String,
|
||||
peer_onion: String,
|
||||
peer_name: Option<String>,
|
||||
message: Option<String>,
|
||||
approved_endpoint: Option<String>,
|
||||
credential_secret_path: Option<String>,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
enum RelayRequestDirection {
|
||||
Incoming,
|
||||
Outbound,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
enum RelayRequestStatus {
|
||||
Pending,
|
||||
Approved,
|
||||
Rejected,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct TrustedRelayPeer {
|
||||
pubkey: String,
|
||||
onion: String,
|
||||
name: Option<String>,
|
||||
relay_approved: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct TxRelayCredentials {
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
pub(super) async fn handle_bitcoin_relay_status(&self) -> Result<serde_json::Value> {
|
||||
let mut state = load_relay_state(&self.config.data_dir).await?;
|
||||
hydrate_tor_endpoint(&self.config.data_dir, &mut state).await;
|
||||
let known_peers = peers::load_peers(&self.config.data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let trusted_nodes = trusted_relay_peers(&known_peers, &state);
|
||||
let local_node = local_sync_status().await;
|
||||
let credential_status = txrelay_credential_status(&self.config.data_dir).await;
|
||||
|
||||
Ok(json!({
|
||||
"settings": state.settings,
|
||||
"trusted_nodes": trusted_nodes,
|
||||
"requests": state.requests,
|
||||
"local_node": local_node,
|
||||
"credentials": credential_status,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_bitcoin_relay_update_settings(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let mut state = load_relay_state(&self.config.data_dir).await?;
|
||||
let known_peers = peers::load_peers(&self.config.data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
update_bool(
|
||||
¶ms,
|
||||
"enabled_for_peers",
|
||||
&mut state.settings.enabled_for_peers,
|
||||
);
|
||||
update_bool(
|
||||
¶ms,
|
||||
"allow_peer_requests",
|
||||
&mut state.settings.allow_peer_requests,
|
||||
);
|
||||
update_bool(¶ms, "allow_http", &mut state.settings.allow_http);
|
||||
update_bool(¶ms, "allow_https", &mut state.settings.allow_https);
|
||||
update_bool(¶ms, "allow_tor", &mut state.settings.allow_tor);
|
||||
|
||||
update_endpoint(¶ms, "http_endpoint", &mut state.settings.http_endpoint)?;
|
||||
update_endpoint(
|
||||
¶ms,
|
||||
"https_endpoint",
|
||||
&mut state.settings.https_endpoint,
|
||||
)?;
|
||||
update_endpoint(¶ms, "tor_endpoint", &mut state.settings.tor_endpoint)?;
|
||||
|
||||
if state.settings.enabled_for_peers {
|
||||
let credentials_were_ready = txrelay_credentials_available(&self.config.data_dir).await;
|
||||
ensure_txrelay_credentials(&self.config.data_dir).await?;
|
||||
if !credentials_were_ready {
|
||||
self.restart_bitcoin_backends_for_txrelay().await;
|
||||
}
|
||||
}
|
||||
|
||||
if params.get("selected_peer_pubkey").is_some() {
|
||||
let selected = params
|
||||
.get("selected_peer_pubkey")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty());
|
||||
if let Some(pubkey) = selected {
|
||||
if !known_peers.iter().any(|p| p.pubkey == pubkey) {
|
||||
anyhow::bail!("Selected relay peer is not in trusted nodes");
|
||||
}
|
||||
state.settings.selected_peer_pubkey = Some(pubkey.to_string());
|
||||
} else {
|
||||
state.settings.selected_peer_pubkey = None;
|
||||
}
|
||||
}
|
||||
|
||||
state.updated_at = Some(now());
|
||||
save_relay_state(&self.config.data_dir, &state).await?;
|
||||
self.notify(
|
||||
"Bitcoin relay settings updated",
|
||||
"Transaction relay sharing preferences were saved.",
|
||||
)
|
||||
.await;
|
||||
self.handle_bitcoin_relay_status().await
|
||||
}
|
||||
|
||||
pub(super) async fn handle_bitcoin_relay_request_peer(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let peer_pubkey = params
|
||||
.get("peer_pubkey")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: peer_pubkey"))?;
|
||||
let message = params
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(sanitize_optional_text)
|
||||
.transpose()?;
|
||||
let peer = peers::load_peers(&self.config.data_dir)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.find(|p| p.pubkey == peer_pubkey)
|
||||
.ok_or_else(|| anyhow::anyhow!("Peer is not in trusted nodes"))?;
|
||||
|
||||
let mut state = load_relay_state(&self.config.data_dir).await?;
|
||||
let existing = state.requests.iter_mut().find(|r| {
|
||||
r.direction == RelayRequestDirection::Outbound
|
||||
&& r.peer_pubkey == peer.pubkey
|
||||
&& r.status == RelayRequestStatus::Pending
|
||||
});
|
||||
let request_id = if let Some(req) = existing {
|
||||
req.message = message.clone();
|
||||
req.updated_at = now();
|
||||
req.id.clone()
|
||||
} else {
|
||||
let timestamp = now();
|
||||
let req = BitcoinRelayRequest {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
direction: RelayRequestDirection::Outbound,
|
||||
status: RelayRequestStatus::Pending,
|
||||
peer_pubkey: peer.pubkey.clone(),
|
||||
peer_onion: peer.onion.clone(),
|
||||
peer_name: peer.name.clone(),
|
||||
message: message.clone(),
|
||||
approved_endpoint: None,
|
||||
credential_secret_path: None,
|
||||
created_at: timestamp.clone(),
|
||||
updated_at: timestamp,
|
||||
};
|
||||
let id = req.id.clone();
|
||||
state.requests.push(req);
|
||||
id
|
||||
};
|
||||
state.updated_at = Some(now());
|
||||
save_relay_state(&self.config.data_dir, &state).await?;
|
||||
|
||||
if let Err(e) = self
|
||||
.send_relay_peer_message(
|
||||
&peer,
|
||||
json!({
|
||||
"type": "bitcoin_relay_request",
|
||||
"request_id": request_id,
|
||||
"message": message,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(peer = %peer.onion, error = %e, "Failed to send Bitcoin relay request");
|
||||
}
|
||||
|
||||
self.notify(
|
||||
"Bitcoin relay request sent",
|
||||
"A trusted peer was asked to approve transaction relay access.",
|
||||
)
|
||||
.await;
|
||||
Ok(json!({ "ok": true, "request_id": request_id }))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_bitcoin_relay_approve_request(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
self.update_relay_request_status(params, RelayRequestStatus::Approved)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) async fn handle_bitcoin_relay_reject_request(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
self.update_relay_request_status(params, RelayRequestStatus::Rejected)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) async fn handle_bitcoin_relay_create_tor_service(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = json!({
|
||||
"name": "bitcoin-rpc",
|
||||
"local_port": 80,
|
||||
"remote_port": 80,
|
||||
});
|
||||
let created = match self.handle_tor_create_service(Some(params)).await {
|
||||
Ok(v) => v,
|
||||
Err(e) if e.to_string().contains("already exists") => {
|
||||
self.handle_tor_get_onion_address(Some(json!({ "name": "bitcoin-rpc" })))
|
||||
.await?
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
|
||||
let onion = created
|
||||
.get("onion_address")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
if let Some(onion) = onion {
|
||||
let mut state = load_relay_state(&self.config.data_dir).await?;
|
||||
state.settings.allow_tor = true;
|
||||
state.settings.tor_endpoint = Some(format!("http://{onion}/"));
|
||||
state.updated_at = Some(now());
|
||||
save_relay_state(&self.config.data_dir, &state).await?;
|
||||
}
|
||||
|
||||
self.notify(
|
||||
"Bitcoin relay Tor service enabled",
|
||||
"A Tor endpoint was created for Bitcoin transaction relay access.",
|
||||
)
|
||||
.await;
|
||||
Ok(created)
|
||||
}
|
||||
|
||||
async fn update_relay_request_status(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
status: RelayRequestStatus,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let request_id = params
|
||||
.get("id")
|
||||
.or_else(|| params.get("request_id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
|
||||
let mut state = load_relay_state(&self.config.data_dir).await?;
|
||||
let serving_endpoint = if status == RelayRequestStatus::Approved {
|
||||
preferred_endpoint(&state.settings)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let request_direction = state
|
||||
.requests
|
||||
.iter()
|
||||
.find(|r| r.id == request_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Request not found: {}", request_id))?
|
||||
.direction;
|
||||
if status == RelayRequestStatus::Approved
|
||||
&& request_direction == RelayRequestDirection::Incoming
|
||||
&& serving_endpoint.is_none()
|
||||
{
|
||||
anyhow::bail!(
|
||||
"Configure an HTTP, HTTPS, or Tor relay endpoint before approving access"
|
||||
);
|
||||
}
|
||||
let credentials = if status == RelayRequestStatus::Approved {
|
||||
let credentials = ensure_txrelay_credentials(&self.config.data_dir).await?;
|
||||
if request_direction == RelayRequestDirection::Incoming {
|
||||
self.restart_bitcoin_backends_for_txrelay().await;
|
||||
}
|
||||
Some(credentials)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let (peer_pubkey, peer_onion, peer_name, direction) = {
|
||||
let req = state
|
||||
.requests
|
||||
.iter_mut()
|
||||
.find(|r| r.id == request_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Request not found: {}", request_id))?;
|
||||
req.status = status;
|
||||
req.updated_at = now();
|
||||
if let Some(endpoint) = &serving_endpoint {
|
||||
req.approved_endpoint = Some(endpoint.clone());
|
||||
}
|
||||
(
|
||||
req.peer_pubkey.clone(),
|
||||
req.peer_onion.clone(),
|
||||
req.peer_name.clone(),
|
||||
req.direction,
|
||||
)
|
||||
};
|
||||
let peer = peers::load_peers(&self.config.data_dir)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.find(|p| p.pubkey == peer_pubkey);
|
||||
let peer_name = peer_name.unwrap_or_else(|| peer_onion.clone());
|
||||
state.updated_at = Some(now());
|
||||
save_relay_state(&self.config.data_dir, &state).await?;
|
||||
|
||||
if let Some(peer) = peer {
|
||||
let message_type = match status {
|
||||
RelayRequestStatus::Approved => "bitcoin_relay_approved",
|
||||
RelayRequestStatus::Rejected => "bitcoin_relay_rejected",
|
||||
RelayRequestStatus::Pending => "bitcoin_relay_pending",
|
||||
};
|
||||
if let Err(e) = self
|
||||
.send_relay_peer_message(
|
||||
&peer,
|
||||
relay_response_payload(
|
||||
message_type,
|
||||
request_id,
|
||||
direction,
|
||||
serving_endpoint.as_deref(),
|
||||
credentials.as_ref(),
|
||||
),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(peer = %peer.onion, error = %e, "Failed to send Bitcoin relay response");
|
||||
}
|
||||
}
|
||||
|
||||
let title = match status {
|
||||
RelayRequestStatus::Approved => "Bitcoin relay request approved",
|
||||
RelayRequestStatus::Rejected => "Bitcoin relay request rejected",
|
||||
RelayRequestStatus::Pending => "Bitcoin relay request updated",
|
||||
};
|
||||
self.notify(
|
||||
title,
|
||||
&format!("Relay access request for {peer_name} was updated."),
|
||||
)
|
||||
.await;
|
||||
Ok(json!({ "ok": true, "request_id": request_id }))
|
||||
}
|
||||
|
||||
async fn send_relay_peer_message(
|
||||
&self,
|
||||
peer: &peers::KnownPeer,
|
||||
mut payload: serde_json::Value,
|
||||
) -> Result<()> {
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let my_pubkey = data.server_info.pubkey.clone();
|
||||
let my_did = identity::did_key_from_pubkey_hex(&my_pubkey).ok();
|
||||
let my_onion = docker_packages::read_tor_address("archipelago")
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
payload["from_did"] = my_did.map(serde_json::Value::String).unwrap_or_default();
|
||||
payload["from_pubkey"] = serde_json::Value::String(my_pubkey.clone());
|
||||
payload["from_onion"] = serde_json::Value::String(my_onion);
|
||||
payload["from_name"] = data
|
||||
.server_info
|
||||
.name
|
||||
.clone()
|
||||
.map(serde_json::Value::String)
|
||||
.unwrap_or_default();
|
||||
|
||||
let to_fips_npub =
|
||||
crate::federation::fips_npub_for_onion(&self.config.data_dir, &peer.onion).await;
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let signing_key = crate::identity::NodeIdentity::load_or_create(&identity_dir)
|
||||
.await
|
||||
.ok();
|
||||
crate::node_message::send_to_peer(
|
||||
&peer.onion,
|
||||
to_fips_npub.as_deref(),
|
||||
&my_pubkey,
|
||||
&payload.to_string(),
|
||||
signing_key.as_ref().map(|i| i.signing_key()),
|
||||
Some(&peer.pubkey),
|
||||
data.server_info.name.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn notify(&self, title: &str, message: &str) {
|
||||
let (mut data, _) = self.state_manager.get_snapshot().await;
|
||||
data.notifications.push(Notification {
|
||||
id: format!("bitcoin-relay-{}", uuid::Uuid::new_v4()),
|
||||
level: NotificationLevel::Info,
|
||||
title: title.to_string(),
|
||||
message: message.to_string(),
|
||||
timestamp: now(),
|
||||
app_id: Some("bitcoin-knots".to_string()),
|
||||
});
|
||||
let len = data.notifications.len();
|
||||
if len > 30 {
|
||||
data.notifications.drain(0..len - 30);
|
||||
}
|
||||
self.state_manager.update_data(data).await;
|
||||
}
|
||||
|
||||
async fn restart_bitcoin_backends_for_txrelay(&self) {
|
||||
let Some(orchestrator) = self.orchestrator.as_ref().cloned() else {
|
||||
tracing::debug!("Skipping txrelay backend restart; orchestrator unavailable");
|
||||
return;
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
for app_id in ["bitcoin-knots", "bitcoin-core"] {
|
||||
let Ok(status) = orchestrator.status(app_id).await else {
|
||||
continue;
|
||||
};
|
||||
if status.state != ContainerState::Running {
|
||||
continue;
|
||||
}
|
||||
match orchestrator.restart(app_id).await {
|
||||
Ok(()) => tracing::info!(
|
||||
app_id,
|
||||
"Restarted Bitcoin backend to load txrelay RPC credentials"
|
||||
),
|
||||
Err(e) => tracing::warn!(
|
||||
app_id,
|
||||
error = %e,
|
||||
"Failed to restart Bitcoin backend after txrelay credential update"
|
||||
),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn record_incoming_relay_message(
|
||||
data_dir: &Path,
|
||||
from_pubkey: &str,
|
||||
from_name: Option<&str>,
|
||||
payload: &serde_json::Value,
|
||||
) -> Result<Option<&'static str>> {
|
||||
let msg_type = payload.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
match msg_type {
|
||||
"bitcoin_relay_request" => {
|
||||
let from_onion = payload
|
||||
.get("from_onion")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let message = payload
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(sanitize_optional_text)
|
||||
.transpose()?;
|
||||
let remote_request_id = payload
|
||||
.get("request_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default();
|
||||
let mut state = load_relay_state(data_dir).await?;
|
||||
if !state.settings.allow_peer_requests {
|
||||
return Ok(Some("bitcoin_relay_request_disabled"));
|
||||
}
|
||||
if !state.requests.iter().any(|r| {
|
||||
r.direction == RelayRequestDirection::Incoming
|
||||
&& r.peer_pubkey == from_pubkey
|
||||
&& r.status == RelayRequestStatus::Pending
|
||||
}) {
|
||||
let timestamp = now();
|
||||
state.requests.push(BitcoinRelayRequest {
|
||||
id: if remote_request_id.is_empty() {
|
||||
uuid::Uuid::new_v4().to_string()
|
||||
} else {
|
||||
remote_request_id.to_string()
|
||||
},
|
||||
direction: RelayRequestDirection::Incoming,
|
||||
status: RelayRequestStatus::Pending,
|
||||
peer_pubkey: from_pubkey.to_string(),
|
||||
peer_onion: from_onion,
|
||||
peer_name: from_name.map(String::from),
|
||||
message,
|
||||
approved_endpoint: None,
|
||||
credential_secret_path: None,
|
||||
created_at: timestamp.clone(),
|
||||
updated_at: timestamp,
|
||||
});
|
||||
state.updated_at = Some(now());
|
||||
save_relay_state(data_dir, &state).await?;
|
||||
}
|
||||
Ok(Some("bitcoin_relay_request"))
|
||||
}
|
||||
"bitcoin_relay_approved" | "bitcoin_relay_rejected" => {
|
||||
let request_id = payload.get("request_id").and_then(|v| v.as_str());
|
||||
let mut state = load_relay_state(data_dir).await?;
|
||||
let status = if msg_type == "bitcoin_relay_approved" {
|
||||
RelayRequestStatus::Approved
|
||||
} else {
|
||||
RelayRequestStatus::Rejected
|
||||
};
|
||||
let approved_access = if status == RelayRequestStatus::Approved {
|
||||
save_peer_relay_access(data_dir, from_pubkey, payload).await?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(req) = state.requests.iter_mut().find(|r| {
|
||||
r.direction == RelayRequestDirection::Outbound
|
||||
&& r.peer_pubkey == from_pubkey
|
||||
&& request_id.map(|id| id == r.id).unwrap_or(true)
|
||||
}) {
|
||||
req.status = status;
|
||||
req.updated_at = now();
|
||||
if let Some((endpoint, secret_path)) = approved_access {
|
||||
req.approved_endpoint = Some(endpoint);
|
||||
req.credential_secret_path = Some(secret_path);
|
||||
}
|
||||
state.updated_at = Some(now());
|
||||
save_relay_state(data_dir, &state).await?;
|
||||
}
|
||||
Ok(Some(if msg_type == "bitcoin_relay_approved" {
|
||||
"bitcoin_relay_approved"
|
||||
} else {
|
||||
"bitcoin_relay_rejected"
|
||||
}))
|
||||
}
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn trusted_relay_peers(
|
||||
known_peers: &[peers::KnownPeer],
|
||||
state: &BitcoinRelayState,
|
||||
) -> Vec<TrustedRelayPeer> {
|
||||
known_peers
|
||||
.iter()
|
||||
.map(|peer| TrustedRelayPeer {
|
||||
pubkey: peer.pubkey.clone(),
|
||||
onion: peer.onion.clone(),
|
||||
name: peer.name.clone(),
|
||||
relay_approved: state.requests.iter().any(|req| {
|
||||
req.peer_pubkey == peer.pubkey && req.status == RelayRequestStatus::Approved
|
||||
}),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn txrelay_credential_status(data_dir: &Path) -> serde_json::Value {
|
||||
let credentials_available = txrelay_credentials_available(data_dir).await;
|
||||
let (password_path, rpcauth_path, client_env_path) = txrelay_secret_paths(data_dir);
|
||||
let password_available = fs::metadata(&password_path).await.is_ok();
|
||||
let rpcauth_available = fs::metadata(&rpcauth_path).await.is_ok();
|
||||
let client_env_available = fs::metadata(&client_env_path).await.is_ok();
|
||||
json!({
|
||||
"username": TXRELAY_USER,
|
||||
"available": credentials_available,
|
||||
"password_available": password_available,
|
||||
"rpcauth_available": rpcauth_available,
|
||||
"client_env_available": client_env_available,
|
||||
"client_env_path": client_env_path.display().to_string(),
|
||||
"restart_hint": "Archipelago restarts the active Bitcoin backend after generating txrelay credentials so bitcoind loads the restricted rpcauth whitelist.",
|
||||
})
|
||||
}
|
||||
|
||||
async fn txrelay_credentials_available(data_dir: &Path) -> bool {
|
||||
let (password_path, rpcauth_path, client_env_path) = txrelay_secret_paths(data_dir);
|
||||
fs::metadata(&password_path).await.is_ok()
|
||||
&& fs::metadata(&rpcauth_path).await.is_ok()
|
||||
&& fs::metadata(&client_env_path).await.is_ok()
|
||||
}
|
||||
|
||||
/// Idempotently ensure the tx-relay credential trio exists in the secrets dir:
|
||||
/// the random password, its derived `rpcauth` line, and the client env file.
|
||||
/// Bitcoin backend manifests reference `bitcoin-rpc-txrelay-rpcauth` as a
|
||||
/// required `secret_env`, so this must run before bitcoind starts — otherwise
|
||||
/// secret resolution hard-fails and the whole Bitcoin stack cascades (the .198
|
||||
/// failure). Safe to call repeatedly; it only writes what's missing or stale.
|
||||
pub(crate) async fn ensure_txrelay_credentials(data_dir: &Path) -> Result<TxRelayCredentials> {
|
||||
let (password_path, rpcauth_path, client_env_path) = txrelay_secret_paths(data_dir);
|
||||
let password = match read_trimmed(&password_path).await {
|
||||
Some(value) => value,
|
||||
None => {
|
||||
let generated = generate_random_password();
|
||||
write_secret_file(&password_path, &generated).await?;
|
||||
generated
|
||||
}
|
||||
};
|
||||
let rpcauth = match read_trimmed(&rpcauth_path).await {
|
||||
Some(value) if rpcauth_matches_password(&value, TXRELAY_USER, &password) => value,
|
||||
_ => {
|
||||
let generated = generate_rpcauth(TXRELAY_USER, &password);
|
||||
write_secret_file(&rpcauth_path, &generated).await?;
|
||||
generated
|
||||
}
|
||||
};
|
||||
let client_env = format!(
|
||||
"BITCOIN_RPC_TXRELAY_USER={}\nBITCOIN_RPC_TXRELAY_PASSWORD={}\nBITCOIN_RPC_TXRELAY_RPCAUTH={}\n",
|
||||
TXRELAY_USER, password, rpcauth
|
||||
);
|
||||
write_secret_file(&client_env_path, &client_env).await?;
|
||||
|
||||
Ok(TxRelayCredentials {
|
||||
username: TXRELAY_USER.to_string(),
|
||||
password,
|
||||
})
|
||||
}
|
||||
|
||||
fn txrelay_secret_paths(data_dir: &Path) -> (PathBuf, PathBuf, PathBuf) {
|
||||
let secrets_dir = data_dir.join("secrets");
|
||||
(
|
||||
secrets_dir.join(TXRELAY_PASSWORD_FILE),
|
||||
secrets_dir.join(TXRELAY_RPCAUTH_FILE),
|
||||
secrets_dir.join(TXRELAY_CLIENT_ENV_FILE),
|
||||
)
|
||||
}
|
||||
|
||||
async fn read_trimmed(path: &Path) -> Option<String> {
|
||||
fs::read_to_string(path)
|
||||
.await
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
async fn write_secret_file(path: &Path, contents: &str) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).await?;
|
||||
}
|
||||
fs::write(path, contents).await?;
|
||||
set_private_permissions(path).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_private_permissions(path: &Path) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_random_password() -> String {
|
||||
let mut bytes = [0u8; 32];
|
||||
rand::rngs::OsRng.fill_bytes(&mut bytes);
|
||||
BASE64.encode(bytes)
|
||||
}
|
||||
|
||||
fn generate_rpcauth(username: &str, password: &str) -> String {
|
||||
let mut salt_bytes = [0u8; 16];
|
||||
rand::rngs::OsRng.fill_bytes(&mut salt_bytes);
|
||||
let salt_hex = hex::encode(salt_bytes);
|
||||
let mut mac =
|
||||
Hmac::<Sha256>::new_from_slice(salt_hex.as_bytes()).expect("HMAC accepts any key length");
|
||||
mac.update(password.as_bytes());
|
||||
let hash_hex = hex::encode(mac.finalize().into_bytes());
|
||||
format!("{username}:{salt_hex}${hash_hex}")
|
||||
}
|
||||
|
||||
fn rpcauth_matches_password(rpcauth: &str, username: &str, password: &str) -> bool {
|
||||
let Some(rest) = rpcauth.strip_prefix(&format!("{username}:")) else {
|
||||
return false;
|
||||
};
|
||||
let Some((salt_hex, expected_hash)) = rest.split_once('$') else {
|
||||
return false;
|
||||
};
|
||||
if salt_hex.is_empty() || expected_hash.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let Ok(mut mac) = Hmac::<Sha256>::new_from_slice(salt_hex.as_bytes()) else {
|
||||
return false;
|
||||
};
|
||||
mac.update(password.as_bytes());
|
||||
let hash_hex = hex::encode(mac.finalize().into_bytes());
|
||||
hash_hex.eq_ignore_ascii_case(expected_hash)
|
||||
}
|
||||
|
||||
fn preferred_endpoint(settings: &BitcoinRelaySettings) -> Option<String> {
|
||||
if settings.allow_https {
|
||||
if let Some(endpoint) = settings.https_endpoint.clone() {
|
||||
return Some(endpoint);
|
||||
}
|
||||
}
|
||||
if settings.allow_tor {
|
||||
if let Some(endpoint) = settings.tor_endpoint.clone() {
|
||||
return Some(endpoint);
|
||||
}
|
||||
}
|
||||
if settings.allow_http {
|
||||
if let Some(endpoint) = settings.http_endpoint.clone() {
|
||||
return Some(endpoint);
|
||||
}
|
||||
}
|
||||
settings
|
||||
.https_endpoint
|
||||
.clone()
|
||||
.or_else(|| settings.tor_endpoint.clone())
|
||||
.or_else(|| settings.http_endpoint.clone())
|
||||
}
|
||||
|
||||
fn relay_response_payload(
|
||||
message_type: &str,
|
||||
request_id: &str,
|
||||
request_direction: RelayRequestDirection,
|
||||
endpoint: Option<&str>,
|
||||
credentials: Option<&TxRelayCredentials>,
|
||||
) -> serde_json::Value {
|
||||
let mut payload = json!({
|
||||
"type": message_type,
|
||||
"request_id": request_id,
|
||||
});
|
||||
if message_type == "bitcoin_relay_approved"
|
||||
&& request_direction == RelayRequestDirection::Incoming
|
||||
{
|
||||
if let (Some(endpoint), Some(credentials)) = (endpoint, credentials) {
|
||||
payload["relay_access"] = json!({
|
||||
"endpoint": endpoint,
|
||||
"username": &credentials.username,
|
||||
"password": &credentials.password,
|
||||
});
|
||||
}
|
||||
}
|
||||
payload
|
||||
}
|
||||
|
||||
async fn save_peer_relay_access(
|
||||
data_dir: &Path,
|
||||
from_pubkey: &str,
|
||||
payload: &serde_json::Value,
|
||||
) -> Result<Option<(String, String)>> {
|
||||
let Some(access) = payload.get("relay_access") else {
|
||||
return Ok(None);
|
||||
};
|
||||
let endpoint = access
|
||||
.get("endpoint")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(validate_endpoint)
|
||||
.transpose()?;
|
||||
let username = access.get("username").and_then(|v| v.as_str());
|
||||
let password = access.get("password").and_then(|v| v.as_str());
|
||||
let (Some(endpoint), Some(username), Some(password)) = (endpoint, username, password) else {
|
||||
return Ok(None);
|
||||
};
|
||||
validate_env_value(username)?;
|
||||
validate_env_value(password)?;
|
||||
|
||||
let secret_path = data_dir.join("secrets").join(format!(
|
||||
"bitcoin-relay-peer-{}.env",
|
||||
safe_pubkey_fragment(from_pubkey)
|
||||
));
|
||||
let contents = format!(
|
||||
"BITCOIN_RELAY_PEER_PUBKEY={}\nBITCOIN_RELAY_ENDPOINT={}\nBITCOIN_RELAY_USERNAME={}\nBITCOIN_RELAY_PASSWORD={}\n",
|
||||
from_pubkey, endpoint, username, password
|
||||
);
|
||||
write_secret_file(&secret_path, &contents).await?;
|
||||
Ok(Some((endpoint, secret_path.display().to_string())))
|
||||
}
|
||||
|
||||
fn validate_env_value(value: &str) -> Result<()> {
|
||||
if value.is_empty() || value.len() > 1024 || value.contains('\n') || value.contains('\r') {
|
||||
anyhow::bail!("Invalid relay credential value");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn safe_pubkey_fragment(pubkey: &str) -> String {
|
||||
let fragment = pubkey
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_hexdigit())
|
||||
.take(24)
|
||||
.collect::<String>();
|
||||
if fragment.is_empty() {
|
||||
"unknown".to_string()
|
||||
} else {
|
||||
fragment
|
||||
}
|
||||
}
|
||||
|
||||
async fn hydrate_tor_endpoint(data_dir: &Path, state: &mut BitcoinRelayState) {
|
||||
if state.settings.tor_endpoint.is_some() {
|
||||
return;
|
||||
}
|
||||
if let Some(onion) = docker_packages::read_tor_address("bitcoin-rpc").await {
|
||||
let onion = onion.trim().trim_end_matches('/').to_string();
|
||||
if !onion.is_empty() {
|
||||
state.settings.tor_endpoint = Some(format!("http://{onion}/"));
|
||||
if let Err(e) = save_relay_state(data_dir, state).await {
|
||||
tracing::warn!("Failed to persist relay tor endpoint: {e:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn local_sync_status() -> serde_json::Value {
|
||||
let status = bitcoin_status::get_bitcoin_status().await;
|
||||
let blockchain = status.blockchain_info.as_ref();
|
||||
let blocks = blockchain
|
||||
.and_then(|v| v.get("blocks"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
let headers = blockchain
|
||||
.and_then(|v| v.get("headers"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
let initial_block_download = blockchain
|
||||
.and_then(|v| v.get("initialblockdownload"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(true);
|
||||
let synced =
|
||||
status.ok && headers > 0 && blocks >= headers.saturating_sub(1) && !initial_block_download;
|
||||
|
||||
json!({
|
||||
"synced": synced,
|
||||
"blocks": blocks,
|
||||
"headers": headers,
|
||||
"chain": blockchain
|
||||
.and_then(|v| v.get("chain"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown"),
|
||||
"status_ok": status.ok,
|
||||
"status_stale": status.stale,
|
||||
"error": status.error,
|
||||
})
|
||||
}
|
||||
|
||||
async fn load_relay_state(data_dir: &Path) -> Result<BitcoinRelayState> {
|
||||
let path = state_path(data_dir);
|
||||
if !path.exists() {
|
||||
return Ok(BitcoinRelayState::default());
|
||||
}
|
||||
let content = fs::read_to_string(&path)
|
||||
.await
|
||||
.with_context(|| format!("Failed to read {}", path.display()))?;
|
||||
Ok(serde_json::from_str(&content).unwrap_or_default())
|
||||
}
|
||||
|
||||
async fn save_relay_state(data_dir: &Path, state: &BitcoinRelayState) -> Result<()> {
|
||||
let dir = data_dir.join(RELAY_DIR);
|
||||
fs::create_dir_all(&dir).await?;
|
||||
let content = serde_json::to_string_pretty(state)?;
|
||||
fs::write(dir.join(RELAY_STATE_FILE), content).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn state_path(data_dir: &Path) -> PathBuf {
|
||||
data_dir.join(RELAY_DIR).join(RELAY_STATE_FILE)
|
||||
}
|
||||
|
||||
fn update_bool(params: &serde_json::Value, key: &str, target: &mut bool) {
|
||||
if let Some(value) = params.get(key).and_then(|v| v.as_bool()) {
|
||||
*target = value;
|
||||
}
|
||||
}
|
||||
|
||||
fn update_endpoint(
|
||||
params: &serde_json::Value,
|
||||
key: &str,
|
||||
target: &mut Option<String>,
|
||||
) -> Result<()> {
|
||||
if !params.get(key).is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let endpoint = params
|
||||
.get(key)
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty());
|
||||
*target = endpoint.map(validate_endpoint).transpose()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_endpoint(endpoint: &str) -> Result<String> {
|
||||
if endpoint.len() > 512 || endpoint.contains('\n') || endpoint.contains('\r') {
|
||||
anyhow::bail!("Invalid endpoint");
|
||||
}
|
||||
let lower = endpoint.to_ascii_lowercase();
|
||||
if !(lower.starts_with("http://") || lower.starts_with("https://")) {
|
||||
anyhow::bail!("Endpoint must start with http:// or https://");
|
||||
}
|
||||
Ok(endpoint.to_string())
|
||||
}
|
||||
|
||||
fn sanitize_optional_text(value: &str) -> Result<String> {
|
||||
let value = value.trim();
|
||||
if value.len() > 500 || value.contains('\0') {
|
||||
anyhow::bail!("Invalid message");
|
||||
}
|
||||
Ok(value.to_string())
|
||||
}
|
||||
|
||||
fn now() -> String {
|
||||
chrono::Utc::now().to_rfc3339()
|
||||
}
|
||||
@@ -0,0 +1,931 @@
|
||||
use super::package::validate_app_id;
|
||||
use super::transitional::Op;
|
||||
use super::RpcHandler;
|
||||
use anyhow::{Context, Result};
|
||||
use std::time::Duration;
|
||||
|
||||
const PODMAN_INSPECT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const PODMAN_PS_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const ORCHESTRATOR_HEALTH_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
impl RpcHandler {
|
||||
pub(super) async fn handle_container_install(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
// The `container-install { manifest_path }` RPC is a dev-mode convenience
|
||||
// that points at an arbitrary YAML on disk. Production install happens via
|
||||
// the reconciler (BootReconciler, Step 5) and via the unified
|
||||
// ContainerOrchestrator::install(app_id) trait call, which can be exposed
|
||||
// through a separate `container-install-by-id` RPC when needed.
|
||||
let dev = self.dev_orchestrator.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!("container-install with manifest_path is only available in dev mode")
|
||||
})?;
|
||||
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let manifest_path = params
|
||||
.get("manifest_path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing manifest_path"))?;
|
||||
|
||||
// Validate manifest path: reject traversal, resolve to canonical path
|
||||
if manifest_path.contains("..") || manifest_path.contains('\0') {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid manifest_path: path traversal not allowed"
|
||||
));
|
||||
}
|
||||
let apps_dir = self.config.data_dir.join("apps");
|
||||
let resolved = if std::path::Path::new(manifest_path).is_absolute() {
|
||||
std::path::PathBuf::from(manifest_path)
|
||||
} else {
|
||||
apps_dir.join(manifest_path)
|
||||
};
|
||||
let canonical = resolved
|
||||
.canonicalize()
|
||||
.context("Invalid manifest_path: file not found")?;
|
||||
if !canonical.starts_with(&apps_dir) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid manifest_path: must be under the apps directory"
|
||||
));
|
||||
}
|
||||
|
||||
// Load manifest
|
||||
let manifest_content = tokio::fs::read_to_string(&canonical)
|
||||
.await
|
||||
.context("Failed to read manifest file")?;
|
||||
let manifest: archipelago_container::AppManifest =
|
||||
serde_yaml::from_str(&manifest_content).context("Failed to parse manifest")?;
|
||||
|
||||
let container_name = dev
|
||||
.install_container(&manifest, manifest_path)
|
||||
.await
|
||||
.context("Failed to install container")?;
|
||||
|
||||
Ok(serde_json::json!(container_name))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_container_start(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let app_id = params
|
||||
.get("app_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
|
||||
validate_app_id(app_id)?;
|
||||
|
||||
// User explicitly started the app — clear the user-stopped marker so
|
||||
// crash recovery / health monitor won't second-guess it. Must happen
|
||||
// BEFORE the spawn (see runtime.rs:145-148 for the symmetric stop
|
||||
// side and the ordering contract crash recovery depends on).
|
||||
crate::crash_recovery::clear_user_stopped(&self.config.data_dir, app_id).await;
|
||||
|
||||
// spawn_transitional returns as soon as the background task is
|
||||
// launched (<1s). The UI sees Starting… immediately via WebSocket.
|
||||
self.spawn_transitional(Op::Start, app_id.to_string())
|
||||
.await?;
|
||||
|
||||
Ok(serde_json::json!({ "status": "starting" }))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_container_stop(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let app_id = params
|
||||
.get("app_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
|
||||
validate_app_id(app_id)?;
|
||||
|
||||
// Mark as user-stopped BEFORE the spawn — ordering is load-bearing
|
||||
// (crash recovery / health monitor inspect this flag concurrently
|
||||
// with the in-flight stop; see runtime.rs:145-148 for the package
|
||||
// path that also writes this in the same order).
|
||||
crate::crash_recovery::mark_user_stopped(&self.config.data_dir, app_id).await;
|
||||
|
||||
// podman stop -t 600 (bitcoin-core) / -t 330 (lnd) runs in the
|
||||
// background; the RPC returns now with "stopping".
|
||||
self.spawn_transitional(Op::Stop, app_id.to_string())
|
||||
.await?;
|
||||
|
||||
Ok(serde_json::json!({ "status": "stopping" }))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_container_restart(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let app_id = params
|
||||
.get("app_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
|
||||
validate_app_id(app_id)?;
|
||||
|
||||
// Restart does not mark user-stopped (the user wants the app to
|
||||
// keep running). Clear the marker as a defensive measure in case a
|
||||
// prior stop left it set and the restart is intended to revive the
|
||||
// normal running state.
|
||||
crate::crash_recovery::clear_user_stopped(&self.config.data_dir, app_id).await;
|
||||
|
||||
self.spawn_transitional(Op::Restart, app_id.to_string())
|
||||
.await?;
|
||||
|
||||
Ok(serde_json::json!({ "status": "restarting" }))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_container_remove(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let orchestrator = self
|
||||
.orchestrator
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available"))?;
|
||||
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let app_id = params
|
||||
.get("app_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
|
||||
validate_app_id(app_id)?;
|
||||
let preserve_data = params
|
||||
.get("preserve_data")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
orchestrator
|
||||
.remove(app_id, preserve_data)
|
||||
.await
|
||||
.context("Failed to remove container")?;
|
||||
|
||||
Ok(serde_json::json!({ "status": "removed" }))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_container_list(&self) -> Result<serde_json::Value> {
|
||||
// Use the scanner's cached state for consistency with WebSocket updates.
|
||||
// This prevents the container-list RPC from returning different results
|
||||
// than the WebSocket-delivered package_data, which caused apps to flicker
|
||||
// between "installed" and "not-installed" in the UI.
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
// Apps the user explicitly stopped must read as "stopped" even though a
|
||||
// UI companion (electrs-ui, bitcoin-ui, …) keeps serving the launch port:
|
||||
// launch_port_reachable() below would otherwise upgrade an exited backend
|
||||
// back to "running". The reconcile guard keeps these backends down, so the
|
||||
// marker is authoritative here.
|
||||
let user_stopped = crate::crash_recovery::load_user_stopped(&self.config.data_dir).await;
|
||||
if data.server_info.status_info.containers_scanned && !data.package_data.is_empty() {
|
||||
let mut containers = Vec::with_capacity(data.package_data.len());
|
||||
for (id, pkg) in &data.package_data {
|
||||
// Keep this mapping in sync with the UI's
|
||||
// ContainerStatus.state union in
|
||||
// neode-ui/src/api/container-client.ts. The UI maps
|
||||
// transitional variants to single-button labels
|
||||
// (Stopping… / Starting… / Restarting…).
|
||||
let mut state = match &pkg.state {
|
||||
crate::data_model::PackageState::Running => "running".to_string(),
|
||||
crate::data_model::PackageState::Stopped => "stopped".to_string(),
|
||||
crate::data_model::PackageState::Exited => "exited".to_string(),
|
||||
crate::data_model::PackageState::Starting => "starting".to_string(),
|
||||
crate::data_model::PackageState::Stopping => "stopping".to_string(),
|
||||
crate::data_model::PackageState::Restarting => "restarting".to_string(),
|
||||
crate::data_model::PackageState::Installing => "installing".to_string(),
|
||||
crate::data_model::PackageState::Installed => "installed".to_string(),
|
||||
crate::data_model::PackageState::Updating => "updating".to_string(),
|
||||
crate::data_model::PackageState::Removing => "removing".to_string(),
|
||||
crate::data_model::PackageState::CreatingBackup => {
|
||||
"creating-backup".to_string()
|
||||
}
|
||||
crate::data_model::PackageState::RestoringBackup => {
|
||||
"restoring-backup".to_string()
|
||||
}
|
||||
crate::data_model::PackageState::BackingUp => "backing-up".to_string(),
|
||||
};
|
||||
|
||||
// Scanner backoff preserves cached package_data. Refresh stable
|
||||
// states so callers do not see stale `running`/`exited` after
|
||||
// health-monitor recovery or Quadlet --rm container removal.
|
||||
if user_stopped.contains(id) {
|
||||
// User stopped it → authoritative "stopped". Do NOT let a
|
||||
// still-running UI companion's launch port mark it running.
|
||||
state = "stopped".to_string();
|
||||
} else if state == "running" && requires_launch_port_for_health(id) {
|
||||
if !self.cached_reachable_health(id).await?.is_some() {
|
||||
state = live_state_for_app(id)
|
||||
.await
|
||||
.unwrap_or("starting".to_string());
|
||||
}
|
||||
} else if should_refresh_cached_state(&state) {
|
||||
if launch_port_reachable(id).await {
|
||||
state = "running".to_string();
|
||||
} else {
|
||||
if let Some(live) = live_state_for_app(id).await {
|
||||
state = live;
|
||||
} else if quadlet_service_active(id).await {
|
||||
state = "starting".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let lan = pkg
|
||||
.installed
|
||||
.as_ref()
|
||||
.and_then(|i| i.interface_addresses.get("main"))
|
||||
.and_then(|a| a.lan_address.as_deref());
|
||||
containers.push(serde_json::json!({
|
||||
"id": id,
|
||||
"name": id,
|
||||
"state": state,
|
||||
"image": "",
|
||||
"created": "",
|
||||
"ports": [],
|
||||
"lan_address": lan,
|
||||
}));
|
||||
}
|
||||
return Ok(serde_json::json!(containers));
|
||||
}
|
||||
|
||||
// Fallback: scanner hasn't run yet, query the orchestrator directly.
|
||||
if let Some(orchestrator) = &self.orchestrator {
|
||||
if let Ok(containers) = orchestrator.list().await {
|
||||
if !containers.is_empty() {
|
||||
return Ok(serde_json::to_value(containers)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let output = tokio::process::Command::new("podman")
|
||||
.args(["ps", "-a", "--format", "json"])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to list containers via podman")?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Ok(serde_json::json!([]));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
if stdout.trim().is_empty() {
|
||||
return Ok(serde_json::json!([]));
|
||||
}
|
||||
|
||||
let podman_containers: Vec<serde_json::Value> =
|
||||
serde_json::from_str(&stdout).unwrap_or_else(|_| Vec::new());
|
||||
|
||||
let containers: Vec<serde_json::Value> = podman_containers
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let state = c.get("State").and_then(|v| v.as_str()).unwrap_or("unknown");
|
||||
let mapped_state = match state.to_lowercase().as_str() {
|
||||
"running" => "running",
|
||||
"exited" => "exited",
|
||||
"stopped" => "stopped",
|
||||
"created" => "created",
|
||||
"paused" => "paused",
|
||||
_ => "unknown",
|
||||
};
|
||||
let name = c
|
||||
.get("Names")
|
||||
.and_then(|v| v.as_array())
|
||||
.and_then(|a| a.first())
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let ports: Vec<String> = c
|
||||
.get("Ports")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|p| {
|
||||
let host = p.get("host_port").and_then(|v| v.as_u64())?;
|
||||
let container = p.get("container_port").and_then(|v| v.as_u64())?;
|
||||
let proto =
|
||||
p.get("protocol").and_then(|v| v.as_str()).unwrap_or("tcp");
|
||||
Some(format!("0.0.0.0:{}->{}/{}", host, container, proto))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
serde_json::json!({
|
||||
"id": c.get("Id").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"name": name,
|
||||
"state": mapped_state,
|
||||
"image": c.get("Image").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"created": c.get("Created").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"ports": ports,
|
||||
"lan_address": serde_json::Value::Null,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(serde_json::json!(containers))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_container_status(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let orchestrator = self
|
||||
.orchestrator
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available"))?;
|
||||
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let app_id = params
|
||||
.get("app_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
|
||||
validate_app_id(app_id)?;
|
||||
|
||||
let mut last_err: Option<anyhow::Error> = None;
|
||||
for candidate in status_app_id_candidates(app_id) {
|
||||
match orchestrator.status(&candidate).await {
|
||||
Ok(status) => return Ok(serde_json::to_value(status)?),
|
||||
Err(e) => last_err = Some(e),
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for alias drift: query podman directly by likely container
|
||||
// names so status checks stay useful during migration.
|
||||
for name in status_container_name_candidates(app_id) {
|
||||
if let Some(v) = inspect_container_state_value(&name).await {
|
||||
return Ok(v);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(e) = last_err {
|
||||
return Err(e.context("Failed to get container status"));
|
||||
}
|
||||
Err(anyhow::anyhow!("Failed to get container status"))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_container_logs(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let orchestrator = self
|
||||
.orchestrator
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available"))?;
|
||||
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let app_id = params
|
||||
.get("app_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
|
||||
validate_app_id(app_id)?;
|
||||
let lines = params.get("lines").and_then(|v| v.as_u64()).unwrap_or(100) as u32;
|
||||
|
||||
let logs = orchestrator
|
||||
.logs(app_id, lines)
|
||||
.await
|
||||
.context("Failed to get container logs")?;
|
||||
|
||||
Ok(serde_json::to_value(logs)?)
|
||||
}
|
||||
|
||||
/// Used by HTTP GET /api/container/logs (same logic as container-logs RPC).
|
||||
pub async fn get_container_logs_value(
|
||||
&self,
|
||||
app_id: &str,
|
||||
lines: u32,
|
||||
) -> Result<serde_json::Value> {
|
||||
let orchestrator = self
|
||||
.orchestrator
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available"))?;
|
||||
|
||||
let logs = orchestrator
|
||||
.logs(app_id, lines)
|
||||
.await
|
||||
.context("Failed to get container logs")?;
|
||||
|
||||
Ok(serde_json::to_value(logs)?)
|
||||
}
|
||||
|
||||
pub(super) async fn handle_container_health(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let orchestrator = self
|
||||
.orchestrator
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available"))?;
|
||||
|
||||
// If app_id is provided, get health for that app.
|
||||
if let Some(params) = params {
|
||||
if let Some(app_id) = params.get("app_id").and_then(|v| v.as_str()) {
|
||||
if let Some(health) = self.cached_reachable_health(app_id).await? {
|
||||
return Ok(serde_json::json!({ app_id: health }));
|
||||
}
|
||||
|
||||
if let Some(health) = self.cached_state_health(app_id).await {
|
||||
return Ok(serde_json::json!({ app_id: health }));
|
||||
}
|
||||
|
||||
if requires_launch_port_for_health(app_id) {
|
||||
return Ok(serde_json::json!({ app_id: "starting" }));
|
||||
}
|
||||
|
||||
if let Some(health) = self.stack_health(app_id).await? {
|
||||
return Ok(serde_json::json!({ app_id: health }));
|
||||
}
|
||||
|
||||
let mut last_err: Option<anyhow::Error> = None;
|
||||
for candidate in status_app_id_candidates(app_id) {
|
||||
match tokio::time::timeout(
|
||||
ORCHESTRATOR_HEALTH_TIMEOUT,
|
||||
orchestrator.health(&candidate),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(health)) => return Ok(serde_json::json!({ app_id: health })),
|
||||
Ok(Err(e)) => last_err = Some(e),
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
for name in status_container_name_candidates(app_id) {
|
||||
if let Some(health) = inspect_container_health_value(&name).await {
|
||||
return Ok(serde_json::json!({ app_id: health }));
|
||||
}
|
||||
}
|
||||
if let Some(e) = last_err {
|
||||
return Err(e.context("Failed to get container health"));
|
||||
}
|
||||
return Err(anyhow::anyhow!("Failed to get container health"));
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, get health for all containers.
|
||||
let containers = orchestrator
|
||||
.list()
|
||||
.await
|
||||
.context("Failed to list containers")?;
|
||||
|
||||
let mut health_map = serde_json::Map::new();
|
||||
for container in containers {
|
||||
// Map the runtime container name back to the app_id the orchestrator
|
||||
// knows about. Dev orchestrator uses `archipelago-<id>-dev`; Prod
|
||||
// uses bare `<id>` (or `archy-<id>` for UIs — health() accepts the
|
||||
// app_id either way since UI_APP_IDS is centralised).
|
||||
let app_id_candidate = container
|
||||
.name
|
||||
.strip_prefix("archipelago-")
|
||||
.and_then(|s| s.strip_suffix("-dev"))
|
||||
.or_else(|| container.name.strip_prefix("archy-"))
|
||||
.unwrap_or(container.name.as_str());
|
||||
match tokio::time::timeout(
|
||||
ORCHESTRATOR_HEALTH_TIMEOUT,
|
||||
orchestrator.health(app_id_candidate),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(health)) => {
|
||||
health_map.insert(
|
||||
app_id_candidate.to_string(),
|
||||
serde_json::Value::String(health),
|
||||
);
|
||||
}
|
||||
Ok(Err(_)) | Err(_) => {
|
||||
health_map.insert(
|
||||
app_id_candidate.to_string(),
|
||||
serde_json::Value::String("unknown".to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::Value::Object(health_map))
|
||||
}
|
||||
|
||||
async fn cached_state_health(&self, app_id: &str) -> Option<&'static str> {
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let Some(pkg) = data.package_data.get(app_id) else {
|
||||
if data.server_info.status_info.containers_scanned {
|
||||
return Some("stopped");
|
||||
}
|
||||
return None;
|
||||
};
|
||||
match pkg.state {
|
||||
crate::data_model::PackageState::Running => None,
|
||||
crate::data_model::PackageState::Installing
|
||||
| crate::data_model::PackageState::Installed
|
||||
| crate::data_model::PackageState::Starting => Some("starting"),
|
||||
crate::data_model::PackageState::Stopping
|
||||
| crate::data_model::PackageState::Stopped
|
||||
| crate::data_model::PackageState::Exited => Some("stopped"),
|
||||
crate::data_model::PackageState::Removing => Some("removing"),
|
||||
crate::data_model::PackageState::Restarting
|
||||
| crate::data_model::PackageState::Updating
|
||||
| crate::data_model::PackageState::CreatingBackup
|
||||
| crate::data_model::PackageState::RestoringBackup
|
||||
| crate::data_model::PackageState::BackingUp => Some("starting"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn cached_reachable_health(&self, app_id: &str) -> Result<Option<String>> {
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let pkg = data.package_data.get(app_id);
|
||||
if matches!(
|
||||
pkg.map(|pkg| &pkg.state),
|
||||
Some(crate::data_model::PackageState::Removing)
|
||||
) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let url = pkg
|
||||
.and_then(|pkg| pkg.installed.as_ref())
|
||||
.and_then(|i| i.interface_addresses.get("main"))
|
||||
.and_then(|a| a.lan_address.as_deref())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| health_probe_url_for_app(app_id));
|
||||
|
||||
let Some(url) = url else {
|
||||
return Ok(None);
|
||||
};
|
||||
if url.starts_with("http://") || url.starts_with("https://") {
|
||||
return Ok(http_launch_url_reachable(&url)
|
||||
.await
|
||||
.then(|| "healthy".to_string()));
|
||||
}
|
||||
|
||||
let Some(port) = port_from_url(&url) else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(launch_port_reachable_by_port(port)
|
||||
.await
|
||||
.then(|| "healthy".to_string()))
|
||||
}
|
||||
|
||||
async fn stack_health(&self, app_id: &str) -> Result<Option<String>> {
|
||||
let Some(members) = stack_health_members(app_id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let orchestrator = self
|
||||
.orchestrator
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available"))?;
|
||||
|
||||
let mut saw_starting = false;
|
||||
let mut saw_unknown = false;
|
||||
for member in members {
|
||||
match member_health(orchestrator.as_ref(), member)
|
||||
.await
|
||||
.as_deref()
|
||||
{
|
||||
Ok(health) if health == "healthy" => {}
|
||||
Ok(health) if health == "starting" => saw_starting = true,
|
||||
Ok(health) if health == "unknown" => saw_unknown = true,
|
||||
Ok(_) => return Ok(Some("unhealthy".to_string())),
|
||||
Err(_) => saw_unknown = true,
|
||||
}
|
||||
}
|
||||
|
||||
if saw_unknown {
|
||||
if let Some(health) = self.cached_reachable_health(app_id).await? {
|
||||
return Ok(Some(health));
|
||||
}
|
||||
Ok(Some("unknown".to_string()))
|
||||
} else if saw_starting {
|
||||
if let Some(health) = self.cached_reachable_health(app_id).await? {
|
||||
return Ok(Some(health));
|
||||
}
|
||||
Ok(Some("starting".to_string()))
|
||||
} else {
|
||||
Ok(Some("healthy".to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn member_health(
|
||||
orchestrator: &dyn crate::container::traits::ContainerOrchestrator,
|
||||
app_id: &str,
|
||||
) -> Result<String> {
|
||||
if let Ok(Ok(health)) =
|
||||
tokio::time::timeout(ORCHESTRATOR_HEALTH_TIMEOUT, orchestrator.health(app_id)).await
|
||||
{
|
||||
return Ok(health);
|
||||
}
|
||||
for name in status_container_name_candidates(app_id) {
|
||||
if let Some(health) = inspect_container_health_value(&name).await {
|
||||
return Ok(health);
|
||||
}
|
||||
}
|
||||
Ok("unknown".to_string())
|
||||
}
|
||||
|
||||
fn stack_health_members(app_id: &str) -> Option<&'static [&'static str]> {
|
||||
match app_id {
|
||||
"mempool" | "mempool-web" => {
|
||||
Some(&["archy-mempool-db", "mempool-api", "archy-mempool-web"])
|
||||
}
|
||||
"btcpay-server" | "btcpayserver" | "btcpay" => {
|
||||
Some(&["archy-btcpay-db", "archy-nbxplorer", "btcpay-server"])
|
||||
}
|
||||
"immich" => Some(&["immich_postgres", "immich_redis", "immich_server"]),
|
||||
"indeedhub" => Some(&[
|
||||
"indeedhub-postgres",
|
||||
"indeedhub-redis",
|
||||
"indeedhub-minio",
|
||||
"indeedhub-relay",
|
||||
"indeedhub-api",
|
||||
"indeedhub",
|
||||
]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn status_app_id_candidates(app_id: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut push = |s: &str| {
|
||||
if !out.iter().any(|e: &String| e == s) {
|
||||
out.push(s.to_string());
|
||||
}
|
||||
};
|
||||
|
||||
match app_id {
|
||||
"bitcoin-knots" => {
|
||||
push("bitcoin-knots");
|
||||
push("bitcoin-core");
|
||||
push("bitcoin");
|
||||
}
|
||||
"bitcoin-core" | "bitcoin" => {
|
||||
push("bitcoin-core");
|
||||
push("bitcoin-knots");
|
||||
push("bitcoin");
|
||||
}
|
||||
"electrs" | "mempool-electrs" => {
|
||||
push("electrs");
|
||||
push("mempool-electrs");
|
||||
push("electrumx");
|
||||
}
|
||||
"mempool" | "mempool-web" => {
|
||||
push("mempool");
|
||||
push("archy-mempool-web");
|
||||
}
|
||||
"immich" => {
|
||||
push("immich");
|
||||
push("immich_server");
|
||||
}
|
||||
_ => push(app_id),
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn status_container_name_candidates(app_id: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut push = |s: &str| {
|
||||
if !out.iter().any(|e: &String| e == s) {
|
||||
out.push(s.to_string());
|
||||
}
|
||||
};
|
||||
|
||||
match app_id {
|
||||
"bitcoin-knots" | "bitcoin-core" | "bitcoin" => push("bitcoin-knots"),
|
||||
"bitcoin-ui" => push("archy-bitcoin-ui"),
|
||||
"lnd-ui" => push("archy-lnd-ui"),
|
||||
"electrs-ui" => push("archy-electrs-ui"),
|
||||
"electrs" | "mempool-electrs" => push("electrumx"),
|
||||
"mempool" | "mempool-web" | "archy-mempool-web" => push("mempool"),
|
||||
"immich" => push("immich_server"),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
push(app_id);
|
||||
if let Some(stripped) = app_id.strip_prefix("archy-") {
|
||||
push(stripped);
|
||||
} else {
|
||||
push(&format!("archy-{}", app_id));
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn should_refresh_cached_state(state: &str) -> bool {
|
||||
matches!(state, "exited" | "stopped" | "stopping")
|
||||
}
|
||||
|
||||
async fn live_state_for_app(app_id: &str) -> Option<String> {
|
||||
for name in status_container_name_candidates(app_id) {
|
||||
if let Some(live) = inspect_container_state_value(&name).await {
|
||||
if let Some(live_state) = live.get("state").and_then(|v| v.as_str()) {
|
||||
return Some(live_state.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
async fn quadlet_service_active(app_id: &str) -> bool {
|
||||
for name in status_container_name_candidates(app_id) {
|
||||
let service = format!("{name}.service");
|
||||
let mut cmd = tokio::process::Command::new("systemctl");
|
||||
cmd.args(["--user", "is-active", "--quiet", &service]);
|
||||
cmd.kill_on_drop(true);
|
||||
if matches!(
|
||||
tokio::time::timeout(Duration::from_secs(2), cmd.status()).await,
|
||||
Ok(Ok(status)) if status.success()
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn health_probe_url_for_app(app_id: &str) -> Option<String> {
|
||||
let port = match app_id {
|
||||
"bitcoin-ui" => 8334,
|
||||
"botfights" => 9100,
|
||||
"btcpay-server" | "btcpay" | "btcpayserver" => 23000,
|
||||
"electrumx" | "electrs" | "mempool-electrs" | "electrs-ui" => 50002,
|
||||
"fedimint" | "fedimintd" => 8175,
|
||||
"filebrowser" => 8083,
|
||||
"gitea" => 3001,
|
||||
"grafana" => 3000,
|
||||
"homeassistant" | "home-assistant" => 8123,
|
||||
"immich" | "immich_server" => 2283,
|
||||
"indeedhub" => 7778,
|
||||
"jellyfin" => 8096,
|
||||
"lnd" | "lnd-ui" => 18083,
|
||||
"mempool" | "mempool-web" => 4080,
|
||||
"nginx-proxy-manager" => 8081,
|
||||
"ollama" => 11434,
|
||||
"photoprism" => 2342,
|
||||
"portainer" => 9000,
|
||||
"searxng" => 8888,
|
||||
"tailscale" => 8240,
|
||||
"uptime-kuma" => 3002,
|
||||
"vaultwarden" => 8082,
|
||||
_ => return None,
|
||||
};
|
||||
Some(format!("http://localhost:{port}"))
|
||||
}
|
||||
|
||||
fn requires_launch_port_for_health(app_id: &str) -> bool {
|
||||
matches!(app_id, "fedimint" | "fedimintd" | "fedimint-gateway")
|
||||
}
|
||||
|
||||
async fn launch_port_reachable(app_id: &str) -> bool {
|
||||
let Some(port) = health_probe_url_for_app(app_id).and_then(|url| port_from_url(&url)) else {
|
||||
return false;
|
||||
};
|
||||
launch_port_reachable_by_port(port).await
|
||||
}
|
||||
|
||||
async fn launch_port_reachable_by_port(port: u16) -> bool {
|
||||
matches!(
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
tokio::net::TcpStream::connect(("127.0.0.1", port)),
|
||||
)
|
||||
.await,
|
||||
Ok(Ok(_))
|
||||
)
|
||||
}
|
||||
|
||||
async fn http_launch_url_reachable(url: &str) -> bool {
|
||||
let Ok(client) = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(2))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
match client.get(url).send().await {
|
||||
Ok(response) => {
|
||||
let status = response.status();
|
||||
status.is_success() || status.is_redirection()
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::api::rpc) fn port_from_url(url: &str) -> Option<u16> {
|
||||
let after_colon = url.rsplit_once(':')?.1;
|
||||
let port = after_colon
|
||||
.chars()
|
||||
.take_while(|c| c.is_ascii_digit())
|
||||
.collect::<String>();
|
||||
port.parse::<u16>().ok()
|
||||
}
|
||||
|
||||
async fn inspect_container_state_value(name: &str) -> Option<serde_json::Value> {
|
||||
if let Some(v) = ps_container_state_value(name).await {
|
||||
return Some(v);
|
||||
}
|
||||
|
||||
let mut cmd = tokio::process::Command::new("podman");
|
||||
cmd.args([
|
||||
"inspect",
|
||||
name,
|
||||
"--format",
|
||||
"{{.State.Status}} {{.State.Running}} {{if .State.Healthcheck}}{{.State.Healthcheck.Status}}{{else}}none{{end}}",
|
||||
]);
|
||||
cmd.kill_on_drop(true);
|
||||
let out = tokio::time::timeout(PODMAN_INSPECT_TIMEOUT, cmd.output())
|
||||
.await
|
||||
.ok()?
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let line = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
if line.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut parts = line.split_whitespace();
|
||||
let status = parts.next().unwrap_or("unknown");
|
||||
let running = parts.next().unwrap_or("false") == "true";
|
||||
let health = parts.next().unwrap_or("none");
|
||||
Some(serde_json::json!({
|
||||
"name": name,
|
||||
"status": status,
|
||||
"state": status,
|
||||
"running": running,
|
||||
"health": health,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn ps_container_state_value(name: &str) -> Option<serde_json::Value> {
|
||||
let mut cmd = tokio::process::Command::new("podman");
|
||||
cmd.args([
|
||||
"ps",
|
||||
"-a",
|
||||
"--filter",
|
||||
&format!("name={name}"),
|
||||
"--format",
|
||||
"{{.Names}}|{{.Status}}",
|
||||
]);
|
||||
cmd.kill_on_drop(true);
|
||||
let out = tokio::time::timeout(PODMAN_PS_TIMEOUT, cmd.output())
|
||||
.await
|
||||
.ok()?
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
for line in stdout.lines() {
|
||||
let mut parts = line.splitn(2, '|');
|
||||
let container_name = parts.next().unwrap_or_default();
|
||||
if container_name != name {
|
||||
continue;
|
||||
}
|
||||
let status = parts.next().unwrap_or_default();
|
||||
let state = state_from_podman_status(status);
|
||||
let health = parse_health_from_status(status).unwrap_or("none");
|
||||
return Some(serde_json::json!({
|
||||
"name": name,
|
||||
"status": state,
|
||||
"state": state,
|
||||
"running": state.eq_ignore_ascii_case("running"),
|
||||
"health": health,
|
||||
}));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn state_from_podman_status(status: &str) -> &str {
|
||||
if status.starts_with("Up ") {
|
||||
"running"
|
||||
} else if status.starts_with("Exited ") {
|
||||
"exited"
|
||||
} else if status.starts_with("Created") {
|
||||
"created"
|
||||
} else if status.starts_with("Stopping") {
|
||||
"stopping"
|
||||
} else if status.starts_with("Removing") {
|
||||
"removing"
|
||||
} else {
|
||||
"unknown"
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_health_from_status(status: &str) -> Option<&str> {
|
||||
let start = status.rfind('(')?;
|
||||
let end = status.rfind(')')?;
|
||||
(start < end).then(|| &status[start + 1..end])
|
||||
}
|
||||
|
||||
async fn inspect_container_health_value(name: &str) -> Option<String> {
|
||||
let v = inspect_container_state_value(name).await?;
|
||||
if let Some(health) = v.get("health").and_then(|s| s.as_str()) {
|
||||
if health != "none" {
|
||||
return Some(health.to_string());
|
||||
}
|
||||
}
|
||||
match v.get("state").and_then(|s| s.as_str()).unwrap_or("unknown") {
|
||||
"running" => Some("healthy".to_string()),
|
||||
"created" => Some("starting".to_string()),
|
||||
"paused" => Some("paused".to_string()),
|
||||
"stopping" => Some("unhealthy".to_string()),
|
||||
"exited" | "stopped" => Some("unhealthy".to_string()),
|
||||
other => Some(format!("unknown:{other}")),
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,263 @@
|
||||
use super::RpcHandler;
|
||||
use crate::credentials;
|
||||
use crate::identity_manager::IdentityManager;
|
||||
use anyhow::Result;
|
||||
|
||||
impl RpcHandler {
|
||||
/// Issue a Verifiable Credential from one of the user's identities.
|
||||
pub(super) async fn handle_identity_issue_credential(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let issuer_id = params
|
||||
.get("issuer_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing issuer_id"))?;
|
||||
let subject_did = params
|
||||
.get("subject_did")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing subject_did"))?;
|
||||
let credential_type = params
|
||||
.get("type")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("VerifiableCredential");
|
||||
let claims = params
|
||||
.get("claims")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::json!({}));
|
||||
let expires_at = params.get("expires_at").and_then(|v| v.as_str());
|
||||
|
||||
let prefer_dht = params
|
||||
.get("prefer_dht_did")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||
let issuer_record = manager.get(issuer_id).await?;
|
||||
// Use did:dht if available and preferred, otherwise did:key
|
||||
let issuer_did = if prefer_dht {
|
||||
issuer_record
|
||||
.dht_did
|
||||
.as_deref()
|
||||
.unwrap_or(&issuer_record.did)
|
||||
.to_string()
|
||||
} else {
|
||||
issuer_record.did.clone()
|
||||
};
|
||||
|
||||
// Capture identity_id for the signing closure
|
||||
let data_dir = self.config.data_dir.clone();
|
||||
let sign_id = issuer_id.to_string();
|
||||
|
||||
let vc = credentials::issue_credential(
|
||||
&self.config.data_dir,
|
||||
&issuer_did,
|
||||
subject_did,
|
||||
credential_type,
|
||||
claims,
|
||||
expires_at,
|
||||
|bytes| {
|
||||
// Use block_in_place to avoid deadlocking the tokio runtime
|
||||
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
|
||||
})
|
||||
})
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let status = if credentials::is_revoked(&vc) {
|
||||
"revoked"
|
||||
} else {
|
||||
"active"
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"id": vc.id,
|
||||
"issuer": vc.issuer,
|
||||
"subject": vc.credential_subject.id,
|
||||
"type": vc.credential_type,
|
||||
"issued_at": vc.issuance_date,
|
||||
"status": status,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Verify a credential by its ID.
|
||||
pub(super) async fn handle_identity_verify_credential(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let credential_id = params
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing id"))?;
|
||||
|
||||
let store = credentials::load_credentials(&self.config.data_dir).await?;
|
||||
let vc = store
|
||||
.credentials
|
||||
.iter()
|
||||
.find(|c| c.id == credential_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Credential not found"))?;
|
||||
|
||||
let data_dir = self.config.data_dir.clone();
|
||||
let valid = credentials::verify_credential(vc, |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
|
||||
})
|
||||
})
|
||||
})?;
|
||||
|
||||
let status = if credentials::is_revoked(vc) {
|
||||
"revoked"
|
||||
} else {
|
||||
"active"
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"id": vc.id,
|
||||
"valid": valid,
|
||||
"status": status,
|
||||
}))
|
||||
}
|
||||
|
||||
/// List all credentials, optionally filtered by DID.
|
||||
pub(super) async fn handle_identity_list_credentials(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let filter_did = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("did"))
|
||||
.and_then(|v| v.as_str());
|
||||
|
||||
let creds = credentials::list_credentials(&self.config.data_dir, filter_did).await?;
|
||||
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,
|
||||
"type": c.credential_type,
|
||||
"issuer": c.issuer,
|
||||
"credentialSubject": c.credential_subject,
|
||||
"issuanceDate": c.issuance_date,
|
||||
"expirationDate": c.expiration_date,
|
||||
"proof": c.proof,
|
||||
"status": status,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(serde_json::json!({ "credentials": items }))
|
||||
}
|
||||
|
||||
/// Revoke a credential.
|
||||
pub(super) async fn handle_identity_revoke_credential(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let id = params
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing id"))?;
|
||||
|
||||
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,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,634 @@
|
||||
use super::RpcHandler;
|
||||
use anyhow::Result;
|
||||
use std::sync::Arc;
|
||||
|
||||
impl RpcHandler {
|
||||
/// Route an RPC method name to its handler, returning the result value.
|
||||
pub(super) async fn dispatch(
|
||||
self: &Arc<Self>,
|
||||
method: &str,
|
||||
params: Option<serde_json::Value>,
|
||||
session_token: &Option<String>,
|
||||
) -> Result<serde_json::Value> {
|
||||
match method {
|
||||
"echo" => self.handle_echo(params).await,
|
||||
"server.echo" => self.handle_echo(params).await,
|
||||
"server.get-state" => self.handle_server_get_state().await,
|
||||
"health" => self.handle_health().await,
|
||||
"auth.login" => self.handle_auth_login(params).await,
|
||||
"auth.logout" => self.handle_auth_logout().await,
|
||||
"auth.changePassword" => {
|
||||
self.handle_auth_change_password(params, session_token)
|
||||
.await
|
||||
}
|
||||
"auth.isSetup" => self.handle_auth_is_setup().await,
|
||||
"auth.setup" => self.handle_auth_setup(params).await,
|
||||
"auth.onboardingComplete" => self.handle_auth_onboarding_complete().await,
|
||||
"auth.isOnboardingComplete" => self.handle_auth_is_onboarding_complete().await,
|
||||
"auth.resetOnboarding" => self.handle_auth_reset_onboarding(params).await,
|
||||
"auth.createDeviceToken" => self.handle_auth_create_device_token(params).await,
|
||||
"auth.listDeviceTokens" => self.handle_auth_list_device_tokens().await,
|
||||
"auth.revokeDeviceToken" => self.handle_auth_revoke_device_token(params).await,
|
||||
|
||||
// Seed management (BIP-39 mnemonic)
|
||||
"seed.generate" => self.handle_seed_generate().await,
|
||||
"seed.verify" => self.handle_seed_verify(params).await,
|
||||
"seed.restore" => self.handle_seed_restore(params).await,
|
||||
"seed.save-encrypted" => self.handle_seed_save_encrypted(params).await,
|
||||
"seed.status" => self.handle_seed_status().await,
|
||||
"seed.reveal" => self.handle_seed_reveal(params).await,
|
||||
|
||||
// Container orchestration (for Archipelago-managed containers)
|
||||
"container-install" => self.handle_container_install(params).await,
|
||||
"container-start" => self.handle_container_start(params).await,
|
||||
"container-stop" => self.handle_container_stop(params).await,
|
||||
"container-restart" => self.handle_container_restart(params).await,
|
||||
"container-remove" => self.handle_container_remove(params).await,
|
||||
"container-list" => self.handle_container_list().await,
|
||||
"container-status" => self.handle_container_status(params).await,
|
||||
"container-logs" => self.handle_container_logs(params).await,
|
||||
"container-health" => self.handle_container_health(params).await,
|
||||
|
||||
// Package management (for docker-compose apps).
|
||||
// install/uninstall/update return immediately with a
|
||||
// transitional status; the actual work runs in a background
|
||||
// tokio::spawn so the HTTP request doesn't block for minutes.
|
||||
"package.install" => self.clone().spawn_package_install(params).await,
|
||||
"package.start" => self.handle_package_start(params).await,
|
||||
"package.stop" => self.handle_package_stop(params).await,
|
||||
"package.restart" => self.handle_package_restart(params).await,
|
||||
"package.uninstall" => self.clone().spawn_package_uninstall(params).await,
|
||||
"package.update" => self.clone().spawn_package_update(params).await,
|
||||
"package.check-updates" => self.handle_package_check_updates(params).await,
|
||||
"package.versions" => self.handle_package_versions(params).await,
|
||||
"package.set-config" => self.clone().handle_package_set_config(params).await,
|
||||
"package.credentials" => self.handle_package_credentials(params).await,
|
||||
"app.filebrowser-token" => self.handle_filebrowser_token().await,
|
||||
|
||||
// Bundled app management (for pre-loaded container images)
|
||||
"bundled-app-start" => self.handle_bundled_app_start(params).await,
|
||||
"bundled-app-stop" => self.handle_bundled_app_stop(params).await,
|
||||
|
||||
// Node identity and P2P peers
|
||||
"node-add-peer" => self.handle_node_add_peer(params).await,
|
||||
"node-list-peers" => self.handle_node_list_peers().await,
|
||||
"node-remove-peer" => self.handle_node_remove_peer(params).await,
|
||||
"node-send-message" => self.handle_node_send_message(params).await,
|
||||
"node-check-peer" => self.handle_node_check_peer(params).await,
|
||||
"node-messages-received" => self.handle_node_messages_received().await,
|
||||
"node-store-sent" => self.handle_node_store_sent(params).await,
|
||||
"node-nostr-discover" => self.handle_node_nostr_discover().await,
|
||||
"node.did" => self.handle_node_did().await,
|
||||
"node.signChallenge" => self.handle_node_sign_challenge(params).await,
|
||||
"node.createBackup" => self.handle_node_create_backup(params).await,
|
||||
"node.tor-address" => self.handle_node_tor_address().await,
|
||||
"node.nostr-publish" => self.handle_node_nostr_publish().await,
|
||||
"node.nostr-pubkey" => self.handle_node_nostr_pubkey().await,
|
||||
"node.nostr-sign" => self.handle_node_nostr_sign(params).await,
|
||||
"node-nostr-verify-revoked" => self.handle_node_nostr_verify_revoked().await,
|
||||
"node.rotate-did" => self.handle_node_rotate_did(params).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,
|
||||
"nostr.discovery-status" => self.handle_nostr_discovery_status().await,
|
||||
"nostr.set-discovery" => self.handle_nostr_set_discovery(params).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,
|
||||
"auth.totp.disable" => self.handle_totp_disable(params).await,
|
||||
"auth.totp.status" => self.handle_totp_status().await,
|
||||
"auth.login.totp" => self.handle_login_totp(params, session_token).await,
|
||||
"auth.login.backup" => self.handle_login_backup(params, session_token).await,
|
||||
|
||||
// Bitcoin & Lightning deep data
|
||||
"bitcoin.getinfo" => self.handle_bitcoin_getinfo().await,
|
||||
"bitcoin.relay-status" => self.handle_bitcoin_relay_status().await,
|
||||
"bitcoin.relay-update-settings" => {
|
||||
self.handle_bitcoin_relay_update_settings(params).await
|
||||
}
|
||||
"bitcoin.relay-request-peer" => self.handle_bitcoin_relay_request_peer(params).await,
|
||||
"bitcoin.relay-approve-request" => {
|
||||
self.handle_bitcoin_relay_approve_request(params).await
|
||||
}
|
||||
"bitcoin.relay-reject-request" => {
|
||||
self.handle_bitcoin_relay_reject_request(params).await
|
||||
}
|
||||
"bitcoin.relay-create-tor-service" => {
|
||||
self.handle_bitcoin_relay_create_tor_service().await
|
||||
}
|
||||
"bitcoin.init-wallet-from-seed" => {
|
||||
self.handle_bitcoin_init_wallet_from_seed(params).await
|
||||
}
|
||||
"lnd.getinfo" => self.handle_lnd_getinfo().await,
|
||||
"lnd.listchannels" => self.handle_lnd_listchannels().await,
|
||||
"lnd.closedchannels" => self.handle_lnd_closedchannels().await,
|
||||
"lnd.openchannel" => self.handle_lnd_openchannel(params).await,
|
||||
"lnd.closechannel" => self.handle_lnd_closechannel(params).await,
|
||||
"lnd.newaddress" => self.handle_lnd_newaddress().await,
|
||||
"lnd.sendcoins" => self.handle_lnd_sendcoins(params).await,
|
||||
"lnd.estimatefee" => self.handle_lnd_estimatefee(params).await,
|
||||
"lnd.createinvoice" => self.handle_lnd_createinvoice(params).await,
|
||||
"lnd.payinvoice" => self.handle_lnd_payinvoice(params).await,
|
||||
"lnd.paymentstatus" => self.handle_lnd_paymentstatus(params).await,
|
||||
"lnd.create-psbt" => self.handle_lnd_create_psbt(params).await,
|
||||
"lnd.finalize-psbt" => self.handle_lnd_finalize_psbt(params).await,
|
||||
"lnd.create-raw-tx" => self.handle_lnd_create_raw_tx(params).await,
|
||||
"lnd.gettransactions" => self.handle_lnd_gettransactions().await,
|
||||
"lnd.lightning-history" => self.handle_lnd_lightning_history().await,
|
||||
"lnd.connect-info" => self.handle_lnd_connect_info().await,
|
||||
"lnd.export-channel-backup" => self.handle_lnd_export_channel_backup().await,
|
||||
"lnd.init-wallet-from-seed" => self.handle_lnd_init_wallet_from_seed(params).await,
|
||||
"lnd.seed-backup-status" => self.handle_lnd_seed_backup_status().await,
|
||||
"lnd.seed-reveal" => self.handle_lnd_seed_reveal(params).await,
|
||||
"lnd.seed-backup-ack" => self.handle_lnd_seed_backup_ack().await,
|
||||
|
||||
// Multi-identity management
|
||||
"identity.list" => self.handle_identity_list(params).await,
|
||||
"identity.create" => self.handle_identity_create(params).await,
|
||||
"identity.get" => self.handle_identity_get(params).await,
|
||||
"identity.delete" => self.handle_identity_delete(params).await,
|
||||
"identity.set-default" => self.handle_identity_set_default(params).await,
|
||||
"identity.sign" => self.handle_identity_sign(params).await,
|
||||
"identity.verify" => self.handle_identity_verify(params).await,
|
||||
"identity.resolve-did" => self.handle_identity_resolve_did(params).await,
|
||||
"identity.resolve-remote-did" => self.handle_identity_resolve_remote_did(params).await,
|
||||
"identity.verify-did-document" => {
|
||||
self.handle_identity_verify_did_document(params).await
|
||||
}
|
||||
"identity.create-dht-did" => self.handle_identity_create_dht_did(params).await,
|
||||
"identity.resolve-dht-did" => self.handle_identity_resolve_dht_did(params).await,
|
||||
"identity.refresh-dht-did" => self.handle_identity_refresh_dht_did(params).await,
|
||||
"identity.dht-status" => self.handle_identity_dht_status(params).await,
|
||||
"identity.update-profile" => self.handle_identity_update_profile(params).await,
|
||||
"identity.publish-profile" => self.handle_identity_publish_profile(params).await,
|
||||
"identity.export-keys" => self.handle_identity_export_keys(params).await,
|
||||
"identity.create-nostr-key" => self.handle_identity_create_nostr_key(params).await,
|
||||
"identity.nostr-sign" => self.handle_identity_nostr_sign(params).await,
|
||||
"identity.nostr-encrypt-nip04" => {
|
||||
self.handle_identity_nostr_encrypt_nip04(params).await
|
||||
}
|
||||
"identity.nostr-decrypt-nip04" => {
|
||||
self.handle_identity_nostr_decrypt_nip04(params).await
|
||||
}
|
||||
"identity.nostr-encrypt-nip44" => {
|
||||
self.handle_identity_nostr_encrypt_nip44(params).await
|
||||
}
|
||||
"identity.nostr-decrypt-nip44" => {
|
||||
self.handle_identity_nostr_decrypt_nip44(params).await
|
||||
}
|
||||
|
||||
// Bitcoin domain names (NIP-05)
|
||||
"identity.register-name" => self.handle_identity_register_name(params).await,
|
||||
"identity.remove-name" => self.handle_identity_remove_name(params).await,
|
||||
"identity.resolve-name" => self.handle_identity_resolve_name(params).await,
|
||||
"identity.list-names" => self.handle_identity_list_names(params).await,
|
||||
"identity.link-name" => self.handle_identity_link_name(params).await,
|
||||
|
||||
// Verifiable Credentials
|
||||
"identity.issue-credential" => self.handle_identity_issue_credential(params).await,
|
||||
"identity.verify-credential" => self.handle_identity_verify_credential(params).await,
|
||||
"identity.list-credentials" => self.handle_identity_list_credentials(params).await,
|
||||
"identity.revoke-credential" => self.handle_identity_revoke_credential(params).await,
|
||||
"identity.create-presentation" => {
|
||||
self.handle_identity_create_presentation(params).await
|
||||
}
|
||||
"identity.verify-presentation" => {
|
||||
self.handle_identity_verify_presentation(params).await
|
||||
}
|
||||
|
||||
// Network overlay
|
||||
"network.get-visibility" => self.handle_network_get_visibility().await,
|
||||
"network.set-visibility" => self.handle_network_set_visibility(params).await,
|
||||
"network.request-connection" => self.handle_network_request_connection(params).await,
|
||||
"network.list-requests" => self.handle_network_list_requests().await,
|
||||
"network.accept-request" => self.handle_network_accept_request(params).await,
|
||||
"network.reject-request" => self.handle_network_reject_request(params).await,
|
||||
|
||||
// Tor hidden services
|
||||
"tor.list-services" => self.handle_tor_list_services().await,
|
||||
"tor.create-service" => self.handle_tor_create_service(params).await,
|
||||
"tor.delete-service" => self.handle_tor_delete_service(params).await,
|
||||
"tor.get-onion-address" => self.handle_tor_get_onion_address(params).await,
|
||||
"tor.rotate-service" => self.handle_tor_rotate_service(params).await,
|
||||
"tor.cleanup-rotated" => self.handle_tor_cleanup_rotated().await,
|
||||
"tor.toggle-app" => self.handle_tor_toggle_app(params).await,
|
||||
"tor.restart" => self.handle_tor_restart().await,
|
||||
|
||||
// Nostr relay management
|
||||
"nostr.list-relays" => self.handle_nostr_list_relays().await,
|
||||
"nostr.add-relay" => self.handle_nostr_add_relay(params).await,
|
||||
"nostr.remove-relay" => self.handle_nostr_remove_relay(params).await,
|
||||
"nostr.toggle-relay" => self.handle_nostr_toggle_relay(params).await,
|
||||
"nostr.get-stats" => self.handle_nostr_get_stats().await,
|
||||
|
||||
// Router / UPnP
|
||||
"router.discover" => self.handle_router_discover().await,
|
||||
"router.list-forwards" => self.handle_router_list_forwards().await,
|
||||
"router.add-forward" => self.handle_router_add_forward(params).await,
|
||||
"router.remove-forward" => self.handle_router_remove_forward(params).await,
|
||||
"network.diagnostics" => self.handle_network_diagnostics().await,
|
||||
"network.list-interfaces" => self.handle_network_list_interfaces().await,
|
||||
"network.scan-wifi" => self.handle_network_scan_wifi().await,
|
||||
"network.configure-wifi" => self.handle_network_configure_wifi(params).await,
|
||||
"network.set-wifi-radio" => self.handle_network_set_wifi_radio(params).await,
|
||||
"network.configure-ethernet" => self.handle_network_configure_ethernet(params).await,
|
||||
"network.dns-status" => self.handle_network_dns_status().await,
|
||||
"network.configure-dns" => self.handle_network_configure_dns(params).await,
|
||||
"router.detect" => self.handle_router_detect(params).await,
|
||||
"router.info" => self.handle_router_info().await,
|
||||
"router.configure" => self.handle_router_configure(params).await,
|
||||
|
||||
// OpenWrt / TollGate
|
||||
"openwrt.scan" => self.handle_openwrt_scan(params).await,
|
||||
"openwrt.get-status" => self.handle_openwrt_get_status(params).await,
|
||||
"openwrt.provision-tollgate" => self.handle_openwrt_provision_tollgate(params).await,
|
||||
"openwrt.scan-wifi" => self.handle_openwrt_scan_wifi(params).await,
|
||||
"openwrt.configure-wan" => self.handle_openwrt_configure_wan(params).await,
|
||||
|
||||
// Ecash wallet
|
||||
"wallet.ecash-balance" => self.handle_wallet_ecash_balance().await,
|
||||
"wallet.ecash-mint" => self.handle_wallet_ecash_mint(params).await,
|
||||
"wallet.ecash-mint-claim" => self.handle_wallet_ecash_mint_claim(params).await,
|
||||
"wallet.ecash-melt" => self.handle_wallet_ecash_melt(params).await,
|
||||
"wallet.ecash-melt-confirm" => self.handle_wallet_ecash_melt_confirm(params).await,
|
||||
"wallet.ecash-send" => self.handle_wallet_ecash_send(params).await,
|
||||
"wallet.ecash-receive" => self.handle_wallet_ecash_receive(params).await,
|
||||
"wallet.ecash-history" => self.handle_wallet_ecash_history().await,
|
||||
"wallet.networking-profits" => self.handle_wallet_networking_profits().await,
|
||||
// Fedimint ecash (via fedimint-clientd sidecar)
|
||||
"wallet.fedimint-list" => self.handle_wallet_fedimint_list().await,
|
||||
"wallet.fedimint-join" => self.handle_wallet_fedimint_join(params).await,
|
||||
"wallet.fedimint-leave" => self.handle_wallet_fedimint_leave(params).await,
|
||||
"wallet.fedimint-balance" => self.handle_wallet_fedimint_balance().await,
|
||||
"wallet.fedimint-send" => self.handle_wallet_fedimint_send(params).await,
|
||||
|
||||
// Ark protocol (via barkd sidecar)
|
||||
"wallet.ark-status" => self.handle_wallet_ark_status().await,
|
||||
"wallet.ark-balance" => self.handle_wallet_ark_balance().await,
|
||||
"wallet.ark-address" => self.handle_wallet_ark_address(params).await,
|
||||
"wallet.ark-send" => self.handle_wallet_ark_send(params).await,
|
||||
"wallet.ark-invoice" => self.handle_wallet_ark_invoice(params).await,
|
||||
"wallet.ark-board" => self.handle_wallet_ark_board(params).await,
|
||||
"wallet.ark-offboard" => self.handle_wallet_ark_offboard(params).await,
|
||||
"wallet.ark-history" => self.handle_wallet_ark_history().await,
|
||||
"wallet.ark-configure" => self.handle_wallet_ark_configure(params).await,
|
||||
|
||||
// Container registries
|
||||
"registry.list" => self.handle_registry_list().await,
|
||||
"registry.add" => self.handle_registry_add(params).await,
|
||||
"registry.remove" => self.handle_registry_remove(params).await,
|
||||
"registry.set-primary" => self.handle_registry_set_primary(params).await,
|
||||
"registry.test" => self.handle_registry_test(params).await,
|
||||
|
||||
// Streaming ecash payments
|
||||
"streaming.list-services" => self.handle_streaming_list_services().await,
|
||||
"streaming.configure-service" => self.handle_streaming_configure_service(params).await,
|
||||
"streaming.toggle-service" => self.handle_streaming_toggle_service(params).await,
|
||||
"streaming.pay" => self.handle_streaming_pay(params).await,
|
||||
"streaming.prepare-payment" => self.handle_streaming_prepare_payment(params).await,
|
||||
"streaming.discover" => self.handle_streaming_discover().await,
|
||||
"streaming.usage" => self.handle_streaming_usage(params).await,
|
||||
"streaming.session" => self.handle_streaming_session(params).await,
|
||||
"streaming.list-sessions" => self.handle_streaming_list_sessions().await,
|
||||
"streaming.close-session" => self.handle_streaming_close_session(params).await,
|
||||
"streaming.advertise" => self.handle_streaming_advertise().await,
|
||||
"streaming.list-mints" => self.handle_streaming_list_mints().await,
|
||||
"streaming.configure-mints" => self.handle_streaming_configure_mints(params).await,
|
||||
"streaming.maintenance" => self.handle_streaming_maintenance().await,
|
||||
|
||||
// Content catalog management
|
||||
"content.list-mine" => self.handle_content_list_mine().await,
|
||||
"content.add" => self.handle_content_add(params).await,
|
||||
"content.remove" => self.handle_content_remove(params).await,
|
||||
"content.set-pricing" => self.handle_content_set_pricing(params).await,
|
||||
"content.set-availability" => self.handle_content_set_availability(params).await,
|
||||
"content.browse-peer" => self.handle_content_browse_peer(params).await,
|
||||
"content.download-peer" => self.handle_content_download_peer(params).await,
|
||||
"content.download-peer-paid" => self.handle_content_download_peer_paid(params).await,
|
||||
"content.owned-list" => self.handle_content_owned_list().await,
|
||||
"content.owned-get" => self.handle_content_owned_get(params).await,
|
||||
"content.request-invoice" => self.handle_content_request_invoice(params).await,
|
||||
"content.invoice-status" => self.handle_content_invoice_status(params).await,
|
||||
"content.download-peer-invoice" => {
|
||||
self.handle_content_download_peer_invoice(params).await
|
||||
}
|
||||
"content.request-onchain" => self.handle_content_request_onchain(params).await,
|
||||
"content.onchain-status" => self.handle_content_onchain_status(params).await,
|
||||
"content.download-peer-onchain" => {
|
||||
self.handle_content_download_peer_onchain(params).await
|
||||
}
|
||||
"content.preview-peer" => self.handle_content_preview_peer(params).await,
|
||||
|
||||
// DWN (Decentralized Web Node)
|
||||
"dwn.status" => self.handle_dwn_status().await,
|
||||
"dwn.sync" => self.handle_dwn_sync().await,
|
||||
"dwn.register-protocol" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_dwn_register_protocol(&p).await
|
||||
}
|
||||
"dwn.list-protocols" => self.handle_dwn_list_protocols().await,
|
||||
"dwn.remove-protocol" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_dwn_remove_protocol(&p).await
|
||||
}
|
||||
"dwn.query-messages" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_dwn_query_messages(&p).await
|
||||
}
|
||||
"dwn.write-message" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_dwn_write_message(&p).await
|
||||
}
|
||||
|
||||
// Federation
|
||||
"federation.invite" => self.handle_federation_invite(params).await,
|
||||
"federation.join" => self.handle_federation_join(params).await,
|
||||
"federation.list-nodes" => self.handle_federation_list_nodes().await,
|
||||
"federation.remove-node" => self.handle_federation_remove_node(params).await,
|
||||
"federation.set-trust" => self.handle_federation_set_trust(params).await,
|
||||
"federation.sync-state" => self.handle_federation_sync_state().await,
|
||||
"federation.get-state" => self.handle_federation_get_state().await,
|
||||
"federation.peer-joined" => self.handle_federation_peer_joined(params).await,
|
||||
"federation.deploy-app" => self.handle_federation_deploy_app(params).await,
|
||||
"federation.peer-address-changed" => {
|
||||
self.handle_federation_peer_address_changed(params).await
|
||||
}
|
||||
"federation.notify-did-change" => {
|
||||
self.handle_federation_notify_did_change(params).await
|
||||
}
|
||||
"federation.peer-did-changed" => self.handle_federation_peer_did_changed(params).await,
|
||||
"federation.list-pending-requests" => {
|
||||
self.handle_federation_list_pending_requests().await
|
||||
}
|
||||
"federation.approve-request" => self.handle_federation_approve_request(params).await,
|
||||
"federation.reject-request" => self.handle_federation_reject_request(params).await,
|
||||
"federation.cancel-request" => self.handle_federation_cancel_request(params).await,
|
||||
|
||||
// VPN & Remote Access
|
||||
"vpn.status" => self.handle_vpn_status().await,
|
||||
"vpn.configure" => self.handle_vpn_configure(params).await,
|
||||
"vpn.disconnect" => self.handle_vpn_disconnect().await,
|
||||
"vpn.invite" => self.handle_vpn_invite(params).await,
|
||||
"vpn.add-participant" => self.handle_vpn_add_participant(params).await,
|
||||
"vpn.create-peer" => self.handle_vpn_create_peer(params).await,
|
||||
"vpn.list-peers" => self.handle_vpn_list_peers().await,
|
||||
"vpn.peer-config" => self.handle_vpn_peer_config(params).await,
|
||||
"vpn.remove-peer" => self.handle_vpn_remove_peer(params).await,
|
||||
"remote.setup" => self.handle_remote_setup(params).await,
|
||||
|
||||
// Marketplace
|
||||
"marketplace.discover" => self.handle_marketplace_discover().await,
|
||||
"marketplace.publish" => self.handle_marketplace_publish(params).await,
|
||||
"marketplace.get-manifest" => self.handle_marketplace_get_manifest(params).await,
|
||||
"marketplace.list-published" => self.handle_marketplace_list_published().await,
|
||||
"marketplace.verify" => self.handle_marketplace_verify(params).await,
|
||||
"marketplace.create-invoice" => self.handle_marketplace_create_invoice(params).await,
|
||||
"marketplace.check-payment" => self.handle_marketplace_check_payment(params).await,
|
||||
|
||||
// Mesh networking (Meshcore LoRa)
|
||||
"mesh.status" => self.handle_mesh_status().await,
|
||||
"mesh.probe-device" => self.handle_mesh_probe_device(params).await,
|
||||
"mesh.peers" => self.handle_mesh_peers().await,
|
||||
"mesh.messages" => self.handle_mesh_messages(params).await,
|
||||
"mesh.debug-dump" => self.handle_mesh_debug_dump().await,
|
||||
"mesh.send" => self.handle_mesh_send(params).await,
|
||||
"mesh.send-channel" => self.handle_mesh_send_channel(params).await,
|
||||
"mesh.broadcast" => self.handle_mesh_broadcast().await,
|
||||
"mesh.reboot-radio" => self.handle_mesh_reboot_radio(params).await,
|
||||
"mesh.configure" => self.handle_mesh_configure(params).await,
|
||||
"mesh.send-invoice" => self.handle_mesh_send_invoice(params).await,
|
||||
"mesh.send-coordinate" => self.handle_mesh_send_coordinate(params).await,
|
||||
"mesh.send-alert" => self.handle_mesh_send_alert(params).await,
|
||||
"mesh.send-content" => self.handle_mesh_send_content(params).await,
|
||||
"mesh.send-content-inline" => self.handle_mesh_send_content_inline(params).await,
|
||||
"mesh.transport-advice" => self.handle_mesh_transport_advice(params).await,
|
||||
"mesh.fetch-content" => self.handle_mesh_fetch_content(params).await,
|
||||
"mesh.send-reply" => self.handle_mesh_send_reply(params).await,
|
||||
"mesh.send-reaction" => self.handle_mesh_send_reaction(params).await,
|
||||
"mesh.send-read-receipt" => self.handle_mesh_send_read_receipt(params).await,
|
||||
"mesh.forward-message" => self.handle_mesh_forward_message(params).await,
|
||||
"mesh.edit-message" => self.handle_mesh_edit_message(params).await,
|
||||
"mesh.delete-message" => self.handle_mesh_delete_message(params).await,
|
||||
"mesh.send-psbt" => self.handle_mesh_send_psbt(params).await,
|
||||
"mesh.broadcast-presence" => self.handle_mesh_broadcast_presence(params).await,
|
||||
"mesh.presence-list" => self.handle_mesh_presence_list(params).await,
|
||||
"mesh.contacts-list" => self.handle_mesh_contacts_list(params).await,
|
||||
"mesh.contacts-save" => self.handle_mesh_contacts_save(params).await,
|
||||
"mesh.contacts-block" => self.handle_mesh_contacts_block(params).await,
|
||||
"mesh.send-channel-invite" => self.handle_mesh_send_channel_invite(params).await,
|
||||
"conversations.list" => self.handle_conversations_list(params).await,
|
||||
"conversations.messages" => self.handle_conversations_messages(params).await,
|
||||
"mesh.clear-all" => self.handle_mesh_clear_all().await,
|
||||
"mesh.outbox" => self.handle_mesh_outbox(params).await,
|
||||
"mesh.session-status" => self.handle_mesh_session_status(params).await,
|
||||
"mesh.rotate-prekeys" => self.handle_mesh_rotate_prekeys().await,
|
||||
// Phase 4: Off-grid Bitcoin operations
|
||||
"mesh.relay-tx" => self.handle_mesh_relay_tx(params).await,
|
||||
"mesh.relay-status" => self.handle_mesh_relay_status(params).await,
|
||||
"mesh.block-headers" => self.handle_mesh_block_headers(params).await,
|
||||
"mesh.relay-lightning" => self.handle_mesh_relay_lightning(params).await,
|
||||
"mesh.deadman-status" => self.handle_mesh_deadman_status().await,
|
||||
"mesh.deadman-configure" => self.handle_mesh_deadman_configure(params).await,
|
||||
"mesh.deadman-checkin" => self.handle_mesh_deadman_checkin().await,
|
||||
"mesh.assistant-status" => self.handle_mesh_assistant_status().await,
|
||||
"mesh.assistant-configure" => self.handle_mesh_assistant_configure(params).await,
|
||||
"mesh.schedule-message" => self.handle_mesh_schedule_message(params).await,
|
||||
"mesh.list-scheduled" => self.handle_mesh_list_scheduled().await,
|
||||
"mesh.cancel-scheduled" => self.handle_mesh_cancel_scheduled(params).await,
|
||||
"mesh.test-send" => self.handle_mesh_test_send(params).await,
|
||||
|
||||
// Transport layer (unified routing)
|
||||
"transport.status" => self.handle_transport_status().await,
|
||||
"transport.peers" => self.handle_transport_peers().await,
|
||||
"transport.send" => self.handle_transport_send(params).await,
|
||||
"transport.set-mode" => self.handle_transport_set_mode(params).await,
|
||||
"transport.preferences" => self.handle_transport_preferences().await,
|
||||
"transport.set-preference" => self.handle_transport_set_preference(params).await,
|
||||
|
||||
// Server settings
|
||||
"server.set-name" => self.handle_server_set_name(params).await,
|
||||
"server.set-location" => self.handle_server_set_location(params).await,
|
||||
|
||||
// System monitoring
|
||||
"system.get-hostname" => self.handle_system_get_hostname().await,
|
||||
"system.stats" => self.handle_system_stats().await,
|
||||
"system.processes" => self.handle_system_processes().await,
|
||||
"system.temperature" => self.handle_system_temperature().await,
|
||||
"system.detect-usb-devices" => self.handle_system_detect_usb_devices().await,
|
||||
"system.disk-status" => self.handle_system_disk_status().await,
|
||||
"system.disk-cleanup" => self.handle_system_disk_cleanup().await,
|
||||
"system.reboot" => self.handle_system_reboot(params).await,
|
||||
"system.factory-reset" => self.handle_system_factory_reset(params).await,
|
||||
"system.settings.get" => self.handle_system_settings_get(params).await,
|
||||
"system.settings.set" => self.handle_system_settings_set(params).await,
|
||||
"system.kiosk-display.get" => self.handle_system_kiosk_display_get().await,
|
||||
"system.kiosk-display.set" => self.handle_system_kiosk_display_set(params).await,
|
||||
|
||||
// Opt-in anonymous analytics
|
||||
"analytics.get-status" => self.handle_analytics_get_status().await,
|
||||
"analytics.enable" => self.handle_analytics_enable().await,
|
||||
"analytics.disable" => self.handle_analytics_disable().await,
|
||||
"analytics.get-snapshot" => self.handle_analytics_get_snapshot().await,
|
||||
"telemetry.report" => self.handle_telemetry_report().await,
|
||||
"telemetry.ingest" => self.handle_telemetry_ingest(params).await,
|
||||
"telemetry.fleet-status" => self.handle_telemetry_fleet_status().await,
|
||||
"telemetry.fleet-node-history" => {
|
||||
self.handle_telemetry_fleet_node_history(params).await
|
||||
}
|
||||
"telemetry.fleet-alerts" => self.handle_telemetry_fleet_alerts().await,
|
||||
|
||||
// Real-time metrics monitoring
|
||||
"monitoring.current" => self.handle_monitoring_current().await,
|
||||
"monitoring.history" => self.handle_monitoring_history(params).await,
|
||||
"monitoring.containers" => self.handle_monitoring_containers().await,
|
||||
"monitoring.alerts" => self.handle_monitoring_alerts(params).await,
|
||||
"monitoring.alert-rules" => self.handle_monitoring_alert_rules().await,
|
||||
"monitoring.configure-alert" => self.handle_monitoring_configure_alert(params).await,
|
||||
"monitoring.acknowledge-alert" => {
|
||||
self.handle_monitoring_acknowledge_alert(params).await
|
||||
}
|
||||
"monitoring.export" => self.handle_monitoring_export(params).await,
|
||||
|
||||
// FIPS mesh transport
|
||||
"fips.status" => self.handle_fips_status().await,
|
||||
"fips.pair-info" => self.handle_fips_pair_info().await,
|
||||
"fips.check-update" => self.handle_fips_check_update().await,
|
||||
"fips.apply-update" => self.handle_fips_apply_update().await,
|
||||
"fips.install" => self.handle_fips_install().await,
|
||||
"fips.restart" => self.handle_fips_restart().await,
|
||||
"fips.reconnect" => self.handle_fips_reconnect().await,
|
||||
"fips.list-seed-anchors" => self.handle_fips_list_seed_anchors().await,
|
||||
"fips.add-seed-anchor" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_fips_add_seed_anchor(&p).await
|
||||
}
|
||||
"fips.remove-seed-anchor" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_fips_remove_seed_anchor(&p).await
|
||||
}
|
||||
"fips.apply-seed-anchors" => self.handle_fips_apply_seed_anchors().await,
|
||||
|
||||
// System updates
|
||||
"update.check" => self.handle_update_check().await,
|
||||
"update.status" => self.handle_update_status().await,
|
||||
"update.dismiss" => self.handle_update_dismiss().await,
|
||||
"update.download" => self.handle_update_download().await,
|
||||
"update.cancel-download" => self.handle_update_cancel_download().await,
|
||||
"update.list-mirrors" => self.handle_update_list_mirrors().await,
|
||||
"update.add-mirror" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_update_add_mirror(&p).await
|
||||
}
|
||||
"update.remove-mirror" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_update_remove_mirror(&p).await
|
||||
}
|
||||
"update.set-primary-mirror" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_update_set_primary_mirror(&p).await
|
||||
}
|
||||
"update.test-mirror" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_update_test_mirror(&p).await
|
||||
}
|
||||
"update.get-source" => self.handle_update_get_source().await,
|
||||
"update.set-source" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_update_set_source(&p).await
|
||||
}
|
||||
"update.apply" => self.handle_update_apply().await,
|
||||
"update.git-apply" => self.handle_update_git_apply().await,
|
||||
"update.rollback" => self.handle_update_rollback().await,
|
||||
"update.get-schedule" => self.handle_update_get_schedule().await,
|
||||
"update.set-schedule" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_update_set_schedule(&p).await
|
||||
}
|
||||
|
||||
// Backup & Restore
|
||||
"backup.create" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_backup_create(&p).await
|
||||
}
|
||||
"backup.list" => self.handle_backup_list().await,
|
||||
"backup.verify" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_backup_verify(&p).await
|
||||
}
|
||||
"backup.restore" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_backup_restore(&p).await
|
||||
}
|
||||
"backup.restore-identity" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_backup_restore_identity(&p).await
|
||||
}
|
||||
"backup.delete" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_backup_delete(&p).await
|
||||
}
|
||||
"backup.list-drives" => self.handle_backup_list_drives().await,
|
||||
"backup.to-usb" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_backup_to_usb(&p).await
|
||||
}
|
||||
"backup.upload-s3" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_backup_upload_s3(&p).await
|
||||
}
|
||||
"backup.download-s3" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_backup_download_s3(&p).await
|
||||
}
|
||||
|
||||
// Security / secrets
|
||||
"security.rotate-secrets" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_security_rotate_secrets(&p).await
|
||||
}
|
||||
"security.list-expiring" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_security_list_expiring(&p).await
|
||||
}
|
||||
|
||||
// Webhooks
|
||||
"webhook.get-config" => self.handle_webhook_get_config().await,
|
||||
"webhook.configure" => self.handle_webhook_configure(params).await,
|
||||
"webhook.test" => self.handle_webhook_test().await,
|
||||
|
||||
_ => Err(anyhow::anyhow!("Unknown method: {}", method)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn handle_echo(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
if let Some(p) = params {
|
||||
if let Some(msg) = p.get("message").and_then(|v| v.as_str()) {
|
||||
return Ok(serde_json::json!({ "message": msg }));
|
||||
}
|
||||
}
|
||||
Ok(serde_json::json!({ "message": "Hello from Archipelago!" }))
|
||||
}
|
||||
|
||||
async fn handle_server_get_state(&self) -> Result<serde_json::Value> {
|
||||
let (data, rev) = self.state_manager.get_snapshot().await;
|
||||
Ok(serde_json::json!({ "data": data, "rev": rev }))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_health(&self) -> Result<serde_json::Value> {
|
||||
let recovery_complete = crate::crash_recovery::is_recovery_complete();
|
||||
let uptime = crate::crash_recovery::uptime_seconds();
|
||||
let status = if recovery_complete { "ok" } else { "degraded" };
|
||||
Ok(serde_json::json!({
|
||||
"status": status,
|
||||
"crash_recovery_complete": recovery_complete,
|
||||
"uptime_seconds": uptime,
|
||||
"version": format!("{}-{}", env!("CARGO_PKG_VERSION"), option_env!("GIT_HASH").unwrap_or("dev")),
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
use super::RpcHandler;
|
||||
use crate::federation;
|
||||
use crate::network::dwn_store::{DwnStore, MessageQuery, ProtocolDefinition};
|
||||
use crate::network::dwn_sync;
|
||||
use anyhow::Result;
|
||||
|
||||
impl RpcHandler {
|
||||
/// Get DWN status and sync state.
|
||||
pub(super) async fn handle_dwn_status(&self) -> Result<serde_json::Value> {
|
||||
let sync_state = dwn_sync::load_sync_state(&self.config.data_dir).await?;
|
||||
let server_status =
|
||||
dwn_sync::get_dwn_status()
|
||||
.await
|
||||
.unwrap_or(dwn_sync::DwnStatusResponse {
|
||||
running: false,
|
||||
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": 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,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Trigger DWN sync with connected peers.
|
||||
/// Spawns sync as a background task and returns immediately.
|
||||
pub(super) async fn handle_dwn_sync(&self) -> Result<serde_json::Value> {
|
||||
// Check if already syncing
|
||||
let current_state = dwn_sync::load_sync_state(&self.config.data_dir).await?;
|
||||
if matches!(current_state.status, dwn_sync::SyncStatus::Syncing) {
|
||||
return Ok(serde_json::json!({
|
||||
"sync_status": "syncing",
|
||||
"last_sync": current_state.last_sync,
|
||||
"messages_synced": current_state.messages_synced,
|
||||
}));
|
||||
}
|
||||
|
||||
let nodes = federation::load_nodes(&self.config.data_dir).await?;
|
||||
let onions: Vec<String> = nodes
|
||||
.iter()
|
||||
.filter(|n| !n.onion.is_empty() && n.trust_level != federation::TrustLevel::Untrusted)
|
||||
.map(|n| n.onion.clone())
|
||||
.collect();
|
||||
|
||||
// Spawn sync in background so we don't block the RPC response
|
||||
let data_dir = self.config.data_dir.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = dwn_sync::sync_with_peers(&data_dir, &onions).await {
|
||||
tracing::warn!(error = %e, "DWN background sync failed");
|
||||
}
|
||||
});
|
||||
|
||||
// Return immediately with "syncing" status
|
||||
Ok(serde_json::json!({
|
||||
"sync_status": "syncing",
|
||||
"last_sync": current_state.last_sync,
|
||||
"messages_synced": current_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();
|
||||
|
||||
// Limit data size to 10MB to prevent disk exhaustion
|
||||
if let Some(ref d) = data {
|
||||
let data_str = d.to_string();
|
||||
if data_str.len() > 10_485_760 {
|
||||
anyhow::bail!("Message data too large (max 10MB)");
|
||||
}
|
||||
}
|
||||
|
||||
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}))
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
mod handlers;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
pub(super) fn validate_did(did: &str) -> Result<()> {
|
||||
if did.is_empty() || did.len() > 256 {
|
||||
anyhow::bail!("Invalid DID: must be 1-256 characters");
|
||||
}
|
||||
if !did.starts_with("did:") {
|
||||
anyhow::bail!("Invalid DID: must start with 'did:'");
|
||||
}
|
||||
if did.contains("..") || did.contains('/') || did.contains('\\') || did.contains('\0') {
|
||||
anyhow::bail!("Invalid DID: contains forbidden characters");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
//! Fedimint ecash RPCs — bridge to the `fedimint-clientd` sidecar.
|
||||
//!
|
||||
//! Companion to the Cashu wallet RPCs in [`super::wallet`]. Joining/holding
|
||||
//! Fedimint ecash is delegated to the clientd container via
|
||||
//! [`crate::wallet::fedimint_client::FedimintClient`]; here we expose the
|
||||
//! node's JSON-RPC surface and keep a local registry of joined federations so
|
||||
//! the list survives clientd being temporarily unreachable.
|
||||
//!
|
||||
//! See `docs/dual-ecash-design.md`.
|
||||
|
||||
use super::RpcHandler;
|
||||
use crate::wallet::fedimint_client::{self, FedimintClient, JoinedFederation};
|
||||
use anyhow::Result;
|
||||
|
||||
impl RpcHandler {
|
||||
/// `wallet.fedimint-list` — joined federations with live balances.
|
||||
pub(super) async fn handle_wallet_fedimint_list(&self) -> Result<serde_json::Value> {
|
||||
// Best-effort: make sure the default federation is joined/tracked.
|
||||
let _ = fedimint_client::ensure_default_federation(&self.config.data_dir).await;
|
||||
|
||||
let reg = fedimint_client::load_registry(&self.config.data_dir).await?;
|
||||
|
||||
// Live balances are best-effort: if clientd is down we still return the
|
||||
// tracked federations (with 0 balance) rather than failing the call.
|
||||
let info = match FedimintClient::from_node(&self.config.data_dir).await {
|
||||
Ok(client) => client.info().await.ok(),
|
||||
Err(_) => None,
|
||||
};
|
||||
|
||||
let federations: Vec<serde_json::Value> = reg
|
||||
.federations
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let balance_sats = info
|
||||
.as_ref()
|
||||
.and_then(|i| i.get(&f.federation_id))
|
||||
.and_then(|e| {
|
||||
e.get("totalAmountMsat")
|
||||
.or_else(|| e.get("totalMsat"))
|
||||
.and_then(|v| v.as_u64())
|
||||
})
|
||||
.map(|msat| msat / 1000)
|
||||
.unwrap_or(0);
|
||||
serde_json::json!({
|
||||
"federation_id": f.federation_id,
|
||||
"name": f.name,
|
||||
"balance_sats": balance_sats,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(serde_json::json!({ "federations": federations }))
|
||||
}
|
||||
|
||||
/// `wallet.fedimint-join` — join a federation by invite code.
|
||||
pub(super) async fn handle_wallet_fedimint_join(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let invite_code = params
|
||||
.get("invite_code")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing invite_code"))?;
|
||||
|
||||
let client = FedimintClient::from_node(&self.config.data_dir).await?;
|
||||
let federation_id = client.join(invite_code).await?;
|
||||
|
||||
// Try to label it from the federation meta (best-effort).
|
||||
let name = client.info().await.ok().and_then(|i| {
|
||||
i.get(&federation_id)
|
||||
.and_then(|e| e.get("meta"))
|
||||
.and_then(|m| {
|
||||
m.get("federation_name")
|
||||
.or_else(|| m.get("federation_expiry_timestamp"))
|
||||
})
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
});
|
||||
|
||||
let mut reg = fedimint_client::load_registry(&self.config.data_dir).await?;
|
||||
if !reg
|
||||
.federations
|
||||
.iter()
|
||||
.any(|f| f.federation_id == federation_id)
|
||||
{
|
||||
reg.federations.push(JoinedFederation {
|
||||
federation_id: federation_id.clone(),
|
||||
name,
|
||||
});
|
||||
fedimint_client::save_registry(&self.config.data_dir, ®).await?;
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({ "federation_id": federation_id }))
|
||||
}
|
||||
|
||||
/// `wallet.fedimint-leave` — stop tracking a federation locally.
|
||||
pub(super) async fn handle_wallet_fedimint_leave(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let federation_id = params
|
||||
.get("federation_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing federation_id"))?;
|
||||
|
||||
let mut reg = fedimint_client::load_registry(&self.config.data_dir).await?;
|
||||
let before = reg.federations.len();
|
||||
reg.federations.retain(|f| f.federation_id != federation_id);
|
||||
let removed = reg.federations.len() != before;
|
||||
if removed {
|
||||
fedimint_client::save_registry(&self.config.data_dir, ®).await?;
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({ "removed": removed }))
|
||||
}
|
||||
|
||||
/// `wallet.fedimint-send` — spend ecash notes from any joined federation
|
||||
/// with sufficient balance. Returns the notes token for the recipient
|
||||
/// (rendered as text + QR by the send modal — the wallet's Fedi rail,
|
||||
/// split from Cashu 2026-07-22). The heavy lifting already existed in
|
||||
/// `fedimint_client::spend_from_any`; it was simply never exposed.
|
||||
pub(super) async fn handle_wallet_fedimint_send(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let amount_sats = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("amount_sats"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing amount_sats"))?;
|
||||
anyhow::ensure!(amount_sats > 0, "must be at least 1 sat");
|
||||
let (token, federation_id) =
|
||||
crate::wallet::fedimint_client::spend_from_any(&self.config.data_dir, amount_sats)
|
||||
.await?;
|
||||
Ok(serde_json::json!({
|
||||
"token": token,
|
||||
"federation_id": federation_id,
|
||||
"amount_sats": amount_sats,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `wallet.fedimint-balance` — total sats across all joined federations.
|
||||
pub(super) async fn handle_wallet_fedimint_balance(&self) -> Result<serde_json::Value> {
|
||||
// Soft-fail to zero when clientd isn't installed/running, so the unified
|
||||
// wallet balance still renders from the Cashu side.
|
||||
let balance_sats = match FedimintClient::from_node(&self.config.data_dir).await {
|
||||
Ok(client) => client.total_balance_sats().await.unwrap_or(0),
|
||||
Err(_) => 0,
|
||||
};
|
||||
Ok(serde_json::json!({ "balance_sats": balance_sats }))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
//! RPC handlers for the FIPS mesh transport subsystem.
|
||||
//!
|
||||
//! Surface is deliberately thin: a read-only `fips.status`, a user-gated
|
||||
//! `fips.check-update`, a stubbed `fips.apply-update`, and a
|
||||
//! `fips.install` that (re-)materialises the daemon config + key and
|
||||
//! activates the service. All writes go through `sudo` helpers in
|
||||
//! `crate::fips`.
|
||||
|
||||
use super::RpcHandler;
|
||||
use crate::fips;
|
||||
use anyhow::Result;
|
||||
|
||||
impl RpcHandler {
|
||||
pub(super) async fn handle_fips_status(&self) -> Result<serde_json::Value> {
|
||||
let status = fips::FipsStatus::query(&self.config.data_dir).await;
|
||||
Ok(serde_json::to_value(status)?)
|
||||
}
|
||||
|
||||
/// Everything the companion app needs to join this node's mesh, embedded
|
||||
/// in the pairing QR by the web UI: the daemon's npub (identity to dial),
|
||||
/// the fips0 ULA (where the UI is reachable once the phone is meshed),
|
||||
/// and the transport ports on this host. The QR builder supplies the
|
||||
/// host/IP itself — it knows which origin the browser reached the node on.
|
||||
pub(super) async fn handle_fips_pair_info(&self) -> Result<serde_json::Value> {
|
||||
let identity_dir = fips::identity_dir_from(&self.config.data_dir);
|
||||
let npub = crate::identity::fips_npub(&identity_dir)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!("FIPS identity not provisioned yet — complete onboarding first")
|
||||
})?;
|
||||
let ula = fips::iface::fips0_ula().map(|ip| ip.to_string());
|
||||
// The node's seed anchors ride along so the phone can rendezvous
|
||||
// through the same public mesh points when the node's LAN endpoint
|
||||
// isn't directly dialable (phone away from home, node behind NAT).
|
||||
let mut anchor_list = fips::anchors::load(&self.config.data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
// Pairing must always carry a public rendezvous point: without one the
|
||||
// phone is IP-bound to the LAN host it scanned and goes dark the
|
||||
// moment it leaves that network. This is a pairing hint only — the
|
||||
// node's own anchor file is not modified, so an operator's removal of
|
||||
// the default anchors still sticks for the node itself.
|
||||
if !anchor_list
|
||||
.iter()
|
||||
.any(|a| a.npub == fips::anchors::ARCHY_ANCHOR_NPUB)
|
||||
{
|
||||
anchor_list.push(fips::anchors::archy_anchor());
|
||||
}
|
||||
let anchors = anchor_list
|
||||
.into_iter()
|
||||
.map(|a| {
|
||||
serde_json::json!({
|
||||
"npub": a.npub,
|
||||
"addr": a.address,
|
||||
"transport": a.transport,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Ok(serde_json::json!({
|
||||
"npub": npub,
|
||||
"ula": ula,
|
||||
"udp_port": fips::PUBLISHED_UDP_PORT,
|
||||
"tcp_port": fips::DEFAULT_TCP_PORT,
|
||||
"anchors": anchors,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_fips_check_update(&self) -> Result<serde_json::Value> {
|
||||
let check = fips::update::check().await?;
|
||||
Ok(serde_json::to_value(check)?)
|
||||
}
|
||||
|
||||
pub(super) async fn handle_fips_apply_update(&self) -> Result<serde_json::Value> {
|
||||
fips::update::apply().await?;
|
||||
Ok(serde_json::json!({ "applied": true }))
|
||||
}
|
||||
|
||||
/// Install config + key into /etc/fips and activate the service.
|
||||
/// Intended to be called:
|
||||
/// - once by the seed-onboarding flow, right after the FIPS key
|
||||
/// is written to /data/identity/fips_key, and
|
||||
/// - on user demand from the dashboard if something drifted.
|
||||
pub(super) async fn handle_fips_install(&self) -> Result<serde_json::Value> {
|
||||
let identity_dir = fips::identity_dir_from(&self.config.data_dir);
|
||||
fips::config::install(&identity_dir).await?;
|
||||
fips::service::activate(fips::SERVICE_UNIT).await?;
|
||||
let status = fips::FipsStatus::query(&self.config.data_dir).await;
|
||||
Ok(serde_json::to_value(status)?)
|
||||
}
|
||||
|
||||
/// Restart whichever fips unit is supervising the daemon on this host.
|
||||
/// Nodes installed from the archipelago ISO use `archipelago-fips.service`;
|
||||
/// nodes that had the upstream debian package set up first may only have
|
||||
/// `fips.service`. We resolve the active one via `service::active_unit()`
|
||||
/// so the UI button is never a no-op.
|
||||
pub(super) async fn handle_fips_restart(&self) -> Result<serde_json::Value> {
|
||||
let unit = fips::service::active_unit().await;
|
||||
fips::service::restart(unit).await?;
|
||||
Ok(serde_json::json!({ "restarted": true, "unit": unit }))
|
||||
}
|
||||
|
||||
/// Full reconnect: stop the daemon, bring it back, wait for the DHT
|
||||
/// bootstrap window, poll the identity-cache + peer list, and
|
||||
/// classify what recovered (or didn't) so the UI can explain it to
|
||||
/// the user instead of showing a generic failure.
|
||||
///
|
||||
/// Runtime: ~20s. Needs an RPC timeout ≥ 45s on the client.
|
||||
pub(super) async fn handle_fips_reconnect(&self) -> Result<serde_json::Value> {
|
||||
let identity_dir = fips::identity_dir_from(&self.config.data_dir);
|
||||
let before = fips::FipsStatus::query(&self.config.data_dir).await;
|
||||
|
||||
// Heal the pre-fix bech32-text fips_key.pub → 32-raw-bytes
|
||||
// mismatch. The daemon silently authenticates with a garbage
|
||||
// pubkey when the .pub file is 63-char text, which looks like
|
||||
// "anchor unreachable" to the user even though the real fault
|
||||
// was an identity malformed on the node itself. Re-install the
|
||||
// config + keys so /etc/fips gets the healed .pub.
|
||||
let key_src = identity_dir.join("fips_key");
|
||||
let pub_src = identity_dir.join("fips_key.pub");
|
||||
if key_src.exists() {
|
||||
let _ = fips::config::normalize_pub_file(&key_src, &pub_src).await;
|
||||
// Re-install refreshes /etc/fips/fips.pub from the healed
|
||||
// source. No-op if nothing changed.
|
||||
let _ = fips::config::install(&identity_dir).await;
|
||||
}
|
||||
|
||||
// Operate on whichever fips unit is actually up — nodes that
|
||||
// have the upstream `fips.service` rather than the
|
||||
// archipelago-managed `archipelago-fips.service` used to see
|
||||
// Reconnect silently fail because we stopped a unit that
|
||||
// didn't exist. Clean stop+start rather than `restart` so a
|
||||
// daemon that fails to come back up surfaces as
|
||||
// service_active=false instead of quietly sticking with the
|
||||
// old process.
|
||||
let unit = fips::service::active_unit().await;
|
||||
let _ = fips::service::stop(unit).await;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(800)).await;
|
||||
fips::service::activate(unit).await?;
|
||||
|
||||
// Re-push seed anchors after restart so freshly-bound daemons
|
||||
// don't have to wait 5 min for the periodic apply loop.
|
||||
if let Ok(list) = fips::anchors::load(&self.config.data_dir).await {
|
||||
if !list.is_empty() {
|
||||
let _ = fips::anchors::apply(&list).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Anchor bootstrap window: poll the status every ~3s for up to
|
||||
// 20s. Bail as soon as the anchor is connected.
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
|
||||
let after = loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||
let s = fips::FipsStatus::query(&self.config.data_dir).await;
|
||||
if s.anchor_connected || std::time::Instant::now() >= deadline {
|
||||
break s;
|
||||
}
|
||||
};
|
||||
|
||||
let recovered = after.anchor_connected && !before.anchor_connected;
|
||||
let likely_cause = if after.anchor_connected {
|
||||
"connected"
|
||||
} else if !after.service_active {
|
||||
"daemon_down"
|
||||
} else if !after.key_present {
|
||||
"no_seed_key"
|
||||
} else if after.authenticated_peer_count == 0 {
|
||||
// Daemon is up with a key but hasn't authenticated any peers —
|
||||
// almost always the outbound connection to the anchor being
|
||||
// dropped by the local firewall/router, or the anchor itself
|
||||
// being down. The public anchor is reached over TCP/8443 (not
|
||||
// UDP/8668 — that endpoint is dead).
|
||||
"no_outbound_or_anchor_down"
|
||||
} else {
|
||||
"peers_but_no_anchor"
|
||||
};
|
||||
let hint = match likely_cause {
|
||||
"connected" => "An anchor is reachable.",
|
||||
"daemon_down" => "The FIPS daemon didn't come back up — check the FIPS service on this host.",
|
||||
"no_seed_key" => "No seed-derived FIPS key on disk. Re-run the onboarding unlock step.",
|
||||
"no_outbound_or_anchor_down" =>
|
||||
"Daemon is running but no peers handshook. Your router or ISP may be blocking the outbound connection to the mesh anchor (TCP port 8443), or every configured anchor is down. The public anchor is added automatically — if it still won't connect, add another reachable peer in Seed Anchors.",
|
||||
"peers_but_no_anchor" =>
|
||||
"Mesh has peers but none of them are anchors we recognise. Add your cluster's anchor in Seed Anchors.",
|
||||
_ => "",
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"recovered": recovered,
|
||||
"likely_cause": likely_cause,
|
||||
"hint": hint,
|
||||
"before": before,
|
||||
"after": after,
|
||||
}))
|
||||
}
|
||||
|
||||
/// List the seed-anchor entries configured on this node.
|
||||
pub(super) async fn handle_fips_list_seed_anchors(&self) -> Result<serde_json::Value> {
|
||||
let list = fips::anchors::load(&self.config.data_dir).await?;
|
||||
Ok(serde_json::json!({ "seed_anchors": list }))
|
||||
}
|
||||
|
||||
/// Add (or update) a seed anchor and immediately push it into the
|
||||
/// running daemon. Params: `{ npub, address, transport?, label? }`.
|
||||
pub(super) async fn handle_fips_add_seed_anchor(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let anchor: fips::anchors::SeedAnchor = serde_json::from_value(params.clone())
|
||||
.map_err(|e| anyhow::anyhow!("bad seed anchor payload: {}", e))?;
|
||||
if !anchor.npub.starts_with("npub1") {
|
||||
anyhow::bail!("npub must be bech32 (npub1...)");
|
||||
}
|
||||
if !anchor.address.contains(':') {
|
||||
anyhow::bail!("address must be host:port (e.g. 192.168.1.116:8668)");
|
||||
}
|
||||
let list = fips::anchors::add(&self.config.data_dir, anchor.clone()).await?;
|
||||
// Push just the newly-added anchor into the running daemon so
|
||||
// the user sees effect without waiting for the periodic apply.
|
||||
let results = fips::anchors::apply(&[anchor]).await;
|
||||
Ok(serde_json::json!({
|
||||
"seed_anchors": list,
|
||||
"apply": results.iter().map(|r| {
|
||||
serde_json::json!({ "npub": r.npub, "ok": r.ok, "message": r.message })
|
||||
}).collect::<Vec<_>>(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Remove a seed anchor by npub. Params: `{ npub }`. Does NOT tear
|
||||
/// down an already-authenticated peer connection — it only stops
|
||||
/// us from re-dialing the anchor on the next apply cycle.
|
||||
pub(super) async fn handle_fips_remove_seed_anchor(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let npub = params
|
||||
.get("npub")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("missing npub"))?;
|
||||
let list = fips::anchors::remove(&self.config.data_dir, npub).await?;
|
||||
Ok(serde_json::json!({ "seed_anchors": list }))
|
||||
}
|
||||
|
||||
/// Re-apply all seed anchors to the running daemon. Useful after a
|
||||
/// FIPS restart or when the user wants to force a reconnection
|
||||
/// attempt without waiting for the periodic apply loop.
|
||||
pub(super) async fn handle_fips_apply_seed_anchors(&self) -> Result<serde_json::Value> {
|
||||
let list = fips::anchors::load(&self.config.data_dir).await?;
|
||||
let results = fips::anchors::apply(&list).await;
|
||||
Ok(serde_json::json!({
|
||||
"applied": results.len(),
|
||||
"results": results.iter().map(|r| {
|
||||
serde_json::json!({ "npub": r.npub, "ok": r.ok, "message": r.message })
|
||||
}).collect::<Vec<_>>(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
//! Nostr peer-discovery RPCs.
|
||||
//!
|
||||
//! `handshake.discover` — browse other nodes' presence events on configured
|
||||
//! relays. Returns DID + nostr pubkey only; no onion is ever exposed.
|
||||
//!
|
||||
//! `handshake.connect` — send a `PeerRequest` to a discovered node's nostr
|
||||
//! pubkey. Records the outbound request locally so the user can see what
|
||||
//! they've sent. Does NOT include our onion address on the wire.
|
||||
//!
|
||||
//! `handshake.poll` — fetch new NIP-44 DMs addressed to our nostr pubkey
|
||||
//! and dispatch them: inbound `PeerRequest` is queued in
|
||||
//! `federation::pending` for manual approval; inbound `PeerInvite` is
|
||||
//! applied via the existing federation invite-acceptance flow (which
|
||||
//! adds the new peer as `Observer` — see federation.rs); inbound
|
||||
//! `PeerReject` is recorded against the matching outbound row.
|
||||
|
||||
use super::RpcHandler;
|
||||
use crate::federation::pending::{self, PendingPeerRequest, PendingState};
|
||||
use crate::nostr_handshake::{self, HandshakeMessage};
|
||||
use anyhow::{Context, Result};
|
||||
use nostr_sdk::FromBech32;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const NOSTR_STATE_FILE: &str = "nostr_discovery_state.json";
|
||||
|
||||
/// Runtime override for `Config::nostr_discovery_enabled`. The OS-level
|
||||
/// config file is read once at boot and is OFF by default; this state file
|
||||
/// lets the user flip discoverability on/off at runtime via the Federation
|
||||
/// UI without restarting the service. Both the boot-time presence publish
|
||||
/// and the `handshake.poll` handler check this file before doing anything.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
struct NostrDiscoveryState {
|
||||
#[serde(default)]
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
async fn load_discovery_state(data_dir: &std::path::Path) -> NostrDiscoveryState {
|
||||
let path = data_dir.join(NOSTR_STATE_FILE);
|
||||
match tokio::fs::read_to_string(&path).await {
|
||||
Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
|
||||
Err(_) => NostrDiscoveryState::default(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn save_discovery_state(
|
||||
data_dir: &std::path::Path,
|
||||
state: &NostrDiscoveryState,
|
||||
) -> Result<()> {
|
||||
let path = data_dir.join(NOSTR_STATE_FILE);
|
||||
let content = serde_json::to_string_pretty(state).context("serialize discovery state")?;
|
||||
tokio::fs::write(&path, content)
|
||||
.await
|
||||
.context("write discovery state")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// Read the current runtime discoverability flag.
|
||||
pub(super) async fn handle_nostr_discovery_status(&self) -> Result<serde_json::Value> {
|
||||
let state = load_discovery_state(&self.config.data_dir).await;
|
||||
Ok(serde_json::json!({ "enabled": state.enabled }))
|
||||
}
|
||||
|
||||
/// Set the runtime discoverability flag. If turning ON, publish presence
|
||||
/// once immediately so the user gets visible feedback that the relays
|
||||
/// have been notified. If turning OFF, do NOT actively scrub the relays
|
||||
/// here — `nostr_handshake::publish_presence` is replaceable, so the
|
||||
/// next reboot's startup pass plus the existing legacy revocation in
|
||||
/// `nostr_discovery::revoke_legacy_advertisements` are sufficient. A
|
||||
/// future Layer 3 task adds an explicit "tombstone" publish if needed.
|
||||
pub(super) async fn handle_nostr_set_discovery(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let enabled = params
|
||||
.get("enabled")
|
||||
.and_then(|v| v.as_bool())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing enabled"))?;
|
||||
|
||||
save_discovery_state(&self.config.data_dir, &NostrDiscoveryState { enabled }).await?;
|
||||
|
||||
if enabled && !self.config.nostr_relays.is_empty() {
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)
|
||||
.unwrap_or_default();
|
||||
let version = data.server_info.version.clone();
|
||||
let relays = self.handshake_relays().await;
|
||||
let tor_proxy = self.config.nostr_tor_proxy.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = nostr_handshake::publish_presence(
|
||||
&identity_dir,
|
||||
&did,
|
||||
&version,
|
||||
&relays,
|
||||
tor_proxy.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Initial presence publish failed: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({ "enabled": enabled }))
|
||||
}
|
||||
|
||||
/// The relay set every handshake operation uses: the user-managed relay
|
||||
/// list (Settings → Relays, `nostr_relays.json`) merged with the config
|
||||
/// defaults. Before 2026-07-22 handshake send/poll used ONLY the two
|
||||
/// hardcoded config relays (one of which is defunct) and ignored user
|
||||
/// relay edits entirely — so a sender publishing where the receiver
|
||||
/// never read was a routine, silent way for peer requests to vanish.
|
||||
pub(super) async fn handshake_relays(&self) -> Vec<String> {
|
||||
crate::nostr_relays::merged_relay_list(&self.config.data_dir, &self.config.nostr_relays)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Discover discoverable nodes via Nostr presence events.
|
||||
/// Returns (nostr_pubkey, npub, DID, version) only — never an onion.
|
||||
pub(super) async fn handle_handshake_discover(&self) -> Result<serde_json::Value> {
|
||||
// Discoverability gate: respect the runtime toggle. We allow `discover`
|
||||
// to query relays as long as the user is actively browsing — they're
|
||||
// an anonymous observer of presence events, not publishing anything.
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let relays = self.handshake_relays().await;
|
||||
let nodes = nostr_handshake::discover_nodes(
|
||||
&identity_dir,
|
||||
&relays,
|
||||
self.config.nostr_tor_proxy.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
Ok(serde_json::json!({ "nodes": nodes }))
|
||||
}
|
||||
|
||||
/// Send a `PeerRequest` to a discovered node. Onion is never sent.
|
||||
/// Params: `{ recipient_nostr_pubkey, message?, name? }`.
|
||||
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_raw = params
|
||||
.get("recipient_nostr_pubkey")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing recipient_nostr_pubkey"))?;
|
||||
let recipient_hex = if recipient_raw.starts_with("npub1") {
|
||||
nostr_sdk::PublicKey::from_bech32(recipient_raw)
|
||||
.map_err(|e| anyhow::anyhow!("Invalid npub: {}", e))?
|
||||
.to_hex()
|
||||
} else {
|
||||
recipient_raw.to_string()
|
||||
};
|
||||
let recipient_npub = nostr_sdk::PublicKey::from_hex(&recipient_hex)
|
||||
.ok()
|
||||
.and_then(|pk| nostr_sdk::ToBech32::to_bech32(&pk).ok())
|
||||
.unwrap_or_default();
|
||||
let message = params.get("message").and_then(|v| v.as_str());
|
||||
let optional_name = params.get("name").and_then(|v| v.as_str());
|
||||
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let our_did =
|
||||
crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey).unwrap_or_default();
|
||||
let our_version = &data.server_info.version;
|
||||
let our_name = optional_name.or(data.server_info.name.as_deref());
|
||||
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
nostr_handshake::send_peer_request(
|
||||
&identity_dir,
|
||||
&recipient_hex,
|
||||
&our_did,
|
||||
our_version,
|
||||
our_name,
|
||||
message,
|
||||
&self.handshake_relays().await,
|
||||
self.config.nostr_tor_proxy.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Record the outbound request so the user can see "Sent" status
|
||||
// and so the eventual NIP-44 PeerInvite reply can be matched.
|
||||
let row = pending::insert_outbound(
|
||||
&self.config.data_dir,
|
||||
recipient_hex.clone(),
|
||||
recipient_npub,
|
||||
String::new(), // remote DID unknown until they reply
|
||||
None,
|
||||
message.map(String::from),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"ok": true,
|
||||
"sent_to": recipient_hex,
|
||||
"id": row.id,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Poll relays for inbound NIP-44 handshake messages, then dispatch:
|
||||
/// - `PeerRequest` → queue in `federation::pending` for approval
|
||||
/// - `PeerInvite` → apply via federation invite flow (adds as Observer)
|
||||
/// - `PeerReject` → mark matching outbound row as `Rejected`
|
||||
///
|
||||
/// Never auto-adds peers, never auto-responds, never sends our onion.
|
||||
/// Background relay poll (2026-07-22): before this, `handshake.poll` ran
|
||||
/// ONLY when a user opened Federation and pressed the Poll button — a
|
||||
/// peer request sat on the relay until the target's operator happened to
|
||||
/// click, i.e. for most nodes forever ("requests never arrive"). Runs the
|
||||
/// same poll+dispatch as the RPC (the disabled gate inside still applies)
|
||||
/// and nudges the websocket revision when anything new lands so open UIs
|
||||
/// refresh immediately.
|
||||
pub async fn background_handshake_poll(self: &std::sync::Arc<Self>) {
|
||||
match self.handle_handshake_poll().await {
|
||||
Ok(res) => {
|
||||
let new = res
|
||||
.get("new_requests")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| a.len())
|
||||
.unwrap_or(0);
|
||||
let applied = res
|
||||
.get("applied_invites")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| a.len())
|
||||
.unwrap_or(0);
|
||||
if new > 0 || applied > 0 {
|
||||
tracing::info!(
|
||||
new_requests = new,
|
||||
applied_invites = applied,
|
||||
"handshake poll: inbound peer activity"
|
||||
);
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
self.state_manager.update_data(data).await;
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::debug!("background handshake poll failed: {e:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn handle_handshake_poll(&self) -> Result<serde_json::Value> {
|
||||
// Runtime gate: if the user hasn't enabled discoverability, don't
|
||||
// touch the relays. The poll endpoint is a hard no-op until they
|
||||
// explicitly opt in via the Federation UI toggle.
|
||||
let state = load_discovery_state(&self.config.data_dir).await;
|
||||
if !state.enabled {
|
||||
return Ok(serde_json::json!({
|
||||
"polled": 0,
|
||||
"new_requests": Vec::<PendingPeerRequest>::new(),
|
||||
"applied_invites": Vec::<String>::new(),
|
||||
"rejected_outbound": Vec::<String>::new(),
|
||||
"skipped": Vec::<String>::new(),
|
||||
"discovery_disabled": true,
|
||||
}));
|
||||
}
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let relays = self.handshake_relays().await;
|
||||
let handshakes = nostr_handshake::poll_handshakes(
|
||||
&identity_dir,
|
||||
&relays,
|
||||
self.config.nostr_tor_proxy.as_deref(),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut new_requests: Vec<PendingPeerRequest> = Vec::new();
|
||||
let mut applied_invites: Vec<String> = Vec::new();
|
||||
let mut rejected_outbound: Vec<String> = Vec::new();
|
||||
let mut cancelled_inbound: Vec<String> = Vec::new();
|
||||
let mut skipped: Vec<String> = Vec::new();
|
||||
|
||||
for hs in &handshakes {
|
||||
match &hs.message {
|
||||
HandshakeMessage::PeerRequest {
|
||||
from_did,
|
||||
version: _,
|
||||
name,
|
||||
message,
|
||||
} => {
|
||||
match pending::insert_inbound(
|
||||
&self.config.data_dir,
|
||||
hs.from_nostr_pubkey.clone(),
|
||||
hs.from_nostr_npub.clone(),
|
||||
from_did.clone(),
|
||||
name.clone(),
|
||||
message.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(row)) => new_requests.push(row),
|
||||
Ok(None) => skipped.push(hs.from_nostr_pubkey.clone()),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
from = %hs.from_nostr_pubkey,
|
||||
error = %e,
|
||||
"Dropped peer request (rate limit or storage error)"
|
||||
);
|
||||
skipped.push(hs.from_nostr_pubkey.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
HandshakeMessage::PeerInvite { invite_code } => {
|
||||
// Match against an outbound Sent request from this nostr
|
||||
// pubkey. If we never sent them anything, ignore — we
|
||||
// don't accept unsolicited invites over Nostr.
|
||||
let pendings = pending::load_pending(&self.config.data_dir).await?;
|
||||
let matching = pendings.iter().find(|r| {
|
||||
r.outbound
|
||||
&& r.from_nostr_pubkey == hs.from_nostr_pubkey
|
||||
&& matches!(r.state, PendingState::Sent)
|
||||
});
|
||||
let Some(row) = matching else {
|
||||
tracing::warn!(
|
||||
from = %hs.from_nostr_pubkey,
|
||||
"Ignoring unsolicited PeerInvite — no matching Sent request"
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let row_id = row.id.clone();
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let local_did =
|
||||
crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)
|
||||
.unwrap_or_default();
|
||||
let local_onion = data.server_info.tor_address.clone().unwrap_or_default();
|
||||
let local_pubkey = data.server_info.pubkey.clone();
|
||||
|
||||
let identity_dir2 = self.config.data_dir.join("identity");
|
||||
let node_identity =
|
||||
crate::identity::NodeIdentity::load_or_create(&identity_dir2).await?;
|
||||
let local_fips_npub = crate::identity::fips_npub(&identity_dir2)
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
let local_name = data.server_info.name.clone();
|
||||
match crate::federation::accept_invite(
|
||||
&self.config.data_dir,
|
||||
invite_code,
|
||||
&local_did,
|
||||
&local_onion,
|
||||
&local_pubkey,
|
||||
local_fips_npub.as_deref(),
|
||||
local_name.as_deref(),
|
||||
|bytes| node_identity.sign(bytes),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(node) => {
|
||||
// Approved-by-them: their box already has us as Observer
|
||||
// (their approval handler added us under that trust level
|
||||
// before sending the invite). Discovery invites are now
|
||||
// minted with trust=observer, so accept_invite already
|
||||
// lands on Observer; keep this explicit demotion as a
|
||||
// safety net for legacy Trusted-only invite codes — the
|
||||
// discovery flow should never auto-trust.
|
||||
let _ = crate::federation::set_trust_level(
|
||||
&self.config.data_dir,
|
||||
&node.did,
|
||||
crate::federation::TrustLevel::Observer,
|
||||
)
|
||||
.await;
|
||||
|
||||
// Mirror into the mesh peer table immediately so the
|
||||
// chat UI can address the new peer without waiting
|
||||
// for the next mesh restart.
|
||||
let svc = self.mesh_service.read().await;
|
||||
if let Some(svc) = svc.as_ref() {
|
||||
crate::mesh::upsert_federation_peer(
|
||||
&svc.shared_state(),
|
||||
&node.pubkey,
|
||||
&node.did,
|
||||
node.name.as_deref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
pending::set_state(
|
||||
&self.config.data_dir,
|
||||
&row_id,
|
||||
PendingState::Approved,
|
||||
)
|
||||
.await?;
|
||||
applied_invites.push(node.did);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
from = %hs.from_nostr_pubkey,
|
||||
error = %e,
|
||||
"Failed to apply PeerInvite"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
HandshakeMessage::PeerReject { reason } => {
|
||||
let pendings = pending::load_pending(&self.config.data_dir).await?;
|
||||
if let Some(row) = pendings.iter().find(|r| {
|
||||
r.outbound
|
||||
&& r.from_nostr_pubkey == hs.from_nostr_pubkey
|
||||
&& matches!(r.state, PendingState::Sent)
|
||||
}) {
|
||||
let row_id = row.id.clone();
|
||||
pending::set_state(&self.config.data_dir, &row_id, PendingState::Rejected)
|
||||
.await?;
|
||||
rejected_outbound.push(row_id);
|
||||
tracing::info!(
|
||||
from = %hs.from_nostr_pubkey,
|
||||
reason = ?reason,
|
||||
"Outbound peer request rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
HandshakeMessage::PeerCancel { reason } => {
|
||||
// Peer withdrew their PeerRequest — drop our matching
|
||||
// inbound pending row so it disappears from the UI.
|
||||
let pendings = pending::load_pending(&self.config.data_dir).await?;
|
||||
if let Some(row) = pendings.iter().find(|r| {
|
||||
!r.outbound
|
||||
&& r.from_nostr_pubkey == hs.from_nostr_pubkey
|
||||
&& matches!(r.state, PendingState::Pending)
|
||||
}) {
|
||||
let row_id = row.id.clone();
|
||||
pending::delete(&self.config.data_dir, &row_id).await?;
|
||||
cancelled_inbound.push(row_id);
|
||||
tracing::info!(
|
||||
from = %hs.from_nostr_pubkey,
|
||||
reason = ?reason,
|
||||
"Inbound peer request cancelled by sender"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"polled": handshakes.len(),
|
||||
"new_requests": new_requests,
|
||||
"applied_invites": applied_invites,
|
||||
"rejected_outbound": rejected_outbound,
|
||||
"cancelled_inbound": cancelled_inbound,
|
||||
"skipped": skipped,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,879 @@
|
||||
use super::*;
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use crate::identity_manager::{IdentityManager, IdentityProfile, IdentityPurpose};
|
||||
use crate::network::did_dht;
|
||||
use anyhow::{Context, Result};
|
||||
use nostr_sdk::ToBech32;
|
||||
|
||||
impl RpcHandler {
|
||||
/// List all identities with their default status.
|
||||
pub(in crate::api::rpc) async fn handle_identity_list(
|
||||
&self,
|
||||
_params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||
let (identities, default_id) = manager.list().await?;
|
||||
|
||||
// #49: The canonical node Nostr key is the node-level HKDF key
|
||||
// (`derive_node_nostr_key`) that Settings and Nostr discovery both use
|
||||
// via `node.nostr-pubkey`. The mirrored "Node" identity stores
|
||||
// nostr=None, and seed identities use a different BIP-32 NIP-06 key, so
|
||||
// the "Node" entry in Web5 > Identities disagreed with Settings. Resolve
|
||||
// the node-level key once and override it onto whichever identity record
|
||||
// is the node's own (its ed25519 matches `server_info.pubkey`), so both
|
||||
// surfaces always show the same npub. Display-only — no key is rewritten.
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let node_nostr_hex = crate::nostr_discovery::get_nostr_pubkey(&identity_dir)
|
||||
.await
|
||||
.ok();
|
||||
let node_nostr_npub = node_nostr_hex.as_ref().and_then(|h| {
|
||||
nostr_sdk::PublicKey::from_hex(h)
|
||||
.ok()
|
||||
.and_then(|pk| pk.to_bech32().ok())
|
||||
});
|
||||
let (snapshot, _) = self.state_manager.get_snapshot().await;
|
||||
let node_pubkey_hex = snapshot.server_info.pubkey.clone();
|
||||
|
||||
let items: Vec<serde_json::Value> = identities
|
||||
.into_iter()
|
||||
.map(|id| {
|
||||
let is_default = default_id.as_deref() == Some(&id.id);
|
||||
let is_node = !node_pubkey_hex.is_empty() && id.pubkey_hex == node_pubkey_hex;
|
||||
let (nostr_pubkey, nostr_npub) = if is_node {
|
||||
(
|
||||
node_nostr_hex.clone().or(id.nostr_pubkey),
|
||||
node_nostr_npub.clone().or(id.nostr_npub),
|
||||
)
|
||||
} else {
|
||||
(id.nostr_pubkey, id.nostr_npub)
|
||||
};
|
||||
serde_json::json!({
|
||||
"id": id.id,
|
||||
"name": id.name,
|
||||
"purpose": id.purpose,
|
||||
"pubkey": id.pubkey_hex,
|
||||
"did": id.did,
|
||||
"created_at": id.created_at,
|
||||
"is_default": is_default,
|
||||
"nostr_pubkey": nostr_pubkey,
|
||||
"nostr_npub": nostr_npub,
|
||||
"profile": id.profile,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(serde_json::json!({ "identities": items }))
|
||||
}
|
||||
|
||||
/// Create a new identity.
|
||||
pub(in crate::api::rpc) async fn handle_identity_create(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let name = params
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Personal");
|
||||
if name.len() > 100 {
|
||||
anyhow::bail!("Identity name must be 100 characters or fewer");
|
||||
}
|
||||
let name = name.to_string();
|
||||
|
||||
let purpose_str = params
|
||||
.get("purpose")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("personal");
|
||||
|
||||
let purpose = match purpose_str {
|
||||
"business" => IdentityPurpose::Business,
|
||||
"anonymous" => IdentityPurpose::Anonymous,
|
||||
_ => IdentityPurpose::Personal,
|
||||
};
|
||||
|
||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||
let record = manager.create(name, purpose).await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"id": record.id,
|
||||
"name": record.name,
|
||||
"purpose": record.purpose,
|
||||
"pubkey": record.pubkey_hex,
|
||||
"did": record.did,
|
||||
"created_at": record.created_at,
|
||||
"nostr_pubkey": record.nostr_pubkey,
|
||||
"nostr_npub": record.nostr_npub,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Get a single identity by ID.
|
||||
pub(in crate::api::rpc) async fn handle_identity_get(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let id = params
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
|
||||
validate_identity_id(id)?;
|
||||
|
||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||
let record = manager.get(id).await?;
|
||||
let (_, default_id) = manager.list().await?;
|
||||
let is_default = default_id.as_deref() == Some(&record.id);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"id": record.id,
|
||||
"name": record.name,
|
||||
"purpose": record.purpose,
|
||||
"pubkey": record.pubkey_hex,
|
||||
"did": record.did,
|
||||
"created_at": record.created_at,
|
||||
"is_default": is_default,
|
||||
"nostr_pubkey": record.nostr_pubkey,
|
||||
"nostr_npub": record.nostr_npub,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Delete an identity.
|
||||
pub(in crate::api::rpc) async fn handle_identity_delete(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let id = params
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
|
||||
validate_identity_id(id)?;
|
||||
|
||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||
manager.delete(id).await?;
|
||||
|
||||
Ok(serde_json::json!({ "ok": true }))
|
||||
}
|
||||
|
||||
/// Set the default identity.
|
||||
pub(in crate::api::rpc) async fn handle_identity_set_default(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let id = params
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
|
||||
validate_identity_id(id)?;
|
||||
|
||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||
manager.set_default(id).await?;
|
||||
|
||||
Ok(serde_json::json!({ "ok": true }))
|
||||
}
|
||||
|
||||
/// Sign a message with a specific identity.
|
||||
pub(in crate::api::rpc) async fn handle_identity_sign(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let id = params
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
|
||||
let message = params
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: message"))?;
|
||||
|
||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||
let signature = manager.sign(id, message.as_bytes()).await?;
|
||||
let record = manager.get(id).await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"did": record.did,
|
||||
"message": message,
|
||||
"signature": signature,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Verify a signature against a DID.
|
||||
pub(in crate::api::rpc) async fn handle_identity_verify(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let did = params
|
||||
.get("did")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: did"))?;
|
||||
let message = params
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: message"))?;
|
||||
let signature = params
|
||||
.get("signature")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: signature"))?;
|
||||
|
||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||
let valid = manager.verify(did, message.as_bytes(), signature).await?;
|
||||
|
||||
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(in crate::api::rpc) 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 is_local = params.get("did").and_then(|v| v.as_str()).is_none();
|
||||
let pubkey_hex = if let Some(did) = params.get("did").and_then(|v| v.as_str()) {
|
||||
let pubkey_bytes = crate::identity::pubkey_bytes_from_did_key(did)?;
|
||||
hex::encode(pubkey_bytes)
|
||||
} else {
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
data.server_info.pubkey.clone()
|
||||
};
|
||||
|
||||
// For local node, include Nostr secp256k1 key in DID Document (paired identity)
|
||||
let document = if is_local {
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
match crate::nostr_discovery::get_nostr_pubkey(&identity_dir).await {
|
||||
Ok(nostr_pubkey) => {
|
||||
crate::identity::did_document_with_nostr(&pubkey_hex, &nostr_pubkey)?
|
||||
}
|
||||
Err(_) => crate::identity::did_document_from_pubkey_hex(&pubkey_hex)?,
|
||||
}
|
||||
} else {
|
||||
crate::identity::did_document_from_pubkey_hex(&pubkey_hex)?
|
||||
};
|
||||
Ok(document)
|
||||
}
|
||||
|
||||
/// Verify a DID Document: validate structure, check key material matches DID.
|
||||
pub(in crate::api::rpc) 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()
|
||||
.is_none_or(|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(in crate::api::rpc) async fn handle_identity_create_nostr_key(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let id = params
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
|
||||
|
||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||
let pubkey_hex = manager.create_nostr_key(id).await?;
|
||||
|
||||
// Derive npub (bech32 NIP-19) from hex
|
||||
let npub = nostr_sdk::PublicKey::from_hex(&pubkey_hex)
|
||||
.ok()
|
||||
.and_then(|pk| pk.to_bech32().ok());
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"nostr_pubkey": pubkey_hex,
|
||||
"nostr_npub": npub,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Sign a Nostr event with an identity's Nostr key.
|
||||
///
|
||||
/// Accepts either:
|
||||
/// - `event_hash` (hex) + `id` — sign a pre-computed hash
|
||||
/// - `event` (full event object) — compute NIP-01 hash, fill pubkey, sign
|
||||
/// If `id` is omitted, uses the default identity.
|
||||
pub(in crate::api::rpc) async fn handle_identity_nostr_sign(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||
let (records, _) = manager.list().await?;
|
||||
|
||||
// Resolve identity: prefer explicit id, then default, then any with Nostr key
|
||||
let id = if let Some(id) = params.get("id").and_then(|v| v.as_str()) {
|
||||
id.to_string()
|
||||
} else {
|
||||
// Prefer an identity with a Nostr key
|
||||
records
|
||||
.iter()
|
||||
.find(|r| r.nostr_pubkey.is_some())
|
||||
.map(|r| r.id.clone())
|
||||
.ok_or_else(|| anyhow::anyhow!("No identity with Nostr key found"))?
|
||||
};
|
||||
|
||||
let identity = records
|
||||
.iter()
|
||||
.find(|r| r.id == id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Identity not found: {}", id))?;
|
||||
let pubkey_hex = identity
|
||||
.nostr_pubkey
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow::anyhow!("Identity has no Nostr key"))?;
|
||||
|
||||
if let Some(event_hash) = params.get("event_hash").and_then(|v| v.as_str()) {
|
||||
// Direct hash signing
|
||||
let signature = manager.nostr_sign(&id, event_hash).await?;
|
||||
return Ok(serde_json::json!({ "signature": signature }));
|
||||
}
|
||||
|
||||
// Full event signing: compute NIP-01 event hash
|
||||
let event = params
|
||||
.get("event")
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'event' or 'event_hash' parameter"))?;
|
||||
|
||||
let kind = event.get("kind").and_then(|v| v.as_u64()).unwrap_or(1);
|
||||
let content = event.get("content").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let created_at = event
|
||||
.get("created_at")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or_else(|| {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
});
|
||||
let tags = event
|
||||
.get("tags")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| serde_json::json!([]));
|
||||
|
||||
// NIP-01 serialization: [0, pubkey, created_at, kind, tags, content]
|
||||
let serialized = serde_json::json!([0, pubkey_hex, created_at, kind, tags, content]);
|
||||
let serialized_str = serde_json::to_string(&serialized)?;
|
||||
|
||||
// SHA-256 hash
|
||||
use sha2::{Digest, Sha256};
|
||||
let hash = Sha256::digest(serialized_str.as_bytes());
|
||||
let event_hash_hex = hex::encode(hash);
|
||||
|
||||
let signature = manager.nostr_sign(&id, &event_hash_hex).await?;
|
||||
|
||||
// Return the complete signed event
|
||||
Ok(serde_json::json!({
|
||||
"id": event_hash_hex,
|
||||
"pubkey": pubkey_hex,
|
||||
"created_at": created_at,
|
||||
"kind": kind,
|
||||
"tags": tags,
|
||||
"content": content,
|
||||
"sig": signature,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Resolve the identity ID from params, falling back to the default identity.
|
||||
async fn resolve_identity_id(&self, params: &serde_json::Value) -> Result<String> {
|
||||
if let Some(id) = params.get("id").and_then(|v| v.as_str()) {
|
||||
return Ok(id.to_string());
|
||||
}
|
||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||
let (records, default_id) = manager.list().await?;
|
||||
// Prefer the default identity
|
||||
if let Some(default_id) = default_id {
|
||||
return Ok(default_id);
|
||||
}
|
||||
// Fall back to first identity with a Nostr key, or just the first identity
|
||||
records
|
||||
.iter()
|
||||
.find(|i| i.nostr_pubkey.is_some())
|
||||
.or(records.first())
|
||||
.map(|i| i.id.clone())
|
||||
.ok_or_else(|| anyhow::anyhow!("No identity found"))
|
||||
}
|
||||
|
||||
/// NIP-04 encrypt plaintext for a peer.
|
||||
pub(in crate::api::rpc) async fn handle_identity_nostr_encrypt_nip04(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let id = self.resolve_identity_id(¶ms).await?;
|
||||
let pubkey = params
|
||||
.get("pubkey")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: pubkey"))?;
|
||||
let plaintext = params
|
||||
.get("plaintext")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: plaintext"))?;
|
||||
|
||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||
let ciphertext = manager.nostr_encrypt_nip04(&id, pubkey, plaintext).await?;
|
||||
|
||||
Ok(serde_json::json!({ "ciphertext": ciphertext }))
|
||||
}
|
||||
|
||||
/// NIP-04 decrypt ciphertext from a peer.
|
||||
pub(in crate::api::rpc) async fn handle_identity_nostr_decrypt_nip04(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let id = self.resolve_identity_id(¶ms).await?;
|
||||
let pubkey = params
|
||||
.get("pubkey")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: pubkey"))?;
|
||||
let ciphertext = params
|
||||
.get("ciphertext")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: ciphertext"))?;
|
||||
|
||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||
let plaintext = manager.nostr_decrypt_nip04(&id, pubkey, ciphertext).await?;
|
||||
|
||||
Ok(serde_json::json!({ "plaintext": plaintext }))
|
||||
}
|
||||
|
||||
/// NIP-44 encrypt plaintext for a peer.
|
||||
pub(in crate::api::rpc) async fn handle_identity_nostr_encrypt_nip44(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let id = self.resolve_identity_id(¶ms).await?;
|
||||
let pubkey = params
|
||||
.get("pubkey")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: pubkey"))?;
|
||||
let plaintext = params
|
||||
.get("plaintext")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: plaintext"))?;
|
||||
|
||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||
let ciphertext = manager.nostr_encrypt_nip44(&id, pubkey, plaintext).await?;
|
||||
|
||||
Ok(serde_json::json!({ "ciphertext": ciphertext }))
|
||||
}
|
||||
|
||||
/// NIP-44 decrypt ciphertext from a peer.
|
||||
pub(in crate::api::rpc) async fn handle_identity_nostr_decrypt_nip44(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let id = self.resolve_identity_id(¶ms).await?;
|
||||
let pubkey = params
|
||||
.get("pubkey")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: pubkey"))?;
|
||||
let ciphertext = params
|
||||
.get("ciphertext")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: ciphertext"))?;
|
||||
|
||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||
let plaintext = manager.nostr_decrypt_nip44(&id, pubkey, ciphertext).await?;
|
||||
|
||||
Ok(serde_json::json!({ "plaintext": plaintext }))
|
||||
}
|
||||
|
||||
/// Resolve a remote peer's DID Document over Tor.
|
||||
/// Queries the peer's /rpc/ endpoint for identity.resolve-did.
|
||||
pub(in crate::api::rpc) 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(crate::constants::TOR_SOCKS_PROXY)
|
||||
.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,
|
||||
}))
|
||||
}
|
||||
|
||||
/// identity.create-dht-did — Publish an identity's DID to the Mainline DHT.
|
||||
pub(in crate::api::rpc) async fn handle_identity_create_dht_did(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let identity_id = params
|
||||
.get("identity_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing identity_id"))?;
|
||||
validate_identity_id(identity_id)?;
|
||||
|
||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||
let signing_key = manager.get_signing_key(identity_id).await?;
|
||||
|
||||
let dht_did = did_dht::create_and_publish(&signing_key, &[]).await?;
|
||||
|
||||
// Save the dht_did back to the identity record
|
||||
did_dht::save_dht_did(&self.config.data_dir, identity_id, &dht_did).await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"dht_did": dht_did,
|
||||
"published": true,
|
||||
}))
|
||||
}
|
||||
|
||||
/// identity.resolve-dht-did — Resolve a did:dht from the DHT.
|
||||
pub(in crate::api::rpc) async fn handle_identity_resolve_dht_did(
|
||||
&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"))?;
|
||||
|
||||
if !did.starts_with("did:dht:") {
|
||||
anyhow::bail!("Not a did:dht identifier");
|
||||
}
|
||||
|
||||
let doc = did_dht::resolve(did, None).await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"did": did,
|
||||
"document": doc,
|
||||
}))
|
||||
}
|
||||
|
||||
/// identity.refresh-dht-did — Re-publish an identity's did:dht to keep it alive in the DHT.
|
||||
pub(in crate::api::rpc) async fn handle_identity_refresh_dht_did(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let identity_id = params
|
||||
.get("identity_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing identity_id"))?;
|
||||
validate_identity_id(identity_id)?;
|
||||
|
||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||
let record = manager.get(identity_id).await?;
|
||||
|
||||
if record.dht_did.is_none() {
|
||||
anyhow::bail!(
|
||||
"Identity has no did:dht — create one first with identity.create-dht-did"
|
||||
);
|
||||
}
|
||||
|
||||
let signing_key = manager.get_signing_key(identity_id).await?;
|
||||
let dht_did = did_dht::create_and_publish(&signing_key, &[]).await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"dht_did": dht_did,
|
||||
"refreshed": true,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Update profile metadata for an identity.
|
||||
pub(in crate::api::rpc) async fn handle_identity_update_profile(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let id = params
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
|
||||
validate_identity_id(id)?;
|
||||
|
||||
let profile = IdentityProfile {
|
||||
display_name: params
|
||||
.get("display_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
about: params
|
||||
.get("about")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
picture: params
|
||||
.get("picture")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
banner: params
|
||||
.get("banner")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
website: params
|
||||
.get("website")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
nip05: params
|
||||
.get("nip05")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
lud16: params
|
||||
.get("lud16")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
};
|
||||
|
||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||
manager.update_profile(id, profile).await?;
|
||||
|
||||
Ok(serde_json::json!({ "ok": true }))
|
||||
}
|
||||
|
||||
/// Publish kind 0 (metadata) profile to every enabled Nostr relay
|
||||
/// configured in Manage Relays. Callers can override the default
|
||||
/// list by passing `relays: [..]` in params (e.g. to publish to a
|
||||
/// single relay for testing).
|
||||
pub(in crate::api::rpc) async fn handle_identity_publish_profile(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let id = params
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
|
||||
validate_identity_id(id)?;
|
||||
|
||||
let relay_urls: Vec<String> =
|
||||
if let Some(arr) = params.get("relays").and_then(|v| v.as_array()) {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.collect()
|
||||
} else if let Some(single) = params.get("relay").and_then(|v| v.as_str()) {
|
||||
vec![single.to_string()]
|
||||
} else {
|
||||
// Default: every enabled relay in the user's Manage Relays list.
|
||||
let statuses = crate::nostr_relays::list_relays(&self.config.data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
statuses
|
||||
.into_iter()
|
||||
.filter(|s| s.enabled)
|
||||
.map(|s| s.url)
|
||||
.collect()
|
||||
};
|
||||
|
||||
if relay_urls.is_empty() {
|
||||
anyhow::bail!("No enabled relays configured; add one under Manage Relays");
|
||||
}
|
||||
|
||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||
let outcome = manager.publish_profile(id, &relay_urls).await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"event_id": outcome.event_id,
|
||||
"accepted": outcome.accepted,
|
||||
"rejected": outcome.rejected,
|
||||
"relays_attempted": relay_urls.len(),
|
||||
"published": !outcome.accepted.is_empty(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Export private keys for an identity — REQUIRES password verification.
|
||||
pub(in crate::api::rpc) async fn handle_identity_export_keys(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let id = params
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
|
||||
let password = params
|
||||
.get("password")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: password"))?;
|
||||
validate_identity_id(id)?;
|
||||
|
||||
// Verify password against auth system
|
||||
if !self.auth_manager.verify_password(password).await? {
|
||||
anyhow::bail!("Invalid password");
|
||||
}
|
||||
|
||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||
let keys = manager.export_keys(id).await?;
|
||||
let record = manager.get(id).await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"id": record.id,
|
||||
"name": record.name,
|
||||
"pubkey": record.pubkey_hex,
|
||||
"did": record.did,
|
||||
"nostr_pubkey": record.nostr_pubkey,
|
||||
"nostr_npub": record.nostr_npub,
|
||||
"ed25519_secret_hex": keys["ed25519_secret_hex"],
|
||||
"nostr_secret_hex": keys["nostr_secret_hex"],
|
||||
"nostr_nsec": keys["nostr_nsec"],
|
||||
}))
|
||||
}
|
||||
|
||||
/// identity.dht-status — Check if an identity's did:dht is published and resolvable.
|
||||
pub(in crate::api::rpc) async fn handle_identity_dht_status(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let identity_id = params
|
||||
.get("identity_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing identity_id"))?;
|
||||
validate_identity_id(identity_id)?;
|
||||
|
||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||
let record = manager.get(identity_id).await?;
|
||||
|
||||
let (published, resolvable) = match &record.dht_did {
|
||||
Some(dht_did) => {
|
||||
let resolvable = did_dht::resolve(dht_did, None).await.is_ok();
|
||||
(true, resolvable)
|
||||
}
|
||||
None => (false, false),
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"identity_id": identity_id,
|
||||
"did_key": record.did,
|
||||
"dht_did": record.dht_did,
|
||||
"published": published,
|
||||
"resolvable": resolvable,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
mod handlers;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
pub(super) fn validate_identity_id(id: &str) -> Result<()> {
|
||||
if id.is_empty() || id.len() > 128 {
|
||||
anyhow::bail!("Invalid identity id: must be 1-128 characters");
|
||||
}
|
||||
if id.contains("..") || id.contains('/') || id.contains('\\') || id.contains('\0') {
|
||||
anyhow::bail!("Invalid identity id: contains forbidden characters");
|
||||
}
|
||||
if !id
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b':')
|
||||
{
|
||||
anyhow::bail!("Invalid identity id: must be alphanumeric, hyphens, underscores, or colons");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
use super::RpcHandler;
|
||||
use crate::network::dns;
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::debug;
|
||||
|
||||
impl RpcHandler {
|
||||
/// network.list-interfaces — list all network interfaces with IP, MAC, status.
|
||||
pub(super) async fn handle_network_list_interfaces(&self) -> Result<serde_json::Value> {
|
||||
debug!("Listing network interfaces");
|
||||
let interfaces = list_interfaces().await?;
|
||||
Ok(serde_json::json!({ "interfaces": interfaces }))
|
||||
}
|
||||
|
||||
/// network.scan-wifi — scan for available WiFi networks.
|
||||
pub(super) async fn handle_network_scan_wifi(&self) -> Result<serde_json::Value> {
|
||||
debug!("Scanning WiFi networks");
|
||||
let networks = scan_wifi().await?;
|
||||
Ok(serde_json::json!({ "networks": networks }))
|
||||
}
|
||||
|
||||
/// network.set-wifi-radio — turn the wifi adapter fully on or off (not just
|
||||
/// disconnect from a network). Params: `{ "enabled": bool }`.
|
||||
pub(super) async fn handle_network_set_wifi_radio(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let enabled = params
|
||||
.get("enabled")
|
||||
.and_then(|v| v.as_bool())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: enabled"))?;
|
||||
|
||||
tracing::info!(enabled, "Setting wifi radio state");
|
||||
set_wifi_radio(enabled).await?;
|
||||
|
||||
Ok(serde_json::json!({ "ok": true, "enabled": enabled }))
|
||||
}
|
||||
|
||||
/// network.configure-wifi — connect to a WiFi network.
|
||||
pub(super) async fn handle_network_configure_wifi(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let ssid = params
|
||||
.get("ssid")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: ssid"))?;
|
||||
let password = params
|
||||
.get("password")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
// Validate SSID (prevent command injection)
|
||||
if ssid.len() > 64 || ssid.contains('\0') {
|
||||
anyhow::bail!("Invalid SSID");
|
||||
}
|
||||
// Validate WiFi password
|
||||
if password.len() > 63 || password.contains('\0') {
|
||||
anyhow::bail!("Invalid WiFi password (max 63 chars, no null bytes)");
|
||||
}
|
||||
|
||||
tracing::info!("Connecting to WiFi network: {}", ssid);
|
||||
connect_wifi(ssid, password).await?;
|
||||
|
||||
Ok(serde_json::json!({ "ok": true, "ssid": ssid }))
|
||||
}
|
||||
|
||||
/// network.configure-ethernet — set DHCP or static IP for an ethernet interface.
|
||||
pub(super) async fn handle_network_configure_ethernet(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let interface = params
|
||||
.get("interface")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: interface"))?;
|
||||
let mode = params
|
||||
.get("mode")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("dhcp");
|
||||
|
||||
// Validate interface name (alphanumeric + digits only)
|
||||
if !interface
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
|
||||
{
|
||||
anyhow::bail!("Invalid interface name");
|
||||
}
|
||||
|
||||
match mode {
|
||||
"dhcp" => {
|
||||
tracing::info!("Setting {} to DHCP", interface);
|
||||
configure_ethernet_dhcp(interface).await?;
|
||||
}
|
||||
"static" => {
|
||||
let ip = params.get("ip").and_then(|v| v.as_str()).ok_or_else(|| {
|
||||
anyhow::anyhow!("Missing required parameter: ip for static mode")
|
||||
})?;
|
||||
let gateway = params.get("gateway").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let dns = params
|
||||
.get("dns")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("1.1.1.1");
|
||||
|
||||
// Validate IP: must parse as IP or CIDR
|
||||
let ip_part = ip.split('/').next().unwrap_or("");
|
||||
if ip_part.parse::<std::net::IpAddr>().is_err() {
|
||||
anyhow::bail!("Invalid IP address format");
|
||||
}
|
||||
|
||||
// Validate gateway if provided
|
||||
if !gateway.is_empty() && gateway.parse::<std::net::IpAddr>().is_err() {
|
||||
anyhow::bail!("Invalid gateway IP address");
|
||||
}
|
||||
|
||||
// Validate DNS server IP
|
||||
if dns.parse::<std::net::IpAddr>().is_err() {
|
||||
anyhow::bail!("Invalid DNS server IP address");
|
||||
}
|
||||
|
||||
tracing::info!("Setting {} to static IP {}", interface, ip);
|
||||
configure_ethernet_static(interface, ip, gateway, dns).await?;
|
||||
}
|
||||
_ => anyhow::bail!("Invalid mode: {}. Use 'dhcp' or 'static'", mode),
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({ "ok": true, "interface": interface, "mode": mode }))
|
||||
}
|
||||
|
||||
/// network.dns-status — get current DNS configuration and status.
|
||||
pub(super) async fn handle_network_dns_status(&self) -> Result<serde_json::Value> {
|
||||
debug!("Getting DNS status");
|
||||
let status = dns::get_status(&self.config.data_dir).await?;
|
||||
Ok(serde_json::to_value(status)?)
|
||||
}
|
||||
|
||||
/// network.configure-dns — configure DNS servers and provider.
|
||||
pub(super) async fn handle_network_configure_dns(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let provider_str = params
|
||||
.get("provider")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: provider"))?;
|
||||
|
||||
let provider = match provider_str {
|
||||
"system" => dns::DnsProvider::System,
|
||||
"cloudflare" => dns::DnsProvider::Cloudflare,
|
||||
"google" => dns::DnsProvider::Google,
|
||||
"quad9" => dns::DnsProvider::Quad9,
|
||||
"mullvad" => dns::DnsProvider::Mullvad,
|
||||
"custom" => dns::DnsProvider::Custom,
|
||||
other => anyhow::bail!(
|
||||
"Unknown DNS provider: {}. Use: system, cloudflare, google, quad9, mullvad, custom",
|
||||
other
|
||||
),
|
||||
};
|
||||
|
||||
let custom_servers: Vec<String> = if provider == dns::DnsProvider::Custom {
|
||||
params
|
||||
.get("servers")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(String::from))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
if provider == dns::DnsProvider::Custom && custom_servers.is_empty() {
|
||||
anyhow::bail!("Custom provider requires at least one DNS server in 'servers' array");
|
||||
}
|
||||
|
||||
// Validate custom server IPs
|
||||
for s in &custom_servers {
|
||||
if s.parse::<std::net::IpAddr>().is_err() {
|
||||
anyhow::bail!("Invalid DNS server IP: {}", s);
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(provider = provider_str, "Configuring DNS");
|
||||
let config = dns::configure(&self.config.data_dir, provider, custom_servers).await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"ok": true,
|
||||
"provider": config.provider.to_string(),
|
||||
"servers": config.servers,
|
||||
"doh_enabled": config.doh_enabled,
|
||||
"doh_url": config.doh_url,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// List network interfaces using `ip -j addr show`.
|
||||
async fn list_interfaces() -> Result<Vec<serde_json::Value>> {
|
||||
let output = tokio::process::Command::new("ip")
|
||||
.args(["-j", "addr", "show"])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to run `ip addr show`")?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"ip addr show failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
let raw: Vec<serde_json::Value> =
|
||||
serde_json::from_slice(&output.stdout).context("Failed to parse ip JSON output")?;
|
||||
|
||||
let interfaces: Vec<serde_json::Value> = raw
|
||||
.into_iter()
|
||||
.filter_map(|iface| {
|
||||
let name = iface.get("ifname")?.as_str()?;
|
||||
// Skip loopback
|
||||
if name == "lo" {
|
||||
return None;
|
||||
}
|
||||
let operstate = iface
|
||||
.get("operstate")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("UNKNOWN");
|
||||
let mac = iface.get("address").and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
// Get IPv4 addresses
|
||||
let addrs: Vec<String> = iface
|
||||
.get("addr_info")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter(|a| a.get("family").and_then(|f| f.as_str()) == Some("inet"))
|
||||
.filter_map(|a| {
|
||||
let local = a.get("local")?.as_str()?;
|
||||
let prefix = a.get("prefixlen")?.as_u64()?;
|
||||
Some(format!("{}/{}", local, prefix))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let iface_type = if name.starts_with("wl") {
|
||||
"wifi"
|
||||
} else if name.starts_with("en") || name.starts_with("eth") {
|
||||
"ethernet"
|
||||
} else if name.starts_with("veth")
|
||||
|| name.starts_with("br-")
|
||||
|| name.starts_with("docker")
|
||||
|| name.starts_with("podman")
|
||||
{
|
||||
"virtual"
|
||||
} else {
|
||||
"other"
|
||||
};
|
||||
|
||||
Some(serde_json::json!({
|
||||
"name": name,
|
||||
"type": iface_type,
|
||||
"state": operstate.to_lowercase(),
|
||||
"mac": mac,
|
||||
"ipv4": addrs,
|
||||
}))
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(interfaces)
|
||||
}
|
||||
|
||||
/// Scan WiFi networks using `nmcli -t -f SSID,SIGNAL,SECURITY device wifi list`.
|
||||
async fn scan_wifi() -> Result<Vec<serde_json::Value>> {
|
||||
// Trigger a rescan first
|
||||
let _ = tokio::process::Command::new("nmcli")
|
||||
.args(["device", "wifi", "rescan"])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
// Short delay for scan to complete
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
|
||||
let output = tokio::process::Command::new("nmcli")
|
||||
.args(["-t", "-f", "SSID,SIGNAL,SECURITY", "device", "wifi", "list"])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to run nmcli wifi list")?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"nmcli wifi list failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8(output.stdout).context("nmcli output not utf8")?;
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let networks: Vec<serde_json::Value> = stdout
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let parts = split_nmcli_escaped(line, 3);
|
||||
if parts.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
let ssid = parts[0].trim();
|
||||
if ssid.is_empty() || !seen.insert(ssid.to_string()) {
|
||||
return None;
|
||||
}
|
||||
let signal: u32 = parts[1].parse().unwrap_or(0);
|
||||
let security = parts[2].trim();
|
||||
Some(serde_json::json!({
|
||||
"ssid": ssid,
|
||||
"signal": signal,
|
||||
"security": security,
|
||||
}))
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(networks)
|
||||
}
|
||||
|
||||
fn split_nmcli_escaped(line: &str, limit: usize) -> Vec<String> {
|
||||
let mut fields = Vec::new();
|
||||
let mut current = String::new();
|
||||
let mut chars = line.chars();
|
||||
|
||||
while let Some(ch) = chars.next() {
|
||||
if ch == '\\' {
|
||||
if let Some(next) = chars.next() {
|
||||
current.push(next);
|
||||
}
|
||||
} else if ch == ':' && fields.len() + 1 < limit {
|
||||
fields.push(current);
|
||||
current = String::new();
|
||||
} else {
|
||||
current.push(ch);
|
||||
}
|
||||
}
|
||||
|
||||
fields.push(current);
|
||||
fields
|
||||
}
|
||||
|
||||
/// Turn the wifi radio fully on or off using nmcli (a rfkill-level toggle, not
|
||||
/// just disconnecting from the current network — the adapter stops scanning/
|
||||
/// associating entirely until switched back on).
|
||||
async fn set_wifi_radio(enabled: bool) -> Result<()> {
|
||||
let state = if enabled { "on" } else { "off" };
|
||||
let output = tokio::process::Command::new("nmcli")
|
||||
.args(["radio", "wifi", state])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to run nmcli radio wifi")?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"nmcli radio wifi {} failed: {}",
|
||||
state,
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Connect to a WiFi network using nmcli.
|
||||
async fn connect_wifi(ssid: &str, password: &str) -> Result<()> {
|
||||
let conn_name = format!("archipelago-wifi-{ssid}");
|
||||
|
||||
// Delete prior profiles for this SSID/name first. Failed attempts can leave
|
||||
// a profile with key-mgmt but no saved PSK, causing future retries to fail
|
||||
// with "no secrets" before the supplied password is used.
|
||||
let _ = tokio::process::Command::new("nmcli")
|
||||
.args(["connection", "delete", &conn_name])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("nmcli")
|
||||
.args(["connection", "delete", ssid])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
let mut args = vec![
|
||||
"connection",
|
||||
"add",
|
||||
"type",
|
||||
"wifi",
|
||||
"con-name",
|
||||
&conn_name,
|
||||
"ifname",
|
||||
"*",
|
||||
"ssid",
|
||||
ssid,
|
||||
"ipv4.method",
|
||||
"auto",
|
||||
"ipv6.method",
|
||||
"auto",
|
||||
];
|
||||
if !password.is_empty() {
|
||||
args.extend(["wifi-sec.key-mgmt", "wpa-psk", "wifi-sec.psk", password]);
|
||||
}
|
||||
|
||||
let output = tokio::process::Command::new("nmcli")
|
||||
.args(args)
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to run nmcli wifi profile create")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!("WiFi profile create failed: {}", stderr);
|
||||
}
|
||||
|
||||
let activate = tokio::process::Command::new("nmcli")
|
||||
.args(["connection", "up", &conn_name])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to run nmcli wifi connect")?;
|
||||
|
||||
if !activate.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&activate.stderr);
|
||||
let _ = tokio::process::Command::new("nmcli")
|
||||
.args(["connection", "delete", &conn_name])
|
||||
.output()
|
||||
.await;
|
||||
anyhow::bail!("WiFi connection failed: {}", stderr);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Configure ethernet interface for DHCP using nmcli.
|
||||
async fn configure_ethernet_dhcp(interface: &str) -> Result<()> {
|
||||
// Find or create a connection for this interface
|
||||
let conn_name = format!("archipelago-{}", interface);
|
||||
|
||||
// Delete existing connection if any
|
||||
let _ = tokio::process::Command::new("nmcli")
|
||||
.args(["connection", "delete", &conn_name])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
// Create new DHCP connection
|
||||
let output = tokio::process::Command::new("nmcli")
|
||||
.args([
|
||||
"connection",
|
||||
"add",
|
||||
"type",
|
||||
"ethernet",
|
||||
"con-name",
|
||||
&conn_name,
|
||||
"ifname",
|
||||
interface,
|
||||
"ipv4.method",
|
||||
"auto",
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to create DHCP connection")?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"nmcli connection add failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
// Activate the connection
|
||||
let activate = tokio::process::Command::new("nmcli")
|
||||
.args(["connection", "up", &conn_name])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to activate connection")?;
|
||||
|
||||
if !activate.status.success() {
|
||||
anyhow::bail!(
|
||||
"nmcli connection up failed: {}",
|
||||
String::from_utf8_lossy(&activate.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Configure ethernet interface with a static IP.
|
||||
async fn configure_ethernet_static(
|
||||
interface: &str,
|
||||
ip: &str,
|
||||
gateway: &str,
|
||||
dns: &str,
|
||||
) -> Result<()> {
|
||||
let conn_name = format!("archipelago-{}", interface);
|
||||
|
||||
// Delete existing connection if any
|
||||
let _ = tokio::process::Command::new("nmcli")
|
||||
.args(["connection", "delete", &conn_name])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
let mut args = vec![
|
||||
"connection",
|
||||
"add",
|
||||
"type",
|
||||
"ethernet",
|
||||
"con-name",
|
||||
&conn_name,
|
||||
"ifname",
|
||||
interface,
|
||||
"ipv4.method",
|
||||
"manual",
|
||||
"ipv4.addresses",
|
||||
ip,
|
||||
];
|
||||
|
||||
if !gateway.is_empty() {
|
||||
args.push("ipv4.gateway");
|
||||
args.push(gateway);
|
||||
}
|
||||
|
||||
args.push("ipv4.dns");
|
||||
args.push(dns);
|
||||
|
||||
let output = tokio::process::Command::new("nmcli")
|
||||
.args(&args)
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to create static connection")?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"nmcli connection add failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
let activate = tokio::process::Command::new("nmcli")
|
||||
.args(["connection", "up", &conn_name])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to activate connection")?;
|
||||
|
||||
if !activate.status.success() {
|
||||
anyhow::bail!(
|
||||
"nmcli connection up failed: {}",
|
||||
String::from_utf8_lossy(&activate.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::info;
|
||||
|
||||
use super::LND_REST_BASE_URL;
|
||||
|
||||
/// LND rejects RPCs with "server is still in the process of starting" for a
|
||||
/// short window after wallet unlock (p2p/graph subsystems still loading).
|
||||
/// Transient by design — match it so callers retry and, if it persists,
|
||||
/// surface a calm notice instead of a scary failure. The exact phrase below
|
||||
/// is what the frontend keys its softer (non-red) styling on.
|
||||
fn lnd_still_starting(msg: &str) -> bool {
|
||||
let m = msg.to_ascii_lowercase();
|
||||
m.contains("in the process of starting") || m.contains("server is still starting")
|
||||
}
|
||||
|
||||
/// User-facing text for the still-starting state. Deliberately calm: this is
|
||||
/// a "wait a moment", not an error.
|
||||
const LND_STARTING_MSG: &str = "Your Lightning node is still finishing its startup — this \
|
||||
usually takes a minute or two after the node comes online. Please try again shortly.";
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ChannelInfo {
|
||||
chan_id: String,
|
||||
remote_pubkey: String,
|
||||
capacity: i64,
|
||||
local_balance: i64,
|
||||
remote_balance: i64,
|
||||
active: bool,
|
||||
status: String,
|
||||
channel_point: String,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
closing_txid: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ChannelListResult {
|
||||
channels: Vec<ChannelInfo>,
|
||||
total_inbound: i64,
|
||||
total_outbound: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LndListChannelsResponse {
|
||||
channels: Option<Vec<LndChannel>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LndChannel {
|
||||
chan_id: Option<String>,
|
||||
remote_pubkey: Option<String>,
|
||||
capacity: Option<String>,
|
||||
local_balance: Option<String>,
|
||||
remote_balance: Option<String>,
|
||||
active: Option<bool>,
|
||||
channel_point: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
struct LndPendingChannelsResponse {
|
||||
pending_open_channels: Option<Vec<LndPendingOpenChannel>>,
|
||||
// Cooperative closes waiting for their closing tx to confirm
|
||||
waiting_close_channels: Option<Vec<LndWaitingCloseChannel>>,
|
||||
// Force closes serving out their timelock
|
||||
pending_force_closing_channels: Option<Vec<LndForceClosingChannel>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LndPendingOpenChannel {
|
||||
channel: Option<LndPendingChannel>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LndWaitingCloseChannel {
|
||||
channel: Option<LndPendingChannel>,
|
||||
closing_txid: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LndForceClosingChannel {
|
||||
channel: Option<LndPendingChannel>,
|
||||
closing_txid: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LndPendingChannel {
|
||||
remote_node_pub: Option<String>,
|
||||
capacity: Option<String>,
|
||||
local_balance: Option<String>,
|
||||
remote_balance: Option<String>,
|
||||
channel_point: Option<String>,
|
||||
}
|
||||
|
||||
impl LndPendingChannel {
|
||||
fn into_channel_info(self, status: &str, closing_txid: Option<String>) -> ChannelInfo {
|
||||
let parse = |s: &Option<String>| s.as_deref().and_then(|v| v.parse().ok()).unwrap_or(0);
|
||||
ChannelInfo {
|
||||
chan_id: String::new(),
|
||||
remote_pubkey: self.remote_node_pub.clone().unwrap_or_default(),
|
||||
capacity: parse(&self.capacity),
|
||||
local_balance: parse(&self.local_balance),
|
||||
remote_balance: parse(&self.remote_balance),
|
||||
active: false,
|
||||
status: status.into(),
|
||||
channel_point: self.channel_point.unwrap_or_default(),
|
||||
closing_txid: closing_txid.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
struct LndClosedChannelsResponse {
|
||||
channels: Option<Vec<LndClosedChannel>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LndClosedChannel {
|
||||
chan_id: Option<String>,
|
||||
remote_pubkey: Option<String>,
|
||||
capacity: Option<String>,
|
||||
settled_balance: Option<String>,
|
||||
close_type: Option<String>,
|
||||
closing_tx_hash: Option<String>,
|
||||
channel_point: Option<String>,
|
||||
close_height: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ClosedChannelInfo {
|
||||
chan_id: String,
|
||||
remote_pubkey: String,
|
||||
capacity: i64,
|
||||
settled_balance: i64,
|
||||
close_type: String,
|
||||
closing_tx_hash: String,
|
||||
channel_point: String,
|
||||
close_height: i64,
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
pub(in crate::api::rpc) async fn handle_lnd_listchannels(&self) -> Result<serde_json::Value> {
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let channels_resp: LndListChannelsResponse = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/channels"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND REST connection failed")?
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse LND channels response")?;
|
||||
|
||||
let pending_resp: LndPendingChannelsResponse = match client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/channels/pending"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp.json().await.unwrap_or_default(),
|
||||
Err(_) => LndPendingChannelsResponse::default(),
|
||||
};
|
||||
|
||||
let channels: Vec<ChannelInfo> = channels_resp
|
||||
.channels
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|ch| {
|
||||
let capacity: i64 = ch
|
||||
.capacity
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
let local: i64 = ch
|
||||
.local_balance
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
let remote: i64 = ch
|
||||
.remote_balance
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
ChannelInfo {
|
||||
chan_id: ch.chan_id.unwrap_or_default(),
|
||||
remote_pubkey: ch.remote_pubkey.unwrap_or_default(),
|
||||
capacity,
|
||||
local_balance: local,
|
||||
remote_balance: remote,
|
||||
active: ch.active.unwrap_or(false),
|
||||
status: if ch.active.unwrap_or(false) {
|
||||
"active".into()
|
||||
} else {
|
||||
"inactive".into()
|
||||
},
|
||||
channel_point: ch.channel_point.unwrap_or_default(),
|
||||
closing_txid: String::new(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut pending_channels: Vec<ChannelInfo> = Vec::new();
|
||||
for pch in pending_resp.pending_open_channels.unwrap_or_default() {
|
||||
if let Some(ch) = pch.channel {
|
||||
pending_channels.push(ch.into_channel_info("pending_open", None));
|
||||
}
|
||||
}
|
||||
for wch in pending_resp.waiting_close_channels.unwrap_or_default() {
|
||||
if let Some(ch) = wch.channel {
|
||||
pending_channels.push(ch.into_channel_info("closing", wch.closing_txid));
|
||||
}
|
||||
}
|
||||
for fch in pending_resp
|
||||
.pending_force_closing_channels
|
||||
.unwrap_or_default()
|
||||
{
|
||||
if let Some(ch) = fch.channel {
|
||||
pending_channels.push(ch.into_channel_info("force_closing", fch.closing_txid));
|
||||
}
|
||||
}
|
||||
|
||||
let total_local: i64 = channels.iter().map(|c| c.local_balance).sum();
|
||||
let total_remote: i64 = channels.iter().map(|c| c.remote_balance).sum();
|
||||
|
||||
let mut all_channels = channels;
|
||||
all_channels.extend(pending_channels);
|
||||
|
||||
let result = ChannelListResult {
|
||||
channels: all_channels,
|
||||
total_inbound: total_remote,
|
||||
total_outbound: total_local,
|
||||
};
|
||||
|
||||
Ok(serde_json::to_value(result)?)
|
||||
}
|
||||
|
||||
pub(in crate::api::rpc) async fn handle_lnd_openchannel(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let pubkey = params
|
||||
.get("pubkey")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'pubkey' parameter"))?;
|
||||
let amount = params
|
||||
.get("amount")
|
||||
.and_then(|v| v.as_i64())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'amount' parameter (sats)"))?;
|
||||
|
||||
// Validate pubkey: must be 66-char hex (compressed secp256k1)
|
||||
if pubkey.len() != 66 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid pubkey: must be 66-character hex string"
|
||||
));
|
||||
}
|
||||
|
||||
if amount < 20000 {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Channel amount must be at least 20,000 sats"
|
||||
));
|
||||
}
|
||||
if amount > 16_777_215 {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Channel amount exceeds maximum (16,777,215 sats)"
|
||||
));
|
||||
}
|
||||
|
||||
let private = params
|
||||
.get("private")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
// Fee control: either a confirmation target or an explicit fee rate
|
||||
let target_conf = params.get("target_conf").and_then(|v| v.as_i64());
|
||||
let sat_per_vbyte = params.get("sat_per_vbyte").and_then(|v| v.as_i64());
|
||||
if target_conf.is_some() && sat_per_vbyte.is_some() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid fee parameters: specify either target_conf or sat_per_vbyte, not both"
|
||||
));
|
||||
}
|
||||
if let Some(tc) = target_conf {
|
||||
if !(1..=1008).contains(&tc) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid target_conf: must be between 1 and 1008 blocks"
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(rate) = sat_per_vbyte {
|
||||
if !(1..=5000).contains(&rate) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid sat_per_vbyte: must be between 1 and 5000"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
peer = pubkey,
|
||||
amount = amount,
|
||||
private = private,
|
||||
"Opening Lightning channel"
|
||||
);
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
// First connect to the peer if an address is provided.
|
||||
// perm=false makes LND connect synchronously, so the peer is online
|
||||
// (or we get a real error) before we attempt the channel open.
|
||||
// perm=true queues the connection in the background and returns
|
||||
// immediately, which makes the subsequent open race and fail with
|
||||
// "peer is not online".
|
||||
if let Some(addr) = params.get("address").and_then(|v| v.as_str()) {
|
||||
// Validate peer address format (host:port)
|
||||
if addr.len() > 256 || addr.contains('\0') || addr.contains(' ') {
|
||||
return Err(anyhow::anyhow!("Invalid peer address format"));
|
||||
}
|
||||
let connect_body = serde_json::json!({
|
||||
"addr": { "pubkey": pubkey, "host": addr },
|
||||
"perm": false,
|
||||
"timeout": "30"
|
||||
});
|
||||
// Right after wallet unlock, LND's RPC answers while its p2p
|
||||
// server is still spinning up, and every connect attempt gets
|
||||
// "server is still in the process of starting". That's a
|
||||
// transient state, not a failure — it clears in seconds — so
|
||||
// retry quietly for ~30s before surfacing a calm, non-scary
|
||||
// notice (the frontend styles LND_STARTING_MSG as info, not red).
|
||||
let mut attempt = 0u32;
|
||||
loop {
|
||||
let connect_resp = client
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/peers"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&connect_body)
|
||||
.timeout(std::time::Duration::from_secs(35))
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to connect to peer")?;
|
||||
|
||||
if connect_resp.status().is_success() {
|
||||
break;
|
||||
}
|
||||
let body: serde_json::Value = connect_resp.json().await.unwrap_or_default();
|
||||
let msg = body
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
// LND returns an error if we already have this peer — that is fine
|
||||
if msg.contains("already connected") {
|
||||
break;
|
||||
}
|
||||
if lnd_still_starting(msg) && attempt < 5 {
|
||||
attempt += 1;
|
||||
info!(attempt, "LND still starting — retrying peer connect in 6s");
|
||||
tokio::time::sleep(std::time::Duration::from_secs(6)).await;
|
||||
continue;
|
||||
}
|
||||
if lnd_still_starting(msg) {
|
||||
return Err(anyhow::anyhow!("{LND_STARTING_MSG}"));
|
||||
}
|
||||
return Err(anyhow::anyhow!("Failed to connect to peer: {}", msg));
|
||||
}
|
||||
}
|
||||
|
||||
let mut open_body = serde_json::json!({
|
||||
"node_pubkey_string": pubkey,
|
||||
"local_funding_amount": amount.to_string(),
|
||||
"private": private,
|
||||
});
|
||||
if let Some(tc) = target_conf {
|
||||
open_body["target_conf"] = serde_json::json!(tc);
|
||||
}
|
||||
if let Some(rate) = sat_per_vbyte {
|
||||
// LND REST encodes uint64 as a JSON string
|
||||
open_body["sat_per_vbyte"] = serde_json::json!(rate.to_string());
|
||||
}
|
||||
|
||||
let resp = client
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/channels"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&open_body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to open channel")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse open channel response")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
if lnd_still_starting(msg) {
|
||||
return Err(anyhow::anyhow!("{LND_STARTING_MSG}"));
|
||||
}
|
||||
return Err(anyhow::anyhow!("Failed to open channel: {}", msg));
|
||||
}
|
||||
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
pub(in crate::api::rpc) async fn handle_lnd_closedchannels(&self) -> Result<serde_json::Value> {
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let resp: LndClosedChannelsResponse = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/channels/closed"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND REST connection failed")?
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse LND closed channels response")?;
|
||||
|
||||
let channels: Vec<ClosedChannelInfo> = resp
|
||||
.channels
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|ch| ClosedChannelInfo {
|
||||
chan_id: ch.chan_id.unwrap_or_default(),
|
||||
remote_pubkey: ch.remote_pubkey.unwrap_or_default(),
|
||||
capacity: ch
|
||||
.capacity
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0),
|
||||
settled_balance: ch
|
||||
.settled_balance
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0),
|
||||
close_type: ch.close_type.unwrap_or_default(),
|
||||
closing_tx_hash: ch.closing_tx_hash.unwrap_or_default(),
|
||||
channel_point: ch.channel_point.unwrap_or_default(),
|
||||
close_height: ch.close_height.unwrap_or(0),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(serde_json::json!({ "channels": channels }))
|
||||
}
|
||||
|
||||
pub(in crate::api::rpc) async fn handle_lnd_closechannel(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let channel_point = params
|
||||
.get("channel_point")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!("Missing 'channel_point' parameter (txid:output_index)")
|
||||
})?;
|
||||
|
||||
let parts: Vec<&str> = channel_point.split(':').collect();
|
||||
if parts.len() != 2 {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid channel_point format. Expected 'txid:output_index'"
|
||||
));
|
||||
}
|
||||
// Validate txid is 64-char hex and output_index is numeric
|
||||
if parts[0].len() != 64 || !parts[0].chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid txid in channel_point: must be 64-character hex"
|
||||
));
|
||||
}
|
||||
if parts[1].parse::<u32>().is_err() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid output_index in channel_point: must be a number"
|
||||
));
|
||||
}
|
||||
|
||||
let force = params
|
||||
.get("force")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
info!(
|
||||
channel_point = channel_point,
|
||||
force = force,
|
||||
"Closing Lightning channel"
|
||||
);
|
||||
|
||||
let (_, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
// The close endpoint is server-streaming: LND holds the connection
|
||||
// open and emits updates until the closing tx CONFIRMS on-chain
|
||||
// (potentially hours). Reading the whole body hangs the RPC even
|
||||
// though the close already went through, and the shared lnd_client's
|
||||
// 15s total timeout would abort the stream mid-read. Use a dedicated
|
||||
// client and return as soon as the first streamed update arrives.
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.context("Failed to create streaming HTTP client")?;
|
||||
|
||||
let url = format!(
|
||||
"{LND_REST_BASE_URL}/v1/channels/{}/{}?force={}",
|
||||
parts[0], parts[1], force
|
||||
);
|
||||
|
||||
let mut resp = client
|
||||
.delete(&url)
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to close channel")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
let msg = body
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
return Err(anyhow::anyhow!("Failed to close channel: {}", msg));
|
||||
}
|
||||
|
||||
// First streamed line is {"result":{"close_pending":…}} on success or
|
||||
// {"error":…} — the stream reports errors in-band after a 200.
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
let first_update = tokio::time::timeout(std::time::Duration::from_secs(25), async {
|
||||
while let Some(chunk) = resp.chunk().await? {
|
||||
buf.extend_from_slice(&chunk);
|
||||
let line = match buf.iter().position(|&b| b == b'\n') {
|
||||
Some(pos) => &buf[..pos],
|
||||
None => &buf[..],
|
||||
};
|
||||
if let Ok(v) = serde_json::from_slice::<serde_json::Value>(line) {
|
||||
return Ok::<_, anyhow::Error>(Some(v));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
})
|
||||
.await;
|
||||
|
||||
match first_update {
|
||||
Ok(Ok(Some(update))) => {
|
||||
if let Some(err) = update.get("error") {
|
||||
let msg = err
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
return Err(anyhow::anyhow!("Failed to close channel: {}", msg));
|
||||
}
|
||||
// txid arrives base64-encoded in internal byte order; flip it
|
||||
// into the display order explorers use.
|
||||
use base64::Engine as _;
|
||||
let closing_txid = update
|
||||
.pointer("/result/close_pending/txid")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|b64| base64::engine::general_purpose::STANDARD.decode(b64).ok())
|
||||
.map(|mut bytes| {
|
||||
bytes.reverse();
|
||||
hex::encode(bytes)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
info!(channel_point, closing_txid, "Channel close initiated");
|
||||
Ok(serde_json::json!({ "success": true, "closing_txid": closing_txid }))
|
||||
}
|
||||
Ok(Ok(None)) => Err(anyhow::anyhow!(
|
||||
"LND ended the close stream without an update — check the channel list"
|
||||
)),
|
||||
Ok(Err(e)) => Err(e).context("Failed reading close channel response"),
|
||||
// No update inside the window: the close is almost certainly still
|
||||
// negotiating with the peer — report initiated, the channel list
|
||||
// will show it under Closing.
|
||||
Err(_) => Ok(serde_json::json!({ "success": true, "closing_txid": "" })),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use anyhow::{Context, Result};
|
||||
use base64::Engine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{read_lnd_admin_macaroon, LndAmount, LndBalanceResponse, LND_REST_BASE_URL};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct LndInfo {
|
||||
alias: String,
|
||||
num_active_channels: u32,
|
||||
num_peers: u32,
|
||||
synced_to_chain: bool,
|
||||
block_height: u64,
|
||||
balance_sats: i64,
|
||||
channel_balance_sats: i64,
|
||||
pending_open_balance: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LndGetInfoResponse {
|
||||
alias: Option<String>,
|
||||
num_active_channels: Option<u32>,
|
||||
num_peers: Option<u32>,
|
||||
synced_to_chain: Option<bool>,
|
||||
block_height: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LndChannelBalanceResponse {
|
||||
local_balance: Option<LndAmount>,
|
||||
pending_open_local_balance: Option<LndAmount>,
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
pub(in crate::api::rpc) async fn handle_lnd_getinfo(&self) -> Result<serde_json::Value> {
|
||||
let macaroon_bytes = read_lnd_admin_macaroon().await?;
|
||||
let macaroon_hex = hex::encode(&macaroon_bytes);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.context("Failed to create HTTP client")?;
|
||||
|
||||
let get_info: LndGetInfoResponse = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/getinfo"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND REST connection failed")?
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse LND getinfo response")?;
|
||||
|
||||
let channel_balance: LndChannelBalanceResponse = match client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/balance/channels"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp.json().await.unwrap_or(LndChannelBalanceResponse {
|
||||
local_balance: None,
|
||||
pending_open_local_balance: None,
|
||||
}),
|
||||
Err(_) => LndChannelBalanceResponse {
|
||||
local_balance: None,
|
||||
pending_open_local_balance: None,
|
||||
},
|
||||
};
|
||||
|
||||
let wallet_balance: LndBalanceResponse = match client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/balance/blockchain"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp.json().await.unwrap_or(LndBalanceResponse {
|
||||
total_balance: None,
|
||||
}),
|
||||
Err(_) => LndBalanceResponse {
|
||||
total_balance: None,
|
||||
},
|
||||
};
|
||||
|
||||
let info = LndInfo {
|
||||
alias: get_info.alias.unwrap_or_default(),
|
||||
num_active_channels: get_info.num_active_channels.unwrap_or(0),
|
||||
num_peers: get_info.num_peers.unwrap_or(0),
|
||||
synced_to_chain: get_info.synced_to_chain.unwrap_or(false),
|
||||
block_height: get_info.block_height.unwrap_or(0),
|
||||
balance_sats: wallet_balance
|
||||
.total_balance
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0),
|
||||
channel_balance_sats: channel_balance
|
||||
.local_balance
|
||||
.and_then(|a| a.sat.and_then(|s| s.parse().ok()))
|
||||
.unwrap_or(0),
|
||||
pending_open_balance: channel_balance
|
||||
.pending_open_local_balance
|
||||
.and_then(|a| a.sat.and_then(|s| s.parse().ok()))
|
||||
.unwrap_or(0),
|
||||
};
|
||||
|
||||
Ok(serde_json::to_value(info)?)
|
||||
}
|
||||
|
||||
/// Return LND connection info: base64url-encoded TLS cert and admin macaroon
|
||||
/// for building lndconnect:// URIs in the frontend.
|
||||
pub(crate) async fn handle_lnd_connect_info(&self) -> Result<serde_json::Value> {
|
||||
let cert_path = "/var/lib/archipelago/lnd/tls.cert";
|
||||
|
||||
// Read and encode TLS cert (PEM -> DER -> base64url)
|
||||
let cert_pem = tokio::fs::read_to_string(cert_path)
|
||||
.await
|
||||
.context("Failed to read LND TLS certificate")?;
|
||||
let cert_der_b64: String = cert_pem
|
||||
.lines()
|
||||
.filter(|l| !l.starts_with("-----"))
|
||||
.collect();
|
||||
let cert_der = base64::engine::general_purpose::STANDARD
|
||||
.decode(&cert_der_b64)
|
||||
.context("Failed to decode PEM base64")?;
|
||||
let cert_b64url = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&cert_der);
|
||||
|
||||
// Read and encode macaroon (binary -> base64url)
|
||||
let macaroon_bytes = read_lnd_admin_macaroon().await?;
|
||||
let macaroon_b64url =
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&macaroon_bytes);
|
||||
|
||||
// Read Tor onion address -- check system Tor path first, then legacy
|
||||
let tor_onion = {
|
||||
let mut onion = None;
|
||||
for path in &[
|
||||
"/var/lib/archipelago/tor-hostnames/lnd",
|
||||
"/var/lib/tor/hidden_service_lnd/hostname",
|
||||
"/var/lib/archipelago/tor/hidden_service_lnd/hostname",
|
||||
] {
|
||||
if let Ok(addr) = tokio::fs::read_to_string(path).await {
|
||||
let addr = addr.trim().to_string();
|
||||
if addr.ends_with(".onion") {
|
||||
onion = Some(addr);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Try sudo for system Tor dirs (owned by debian-tor, 0700)
|
||||
if let Ok(output) = tokio::process::Command::new("sudo")
|
||||
.args(["cat", path])
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
if output.status.success() {
|
||||
let addr = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if addr.ends_with(".onion") {
|
||||
onion = Some(addr);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
onion
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"cert_base64url": cert_b64url,
|
||||
"macaroon_base64url": macaroon_b64url,
|
||||
"tor_onion": tor_onion,
|
||||
"rest_port": 18080,
|
||||
"grpc_port": 10009,
|
||||
}))
|
||||
}
|
||||
|
||||
/// lnd.export-channel-backup -- Export all channel static backups (SCB).
|
||||
/// Returns base64-encoded multi-channel backup that can restore channels on a new node.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_export_channel_backup(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
let macaroon_bytes = read_lnd_admin_macaroon().await?;
|
||||
let macaroon_hex = hex::encode(&macaroon_bytes);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.context("Failed to build HTTP client")?;
|
||||
|
||||
let resp = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/channels/backup"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to reach LND REST API")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("LND returned {}", resp.status());
|
||||
}
|
||||
|
||||
let data: serde_json::Value = resp.json().await.context("Invalid JSON from LND")?;
|
||||
|
||||
// Extract the multi_chan_backup bytes
|
||||
let backup_b64 = data
|
||||
.get("multi_chan_backup")
|
||||
.and_then(|m| m.get("multi_chan_backup"))
|
||||
.and_then(|b| b.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"backup": backup_b64,
|
||||
"channel_count": data.get("multi_chan_backup")
|
||||
.and_then(|m| m.get("chan_points"))
|
||||
.and_then(|c| c.as_array())
|
||||
.map(|a| a.len())
|
||||
.unwrap_or(0),
|
||||
"timestamp": chrono::Utc::now().to_rfc3339(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
mod channels;
|
||||
mod info;
|
||||
mod payments;
|
||||
mod seed_backup;
|
||||
mod wallet;
|
||||
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
|
||||
/// Canonical on-host path for LND's admin macaroon.
|
||||
pub(crate) const LND_ADMIN_MACAROON_PATH: &str =
|
||||
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||
pub(in crate::api) const LND_REST_BASE_URL: &str = "https://127.0.0.1:18080";
|
||||
|
||||
// Shared LND response types used by multiple submodules
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub(super) struct LndBalanceResponse {
|
||||
pub total_balance: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub(super) struct LndAmount {
|
||||
pub sat: Option<String>,
|
||||
}
|
||||
|
||||
/// Read LND's admin macaroon from disk.
|
||||
///
|
||||
/// The macaroon lives inside LND's container data dir and is owned by a
|
||||
/// rootless-podman subordinate UID (typically 100000), mode 640. The
|
||||
/// archipelago server runs as UID 1000 and therefore cannot read it
|
||||
/// directly. We first try a plain read (works if an operator has relaxed
|
||||
/// permissions), then fall back to `sudo cat` — mirroring the pattern
|
||||
/// already used for Tor hidden-service hostnames.
|
||||
pub(crate) async fn read_lnd_admin_macaroon() -> Result<Vec<u8>> {
|
||||
match tokio::fs::read(LND_ADMIN_MACAROON_PATH).await {
|
||||
Ok(bytes) => Ok(bytes),
|
||||
Err(direct_err) => {
|
||||
let output = tokio::process::Command::new("sudo")
|
||||
.args(["-n", "cat", LND_ADMIN_MACAROON_PATH])
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Failed to read LND admin macaroon (direct: {direct_err}); sudo fallback also failed"
|
||||
)
|
||||
})?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow!(
|
||||
"Failed to read LND admin macaroon — is LND installed? (direct: {direct_err}; sudo: {})",
|
||||
stderr.trim()
|
||||
));
|
||||
}
|
||||
Ok(output.stdout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Real-time wallet push (user req 2026-07-22): the UI must reflect an
|
||||
/// incoming on-chain transaction the moment the node sees it, not on the
|
||||
/// next poll. Streams LND's `/v1/transactions/subscribe` — it fires on
|
||||
/// 0-conf mempool arrival AND again on each confirmation — and nudges the
|
||||
/// shared data-model revision on every event; /ws/db pushes that to every
|
||||
/// connected client and the frontend refetches wallet balance/transactions.
|
||||
/// Reconnects forever with capped backoff: LND restarting, wallet locked, or
|
||||
/// LND not installed yet all just mean "try again shortly".
|
||||
pub(crate) fn spawn_lnd_tx_watcher(state_manager: std::sync::Arc<crate::state::StateManager>) {
|
||||
tokio::spawn(async move {
|
||||
let mut delay = std::time::Duration::from_secs(5);
|
||||
loop {
|
||||
match stream_lnd_transactions(&state_manager).await {
|
||||
// Stream ended cleanly (LND shutdown) — resume fast.
|
||||
Ok(()) => delay = std::time::Duration::from_secs(5),
|
||||
Err(e) => {
|
||||
tracing::debug!("lnd tx watcher: {e:#} — retrying in {delay:?}");
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(delay).await;
|
||||
delay = (delay * 2).min(std::time::Duration::from_secs(120));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn stream_lnd_transactions(sm: &crate::state::StateManager) -> Result<()> {
|
||||
let macaroon_hex = hex::encode(read_lnd_admin_macaroon().await?);
|
||||
// Dedicated client: the shared lnd_client() carries a 15s total timeout,
|
||||
// which would kill this deliberately long-lived stream.
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.context("Failed to create streaming HTTP client")?;
|
||||
let mut resp = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/transactions/subscribe"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("subscribe request failed")?;
|
||||
anyhow::ensure!(
|
||||
resp.status().is_success(),
|
||||
"transactions/subscribe returned {}",
|
||||
resp.status()
|
||||
);
|
||||
tracing::info!("lnd tx watcher: streaming wallet transaction events");
|
||||
while let Some(chunk) = resp.chunk().await? {
|
||||
if chunk.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Any streamed event = wallet activity. Revision bump is the push
|
||||
// contract (same pattern as the mesh-peer bridge in server.rs) — the
|
||||
// clients refetch, so we don't need to parse the event body.
|
||||
let (data, _) = sm.get_snapshot().await;
|
||||
sm.update_data(data).await;
|
||||
tracing::debug!(
|
||||
bytes = chunk.len(),
|
||||
"lnd tx watcher: wallet tx event — nudged ws clients"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// LND wedge watchdog (2026-07-22, "100% uptime"): framework-pt's LND sat
|
||||
/// for 14 HOURS with its RPC answering but the server never finishing
|
||||
/// startup — synced_to_chain=false, zero peers, every channel inactive —
|
||||
/// and nothing noticed until a human tried to open a channel. The wedge
|
||||
/// signature is precise: RPC healthy while (!synced_to_chain, or zero peers
|
||||
/// with channels that need a peer) persists. A restart reliably clears it
|
||||
/// (the backend-churn wedge is a known lnd+rpcpolling failure mode), so
|
||||
/// after 15 consecutive bad minutes we bounce the container ourselves, with
|
||||
/// a 30-minute cooldown so a genuinely broken LND can't restart-loop.
|
||||
/// RPC-unreachable and locked-wallet states are deliberately NOT handled
|
||||
/// here — container-down is crash-recovery's job, and unlocking needs the
|
||||
/// operator.
|
||||
pub(crate) fn spawn_lnd_health_watchdog() {
|
||||
tokio::spawn(async move {
|
||||
let mut bad_minutes: u32 = 0;
|
||||
let mut last_restart: Option<tokio::time::Instant> = None;
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
|
||||
let Ok(bytes) = read_lnd_admin_macaroon().await else {
|
||||
bad_minutes = 0; // no LND on this node (or not set up yet)
|
||||
continue;
|
||||
};
|
||||
let macaroon_hex = hex::encode(bytes);
|
||||
let Ok(client) = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Ok(resp) = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/getinfo"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
else {
|
||||
bad_minutes = 0; // down/locked — not the wedge signature
|
||||
continue;
|
||||
};
|
||||
let Ok(info) = resp.json::<serde_json::Value>().await else {
|
||||
bad_minutes = 0;
|
||||
continue;
|
||||
};
|
||||
let synced = info
|
||||
.get("synced_to_chain")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(true);
|
||||
let peers = info.get("num_peers").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let channels = info
|
||||
.get("num_active_channels")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0)
|
||||
+ info
|
||||
.get("num_inactive_channels")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0)
|
||||
+ info
|
||||
.get("num_pending_channels")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
let wedged = !synced || (channels > 0 && peers == 0);
|
||||
if !wedged {
|
||||
bad_minutes = 0;
|
||||
continue;
|
||||
}
|
||||
bad_minutes += 1;
|
||||
if bad_minutes < 15 {
|
||||
continue;
|
||||
}
|
||||
if last_restart
|
||||
.map(|t| t.elapsed() < std::time::Duration::from_secs(1800))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
tracing::warn!(
|
||||
synced_to_chain = synced,
|
||||
num_peers = peers,
|
||||
channels,
|
||||
"LND wedged for {bad_minutes} minutes (RPC up, server never ready) — restarting the lnd container"
|
||||
);
|
||||
let out = tokio::process::Command::new("podman")
|
||||
.args(["restart", "lnd"])
|
||||
.output()
|
||||
.await;
|
||||
match out {
|
||||
Ok(o) if o.status.success() => {
|
||||
tracing::info!("LND watchdog restart complete");
|
||||
}
|
||||
Ok(o) => tracing::warn!(
|
||||
"LND watchdog restart failed: {}",
|
||||
String::from_utf8_lossy(&o.stderr).trim()
|
||||
),
|
||||
Err(e) => tracing::warn!("LND watchdog restart failed: {e}"),
|
||||
}
|
||||
last_restart = Some(tokio::time::Instant::now());
|
||||
bad_minutes = 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// Helper: create an authenticated LND REST client.
|
||||
/// Returns an HTTP client configured for LND's self-signed TLS and the
|
||||
/// hex-encoded admin macaroon for request headers.
|
||||
pub(crate) async fn lnd_client(&self) -> Result<(reqwest::Client, String)> {
|
||||
let macaroon_bytes = read_lnd_admin_macaroon().await?;
|
||||
let macaroon_hex = hex::encode(&macaroon_bytes);
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.context("Failed to create HTTP client")?;
|
||||
Ok((client, macaroon_hex))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::info;
|
||||
|
||||
use super::LND_REST_BASE_URL;
|
||||
|
||||
impl RpcHandler {
|
||||
/// Pay a Lightning invoice.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_payinvoice(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let payment_request = params
|
||||
.get("payment_request")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'payment_request' parameter"))?;
|
||||
|
||||
// Basic validation: Lightning invoices start with lnbc/lntb/lnbcrt
|
||||
if payment_request.len() < 10 || payment_request.len() > 2048 {
|
||||
return Err(anyhow::anyhow!("Invalid payment request length"));
|
||||
}
|
||||
let lower = payment_request.to_lowercase();
|
||||
if !lower.starts_with("lnbc") && !lower.starts_with("lntb") && !lower.starts_with("lnbcrt")
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid payment request: must be a Lightning invoice (lnbc...)"
|
||||
));
|
||||
}
|
||||
|
||||
// Zero-amount invoices need the amount supplied by the payer; LND's
|
||||
// REST API takes it as an `amt` string alongside the payment request.
|
||||
let amount_sats = params.get("amount_sats").and_then(|v| v.as_u64());
|
||||
|
||||
info!("Paying Lightning invoice");
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
// Decode the invoice up front (fast, local) so we know its payment
|
||||
// hash BEFORE handing it to LND. If the payment outlives our wait
|
||||
// below, the hash is what lets the UI keep tracking it instead of
|
||||
// declaring a false failure. Best-effort: a decode hiccup must not
|
||||
// block the payment itself.
|
||||
let (decoded_hash, decoded_amt) = match client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/payreq/{payment_request}"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => match r.json::<serde_json::Value>().await {
|
||||
Ok(d) => (
|
||||
d.get("payment_hash")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
d.get("num_satoshis")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.unwrap_or(0),
|
||||
),
|
||||
Err(_) => (String::new(), 0),
|
||||
},
|
||||
Err(_) => (String::new(), 0),
|
||||
};
|
||||
|
||||
let mut pay_body = serde_json::json!({
|
||||
"payment_request": payment_request,
|
||||
});
|
||||
if let Some(amt) = amount_sats {
|
||||
pay_body["amt"] = serde_json::json!(amt.to_string());
|
||||
}
|
||||
|
||||
// `/v1/channels/transactions` is SYNCHRONOUS: it blocks until the
|
||||
// payment settles or definitively fails, and multi-hop routing with
|
||||
// retries routinely takes longer than the shared client's 15s budget.
|
||||
// That 15s abort used to surface as "Payment failed" while LND kept
|
||||
// paying in the background — the payment then succeeded and appeared
|
||||
// in history a minute later. Wait up to 120s on a dedicated client,
|
||||
// and treat a post-connect timeout as IN FLIGHT (status: pending),
|
||||
// never as failure — only LND may declare a payment failed.
|
||||
let pay_client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.context("Failed to create HTTP client")?;
|
||||
|
||||
let resp = match pay_client
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/channels/transactions"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&pay_body)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) if e.is_connect() => {
|
||||
// Never reached LND — nothing was sent; this IS a hard error.
|
||||
return Err(anyhow::anyhow!("Could not reach LND to pay: {e}"));
|
||||
}
|
||||
Err(_) => {
|
||||
// Timed out (or lost the connection) AFTER the payment was
|
||||
// handed to LND — it may well still succeed. Report pending
|
||||
// with the hash so the caller can poll lnd.paymentstatus.
|
||||
info!("payinvoice wait elapsed; payment still in flight");
|
||||
return Ok(serde_json::json!({
|
||||
"status": "pending",
|
||||
"payment_hash": decoded_hash,
|
||||
"amount_sats": decoded_amt,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse payment response")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
// Invoices are short-lived; retrying the same one can never
|
||||
// succeed, so tell the user the way out instead of just the fact.
|
||||
if msg.contains("invoice expired") {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Payment failed: this invoice has expired ({}). Ask the recipient for a fresh invoice and try again.",
|
||||
msg.trim_start_matches("invoice expired. ")
|
||||
));
|
||||
}
|
||||
return Err(anyhow::anyhow!("Payment failed: {}", msg));
|
||||
}
|
||||
|
||||
let payment_error = body
|
||||
.get("payment_error")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
if !payment_error.is_empty() {
|
||||
return Err(anyhow::anyhow!("Payment failed: {}", payment_error));
|
||||
}
|
||||
|
||||
let amount_sat = body
|
||||
.get("payment_route")
|
||||
.and_then(|r| r.get("total_amt"))
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.unwrap_or(decoded_amt);
|
||||
|
||||
let payment_hash = body
|
||||
.get("payment_hash")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or(decoded_hash);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"status": "succeeded",
|
||||
"payment_hash": payment_hash,
|
||||
"amount_sats": amount_sat,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Status of an outgoing Lightning payment by hex payment hash. Lets the
|
||||
/// UI resolve a payinvoice that outlived its synchronous wait (`status:
|
||||
/// "pending"`) to a real terminal state instead of guessing.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_paymentstatus(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let payment_hash = params
|
||||
.get("payment_hash")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'payment_hash' parameter"))?;
|
||||
if payment_hash.len() != 64 || !payment_hash.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err(anyhow::anyhow!("Invalid payment hash"));
|
||||
}
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
let resp = client
|
||||
.get(format!(
|
||||
"{LND_REST_BASE_URL}/v1/payments?include_incomplete=true&max_payments=100&reversed=true"
|
||||
))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND REST connection failed")?;
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse payments response")?;
|
||||
|
||||
let hash_lower = payment_hash.to_lowercase();
|
||||
let found = body
|
||||
.get("payments")
|
||||
.and_then(|v| v.as_array())
|
||||
.and_then(|arr| {
|
||||
arr.iter().find(|p| {
|
||||
p.get("payment_hash").and_then(|v| v.as_str())
|
||||
== Some(hash_lower.as_str())
|
||||
})
|
||||
});
|
||||
|
||||
let Some(p) = found else {
|
||||
// Not in the latest window — either very old or LND never saw it.
|
||||
return Ok(serde_json::json!({ "status": "unknown" }));
|
||||
};
|
||||
|
||||
let lnd_status = p.get("status").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let status = match lnd_status {
|
||||
"SUCCEEDED" => "succeeded",
|
||||
"FAILED" => "failed",
|
||||
_ => "in_flight",
|
||||
};
|
||||
let failure_reason = match p
|
||||
.get("failure_reason")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
{
|
||||
"FAILURE_REASON_NO_ROUTE" => "No route to the recipient",
|
||||
"FAILURE_REASON_INSUFFICIENT_BALANCE" => "Insufficient channel balance",
|
||||
"FAILURE_REASON_TIMEOUT" => "Payment timed out in the network",
|
||||
"FAILURE_REASON_INCORRECT_PAYMENT_DETAILS" => {
|
||||
"Recipient rejected the payment (wrong details or expired invoice)"
|
||||
}
|
||||
"FAILURE_REASON_ERROR" => "Payment failed",
|
||||
_ => "",
|
||||
};
|
||||
|
||||
fn amt(p: &serde_json::Value, key: &str) -> i64 {
|
||||
p.get(key)
|
||||
.and_then(|f| f.as_str())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.or_else(|| p.get(key).and_then(|f| f.as_i64()))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"status": status,
|
||||
"failure_reason": failure_reason,
|
||||
"amount_sats": amt(p, "value_sat"),
|
||||
"fee_sats": amt(p, "fee_sat"),
|
||||
}))
|
||||
}
|
||||
|
||||
/// List on-chain transactions from LND.
|
||||
/// Returns all transactions, with incoming (amount > 0) flagged.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_gettransactions(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let resp = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/transactions"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND REST connection failed")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse transactions response")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
return Err(anyhow::anyhow!("Failed to list transactions: {}", msg));
|
||||
}
|
||||
|
||||
let empty_vec = vec![];
|
||||
let raw_txs = body
|
||||
.get("transactions")
|
||||
.and_then(|v| v.as_array())
|
||||
.unwrap_or(&empty_vec);
|
||||
|
||||
let mut transactions: Vec<serde_json::Value> = Vec::new();
|
||||
for tx in raw_txs {
|
||||
let amount: i64 = tx
|
||||
.get("amount")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.or_else(|| tx.get("amount").and_then(|v| v.as_i64()))
|
||||
.unwrap_or(0);
|
||||
|
||||
let num_confirmations: i64 = tx
|
||||
.get("num_confirmations")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0);
|
||||
|
||||
let tx_hash = tx
|
||||
.get("tx_hash")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let time_stamp: i64 = tx
|
||||
.get("time_stamp")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.or_else(|| tx.get("time_stamp").and_then(|v| v.as_i64()))
|
||||
.unwrap_or(0);
|
||||
|
||||
let total_fees: i64 = tx
|
||||
.get("total_fees")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.or_else(|| tx.get("total_fees").and_then(|v| v.as_i64()))
|
||||
.unwrap_or(0);
|
||||
|
||||
let dest_addresses: Vec<String> = tx
|
||||
.get("dest_addresses")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|a| a.as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let label = tx
|
||||
.get("label")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let block_height: i64 = tx.get("block_height").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
|
||||
let direction = if amount > 0 { "incoming" } else { "outgoing" };
|
||||
|
||||
transactions.push(serde_json::json!({
|
||||
"tx_hash": tx_hash,
|
||||
"amount_sats": amount.abs(),
|
||||
"direction": direction,
|
||||
"num_confirmations": num_confirmations,
|
||||
"time_stamp": time_stamp,
|
||||
"total_fees": total_fees,
|
||||
"dest_addresses": dest_addresses,
|
||||
"label": label,
|
||||
"block_height": block_height,
|
||||
}));
|
||||
}
|
||||
|
||||
// Sort by timestamp descending (most recent first)
|
||||
transactions.sort_by(|a, b| {
|
||||
let ta = a.get("time_stamp").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let tb = b.get("time_stamp").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
tb.cmp(&ta)
|
||||
});
|
||||
|
||||
let incoming_pending: usize = transactions
|
||||
.iter()
|
||||
.filter(|t| {
|
||||
t.get("direction").and_then(|v| v.as_str()) == Some("incoming")
|
||||
&& t.get("num_confirmations").and_then(|v| v.as_i64()) == Some(0)
|
||||
})
|
||||
.count();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"transactions": transactions,
|
||||
"incoming_pending_count": incoming_pending,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Unified Lightning history: settled invoices (incoming) + succeeded
|
||||
/// payments (outgoing), normalized to the wallet-transaction shape the
|
||||
/// UI already renders. On-chain history stays in lnd.gettransactions.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_lightning_history(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
use base64::Engine;
|
||||
|
||||
fn field_i64(v: &serde_json::Value, key: &str) -> i64 {
|
||||
v.get(key)
|
||||
.and_then(|f| f.as_str())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.or_else(|| v.get(key).and_then(|f| f.as_i64()))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
let mut transactions: Vec<serde_json::Value> = Vec::new();
|
||||
|
||||
// Outgoing: succeeded payments only (include_incomplete=false)
|
||||
let payments_resp = client
|
||||
.get(format!(
|
||||
"{LND_REST_BASE_URL}/v1/payments?include_incomplete=false&max_payments=100&reversed=true"
|
||||
))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND REST connection failed")?;
|
||||
if payments_resp.status().is_success() {
|
||||
let body: serde_json::Value = payments_resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse payments response")?;
|
||||
for p in body
|
||||
.get("payments")
|
||||
.and_then(|v| v.as_array())
|
||||
.unwrap_or(&vec![])
|
||||
{
|
||||
let amount = field_i64(p, "value_sat");
|
||||
if amount == 0 {
|
||||
continue;
|
||||
}
|
||||
transactions.push(serde_json::json!({
|
||||
"tx_hash": p.get("payment_hash").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"amount_sats": amount,
|
||||
"direction": "outgoing",
|
||||
"num_confirmations": 1,
|
||||
"time_stamp": field_i64(p, "creation_date"),
|
||||
"total_fees": field_i64(p, "fee_sat"),
|
||||
"dest_addresses": [],
|
||||
"label": "",
|
||||
"block_height": 0,
|
||||
"kind": "lightning",
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Incoming: settled invoices only
|
||||
let invoices_resp = client
|
||||
.get(format!(
|
||||
"{LND_REST_BASE_URL}/v1/invoices?num_max_invoices=100&reversed=true"
|
||||
))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND REST connection failed")?;
|
||||
if invoices_resp.status().is_success() {
|
||||
let body: serde_json::Value = invoices_resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse invoices response")?;
|
||||
for inv in body
|
||||
.get("invoices")
|
||||
.and_then(|v| v.as_array())
|
||||
.unwrap_or(&vec![])
|
||||
{
|
||||
let settled = inv.get("state").and_then(|v| v.as_str()) == Some("SETTLED")
|
||||
|| inv.get("settled").and_then(|v| v.as_bool()) == Some(true);
|
||||
if !settled {
|
||||
continue;
|
||||
}
|
||||
// r_hash arrives base64 from REST; the UI shows hex
|
||||
let r_hash_hex = inv
|
||||
.get("r_hash")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|b64| base64::engine::general_purpose::STANDARD.decode(b64).ok())
|
||||
.map(hex::encode)
|
||||
.unwrap_or_default();
|
||||
transactions.push(serde_json::json!({
|
||||
"tx_hash": r_hash_hex,
|
||||
"amount_sats": field_i64(inv, "amt_paid_sat"),
|
||||
"direction": "incoming",
|
||||
"num_confirmations": 1,
|
||||
"time_stamp": field_i64(inv, "settle_date"),
|
||||
"total_fees": 0,
|
||||
"dest_addresses": [],
|
||||
"label": inv.get("memo").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"block_height": 0,
|
||||
"kind": "lightning",
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
transactions.sort_by(|a, b| {
|
||||
let ta = a.get("time_stamp").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let tb = b.get("time_stamp").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
tb.cmp(&ta)
|
||||
});
|
||||
|
||||
Ok(serde_json::json!({ "transactions": transactions }))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//! Encrypted LND aezeed backup: status, reveal, and acknowledgment.
|
||||
//!
|
||||
//! The aezeed is captured once at wallet-init time (see
|
||||
//! `crate::container::lnd::persist_aezeed_backup`) and stored under
|
||||
//! `identity/lnd_aezeed.enc`, encrypted with the per-node wallet secret.
|
||||
//! Reveal is gated like `seed.reveal`: authenticated session + password
|
||||
//! re-verification + TOTP when enabled.
|
||||
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use anyhow::Result;
|
||||
use zeroize::Zeroize;
|
||||
|
||||
impl RpcHandler {
|
||||
/// Whether an encrypted aezeed backup exists and whether the user has
|
||||
/// confirmed writing it down. Drives the first-launch backup prompt.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_seed_backup_status(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
let data_dir = &self.config.data_dir;
|
||||
Ok(serde_json::json!({
|
||||
"available": crate::seed::lnd_aezeed_exists(data_dir),
|
||||
"acknowledged": crate::seed::lnd_aezeed_acknowledged(data_dir),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Reveal the Lightning wallet's 24 aezeed words. Same gating as
|
||||
/// `seed.reveal`; the words are returned to the caller only, never logged.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_seed_reveal(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
|
||||
if !crate::seed::lnd_aezeed_exists(&self.config.data_dir) {
|
||||
anyhow::bail!(
|
||||
"No Lightning seed backup exists on this node. It is captured \
|
||||
automatically when the Lightning wallet is first created."
|
||||
);
|
||||
}
|
||||
|
||||
let mut password = self
|
||||
.verify_reveal_auth(¶ms, "the Lightning seed")
|
||||
.await?;
|
||||
password.zeroize();
|
||||
|
||||
// The backup is encrypted with the per-node wallet secret (the boot
|
||||
// path has no user password), so re-auth above is the actual gate.
|
||||
let mut node_secret = crate::container::lnd::wallet_password_if_exists()
|
||||
.await
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Could not decrypt the saved Lightning seed — the per-node \
|
||||
wallet secret is missing"
|
||||
)
|
||||
})?;
|
||||
let words =
|
||||
crate::seed::load_lnd_aezeed_encrypted(&self.config.data_dir, &node_secret).await;
|
||||
node_secret.zeroize();
|
||||
let words = words
|
||||
.map_err(|_| anyhow::anyhow!("Could not decrypt the saved Lightning seed backup"))?;
|
||||
|
||||
let word_count = words.len();
|
||||
Ok(serde_json::json!({ "words": words, "word_count": word_count }))
|
||||
}
|
||||
|
||||
/// Record that the user confirmed backing up the Lightning seed, which
|
||||
/// dismisses the first-launch prompt.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_seed_backup_ack(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
crate::seed::mark_lnd_aezeed_acknowledged(&self.config.data_dir).await?;
|
||||
Ok(serde_json::json!({ "acknowledged": true }))
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,212 @@
|
||||
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,
|
||||
}))
|
||||
}
|
||||
|
||||
/// marketplace.create-invoice — Generate a Lightning invoice for app purchase.
|
||||
/// Returns BOLT11 invoice string and payment hash.
|
||||
pub(super) async fn handle_marketplace_create_invoice(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let app_id = params
|
||||
.get("app_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
|
||||
let amount_sats = params
|
||||
.get("amount_sats")
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing amount_sats"))?;
|
||||
|
||||
// Create LND invoice via the existing wallet integration
|
||||
let invoice_params = serde_json::json!({
|
||||
"amount": amount_sats,
|
||||
"memo": format!("Archipelago app: {}", app_id),
|
||||
});
|
||||
let invoice_result = self.handle_lnd_createinvoice(Some(invoice_params)).await?;
|
||||
|
||||
let payment_request = invoice_result
|
||||
.get("payment_request")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let r_hash = invoice_result
|
||||
.get("r_hash")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"app_id": app_id,
|
||||
"amount_sats": amount_sats,
|
||||
"payment_request": payment_request,
|
||||
"r_hash": r_hash,
|
||||
}))
|
||||
}
|
||||
|
||||
/// marketplace.check-payment — Check if a Lightning payment has been received.
|
||||
pub(super) async fn handle_marketplace_check_payment(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let r_hash = params
|
||||
.get("r_hash")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing r_hash"))?;
|
||||
|
||||
// Validate r_hash is hex-encoded (LND payment hashes are 32 bytes = 64 hex chars)
|
||||
if r_hash.len() != 64 || !r_hash.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid r_hash: must be 64-character hex string"
|
||||
));
|
||||
}
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let url = format!("{}/v1/invoice/{r_hash}", super::lnd::LND_REST_BASE_URL);
|
||||
let paid = match client
|
||||
.get(&url)
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) if r.status().is_success() => {
|
||||
let body: serde_json::Value = r.json().await.unwrap_or_default();
|
||||
body.get("settled")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"r_hash": r_hash,
|
||||
"paid": paid,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
//! Mesh-AI assistant RPCs (issue #50): read/update the local assistant config
|
||||
//! and report whether a local Ollama is available (for the install deep-link).
|
||||
|
||||
use super::super::RpcHandler;
|
||||
use anyhow::Result;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Default model when the node hasn't picked one (kept in sync with the mesh
|
||||
/// assistant handler's `DEFAULT_MODEL`).
|
||||
const DEFAULT_MODEL: &str = "qwen2.5-coder";
|
||||
|
||||
impl RpcHandler {
|
||||
/// mesh.assistant-status — current settings + local Ollama availability.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_assistant_status(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
let (cfg, denied_askers) = {
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
(
|
||||
svc.assistant_config().await,
|
||||
svc.assistant_denied_askers().await,
|
||||
)
|
||||
};
|
||||
|
||||
let (ollama_detected, models) = detect_ollama().await;
|
||||
let claude_available =
|
||||
tokio::fs::metadata(self.config.data_dir.join("secrets/claude-api-key"))
|
||||
.await
|
||||
.is_ok();
|
||||
Ok(serde_json::json!({
|
||||
"enabled": cfg.enabled,
|
||||
"model": cfg.model,
|
||||
"trusted_only": cfg.trusted_only,
|
||||
"backend": cfg.backend,
|
||||
"allowed_contacts": cfg.allowed_contacts,
|
||||
"default_model": DEFAULT_MODEL,
|
||||
"ollama_detected": ollama_detected,
|
||||
"claude_available": claude_available,
|
||||
"models": models,
|
||||
"denied_askers": denied_askers,
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.assistant-configure — update assistant settings live.
|
||||
/// Params: `enabled?: bool`, `trusted_only?: bool`,
|
||||
/// `model?: string|null` (string sets, null clears to default, absent leaves).
|
||||
pub(in crate::api::rpc) async fn handle_mesh_assistant_configure(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
let enabled = params.get("enabled").and_then(|v| v.as_bool());
|
||||
let trusted_only = params.get("trusted_only").and_then(|v| v.as_bool());
|
||||
let backend = params
|
||||
.get("backend")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
// model: key present + string => set; present + null => clear; absent => leave
|
||||
let model = if let Some(v) = params.get("model") {
|
||||
Some(v.as_str().map(|s| s.to_string()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// allowed_contacts: present + array => replace the allowlist (pubkey hex
|
||||
// strings); absent => leave unchanged.
|
||||
let allowed_contacts = params
|
||||
.get("allowed_contacts")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|e| e.as_str().map(|s| s.to_string()))
|
||||
.collect::<Vec<String>>()
|
||||
});
|
||||
|
||||
svc.configure_assistant(enabled, model, trusted_only, backend, allowed_contacts)
|
||||
.await?;
|
||||
let cfg = svc.assistant_config().await;
|
||||
Ok(serde_json::json!({
|
||||
"enabled": cfg.enabled,
|
||||
"model": cfg.model,
|
||||
"trusted_only": cfg.trusted_only,
|
||||
"backend": cfg.backend,
|
||||
"allowed_contacts": cfg.allowed_contacts,
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.schedule-message — queue a message to send at a future time.
|
||||
/// Params: `body: string`, `fire_at: i64` (unix secs), and one of
|
||||
/// `contact_id: u32` (DM) or `channel: u8` (broadcast).
|
||||
pub(in crate::api::rpc) async fn handle_mesh_schedule_message(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let p = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let body = p
|
||||
.get("body")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("body is required"))?
|
||||
.to_string();
|
||||
let fire_at = p
|
||||
.get("fire_at")
|
||||
.and_then(|v| v.as_i64())
|
||||
.ok_or_else(|| anyhow::anyhow!("fire_at (unix seconds) is required"))?;
|
||||
let contact_id = p
|
||||
.get("contact_id")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v as u32);
|
||||
let channel = p.get("channel").and_then(|v| v.as_u64()).map(|v| v as u8);
|
||||
if contact_id.is_none() && channel.is_none() {
|
||||
anyhow::bail!("either contact_id or channel is required");
|
||||
}
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
let msg = svc
|
||||
.scheduler
|
||||
.add(contact_id, channel, body, fire_at)
|
||||
.await?;
|
||||
Ok(serde_json::to_value(msg)?)
|
||||
}
|
||||
|
||||
/// mesh.list-scheduled — list queued messages (sorted by fire time).
|
||||
pub(in crate::api::rpc) async fn handle_mesh_list_scheduled(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
let messages = svc.scheduler.list().await;
|
||||
Ok(serde_json::json!({ "messages": messages }))
|
||||
}
|
||||
|
||||
/// mesh.cancel-scheduled — remove a queued message by id.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_cancel_scheduled(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let id = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("id"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| anyhow::anyhow!("id is required"))?;
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
let cancelled = svc.scheduler.cancel(id).await?;
|
||||
Ok(serde_json::json!({ "cancelled": cancelled }))
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe the local Ollama HTTP API; return (detected, model_names).
|
||||
async fn detect_ollama() -> (bool, Vec<String>) {
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(2))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(_) => return (false, Vec::new()),
|
||||
};
|
||||
match client.get("http://localhost:11434/api/tags").send().await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let json: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
let models = json
|
||||
.get("models")
|
||||
.and_then(|m| m.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|m| {
|
||||
m.get("name")
|
||||
.and_then(|n| n.as_str())
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
(true, models)
|
||||
}
|
||||
_ => (false, Vec::new()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
use super::super::RpcHandler;
|
||||
use anyhow::Result;
|
||||
use tracing::info;
|
||||
|
||||
impl RpcHandler {
|
||||
/// mesh.relay-tx — Send a raw transaction for relay by an internet-connected mesh peer.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_relay_tx(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let tx_hex = params["tx_hex"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing tx_hex"))?;
|
||||
|
||||
let relay_mode = params["relay_mode"].as_str().unwrap_or("archy");
|
||||
|
||||
if tx_hex.len() < 20 || tx_hex.len() > 200_000 {
|
||||
anyhow::bail!("Invalid tx_hex length");
|
||||
}
|
||||
// Validate hex
|
||||
if hex::decode(tx_hex).is_err() {
|
||||
anyhow::bail!("tx_hex is not valid hexadecimal");
|
||||
}
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
let request_id = chrono::Utc::now().timestamp() as u64;
|
||||
svc.relay_tracker
|
||||
.track_tx_relay(request_id, svc.our_did())
|
||||
.await;
|
||||
|
||||
let wire = crate::mesh::bitcoin_relay::build_tx_relay_request(tx_hex, request_id)?;
|
||||
|
||||
let mut sent_count = 0u32;
|
||||
|
||||
if relay_mode == "broadcast" {
|
||||
// Broadcast mode: send on channel 0 (all mesh nodes relay)
|
||||
// Still encrypted — only Archy nodes can decrypt and broadcast the TX
|
||||
let shared_state = svc.shared_state();
|
||||
let shared_secrets = shared_state.shared_secrets.read().await;
|
||||
|
||||
// Encrypt with first available Archy peer's shared secret
|
||||
// (any Archy node that receives it can try decrypting)
|
||||
let payload = shared_secrets
|
||||
.values()
|
||||
.next()
|
||||
.and_then(|secret| {
|
||||
crate::mesh::crypto::encrypt(secret, &wire).ok().map(|ct| {
|
||||
let mut encrypted = Vec::with_capacity(1 + ct.len());
|
||||
encrypted.push(crate::mesh::message_types::ENCRYPTED_TYPED_MARKER);
|
||||
encrypted.extend_from_slice(&ct);
|
||||
encrypted
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| wire.clone());
|
||||
drop(shared_secrets);
|
||||
|
||||
{
|
||||
use base64::Engine;
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(&payload);
|
||||
let _ = shared_state
|
||||
.send_cmd(crate::mesh::listener::MeshCommand::BroadcastChannel {
|
||||
channel: 0,
|
||||
payload: b64.into_bytes(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
info!(
|
||||
request_id,
|
||||
tx_len = tx_hex.len(),
|
||||
"TX relay broadcast on mesh channel 0 (encrypted)"
|
||||
);
|
||||
} else {
|
||||
// Archy mode: E2E encrypted per-peer, direct to known Archy nodes
|
||||
let peers = svc.peers().await;
|
||||
let shared_state = svc.shared_state();
|
||||
let shared_secrets = shared_state.shared_secrets.read().await;
|
||||
for peer in &peers {
|
||||
if !peer.advert_name.starts_with("Archy-") {
|
||||
continue;
|
||||
}
|
||||
if let Some(ref pk) = peer.pubkey_hex {
|
||||
if let Ok(pk_bytes) = hex::decode(pk) {
|
||||
if pk_bytes.len() >= 6 {
|
||||
let mut prefix = [0u8; 6];
|
||||
prefix.copy_from_slice(&pk_bytes[..6]);
|
||||
|
||||
let payload = if let Some(secret) = shared_secrets.get(&peer.contact_id)
|
||||
{
|
||||
match crate::mesh::crypto::encrypt(secret, &wire) {
|
||||
Ok(ciphertext) => {
|
||||
let mut encrypted =
|
||||
Vec::with_capacity(1 + ciphertext.len());
|
||||
encrypted.push(
|
||||
crate::mesh::message_types::ENCRYPTED_TYPED_MARKER,
|
||||
);
|
||||
encrypted.extend_from_slice(&ciphertext);
|
||||
encrypted
|
||||
}
|
||||
Err(_) => wire.clone(),
|
||||
}
|
||||
} else {
|
||||
wire.clone()
|
||||
};
|
||||
|
||||
let _ = svc
|
||||
.shared_state()
|
||||
.send_cmd(crate::mesh::listener::MeshCommand::SendRaw {
|
||||
dest_pubkey_prefix: prefix,
|
||||
payload,
|
||||
})
|
||||
.await;
|
||||
sent_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(shared_secrets);
|
||||
info!(
|
||||
request_id,
|
||||
tx_len = tx_hex.len(),
|
||||
archy_peers = sent_count,
|
||||
"TX relay sent to Archy peers (E2E encrypted)"
|
||||
);
|
||||
}
|
||||
Ok(serde_json::json!({
|
||||
"request_id": request_id,
|
||||
"queued": true,
|
||||
"tx_hex_len": tx_hex.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.relay-status — Check the status of a pending or completed TX relay.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_relay_status(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let request_id = params["request_id"]
|
||||
.as_u64()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing request_id"))?;
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
// Check completed results first
|
||||
if let Some(result) = svc.relay_tracker.get_result(request_id).await {
|
||||
return Ok(serde_json::json!({
|
||||
"status": if result.txid.is_some() { "confirmed" } else { "failed" },
|
||||
"request_id": result.request_id,
|
||||
"txid": result.txid,
|
||||
"error": result.error,
|
||||
"error_code": result.error_code,
|
||||
"completed_at": result.completed_at,
|
||||
}));
|
||||
}
|
||||
|
||||
// Check if still pending
|
||||
if svc.relay_tracker.is_pending(request_id).await {
|
||||
return Ok(serde_json::json!({
|
||||
"status": "pending",
|
||||
"request_id": request_id,
|
||||
}));
|
||||
}
|
||||
|
||||
// Unknown — either expired or never existed
|
||||
Ok(serde_json::json!({
|
||||
"status": "unknown",
|
||||
"request_id": request_id,
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.block-headers — Get cached block headers received from mesh peers.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_block_headers(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let count = params
|
||||
.as_ref()
|
||||
.and_then(|p| p["count"].as_u64())
|
||||
.unwrap_or(10) as usize;
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
let headers = svc.block_header_cache.recent_headers(count).await;
|
||||
let latest = svc.block_header_cache.latest_height().await;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"headers": headers.iter().map(|h| serde_json::json!({
|
||||
"height": h.height,
|
||||
"hash": h.hash,
|
||||
"prev_hash": h.prev_hash,
|
||||
"timestamp": h.timestamp,
|
||||
"announced_by": h.announced_by,
|
||||
})).collect::<Vec<_>>(),
|
||||
"latest_height": latest,
|
||||
"count": headers.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.relay-lightning — Send a Lightning invoice for payment by an internet-connected peer.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_relay_lightning(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let bolt11 = params["bolt11"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing bolt11"))?;
|
||||
let amount_sats = params["amount_sats"]
|
||||
.as_u64()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing amount_sats"))?;
|
||||
|
||||
if !bolt11.starts_with("lnbc") && !bolt11.starts_with("lntb") {
|
||||
anyhow::bail!("Invalid bolt11 invoice — must start with lnbc or lntb");
|
||||
}
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
let request_id = chrono::Utc::now().timestamp() as u64;
|
||||
svc.relay_tracker
|
||||
.track_lightning_relay(request_id, svc.our_did())
|
||||
.await;
|
||||
|
||||
let wire = crate::mesh::bitcoin_relay::build_lightning_relay_request(
|
||||
bolt11,
|
||||
amount_sats,
|
||||
request_id,
|
||||
)?;
|
||||
|
||||
// Send to Archipelago peers — E2E encrypted per-peer
|
||||
let peers = svc.peers().await;
|
||||
let shared_state = svc.shared_state();
|
||||
let shared_secrets = shared_state.shared_secrets.read().await;
|
||||
let mut sent_count = 0u32;
|
||||
for peer in &peers {
|
||||
if !peer.advert_name.starts_with("Archy-") {
|
||||
continue;
|
||||
}
|
||||
if let Some(ref pk) = peer.pubkey_hex {
|
||||
if let Ok(pk_bytes) = hex::decode(pk) {
|
||||
if pk_bytes.len() >= 6 {
|
||||
let mut prefix = [0u8; 6];
|
||||
prefix.copy_from_slice(&pk_bytes[..6]);
|
||||
|
||||
let payload = if let Some(secret) = shared_secrets.get(&peer.contact_id) {
|
||||
match crate::mesh::crypto::encrypt(secret, &wire) {
|
||||
Ok(ciphertext) => {
|
||||
let mut encrypted = Vec::with_capacity(1 + ciphertext.len());
|
||||
encrypted
|
||||
.push(crate::mesh::message_types::ENCRYPTED_TYPED_MARKER);
|
||||
encrypted.extend_from_slice(&ciphertext);
|
||||
encrypted
|
||||
}
|
||||
Err(_) => wire.clone(),
|
||||
}
|
||||
} else {
|
||||
wire.clone()
|
||||
};
|
||||
|
||||
let _ = svc
|
||||
.shared_state()
|
||||
.send_cmd(crate::mesh::listener::MeshCommand::SendRaw {
|
||||
dest_pubkey_prefix: prefix,
|
||||
payload,
|
||||
})
|
||||
.await;
|
||||
sent_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(shared_secrets);
|
||||
|
||||
info!(
|
||||
request_id,
|
||||
amount_sats,
|
||||
archy_peers = sent_count,
|
||||
"Lightning relay sent (E2E encrypted)"
|
||||
);
|
||||
Ok(serde_json::json!({
|
||||
"request_id": request_id,
|
||||
"queued": true,
|
||||
"amount_sats": amount_sats,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
use super::super::RpcHandler;
|
||||
use crate::mesh;
|
||||
use anyhow::Result;
|
||||
use tracing::info;
|
||||
|
||||
impl RpcHandler {
|
||||
/// mesh.send — Send an encrypted message to a mesh peer.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_send(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
|
||||
let contact_id = params
|
||||
.get("contact_id")
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing contact_id"))? as u32;
|
||||
|
||||
let message = params
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing message"))?;
|
||||
|
||||
if message.is_empty() {
|
||||
anyhow::bail!("Message cannot be empty");
|
||||
}
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running. Enable mesh first."))?;
|
||||
|
||||
let msg = svc.send_message(contact_id, message).await?;
|
||||
info!(contact_id, encrypted = msg.encrypted, "Sent mesh message");
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"sent": true,
|
||||
"message_id": msg.id,
|
||||
"encrypted": msg.encrypted,
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.send-channel — Send a text message to a mesh channel (broadcast).
|
||||
pub(in crate::api::rpc) async fn handle_mesh_send_channel(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
|
||||
let channel = params.get("channel").and_then(|v| v.as_u64()).unwrap_or(0) as u8;
|
||||
|
||||
let message = params
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing message"))?;
|
||||
|
||||
if message.is_empty() {
|
||||
anyhow::bail!("Message cannot be empty");
|
||||
}
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running. Enable mesh first."))?;
|
||||
|
||||
let msg = svc.send_channel_message(channel, message).await?;
|
||||
info!(channel, "Sent mesh channel message");
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"sent": true,
|
||||
"message_id": msg.id,
|
||||
"channel": channel,
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.broadcast — Broadcast our node identity over mesh.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_broadcast(&self) -> Result<serde_json::Value> {
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running. Enable mesh first."))?;
|
||||
|
||||
svc.broadcast_identity().await?;
|
||||
info!("Broadcast identity over mesh");
|
||||
|
||||
Ok(serde_json::json!({ "broadcast": true }))
|
||||
}
|
||||
|
||||
/// mesh.reboot-radio — Reboot the locally-connected radio firmware to
|
||||
/// recover a wedged / RX-deaf radio. Optional `seconds` delay (default 2).
|
||||
pub(in crate::api::rpc) async fn handle_mesh_reboot_radio(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let seconds = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("seconds"))
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(2);
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running. Enable mesh first."))?;
|
||||
|
||||
svc.reboot_radio(seconds).await?;
|
||||
info!(seconds, "Mesh radio reboot requested via RPC");
|
||||
|
||||
Ok(serde_json::json!({ "reboot": true, "seconds": seconds }))
|
||||
}
|
||||
|
||||
/// mesh.configure — Enable/disable mesh and set device path.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_configure(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
|
||||
let mut config = mesh::load_config(&self.config.data_dir).await?;
|
||||
|
||||
if let Some(enabled) = params.get("enabled").and_then(|v| v.as_bool()) {
|
||||
config.enabled = enabled;
|
||||
}
|
||||
if let Some(device) = params.get("device_path").and_then(|v| v.as_str()) {
|
||||
config.device_path = Some(device.to_string());
|
||||
}
|
||||
if let Some(channel) = params.get("channel_name").and_then(|v| v.as_str()) {
|
||||
config.channel_name = Some(channel.to_string());
|
||||
}
|
||||
if let Some(broadcast) = params.get("broadcast_identity").and_then(|v| v.as_bool()) {
|
||||
config.broadcast_identity = broadcast;
|
||||
}
|
||||
if let Some(name) = params.get("advert_name").and_then(|v| v.as_str()) {
|
||||
config.advert_name = Some(name.to_string());
|
||||
}
|
||||
if let Some(announce) = params
|
||||
.get("announce_block_headers")
|
||||
.and_then(|v| v.as_bool())
|
||||
{
|
||||
config.announce_block_headers = announce;
|
||||
}
|
||||
if let Some(receive) = params
|
||||
.get("receive_block_headers")
|
||||
.and_then(|v| v.as_bool())
|
||||
{
|
||||
config.receive_block_headers = receive;
|
||||
}
|
||||
// LoRa region (Meshtastic): validated against the driver's region
|
||||
// table so a typo can't be persisted and silently ignored on connect.
|
||||
// Empty string clears the setting (radio keeps/uses its own region).
|
||||
if let Some(region) = params.get("lora_region").and_then(|v| v.as_str()) {
|
||||
let trimmed = region.trim();
|
||||
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("unset") {
|
||||
config.lora_region = None;
|
||||
} else if mesh::meshtastic_region_is_valid(trimmed) {
|
||||
config.lora_region = Some(trimmed.to_uppercase());
|
||||
} else {
|
||||
anyhow::bail!("Unknown LoRa region: {trimmed}");
|
||||
}
|
||||
}
|
||||
// Meshcore LoRa PHY params (freq/bw/sf/cr, firmware field units — see
|
||||
// mesh::LoraRadioParams). Validated against the firmware's accepted
|
||||
// ranges here so a bad value errors at the API instead of being sent
|
||||
// to the radio and rejected on-device. `null` clears the setting.
|
||||
if let Some(rp) = params.get("lora_radio_params") {
|
||||
if rp.is_null() {
|
||||
config.lora_radio_params = None;
|
||||
} else {
|
||||
let parsed: mesh::LoraRadioParams = serde_json::from_value(rp.clone())
|
||||
.map_err(|e| anyhow::anyhow!("Invalid lora_radio_params: {e}"))?;
|
||||
anyhow::ensure!(
|
||||
(150_000..=2_500_000).contains(&parsed.freq_khz),
|
||||
"freq_khz out of range (150000..=2500000)"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
(7_000..=500_000).contains(&parsed.bw_hz),
|
||||
"bw_hz out of range (7000..=500000)"
|
||||
);
|
||||
anyhow::ensure!((5..=12).contains(&parsed.sf), "sf out of range (5..=12)");
|
||||
anyhow::ensure!((5..=8).contains(&parsed.cr), "cr out of range (5..=8)");
|
||||
config.lora_radio_params = Some(parsed);
|
||||
}
|
||||
}
|
||||
// Hot-swap "keep as is": false = never write config to the radio
|
||||
// (region/channel/PHY/advert name) — use it exactly as flashed.
|
||||
if let Some(manage) = params.get("manage_radio").and_then(|v| v.as_bool()) {
|
||||
config.manage_radio = manage;
|
||||
}
|
||||
// Firmware pin: probe only the named firmware on the port ("auto"/""
|
||||
// clears the pin and restores strict-probe auto-detect).
|
||||
if let Some(kind) = params.get("device_kind").and_then(|v| v.as_str()) {
|
||||
config.device_kind = match kind.trim().to_lowercase().as_str() {
|
||||
"" | "auto" => None,
|
||||
"meshcore" => Some(mesh::types::DeviceType::Meshcore),
|
||||
"meshtastic" => Some(mesh::types::DeviceType::Meshtastic),
|
||||
"reticulum" | "rnode" => Some(mesh::types::DeviceType::Reticulum),
|
||||
other => anyhow::bail!(
|
||||
"Unknown device_kind: {other} (expected auto|meshcore|meshtastic|reticulum)"
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
mesh::save_config(&self.config.data_dir, &config).await?;
|
||||
|
||||
// If we have a running service, update its config
|
||||
let mut service = self.mesh_service.write().await;
|
||||
if let Some(svc) = service.as_mut() {
|
||||
svc.configure(config.clone()).await?;
|
||||
}
|
||||
|
||||
info!("Mesh config updated");
|
||||
Ok(serde_json::json!({
|
||||
"configured": true,
|
||||
"enabled": config.enabled,
|
||||
"device_path": config.device_path,
|
||||
"announce_block_headers": config.announce_block_headers,
|
||||
"receive_block_headers": config.receive_block_headers,
|
||||
"lora_region": config.lora_region,
|
||||
"device_kind": config.device_kind.map(|k| k.to_string()),
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
mod assistant;
|
||||
mod bitcoin_ops;
|
||||
mod messaging;
|
||||
mod safety;
|
||||
mod status;
|
||||
mod typed_messages;
|
||||
@@ -0,0 +1,245 @@
|
||||
use super::super::RpcHandler;
|
||||
use crate::mesh;
|
||||
use crate::mesh::message_types::Coordinate;
|
||||
use anyhow::Result;
|
||||
use tracing::info;
|
||||
|
||||
impl RpcHandler {
|
||||
/// mesh.outbox — List pending store-and-forward messages.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_outbox(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let limit = params
|
||||
.as_ref()
|
||||
.and_then(|p| p["limit"].as_u64())
|
||||
.map(|n| n as usize);
|
||||
|
||||
// Check if outbox file exists
|
||||
let outbox = mesh::outbox::MeshOutbox::load(&self.config.data_dir).await?;
|
||||
let messages = outbox.list(limit).await;
|
||||
let count = outbox.count().await;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"messages": messages.iter().map(|m| serde_json::json!({
|
||||
"id": m.id,
|
||||
"dest_did": m.dest_did,
|
||||
"from_did": m.from_did,
|
||||
"created_at": m.created_at,
|
||||
"ttl_secs": m.ttl_secs,
|
||||
"retry_count": m.retry_count,
|
||||
"relay_hops": m.relay_hops,
|
||||
"expired": m.is_expired(),
|
||||
})).collect::<Vec<_>>(),
|
||||
"count": count,
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.deadman-status — Get dead man's switch status.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_deadman_status(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
let status = svc.dead_man_switch.status().await;
|
||||
Ok(serde_json::to_value(status)?)
|
||||
}
|
||||
|
||||
/// mesh.deadman-configure — Configure the dead man's switch.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_deadman_configure(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
let mut config = svc.dead_man_switch.get_config().await;
|
||||
|
||||
if let Some(enabled) = params.get("enabled").and_then(|v| v.as_bool()) {
|
||||
config.dead_man_enabled = enabled;
|
||||
}
|
||||
if let Some(interval) = params.get("interval_secs").and_then(|v| v.as_u64()) {
|
||||
if interval < 60 {
|
||||
anyhow::bail!("Interval must be at least 60 seconds");
|
||||
}
|
||||
config.dead_man_interval_secs = interval;
|
||||
}
|
||||
if let (Some(lat), Some(lng)) = (
|
||||
params.get("lat").and_then(|v| v.as_f64()),
|
||||
params.get("lng").and_then(|v| v.as_f64()),
|
||||
) {
|
||||
let label = params
|
||||
.get("label")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
config.last_gps = Some(Coordinate::from_degrees(lat, lng, label));
|
||||
}
|
||||
if let Some(contacts) = params.get("contacts").and_then(|v| v.as_array()) {
|
||||
config.emergency_contacts = contacts
|
||||
.iter()
|
||||
.filter_map(|c| c.as_str().map(|s| s.to_string()))
|
||||
.collect();
|
||||
}
|
||||
if let Some(msg) = params.get("custom_message").and_then(|v| v.as_str()) {
|
||||
config.custom_message = Some(msg.to_string());
|
||||
}
|
||||
if let Some(auto_gps) = params.get("auto_gps").and_then(|v| v.as_bool()) {
|
||||
config.auto_include_gps = auto_gps;
|
||||
}
|
||||
|
||||
svc.dead_man_switch.configure(config).await?;
|
||||
// Reset timer on configure
|
||||
svc.dead_man_switch.check_in().await;
|
||||
|
||||
let status = svc.dead_man_switch.status().await;
|
||||
info!("Dead man's switch configured");
|
||||
Ok(serde_json::to_value(status)?)
|
||||
}
|
||||
|
||||
/// mesh.deadman-checkin — Heartbeat to reset the dead man's switch timer.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_deadman_checkin(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
svc.dead_man_check_in().await;
|
||||
let remaining = svc.dead_man_switch.time_remaining_secs().await;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"checked_in": true,
|
||||
"time_remaining_secs": remaining,
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.rotate-prekeys — Force prekey rotation for X3DH.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_rotate_prekeys(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
// Load identity signing key
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let node_key_path = identity_dir.join("node_key");
|
||||
let key_bytes = tokio::fs::read(&node_key_path)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("Node identity not found"))?;
|
||||
if key_bytes.len() != 32 {
|
||||
anyhow::bail!("Invalid node key");
|
||||
}
|
||||
let mut seed = [0u8; 32];
|
||||
seed.copy_from_slice(&key_bytes);
|
||||
let signing_key = ed25519_dalek::SigningKey::from_bytes(&seed);
|
||||
|
||||
// Generate new prekey bundle
|
||||
let (bundle, _secrets) = mesh::x3dh::generate_prekey_bundle(&signing_key, 10)?;
|
||||
|
||||
// Save bundle for distribution
|
||||
let bundle_bytes = mesh::x3dh::encode_bundle(&bundle)?;
|
||||
let prekey_dir = self.config.data_dir.join("prekeys");
|
||||
tokio::fs::create_dir_all(&prekey_dir).await?;
|
||||
tokio::fs::write(prekey_dir.join("bundle.cbor"), &bundle_bytes).await?;
|
||||
|
||||
info!(
|
||||
one_time_keys = bundle.one_time_prekeys.len(),
|
||||
"Prekey bundle rotated"
|
||||
);
|
||||
Ok(serde_json::json!({
|
||||
"rotated": true,
|
||||
"signed_prekey_id": bundle.signed_prekey.id,
|
||||
"one_time_prekeys": bundle.one_time_prekeys.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.test-send — Send test payloads of various sizes to diagnose radio link.
|
||||
/// Sends plain text markers that the receiver can count.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_test_send(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let contact_id = params["contact_id"]
|
||||
.as_u64()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing contact_id"))? as u32;
|
||||
|
||||
// Test modes: "ping" (small), "medium" (80 bytes), "large" (150 bytes), "chunked" (400 bytes)
|
||||
let mode = params["mode"].as_str().unwrap_or("ping");
|
||||
let count = params["count"].as_u64().unwrap_or(3) as usize;
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
let mut sent = 0usize;
|
||||
let test_id = chrono::Utc::now().timestamp() as u32;
|
||||
|
||||
for i in 0..count {
|
||||
let payload = match mode {
|
||||
"ping" => format!("MESHTEST:{}:{}:PING", test_id, i),
|
||||
"medium" => format!("MESHTEST:{}:{}:{}", test_id, i, "X".repeat(60)),
|
||||
"large" => format!("MESHTEST:{}:{}:{}", test_id, i, "X".repeat(130)),
|
||||
"chunked" => {
|
||||
// Send a TypedEnvelope that requires chunking (>140 base64 chars)
|
||||
let fake_tx = "0".repeat(400); // simulates TX hex
|
||||
let wire = crate::mesh::bitcoin_relay::build_tx_relay_request(
|
||||
&fake_tx,
|
||||
test_id as u64 + i as u64,
|
||||
)?;
|
||||
// Send via SendRaw which handles base64 + chunking
|
||||
let peers = svc.peers().await;
|
||||
if let Some(peer) = peers.iter().find(|p| p.contact_id == contact_id) {
|
||||
if let Some(ref pk) = peer.pubkey_hex {
|
||||
if let Ok(pk_bytes) = hex::decode(pk) {
|
||||
if pk_bytes.len() >= 6 {
|
||||
let mut prefix = [0u8; 6];
|
||||
prefix.copy_from_slice(&pk_bytes[..6]);
|
||||
let _ = svc
|
||||
.shared_state()
|
||||
.send_cmd(crate::mesh::listener::MeshCommand::SendRaw {
|
||||
dest_pubkey_prefix: prefix,
|
||||
payload: wire,
|
||||
})
|
||||
.await;
|
||||
sent += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Delay between chunked sends
|
||||
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||
continue;
|
||||
}
|
||||
_ => format!("MESHTEST:{}:{}:UNKNOWN", test_id, i),
|
||||
};
|
||||
|
||||
// Send as plain text for ping/medium/large
|
||||
let _msg = svc.send_message(contact_id, &payload).await?;
|
||||
sent += 1;
|
||||
info!(
|
||||
test_id,
|
||||
seq = i,
|
||||
mode,
|
||||
len = payload.len(),
|
||||
"Test message sent"
|
||||
);
|
||||
|
||||
// Small delay between sends
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"test_id": test_id,
|
||||
"mode": mode,
|
||||
"sent": sent,
|
||||
"count": count,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
use super::super::RpcHandler;
|
||||
use crate::mesh;
|
||||
use anyhow::Result;
|
||||
use tracing::warn;
|
||||
|
||||
impl RpcHandler {
|
||||
/// mesh.status — Get mesh radio status, device info, and peer count.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_status(&self) -> Result<serde_json::Value> {
|
||||
// Block-header send/receive prefs live in MeshConfig; surface them in
|
||||
// status so the UI toggles (issue #28) can show the persisted state.
|
||||
let config = mesh::load_config(&self.config.data_dir).await?;
|
||||
let service = self.mesh_service.read().await;
|
||||
let mut value = if let Some(svc) = service.as_ref() {
|
||||
let status = svc.status().await;
|
||||
serde_json::to_value(status)?
|
||||
} else {
|
||||
// No service running — return basic config + device detection
|
||||
let devices = mesh::detect_devices().await;
|
||||
serde_json::json!({
|
||||
"enabled": config.enabled,
|
||||
"device_connected": false,
|
||||
"device_type": "unknown",
|
||||
"device_path": config.device_path,
|
||||
"channel_name": config.channel_name.clone().unwrap_or_else(|| "archipelago".to_string()),
|
||||
"detected_devices": devices,
|
||||
"peer_count": 0,
|
||||
"messages_sent": 0,
|
||||
"messages_received": 0,
|
||||
})
|
||||
};
|
||||
if let Some(obj) = value.as_object_mut() {
|
||||
obj.insert(
|
||||
"announce_block_headers".into(),
|
||||
config.announce_block_headers.into(),
|
||||
);
|
||||
obj.insert(
|
||||
"receive_block_headers".into(),
|
||||
config.receive_block_headers.into(),
|
||||
);
|
||||
// Persisted config values the settings UI edits (distinct from the
|
||||
// live radio-reported `region`): the configured LoRa region and
|
||||
// the firmware pin ("meshcore"|"meshtastic"|"reticulum"|null=auto).
|
||||
obj.insert("lora_region".into(), config.lora_region.clone().into());
|
||||
obj.insert(
|
||||
"lora_radio_params".into(),
|
||||
serde_json::to_value(config.lora_radio_params).unwrap_or_default(),
|
||||
);
|
||||
obj.insert(
|
||||
"device_kind".into(),
|
||||
config
|
||||
.device_kind
|
||||
.map(|k| k.to_string().to_lowercase())
|
||||
.into(),
|
||||
);
|
||||
// Hot-swap "keep as is" state: false = archipelago never writes
|
||||
// config to the radio (see MeshConfig::manage_radio).
|
||||
obj.insert("manage_radio".into(), config.manage_radio.into());
|
||||
// USB identity per detected port so the setup modal can show the
|
||||
// actual board (product string on native-USB boards, vid:pid as
|
||||
// the fallback for bridge chips).
|
||||
obj.insert(
|
||||
"detected_device_info".into(),
|
||||
serde_json::to_value(mesh::detect_devices_info().await).unwrap_or_default(),
|
||||
);
|
||||
// Raw serial-device presence, in BOTH branches. MeshStatus has no
|
||||
// such field, so while the service was running the UI couldn't
|
||||
// tell "no radio plugged in" from "radio present but the session
|
||||
// can't open it yet" — both looked like device_connected=false.
|
||||
if !obj.contains_key("detected_devices") {
|
||||
let devices = mesh::detect_devices().await;
|
||||
obj.insert("device_present".into(), (!devices.is_empty()).into());
|
||||
obj.insert("detected_devices".into(), devices.into());
|
||||
} else {
|
||||
let present = obj
|
||||
.get("detected_devices")
|
||||
.and_then(|v| v.as_array())
|
||||
.is_some_and(|a| !a.is_empty());
|
||||
obj.insert("device_present".into(), present.into());
|
||||
}
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
/// mesh.probe-device — Identify the firmware on a detected serial port and
|
||||
/// read its current configuration WITHOUT provisioning it. Powers the
|
||||
/// hot-swap "device detected" modal's current-details view. The path must
|
||||
/// be one of the currently detected candidate ports (no arbitrary device
|
||||
/// paths), and probing the live session's own port is refused.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_probe_device(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let path = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("path"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing path"))?
|
||||
.to_string();
|
||||
let detected = mesh::detect_devices().await;
|
||||
anyhow::ensure!(
|
||||
detected.iter().any(|d| d == &path),
|
||||
"{path} is not a detected mesh-radio candidate port"
|
||||
);
|
||||
let service = self.mesh_service.read().await;
|
||||
let probe = match service.as_ref() {
|
||||
Some(svc) => svc.probe_device(&path).await?,
|
||||
// No mesh service yet (radio never enabled) — probe directly.
|
||||
None => mesh::listener::probe_device(&path).await?,
|
||||
};
|
||||
Ok(serde_json::to_value(probe)?)
|
||||
}
|
||||
|
||||
/// mesh.peers — List discovered mesh peers.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_peers(&self) -> Result<serde_json::Value> {
|
||||
let service = self.mesh_service.read().await;
|
||||
if let Some(svc) = service.as_ref() {
|
||||
let peers = svc.peers().await;
|
||||
Ok(serde_json::json!({
|
||||
"peers": peers,
|
||||
"count": peers.len(),
|
||||
}))
|
||||
} else {
|
||||
Ok(serde_json::json!({
|
||||
"peers": [],
|
||||
"count": 0,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// mesh.messages — Get recent mesh message history.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_messages(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let limit = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("limit"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|n| n as usize);
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
if let Some(svc) = service.as_ref() {
|
||||
let messages = svc.messages(limit).await;
|
||||
Ok(serde_json::json!({
|
||||
"messages": messages,
|
||||
"count": messages.len(),
|
||||
}))
|
||||
} else {
|
||||
Ok(serde_json::json!({
|
||||
"messages": [],
|
||||
"count": 0,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// conversations.list — Unified inbox across mesh peers, mesh channels,
|
||||
/// and federation nodes. Each conversation returns its latest message
|
||||
/// timestamp + snippet + transport tag so the UI can render one sorted list.
|
||||
pub(in crate::api::rpc) async fn handle_conversations_list(
|
||||
&self,
|
||||
_params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let mut conversations: Vec<serde_json::Value> = Vec::new();
|
||||
let service = self.mesh_service.read().await;
|
||||
if let Some(svc) = service.as_ref() {
|
||||
let peers = svc.peers().await;
|
||||
let messages = svc.messages(None).await;
|
||||
// Collapse radio/federation twins into one conversation per identity
|
||||
// so a node reachable both ways shows once, with its messages unioned
|
||||
// across both twin contact_ids (#12).
|
||||
let groups = mesh::group_peer_twins(&peers);
|
||||
for group in &groups {
|
||||
let peer = &group.canonical;
|
||||
// Newest message across ALL twin contact_ids in this group.
|
||||
let last = messages
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|m| group.contact_ids.contains(&m.peer_contact_id));
|
||||
let is_federation = peer.contact_id & 0x8000_0000 != 0;
|
||||
conversations.push(serde_json::json!({
|
||||
"id": format!("{}:{}", if is_federation { "federation" } else { "mesh" }, peer.contact_id),
|
||||
"transport": if is_federation { "federation" } else { "mesh" },
|
||||
"contact_id": peer.contact_id,
|
||||
"name": peer.advert_name,
|
||||
"pubkey": peer.pubkey_hex,
|
||||
"last_text": last.map(|m| m.plaintext.clone()),
|
||||
"last_timestamp": last.map(|m| m.timestamp.clone()),
|
||||
"last_direction": last.map(|m| format!("{:?}", m.direction).to_lowercase()),
|
||||
}));
|
||||
}
|
||||
// Channel 0 ("Archipelago") as a synthetic conversation.
|
||||
let channel_last = messages
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|m| m.message_type == "text" && m.peer_contact_id == 0);
|
||||
conversations.push(serde_json::json!({
|
||||
"id": "channel:0",
|
||||
"transport": "channel",
|
||||
"channel": 0,
|
||||
"name": "Archipelago",
|
||||
"last_text": channel_last.map(|m| m.plaintext.clone()),
|
||||
"last_timestamp": channel_last.map(|m| m.timestamp.clone()),
|
||||
}));
|
||||
}
|
||||
// Sort by last_timestamp desc (missing timestamps sink).
|
||||
conversations.sort_by(|a, b| {
|
||||
let at = a
|
||||
.get("last_timestamp")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let bt = b
|
||||
.get("last_timestamp")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
bt.cmp(at)
|
||||
});
|
||||
Ok(serde_json::json!({ "conversations": conversations }))
|
||||
}
|
||||
|
||||
/// conversations.messages — Return messages for a ConversationId string
|
||||
/// (format: `mesh:<contact_id>` | `federation:<contact_id>` | `channel:<u8>`).
|
||||
pub(in crate::api::rpc) async fn handle_conversations_messages(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let id = params["id"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing id"))?;
|
||||
let (kind, rest) = id
|
||||
.split_once(':')
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid conversation id"))?;
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
let all = svc.messages(None).await;
|
||||
let filtered: Vec<_> = match kind {
|
||||
"mesh" | "federation" => {
|
||||
let contact_id: u32 = rest.parse().unwrap_or(0);
|
||||
// Resolve this id's twin group and union messages across all of
|
||||
// its contact_ids, so opening either twin shows the full thread
|
||||
// (federation-injected + radio messages) (#12).
|
||||
let ids: Vec<u32> = mesh::group_peer_twins(&svc.peers().await)
|
||||
.into_iter()
|
||||
.find(|g| g.contact_ids.contains(&contact_id))
|
||||
.map(|g| g.contact_ids)
|
||||
.unwrap_or_else(|| vec![contact_id]);
|
||||
all.into_iter()
|
||||
.filter(|m| ids.contains(&m.peer_contact_id))
|
||||
.collect()
|
||||
}
|
||||
"channel" => {
|
||||
// For now the channel bucket keeps contact_id = 0.
|
||||
all.into_iter().filter(|m| m.peer_contact_id == 0).collect()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
Ok(serde_json::json!({ "messages": filtered }))
|
||||
}
|
||||
|
||||
/// mesh.debug-dump — Full in-memory state snapshot for debugging.
|
||||
/// Returns peers, all messages, status, shared-secret peer ids, encrypt_relay
|
||||
/// flag, and stego mode. Intended for smoke tests and bug investigation.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_debug_dump(&self) -> Result<serde_json::Value> {
|
||||
let service = self.mesh_service.read().await;
|
||||
if let Some(svc) = service.as_ref() {
|
||||
Ok(svc.debug_dump().await)
|
||||
} else {
|
||||
Ok(serde_json::json!({ "running": false }))
|
||||
}
|
||||
}
|
||||
|
||||
/// mesh.session-status — Get ratchet session info for a peer.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_session_status(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let contact_id = params["contact_id"]
|
||||
.as_u64()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing contact_id"))? as u32;
|
||||
|
||||
// Look up peer DID from mesh service
|
||||
let service = self.mesh_service.read().await;
|
||||
let peer_did = if let Some(svc) = service.as_ref() {
|
||||
let peers = svc.peers().await;
|
||||
peers
|
||||
.iter()
|
||||
.find(|p| p.contact_id == contact_id)
|
||||
.and_then(|p| p.did.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(did) = peer_did {
|
||||
let session_mgr = mesh::session::SessionManager::new(&self.config.data_dir);
|
||||
if let Some(info) = session_mgr.session_info(&did).await {
|
||||
Ok(serde_json::json!({
|
||||
"has_session": info.has_session,
|
||||
"forward_secrecy": info.forward_secrecy,
|
||||
"message_count": info.message_count,
|
||||
"ratchet_generation": info.ratchet_generation,
|
||||
"peer_did": did,
|
||||
}))
|
||||
} else {
|
||||
Ok(serde_json::json!({
|
||||
"has_session": false,
|
||||
"forward_secrecy": false,
|
||||
"message_count": 0,
|
||||
"ratchet_generation": 0,
|
||||
"peer_did": did,
|
||||
}))
|
||||
}
|
||||
} else {
|
||||
Ok(serde_json::json!({
|
||||
"has_session": false,
|
||||
"forward_secrecy": false,
|
||||
"message_count": 0,
|
||||
"ratchet_generation": 0,
|
||||
"peer_did": null,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// mesh.clear-all — Nuclear reset: wipe all mesh state files and restart
|
||||
/// the service for a completely clean slate.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_clear_all(&self) -> Result<serde_json::Value> {
|
||||
let data_dir = self.config.data_dir.clone();
|
||||
// Delete all mesh state files
|
||||
for filename in &[
|
||||
"messages.json",
|
||||
"mesh-contacts.json",
|
||||
"sessions.json",
|
||||
"mesh-outbox.json",
|
||||
] {
|
||||
let _ = tokio::fs::remove_file(data_dir.join(filename)).await;
|
||||
}
|
||||
// Clear in-memory state
|
||||
let service = self.mesh_service.read().await;
|
||||
if let Some(svc) = service.as_ref() {
|
||||
let state = svc.state();
|
||||
|
||||
// NOTE: `clear-all` intentionally does NOT build a radio-contact
|
||||
// blocklist. Permanently ignoring firmware contacts meant a cleared
|
||||
// peer could never return even when it re-advertised (it also broke
|
||||
// re-pairing a phone after a clear). Real per-contact blocking will
|
||||
// be a separate, explicit feature. Here we just wipe the app-side
|
||||
// view and ALSO clear any blocklist left over from older builds, so
|
||||
// previously-hidden contacts can re-appear when next heard. The
|
||||
// firmware's own contact table is the source of truth on refresh.
|
||||
{
|
||||
let mut set = state.radio_contact_blocklist.write().await;
|
||||
set.clear();
|
||||
}
|
||||
if let Err(e) = crate::mesh::save_ignored_radio_contacts(&data_dir, &[]).await {
|
||||
warn!("Failed to persist cleared radio-contact blocklist: {e:#}");
|
||||
}
|
||||
|
||||
// Actually DELETE each radio contact from the firmware table (via
|
||||
// CMD_REMOVE_CONTACT) so wiped peers don't just reappear on the next
|
||||
// refresh. They come back only when they re-advertise (reachable).
|
||||
// Federation-synthetic peers (high contact_id bit) aren't firmware
|
||||
// contacts, so skip those.
|
||||
let firmware_pubkeys: Vec<[u8; 32]> = state
|
||||
.peers
|
||||
.read()
|
||||
.await
|
||||
.values()
|
||||
.filter(|p| p.contact_id & 0x8000_0000 == 0)
|
||||
.filter_map(|p| p.pubkey_hex.as_deref())
|
||||
.filter_map(|h| hex::decode(h).ok())
|
||||
.filter(|b| b.len() == 32)
|
||||
.map(|b| {
|
||||
let mut k = [0u8; 32];
|
||||
k.copy_from_slice(&b);
|
||||
k
|
||||
})
|
||||
.collect();
|
||||
for pk in firmware_pubkeys {
|
||||
let _ = state
|
||||
.send_cmd(crate::mesh::listener::MeshCommand::RemoveContact { pubkey: pk })
|
||||
.await;
|
||||
}
|
||||
|
||||
state.peers.write().await.clear();
|
||||
state.messages.write().await.clear();
|
||||
state.contacts.write().await.clear();
|
||||
state.presence.write().await.clear();
|
||||
state.chunk_buffer.write().await.clear();
|
||||
state.shared_secrets.write().await.clear();
|
||||
// Re-seed federation peers
|
||||
crate::mesh::seed_federation_peers_into_mesh(state, &data_dir).await;
|
||||
// Trigger a contact refresh from the radio device
|
||||
let _ = state
|
||||
.send_cmd(crate::mesh::listener::MeshCommand::RefreshContacts)
|
||||
.await;
|
||||
}
|
||||
Ok(serde_json::json!({ "status": "cleared" }))
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,314 @@
|
||||
use crate::session::SessionStore;
|
||||
use std::net::IpAddr;
|
||||
|
||||
/// Methods that do not require a valid session cookie.
|
||||
pub(super) const UNAUTHENTICATED_METHODS: &[&str] = &[
|
||||
"auth.login",
|
||||
"auth.login.totp",
|
||||
"auth.login.backup",
|
||||
"auth.isOnboardingComplete",
|
||||
"auth.isSetup",
|
||||
"auth.setup",
|
||||
"auth.onboardingComplete",
|
||||
"health",
|
||||
// Server readiness check (Login.vue polls this before showing form)
|
||||
"server.echo",
|
||||
// Onboarding flow (before user has a session — DID creation, signing, backup)
|
||||
"node.did",
|
||||
"node.signChallenge",
|
||||
"node.nostr-pubkey",
|
||||
"node.createBackup",
|
||||
"identity.create",
|
||||
"identity.verify",
|
||||
"identity.resolve-did",
|
||||
// Seed management (onboarding — before user has a session)
|
||||
"seed.generate",
|
||||
"seed.verify",
|
||||
"seed.restore",
|
||||
"seed.save-encrypted",
|
||||
// Onboarding restore (before user account exists)
|
||||
"backup.restore-identity",
|
||||
// Inter-node RPC: called by federated peers over Tor, no session cookies
|
||||
"federation.peer-joined",
|
||||
"federation.peer-address-changed",
|
||||
"federation.peer-did-changed",
|
||||
"federation.get-state",
|
||||
// Fleet telemetry ingest: called by remote nodes posting reports
|
||||
"telemetry.ingest",
|
||||
];
|
||||
|
||||
/// Methods whose responses can be cached for a few seconds.
|
||||
pub(super) const CACHEABLE_METHODS: &[&str] = &["system.stats", "federation.list-nodes"];
|
||||
|
||||
/// Sanitize error messages before returning to clients.
|
||||
/// Keeps user-facing validation errors but strips internal system details.
|
||||
pub(super) fn sanitize_error_message(msg: &str) -> String {
|
||||
// Allow known validation errors through (these are user-actionable)
|
||||
let user_facing_prefixes = [
|
||||
"Invalid",
|
||||
"Missing",
|
||||
"Not found",
|
||||
"Already exists",
|
||||
"Rate limit",
|
||||
"Unauthorized",
|
||||
"Forbidden",
|
||||
"Not supported",
|
||||
"Requires",
|
||||
"requires",
|
||||
"must be",
|
||||
"cannot",
|
||||
"Password",
|
||||
"Session",
|
||||
"Failed to pull",
|
||||
"Failed to start",
|
||||
"Failed to open channel",
|
||||
"Failed to close channel",
|
||||
"Failed to connect to peer",
|
||||
// App-install dependency errors (package/dependencies.rs) — masking
|
||||
// these left users retrying installs blind ("LND install failed" on a
|
||||
// fresh node was really "Bitcoin Knots isn't running yet")
|
||||
"LND requires",
|
||||
"ElectrumX requires",
|
||||
"BTCPay Server requires",
|
||||
"Mempool requires",
|
||||
"Container",
|
||||
"Image",
|
||||
// Wallet-actionable errors: masking "Insufficient balance: need 80
|
||||
// sats, have 0 sats" behind "Operation failed. Check server logs."
|
||||
// sent the operator to journalctl for a message that was written for
|
||||
// them in the first place (ecash send, 2026-07-22).
|
||||
"Insufficient balance",
|
||||
"Insufficient funds",
|
||||
// Lightning payment failures carry LND's reason ("invoice expired.
|
||||
// Valid until …", "no route", …) — the user can act on every one of
|
||||
// them, and masking sent the operator to journalctl (invoice-expired
|
||||
// send, 2026-07-23).
|
||||
"Payment failed",
|
||||
"Invalid payment request",
|
||||
"Missing 'payment_request'",
|
||||
"Your Lightning node is still finishing",
|
||||
"Bitcoin address",
|
||||
"No router",
|
||||
"No OpenWrt",
|
||||
"No space left",
|
||||
"Not enough flash",
|
||||
"Not enough space",
|
||||
"TollGate installation failed",
|
||||
"No pre-built TollGate",
|
||||
"opkg not found",
|
||||
"apk update failed",
|
||||
"No wireless interface",
|
||||
"No wireless radio",
|
||||
"WiFi radio enabled but",
|
||||
"Missing required field",
|
||||
// seed.reveal / auth flows — user-actionable, no internals to leak.
|
||||
// Without these the sanitizer collapsed every reveal failure into
|
||||
// "Operation failed. Check server logs." (which isn't even a crash).
|
||||
"Incorrect",
|
||||
"This node has no encrypted seed",
|
||||
"No Lightning seed backup",
|
||||
"Could not decrypt the saved Lightning seed",
|
||||
"A 2FA code is required",
|
||||
"2FA is enabled but",
|
||||
"Could not decrypt the saved seed",
|
||||
"Could not unlock 2FA",
|
||||
"No mnemonic available",
|
||||
"No pending seed generation",
|
||||
"Submitted words",
|
||||
"Already set up",
|
||||
];
|
||||
for prefix in &user_facing_prefixes {
|
||||
if msg.starts_with(prefix) {
|
||||
// Truncate long messages and strip file paths
|
||||
let sanitized = msg
|
||||
.replace("/var/lib/archipelago/", "[data]/")
|
||||
.replace("/usr/local/bin/", "[bin]/")
|
||||
.replace("/etc/", "[config]/");
|
||||
return if sanitized.len() > 200 {
|
||||
format!("{}...", &sanitized[..200])
|
||||
} else {
|
||||
sanitized
|
||||
};
|
||||
}
|
||||
}
|
||||
// For all other errors, return a generic message
|
||||
"Operation failed. Check server logs for details.".to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod sanitize_tests {
|
||||
use super::sanitize_error_message;
|
||||
|
||||
#[test]
|
||||
fn seed_reveal_errors_pass_through() {
|
||||
// Every user-actionable seed.reveal failure must reach the user —
|
||||
// masking them as "Check server logs" sent a real user hunting a
|
||||
// crash that never happened.
|
||||
for msg in [
|
||||
"Incorrect password",
|
||||
"This node has no encrypted seed backup, so the recovery phrase cannot be shown. It was only displayed once during setup.",
|
||||
"A 2FA code is required to reveal the recovery phrase",
|
||||
"2FA is enabled but no TOTP data found",
|
||||
"Could not decrypt the saved seed. If you set a separate backup passphrase during setup, enter that passphrase.",
|
||||
"Could not unlock 2FA with this password",
|
||||
"No mnemonic available. Generate or restore a seed first.",
|
||||
"No Lightning seed backup exists on this node. It is captured automatically when the Lightning wallet is first created.",
|
||||
"Could not decrypt the saved Lightning seed backup",
|
||||
"Submitted words do not match generated seed",
|
||||
"Already set up. Use auth.changePassword to change.",
|
||||
] {
|
||||
assert_ne!(
|
||||
sanitize_error_message(msg),
|
||||
"Operation failed. Check server logs for details.",
|
||||
"masked: {msg}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lightning_payment_errors_pass_through() {
|
||||
// LND's payment-failure reasons are written for the payer — masking
|
||||
// "invoice expired" as "Check server logs" left a user retrying a
|
||||
// dead invoice (framework-pt, 2026-07-23).
|
||||
for msg in [
|
||||
"Payment failed: this invoice has expired (Valid until 2026-07-23 07:41:42 +0000 UTC). Ask the recipient for a fresh invoice and try again.",
|
||||
"Payment failed: unable to find a path to destination",
|
||||
"Invalid payment request: must be a Lightning invoice (lnbc...)",
|
||||
"Missing 'payment_request' parameter",
|
||||
] {
|
||||
assert_ne!(
|
||||
sanitize_error_message(msg),
|
||||
"Operation failed. Check server logs for details.",
|
||||
"masked: {msg}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_errors_stay_generic() {
|
||||
assert_eq!(
|
||||
sanitize_error_message("thread panicked at src/foo.rs:42"),
|
||||
"Operation failed. Check server logs for details."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive a CSRF token from the session token via HMAC.
|
||||
/// Deterministic: same session token always produces the same CSRF token.
|
||||
/// Survives backend restarts because it depends only on the session token
|
||||
/// and the on-disk remember secret (not ephemeral state).
|
||||
pub(super) async fn derive_csrf_token(session_token: &str) -> String {
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
let secret = SessionStore::load_or_create_remember_secret().await;
|
||||
let mut mac = HmacSha256::new_from_slice(&secret).expect("HMAC key");
|
||||
mac.update(format!("csrf:{}", session_token).as_bytes());
|
||||
hex::encode(mac.finalize().into_bytes())
|
||||
}
|
||||
|
||||
/// Extract a named cookie value from headers.
|
||||
pub(super) fn extract_cookie(headers: &hyper::HeaderMap, name: &str) -> Option<String> {
|
||||
let prefix = format!("{}=", name);
|
||||
for value in headers.get_all("cookie") {
|
||||
if let Ok(s) = value.to_str() {
|
||||
for part in s.split(';') {
|
||||
let part = part.trim();
|
||||
if let Some(val) = part.strip_prefix(&prefix) {
|
||||
let val = val.trim();
|
||||
if !val.is_empty() {
|
||||
return Some(val.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The TCP peer address of the connection a request arrived on, injected
|
||||
/// into request extensions by the server accept loop.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct PeerAddr(pub std::net::SocketAddr);
|
||||
|
||||
/// Extract the client IP for rate limiting.
|
||||
///
|
||||
/// `X-Real-IP`/`X-Forwarded-For` are only honored when the connection
|
||||
/// itself comes from loopback — i.e. from our local nginx, which sets
|
||||
/// `X-Real-IP $remote_addr`. On a direct connection (the FIPS peer
|
||||
/// listener, or anything that isn't the local proxy) the headers are
|
||||
/// client-supplied, so trusting them let an attacker rotate per-request
|
||||
/// "IPs" and defeat the login rate limiter; there we use the socket
|
||||
/// address instead.
|
||||
pub(super) fn extract_client_ip(parts: &hyper::http::request::Parts) -> IpAddr {
|
||||
let socket_ip = parts.extensions.get::<PeerAddr>().map(|p| p.0.ip());
|
||||
match socket_ip {
|
||||
Some(ip) if ip.is_loopback() => forwarded_client_ip(&parts.headers).unwrap_or(ip),
|
||||
Some(ip) => ip,
|
||||
// No socket info recorded (shouldn't happen in the server path);
|
||||
// fall back to the pre-extension behavior.
|
||||
None => {
|
||||
forwarded_client_ip(&parts.headers).unwrap_or(IpAddr::V4(std::net::Ipv4Addr::LOCALHOST))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The proxy-reported client IP, if a forwarded header carries one.
|
||||
fn forwarded_client_ip(headers: &hyper::HeaderMap) -> Option<IpAddr> {
|
||||
headers
|
||||
.get("x-real-ip")
|
||||
.or_else(|| headers.get("x-forwarded-for"))
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.split(',').next())
|
||||
.and_then(|s| s.trim().parse::<IpAddr>().ok())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod client_ip_tests {
|
||||
use super::*;
|
||||
use std::net::SocketAddr;
|
||||
|
||||
fn parts_with(peer: Option<&str>, real_ip: Option<&str>) -> hyper::http::request::Parts {
|
||||
let mut builder = hyper::Request::builder().uri("/rpc/v1");
|
||||
if let Some(ip) = real_ip {
|
||||
builder = builder.header("x-real-ip", ip);
|
||||
}
|
||||
let (mut parts, _) = builder.body(()).unwrap().into_parts();
|
||||
if let Some(addr) = peer {
|
||||
parts
|
||||
.extensions
|
||||
.insert(PeerAddr(addr.parse::<SocketAddr>().unwrap()));
|
||||
}
|
||||
parts
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loopback_connection_trusts_forwarded_header() {
|
||||
// nginx on loopback forwards the real client IP — use it.
|
||||
let parts = parts_with(Some("127.0.0.1:44412"), Some("192.168.1.50"));
|
||||
assert_eq!(
|
||||
extract_client_ip(&parts),
|
||||
"192.168.1.50".parse::<IpAddr>().unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_connection_ignores_spoofed_header() {
|
||||
// A direct (non-proxy) client rotating X-Real-IP per request must
|
||||
// still bucket under its socket address.
|
||||
let parts = parts_with(Some("203.0.113.9:9999"), Some("10.0.0.1"));
|
||||
assert_eq!(
|
||||
extract_client_ip(&parts),
|
||||
"203.0.113.9".parse::<IpAddr>().unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loopback_connection_without_header_uses_socket_ip() {
|
||||
let parts = parts_with(Some("127.0.0.1:5000"), None);
|
||||
assert_eq!(
|
||||
extract_client_ip(&parts),
|
||||
"127.0.0.1".parse::<IpAddr>().unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,701 @@
|
||||
mod analytics;
|
||||
mod ark;
|
||||
mod auth;
|
||||
mod backup_rpc;
|
||||
mod bitcoin;
|
||||
pub(crate) mod bitcoin_relay;
|
||||
mod container;
|
||||
mod content;
|
||||
mod credentials;
|
||||
mod dispatcher;
|
||||
mod dwn;
|
||||
mod federation;
|
||||
mod fedimint;
|
||||
mod fips;
|
||||
mod handshake;
|
||||
mod identity;
|
||||
mod interfaces;
|
||||
pub(crate) mod lnd;
|
||||
mod marketplace;
|
||||
mod mesh;
|
||||
mod middleware;
|
||||
mod monitoring;
|
||||
mod names;
|
||||
mod network;
|
||||
mod node;
|
||||
mod nostr;
|
||||
mod openwrt;
|
||||
mod package;
|
||||
pub(crate) use package::wyoming_satellite_keeper;
|
||||
mod peers;
|
||||
mod pine_status;
|
||||
mod response;
|
||||
mod router;
|
||||
mod security;
|
||||
mod seed_rpc;
|
||||
mod streaming;
|
||||
mod system;
|
||||
mod tor;
|
||||
mod totp;
|
||||
mod transitional;
|
||||
mod transport;
|
||||
mod update;
|
||||
mod vpn;
|
||||
mod wallet;
|
||||
mod webhooks;
|
||||
|
||||
use crate::auth::AuthManager;
|
||||
use crate::config::Config;
|
||||
use crate::container::{ContainerOrchestrator, DevContainerOrchestrator};
|
||||
use crate::monitoring::MetricsStore;
|
||||
use crate::port_allocator::PortAllocator;
|
||||
use crate::rate_limit::{EndpointRateLimiter, LoginRateLimiter};
|
||||
use crate::session::{self, SessionStore, REMEMBER_TTL};
|
||||
use crate::state::StateManager;
|
||||
use anyhow::{Context, Result};
|
||||
use hyper::{Request, Response, StatusCode};
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug, error};
|
||||
|
||||
pub use middleware::PeerAddr;
|
||||
use middleware::{
|
||||
derive_csrf_token, extract_client_ip, extract_cookie, sanitize_error_message,
|
||||
CACHEABLE_METHODS, UNAUTHENTICATED_METHODS,
|
||||
};
|
||||
use response::{cookie_header, json_response, ResponseCache, RpcError, RpcRequest, RpcResponse};
|
||||
|
||||
/// Default dev password when no user is set up (matches mock-backend).
|
||||
/// Dev builds only — the pre-setup login bypass that reads this is
|
||||
/// cfg-gated out of release binaries.
|
||||
#[cfg(debug_assertions)]
|
||||
pub(crate) const DEV_DEFAULT_PASSWORD: &str = "password123";
|
||||
|
||||
pub struct RpcHandler {
|
||||
config: Config,
|
||||
auth_manager: AuthManager,
|
||||
/// Shared lifecycle orchestrator (Dev or Prod). Always `Some` in a normal
|
||||
/// build — the only reason it is `Option` is so tests that don't exercise
|
||||
/// container RPCs can skip constructing one.
|
||||
orchestrator: Option<Arc<dyn ContainerOrchestrator>>,
|
||||
/// Concrete handle to the dev orchestrator, when we're in dev mode. Used by
|
||||
/// `container-install { manifest_path }` which takes an ad-hoc manifest
|
||||
/// path and is not part of the shared trait.
|
||||
dev_orchestrator: Option<Arc<DevContainerOrchestrator>>,
|
||||
state_manager: Arc<StateManager>,
|
||||
pub(crate) metrics_store: Arc<MetricsStore>,
|
||||
port_allocator: Arc<tokio::sync::Mutex<PortAllocator>>,
|
||||
pub session_store: SessionStore,
|
||||
login_rate_limiter: LoginRateLimiter,
|
||||
endpoint_rate_limiter: EndpointRateLimiter,
|
||||
response_cache: ResponseCache,
|
||||
mesh_service: Arc<tokio::sync::RwLock<Option<crate::mesh::MeshService>>>,
|
||||
transport_router: Arc<tokio::sync::RwLock<Option<Arc<crate::transport::TransportRouter>>>>,
|
||||
/// Shared content-addressed blob store. Set by ApiHandler after construction
|
||||
/// so mesh.send-content / mesh.fetch-content RPCs can reach it without a
|
||||
/// second instance and duplicated cap_key.
|
||||
pub(crate) blob_store: Arc<tokio::sync::RwLock<Option<Arc<crate::blobs::BlobStore>>>>,
|
||||
/// Our own Ed25519 pubkey hex — needed by ContentRef senders for cap scoping
|
||||
/// and by ContentRef receivers to request caps scoped to themselves.
|
||||
pub(crate) self_pubkey_hex: Arc<tokio::sync::RwLock<Option<String>>>,
|
||||
/// Kick the package scanner to run immediately (bypassing the 60s interval).
|
||||
/// Used by install/update success paths so the fresh manifest (with populated
|
||||
/// `interfaces.main.ui`) lands before we flip state to Running — closes the
|
||||
/// "Launch button is missing for up to 60s after install" UX gap.
|
||||
pub(crate) scan_kick: Arc<tokio::sync::Notify>,
|
||||
/// Monotonic counter incremented by the scan loop after each completed scan.
|
||||
/// Install/update success paths subscribe to this to know when a kicked scan
|
||||
/// has actually finished before flipping to the terminal state.
|
||||
pub(crate) scan_tick: Arc<tokio::sync::watch::Sender<u64>>,
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
pub async fn new(
|
||||
config: Config,
|
||||
state_manager: Arc<StateManager>,
|
||||
metrics_store: Arc<MetricsStore>,
|
||||
session_store: SessionStore,
|
||||
orchestrator: Option<Arc<dyn ContainerOrchestrator>>,
|
||||
dev_orchestrator: Option<Arc<DevContainerOrchestrator>>,
|
||||
) -> Result<Self> {
|
||||
let auth_manager = AuthManager::new(config.data_dir.clone());
|
||||
let port_allocator = Arc::new(tokio::sync::Mutex::new(
|
||||
PortAllocator::new(&config.data_dir).await?,
|
||||
));
|
||||
|
||||
let login_rate_limiter = LoginRateLimiter::new();
|
||||
let endpoint_rate_limiter = EndpointRateLimiter::new();
|
||||
|
||||
// Spawn periodic rate limiter cleanup (every 5 minutes)
|
||||
{
|
||||
let limiter = endpoint_rate_limiter.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(300));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
limiter.cleanup().await;
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let limiter = login_rate_limiter.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(300));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
limiter.cleanup().await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
auth_manager,
|
||||
orchestrator,
|
||||
dev_orchestrator,
|
||||
state_manager,
|
||||
metrics_store,
|
||||
port_allocator,
|
||||
session_store,
|
||||
login_rate_limiter,
|
||||
endpoint_rate_limiter,
|
||||
response_cache: ResponseCache::new(5),
|
||||
mesh_service: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
transport_router: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
blob_store: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
self_pubkey_hex: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
scan_kick: Arc::new(tokio::sync::Notify::new()),
|
||||
scan_tick: Arc::new(tokio::sync::watch::channel(0u64).0),
|
||||
})
|
||||
}
|
||||
|
||||
/// Set the mesh service (called after identity is loaded).
|
||||
pub async fn set_mesh_service(&self, service: crate::mesh::MeshService) {
|
||||
// If the blob store is already initialised, propagate it into the
|
||||
// freshly-started mesh state so the listener can persist inline
|
||||
// attachments. Mirrors `set_blob_store`'s forward-propagation.
|
||||
if let Some(store) = self.blob_store.read().await.as_ref().cloned() {
|
||||
*service.shared_state().blob_store.write().await = Some(store);
|
||||
}
|
||||
*self.mesh_service.write().await = Some(service);
|
||||
}
|
||||
|
||||
/// Set the transport router (called after all transports are initialized).
|
||||
pub async fn set_transport_router(&self, router: Arc<crate::transport::TransportRouter>) {
|
||||
*self.transport_router.write().await = Some(router);
|
||||
}
|
||||
|
||||
/// Share the blob store + our pubkey so mesh.send-content / fetch-content
|
||||
/// can reach them. Called once from ApiHandler::new.
|
||||
pub async fn set_blob_store(
|
||||
&self,
|
||||
store: Arc<crate::blobs::BlobStore>,
|
||||
self_pubkey_hex: String,
|
||||
) {
|
||||
*self.blob_store.write().await = Some(store.clone());
|
||||
*self.self_pubkey_hex.write().await = Some(self_pubkey_hex);
|
||||
// Propagate into a running mesh service if one is already up — keeps
|
||||
// `set_blob_store` and `set_mesh_service` order-independent.
|
||||
if let Some(svc) = self.mesh_service.read().await.as_ref() {
|
||||
*svc.shared_state().blob_store.write().await = Some(store);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get reference to the mesh service Arc (for MeshTransport wrapper).
|
||||
pub fn mesh_service_arc(&self) -> Arc<tokio::sync::RwLock<Option<crate::mesh::MeshService>>> {
|
||||
Arc::clone(&self.mesh_service)
|
||||
}
|
||||
|
||||
/// Shared Notify handle the package-scanner loop waits on (in addition to
|
||||
/// its periodic tick). Install/update success paths call `notify_one()` to
|
||||
/// trigger an immediate scan so the fresh manifest lands before we flip to
|
||||
/// the terminal Running state.
|
||||
pub fn scan_kick(&self) -> Arc<tokio::sync::Notify> {
|
||||
Arc::clone(&self.scan_kick)
|
||||
}
|
||||
|
||||
/// Sender half of the scan-completion watch channel. The scanner bumps this
|
||||
/// counter after every finished scan; install/update wait for an advance
|
||||
/// after kicking so they know the fresh manifest has landed.
|
||||
pub fn scan_tick(&self) -> Arc<tokio::sync::watch::Sender<u64>> {
|
||||
Arc::clone(&self.scan_tick)
|
||||
}
|
||||
|
||||
fn cookie_suffix_for_request(&self, headers: &hyper::header::HeaderMap) -> &'static str {
|
||||
// Only set Secure flag when the original request was over HTTPS.
|
||||
// Nginx sends X-Forwarded-Proto: https for HTTPS connections.
|
||||
// On LAN HTTP, Secure flag prevents browsers from sending cookies back.
|
||||
if self.config.dev_mode {
|
||||
return "";
|
||||
}
|
||||
if let Some(proto) = headers.get("x-forwarded-proto") {
|
||||
if proto.as_bytes() == b"https" {
|
||||
tracing::debug!("[onboarding] cookie: Secure (X-Forwarded-Proto: https)");
|
||||
return "; Secure";
|
||||
}
|
||||
}
|
||||
tracing::debug!("[onboarding] cookie: no Secure flag (HTTP or no X-Forwarded-Proto)");
|
||||
""
|
||||
}
|
||||
|
||||
pub async fn handle(
|
||||
self: Arc<Self>,
|
||||
req: Request<hyper::Body>,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
// Extract session cookie before consuming the request
|
||||
let (parts, body) = req.into_parts();
|
||||
let session_token = session::extract_session_cookie(&parts.headers);
|
||||
let secure_suffix = self.cookie_suffix_for_request(&parts.headers);
|
||||
|
||||
let body_bytes = hyper::body::to_bytes(body)
|
||||
.await
|
||||
.context("Failed to read body")?;
|
||||
|
||||
let rpc_req: RpcRequest =
|
||||
serde_json::from_slice(&body_bytes).context("Invalid RPC request")?;
|
||||
|
||||
debug!("RPC method: {}", rpc_req.method);
|
||||
|
||||
// Enforce authentication for non-allowlisted methods
|
||||
let is_unauthenticated = UNAUTHENTICATED_METHODS.contains(&rpc_req.method.as_str());
|
||||
let mut new_session_cookies: Option<(String, String)> = None;
|
||||
if !is_unauthenticated {
|
||||
let mut authenticated = match &session_token {
|
||||
Some(token) => self.session_store.validate(token).await,
|
||||
None => false,
|
||||
};
|
||||
|
||||
// If session invalid, try remember-me token to auto-restore session
|
||||
if !authenticated {
|
||||
if let Some(remember) = extract_cookie(&parts.headers, "remember") {
|
||||
if crate::session::SessionStore::validate_remember_token(&remember).await {
|
||||
let new_token = self.session_store.create().await;
|
||||
let new_csrf = derive_csrf_token(&new_token).await;
|
||||
tracing::info!("Auto-restored session from remember-me token");
|
||||
new_session_cookies = Some((new_token, new_csrf));
|
||||
authenticated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !authenticated {
|
||||
let reason = if session_token.is_none() {
|
||||
"no session cookie"
|
||||
} else {
|
||||
"invalid/expired token"
|
||||
};
|
||||
tracing::warn!(method = %rpc_req.method, reason, "401 Unauthorized — rejecting RPC call");
|
||||
return Ok(self.error_response(401, "Unauthorized", StatusCode::UNAUTHORIZED));
|
||||
}
|
||||
}
|
||||
|
||||
// RBAC: check if the user's role allows this method
|
||||
if !is_unauthenticated {
|
||||
if let Ok(Some(user)) = self.auth_manager.get_user().await {
|
||||
if !user.role.can_access(&rpc_req.method) {
|
||||
return Ok(self.error_response(
|
||||
403,
|
||||
"Forbidden: insufficient permissions",
|
||||
StatusCode::FORBIDDEN,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CSRF protection: validate X-CSRF-Token header via HMAC derivation from session token.
|
||||
// Skip CSRF for read-only methods (polling, status) — CSRF prevents state-changing forgery.
|
||||
// Skip when session was just auto-restored from remember-me (browser has stale CSRF cookie).
|
||||
let csrf_exempt = matches!(
|
||||
rpc_req.method.as_str(),
|
||||
"node-messages-received"
|
||||
| "server.echo"
|
||||
| "server.get-state"
|
||||
| "system.stats"
|
||||
| "tor.status"
|
||||
| "tor.onion-addresses"
|
||||
| "bitcoin.relay-status"
|
||||
| "federation.list-nodes"
|
||||
| "system.get-settings"
|
||||
| "system.get-node-key"
|
||||
| "system.get-metrics"
|
||||
| "system.get-version"
|
||||
);
|
||||
if !is_unauthenticated && new_session_cookies.is_none() && !csrf_exempt {
|
||||
let csrf_header = parts
|
||||
.headers
|
||||
.get("x-csrf-token")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let csrf_valid = match (&session_token, &csrf_header) {
|
||||
(Some(token), Some(header)) => {
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
let secret = SessionStore::load_or_create_remember_secret().await;
|
||||
let mut mac = match HmacSha256::new_from_slice(&secret) {
|
||||
Ok(m) => m,
|
||||
Err(_) => {
|
||||
return Ok(json_response(StatusCode::INTERNAL_SERVER_ERROR, b"{}"));
|
||||
}
|
||||
};
|
||||
mac.update(format!("csrf:{}", token).as_bytes());
|
||||
match hex::decode(header) {
|
||||
Ok(header_bytes) => mac.verify_slice(&header_bytes).is_ok(),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if !csrf_valid {
|
||||
// Debug: log expected vs received for diagnosis
|
||||
if let (Some(token), Some(header)) = (&session_token, &csrf_header) {
|
||||
let expected = derive_csrf_token(token).await;
|
||||
tracing::warn!(
|
||||
method = %rpc_req.method,
|
||||
session_prefix = %&token[..8.min(token.len())],
|
||||
csrf_prefix = %&header[..8.min(header.len())],
|
||||
expected_prefix = %&expected[..8.min(expected.len())],
|
||||
"403 CSRF mismatch — session/csrf/expected prefixes shown"
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
method = %rpc_req.method,
|
||||
has_session = session_token.is_some(),
|
||||
has_header = csrf_header.is_some(),
|
||||
"403 CSRF validation failed — rejecting RPC call"
|
||||
);
|
||||
}
|
||||
return Ok(self.error_response(
|
||||
403,
|
||||
"CSRF token missing or invalid",
|
||||
StatusCode::FORBIDDEN,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Rate limit login attempts
|
||||
if rpc_req.method == "auth.login" {
|
||||
let client_ip = extract_client_ip(&parts);
|
||||
if !self.login_rate_limiter.check(client_ip).await {
|
||||
return Ok(self.rate_limit_response());
|
||||
}
|
||||
}
|
||||
|
||||
// Rate limit sensitive endpoints
|
||||
{
|
||||
let client_ip = extract_client_ip(&parts);
|
||||
if !self
|
||||
.endpoint_rate_limiter
|
||||
.check(&rpc_req.method, client_ip)
|
||||
.await
|
||||
{
|
||||
return Ok(self.rate_limit_response());
|
||||
}
|
||||
self.endpoint_rate_limiter
|
||||
.record(&rpc_req.method, client_ip)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Extract params; clone for post-routing use (login 2FA check needs password)
|
||||
let params = rpc_req.params;
|
||||
let login_params: Option<serde_json::Value> = if rpc_req.method == "auth.login" {
|
||||
params.clone()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Check cache for cacheable methods
|
||||
let is_cacheable = CACHEABLE_METHODS.contains(&rpc_req.method.as_str());
|
||||
if is_cacheable {
|
||||
if let Some(cached) = self.response_cache.get(&rpc_req.method).await {
|
||||
let rpc_resp = RpcResponse {
|
||||
result: Some(cached),
|
||||
error: None,
|
||||
};
|
||||
let body = serde_json::to_vec(&rpc_resp)?;
|
||||
return Ok(json_response(StatusCode::OK, &body));
|
||||
}
|
||||
}
|
||||
|
||||
// Route to handler (track latency for metrics)
|
||||
let rpc_start = std::time::Instant::now();
|
||||
let result = Self::dispatch(&self, &rpc_req.method, params, &session_token).await;
|
||||
|
||||
// Record RPC latency for monitoring
|
||||
let elapsed_ms = rpc_start.elapsed().as_secs_f64() * 1000.0;
|
||||
self.metrics_store.record_rpc_latency(elapsed_ms).await;
|
||||
|
||||
// Build response (cache successful results for cacheable methods)
|
||||
let mut rpc_resp = match result {
|
||||
Ok(data) => {
|
||||
if is_cacheable {
|
||||
self.response_cache
|
||||
.set(rpc_req.method.clone(), data.clone())
|
||||
.await;
|
||||
}
|
||||
RpcResponse {
|
||||
result: Some(data),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// `{:#}` renders the whole anyhow context chain. Logging only the
|
||||
// outermost context threw away the actual cause: a peer-files
|
||||
// failure logged just "Failed to connect to peer", with the real
|
||||
// error (Tor SOCKS failure, FIPS resolve, timeout) discarded — so
|
||||
// the logs couldn't distinguish a dead peer from a slow circuit.
|
||||
// The client-facing message below stays `{}` so internals aren't leaked.
|
||||
error!("RPC error on {}: {:#}", rpc_req.method, e);
|
||||
let user_message = sanitize_error_message(&e.to_string());
|
||||
RpcResponse {
|
||||
result: None,
|
||||
error: Some(RpcError {
|
||||
code: -1,
|
||||
message: user_message,
|
||||
data: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let resp_body = serde_json::to_vec(&rpc_resp).context("Failed to serialize response")?;
|
||||
|
||||
let mut response = json_response(StatusCode::OK, &resp_body);
|
||||
|
||||
// Post-dispatch: set cookies for auth-related methods
|
||||
let client_ip = extract_client_ip(&parts);
|
||||
self.apply_auth_cookies(
|
||||
&rpc_req.method,
|
||||
&mut rpc_resp,
|
||||
&mut response,
|
||||
&session_token,
|
||||
&login_params,
|
||||
&new_session_cookies,
|
||||
client_ip,
|
||||
secure_suffix,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Build a JSON error response with the given RPC error code and HTTP status.
|
||||
fn error_response(
|
||||
&self,
|
||||
code: i32,
|
||||
message: &str,
|
||||
status: StatusCode,
|
||||
) -> Response<hyper::Body> {
|
||||
let rpc_resp = RpcResponse {
|
||||
result: None,
|
||||
error: Some(RpcError {
|
||||
code,
|
||||
message: message.to_string(),
|
||||
data: None,
|
||||
}),
|
||||
};
|
||||
let resp_body = serde_json::to_vec(&rpc_resp).unwrap_or_default();
|
||||
json_response(status, &resp_body)
|
||||
}
|
||||
|
||||
/// Build a 429 Too Many Requests response.
|
||||
fn rate_limit_response(&self) -> Response<hyper::Body> {
|
||||
let rpc_resp = RpcResponse {
|
||||
result: None,
|
||||
error: Some(RpcError {
|
||||
code: 429,
|
||||
message: "Rate limit exceeded. Try again later.".to_string(),
|
||||
data: None,
|
||||
}),
|
||||
};
|
||||
let resp_body = serde_json::to_vec(&rpc_resp).unwrap_or_default();
|
||||
let mut resp = json_response(StatusCode::TOO_MANY_REQUESTS, &resp_body);
|
||||
resp.headers_mut()
|
||||
.insert("Retry-After", cookie_header("60"));
|
||||
resp
|
||||
}
|
||||
|
||||
/// Apply session/CSRF/remember-me cookies after dispatch for auth-related methods.
|
||||
async fn apply_auth_cookies(
|
||||
&self,
|
||||
method: &str,
|
||||
rpc_resp: &mut RpcResponse,
|
||||
response: &mut Response<hyper::Body>,
|
||||
session_token: &Option<String>,
|
||||
login_params: &Option<serde_json::Value>,
|
||||
new_session_cookies: &Option<(String, String)>,
|
||||
client_ip: std::net::IpAddr,
|
||||
secure_suffix: &str,
|
||||
) {
|
||||
// Track failed login attempts for rate limiting
|
||||
if method == "auth.login" && rpc_resp.error.is_some() {
|
||||
self.login_rate_limiter.record_failure(client_ip).await;
|
||||
}
|
||||
|
||||
// On successful login, check if 2FA is required. Device-token logins
|
||||
// (companion pairing QR) skip the TOTP challenge like remember-me does:
|
||||
// the token was minted from an already-authenticated session, and there
|
||||
// is no password with which to decrypt the TOTP secret anyway.
|
||||
if method == "auth.login" && rpc_resp.error.is_none() {
|
||||
let password = login_params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("password"))
|
||||
.and_then(|v| v.as_str());
|
||||
let is_token_login = login_params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("token"))
|
||||
.and_then(|v| v.as_str())
|
||||
.is_some()
|
||||
|| match password {
|
||||
// Companion device tokens also arrive through the password
|
||||
// field (see handle_auth_login) — those logins get a full
|
||||
// session too; there's no password to decrypt TOTP with.
|
||||
Some(pw) => crate::device_tokens::verify(&self.config.data_dir, pw)
|
||||
.await
|
||||
.is_some(),
|
||||
None => false,
|
||||
};
|
||||
let totp_enabled =
|
||||
!is_token_login && self.auth_manager.is_totp_enabled().await.unwrap_or(false);
|
||||
if totp_enabled {
|
||||
let password = login_params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("password"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
if let Ok(Some(totp_data)) = self.auth_manager.get_totp_data().await {
|
||||
if let Ok(secret) = crate::totp::decrypt_secret(&totp_data, password) {
|
||||
let token = self.session_store.create_pending(secret).await;
|
||||
let csrf_token = derive_csrf_token(&token).await;
|
||||
self.set_session_cookie(response, &token, secure_suffix);
|
||||
self.set_csrf_cookie(response, &csrf_token, secure_suffix);
|
||||
let totp_body = serde_json::json!({
|
||||
"result": { "requires_totp": true },
|
||||
"error": null
|
||||
});
|
||||
*response.body_mut() =
|
||||
hyper::Body::from(serde_json::to_vec(&totp_body).unwrap_or_default());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let token = self.session_store.create().await;
|
||||
let csrf_token = derive_csrf_token(&token).await;
|
||||
let remember_token = self.session_store.create_remember_token().await;
|
||||
self.set_session_cookie(response, &token, secure_suffix);
|
||||
self.set_csrf_cookie(response, &csrf_token, secure_suffix);
|
||||
self.set_remember_cookie(response, &remember_token, secure_suffix);
|
||||
}
|
||||
}
|
||||
|
||||
// On successful TOTP verification, set the rotated session cookie
|
||||
if (method == "auth.login.totp" || method == "auth.login.backup")
|
||||
&& rpc_resp.error.is_none()
|
||||
{
|
||||
let new_token_opt = rpc_resp
|
||||
.result
|
||||
.as_ref()
|
||||
.and_then(|r| r.get("new_session_token"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
if let Some(new_token) = new_token_opt {
|
||||
let csrf_token = derive_csrf_token(&new_token).await;
|
||||
let remember_token = self.session_store.create_remember_token().await;
|
||||
self.set_session_cookie(response, &new_token, secure_suffix);
|
||||
self.set_csrf_cookie(response, &csrf_token, secure_suffix);
|
||||
self.set_remember_cookie(response, &remember_token, secure_suffix);
|
||||
// Strip the token from the response body
|
||||
if let Some(result) = rpc_resp.result.as_mut() {
|
||||
if let Some(obj) = result.as_object_mut() {
|
||||
obj.remove("new_session_token");
|
||||
}
|
||||
}
|
||||
let body_bytes = serde_json::to_vec(&rpc_resp).unwrap_or_default();
|
||||
*response.body_mut() = hyper::Body::from(body_bytes);
|
||||
}
|
||||
}
|
||||
|
||||
// On password change, rotate the session token for the caller
|
||||
if method == "auth.changePassword" && rpc_resp.error.is_none() {
|
||||
if let Some(token) = session_token {
|
||||
let new_token = self.session_store.rotate(token).await;
|
||||
let csrf_token = derive_csrf_token(&new_token).await;
|
||||
self.set_session_cookie(response, &new_token, secure_suffix);
|
||||
self.set_csrf_cookie(response, &csrf_token, secure_suffix);
|
||||
}
|
||||
}
|
||||
|
||||
// On logout, invalidate session and expire cookies
|
||||
if method == "auth.logout" {
|
||||
if let Some(token) = session_token {
|
||||
self.session_store.remove(token).await;
|
||||
}
|
||||
response.headers_mut().append(
|
||||
"Set-Cookie",
|
||||
cookie_header(&format!(
|
||||
"session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0{}",
|
||||
secure_suffix
|
||||
)),
|
||||
);
|
||||
response.headers_mut().append(
|
||||
"Set-Cookie",
|
||||
cookie_header(&format!(
|
||||
"csrf_token=; SameSite=Lax; Path=/; Max-Age=0{}",
|
||||
secure_suffix
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
// If session was auto-restored from remember-me, set new cookies
|
||||
if let Some((new_session, new_csrf)) = new_session_cookies {
|
||||
self.set_session_cookie(response, new_session, secure_suffix);
|
||||
self.set_csrf_cookie(response, new_csrf, secure_suffix);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_session_cookie(
|
||||
&self,
|
||||
response: &mut Response<hyper::Body>,
|
||||
token: &str,
|
||||
secure_suffix: &str,
|
||||
) {
|
||||
response.headers_mut().append(
|
||||
"Set-Cookie",
|
||||
cookie_header(&format!(
|
||||
"session={}; HttpOnly; SameSite=Lax; Path=/{}",
|
||||
token, secure_suffix
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
fn set_csrf_cookie(
|
||||
&self,
|
||||
response: &mut Response<hyper::Body>,
|
||||
csrf_token: &str,
|
||||
secure_suffix: &str,
|
||||
) {
|
||||
response.headers_mut().append(
|
||||
"Set-Cookie",
|
||||
cookie_header(&format!(
|
||||
"csrf_token={}; SameSite=Lax; Path=/{}",
|
||||
csrf_token, secure_suffix
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
fn set_remember_cookie(
|
||||
&self,
|
||||
response: &mut Response<hyper::Body>,
|
||||
remember_token: &str,
|
||||
secure_suffix: &str,
|
||||
) {
|
||||
response.headers_mut().append(
|
||||
"Set-Cookie",
|
||||
cookie_header(&format!(
|
||||
"remember={}; HttpOnly; SameSite=Lax; Path=/; Max-Age={}{}",
|
||||
remember_token, REMEMBER_TTL, secure_suffix
|
||||
)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
use super::RpcHandler;
|
||||
use crate::monitoring::AlertRuleKind;
|
||||
use anyhow::Result;
|
||||
use tracing::debug;
|
||||
|
||||
impl RpcHandler {
|
||||
/// monitoring.current — latest metrics snapshot
|
||||
pub(super) async fn handle_monitoring_current(&self) -> Result<serde_json::Value> {
|
||||
debug!("Getting current metrics");
|
||||
|
||||
match self.metrics_store.latest().await {
|
||||
Some(snapshot) => Ok(serde_json::to_value(snapshot)?),
|
||||
None => Ok(
|
||||
serde_json::json!({ "status": "collecting", "message": "No metrics collected yet" }),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// monitoring.history — historical metrics at given resolution
|
||||
pub(super) async fn handle_monitoring_history(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
debug!("Getting metrics history");
|
||||
|
||||
let resolution = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("resolution"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("minute");
|
||||
|
||||
let count = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("count"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(60) as usize;
|
||||
|
||||
// Clamp count to reasonable limits
|
||||
let count = count.min(1440);
|
||||
|
||||
let data = match resolution {
|
||||
"quarter_hour" | "15min" => self.metrics_store.history_quarter_hours(count).await,
|
||||
_ => self.metrics_store.history_minutes(count).await,
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"resolution": resolution,
|
||||
"count": data.len(),
|
||||
"data": data,
|
||||
}))
|
||||
}
|
||||
|
||||
/// monitoring.containers — latest per-container metrics
|
||||
pub(super) async fn handle_monitoring_containers(&self) -> Result<serde_json::Value> {
|
||||
debug!("Getting container metrics");
|
||||
|
||||
match self.metrics_store.latest().await {
|
||||
Some(snapshot) => Ok(serde_json::json!({
|
||||
"timestamp": snapshot.timestamp,
|
||||
"containers": snapshot.containers,
|
||||
})),
|
||||
None => Ok(serde_json::json!({ "containers": [] })),
|
||||
}
|
||||
}
|
||||
|
||||
/// monitoring.alerts — get fired alert history
|
||||
pub(super) async fn handle_monitoring_alerts(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
debug!("Getting alert history");
|
||||
|
||||
let count = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("count"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(50) as usize;
|
||||
|
||||
let alerts = self.metrics_store.get_fired_alerts(count).await;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"count": alerts.len(),
|
||||
"alerts": alerts,
|
||||
}))
|
||||
}
|
||||
|
||||
/// monitoring.alert-rules — get current alert rules
|
||||
pub(super) async fn handle_monitoring_alert_rules(&self) -> Result<serde_json::Value> {
|
||||
debug!("Getting alert rules");
|
||||
|
||||
let rules = self.metrics_store.get_alert_rules().await;
|
||||
Ok(serde_json::json!({ "rules": rules }))
|
||||
}
|
||||
|
||||
/// monitoring.configure-alert — update an alert rule
|
||||
pub(super) async fn handle_monitoring_configure_alert(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
|
||||
let kind_str = params
|
||||
.get("kind")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'kind' parameter"))?;
|
||||
|
||||
let kind: AlertRuleKind = serde_json::from_value(serde_json::json!(kind_str))
|
||||
.map_err(|_| anyhow::anyhow!("Invalid alert kind: {}", kind_str))?;
|
||||
|
||||
let enabled = params.get("enabled").and_then(|v| v.as_bool());
|
||||
let threshold = params.get("threshold").and_then(|v| v.as_f64());
|
||||
|
||||
self.metrics_store
|
||||
.update_alert_rule(&kind, enabled, threshold)
|
||||
.await;
|
||||
|
||||
debug!("Updated alert rule: {:?}", kind);
|
||||
Ok(serde_json::json!({ "updated": true, "kind": kind_str }))
|
||||
}
|
||||
|
||||
/// monitoring.export — export metrics as CSV or JSON
|
||||
pub(super) async fn handle_monitoring_export(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
debug!("Exporting metrics");
|
||||
|
||||
let format = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("format"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("csv");
|
||||
|
||||
let resolution = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("resolution"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("minute");
|
||||
|
||||
let count = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("count"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(1440) as usize;
|
||||
|
||||
let count = count.min(1440);
|
||||
|
||||
let data = match resolution {
|
||||
"quarter_hour" | "15min" => self.metrics_store.history_quarter_hours(count).await,
|
||||
_ => self.metrics_store.history_minutes(count).await,
|
||||
};
|
||||
|
||||
match format {
|
||||
"json" => Ok(serde_json::json!({
|
||||
"format": "json",
|
||||
"resolution": resolution,
|
||||
"count": data.len(),
|
||||
"data": data,
|
||||
})),
|
||||
_ => {
|
||||
// CSV format
|
||||
let mut csv = String::from(
|
||||
"timestamp,cpu_percent,mem_used_bytes,mem_total_bytes,disk_used_bytes,disk_total_bytes,net_rx_bytes,net_tx_bytes,load_avg_1,load_avg_5,load_avg_15,rpc_latency_ms,ws_connections\n"
|
||||
);
|
||||
for snapshot in &data {
|
||||
csv.push_str(&format!(
|
||||
"{},{:.1},{},{},{},{},{},{},{:.2},{:.2},{:.2},{:.1},{}\n",
|
||||
snapshot.timestamp,
|
||||
snapshot.system.cpu_percent,
|
||||
snapshot.system.mem_used_bytes,
|
||||
snapshot.system.mem_total_bytes,
|
||||
snapshot.system.disk_used_bytes,
|
||||
snapshot.system.disk_total_bytes,
|
||||
snapshot.system.net_rx_bytes,
|
||||
snapshot.system.net_tx_bytes,
|
||||
snapshot.system.load_avg_1,
|
||||
snapshot.system.load_avg_5,
|
||||
snapshot.system.load_avg_15,
|
||||
snapshot.rpc_latency_ms,
|
||||
snapshot.ws_connections,
|
||||
));
|
||||
}
|
||||
Ok(serde_json::json!({
|
||||
"format": "csv",
|
||||
"resolution": resolution,
|
||||
"count": data.len(),
|
||||
"csv": csv,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// monitoring.acknowledge-alert — acknowledge a fired alert
|
||||
pub(super) async fn handle_monitoring_acknowledge_alert(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
|
||||
let alert_id = params
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'id' parameter"))?;
|
||||
|
||||
let found = self.metrics_store.acknowledge_alert(alert_id).await;
|
||||
|
||||
Ok(serde_json::json!({ "acknowledged": found, "id": alert_id }))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
use super::RpcHandler;
|
||||
use crate::names;
|
||||
use anyhow::Result;
|
||||
|
||||
impl RpcHandler {
|
||||
/// List all registered names.
|
||||
pub(super) async fn handle_identity_list_names(
|
||||
&self,
|
||||
_params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let store = names::load_names(&self.config.data_dir).await?;
|
||||
let items: Vec<serde_json::Value> = store
|
||||
.names
|
||||
.into_iter()
|
||||
.map(|n| {
|
||||
serde_json::json!({
|
||||
"id": n.id,
|
||||
"name": n.name,
|
||||
"domain": n.domain,
|
||||
"nip05": n.nip05,
|
||||
"identity_id": n.identity_id,
|
||||
"did": n.did,
|
||||
"nostr_pubkey": n.nostr_pubkey,
|
||||
"status": n.status,
|
||||
"registered_at": n.registered_at,
|
||||
"expires_at": n.expires_at,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(serde_json::json!({ "names": items }))
|
||||
}
|
||||
|
||||
/// Register a new name linked to an identity.
|
||||
pub(super) async fn handle_identity_register_name(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let name = params
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing name"))?;
|
||||
let domain = params
|
||||
.get("domain")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing domain"))?;
|
||||
let identity_id = params
|
||||
.get("identity_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing identity_id"))?;
|
||||
let did = params
|
||||
.get("did")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing did"))?;
|
||||
let nostr_pubkey = params.get("nostr_pubkey").and_then(|v| v.as_str());
|
||||
|
||||
let record = names::register_name(
|
||||
&self.config.data_dir,
|
||||
name,
|
||||
domain,
|
||||
identity_id,
|
||||
did,
|
||||
nostr_pubkey,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"id": record.id,
|
||||
"nip05": record.nip05,
|
||||
"status": record.status,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Remove a registered name.
|
||||
pub(super) async fn handle_identity_remove_name(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let id = params
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing id"))?;
|
||||
|
||||
names::remove_name(&self.config.data_dir, id).await?;
|
||||
Ok(serde_json::json!({ "ok": true }))
|
||||
}
|
||||
|
||||
/// Resolve a NIP-05 identifier to verify it.
|
||||
pub(super) async fn handle_identity_resolve_name(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let identifier = params
|
||||
.get("identifier")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing identifier (user@domain)"))?;
|
||||
|
||||
let result = names::resolve_nip05(identifier).await?;
|
||||
Ok(serde_json::json!({
|
||||
"name": result.name,
|
||||
"domain": result.domain,
|
||||
"nostr_pubkey": result.nostr_pubkey,
|
||||
"relays": result.relays,
|
||||
"verified": result.verified,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Link a name to a different DID/identity.
|
||||
pub(super) async fn handle_identity_link_name(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let name_id = params
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing id"))?;
|
||||
let did = params
|
||||
.get("did")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing did"))?;
|
||||
let identity_id = params
|
||||
.get("identity_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing identity_id"))?;
|
||||
|
||||
let updated =
|
||||
names::link_name_to_did(&self.config.data_dir, name_id, did, identity_id).await?;
|
||||
Ok(serde_json::json!({
|
||||
"id": updated.id,
|
||||
"nip05": updated.nip05,
|
||||
"did": updated.did,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
//! RPC handlers for node network visibility and overlay controls.
|
||||
|
||||
use super::RpcHandler;
|
||||
use crate::container::docker_packages;
|
||||
use crate::{identity, peers};
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::fs;
|
||||
|
||||
const VISIBILITY_FILE: &str = "network_visibility";
|
||||
const REQUESTS_DIR: &str = "connection_requests";
|
||||
|
||||
/// A pending connection request from another node.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct ConnectionRequest {
|
||||
id: String,
|
||||
from_did: String,
|
||||
from_onion: String,
|
||||
from_pubkey: String,
|
||||
message: Option<String>,
|
||||
created_at: String,
|
||||
}
|
||||
|
||||
/// Node visibility levels for peer discovery.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum NodeVisibility {
|
||||
Hidden,
|
||||
Discoverable,
|
||||
Public,
|
||||
}
|
||||
|
||||
impl NodeVisibility {
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
NodeVisibility::Hidden => "hidden",
|
||||
NodeVisibility::Discoverable => "discoverable",
|
||||
NodeVisibility::Public => "public",
|
||||
}
|
||||
}
|
||||
|
||||
fn from_str(s: &str) -> Self {
|
||||
match s.trim().to_lowercase().as_str() {
|
||||
"discoverable" => NodeVisibility::Discoverable,
|
||||
"public" => NodeVisibility::Public,
|
||||
_ => NodeVisibility::Hidden,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// Get the current node visibility setting.
|
||||
pub(super) async fn handle_network_get_visibility(&self) -> Result<serde_json::Value> {
|
||||
let vis = self.load_visibility().await;
|
||||
let tor_address = docker_packages::read_tor_address("archipelago").await;
|
||||
Ok(serde_json::json!({
|
||||
"visibility": vis.as_str(),
|
||||
"tor_address": tor_address,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Set node visibility. When discoverable/public, publishes to Nostr relays.
|
||||
/// When hidden, stops advertising.
|
||||
pub(super) async fn handle_network_set_visibility(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let vis_str = params
|
||||
.get("visibility")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: visibility"))?;
|
||||
|
||||
let vis = NodeVisibility::from_str(vis_str);
|
||||
|
||||
// Persist the setting
|
||||
let vis_path = self.config.data_dir.join(VISIBILITY_FILE);
|
||||
fs::write(&vis_path, vis.as_str().as_bytes())
|
||||
.await
|
||||
.context("Failed to write visibility setting")?;
|
||||
|
||||
// Visibility is stored but we never publish to public relays.
|
||||
// Nodes connect via federation ID, not Nostr discovery.
|
||||
tracing::info!("Node visibility set to {}", vis.as_str());
|
||||
Ok(serde_json::json!({
|
||||
"visibility": vis.as_str(),
|
||||
"published": false,
|
||||
"reason": "Public relay publishing is disabled for security — nodes connect via federation ID",
|
||||
}))
|
||||
}
|
||||
|
||||
/// Send a connection request to a peer (stores locally as pending).
|
||||
pub(super) async fn handle_network_request_connection(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let to_did = params
|
||||
.get("did")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: did"))?;
|
||||
let to_onion = params
|
||||
.get("onion")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: onion"))?;
|
||||
let to_pubkey = params
|
||||
.get("pubkey")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: pubkey"))?;
|
||||
let message = params
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
|
||||
// Send a message to the peer over Tor with connection request
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let my_pubkey = &data.server_info.pubkey;
|
||||
let my_did = identity::did_key_from_pubkey_hex(my_pubkey)?;
|
||||
let my_onion = docker_packages::read_tor_address("archipelago")
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let req_msg = serde_json::json!({
|
||||
"type": "connection_request",
|
||||
"from_did": my_did,
|
||||
"from_onion": my_onion,
|
||||
"from_pubkey": my_pubkey,
|
||||
"message": message,
|
||||
});
|
||||
|
||||
let to_fips_npub =
|
||||
crate::federation::fips_npub_for_onion(&self.config.data_dir, to_onion).await;
|
||||
crate::node_message::send_to_peer(
|
||||
to_onion,
|
||||
to_fips_npub.as_deref(),
|
||||
my_pubkey,
|
||||
&req_msg.to_string(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Also add them as a pending peer locally
|
||||
let req = ConnectionRequest {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
from_did: to_did.to_string(),
|
||||
from_onion: to_onion.to_string(),
|
||||
from_pubkey: to_pubkey.to_string(),
|
||||
message,
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
};
|
||||
self.save_request(&req).await?;
|
||||
|
||||
Ok(serde_json::json!({ "ok": true, "request_id": req.id }))
|
||||
}
|
||||
|
||||
/// List pending connection requests.
|
||||
pub(super) async fn handle_network_list_requests(&self) -> Result<serde_json::Value> {
|
||||
let requests = self.load_requests().await?;
|
||||
Ok(serde_json::json!({ "requests": requests }))
|
||||
}
|
||||
|
||||
/// Accept a connection request — add peer to trusted list AND send
|
||||
/// a `connection_accepted` notification back to the requester so
|
||||
/// their side auto-adds us without a second manual round-trip.
|
||||
pub(super) async fn handle_network_accept_request(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
// The web UI historically sent `request_id`; accept both spellings.
|
||||
let request_id = params
|
||||
.get("id")
|
||||
.or_else(|| params.get("request_id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
|
||||
|
||||
let requests = self.load_requests().await?;
|
||||
let req = requests
|
||||
.iter()
|
||||
.find(|r| r.id == request_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Request not found: {}", request_id))?
|
||||
.clone();
|
||||
|
||||
// Add to known peers
|
||||
let peer = peers::KnownPeer {
|
||||
onion: req.from_onion.clone(),
|
||||
pubkey: req.from_pubkey.clone(),
|
||||
name: None,
|
||||
added_at: Some(chrono::Utc::now().to_rfc3339()),
|
||||
};
|
||||
peers::add_peer(&self.config.data_dir, peer).await?;
|
||||
|
||||
// Remove the request
|
||||
self.delete_request(request_id).await?;
|
||||
|
||||
// Notify the requester we've accepted so their UI auto-adds us and
|
||||
// clears its outbound pending row. Best-effort — if the peer is
|
||||
// offline we don't fail the accept; the next connection_request
|
||||
// retry on their side will resolve eventually.
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let my_pubkey = data.server_info.pubkey.clone();
|
||||
let my_did = crate::identity::did_key_from_pubkey_hex(&my_pubkey).ok();
|
||||
let my_onion = crate::container::docker_packages::read_tor_address("archipelago")
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let accept_msg = serde_json::json!({
|
||||
"type": "connection_accepted",
|
||||
"request_id": request_id,
|
||||
"from_did": my_did,
|
||||
"from_onion": my_onion,
|
||||
"from_pubkey": my_pubkey,
|
||||
});
|
||||
let to_fips_npub =
|
||||
crate::federation::fips_npub_for_onion(&self.config.data_dir, &req.from_onion).await;
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let signing_key = crate::identity::NodeIdentity::load_or_create(&identity_dir)
|
||||
.await
|
||||
.ok();
|
||||
if let Err(e) = crate::node_message::send_to_peer(
|
||||
&req.from_onion,
|
||||
to_fips_npub.as_deref(),
|
||||
&my_pubkey,
|
||||
&accept_msg.to_string(),
|
||||
signing_key.as_ref().map(|i| i.signing_key()),
|
||||
Some(&req.from_pubkey),
|
||||
data.server_info.name.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
to = %req.from_did,
|
||||
error = %e,
|
||||
"connection_accepted notify failed (requester will still be able to see us on their next retry)"
|
||||
);
|
||||
}
|
||||
|
||||
tracing::info!("Accepted connection from {}", req.from_did);
|
||||
Ok(serde_json::json!({ "ok": true }))
|
||||
}
|
||||
|
||||
/// Reject a connection request.
|
||||
pub(super) async fn handle_network_reject_request(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
// The web UI historically sent `request_id`; accept both spellings.
|
||||
let request_id = params
|
||||
.get("id")
|
||||
.or_else(|| params.get("request_id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
|
||||
|
||||
self.delete_request(request_id).await?;
|
||||
Ok(serde_json::json!({ "ok": true }))
|
||||
}
|
||||
|
||||
// --- internal helpers ---
|
||||
|
||||
/// Load current visibility setting from disk (defaults to hidden).
|
||||
async fn load_visibility(&self) -> NodeVisibility {
|
||||
let vis_path = self.config.data_dir.join(VISIBILITY_FILE);
|
||||
match fs::read_to_string(&vis_path).await {
|
||||
Ok(s) => NodeVisibility::from_str(&s),
|
||||
Err(_) => NodeVisibility::Hidden,
|
||||
}
|
||||
}
|
||||
|
||||
async fn requests_dir(&self) -> Result<std::path::PathBuf> {
|
||||
let dir = self.config.data_dir.join(REQUESTS_DIR);
|
||||
fs::create_dir_all(&dir)
|
||||
.await
|
||||
.context("Failed to create requests dir")?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
async fn save_request(&self, req: &ConnectionRequest) -> Result<()> {
|
||||
let dir = self.requests_dir().await?;
|
||||
let path = dir.join(format!("{}.json", req.id));
|
||||
let json = serde_json::to_string_pretty(req).context("Failed to serialize request")?;
|
||||
fs::write(&path, json)
|
||||
.await
|
||||
.context("Failed to write request")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_requests(&self) -> Result<Vec<ConnectionRequest>> {
|
||||
let dir = self.requests_dir().await?;
|
||||
let mut requests = Vec::new();
|
||||
let mut entries = fs::read_dir(&dir).await?;
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
if let Ok(data) = fs::read(&path).await {
|
||||
if let Ok(req) = serde_json::from_slice::<ConnectionRequest>(&data) {
|
||||
requests.push(req);
|
||||
}
|
||||
}
|
||||
}
|
||||
requests.sort_by(|a, b| a.created_at.cmp(&b.created_at));
|
||||
Ok(requests)
|
||||
}
|
||||
|
||||
async fn delete_request(&self, id: &str) -> Result<()> {
|
||||
// Validate ID to prevent path traversal
|
||||
if id.is_empty()
|
||||
|| id.len() > 128
|
||||
|| id.contains('/')
|
||||
|| id.contains('\\')
|
||||
|| id.contains("..")
|
||||
|| id.contains('\0')
|
||||
{
|
||||
anyhow::bail!("Invalid request ID");
|
||||
}
|
||||
let dir = self.requests_dir().await?;
|
||||
let path = dir.join(format!("{}.json", id));
|
||||
if path.exists() {
|
||||
fs::remove_file(&path)
|
||||
.await
|
||||
.context("Failed to delete request")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
use super::RpcHandler;
|
||||
use crate::container::docker_packages;
|
||||
use crate::{backup, identity, nostr_discovery};
|
||||
use anyhow::{Context, Result};
|
||||
use ed25519_dalek::SigningKey;
|
||||
use nostr_sdk::ToBech32;
|
||||
use rand::rngs::OsRng;
|
||||
use tokio::fs;
|
||||
|
||||
impl RpcHandler {
|
||||
pub(super) async fn handle_node_did(&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 identity_dir = self.config.data_dir.join("identity");
|
||||
let nostr_pubkey = nostr_discovery::get_nostr_pubkey(&identity_dir).await.ok();
|
||||
let nostr_npub = nostr_pubkey.as_ref().and_then(|hex| {
|
||||
nostr_sdk::PublicKey::from_hex(hex)
|
||||
.ok()
|
||||
.and_then(|pk| pk.to_bech32().ok())
|
||||
});
|
||||
Ok(serde_json::json!({
|
||||
"did": did,
|
||||
"pubkey": data.server_info.pubkey,
|
||||
"nostr_pubkey": nostr_pubkey,
|
||||
"nostr_npub": nostr_npub,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Sign a challenge to prove control of the node DID (proof-of-control for onboarding).
|
||||
pub(super) async fn handle_node_sign_challenge(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let challenge = params
|
||||
.get("challenge")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing challenge string"))?;
|
||||
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let identity = identity::NodeIdentity::load_or_create(&identity_dir).await?;
|
||||
let signature = identity.sign(challenge.as_bytes());
|
||||
|
||||
Ok(serde_json::json!({ "signature": signature }))
|
||||
}
|
||||
|
||||
/// Create an encrypted backup of the node identity (for onboarding).
|
||||
pub(super) async fn handle_node_create_backup(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let passphrase = params
|
||||
.get("passphrase")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing passphrase"))?;
|
||||
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
|
||||
let backup = backup::create_encrypted_backup(
|
||||
&identity_dir,
|
||||
passphrase,
|
||||
&did,
|
||||
&data.server_info.pubkey,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(backup)
|
||||
}
|
||||
|
||||
pub(super) async fn handle_node_tor_address(&self) -> Result<serde_json::Value> {
|
||||
let tor_address = docker_packages::read_tor_address("archipelago").await;
|
||||
Ok(serde_json::json!({ "tor_address": tor_address }))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_node_nostr_publish(&self) -> Result<serde_json::Value> {
|
||||
// Publishing node identity (including Tor addresses) to public Nostr relays is disabled
|
||||
// for security. Nodes connect via federation ID, not public discovery.
|
||||
anyhow::bail!("Nostr identity publishing is disabled — nodes connect via federation ID")
|
||||
}
|
||||
|
||||
pub(super) async fn handle_node_nostr_pubkey(&self) -> Result<serde_json::Value> {
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let pubkey_hex = nostr_discovery::get_nostr_pubkey(&identity_dir).await?;
|
||||
let npub = nostr_sdk::PublicKey::from_hex(&pubkey_hex)
|
||||
.ok()
|
||||
.and_then(|pk| pk.to_bech32().ok());
|
||||
Ok(serde_json::json!({
|
||||
"nostr_pubkey": pubkey_hex,
|
||||
"nostr_npub": npub,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Sign a Nostr event with the node's Nostr key.
|
||||
/// Accepts full event object, computes NIP-01 hash, returns signed event.
|
||||
pub(super) async fn handle_node_nostr_sign(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let pubkey_hex = nostr_discovery::get_nostr_pubkey(&identity_dir).await?;
|
||||
|
||||
let event = params
|
||||
.get("event")
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'event' parameter"))?;
|
||||
|
||||
let kind = event.get("kind").and_then(|v| v.as_u64()).unwrap_or(1);
|
||||
let content = event.get("content").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let created_at = event
|
||||
.get("created_at")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or_else(|| {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
});
|
||||
let tags = event
|
||||
.get("tags")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| serde_json::json!([]));
|
||||
|
||||
// NIP-01 serialization: [0, pubkey, created_at, kind, tags, content]
|
||||
let serialized = serde_json::json!([0, pubkey_hex, created_at, kind, tags, content]);
|
||||
let serialized_str = serde_json::to_string(&serialized)?;
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
let hash = Sha256::digest(serialized_str.as_bytes());
|
||||
let event_hash_hex = hex::encode(hash);
|
||||
|
||||
let signature = nostr_discovery::nostr_sign_hash(&identity_dir, &event_hash_hex).await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"id": event_hash_hex,
|
||||
"pubkey": pubkey_hex,
|
||||
"created_at": created_at,
|
||||
"kind": kind,
|
||||
"tags": tags,
|
||||
"content": content,
|
||||
"sig": signature,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_node_nostr_verify_revoked(&self) -> Result<serde_json::Value> {
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let status = nostr_discovery::verify_revocation(
|
||||
&identity_dir,
|
||||
self.config.nostr_tor_proxy.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
Ok(serde_json::json!({
|
||||
"revoked": status.revoked,
|
||||
"nostr_pubkey": status.nostr_pubkey,
|
||||
"latest_content": status.latest_content,
|
||||
"error": status.error,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Rotate the node's Ed25519 identity keypair.
|
||||
/// Requires password re-verification. Returns a signed proof that peers can
|
||||
/// use to verify the rotation was authorized by the holder of the old key.
|
||||
pub(super) async fn handle_node_rotate_did(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let password = params
|
||||
.get("password")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'password' parameter"))?;
|
||||
|
||||
// Re-verify password before allowing key rotation
|
||||
if !self.auth_manager.verify_password(password).await? {
|
||||
anyhow::bail!("Password verification failed");
|
||||
}
|
||||
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
|
||||
// Load the current identity to get old DID and signing key
|
||||
let old_identity = identity::NodeIdentity::load_or_create(&identity_dir).await?;
|
||||
let old_pubkey_hex = old_identity.pubkey_hex();
|
||||
let old_did = identity::did_key_from_pubkey_hex(&old_pubkey_hex)?;
|
||||
|
||||
// Generate a new Ed25519 keypair
|
||||
let new_signing_key = SigningKey::generate(&mut OsRng);
|
||||
let new_pubkey_hex = hex::encode(new_signing_key.verifying_key().as_bytes());
|
||||
let new_did = identity::did_key_from_pubkey_hex(&new_pubkey_hex)?;
|
||||
|
||||
// Create a rotation proof signed by the OLD key:
|
||||
// "did-rotate:{old_did}:{new_did}:{timestamp}"
|
||||
let timestamp = chrono::Utc::now().to_rfc3339();
|
||||
let proof_message = format!("did-rotate:{}:{}:{}", old_did, new_did, timestamp);
|
||||
let proof_signature = old_identity.sign(proof_message.as_bytes());
|
||||
|
||||
// Write the new key files, overwriting the old ones
|
||||
let key_path = identity_dir.join("node_key");
|
||||
let pub_path = identity_dir.join("node_key.pub");
|
||||
|
||||
fs::write(&key_path, new_signing_key.to_bytes())
|
||||
.await
|
||||
.context("Failed to write new node key")?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600))
|
||||
.await
|
||||
.context("Failed to set key permissions")?;
|
||||
}
|
||||
|
||||
fs::write(&pub_path, new_signing_key.verifying_key().as_bytes())
|
||||
.await
|
||||
.context("Failed to write new node public key")?;
|
||||
|
||||
// Update in-memory state so the new pubkey is reflected immediately
|
||||
let (mut data, _) = self.state_manager.get_snapshot().await;
|
||||
data.server_info.pubkey = new_pubkey_hex.clone();
|
||||
self.state_manager.update_data(data).await;
|
||||
|
||||
tracing::info!(
|
||||
old_did = %old_did,
|
||||
new_did = %new_did,
|
||||
"Node DID rotated successfully"
|
||||
);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"old_did": old_did,
|
||||
"new_did": new_did,
|
||||
"new_pubkey": new_pubkey_hex,
|
||||
"proof_signature": proof_signature,
|
||||
"proof_message": proof_message,
|
||||
"timestamp": timestamp,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
use super::RpcHandler;
|
||||
use crate::nostr_relays;
|
||||
use anyhow::Result;
|
||||
|
||||
impl RpcHandler {
|
||||
/// List all configured relays with their connection status.
|
||||
pub(super) async fn handle_nostr_list_relays(&self) -> Result<serde_json::Value> {
|
||||
let relays = nostr_relays::list_relays(&self.config.data_dir).await?;
|
||||
let items: Vec<serde_json::Value> = relays
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
serde_json::json!({
|
||||
"url": r.url,
|
||||
"connected": r.connected,
|
||||
"enabled": r.enabled,
|
||||
"added_at": r.added_at,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(serde_json::json!({ "relays": items }))
|
||||
}
|
||||
|
||||
/// Add a new relay.
|
||||
pub(super) async fn handle_nostr_add_relay(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let url = params
|
||||
.get("url")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing url"))?;
|
||||
|
||||
let relay = nostr_relays::add_relay(&self.config.data_dir, url).await?;
|
||||
Ok(serde_json::json!({
|
||||
"url": relay.url,
|
||||
"enabled": relay.enabled,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Remove a relay.
|
||||
pub(super) async fn handle_nostr_remove_relay(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let url = params
|
||||
.get("url")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing url"))?;
|
||||
|
||||
nostr_relays::remove_relay(&self.config.data_dir, url).await?;
|
||||
Ok(serde_json::json!({ "ok": true }))
|
||||
}
|
||||
|
||||
/// Toggle a relay on/off.
|
||||
pub(super) async fn handle_nostr_toggle_relay(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let url = params
|
||||
.get("url")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing url"))?;
|
||||
let enabled = params
|
||||
.get("enabled")
|
||||
.and_then(|v| v.as_bool())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing enabled"))?;
|
||||
|
||||
nostr_relays::toggle_relay(&self.config.data_dir, url, enabled).await?;
|
||||
Ok(serde_json::json!({ "ok": true }))
|
||||
}
|
||||
|
||||
/// Get relay stats.
|
||||
pub(super) async fn handle_nostr_get_stats(&self) -> Result<serde_json::Value> {
|
||||
let stats = nostr_relays::get_stats(&self.config.data_dir).await?;
|
||||
Ok(serde_json::json!({
|
||||
"total_relays": stats.total_relays,
|
||||
"connected_count": stats.connected_count,
|
||||
"enabled_count": stats.enabled_count,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
use super::RpcHandler;
|
||||
use crate::network::router as net_router;
|
||||
use anyhow::Result;
|
||||
use archipelago_openwrt::{
|
||||
detect,
|
||||
router::Router,
|
||||
tollgate::{self, TollGateConfig},
|
||||
wan, wifi_scan,
|
||||
};
|
||||
|
||||
/// Default port for the local Cashu mint (nutshell / cashu-mint app).
|
||||
const LOCAL_MINT_PORT: u16 = 3338;
|
||||
|
||||
impl RpcHandler {
|
||||
/// Scan the local subnet for OpenWrt routers.
|
||||
///
|
||||
/// Params: `{ "subnet": "192.168.1.0", "prefix": 24,
|
||||
/// "ssh_user": "root", "ssh_password": "" }`
|
||||
pub(super) async fn handle_openwrt_scan(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let p = params.unwrap_or_default();
|
||||
let subnet: [u8; 4] = parse_ipv4(
|
||||
p.get("subnet")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("192.168.1.0"),
|
||||
)?;
|
||||
let prefix = p.get("prefix").and_then(|v| v.as_u64()).unwrap_or(24) as u8;
|
||||
let ssh_user = p
|
||||
.get("ssh_user")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("root")
|
||||
.to_string();
|
||||
let ssh_password = p
|
||||
.get("ssh_password")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let routers = detect::scan_subnet(subnet, prefix, &ssh_user, &ssh_password).await;
|
||||
let ips: Vec<String> = routers.iter().map(|ip| ip.to_string()).collect();
|
||||
|
||||
Ok(serde_json::json!({ "routers": ips }))
|
||||
}
|
||||
|
||||
/// Read current settings from a saved or ad-hoc OpenWrt router via SSH/UCI.
|
||||
///
|
||||
/// Params (all optional): `{ "host": "...", "ssh_user": "root", "ssh_password": "" }`
|
||||
/// If params are omitted the saved `router_config.json` credentials are used.
|
||||
pub(super) async fn handle_openwrt_get_status(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let saved = net_router::load_router_config(&self.config.data_dir).await?;
|
||||
let p = params.unwrap_or_default();
|
||||
let host_from_params = p.get("host").and_then(|v| v.as_str()).is_some();
|
||||
|
||||
let host = p
|
||||
.get("host")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| {
|
||||
if saved.configured {
|
||||
Some(saved.address.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"No router configured — provide host or call router.configure first"
|
||||
)
|
||||
})?;
|
||||
|
||||
let ssh_user = p
|
||||
.get("ssh_user")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| saved.username.clone())
|
||||
.unwrap_or_else(|| "root".to_string());
|
||||
|
||||
let ssh_password = p
|
||||
.get("ssh_password")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| saved.password.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
|
||||
router.verify_openwrt()?;
|
||||
|
||||
// Persist the connection so other views (e.g. the Home dashboard's
|
||||
// Network tile) can poll `openwrt.get-status` with no params instead
|
||||
// of every caller needing to carry host/credentials around. Only do
|
||||
// this when the host actually came from params — otherwise every
|
||||
// no-args poll would re-save the same thing it just read.
|
||||
if host_from_params {
|
||||
let _ = net_router::configure_router(
|
||||
&self.config.data_dir,
|
||||
net_router::RouterType::OpenWrt,
|
||||
&host,
|
||||
None,
|
||||
Some(&ssh_user),
|
||||
Some(&ssh_password),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// System info
|
||||
let release = router
|
||||
.run_ok("cat /etc/openwrt_release")
|
||||
.unwrap_or_default();
|
||||
let hostname = router
|
||||
.uci_get("system.@system[0].hostname")
|
||||
.unwrap_or_else(|_| "unknown".into());
|
||||
let uptime_secs: u64 = router
|
||||
.run_ok("cat /proc/uptime")
|
||||
.unwrap_or_default()
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.and_then(|s| s.split('.').next())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
// TollGate — check via opkg (≤24.x) or binary presence (25.x apk-native).
|
||||
// The service binary is /usr/bin/tollgate-wrt (per its init.d script),
|
||||
// not /usr/bin/tollgate-module-basic-go — that's only the opkg/apk
|
||||
// *package* name, never an on-disk filename.
|
||||
let tollgate_installed = router
|
||||
.run("/usr/bin/opkg list-installed 2>/dev/null | grep -q '^tollgate-module-basic-go ' || \
|
||||
test -f /usr/bin/tollgate-wrt 2>/dev/null")
|
||||
.map(|(_, code)| code == 0)
|
||||
.unwrap_or(false);
|
||||
|
||||
let tollgate = if tollgate_installed {
|
||||
serde_json::json!({
|
||||
"installed": true,
|
||||
"enabled": router.uci_get("tollgate.main.enabled").map(|v| v == "1").unwrap_or(false),
|
||||
"metric": router.uci_get("tollgate.main.metric").unwrap_or_default(),
|
||||
"step_size_ms": router.uci_get("tollgate.main.step_size").ok().and_then(|v| v.parse::<u64>().ok()).unwrap_or(0),
|
||||
"price_per_step":router.uci_get("tollgate.main.price_per_step").ok().and_then(|v| v.parse::<u64>().ok()).unwrap_or(0),
|
||||
"min_steps": router.uci_get("tollgate.main.min_steps").ok().and_then(|v| v.parse::<u32>().ok()).unwrap_or(1),
|
||||
"currency": router.uci_get("tollgate.main.currency").unwrap_or_default(),
|
||||
"mint_url": router.uci_get("tollgate.main.mint_url").unwrap_or_default(),
|
||||
})
|
||||
} else {
|
||||
serde_json::json!({ "installed": false })
|
||||
};
|
||||
|
||||
// WiFi interfaces
|
||||
let wifi_raw = router.run_ok("uci show wireless").unwrap_or_default();
|
||||
let wifi_interfaces = parse_wifi_interfaces(&wifi_raw);
|
||||
|
||||
let wan_status = wan::get_wan_status(&router);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"host": host,
|
||||
"hostname": hostname,
|
||||
"uptime_secs": uptime_secs,
|
||||
"release": parse_release(&release),
|
||||
"tollgate": tollgate,
|
||||
"wifi_interfaces": wifi_interfaces,
|
||||
"wan": wan_status,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Provision TollGate on an OpenWrt router and create the "archipelago" SSID.
|
||||
///
|
||||
/// Params: `{ "host": "192.168.1.1", "ssh_user": "root", "ssh_password": "",
|
||||
/// "price_sats": 10, "step_size_ms": 60000, "min_steps": 1,
|
||||
/// "mint_url": "<optional override>" }`
|
||||
///
|
||||
/// `mint_url` defaults to `http://<this node's IP>:3338` — the local Cashu
|
||||
/// mint that must be running as an Archy app before calling this endpoint.
|
||||
pub(super) async fn handle_openwrt_provision_tollgate(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let saved = net_router::load_router_config(&self.config.data_dir).await?;
|
||||
let p = params.unwrap_or_default();
|
||||
|
||||
let host = p
|
||||
.get("host")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| {
|
||||
if saved.configured {
|
||||
Some(saved.address.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"No router configured — provide host or call router.configure first"
|
||||
)
|
||||
})?;
|
||||
let ssh_user = p
|
||||
.get("ssh_user")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| saved.username.clone())
|
||||
.unwrap_or_else(|| "root".to_string());
|
||||
let ssh_password = p
|
||||
.get("ssh_password")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| saved.password.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let default_mint_url = format!("http://{}:{}", self.config.host_ip, LOCAL_MINT_PORT);
|
||||
let mint_url = p
|
||||
.get("mint_url")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&default_mint_url)
|
||||
.to_string();
|
||||
|
||||
let config = TollGateConfig {
|
||||
ssid: "archipelago".to_string(),
|
||||
mint_url,
|
||||
price_sats: p.get("price_sats").and_then(|v| v.as_u64()).unwrap_or(10),
|
||||
step_size_ms: p
|
||||
.get("step_size_ms")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(60_000),
|
||||
min_steps: p.get("min_steps").and_then(|v| v.as_u64()).unwrap_or(1) as u32,
|
||||
enabled: p.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true),
|
||||
};
|
||||
|
||||
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
|
||||
router.verify_openwrt()?;
|
||||
tollgate::provision(&router, &config).await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"ok": true,
|
||||
"host": host,
|
||||
"ssid": config.ssid,
|
||||
"mint_url": config.mint_url,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Scan for visible WiFi networks from the router's radio.
|
||||
///
|
||||
/// Params: same host/credentials as other openwrt methods.
|
||||
pub(super) async fn handle_openwrt_scan_wifi(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let saved = net_router::load_router_config(&self.config.data_dir).await?;
|
||||
let p = params.unwrap_or_default();
|
||||
|
||||
let host = p
|
||||
.get("host")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| {
|
||||
if saved.configured {
|
||||
Some(saved.address.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"No router configured — provide host or call router.configure first"
|
||||
)
|
||||
})?;
|
||||
let ssh_user = p
|
||||
.get("ssh_user")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| saved.username.clone())
|
||||
.unwrap_or_else(|| "root".to_string());
|
||||
let ssh_password = p
|
||||
.get("ssh_password")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| saved.password.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
|
||||
router.verify_openwrt()?;
|
||||
|
||||
let networks = wifi_scan::scan_networks(&router)?;
|
||||
let result: Vec<serde_json::Value> = networks
|
||||
.iter()
|
||||
.map(|n| {
|
||||
serde_json::json!({
|
||||
"ssid": n.ssid,
|
||||
"bssid": n.bssid,
|
||||
"signal": n.signal,
|
||||
"channel": n.channel,
|
||||
"encryption": n.encryption,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(serde_json::json!({ "networks": result }))
|
||||
}
|
||||
|
||||
/// Configure WAN/WISP — connect the router to an upstream WiFi network.
|
||||
///
|
||||
/// Params: host/credentials + `{ "ssid": "...", "password": "...", "encryption": "psk2" }`
|
||||
pub(super) async fn handle_openwrt_configure_wan(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let saved = net_router::load_router_config(&self.config.data_dir).await?;
|
||||
let p = params.unwrap_or_default();
|
||||
|
||||
let host = p
|
||||
.get("host")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| {
|
||||
if saved.configured {
|
||||
Some(saved.address.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"No router configured — provide host or call router.configure first"
|
||||
)
|
||||
})?;
|
||||
let ssh_user = p
|
||||
.get("ssh_user")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| saved.username.clone())
|
||||
.unwrap_or_else(|| "root".to_string());
|
||||
let ssh_password = p
|
||||
.get("ssh_password")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| saved.password.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let ssid = p
|
||||
.get("ssid")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required field: ssid"))?
|
||||
.to_string();
|
||||
let password = p
|
||||
.get("password")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let encryption = p
|
||||
.get("encryption")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("psk2")
|
||||
.to_string();
|
||||
let dhcp_start = p.get("dhcp_start").and_then(|v| v.as_u64()).unwrap_or(100) as u32;
|
||||
let dhcp_limit = p.get("dhcp_limit").and_then(|v| v.as_u64()).unwrap_or(150) as u32;
|
||||
let masq = p.get("masq").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
|
||||
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
|
||||
router.verify_openwrt()?;
|
||||
|
||||
let config = wan::WispConfig {
|
||||
ssid: ssid.clone(),
|
||||
password,
|
||||
encryption,
|
||||
dhcp_start,
|
||||
dhcp_limit,
|
||||
masq,
|
||||
};
|
||||
wan::configure_wisp(&router, &config)?;
|
||||
|
||||
Ok(serde_json::json!({ "ok": true, "host": host, "ssid": ssid }))
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse /etc/openwrt_release key=value pairs into a JSON object.
|
||||
fn parse_release(raw: &str) -> serde_json::Value {
|
||||
let mut m = serde_json::Map::new();
|
||||
for line in raw.lines() {
|
||||
if let Some((k, v)) = line.split_once('=') {
|
||||
m.insert(
|
||||
k.to_lowercase(),
|
||||
serde_json::Value::String(v.trim_matches('"').to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
serde_json::Value::Object(m)
|
||||
}
|
||||
|
||||
/// Extract AP wifi-iface sections from `uci show wireless` output.
|
||||
fn parse_wifi_interfaces(raw: &str) -> Vec<serde_json::Value> {
|
||||
use std::collections::HashMap;
|
||||
let mut sections: HashMap<String, HashMap<String, String>> = HashMap::new();
|
||||
|
||||
for line in raw.lines() {
|
||||
if let Some((lhs, rhs)) = line.trim().split_once('=') {
|
||||
let parts: Vec<&str> = lhs.splitn(3, '.').collect();
|
||||
if parts.len() == 3 && parts[0] == "wireless" {
|
||||
sections
|
||||
.entry(parts[1].to_string())
|
||||
.or_default()
|
||||
.insert(parts[2].to_string(), rhs.trim_matches('\'').to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut ifaces: Vec<serde_json::Value> = sections
|
||||
.into_iter()
|
||||
.filter(|(_, f)| f.get("mode").map(|m| m == "ap").unwrap_or(false))
|
||||
.map(|(name, f)| {
|
||||
serde_json::json!({
|
||||
"section": name,
|
||||
"ssid": f.get("ssid").cloned().unwrap_or_default(),
|
||||
"device": f.get("device").cloned().unwrap_or_default(),
|
||||
"encryption": f.get("encryption").cloned().unwrap_or_else(|| "none".into()),
|
||||
"network": f.get("network").cloned().unwrap_or_default(),
|
||||
"disabled": f.get("disabled").map(|v| v == "1").unwrap_or(false),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
ifaces.sort_by_key(|v| v["section"].as_str().unwrap_or("").to_string());
|
||||
ifaces
|
||||
}
|
||||
|
||||
fn parse_ipv4(s: &str) -> Result<[u8; 4]> {
|
||||
let parts: Vec<&str> = s.split('.').collect();
|
||||
if parts.len() != 4 {
|
||||
anyhow::bail!("Invalid IPv4: {}", s);
|
||||
}
|
||||
Ok([
|
||||
parts[0].parse()?,
|
||||
parts[1].parse()?,
|
||||
parts[2].parse()?,
|
||||
parts[3].parse()?,
|
||||
])
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
//! Async wrappers for `package.install`, `package.uninstall`, `package.update`.
|
||||
//!
|
||||
//! The inner `handle_package_*` functions are large (install is 480 lines with
|
||||
//! the stack dispatchers, update is 300, uninstall is 200) and do their own
|
||||
//! fine-grained progress tracking via `install_progress` and `uninstall_stage`.
|
||||
//! We wrap them rather than refactor them.
|
||||
//!
|
||||
//! Each wrapper:
|
||||
//! 1. Parses + validates the RPC params (cheap, synchronous). Errors here
|
||||
//! return immediately to the caller before any state change.
|
||||
//! 2. Flips the package state to the transitional variant
|
||||
//! (`Installing` / `Removing` / `Updating`) so the UI sees it on the
|
||||
//! next WebSocket push (before the RPC response even lands).
|
||||
//! 3. `tokio::spawn`s a background task that invokes the existing
|
||||
//! `handle_package_*` method on the Arc-held self.
|
||||
//! 4. On task success: no state change needed — the inner handler has
|
||||
//! already written the terminal state (Running for install/update, or
|
||||
//! removed the entry for uninstall).
|
||||
//! 5. On task failure: revert state to the pre-transition value (or delete
|
||||
//! the entry for install, since there was no pre-state), write a line
|
||||
//! to the persistent install log, and clear any stale progress fields.
|
||||
//! 6. Returns `{ "status": "installing" }` etc. immediately.
|
||||
//!
|
||||
//! The server package-scan loop's `merge_preserving_transitional` helper
|
||||
//! already knows to preserve `Installing` / `Removing` / `Updating` between
|
||||
//! scans, so live progress updates broadcast from inside the spawned task
|
||||
//! reach the UI correctly.
|
||||
|
||||
use super::install::install_log;
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use crate::data_model::PackageState;
|
||||
use crate::state::StateManager;
|
||||
use anyhow::Result;
|
||||
use std::sync::Arc;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
impl RpcHandler {
|
||||
/// Async wrapper for `package.install`. Returns `{ "status": "installing" }`
|
||||
/// immediately after flipping state to `Installing` and spawning the
|
||||
/// actual install pipeline. On failure, removes the package entry from
|
||||
/// state so the UI reverts to "not installed".
|
||||
pub(in crate::api::rpc) async fn spawn_package_install(
|
||||
self: Arc<Self>,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
// Extract + validate package_id synchronously so bad params fail
|
||||
// fast without touching state.
|
||||
let params_val = params
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let package_id = params_val
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?
|
||||
.to_string();
|
||||
super::validation::validate_app_id(&package_id)?;
|
||||
super::dependencies::check_bitcoin_pruning_compatibility(&package_id).await?;
|
||||
|
||||
// Reject if already in a transitional lifecycle (prevents double-click
|
||||
// queuing two installs on the same package).
|
||||
{
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
if let Some(entry) = data.package_data.get(&package_id) {
|
||||
if matches!(
|
||||
entry.state,
|
||||
PackageState::Installing | PackageState::Removing | PackageState::Updating
|
||||
) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"{} is already {:?}",
|
||||
package_id,
|
||||
entry.state
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flip state to Installing BEFORE the spawn so the first WebSocket
|
||||
// push carries the transitional state. Uses the same
|
||||
// `create_installing_entry` path the inner handler would use once
|
||||
// it starts pulling, so the UI sees a consistent shape.
|
||||
flip_to_installing(&self.state_manager, &package_id).await;
|
||||
|
||||
install_log(&format!("INSTALL SPAWN: {}", package_id)).await;
|
||||
|
||||
let handler = Arc::clone(&self);
|
||||
let package_id_spawn = package_id.clone();
|
||||
tokio::spawn(async move {
|
||||
match handler.handle_package_install(params).await {
|
||||
Ok(_) => {
|
||||
info!("package.install {}: complete", package_id_spawn);
|
||||
// The install pipeline has verified the container is up
|
||||
// and healthy (see install.rs post-start exit check).
|
||||
// Kick the scanner first so the fresh manifest (with
|
||||
// `interfaces.main.ui` from the live port binding) lands
|
||||
// BEFORE we flip to Running — without this the Launch
|
||||
// button is missing for up to 60s after a successful
|
||||
// install, because the skeletal install-time manifest
|
||||
// has `interfaces: None`.
|
||||
kick_scanner_and_wait(&handler).await;
|
||||
// We MUST explicitly transition out of Installing here:
|
||||
// `merge_preserving_transitional` in the package-scan
|
||||
// loop treats Installing as RPC-owned and refuses to
|
||||
// let the scanner overwrite it with the observed
|
||||
// Running state. Without this write, the entry stays
|
||||
// stuck at Installing forever.
|
||||
set_package_state(
|
||||
&handler.state_manager,
|
||||
&package_id_spawn,
|
||||
PackageState::Running,
|
||||
)
|
||||
.await;
|
||||
handler.clear_install_progress(&package_id_spawn).await;
|
||||
// Auto-expose the app over Tor (best-effort, detached) —
|
||||
// every installed app gets its .onion without a manual
|
||||
// "Add Service" step.
|
||||
let tor_handler = Arc::clone(&handler);
|
||||
let tor_app = package_id_spawn.clone();
|
||||
tokio::spawn(async move {
|
||||
tor_handler.auto_add_tor_service(&tor_app).await;
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
error!("package.install {} failed: {:#}", package_id_spawn, e);
|
||||
install_log(&format!("INSTALL FAIL: {} — {:#}", package_id_spawn, e)).await;
|
||||
// handle_package_install saves the catalog-provided
|
||||
// dynamic app config to /var/lib/archipelago/app-configs
|
||||
// BEFORE the install pipeline runs, so a failure can
|
||||
// strand that file (and the optimistic state entry) with
|
||||
// no container behind it. Probe once here; both cleanup
|
||||
// branches below only fire when the app has no footprint.
|
||||
// A retry re-saves the config (the frontend sends
|
||||
// containerConfig on every install), so removal is safe.
|
||||
let left_container =
|
||||
failed_install_left_container(&handler, &package_id_spawn).await;
|
||||
// Dependency-gate rejections happen BEFORE any resource
|
||||
// (container/image/data dir) exists for this package, so
|
||||
// keeping the optimistic entry would leave a phantom
|
||||
// "Stopped" tile whose Start fails with `no such object`
|
||||
// (the log-confirmed LND fresh-install failure). Remove
|
||||
// the entry so the card reverts to installable, and
|
||||
// surface the reason as a notification instead.
|
||||
if let Some(gate) = e.downcast_ref::<super::dependencies::DependencyGateError>()
|
||||
{
|
||||
if !left_container {
|
||||
remove_dynamic_app_config(&package_id_spawn).await;
|
||||
}
|
||||
remove_entry_with_notification(
|
||||
&handler,
|
||||
&package_id_spawn,
|
||||
"install-deps",
|
||||
&gate.to_string(),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
// A failed install that left NO container behind has no
|
||||
// real footprint either — keeping the entry would leave
|
||||
// the same phantom "Stopped" tile in My Apps (and the
|
||||
// scanner-side absence eviction takes 3 scans to catch
|
||||
// it). Remove the saved config + entry and surface the
|
||||
// failure as a notification, exactly like the gate case.
|
||||
if !left_container {
|
||||
remove_dynamic_app_config(&package_id_spawn).await;
|
||||
remove_entry_with_notification(
|
||||
&handler,
|
||||
&package_id_spawn,
|
||||
"install-failed",
|
||||
&format!("Install failed: {:#}", e),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
// A container exists (crash-after-start kept for
|
||||
// visibility, retry over an existing install, upgrade) —
|
||||
// don't remove the entry, that's what made the card
|
||||
// vanish from My Apps mid-install / between retry-loop
|
||||
// attempts (e.g. tailscale's entrypoint failure). Leave
|
||||
// the entry visible with state=Stopped + the install
|
||||
// error in install_progress.message so the user can see
|
||||
// what went wrong and decide whether to retry or
|
||||
// uninstall. clear_install_progress would erase the
|
||||
// message, so we set it explicitly here instead. The
|
||||
// phase is cleared (None) so no stale InstallPhase
|
||||
// lingers on the card.
|
||||
let err_msg = format!("Install failed: {:#}", e);
|
||||
let (mut data, _) = handler.state_manager.get_snapshot().await;
|
||||
if let Some(entry) = data.package_data.get_mut(&package_id_spawn) {
|
||||
entry.state = PackageState::Stopped;
|
||||
entry.install_progress = Some(crate::data_model::InstallProgress {
|
||||
size: 0,
|
||||
downloaded: 0,
|
||||
phase: None,
|
||||
message: Some(err_msg),
|
||||
});
|
||||
handler.state_manager.update_data(data).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"status": "installing",
|
||||
"package_id": package_id,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Async wrapper for `package.uninstall`. Returns `{ "status": "removing" }`
|
||||
/// immediately. State stays `Removing` until the inner handler finishes
|
||||
/// (including the `sudo rm -rf` of app data, which can take minutes for
|
||||
/// bitcoin-core's chainstate). On failure, reverts to the pre-transition
|
||||
/// state (usually Running or Stopped) so the user can retry.
|
||||
pub(in crate::api::rpc) async fn spawn_package_uninstall(
|
||||
self: Arc<Self>,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params_val = params
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let package_id = params_val
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?
|
||||
.to_string();
|
||||
super::validation::validate_app_id(&package_id)?;
|
||||
|
||||
// Reject if already in a transitional lifecycle.
|
||||
{
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
if let Some(entry) = data.package_data.get(&package_id) {
|
||||
if matches!(
|
||||
entry.state,
|
||||
PackageState::Installing | PackageState::Removing | PackageState::Updating
|
||||
) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"{} is already {:?}",
|
||||
package_id,
|
||||
entry.state
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let pre_state =
|
||||
flip_package_state(&self.state_manager, &package_id, PackageState::Removing).await;
|
||||
|
||||
install_log(&format!("UNINSTALL SPAWN: {}", package_id)).await;
|
||||
|
||||
let handler = Arc::clone(&self);
|
||||
let package_id_spawn = package_id.clone();
|
||||
tokio::spawn(async move {
|
||||
match handler.handle_package_uninstall(params).await {
|
||||
Ok(_) => {
|
||||
info!("package.uninstall {}: complete", package_id_spawn);
|
||||
// Inner handler already removed the package entry on
|
||||
// success. Nothing more to do here.
|
||||
}
|
||||
Err(e) => {
|
||||
error!("package.uninstall {} failed: {:#}", package_id_spawn, e);
|
||||
install_log(&format!("UNINSTALL FAIL: {} — {:#}", package_id_spawn, e)).await;
|
||||
// Revert to pre-transition state so the user can retry.
|
||||
// Also clear any stale uninstall_stage label.
|
||||
if let Some(prev) = pre_state {
|
||||
set_package_state_and_clear_uninstall_stage(
|
||||
&handler.state_manager,
|
||||
&package_id_spawn,
|
||||
prev,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"status": "removing",
|
||||
"package_id": package_id,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Async wrapper for `package.update`. Returns `{ "status": "updating" }`
|
||||
/// immediately. The inner handler already manages its own rollback on
|
||||
/// failure (restarts old containers); this wrapper just flips state and
|
||||
/// spawns.
|
||||
pub(in crate::api::rpc) async fn spawn_package_update(
|
||||
self: Arc<Self>,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params_val = params
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let package_id = params_val
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?
|
||||
.to_string();
|
||||
super::validation::validate_app_id(&package_id)?;
|
||||
|
||||
// Reject if already in a transitional lifecycle.
|
||||
{
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
if let Some(entry) = data.package_data.get(&package_id) {
|
||||
if matches!(
|
||||
entry.state,
|
||||
PackageState::Installing | PackageState::Removing | PackageState::Updating
|
||||
) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"{} is already {:?}",
|
||||
package_id,
|
||||
entry.state
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The inner handler flips state to Updating itself, but we do it
|
||||
// here too so the transitional state lands before the spawn yields.
|
||||
let pre_state =
|
||||
flip_package_state(&self.state_manager, &package_id, PackageState::Updating).await;
|
||||
|
||||
install_log(&format!("UPDATE SPAWN: {}", package_id)).await;
|
||||
|
||||
let handler = Arc::clone(&self);
|
||||
let package_id_spawn = package_id.clone();
|
||||
tokio::spawn(async move {
|
||||
match handler.handle_package_update(params).await {
|
||||
Ok(_) => {
|
||||
info!("package.update {}: complete", package_id_spawn);
|
||||
// Same reasoning as install: the merge_preserving_transitional
|
||||
// helper treats Updating as RPC-owned, so we MUST write the
|
||||
// terminal Running state ourselves or the entry will stay
|
||||
// stuck at Updating forever. The update pipeline has
|
||||
// already verified the new container is running via its
|
||||
// post-recreate check.
|
||||
// Kick the scanner first so any manifest changes from the
|
||||
// new image version (interfaces, ports, etc.) land before
|
||||
// we flip to Running.
|
||||
kick_scanner_and_wait(&handler).await;
|
||||
set_package_state(
|
||||
&handler.state_manager,
|
||||
&package_id_spawn,
|
||||
PackageState::Running,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("package.update {} failed: {:#}", package_id_spawn, e);
|
||||
install_log(&format!("UPDATE FAIL: {} — {:#}", package_id_spawn, e)).await;
|
||||
// Inner handler already ran rollback_update + cleared
|
||||
// update state, but be defensive: revert to pre-state
|
||||
// in case the inner flow died before its cleanup.
|
||||
if let Some(prev) = pre_state {
|
||||
set_package_state(&handler.state_manager, &package_id_spawn, prev).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"status": "updating",
|
||||
"package_id": package_id,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State-manager helpers (free fns, usable from inside spawned tasks)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Create or update the entry for this package with `Installing` state.
|
||||
/// Matches what the inner handler's `set_install_progress` would do on first
|
||||
/// call, but fires before the spawn so the UI sees it immediately.
|
||||
async fn flip_to_installing(state_manager: &StateManager, package_id: &str) {
|
||||
use crate::data_model::{Description, Manifest, PackageDataEntry, StaticFiles};
|
||||
let (mut data, _) = state_manager.get_snapshot().await;
|
||||
let entry = data
|
||||
.package_data
|
||||
.entry(package_id.to_string())
|
||||
.or_insert_with(|| PackageDataEntry {
|
||||
state: PackageState::Installing,
|
||||
health: None,
|
||||
exit_code: None,
|
||||
static_files: StaticFiles {
|
||||
license: String::new(),
|
||||
instructions: String::new(),
|
||||
// Leave icon empty during the transient Installing window:
|
||||
// hardcoding `<id>.png` is wrong for ~half our apps (many use
|
||||
// `.svg` / `.webp`), producing a broken-image flicker until
|
||||
// the scanner refreshes the entry. The frontend's `icon`
|
||||
// computed falls through to `curatedMap.get(id)?.icon` which
|
||||
// has the correct extensions for known apps.
|
||||
icon: String::new(),
|
||||
},
|
||||
manifest: Manifest {
|
||||
id: package_id.to_string(),
|
||||
title: package_id.to_string(),
|
||||
version: String::new(),
|
||||
description: Description {
|
||||
short: "Installing...".to_string(),
|
||||
long: String::new(),
|
||||
},
|
||||
release_notes: String::new(),
|
||||
license: String::new(),
|
||||
wrapper_repo: String::new(),
|
||||
upstream_repo: String::new(),
|
||||
support_site: String::new(),
|
||||
marketing_site: String::new(),
|
||||
donation_url: None,
|
||||
author: None,
|
||||
website: None,
|
||||
interfaces: None,
|
||||
tier: None,
|
||||
},
|
||||
installed: None,
|
||||
install_progress: None,
|
||||
uninstall_stage: None,
|
||||
available_update: None,
|
||||
});
|
||||
entry.state = PackageState::Installing;
|
||||
state_manager.update_data(data).await;
|
||||
}
|
||||
|
||||
/// True when the failed install still has a real footprint: any container
|
||||
/// belonging to `package_id` exists (any state — created/exited count too;
|
||||
/// the install-crash path deliberately keeps the exited container visible),
|
||||
/// or the app carries a user-stopped marker (Quadlet units run with `--rm`,
|
||||
/// so a cleanly user-stopped app legitimately has no podman record). Errors
|
||||
/// from the podman probe count as "exists" — never clean up on an uncertain
|
||||
/// reading.
|
||||
async fn failed_install_left_container(handler: &RpcHandler, package_id: &str) -> bool {
|
||||
if crate::crash_recovery::load_user_stopped(&handler.config.data_dir)
|
||||
.await
|
||||
.contains(package_id)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
match super::config::get_containers_for_app(package_id).await {
|
||||
Ok(containers) => !containers.is_empty(),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"install cleanup {}: container probe failed ({:#}); keeping saved config",
|
||||
package_id, e
|
||||
);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the catalog-provided dynamic app config that
|
||||
/// `handle_package_install` saved before the pipeline ran (mirror of the
|
||||
/// write in install.rs). Only called when the app has no container — for an
|
||||
/// existing install (retry/upgrade) the file is still the app's live runtime
|
||||
/// config and must be kept.
|
||||
async fn remove_dynamic_app_config(package_id: &str) {
|
||||
let config_path = format!("/var/lib/archipelago/app-configs/{}.json", package_id);
|
||||
match tokio::fs::remove_file(&config_path).await {
|
||||
Ok(()) => info!(
|
||||
"Removed dynamic app config for {} after failed install (no container)",
|
||||
package_id
|
||||
),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => warn!(
|
||||
"Failed to remove dynamic app config for {}: {}",
|
||||
package_id, e
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the package's optimistic state entry (clearing any pending install
|
||||
/// phase with it) so the card reverts to installable, and surface the failure
|
||||
/// reason as an error notification instead.
|
||||
async fn remove_entry_with_notification(
|
||||
handler: &RpcHandler,
|
||||
package_id: &str,
|
||||
id_prefix: &str,
|
||||
message: &str,
|
||||
) {
|
||||
let (mut data, _) = handler.state_manager.get_snapshot().await;
|
||||
data.package_data.remove(package_id);
|
||||
data.notifications.push(crate::data_model::Notification {
|
||||
id: format!("{id_prefix}-{package_id}"),
|
||||
level: crate::data_model::NotificationLevel::Error,
|
||||
title: format!("Could not install {package_id}"),
|
||||
message: message.to_string(),
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
app_id: Some(package_id.to_string()),
|
||||
});
|
||||
while data.notifications.len() > 20 {
|
||||
data.notifications.remove(0);
|
||||
}
|
||||
handler.state_manager.update_data(data).await;
|
||||
}
|
||||
|
||||
/// Flip an existing entry's state and return the pre-flip value (or None if
|
||||
/// no entry existed). Used for revert-on-failure.
|
||||
async fn flip_package_state(
|
||||
state_manager: &StateManager,
|
||||
package_id: &str,
|
||||
new_state: PackageState,
|
||||
) -> Option<PackageState> {
|
||||
let (mut data, _) = state_manager.get_snapshot().await;
|
||||
let prev = data.package_data.get(package_id).map(|e| e.state.clone());
|
||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||
entry.state = new_state;
|
||||
state_manager.update_data(data).await;
|
||||
} else {
|
||||
warn!(
|
||||
"flip_package_state: no entry for {} — cannot flip",
|
||||
package_id
|
||||
);
|
||||
}
|
||||
prev
|
||||
}
|
||||
|
||||
/// Set state unconditionally (no-op if entry no longer exists).
|
||||
async fn set_package_state(
|
||||
state_manager: &StateManager,
|
||||
package_id: &str,
|
||||
new_state: PackageState,
|
||||
) {
|
||||
let (mut data, _) = state_manager.get_snapshot().await;
|
||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||
if entry.state != new_state {
|
||||
entry.state = new_state;
|
||||
state_manager.update_data(data).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set state and clear the uninstall_stage label. Used when an uninstall
|
||||
/// fails and we revert — the user doesn't want a stale "Removing app data"
|
||||
/// message sitting on a Running entry.
|
||||
async fn set_package_state_and_clear_uninstall_stage(
|
||||
state_manager: &StateManager,
|
||||
package_id: &str,
|
||||
new_state: PackageState,
|
||||
) {
|
||||
let (mut data, _) = state_manager.get_snapshot().await;
|
||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||
entry.state = new_state;
|
||||
entry.uninstall_stage = None;
|
||||
state_manager.update_data(data).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Kick the container scanner to run immediately and wait for it to finish
|
||||
/// (with a 2s timeout). Used by install/update success paths so the fresh
|
||||
/// manifest — with `interfaces.main.ui` populated from the now-running
|
||||
/// container's port binding — lands BEFORE we flip state to Running.
|
||||
///
|
||||
/// Without this, the frontend sees `state = running` but the skeletal
|
||||
/// install-time manifest (interfaces = None), and hides the Launch button
|
||||
/// for up to the full 60s scan interval.
|
||||
///
|
||||
/// The scan merges via `merge_preserving_transitional`, which keeps
|
||||
/// state = Installing (we haven't flipped yet) while taking the fresh
|
||||
/// manifest. After this returns, the caller writes Running on top of the
|
||||
/// now-populated manifest.
|
||||
async fn kick_scanner_and_wait(handler: &RpcHandler) {
|
||||
let mut rx = handler.scan_tick.subscribe();
|
||||
let start = *rx.borrow_and_update();
|
||||
handler.scan_kick.notify_one();
|
||||
// 2s is well above a typical podman scan (~200ms on .228, ~500ms worst
|
||||
// case). If it times out we proceed anyway — the next 60s scan will
|
||||
// self-heal and the worst case is the pre-fix behavior (Launch button
|
||||
// appears a bit late).
|
||||
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), async {
|
||||
while *rx.borrow_and_update() == start {
|
||||
if rx.changed().await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
||||
// Container lifecycle operations.
|
||||
//
|
||||
// Split into focused sub-modules:
|
||||
// - install.rs — Image pulling, container creation, volume setup, multi-container stacks
|
||||
// - runtime.rs — Start, stop, restart, uninstall operations
|
||||
// - dependencies.rs — Dependency resolution, startup ordering, network requirements
|
||||
//
|
||||
// All public handler methods (handle_package_*) are implemented on RpcHandler
|
||||
// in their respective sub-modules and remain callable from the RPC dispatcher.
|
||||
@@ -0,0 +1,17 @@
|
||||
mod async_lifecycle;
|
||||
mod config;
|
||||
mod dependencies;
|
||||
mod install;
|
||||
mod lifecycle;
|
||||
mod pine_ha;
|
||||
pub(crate) use pine_ha::wyoming_satellite_keeper;
|
||||
mod progress;
|
||||
mod runtime;
|
||||
mod set_config;
|
||||
mod stacks;
|
||||
mod update;
|
||||
mod validation;
|
||||
|
||||
// Re-export items needed by sibling modules (container.rs, security.rs, transitional.rs)
|
||||
pub(in crate::api::rpc) use install::install_log;
|
||||
pub(super) use validation::validate_app_id;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,219 @@
|
||||
//! Install progress tracking and podman pull output parsing.
|
||||
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use crate::data_model::{
|
||||
Description, InstallPhase, InstallProgress, Manifest, PackageDataEntry, PackageState,
|
||||
StaticFiles,
|
||||
};
|
||||
|
||||
impl RpcHandler {
|
||||
/// Set install progress for a package and broadcast the update.
|
||||
/// Creates a minimal package entry if one doesn't exist yet.
|
||||
///
|
||||
/// Prefer `set_install_phase` — this byte-counter API is kept for
|
||||
/// the rare case where the pull stream actually parses, but podman
|
||||
/// almost never emits parseable progress on a piped stderr.
|
||||
pub(super) async fn set_install_progress(&self, package_id: &str, downloaded: u64, size: u64) {
|
||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
||||
let entry = data
|
||||
.package_data
|
||||
.entry(package_id.to_string())
|
||||
.or_insert_with(|| create_installing_entry(package_id));
|
||||
entry.state = PackageState::Installing;
|
||||
let existing_phase = entry.install_progress.as_ref().and_then(|p| p.phase);
|
||||
entry.install_progress = Some(InstallProgress {
|
||||
size,
|
||||
downloaded,
|
||||
phase: existing_phase,
|
||||
message: None,
|
||||
});
|
||||
self.state_manager.update_data(data).await;
|
||||
}
|
||||
|
||||
/// Set the install pipeline phase and broadcast. This is the
|
||||
/// primary progress signal — the UI maps each phase to a
|
||||
/// percentage and a user-facing label. Byte counters are retained
|
||||
/// for the rare case podman emits parseable progress.
|
||||
pub(super) async fn set_install_phase(&self, package_id: &str, phase: InstallPhase) {
|
||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
||||
let entry = data
|
||||
.package_data
|
||||
.entry(package_id.to_string())
|
||||
.or_insert_with(|| create_installing_entry(package_id));
|
||||
// Preparing / PullingImage / CreatingContainer / StartingContainer /
|
||||
// WaitingHealthy / PostInstall all map to the Installing state.
|
||||
// Updates use Updating state — the wrapper has already flipped
|
||||
// state to Updating, so don't clobber it.
|
||||
if entry.state != PackageState::Updating {
|
||||
entry.state = PackageState::Installing;
|
||||
}
|
||||
let (size, downloaded) = entry
|
||||
.install_progress
|
||||
.as_ref()
|
||||
.map(|p| (p.size, p.downloaded))
|
||||
.unwrap_or((0, 0));
|
||||
entry.install_progress = Some(InstallProgress {
|
||||
size,
|
||||
downloaded,
|
||||
phase: Some(phase),
|
||||
message: None,
|
||||
});
|
||||
self.state_manager.update_data(data).await;
|
||||
}
|
||||
|
||||
/// Set a user-facing install status message (e.g. "Waiting for Bitcoin
|
||||
/// to start…") without disturbing the current phase/byte counters.
|
||||
pub(super) async fn set_install_message(&self, package_id: &str, message: &str) {
|
||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
||||
let entry = data
|
||||
.package_data
|
||||
.entry(package_id.to_string())
|
||||
.or_insert_with(|| create_installing_entry(package_id));
|
||||
if entry.state != PackageState::Updating {
|
||||
entry.state = PackageState::Installing;
|
||||
}
|
||||
let (size, downloaded, phase) = entry
|
||||
.install_progress
|
||||
.as_ref()
|
||||
.map(|p| (p.size, p.downloaded, p.phase))
|
||||
.unwrap_or((0, 0, None));
|
||||
entry.install_progress = Some(InstallProgress {
|
||||
size,
|
||||
downloaded,
|
||||
phase,
|
||||
message: Some(message.to_string()),
|
||||
});
|
||||
self.state_manager.update_data(data).await;
|
||||
}
|
||||
|
||||
/// Clear install progress after pull completes or fails.
|
||||
pub(super) async fn clear_install_progress(&self, package_id: &str) {
|
||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||
entry.install_progress = None;
|
||||
}
|
||||
self.state_manager.update_data(data).await;
|
||||
}
|
||||
|
||||
/// Set the uninstall stage label so the UI can show what's happening
|
||||
/// instead of a generic spinner. Each call broadcasts a state change
|
||||
/// — call sparingly (one per pipeline phase, not per container).
|
||||
pub(super) async fn set_uninstall_stage(&self, package_id: &str, stage: &str) {
|
||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||
entry.uninstall_stage = Some(stage.to_string());
|
||||
entry.state = crate::data_model::PackageState::Removing;
|
||||
}
|
||||
self.state_manager.update_data(data).await;
|
||||
}
|
||||
|
||||
/// Update install progress (static method for use in async closures).
|
||||
pub(super) async fn update_install_progress(
|
||||
state_manager: &crate::state::StateManager,
|
||||
package_id: &str,
|
||||
downloaded: u64,
|
||||
total: u64,
|
||||
) {
|
||||
let (mut data, _rev) = state_manager.get_snapshot().await;
|
||||
let entry = data
|
||||
.package_data
|
||||
.entry(package_id.to_string())
|
||||
.or_insert_with(|| create_installing_entry(package_id));
|
||||
let existing_phase = entry.install_progress.as_ref().and_then(|p| p.phase);
|
||||
entry.install_progress = Some(InstallProgress {
|
||||
size: total,
|
||||
downloaded,
|
||||
phase: existing_phase,
|
||||
message: None,
|
||||
});
|
||||
state_manager.update_data(data).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a minimal PackageDataEntry for a package being installed.
|
||||
fn create_installing_entry(package_id: &str) -> PackageDataEntry {
|
||||
PackageDataEntry {
|
||||
state: PackageState::Installing,
|
||||
health: None,
|
||||
exit_code: None,
|
||||
static_files: StaticFiles {
|
||||
license: String::new(),
|
||||
instructions: String::new(),
|
||||
// Empty icon: hardcoding `<id>.png` is wrong for apps that use
|
||||
// `.svg` or `.webp` assets and produces a broken-image flicker.
|
||||
// The frontend's `icon` computed falls through to the curated
|
||||
// map which has correct extensions for known apps.
|
||||
icon: String::new(),
|
||||
},
|
||||
manifest: Manifest {
|
||||
id: package_id.to_string(),
|
||||
title: package_id.to_string(),
|
||||
version: String::new(),
|
||||
description: Description {
|
||||
short: "Installing...".to_string(),
|
||||
long: String::new(),
|
||||
},
|
||||
release_notes: String::new(),
|
||||
license: String::new(),
|
||||
wrapper_repo: String::new(),
|
||||
upstream_repo: String::new(),
|
||||
support_site: String::new(),
|
||||
marketing_site: String::new(),
|
||||
donation_url: None,
|
||||
author: None,
|
||||
website: None,
|
||||
interfaces: None,
|
||||
tier: None,
|
||||
},
|
||||
installed: None,
|
||||
install_progress: None,
|
||||
uninstall_stage: None,
|
||||
available_update: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse podman pull progress output.
|
||||
/// Podman outputs lines like: "Copying blob sha256:abc done | 50.0MiB / 100.0MiB"
|
||||
/// Returns (downloaded_bytes, total_bytes) if parseable.
|
||||
pub(super) fn parse_pull_progress(line: &str) -> Option<(u64, u64)> {
|
||||
let line = line.trim();
|
||||
let parts: Vec<&str> = line.split('/').collect();
|
||||
if parts.len() != 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let downloaded = parse_size_value(parts[0].trim())?;
|
||||
let total = parse_size_value(parts[1].trim())?;
|
||||
|
||||
if total > 0 {
|
||||
Some((downloaded, total))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a size value like "50.0MiB", "1.2GiB", "500KiB" into bytes.
|
||||
fn parse_size_value(s: &str) -> Option<u64> {
|
||||
let s = s.trim();
|
||||
|
||||
let (num_str, multiplier) = if let Some(pos) = s.rfind("GiB") {
|
||||
(s[..pos].split_whitespace().last()?, 1024 * 1024 * 1024)
|
||||
} else if let Some(pos) = s.rfind("MiB") {
|
||||
(s[..pos].split_whitespace().last()?, 1024 * 1024)
|
||||
} else if let Some(pos) = s.rfind("KiB") {
|
||||
(s[..pos].split_whitespace().last()?, 1024)
|
||||
} else if let Some(pos) = s.rfind("GB") {
|
||||
(s[..pos].split_whitespace().last()?, 1_000_000_000)
|
||||
} else if let Some(pos) = s.rfind("MB") {
|
||||
(s[..pos].split_whitespace().last()?, 1_000_000)
|
||||
} else if let Some(pos) = s.rfind("KB") {
|
||||
(s[..pos].split_whitespace().last()?, 1_000)
|
||||
} else if let Some(pos) = s.rfind('B') {
|
||||
(s[..pos].split_whitespace().last()?, 1)
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let num: f64 = num_str.parse().ok()?;
|
||||
Some((num * multiplier as f64) as u64)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,352 @@
|
||||
//! Multi-version support — version listing + in-app version switch / pin /
|
||||
//! auto-update toggle (`docs/bitcoin-multi-version-design.md` §3 Phase 3).
|
||||
//!
|
||||
//! Two RPCs:
|
||||
//! - `package.versions` — read the selectable versions for an app plus the
|
||||
//! runner's current pin / auto-update preference and (best-effort) the
|
||||
//! version actually running. Drives the install modal + "Version & Updates"
|
||||
//! card.
|
||||
//! - `package.set-config` — persist a version pin (or un-pin to track latest)
|
||||
//! and/or the auto-update toggle, then recreate the app at the chosen image
|
||||
//! when the version actually changed. A DOWNGRADE (older release over a
|
||||
//! newer chainstate — the highest-risk operation, design §4) is refused
|
||||
//! unless the caller passes `confirm: true`, so the UI can warn first.
|
||||
|
||||
use super::config::get_containers_for_app;
|
||||
use super::install::install_log;
|
||||
use super::validation::validate_app_id;
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use crate::container::{app_catalog, version_config};
|
||||
use anyhow::Result;
|
||||
use std::sync::Arc;
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// Apps that participate in multi-version selection today. Kept narrow on
|
||||
/// purpose: version switching recreates the container, which is only safe for
|
||||
/// the single-container, orchestrator-managed Bitcoin backends whose data and
|
||||
/// downgrade semantics we understand. Any app the catalog gives a `versions[]`
|
||||
/// list also qualifies (third-party registry apps inherit the capability).
|
||||
fn supports_versions(app_id: &str) -> bool {
|
||||
matches!(app_id, "bitcoin-core" | "bitcoin-knots")
|
||||
|| !app_catalog::catalog_versions(app_id).is_empty()
|
||||
}
|
||||
|
||||
/// Extract the tag from a full image reference, leaving a `registry:port/repo`
|
||||
/// host-port colon intact (only a colon AFTER the last `/` is a tag).
|
||||
fn image_tag(image: &str) -> Option<String> {
|
||||
let after_slash = image.rsplit_once('/').map(|(_, r)| r).unwrap_or(image);
|
||||
after_slash
|
||||
.rsplit_once(':')
|
||||
.map(|(_, tag)| tag.to_string())
|
||||
.filter(|t| !t.is_empty())
|
||||
}
|
||||
|
||||
/// Best-effort: the version tag of the backend container actually running for
|
||||
/// `app_id`, by inspecting its image. `None` when not installed or unreadable.
|
||||
async fn installed_version(app_id: &str) -> Option<String> {
|
||||
let containers = get_containers_for_app(app_id).await.ok()?;
|
||||
// Prefer the backend container (exact id / `archy-<id>`) over UI companions.
|
||||
let name = containers
|
||||
.iter()
|
||||
.find(|n| n.as_str() == app_id || n.as_str() == format!("archy-{app_id}"))
|
||||
.or_else(|| containers.first())?;
|
||||
let out = tokio::process::Command::new("podman")
|
||||
.args(["inspect", name, "--format", "{{.ImageName}}"])
|
||||
.output()
|
||||
.await
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let image = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
let tag = image_tag(&image)?;
|
||||
// A floating tag (latest/stable/...) names the reference used to CREATE the
|
||||
// container, not what's actually running — podman never re-resolves it once
|
||||
// cached, so a stale local `:latest` reports "latest" even when the real
|
||||
// `latest` moved on months ago (.228, 2026-07-01: ran a 4-month-old cached
|
||||
// image while a newer one already sat locally, unused). Ask the Bitcoin
|
||||
// backends directly instead of trusting the tag literal in that case.
|
||||
if is_floating_tag(&tag) {
|
||||
if let Some(real) = bitcoind_reported_version(app_id, name).await {
|
||||
return Some(real);
|
||||
}
|
||||
}
|
||||
Some(tag)
|
||||
}
|
||||
|
||||
fn is_floating_tag(tag: &str) -> bool {
|
||||
matches!(tag, "latest" | "stable" | "release" | "main")
|
||||
}
|
||||
|
||||
/// Best-effort: ask the running bitcoind binary for its own version, trimmed to
|
||||
/// the catalog's version-tag format (e.g. `29.3.knots20260210`, `29.2`). `None`
|
||||
/// for apps other than the Bitcoin backends (no generic way to introspect a
|
||||
/// third-party image's content version this way) or if the exec fails.
|
||||
async fn bitcoind_reported_version(app_id: &str, container_name: &str) -> Option<String> {
|
||||
if !matches!(app_id, "bitcoin-core" | "bitcoin-knots") {
|
||||
return None;
|
||||
}
|
||||
let out = tokio::process::Command::new("podman")
|
||||
.args(["exec", container_name, "bitcoind", "--version"])
|
||||
.output()
|
||||
.await
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
parse_bitcoind_version_output(&String::from_utf8_lossy(&out.stdout))
|
||||
}
|
||||
|
||||
/// Parses e.g. "Bitcoin Knots daemon version v29.3.knots20260210\n..." or
|
||||
/// "Bitcoin Core version v29.2.0\n..." down to the version tag after `version v`.
|
||||
fn parse_bitcoind_version_output(output: &str) -> Option<String> {
|
||||
let first_line = output.lines().next()?;
|
||||
let (_, version) = first_line.rsplit_once("version v")?;
|
||||
let version = version.trim();
|
||||
if version.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(version.to_string())
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// `package.versions` — what a runner can install / switch to for this app,
|
||||
/// plus their current preference and the running version.
|
||||
pub(in crate::api::rpc) async fn handle_package_versions(
|
||||
&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("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||
validate_app_id(app_id)?;
|
||||
|
||||
let versions = app_catalog::catalog_versions(app_id);
|
||||
let default = app_catalog::catalog_default_version(app_id);
|
||||
let cfg = version_config::read(app_id);
|
||||
let installed = installed_version(app_id).await;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"id": app_id,
|
||||
"supportsVersions": supports_versions(app_id),
|
||||
"default": default,
|
||||
"installedVersion": installed,
|
||||
"pinnedVersion": cfg.pinned_version,
|
||||
"autoUpdate": cfg.auto_update,
|
||||
"versions": versions.iter().map(|v| serde_json::json!({
|
||||
"version": v.version,
|
||||
"default": v.default,
|
||||
"deprecated": v.deprecated,
|
||||
"eol": v.eol,
|
||||
})).collect::<Vec<_>>(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// `package.set-config` — persist version pin + auto-update preference and
|
||||
/// recreate on an actual version change. Downgrades require `confirm:true`.
|
||||
pub(in crate::api::rpc) async fn handle_package_set_config(
|
||||
self: Arc<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("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?
|
||||
.to_string();
|
||||
validate_app_id(&app_id)?;
|
||||
|
||||
if !supports_versions(&app_id) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"{} has no selectable versions in the catalog",
|
||||
app_id
|
||||
));
|
||||
}
|
||||
|
||||
let confirm = params
|
||||
.get("confirm")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let existing = version_config::read(&app_id);
|
||||
let default = app_catalog::catalog_default_version(&app_id);
|
||||
|
||||
// ---- Resolve the requested pin (if a version was supplied) ----------
|
||||
// Absent `version` => leave the pin unchanged (an auto-update-only edit).
|
||||
// `version == default` => un-pin (track latest). Any other version must
|
||||
// exist in the catalog and resolve to a same-repo image, else reject.
|
||||
let version_param = params
|
||||
.get("version")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string);
|
||||
let mut new_pin = existing.pinned_version.clone();
|
||||
let mut version_changed = false;
|
||||
if let Some(req) = version_param.as_deref() {
|
||||
let resolved_pin = if default.as_deref() == Some(req) {
|
||||
None // selecting the default un-pins
|
||||
} else {
|
||||
// Validate the version is real + same-repo before pinning.
|
||||
if !app_catalog::catalog_versions(&app_id)
|
||||
.iter()
|
||||
.any(|v| v.version == req)
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"version {} is not offered for {}",
|
||||
req,
|
||||
app_id
|
||||
));
|
||||
}
|
||||
Some(req.to_string())
|
||||
};
|
||||
version_changed = resolved_pin != existing.pinned_version;
|
||||
new_pin = resolved_pin;
|
||||
}
|
||||
|
||||
let new_auto_update = params
|
||||
.get("autoUpdate")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(existing.auto_update);
|
||||
|
||||
// ---- Downgrade gate (design §4: warn + confirm + allow) -------------
|
||||
// "Current" = what wrote the on-disk chainstate: the running version if
|
||||
// we can read it, else the existing pin, else the catalog default.
|
||||
if version_changed {
|
||||
let target = version_param.as_deref().unwrap_or_default();
|
||||
let current = installed_version(&app_id)
|
||||
.await
|
||||
.or_else(|| existing.pinned_version.clone())
|
||||
.or_else(|| default.clone());
|
||||
if let Some(current) = current {
|
||||
if version_config::is_downgrade(¤t, target) && !confirm {
|
||||
warn!(
|
||||
"set-config {}: refusing un-confirmed downgrade {} -> {}",
|
||||
app_id, current, target
|
||||
);
|
||||
return Ok(serde_json::json!({
|
||||
"status": "confirm_required",
|
||||
"kind": "downgrade",
|
||||
"id": app_id,
|
||||
"currentVersion": current,
|
||||
"targetVersion": target,
|
||||
"warning": format!(
|
||||
"Switching {app_id} from {current} down to {target} is a \
|
||||
downgrade. Bitcoin may refuse to start on a chainstate \
|
||||
written by the newer version without a full reindex, and \
|
||||
a pruned node can lose block data. Re-confirm to proceed."
|
||||
),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Persist preference --------------------------------------------
|
||||
version_config::write(
|
||||
&app_id,
|
||||
&version_config::AppVersionConfig {
|
||||
pinned_version: new_pin.clone(),
|
||||
auto_update: new_auto_update,
|
||||
},
|
||||
)?;
|
||||
install_log(&format!(
|
||||
"SET-CONFIG {}: pinned={:?} autoUpdate={} (version_changed={})",
|
||||
app_id, new_pin, new_auto_update, version_changed
|
||||
))
|
||||
.await;
|
||||
info!(
|
||||
app_id = %app_id,
|
||||
pinned = ?new_pin,
|
||||
auto_update = new_auto_update,
|
||||
version_changed,
|
||||
"package.set-config applied"
|
||||
);
|
||||
|
||||
// ---- Recreate when the version actually changed + app is installed --
|
||||
// The orchestrator's install/recreate path reads the pin we just wrote
|
||||
// (prod_orchestrator image resolution), so reusing the update machinery
|
||||
// pulls + recreates at the chosen image. An auto-update-only edit, or a
|
||||
// change to a not-installed app, just persists the preference.
|
||||
let mut recreating = false;
|
||||
if version_changed {
|
||||
let installed = get_containers_for_app(&app_id)
|
||||
.await
|
||||
.map(|c| !c.is_empty())
|
||||
.unwrap_or(false);
|
||||
if installed {
|
||||
recreating = true;
|
||||
// Fire the existing async update flow; it flips state to
|
||||
// Updating and recreates honoring the new pin. The UI polls.
|
||||
self.clone()
|
||||
.spawn_package_update(Some(serde_json::json!({ "id": app_id })))
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"status": "ok",
|
||||
"id": app_id,
|
||||
"pinnedVersion": new_pin,
|
||||
"autoUpdate": new_auto_update,
|
||||
"versionChanged": version_changed,
|
||||
"recreating": recreating,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{image_tag, is_floating_tag, parse_bitcoind_version_output};
|
||||
|
||||
#[test]
|
||||
fn floating_tag_detects_generic_channel_names() {
|
||||
for tag in ["latest", "stable", "release", "main"] {
|
||||
assert!(is_floating_tag(tag), "{tag}");
|
||||
}
|
||||
for tag in ["29.3.knots20260508", "28.4", "v29.2.0"] {
|
||||
assert!(!is_floating_tag(tag), "{tag}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_knots_version_line() {
|
||||
assert_eq!(
|
||||
parse_bitcoind_version_output(
|
||||
"Bitcoin Knots daemon version v29.3.knots20260210\nCopyright...\n"
|
||||
)
|
||||
.as_deref(),
|
||||
Some("29.3.knots20260210")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_core_version_line() {
|
||||
assert_eq!(
|
||||
parse_bitcoind_version_output("Bitcoin Core version v29.2.0\n").as_deref(),
|
||||
Some("29.2.0")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_returns_none_when_output_has_no_version_marker() {
|
||||
assert_eq!(parse_bitcoind_version_output("garbage output\n"), None);
|
||||
assert_eq!(parse_bitcoind_version_output(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_tag_keeps_registry_port_colon() {
|
||||
assert_eq!(
|
||||
image_tag("146.59.87.168:3000/lfg2025/bitcoin:28.4").as_deref(),
|
||||
Some("28.4")
|
||||
);
|
||||
assert_eq!(
|
||||
image_tag("146.59.87.168:3000/lfg2025/bitcoin-knots:29.3.knots20260508").as_deref(),
|
||||
Some("29.3.knots20260508")
|
||||
);
|
||||
// No tag => None (don't mistake the registry port for a tag).
|
||||
assert_eq!(image_tag("146.59.87.168:3000/lfg2025/bitcoin"), None);
|
||||
assert_eq!(
|
||||
image_tag("docker.io/library/redis:7"),
|
||||
Some("7".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,641 @@
|
||||
//! Per-app manual update handler.
|
||||
//!
|
||||
//! Flow: validate → set Updating state → graceful stop → pull new image(s) →
|
||||
//! remove old container(s) → recreate (orchestrator-first, legacy fallback) → verify running.
|
||||
//! Data volumes are preserved (bind mounts, not stored in container).
|
||||
|
||||
use super::config::get_containers_for_app;
|
||||
use super::install::install_log;
|
||||
use super::progress::parse_pull_progress;
|
||||
use super::runtime::stop_timeout_secs;
|
||||
use super::validation::validate_app_id;
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use crate::container::image_versions;
|
||||
use crate::data_model::{InstallPhase, PackageState};
|
||||
use anyhow::{Context, Result};
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
const PODMAN_UPDATE_PULL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
|
||||
|
||||
impl RpcHandler {
|
||||
/// Update a package to the version pinned in image-versions.sh.
|
||||
/// This is a manual operation — the user clicks "Update" in the UI.
|
||||
pub(in crate::api::rpc) async fn handle_package_update(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let package_id = params
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||
validate_app_id(package_id)?;
|
||||
|
||||
// Resolve the target image. Prefer the remote app catalog (decoupled
|
||||
// from the binary OTA), falling back to the image-versions.sh pin. This
|
||||
// is OPTIONAL for orchestrator-managed apps: the orchestrator resolves
|
||||
// the image itself (manifest + catalog + version_config pin) in its
|
||||
// upgrade path, so an app the catalog doesn't carry a primary image for
|
||||
// (e.g. bitcoin-core, image lives in the embedded manifest + versions[])
|
||||
// still upgrades. Only the legacy/stack path below hard-requires it.
|
||||
let pinned = crate::container::app_catalog::catalog_primary_image(package_id)
|
||||
.or_else(|| image_versions::pinned_image_for_app(package_id));
|
||||
|
||||
// Note: the `already updating` guard lives in `spawn_package_update`
|
||||
// (the async wrapper that dispatch actually routes to). By the time
|
||||
// this inner function runs, the wrapper has already flipped state to
|
||||
// `Updating`, so duplicating the check here would be a false positive.
|
||||
|
||||
install_log(&format!(
|
||||
"UPDATE: {} → {}",
|
||||
package_id,
|
||||
pinned.as_deref().unwrap_or("(orchestrator-resolved)")
|
||||
))
|
||||
.await;
|
||||
|
||||
// Set state to Updating
|
||||
{
|
||||
let (mut data, _) = self.state_manager.get_snapshot().await;
|
||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||
entry.state = PackageState::Updating;
|
||||
entry.available_update = None;
|
||||
}
|
||||
self.state_manager.update_data(data).await;
|
||||
}
|
||||
|
||||
// Preferred path: for single-container apps managed by manifests, route
|
||||
// updates through the orchestrator's upgrade lifecycle instead of the
|
||||
// legacy shell/CLI flow. Keep stack-style packages on legacy for now.
|
||||
if should_try_orchestrator_update(package_id, self.orchestrator.is_some()) {
|
||||
let orchestrator_app_id = orchestrator_update_app_id(package_id);
|
||||
self.set_install_phase(package_id, InstallPhase::Preparing)
|
||||
.await;
|
||||
install_log(&format!(
|
||||
"UPDATE ORCH: {} — attempting orchestrator upgrade as {}",
|
||||
package_id, orchestrator_app_id
|
||||
))
|
||||
.await;
|
||||
|
||||
if let Some(orchestrator) = self.orchestrator.as_ref() {
|
||||
match orchestrator.upgrade(orchestrator_app_id).await {
|
||||
Ok(()) => {
|
||||
self.set_install_phase(package_id, InstallPhase::WaitingHealthy)
|
||||
.await;
|
||||
if let Ok(health) = orchestrator.health(orchestrator_app_id).await {
|
||||
if health != "healthy" {
|
||||
warn!(
|
||||
"Update {}: orchestrator upgrade completed with health={} (expected healthy)",
|
||||
package_id, health
|
||||
);
|
||||
}
|
||||
}
|
||||
install_log(&format!(
|
||||
"UPDATE ORCH OK: {} (app={})",
|
||||
package_id, orchestrator_app_id
|
||||
))
|
||||
.await;
|
||||
self.clear_install_progress(package_id).await;
|
||||
return Ok(serde_json::json!({
|
||||
"status": "updated",
|
||||
"package_id": package_id,
|
||||
}));
|
||||
}
|
||||
Err(e) if is_unknown_app_id_error(&e) => {
|
||||
info!(
|
||||
"Update {}: orchestrator has no manifest mapping yet, falling back to legacy updater",
|
||||
package_id
|
||||
);
|
||||
install_log(&format!(
|
||||
"UPDATE ORCH SKIP: {} — unknown app_id, using legacy flow",
|
||||
package_id
|
||||
))
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
install_log(&format!("UPDATE ORCH FAIL: {} — {}", package_id, e)).await;
|
||||
self.clear_install_progress(package_id).await;
|
||||
self.clear_update_state(package_id).await;
|
||||
return Err(e.context(format!("Orchestrator update {} failed", package_id)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy/stack path hard-requires a concrete primary image (the
|
||||
// orchestrator path above already returned for apps it manages).
|
||||
let pinned = match pinned {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
self.clear_update_state(package_id).await;
|
||||
return Err(anyhow::anyhow!("No pinned image found for {}", package_id));
|
||||
}
|
||||
};
|
||||
|
||||
// Resolve images to pull — either a stack or single container
|
||||
let images_to_pull = self.resolve_images_to_pull(package_id, &pinned);
|
||||
|
||||
// Get all containers for this app
|
||||
let containers = get_containers_for_app(package_id).await?;
|
||||
if containers.is_empty() {
|
||||
self.clear_update_state(package_id).await;
|
||||
return Err(anyhow::anyhow!("No containers found for {}", package_id));
|
||||
}
|
||||
|
||||
// Execute update — on failure, attempt rollback by restarting old containers
|
||||
match self
|
||||
.execute_update(package_id, &containers, &images_to_pull)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
install_log(&format!("UPDATE OK: {}", package_id)).await;
|
||||
self.clear_install_progress(package_id).await;
|
||||
Ok(serde_json::json!({
|
||||
"status": "updated",
|
||||
"package_id": package_id,
|
||||
}))
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Update {} failed: {}. Attempting rollback.", package_id, e);
|
||||
install_log(&format!(
|
||||
"UPDATE FAIL: {} — {}. Rolling back.",
|
||||
package_id, e
|
||||
))
|
||||
.await;
|
||||
self.rollback_update(package_id, &containers).await;
|
||||
self.clear_install_progress(package_id).await;
|
||||
self.clear_update_state(package_id).await;
|
||||
Err(e.context(format!("Update {} failed, rolled back", package_id)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Manual "check for updates": refresh the remote app catalog now. The
|
||||
/// package scanner recomputes each app's `available-update` from the fresh
|
||||
/// catalog on its next cycle and pushes it to the UI. When the catalog
|
||||
/// bytes changed, the orchestrator's manifest overlay is reloaded in the
|
||||
/// same call so catalog-shipped manifest fixes apply without a service
|
||||
/// restart. Best-effort — a fetch failure leaves the cached catalog in
|
||||
/// place and reports `refreshed: false`.
|
||||
pub(in crate::api::rpc) async fn handle_package_check_updates(
|
||||
&self,
|
||||
_params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
match crate::container::app_catalog::refresh_catalog(&self.config.data_dir).await {
|
||||
Ok(refresh) => {
|
||||
let mut manifests_reloaded = serde_json::Value::Null;
|
||||
if refresh.changed {
|
||||
if let Some(orch) = &self.orchestrator {
|
||||
match orch.reload_manifests().await {
|
||||
Ok(n) => manifests_reloaded = serde_json::json!(n),
|
||||
Err(e) => tracing::warn!(
|
||||
"check-updates: manifest reload after catalog change failed: {e}"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(serde_json::json!({
|
||||
"status": "ok",
|
||||
"refreshed": true,
|
||||
"catalog_apps": refresh.apps,
|
||||
"catalog_changed": refresh.changed,
|
||||
"manifests_reloaded": manifests_reloaded,
|
||||
}))
|
||||
}
|
||||
Err(e) => Ok(serde_json::json!({
|
||||
"status": "ok",
|
||||
"refreshed": false,
|
||||
"error": e.to_string(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Core update execution: stop → pull → remove → recreate → verify.
|
||||
async fn execute_update(
|
||||
&self,
|
||||
package_id: &str,
|
||||
containers: &[String],
|
||||
images_to_pull: &[(String, String)],
|
||||
) -> Result<()> {
|
||||
// Phase: Preparing — about to stop the running container(s) so
|
||||
// we can swap images. Fast.
|
||||
self.set_install_phase(package_id, InstallPhase::Preparing)
|
||||
.await;
|
||||
|
||||
// 1. Graceful stop all containers (reverse order for dependencies)
|
||||
info!(
|
||||
"Update {}: stopping {} containers",
|
||||
package_id,
|
||||
containers.len()
|
||||
);
|
||||
for name in containers.iter().rev() {
|
||||
let timeout = stop_timeout_secs(name);
|
||||
info!(
|
||||
"Update {}: stopping {} (timeout: {}s)",
|
||||
package_id, name, timeout
|
||||
);
|
||||
let out = tokio::process::Command::new("podman")
|
||||
.args(["stop", "-t", timeout, name])
|
||||
.output()
|
||||
.await
|
||||
.context(format!("Failed to stop {}", name))?;
|
||||
if !out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
warn!(
|
||||
"Update {}: stop {} failed: {}",
|
||||
package_id,
|
||||
name,
|
||||
stderr.trim()
|
||||
);
|
||||
// Continue — container might already be stopped
|
||||
}
|
||||
}
|
||||
|
||||
// Phase: PullingImage — about to fetch each pinned image in turn.
|
||||
self.set_install_phase(package_id, InstallPhase::PullingImage)
|
||||
.await;
|
||||
|
||||
// 2. Pull new images with progress
|
||||
info!(
|
||||
"Update {}: pulling {} images",
|
||||
package_id,
|
||||
images_to_pull.len()
|
||||
);
|
||||
for (i, (name, image)) in images_to_pull.iter().enumerate() {
|
||||
info!(
|
||||
"Update {}: pulling image {}/{} ({})",
|
||||
package_id,
|
||||
i + 1,
|
||||
images_to_pull.len(),
|
||||
image
|
||||
);
|
||||
self.pull_update_image(package_id, image)
|
||||
.await
|
||||
.context(format!("Failed to pull {} for {}", image, name))?;
|
||||
}
|
||||
|
||||
// 3. Remove old containers
|
||||
info!("Update {}: removing old containers", package_id);
|
||||
for name in containers {
|
||||
let out = tokio::process::Command::new("podman")
|
||||
.args(["rm", name])
|
||||
.output()
|
||||
.await
|
||||
.context(format!("Failed to remove {}", name))?;
|
||||
if !out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
// Force remove as fallback
|
||||
warn!(
|
||||
"Update {}: rm {} failed ({}), forcing",
|
||||
package_id,
|
||||
name,
|
||||
stderr.trim()
|
||||
);
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["rm", "-f", name])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
// Phase: CreatingContainer — about to recreate each container.
|
||||
self.set_install_phase(package_id, InstallPhase::CreatingContainer)
|
||||
.await;
|
||||
|
||||
// 4. Recreate containers (orchestrator-first, reconcile fallback)
|
||||
info!("Update {}: recreating containers", package_id);
|
||||
for name in containers {
|
||||
self.recreate_container_for_update(package_id, name).await?;
|
||||
// Brief delay between containers for dependency initialization
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
}
|
||||
|
||||
// Phase: WaitingHealthy — reconcile has started every container,
|
||||
// now verifying each reached running state.
|
||||
self.set_install_phase(package_id, InstallPhase::WaitingHealthy)
|
||||
.await;
|
||||
|
||||
// 5. Verify containers reached running state
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
for name in containers {
|
||||
let status = tokio::process::Command::new("podman")
|
||||
.args(["inspect", name, "--format", "{{.State.Status}}"])
|
||||
.output()
|
||||
.await;
|
||||
if let Ok(o) = status {
|
||||
let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
|
||||
if state == "exited" {
|
||||
warn!(
|
||||
"Update {}: container {} exited after recreate",
|
||||
package_id, name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn recreate_container_for_update(
|
||||
&self,
|
||||
package_id: &str,
|
||||
container_name: &str,
|
||||
) -> Result<()> {
|
||||
let Some(orchestrator) = self.orchestrator.as_ref() else {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Cannot recreate {} during update {}: orchestrator unavailable",
|
||||
container_name,
|
||||
package_id
|
||||
));
|
||||
};
|
||||
|
||||
let mut attempted = Vec::new();
|
||||
for app_id in candidate_app_ids_for_container(container_name) {
|
||||
attempted.push(app_id.clone());
|
||||
match orchestrator.install(&app_id).await {
|
||||
Ok(created_name) => {
|
||||
install_log(&format!(
|
||||
"UPDATE ORCH RECREATE OK: {} — container={} app_id={} created={}",
|
||||
package_id, container_name, app_id, created_name
|
||||
))
|
||||
.await;
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) if is_unknown_app_id_error(&e) => {
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(e.context(format!(
|
||||
"orchestrator recreate failed for update {} (container={}, app_id={})",
|
||||
package_id, container_name, app_id
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow::anyhow!(
|
||||
"No manifest mapping found while recreating {} during update {} (attempted app_ids: {})",
|
||||
container_name,
|
||||
package_id,
|
||||
attempted.join(", ")
|
||||
))
|
||||
}
|
||||
|
||||
/// Pull a single image with progress broadcasting (reuses install progress pattern).
|
||||
async fn pull_update_image(&self, package_id: &str, image: &str) -> Result<()> {
|
||||
self.set_install_progress(package_id, 0, 0).await;
|
||||
|
||||
let mut cmd = tokio::process::Command::new("podman");
|
||||
cmd.arg("pull");
|
||||
if archipelago_container::image_uses_insecure_registry(image) {
|
||||
cmd.arg("--tls-verify=false");
|
||||
}
|
||||
cmd.kill_on_drop(true);
|
||||
let mut child = cmd
|
||||
.arg(image)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.context("Failed to start image pull")?;
|
||||
|
||||
let progress_task = if let Some(stderr) = child.stderr.take() {
|
||||
let reader = BufReader::new(stderr);
|
||||
let mut lines = reader.lines();
|
||||
let pkg_id = package_id.to_string();
|
||||
let state_mgr = self.state_manager.clone();
|
||||
|
||||
Some(tokio::spawn(async move {
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
if let Some((downloaded, total)) = parse_pull_progress(&line) {
|
||||
Self::update_install_progress(&state_mgr, &pkg_id, downloaded, total).await;
|
||||
}
|
||||
}
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let status = match tokio::time::timeout(PODMAN_UPDATE_PULL_TIMEOUT, child.wait()).await {
|
||||
Ok(result) => result.context("Failed to wait for image pull")?,
|
||||
Err(_) => {
|
||||
let _ = child.kill().await;
|
||||
return Err(anyhow::anyhow!(
|
||||
"podman pull {} timed out after {}s",
|
||||
image,
|
||||
PODMAN_UPDATE_PULL_TIMEOUT.as_secs()
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(task) = progress_task {
|
||||
let _ = task.await;
|
||||
}
|
||||
if !status.success() {
|
||||
return Err(anyhow::anyhow!("podman pull {} failed", image));
|
||||
}
|
||||
|
||||
self.set_install_progress(package_id, 100, 100).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Determine which images need to be pulled for this update.
|
||||
/// For multi-container stacks, pulls all component images.
|
||||
/// For single-container apps, pulls just the pinned image.
|
||||
fn resolve_images_to_pull(
|
||||
&self,
|
||||
package_id: &str,
|
||||
pinned_primary: &str,
|
||||
) -> Vec<(String, String)> {
|
||||
let mut stack_images = image_versions::pinned_images_for_stack(package_id);
|
||||
if stack_images.is_empty() {
|
||||
// Single container app — pinned_primary already prefers the catalog.
|
||||
return vec![(package_id.to_string(), pinned_primary.to_string())];
|
||||
}
|
||||
// Stack app: override per-container images with the catalog where it
|
||||
// provides them; components the catalog omits keep the image-versions.sh
|
||||
// pin. This lets a single component (e.g. the IndeeHub frontend) be
|
||||
// bumped without touching the rest of the stack.
|
||||
let catalog_images = crate::container::app_catalog::catalog_stack_images(package_id);
|
||||
if !catalog_images.is_empty() {
|
||||
for (name, image) in stack_images.iter_mut() {
|
||||
if let Some(catalog_image) = catalog_images.get(name) {
|
||||
*image = catalog_image.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
stack_images
|
||||
}
|
||||
|
||||
/// Rollback: restart old containers if they still exist.
|
||||
/// Called when update fails partway through.
|
||||
async fn rollback_update(&self, package_id: &str, containers: &[String]) {
|
||||
warn!("Rolling back update for {}", package_id);
|
||||
for name in containers {
|
||||
// Try to start — works if container still exists (wasn't removed yet)
|
||||
let out = tokio::process::Command::new("podman")
|
||||
.args(["start", name])
|
||||
.output()
|
||||
.await;
|
||||
match out {
|
||||
Ok(o) if o.status.success() => {
|
||||
info!("Rollback: restarted {}", name);
|
||||
}
|
||||
Ok(o) => {
|
||||
let stderr = String::from_utf8_lossy(&o.stderr);
|
||||
warn!("Rollback: could not restart {}: {}", name, stderr.trim());
|
||||
// Container was already removed (forward path ran `podman rm`).
|
||||
// Recreate via orchestrator-first path with legacy fallback.
|
||||
if let Err(recreate_err) =
|
||||
self.recreate_container_for_update(package_id, name).await
|
||||
{
|
||||
error!(
|
||||
"Rollback: failed to recreate {} during rollback of {}: {}",
|
||||
name, package_id, recreate_err
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Rollback: failed to restart {}: {}", name, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the Updating state (used on failure/rollback).
|
||||
async fn clear_update_state(&self, package_id: &str) {
|
||||
let (mut data, _) = self.state_manager.get_snapshot().await;
|
||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||
// Don't overwrite state from scanner — just clear if still Updating
|
||||
if entry.state == PackageState::Updating {
|
||||
entry.state = PackageState::Stopped;
|
||||
}
|
||||
}
|
||||
self.state_manager.update_data(data).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn should_try_orchestrator_update(package_id: &str, orchestrator_available: bool) -> bool {
|
||||
orchestrator_available && !uses_legacy_update_flow(package_id)
|
||||
}
|
||||
|
||||
fn orchestrator_update_app_id(package_id: &str) -> &str {
|
||||
match package_id {
|
||||
"electrs" | "mempool-electrs" => "electrumx",
|
||||
_ => package_id,
|
||||
}
|
||||
}
|
||||
|
||||
fn uses_legacy_update_flow(package_id: &str) -> bool {
|
||||
matches!(
|
||||
package_id,
|
||||
// Multi-container stacks still updated via the stack-aware path.
|
||||
"immich" | "penpot" | "penpot-frontend" | "indeedhub"
|
||||
)
|
||||
}
|
||||
|
||||
fn is_unknown_app_id_error(err: &anyhow::Error) -> bool {
|
||||
err.chain()
|
||||
.any(|cause| cause.to_string().contains("unknown app_id"))
|
||||
}
|
||||
|
||||
fn candidate_app_ids_for_container(container_name: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut push = |s: &str| {
|
||||
if !out.iter().any(|e: &String| e == s) {
|
||||
out.push(s.to_string());
|
||||
}
|
||||
};
|
||||
|
||||
match container_name {
|
||||
"bitcoin-knots" | "bitcoin-core" => {
|
||||
push("bitcoin-knots");
|
||||
push("bitcoin-core");
|
||||
}
|
||||
"archy-bitcoin-ui" => push("bitcoin-ui"),
|
||||
"archy-lnd-ui" => push("lnd-ui"),
|
||||
"archy-electrs-ui" => push("electrs-ui"),
|
||||
"mempool" => {
|
||||
push("archy-mempool-web");
|
||||
push("mempool");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
push(container_name);
|
||||
if let Some(stripped) = container_name.strip_prefix("archy-") {
|
||||
push(stripped);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
candidate_app_ids_for_container, orchestrator_update_app_id,
|
||||
should_try_orchestrator_update, uses_legacy_update_flow,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn legacy_flow_for_stack_apps() {
|
||||
for app in ["immich", "penpot", "indeedhub"] {
|
||||
assert!(uses_legacy_update_flow(app), "{app} should stay legacy");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orchestrator_flow_for_single_apps() {
|
||||
for app in [
|
||||
"lnd",
|
||||
"bitcoin-core",
|
||||
"searxng",
|
||||
"grafana",
|
||||
"btcpay-server",
|
||||
"mempool",
|
||||
"fedimint",
|
||||
] {
|
||||
assert!(
|
||||
!uses_legacy_update_flow(app),
|
||||
"{app} should be orchestrator-first"
|
||||
);
|
||||
assert!(
|
||||
should_try_orchestrator_update(app, true),
|
||||
"{app} should use orchestrator when available"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_orchestrator_means_no_orchestrator_flow() {
|
||||
assert!(!should_try_orchestrator_update("lnd", false));
|
||||
assert!(!should_try_orchestrator_update("btcpay-server", false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn container_name_candidates_cover_common_aliases() {
|
||||
assert_eq!(
|
||||
candidate_app_ids_for_container("bitcoin-knots"),
|
||||
vec!["bitcoin-knots", "bitcoin-core"]
|
||||
);
|
||||
assert_eq!(
|
||||
candidate_app_ids_for_container("archy-bitcoin-ui"),
|
||||
vec!["bitcoin-ui", "archy-bitcoin-ui"]
|
||||
);
|
||||
assert_eq!(
|
||||
candidate_app_ids_for_container("mempool"),
|
||||
vec!["archy-mempool-web", "mempool"]
|
||||
);
|
||||
assert_eq!(
|
||||
candidate_app_ids_for_container("archy-mempool-db"),
|
||||
vec!["archy-mempool-db", "mempool-db"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_aliases_map_to_manifest_app_ids() {
|
||||
assert_eq!(orchestrator_update_app_id("bitcoin-knots"), "bitcoin-knots");
|
||||
assert_eq!(orchestrator_update_app_id("bitcoin-core"), "bitcoin-core");
|
||||
assert_eq!(orchestrator_update_app_id("electrs"), "electrumx");
|
||||
assert_eq!(orchestrator_update_app_id("mempool-electrs"), "electrumx");
|
||||
assert_eq!(orchestrator_update_app_id("fedimint"), "fedimint");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
use anyhow::Result;
|
||||
|
||||
/// Validate that a package/app ID is safe (lowercase alphanumeric + hyphens, 1-64 chars).
|
||||
pub(in crate::api::rpc) fn validate_app_id(id: &str) -> Result<()> {
|
||||
if id.is_empty() || id.len() > 64 {
|
||||
anyhow::bail!("Invalid app id: must be 1-64 characters");
|
||||
}
|
||||
if !id
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
|
||||
{
|
||||
anyhow::bail!("Invalid app id: only lowercase letters, digits, and hyphens allowed");
|
||||
}
|
||||
if id.starts_with('-') {
|
||||
anyhow::bail!("Invalid app id: must not start with a hyphen");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
use super::RpcHandler;
|
||||
use crate::peers::KnownPeer;
|
||||
use crate::{federation, node_message, nostr_discovery, peers};
|
||||
use anyhow::Result;
|
||||
|
||||
impl RpcHandler {
|
||||
pub(super) async fn handle_node_add_peer(
|
||||
&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 onion"))?;
|
||||
let pubkey = params
|
||||
.get("pubkey")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing pubkey"))?;
|
||||
let name = params
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
|
||||
let peer = KnownPeer {
|
||||
onion: onion.to_string(),
|
||||
pubkey: pubkey.to_string(),
|
||||
name,
|
||||
added_at: Some(chrono::Utc::now().to_rfc3339()),
|
||||
};
|
||||
let peers = peers::add_peer(&self.config.data_dir, peer).await?;
|
||||
Ok(serde_json::json!({ "peers": peers }))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_node_list_peers(&self) -> Result<serde_json::Value> {
|
||||
let peers = peers::load_peers(&self.config.data_dir).await?;
|
||||
Ok(serde_json::json!({ "peers": peers }))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_node_remove_peer(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let pubkey = params
|
||||
.get("pubkey")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing pubkey"))?;
|
||||
let peers = peers::remove_peer(&self.config.data_dir, pubkey).await?;
|
||||
Ok(serde_json::json!({ "peers": peers }))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_node_send_message(
|
||||
&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 onion"))?;
|
||||
let message = params
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing message"))?;
|
||||
|
||||
// Limit message size to 1MB to prevent DoS
|
||||
if message.len() > 1_048_576 {
|
||||
anyhow::bail!("Message too large (max 1MB)");
|
||||
}
|
||||
|
||||
// Validate onion is a known peer or federated node to prevent SSRF
|
||||
let known_peers = peers::load_peers(&self.config.data_dir).await?;
|
||||
let is_known_peer = known_peers.iter().any(|p| {
|
||||
p.onion == onion
|
||||
|| p.onion == format!("{}.onion", onion)
|
||||
|| format!("{}.onion", p.onion) == onion
|
||||
});
|
||||
let is_known_fed = if !is_known_peer {
|
||||
let fed_nodes = federation::load_nodes(&self.config.data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
fed_nodes.iter().any(|n| {
|
||||
n.onion == onion
|
||||
|| n.onion == format!("{}.onion", onion)
|
||||
|| format!("{}.onion", n.onion) == onion
|
||||
})
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if !is_known_peer && !is_known_fed {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Onion address not in known peers or federation. Add the peer first."
|
||||
));
|
||||
}
|
||||
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let pubkey = data.server_info.pubkey.clone();
|
||||
|
||||
// Skip sending to ourselves (prevents duplicate messages in group chat)
|
||||
if let Some(ref our_onion) = data.server_info.tor_address {
|
||||
let our = our_onion.trim_end_matches(".onion");
|
||||
let their = onion.trim_end_matches(".onion");
|
||||
if our == their {
|
||||
return Ok(serde_json::json!({ "ok": true, "sent_to": onion, "skipped": "self" }));
|
||||
}
|
||||
}
|
||||
|
||||
// Load signing key for E2E encryption
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let node_id = crate::identity::NodeIdentity::load_or_create(&identity_dir).await?;
|
||||
|
||||
// Look up recipient's pubkey from federation nodes
|
||||
let fed_nodes = federation::load_nodes(&self.config.data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let recipient = fed_nodes.iter().find(|n| {
|
||||
n.onion == onion
|
||||
|| n.onion == format!("{}.onion", onion)
|
||||
|| format!("{}.onion", n.onion) == onion
|
||||
});
|
||||
let recipient_pubkey = recipient.map(|n| n.pubkey.clone());
|
||||
let recipient_fips_npub = recipient.and_then(|n| n.fips_npub.clone());
|
||||
|
||||
// Include our node name so the recipient can display it
|
||||
let node_name = data.server_info.name.clone();
|
||||
|
||||
node_message::send_to_peer(
|
||||
onion,
|
||||
recipient_fips_npub.as_deref(),
|
||||
&pubkey,
|
||||
message,
|
||||
Some(node_id.signing_key()),
|
||||
recipient_pubkey.as_deref(),
|
||||
node_name.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
Ok(serde_json::json!({ "ok": true, "sent_to": onion }))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_node_check_peer(
|
||||
&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 onion"))?;
|
||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
let reachable = node_message::check_peer_reachable(onion, fips_npub.as_deref())
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
Ok(serde_json::json!({ "onion": onion, "reachable": reachable }))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_node_messages_received(&self) -> Result<serde_json::Value> {
|
||||
let messages = node_message::get_received();
|
||||
Ok(serde_json::json!({ "messages": messages }))
|
||||
}
|
||||
|
||||
/// Store a sent message for Archipelago channel history persistence.
|
||||
pub(super) async fn handle_node_store_sent(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let message = params
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing message"))?;
|
||||
node_message::store_sent(message);
|
||||
Ok(serde_json::json!({ "ok": true }))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_node_nostr_discover(&self) -> Result<serde_json::Value> {
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let nodes = nostr_discovery::discover_archipelago_nodes(
|
||||
&identity_dir,
|
||||
&self.config.nostr_relays,
|
||||
self.config.nostr_tor_proxy.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
Ok(serde_json::json!({ "nodes": nodes }))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
//! Node-status JSON for the Pine voice stack (`GET /api/pine/status`).
|
||||
//!
|
||||
//! Two tiers in one endpoint:
|
||||
//! - **Public** (no credentials): version, uptime, bitcoin height / sync /
|
||||
//! peer count, mesh peer count. Feeds the Pine launcher page's live status
|
||||
//! card — nothing here a LAN visitor couldn't already infer from the
|
||||
//! existing unauthenticated `/bitcoin-status`.
|
||||
//! - **Token** (`Authorization: Bearer <pine-status-token>`): adds Lightning
|
||||
//! balances and the most recent received mesh text message. Feeds the
|
||||
//! Home Assistant REST sensors seeded by `package::pine_ha` — the seeder
|
||||
//! mints the token into `data_dir/secrets/pine-status-token` (0600) and
|
||||
//! embeds it in HA's configuration, so only HA (and the node owner) can
|
||||
//! read balances or message text.
|
||||
//!
|
||||
//! Replaces the interim per-node socat forwarder + bitcoind-RPC-credentials-
|
||||
//! in-configuration.yaml stopgap used before this endpoint existed.
|
||||
|
||||
use super::RpcHandler;
|
||||
use crate::mesh::types::MessageDirection;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// File under `data_dir/secrets/` holding the bearer token that unlocks the
|
||||
/// sensitive tier. Written by the pine/HA seeder, read per-request here.
|
||||
pub(crate) const PINE_STATUS_TOKEN_FILE: &str = "pine-status-token";
|
||||
|
||||
/// Constant-time-ish equality — avoids early-exit timing on the token compare.
|
||||
fn token_eq(a: &str, b: &str) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
a.bytes()
|
||||
.zip(b.bytes())
|
||||
.fold(0u8, |acc, (x, y)| acc | (x ^ y))
|
||||
== 0
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// True when `presented` matches the on-disk pine status token. Missing or
|
||||
/// empty token file means the sensitive tier is locked (seeder not run).
|
||||
pub(crate) async fn pine_status_token_ok(&self, presented: &str) -> bool {
|
||||
let path = self
|
||||
.config
|
||||
.data_dir
|
||||
.join("secrets")
|
||||
.join(PINE_STATUS_TOKEN_FILE);
|
||||
match tokio::fs::read_to_string(&path).await {
|
||||
Ok(tok) => {
|
||||
let tok = tok.trim();
|
||||
!tok.is_empty() && token_eq(tok, presented.trim())
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Assemble the status document. `authorized` selects the token tier.
|
||||
/// Every sub-source is best-effort: a dead bitcoind/LND/mesh never turns
|
||||
/// the endpoint into an error — the field just reports what it can.
|
||||
pub(crate) async fn pine_status_json(&self, authorized: bool) -> Value {
|
||||
let bs = crate::bitcoin_status::get_bitcoin_status().await;
|
||||
let bitcoin = {
|
||||
let info = bs.blockchain_info.as_ref();
|
||||
let height = info.and_then(|i| i.get("blocks")).and_then(Value::as_u64);
|
||||
let progress = info
|
||||
.and_then(|i| i.get("verificationprogress"))
|
||||
.and_then(Value::as_f64);
|
||||
let ibd = info
|
||||
.and_then(|i| i.get("initialblockdownload"))
|
||||
.and_then(Value::as_bool);
|
||||
let peers = bs
|
||||
.network_info
|
||||
.as_ref()
|
||||
.and_then(|n| n.get("connections"))
|
||||
.and_then(Value::as_u64);
|
||||
json!({
|
||||
"ok": bs.ok,
|
||||
"height": height,
|
||||
"sync_percent": progress.map(|p| (p * 10000.0).round() / 100.0),
|
||||
"ibd": ibd,
|
||||
"peers": peers,
|
||||
})
|
||||
};
|
||||
|
||||
let (mesh, mesh_message) = {
|
||||
let guard = self.mesh_service.read().await;
|
||||
match guard.as_ref() {
|
||||
Some(svc) => {
|
||||
let status = svc.status().await;
|
||||
let latest = if authorized {
|
||||
svc.messages(None)
|
||||
.await
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|m| {
|
||||
m.direction == MessageDirection::Received
|
||||
&& m.message_type == "text"
|
||||
})
|
||||
.map(|m| {
|
||||
json!({
|
||||
"id": m.id,
|
||||
"from": m.peer_name.clone()
|
||||
.unwrap_or_else(|| format!("contact {}", m.peer_contact_id)),
|
||||
"text": m.plaintext,
|
||||
"timestamp": m.timestamp,
|
||||
})
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
(
|
||||
json!({ "enabled": status.enabled, "peers": status.peer_count }),
|
||||
latest,
|
||||
)
|
||||
}
|
||||
None => (json!({ "enabled": false, "peers": 0 }), None),
|
||||
}
|
||||
};
|
||||
|
||||
let lightning = if authorized {
|
||||
match self.handle_lnd_getinfo().await {
|
||||
Ok(info) => json!({
|
||||
"balance_sats": info.get("balance_sats"),
|
||||
"channel_balance_sats": info.get("channel_balance_sats"),
|
||||
"active_channels": info.get("num_active_channels"),
|
||||
"synced_to_chain": info.get("synced_to_chain"),
|
||||
}),
|
||||
Err(_) => Value::Null,
|
||||
}
|
||||
} else {
|
||||
Value::Null
|
||||
};
|
||||
|
||||
json!({
|
||||
"ok": true,
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"uptime_seconds": crate::crash_recovery::uptime_seconds(),
|
||||
"bitcoin": bitcoin,
|
||||
"mesh": mesh,
|
||||
"lightning": lightning,
|
||||
// Always an object: HA's REST sensor reads attributes via
|
||||
// json_attributes_path "$.mesh_message", and a null there makes
|
||||
// HA log a "JSON result was not a dictionary" warning every scan.
|
||||
"mesh_message": mesh_message.unwrap_or_else(|| json!({})),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::token_eq;
|
||||
|
||||
#[test]
|
||||
fn token_eq_matches_only_exact() {
|
||||
assert!(token_eq("abc123", "abc123"));
|
||||
assert!(!token_eq("abc123", "abc124"));
|
||||
assert!(!token_eq("abc123", "abc12"));
|
||||
assert!(!token_eq("", "x"));
|
||||
assert!(token_eq("", ""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
use hyper::{Response, StatusCode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct RpcRequest {
|
||||
pub method: String,
|
||||
pub params: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct RpcResponse {
|
||||
pub result: Option<serde_json::Value>,
|
||||
pub error: Option<RpcError>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct RpcError {
|
||||
pub code: i32,
|
||||
pub message: String,
|
||||
pub data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Simple TTL cache for read-only RPC responses.
|
||||
pub(super) struct ResponseCache {
|
||||
entries: tokio::sync::RwLock<
|
||||
std::collections::HashMap<String, (std::time::Instant, serde_json::Value)>,
|
||||
>,
|
||||
ttl: std::time::Duration,
|
||||
}
|
||||
|
||||
impl ResponseCache {
|
||||
pub fn new(ttl_secs: u64) -> Self {
|
||||
Self {
|
||||
entries: tokio::sync::RwLock::new(std::collections::HashMap::new()),
|
||||
ttl: std::time::Duration::from_secs(ttl_secs),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get(&self, key: &str) -> Option<serde_json::Value> {
|
||||
let entries = self.entries.read().await;
|
||||
if let Some((ts, value)) = entries.get(key) {
|
||||
if ts.elapsed() < self.ttl {
|
||||
return Some(value.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn set(&self, key: String, value: serde_json::Value) {
|
||||
let mut entries = self.entries.write().await;
|
||||
entries.insert(key, (std::time::Instant::now(), value));
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a JSON HTTP response without unwrap. Falls back to a plain 500 if builder fails.
|
||||
pub(super) fn json_response(status: StatusCode, body: &[u8]) -> Response<hyper::Body> {
|
||||
Response::builder()
|
||||
.status(status)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(body.to_vec()))
|
||||
.unwrap_or_else(|_| {
|
||||
Response::new(hyper::Body::from(
|
||||
r#"{"error":{"code":500,"message":"Internal error"}}"#,
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a Set-Cookie header value, returning a default if parsing fails.
|
||||
pub(super) fn cookie_header(value: &str) -> hyper::header::HeaderValue {
|
||||
value
|
||||
.parse()
|
||||
.unwrap_or_else(|_| hyper::header::HeaderValue::from_static(""))
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
use super::RpcHandler;
|
||||
use crate::network::router;
|
||||
use anyhow::Result;
|
||||
|
||||
impl RpcHandler {
|
||||
/// Discover UPnP router on the local network.
|
||||
pub(super) async fn handle_router_discover(&self) -> Result<serde_json::Value> {
|
||||
let info = router::discover_router().await?;
|
||||
Ok(serde_json::json!({
|
||||
"discovered": info.discovered,
|
||||
"device_name": info.device_name,
|
||||
"wan_ip": info.wan_ip,
|
||||
"upnp_available": info.upnp_available,
|
||||
}))
|
||||
}
|
||||
|
||||
/// List all configured port forwards.
|
||||
pub(super) async fn handle_router_list_forwards(&self) -> Result<serde_json::Value> {
|
||||
let forwards = router::list_forwards(&self.config.data_dir).await?;
|
||||
let items: Vec<serde_json::Value> = forwards
|
||||
.into_iter()
|
||||
.map(|f| {
|
||||
serde_json::json!({
|
||||
"id": f.id,
|
||||
"service_name": f.service_name,
|
||||
"internal_port": f.internal_port,
|
||||
"external_port": f.external_port,
|
||||
"protocol": f.protocol,
|
||||
"enabled": f.enabled,
|
||||
"created_at": f.created_at,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(serde_json::json!({ "forwards": items }))
|
||||
}
|
||||
|
||||
/// Add a port forward.
|
||||
pub(super) async fn handle_router_add_forward(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let service_name = params
|
||||
.get("service_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing service_name"))?;
|
||||
let internal_port = params
|
||||
.get("internal_port")
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing internal_port"))?
|
||||
as u16;
|
||||
let external_port = params
|
||||
.get("external_port")
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing external_port"))?
|
||||
as u16;
|
||||
let protocol = params
|
||||
.get("protocol")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("TCP");
|
||||
|
||||
let forward = router::add_forward(
|
||||
&self.config.data_dir,
|
||||
service_name,
|
||||
internal_port,
|
||||
external_port,
|
||||
protocol,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"id": forward.id,
|
||||
"service_name": forward.service_name,
|
||||
"external_port": forward.external_port,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Remove a port forward.
|
||||
pub(super) async fn handle_router_remove_forward(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let id = params
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing id"))?;
|
||||
|
||||
router::remove_forward(&self.config.data_dir, id).await?;
|
||||
Ok(serde_json::json!({ "ok": true }))
|
||||
}
|
||||
|
||||
/// Run network diagnostics.
|
||||
pub(super) async fn handle_network_diagnostics(&self) -> Result<serde_json::Value> {
|
||||
let diag = router::run_diagnostics().await?;
|
||||
Ok(serde_json::json!({
|
||||
"wan_ip": diag.wan_ip,
|
||||
"nat_type": diag.nat_type,
|
||||
"upnp_available": diag.upnp_available,
|
||||
"tor_connected": diag.tor_connected,
|
||||
"dns_working": diag.dns_working,
|
||||
"recommendations": diag.recommendations,
|
||||
"wifi_ssid": diag.wifi_ssid,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Detect the type of router at a given gateway address.
|
||||
pub(super) async fn handle_router_detect(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let gateway = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("gateway"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("192.168.1.1");
|
||||
|
||||
let router_type = router::detect_router_type(gateway).await;
|
||||
Ok(serde_json::json!({
|
||||
"gateway": gateway,
|
||||
"router_type": router_type,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Get router info and capabilities.
|
||||
pub(super) async fn handle_router_info(&self) -> Result<serde_json::Value> {
|
||||
router::get_router_info(&self.config.data_dir).await
|
||||
}
|
||||
|
||||
/// Configure router API access.
|
||||
pub(super) async fn handle_router_configure(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let router_type_str = params
|
||||
.get("router_type")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
let address = params
|
||||
.get("address")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing address"))?;
|
||||
let api_key = params.get("api_key").and_then(|v| v.as_str());
|
||||
let username = params.get("username").and_then(|v| v.as_str());
|
||||
let password = params.get("password").and_then(|v| v.as_str());
|
||||
|
||||
let router_type = match router_type_str {
|
||||
"openwrt" => router::RouterType::OpenWrt,
|
||||
"pfsense" => router::RouterType::PfSense,
|
||||
"opnsense" => router::RouterType::OPNsense,
|
||||
"upnp" => router::RouterType::UPnP,
|
||||
_ => router::RouterType::Unknown,
|
||||
};
|
||||
|
||||
let config = router::configure_router(
|
||||
&self.config.data_dir,
|
||||
router_type,
|
||||
address,
|
||||
api_key,
|
||||
username,
|
||||
password,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"configured": config.configured,
|
||||
"router_type": config.router_type,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
use super::package::validate_app_id;
|
||||
use super::RpcHandler;
|
||||
use anyhow::Result;
|
||||
|
||||
impl RpcHandler {
|
||||
pub(super) async fn handle_security_rotate_secrets(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let app_id = params
|
||||
.get("app_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
|
||||
validate_app_id(app_id)?;
|
||||
|
||||
let secrets_dir = self.config.data_dir.join("secrets");
|
||||
let encryption_key = self.get_secrets_key();
|
||||
let mgr = archipelago_security::SecretsManager::new(secrets_dir, encryption_key)?;
|
||||
|
||||
let secret_ids = mgr.list_secrets(app_id).await?;
|
||||
let mut rotated = Vec::new();
|
||||
|
||||
for secret_id in &secret_ids {
|
||||
mgr.rotate_secret(app_id, secret_id).await?;
|
||||
rotated.push(secret_id.clone());
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"app_id": app_id,
|
||||
"rotated_count": rotated.len(),
|
||||
"rotated_ids": rotated,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_security_list_expiring(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let max_age_days = params
|
||||
.get("max_age_days")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(90);
|
||||
|
||||
let secrets_dir = self.config.data_dir.join("secrets");
|
||||
let encryption_key = self.get_secrets_key();
|
||||
let mgr = archipelago_security::SecretsManager::new(secrets_dir, encryption_key)?;
|
||||
|
||||
let expiring = mgr.list_expiring(max_age_days).await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"max_age_days": max_age_days,
|
||||
"expiring_count": expiring.len(),
|
||||
"secrets": expiring,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Derive a 32-byte encryption key for secrets.
|
||||
/// Uses a fixed derivation from the data directory path as a stable key.
|
||||
fn get_secrets_key(&self) -> Vec<u8> {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(b"archipelago-secrets-v1-");
|
||||
hasher.update(self.config.data_dir.to_string_lossy().as_bytes());
|
||||
hasher.finalize().to_vec()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
//! RPC handlers for BIP-39 seed management.
|
||||
//! Endpoints: seed.generate, seed.verify, seed.restore, seed.save-encrypted, seed.status
|
||||
|
||||
use super::RpcHandler;
|
||||
use anyhow::{Context, Result};
|
||||
use nostr_sdk::ToBech32;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use zeroize::Zeroize;
|
||||
|
||||
/// In-memory storage for the mnemonic between generate and verify steps.
|
||||
/// Auto-cleared after 10 minutes.
|
||||
static ONBOARDING_MNEMONIC: std::sync::LazyLock<Arc<Mutex<Option<OnboardingMnemonicState>>>> =
|
||||
std::sync::LazyLock::new(|| Arc::new(Mutex::new(None)));
|
||||
|
||||
struct OnboardingMnemonicState {
|
||||
words: String,
|
||||
created_at: std::time::Instant,
|
||||
}
|
||||
|
||||
impl Drop for OnboardingMnemonicState {
|
||||
fn drop(&mut self) {
|
||||
self.words.zeroize();
|
||||
}
|
||||
}
|
||||
|
||||
const MNEMONIC_TTL: std::time::Duration = std::time::Duration::from_secs(600); // 10 minutes
|
||||
|
||||
/// Persist the pending onboarding mnemonic as `identity/master_seed.enc`,
|
||||
/// encrypted with `passphrase`. Called from `auth.setup` — the first moment a
|
||||
/// user password exists — so "Reveal recovery phrase" works after onboarding
|
||||
/// without the frontend having to remember a separate save step (it never
|
||||
/// did, which left every onboarded node with no encrypted seed backup).
|
||||
///
|
||||
/// Deliberately ignores MNEMONIC_TTL: the mnemonic stays in memory until
|
||||
/// overwritten regardless, so using it here widens nothing, and onboarding
|
||||
/// legitimately takes longer than 10 minutes when the user carefully writes
|
||||
/// down 24 words. Clears the in-memory copy on success — password setup is
|
||||
/// the end of onboarding, so the plaintext no longer needs to linger.
|
||||
///
|
||||
/// Returns Ok(true) if a seed was saved, Ok(false) if none was pending.
|
||||
pub(in crate::api::rpc) async fn save_pending_seed_encrypted(
|
||||
data_dir: &std::path::Path,
|
||||
passphrase: &str,
|
||||
) -> Result<bool> {
|
||||
let mut state = ONBOARDING_MNEMONIC.lock().await;
|
||||
let Some(pending) = state.as_ref() else {
|
||||
return Ok(false);
|
||||
};
|
||||
let mnemonic: bip39::Mnemonic = pending
|
||||
.words
|
||||
.parse()
|
||||
.context("Invalid mnemonic in memory")?;
|
||||
crate::seed::save_seed_encrypted(data_dir, &mnemonic, passphrase).await?;
|
||||
*state = None;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Best-effort: install fips.yaml + start archipelago-fips.service after the
|
||||
/// seed onboarding has written the fips_key to disk. Runs in a detached task
|
||||
/// so the user-facing RPC returns immediately — the systemctl calls can take
|
||||
/// a few seconds the first time on slow hardware. Any failure is logged but
|
||||
/// does not break onboarding; the user can still hit fips.install manually
|
||||
/// from the dashboard as an escape hatch.
|
||||
fn spawn_post_onboarding_fips_activate(data_dir: std::path::PathBuf) {
|
||||
tokio::spawn(async move {
|
||||
let identity_dir = data_dir.join("identity");
|
||||
if !crate::identity::fips_key_exists(&identity_dir) {
|
||||
return;
|
||||
}
|
||||
// Touch load_fips_keys first so any legacy raw-byte file is migrated
|
||||
// to bech32 before we copy it into /etc/fips/.
|
||||
if let Err(e) = crate::identity::load_fips_keys(&identity_dir).await {
|
||||
tracing::warn!("post-onboarding fips key load/migrate failed: {}", e);
|
||||
return;
|
||||
}
|
||||
if let Err(e) = crate::fips::config::install(&identity_dir).await {
|
||||
tracing::warn!("post-onboarding fips config install failed: {}", e);
|
||||
return;
|
||||
}
|
||||
if let Err(e) = crate::fips::service::activate(crate::fips::SERVICE_UNIT).await {
|
||||
tracing::warn!("post-onboarding archipelago-fips activate failed: {}", e);
|
||||
return;
|
||||
}
|
||||
tracing::info!("archipelago-fips auto-activated post-onboarding");
|
||||
});
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// Generate a new 24-word BIP-39 mnemonic, derive and persist node keys.
|
||||
/// Returns the words for the user to write down.
|
||||
pub(in crate::api::rpc) async fn handle_seed_generate(&self) -> Result<serde_json::Value> {
|
||||
// Serialize concurrent / retried generate calls. The web client aborts
|
||||
// at 15s and retries internally (up to 3x), and the onboarding view
|
||||
// re-fires every 4s while the server is still booting on slow first-boot
|
||||
// hardware. Without this guard each hit would mint a brand-new seed and
|
||||
// overwrite the node keys mid-flight, leaving the words shown to the user
|
||||
// out of sync with what `seed.verify` expects — the classic "error at the
|
||||
// DID-creation screen". Holding the lock across the whole op fully
|
||||
// serializes them.
|
||||
let mut state = ONBOARDING_MNEMONIC.lock().await;
|
||||
|
||||
// Idempotent fast-path: a fresh pending mnemonic already exists, so the
|
||||
// node keys are already on disk. Return the SAME words rather than
|
||||
// regenerating, so every retry yields a consistent result.
|
||||
if let Some(existing) = state.as_ref() {
|
||||
if existing.created_at.elapsed() < MNEMONIC_TTL {
|
||||
let words: Vec<String> = existing
|
||||
.words
|
||||
.split_whitespace()
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
return Ok(serde_json::json!({ "words": words }));
|
||||
}
|
||||
}
|
||||
|
||||
let (mnemonic, seed) = crate::seed::MasterSeed::generate()?;
|
||||
|
||||
// Derive and write node Ed25519 key.
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
crate::identity::NodeIdentity::from_seed(&identity_dir, &seed).await?;
|
||||
|
||||
// Derive and write node-level Nostr key.
|
||||
let nostr_keys = crate::seed::derive_node_nostr_key(&seed)?;
|
||||
let nostr_secret_path = identity_dir.join("nostr_secret");
|
||||
let nostr_pub_path = identity_dir.join("nostr_pubkey");
|
||||
let secret_hex = nostr_keys.secret_key().display_secret().to_string();
|
||||
let pubkey_hex = nostr_keys.public_key().to_hex();
|
||||
tokio::fs::write(&nostr_secret_path, secret_hex.as_bytes()).await?;
|
||||
tokio::fs::write(&nostr_pub_path, pubkey_hex.as_bytes()).await?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
tokio::fs::set_permissions(&nostr_secret_path, std::fs::Permissions::from_mode(0o600))
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Initialize identity index at 0.
|
||||
crate::seed::save_identity_index(&self.config.data_dir, 0).await?;
|
||||
|
||||
// fips_key is now on disk — auto-activate archipelago-fips so the
|
||||
// user doesn't have to hit an "Activate" button. Detached task;
|
||||
// the onboarding RPC returns immediately.
|
||||
spawn_post_onboarding_fips_activate(self.config.data_dir.clone());
|
||||
|
||||
let words: Vec<String> = mnemonic.words().map(str::to_string).collect();
|
||||
|
||||
// Hold mnemonic in memory for the verify step. We already own the lock
|
||||
// guard (`state`) from the top of the function, so just write through it.
|
||||
*state = Some(OnboardingMnemonicState {
|
||||
words: mnemonic.to_string(),
|
||||
created_at: std::time::Instant::now(),
|
||||
});
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"words": words,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Verify the user wrote down their seed correctly.
|
||||
/// Also confirms the mnemonic by re-deriving and returning DID + npub.
|
||||
pub(in crate::api::rpc) async fn handle_seed_verify(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let submitted_words: Vec<String> = serde_json::from_value(
|
||||
params
|
||||
.get("words")
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing words"))?,
|
||||
)
|
||||
.context("Invalid words array")?;
|
||||
|
||||
// Validate against the held mnemonic.
|
||||
let mnemonic_str = {
|
||||
let mut state = ONBOARDING_MNEMONIC.lock().await;
|
||||
match state.as_ref() {
|
||||
Some(s) if s.created_at.elapsed() < MNEMONIC_TTL => s.words.clone(),
|
||||
_ => {
|
||||
*state = None;
|
||||
anyhow::bail!(
|
||||
"No pending seed generation or session expired. Please regenerate."
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let expected_words: Vec<&str> = mnemonic_str.split_whitespace().collect();
|
||||
let submitted: Vec<&str> = submitted_words.iter().map(|s| s.as_str()).collect();
|
||||
if expected_words != submitted {
|
||||
anyhow::bail!("Submitted words do not match generated seed");
|
||||
}
|
||||
|
||||
// Re-derive to get DID and npub.
|
||||
let (mnemonic, seed) = crate::seed::MasterSeed::from_mnemonic_words(&mnemonic_str)?;
|
||||
let node_key = crate::seed::derive_node_ed25519(&seed)?;
|
||||
let pubkey_hex = hex::encode(node_key.verifying_key().as_bytes());
|
||||
let did = crate::identity::did_key_from_pubkey_hex(&pubkey_hex)?;
|
||||
|
||||
let nostr_keys = crate::seed::derive_node_nostr_key(&seed)?;
|
||||
let nostr_npub = nostr_keys.public_key().to_bech32().unwrap_or_default();
|
||||
|
||||
// Intentionally DO NOT clear the mnemonic here. The web client aborts
|
||||
// slow requests at 15s and retries internally; if we wiped it on the
|
||||
// first (successful) verify, a retried request would fail with
|
||||
// "No pending seed generation or session expired" even though the user
|
||||
// did everything right. The mnemonic is bounded by MNEMONIC_TTL (10 min)
|
||||
// and is overwritten on the next generate, so leaving it makes verify
|
||||
// idempotent without meaningfully widening the in-memory window.
|
||||
|
||||
// Save the encrypted seed for convenience backup.
|
||||
// Use empty passphrase placeholder — the real encrypted save happens via seed.save-encrypted.
|
||||
// For now we just confirm the mnemonic was valid.
|
||||
drop(mnemonic);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"verified": true,
|
||||
"did": did,
|
||||
"nostr_npub": nostr_npub,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Restore node identity from a 24-word seed phrase.
|
||||
pub(in crate::api::rpc) async fn handle_seed_restore(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let words: Vec<String> = serde_json::from_value(
|
||||
params
|
||||
.get("words")
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing words"))?,
|
||||
)
|
||||
.context("Invalid words array")?;
|
||||
|
||||
let phrase = words.join(" ");
|
||||
let (_mnemonic, seed) = crate::seed::MasterSeed::from_mnemonic_words(&phrase)?;
|
||||
|
||||
// Stash the restored words like seed.generate does, so auth.setup can
|
||||
// persist the encrypted backup once the user's password exists and
|
||||
// "Reveal recovery phrase" works on restored nodes too.
|
||||
{
|
||||
let mut state = ONBOARDING_MNEMONIC.lock().await;
|
||||
*state = Some(OnboardingMnemonicState {
|
||||
words: phrase.clone(),
|
||||
created_at: std::time::Instant::now(),
|
||||
});
|
||||
}
|
||||
|
||||
// Derive and write node Ed25519 key.
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
crate::identity::NodeIdentity::from_seed(&identity_dir, &seed).await?;
|
||||
|
||||
// Derive and write node-level Nostr key.
|
||||
let nostr_keys = crate::seed::derive_node_nostr_key(&seed)?;
|
||||
let secret_hex = nostr_keys.secret_key().display_secret().to_string();
|
||||
let pubkey_hex_nostr = nostr_keys.public_key().to_hex();
|
||||
tokio::fs::write(identity_dir.join("nostr_secret"), secret_hex.as_bytes()).await?;
|
||||
tokio::fs::write(
|
||||
identity_dir.join("nostr_pubkey"),
|
||||
pubkey_hex_nostr.as_bytes(),
|
||||
)
|
||||
.await?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
tokio::fs::set_permissions(
|
||||
identity_dir.join("nostr_secret"),
|
||||
std::fs::Permissions::from_mode(0o600),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Initialize identity index.
|
||||
crate::seed::save_identity_index(&self.config.data_dir, 0).await?;
|
||||
|
||||
// Create default identity from seed.
|
||||
let manager = crate::identity_manager::IdentityManager::new(&self.config.data_dir).await?;
|
||||
manager
|
||||
.create_from_seed(
|
||||
"Personal".to_string(),
|
||||
crate::identity_manager::IdentityPurpose::Personal,
|
||||
&seed,
|
||||
&self.config.data_dir,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Get DID and npub for the response.
|
||||
let node_key = crate::seed::derive_node_ed25519(&seed)?;
|
||||
let pubkey_hex = hex::encode(node_key.verifying_key().as_bytes());
|
||||
let did = crate::identity::did_key_from_pubkey_hex(&pubkey_hex)?;
|
||||
let nostr_npub = nostr_keys.public_key().to_bech32().unwrap_or_default();
|
||||
|
||||
// Same as seed.generate: the key is materialised, kick the FIPS
|
||||
// service up without user interaction.
|
||||
spawn_post_onboarding_fips_activate(self.config.data_dir.clone());
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"did": did,
|
||||
"nostr_npub": nostr_npub,
|
||||
"restored": true,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Encrypt and save the mnemonic to disk for convenience backup.
|
||||
pub(in crate::api::rpc) async fn handle_seed_save_encrypted(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let passphrase = params
|
||||
.get("passphrase")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing passphrase"))?;
|
||||
|
||||
// Try to get mnemonic from in-memory state first.
|
||||
let mnemonic_str = {
|
||||
let state = ONBOARDING_MNEMONIC.lock().await;
|
||||
state
|
||||
.as_ref()
|
||||
.filter(|s| s.created_at.elapsed() < MNEMONIC_TTL)
|
||||
.map(|s| s.words.clone())
|
||||
};
|
||||
|
||||
let mnemonic: bip39::Mnemonic = if let Some(words) = mnemonic_str {
|
||||
words.parse().context("Invalid mnemonic in memory")?
|
||||
} else {
|
||||
anyhow::bail!("No mnemonic available. Generate or restore a seed first.");
|
||||
};
|
||||
|
||||
crate::seed::save_seed_encrypted(&self.config.data_dir, &mnemonic, passphrase).await?;
|
||||
|
||||
Ok(serde_json::json!({ "saved": true }))
|
||||
}
|
||||
|
||||
/// Return seed status information.
|
||||
pub(in crate::api::rpc) async fn handle_seed_status(&self) -> Result<serde_json::Value> {
|
||||
let has_seed = crate::seed::seed_exists(&self.config.data_dir);
|
||||
let has_node_key =
|
||||
crate::identity::NodeIdentity::key_exists(&self.config.data_dir.join("identity"));
|
||||
let is_legacy = has_node_key && !has_seed;
|
||||
let next_index = crate::seed::load_identity_index(&self.config.data_dir)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
let manager = crate::identity_manager::IdentityManager::new(&self.config.data_dir).await?;
|
||||
let (identities, _) = manager.list().await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"has_seed": has_seed,
|
||||
"is_legacy": is_legacy,
|
||||
"identity_count": identities.len(),
|
||||
"next_index": next_index,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Reveal the node's 24-word recovery phrase after onboarding. Heavily
|
||||
/// gated, because this is the keys to the whole node:
|
||||
/// - requires a full authenticated session (enforced upstream: this
|
||||
/// method is NOT in the public auth whitelist),
|
||||
/// - re-verifies the login password,
|
||||
/// - requires a valid TOTP code when 2FA is enabled (replay-protected),
|
||||
/// - decrypts `identity/master_seed.enc` with the backup passphrase
|
||||
/// (defaults to the login password when the user used the same value).
|
||||
/// The words are returned to the caller only and never logged.
|
||||
pub(in crate::api::rpc) async fn handle_seed_reveal(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
|
||||
// Nothing to reveal if this node never stored an encrypted seed.
|
||||
if !crate::seed::seed_exists(&self.config.data_dir) {
|
||||
anyhow::bail!(
|
||||
"This node has no encrypted seed backup, so the recovery phrase \
|
||||
cannot be shown. It was only displayed once during setup."
|
||||
);
|
||||
}
|
||||
|
||||
let mut password = self
|
||||
.verify_reveal_auth(¶ms, "the recovery phrase")
|
||||
.await?;
|
||||
|
||||
// 3) Decrypt the stored seed. The backup passphrase may differ from the
|
||||
// login password, so accept an explicit one and fall back to the
|
||||
// password when the user used the same value for both.
|
||||
let passphrase = params
|
||||
.get("passphrase")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let secret_phrase = passphrase.unwrap_or_else(|| password.clone());
|
||||
let reveal = crate::seed::load_seed_encrypted(&self.config.data_dir, &secret_phrase).await;
|
||||
password.zeroize();
|
||||
let mnemonic = reveal.map_err(|_| {
|
||||
anyhow::anyhow!(
|
||||
"Could not decrypt the saved seed. If you set a separate backup \
|
||||
passphrase during setup, enter that passphrase."
|
||||
)
|
||||
})?;
|
||||
|
||||
let words: Vec<String> = mnemonic.words().map(|w| w.to_string()).collect();
|
||||
let word_count = words.len();
|
||||
Ok(serde_json::json!({ "words": words, "word_count": word_count }))
|
||||
}
|
||||
|
||||
/// Re-authenticate a sensitive reveal: verify the login password from
|
||||
/// `params.password` and, when 2FA is enabled, require a valid
|
||||
/// replay-protected TOTP code from `params.code`. Returns the verified
|
||||
/// password (some callers also use it as a decryption passphrase); the
|
||||
/// caller must zeroize it. `what` names the secret in error messages,
|
||||
/// e.g. "the recovery phrase".
|
||||
pub(in crate::api::rpc) async fn verify_reveal_auth(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
what: &str,
|
||||
) -> Result<String> {
|
||||
let mut password = params
|
||||
.get("password")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if password.is_empty() {
|
||||
anyhow::bail!("Password is required to reveal {what}");
|
||||
}
|
||||
|
||||
if !self.auth_manager.verify_password(&password).await? {
|
||||
password.zeroize();
|
||||
anyhow::bail!("Incorrect password");
|
||||
}
|
||||
|
||||
if self.auth_manager.is_totp_enabled().await.unwrap_or(false) {
|
||||
let code = params
|
||||
.get("code")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if code.is_empty() {
|
||||
password.zeroize();
|
||||
anyhow::bail!("A 2FA code is required to reveal {what}");
|
||||
}
|
||||
let totp_data = self
|
||||
.auth_manager
|
||||
.get_totp_data()
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("2FA is enabled but no TOTP data found"))?;
|
||||
let secret = crate::totp::decrypt_secret(&totp_data, &password)
|
||||
.context("Could not unlock 2FA with this password")?;
|
||||
match crate::totp::verify_code(&secret, &code, &totp_data.used_steps)? {
|
||||
Some(step) => {
|
||||
// Record the used step for replay protection, pruning old ones.
|
||||
let mut data = totp_data;
|
||||
data.used_steps.push(step);
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
let cutoff = (now / 30) - 10; // ~5 minutes
|
||||
data.used_steps.retain(|s| *s > cutoff);
|
||||
let _ = self.auth_manager.update_totp(data).await;
|
||||
}
|
||||
None => {
|
||||
password.zeroize();
|
||||
anyhow::bail!("Invalid 2FA code");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(password)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
//! RPC handlers for streaming ecash payments.
|
||||
//!
|
||||
//! Endpoints for managing priced services, processing payments,
|
||||
//! checking sessions/usage, and publishing service advertisements.
|
||||
|
||||
use super::RpcHandler;
|
||||
use crate::streaming::{advertisement, gate, meter, pricing, session};
|
||||
use crate::wallet::ecash;
|
||||
use anyhow::Result;
|
||||
|
||||
impl RpcHandler {
|
||||
// ── Service pricing management ──
|
||||
|
||||
/// List all configured streaming services and their pricing.
|
||||
pub(super) async fn handle_streaming_list_services(&self) -> Result<serde_json::Value> {
|
||||
let config = pricing::load_pricing(&self.config.data_dir).await?;
|
||||
Ok(serde_json::json!({
|
||||
"services": config.services,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Configure pricing for a streaming service.
|
||||
pub(super) async fn handle_streaming_configure_service(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
|
||||
let service_id = params
|
||||
.get("service_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing service_id"))?;
|
||||
let name = params
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(service_id);
|
||||
let metric_str = params
|
||||
.get("metric")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("requests");
|
||||
let step_size = params
|
||||
.get("step_size")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(1);
|
||||
let price_per_step = params
|
||||
.get("price_per_step")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(1);
|
||||
let min_steps = params
|
||||
.get("min_steps")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
let enabled = params
|
||||
.get("enabled")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(true);
|
||||
let description = params
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let metric = match metric_str {
|
||||
"bytes" => pricing::Metric::Bytes,
|
||||
"milliseconds" | "time" => pricing::Metric::Milliseconds,
|
||||
"requests" => pricing::Metric::Requests,
|
||||
_ => return Err(anyhow::anyhow!("Invalid metric: {}", metric_str)),
|
||||
};
|
||||
|
||||
let accepted_mints: Vec<String> = params
|
||||
.get("accepted_mints")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let service = pricing::ServicePricing {
|
||||
service_id: service_id.to_string(),
|
||||
name: name.to_string(),
|
||||
metric,
|
||||
step_size,
|
||||
price_per_step,
|
||||
min_steps,
|
||||
enabled,
|
||||
description: description.to_string(),
|
||||
accepted_mints,
|
||||
};
|
||||
service.validate()?;
|
||||
|
||||
let mut config = pricing::load_pricing(&self.config.data_dir).await?;
|
||||
|
||||
// Update existing or add new
|
||||
if let Some(existing) = config
|
||||
.services
|
||||
.iter_mut()
|
||||
.find(|s| s.service_id == service_id)
|
||||
{
|
||||
*existing = service.clone();
|
||||
} else {
|
||||
config.services.push(service.clone());
|
||||
}
|
||||
|
||||
pricing::save_pricing(&self.config.data_dir, &config).await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"service": service,
|
||||
"updated": true,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Enable or disable a streaming service.
|
||||
pub(super) async fn handle_streaming_toggle_service(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let service_id = params
|
||||
.get("service_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing service_id"))?;
|
||||
let enabled = params
|
||||
.get("enabled")
|
||||
.and_then(|v| v.as_bool())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing enabled"))?;
|
||||
|
||||
let mut config = pricing::load_pricing(&self.config.data_dir).await?;
|
||||
if let Some(service) = config
|
||||
.services
|
||||
.iter_mut()
|
||||
.find(|s| s.service_id == service_id)
|
||||
{
|
||||
service.enabled = enabled;
|
||||
pricing::save_pricing(&self.config.data_dir, &config).await?;
|
||||
Ok(serde_json::json!({
|
||||
"service_id": service_id,
|
||||
"enabled": enabled,
|
||||
}))
|
||||
} else {
|
||||
Err(anyhow::anyhow!("Service '{}' not found", service_id))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Payment processing ──
|
||||
|
||||
/// Process a streaming payment — submit a Cashu token for a service.
|
||||
/// Returns session details with allotment on success.
|
||||
pub(super) async fn handle_streaming_pay(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let service_id = params
|
||||
.get("service_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing service_id"))?;
|
||||
let token = params
|
||||
.get("token")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing token (cashuA token string)"))?;
|
||||
let peer_id = params
|
||||
.get("peer_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing peer_id"))?;
|
||||
|
||||
if token.is_empty() {
|
||||
return Err(anyhow::anyhow!("Token cannot be empty"));
|
||||
}
|
||||
if peer_id.is_empty() {
|
||||
return Err(anyhow::anyhow!("Peer ID cannot be empty"));
|
||||
}
|
||||
|
||||
let result =
|
||||
gate::check_gate(&self.config.data_dir, peer_id, service_id, Some(token), 0).await?;
|
||||
|
||||
match result {
|
||||
gate::GateResult::PaidAndAllowed {
|
||||
session_id,
|
||||
allotment,
|
||||
paid_sats,
|
||||
} => Ok(serde_json::json!({
|
||||
"status": "paid",
|
||||
"session_id": session_id,
|
||||
"allotment": allotment,
|
||||
"paid_sats": paid_sats,
|
||||
})),
|
||||
gate::GateResult::InsufficientPayment {
|
||||
provided_sats,
|
||||
minimum_sats,
|
||||
} => Ok(serde_json::json!({
|
||||
"status": "insufficient",
|
||||
"error": { "code": "insufficient_payment", "message": format!("Need {} sats, got {}", minimum_sats, provided_sats) },
|
||||
"minimum_sats": minimum_sats,
|
||||
"provided_sats": provided_sats,
|
||||
})),
|
||||
gate::GateResult::PaymentFailed { reason } => Ok(serde_json::json!({
|
||||
"status": "failed",
|
||||
"error": { "code": "payment_failed", "message": reason },
|
||||
})),
|
||||
gate::GateResult::ServiceUnavailable => {
|
||||
Err(anyhow::anyhow!("Service '{}' not available", service_id))
|
||||
}
|
||||
_ => Err(anyhow::anyhow!("Unexpected gate result")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a payment token for a remote seeder (payer side, cross-mint aware).
|
||||
///
|
||||
/// Given the seeder's advertised `accepted_mints` and `price_sats`, builds a
|
||||
/// `cashuA` token denominated in one of those mints — paying directly if we
|
||||
/// already hold the right mint, else auto-swapping into a trusted accepted
|
||||
/// mint (within `max_fee_sats`). If the price is over `budget_sats`, the
|
||||
/// wallet can't cover it, or the swap is too costly, returns `declined` so
|
||||
/// the caller falls back to the free origin (origin always wins).
|
||||
pub(super) async fn handle_streaming_prepare_payment(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let accepted_mints: Vec<String> = params
|
||||
.get("accepted_mints")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let price_sats = params
|
||||
.get("price_sats")
|
||||
.or_else(|| params.get("amount_sats"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing price_sats"))?;
|
||||
// Default budget = the asked price (willing to pay exactly what's quoted).
|
||||
let budget_sats = params
|
||||
.get("budget_sats")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(price_sats);
|
||||
let max_fee_sats = params
|
||||
.get("max_fee_sats")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
|
||||
let policy = crate::swarm::payment::PaymentPolicy::with_budget(budget_sats, max_fee_sats);
|
||||
match crate::swarm::payment::auto_pay_token(
|
||||
&self.config.data_dir,
|
||||
&policy,
|
||||
&accepted_mints,
|
||||
price_sats,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Some(token) => Ok(serde_json::json!({
|
||||
"status": "ready",
|
||||
"token": token,
|
||||
"paid_sats": price_sats,
|
||||
})),
|
||||
None => Ok(serde_json::json!({
|
||||
"status": "declined",
|
||||
"message": "payment declined (over budget, unpayable, or swap too costly) — use free origin",
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Discover available streaming services (pricing info).
|
||||
/// This is the unauthenticated discovery endpoint.
|
||||
pub(super) async fn handle_streaming_discover(&self) -> Result<serde_json::Value> {
|
||||
let config = pricing::load_pricing(&self.config.data_dir).await?;
|
||||
let accepted_mints = ecash::load_accepted_mints(&self.config.data_dir).await?;
|
||||
|
||||
let services: Vec<serde_json::Value> = config
|
||||
.services
|
||||
.iter()
|
||||
.filter(|s| s.enabled)
|
||||
.map(|s| {
|
||||
let mints = if s.accepted_mints.is_empty() {
|
||||
&accepted_mints.mints
|
||||
} else {
|
||||
&s.accepted_mints
|
||||
};
|
||||
serde_json::json!({
|
||||
"service_id": s.service_id,
|
||||
"name": s.name,
|
||||
"description": s.description,
|
||||
"metric": s.metric,
|
||||
"step_size": s.step_size,
|
||||
"price_per_step": s.price_per_step,
|
||||
"min_steps": s.min_steps,
|
||||
"minimum_sats": s.minimum_payment(),
|
||||
"accepted_mints": mints,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"services": services,
|
||||
}))
|
||||
}
|
||||
|
||||
// ── Session management ──
|
||||
|
||||
/// Check usage for a peer's active session.
|
||||
pub(super) async fn handle_streaming_usage(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let peer_id = params
|
||||
.get("peer_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing peer_id"))?;
|
||||
let service_id = params
|
||||
.get("service_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing service_id"))?;
|
||||
|
||||
match meter::get_peer_usage(&self.config.data_dir, peer_id, service_id).await? {
|
||||
Some(usage) => Ok(serde_json::json!({ "usage": usage })),
|
||||
None => Ok(serde_json::json!({
|
||||
"usage": null,
|
||||
"message": "No active session",
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get details of a specific session by ID.
|
||||
pub(super) async fn handle_streaming_session(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let session_id = params
|
||||
.get("session_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing session_id"))?;
|
||||
|
||||
let store = session::load_sessions(&self.config.data_dir).await?;
|
||||
match store.get(session_id) {
|
||||
Some(s) => Ok(serde_json::json!({ "session": s })),
|
||||
None => Err(anyhow::anyhow!("Session not found")),
|
||||
}
|
||||
}
|
||||
|
||||
/// List all active streaming sessions (admin view).
|
||||
pub(super) async fn handle_streaming_list_sessions(&self) -> Result<serde_json::Value> {
|
||||
let store = session::load_sessions(&self.config.data_dir).await?;
|
||||
let active = store.active_sessions();
|
||||
let revenue = store.total_revenue();
|
||||
let by_service = store.revenue_by_service();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"sessions": active,
|
||||
"total_active": active.len(),
|
||||
"total_revenue_sats": revenue,
|
||||
"revenue_by_service": by_service,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Close a specific session.
|
||||
pub(super) async fn handle_streaming_close_session(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let session_id = params
|
||||
.get("session_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing session_id"))?;
|
||||
|
||||
let mut store = session::load_sessions(&self.config.data_dir).await?;
|
||||
if let Some(s) = store.get_mut(session_id) {
|
||||
s.close();
|
||||
session::save_sessions(&self.config.data_dir, &store).await?;
|
||||
Ok(serde_json::json!({ "closed": true }))
|
||||
} else {
|
||||
Err(anyhow::anyhow!("Session not found"))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Advertisement ──
|
||||
|
||||
/// Publish a streaming service advertisement to Nostr relays.
|
||||
pub(super) async fn handle_streaming_advertise(&self) -> Result<serde_json::Value> {
|
||||
let config = pricing::load_pricing(&self.config.data_dir).await?;
|
||||
let accepted_mints = ecash::load_accepted_mints(&self.config.data_dir).await?;
|
||||
|
||||
let enabled_count = config.services.iter().filter(|s| s.enabled).count();
|
||||
if enabled_count == 0 {
|
||||
return Err(anyhow::anyhow!("No enabled services to advertise"));
|
||||
}
|
||||
|
||||
// Get node's onion address for the endpoint tag
|
||||
let onion = crate::container::docker_packages::read_tor_address("archipelago").await;
|
||||
|
||||
let tags = advertisement::build_advertisement_tags(
|
||||
&config,
|
||||
&accepted_mints.mints,
|
||||
onion.as_deref(),
|
||||
);
|
||||
let content = advertisement::build_advertisement_content(&config);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"kind": advertisement::KIND_SERVICE_ADVERTISEMENT,
|
||||
"content": content,
|
||||
"tags": tags,
|
||||
"services_count": enabled_count,
|
||||
"ready_to_publish": true,
|
||||
}))
|
||||
}
|
||||
|
||||
// ── Accepted mints management ──
|
||||
|
||||
/// List accepted mints for streaming payments.
|
||||
pub(super) async fn handle_streaming_list_mints(&self) -> Result<serde_json::Value> {
|
||||
let mints = ecash::load_accepted_mints(&self.config.data_dir).await?;
|
||||
Ok(serde_json::json!({ "mints": mints.mints }))
|
||||
}
|
||||
|
||||
/// Add or remove accepted mints.
|
||||
pub(super) async fn handle_streaming_configure_mints(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let mints = params
|
||||
.get("mints")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing mints array"))?;
|
||||
|
||||
let mint_urls: Vec<String> = mints
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect();
|
||||
|
||||
if mint_urls.is_empty() {
|
||||
return Err(anyhow::anyhow!("Must have at least one accepted mint"));
|
||||
}
|
||||
|
||||
// Basic validation
|
||||
for url in &mint_urls {
|
||||
if !url.starts_with("http://") && !url.starts_with("https://") {
|
||||
return Err(anyhow::anyhow!("Invalid mint URL: {}", url));
|
||||
}
|
||||
}
|
||||
|
||||
let config = ecash::AcceptedMints {
|
||||
mints: mint_urls.clone(),
|
||||
};
|
||||
ecash::save_accepted_mints(&self.config.data_dir, &config).await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"mints": mint_urls,
|
||||
"updated": true,
|
||||
}))
|
||||
}
|
||||
|
||||
// ── Maintenance ──
|
||||
|
||||
/// Run streaming maintenance (close expired sessions, prune old records).
|
||||
pub(super) async fn handle_streaming_maintenance(&self) -> Result<serde_json::Value> {
|
||||
let closed = meter::maintenance(&self.config.data_dir).await?;
|
||||
Ok(serde_json::json!({
|
||||
"expired_closed": closed,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,820 @@
|
||||
use super::*;
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
impl RpcHandler {
|
||||
/// server.set-name — Rename the server (persisted to data_dir/server-name)
|
||||
pub(in crate::api::rpc) async fn handle_server_set_name(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let name = params
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: name"))?
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
if name.is_empty() || name.len() > 64 {
|
||||
anyhow::bail!("Name must be 1-64 characters");
|
||||
}
|
||||
|
||||
// Persist to file
|
||||
let name_file = self.config.data_dir.join("server-name");
|
||||
tokio::fs::write(&name_file, &name)
|
||||
.await
|
||||
.context("Failed to write server name")?;
|
||||
|
||||
// Update live state
|
||||
let (mut data, _) = self.state_manager.get_snapshot().await;
|
||||
data.server_info.name = Some(name.clone());
|
||||
self.state_manager.update_data(data).await;
|
||||
|
||||
let hostname = hostname_from_server_name(&name);
|
||||
let hostname_result = set_system_hostname(&hostname).await;
|
||||
let (hostname_updated, hostname_error) = match hostname_result {
|
||||
Ok(()) => (true, None),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
name = %name,
|
||||
hostname = %hostname,
|
||||
"Server name persisted but OS hostname update failed: {}",
|
||||
e
|
||||
);
|
||||
(false, Some(e.to_string()))
|
||||
}
|
||||
};
|
||||
|
||||
// Keep the self-signed HTTPS cert's SAN in sync with the new hostname —
|
||||
// best-effort, never blocks the rename itself. Without this the cert
|
||||
// stays pinned to whatever name was set at install time, so browsers
|
||||
// hit a hostname-mismatch warning on top of the usual self-signed one
|
||||
// the moment a node is renamed.
|
||||
if hostname_updated {
|
||||
sync_hostname_side_effects(&hostname).await;
|
||||
if let Err(e) = regenerate_tls_cert(&hostname).await {
|
||||
warn!(hostname = %hostname, "TLS cert regen after rename failed: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
info!("Server name updated to: {}", name);
|
||||
|
||||
// Push the new name to federation peers in background
|
||||
let data_dir = self.config.data_dir.clone();
|
||||
let state_manager = self.state_manager.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = push_name_to_peers(&data_dir, &state_manager).await {
|
||||
debug!("Federation name push (non-fatal): {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"name": name,
|
||||
"hostname": hostname,
|
||||
"hostname_updated": hostname_updated,
|
||||
"hostname_error": hostname_error,
|
||||
}))
|
||||
}
|
||||
|
||||
/// server.set-location — Set this node's own lat/lon + whether to share
|
||||
/// it with trusted federation peers (for the Mesh Map). `lat`/`lon` are
|
||||
/// optional so a caller can flip `share` off without clearing the saved
|
||||
/// position, or clear the position by passing nulls.
|
||||
pub(in crate::api::rpc) async fn handle_server_set_location(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let lat = params.get("lat").and_then(|v| v.as_f64());
|
||||
let lon = params.get("lon").and_then(|v| v.as_f64());
|
||||
let share_location = params
|
||||
.get("share")
|
||||
.and_then(|v| v.as_bool())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: share"))?;
|
||||
|
||||
if let (Some(lat), Some(lon)) = (lat, lon) {
|
||||
if !(-90.0..=90.0).contains(&lat) || !(-180.0..=180.0).contains(&lon) {
|
||||
anyhow::bail!("Invalid lat/lon");
|
||||
}
|
||||
}
|
||||
|
||||
let location_file = self.config.data_dir.join("server-location.json");
|
||||
let payload =
|
||||
serde_json::json!({ "lat": lat, "lon": lon, "share_location": share_location });
|
||||
tokio::fs::write(&location_file, serde_json::to_vec(&payload)?)
|
||||
.await
|
||||
.context("Failed to write server location")?;
|
||||
|
||||
let (mut data, _) = self.state_manager.get_snapshot().await;
|
||||
data.server_info.lat = lat;
|
||||
data.server_info.lon = lon;
|
||||
data.server_info.share_location = share_location;
|
||||
self.state_manager.update_data(data).await;
|
||||
|
||||
info!(share_location, "Server location updated");
|
||||
|
||||
// Push the new location to federation peers in background, same as
|
||||
// a rename — trusted peers' next state sync picks it up.
|
||||
let data_dir = self.config.data_dir.clone();
|
||||
let state_manager = self.state_manager.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = push_name_to_peers(&data_dir, &state_manager).await {
|
||||
debug!("Federation location push (non-fatal): {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(serde_json::json!({ "lat": lat, "lon": lon, "share_location": share_location }))
|
||||
}
|
||||
|
||||
/// system.get-hostname — Current OS hostname + the mDNS `.local` name it
|
||||
/// resolves to on the LAN (avahi-daemon advertises `<hostname>.local`).
|
||||
/// Lets Settings show users where to reach this node over HTTPS for
|
||||
/// features (mic/camera access) that require a secure context.
|
||||
pub(in crate::api::rpc) async fn handle_system_get_hostname(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
let hostname = tokio::fs::read_to_string("/etc/hostname")
|
||||
.await
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|_| "archipelago".to_string());
|
||||
// LAN IPv4 rides along for the companion pairing QR: when the
|
||||
// operator's browser reaches this node over Tailscale/VPN or
|
||||
// localhost, that origin is useless to a phone on the LAN — the QR
|
||||
// must advertise an address the phone can actually dial
|
||||
// (2026-07-22: a pairing QR carried a tailnet 100.x IP and the
|
||||
// companion could never connect).
|
||||
let lan_ip = crate::host_ip::primary_host_ipv4().await;
|
||||
Ok(serde_json::json!({
|
||||
"hostname": hostname,
|
||||
"mdns_hostname": format!("{hostname}.local"),
|
||||
"lan_ip": lan_ip,
|
||||
}))
|
||||
}
|
||||
|
||||
/// system.stats — CPU usage, RAM used/total, disk used/total, uptime, load average
|
||||
pub(in crate::api::rpc) async fn handle_system_stats(&self) -> Result<serde_json::Value> {
|
||||
debug!("Getting system stats");
|
||||
|
||||
let uptime = read_uptime().await.unwrap_or(0.0);
|
||||
let load = read_loadavg().await.unwrap_or((0.0, 0.0, 0.0));
|
||||
let cpu = read_cpu_usage().await.unwrap_or(0.0);
|
||||
let (mem_used, mem_total) = read_meminfo().await.unwrap_or((0, 0));
|
||||
// Prefer encrypted data partition if it exists
|
||||
let data_path = std::path::Path::new("/var/lib/archipelago");
|
||||
let df_target = if data_path.exists() {
|
||||
"/var/lib/archipelago"
|
||||
} else {
|
||||
"/"
|
||||
};
|
||||
let (disk_used, disk_total) = read_disk_usage_path(df_target).await.unwrap_or((0, 0));
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"uptime_secs": uptime as u64,
|
||||
"load_avg_1": load.0,
|
||||
"load_avg_5": load.1,
|
||||
"load_avg_15": load.2,
|
||||
"cpu_usage_percent": cpu,
|
||||
"mem_used_bytes": mem_used,
|
||||
"mem_total_bytes": mem_total,
|
||||
"disk_used_bytes": disk_used,
|
||||
"disk_total_bytes": disk_total,
|
||||
}))
|
||||
}
|
||||
|
||||
/// system.processes — top 10 processes by CPU
|
||||
pub(in crate::api::rpc) async fn handle_system_processes(&self) -> Result<serde_json::Value> {
|
||||
debug!("Getting top processes");
|
||||
|
||||
let procs = read_top_processes().await.unwrap_or_default();
|
||||
|
||||
Ok(serde_json::json!({ "processes": procs }))
|
||||
}
|
||||
|
||||
/// system.temperature — thermal zone readings
|
||||
pub(in crate::api::rpc) async fn handle_system_temperature(&self) -> Result<serde_json::Value> {
|
||||
debug!("Getting system temperature");
|
||||
|
||||
let temps = read_temperatures().await.unwrap_or_default();
|
||||
|
||||
Ok(serde_json::json!({ "temperatures": temps }))
|
||||
}
|
||||
|
||||
/// system.detect-usb-devices — scan for known hardware wallet USB devices
|
||||
pub(in crate::api::rpc) async fn handle_system_detect_usb_devices(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
debug!("Scanning for USB hardware wallets");
|
||||
|
||||
let devices = detect_usb_hardware_wallets().await.unwrap_or_default();
|
||||
|
||||
Ok(serde_json::json!({ "devices": devices }))
|
||||
}
|
||||
|
||||
/// system.disk-status — Disk usage with warning/critical thresholds.
|
||||
pub(in crate::api::rpc) async fn handle_system_disk_status(&self) -> Result<serde_json::Value> {
|
||||
// Prefer the encrypted data partition if it exists
|
||||
let data_path = std::path::Path::new("/var/lib/archipelago");
|
||||
let df_target = if data_path.exists() {
|
||||
"/var/lib/archipelago"
|
||||
} else {
|
||||
"/"
|
||||
};
|
||||
|
||||
let (used, total) = read_disk_usage_path(df_target).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"
|
||||
};
|
||||
|
||||
// Detect LUKS encryption (device name varies by install)
|
||||
let encrypted = std::path::Path::new("/dev/mapper/archipelago_crypt").exists()
|
||||
|| std::path::Path::new("/dev/mapper/archipelago-data").exists()
|
||||
|| std::path::Path::new("/dev/mapper/archipelago_data").exists();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"used_bytes": used,
|
||||
"total_bytes": total,
|
||||
"free_bytes": total.saturating_sub(used),
|
||||
"used_percent": percent_rounded,
|
||||
"level": level,
|
||||
"encrypted": encrypted,
|
||||
"partition": df_target,
|
||||
}))
|
||||
}
|
||||
|
||||
/// system.disk-cleanup — Remove old container images, stale logs, and temp files.
|
||||
pub(in crate::api::rpc) async fn handle_system_disk_cleanup(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
tracing::info!("Starting disk cleanup");
|
||||
let mut freed_bytes: u64 = 0;
|
||||
let mut actions: Vec<String> = Vec::new();
|
||||
|
||||
// 1. 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)),
|
||||
}
|
||||
|
||||
match vacuum_journal_logs("200M").await {
|
||||
Ok(bytes) => {
|
||||
if bytes > 0 {
|
||||
freed_bytes += bytes;
|
||||
actions.push(format!(
|
||||
"Vacuumed journal logs: {} freed",
|
||||
format_bytes(bytes)
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(e) => actions.push(format!("Journal cleanup failed: {}", e)),
|
||||
}
|
||||
|
||||
// 2. 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)),
|
||||
}
|
||||
|
||||
// 3. Keep only the most recent backend deploy backups. These are useful
|
||||
// for rollback, but a long-lived alpha node can accumulate gigabytes of
|
||||
// old binaries under /usr/local/bin.
|
||||
match clean_backend_backups(3).await {
|
||||
Ok(bytes) => {
|
||||
if bytes > 0 {
|
||||
freed_bytes += bytes;
|
||||
actions.push(format!(
|
||||
"Removed old backend backups: {} freed",
|
||||
format_bytes(bytes)
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(e) => actions.push(format!("Backend backup cleanup failed: {}", e)),
|
||||
}
|
||||
|
||||
match clean_legacy_backend_backups(3).await {
|
||||
Ok(bytes) => {
|
||||
if bytes > 0 {
|
||||
freed_bytes += bytes;
|
||||
actions.push(format!(
|
||||
"Removed old legacy backend backups: {} freed",
|
||||
format_bytes(bytes)
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(e) => actions.push(format!("Legacy backend backup cleanup failed: {}", e)),
|
||||
}
|
||||
|
||||
match clean_web_ui_backups(3).await {
|
||||
Ok(bytes) => {
|
||||
if bytes > 0 {
|
||||
freed_bytes += bytes;
|
||||
actions.push(format!(
|
||||
"Removed old web UI backups: {} freed",
|
||||
format_bytes(bytes)
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(e) => actions.push(format!("Web UI backup cleanup failed: {}", e)),
|
||||
}
|
||||
|
||||
actions.push(
|
||||
"Skipped Podman image/volume prune: Podman store commands can block app health on busy nodes"
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
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,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn hostname_from_server_name(name: &str) -> String {
|
||||
let mut hostname = String::with_capacity(name.len());
|
||||
let mut previous_dash = false;
|
||||
|
||||
for c in name.trim().chars().flat_map(char::to_lowercase) {
|
||||
let valid = c.is_ascii_lowercase() || c.is_ascii_digit();
|
||||
if valid {
|
||||
hostname.push(c);
|
||||
previous_dash = false;
|
||||
} else if !previous_dash {
|
||||
hostname.push('-');
|
||||
previous_dash = true;
|
||||
}
|
||||
if hostname.len() >= 63 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let hostname = hostname.trim_matches('-').to_string();
|
||||
if hostname.is_empty() {
|
||||
"archipelago".to_string()
|
||||
} else {
|
||||
hostname
|
||||
}
|
||||
}
|
||||
|
||||
/// Post-rename side effects that keep the OS consistent with the new
|
||||
/// hostname — all best-effort, the rename itself has already succeeded.
|
||||
/// Debian resolves the local hostname via the 127.0.1.1 line in /etc/hosts;
|
||||
/// leaving the old name there breaks `sudo` ("unable to resolve host") and
|
||||
/// `hostname -f`. And while avahi eventually follows the kernel hostname,
|
||||
/// re-announcing immediately makes http(s)://<hostname>.local links work
|
||||
/// right after the rename instead of minutes later.
|
||||
async fn sync_hostname_side_effects(hostname: &str) {
|
||||
// hostname_from_server_name guarantees [a-z0-9-], safe to interpolate.
|
||||
let script = format!(
|
||||
"if grep -q '^127\\.0\\.1\\.1' /etc/hosts; then sed -i 's/^127\\.0\\.1\\.1.*/127.0.1.1\\t{h}/' /etc/hosts; else printf '127.0.1.1\\t{h}\\n' >> /etc/hosts; fi",
|
||||
h = hostname
|
||||
);
|
||||
match tokio::process::Command::new("/usr/bin/sudo")
|
||||
.args(["-n", "/bin/sh", "-c", &script])
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
Ok(o) if o.status.success() => {}
|
||||
Ok(o) => warn!(
|
||||
"/etc/hosts hostname sync failed: {}",
|
||||
String::from_utf8_lossy(&o.stderr).trim()
|
||||
),
|
||||
Err(e) => warn!("/etc/hosts hostname sync failed: {}", e),
|
||||
}
|
||||
|
||||
// The kiosk Chromium's profile lock is a symlink encoding <hostname>-<pid>;
|
||||
// after a rename the stale lock reads as "another computer" holding the
|
||||
// profile, Chromium refuses to start (--noerrdialogs hides the dialog), and
|
||||
// the kiosk black-screens on the next boot (#98). Clear it here — Chromium
|
||||
// recreates the files on launch, and the kiosk launcher pkills any running
|
||||
// instance before starting a new one.
|
||||
for f in ["SingletonLock", "SingletonCookie", "SingletonSocket"] {
|
||||
let _ = tokio::fs::remove_file(format!("/var/lib/archipelago/chromium-kiosk/{f}")).await;
|
||||
}
|
||||
|
||||
let republished = tokio::process::Command::new("/usr/bin/sudo")
|
||||
.args(["-n", "/usr/bin/avahi-set-host-name", hostname])
|
||||
.output()
|
||||
.await
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false);
|
||||
if !republished {
|
||||
let _ = tokio::process::Command::new("/usr/bin/sudo")
|
||||
.args(["-n", "/usr/bin/systemctl", "try-restart", "avahi-daemon"])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_system_hostname(hostname: &str) -> Result<()> {
|
||||
let output = tokio::process::Command::new("/usr/bin/sudo")
|
||||
.args(["-n", "/usr/bin/hostnamectl", "set-hostname", hostname])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to run hostnamectl")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
anyhow::bail!(
|
||||
"{}",
|
||||
if stderr.is_empty() {
|
||||
"hostnamectl failed".to_string()
|
||||
} else {
|
||||
stderr
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Regenerate the self-signed HTTPS cert (`/etc/archipelago/ssl/archipelago.{crt,key}`)
|
||||
/// with a SAN covering `hostname`, `hostname.local`, `localhost`, and 127.0.0.1, then
|
||||
/// reload nginx so it picks up the new cert. Still self-signed (browsers will warn
|
||||
/// on first visit regardless), but avoids stacking a hostname-mismatch warning on
|
||||
/// top once a node has been renamed away from the install-time default.
|
||||
async fn regenerate_tls_cert(hostname: &str) -> Result<()> {
|
||||
let subj = format!("/C=XX/ST=Bitcoin/L=Node/O=Archipelago/CN={hostname}");
|
||||
let san =
|
||||
format!("subjectAltName=DNS:{hostname},DNS:{hostname}.local,DNS:localhost,IP:127.0.0.1");
|
||||
let output = tokio::process::Command::new("/usr/bin/sudo")
|
||||
.args([
|
||||
"-n",
|
||||
"/usr/bin/openssl",
|
||||
"req",
|
||||
"-x509",
|
||||
"-nodes",
|
||||
"-days",
|
||||
"3650",
|
||||
"-newkey",
|
||||
"rsa:2048",
|
||||
"-keyout",
|
||||
"/etc/archipelago/ssl/archipelago.key",
|
||||
"-out",
|
||||
"/etc/archipelago/ssl/archipelago.crt",
|
||||
"-subj",
|
||||
&subj,
|
||||
"-addext",
|
||||
&san,
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to run openssl")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
anyhow::bail!(
|
||||
"{}",
|
||||
if stderr.is_empty() {
|
||||
"openssl cert regen failed".to_string()
|
||||
} else {
|
||||
stderr
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
let reload = tokio::process::Command::new("/usr/bin/sudo")
|
||||
.args(["-n", "/usr/bin/systemctl", "reload", "nginx"])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to reload nginx")?;
|
||||
if !reload.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&reload.stderr).trim().to_string();
|
||||
anyhow::bail!("nginx reload failed: {}", stderr);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// system.factory-reset — Wipe all user data, remove containers, and restart.
|
||||
/// Only preserves the data_dir itself (recreated empty on restart).
|
||||
/// system.reboot — Reboot the machine. Requires password re-verification.
|
||||
pub(in crate::api::rpc) async fn handle_system_reboot(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let password = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("password"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing password — re-authentication required"))?;
|
||||
|
||||
let valid = self.auth_manager.verify_password(password).await?;
|
||||
if !valid {
|
||||
return Err(anyhow::anyhow!("Password incorrect"));
|
||||
}
|
||||
|
||||
info!("System reboot initiated by user");
|
||||
|
||||
// Schedule reboot in 2 seconds (gives time for the RPC response to reach the client)
|
||||
// Uses the tor-helper path unit pattern (writes action file, systemd triggers root service)
|
||||
tokio::spawn(async {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
let action = serde_json::json!({"action": "reboot"});
|
||||
let _ = tokio::fs::write(
|
||||
"/var/lib/archipelago/tor-config/tor-action",
|
||||
serde_json::to_string(&action).unwrap_or_default(),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
Ok(serde_json::json!({ "rebooting": true }))
|
||||
}
|
||||
|
||||
pub(in crate::api::rpc) async fn handle_system_factory_reset(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
// Safety check: require { confirm: true }
|
||||
let confirmed = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("confirm"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
if !confirmed {
|
||||
anyhow::bail!("Factory reset requires {{ \"confirm\": true }}");
|
||||
}
|
||||
|
||||
// Require password re-authentication for destructive operations
|
||||
let password = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("password"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing password — re-authentication required"))?;
|
||||
|
||||
let valid = self.auth_manager.verify_password(password).await?;
|
||||
if !valid {
|
||||
return Err(anyhow::anyhow!("Password Incorrect"));
|
||||
}
|
||||
|
||||
tracing::warn!("Factory reset initiated — wiping ALL user data and containers");
|
||||
|
||||
let data_dir = &self.config.data_dir;
|
||||
|
||||
// 1. Stop and remove ALL containers (force)
|
||||
let client = archipelago_container::PodmanClient::new("archipelago".to_string());
|
||||
if let Ok(containers) = client.list_containers().await {
|
||||
for c in &containers {
|
||||
tracing::info!("Factory reset: removing container {}", c.name);
|
||||
let _ = client.stop_container(&c.name).await;
|
||||
let _ = client.remove_container(&c.name).await;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Remove all container images
|
||||
tracing::info!("Factory reset: pruning all container images");
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["rmi", "--all", "--force"])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
// 3. Prune volumes and build cache
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["volume", "prune", "-f"])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["system", "prune", "-af"])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
// 4. Wipe the entire data directory contents
|
||||
// Delete everything inside data_dir, then recreate the empty dir.
|
||||
if let Ok(mut entries) = tokio::fs::read_dir(data_dir).await {
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
let path = entry.path();
|
||||
let name = entry.file_name();
|
||||
let name_str = name.to_string_lossy();
|
||||
|
||||
// Skip the tor directory (managed by system debian-tor user)
|
||||
if name_str == "tor" {
|
||||
continue;
|
||||
}
|
||||
|
||||
tracing::info!("Factory reset: removing {}", path.display());
|
||||
if path.is_dir() {
|
||||
let _ = tokio::fs::remove_dir_all(&path).await;
|
||||
} else {
|
||||
let _ = tokio::fs::remove_file(&path).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Clear all sessions
|
||||
self.session_store.invalidate_all_except("").await;
|
||||
|
||||
tracing::warn!("Factory reset complete — all data wiped, restarting service");
|
||||
|
||||
// Restart the service via systemd
|
||||
tokio::spawn(async {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["systemctl", "restart", "archipelago"])
|
||||
.spawn();
|
||||
});
|
||||
|
||||
Ok(serde_json::json!({ "status": "resetting" }))
|
||||
}
|
||||
|
||||
/// system.settings.get — Read a settings value
|
||||
pub(in crate::api::rpc) async fn handle_system_settings_get(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let key = params
|
||||
.get("key")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing key"))?;
|
||||
|
||||
match key {
|
||||
"claude_api_key_set" => {
|
||||
let key_file = self.config.data_dir.join("secrets/claude-api-key");
|
||||
let has_key = tokio::fs::metadata(&key_file).await.is_ok();
|
||||
Ok(serde_json::json!({ "value": has_key }))
|
||||
}
|
||||
_ => Ok(serde_json::json!({ "value": null })),
|
||||
}
|
||||
}
|
||||
|
||||
/// system.settings.set — Write a settings value
|
||||
pub(in crate::api::rpc) async fn handle_system_settings_set(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let key = params
|
||||
.get("key")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing key"))?;
|
||||
let value = params.get("value").and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
match key {
|
||||
"claude_api_key" => {
|
||||
let secrets_dir = self.config.data_dir.join("secrets");
|
||||
tokio::fs::create_dir_all(&secrets_dir)
|
||||
.await
|
||||
.context("Failed to create secrets dir")?;
|
||||
let key_file = secrets_dir.join("claude-api-key");
|
||||
|
||||
if value.is_empty() {
|
||||
// Remove key
|
||||
tokio::fs::remove_file(&key_file).await.ok();
|
||||
info!("Claude API key removed");
|
||||
} else {
|
||||
// Save key
|
||||
tokio::fs::write(&key_file, value)
|
||||
.await
|
||||
.context("Failed to write API key")?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&key_file, std::fs::Permissions::from_mode(0o600))
|
||||
.ok();
|
||||
}
|
||||
info!("Claude API key saved");
|
||||
}
|
||||
|
||||
// Update the claude-api-proxy environment and restart
|
||||
let env_line = format!("ANTHROPIC_API_KEY={}", value);
|
||||
let env_file = self.config.data_dir.join("secrets/claude-api-proxy.env");
|
||||
tokio::fs::write(&env_file, &env_line).await.ok();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&env_file, std::fs::Permissions::from_mode(0o600))
|
||||
.ok();
|
||||
}
|
||||
|
||||
// Restart the proxy to pick up the new key
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["systemctl", "restart", "claude-api-proxy"])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
Ok(serde_json::json!({ "saved": true }))
|
||||
}
|
||||
_ => anyhow::bail!("Unknown setting: {}", key),
|
||||
}
|
||||
}
|
||||
|
||||
/// system.kiosk-display.get — Current kiosk display preset + whether this
|
||||
/// node has a kiosk at all (no kiosk unit -> the Settings section hides).
|
||||
pub(in crate::api::rpc) async fn handle_system_kiosk_display_get(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
let has_kiosk = tokio::fs::metadata("/etc/systemd/system/archipelago-kiosk.service")
|
||||
.await
|
||||
.is_ok();
|
||||
let conf = tokio::fs::read_to_string(KIOSK_DISPLAY_CONF)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let preset = if conf.contains("ARCHIPELAGO_KIOSK_SCALE=1") {
|
||||
"native"
|
||||
} else if conf.contains("ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH=1920") {
|
||||
"balanced"
|
||||
} else if conf.contains("ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH=1280") {
|
||||
"large"
|
||||
} else {
|
||||
"auto"
|
||||
};
|
||||
Ok(serde_json::json!({ "has_kiosk": has_kiosk, "preset": preset }))
|
||||
}
|
||||
|
||||
/// system.kiosk-display.set — Write the kiosk display preset and restart
|
||||
/// the kiosk (only if it is running) so it takes effect immediately. The
|
||||
/// launcher sources /etc/archipelago/kiosk-display.conf at startup.
|
||||
pub(in crate::api::rpc) async fn handle_system_kiosk_display_set(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let preset = params
|
||||
.get("preset")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing preset"))?;
|
||||
|
||||
let conf = match preset {
|
||||
// Resolution-derived default: 4K -> 2.0 (1920-wide layout),
|
||||
// 1080p TV -> 1.5, laptop panels -> 1.0.
|
||||
"auto" => String::new(),
|
||||
// Biggest UI: every panel targets a 1280-wide layout.
|
||||
"large" => "ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH=1280\n".to_string(),
|
||||
// Full-HD layout on any panel that can carry it.
|
||||
"balanced" => "ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH=1920\n".to_string(),
|
||||
// No scaling: native CSS viewport, most content, smallest UI.
|
||||
"native" => "ARCHIPELAGO_KIOSK_SCALE=1\n".to_string(),
|
||||
other => anyhow::bail!("Unknown display preset: {other}"),
|
||||
};
|
||||
|
||||
host_sudo(&["/usr/bin/mkdir", "-p", "/etc/archipelago"]).await?;
|
||||
if conf.is_empty() {
|
||||
let _ = host_sudo(&["/usr/bin/rm", "-f", KIOSK_DISPLAY_CONF]).await;
|
||||
} else {
|
||||
// tee via sudo — the backend runs unprivileged and /etc is root's.
|
||||
let mut child = tokio::process::Command::new("/usr/bin/sudo")
|
||||
.args(["-n", "/usr/bin/tee", KIOSK_DISPLAY_CONF])
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.context("spawn sudo tee for kiosk display conf")?;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
stdin.write_all(conf.as_bytes()).await?;
|
||||
}
|
||||
let out = child.wait_with_output().await?;
|
||||
if !out.status.success() {
|
||||
anyhow::bail!(
|
||||
"writing {} failed: {}",
|
||||
KIOSK_DISPLAY_CONF,
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// try-restart: only bounces a kiosk that is actually running, so this
|
||||
// never starts a kiosk an operator disabled.
|
||||
let _ = host_sudo(&[
|
||||
"/usr/bin/systemctl",
|
||||
"try-restart",
|
||||
"archipelago-kiosk.service",
|
||||
])
|
||||
.await;
|
||||
|
||||
info!(preset, "Kiosk display preset applied");
|
||||
Ok(serde_json::json!({ "preset": preset, "applied": true }))
|
||||
}
|
||||
}
|
||||
|
||||
const KIOSK_DISPLAY_CONF: &str = "/etc/archipelago/kiosk-display.conf";
|
||||
@@ -0,0 +1,763 @@
|
||||
mod handlers;
|
||||
|
||||
use crate::update::host_sudo;
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::SystemTime;
|
||||
use tracing::{debug, info};
|
||||
|
||||
/// Push the server name to all federation peers by syncing state.
|
||||
pub(super) async fn push_name_to_peers(
|
||||
data_dir: &std::path::Path,
|
||||
state_manager: &std::sync::Arc<crate::state::StateManager>,
|
||||
) -> Result<()> {
|
||||
use crate::{federation, identity};
|
||||
|
||||
let nodes = federation::load_nodes(data_dir).await?;
|
||||
if nodes.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let (data, _) = state_manager.get_snapshot().await;
|
||||
let local_did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
let identity_dir = data_dir.join("identity");
|
||||
let node_identity = identity::NodeIdentity::load_or_create(&identity_dir).await?;
|
||||
|
||||
let mut synced = 0u32;
|
||||
for node in &nodes {
|
||||
if node.trust_level == federation::TrustLevel::Untrusted {
|
||||
continue;
|
||||
}
|
||||
match federation::sync_with_peer(data_dir, node, &local_did, |bytes| {
|
||||
node_identity.sign(bytes)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(_) => synced += 1,
|
||||
Err(e) => debug!(
|
||||
"Sync with {} after rename: {}",
|
||||
node.did.chars().take(20).collect::<String>(),
|
||||
e
|
||||
),
|
||||
}
|
||||
}
|
||||
info!("Pushed server name to {}/{} peers", synced, nodes.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read system uptime from /proc/uptime (seconds since boot).
|
||||
pub(super) async fn read_uptime() -> Result<f64> {
|
||||
let content = tokio::fs::read_to_string("/proc/uptime")
|
||||
.await
|
||||
.context("Failed to read /proc/uptime")?;
|
||||
let uptime: f64 = content
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("Empty /proc/uptime"))?
|
||||
.parse()
|
||||
.context("Failed to parse uptime")?;
|
||||
Ok(uptime)
|
||||
}
|
||||
|
||||
/// Read load averages from /proc/loadavg.
|
||||
pub(super) async fn read_loadavg() -> Result<(f64, f64, f64)> {
|
||||
let content = tokio::fs::read_to_string("/proc/loadavg")
|
||||
.await
|
||||
.context("Failed to read /proc/loadavg")?;
|
||||
let mut parts = content.split_whitespace();
|
||||
let l1: f64 = parts
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing load1"))?
|
||||
.parse()
|
||||
.context("parse load1")?;
|
||||
let l5: f64 = parts
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing load5"))?
|
||||
.parse()
|
||||
.context("parse load5")?;
|
||||
let l15: f64 = parts
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing load15"))?
|
||||
.parse()
|
||||
.context("parse load15")?;
|
||||
Ok((l1, l5, l15))
|
||||
}
|
||||
|
||||
/// Compute CPU usage by sampling /proc/stat twice with a 250ms gap.
|
||||
pub(super) async fn read_cpu_usage() -> Result<f64> {
|
||||
let snap1 = read_cpu_jiffies().await?;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
|
||||
let snap2 = read_cpu_jiffies().await?;
|
||||
|
||||
let total_delta = snap2.total.saturating_sub(snap1.total);
|
||||
let idle_delta = snap2.idle.saturating_sub(snap1.idle);
|
||||
|
||||
if total_delta == 0 {
|
||||
return Ok(0.0);
|
||||
}
|
||||
|
||||
let usage = 100.0 * (1.0 - (idle_delta as f64 / total_delta as f64));
|
||||
Ok((usage * 10.0).round() / 10.0) // one decimal
|
||||
}
|
||||
|
||||
struct CpuJiffies {
|
||||
total: u64,
|
||||
idle: u64,
|
||||
}
|
||||
|
||||
async fn read_cpu_jiffies() -> Result<CpuJiffies> {
|
||||
let content = tokio::fs::read_to_string("/proc/stat")
|
||||
.await
|
||||
.context("Failed to read /proc/stat")?;
|
||||
let cpu_line = content
|
||||
.lines()
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("Empty /proc/stat"))?;
|
||||
// cpu user nice system idle iowait irq softirq steal guest guest_nice
|
||||
let vals: Vec<u64> = cpu_line
|
||||
.split_whitespace()
|
||||
.skip(1) // skip "cpu"
|
||||
.filter_map(|v| v.parse().ok())
|
||||
.collect();
|
||||
if vals.len() < 4 {
|
||||
anyhow::bail!("Not enough fields in /proc/stat cpu line");
|
||||
}
|
||||
let idle = vals[3]; // idle column
|
||||
let total: u64 = vals.iter().sum();
|
||||
Ok(CpuJiffies { total, idle })
|
||||
}
|
||||
|
||||
/// Read memory info from /proc/meminfo.
|
||||
/// Returns (used_bytes, total_bytes).
|
||||
pub(super) async fn read_meminfo() -> Result<(u64, u64)> {
|
||||
let content = tokio::fs::read_to_string("/proc/meminfo")
|
||||
.await
|
||||
.context("Failed to read /proc/meminfo")?;
|
||||
|
||||
let mut total_kb: u64 = 0;
|
||||
let mut available_kb: u64 = 0;
|
||||
|
||||
for line in content.lines() {
|
||||
if let Some(val) = line.strip_prefix("MemTotal:") {
|
||||
total_kb = parse_meminfo_kb(val)?;
|
||||
} else if let Some(val) = line.strip_prefix("MemAvailable:") {
|
||||
available_kb = parse_meminfo_kb(val)?;
|
||||
}
|
||||
}
|
||||
|
||||
let used_bytes = total_kb.saturating_sub(available_kb) * 1024;
|
||||
let total_bytes = total_kb * 1024;
|
||||
Ok((used_bytes, total_bytes))
|
||||
}
|
||||
|
||||
pub(super) fn parse_meminfo_kb(val: &str) -> Result<u64> {
|
||||
val.trim()
|
||||
.trim_end_matches("kB")
|
||||
.trim()
|
||||
.parse::<u64>()
|
||||
.context("parse meminfo value")
|
||||
}
|
||||
|
||||
/// Read disk usage via `df` for the root filesystem.
|
||||
/// Returns (used_bytes, total_bytes).
|
||||
#[allow(dead_code)]
|
||||
pub(super) async fn read_disk_usage() -> Result<(u64, u64)> {
|
||||
read_disk_usage_path("/").await
|
||||
}
|
||||
|
||||
/// Read disk usage via `df` for a given path.
|
||||
pub(super) async fn read_disk_usage_path(path: &str) -> Result<(u64, u64)> {
|
||||
let output = tokio::process::Command::new("df")
|
||||
.args(["--block-size=1", "--output=used,size", path])
|
||||
.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")?;
|
||||
// Skip header line
|
||||
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")?;
|
||||
|
||||
Ok((used, total))
|
||||
}
|
||||
|
||||
/// Read top 10 processes by CPU from `ps`.
|
||||
pub(super) async fn read_top_processes() -> Result<Vec<serde_json::Value>> {
|
||||
let output = tokio::process::Command::new("ps")
|
||||
.args(["--no-headers", "-eo", "pid,%cpu,%mem,comm", "--sort=-%cpu"])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to run ps")?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!("ps failed: {}", String::from_utf8_lossy(&output.stderr));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8(output.stdout).context("ps output not utf8")?;
|
||||
let procs: Vec<serde_json::Value> = stdout
|
||||
.lines()
|
||||
.take(10)
|
||||
.filter_map(|line| {
|
||||
let mut parts = line.split_whitespace();
|
||||
let pid = parts.next()?.parse::<u32>().ok()?;
|
||||
let cpu: f64 = parts.next()?.parse().ok()?;
|
||||
let mem: f64 = parts.next()?.parse().ok()?;
|
||||
let name = parts.collect::<Vec<_>>().join(" ");
|
||||
Some(serde_json::json!({
|
||||
"pid": pid,
|
||||
"cpu_percent": cpu,
|
||||
"mem_percent": mem,
|
||||
"name": name,
|
||||
}))
|
||||
})
|
||||
.collect();
|
||||
|
||||
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.
|
||||
pub(super) async fn detect_usb_hardware_wallets() -> Result<Vec<serde_json::Value>> {
|
||||
let usb_dir = std::path::Path::new("/sys/bus/usb/devices");
|
||||
if !usb_dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/// Clean log files older than `max_age_days` from common log directories.
|
||||
pub(super) async fn clean_old_logs(max_age_days: u64) -> Result<u64> {
|
||||
let output = tokio::process::Command::new("timeout")
|
||||
.args([
|
||||
"60s",
|
||||
"sudo",
|
||||
"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("timeout")
|
||||
.args([
|
||||
"60s",
|
||||
"sudo",
|
||||
"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
|
||||
}
|
||||
|
||||
/// Vacuum systemd journals to a bounded size. Returns measured bytes freed.
|
||||
pub(super) async fn vacuum_journal_logs(max_size: &str) -> Result<u64> {
|
||||
let before = journal_disk_usage().await.unwrap_or(0);
|
||||
let output = tokio::process::Command::new("timeout")
|
||||
.args(["60s", "sudo", "journalctl", "--vacuum-size", max_size])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to run journal vacuum")?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"journal vacuum failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
let after = journal_disk_usage().await.unwrap_or(before);
|
||||
Ok(before.saturating_sub(after))
|
||||
}
|
||||
|
||||
async fn journal_disk_usage() -> Result<u64> {
|
||||
let output = tokio::process::Command::new("sudo")
|
||||
.args(["-n", "journalctl", "--disk-usage"])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to read journal disk usage")?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"journalctl --disk-usage failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
parse_journal_disk_usage(&String::from_utf8_lossy(&output.stdout))
|
||||
.ok_or_else(|| anyhow::anyhow!("could not parse journal disk usage"))
|
||||
}
|
||||
|
||||
fn parse_journal_disk_usage(output: &str) -> Option<u64> {
|
||||
let mut parts = output.split_whitespace();
|
||||
while let Some(part) = parts.next() {
|
||||
let (number, inline_unit) = split_number_unit(part);
|
||||
let Ok(value) = number.parse::<f64>() else {
|
||||
continue;
|
||||
};
|
||||
let unit = inline_unit.unwrap_or_else(|| parts.next().unwrap_or_default());
|
||||
let multiplier = match unit {
|
||||
"B" | "bytes" => 1.0,
|
||||
"K" | "KB" | "KiB" => 1024.0,
|
||||
"M" | "MB" | "MiB" => 1024.0 * 1024.0,
|
||||
"G" | "GB" | "GiB" => 1024.0 * 1024.0 * 1024.0,
|
||||
_ => continue,
|
||||
};
|
||||
return Some((value * multiplier) as u64);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn split_number_unit(value: &str) -> (&str, Option<&str>) {
|
||||
let split_at = value
|
||||
.char_indices()
|
||||
.find_map(|(idx, ch)| (!ch.is_ascii_digit() && ch != '.').then_some(idx))
|
||||
.unwrap_or(value.len());
|
||||
let (number, unit) = value.split_at(split_at);
|
||||
(number, (!unit.is_empty()).then_some(unit))
|
||||
}
|
||||
|
||||
/// Remove stale temp files from /tmp and /var/tmp.
|
||||
pub(super) async fn clean_temp_files() -> Result<u64> {
|
||||
let mut freed = 0u64;
|
||||
|
||||
for dir in &["/tmp", "/var/tmp"] {
|
||||
let output = tokio::process::Command::new("timeout")
|
||||
.args([
|
||||
"45s", "sudo", "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)
|
||||
}
|
||||
|
||||
/// Keep the newest timestamped backend backups and remove older ones.
|
||||
pub(super) async fn clean_backend_backups(keep: usize) -> Result<u64> {
|
||||
clean_backend_backups_in(Path::new("/usr/local/bin"), keep).await
|
||||
}
|
||||
|
||||
/// Keep the newest legacy backend backups and remove older alpha-era deploy artifacts.
|
||||
pub(super) async fn clean_legacy_backend_backups(keep: usize) -> Result<u64> {
|
||||
clean_named_backups_in(
|
||||
Path::new("/usr/local/bin"),
|
||||
keep,
|
||||
|name| name.starts_with("archipelago.bak") || name.starts_with("archipelago.before-"),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Keep the newest web UI rollback backups and remove older copies.
|
||||
pub(super) async fn clean_web_ui_backups(keep: usize) -> Result<u64> {
|
||||
clean_named_backups_in(
|
||||
Path::new("/opt/archipelago"),
|
||||
keep,
|
||||
|name| name.starts_with("web-ui.bak") || name == "web-ui.old",
|
||||
true,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn clean_backend_backups_in(dir: &Path, keep: usize) -> Result<u64> {
|
||||
let mut backups = backend_backup_candidates(dir).await?;
|
||||
remove_old_backups(&mut backups, keep, false).await
|
||||
}
|
||||
|
||||
async fn clean_named_backups_in(
|
||||
dir: &Path,
|
||||
keep: usize,
|
||||
matches_name: impl Fn(&str) -> bool,
|
||||
allow_dirs: bool,
|
||||
) -> Result<u64> {
|
||||
let mut backups = named_backup_candidates(dir, matches_name, allow_dirs).await?;
|
||||
remove_old_backups(&mut backups, keep, allow_dirs).await
|
||||
}
|
||||
|
||||
async fn remove_old_backups(
|
||||
backups: &mut Vec<BackupArtifact>,
|
||||
keep: usize,
|
||||
allow_dirs: bool,
|
||||
) -> Result<u64> {
|
||||
backups.sort_by(|a, b| {
|
||||
b.modified
|
||||
.cmp(&a.modified)
|
||||
.then_with(|| b.name.cmp(&a.name))
|
||||
});
|
||||
|
||||
let mut freed = 0u64;
|
||||
for backup in backups.iter().skip(keep) {
|
||||
let remove_result = if backup.is_dir && allow_dirs {
|
||||
tokio::fs::remove_dir_all(&backup.path).await
|
||||
} else {
|
||||
tokio::fs::remove_file(&backup.path).await
|
||||
};
|
||||
match remove_result {
|
||||
Ok(()) => freed += backup.size,
|
||||
Err(_) => {
|
||||
remove_path_with_sudo(&backup.path, backup.is_dir && allow_dirs).await?;
|
||||
freed += backup.size;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(freed)
|
||||
}
|
||||
|
||||
async fn remove_path_with_sudo(path: &Path, recursive: bool) -> Result<()> {
|
||||
let path = path.to_string_lossy();
|
||||
let args = if recursive {
|
||||
vec!["rm", "-rf", path.as_ref()]
|
||||
} else {
|
||||
vec!["rm", "-f", path.as_ref()]
|
||||
};
|
||||
let status = host_sudo(&args)
|
||||
.await
|
||||
.with_context(|| format!("removing {path} via sudo"))?;
|
||||
if !status.success() {
|
||||
anyhow::bail!(
|
||||
"sudo rm {} {path} exited with {status}",
|
||||
if recursive { "-rf" } else { "-f" }
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct BackupArtifact {
|
||||
path: PathBuf,
|
||||
name: String,
|
||||
modified: SystemTime,
|
||||
size: u64,
|
||||
is_dir: bool,
|
||||
}
|
||||
|
||||
async fn backend_backup_candidates(dir: &Path) -> Result<Vec<BackupArtifact>> {
|
||||
named_backup_candidates(
|
||||
dir,
|
||||
|name| {
|
||||
name.strip_prefix("archipelago.backup-")
|
||||
.is_some_and(|suffix| !suffix.is_empty() && !suffix.contains('/'))
|
||||
},
|
||||
false,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn named_backup_candidates(
|
||||
dir: &Path,
|
||||
matches_name: impl Fn(&str) -> bool,
|
||||
allow_dirs: bool,
|
||||
) -> Result<Vec<BackupArtifact>> {
|
||||
let mut backups = Vec::new();
|
||||
let mut entries = match tokio::fs::read_dir(dir).await {
|
||||
Ok(entries) => entries,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(backups),
|
||||
Err(e) => return Err(e).with_context(|| format!("reading {}", dir.display())),
|
||||
};
|
||||
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let file_name = entry.file_name();
|
||||
let name = file_name.to_string_lossy();
|
||||
if !matches_name(&name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let meta = entry.metadata().await?;
|
||||
if !meta.is_file() && !(allow_dirs && meta.is_dir()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
backups.push(BackupArtifact {
|
||||
path: entry.path(),
|
||||
name: name.to_string(),
|
||||
modified: meta.modified().unwrap_or(SystemTime::UNIX_EPOCH),
|
||||
size: path_size(&entry.path(), &meta).await.unwrap_or(meta.len()),
|
||||
is_dir: meta.is_dir(),
|
||||
});
|
||||
}
|
||||
Ok(backups)
|
||||
}
|
||||
|
||||
async fn path_size(path: &Path, meta: &std::fs::Metadata) -> Result<u64> {
|
||||
if meta.is_file() {
|
||||
return Ok(meta.len());
|
||||
}
|
||||
if !meta.is_dir() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let output = tokio::process::Command::new("du")
|
||||
.args(["-sb", &path.to_string_lossy()])
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| format!("du -sb {}", path.display()))?;
|
||||
if !output.status.success() {
|
||||
anyhow::bail!("du -sb {} failed", path.display());
|
||||
}
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
stdout
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("du output missing size for {}", path.display()))?
|
||||
.parse::<u64>()
|
||||
.with_context(|| format!("parse du size for {}", path.display()))
|
||||
}
|
||||
|
||||
pub(super) 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)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn backend_backup_cleanup_keeps_newest_files() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
for name in [
|
||||
"archipelago.backup-20260501",
|
||||
"archipelago.backup-20260502",
|
||||
"archipelago.backup-20260503",
|
||||
"archipelago.backup-20260504",
|
||||
"archipelago.backup-20260505",
|
||||
"archipelago.bak",
|
||||
"archipelago",
|
||||
] {
|
||||
tokio::fs::write(dir.path().join(name), b"12345")
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let freed = clean_backend_backups_in(dir.path(), 3).await.unwrap();
|
||||
|
||||
assert_eq!(freed, 10);
|
||||
assert!(!dir.path().join("archipelago.backup-20260501").exists());
|
||||
assert!(!dir.path().join("archipelago.backup-20260502").exists());
|
||||
assert!(dir.path().join("archipelago.backup-20260503").exists());
|
||||
assert!(dir.path().join("archipelago.backup-20260504").exists());
|
||||
assert!(dir.path().join("archipelago.backup-20260505").exists());
|
||||
assert!(dir.path().join("archipelago.bak").exists());
|
||||
assert!(dir.path().join("archipelago").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_backend_backup_cleanup_keeps_newest_matching_files() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
for name in [
|
||||
"archipelago.bak-1",
|
||||
"archipelago.bak-2",
|
||||
"archipelago.before-3",
|
||||
"archipelago.backup-keep-separate",
|
||||
"archipelago",
|
||||
] {
|
||||
tokio::fs::write(dir.path().join(name), b"12345")
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let freed = clean_named_backups_in(
|
||||
dir.path(),
|
||||
1,
|
||||
|name| name.starts_with("archipelago.bak") || name.starts_with("archipelago.before-"),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(freed, 10);
|
||||
assert_eq!(
|
||||
[
|
||||
"archipelago.bak-1",
|
||||
"archipelago.bak-2",
|
||||
"archipelago.before-3"
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|name| dir.path().join(name).exists())
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert!(dir.path().join("archipelago.backup-keep-separate").exists());
|
||||
assert!(dir.path().join("archipelago").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostname_from_server_name_derives_linux_safe_hostname() {
|
||||
assert_eq!(
|
||||
handlers::hostname_from_server_name("My Archipelago Node"),
|
||||
"my-archipelago-node"
|
||||
);
|
||||
assert_eq!(
|
||||
handlers::hostname_from_server_name("Kitchen_Node!! 01"),
|
||||
"kitchen-node-01"
|
||||
);
|
||||
assert_eq!(handlers::hostname_from_server_name("!!!"), "archipelago");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_journal_disk_usage() {
|
||||
assert_eq!(
|
||||
parse_journal_disk_usage(
|
||||
"Archived and active journals take up 463.9M in the file system."
|
||||
),
|
||||
Some(486_434_406)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Read temperatures from /sys/class/thermal/thermal_zone*/temp.
|
||||
pub(super) async fn read_temperatures() -> Result<Vec<serde_json::Value>> {
|
||||
let mut temps = Vec::new();
|
||||
let thermal_dir = std::path::Path::new("/sys/class/thermal");
|
||||
if !thermal_dir.exists() {
|
||||
return Ok(temps);
|
||||
}
|
||||
|
||||
let mut entries = tokio::fs::read_dir(thermal_dir)
|
||||
.await
|
||||
.context("Failed to read /sys/class/thermal")?;
|
||||
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let name = entry.file_name();
|
||||
let name_str = name.to_string_lossy();
|
||||
if !name_str.starts_with("thermal_zone") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let temp_path = entry.path().join("temp");
|
||||
let type_path = entry.path().join("type");
|
||||
|
||||
let millideg = match tokio::fs::read_to_string(&temp_path).await {
|
||||
Ok(s) => s.trim().parse::<i64>().unwrap_or(0),
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let zone_type = tokio::fs::read_to_string(&type_path)
|
||||
.await
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|_| name_str.to_string());
|
||||
|
||||
temps.push(serde_json::json!({
|
||||
"zone": zone_type,
|
||||
"temp_celsius": millideg as f64 / 1000.0,
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(temps)
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
use super::*;
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
impl RpcHandler {
|
||||
/// List all configured hidden services with their .onion addresses.
|
||||
/// Services for known-but-uninstalled apps are hidden (issue #79).
|
||||
pub(in crate::api::rpc) async fn handle_tor_list_services(&self) -> Result<serde_json::Value> {
|
||||
let config_dir = self.config.data_dir.join("tor-config");
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let mut apps = AppInstallState {
|
||||
known: Default::default(),
|
||||
installed: Default::default(),
|
||||
};
|
||||
for (id, pkg) in &data.package_data {
|
||||
apps.known.insert(id.clone());
|
||||
if pkg.installed.is_some() {
|
||||
apps.installed.insert(id.clone());
|
||||
}
|
||||
}
|
||||
let services = list_services(&config_dir, Some(&apps)).await?;
|
||||
let tor_running = check_tor_running().await;
|
||||
Ok(serde_json::json!({ "services": services, "tor_running": tor_running }))
|
||||
}
|
||||
|
||||
/// Create a new hidden service for a given local port.
|
||||
pub(in crate::api::rpc) async fn handle_tor_create_service(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let name = params
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing name"))?;
|
||||
let raw_port = params
|
||||
.get("local_port")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u16;
|
||||
let remote_port = params
|
||||
.get("remote_port")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v as u16);
|
||||
|
||||
validate_service_name(name)?;
|
||||
|
||||
let local_port = if raw_port == 0 {
|
||||
self.resolve_app_local_port(name).await.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"No local web port found for '{}' — the app isn't running or exposes no UI port; specify local_port manually",
|
||||
name
|
||||
)
|
||||
})?
|
||||
} else {
|
||||
raw_port
|
||||
};
|
||||
|
||||
let config_dir = self.config.data_dir.join("tor-config");
|
||||
let mut config = load_services_config(&config_dir).await;
|
||||
if config.services.iter().any(|s| s.name == name) {
|
||||
return Err(anyhow::anyhow!("Service '{}' already exists", name));
|
||||
}
|
||||
|
||||
let is_proto = is_protocol_service(name);
|
||||
config.services.push(TorServiceEntry {
|
||||
name: name.to_string(),
|
||||
local_port,
|
||||
remote_port,
|
||||
unauthenticated: is_proto,
|
||||
enabled: true,
|
||||
});
|
||||
save_services_config(&config_dir, &config).await?;
|
||||
|
||||
regenerate_torrc(&config).await?;
|
||||
restart_tor().await?;
|
||||
|
||||
let onion = wait_for_hostname(name, 60).await;
|
||||
if let Some(ref addr) = onion {
|
||||
sync_single_hostname(name, addr).await;
|
||||
}
|
||||
|
||||
info!(
|
||||
service = name,
|
||||
port = local_port,
|
||||
"Created Tor hidden service"
|
||||
);
|
||||
Ok(serde_json::json!({
|
||||
"created": true,
|
||||
"name": name,
|
||||
"onion_address": onion,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Delete a hidden service.
|
||||
pub(in crate::api::rpc) async fn handle_tor_delete_service(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let name = params
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing name"))?;
|
||||
|
||||
validate_service_name(name)?;
|
||||
|
||||
if name == "archipelago" {
|
||||
return Err(anyhow::anyhow!("Cannot delete the node's own Tor service"));
|
||||
}
|
||||
|
||||
let config_dir = self.config.data_dir.join("tor-config");
|
||||
let mut config = load_services_config(&config_dir).await;
|
||||
let before = config.services.len();
|
||||
config.services.retain(|s| s.name != name);
|
||||
if config.services.len() == before {
|
||||
return Err(anyhow::anyhow!("Service '{}' not found", name));
|
||||
}
|
||||
save_services_config(&config_dir, &config).await?;
|
||||
|
||||
delete_hidden_service_dir(name).await;
|
||||
|
||||
regenerate_torrc(&config).await?;
|
||||
restart_tor().await?;
|
||||
|
||||
info!(service = name, "Deleted Tor hidden service");
|
||||
Ok(serde_json::json!({ "deleted": true, "name": name }))
|
||||
}
|
||||
|
||||
/// Get the .onion address for a specific service.
|
||||
pub(in crate::api::rpc) async fn handle_tor_get_onion_address(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let name = params
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing name"))?;
|
||||
|
||||
validate_service_name(name)?;
|
||||
|
||||
let onion = read_onion_address(name).await;
|
||||
Ok(serde_json::json!({ "name": name, "onion_address": onion }))
|
||||
}
|
||||
|
||||
/// Rotate a hidden service's .onion address by generating a new keypair.
|
||||
pub(in crate::api::rpc) async fn handle_tor_rotate_service(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let name = params
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing name"))?;
|
||||
|
||||
validate_service_name(name)?;
|
||||
|
||||
let old_onion = read_onion_address(name).await;
|
||||
if old_onion.is_none() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Service '{}' has no .onion address to rotate",
|
||||
name
|
||||
));
|
||||
}
|
||||
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
rename_hidden_service_dir(name, timestamp).await;
|
||||
|
||||
info!(
|
||||
service = name,
|
||||
old_onion = ?old_onion,
|
||||
"Renamed old Tor service dir — restarting Tor to generate new keypair"
|
||||
);
|
||||
|
||||
restart_tor().await?;
|
||||
|
||||
let new_onion = wait_for_hostname(name, 60).await;
|
||||
|
||||
if let Some(ref new_addr) = new_onion {
|
||||
sync_single_hostname(name, new_addr).await;
|
||||
}
|
||||
|
||||
let old_name = format!("{}_old_{}", name, timestamp);
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(3600)).await;
|
||||
info!(old_dir = %old_name, "Transition period elapsed — deleting old Tor service dir");
|
||||
delete_hidden_service_dir(&old_name).await;
|
||||
});
|
||||
|
||||
if let Some(ref new_addr) = new_onion {
|
||||
let data_dir = self.config.data_dir.clone();
|
||||
let tor_proxy = self.config.nostr_tor_proxy.clone();
|
||||
let new_addr_clone = new_addr.clone();
|
||||
let old_onion_clone = old_onion.clone();
|
||||
tokio::spawn(async move {
|
||||
notify_federation_peers_address_change(
|
||||
&data_dir,
|
||||
&new_addr_clone,
|
||||
old_onion_clone.as_deref(),
|
||||
tor_proxy.as_deref(),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"rotated": true,
|
||||
"name": name,
|
||||
"old_onion": old_onion,
|
||||
"new_onion": new_onion,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Clean up expired rotated service directories past the transition period.
|
||||
pub(in crate::api::rpc) async fn handle_tor_cleanup_rotated(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
let base = detect_hidden_service_base();
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
let mut cleaned = Vec::new();
|
||||
if let Ok(mut entries) = tokio::fs::read_dir(&base).await {
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if !name.contains("_old_") {
|
||||
continue;
|
||||
}
|
||||
if let Some(ts_str) = name.rsplit('_').next() {
|
||||
if let Ok(ts) = ts_str.parse::<u64>() {
|
||||
if now - ts > ROTATION_TRANSITION_SECS {
|
||||
let path = entry.path();
|
||||
let status = tokio::process::Command::new("sudo")
|
||||
.args(["rm", "-rf", &path.to_string_lossy()])
|
||||
.status()
|
||||
.await;
|
||||
if status.map(|s| s.success()).unwrap_or(false) {
|
||||
info!(dir = %name, "Cleaned up expired rotated Tor service");
|
||||
cleaned.push(name);
|
||||
} else {
|
||||
warn!(dir = %name, "Failed to clean up rotated Tor service");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({ "cleaned": cleaned, "count": cleaned.len() }))
|
||||
}
|
||||
|
||||
/// Toggle Tor access for a specific app (enable/disable).
|
||||
pub(in crate::api::rpc) async fn handle_tor_toggle_app(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let app_id = params
|
||||
.get("app_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
|
||||
|
||||
validate_service_name(app_id)?;
|
||||
|
||||
let enabled = params
|
||||
.get("enabled")
|
||||
.and_then(|v| v.as_bool())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing enabled (bool)"))?;
|
||||
|
||||
let config_dir = self.config.data_dir.join("tor-config");
|
||||
let mut config = load_services_config(&config_dir).await;
|
||||
|
||||
let found = config.services.iter_mut().find(|s| s.name == app_id);
|
||||
match found {
|
||||
Some(entry) => {
|
||||
if entry.enabled == enabled {
|
||||
return Ok(serde_json::json!({
|
||||
"app_id": app_id,
|
||||
"enabled": enabled,
|
||||
"changed": false,
|
||||
}));
|
||||
}
|
||||
entry.enabled = enabled;
|
||||
}
|
||||
None => {
|
||||
if !enabled {
|
||||
return Ok(serde_json::json!({
|
||||
"app_id": app_id,
|
||||
"enabled": false,
|
||||
"changed": false,
|
||||
}));
|
||||
}
|
||||
let port = self.resolve_app_local_port(app_id).await.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"No local web port found for '{}' — the app isn't running or exposes no UI port",
|
||||
app_id
|
||||
)
|
||||
})?;
|
||||
let is_proto = is_protocol_service(app_id);
|
||||
config.services.push(TorServiceEntry {
|
||||
name: app_id.to_string(),
|
||||
local_port: port,
|
||||
remote_port: None,
|
||||
unauthenticated: is_proto,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
save_services_config(&config_dir, &config).await?;
|
||||
|
||||
if !enabled {
|
||||
delete_hidden_service_dir(app_id).await;
|
||||
info!(
|
||||
app = app_id,
|
||||
"Disabled Tor access — removed hidden service dir"
|
||||
);
|
||||
}
|
||||
|
||||
regenerate_torrc(&config).await?;
|
||||
restart_tor().await?;
|
||||
|
||||
let new_onion = if enabled {
|
||||
let onion = wait_for_hostname(app_id, 60).await;
|
||||
if let Some(ref addr) = onion {
|
||||
sync_single_hostname(app_id, addr).await;
|
||||
}
|
||||
onion
|
||||
} else {
|
||||
let hostnames_dir = self.config.data_dir.join("tor-hostnames");
|
||||
let _ = tokio::fs::remove_file(hostnames_dir.join(app_id)).await;
|
||||
None
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"app_id": app_id,
|
||||
"enabled": enabled,
|
||||
"changed": true,
|
||||
"onion_address": new_onion,
|
||||
}))
|
||||
}
|
||||
|
||||
/// The host-local port Tor should forward to for an app: the static map
|
||||
/// first (protocol apps like bitcoin must expose 8333, not a UI port),
|
||||
/// then the live launch address the scanner derived from the app's
|
||||
/// published container ports — so any manifest-driven app works without
|
||||
/// a per-app entry.
|
||||
pub(in crate::api::rpc) async fn resolve_app_local_port(&self, name: &str) -> Option<u16> {
|
||||
let known = known_service_port(name);
|
||||
if known != 0 {
|
||||
return Some(known);
|
||||
}
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let lan = data
|
||||
.package_data
|
||||
.get(name)?
|
||||
.installed
|
||||
.as_ref()?
|
||||
.interface_addresses
|
||||
.get("main")?
|
||||
.lan_address
|
||||
.clone()?;
|
||||
crate::api::rpc::container::port_from_url(&lan)
|
||||
}
|
||||
|
||||
/// Best-effort auto-exposure of a freshly installed app as a Tor hidden
|
||||
/// service. Skips protocol services (bitcoin/lnd keep their explicit
|
||||
/// flows), the node's own service, apps that already have one, and apps
|
||||
/// with no resolvable web port. Runs detached after install — it never
|
||||
/// fails the caller, it only logs.
|
||||
pub(in crate::api::rpc) async fn auto_add_tor_service(&self, app_id: &str) {
|
||||
if app_id == "archipelago" || is_protocol_service(app_id) {
|
||||
return;
|
||||
}
|
||||
let config_dir = self.config.data_dir.join("tor-config");
|
||||
// The scanner may still be deriving the launch address on slower
|
||||
// nodes; retry for up to ~5 minutes before giving up quietly.
|
||||
for _ in 0..10u32 {
|
||||
let config = load_services_config(&config_dir).await;
|
||||
if config.services.iter().any(|s| s.name == app_id) {
|
||||
return;
|
||||
}
|
||||
if let Some(port) = self.resolve_app_local_port(app_id).await {
|
||||
let params = serde_json::json!({ "name": app_id, "local_port": port });
|
||||
match self.handle_tor_create_service(Some(params)).await {
|
||||
Ok(v) => info!(
|
||||
app = app_id,
|
||||
port,
|
||||
onion = ?v.get("onion_address"),
|
||||
"Auto-created Tor hidden service after install"
|
||||
),
|
||||
Err(e) => warn!(app = app_id, "Auto Tor service creation failed: {}", e),
|
||||
}
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(30)).await;
|
||||
}
|
||||
debug!(
|
||||
app = app_id,
|
||||
"No web port resolved — skipping auto Tor service"
|
||||
);
|
||||
}
|
||||
|
||||
/// Restart Tor daemon (system or container).
|
||||
pub(in crate::api::rpc) async fn handle_tor_restart(&self) -> Result<serde_json::Value> {
|
||||
info!("Manual Tor restart requested");
|
||||
|
||||
let config_dir = self.config.data_dir.join("tor-config");
|
||||
let config = load_services_config(&config_dir).await;
|
||||
regenerate_torrc(&config).await?;
|
||||
|
||||
restart_tor().await?;
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
|
||||
sync_all_hostname_copies(&config).await;
|
||||
|
||||
let running = check_tor_running().await;
|
||||
Ok(serde_json::json!({ "restarted": true, "tor_running": running }))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
mod handlers;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::{federation, identity};
|
||||
|
||||
pub(super) const TOR_DATA_DIR: &str = "/var/lib/archipelago/tor";
|
||||
pub(super) const SERVICES_CONFIG: &str = "services.json";
|
||||
/// How long old service directories are kept during transition (seconds).
|
||||
pub(super) const ROTATION_TRANSITION_SECS: u64 = 86400; // 24 hours
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(super) struct TorService {
|
||||
pub name: String,
|
||||
pub local_port: u16,
|
||||
pub onion_address: Option<String>,
|
||||
pub enabled: bool,
|
||||
pub unauthenticated: bool,
|
||||
pub protocol: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub(in crate::api::rpc) struct ServicesConfig {
|
||||
pub services: Vec<TorServiceEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(in crate::api::rpc) struct TorServiceEntry {
|
||||
pub name: String,
|
||||
pub local_port: u16,
|
||||
#[serde(default)]
|
||||
pub remote_port: Option<u16>,
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub unauthenticated: bool,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
// ─── Validation ───────────────────────────────────────────────────
|
||||
|
||||
pub(super) fn validate_service_name(name: &str) -> Result<()> {
|
||||
if name.is_empty()
|
||||
|| name.len() > 64
|
||||
|| !name
|
||||
.chars()
|
||||
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid service name (alphanumeric, hyphens, underscores only)"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── Tor Daemon Control ──────────────────────────────────────────
|
||||
|
||||
const TOR_ACTION_FILE: &str = "/var/lib/archipelago/tor-config/tor-action";
|
||||
const TOR_RESULT_FILE: &str = "/var/lib/archipelago/tor-config/tor-result";
|
||||
|
||||
/// Write an action file and wait for the tor-helper service to process it.
|
||||
pub(super) async fn dispatch_tor_action(action: serde_json::Value) -> Result<()> {
|
||||
let _ = tokio::fs::remove_file(TOR_RESULT_FILE).await;
|
||||
|
||||
let content = serde_json::to_string(&action).context("Failed to serialize tor action")?;
|
||||
let config_dir = Path::new(TOR_ACTION_FILE)
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("/var/lib/archipelago/tor-config"));
|
||||
tokio::fs::create_dir_all(config_dir).await.ok();
|
||||
tokio::fs::write(TOR_ACTION_FILE, &content)
|
||||
.await
|
||||
.context("Failed to write tor-action file")?;
|
||||
|
||||
for _ in 0..90 {
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||
if let Ok(result_str) = tokio::fs::read_to_string(TOR_RESULT_FILE).await {
|
||||
let _ = tokio::fs::remove_file(TOR_RESULT_FILE).await;
|
||||
if let Ok(result) = serde_json::from_str::<serde_json::Value>(&result_str) {
|
||||
if result.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||
return Ok(());
|
||||
}
|
||||
let err = result
|
||||
.get("error")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
return Err(anyhow::anyhow!("Tor helper: {}", err));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(anyhow::anyhow!(
|
||||
"Tor helper timed out — is archipelago-tor-helper.path enabled?"
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) async fn delete_hidden_service_dir(name: &str) {
|
||||
if let Err(e) = dispatch_tor_action(serde_json::json!({
|
||||
"action": "delete-service",
|
||||
"name": name,
|
||||
}))
|
||||
.await
|
||||
{
|
||||
warn!("Failed to delete hidden service dir for {}: {}", name, e);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn rename_hidden_service_dir(name: &str, timestamp: u64) {
|
||||
if let Err(e) = dispatch_tor_action(serde_json::json!({
|
||||
"action": "rename-service",
|
||||
"name": name,
|
||||
"timestamp": timestamp,
|
||||
}))
|
||||
.await
|
||||
{
|
||||
warn!("Failed to rename hidden service dir for {}: {}", name, e);
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::api::rpc) async fn restart_tor() -> Result<()> {
|
||||
dispatch_tor_action(serde_json::json!({
|
||||
"action": "write-torrc-and-restart",
|
||||
}))
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) async fn check_tor_running() -> bool {
|
||||
tokio::net::TcpStream::connect("127.0.0.1:9050")
|
||||
.await
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
// ─── torrc Generation ────────────────────────────────────────────
|
||||
|
||||
pub(super) fn detect_hidden_service_base() -> String {
|
||||
if Path::new("/var/lib/tor/hidden_service_archipelago").exists() {
|
||||
return "/var/lib/tor".to_string();
|
||||
}
|
||||
let custom = tor_data_dir();
|
||||
if Path::new(&custom)
|
||||
.join("hidden_service_archipelago")
|
||||
.exists()
|
||||
{
|
||||
return custom;
|
||||
}
|
||||
"/var/lib/tor".to_string()
|
||||
}
|
||||
|
||||
pub(in crate::api::rpc) async fn regenerate_torrc(config: &ServicesConfig) -> Result<()> {
|
||||
let base = detect_hidden_service_base();
|
||||
let mut lines = vec![
|
||||
"# Auto-generated by Archipelago — do not edit manually".to_string(),
|
||||
"SocksPort 9050".to_string(),
|
||||
"# ControlPort disabled for security".to_string(),
|
||||
String::new(),
|
||||
];
|
||||
|
||||
for svc in &config.services {
|
||||
if !svc.enabled {
|
||||
continue;
|
||||
}
|
||||
let dir = format!("{}/hidden_service_{}", base, svc.name);
|
||||
lines.push(format!("HiddenServiceDir {}", dir));
|
||||
|
||||
if is_protocol_service(&svc.name) {
|
||||
let remote_port = svc.remote_port.unwrap_or(svc.local_port);
|
||||
lines.push(format!(
|
||||
"HiddenServicePort {} 127.0.0.1:{}",
|
||||
remote_port, svc.local_port
|
||||
));
|
||||
if svc.name == "lnd" {
|
||||
lines.push("HiddenServicePort 9735 127.0.0.1:9735".to_string());
|
||||
lines.push("HiddenServicePort 10009 127.0.0.1:10009".to_string());
|
||||
}
|
||||
} else {
|
||||
lines.push(format!("HiddenServicePort 80 127.0.0.1:{}", svc.local_port));
|
||||
}
|
||||
|
||||
lines.push(String::new());
|
||||
}
|
||||
|
||||
let content = lines.join("\n");
|
||||
let staging = "/var/lib/archipelago/tor-config/torrc.staged";
|
||||
let config_dir = Path::new(staging)
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("/var/lib/archipelago/tor-config"));
|
||||
tokio::fs::create_dir_all(config_dir).await.ok();
|
||||
tokio::fs::write(staging, &content)
|
||||
.await
|
||||
.context("Failed to write staged torrc")?;
|
||||
|
||||
debug!(
|
||||
"Staged torrc with {} enabled services",
|
||||
config.services.iter().filter(|s| s.enabled).count()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── Hostname Sync ───────────────────────────────────────────────
|
||||
|
||||
pub(in crate::api::rpc) async fn sync_single_hostname(name: &str, address: &str) {
|
||||
let hostnames_dir = Path::new("/var/lib/archipelago/tor-hostnames");
|
||||
if let Err(e) = tokio::fs::create_dir_all(hostnames_dir).await {
|
||||
warn!("Failed to create tor-hostnames dir: {}", e);
|
||||
return;
|
||||
}
|
||||
if let Err(e) = tokio::fs::write(hostnames_dir.join(name), address).await {
|
||||
warn!("Failed to write tor-hostname copy for {}: {}", name, e);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn sync_all_hostname_copies(config: &ServicesConfig) {
|
||||
for svc in &config.services {
|
||||
if !svc.enabled {
|
||||
continue;
|
||||
}
|
||||
if let Some(addr) = read_onion_address(&svc.name).await {
|
||||
sync_single_hostname(&svc.name, &addr).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Service Listing ─────────────────────────────────────────────
|
||||
|
||||
/// Which packages the node knows about and which are installed — used to
|
||||
/// hide hidden services for apps that aren't installed. ISO first-boot used
|
||||
/// to pre-bake onions for a fixed app list (bitcoin/electrumx/lnd/btcpay/
|
||||
/// mempool/fedimint), so fresh nodes showed Tor sites for apps that were
|
||||
/// never installed (issue #79).
|
||||
pub(super) struct AppInstallState {
|
||||
pub known: std::collections::HashSet<String>,
|
||||
pub installed: std::collections::HashSet<String>,
|
||||
}
|
||||
|
||||
/// Package ids a Tor service name may correspond to. Service names predate
|
||||
/// the catalog app ids (the ISO baked "bitcoin"/"btcpay"), so one service
|
||||
/// can map to several package ids.
|
||||
fn service_alias_candidates(name: &str) -> Vec<&str> {
|
||||
match name {
|
||||
"bitcoin" | "bitcoin-knots" | "bitcoin-core" => {
|
||||
vec!["bitcoin", "bitcoin-knots", "bitcoin-core"]
|
||||
}
|
||||
"electrumx" | "electrs" | "mempool-electrs" => {
|
||||
vec!["electrumx", "electrs", "mempool-electrs"]
|
||||
}
|
||||
"btcpay" | "btcpay-server" | "btcpayserver" => {
|
||||
vec!["btcpay", "btcpay-server", "btcpayserver"]
|
||||
}
|
||||
"mempool" | "mempool-web" => vec!["mempool", "mempool-web"],
|
||||
other => vec![other],
|
||||
}
|
||||
}
|
||||
|
||||
impl AppInstallState {
|
||||
/// A service is listed unless it names a known-but-uninstalled app.
|
||||
/// The node's own service, the content relay, and custom user-created
|
||||
/// services (names matching no catalog package) always show.
|
||||
fn service_visible(&self, name: &str) -> bool {
|
||||
if name == "archipelago" || name == "relay" {
|
||||
return true;
|
||||
}
|
||||
let candidates = service_alias_candidates(name);
|
||||
if !candidates.iter().any(|c| self.known.contains(*c)) {
|
||||
return true; // not an app — custom hidden service
|
||||
}
|
||||
candidates.iter().any(|c| self.installed.contains(*c))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn list_services(
|
||||
config_dir: &std::path::Path,
|
||||
apps: Option<&AppInstallState>,
|
||||
) -> Result<Vec<TorService>> {
|
||||
let base = detect_hidden_service_base();
|
||||
let config = load_services_config(config_dir).await;
|
||||
let mut services = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let visible = |name: &str| apps.map(|a| a.service_visible(name)).unwrap_or(true);
|
||||
|
||||
for entry in &config.services {
|
||||
seen.insert(entry.name.clone());
|
||||
if !visible(&entry.name) {
|
||||
continue;
|
||||
}
|
||||
let onion = read_onion_address(&entry.name).await;
|
||||
services.push(TorService {
|
||||
name: entry.name.clone(),
|
||||
local_port: entry.local_port,
|
||||
onion_address: onion,
|
||||
enabled: entry.enabled,
|
||||
unauthenticated: entry.unauthenticated,
|
||||
protocol: is_protocol_service(&entry.name),
|
||||
});
|
||||
}
|
||||
|
||||
for scan_dir in ["/var/lib/tor", &base] {
|
||||
if let Ok(mut entries) = tokio::fs::read_dir(scan_dir).await {
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
let is_dir = entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false);
|
||||
if name.starts_with("hidden_service_") && !name.contains("_old_") && is_dir {
|
||||
let service_name = name
|
||||
.strip_prefix("hidden_service_")
|
||||
.unwrap_or(&name)
|
||||
.to_string();
|
||||
if seen.contains(&service_name) {
|
||||
continue;
|
||||
}
|
||||
seen.insert(service_name.clone());
|
||||
if !visible(&service_name) {
|
||||
continue;
|
||||
}
|
||||
let onion = read_onion_address(&service_name).await;
|
||||
let port = known_service_port(&service_name);
|
||||
let is_proto = is_protocol_service(&service_name);
|
||||
services.push(TorService {
|
||||
name: service_name,
|
||||
local_port: port,
|
||||
onion_address: onion,
|
||||
enabled: true,
|
||||
unauthenticated: is_proto,
|
||||
protocol: is_proto,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(services)
|
||||
}
|
||||
|
||||
// ─── Onion Address Reading ───────────────────────────────────────
|
||||
|
||||
pub(super) async fn read_onion_address(service_name: &str) -> Option<String> {
|
||||
let hostnames_path = Path::new("/var/lib/archipelago/tor-hostnames").join(service_name);
|
||||
if let Some(addr) = read_and_validate_onion(&hostnames_path).await {
|
||||
return Some(addr);
|
||||
}
|
||||
|
||||
let base = tor_data_dir();
|
||||
for search_base in &["/var/lib/tor", base.as_str()] {
|
||||
let path = Path::new(search_base)
|
||||
.join(format!("hidden_service_{}", service_name))
|
||||
.join("hostname");
|
||||
|
||||
if let Some(addr) = read_and_validate_onion(&path).await {
|
||||
return Some(addr);
|
||||
}
|
||||
|
||||
if let Some(addr) = sudo_read_and_validate_onion(&path).await {
|
||||
return Some(addr);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
async fn read_and_validate_onion(path: &Path) -> Option<String> {
|
||||
tokio::fs::read_to_string(path)
|
||||
.await
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| is_valid_v3_onion(s))
|
||||
}
|
||||
|
||||
async fn sudo_read_and_validate_onion(path: &Path) -> Option<String> {
|
||||
tokio::process::Command::new("sudo")
|
||||
.args(["cat", &path.to_string_lossy()])
|
||||
.output()
|
||||
.await
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| is_valid_v3_onion(s))
|
||||
}
|
||||
|
||||
fn is_valid_v3_onion(s: &str) -> bool {
|
||||
s.len() == 62
|
||||
&& s.ends_with(".onion")
|
||||
&& !s.contains(':')
|
||||
&& s[..56].chars().all(|c| c.is_ascii_alphanumeric())
|
||||
}
|
||||
|
||||
// ─── Known Ports ─────────────────────────────────────────────────
|
||||
|
||||
pub(in crate::api::rpc) fn known_service_port(name: &str) -> u16 {
|
||||
match name {
|
||||
"archipelago" => 80,
|
||||
"bitcoin" | "bitcoin-knots" => 8333,
|
||||
"electrs" | "electrumx" => 50001,
|
||||
"lnd" => 8080,
|
||||
"btcpay" | "btcpay-server" | "btcpayserver" => 23000,
|
||||
"mempool" => 4080,
|
||||
"fedimint" => 8175,
|
||||
"nostr-relay" | "nostr-rs-relay" => 8080,
|
||||
"searxng" => 8888,
|
||||
"ollama" => 11434,
|
||||
"filebrowser" => 8083,
|
||||
"grafana" => 3000,
|
||||
"home-assistant" => 8123,
|
||||
"immich" => 2283,
|
||||
"photoprism" => 2342,
|
||||
"penpot" => 9001,
|
||||
"nginx-proxy-manager" => 8081,
|
||||
"vaultwarden" => 8343,
|
||||
"indeedhub" => 7778,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::api::rpc) fn is_protocol_service(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"bitcoin" | "bitcoin-knots" | "electrs" | "electrumx" | "lnd"
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Config I/O ──────────────────────────────────────────────────
|
||||
|
||||
fn tor_data_dir() -> String {
|
||||
std::env::var("TOR_DATA_DIR").unwrap_or_else(|_| TOR_DATA_DIR.to_string())
|
||||
}
|
||||
|
||||
pub(in crate::api::rpc) async fn load_services_config(
|
||||
config_dir: &std::path::Path,
|
||||
) -> ServicesConfig {
|
||||
let path = config_dir.join(SERVICES_CONFIG);
|
||||
match tokio::fs::read_to_string(&path).await {
|
||||
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
|
||||
Err(_) => ServicesConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::api::rpc) async fn save_services_config(
|
||||
config_dir: &std::path::Path,
|
||||
config: &ServicesConfig,
|
||||
) -> Result<()> {
|
||||
tokio::fs::create_dir_all(config_dir)
|
||||
.await
|
||||
.context("Failed to create tor config dir")?;
|
||||
let path = config_dir.join(SERVICES_CONFIG);
|
||||
let content =
|
||||
serde_json::to_string_pretty(config).context("Failed to serialize services config")?;
|
||||
tokio::fs::write(&path, content)
|
||||
.await
|
||||
.context("Failed to write services config")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── Federation Notification ─────────────────────────────────────
|
||||
|
||||
pub(super) async fn notify_federation_peers_address_change(
|
||||
data_dir: &std::path::Path,
|
||||
new_onion: &str,
|
||||
old_onion: Option<&str>,
|
||||
tor_proxy: Option<&str>,
|
||||
) {
|
||||
let identity_dir = data_dir.join("identity");
|
||||
match identity::NodeIdentity::load_or_create(&identity_dir).await {
|
||||
Ok(node_id) => {
|
||||
let did = match node_id.did_key() {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to derive DID key: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
// `tor_proxy` is retained for API compat but unused — the FIPS
|
||||
// fallback dial uses constants::TOR_SOCKS_PROXY internally.
|
||||
let _ = tor_proxy;
|
||||
match federation::load_nodes(data_dir).await {
|
||||
Ok(peers) => {
|
||||
for peer in peers {
|
||||
if peer.onion.is_empty() && peer.fips_npub.is_none() {
|
||||
continue;
|
||||
}
|
||||
let payload = serde_json::json!({
|
||||
"method": "federation.peer-address-changed",
|
||||
"params": {
|
||||
"did": did,
|
||||
"new_onion": new_onion,
|
||||
"old_onion": old_onion,
|
||||
}
|
||||
});
|
||||
// FIPS-preferred: peer's fips_npub is stable across
|
||||
// onion rotation, so this notification reaches them
|
||||
// even when their (or our) old onion is now stale.
|
||||
let req = crate::fips::dial::PeerRequest::new(
|
||||
peer.fips_npub.as_deref(),
|
||||
&peer.onion,
|
||||
"/rpc/v1",
|
||||
)
|
||||
.service(crate::settings::transport::PeerService::Peers)
|
||||
.timeout(std::time::Duration::from_secs(30));
|
||||
match req.send_json(&payload).await {
|
||||
Ok((_, transport)) => {
|
||||
info!(peer_did = %peer.did, transport = %transport, "Notified peer of address change")
|
||||
}
|
||||
Err(e) => warn!(peer_did = %peer.did, "Failed to notify peer: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("Failed to load federation peers: {}", e),
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("Failed to load node identity for propagation: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Hostname Waiting ────────────────────────────────────────────
|
||||
|
||||
pub(in crate::api::rpc) async fn wait_for_hostname(
|
||||
service_name: &str,
|
||||
max_secs: u64,
|
||||
) -> Option<String> {
|
||||
for _ in 0..max_secs {
|
||||
if let Some(addr) = read_onion_address(service_name).await {
|
||||
return Some(addr);
|
||||
}
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
warn!(
|
||||
service = service_name,
|
||||
"Timed out waiting for new .onion hostname"
|
||||
);
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
use super::RpcHandler;
|
||||
use anyhow::Result;
|
||||
|
||||
impl RpcHandler {
|
||||
/// Begin 2FA setup: generate TOTP secret, return QR code + base32 secret.
|
||||
/// The secret is cached in a pending setup session (in memory only).
|
||||
pub(super) async fn handle_totp_setup_begin(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let password = params
|
||||
.get("password")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing password"))?;
|
||||
|
||||
// Re-verify password before setup
|
||||
if !self.auth_manager.verify_password(password).await? {
|
||||
anyhow::bail!("Password Incorrect");
|
||||
}
|
||||
|
||||
// Check 2FA isn't already enabled
|
||||
if self.auth_manager.is_totp_enabled().await? {
|
||||
anyhow::bail!("2FA is already enabled. Disable it first.");
|
||||
}
|
||||
|
||||
let setup = crate::totp::setup(password)?;
|
||||
|
||||
// Cache the setup result in a pending session so confirm can use it
|
||||
// We store the encrypted TotpData and backup codes temporarily
|
||||
let setup_json = serde_json::json!({
|
||||
"totp_data": setup.totp_data,
|
||||
"backup_codes": setup.backup_codes,
|
||||
});
|
||||
let setup_bytes = serde_json::to_vec(&setup_json)?;
|
||||
let pending_token = self.session_store.create_pending(setup_bytes).await;
|
||||
|
||||
// Return QR + secret for display (the pending token is set as a cookie by mod.rs)
|
||||
Ok(serde_json::json!({
|
||||
"qr_svg": setup.qr_svg,
|
||||
"secret_base32": setup.secret_base32,
|
||||
"pending_token": pending_token,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Confirm 2FA setup: verify the user's first TOTP code, enable 2FA, return backup codes.
|
||||
pub(super) async fn handle_totp_setup_confirm(
|
||||
&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"))?;
|
||||
let pending_token = params
|
||||
.get("pendingToken")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing pendingToken"))?;
|
||||
let password = params
|
||||
.get("password")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing password"))?;
|
||||
|
||||
// Re-verify password
|
||||
if !self.auth_manager.verify_password(password).await? {
|
||||
anyhow::bail!("Password Incorrect");
|
||||
}
|
||||
|
||||
// Retrieve the pending setup data
|
||||
let setup_bytes = self
|
||||
.session_store
|
||||
.get_pending_secret(pending_token)
|
||||
.await
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!("Setup session expired or invalid. Please start again.")
|
||||
})?;
|
||||
|
||||
let setup_json: serde_json::Value = serde_json::from_slice(&setup_bytes)?;
|
||||
let totp_data: crate::totp::TotpData =
|
||||
serde_json::from_value(setup_json["totp_data"].clone())?;
|
||||
let backup_codes: Vec<String> = serde_json::from_value(setup_json["backup_codes"].clone())?;
|
||||
|
||||
// Decrypt and verify the TOTP code
|
||||
let secret = crate::totp::decrypt_secret(&totp_data, password)?;
|
||||
let step = crate::totp::verify_code(&secret, code, &[])?;
|
||||
if step.is_none() {
|
||||
anyhow::bail!("Invalid code. Please check your authenticator app and try again.");
|
||||
}
|
||||
|
||||
// Persist TOTP data
|
||||
self.auth_manager.save_totp(totp_data).await?;
|
||||
|
||||
// Clean up the pending session
|
||||
self.session_store.remove(pending_token).await;
|
||||
|
||||
tracing::info!("2FA enabled successfully");
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"enabled": true,
|
||||
"backup_codes": backup_codes,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Disable 2FA. Requires password + current TOTP code.
|
||||
pub(super) async fn handle_totp_disable(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let password = params
|
||||
.get("password")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing password"))?;
|
||||
let code = params
|
||||
.get("code")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing code"))?;
|
||||
|
||||
// Verify password
|
||||
if !self.auth_manager.verify_password(password).await? {
|
||||
anyhow::bail!("Password Incorrect");
|
||||
}
|
||||
|
||||
// Get and verify TOTP
|
||||
let totp_data = self
|
||||
.auth_manager
|
||||
.get_totp_data()
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("2FA is not enabled"))?;
|
||||
let secret = crate::totp::decrypt_secret(&totp_data, password)?;
|
||||
let step = crate::totp::verify_code(&secret, code, &totp_data.used_steps)?;
|
||||
if step.is_none() {
|
||||
anyhow::bail!("Invalid TOTP code");
|
||||
}
|
||||
|
||||
self.auth_manager.remove_totp().await?;
|
||||
tracing::info!("2FA disabled successfully");
|
||||
|
||||
Ok(serde_json::json!({ "disabled": true }))
|
||||
}
|
||||
|
||||
/// Get 2FA status.
|
||||
pub(super) async fn handle_totp_status(&self) -> Result<serde_json::Value> {
|
||||
let enabled = self.auth_manager.is_totp_enabled().await?;
|
||||
Ok(serde_json::json!({ "enabled": enabled }))
|
||||
}
|
||||
|
||||
/// Step 2 of login: verify TOTP code using the cached secret from the pending session.
|
||||
pub(super) async fn handle_login_totp(
|
||||
&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 code = params
|
||||
.get("code")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing code"))?;
|
||||
|
||||
let token = session_token
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("No pending session"))?;
|
||||
|
||||
let secret = self
|
||||
.session_store
|
||||
.get_pending_secret(token)
|
||||
.await
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!("Session expired or too many attempts. Please log in again.")
|
||||
})?;
|
||||
|
||||
// Get used steps from stored data for replay protection
|
||||
let totp_data = self.auth_manager.get_totp_data().await?;
|
||||
let used_steps = totp_data
|
||||
.as_ref()
|
||||
.map(|d| d.used_steps.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let step = crate::totp::verify_code(&secret, code, &used_steps)?;
|
||||
match step {
|
||||
Some(used_step) => {
|
||||
// Record the used step for replay protection
|
||||
if let Some(mut data) = totp_data {
|
||||
data.used_steps.push(used_step);
|
||||
// Prune old steps (keep only last 5 minutes worth)
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64;
|
||||
let cutoff = (now / 30) - 10; // ~5 minutes
|
||||
data.used_steps.retain(|s| *s > cutoff);
|
||||
let _ = self.auth_manager.update_totp(data).await;
|
||||
}
|
||||
|
||||
// Upgrade pending session to full (rotates token)
|
||||
let new_token = self
|
||||
.session_store
|
||||
.upgrade_to_full(token)
|
||||
.await
|
||||
.ok_or_else(|| anyhow::anyhow!("Session expired. Please log in again."))?;
|
||||
|
||||
Ok(serde_json::json!({ "success": true, "new_session_token": new_token }))
|
||||
}
|
||||
None => {
|
||||
anyhow::bail!("Invalid code. Please try again.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Step 2 of login (alternative): verify backup code.
|
||||
pub(super) async fn handle_login_backup(
|
||||
&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 code = params
|
||||
.get("code")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing code"))?;
|
||||
|
||||
let token = session_token
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("No pending session"))?;
|
||||
|
||||
// Verify the pending session is valid (increments attempts)
|
||||
let _secret = self
|
||||
.session_store
|
||||
.get_pending_secret(token)
|
||||
.await
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!("Session expired or too many attempts. Please log in again.")
|
||||
})?;
|
||||
|
||||
// Verify backup code against stored hashes
|
||||
let mut totp_data = self
|
||||
.auth_manager
|
||||
.get_totp_data()
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("2FA is not enabled"))?;
|
||||
|
||||
match crate::totp::verify_backup_code(&totp_data.backup_codes, code)? {
|
||||
Some(idx) => {
|
||||
// Remove the used backup code (one-time use)
|
||||
totp_data.backup_codes.remove(idx);
|
||||
self.auth_manager.update_totp(totp_data).await?;
|
||||
|
||||
// Upgrade pending session to full (rotates token)
|
||||
let new_token = self
|
||||
.session_store
|
||||
.upgrade_to_full(token)
|
||||
.await
|
||||
.ok_or_else(|| anyhow::anyhow!("Session expired. Please log in again."))?;
|
||||
|
||||
tracing::info!(
|
||||
"Login via backup code (codes remaining: {})",
|
||||
self.auth_manager
|
||||
.get_totp_data()
|
||||
.await?
|
||||
.map(|d| d.backup_codes.len())
|
||||
.unwrap_or(0)
|
||||
);
|
||||
|
||||
Ok(serde_json::json!({ "success": true, "new_session_token": new_token }))
|
||||
}
|
||||
None => {
|
||||
anyhow::bail!("Invalid backup code");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
//! Async lifecycle helper for container Stop/Start/Restart RPCs.
|
||||
//!
|
||||
//! The `ContainerOrchestrator` trait is intentionally synchronous — blocking
|
||||
//! calls keep the reconciler, boot flow, chaos harness, and unit tests
|
||||
//! deterministic. But the RPC layer must return to the UI in <1s so the
|
||||
//! dashboard can render a transitional "Stopping…" / "Starting…" label while
|
||||
//! the underlying `podman stop` (up to 600s for bitcoin-core) runs in the
|
||||
//! background.
|
||||
//!
|
||||
//! `RpcHandler::spawn_transitional` bridges the two: it
|
||||
//! 1. flips the package state in `StateManager` to the appropriate
|
||||
//! transitional variant (`Stopping` / `Starting` / `Restarting`),
|
||||
//! which fans out to WebSocket clients immediately.
|
||||
//! 2. `tokio::spawn`s the actual orchestrator call.
|
||||
//! 3. on success, writes the final state (`Stopped` / `Running`).
|
||||
//! 4. on error, reverts to the pre-transition state and logs via
|
||||
//! `install_log()` so the incident shows up in
|
||||
//! `/var/log/archipelago/container-installs.log`.
|
||||
//!
|
||||
//! The server.rs package-scan loop must also be taught to preserve
|
||||
//! transitional states — see `server.rs:scan_and_update_packages`'s merge
|
||||
//! logic and the companion `merge_preserving_transitional` helper.
|
||||
|
||||
use super::package::install_log;
|
||||
use super::RpcHandler;
|
||||
use crate::container::ContainerOrchestrator;
|
||||
use crate::data_model::PackageState;
|
||||
use crate::state::StateManager;
|
||||
use anyhow::Result;
|
||||
use std::sync::Arc;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
/// The three transitional lifecycle operations that run asynchronously from
|
||||
/// the RPC handler. `Install` and `Remove` are intentionally NOT here — they
|
||||
/// already have their own progress-tracking paths (`install_progress`,
|
||||
/// `uninstall_stage`) with multi-step UI feedback.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(super) enum Op {
|
||||
Stop,
|
||||
Start,
|
||||
Restart,
|
||||
}
|
||||
|
||||
impl Op {
|
||||
/// The `PackageState` to set on the entry while the operation is in
|
||||
/// flight. The package-scan merge loop must preserve this variant and
|
||||
/// refuse to overwrite it with whatever podman reports (see
|
||||
/// `merge_preserving_transitional` in server.rs).
|
||||
fn transitional_state(self) -> PackageState {
|
||||
match self {
|
||||
Op::Stop => PackageState::Stopping,
|
||||
Op::Start => PackageState::Starting,
|
||||
Op::Restart => PackageState::Restarting,
|
||||
}
|
||||
}
|
||||
|
||||
/// The `PackageState` to set on success. On error the caller reverts to
|
||||
/// the pre-transition state rather than using these.
|
||||
fn final_state_on_success(self) -> PackageState {
|
||||
match self {
|
||||
Op::Stop => PackageState::Stopped,
|
||||
Op::Start => PackageState::Running,
|
||||
Op::Restart => PackageState::Running,
|
||||
}
|
||||
}
|
||||
|
||||
/// Prefix used in `install_log` entries so post-mortem readers can grep
|
||||
/// the operation that failed.
|
||||
fn log_prefix(self) -> &'static str {
|
||||
match self {
|
||||
Op::Stop => "STOP",
|
||||
Op::Start => "START",
|
||||
Op::Restart => "RESTART",
|
||||
}
|
||||
}
|
||||
|
||||
/// Call the orchestrator for this op. Kept in one place so the spawned
|
||||
/// task doesn't repeat the match four times.
|
||||
async fn dispatch(self, orch: &dyn ContainerOrchestrator, app_id: &str) -> Result<()> {
|
||||
match self {
|
||||
Op::Stop => orch.stop(app_id).await,
|
||||
Op::Start => orch.start(app_id).await,
|
||||
Op::Restart => orch.restart(app_id).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// Flip the package state to `op.transitional_state()`, spawn a background
|
||||
/// task that runs `op.dispatch()`, and return immediately. The spawned
|
||||
/// task writes the final state on completion or reverts to the
|
||||
/// pre-transition state on failure.
|
||||
///
|
||||
/// If no package entry exists for `app_id` (e.g. Start on a container
|
||||
/// that was never installed), no pre-state is recorded and the spawn
|
||||
/// still runs — the post-success path will no-op the state write and
|
||||
/// the next scan will pick up the newly-created entry with the correct
|
||||
/// state. This keeps the helper usable for stacks that lazily create
|
||||
/// their entries.
|
||||
pub(super) async fn spawn_transitional(&self, op: Op, app_id: String) -> Result<()> {
|
||||
let orchestrator = self
|
||||
.orchestrator
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available"))?
|
||||
.clone();
|
||||
let state_manager = Arc::clone(&self.state_manager);
|
||||
|
||||
// Snapshot pre-transition state (for revert on error) and flip to
|
||||
// transitional variant. Done BEFORE the spawn so the WebSocket push
|
||||
// beats the RPC response — the UI should see "Stopping…" the moment
|
||||
// it gets the RPC ok, not on the next scan.
|
||||
let pre_state =
|
||||
flip_to_transitional(&state_manager, &app_id, op.transitional_state()).await;
|
||||
|
||||
let log_prefix = op.log_prefix();
|
||||
let app_id_log = app_id.clone();
|
||||
install_log(&format!("{}: {}", log_prefix, app_id_log)).await;
|
||||
|
||||
tokio::spawn(async move {
|
||||
match op.dispatch(orchestrator.as_ref(), &app_id).await {
|
||||
Ok(()) => {
|
||||
info!("{} complete: {}", log_prefix, app_id);
|
||||
set_state(&state_manager, &app_id, op.final_state_on_success()).await;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("{} failed for {}: {:#}", log_prefix, app_id, e);
|
||||
install_log(&format!("{} FAIL: {} — {:#}", log_prefix, app_id, e)).await;
|
||||
// Revert to pre-transition state if we had one; otherwise
|
||||
// leave the entry untouched so the next scan reconciles.
|
||||
if let Some(prev) = pre_state {
|
||||
set_state(&state_manager, &app_id, prev).await;
|
||||
} else {
|
||||
warn!(
|
||||
"{}: no pre-transition state recorded for {}; leaving entry to next scan",
|
||||
log_prefix, app_id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Flip the entry's state to `transitional` and return the previous state.
|
||||
/// Returns `None` if there is no entry for `app_id`.
|
||||
async fn flip_to_transitional(
|
||||
state_manager: &StateManager,
|
||||
app_id: &str,
|
||||
transitional: PackageState,
|
||||
) -> Option<PackageState> {
|
||||
let (mut data, _) = state_manager.get_snapshot().await;
|
||||
let prev = data.package_data.get(app_id).map(|e| e.state.clone());
|
||||
if let Some(entry) = data.package_data.get_mut(app_id) {
|
||||
entry.state = transitional;
|
||||
state_manager.update_data(data).await;
|
||||
}
|
||||
prev
|
||||
}
|
||||
|
||||
/// Set the entry's state to `new_state`. No-ops if the entry has since been
|
||||
/// removed (e.g. uninstall ran concurrently).
|
||||
async fn set_state(state_manager: &StateManager, app_id: &str, new_state: PackageState) {
|
||||
let (mut data, _) = state_manager.get_snapshot().await;
|
||||
if let Some(entry) = data.package_data.get_mut(app_id) {
|
||||
if entry.state != new_state {
|
||||
entry.state = new_state;
|
||||
state_manager.update_data(data).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
use super::RpcHandler;
|
||||
use crate::transport::{MessageType, TransportMessage};
|
||||
use anyhow::Result;
|
||||
use tracing::info;
|
||||
|
||||
impl RpcHandler {
|
||||
/// transport.status — Get available transports and their status.
|
||||
pub(super) async fn handle_transport_status(&self) -> Result<serde_json::Value> {
|
||||
let router = self.transport_router.read().await;
|
||||
if let Some(r) = router.as_ref() {
|
||||
let transports: Vec<serde_json::Value> = r
|
||||
.transport_status()
|
||||
.into_iter()
|
||||
.map(|(kind, available)| {
|
||||
serde_json::json!({
|
||||
"kind": kind.to_string(),
|
||||
"available": available,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let peer_count = r.registry.count().await;
|
||||
let mesh_only = r.is_mesh_only().await;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"transports": transports,
|
||||
"mesh_only": mesh_only,
|
||||
"peer_count": peer_count,
|
||||
}))
|
||||
} else {
|
||||
Ok(serde_json::json!({
|
||||
"transports": [],
|
||||
"mesh_only": false,
|
||||
"peer_count": 0,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// transport.peers — Get unified peer list with per-peer transport capabilities.
|
||||
pub(super) async fn handle_transport_peers(&self) -> Result<serde_json::Value> {
|
||||
let router = self.transport_router.read().await;
|
||||
if let Some(r) = router.as_ref() {
|
||||
let peers = r.registry.all_peers().await;
|
||||
let peer_values: Vec<serde_json::Value> = peers
|
||||
.into_iter()
|
||||
.map(|p| {
|
||||
let available = p.available_transports();
|
||||
let preferred = available.first().map(|t| t.to_string());
|
||||
serde_json::json!({
|
||||
"did": p.did,
|
||||
"pubkey_hex": p.pubkey_hex,
|
||||
"name": p.name,
|
||||
"trust_level": p.trust_level,
|
||||
"mesh_contact_id": p.mesh_contact_id,
|
||||
"lan_address": p.lan_address,
|
||||
"onion_address": p.onion_address,
|
||||
"preferred_transport": preferred,
|
||||
"available_transports": available.iter().map(|t| t.to_string()).collect::<Vec<_>>(),
|
||||
"last_seen": p.last_mesh.or(p.last_lan).or(p.last_tor),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(serde_json::json!({ "peers": peer_values }))
|
||||
} else {
|
||||
Ok(serde_json::json!({ "peers": [] }))
|
||||
}
|
||||
}
|
||||
|
||||
/// transport.send — Send a message to a peer via best available transport.
|
||||
pub(super) async fn handle_transport_send(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let did = params["did"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'did' param"))?
|
||||
.to_string();
|
||||
let payload = params["payload"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'payload' param"))?
|
||||
.to_string();
|
||||
|
||||
let router = self.transport_router.read().await;
|
||||
let router = router
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Transport router not initialized"))?;
|
||||
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let our_did =
|
||||
crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey).unwrap_or_default();
|
||||
|
||||
let message = TransportMessage {
|
||||
from_did: our_did,
|
||||
payload: payload.as_bytes().to_vec(),
|
||||
message_type: MessageType::PeerMessage,
|
||||
};
|
||||
|
||||
let transport_used = router.send_to_peer(&did, &message).await?;
|
||||
|
||||
info!(did = %did, transport = %transport_used, "Sent message via transport");
|
||||
Ok(serde_json::json!({
|
||||
"sent": true,
|
||||
"transport_used": transport_used.to_string(),
|
||||
"did": did,
|
||||
}))
|
||||
}
|
||||
|
||||
/// transport.preferences — Return the user's per-service transport
|
||||
/// preferences. The UI renders these as five FIPS/Auto/Tor rows.
|
||||
pub(super) async fn handle_transport_preferences(&self) -> Result<serde_json::Value> {
|
||||
let prefs = crate::settings::transport::snapshot().await;
|
||||
Ok(serde_json::to_value(prefs)?)
|
||||
}
|
||||
|
||||
/// transport.set-preference — Change a single service preference.
|
||||
/// Persists to disk and hot-swaps the in-memory handle so future
|
||||
/// calls see the new value without restart.
|
||||
pub(super) async fn handle_transport_set_preference(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
use crate::settings::transport::{set, PeerService, TransportPref};
|
||||
let params = params
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let service: PeerService = serde_json::from_value(
|
||||
params
|
||||
.get("service")
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'service' param"))?,
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("Invalid service: {}", e))?;
|
||||
let pref: TransportPref = serde_json::from_value(
|
||||
params
|
||||
.get("pref")
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'pref' param"))?,
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("Invalid pref: {}", e))?;
|
||||
|
||||
set(&self.config.data_dir, service, pref).await?;
|
||||
info!(service = ?service, pref = ?pref, "Transport preference updated");
|
||||
|
||||
let current = crate::settings::transport::snapshot().await;
|
||||
Ok(serde_json::to_value(current)?)
|
||||
}
|
||||
|
||||
/// transport.set-mode — Toggle mesh-only (off-grid) mode.
|
||||
pub(super) async fn handle_transport_set_mode(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let mesh_only = params["mesh_only"]
|
||||
.as_bool()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'mesh_only' bool param"))?;
|
||||
|
||||
let router = self.transport_router.read().await;
|
||||
let router = router
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Transport router not initialized"))?;
|
||||
|
||||
router.set_mesh_only(mesh_only).await;
|
||||
|
||||
// Also persist to mesh config
|
||||
let mut mesh_config = crate::mesh::load_config(&self.config.data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
mesh_config.mesh_only_mode = Some(mesh_only);
|
||||
crate::mesh::save_config(&self.config.data_dir, &mesh_config).await?;
|
||||
|
||||
info!(mesh_only = mesh_only, "Transport mode updated");
|
||||
Ok(serde_json::json!({
|
||||
"mesh_only": mesh_only,
|
||||
"configured": true,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
use super::RpcHandler;
|
||||
use crate::update;
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
impl RpcHandler {
|
||||
/// Check for available system updates.
|
||||
/// Prefer manifest-based OTA so installed nodes with a checked-out repo do
|
||||
/// not depend on a potentially stale git remote. Git remains a dev fallback.
|
||||
pub(super) async fn handle_update_check(&self) -> Result<serde_json::Value> {
|
||||
let state = update::check_for_updates(&self.config.data_dir).await?;
|
||||
|
||||
let update_info = state.available_update.as_ref().map(|u| {
|
||||
serde_json::json!({
|
||||
"version": u.version,
|
||||
"release_date": u.release_date,
|
||||
"changelog": u.changelog,
|
||||
"components": u.components.len(),
|
||||
})
|
||||
});
|
||||
|
||||
if update_info.is_some() {
|
||||
return Ok(serde_json::json!({
|
||||
"current_version": state.current_version,
|
||||
"last_check": state.last_check,
|
||||
"update_available": true,
|
||||
"update": update_info,
|
||||
"manifest_mirror": state.manifest_mirror,
|
||||
}));
|
||||
}
|
||||
|
||||
let repo_dir = std::path::PathBuf::from(
|
||||
std::env::var("HOME").unwrap_or_else(|_| "/home/archipelago".to_string()),
|
||||
)
|
||||
.join("archy");
|
||||
if std::env::var("ARCHIPELAGO_GIT_UPDATES").is_ok() && repo_dir.join(".git").exists() {
|
||||
if let Ok(git_status) = self.git_check_update(&repo_dir).await {
|
||||
return Ok(git_status);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"current_version": state.current_version,
|
||||
"last_check": state.last_check,
|
||||
"update_available": false,
|
||||
"update": update_info,
|
||||
"manifest_mirror": state.manifest_mirror,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Git-based update check: runs `git fetch` and compares HEAD to origin/main.
|
||||
async fn git_check_update(&self, repo_dir: &std::path::Path) -> Result<serde_json::Value> {
|
||||
let repo_str = repo_dir.to_string_lossy().to_string();
|
||||
|
||||
// git fetch origin main
|
||||
let fetch = tokio::process::Command::new("git")
|
||||
.args(["fetch", "origin", "main", "--quiet"])
|
||||
.current_dir(&repo_str)
|
||||
.output()
|
||||
.await
|
||||
.context("git fetch failed")?;
|
||||
|
||||
if !fetch.status.success() {
|
||||
anyhow::bail!(
|
||||
"git fetch failed: {}",
|
||||
String::from_utf8_lossy(&fetch.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
// Get local and remote HEADs
|
||||
let local = tokio::process::Command::new("git")
|
||||
.args(["rev-parse", "--short", "HEAD"])
|
||||
.current_dir(&repo_str)
|
||||
.output()
|
||||
.await?;
|
||||
let local_hash = String::from_utf8_lossy(&local.stdout).trim().to_string();
|
||||
|
||||
let remote = tokio::process::Command::new("git")
|
||||
.args(["rev-parse", "--short", "origin/main"])
|
||||
.current_dir(&repo_str)
|
||||
.output()
|
||||
.await?;
|
||||
let remote_hash = String::from_utf8_lossy(&remote.stdout).trim().to_string();
|
||||
|
||||
let update_available = local_hash != remote_hash;
|
||||
|
||||
// Get commit count and changelog if update available
|
||||
let mut changelog = Vec::new();
|
||||
let mut commits_behind = 0u64;
|
||||
if update_available {
|
||||
let count = tokio::process::Command::new("git")
|
||||
.args(["rev-list", "HEAD..origin/main", "--count"])
|
||||
.current_dir(&repo_str)
|
||||
.output()
|
||||
.await?;
|
||||
commits_behind = String::from_utf8_lossy(&count.stdout)
|
||||
.trim()
|
||||
.parse()
|
||||
.unwrap_or(0);
|
||||
|
||||
let log = tokio::process::Command::new("git")
|
||||
.args([
|
||||
"log",
|
||||
"HEAD..origin/main",
|
||||
"--oneline",
|
||||
"--no-merges",
|
||||
"-20",
|
||||
])
|
||||
.current_dir(&repo_str)
|
||||
.output()
|
||||
.await?;
|
||||
changelog = String::from_utf8_lossy(&log.stdout)
|
||||
.lines()
|
||||
.map(|l| l.to_string())
|
||||
.collect();
|
||||
}
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"current_version": local_hash,
|
||||
"last_check": now,
|
||||
"update_available": update_available,
|
||||
"update_method": "git",
|
||||
"update": if update_available {
|
||||
Some(serde_json::json!({
|
||||
"version": remote_hash,
|
||||
"commits_behind": commits_behind,
|
||||
"changelog": changelog,
|
||||
}))
|
||||
} else { None },
|
||||
}))
|
||||
}
|
||||
|
||||
/// Apply git-based update: runs self-update.sh which pulls, builds, and restarts.
|
||||
pub(super) async fn handle_update_git_apply(&self) -> Result<serde_json::Value> {
|
||||
if std::env::var("ARCHIPELAGO_GIT_UPDATES").is_err() {
|
||||
anyhow::bail!("git/self-build updates are disabled; use manifest OTA updates instead");
|
||||
}
|
||||
|
||||
let script = std::path::PathBuf::from(
|
||||
std::env::var("HOME").unwrap_or_else(|_| "/home/archipelago".to_string()),
|
||||
)
|
||||
.join("archy/scripts/self-update.sh");
|
||||
|
||||
if !script.exists() {
|
||||
anyhow::bail!("self-update.sh not found at {}", script.display());
|
||||
}
|
||||
|
||||
// Spawn the update script in the background (it will restart the service)
|
||||
let child = tokio::process::Command::new("bash")
|
||||
.arg(&script)
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.context("Failed to spawn self-update.sh")?;
|
||||
|
||||
tracing::info!(pid = child.id(), "Self-update script spawned");
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"started": true,
|
||||
"message": "Update started. The service will restart when complete.",
|
||||
}))
|
||||
}
|
||||
|
||||
/// Get update status without checking remote.
|
||||
pub(super) async fn handle_update_status(&self) -> Result<serde_json::Value> {
|
||||
let state = update::get_status(&self.config.data_dir).await?;
|
||||
// Expose live download progress so the UI can resume the
|
||||
// progress bar after navigation instead of showing the fake
|
||||
// creep again. An RPC poll every ~1s during download drives a
|
||||
// real progress indicator that survives route changes.
|
||||
let downloaded = update::DOWNLOAD_BYTES.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let total = update::DOWNLOAD_TOTAL.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let active = total > 0 && downloaded < total;
|
||||
let completed = total > 0 && downloaded >= total;
|
||||
|
||||
// Stall detection: if the progress-at timestamp hasn't advanced
|
||||
// for 30+ seconds while active, the download is wedged (usually
|
||||
// HTTP stream silently dropped and reqwest is waiting out its
|
||||
// read timeout). The UI uses this to surface a Cancel button
|
||||
// with explanatory copy.
|
||||
let stalled = if active {
|
||||
let last_at = update::DOWNLOAD_PROGRESS_AT.load(std::sync::atomic::Ordering::Relaxed);
|
||||
if last_at > 0 {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
now.saturating_sub(last_at) > 30_000
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"current_version": state.current_version,
|
||||
"last_check": state.last_check,
|
||||
"update_available": state.available_update.is_some(),
|
||||
"update_in_progress": state.update_in_progress,
|
||||
"rollback_available": state.rollback_available,
|
||||
"manifest_mirror": state.manifest_mirror,
|
||||
"download_progress": if active || completed {
|
||||
Some(serde_json::json!({
|
||||
"bytes_downloaded": downloaded,
|
||||
"total_bytes": total,
|
||||
"active": active,
|
||||
"stalled": stalled,
|
||||
}))
|
||||
} else { None },
|
||||
}))
|
||||
}
|
||||
|
||||
/// Dismiss the update notification.
|
||||
pub(super) async fn handle_update_dismiss(&self) -> Result<serde_json::Value> {
|
||||
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,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Cancel an in-flight or stuck download. Clears the live counters
|
||||
/// and staging dir so the UI returns to the "Download Update" state.
|
||||
pub(super) async fn handle_update_cancel_download(&self) -> Result<serde_json::Value> {
|
||||
update::cancel_download(&self.config.data_dir).await?;
|
||||
Ok(serde_json::json!({ "canceled": true }))
|
||||
}
|
||||
|
||||
/// 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 }))
|
||||
}
|
||||
|
||||
/// List configured update mirrors in priority order.
|
||||
pub(super) async fn handle_update_list_mirrors(&self) -> Result<serde_json::Value> {
|
||||
let list = update::load_mirrors(&self.config.data_dir).await?;
|
||||
Ok(serde_json::json!({ "mirrors": list }))
|
||||
}
|
||||
|
||||
/// Report the node's swarm prefs (fetch source + whether it provides to the
|
||||
/// swarm) plus swarm capability, so the UI can show whether DHT mode is
|
||||
/// actually usable on this build.
|
||||
pub(super) async fn handle_update_get_source(&self) -> Result<serde_json::Value> {
|
||||
let source = update::load_update_source(&self.config.data_dir).await;
|
||||
let provide_dht = update::load_provide_dht(&self.config.data_dir).await;
|
||||
let source_str = match source {
|
||||
update::UpdateSource::Origin => "origin",
|
||||
update::UpdateSource::Swarm => "swarm",
|
||||
};
|
||||
Ok(serde_json::json!({
|
||||
"source": source_str,
|
||||
// Whether this node seeds/serves blobs to peers (default true).
|
||||
"provide_dht": provide_dht,
|
||||
// Compiled with the iroh swarm engine? If false, "swarm" mode has no
|
||||
// peers and silently behaves like origin.
|
||||
"swarm_available": cfg!(feature = "iroh-swarm"),
|
||||
// Runtime swarm-assist gate from config (ARCHIPELAGO_SWARM_ENABLED).
|
||||
"swarm_enabled": self.config.swarm_enabled,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Update the node's swarm prefs. Params (both optional, at least one):
|
||||
/// `{ source?: "origin" | "swarm", provide?: bool }`.
|
||||
pub(super) async fn handle_update_set_source(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let mut touched = false;
|
||||
if let Some(s) = params.get("source").and_then(|v| v.as_str()) {
|
||||
let source = match s {
|
||||
"origin" => update::UpdateSource::Origin,
|
||||
"swarm" => update::UpdateSource::Swarm,
|
||||
_ => anyhow::bail!("source must be \"origin\" or \"swarm\""),
|
||||
};
|
||||
update::save_update_source(&self.config.data_dir, source).await?;
|
||||
touched = true;
|
||||
}
|
||||
if let Some(provide) = params.get("provide").and_then(|v| v.as_bool()) {
|
||||
update::save_provide_dht(&self.config.data_dir, provide).await?;
|
||||
touched = true;
|
||||
}
|
||||
if !touched {
|
||||
anyhow::bail!("expected \"source\" and/or \"provide\"");
|
||||
}
|
||||
self.handle_update_get_source().await
|
||||
}
|
||||
|
||||
/// Add a mirror to the end of the list. Params: `{ url, label? }`.
|
||||
/// Duplicates (same URL) are replaced rather than added twice.
|
||||
pub(super) async fn handle_update_add_mirror(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let url = params
|
||||
.get("url")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("missing url"))?
|
||||
.trim()
|
||||
.to_string();
|
||||
if !url.starts_with("http://") && !url.starts_with("https://") {
|
||||
anyhow::bail!("url must start with http:// or https://");
|
||||
}
|
||||
let label = params
|
||||
.get("label")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
let mut list = update::load_mirrors(&self.config.data_dir).await?;
|
||||
list.retain(|m| m.url != url);
|
||||
list.push(update::UpdateMirror { url, label });
|
||||
update::save_mirrors(&self.config.data_dir, &list).await?;
|
||||
Ok(serde_json::json!({ "mirrors": list }))
|
||||
}
|
||||
|
||||
/// Remove a mirror by URL. Params: `{ url }`.
|
||||
pub(super) async fn handle_update_remove_mirror(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let url = params
|
||||
.get("url")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("missing url"))?;
|
||||
let mut list = update::load_mirrors(&self.config.data_dir).await?;
|
||||
list.retain(|m| m.url != url);
|
||||
update::save_mirrors(&self.config.data_dir, &list).await?;
|
||||
Ok(serde_json::json!({ "mirrors": list }))
|
||||
}
|
||||
|
||||
/// Ping a mirror's manifest URL. Returns reachability, wall-clock
|
||||
/// latency, and HTTP status. Params: `{ url }`.
|
||||
pub(super) async fn handle_update_test_mirror(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let url = params
|
||||
.get("url")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("missing url"))?;
|
||||
let result = update::test_mirror(url).await;
|
||||
Ok(serde_json::to_value(result)?)
|
||||
}
|
||||
|
||||
/// Move a mirror to the top of the list so it's tried first.
|
||||
/// Params: `{ url }`.
|
||||
pub(super) async fn handle_update_set_primary_mirror(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let url = params
|
||||
.get("url")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("missing url"))?;
|
||||
let mut list = update::load_mirrors(&self.config.data_dir).await?;
|
||||
let Some(idx) = list.iter().position(|m| m.url == url) else {
|
||||
anyhow::bail!("mirror not in list");
|
||||
};
|
||||
let entry = list.remove(idx);
|
||||
list.insert(0, entry);
|
||||
update::save_mirrors(&self.config.data_dir, &list).await?;
|
||||
Ok(serde_json::json!({ "mirrors": list }))
|
||||
}
|
||||
|
||||
/// 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 }))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,761 @@
|
||||
use super::RpcHandler;
|
||||
use crate::vpn;
|
||||
use anyhow::Result;
|
||||
use tracing::info;
|
||||
|
||||
impl RpcHandler {
|
||||
/// vpn.status — Get current VPN connection status.
|
||||
pub(super) async fn handle_vpn_status(&self) -> Result<serde_json::Value> {
|
||||
let status = vpn::get_status().await;
|
||||
let config = vpn::load_config(&self.config.data_dir).await?;
|
||||
|
||||
// Check WireGuard wg0 interface for its IP
|
||||
let wg_ip = match tokio::process::Command::new("ip")
|
||||
.args(["-4", "addr", "show", "wg0"])
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
Ok(o) => {
|
||||
let stdout = String::from_utf8_lossy(&o.stdout).to_string();
|
||||
let parsed = stdout
|
||||
.lines()
|
||||
.find(|l| l.contains("inet "))
|
||||
.and_then(|l| l.split_whitespace().nth(1))
|
||||
.map(|ip| ip.split('/').next().unwrap_or(ip).to_string());
|
||||
if parsed.is_none() && !stdout.is_empty() {
|
||||
tracing::debug!("wg0 exists but no inet address found");
|
||||
}
|
||||
// Fallback: if wg0 exists but has no server IP, read from config
|
||||
parsed.or_else(|| {
|
||||
// If wg0 link is up, report the static server IP
|
||||
if stdout.contains("UP") || stdout.contains("POINTOPOINT") {
|
||||
Some("10.44.0.1".to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
Err(_) => None,
|
||||
};
|
||||
|
||||
let node_npub = vpn::read_nvpn_config_value("nostr", "public_key")
|
||||
.await
|
||||
.map(|k| vpn::ensure_npub(&k));
|
||||
let (relay_onion, relay_direct) = vpn::get_relay_urls().await;
|
||||
// Prefer onion (always works), fall back to direct IP
|
||||
let relay_url = relay_onion.clone().or(relay_direct.clone());
|
||||
|
||||
// Standalone WireGuard public key
|
||||
let wg_pubkey = tokio::fs::read_to_string("/var/lib/archipelago/wireguard/public.key")
|
||||
.await
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string());
|
||||
|
||||
// Check if nvpn0 tunnel interface actually exists and has an IP
|
||||
let nvpn0_ip = tokio::process::Command::new("ip")
|
||||
.args(["-4", "addr", "show", "nvpn0"])
|
||||
.output()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|o| {
|
||||
let out = String::from_utf8_lossy(&o.stdout).to_string();
|
||||
out.lines()
|
||||
.find(|l| l.contains("inet "))
|
||||
.and_then(|l| l.split_whitespace().nth(1))
|
||||
.map(|s| s.split('/').next().unwrap_or(s).to_string())
|
||||
});
|
||||
|
||||
// NostrVPN IP: only report if nvpn0 tunnel is actually up with its own IP,
|
||||
// and that IP is distinct from the standalone WireGuard IP
|
||||
let nvpn_ip = nvpn0_ip.as_ref().and_then(|ip| {
|
||||
if wg_ip.as_deref() == Some(ip.as_str()) {
|
||||
None
|
||||
} else {
|
||||
Some(ip.clone())
|
||||
}
|
||||
});
|
||||
|
||||
// NostrVPN is connected only if its dedicated tunnel (nvpn0) has a distinct IP
|
||||
let nvpn_connected = status.provider.as_deref() == Some("nostr-vpn") && nvpn_ip.is_some();
|
||||
|
||||
// connected = NostrVPN tunnel is up OR another VPN provider is active OR standalone WireGuard is up
|
||||
let is_connected = if status.provider.as_deref() == Some("nostr-vpn") {
|
||||
nvpn_connected || wg_ip.is_some()
|
||||
} else {
|
||||
status.connected || wg_ip.is_some()
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"connected": is_connected,
|
||||
"provider": status.provider,
|
||||
"interface": status.interface,
|
||||
"ip_address": nvpn_ip,
|
||||
"hostname": status.hostname,
|
||||
"peers_connected": status.peers_connected,
|
||||
"bytes_in": status.bytes_in,
|
||||
"bytes_out": status.bytes_out,
|
||||
"configured": config.enabled,
|
||||
"configured_provider": format!("{:?}", config.provider).to_lowercase(),
|
||||
"wg_ip": wg_ip,
|
||||
"wg_pubkey": wg_pubkey,
|
||||
"node_npub": node_npub,
|
||||
"relay_url": relay_url,
|
||||
"relay_onion": relay_onion,
|
||||
"relay_direct": relay_direct,
|
||||
}))
|
||||
}
|
||||
|
||||
/// vpn.configure — Configure VPN (Tailscale or WireGuard).
|
||||
pub(super) async fn handle_vpn_configure(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let provider = params
|
||||
.get("provider")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'provider' (tailscale or wireguard)"))?;
|
||||
|
||||
match provider {
|
||||
"tailscale" => {
|
||||
let auth_key = params
|
||||
.get("auth_key")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'auth_key' for Tailscale"))?;
|
||||
|
||||
vpn::configure_tailscale(auth_key, &self.config.data_dir).await?;
|
||||
info!("Tailscale VPN configured");
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"configured": true,
|
||||
"provider": "tailscale",
|
||||
}))
|
||||
}
|
||||
"wireguard" => {
|
||||
let address = params
|
||||
.get("address")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("10.0.0.1/24");
|
||||
let dns = params
|
||||
.get("dns")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("1.1.1.1");
|
||||
|
||||
let peer = if let Some(peer_obj) = params.get("peer") {
|
||||
let public_key = peer_obj
|
||||
.get("public_key")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing peer public_key"))?;
|
||||
let endpoint = peer_obj
|
||||
.get("endpoint")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing peer endpoint"))?;
|
||||
let allowed_ips = peer_obj
|
||||
.get("allowed_ips")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("0.0.0.0/0");
|
||||
let keepalive = peer_obj
|
||||
.get("persistent_keepalive")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v as u16);
|
||||
|
||||
Some(vpn::WireGuardPeer {
|
||||
public_key: public_key.to_string(),
|
||||
endpoint: endpoint.to_string(),
|
||||
allowed_ips: allowed_ips.to_string(),
|
||||
persistent_keepalive: keepalive,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let wg_config =
|
||||
vpn::configure_wireguard(&self.config.data_dir, address, dns, peer).await?;
|
||||
|
||||
info!("WireGuard VPN configured");
|
||||
Ok(serde_json::json!({
|
||||
"configured": true,
|
||||
"provider": "wireguard",
|
||||
"public_key": wg_config.public_key,
|
||||
"address": wg_config.address,
|
||||
}))
|
||||
}
|
||||
_ => {
|
||||
anyhow::bail!(
|
||||
"Unknown provider: {} (expected tailscale or wireguard)",
|
||||
provider
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// remote.setup — One-click Tailscale remote access setup.
|
||||
/// Accepts an auth key, configures Tailscale, and restricts access to ports 80/443.
|
||||
pub(super) async fn handle_remote_setup(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let auth_key = params
|
||||
.get("auth_key")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'auth_key' — get one from https://login.tailscale.com/admin/settings/keys"))?;
|
||||
|
||||
// Configure Tailscale
|
||||
vpn::configure_tailscale(auth_key, &self.config.data_dir).await?;
|
||||
info!("Remote access: Tailscale configured");
|
||||
|
||||
// Set ACL-like port restrictions via iptables on tailscale0
|
||||
// Allow only HTTP (80) and HTTPS (443) on the Tailscale interface
|
||||
let restrict_cmds = [
|
||||
"sudo iptables -D INPUT -i tailscale0 -p tcp --dport 80 -j ACCEPT 2>/dev/null; true",
|
||||
"sudo iptables -D INPUT -i tailscale0 -p tcp --dport 443 -j ACCEPT 2>/dev/null; true",
|
||||
"sudo iptables -D INPUT -i tailscale0 -j DROP 2>/dev/null; true",
|
||||
"sudo iptables -A INPUT -i tailscale0 -p tcp --dport 80 -j ACCEPT",
|
||||
"sudo iptables -A INPUT -i tailscale0 -p tcp --dport 443 -j ACCEPT",
|
||||
"sudo iptables -A INPUT -i tailscale0 -p tcp -m state --state ESTABLISHED,RELATED -j ACCEPT",
|
||||
"sudo iptables -A INPUT -i tailscale0 -j DROP",
|
||||
];
|
||||
|
||||
for cmd in &restrict_cmds {
|
||||
let _ = tokio::process::Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(cmd)
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
info!("Remote access: Restricted Tailscale to ports 80/443");
|
||||
|
||||
// Get the Tailscale IP for display
|
||||
let status = vpn::get_status().await;
|
||||
let tailscale_ip = status.ip_address.clone().unwrap_or_default();
|
||||
let hostname = status.hostname.clone().unwrap_or_default();
|
||||
|
||||
// Build the remote access URL
|
||||
let remote_url = if !hostname.is_empty() {
|
||||
format!("http://{}", hostname)
|
||||
} else if !tailscale_ip.is_empty() {
|
||||
format!("http://{}", tailscale_ip)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"configured": true,
|
||||
"provider": "tailscale",
|
||||
"tailscale_ip": tailscale_ip,
|
||||
"hostname": hostname,
|
||||
"remote_url": remote_url,
|
||||
"ports_exposed": [80, 443],
|
||||
}))
|
||||
}
|
||||
|
||||
/// vpn.disconnect — Disable VPN.
|
||||
pub(super) async fn handle_vpn_disconnect(&self) -> Result<serde_json::Value> {
|
||||
let mut config = vpn::load_config(&self.config.data_dir).await?;
|
||||
config.enabled = false;
|
||||
vpn::save_config(&self.config.data_dir, &config).await?;
|
||||
|
||||
// Try to bring down the interface
|
||||
match config.provider {
|
||||
vpn::VpnProvider::Tailscale => {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["exec", "tailscale", "tailscale", "down"])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
vpn::VpnProvider::Wireguard => {
|
||||
let _ = tokio::process::Command::new("wg-quick")
|
||||
.args(["down", "wg0"])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
vpn::VpnProvider::NostrVpn => {
|
||||
let _ = tokio::process::Command::new("systemctl")
|
||||
.args(["stop", "nostr-vpn"])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
info!("VPN disconnected");
|
||||
Ok(serde_json::json!({ "disconnected": true }))
|
||||
}
|
||||
|
||||
/// vpn.invite — Generate a NostrVPN invite URL + QR for the mobile app.
|
||||
/// Optionally accepts `npub` param to add the phone as a participant in the same call.
|
||||
pub(super) async fn handle_vpn_invite(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
// If an npub was provided, add it as a participant first
|
||||
if let Some(ref p) = params {
|
||||
if let Some(peer_npub) = p.get("npub").and_then(|v| v.as_str()) {
|
||||
if !peer_npub.is_empty() {
|
||||
// Reuse add-participant logic
|
||||
self.handle_vpn_add_participant(Some(serde_json::json!({ "npub": peer_npub })))
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read nvpn config to build invite (convert hex to npub1 if needed)
|
||||
let npub = vpn::read_nvpn_config_value("nostr", "public_key")
|
||||
.await
|
||||
.map(|k| vpn::ensure_npub(&k))
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!("No Nostr public key in nvpn config — VPN not configured")
|
||||
})?;
|
||||
// network_id is in [[networks]] array — read first entry
|
||||
let network_id = vpn::read_nvpn_config_list_entry("networks", "network_id")
|
||||
.await
|
||||
.unwrap_or_else(|| "nostr-vpn".to_string());
|
||||
|
||||
// Read relays from config — filter out localhost relays (unreachable from phone)
|
||||
let relays = vpn::read_nvpn_config_list("nostr", "relays").await;
|
||||
let reachable: Vec<String> = relays
|
||||
.iter()
|
||||
.filter(|r| !r.contains("127.0.0.1") && !r.contains("localhost"))
|
||||
.cloned()
|
||||
.collect();
|
||||
let invite_relays = if reachable.is_empty() {
|
||||
vec![
|
||||
"wss://relay.damus.io".to_string(),
|
||||
"wss://relay.primal.net".to_string(),
|
||||
]
|
||||
} else {
|
||||
reachable
|
||||
};
|
||||
|
||||
// Build invite as base64-encoded JSON (nvpn v2 format, no padding)
|
||||
use base64::Engine;
|
||||
let invite_payload = serde_json::json!({
|
||||
"v": 2,
|
||||
"networkName": network_id,
|
||||
"networkId": network_id,
|
||||
"inviterNpub": npub,
|
||||
"inviterNodeName": "archipelago",
|
||||
"admins": [npub],
|
||||
"participants": [npub],
|
||||
"relays": invite_relays,
|
||||
});
|
||||
let invite_b64 = base64::engine::general_purpose::STANDARD_NO_PAD
|
||||
.encode(invite_payload.to_string().as_bytes());
|
||||
let invite_url = format!("nvpn://invite/{}", invite_b64);
|
||||
|
||||
// Generate QR code
|
||||
let qr = qrcode::QrCode::new(invite_url.as_bytes())
|
||||
.map_err(|e| anyhow::anyhow!("QR generation failed: {}", e))?;
|
||||
let svg = qr
|
||||
.render::<qrcode::render::svg::Color>()
|
||||
.min_dimensions(256, 256)
|
||||
.build();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"invite_url": invite_url,
|
||||
"qr_svg": svg,
|
||||
"npub": npub,
|
||||
"network_id": network_id,
|
||||
"relays": invite_relays,
|
||||
}))
|
||||
}
|
||||
|
||||
/// vpn.add-participant — Add an npub to the mesh network.
|
||||
pub(super) async fn handle_vpn_add_participant(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let npub = params
|
||||
.get("npub")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'npub'"))?;
|
||||
|
||||
// Validate npub format
|
||||
if !npub.starts_with("npub1") || npub.len() < 60 {
|
||||
anyhow::bail!("Invalid npub format");
|
||||
}
|
||||
|
||||
// Add participant by editing TOML config directly (nvpn set --participant replaces, not appends)
|
||||
for config_path in vpn::NVPN_CONFIG_PATHS {
|
||||
if let Ok(content) = tokio::fs::read_to_string(config_path).await {
|
||||
if let Ok(mut table) = content.parse::<toml::Table>() {
|
||||
if let Some(networks) = table.get_mut("networks").and_then(|v| v.as_array_mut())
|
||||
{
|
||||
for net in networks.iter_mut() {
|
||||
if let Some(net_table) = net.as_table_mut() {
|
||||
let participants = net_table
|
||||
.entry("participants")
|
||||
.or_insert_with(|| toml::Value::Array(vec![]));
|
||||
if let Some(arr) = participants.as_array_mut() {
|
||||
let npub_val = toml::Value::String(npub.to_string());
|
||||
if !arr.contains(&npub_val) {
|
||||
arr.push(npub_val);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(new_content) = toml::to_string_pretty(&table) {
|
||||
// Try direct write first; fall back to sudo cp for root-owned daemon config
|
||||
if tokio::fs::write(config_path, &new_content).await.is_ok() {
|
||||
info!("Added participant to {}", config_path);
|
||||
} else {
|
||||
// Write to temp file, then sudo cp to target
|
||||
let tmp = format!("/tmp/.nvpn-config-{}", std::process::id());
|
||||
if tokio::fs::write(&tmp, &new_content).await.is_ok() {
|
||||
let cp = tokio::process::Command::new("sudo")
|
||||
.args(["cp", &tmp, config_path])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::fs::remove_file(&tmp).await;
|
||||
match cp {
|
||||
Ok(ref out) if out.status.success() => {
|
||||
info!("Added participant to {} (via sudo)", config_path);
|
||||
}
|
||||
_ => {
|
||||
tracing::warn!(
|
||||
"Failed to write {} (even with sudo)",
|
||||
config_path
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restart daemon to pick up the new participant
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["systemctl", "restart", "nostr-vpn"])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
info!("VPN participant added: {}", npub);
|
||||
Ok(serde_json::json!({ "added": true, "npub": npub }))
|
||||
}
|
||||
|
||||
/// The host address a WireGuard peer should dial — prefer the configured
|
||||
/// host IP, then public-IP lookup, then first local address.
|
||||
async fn current_wg_endpoint_host(&self) -> String {
|
||||
if self.config.host_ip != "127.0.0.1" {
|
||||
return self.config.host_ip.clone();
|
||||
}
|
||||
tokio::process::Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg("curl -s --connect-timeout 5 https://api.ipify.org 2>/dev/null || hostname -I | awk '{print $1}'")
|
||||
.output()
|
||||
.await
|
||||
.ok()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| self.config.host_ip.clone())
|
||||
}
|
||||
|
||||
/// vpn.create-peer — Generate a WireGuard peer config + QR code for mobile devices.
|
||||
pub(super) async fn handle_vpn_create_peer(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or(serde_json::json!({}));
|
||||
let name = params
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Mobile");
|
||||
|
||||
// Check that wg0 is up (standalone WireGuard)
|
||||
let wg0_up = tokio::process::Command::new("ip")
|
||||
.args(["link", "show", "wg0"])
|
||||
.output()
|
||||
.await
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false);
|
||||
if !wg0_up {
|
||||
anyhow::bail!("WireGuard (wg0) is not running. Wait for first-boot to complete.");
|
||||
}
|
||||
|
||||
// Generate a keypair for the new peer using wg genkey/pubkey
|
||||
let genkey = tokio::process::Command::new("wg")
|
||||
.arg("genkey")
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("wg genkey failed: {}", e))?;
|
||||
if !genkey.status.success() {
|
||||
anyhow::bail!(
|
||||
"wg genkey failed: {}",
|
||||
String::from_utf8_lossy(&genkey.stderr)
|
||||
);
|
||||
}
|
||||
let peer_private = String::from_utf8_lossy(&genkey.stdout).trim().to_string();
|
||||
|
||||
let mut pubkey_cmd = tokio::process::Command::new("wg");
|
||||
pubkey_cmd.arg("pubkey");
|
||||
pubkey_cmd.stdin(std::process::Stdio::piped());
|
||||
pubkey_cmd.stdout(std::process::Stdio::piped());
|
||||
let mut pubkey_child = pubkey_cmd
|
||||
.spawn()
|
||||
.map_err(|e| anyhow::anyhow!("wg pubkey spawn failed: {}", e))?;
|
||||
if let Some(ref mut stdin) = pubkey_child.stdin {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
stdin.write_all(peer_private.as_bytes()).await?;
|
||||
stdin.shutdown().await?;
|
||||
}
|
||||
let pubkey_out = pubkey_child.wait_with_output().await?;
|
||||
let peer_public = String::from_utf8_lossy(&pubkey_out.stdout)
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
// Read server's WireGuard public key (standalone WG key, then fall back to nvpn)
|
||||
let server_pubkey = if let Ok(key) =
|
||||
tokio::fs::read_to_string("/var/lib/archipelago/wireguard/public.key").await
|
||||
{
|
||||
key.trim().to_string()
|
||||
} else {
|
||||
vpn::read_nvpn_config_value("node", "public_key")
|
||||
.await
|
||||
.ok_or_else(|| anyhow::anyhow!("Cannot read server public key"))?
|
||||
};
|
||||
|
||||
let endpoint = format!("{}:51820", self.current_wg_endpoint_host().await);
|
||||
|
||||
// Allocate a peer IP (simple: hash the peer name)
|
||||
let peer_num = (name.bytes().map(|b| b as u32).sum::<u32>() % 253) + 2;
|
||||
let peer_ip = format!("10.44.0.{}/32", peer_num);
|
||||
|
||||
// Build WireGuard config for the mobile device
|
||||
let wg_config = format!(
|
||||
"[Interface]\nPrivateKey = {}\nAddress = {}\nDNS = 1.1.1.1\n\n[Peer]\nPublicKey = {}\nEndpoint = {}\nAllowedIPs = 10.44.0.0/16\nPersistentKeepalive = 25\n",
|
||||
peer_private, peer_ip, server_pubkey, endpoint
|
||||
);
|
||||
|
||||
// Generate QR code as SVG
|
||||
let qr = qrcode::QrCode::new(wg_config.as_bytes())
|
||||
.map_err(|e| anyhow::anyhow!("QR generation failed: {}", e))?;
|
||||
let svg = qr
|
||||
.render::<qrcode::render::svg::Color>()
|
||||
.min_dimensions(256, 256)
|
||||
.build();
|
||||
|
||||
// Save peer info
|
||||
let peers_dir = self.config.data_dir.join("nostr-vpn/peers");
|
||||
tokio::fs::create_dir_all(&peers_dir).await.ok();
|
||||
let peer_info = serde_json::json!({
|
||||
"name": name,
|
||||
"public_key": peer_public,
|
||||
"ip": peer_ip,
|
||||
"config": wg_config,
|
||||
"created": chrono::Utc::now().to_rfc3339(),
|
||||
});
|
||||
tokio::fs::write(
|
||||
peers_dir.join(format!("{}.json", name.to_lowercase().replace(' ', "-"))),
|
||||
serde_json::to_string_pretty(&peer_info)?,
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
|
||||
// Add this peer to the server's WireGuard interface (managed by nvpn).
|
||||
// Try add-peer first; if wg0 doesn't exist, run setup then retry.
|
||||
let peer_filename = format!("{}.json", name.to_lowercase().replace(' ', "-"));
|
||||
let mut peer_added = false;
|
||||
for attempt in 0..2 {
|
||||
let add = tokio::process::Command::new("sudo")
|
||||
.args(["archipelago-wg", "add-peer", &peer_public, &peer_ip])
|
||||
.output()
|
||||
.await;
|
||||
match add {
|
||||
Ok(ref out) if out.status.success() => {
|
||||
peer_added = true;
|
||||
break;
|
||||
}
|
||||
Ok(ref out) => {
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
tracing::warn!("add-peer attempt {}: {}", attempt + 1, err);
|
||||
if attempt == 0 {
|
||||
// wg0 may not exist yet — try creating it
|
||||
let server_privkey = vpn::read_nvpn_config_value("node", "private_key")
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
if !server_privkey.is_empty() {
|
||||
let key_path = "/tmp/.wg-server-key";
|
||||
tokio::fs::write(key_path, &server_privkey).await.ok();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(
|
||||
key_path,
|
||||
std::fs::Permissions::from_mode(0o600),
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["archipelago-wg", "setup", key_path])
|
||||
.output()
|
||||
.await;
|
||||
tokio::fs::remove_file(key_path).await.ok();
|
||||
}
|
||||
// Brief pause before retry
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("add-peer command error: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !peer_added {
|
||||
let _ = tokio::fs::remove_file(peers_dir.join(&peer_filename)).await;
|
||||
anyhow::bail!(
|
||||
"Failed to register peer with WireGuard. Check that wg0 interface is up."
|
||||
);
|
||||
}
|
||||
|
||||
info!("VPN peer created: {} ({})", name, peer_ip);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"name": name,
|
||||
"peer_ip": peer_ip,
|
||||
"config": wg_config,
|
||||
"qr_svg": svg,
|
||||
"public_key": peer_public,
|
||||
}))
|
||||
}
|
||||
|
||||
/// vpn.list-peers — List configured VPN peers (WireGuard + NostrVPN participants).
|
||||
pub(super) async fn handle_vpn_list_peers(&self) -> Result<serde_json::Value> {
|
||||
let peers_dir = self.config.data_dir.join("nostr-vpn/peers");
|
||||
let mut peers = Vec::new();
|
||||
|
||||
// WireGuard manual peers (from JSON files)
|
||||
if let Ok(mut entries) = tokio::fs::read_dir(&peers_dir).await {
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
if entry
|
||||
.path()
|
||||
.extension()
|
||||
.map(|e| e == "json")
|
||||
.unwrap_or(false)
|
||||
{
|
||||
if let Ok(content) = tokio::fs::read_to_string(entry.path()).await {
|
||||
if let Ok(mut peer) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
peer.as_object_mut()
|
||||
.map(|o| o.insert("type".to_string(), "wireguard".into()));
|
||||
peers.push(peer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NostrVPN peer loading removed — standalone WireGuard only
|
||||
Ok(serde_json::json!({ "peers": peers }))
|
||||
}
|
||||
|
||||
/// vpn.peer-config — Retrieve stored config + QR for an existing peer.
|
||||
pub(super) async fn handle_vpn_peer_config(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let name = params
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'name'"))?;
|
||||
|
||||
let filename = format!("{}.json", name.to_lowercase().replace(' ', "-"));
|
||||
let peer_file = self.config.data_dir.join("nostr-vpn/peers").join(&filename);
|
||||
|
||||
let content = tokio::fs::read_to_string(&peer_file)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("Peer '{}' not found", name))?;
|
||||
let mut peer: serde_json::Value = serde_json::from_str(&content)?;
|
||||
|
||||
let stored = peer.get("config").and_then(|v| v.as_str()).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"No config stored for peer '{}' — recreate the device to get a new QR code",
|
||||
name
|
||||
)
|
||||
})?;
|
||||
|
||||
// The stored Endpoint is the node's address at creation time; after
|
||||
// the node moves networks it points at a dead IP and the QR produces
|
||||
// a tunnel that can never connect. Refresh it to the current address.
|
||||
let endpoint = format!("{}:51820", self.current_wg_endpoint_host().await);
|
||||
let config: String = stored
|
||||
.lines()
|
||||
.map(|l| {
|
||||
if l.trim_start().starts_with("Endpoint") {
|
||||
format!("Endpoint = {}", endpoint)
|
||||
} else {
|
||||
l.to_string()
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
if config != stored {
|
||||
if let Some(obj) = peer.as_object_mut() {
|
||||
obj.insert("config".to_string(), config.clone().into());
|
||||
}
|
||||
if let Ok(json) = serde_json::to_string_pretty(&peer) {
|
||||
if tokio::fs::write(&peer_file, json).await.is_ok() {
|
||||
info!("VPN peer '{}' endpoint refreshed to {}", name, endpoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let qr = qrcode::QrCode::new(config.as_bytes())
|
||||
.map_err(|e| anyhow::anyhow!("QR generation failed: {}", e))?;
|
||||
let svg = qr
|
||||
.render::<qrcode::render::svg::Color>()
|
||||
.min_dimensions(256, 256)
|
||||
.build();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"name": name,
|
||||
"peer_ip": peer.get("ip").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"config": config,
|
||||
"qr_svg": svg,
|
||||
}))
|
||||
}
|
||||
|
||||
/// vpn.remove-peer — Remove a VPN peer by name.
|
||||
pub(super) async fn handle_vpn_remove_peer(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let name = params
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'name'"))?;
|
||||
|
||||
let filename = format!("{}.json", name.to_lowercase().replace(' ', "-"));
|
||||
let peer_file = self.config.data_dir.join("nostr-vpn/peers").join(&filename);
|
||||
|
||||
// Read peer's public key before deleting, to remove from WireGuard interface
|
||||
let peer_pubkey = tokio::fs::read_to_string(&peer_file)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|c| serde_json::from_str::<serde_json::Value>(&c).ok())
|
||||
.and_then(|v| {
|
||||
v.get("public_key")
|
||||
.and_then(|k| k.as_str())
|
||||
.map(|s| s.to_string())
|
||||
});
|
||||
|
||||
if tokio::fs::remove_file(&peer_file).await.is_ok() {
|
||||
// Remove peer from WireGuard interface
|
||||
if let Some(pubkey) = peer_pubkey {
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["archipelago-wg", "remove-peer", &pubkey])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
info!("VPN peer removed: {}", name);
|
||||
Ok(serde_json::json!({ "removed": true }))
|
||||
} else {
|
||||
anyhow::bail!("Peer '{}' not found", name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
use super::RpcHandler;
|
||||
use crate::wallet::{ark_client, ecash, fedimint_client, profits};
|
||||
use anyhow::Result;
|
||||
|
||||
/// A Cashu token (NUT-00 `cashuA`/`cashuB`, or our legacy `cashuSend_` form)
|
||||
/// always starts with `cashu`. Fedimint ecash notes never do, so a non-`cashu`
|
||||
/// string is routed to the Fedimint reissue path.
|
||||
fn is_cashu_token(token: &str) -> bool {
|
||||
token.trim_start().starts_with("cashu")
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
pub(super) async fn handle_wallet_ecash_balance(&self) -> Result<serde_json::Value> {
|
||||
let wallet = ecash::load_wallet(&self.config.data_dir).await?;
|
||||
let cashu_sats = wallet.balance();
|
||||
// Spendable Fedimint balance too, so callers (e.g. the pay-for-file
|
||||
// pre-check) see funds available across BOTH backends (#3). Best-effort:
|
||||
// if fmcd isn't installed/joined this is just 0, never an error.
|
||||
let fedimint_sats =
|
||||
match fedimint_client::FedimintClient::from_node(&self.config.data_dir).await {
|
||||
Ok(client) => client.total_balance_sats().await.unwrap_or(0),
|
||||
Err(_) => 0,
|
||||
};
|
||||
// Spendable Ark (barkd) balance, same best-effort contract.
|
||||
let ark_sats = ark_client::spendable_sats_or_zero(&self.config.data_dir).await;
|
||||
Ok(serde_json::json!({
|
||||
// `balance_sats` stays Cashu-only for back-compat; `total_sats` is the
|
||||
// spendable amount across Cashu + Fedimint + Ark.
|
||||
"balance_sats": cashu_sats,
|
||||
"cashu_sats": cashu_sats,
|
||||
"fedimint_sats": fedimint_sats,
|
||||
"ark_sats": ark_sats,
|
||||
"total_sats": cashu_sats + fedimint_sats + ark_sats,
|
||||
"proof_count": wallet.proofs.iter().filter(|p| !p.spent && !p.reserved).count(),
|
||||
"mint_url": wallet.mint_url,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_wallet_ecash_mint(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let amount_sats = params
|
||||
.get("amount_sats")
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing amount_sats"))?;
|
||||
|
||||
if amount_sats == 0 || amount_sats > 1_000_000 {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Amount must be between 1 and 1,000,000 sats"
|
||||
));
|
||||
}
|
||||
|
||||
// Step 1: Get a mint quote (returns Lightning invoice)
|
||||
let quote = ecash::mint_quote(&self.config.data_dir, amount_sats).await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"quote_id": quote.quote,
|
||||
"bolt11": quote.request,
|
||||
"state": quote.state,
|
||||
"amount_sats": amount_sats,
|
||||
"message": "Pay the Lightning invoice, then call wallet.ecash-mint-claim with the quote_id",
|
||||
}))
|
||||
}
|
||||
|
||||
/// Claim minted tokens after paying the Lightning invoice.
|
||||
pub(super) async fn handle_wallet_ecash_mint_claim(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let quote_id = params
|
||||
.get("quote_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing quote_id"))?;
|
||||
let amount_sats = params
|
||||
.get("amount_sats")
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing amount_sats"))?;
|
||||
|
||||
let minted = ecash::mint_tokens(&self.config.data_dir, quote_id, amount_sats).await?;
|
||||
Ok(serde_json::json!({
|
||||
"minted_sats": minted,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_wallet_ecash_melt(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let bolt11 = params
|
||||
.get("bolt11")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing bolt11 (Lightning invoice)"))?;
|
||||
|
||||
// Step 1: Get melt quote
|
||||
let quote = ecash::melt_quote(&self.config.data_dir, bolt11).await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"quote_id": quote.quote,
|
||||
"amount_sats": quote.amount,
|
||||
"fee_reserve_sats": quote.fee_reserve,
|
||||
"total_needed_sats": quote.amount + quote.fee_reserve,
|
||||
"message": "Call wallet.ecash-melt-confirm with quote_id and bolt11 to execute",
|
||||
}))
|
||||
}
|
||||
|
||||
/// Confirm and execute a melt (pay Lightning invoice with ecash).
|
||||
pub(super) async fn handle_wallet_ecash_melt_confirm(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let quote_id = params
|
||||
.get("quote_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing quote_id"))?;
|
||||
let bolt11 = params
|
||||
.get("bolt11")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing bolt11"))?;
|
||||
|
||||
let melted = ecash::melt_tokens(&self.config.data_dir, quote_id, bolt11).await?;
|
||||
Ok(serde_json::json!({
|
||||
"melted_sats": melted,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_wallet_ecash_send(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let amount_sats = params
|
||||
.get("amount_sats")
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing amount_sats"))?;
|
||||
|
||||
let token_str = ecash::send_token(&self.config.data_dir, amount_sats).await?;
|
||||
Ok(serde_json::json!({
|
||||
"token": token_str,
|
||||
"amount_sats": amount_sats,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_wallet_ecash_receive(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let token = params
|
||||
.get("token")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing token"))?;
|
||||
|
||||
// Dual-ecash: one "Receive ecash" box accepts either a Cashu token
|
||||
// (redeemed at the mint) or Fedimint notes (reissued via the fmcd
|
||||
// sidecar). Detect by prefix and route accordingly.
|
||||
if is_cashu_token(token) {
|
||||
let amount = ecash::receive_token(&self.config.data_dir, token).await?;
|
||||
return Ok(serde_json::json!({
|
||||
"received_sats": amount,
|
||||
"kind": "cashu",
|
||||
}));
|
||||
}
|
||||
|
||||
let (amount, federation_id) =
|
||||
fedimint_client::reissue_into_any(&self.config.data_dir, token).await?;
|
||||
Ok(serde_json::json!({
|
||||
"received_sats": amount,
|
||||
"kind": "fedimint",
|
||||
"federation_id": federation_id,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_wallet_ecash_history(&self) -> Result<serde_json::Value> {
|
||||
// Unified history: Cashu transactions (tagged kind="cashu") + the local
|
||||
// Fedimint transaction log (kind="fedimint"), newest first. Previously
|
||||
// only Cashu was returned, so a Fedimint receive showed up nowhere.
|
||||
let wallet = ecash::load_wallet(&self.config.data_dir).await?;
|
||||
let mut transactions = wallet.transactions;
|
||||
transactions.extend(fedimint_client::load_fedimint_txs(&self.config.data_dir).await);
|
||||
// Ark movements from barkd (kind="ark"), best-effort like Fedimint.
|
||||
transactions.extend(ark_client::load_ark_txs(&self.config.data_dir).await);
|
||||
// Sort by RFC-3339 timestamp descending (string compare is valid for
|
||||
// same-offset RFC-3339), newest first.
|
||||
transactions.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
|
||||
Ok(serde_json::json!({
|
||||
"transactions": transactions,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_wallet_networking_profits(&self) -> Result<serde_json::Value> {
|
||||
let summary = profits::get_networking_profits(&self.config.data_dir).await?;
|
||||
Ok(serde_json::json!({
|
||||
"total_sats": summary.total_sats,
|
||||
"content_sales_sats": summary.content_sales_sats,
|
||||
"routing_fees_sats": summary.routing_fees_sats,
|
||||
"streaming_revenue_sats": summary.streaming_revenue_sats,
|
||||
"recent": summary.recent,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
use super::RpcHandler;
|
||||
use crate::webhooks;
|
||||
use anyhow::Result;
|
||||
use tracing::info;
|
||||
|
||||
/// Check if a hostname/IP points to a private or internal address.
|
||||
/// Handles: IPv4, IPv6 (including mapped IPv4 like ::ffff:127.0.0.1),
|
||||
/// decimal/octal IP representations, and well-known internal hostnames.
|
||||
fn is_webhook_host_private(host: &str) -> bool {
|
||||
// Strip IPv6 brackets if present
|
||||
let h = host.trim_start_matches('[').trim_end_matches(']');
|
||||
|
||||
// Check well-known internal hostnames
|
||||
let lower = h.to_lowercase();
|
||||
if lower == "localhost"
|
||||
|| lower == "localhost.localdomain"
|
||||
|| lower.ends_with(".local")
|
||||
|| lower.ends_with(".internal")
|
||||
|| lower == "metadata.google.internal"
|
||||
|| lower == "169.254.169.254"
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Try to parse as IP address
|
||||
if let Ok(ip) = h.parse::<std::net::IpAddr>() {
|
||||
return match ip {
|
||||
std::net::IpAddr::V4(v4) => {
|
||||
v4.is_loopback()
|
||||
|| v4.is_private()
|
||||
|| v4.is_link_local()
|
||||
|| v4.is_unspecified()
|
||||
|| v4.octets()[0] == 100 && (64..=127).contains(&v4.octets()[1])
|
||||
// CGNAT
|
||||
}
|
||||
std::net::IpAddr::V6(v6) => {
|
||||
if v6.is_loopback() || v6.is_unspecified() {
|
||||
return true;
|
||||
}
|
||||
// Check IPv4-mapped IPv6 (::ffff:x.x.x.x)
|
||||
if let Some(v4) = v6.to_ipv4_mapped() {
|
||||
return v4.is_loopback()
|
||||
|| v4.is_private()
|
||||
|| v4.is_link_local()
|
||||
|| v4.is_unspecified();
|
||||
}
|
||||
// Unique local (fd00::/8, fc00::/7)
|
||||
let segments = v6.segments();
|
||||
(segments[0] & 0xfe00) == 0xfc00 || (segments[0] & 0xffc0) == 0xfe80
|
||||
// link-local
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Detect decimal IP notation (e.g., "2130706433" = 127.0.0.1)
|
||||
if let Ok(decimal) = h.parse::<u32>() {
|
||||
let octets = decimal.to_be_bytes();
|
||||
let v4 = std::net::Ipv4Addr::new(octets[0], octets[1], octets[2], octets[3]);
|
||||
return v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified();
|
||||
}
|
||||
|
||||
// Detect octal IP notation (e.g., "0177.0.0.1" = 127.0.0.1)
|
||||
if h.contains('.') {
|
||||
let parts: Vec<&str> = h.split('.').collect();
|
||||
if parts.len() == 4 {
|
||||
let mut octets = [0u8; 4];
|
||||
let mut all_ok = true;
|
||||
for (i, part) in parts.iter().enumerate() {
|
||||
let val = if part.starts_with("0x") || part.starts_with("0X") {
|
||||
u64::from_str_radix(part.trim_start_matches("0x").trim_start_matches("0X"), 16)
|
||||
.ok()
|
||||
} else if part.starts_with('0') && part.len() > 1 {
|
||||
u64::from_str_radix(part, 8).ok()
|
||||
} else {
|
||||
part.parse::<u64>().ok()
|
||||
};
|
||||
match val {
|
||||
Some(v) if v <= 255 => octets[i] = v as u8,
|
||||
_ => {
|
||||
all_ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if all_ok {
|
||||
let v4 = std::net::Ipv4Addr::new(octets[0], octets[1], octets[2], octets[3]);
|
||||
return v4.is_loopback()
|
||||
|| v4.is_private()
|
||||
|| v4.is_link_local()
|
||||
|| v4.is_unspecified();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// webhook.get-config — Get current webhook configuration.
|
||||
pub(super) async fn handle_webhook_get_config(&self) -> Result<serde_json::Value> {
|
||||
let config = webhooks::load_config(&self.config.data_dir).await?;
|
||||
Ok(serde_json::json!({
|
||||
"enabled": config.enabled,
|
||||
"url": config.url,
|
||||
"events": config.events,
|
||||
"has_secret": config.secret.is_some(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// webhook.configure — Update webhook configuration.
|
||||
pub(super) async fn handle_webhook_configure(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
|
||||
let mut config = webhooks::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(url) = params.get("url").and_then(|v| v.as_str()) {
|
||||
// Validate webhook URL scheme and reject dangerous targets
|
||||
if !url.is_empty() {
|
||||
if url.len() > 2048 {
|
||||
anyhow::bail!("Webhook URL too long");
|
||||
}
|
||||
// Parse URL properly to handle edge cases (IPv6, userinfo, etc.)
|
||||
let parsed =
|
||||
reqwest::Url::parse(url).map_err(|_| anyhow::anyhow!("Invalid webhook URL"))?;
|
||||
// Require https:// in production
|
||||
if !self.config.dev_mode && parsed.scheme() != "https" {
|
||||
anyhow::bail!("Webhook URL must use HTTPS in production");
|
||||
}
|
||||
if parsed.scheme() != "https" && parsed.scheme() != "http" {
|
||||
anyhow::bail!("Webhook URL must use HTTP(S)");
|
||||
}
|
||||
// Reject URLs with userinfo (user:pass@host) — can be used for credential smuggling
|
||||
if parsed.username() != "" || parsed.password().is_some() {
|
||||
anyhow::bail!("Webhook URL must not contain credentials");
|
||||
}
|
||||
// Extract and validate the host
|
||||
let host = parsed.host_str().unwrap_or("");
|
||||
if host.is_empty() {
|
||||
anyhow::bail!("Webhook URL must have a valid host");
|
||||
}
|
||||
// Reject private/internal addresses (handle IPv4, IPv6, decimal/octal IPs, hostnames)
|
||||
let is_private = is_webhook_host_private(host);
|
||||
if is_private && !self.config.dev_mode {
|
||||
anyhow::bail!("Webhook URL must not point to private/local addresses");
|
||||
}
|
||||
}
|
||||
config.url = url.to_string();
|
||||
}
|
||||
if let Some(secret) = params.get("secret").and_then(|v| v.as_str()) {
|
||||
config.secret = if secret.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(secret.to_string())
|
||||
};
|
||||
}
|
||||
if let Some(events) = params.get("events") {
|
||||
if let Ok(parsed) =
|
||||
serde_json::from_value::<Vec<webhooks::WebhookEvent>>(events.clone())
|
||||
{
|
||||
config.events = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
webhooks::save_config(&self.config.data_dir, &config).await?;
|
||||
info!(
|
||||
"Webhook config updated: enabled={}, url={}",
|
||||
config.enabled, config.url
|
||||
);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"configured": true,
|
||||
"enabled": config.enabled,
|
||||
"url": config.url,
|
||||
}))
|
||||
}
|
||||
|
||||
/// webhook.test — Send a test webhook notification.
|
||||
pub(super) async fn handle_webhook_test(&self) -> Result<serde_json::Value> {
|
||||
let config = webhooks::load_config(&self.config.data_dir).await?;
|
||||
if !config.enabled || config.url.is_empty() {
|
||||
anyhow::bail!("Webhook is not configured. Set a URL and enable it first.");
|
||||
}
|
||||
|
||||
let payload = webhooks::WebhookPayload {
|
||||
event: webhooks::WebhookEvent::ContainerCrash,
|
||||
title: "Test Notification".to_string(),
|
||||
message: "This is a test webhook from your Archipelago node.".to_string(),
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
node_id: {
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
data.server_info.id
|
||||
},
|
||||
details: Some(serde_json::json!({"test": true})),
|
||||
};
|
||||
|
||||
webhooks::send_webhook(&self.config.data_dir, payload).await;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"sent": true,
|
||||
"url": config.url,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
//! Cross-layer registry of per-app lifecycle-operation locks and stack
|
||||
//! membership.
|
||||
//!
|
||||
//! The RPC layer's package.start/stop/restart workers serialize through
|
||||
//! these locks (FIFO, see api::rpc::package::runtime). Background actors
|
||||
//! (the reconciler; eventually the health monitor) must NOT act on an app
|
||||
//! while a lifecycle op is mid-sequence: the reconciler once saw a stack
|
||||
//! member "missing" between a restart worker's stop and start halves and
|
||||
//! repair-recreated it behind systemd's back, killing the worker's fresh
|
||||
//! container and leaving the unit down for minutes (.228 mempool frontend,
|
||||
//! gate 2026-07-09). This module lives outside both layers so each can
|
||||
//! consult the same state without an api ↔ container dependency cycle.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
static APP_OP_LOCKS: std::sync::LazyLock<
|
||||
std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
|
||||
> = std::sync::LazyLock::new(Default::default);
|
||||
|
||||
/// The per-app lifecycle-operation lock for a (normalized) app key. Workers
|
||||
/// take this as their first await; tokio's Mutex is fair (FIFO), so queued
|
||||
/// operations run in RPC arrival order and the final state matches the last
|
||||
/// request.
|
||||
pub fn op_lock(app_key: &str) -> Arc<tokio::sync::Mutex<()>> {
|
||||
APP_OP_LOCKS
|
||||
.lock()
|
||||
.expect("APP_OP_LOCKS poisoned")
|
||||
.entry(app_key.to_string())
|
||||
.or_default()
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Member APP ids (start order) for orchestrator-managed stacks. Every entry
|
||||
/// is a real manifest app id the orchestrator can `start()`/`stop()` so the
|
||||
/// quadlet .service is driven instead of raw podman racing systemd's --rm
|
||||
/// cleanup. Single source of truth — the RPC layer re-exports this.
|
||||
pub fn stack_member_app_ids(package_id: &str) -> &'static [&'static str] {
|
||||
match package_id {
|
||||
"immich" => &["immich-postgres", "immich-redis", "immich"],
|
||||
"indeedhub" => &[
|
||||
"indeedhub-postgres",
|
||||
"indeedhub-redis",
|
||||
"indeedhub-minio",
|
||||
"indeedhub-relay",
|
||||
"indeedhub-api",
|
||||
"indeedhub-ffmpeg",
|
||||
"indeedhub",
|
||||
],
|
||||
"btcpay-server" | "btcpayserver" | "btcpay" => {
|
||||
&["archy-btcpay-db", "archy-nbxplorer", "btcpay-server"]
|
||||
}
|
||||
"netbird" => &["netbird-server", "netbird-dashboard", "netbird"],
|
||||
"pine" => &["pine-whisper", "pine-piper", "pine-openwakeword", "pine"],
|
||||
// The legacy umbrella id maps to the split stack (the orchestrator's
|
||||
// umbrella alias handles this too; listing it here keeps the RPC
|
||||
// layer's fan-out explicit).
|
||||
"mempool" | "mempool-web" => &["archy-mempool-db", "mempool-api", "archy-mempool-web"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// Dependents that resolve a backend's container address once at startup and
|
||||
/// hold it: moving the backend's IP (restart OR recreate) strands them until
|
||||
/// they restart too. lnd dials the bitcoin RPC address it resolved at boot
|
||||
/// and never re-resolves (gate lnd getinfo test, .228 2026-07-09; hardening
|
||||
/// plan §C). The RPC start/restart workers and the reconciler both consult
|
||||
/// this — single source of truth, like the stack table above.
|
||||
pub fn address_caching_dependents(package_id: &str) -> &'static [&'static str] {
|
||||
match package_id {
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => &["lnd"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// The package whose lifecycle lock covers `app_id`: the stack package when
|
||||
/// `app_id` is a member (RPC ops on "mempool" hold the "mempool" lock while
|
||||
/// they drive archy-mempool-web), otherwise the app itself.
|
||||
fn owning_package(app_id: &str) -> &str {
|
||||
const STACKS: &[&str] = &[
|
||||
"immich",
|
||||
"indeedhub",
|
||||
"btcpay-server",
|
||||
"netbird",
|
||||
"mempool",
|
||||
"pine",
|
||||
];
|
||||
for stack in STACKS {
|
||||
if stack_member_app_ids(stack).contains(&app_id) {
|
||||
return stack;
|
||||
}
|
||||
}
|
||||
app_id
|
||||
}
|
||||
|
||||
/// True when a package.start/stop/restart worker currently holds the
|
||||
/// lifecycle lock covering `app_id` (under its own key or its owning stack
|
||||
/// package's key). Background actors use this to skip the app for a cycle
|
||||
/// instead of interleaving with the worker's multi-step sequence. try_lock
|
||||
/// on a fair tokio Mutex is non-blocking and does not queue.
|
||||
pub fn lifecycle_op_in_flight(app_id: &str) -> bool {
|
||||
let keys = [app_id, owning_package(app_id)];
|
||||
for key in keys {
|
||||
let lock = op_lock(key);
|
||||
let held = lock.try_lock().is_err();
|
||||
if held {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn owning_package_maps_members_to_stack() {
|
||||
assert_eq!(owning_package("archy-mempool-web"), "mempool");
|
||||
assert_eq!(owning_package("immich-postgres"), "immich");
|
||||
assert_eq!(owning_package("indeedhub-relay"), "indeedhub");
|
||||
assert_eq!(owning_package("archy-nbxplorer"), "btcpay-server");
|
||||
assert_eq!(owning_package("lnd"), "lnd");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn in_flight_reflects_held_package_lock() {
|
||||
assert!(!lifecycle_op_in_flight("archy-mempool-web"));
|
||||
let lock = op_lock("mempool");
|
||||
let _guard = lock.lock().await;
|
||||
assert!(lifecycle_op_in_flight("archy-mempool-web"));
|
||||
assert!(lifecycle_op_in_flight("mempool"));
|
||||
assert!(!lifecycle_op_in_flight("jellyfin"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
// Authentication module for Archipelago
|
||||
// Handles user setup, onboarding, and login
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use tokio::fs;
|
||||
|
||||
use crate::totp::TotpData;
|
||||
|
||||
/// User role for multi-user RBAC (Year 3 feature).
|
||||
/// - Admin: full access to all operations
|
||||
/// - Viewer: read-only dashboard, container status, monitoring
|
||||
/// - AppUser: access specific apps, no system configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[derive(Default)]
|
||||
pub enum UserRole {
|
||||
#[default]
|
||||
Admin,
|
||||
Viewer,
|
||||
AppUser,
|
||||
}
|
||||
|
||||
impl UserRole {
|
||||
/// Check if this role allows a given RPC method.
|
||||
pub fn can_access(&self, method: &str) -> bool {
|
||||
match self {
|
||||
UserRole::Admin => true,
|
||||
UserRole::Viewer => {
|
||||
// Read-only system methods (explicit allowlist — NOT prefix "system."
|
||||
// which would grant access to system.factory-reset, system.shutdown, etc.)
|
||||
method == "system.stats"
|
||||
|| method == "system.processes"
|
||||
|| method == "system.temperature"
|
||||
|| method == "system.disk-status"
|
||||
|| method == "system.detect-usb-devices"
|
||||
|| method == "node.did"
|
||||
|| method == "node.tor-address"
|
||||
|| method == "node.nostr-pubkey"
|
||||
|| method.starts_with("federation.list")
|
||||
|| method.starts_with("dwn.status")
|
||||
|| method.starts_with("dwn.list")
|
||||
|| method.starts_with("dwn.query")
|
||||
|| method.starts_with("identity.list")
|
||||
|| method.starts_with("identity.get")
|
||||
|| method.starts_with("backup.list")
|
||||
|| method == "container-list"
|
||||
|| method == "container-status"
|
||||
|| method == "container-health"
|
||||
|| method == "health"
|
||||
|| method == "auth.logout"
|
||||
}
|
||||
UserRole::AppUser => {
|
||||
// App access + basic read
|
||||
method.starts_with("system.stats")
|
||||
|| method == "node.did"
|
||||
|| method == "container-list"
|
||||
|| method == "container-status"
|
||||
|| method == "health"
|
||||
|| method == "auth.logout"
|
||||
|| method == "auth.changePassword"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct OnboardingState {
|
||||
complete: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct User {
|
||||
pub password_hash: String,
|
||||
pub setup_complete: bool,
|
||||
pub onboarding_complete: bool,
|
||||
#[serde(default)]
|
||||
pub totp: Option<TotpData>,
|
||||
/// User role for RBAC (defaults to Admin for backward compatibility)
|
||||
#[serde(default)]
|
||||
pub role: UserRole,
|
||||
}
|
||||
|
||||
pub struct AuthManager {
|
||||
data_dir: PathBuf,
|
||||
}
|
||||
|
||||
pub struct ChangePasswordOutcome {
|
||||
pub ssh_updated: bool,
|
||||
pub ssh_error: Option<String>,
|
||||
}
|
||||
|
||||
impl AuthManager {
|
||||
pub fn new(data_dir: PathBuf) -> Self {
|
||||
Self { data_dir }
|
||||
}
|
||||
|
||||
/// Ensure a default user exists on first boot.
|
||||
/// Called once at startup — creates user with default password if none exists.
|
||||
#[allow(dead_code)]
|
||||
pub async fn ensure_default_user(&self) -> Result<()> {
|
||||
if self.is_setup().await? {
|
||||
return Ok(());
|
||||
}
|
||||
tracing::info!(
|
||||
"[onboarding] no user found — creating default user (password: password123)"
|
||||
);
|
||||
self.setup_user("password123").await?;
|
||||
tracing::info!(
|
||||
"[onboarding] default user created — user should change password after login"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn is_setup(&self) -> Result<bool> {
|
||||
let user_file = self.data_dir.join("user.json");
|
||||
Ok(user_file.exists())
|
||||
}
|
||||
|
||||
pub async fn get_user(&self) -> Result<Option<User>> {
|
||||
let user_file = self.data_dir.join("user.json");
|
||||
if !user_file.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&user_file).await?;
|
||||
let user: User = serde_json::from_str(&content)?;
|
||||
Ok(Some(user))
|
||||
}
|
||||
|
||||
pub async fn setup_user(&self, password: &str) -> Result<()> {
|
||||
let password_hash = argon2id_hash(password)?;
|
||||
|
||||
// If onboarding was already completed (before setup), preserve that
|
||||
let onboarding_complete = self.is_onboarding_complete().await?;
|
||||
|
||||
let user = User {
|
||||
password_hash,
|
||||
setup_complete: true,
|
||||
onboarding_complete,
|
||||
totp: None,
|
||||
role: UserRole::default(),
|
||||
};
|
||||
|
||||
let user_file = self.data_dir.join("user.json");
|
||||
let content = serde_json::to_string_pretty(&user)?;
|
||||
fs::write(&user_file, content).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn complete_onboarding(&self) -> Result<()> {
|
||||
// Persist to onboarding.json (works even before user/setup exists)
|
||||
let onboarding_file = self.data_dir.join("onboarding.json");
|
||||
let state = OnboardingState { complete: true };
|
||||
fs::write(&onboarding_file, serde_json::to_string_pretty(&state)?).await?;
|
||||
// Also update user.json if it exists (keeps them in sync)
|
||||
if let Some(mut user) = self.get_user().await? {
|
||||
user.onboarding_complete = true;
|
||||
let user_file = self.data_dir.join("user.json");
|
||||
let content = serde_json::to_string_pretty(&user)?;
|
||||
fs::write(&user_file, content).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reset onboarding state so the user can go through onboarding again (dev/testing).
|
||||
pub async fn reset_onboarding(&self) -> Result<()> {
|
||||
let onboarding_file = self.data_dir.join("onboarding.json");
|
||||
let state = OnboardingState { complete: false };
|
||||
fs::write(&onboarding_file, serde_json::to_string_pretty(&state)?).await?;
|
||||
if let Some(mut user) = self.get_user().await? {
|
||||
user.onboarding_complete = false;
|
||||
let user_file = self.data_dir.join("user.json");
|
||||
let content = serde_json::to_string_pretty(&user)?;
|
||||
fs::write(&user_file, content).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn is_onboarding_complete(&self) -> Result<bool> {
|
||||
// Check onboarding.json first (persisted before user setup)
|
||||
let onboarding_file = self.data_dir.join("onboarding.json");
|
||||
if onboarding_file.exists() {
|
||||
let content = fs::read_to_string(&onboarding_file).await?;
|
||||
if let Ok(state) = serde_json::from_str::<OnboardingState>(&content) {
|
||||
if state.complete {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: user.json. A node that has a password set AND
|
||||
// setup_complete=true has been through onboarding by
|
||||
// definition — you can't reach the password-set step any
|
||||
// other way. The separate `onboarding_complete` flag can drift
|
||||
// out of sync (e.g. the completion RPC never reached disk, or
|
||||
// the node was seeded from a backup pre-dating the flag), so
|
||||
// auto-heal by inferring from setup_complete + password_hash.
|
||||
// Without this, a fully-onboarded node whose `onboarding_complete`
|
||||
// is stuck false will force its user back through the intro
|
||||
// wizard on every cleared browser cache.
|
||||
if let Some(u) = self.get_user().await? {
|
||||
if u.onboarding_complete {
|
||||
return Ok(true);
|
||||
}
|
||||
if u.setup_complete && !u.password_hash.is_empty() {
|
||||
// Persist the healed state so subsequent calls skip this
|
||||
// inference. Ignore write errors — returning true is
|
||||
// still correct even if we can't persist.
|
||||
let healed = OnboardingState { complete: true };
|
||||
if let Ok(json) = serde_json::to_string_pretty(&healed) {
|
||||
let _ = fs::write(&onboarding_file, json).await;
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Check if 2FA is enabled for the user.
|
||||
pub async fn is_totp_enabled(&self) -> Result<bool> {
|
||||
Ok(self
|
||||
.get_user()
|
||||
.await?
|
||||
.map(|u| u.totp.is_some())
|
||||
.unwrap_or(false))
|
||||
}
|
||||
|
||||
/// Get the TOTP data (if 2FA is enabled).
|
||||
pub async fn get_totp_data(&self) -> Result<Option<TotpData>> {
|
||||
Ok(self.get_user().await?.and_then(|u| u.totp))
|
||||
}
|
||||
|
||||
/// Save TOTP data to user.json (enable 2FA).
|
||||
pub async fn save_totp(&self, totp_data: TotpData) -> Result<()> {
|
||||
let mut user = self
|
||||
.get_user()
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("User not set up"))?;
|
||||
user.totp = Some(totp_data);
|
||||
let user_file = self.data_dir.join("user.json");
|
||||
fs::write(&user_file, serde_json::to_string_pretty(&user)?).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove TOTP data from user.json (disable 2FA).
|
||||
pub async fn remove_totp(&self) -> Result<()> {
|
||||
let mut user = self
|
||||
.get_user()
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("User not set up"))?;
|
||||
user.totp = None;
|
||||
let user_file = self.data_dir.join("user.json");
|
||||
fs::write(&user_file, serde_json::to_string_pretty(&user)?).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update TOTP data in place (e.g. after consuming a backup code or recording a used step).
|
||||
pub async fn update_totp(&self, totp_data: TotpData) -> Result<()> {
|
||||
self.save_totp(totp_data).await
|
||||
}
|
||||
|
||||
pub async fn verify_password(&self, password: &str) -> Result<bool> {
|
||||
if let Some(user) = self.get_user().await? {
|
||||
// Detect hash format and verify accordingly
|
||||
if user.password_hash.starts_with("$2") {
|
||||
// Legacy bcrypt hash — verify then auto-upgrade to Argon2id
|
||||
let valid = bcrypt::verify(password, &user.password_hash)?;
|
||||
if valid {
|
||||
// Transparent upgrade: re-hash with Argon2id on successful login
|
||||
let new_hash = argon2id_hash(password)?;
|
||||
let mut upgraded = user.clone();
|
||||
upgraded.password_hash = new_hash;
|
||||
let user_file = self.data_dir.join("user.json");
|
||||
fs::write(&user_file, serde_json::to_string_pretty(&upgraded)?).await?;
|
||||
tracing::info!("Upgraded password hash from bcrypt to Argon2id");
|
||||
}
|
||||
Ok(valid)
|
||||
} else {
|
||||
// Argon2id hash (PHC string format: $argon2id$...)
|
||||
Ok(argon2id_verify(password, &user.password_hash))
|
||||
}
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Change password: verify current, validate new, update user.json and optionally SSH.
|
||||
/// New password must be 12+ chars with upper, lower, digit, and special character.
|
||||
pub async fn change_password(
|
||||
&self,
|
||||
current_password: &str,
|
||||
new_password: &str,
|
||||
also_change_ssh: bool,
|
||||
) -> Result<ChangePasswordOutcome> {
|
||||
if !self.verify_password(current_password).await? {
|
||||
anyhow::bail!("Current password is incorrect");
|
||||
}
|
||||
|
||||
validate_password_strength(new_password)?;
|
||||
|
||||
let password_hash = argon2id_hash(new_password)?;
|
||||
|
||||
let mut user = self
|
||||
.get_user()
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("User not set up"))?;
|
||||
|
||||
user.password_hash = password_hash;
|
||||
|
||||
// Re-encrypt TOTP MEK under new password if 2FA is enabled
|
||||
if let Some(ref totp_data) = user.totp {
|
||||
let rekeyed = crate::totp::rekey(totp_data, current_password, new_password)?;
|
||||
user.totp = Some(rekeyed);
|
||||
}
|
||||
|
||||
let user_file = self.data_dir.join("user.json");
|
||||
let content = serde_json::to_string_pretty(&user)?;
|
||||
fs::write(&user_file, content).await?;
|
||||
|
||||
let mut outcome = ChangePasswordOutcome {
|
||||
ssh_updated: false,
|
||||
ssh_error: None,
|
||||
};
|
||||
if also_change_ssh {
|
||||
match change_ssh_password(new_password).await {
|
||||
Ok(()) => outcome.ssh_updated = true,
|
||||
Err(e) => {
|
||||
tracing::warn!("Web password changed but SSH password update failed: {}", e);
|
||||
outcome.ssh_error = Some(e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(outcome)
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate password strength: 12+ chars, upper, lower, digit, special.
|
||||
fn validate_password_strength(password: &str) -> Result<()> {
|
||||
if password.len() < 12 {
|
||||
anyhow::bail!("Password must be at least 12 characters");
|
||||
}
|
||||
if !password.chars().any(|c| c.is_ascii_uppercase()) {
|
||||
anyhow::bail!("Password must contain at least one uppercase letter");
|
||||
}
|
||||
if !password.chars().any(|c| c.is_ascii_lowercase()) {
|
||||
anyhow::bail!("Password must contain at least one lowercase letter");
|
||||
}
|
||||
if !password.chars().any(|c| c.is_ascii_digit()) {
|
||||
anyhow::bail!("Password must contain at least one digit");
|
||||
}
|
||||
if !password.chars().any(|c| !c.is_ascii_alphanumeric()) {
|
||||
anyhow::bail!("Password must contain at least one special character (!@#$%^&* etc.)");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Change the archipelago user's SSH/login password.
|
||||
/// Uses usermod + openssl to bypass PAM (avoids "Authentication token manipulation" errors).
|
||||
/// Uses absolute paths (/usr/bin/openssl, /usr/sbin/usermod) for systemd's minimal PATH.
|
||||
pub(crate) async fn change_ssh_password(new_password: &str) -> Result<()> {
|
||||
let ssh_user =
|
||||
std::env::var("ARCHIPELAGO_SSH_USER").unwrap_or_else(|_| "archipelago".to_string());
|
||||
|
||||
// Generate crypt hash via openssl (SHA-512, compatible with /etc/shadow)
|
||||
// Use /usr/bin/openssl - systemd services often have minimal PATH
|
||||
let mut hash_child = tokio::process::Command::new("/usr/bin/openssl")
|
||||
.args(["passwd", "-6", "-stdin"])
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to run openssl: {}. Is openssl installed?", e))?;
|
||||
|
||||
{
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let mut stdin = hash_child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to open openssl stdin"))?;
|
||||
stdin.write_all(new_password.as_bytes()).await?;
|
||||
stdin.flush().await?;
|
||||
}
|
||||
|
||||
let hash_result = hash_child.wait_with_output().await?;
|
||||
if !hash_result.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&hash_result.stderr);
|
||||
anyhow::bail!("openssl passwd failed: {}", stderr);
|
||||
}
|
||||
let hash = String::from_utf8(hash_result.stdout)?.trim().to_string();
|
||||
if hash.is_empty() {
|
||||
anyhow::bail!("openssl passwd produced empty hash");
|
||||
}
|
||||
|
||||
// usermod -p writes directly to /etc/shadow, bypassing PAM
|
||||
// Use /usr/sbin/usermod - not always in systemd's PATH
|
||||
let status = tokio::process::Command::new("/usr/bin/sudo")
|
||||
.args(["-n", "/usr/sbin/usermod", "-p", &hash, &ssh_user])
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
if !status.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&status.stderr);
|
||||
anyhow::bail!("sudo usermod failed: {}", stderr);
|
||||
}
|
||||
|
||||
tracing::info!("SSH password updated for user {}", ssh_user);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Hash a password with Argon2id (memory-hard, GPU/ASIC resistant).
|
||||
/// Uses PHC string format ($argon2id$v=19$m=65536,t=3,p=4$...) for self-describing storage.
|
||||
fn argon2id_hash(password: &str) -> Result<String> {
|
||||
use argon2::password_hash::SaltString;
|
||||
use argon2::{Argon2, Params, PasswordHasher};
|
||||
use rand::rngs::OsRng;
|
||||
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let params = Params::new(65536, 3, 4, Some(32))
|
||||
.map_err(|e| anyhow::anyhow!("Invalid Argon2 params: {}", e))?;
|
||||
let hasher = Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
|
||||
let hash = hasher
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.map_err(|e| anyhow::anyhow!("Argon2id hash failed: {}", e))?;
|
||||
Ok(hash.to_string())
|
||||
}
|
||||
|
||||
/// Verify a password against an Argon2id PHC string hash.
|
||||
fn argon2id_verify(password: &str, hash: &str) -> bool {
|
||||
use argon2::password_hash::PasswordHash;
|
||||
use argon2::{Argon2, PasswordVerifier};
|
||||
|
||||
let parsed = match PasswordHash::new(hash) {
|
||||
Ok(h) => h,
|
||||
Err(_) => return false,
|
||||
};
|
||||
Argon2::default()
|
||||
.verify_password(password.as_bytes(), &parsed)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_setup_user_and_verify_password() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let auth = AuthManager::new(dir.path().to_path_buf());
|
||||
|
||||
assert!(!auth.is_setup().await.unwrap());
|
||||
|
||||
auth.setup_user("password123").await.unwrap();
|
||||
|
||||
assert!(auth.is_setup().await.unwrap());
|
||||
assert!(auth.verify_password("password123").await.unwrap());
|
||||
assert!(!auth.verify_password("wrong").await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_verify_password_no_user() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let auth = AuthManager::new(dir.path().to_path_buf());
|
||||
|
||||
assert!(!auth.verify_password("anything").await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_onboarding_lifecycle() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let auth = AuthManager::new(dir.path().to_path_buf());
|
||||
|
||||
assert!(!auth.is_onboarding_complete().await.unwrap());
|
||||
|
||||
auth.complete_onboarding().await.unwrap();
|
||||
assert!(auth.is_onboarding_complete().await.unwrap());
|
||||
|
||||
auth.reset_onboarding().await.unwrap();
|
||||
assert!(!auth.is_onboarding_complete().await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_onboarding_persists_to_user() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let auth = AuthManager::new(dir.path().to_path_buf());
|
||||
|
||||
auth.setup_user("password123").await.unwrap();
|
||||
let user = auth.get_user().await.unwrap().unwrap();
|
||||
assert!(!user.onboarding_complete);
|
||||
|
||||
auth.complete_onboarding().await.unwrap();
|
||||
let user = auth.get_user().await.unwrap().unwrap();
|
||||
assert!(user.onboarding_complete);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_password_strength_valid() {
|
||||
assert!(validate_password_strength("MyP@ssw0rd!123").is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_change_password_updates_web_password_without_ssh() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let auth = AuthManager::new(dir.path().to_path_buf());
|
||||
auth.setup_user("password123").await.unwrap();
|
||||
|
||||
let outcome = auth
|
||||
.change_password("password123", "MyP@ssw0rd!123", false)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!outcome.ssh_updated);
|
||||
assert!(outcome.ssh_error.is_none());
|
||||
assert!(auth.verify_password("MyP@ssw0rd!123").await.unwrap());
|
||||
assert!(!auth.verify_password("password123").await.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_password_strength_too_short() {
|
||||
assert!(validate_password_strength("Ab1!").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_password_strength_no_uppercase() {
|
||||
assert!(validate_password_strength("mypassword1!xx").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_password_strength_no_digit() {
|
||||
assert!(validate_password_strength("MyPassword!!xx").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_password_strength_no_special() {
|
||||
assert!(validate_password_strength("MyPassword1234").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
//! Deterministic default avatars derived from a Nostr/Ed25519 pubkey.
|
||||
//!
|
||||
//! Two flavours are generated as base64-encoded SVG data URLs so they can
|
||||
//! live directly in `IdentityProfile.picture` without any blob-store round
|
||||
//! trip:
|
||||
//!
|
||||
//! - [`identicon`] — a 5×5 symmetric grid (GitHub-style) for sub-identities.
|
||||
//! - [`master_node_svg`] — a hexagonal-network motif for the primary
|
||||
//! seed-derived identity (derivation index 0). Distinct at a glance from
|
||||
//! the identicons so the user can tell their own node at 48 px.
|
||||
//!
|
||||
//! Both read the first 8 bytes of the hex pubkey, so the same key always
|
||||
//! produces the same avatar — useful for reconstructing history without
|
||||
//! storing the blob.
|
||||
|
||||
use base64::Engine;
|
||||
|
||||
/// Convert a byte to an HSL triple biased toward readable foregrounds on
|
||||
/// dark backgrounds (saturation 60–85%, lightness 52–70%).
|
||||
fn hue_color(seed: u8) -> String {
|
||||
let hue = (seed as u32) * 360 / 256;
|
||||
format!("hsl({}, 72%, 60%)", hue)
|
||||
}
|
||||
|
||||
fn accent_color(seed: u8) -> String {
|
||||
let hue = (seed as u32) * 360 / 256;
|
||||
format!("hsl({}, 80%, 68%)", hue)
|
||||
}
|
||||
|
||||
fn encode_svg(svg: &str) -> String {
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(svg.as_bytes());
|
||||
format!("data:image/svg+xml;base64,{}", b64)
|
||||
}
|
||||
|
||||
/// Parse the first 8 bytes from a hex pubkey. Returns `[0u8; 8]` if the
|
||||
/// input is too short or malformed — callers get a consistent default
|
||||
/// avatar rather than an error.
|
||||
fn seed_bytes(pubkey_hex: &str) -> [u8; 8] {
|
||||
let mut out = [0u8; 8];
|
||||
let clean: String = pubkey_hex
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_hexdigit())
|
||||
.collect();
|
||||
for (i, byte) in out.iter_mut().enumerate() {
|
||||
let lo = i * 2;
|
||||
if clean.len() >= lo + 2 {
|
||||
*byte = u8::from_str_radix(&clean[lo..lo + 2], 16).unwrap_or(0);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 5×5 mirrored identicon. ~700 bytes of SVG, ~1 KB as a data URL.
|
||||
pub fn identicon(pubkey_hex: &str) -> String {
|
||||
let bytes = seed_bytes(pubkey_hex);
|
||||
let fg = hue_color(bytes[0]);
|
||||
let bg = "#171a24";
|
||||
|
||||
// 15 bit slots (3 visible columns × 5 rows). Mirror to 5×5.
|
||||
// Use bytes[1..=2] as 16 bits, drop the MSB so we get 15.
|
||||
let bits = u16::from_be_bytes([bytes[1], bytes[2]]) & 0x7fff;
|
||||
|
||||
let mut cells = String::with_capacity(512);
|
||||
let cell_px: u32 = 16;
|
||||
for row in 0..5u32 {
|
||||
for col in 0..5u32 {
|
||||
let src_col = if col < 3 { col } else { 4 - col };
|
||||
let bit_idx = row * 3 + src_col;
|
||||
if (bits >> bit_idx) & 1 == 1 {
|
||||
let x = col * cell_px;
|
||||
let y = row * cell_px;
|
||||
cells.push_str(&format!(
|
||||
"<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\"/>",
|
||||
x, y, cell_px, cell_px
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let svg = format!(
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 80 80\" \
|
||||
shape-rendering=\"crispEdges\">\
|
||||
<rect width=\"80\" height=\"80\" fill=\"{bg}\"/>\
|
||||
<g fill=\"{fg}\">{cells}</g>\
|
||||
</svg>"
|
||||
);
|
||||
encode_svg(&svg)
|
||||
}
|
||||
|
||||
/// Hex-network motif for the master (seed-index-0) identity. Central hex
|
||||
/// plus six ring hexes connected by faint edges, with an accent colour
|
||||
/// derived from the pubkey. Distinct silhouette from the 5×5 identicon so
|
||||
/// the node identity reads differently at every size.
|
||||
pub fn master_node_svg(pubkey_hex: &str) -> String {
|
||||
let bytes = seed_bytes(pubkey_hex);
|
||||
let accent = accent_color(bytes[0]);
|
||||
let accent2 = accent_color(bytes[0].wrapping_add(64));
|
||||
let pattern = bytes[3] & 0x3f; // 6 bits — one per ring hex
|
||||
|
||||
// Hexagon vertices (point-up) at radius 16, centred on (c, c).
|
||||
let hex_path = |cx: f64, cy: f64, r: f64| -> String {
|
||||
let mut pts = String::new();
|
||||
for i in 0..6 {
|
||||
let theta = std::f64::consts::FRAC_PI_3 * (i as f64) - std::f64::consts::FRAC_PI_2;
|
||||
let x = cx + r * theta.cos();
|
||||
let y = cy + r * theta.sin();
|
||||
if i == 0 {
|
||||
pts.push_str(&format!("M{:.2},{:.2}", x, y));
|
||||
} else {
|
||||
pts.push_str(&format!(" L{:.2},{:.2}", x, y));
|
||||
}
|
||||
}
|
||||
pts.push_str(" Z");
|
||||
pts
|
||||
};
|
||||
|
||||
let c = 64.0;
|
||||
let ring_r = 36.0;
|
||||
|
||||
// Ring centres (6 hexes at 60° intervals around centre).
|
||||
let ring_centres: Vec<(f64, f64)> = (0..6)
|
||||
.map(|i| {
|
||||
let theta = std::f64::consts::FRAC_PI_3 * (i as f64) - std::f64::consts::FRAC_PI_2;
|
||||
(c + ring_r * theta.cos(), c + ring_r * theta.sin())
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut ring_hexes = String::new();
|
||||
let mut edges = String::new();
|
||||
for (i, (rx, ry)) in ring_centres.iter().enumerate() {
|
||||
// Alternate fill/stroke based on pattern bits so two nodes never
|
||||
// share the same ring silhouette.
|
||||
let filled = (pattern >> i) & 1 == 1;
|
||||
let fill = if filled { &accent } else { "none" };
|
||||
let stroke_w = if filled { 0.0 } else { 1.4 };
|
||||
ring_hexes.push_str(&format!(
|
||||
"<path d=\"{}\" fill=\"{}\" stroke=\"{}\" stroke-width=\"{}\" opacity=\"0.92\"/>",
|
||||
hex_path(*rx, *ry, 10.5),
|
||||
fill,
|
||||
&accent,
|
||||
stroke_w
|
||||
));
|
||||
// Edge from centre to this ring node.
|
||||
edges.push_str(&format!(
|
||||
"<line x1=\"{:.2}\" y1=\"{:.2}\" x2=\"{:.2}\" y2=\"{:.2}\" \
|
||||
stroke=\"{}\" stroke-width=\"1\" opacity=\"0.35\"/>",
|
||||
c, c, rx, ry, &accent2
|
||||
));
|
||||
}
|
||||
|
||||
let svg = format!(
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 128 128\">\
|
||||
<defs>\
|
||||
<radialGradient id=\"bg\" cx=\"50%\" cy=\"50%\" r=\"60%\">\
|
||||
<stop offset=\"0%\" stop-color=\"#1c2030\"/>\
|
||||
<stop offset=\"100%\" stop-color=\"#0a0d16\"/>\
|
||||
</radialGradient>\
|
||||
</defs>\
|
||||
<rect width=\"128\" height=\"128\" fill=\"url(#bg)\"/>\
|
||||
{edges}\
|
||||
{ring_hexes}\
|
||||
<path d=\"{centre_hex}\" fill=\"{accent}\" stroke=\"#ffffff\" stroke-width=\"1.5\"/>\
|
||||
</svg>",
|
||||
centre_hex = hex_path(c, c, 16.0),
|
||||
);
|
||||
encode_svg(&svg)
|
||||
}
|
||||
|
||||
/// Build a default [`IdentityProfile`]-shaped picture for the given
|
||||
/// identity. The master (seed index 0) gets the node SVG; everyone else
|
||||
/// gets the identicon.
|
||||
pub fn default_picture(pubkey_hex: &str, is_master: bool) -> String {
|
||||
if is_master {
|
||||
master_node_svg(pubkey_hex)
|
||||
} else {
|
||||
identicon(pubkey_hex)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn identicon_is_deterministic() {
|
||||
let a = identicon("aabbccddeeff0011");
|
||||
let b = identicon("aabbccddeeff0011");
|
||||
assert_eq!(a, b);
|
||||
assert!(a.starts_with("data:image/svg+xml;base64,"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn master_is_distinct_from_identicon() {
|
||||
let pk = "aabbccddeeff0011";
|
||||
assert_ne!(identicon(pk), master_node_svg(pk));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_short_or_malformed_hex() {
|
||||
// Shouldn't panic, should still return a valid data URL.
|
||||
let a = identicon("");
|
||||
assert!(a.starts_with("data:image/svg+xml;base64,"));
|
||||
let b = master_node_svg("xyz!!!");
|
||||
assert!(b.starts_with("data:image/svg+xml;base64,"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,820 @@
|
||||
//! 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",
|
||||
// Per-node service secrets. Without these a restored node can't
|
||||
// decrypt identity/lnd_aezeed.enc (the Lightning seed backup is
|
||||
// encrypted with secrets/lnd-wallet-password) or reuse its service
|
||||
// credentials. The archive itself is passphrase-encrypted.
|
||||
"secrets",
|
||||
];
|
||||
|
||||
/// 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,
|
||||
// v3: archive additionally carries the `secrets` dir (needed to
|
||||
// decrypt identity/lnd_aezeed.enc on a restored node).
|
||||
version: 3,
|
||||
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.
|
||||
///
|
||||
/// Uses atomic staging: extracts to a temporary directory first, validates,
|
||||
/// then swaps into place with rollback on failure.
|
||||
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")?;
|
||||
|
||||
// Check disk space: need at least 2x backup size free
|
||||
let backup_size = encrypted.len() as u64;
|
||||
if let Ok(output) = tokio::process::Command::new("df")
|
||||
.args(["--output=avail", "-B1"])
|
||||
.arg(data_dir)
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
if let Ok(stdout) = String::from_utf8(output.stdout) {
|
||||
if let Some(avail) = stdout
|
||||
.lines()
|
||||
.nth(1)
|
||||
.and_then(|l| l.trim().parse::<u64>().ok())
|
||||
{
|
||||
if avail < backup_size * 2 {
|
||||
anyhow::bail!(
|
||||
"Insufficient disk space for restore: need {}MB, have {}MB",
|
||||
backup_size * 2 / (1024 * 1024),
|
||||
avail / (1024 * 1024),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let tar_gz_data = decrypt_data(&encrypted, passphrase)?;
|
||||
|
||||
let staging_dir = data_dir.join(".restore-staging");
|
||||
let rollback_dir = data_dir.join(".restore-backup");
|
||||
|
||||
// Clean up any previous failed restore
|
||||
let _ = fs::remove_dir_all(&staging_dir).await;
|
||||
let _ = fs::remove_dir_all(&rollback_dir).await;
|
||||
|
||||
// Extract to staging directory
|
||||
fs::create_dir_all(&staging_dir)
|
||||
.await
|
||||
.context("Failed to create staging directory")?;
|
||||
|
||||
let staging_clone = staging_dir.clone();
|
||||
if let Err(e) =
|
||||
tokio::task::spawn_blocking(move || extract_tar_gz(&staging_clone, &tar_gz_data)).await?
|
||||
{
|
||||
let _ = fs::remove_dir_all(&staging_dir).await;
|
||||
return Err(e).context("Failed to extract backup to staging");
|
||||
}
|
||||
|
||||
// Validate staging has required files
|
||||
let has_identity = staging_dir.join("identity").exists();
|
||||
if !has_identity {
|
||||
let _ = fs::remove_dir_all(&staging_dir).await;
|
||||
anyhow::bail!("Invalid backup: missing identity directory");
|
||||
}
|
||||
|
||||
// Move current data to rollback directory
|
||||
fs::create_dir_all(&rollback_dir)
|
||||
.await
|
||||
.context("Failed to create rollback directory")?;
|
||||
|
||||
for dir_name in BACKUP_DIRS {
|
||||
// Only displace live data the backup will actually replace —
|
||||
// older archives don't contain every current BACKUP_DIRS entry
|
||||
// (e.g. `secrets` was added later), and moving a live dir to
|
||||
// rollback with nothing staged to take its place would DELETE it
|
||||
// at cleanup time.
|
||||
if !staging_dir.join(dir_name).exists() {
|
||||
continue;
|
||||
}
|
||||
let src = data_dir.join(dir_name);
|
||||
if src.exists() {
|
||||
let dst = rollback_dir.join(dir_name);
|
||||
if let Err(e) = fs::rename(&src, &dst).await {
|
||||
// Rollback: restore what we already moved
|
||||
info!("Restore failed during move, rolling back: {}", e);
|
||||
restore_from_rollback(data_dir, &rollback_dir).await;
|
||||
let _ = fs::remove_dir_all(&staging_dir).await;
|
||||
let _ = fs::remove_dir_all(&rollback_dir).await;
|
||||
return Err(e).context("Failed to move current data to rollback");
|
||||
}
|
||||
}
|
||||
}
|
||||
for file_name in BACKUP_FILES {
|
||||
if !staging_dir.join(file_name).exists() {
|
||||
continue;
|
||||
}
|
||||
let src = data_dir.join(file_name);
|
||||
if src.exists() {
|
||||
let dst = rollback_dir.join(file_name);
|
||||
let _ = fs::rename(&src, &dst).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Move staging contents to data_dir
|
||||
if let Err(e) = move_staging_to_data(data_dir, &staging_dir).await {
|
||||
info!("Restore failed during staging swap, rolling back: {}", e);
|
||||
restore_from_rollback(data_dir, &rollback_dir).await;
|
||||
let _ = fs::remove_dir_all(&staging_dir).await;
|
||||
let _ = fs::remove_dir_all(&rollback_dir).await;
|
||||
return Err(e).context("Failed to move staging data to data_dir");
|
||||
}
|
||||
|
||||
// Clean up
|
||||
let _ = fs::remove_dir_all(&staging_dir).await;
|
||||
let _ = fs::remove_dir_all(&rollback_dir).await;
|
||||
|
||||
info!(id = %backup_id, "Backup restored atomically");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Move staging directory contents into data_dir.
|
||||
async fn move_staging_to_data(data_dir: &Path, staging_dir: &Path) -> Result<()> {
|
||||
let mut entries = fs::read_dir(staging_dir)
|
||||
.await
|
||||
.context("Failed to read staging dir")?;
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let src = entry.path();
|
||||
let name = entry.file_name();
|
||||
let dst = data_dir.join(&name);
|
||||
fs::rename(&src, &dst)
|
||||
.await
|
||||
.with_context(|| format!("Failed to move {:?} from staging", name))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Restore data from rollback directory back to data_dir.
|
||||
async fn restore_from_rollback(data_dir: &Path, rollback_dir: &Path) {
|
||||
if let Ok(mut entries) = fs::read_dir(rollback_dir).await {
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
let src = entry.path();
|
||||
let dst = data_dir.join(entry.file_name());
|
||||
let _ = fs::rename(&src, &dst).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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().is_some_and(|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);
|
||||
let canonical_base = data_dir
|
||||
.canonicalize()
|
||||
.context("Failed to canonicalize data_dir")?;
|
||||
|
||||
for entry_result in archive.entries().context("Failed to read tar entries")? {
|
||||
let mut entry = entry_result.context("Failed to read tar entry")?;
|
||||
let entry_path = entry
|
||||
.path()
|
||||
.context("Failed to get entry path")?
|
||||
.to_path_buf();
|
||||
|
||||
// Reject entries with path traversal components
|
||||
for component in entry_path.components() {
|
||||
if matches!(component, std::path::Component::ParentDir) {
|
||||
anyhow::bail!(
|
||||
"Tar entry contains path traversal: {}",
|
||||
entry_path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let target = data_dir.join(&entry_path);
|
||||
// Verify the resolved path stays within data_dir
|
||||
// For new files that don't exist yet, check the parent directory
|
||||
let check_path = if target.exists() {
|
||||
target.canonicalize()?
|
||||
} else if let Some(parent) = target.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
parent
|
||||
.canonicalize()?
|
||||
.join(target.file_name().unwrap_or_default())
|
||||
} else {
|
||||
target.clone()
|
||||
};
|
||||
if !check_path.starts_with(&canonical_base) {
|
||||
anyhow::bail!(
|
||||
"Tar entry escapes target directory: {}",
|
||||
entry_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
entry
|
||||
.unpack(&target)
|
||||
.with_context(|| format!("Failed to extract: {}", entry_path.display()))?;
|
||||
}
|
||||
|
||||
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 secrets_dir_rides_backup_and_restore() {
|
||||
// The Lightning seed backup (identity/lnd_aezeed.enc) is encrypted
|
||||
// with secrets/lnd-wallet-password — a backup that omits it can't
|
||||
// recover the Lightning wallet on restore.
|
||||
let dir = TempDir::new().unwrap();
|
||||
setup_data_dir(dir.path());
|
||||
std::fs::create_dir_all(dir.path().join("secrets")).unwrap();
|
||||
std::fs::write(dir.path().join("secrets/lnd-wallet-password"), "s3cret").unwrap();
|
||||
|
||||
let meta = create_full_backup(dir.path(), "pass", None).await.unwrap();
|
||||
|
||||
std::fs::remove_dir_all(dir.path().join("secrets")).unwrap();
|
||||
restore_full_backup(dir.path(), &meta.id, "pass")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let pw = std::fs::read_to_string(dir.path().join("secrets/lnd-wallet-password")).unwrap();
|
||||
assert_eq!(pw, "s3cret");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restoring_old_backup_without_secrets_keeps_live_secrets() {
|
||||
// Archives created before `secrets` joined BACKUP_DIRS don't stage
|
||||
// one — the restore must NOT displace (and then delete) the node's
|
||||
// live secrets in that case.
|
||||
let dir = TempDir::new().unwrap();
|
||||
setup_data_dir(dir.path());
|
||||
|
||||
// Backup taken while no secrets dir existed (mimics an old archive).
|
||||
let meta = create_full_backup(dir.path(), "pass", None).await.unwrap();
|
||||
|
||||
// Live secrets appear afterwards.
|
||||
std::fs::create_dir_all(dir.path().join("secrets")).unwrap();
|
||||
std::fs::write(dir.path().join("secrets/lnd-wallet-password"), "keep-me").unwrap();
|
||||
|
||||
restore_full_backup(dir.path(), &meta.id, "pass")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let pw = std::fs::read_to_string(dir.path().join("secrets/lnd-wallet-password")).unwrap();
|
||||
assert_eq!(pw, "keep-me");
|
||||
}
|
||||
|
||||
#[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"}"#);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
//! Encrypted DID identity backup for onboarding.
|
||||
//! Uses Argon2 for key derivation and ChaCha20-Poly1305 for encryption.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use argon2::Argon2;
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
|
||||
use chacha20poly1305::aead::{Aead, KeyInit};
|
||||
use rand::RngCore;
|
||||
use std::path::Path;
|
||||
use tokio::fs;
|
||||
|
||||
const BACKUP_VERSION: u32 = 1;
|
||||
const SALT_LEN: usize = 16;
|
||||
const NONCE_LEN: usize = 12;
|
||||
const KEY_LEN: usize = 32;
|
||||
|
||||
/// Create an encrypted backup of the node identity key.
|
||||
/// Returns JSON-serializable backup metadata + encrypted blob (base64).
|
||||
pub async fn create_encrypted_backup(
|
||||
identity_dir: &Path,
|
||||
passphrase: &str,
|
||||
did: &str,
|
||||
pubkey_hex: &str,
|
||||
) -> Result<serde_json::Value> {
|
||||
let key_path = identity_dir.join("node_key");
|
||||
let key_bytes = fs::read(&key_path)
|
||||
.await
|
||||
.context("Failed to read node key")?;
|
||||
if key_bytes.len() != 32 {
|
||||
anyhow::bail!("Invalid node key length");
|
||||
}
|
||||
|
||||
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!("Argon2 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),
|
||||
key_bytes.as_ref(),
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("Encryption failed: {}", e))?;
|
||||
|
||||
let mut blob = Vec::with_capacity(SALT_LEN + NONCE_LEN + ciphertext.len());
|
||||
blob.extend_from_slice(&salt);
|
||||
blob.extend_from_slice(&nonce);
|
||||
blob.extend_from_slice(&ciphertext);
|
||||
let blob_b64 = BASE64.encode(&blob);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"version": BACKUP_VERSION,
|
||||
"did": did,
|
||||
"pubkey": pubkey_hex,
|
||||
"kid": format!("{}#key-1", did),
|
||||
"encrypted": true,
|
||||
"blob": blob_b64,
|
||||
"timestamp": chrono::Utc::now().to_rfc3339(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Restore a node identity key from an encrypted backup.
|
||||
/// Returns the DID and pubkey of the restored identity.
|
||||
pub async fn restore_encrypted_backup(
|
||||
identity_dir: &Path,
|
||||
backup: &serde_json::Value,
|
||||
passphrase: &str,
|
||||
) -> Result<(String, String)> {
|
||||
let blob_b64 = backup
|
||||
.get("blob")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'blob' in backup"))?;
|
||||
let blob = BASE64
|
||||
.decode(blob_b64)
|
||||
.context("Invalid base64 in backup blob")?;
|
||||
|
||||
if blob.len() < SALT_LEN + NONCE_LEN {
|
||||
anyhow::bail!("Backup blob too short");
|
||||
}
|
||||
|
||||
let salt = &blob[..SALT_LEN];
|
||||
let nonce = &blob[SALT_LEN..SALT_LEN + NONCE_LEN];
|
||||
let ciphertext = &blob[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!("Argon2 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"))?;
|
||||
|
||||
if plaintext.len() != 32 {
|
||||
anyhow::bail!("Decrypted key is not 32 bytes");
|
||||
}
|
||||
|
||||
// Write the restored key
|
||||
fs::create_dir_all(identity_dir).await?;
|
||||
let key_path = identity_dir.join("node_key");
|
||||
fs::write(&key_path, &plaintext)
|
||||
.await
|
||||
.context("Writing restored key")?;
|
||||
|
||||
// Set restrictive permissions
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let perms = std::fs::Permissions::from_mode(0o600);
|
||||
tokio::fs::set_permissions(&key_path, perms).await?;
|
||||
}
|
||||
|
||||
// Derive DID and pubkey from the restored key
|
||||
let signing_key = ed25519_dalek::SigningKey::from_bytes(
|
||||
plaintext
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| anyhow::anyhow!("Invalid key"))?,
|
||||
);
|
||||
let pubkey = signing_key.verifying_key();
|
||||
let pubkey_hex = hex::encode(pubkey.as_bytes());
|
||||
let did = crate::identity::did_key_from_pubkey_hex(&pubkey_hex)?;
|
||||
|
||||
Ok((did, pubkey_hex))
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
pub mod full;
|
||||
mod identity;
|
||||
|
||||
pub use identity::{create_encrypted_backup, restore_encrypted_backup};
|
||||
@@ -0,0 +1,72 @@
|
||||
//! Bitcoin RPC credential management.
|
||||
//!
|
||||
//! Uses `rpcauth` in bitcoin.conf (salted hash — no plaintext in config or CLI).
|
||||
//! The actual password is stored in `/var/lib/archipelago/secrets/bitcoin-rpc-password`
|
||||
//! and stays stable across reboots, restarts, and deploys.
|
||||
|
||||
use tokio::sync::OnceCell;
|
||||
use tracing::debug;
|
||||
|
||||
const SECRETS_PATH: &str = "/var/lib/archipelago/secrets/bitcoin-rpc-password";
|
||||
const RPC_USER: &str = "archipelago";
|
||||
|
||||
static CACHED_PASSWORD: OnceCell<String> = OnceCell::const_new();
|
||||
|
||||
/// Read the Bitcoin RPC password from the secrets file.
|
||||
/// Falls back to env var (dev), then generates and persists a random password.
|
||||
async fn read_password() -> String {
|
||||
// 1. Secrets file (production)
|
||||
if let Ok(pass) = tokio::fs::read_to_string(SECRETS_PATH).await {
|
||||
let pass = pass.trim().to_string();
|
||||
if !pass.is_empty() {
|
||||
debug!("Bitcoin RPC password loaded from secrets file");
|
||||
return pass;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Environment variable (dev)
|
||||
if let Ok(pass) = std::env::var("BITCOIN_RPC_PASSWORD") {
|
||||
if !pass.is_empty() {
|
||||
debug!("Bitcoin RPC password loaded from env var");
|
||||
return pass;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Generate and persist (first boot)
|
||||
let random_pass = generate_random_password();
|
||||
if let Some(parent) = std::path::Path::new(SECRETS_PATH).parent() {
|
||||
let _ = tokio::fs::create_dir_all(parent).await;
|
||||
}
|
||||
match tokio::fs::write(SECRETS_PATH, &random_pass).await {
|
||||
Ok(_) => {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = tokio::fs::set_permissions(
|
||||
SECRETS_PATH,
|
||||
std::fs::Permissions::from_mode(0o600),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
debug!("Bitcoin RPC password generated and saved");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to save Bitcoin RPC password: {}", e);
|
||||
}
|
||||
}
|
||||
random_pass
|
||||
}
|
||||
|
||||
/// Generate a cryptographically random password (32 hex chars).
|
||||
fn generate_random_password() -> String {
|
||||
let bytes: [u8; 16] = rand::random();
|
||||
hex::encode(bytes)
|
||||
}
|
||||
|
||||
/// Get Bitcoin RPC credentials (user, password). Cached after first call.
|
||||
pub async fn bitcoin_rpc_credentials() -> (String, String) {
|
||||
let pass = CACHED_PASSWORD
|
||||
.get_or_init(|| async { read_password().await })
|
||||
.await;
|
||||
(RPC_USER.to_string(), pass.clone())
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
//! Cached Bitcoin node status for browser UIs.
|
||||
//!
|
||||
//! The bitcoin-ui should not poll Bitcoin RPC directly for display state.
|
||||
//! During container restarts, reindexing, and IBD, direct browser RPC polling
|
||||
//! turns short RPC gaps into visible UI failures. This module owns the RPC
|
||||
//! polling loop, caches the last successful snapshot, and serves stale-but-known
|
||||
//! state while the node is reconnecting.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Serialize;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
// Poll frequently and recover fast so the cached snapshot tracks bitcoind's
|
||||
// responsive windows during IBD. During heavy block-connection, getblockchaininfo
|
||||
// can block briefly; a slow 10s/15s/20s cadence let one missed poll age the
|
||||
// snapshot past the UI's 30s "stale" threshold, so the UI dwelled on
|
||||
// "reconnecting…" long after bitcoind was answering again. Tight cadence + short
|
||||
// timeout keeps last-known state fresh and clears the stale banner promptly.
|
||||
const CACHE_REFRESH_SECS: u64 = 5;
|
||||
const CACHE_ERROR_BACKOFF_SECS: u64 = 5;
|
||||
|
||||
// Grace window before a failing poll marks the snapshot "stale" for the UI.
|
||||
// On a busy / swap-thrashing node (e.g. .198) getblockchaininfo intermittently
|
||||
// exceeds the RPC timeout, so a single missed poll is normal and must NOT flip
|
||||
// the UI to "reconnecting…". Only after the cached snapshot is genuinely old —
|
||||
// several polls failed in a row — do we surface the banner.
|
||||
const STALE_GRACE_MS: u64 = 20_000;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct BitcoinNodeStatus {
|
||||
pub ok: bool,
|
||||
pub stale: bool,
|
||||
pub updated_at_ms: u64,
|
||||
// Server-computed age of the snapshot, filled in at serve time. The browser
|
||||
// must not derive this itself (Date.now() - updated_at_ms) because that
|
||||
// compares the browser clock against this node's clock — any skew made a
|
||||
// fresh snapshot look stale and the "reconnecting…" banner never cleared.
|
||||
pub age_ms: u64,
|
||||
pub error: Option<String>,
|
||||
pub blockchain_info: Option<serde_json::Value>,
|
||||
pub network_info: Option<serde_json::Value>,
|
||||
pub index_info: Option<serde_json::Value>,
|
||||
pub zmq_notifications: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl Default for BitcoinNodeStatus {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
ok: false,
|
||||
stale: false,
|
||||
updated_at_ms: 0,
|
||||
age_ms: 0,
|
||||
error: Some("Connecting to Bitcoin node...".to_string()),
|
||||
blockchain_info: None,
|
||||
network_info: None,
|
||||
index_info: None,
|
||||
zmq_notifications: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static STATUS_CACHE: OnceLock<RwLock<BitcoinNodeStatus>> = OnceLock::new();
|
||||
|
||||
fn cache() -> &'static RwLock<BitcoinNodeStatus> {
|
||||
STATUS_CACHE.get_or_init(|| RwLock::new(BitcoinNodeStatus::default()))
|
||||
}
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as u64
|
||||
}
|
||||
|
||||
fn transient_error(err_msg: &str) -> bool {
|
||||
let lower = err_msg.to_lowercase();
|
||||
lower.contains("connect")
|
||||
|| lower.contains("reset")
|
||||
|| lower.contains("refused")
|
||||
|| lower.contains("timed out")
|
||||
|| lower.contains("timeout")
|
||||
|| lower.contains("broken pipe")
|
||||
|| lower.contains("eof")
|
||||
|| lower.contains("500 internal server error")
|
||||
|| lower.contains("503 service unavailable")
|
||||
|| lower.contains("work queue depth exceeded")
|
||||
|| lower.contains("decode bitcoin rpc json")
|
||||
|| lower.contains("error decoding response body")
|
||||
|| lower.contains("expected value at line 1 column 1")
|
||||
}
|
||||
|
||||
fn friendly_transient_error(has_cached_state: bool, err_msg: &str) -> String {
|
||||
let detail = err_msg
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap_or(err_msg)
|
||||
.trim()
|
||||
.trim_end_matches('.');
|
||||
let lower = detail.to_lowercase();
|
||||
let state = if lower.contains("verifying blocks") {
|
||||
Some("verifying blocks after restart")
|
||||
} else if lower.contains("connection reset") {
|
||||
Some("starting up and not yet accepting RPC connections")
|
||||
} else if lower.contains("connection refused") || lower.contains("tcp connect error") {
|
||||
Some("waiting for the Bitcoin RPC listener")
|
||||
} else if lower.contains("timed out") || lower.contains("timeout") {
|
||||
Some("busy and not answering RPC before the timeout")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Recognized transient causes get a clean human sentence only — the raw
|
||||
// transport error (URLs, repeated "os error 104" chains) is operator
|
||||
// noise that was ending up verbatim on the app card. Unrecognized errors
|
||||
// keep a bounded detail so a genuinely new failure stays diagnosable.
|
||||
let (state, detail) = match state {
|
||||
Some(state) => (state, None),
|
||||
None => (
|
||||
"starting or busy syncing",
|
||||
Some(if detail.len() > 120 {
|
||||
let mut cut = 120;
|
||||
while !detail.is_char_boundary(cut) {
|
||||
cut -= 1;
|
||||
}
|
||||
format!("{}…", &detail[..cut])
|
||||
} else {
|
||||
detail.to_string()
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
let base = if has_cached_state {
|
||||
format!("Bitcoin node is {state}; showing last known state and retrying.")
|
||||
} else {
|
||||
format!("Bitcoin node is {state}; retrying automatically.")
|
||||
};
|
||||
match detail {
|
||||
Some(detail) => format!("{base} Detail: {detail}"),
|
||||
None => base,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn spawn_status_cache() {
|
||||
tokio::spawn(async {
|
||||
loop {
|
||||
let fresh = fetch_bitcoin_status().await;
|
||||
let mut cached = cache().write().await;
|
||||
let mut sleep_secs = CACHE_REFRESH_SECS;
|
||||
match fresh {
|
||||
Ok(mut status) => {
|
||||
status.ok = true;
|
||||
status.stale = false;
|
||||
status.error = None;
|
||||
*cached = status;
|
||||
}
|
||||
Err(e) => {
|
||||
let err_msg = format!("{e:#}");
|
||||
if transient_error(&err_msg) {
|
||||
debug!("Bitcoin status: transient RPC failure: {}", err_msg);
|
||||
} else {
|
||||
warn!("Bitcoin status: RPC failure: {}", err_msg);
|
||||
}
|
||||
sleep_secs = CACHE_ERROR_BACKOFF_SECS;
|
||||
|
||||
if cached.blockchain_info.is_some() {
|
||||
cached.ok = false;
|
||||
// Only flip to "stale" once the last good snapshot is older
|
||||
// than the grace window. A brief RPC gap on a busy node keeps
|
||||
// showing last-known state silently instead of a banner flicker.
|
||||
let snapshot_age_ms = now_ms().saturating_sub(cached.updated_at_ms);
|
||||
cached.stale = snapshot_age_ms > STALE_GRACE_MS;
|
||||
cached.error = Some(friendly_transient_error(true, &err_msg));
|
||||
} else {
|
||||
*cached = BitcoinNodeStatus {
|
||||
ok: false,
|
||||
stale: false,
|
||||
updated_at_ms: now_ms(),
|
||||
error: Some(friendly_transient_error(false, &err_msg)),
|
||||
..BitcoinNodeStatus::default()
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(cached);
|
||||
tokio::time::sleep(Duration::from_secs(sleep_secs)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn get_bitcoin_status() -> BitcoinNodeStatus {
|
||||
let mut status = cache().read().await.clone();
|
||||
// Compute age here (server clock only) so the browser never has to subtract
|
||||
// across clocks. A successful snapshot serves age_ms ≈ 0 → the UI clears the
|
||||
// "reconnecting…" banner on its very next poll regardless of browser-clock skew.
|
||||
if status.updated_at_ms > 0 {
|
||||
status.age_ms = now_ms().saturating_sub(status.updated_at_ms);
|
||||
}
|
||||
status
|
||||
}
|
||||
|
||||
async fn fetch_bitcoin_status() -> Result<BitcoinNodeStatus> {
|
||||
// 12s (not 8s): on a swap-thrashing node getblockchaininfo can answer slowly
|
||||
// but correctly; too tight a timeout turned working-but-slow polls into
|
||||
// failures and tripped the "reconnecting…" banner. Stays under STALE_GRACE_MS.
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(12))
|
||||
.build()
|
||||
.context("build Bitcoin status HTTP client")?;
|
||||
|
||||
// Fetch all four calls concurrently: getblockchaininfo gates freshness, so a
|
||||
// slow auxiliary call (network/index/zmq) must not delay the snapshot or block
|
||||
// the next refresh. Only getblockchaininfo failing marks the status stale.
|
||||
let (blockchain_info, network_info, index_info, zmq_notifications) = tokio::join!(
|
||||
bitcoin_rpc_call(&client, "getblockchaininfo", serde_json::json!([])),
|
||||
bitcoin_rpc_call(&client, "getnetworkinfo", serde_json::json!([])),
|
||||
bitcoin_rpc_call(&client, "getindexinfo", serde_json::json!([])),
|
||||
bitcoin_rpc_call(&client, "getzmqnotifications", serde_json::json!([])),
|
||||
);
|
||||
let blockchain_info = blockchain_info.context("getblockchaininfo")?;
|
||||
|
||||
Ok(BitcoinNodeStatus {
|
||||
ok: true,
|
||||
stale: false,
|
||||
updated_at_ms: now_ms(),
|
||||
age_ms: 0,
|
||||
error: None,
|
||||
blockchain_info: Some(blockchain_info),
|
||||
network_info: network_info.ok(),
|
||||
index_info: index_info.ok(),
|
||||
zmq_notifications: zmq_notifications.ok(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn bitcoin_rpc_call(
|
||||
client: &reqwest::Client,
|
||||
method: &str,
|
||||
params: serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let (rpc_user, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
|
||||
let body = serde_json::json!({
|
||||
"jsonrpc": "1.0",
|
||||
"id": "bitcoin-status",
|
||||
"method": method,
|
||||
"params": params,
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post(crate::constants::BITCOIN_RPC_URL)
|
||||
.basic_auth(rpc_user, Some(rpc_pass))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.context("Bitcoin RPC request failed")?;
|
||||
|
||||
let status = resp.status();
|
||||
let json: serde_json::Value = resp.json().await.context("decode Bitcoin RPC JSON")?;
|
||||
if !status.is_success() {
|
||||
anyhow::bail!("Bitcoin RPC returned {}: {}", status, json);
|
||||
}
|
||||
if let Some(error) = json.get("error").filter(|e| !e.is_null()) {
|
||||
anyhow::bail!("Bitcoin RPC {} error: {}", method, error);
|
||||
}
|
||||
json.get("result")
|
||||
.cloned()
|
||||
.context("missing Bitcoin RPC result")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::friendly_transient_error;
|
||||
|
||||
#[test]
|
||||
fn explains_verifying_blocks_without_generic_timeout_copy() {
|
||||
let msg = friendly_transient_error(
|
||||
false,
|
||||
r#"getblockchaininfo: Bitcoin RPC returned 500 Internal Server Error: {"error":{"code":-28,"message":"Verifying blocks..."}}"#,
|
||||
);
|
||||
|
||||
assert!(msg.contains("verifying blocks after restart"));
|
||||
assert!(msg.contains("retrying automatically"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explains_missing_rpc_listener() {
|
||||
let msg = friendly_transient_error(
|
||||
true,
|
||||
"getblockchaininfo: tcp connect error: Connection refused (os error 111)",
|
||||
);
|
||||
|
||||
assert!(msg.contains("waiting for the Bitcoin RPC listener"));
|
||||
assert!(msg.contains("showing last known state"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explains_rpc_timeout() {
|
||||
let msg = friendly_transient_error(
|
||||
false,
|
||||
"getblockchaininfo: Bitcoin RPC request failed: operation timed out",
|
||||
);
|
||||
|
||||
assert!(msg.contains("busy and not answering RPC before the timeout"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_reset_gets_clean_message_without_raw_detail() {
|
||||
// The exact string a fresh install showed on the app card: the raw
|
||||
// reqwest chain (URL + repeated "os error 104") must not surface.
|
||||
let msg = friendly_transient_error(
|
||||
false,
|
||||
"getblockchaininfo: Bitcoin RPC request failed: error sending request for url (http://127.0.0.1:8332/): connection error: Connection reset by peer (os error 104): connection error: Connection reset by peer (os error 104): Connection reset by peer (os error 104)",
|
||||
);
|
||||
|
||||
assert!(msg.contains("starting up and not yet accepting RPC connections"));
|
||||
assert!(!msg.contains("os error"));
|
||||
assert!(!msg.contains("127.0.0.1"));
|
||||
assert!(!msg.contains("Detail:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognized_causes_omit_detail_entirely() {
|
||||
for raw in [
|
||||
"x: Connection refused (os error 111)",
|
||||
"x: operation timed out",
|
||||
r#"x: {"error":{"code":-28,"message":"Verifying blocks..."}}"#,
|
||||
] {
|
||||
let msg = friendly_transient_error(false, raw);
|
||||
assert!(!msg.contains("Detail:"), "leaked detail for: {raw}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_errors_keep_bounded_detail() {
|
||||
let long = format!("weird new failure {}", "x".repeat(300));
|
||||
let msg = friendly_transient_error(false, &long);
|
||||
assert!(msg.contains("Detail: weird new failure"));
|
||||
assert!(msg.len() < 260);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
//! Content-addressed blob store for attachments shared over mesh/federation.
|
||||
//!
|
||||
//! Blobs live at `${data_dir}/blobs/<cid>` where `cid` is the hex-encoded
|
||||
//! SHA-256 of the content. A sibling `<cid>.meta` file holds JSON metadata
|
||||
//! (mime, filename, size, created_at). Capability URLs are HMAC-signed tokens
|
||||
//! scoped to a recipient pubkey and expiry, verified before serving.
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use hmac::{Hmac, Mac};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::fs;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// Default capability URL validity window.
|
||||
pub const DEFAULT_CAP_TTL_SECS: u64 = 7 * 24 * 60 * 60;
|
||||
|
||||
/// Maximum blob size accepted by the store (64 MiB). Keep attachments
|
||||
/// reasonable so /var/lib/archipelago doesn't balloon unnoticed.
|
||||
pub const MAX_BLOB_SIZE: u64 = 64 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BlobMeta {
|
||||
pub cid: String,
|
||||
/// DHT Phase 1: BLAKE3 hash of the content (iroh-native swarm address).
|
||||
/// The on-disk path stays SHA-256-keyed (`cid`) for back-compat; this
|
||||
/// advertises the hash a peer swarm can fetch/range-verify by. Absent in
|
||||
/// legacy metadata written before Phase 1.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub blake3: Option<String>,
|
||||
pub size: u64,
|
||||
pub mime: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub filename: Option<String>,
|
||||
pub created_at: String,
|
||||
/// Optional raw thumbnail bytes (small — up to ~60 bytes is LoRa-safe).
|
||||
/// Stored alongside meta so ContentRef senders don't re-fetch the blob.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub thumb_bytes: Option<Vec<u8>>,
|
||||
/// Public blobs (profile pictures, banners) are served at `/blob/<cid>`
|
||||
/// without a capability check so external Nostr clients can fetch them.
|
||||
/// Missing in legacy metadata = default false (cap required).
|
||||
#[serde(default)]
|
||||
pub public: bool,
|
||||
}
|
||||
|
||||
pub struct BlobStore {
|
||||
root: PathBuf,
|
||||
/// HMAC key used to sign capability URLs. Derived from node identity;
|
||||
/// callers pass it in so we don't duplicate key management here.
|
||||
cap_key: [u8; 32],
|
||||
}
|
||||
|
||||
impl BlobStore {
|
||||
/// Create (or open) a blob store rooted at `data_dir/blobs`.
|
||||
pub async fn open(data_dir: &Path, cap_key: [u8; 32]) -> Result<Self> {
|
||||
let root = data_dir.join("blobs");
|
||||
fs::create_dir_all(&root)
|
||||
.await
|
||||
.context("create blobs dir")?;
|
||||
Ok(Self { root, cap_key })
|
||||
}
|
||||
|
||||
fn path_for(&self, cid: &str) -> PathBuf {
|
||||
self.root.join(cid)
|
||||
}
|
||||
|
||||
fn meta_path_for(&self, cid: &str) -> PathBuf {
|
||||
self.root.join(format!("{}.meta", cid))
|
||||
}
|
||||
|
||||
/// Write bytes to the store, returning the CID and metadata. Idempotent:
|
||||
/// identical bytes produce the same CID and short-circuit re-writes.
|
||||
pub async fn put(
|
||||
&self,
|
||||
bytes: &[u8],
|
||||
mime: &str,
|
||||
filename: Option<String>,
|
||||
thumb_bytes: Option<Vec<u8>>,
|
||||
public: bool,
|
||||
) -> Result<BlobMeta> {
|
||||
if bytes.len() as u64 > MAX_BLOB_SIZE {
|
||||
anyhow::bail!(
|
||||
"Blob too large: {} bytes (max {})",
|
||||
bytes.len(),
|
||||
MAX_BLOB_SIZE
|
||||
);
|
||||
}
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(bytes);
|
||||
let cid = hex::encode(hasher.finalize());
|
||||
let meta = BlobMeta {
|
||||
cid: cid.clone(),
|
||||
blake3: Some(crate::content_hash::blake3_hex(bytes)),
|
||||
size: bytes.len() as u64,
|
||||
mime: mime.to_string(),
|
||||
filename,
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
thumb_bytes,
|
||||
public,
|
||||
};
|
||||
|
||||
let blob_path = self.path_for(&cid);
|
||||
if !blob_path.exists() {
|
||||
let mut f = fs::File::create(&blob_path).await.context("create blob")?;
|
||||
f.write_all(bytes).await.context("write blob")?;
|
||||
f.sync_all().await.ok();
|
||||
}
|
||||
let meta_json = serde_json::to_vec(&meta)?;
|
||||
fs::write(self.meta_path_for(&cid), meta_json)
|
||||
.await
|
||||
.context("write blob meta")?;
|
||||
Ok(meta)
|
||||
}
|
||||
|
||||
/// Read raw bytes for a CID. Errors if missing.
|
||||
pub async fn get(&self, cid: &str) -> Result<Vec<u8>> {
|
||||
let path = self.path_for(cid);
|
||||
fs::read(&path)
|
||||
.await
|
||||
.with_context(|| format!("blob not found: {}", cid))
|
||||
}
|
||||
|
||||
/// Load metadata for a CID.
|
||||
pub async fn meta(&self, cid: &str) -> Result<BlobMeta> {
|
||||
let raw = fs::read(self.meta_path_for(cid))
|
||||
.await
|
||||
.with_context(|| format!("blob meta not found: {}", cid))?;
|
||||
Ok(serde_json::from_slice(&raw)?)
|
||||
}
|
||||
|
||||
/// Check whether a CID is held locally.
|
||||
pub async fn has(&self, cid: &str) -> bool {
|
||||
fs::try_exists(self.path_for(cid)).await.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Sign a capability token: HMAC-SHA256(cid || peer_pubkey || expiry).
|
||||
/// Returned token is hex — callers append `?cap=<token>&exp=<epoch>` to
|
||||
/// the blob URL sent to the peer.
|
||||
pub fn issue_capability(&self, cid: &str, peer_pubkey_hex: &str, expiry_epoch: u64) -> String {
|
||||
let mut mac = HmacSha256::new_from_slice(&self.cap_key).expect("hmac key");
|
||||
mac.update(cid.as_bytes());
|
||||
mac.update(b"|");
|
||||
mac.update(peer_pubkey_hex.as_bytes());
|
||||
mac.update(b"|");
|
||||
mac.update(&expiry_epoch.to_be_bytes());
|
||||
hex::encode(mac.finalize().into_bytes())
|
||||
}
|
||||
|
||||
/// Verify a capability token against (cid, peer_pubkey, expiry).
|
||||
/// Returns Ok(()) on success, Err describing the failure otherwise.
|
||||
/// Expired tokens fail even with a correct signature.
|
||||
pub fn verify_capability(
|
||||
&self,
|
||||
cid: &str,
|
||||
peer_pubkey_hex: &str,
|
||||
expiry_epoch: u64,
|
||||
token_hex: &str,
|
||||
) -> Result<()> {
|
||||
let now = chrono::Utc::now().timestamp() as u64;
|
||||
if expiry_epoch < now {
|
||||
return Err(anyhow!("capability expired"));
|
||||
}
|
||||
let expected = self.issue_capability(cid, peer_pubkey_hex, expiry_epoch);
|
||||
// Constant-time compare via HMAC verify.
|
||||
let token_bytes =
|
||||
hex::decode(token_hex).map_err(|_| anyhow!("capability token not hex"))?;
|
||||
let expected_bytes = hex::decode(&expected).unwrap();
|
||||
if token_bytes.len() != expected_bytes.len() {
|
||||
return Err(anyhow!("capability length mismatch"));
|
||||
}
|
||||
// hmac::Mac::verify is the idiomatic constant-time path, but we
|
||||
// already computed `expected` so fall back to ct_eq via subtle.
|
||||
let mut diff = 0u8;
|
||||
for (a, b) in token_bytes.iter().zip(expected_bytes.iter()) {
|
||||
diff |= a ^ b;
|
||||
}
|
||||
if diff == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("capability signature mismatch"))
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,179 @@
|
||||
//! Release-root signing ceremony — the publisher-side counterpart to
|
||||
//! `trust::anchor`. Run as a subcommand of the same binary so it reuses the
|
||||
//! exact key derivation (`seed::derive_release_root_ed25519`) and canonical
|
||||
//! signing (`trust::signed_doc::sign_detached`) the fleet verifies against.
|
||||
//!
|
||||
//! Usage (the mnemonic is read from the `RELEASE_MASTER_MNEMONIC` env var or
|
||||
//! stdin — never an argv so it stays out of shell history / `ps`):
|
||||
//!
|
||||
//! ```text
|
||||
//! archipelago ceremony gen
|
||||
//! Generate a fresh 24-word release master mnemonic and print it plus the
|
||||
//! derived release-root pubkey + did. Back the mnemonic up OFFLINE.
|
||||
//!
|
||||
//! RELEASE_MASTER_MNEMONIC="word1 …" archipelago ceremony pubkey
|
||||
//! Print the release-root pubkey hex (for ARCHY_RELEASE_ROOT_PUBKEY /
|
||||
//! trust::anchor::RELEASE_ROOT_PUBKEY_HEX) and the signer did:key.
|
||||
//!
|
||||
//! RELEASE_MASTER_MNEMONIC="word1 …" archipelago ceremony sign <file.json>
|
||||
//! Sign a JSON document (e.g. releases/app-catalog.json) in place: insert
|
||||
//! `signature` + `signed_by` over the canonical form, matching exactly
|
||||
//! what `trust::verify_detached` recomputes on every node.
|
||||
//!
|
||||
//! archipelago ceremony verify <file.json>
|
||||
//! Verify a signed JSON document against the compiled-in release-root
|
||||
//! anchor. Exits non-zero unless the signature verifies AND the signer
|
||||
//! is the pinned anchor. Needs no mnemonic — used as the publish gate.
|
||||
//! ```
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use ed25519_dalek::SigningKey;
|
||||
|
||||
use crate::seed::{self, MasterSeed};
|
||||
use crate::trust::{did, signed_doc};
|
||||
|
||||
const ENV_MNEMONIC: &str = "RELEASE_MASTER_MNEMONIC";
|
||||
|
||||
/// True if argv selects the ceremony subcommand. Checked before any server init.
|
||||
pub fn is_ceremony_invocation() -> bool {
|
||||
std::env::args().nth(1).as_deref() == Some("ceremony")
|
||||
}
|
||||
|
||||
/// Entry point for `archipelago ceremony …`. Returns Ok(()) on success; the
|
||||
/// caller (main) should exit without starting the server.
|
||||
pub fn run() -> Result<()> {
|
||||
let sub = std::env::args().nth(2).unwrap_or_default();
|
||||
match sub.as_str() {
|
||||
"gen" => cmd_gen(),
|
||||
"pubkey" => cmd_pubkey(),
|
||||
"sign" => {
|
||||
let file = std::env::args()
|
||||
.nth(3)
|
||||
.context("usage: archipelago ceremony sign <file.json>")?;
|
||||
cmd_sign(&file)
|
||||
}
|
||||
"verify" => {
|
||||
let file = std::env::args()
|
||||
.nth(3)
|
||||
.context("usage: archipelago ceremony verify <file.json>")?;
|
||||
cmd_verify(&file)
|
||||
}
|
||||
other => {
|
||||
bail!(
|
||||
"unknown ceremony subcommand {:?}; expected gen | pubkey | sign <file> | verify <file>",
|
||||
other
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_gen() -> Result<()> {
|
||||
let (mnemonic, seed) = MasterSeed::generate().context("generate mnemonic")?;
|
||||
let key = seed::derive_release_root_ed25519(&seed).context("derive release-root")?;
|
||||
eprintln!("⚠ Back this mnemonic up OFFLINE. It is the ONLY way to re-derive");
|
||||
eprintln!(" the release-root signing key. Anyone with it can sign for the fleet.\n");
|
||||
println!("RELEASE_MASTER_MNEMONIC=\"{}\"", mnemonic);
|
||||
print_key(&key);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_pubkey() -> Result<()> {
|
||||
let key = load_release_root_key()?;
|
||||
print_key(&key);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_sign(path: &str) -> Result<()> {
|
||||
let key = load_release_root_key()?;
|
||||
|
||||
let body = std::fs::read_to_string(path).with_context(|| format!("read {path}"))?;
|
||||
let mut value: serde_json::Value =
|
||||
serde_json::from_str(&body).with_context(|| format!("parse {path} as JSON"))?;
|
||||
{
|
||||
let obj = value
|
||||
.as_object_mut()
|
||||
.context("document root must be a JSON object")?;
|
||||
// Re-sign cleanly: drop any prior signature so the preimage matches.
|
||||
obj.remove("signature");
|
||||
obj.remove("signed_by");
|
||||
}
|
||||
|
||||
let (signature, signed_by) =
|
||||
signed_doc::sign_detached(&key, &value).context("sign document")?;
|
||||
|
||||
let obj = value.as_object_mut().expect("checked above");
|
||||
obj.insert("signature".into(), serde_json::Value::String(signature));
|
||||
obj.insert(
|
||||
"signed_by".into(),
|
||||
serde_json::Value::String(signed_by.clone()),
|
||||
);
|
||||
|
||||
let pretty = serde_json::to_string_pretty(&value).context("serialize signed document")?;
|
||||
let tmp = format!("{path}.tmp");
|
||||
std::fs::write(&tmp, format!("{pretty}\n")).with_context(|| format!("write {tmp}"))?;
|
||||
std::fs::rename(&tmp, path).with_context(|| format!("rename {tmp} -> {path}"))?;
|
||||
|
||||
eprintln!("✓ signed {path}");
|
||||
eprintln!(" signed_by: {signed_by}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_verify(path: &str) -> Result<()> {
|
||||
let body = std::fs::read_to_string(path).with_context(|| format!("read {path}"))?;
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(&body).with_context(|| format!("parse {path} as JSON"))?;
|
||||
match signed_doc::verify_detached(&value)? {
|
||||
signed_doc::SignatureStatus::Verified {
|
||||
signer_did,
|
||||
anchored: true,
|
||||
} => {
|
||||
eprintln!("✓ {path} verified — signed by the pinned release root");
|
||||
eprintln!(" signed_by: {signer_did}");
|
||||
Ok(())
|
||||
}
|
||||
signed_doc::SignatureStatus::Verified {
|
||||
signer_did,
|
||||
anchored: false,
|
||||
} => {
|
||||
// Only reachable if no anchor is compiled in/overridden — the
|
||||
// signature is self-consistent but proves nothing about identity.
|
||||
bail!("{path} signed by {signer_did}, but no release-root anchor is pinned to compare against")
|
||||
}
|
||||
signed_doc::SignatureStatus::Unsigned => {
|
||||
bail!("{path} is NOT signed (no `signature` field)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive the release-root signing key from the mnemonic in env/stdin.
|
||||
fn load_release_root_key() -> Result<SigningKey> {
|
||||
let phrase = read_mnemonic()?;
|
||||
let (_mnemonic, seed) = MasterSeed::from_mnemonic_words(phrase.trim())
|
||||
.context("invalid release master mnemonic")?;
|
||||
seed::derive_release_root_ed25519(&seed).context("derive release-root")
|
||||
}
|
||||
|
||||
/// Read the mnemonic from `RELEASE_MASTER_MNEMONIC` or, if unset, stdin.
|
||||
fn read_mnemonic() -> Result<String> {
|
||||
if let Ok(v) = std::env::var(ENV_MNEMONIC) {
|
||||
if !v.trim().is_empty() {
|
||||
return Ok(v);
|
||||
}
|
||||
}
|
||||
use std::io::Read;
|
||||
eprintln!("Paste the release master mnemonic, then Ctrl-D:");
|
||||
let mut buf = String::new();
|
||||
std::io::stdin()
|
||||
.read_to_string(&mut buf)
|
||||
.context("read mnemonic from stdin")?;
|
||||
if buf.trim().is_empty() {
|
||||
bail!("no mnemonic provided (set {ENV_MNEMONIC} or pipe it on stdin)");
|
||||
}
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
fn print_key(key: &SigningKey) {
|
||||
let vk = key.verifying_key();
|
||||
println!("RELEASE_ROOT_PUBKEY_HEX={}", hex::encode(vk.to_bytes()));
|
||||
println!("signed_by_did={}", did::did_key_for_ed25519(&vk));
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//! Cluster module — high-availability multi-node clustering via Raft consensus.
|
||||
//!
|
||||
//! When 3+ nodes form a cluster, apps can have replicas across nodes.
|
||||
//! If one node goes down, apps failover to remaining nodes automatically.
|
||||
//!
|
||||
//! Architecture:
|
||||
//! - Uses Raft consensus for leader election and log replication
|
||||
//! - Leader node coordinates app placement decisions
|
||||
//! - Follower nodes replicate state and serve read requests
|
||||
//! - Federation provides peer discovery; cluster adds consensus layer
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Cluster node role in the Raft consensus group.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ClusterRole {
|
||||
Leader,
|
||||
Follower,
|
||||
Candidate,
|
||||
Standalone, // Not part of a cluster
|
||||
}
|
||||
|
||||
impl Default for ClusterRole {
|
||||
fn default() -> Self {
|
||||
ClusterRole::Standalone
|
||||
}
|
||||
}
|
||||
|
||||
/// Cluster membership state.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ClusterState {
|
||||
pub enabled: bool,
|
||||
pub role: ClusterRole,
|
||||
pub leader_did: Option<String>,
|
||||
pub members: Vec<ClusterMember>,
|
||||
pub term: u64,
|
||||
pub commit_index: u64,
|
||||
}
|
||||
|
||||
/// A member of the cluster.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ClusterMember {
|
||||
pub did: String,
|
||||
pub onion: String,
|
||||
pub role: ClusterRole,
|
||||
pub last_heartbeat: Option<String>,
|
||||
pub apps: Vec<String>,
|
||||
}
|
||||
|
||||
/// App placement decision — which node should run which app.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppPlacement {
|
||||
pub app_id: String,
|
||||
pub primary_node: String, // DID of primary node
|
||||
pub replica_nodes: Vec<String>, // DIDs of replica nodes
|
||||
pub min_replicas: u32,
|
||||
}
|
||||
|
||||
/// Cluster configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ClusterConfig {
|
||||
pub min_nodes: u32, // Minimum nodes for quorum (default: 3)
|
||||
pub heartbeat_interval_ms: u64, // Raft heartbeat (default: 150ms)
|
||||
pub election_timeout_ms: u64, // Raft election timeout (default: 300ms)
|
||||
pub snapshot_interval: u64, // Log entries before snapshot
|
||||
}
|
||||
|
||||
impl Default for ClusterConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
min_nodes: 3,
|
||||
heartbeat_interval_ms: 150,
|
||||
election_timeout_ms: 300,
|
||||
snapshot_interval: 1000,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::fs;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum ContainerRuntime {
|
||||
Podman,
|
||||
Docker,
|
||||
Auto,
|
||||
}
|
||||
|
||||
impl ContainerRuntime {
|
||||
pub fn from_str(s: &str) -> Self {
|
||||
match s.to_lowercase().as_str() {
|
||||
"podman" => ContainerRuntime::Podman,
|
||||
"docker" => ContainerRuntime::Docker,
|
||||
"auto" | _ => ContainerRuntime::Auto,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum BitcoinSimulation {
|
||||
Mock,
|
||||
Testnet,
|
||||
Mainnet,
|
||||
None,
|
||||
}
|
||||
|
||||
impl BitcoinSimulation {
|
||||
pub fn from_str(s: &str) -> Self {
|
||||
match s.to_lowercase().as_str() {
|
||||
"mock" => BitcoinSimulation::Mock,
|
||||
"testnet" => BitcoinSimulation::Testnet,
|
||||
"mainnet" => BitcoinSimulation::Mainnet,
|
||||
"none" | _ => BitcoinSimulation::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
pub data_dir: PathBuf,
|
||||
pub bind_host: String,
|
||||
pub bind_port: u16,
|
||||
pub log_level: String,
|
||||
/// Host IP for container env vars (FM_API_URL, BACKEND_MAINNET_HTTP_HOST, etc.)
|
||||
pub host_ip: String,
|
||||
// Dev mode configuration
|
||||
pub dev_mode: bool,
|
||||
pub container_runtime: ContainerRuntime,
|
||||
pub port_offset: u16,
|
||||
pub bitcoin_simulation: BitcoinSimulation,
|
||||
pub dev_data_dir: PathBuf,
|
||||
/// Nostr discovery: opt-in only. When true + relays non-empty, publish node to relays.
|
||||
#[serde(default)]
|
||||
pub nostr_discovery_enabled: bool,
|
||||
/// Nostr relay URLs (comma-separated). Only used when nostr_discovery_enabled.
|
||||
#[serde(default)]
|
||||
pub nostr_relays: Vec<String>,
|
||||
/// Tor SOCKS5 proxy (e.g. 127.0.0.1:9050). When set, ALL Nostr traffic routes through Tor.
|
||||
#[serde(default)]
|
||||
pub nostr_tor_proxy: Option<String>,
|
||||
/// Phase 3.2 of v1.7.52: route orchestrator-managed backend installs
|
||||
/// through Quadlet (`.container` units in ~/.config/containers/systemd
|
||||
/// + systemctl --user start) instead of `podman create + start`. Default
|
||||
/// off so the legacy path stays the production path until the harness
|
||||
/// at tests/lifecycle/run-gate.sh has gone green against the new path
|
||||
/// on .228 + .198. See `project_v1_7_52_phase3_quadlet_design`.
|
||||
#[serde(default)]
|
||||
pub use_quadlet_backends: bool,
|
||||
/// DHT swarm-assist (Phase 3): when true AND the binary was built with the
|
||||
/// `iroh-swarm` feature, stand up an iroh-blobs provider that fetches release
|
||||
/// blobs peer-to-peer (origin always wins) and seeds them via signed Nostr
|
||||
/// adverts. Off by default; with the feature absent this is inert. Reuses
|
||||
/// `nostr_relays` + `nostr_tor_proxy` for discovery transport.
|
||||
#[serde(default)]
|
||||
pub swarm_enabled: bool,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Detect primary host IP (default-route interface, not `hostname -I` order)
|
||||
async fn detect_host_ip() -> Result<String> {
|
||||
Ok(crate::host_ip::primary_host_ipv4()
|
||||
.await
|
||||
.unwrap_or_else(|| "127.0.0.1".to_string()))
|
||||
}
|
||||
|
||||
pub async fn load() -> Result<Self> {
|
||||
// Default configuration
|
||||
let mut config = Self::default();
|
||||
|
||||
// Detect if running from macOS app bundle
|
||||
if let Ok(exe_path) = std::env::current_exe() {
|
||||
if let Some(exe_str) = exe_path.to_str() {
|
||||
if exe_str.contains(".app/Contents/MacOS") {
|
||||
// Running from macOS bundle - use user's Library directory
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
let app_support =
|
||||
PathBuf::from(home).join("Library/Application Support/Archipelago");
|
||||
config.data_dir = app_support.join("data");
|
||||
config.dev_data_dir = app_support.join("data");
|
||||
tracing::info!(
|
||||
"🍎 Detected macOS bundle, using: {}",
|
||||
app_support.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to load from config file
|
||||
let config_path = Path::new("/etc/archipelago/config.toml");
|
||||
if config_path.exists() {
|
||||
let content = fs::read_to_string(config_path)
|
||||
.await
|
||||
.context("Failed to read config file")?;
|
||||
let file_config: Config =
|
||||
toml::de::from_str(&content).context("Failed to parse config file")?;
|
||||
config = file_config;
|
||||
}
|
||||
|
||||
// Override with environment variables
|
||||
if let Ok(data_dir) = std::env::var("ARCHIPELAGO_DATA_DIR") {
|
||||
config.data_dir = PathBuf::from(data_dir);
|
||||
}
|
||||
|
||||
if let Ok(bind) = std::env::var("ARCHIPELAGO_BIND") {
|
||||
let parts: Vec<&str> = bind.split(':').collect();
|
||||
if parts.len() == 2 {
|
||||
config.bind_host = parts[0].to_string();
|
||||
config.bind_port = parts[1]
|
||||
.parse()
|
||||
.context("Invalid port in ARCHIPELAGO_BIND")?;
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(level) = std::env::var("ARCHIPELAGO_LOG_LEVEL") {
|
||||
config.log_level = level;
|
||||
}
|
||||
|
||||
// Production binaries must not be switched into dev orchestration by
|
||||
// host environment. Several live nodes carried a stale systemd
|
||||
// ARCHIPELAGO_DEV_MODE override, which rewrote production volume
|
||||
// mounts into /tmp and prevented real installs from starting.
|
||||
if std::env::var("ARCHIPELAGO_DEV_MODE").is_ok() {
|
||||
tracing::warn!("Ignoring ARCHIPELAGO_DEV_MODE in production config");
|
||||
}
|
||||
|
||||
if let Ok(runtime) = std::env::var("ARCHIPELAGO_CONTAINER_RUNTIME") {
|
||||
config.container_runtime = ContainerRuntime::from_str(&runtime);
|
||||
}
|
||||
|
||||
if let Ok(offset) = std::env::var("ARCHIPELAGO_PORT_OFFSET") {
|
||||
config.port_offset = offset
|
||||
.parse()
|
||||
.context("Invalid port offset in ARCHIPELAGO_PORT_OFFSET")?;
|
||||
}
|
||||
|
||||
if let Ok(sim) = std::env::var("ARCHIPELAGO_BITCOIN_SIMULATION") {
|
||||
config.bitcoin_simulation = BitcoinSimulation::from_str(&sim);
|
||||
}
|
||||
|
||||
if let Ok(dev_data_dir) = std::env::var("ARCHIPELAGO_DEV_DATA_DIR") {
|
||||
config.dev_data_dir = PathBuf::from(dev_data_dir);
|
||||
}
|
||||
|
||||
// Nostr discovery (opt-in, secure by default)
|
||||
if let Ok(v) = std::env::var("ARCHIPELAGO_NOSTR_DISCOVERY_ENABLED") {
|
||||
config.nostr_discovery_enabled = v.parse().unwrap_or(false);
|
||||
}
|
||||
if let Ok(v) = std::env::var("ARCHIPELAGO_NOSTR_RELAYS") {
|
||||
config.nostr_relays = v
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
}
|
||||
if let Ok(v) = std::env::var("ARCHIPELAGO_NOSTR_TOR_PROXY") {
|
||||
let s = v.trim().to_string();
|
||||
config.nostr_tor_proxy = if s.is_empty() { None } else { Some(s) };
|
||||
}
|
||||
|
||||
// DHT swarm-assist (Phase 3). Opt-in: only takes effect when the binary
|
||||
// was also built with the `iroh-swarm` feature; otherwise inert.
|
||||
if let Ok(v) = std::env::var("ARCHIPELAGO_SWARM_ENABLED") {
|
||||
config.swarm_enabled = parse_truthy_env(&v);
|
||||
}
|
||||
|
||||
// Phase 3.2 of v1.7.52. Truthy values (1, true, yes, on — case-insensitive)
|
||||
// route backend installs through the Quadlet path without requiring a
|
||||
// config.json edit + archipelago.service restart (which would trigger
|
||||
// FM3 cgroup cascade until 3.5 ships). Anything else (or unset) leaves
|
||||
// the config.json value untouched.
|
||||
if let Ok(v) = std::env::var("ARCHIPELAGO_USE_QUADLET_BACKENDS") {
|
||||
if parse_truthy_env(&v) {
|
||||
config.use_quadlet_backends = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Host IP for container env vars (detect if not set)
|
||||
if let Ok(ip) = std::env::var("ARCHIPELAGO_HOST_IP") {
|
||||
config.host_ip = ip;
|
||||
} else {
|
||||
config.host_ip = Self::detect_host_ip()
|
||||
.await
|
||||
.unwrap_or_else(|_| "127.0.0.1".to_string());
|
||||
}
|
||||
|
||||
// Ensure data directory exists
|
||||
fs::create_dir_all(&config.data_dir)
|
||||
.await
|
||||
.context("Failed to create data directory")?;
|
||||
|
||||
// Ensure dev data directory exists if in dev mode
|
||||
if config.dev_mode {
|
||||
fs::create_dir_all(&config.dev_data_dir)
|
||||
.await
|
||||
.context("Failed to create dev data directory")?;
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
data_dir: PathBuf::from("/var/lib/archipelago"),
|
||||
bind_host: "127.0.0.1".to_string(),
|
||||
bind_port: 5678,
|
||||
log_level: "info".to_string(),
|
||||
host_ip: "127.0.0.1".to_string(),
|
||||
dev_mode: false,
|
||||
container_runtime: ContainerRuntime::Auto,
|
||||
port_offset: 10000,
|
||||
bitcoin_simulation: BitcoinSimulation::Mock,
|
||||
dev_data_dir: PathBuf::from("/tmp/archipelago-dev"),
|
||||
// Discoverability is opt-in. Until the user explicitly enables it
|
||||
// (Settings UI / `nostr_discovery_enabled = true` in config), no
|
||||
// presence event is ever published and `handshake.poll` never
|
||||
// contacts a relay. This is the sole knob that controls whether
|
||||
// we leak our DID + npub to the public Nostr relays.
|
||||
nostr_discovery_enabled: false,
|
||||
nostr_relays: vec![
|
||||
"wss://relay.damus.io".into(),
|
||||
"wss://relay.nostr.info".into(),
|
||||
],
|
||||
nostr_tor_proxy: Some("127.0.0.1:9050".into()),
|
||||
use_quadlet_backends: false,
|
||||
swarm_enabled: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Recognise the canonical "the user meant true" forms for boolean env
|
||||
/// vars: 1, true, yes, on (case-insensitive, surrounding whitespace
|
||||
/// trimmed). Anything else — including the typo'd "ture" or the empty
|
||||
/// string — counts as false. Centralised so future env flags stay
|
||||
/// consistent with each other.
|
||||
fn parse_truthy_env(raw: &str) -> bool {
|
||||
matches!(
|
||||
raw.trim().to_ascii_lowercase().as_str(),
|
||||
"1" | "true" | "yes" | "on"
|
||||
)
|
||||
}
|
||||
|
||||
#[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, "127.0.0.1");
|
||||
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_disabled_by_default() {
|
||||
// Discoverability is opt-in: nothing is published to public relays
|
||||
// until the user explicitly turns it on. Flipping this back to
|
||||
// `true` would silently start leaking the local DID + npub on every
|
||||
// boot — guard rail.
|
||||
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://")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_truthy_env_recognises_canonical_forms() {
|
||||
for t in ["1", "true", "TRUE", "yes", "Yes", "on", "ON", " true "] {
|
||||
assert!(parse_truthy_env(t), "{t:?} should parse truthy");
|
||||
}
|
||||
for f in ["", "0", "false", "no", "off", "ture", "anything else", " "] {
|
||||
assert!(!parse_truthy_env(f), "{f:?} should NOT parse truthy");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_use_quadlet_backends_defaults_off() {
|
||||
// Phase 3.2 of v1.7.52 — the new path stays gated until the 5×
|
||||
// harness goes green on .228 and .198. Flipping this default
|
||||
// ahead of that would route every backend install through code
|
||||
// we haven't fleet-validated yet.
|
||||
let config = Config::default();
|
||||
assert!(!config.use_quadlet_backends);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//! Centralized constants for the Archipelago backend.
|
||||
//! Avoids hardcoded values scattered across the codebase.
|
||||
|
||||
/// Bitcoin Core RPC endpoint (localhost only).
|
||||
pub const BITCOIN_RPC_URL: &str = "http://127.0.0.1:8332/";
|
||||
|
||||
/// DWN (Decentralized Web Node) health check endpoint.
|
||||
pub const DWN_HEALTH_URL: &str = "http://127.0.0.1:3100/health";
|
||||
|
||||
/// Tor SOCKS5 proxy for outbound onion connections.
|
||||
pub const TOR_SOCKS_PROXY: &str = "socks5h://127.0.0.1:9050";
|
||||
@@ -0,0 +1,577 @@
|
||||
//! Remote app version catalog — DECOUPLES per-app updates from the binary OTA.
|
||||
//!
|
||||
//! Background: `image_versions.rs` reads the pinned image tags from
|
||||
//! `image-versions.sh`, which is deployed *with the archipelago binary*. That
|
||||
//! coupled every app update to a full node release. This module adds a remote
|
||||
//! catalog (`app-catalog.json`) fetched over HTTP from the same origin as the
|
||||
//! OTA manifest, refreshed periodically and on demand. Bumping an app's version
|
||||
//! is then a JSON edit + push — no binary release.
|
||||
//!
|
||||
//! Resolution order (origin-always-wins, matching the DHT design's posture):
|
||||
//! 1. Remote catalog (this module) — the live source of "available update".
|
||||
//! 2. `image-versions.sh` pin — offline/baseline fallback when the catalog is
|
||||
//! missing or doesn't cover the app.
|
||||
//!
|
||||
//! ## Forward-compatibility with the DHT distribution plan
|
||||
//! (`docs/dht-distribution-design.md`)
|
||||
//! This catalog IS the "discovery / authenticity" layer of that plan. The schema
|
||||
//! is deliberately extensible so the later phases bolt on WITHOUT a breaking
|
||||
//! change:
|
||||
//! - `signature` / `signed_by` (top level) — Phase 0 seed-derived release-root
|
||||
//! signature over the canonical JSON. Absent today; verified when present.
|
||||
//! - per-image `digest` / `size` — BLAKE3/SHA-256 content address + length, so
|
||||
//! the iroh swarm can fetch images by hash with the registry as origin.
|
||||
//! Unknown fields are ignored (no `deny_unknown_fields`), so adding fields on the
|
||||
//! publisher side never breaks older nodes.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use std::time::SystemTime;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// Filename for both the published catalog and the on-node cache.
|
||||
pub const APP_CATALOG_FILE: &str = "app-catalog.json";
|
||||
|
||||
/// Cache of the parsed catalog, invalidated when the cache file mtime changes.
|
||||
static CACHE: Mutex<Option<CacheEntry>> = Mutex::new(None);
|
||||
|
||||
struct CacheEntry {
|
||||
mtime: SystemTime,
|
||||
catalog: AppCatalog,
|
||||
}
|
||||
|
||||
/// Top-level catalog document.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct AppCatalog {
|
||||
/// Schema version. 1 = current. Bump only on incompatible changes.
|
||||
#[serde(default)]
|
||||
pub schema: u32,
|
||||
/// Publish date (RFC 3339 or YYYY-MM-DD). Informational.
|
||||
#[serde(default)]
|
||||
pub updated: String,
|
||||
/// app_id -> entry.
|
||||
#[serde(default)]
|
||||
pub apps: HashMap<String, AppCatalogEntry>,
|
||||
/// DHT-plan forward-compat: detached signature over the canonical JSON,
|
||||
/// produced by the seed-derived release-root key. Absent today.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub signature: Option<String>,
|
||||
/// DHT-plan forward-compat: publisher identity (did:key / npub).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub signed_by: Option<String>,
|
||||
}
|
||||
|
||||
/// Per-app catalog entry.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct AppCatalogEntry {
|
||||
/// User-facing version string (drives the "Update available" badge text).
|
||||
pub version: String,
|
||||
/// Primary single-container image reference (`registry/repo:tag`). For stack
|
||||
/// apps this is the primary container's image (the one whose version the
|
||||
/// badge tracks — e.g. the IndeeHub frontend).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub image: Option<String>,
|
||||
/// Stack apps only: container_name -> image reference. Components omitted here
|
||||
/// fall back to the `image-versions.sh` pin during an update.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub images: Option<HashMap<String, String>>,
|
||||
/// DHT-plan forward-compat: content address of the primary image (unused now).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub digest: Option<String>,
|
||||
/// DHT-plan forward-compat: size in bytes of the primary image (unused now).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub size: Option<u64>,
|
||||
/// Optional human-readable changelog lines for this version.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub changelog: Vec<String>,
|
||||
/// Multi-version support (`docs/bitcoin-multi-version-design.md`): the bounded
|
||||
/// set of versions a user may install or switch to for this app. Empty for
|
||||
/// single-version apps; `version`/`image` above remain the default/latest for
|
||||
/// back-compat. Old nodes ignore this field (no `deny_unknown_fields`).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub versions: Vec<CatalogVersion>,
|
||||
/// Full app manifest, embedded so the app installs from the registry alone —
|
||||
/// no OTA-shipped `apps/<id>/manifest.yml`. Carried as the raw value the
|
||||
/// publisher signed (so it stays part of the verified preimage) and
|
||||
/// deserialized into an `AppManifest` by the orchestrator at load time, where
|
||||
/// it overrides the disk manifest (origin-wins). Absent during the migration
|
||||
/// window => the node falls back to the disk manifest. See
|
||||
/// `docs/registry-manifest-design.md`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub manifest: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// One selectable version in an app's `versions[]` list. The catalog carries a
|
||||
/// curated, bounded set (current + a few majors back); see
|
||||
/// `docs/bitcoin-multi-version-design.md` §3 Phase 1.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
|
||||
pub struct CatalogVersion {
|
||||
/// User-facing + tag-matching version string (e.g. `31.0`,
|
||||
/// `29.3.knots20260508`). Treated as the image tag.
|
||||
pub version: String,
|
||||
/// Concrete image reference for this version. When omitted the orchestrator
|
||||
/// falls back to composing `<default-repo>:<version>` from the entry image.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub image: Option<String>,
|
||||
/// Marks the default / latest version pre-selected in the install modal.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub default: bool,
|
||||
/// Deprecated versions are still installable but badged in the UI.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub deprecated: bool,
|
||||
/// Optional end-of-life date (YYYY-MM-DD), surfaced in the UI.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub eol: Option<String>,
|
||||
}
|
||||
|
||||
/// Read-side cache file search order. Mirrors `image_versions.rs`: the running
|
||||
/// daemon's data dir first (via env for dev), then the canonical runtime path.
|
||||
fn cache_paths() -> Vec<PathBuf> {
|
||||
let mut paths = Vec::new();
|
||||
if let Ok(dir) = std::env::var("ARCHIPELAGO_DATA_DIR") {
|
||||
paths.push(Path::new(&dir).join(APP_CATALOG_FILE));
|
||||
}
|
||||
paths.push(Path::new("/var/lib/archipelago").join(APP_CATALOG_FILE));
|
||||
paths
|
||||
}
|
||||
|
||||
fn find_cache_file() -> Option<(PathBuf, SystemTime)> {
|
||||
for p in cache_paths() {
|
||||
if let Ok(meta) = p.metadata() {
|
||||
if let Ok(mtime) = meta.modified() {
|
||||
return Some((p, mtime));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Load and cache the on-node catalog. Returns an empty catalog when absent —
|
||||
/// callers then fall back to `image-versions.sh`.
|
||||
fn load_catalog() -> AppCatalog {
|
||||
let (path, mtime) = match find_cache_file() {
|
||||
Some(v) => v,
|
||||
None => return AppCatalog::default(),
|
||||
};
|
||||
|
||||
{
|
||||
let cache = CACHE.lock().unwrap();
|
||||
if let Some(ref entry) = *cache {
|
||||
if entry.mtime == mtime {
|
||||
return entry.catalog.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let content = match std::fs::read_to_string(&path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
debug!("app-catalog: failed to read {}: {}", path.display(), e);
|
||||
return AppCatalog::default();
|
||||
}
|
||||
};
|
||||
let catalog: AppCatalog = match serde_json::from_str(&content) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
warn!("app-catalog: invalid JSON at {}: {}", path.display(), e);
|
||||
return AppCatalog::default();
|
||||
}
|
||||
};
|
||||
|
||||
{
|
||||
let mut cache = CACHE.lock().unwrap();
|
||||
*cache = Some(CacheEntry {
|
||||
mtime,
|
||||
catalog: catalog.clone(),
|
||||
});
|
||||
}
|
||||
catalog
|
||||
}
|
||||
|
||||
fn entry_for(app_id: &str) -> Option<AppCatalogEntry> {
|
||||
load_catalog().apps.get(app_id).cloned()
|
||||
}
|
||||
|
||||
/// Primary image for an app per the remote catalog, if covered.
|
||||
pub fn catalog_primary_image(app_id: &str) -> Option<String> {
|
||||
entry_for(app_id).and_then(|e| e.image)
|
||||
}
|
||||
|
||||
/// Per-container stack image overrides from the catalog (container_name -> image).
|
||||
pub fn catalog_stack_images(app_id: &str) -> HashMap<String, String> {
|
||||
entry_for(app_id).and_then(|e| e.images).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// All `(app_id, manifest-value)` pairs the registry catalog carries. The
|
||||
/// orchestrator deserializes + validates each into an `AppManifest` and prefers
|
||||
/// it over the disk manifest (origin-wins); disk remains the migration fallback.
|
||||
/// Empty when the catalog is absent or no entry embeds a manifest.
|
||||
pub fn catalog_manifest_values() -> Vec<(String, serde_json::Value)> {
|
||||
load_catalog()
|
||||
.apps
|
||||
.into_iter()
|
||||
.filter_map(|(id, e)| e.manifest.map(|m| (id, m)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The catalog's default/latest version string for an app (the top-level
|
||||
/// `version` field), if covered. Used to decide whether an install-time
|
||||
/// selection should pin (older) or track-latest (default).
|
||||
pub fn catalog_default_version(app_id: &str) -> Option<String> {
|
||||
entry_for(app_id)
|
||||
.map(|e| e.version)
|
||||
.filter(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
/// Curated, selectable versions for an app per the remote catalog. Empty when
|
||||
/// the catalog is absent or the app is single-version. The default entry (if
|
||||
/// any) sorts first so callers can pre-select it.
|
||||
pub fn catalog_versions(app_id: &str) -> Vec<CatalogVersion> {
|
||||
let mut versions = entry_for(app_id).map(|e| e.versions).unwrap_or_default();
|
||||
versions.sort_by_key(|v| !v.default); // default first, stable otherwise
|
||||
versions
|
||||
}
|
||||
|
||||
/// Resolve the image for a specific selectable `version` of `app_id`, validated
|
||||
/// same-repo against `manifest_image` (the same guard `catalog_image_override`
|
||||
/// applies). The version's explicit `image` is used when present; otherwise the
|
||||
/// repo of `manifest_image` is retagged with `version`. Returns `None` when the
|
||||
/// version is unknown or would point at a different repository — the caller then
|
||||
/// keeps the default resolution and the switch is refused upstream.
|
||||
pub fn catalog_image_for_version(
|
||||
app_id: &str,
|
||||
version: &str,
|
||||
manifest_image: &str,
|
||||
) -> Option<String> {
|
||||
let entry = catalog_versions(app_id)
|
||||
.into_iter()
|
||||
.find(|v| v.version == version)?;
|
||||
let manifest_repo =
|
||||
crate::container::image_versions::image_without_registry_or_tag(manifest_image);
|
||||
let candidate = match entry.image {
|
||||
Some(img) => img,
|
||||
None => {
|
||||
// Retag the manifest's full registry/repo with the requested version.
|
||||
let repo = manifest_image
|
||||
.rsplit_once(':')
|
||||
// keep registry:port colons intact: only strip a tag after the last '/'
|
||||
.filter(|(left, _)| left.contains('/'))
|
||||
.map(|(left, _)| left)
|
||||
.unwrap_or(manifest_image);
|
||||
format!("{repo}:{version}")
|
||||
}
|
||||
};
|
||||
let same_repo = crate::container::image_versions::image_without_registry_or_tag(&candidate)
|
||||
== manifest_repo;
|
||||
if same_repo {
|
||||
Some(candidate)
|
||||
} else {
|
||||
warn!(
|
||||
"app-catalog: ignoring version {} for {} — repo mismatch (candidate={}, manifest={})",
|
||||
version, app_id, candidate, manifest_image
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Image override for the orchestrator's install/upgrade path. Returns the
|
||||
/// catalog's primary image for `app_id` ONLY when it refers to the same
|
||||
/// repository as the manifest's current image — a guard so a catalog typo can
|
||||
/// never redirect an app to an unrelated image. `None` means "use the manifest
|
||||
/// image as-is" (catalog absent, app uncovered, or repo mismatch).
|
||||
pub fn catalog_image_override(app_id: &str, manifest_image: &str) -> Option<String> {
|
||||
let candidate = catalog_primary_image(app_id)?;
|
||||
let same_repo = crate::container::image_versions::image_without_registry_or_tag(&candidate)
|
||||
== crate::container::image_versions::image_without_registry_or_tag(manifest_image);
|
||||
if same_repo {
|
||||
Some(candidate)
|
||||
} else {
|
||||
warn!(
|
||||
"app-catalog: ignoring image for {} — repo mismatch (catalog={}, manifest={})",
|
||||
app_id, candidate, manifest_image
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Decoupled "available update" check for ALL apps.
|
||||
///
|
||||
/// Prefers the remote catalog; when the catalog covers the app, its verdict is
|
||||
/// authoritative (so we never advertise a stale `image-versions.sh` pin over a
|
||||
/// newer catalog, nor vice-versa). Falls back to the deployed pin only when the
|
||||
/// catalog is missing or doesn't cover the app.
|
||||
pub fn available_update_for_app(app_id: &str, running_image: &str) -> Option<String> {
|
||||
// A runner-pinned version is an explicit "stay here" choice — never advertise
|
||||
// an update over it (design §3 Phase 3). Auto-update, when enabled, ignores
|
||||
// the pin and is driven by the catalog tick, not this badge.
|
||||
if crate::container::version_config::pinned_version(app_id).is_some() {
|
||||
return None;
|
||||
}
|
||||
if let Some(catalog_image) = catalog_primary_image(app_id) {
|
||||
// Catalog covers this app with a concrete image -> authoritative.
|
||||
return crate::container::image_versions::available_update_for_images(
|
||||
&catalog_image,
|
||||
running_image,
|
||||
);
|
||||
}
|
||||
// Not covered by the catalog -> baseline pin from image-versions.sh.
|
||||
crate::container::image_versions::available_update_for_app(app_id, running_image)
|
||||
}
|
||||
|
||||
/// Derive candidate catalog URLs from the OTA mirror list by swapping the
|
||||
/// manifest filename for the catalog filename. Falls back to the default
|
||||
/// manifest origin when no mirrors are configured.
|
||||
fn catalog_urls_from_mirrors(mirrors: &[crate::update::UpdateMirror]) -> Vec<String> {
|
||||
let mut urls: Vec<String> = mirrors
|
||||
.iter()
|
||||
.filter_map(|m| {
|
||||
// mirror.url ends with ".../releases/manifest.json"
|
||||
if m.url.ends_with("manifest.json") {
|
||||
Some(m.url.replace("manifest.json", APP_CATALOG_FILE))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
urls.dedup();
|
||||
urls
|
||||
}
|
||||
|
||||
/// Outcome of [`refresh_catalog`]: the app count of the fetched catalog and
|
||||
/// whether the cached bytes actually changed. `changed` drives the manifest-
|
||||
/// overlay reload — catalog manifests only take effect once `load_manifests`
|
||||
/// re-runs, and reloading on every unchanged hourly poll would be pure churn.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct CatalogRefresh {
|
||||
pub apps: usize,
|
||||
pub changed: bool,
|
||||
}
|
||||
|
||||
/// Fetch the catalog from the first reachable mirror and atomically write it to
|
||||
/// `<data_dir>/app-catalog.json`. Returns the app count and whether the cache
|
||||
/// changed. Best-effort: a fetch failure leaves the existing cache untouched
|
||||
/// (origin-always-wins; updates simply aren't refreshed this cycle).
|
||||
pub async fn refresh_catalog(data_dir: &Path) -> anyhow::Result<CatalogRefresh> {
|
||||
let mirrors = crate::update::load_mirrors(data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let urls = catalog_urls_from_mirrors(&mirrors);
|
||||
if urls.is_empty() {
|
||||
debug!("app-catalog: no mirror-derived URLs to fetch from");
|
||||
return Ok(CatalogRefresh {
|
||||
apps: 0,
|
||||
changed: false,
|
||||
});
|
||||
}
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(20))
|
||||
.build()?;
|
||||
|
||||
let mut last_err: Option<anyhow::Error> = None;
|
||||
for url in &urls {
|
||||
match fetch_one(&client, url).await {
|
||||
Ok((catalog, body)) => {
|
||||
let count = catalog.apps.len();
|
||||
let changed = write_cache(data_dir, &body)?;
|
||||
if changed {
|
||||
// Invalidate the in-process cache so the next read re-parses.
|
||||
*CACHE.lock().unwrap() = None;
|
||||
}
|
||||
info!(
|
||||
"app-catalog: refreshed from {} ({} apps{})",
|
||||
url,
|
||||
count,
|
||||
if changed { ", changed" } else { ", unchanged" }
|
||||
);
|
||||
return Ok(CatalogRefresh {
|
||||
apps: count,
|
||||
changed,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("app-catalog: fetch {} failed: {}", url, e);
|
||||
last_err = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("no catalog mirrors reachable")))
|
||||
}
|
||||
|
||||
async fn fetch_one(client: &reqwest::Client, url: &str) -> anyhow::Result<(AppCatalog, String)> {
|
||||
let resp = client.get(url).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("HTTP {}", resp.status());
|
||||
}
|
||||
let body = resp.text().await?;
|
||||
let catalog: AppCatalog = serde_json::from_str(&body)?;
|
||||
|
||||
// DHT Phase 0 authenticity: verify the release-root signature when present.
|
||||
// We verify against the raw JSON (the exact bytes the publisher signed),
|
||||
// not a re-serialization of the typed struct, so unknown forward-compat
|
||||
// fields stay part of the signed preimage. Unsigned catalogs are still
|
||||
// accepted during the migration window — same trust level as today's
|
||||
// manifest — but a *present* signature that fails is a hard reject so a
|
||||
// tampering mirror cannot pass off altered bytes.
|
||||
let raw: serde_json::Value = serde_json::from_str(&body)?;
|
||||
match crate::trust::verify_detached(&raw)? {
|
||||
crate::trust::SignatureStatus::Unsigned => {
|
||||
debug!("app-catalog: unsigned (accepted during migration window)");
|
||||
}
|
||||
crate::trust::SignatureStatus::Verified {
|
||||
signer_did,
|
||||
anchored,
|
||||
} => {
|
||||
if anchored {
|
||||
info!(
|
||||
"app-catalog: release-root signature verified ({})",
|
||||
signer_did
|
||||
);
|
||||
} else {
|
||||
warn!(
|
||||
"app-catalog: signature self-consistent but release-root anchor \
|
||||
not pinned ({}); cannot confirm signer identity",
|
||||
signer_did
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((catalog, body))
|
||||
}
|
||||
|
||||
/// Atomically write the catalog cache. Caches the RAW fetched bytes — not a
|
||||
/// re-serialization of the typed struct — for two reasons: the struct's
|
||||
/// `apps` HashMap serializes in nondeterministic order (a re-serialized
|
||||
/// comparison would report "changed" on every poll), and the raw bytes are
|
||||
/// the signed preimage, so the cache stays signature-verifiable. Returns
|
||||
/// `false` (skipping the write) when the bytes are identical to what's
|
||||
/// already cached, so callers can tell a genuine catalog change from an
|
||||
/// unchanged poll.
|
||||
fn write_cache(data_dir: &Path, body: &str) -> anyhow::Result<bool> {
|
||||
let dest = data_dir.join(APP_CATALOG_FILE);
|
||||
if std::fs::read_to_string(&dest)
|
||||
.map(|current| current == body)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
let tmp = data_dir.join(format!("{}.tmp", APP_CATALOG_FILE));
|
||||
std::fs::write(&tmp, body)?;
|
||||
std::fs::rename(&tmp, &dest)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_and_ignores_unknown_fields() {
|
||||
let json = r#"{
|
||||
"schema": 1,
|
||||
"updated": "2026-06-16",
|
||||
"future_field": "ignored",
|
||||
"signature": "sig123",
|
||||
"signed_by": "did:key:zABC",
|
||||
"apps": {
|
||||
"indeedhub": {
|
||||
"version": "1.0.1",
|
||||
"image": "146.59.87.168:3000/lfg2025/indeedhub:1.0.1",
|
||||
"digest": "blake3:deadbeef",
|
||||
"size": 12345,
|
||||
"another_future_field": true
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
let cat: AppCatalog = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(cat.schema, 1);
|
||||
assert_eq!(cat.signature.as_deref(), Some("sig123"));
|
||||
let e = cat.apps.get("indeedhub").unwrap();
|
||||
assert_eq!(e.version, "1.0.1");
|
||||
assert_eq!(
|
||||
e.image.as_deref(),
|
||||
Some("146.59.87.168:3000/lfg2025/indeedhub:1.0.1")
|
||||
);
|
||||
assert_eq!(e.digest.as_deref(), Some("blake3:deadbeef"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_cache_reports_changed_only_on_new_bytes() {
|
||||
// The changed flag gates the runtime manifest-overlay reload: an
|
||||
// unchanged hourly poll must NOT report changed (or every tick would
|
||||
// rebuild the manifest map), while a genuinely new catalog must. Raw
|
||||
// fetched bytes are compared — a re-serialized comparison would flap
|
||||
// on the apps HashMap's nondeterministic key order (seen live on .228).
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let body = r#"{"schema":1,"apps":{"demo":{"version":"1.0.0"}}}"#;
|
||||
assert!(
|
||||
write_cache(dir.path(), body).unwrap(),
|
||||
"first write is a change"
|
||||
);
|
||||
assert!(
|
||||
!write_cache(dir.path(), body).unwrap(),
|
||||
"identical rewrite is not a change"
|
||||
);
|
||||
let body2 = r#"{"schema":1,"apps":{"demo":{"version":"1.0.1"}}}"#;
|
||||
assert!(
|
||||
write_cache(dir.path(), body2).unwrap(),
|
||||
"new catalog bytes are a change"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(dir.path().join(APP_CATALOG_FILE)).unwrap(),
|
||||
body2,
|
||||
"cache holds the raw signed preimage bytes"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_carries_embedded_manifest() {
|
||||
let json = r#"{
|
||||
"schema": 1,
|
||||
"apps": {
|
||||
"demo": {
|
||||
"version": "1.0.0",
|
||||
"manifest": {
|
||||
"app": {
|
||||
"id": "demo",
|
||||
"name": "Demo",
|
||||
"version": "1.0.0",
|
||||
"container": { "image": "registry/demo:1.0.0" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
let cat: AppCatalog = serde_json::from_str(json).unwrap();
|
||||
let e = cat.apps.get("demo").unwrap();
|
||||
let m = e.manifest.as_ref().expect("manifest present");
|
||||
assert_eq!(m["app"]["id"], "demo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_catalog_when_absent_is_default() {
|
||||
let cat = AppCatalog::default();
|
||||
assert!(cat.apps.is_empty());
|
||||
assert!(cat.signature.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_url_derived_from_mirror() {
|
||||
let mirrors = vec![crate::update::UpdateMirror {
|
||||
url: "http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json"
|
||||
.to_string(),
|
||||
label: "Server 1".to_string(),
|
||||
}];
|
||||
let urls = catalog_urls_from_mirrors(&mirrors);
|
||||
assert_eq!(
|
||||
urls,
|
||||
vec![
|
||||
"http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/app-catalog.json"
|
||||
.to_string()
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
//! bitcoin-ui nginx.conf renderer.
|
||||
//!
|
||||
//! Step 7 of the rust-orchestrator migration. Replaces the old
|
||||
//! `sed -i __BITCOIN_RPC_AUTH__` approach from `first-boot-containers.sh`
|
||||
//! (which destructively overwrote its own template, broke on rotation,
|
||||
//! and had no story for dual Knots/Core UIs) with a binary-embedded
|
||||
//! template rendered at install/reconcile time and atomic-written to
|
||||
//! disk.
|
||||
//!
|
||||
//! The manifest bind-mounts the rendered file read-only into the
|
||||
//! container at `/etc/nginx/conf.d/default.conf`. On every reconcile
|
||||
//! pass we re-render and compare — if the rendered bytes would differ
|
||||
//! from what's on disk (password rotated, template changed via OTA),
|
||||
//! we rewrite atomically and the reconciler restarts the container.
|
||||
//!
|
||||
//! Source of truth:
|
||||
//! * RPC user: hardcoded `archipelago` (matches the image's `bitcoin.conf`).
|
||||
//! * RPC password: `/var/lib/archipelago/secrets/bitcoin-rpc-password`,
|
||||
//! plaintext, written by the seed-derived credential setup.
|
||||
//!
|
||||
//! Both Knots and Core back-ends expose RPC on 127.0.0.1:8332 with the
|
||||
//! same auth shape, so one template serves both.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use base64::Engine;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tokio::fs;
|
||||
|
||||
/// The nginx.conf template. Embedded at compile time so it can never
|
||||
/// drift from the code that renders it, and ships atomically with OTA.
|
||||
///
|
||||
/// `{{BITCOIN_RPC_AUTH}}` is the only placeholder — replaced with a
|
||||
/// `base64(user:password)` blob at render time.
|
||||
pub(crate) const TEMPLATE: &str = include_str!("bitcoin_ui_nginx.conf.template");
|
||||
|
||||
/// The single placeholder in `TEMPLATE`.
|
||||
const PLACEHOLDER: &str = "{{BITCOIN_RPC_AUTH}}";
|
||||
|
||||
/// Hardcoded RPC user. Matches the user written into `bitcoin.conf` by
|
||||
/// the bitcoin-core/bitcoin-knots bootstrap, and the legacy
|
||||
/// `BITCOIN_RPC_USER="archipelago"` from `first-boot-containers.sh`.
|
||||
const RPC_USER: &str = "archipelago";
|
||||
|
||||
/// Default path to the plaintext RPC password secret.
|
||||
///
|
||||
/// Written by the seed-derived credential flow; same file the bash
|
||||
/// scripts read today at `first-boot-containers.sh:277` and `:1225`.
|
||||
pub const DEFAULT_SECRET_PATH: &str = "/var/lib/archipelago/secrets/bitcoin-rpc-password";
|
||||
|
||||
/// Default output path for the rendered nginx.conf.
|
||||
///
|
||||
/// The manifest bind-mounts this file read-only into the bitcoin-ui
|
||||
/// container at `/etc/nginx/conf.d/default.conf`.
|
||||
pub const DEFAULT_RENDERED_PATH: &str = "/var/lib/archipelago/bitcoin-ui/nginx.conf";
|
||||
|
||||
/// Parameters for rendering. Injectable so tests can hit a tmpdir
|
||||
/// instead of `/var/lib/archipelago`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RenderPaths {
|
||||
/// Path to read the plaintext RPC password from.
|
||||
pub secret_path: PathBuf,
|
||||
/// Path to write the rendered nginx.conf to.
|
||||
pub rendered_path: PathBuf,
|
||||
}
|
||||
|
||||
impl Default for RenderPaths {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
secret_path: PathBuf::from(DEFAULT_SECRET_PATH),
|
||||
rendered_path: PathBuf::from(DEFAULT_RENDERED_PATH),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of a render pass. `Written` if the rendered bytes differed
|
||||
/// from the current on-disk contents and we rewrote; `Unchanged` if
|
||||
/// they matched and we left the file alone.
|
||||
///
|
||||
/// The caller (reconciler / install path) decides whether to restart
|
||||
/// the bitcoin-ui container based on this.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RenderOutcome {
|
||||
Written,
|
||||
Unchanged,
|
||||
}
|
||||
|
||||
/// Render the bitcoin-ui nginx.conf and atomic-write it to disk if it
|
||||
/// differs from what's already there.
|
||||
///
|
||||
/// Idempotent: safe to call on every reconcile pass. Does a byte
|
||||
/// comparison before writing so an unchanged password + template is a
|
||||
/// no-op (no inode churn, no container restart cascade).
|
||||
///
|
||||
/// Errors if the secret file is missing or empty. Upstream callers
|
||||
/// treat that as "bitcoin-ui isn't installable yet" rather than fatal
|
||||
/// — the RPC password comes into being during bitcoin-core's own
|
||||
/// bootstrap, which may not have happened yet on a fresh node.
|
||||
pub async fn render(paths: &RenderPaths) -> Result<RenderOutcome> {
|
||||
let password = read_password(&paths.secret_path).await?;
|
||||
let auth_b64 = encode_basic_auth(RPC_USER, &password);
|
||||
let rendered = TEMPLATE.replace(PLACEHOLDER, &auth_b64);
|
||||
|
||||
// Compare against existing. read-to-string fails on ENOENT (first
|
||||
// install) — treat as "different".
|
||||
let existing = fs::read_to_string(&paths.rendered_path).await.ok();
|
||||
if existing.as_deref() == Some(rendered.as_str()) {
|
||||
return Ok(RenderOutcome::Unchanged);
|
||||
}
|
||||
|
||||
// Atomic write: write to sibling tmp + rename. Keeps the bind-
|
||||
// mounted file pointing at a fully-formed config at all times.
|
||||
let parent = paths
|
||||
.rendered_path
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow::anyhow!("rendered_path has no parent directory"))?;
|
||||
fs::create_dir_all(parent)
|
||||
.await
|
||||
.with_context(|| format!("creating {}", parent.display()))?;
|
||||
|
||||
let tmp = unique_tmp_path(&paths.rendered_path);
|
||||
fs::write(&tmp, &rendered)
|
||||
.await
|
||||
.with_context(|| format!("writing tmp {}", tmp.display()))?;
|
||||
fs::rename(&tmp, &paths.rendered_path)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"renaming {} -> {}",
|
||||
tmp.display(),
|
||||
paths.rendered_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
tracing::info!(
|
||||
path = %paths.rendered_path.display(),
|
||||
auth_hash = %short_hash(&auth_b64),
|
||||
"bitcoin-ui nginx.conf rendered"
|
||||
);
|
||||
|
||||
Ok(RenderOutcome::Written)
|
||||
}
|
||||
|
||||
fn unique_tmp_path(dest: &Path) -> PathBuf {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let ts = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
dest.with_extension(format!("tmp.{ts}.{n}"))
|
||||
}
|
||||
|
||||
/// Read the plaintext RPC password from disk. Trims trailing newlines
|
||||
/// (common from `echo "$PASS" > file`) but rejects an empty result.
|
||||
async fn read_password(path: &Path) -> Result<String> {
|
||||
let raw = fs::read_to_string(path)
|
||||
.await
|
||||
.with_context(|| format!("reading bitcoin RPC password from {}", path.display()))?;
|
||||
let trimmed = raw.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
anyhow::bail!(
|
||||
"bitcoin RPC password file {} is empty — bitcoin-core bootstrap hasn't written it yet",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
Ok(trimmed)
|
||||
}
|
||||
|
||||
/// `base64("user:password")` — the value nginx puts after `Basic ` in
|
||||
/// the upstream `Authorization` header.
|
||||
fn encode_basic_auth(user: &str, password: &str) -> String {
|
||||
let raw = format!("{user}:{password}");
|
||||
base64::engine::general_purpose::STANDARD.encode(raw.as_bytes())
|
||||
}
|
||||
|
||||
/// Short hash of the auth value for logging — we never want the
|
||||
/// plaintext or full base64 in logs (it's a credential), but a stable
|
||||
/// fingerprint helps correlate rotations.
|
||||
fn short_hash(s: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(s.as_bytes());
|
||||
let digest = hasher.finalize();
|
||||
hex::encode(&digest[..4])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn paths_in(dir: &Path, password: &str) -> RenderPaths {
|
||||
let secret = dir.join("bitcoin-rpc-password");
|
||||
std::fs::write(&secret, password).unwrap();
|
||||
RenderPaths {
|
||||
secret_path: secret,
|
||||
rendered_path: dir.join("nginx.conf"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn render_writes_file_with_substitution() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let paths = paths_in(tmp.path(), "hunter2");
|
||||
let outcome = render(&paths).await.unwrap();
|
||||
assert_eq!(outcome, RenderOutcome::Written);
|
||||
let contents = std::fs::read_to_string(&paths.rendered_path).unwrap();
|
||||
// archipelago:hunter2 -> "YXJjaGlwZWxhZ286aHVudGVyMg=="
|
||||
assert!(
|
||||
contents.contains("YXJjaGlwZWxhZ286aHVudGVyMg=="),
|
||||
"base64 auth not found in rendered config:\n{contents}"
|
||||
);
|
||||
assert!(
|
||||
!contents.contains(PLACEHOLDER),
|
||||
"placeholder left in output"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn render_is_idempotent_when_password_unchanged() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let paths = paths_in(tmp.path(), "hunter2");
|
||||
let first = render(&paths).await.unwrap();
|
||||
assert_eq!(first, RenderOutcome::Written);
|
||||
let second = render(&paths).await.unwrap();
|
||||
assert_eq!(second, RenderOutcome::Unchanged);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn render_rewrites_on_password_rotation() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let paths = paths_in(tmp.path(), "old-pass");
|
||||
render(&paths).await.unwrap();
|
||||
// Rotate.
|
||||
std::fs::write(&paths.secret_path, "new-pass").unwrap();
|
||||
let outcome = render(&paths).await.unwrap();
|
||||
assert_eq!(outcome, RenderOutcome::Written);
|
||||
let contents = std::fs::read_to_string(&paths.rendered_path).unwrap();
|
||||
// archipelago:new-pass -> "YXJjaGlwZWxhZ286bmV3LXBhc3M="
|
||||
assert!(contents.contains("YXJjaGlwZWxhZ286bmV3LXBhc3M="));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn render_trims_trailing_newline_from_secret() {
|
||||
// Matches `echo "$PASS" > file` behaviour.
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let paths = paths_in(tmp.path(), "hunter2\n");
|
||||
render(&paths).await.unwrap();
|
||||
let contents = std::fs::read_to_string(&paths.rendered_path).unwrap();
|
||||
assert!(
|
||||
contents.contains("YXJjaGlwZWxhZ286aHVudGVyMg=="),
|
||||
"trailing newline should be stripped before encoding"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn render_errors_on_empty_password() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let paths = paths_in(tmp.path(), "");
|
||||
let err = render(&paths).await.unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("empty"), "unexpected error: {msg}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn render_errors_when_secret_missing() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let paths = RenderPaths {
|
||||
secret_path: tmp.path().join("does-not-exist"),
|
||||
rendered_path: tmp.path().join("nginx.conf"),
|
||||
};
|
||||
let err = render(&paths).await.unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(
|
||||
msg.contains("reading bitcoin RPC password"),
|
||||
"unexpected error: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_contains_exactly_one_placeholder() {
|
||||
// Safety net: if someone adds a second placeholder to the
|
||||
// template without updating the renderer, we want a test to
|
||||
// fail loudly rather than ship a half-substituted config.
|
||||
let count = TEMPLATE.matches(PLACEHOLDER).count();
|
||||
assert_eq!(count, 1, "template must contain exactly one {PLACEHOLDER}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_proxies_bitcoin_rpc_on_8332() {
|
||||
// Lock in the core shape so a bad template edit doesn't ship.
|
||||
assert!(TEMPLATE.contains("proxy_pass http://127.0.0.1:8332/"));
|
||||
assert!(TEMPLATE.contains("location /bitcoin-rpc/"));
|
||||
assert!(TEMPLATE.contains("listen 8334"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
server {
|
||||
listen 8334;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
location /bitcoin-rpc/ {
|
||||
proxy_pass http://127.0.0.1:8332/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Authorization "Basic {{BITCOIN_RPC_AUTH}}";
|
||||
add_header Access-Control-Allow-Origin *;
|
||||
add_header Access-Control-Allow-Methods "POST, GET, OPTIONS";
|
||||
add_header Access-Control-Allow-Headers "Content-Type, Authorization";
|
||||
if ($request_method = OPTIONS) { return 204; }
|
||||
}
|
||||
location /bitcoin-status {
|
||||
proxy_pass http://127.0.0.1:5678/bitcoin-status;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
location /rpc/v1 {
|
||||
proxy_pass http://127.0.0.1:5678/rpc/v1;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
proxy_set_header X-CSRF-Token $http_x_csrf_token;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
location / { try_files $uri $uri/ /index.html; }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user