bug fixing and deploy and build diagnostics

This commit is contained in:
Dorian
2026-03-22 03:30:21 +00:00
parent 1f8287c4c3
commit e4e0ef4f11
198 changed files with 21703 additions and 19587 deletions
+1
View File
@@ -147,6 +147,7 @@ dependencies = [
"async-trait",
"chrono",
"futures",
"hyper 0.14.32",
"indexmap",
"log",
"reqwest",
-916
View File
@@ -1,916 +0,0 @@
use crate::api::rpc::RpcHandler;
use crate::content_server;
use crate::electrs_status;
use crate::monitoring::MetricsStore;
use crate::network::dwn_store::DwnStore;
use crate::node_message as node_msg;
use crate::config::Config;
use crate::session::{self, SessionStore};
use crate::state::StateManager;
use anyhow::Result;
use futures_util::{SinkExt, StreamExt};
use hyper::{Method, Request, Response, StatusCode};
use hyper_ws_listener::WsStream;
use std::sync::Arc;
use tokio::sync::broadcast;
use tokio_tungstenite::tungstenite::Message;
use std::time::Instant;
use tracing::{debug, info};
pub struct ApiHandler {
config: Config,
rpc_handler: Arc<RpcHandler>,
state_manager: Arc<StateManager>,
metrics_store: Arc<MetricsStore>,
session_store: SessionStore,
}
impl ApiHandler {
pub async fn new(
config: Config,
state_manager: Arc<StateManager>,
metrics_store: Arc<MetricsStore>,
) -> Result<Self> {
let session_store = SessionStore::new().await;
let rpc_handler = Arc::new(
RpcHandler::new(
config.clone(),
state_manager.clone(),
metrics_store.clone(),
session_store.clone(),
)
.await?,
);
Ok(Self {
config,
rpc_handler,
state_manager,
metrics_store,
session_store,
})
}
/// Access the RPC handler (for service initialization after construction).
pub fn rpc_handler(&self) -> &Arc<RpcHandler> {
&self.rpc_handler
}
/// Check if the request has a valid session cookie.
async fn is_authenticated(&self, headers: &hyper::HeaderMap) -> bool {
match session::extract_session_cookie(headers) {
Some(token) => self.session_store.validate(&token).await,
None => false,
}
}
/// Build a 401 Unauthorized JSON response.
fn unauthorized() -> Response<hyper::Body> {
let body = serde_json::json!({ "error": "Unauthorized" });
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header("Content-Type", "application/json")
.body(hyper::Body::from(body_bytes))
.unwrap()
}
/// Allowed CORS origins derived from the config host IP.
fn allowed_origins(&self) -> Vec<String> {
let mut origins = vec![
format!("http://{}", self.config.host_ip),
format!("https://{}", self.config.host_ip),
];
if self.config.dev_mode {
origins.push("http://localhost:8100".to_string()); // Vite dev server
}
origins
}
/// Validate the Origin header against allowed origins.
/// Returns the matched origin if valid, None if cross-origin is not allowed.
fn validate_origin(&self, headers: &hyper::HeaderMap) -> Option<String> {
let origin = headers
.get("origin")
.and_then(|v| v.to_str().ok())?;
let allowed = self.allowed_origins();
if allowed.iter().any(|a| a == origin) {
Some(origin.to_string())
} else {
None
}
}
pub async fn handle_request(
&self,
req: Request<hyper::Body>,
) -> Result<Response<hyper::Body>> {
let path = req.uri().path().to_string();
let method = req.method().clone();
// Handle CORS preflight for all routes
if method == Method::OPTIONS {
let mut builder = Response::builder()
.status(StatusCode::NO_CONTENT)
.header("Vary", "Origin");
if let Some(origin) = self.validate_origin(req.headers()) {
builder = builder
.header("Access-Control-Allow-Origin", &origin)
.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
.header("Access-Control-Allow-Headers", "Content-Type, X-CSRF-Token")
.header("Access-Control-Allow-Credentials", "true");
}
return Ok(builder.body(hyper::Body::empty()).unwrap());
}
// WebSocket upgrade — validate session before upgrading
if method == Method::GET && path == "/ws/db" {
if !self.is_authenticated(req.headers()).await {
tracing::warn!("401 WebSocket /ws/db — session invalid or missing");
return Ok(Self::unauthorized());
}
return Self::handle_websocket(req, self.state_manager.clone(), self.metrics_store.clone()).await;
}
// Convert body to bytes for non-WS routes
let headers = req.headers().clone();
let (parts, body) = req.into_parts();
let body_bytes = hyper::body::to_bytes(body).await
.map_err(|e| anyhow::anyhow!("Failed to read body: {}", e))?;
let req_with_bytes = Request::from_parts(parts, hyper::Body::from(body_bytes.clone()));
debug!("{} {}", method, path);
match (method, path.as_str()) {
// RPC — auth is handled inside rpc handler per-method
(Method::POST, "/rpc/v1") => self.rpc_handler.handle(req_with_bytes).await,
// Health — unauthenticated, returns JSON with service status
(Method::GET, "/health") => {
let recovery_complete = crate::crash_recovery::is_recovery_complete();
let uptime = crate::crash_recovery::uptime_seconds();
let health_status = if recovery_complete { "ok" } else { "degraded" };
let status = serde_json::json!({
"status": health_status,
"crash_recovery_complete": recovery_complete,
"uptime_seconds": uptime,
"version": env!("CARGO_PKG_VERSION"),
"services": {
"rpc": true,
"sessions": true,
}
});
Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.body(hyper::Body::from(serde_json::to_vec(&status).unwrap_or_default()))
.unwrap())
}
// Node message — P2P endpoint (authenticated by source validation, not cookie)
(Method::POST, "/archipelago/node-message") => {
Self::handle_node_message(body_bytes).await
}
// Content serving — peers access shared content over Tor (no session auth)
(Method::GET, p) if p.starts_with("/content/") => {
Self::handle_content_request(p, &headers, &self.config).await
}
// Content catalog — list available content (no session auth, for peers)
(Method::GET, "/content") => {
Self::handle_content_catalog(&self.config).await
}
// Electrs status — unauthenticated (read-only sync status)
(Method::GET, "/electrs-status") => Self::handle_electrs_status().await,
// LND connect info — nginx validates session cookie (presence check),
// backend is bound to 127.0.0.1 so only nginx can reach it.
// No backend auth check here because the LND UI iframe fetches this
// endpoint and the session cookie flow is validated at the nginx layer.
(Method::GET, "/lnd-connect-info") => {
Self::handle_lnd_connect_info(self.rpc_handler.clone()).await
}
// Container logs — requires session
(Method::GET, path) if path.starts_with("/api/container/logs") => {
if !self.is_authenticated(&headers).await {
return Ok(Self::unauthorized());
}
let origin = self.validate_origin(&headers).unwrap_or_default();
Self::handle_container_logs_http(self.rpc_handler.clone(), path, &origin).await
}
// LND proxy — requires session
(Method::GET, path) if path.starts_with("/proxy/lnd/") => {
if !self.is_authenticated(&headers).await {
return Ok(Self::unauthorized());
}
let origin = self.validate_origin(&headers).unwrap_or_default();
Self::handle_lnd_proxy(path, &origin).await
}
// DWN health — unauthenticated
(Method::GET, "/dwn/health") => {
Self::handle_dwn_health(&self.config).await
}
// DWN message processing — peers access over Tor for sync (no session auth)
(Method::POST, "/dwn") => {
Self::handle_dwn_message(body_bytes, &self.config).await
}
_ => Ok(Response::builder()
.status(StatusCode::NOT_FOUND)
.body(hyper::Body::from("Not Found"))
.unwrap()),
}
}
async fn handle_container_logs_http(
rpc: Arc<RpcHandler>,
path: &str,
cors_origin: &str,
) -> Result<Response<hyper::Body>> {
let query = path
.strip_prefix("/api/container/logs")
.and_then(|s| s.strip_prefix('?'))
.unwrap_or("");
let params: std::collections::HashMap<String, String> =
query
.split('&')
.filter_map(|p| {
let mut it = p.splitn(2, '=');
let k = it.next()?.to_string();
let v = it.next()?.to_string();
Some((k, v))
})
.collect();
let app_id = params.get("app_id").map(|s| s.as_str()).unwrap_or("lnd");
// Validate app_id format
if !is_valid_app_id(app_id) {
let body = serde_json::json!({ "error": "Invalid app_id" });
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
return Ok(Response::builder()
.status(StatusCode::BAD_REQUEST)
.header("Content-Type", "application/json")
.body(hyper::Body::from(body_bytes))
.unwrap());
}
let lines = params
.get("lines")
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(200);
match rpc.get_container_logs_value(app_id, lines).await {
Ok(value) => {
let body = serde_json::json!({ "result": value });
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.header("Access-Control-Allow-Origin", cors_origin)
.header("Access-Control-Allow-Credentials", "true")
.header("Vary", "Origin")
.body(hyper::Body::from(body_bytes))
.unwrap())
}
Err(e) => {
let body = serde_json::json!({ "error": e.to_string() });
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
Ok(Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.header("Content-Type", "application/json")
.header("Access-Control-Allow-Origin", cors_origin)
.header("Access-Control-Allow-Credentials", "true")
.header("Vary", "Origin")
.body(hyper::Body::from(body_bytes))
.unwrap())
}
}
}
async fn handle_node_message(body: hyper::body::Bytes) -> Result<Response<hyper::Body>> {
#[derive(serde::Deserialize)]
struct Incoming {
from_pubkey: Option<String>,
message: Option<String>,
signature: Option<String>,
#[serde(default)]
encrypted: bool,
}
let incoming: Incoming = serde_json::from_slice(&body).unwrap_or(Incoming {
from_pubkey: None,
message: None,
signature: None,
encrypted: false,
});
if let (Some(from), Some(msg)) = (incoming.from_pubkey.as_ref(), incoming.message.as_ref()) {
// Validate from_pubkey is a valid hex ed25519 pubkey
if !is_valid_pubkey_hex(from) {
return Ok(Response::builder()
.status(StatusCode::BAD_REQUEST)
.header("Content-Type", "application/json")
.body(hyper::Body::from(r#"{"error":"Invalid pubkey format"}"#))
.unwrap());
}
// Verify ed25519 signature if provided (required for trusted messages)
if let Some(sig_hex) = &incoming.signature {
match crate::identity::NodeIdentity::verify(from, msg.as_bytes(), sig_hex) {
Ok(true) => {}
_ => {
return Ok(Response::builder()
.status(StatusCode::FORBIDDEN)
.header("Content-Type", "application/json")
.body(hyper::Body::from(r#"{"error":"Invalid signature"}"#))
.unwrap());
}
}
}
// Decrypt if the message is E2E encrypted
let plaintext = if incoming.encrypted {
// Load our identity to derive shared secret
let data_dir = std::path::Path::new("/var/lib/archipelago");
let identity_dir = data_dir.join("identity");
match crate::identity::NodeIdentity::load_or_create(&identity_dir).await {
Ok(node_id) => {
match node_msg::decrypt_from_peer(node_id.signing_key(), from, msg) {
Ok(decrypted) => {
tracing::info!("Decrypted E2E message from {}...", &from[..16.min(from.len())]);
decrypted
}
Err(e) => {
tracing::warn!("E2E decryption failed from {}: {}", &from[..16.min(from.len())], e);
return Ok(Response::builder()
.status(StatusCode::BAD_REQUEST)
.header("Content-Type", "application/json")
.body(hyper::Body::from(r#"{"error":"Decryption failed"}"#))
.unwrap());
}
}
}
Err(e) => {
tracing::warn!("Cannot decrypt: identity load failed: {}", e);
msg.clone()
}
}
} else {
msg.clone()
};
let safe_from = sanitize_log_string(from);
let safe_msg = sanitize_log_string(&plaintext);
tracing::info!("Received message from {}: {}", safe_from, safe_msg);
let clean_from = sanitize_html(from);
let clean_msg = sanitize_html(&plaintext);
node_msg::store_received(&clean_from, &clean_msg).await;
}
Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.body(hyper::Body::from(r#"{"ok":true}"#))
.unwrap())
}
async fn handle_electrs_status() -> Result<Response<hyper::Body>> {
let status = electrs_status::get_electrs_sync_status().await;
let body = serde_json::to_vec(&status).unwrap_or_default();
Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.body(hyper::Body::from(body))
.unwrap())
}
async fn handle_lnd_connect_info(
rpc: std::sync::Arc<super::rpc::RpcHandler>,
) -> Result<Response<hyper::Body>> {
match rpc.handle_lnd_connect_info().await {
Ok(val) => {
let body = serde_json::to_vec(&val).unwrap_or_default();
Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.body(hyper::Body::from(body))
.unwrap())
}
Err(e) => Ok(Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.header("Content-Type", "application/json")
.body(hyper::Body::from(
serde_json::json!({"error": e.to_string()}).to_string(),
))
.unwrap()),
}
}
async fn handle_lnd_proxy(path: &str, cors_origin: &str) -> Result<Response<hyper::Body>> {
let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/");
let url = format!("http://127.0.0.1:8080{}", suffix);
match reqwest::get(&url).await {
Ok(resp) => {
let status = resp.status().as_u16();
let headers = resp.headers().clone();
let body = resp.bytes().await.unwrap_or_default();
let mut builder = Response::builder().status(status);
if let Some(ct) = headers.get("content-type") {
if let Ok(s) = ct.to_str() {
builder = builder.header("Content-Type", s);
}
}
builder
.header("Access-Control-Allow-Origin", cors_origin)
.header("Access-Control-Allow-Credentials", "true")
.header("Vary", "Origin")
.body(hyper::Body::from(body))
.map_err(|e| anyhow::anyhow!("response build: {}", e))
}
Err(e) => {
let body = serde_json::json!({ "error": e.to_string() });
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
Ok(Response::builder()
.status(StatusCode::BAD_GATEWAY)
.header("Content-Type", "application/json")
.header("Access-Control-Allow-Origin", cors_origin)
.header("Access-Control-Allow-Credentials", "true")
.header("Vary", "Origin")
.body(hyper::Body::from(body_bytes))
.unwrap())
}
}
}
async fn handle_content_catalog(config: &Config) -> Result<Response<hyper::Body>> {
match content_server::load_catalog(&config.data_dir).await {
Ok(catalog) => {
// Only expose public metadata for available items
let items: Vec<serde_json::Value> = catalog
.items
.iter()
.filter(|i| !matches!(i.availability, content_server::Availability::Nobody))
.map(|i| {
serde_json::json!({
"id": i.id,
"filename": i.filename,
"mime_type": i.mime_type,
"size_bytes": i.size_bytes,
"description": i.description,
"access": i.access,
})
})
.collect();
let body = serde_json::to_vec(&serde_json::json!({ "items": items }))
.unwrap_or_default();
Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.body(hyper::Body::from(body))
.unwrap())
}
Err(e) => {
let body = serde_json::json!({ "error": e.to_string() });
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
Ok(Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.header("Content-Type", "application/json")
.body(hyper::Body::from(body_bytes))
.unwrap())
}
}
}
async fn handle_content_request(
path: &str,
headers: &hyper::HeaderMap,
config: &Config,
) -> Result<Response<hyper::Body>> {
let content_id = path.strip_prefix("/content/").unwrap_or("");
if content_id.is_empty() || !is_valid_app_id(content_id) {
return Ok(Response::builder()
.status(StatusCode::BAD_REQUEST)
.body(hyper::Body::from("Invalid content ID"))
.unwrap());
}
// Extract payment token from X-Payment-Token header
let payment_token = headers
.get("x-payment-token")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
// Extract federation peer DID from X-Federation-DID header
let peer_did = headers
.get("x-federation-did")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
// Parse Range header for streaming support
let range = headers
.get("range")
.and_then(|v| v.to_str().ok())
.and_then(content_server::parse_range_header);
match content_server::serve_content(
&config.data_dir,
content_id,
payment_token.as_deref(),
peer_did.as_deref(),
range,
)
.await
{
Ok(content_server::ServeResult::Ok(bytes, mime_type)) => {
let len = bytes.len();
Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", mime_type)
.header("Content-Length", len.to_string())
.header("Accept-Ranges", "bytes")
.body(hyper::Body::from(bytes))
.unwrap())
}
Ok(content_server::ServeResult::Partial {
bytes,
mime_type,
start,
end,
total,
}) => {
Ok(Response::builder()
.status(StatusCode::PARTIAL_CONTENT)
.header("Content-Type", mime_type)
.header("Content-Length", bytes.len().to_string())
.header("Content-Range", format!("bytes {}-{}/{}", start, end, total))
.header("Accept-Ranges", "bytes")
.body(hyper::Body::from(bytes))
.unwrap())
}
Ok(content_server::ServeResult::PaymentRequired(price_sats)) => {
let body = serde_json::json!({
"error": "Payment required",
"price_sats": price_sats,
"payment_header": "X-Payment-Token",
});
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
Ok(Response::builder()
.status(StatusCode::PAYMENT_REQUIRED)
.header("Content-Type", "application/json")
.body(hyper::Body::from(body_bytes))
.unwrap())
}
Ok(content_server::ServeResult::Forbidden) => {
Ok(Response::builder()
.status(StatusCode::FORBIDDEN)
.header("Content-Type", "application/json")
.body(hyper::Body::from(
r#"{"error":"Access denied — federation peer required"}"#,
))
.unwrap())
}
Ok(content_server::ServeResult::NotFound) | Err(_) => {
Ok(Response::builder()
.status(StatusCode::NOT_FOUND)
.body(hyper::Body::from("Content not found"))
.unwrap())
}
}
}
async fn handle_websocket(
req: Request<hyper::Body>,
state_manager: Arc<StateManager>,
metrics_store: Arc<MetricsStore>,
) -> Result<Response<hyper::Body>> {
let (response, ws_fut_opt) = hyper_ws_listener::create_ws(req)
.map_err(|e| anyhow::anyhow!("WebSocket upgrade failed: {}", e))?;
if let Some(ws_fut) = ws_fut_opt {
tokio::spawn(async move {
let ws_stream: WsStream = match ws_fut.await {
Ok(Ok(s)) => s,
Ok(Err(e)) => {
debug!("WebSocket handshake failed (hyper): {}", e);
return;
}
Err(e) => {
debug!("WebSocket task join failed: {}", e);
return;
}
};
metrics_store.increment_ws();
info!("WebSocket /ws/db connected");
let (mut tx, mut rx) = ws_stream.split();
let initial_msg = state_manager.get_initial_message().await;
if let Ok(json_msg) = serde_json::to_string(&initial_msg) {
if let Err(e) = tx.send(Message::Text(json_msg)).await {
debug!("Failed to send initial data: {}", e);
return;
}
debug!("Sent initial data dump at revision {}", initial_msg.rev);
}
let mut state_rx = state_manager.subscribe();
let ping_interval = tokio::time::interval(tokio::time::Duration::from_secs(30));
tokio::pin!(ping_interval);
let mut last_client_activity = Instant::now();
const INACTIVITY_TIMEOUT_SECS: u64 = 300; // 5 minutes
loop {
tokio::select! {
_ = ping_interval.tick() => {
// Check inactivity timeout
if last_client_activity.elapsed().as_secs() >= INACTIVITY_TIMEOUT_SECS {
info!("WebSocket client inactive for {}s, closing", INACTIVITY_TIMEOUT_SECS);
let _ = tx.send(Message::Close(None)).await;
break;
}
if tx.send(Message::Ping(vec![])).await.is_err() {
debug!("Failed to send ping, connection likely closed");
break;
}
}
update = state_rx.recv() => {
match update {
Ok(msg) => {
if let Ok(json_msg) = serde_json::to_string(&msg) {
if let Err(e) = tx.send(Message::Text(json_msg)).await {
debug!("Failed to send state update: {}", e);
break;
}
debug!("Sent state update at revision {}", msg.rev);
}
}
Err(broadcast::error::RecvError::Lagged(skipped)) => {
debug!("Client lagged behind, skipped {} messages", skipped);
}
Err(broadcast::error::RecvError::Closed) => {
debug!("Broadcast channel closed");
break;
}
}
}
msg = rx.next() => {
match msg {
Some(Ok(Message::Close(_))) => break,
Some(Ok(Message::Pong(_))) => {
last_client_activity = Instant::now();
debug!("Received pong");
}
Some(Ok(Message::Ping(data))) => {
last_client_activity = Instant::now();
let _ = tx.send(Message::Pong(data)).await;
}
Some(Ok(Message::Text(text))) => {
last_client_activity = Instant::now();
// Handle JSON ping from frontend
if text.contains("\"type\":\"ping\"") || text.contains("\"type\": \"ping\"") {
let _ = tx.send(Message::Text(r#"{"type":"pong"}"#.to_string())).await;
}
}
Some(Ok(_)) => {
last_client_activity = Instant::now();
}
Some(Err(e)) => {
debug!("WebSocket stream error: {}", e);
break;
}
None => break,
}
}
}
}
metrics_store.decrement_ws();
info!("WebSocket /ws/db disconnected");
});
}
Ok(response)
}
}
/// Validate that an app ID matches the safe pattern: lowercase alphanumeric + hyphens.
fn is_valid_app_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 64
&& id.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
&& id.as_bytes()[0] != b'-'
}
/// Validate that a pubkey is a 64-char hex string.
fn is_valid_pubkey_hex(s: &str) -> bool {
s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit())
}
/// Strip newlines and ANSI escape sequences from strings before logging.
fn sanitize_log_string(s: &str) -> String {
s.replace('\n', "\\n")
.replace('\r', "\\r")
.replace('\x1b', "")
}
/// Strip HTML-sensitive characters to prevent XSS when stored/rendered.
fn sanitize_html(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#x27;")
}
impl ApiHandler {
/// DWN health endpoint — returns store stats.
async fn handle_dwn_health(config: &Config) -> Result<Response<hyper::Body>> {
match DwnStore::new(&config.data_dir).await {
Ok(store) => {
let stats = store.stats().await.unwrap_or(crate::network::dwn_store::StoreStats {
message_count: 0,
protocol_count: 0,
total_bytes: 0,
});
let body = serde_json::json!({
"status": "ok",
"message_count": stats.message_count,
"protocol_count": stats.protocol_count,
"total_bytes": stats.total_bytes,
});
Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.body(hyper::Body::from(body.to_string()))
.unwrap())
}
Err(_) => Ok(Response::builder()
.status(StatusCode::SERVICE_UNAVAILABLE)
.header("Content-Type", "application/json")
.body(hyper::Body::from(r#"{"status":"unavailable"}"#))
.unwrap()),
}
}
/// DWN message processing endpoint — handles RecordsWrite, RecordsQuery, RecordsRead, RecordsDelete.
/// Supports batch processing: all messages in the array are processed.
async fn handle_dwn_message(
body: hyper::body::Bytes,
config: &Config,
) -> Result<Response<hyper::Body>> {
let request: serde_json::Value = match serde_json::from_slice(&body) {
Ok(v) => v,
Err(e) => {
let err = serde_json::json!({"error": format!("Invalid JSON: {}", e)});
return Ok(Response::builder()
.status(StatusCode::BAD_REQUEST)
.header("Content-Type", "application/json")
.body(hyper::Body::from(err.to_string()))
.unwrap());
}
};
// Collect all messages to process
let messages: Vec<serde_json::Value> = if request.get("message").is_some() {
vec![request["message"].clone()]
} else if let Some(msgs) = request["messages"].as_array() {
msgs.clone()
} else {
vec![serde_json::Value::Null]
};
let store = DwnStore::new(&config.data_dir).await?;
let mut results = Vec::new();
for message in &messages {
let interface = message["descriptor"]["interface"]
.as_str()
.unwrap_or("");
let method = message["descriptor"]["method"]
.as_str()
.unwrap_or("");
let result = match (interface, method) {
("Records", "Write") => {
let author = message["author"].as_str().unwrap_or("unknown");
let protocol = message["descriptor"]["protocol"].as_str();
let schema = message["descriptor"]["schema"].as_str();
let data_format = message["descriptor"]["dataFormat"].as_str();
let data = message.get("data").cloned();
// Deduplicate: check if recordId already exists
if let Some(record_id) = message["recordId"].as_str() {
if store.read_message(record_id).await.ok().flatten().is_some() {
serde_json::json!({"status": {"code": 200, "detail": "Already exists"}})
} else {
match store
.write_message(author, protocol, schema, data_format, data)
.await
{
Ok(msg) => {
serde_json::json!({"status": {"code": 202}, "entry": msg})
}
Err(e) => serde_json::json!({"status": {"code": 500, "detail": e.to_string()}}),
}
}
} else {
match store
.write_message(author, protocol, schema, data_format, data)
.await
{
Ok(msg) => serde_json::json!({"status": {"code": 202}, "entry": msg}),
Err(e) => serde_json::json!({"status": {"code": 500, "detail": e.to_string()}}),
}
}
}
("Records", "Query") => {
let query = crate::network::dwn_store::MessageQuery {
protocol: message["descriptor"]["filter"]["protocol"]
.as_str()
.map(|s| s.to_string()),
schema: message["descriptor"]["filter"]["schema"]
.as_str()
.map(|s| s.to_string()),
author: message["descriptor"]["filter"]["author"]
.as_str()
.map(|s| s.to_string()),
date_from: message["descriptor"]["filter"]["dateFrom"]
.as_str()
.map(|s| s.to_string()),
date_to: message["descriptor"]["filter"]["dateTo"]
.as_str()
.map(|s| s.to_string()),
limit: message["descriptor"]["filter"]["limit"]
.as_u64()
.map(|n| n as usize),
};
match store.query_messages(&query).await {
Ok(messages) => {
serde_json::json!({"status": {"code": 200}, "entries": messages})
}
Err(e) => {
serde_json::json!({"status": {"code": 500, "detail": e.to_string()}})
}
}
}
("Records", "Read") => {
let record_id = message["descriptor"]["recordId"]
.as_str()
.unwrap_or("");
match store.read_message(record_id).await {
Ok(Some(msg)) => {
serde_json::json!({"status": {"code": 200}, "entry": msg})
}
Ok(None) => serde_json::json!({"status": {"code": 404, "detail": "Record not found"}}),
Err(e) => {
serde_json::json!({"status": {"code": 500, "detail": e.to_string()}})
}
}
}
("Records", "Delete") => {
let record_id = message["descriptor"]["recordId"]
.as_str()
.unwrap_or("");
match store.delete_message(record_id).await {
Ok(true) => serde_json::json!({"status": {"code": 200}}),
Ok(false) => serde_json::json!({"status": {"code": 404, "detail": "Record not found"}}),
Err(e) => {
serde_json::json!({"status": {"code": 500, "detail": e.to_string()}})
}
}
}
_ => {
serde_json::json!({"status": {"code": 400, "detail": format!("Unknown method: {}.{}", interface, method)}})
}
};
results.push(result);
}
// Return single result for single message, array for batch
let (response_body, http_status) = if results.len() == 1 {
let result = &results[0];
let status_code = result["status"]["code"].as_u64().unwrap_or(200);
let http_status = match status_code {
202 => StatusCode::ACCEPTED,
400 => StatusCode::BAD_REQUEST,
404 => StatusCode::NOT_FOUND,
500 => StatusCode::INTERNAL_SERVER_ERROR,
_ => StatusCode::OK,
};
(result.to_string(), http_status)
} else {
(
serde_json::json!({"replies": results}).to_string(),
StatusCode::OK,
)
};
Ok(Response::builder()
.status(http_status)
.header("Content-Type", "application/json")
.body(hyper::Body::from(response_body))
.unwrap())
}
}
+122
View File
@@ -0,0 +1,122 @@
use crate::config::Config;
use super::build_response;use crate::content_server;
use anyhow::Result;
use hyper::{Response, StatusCode};
use super::{ApiHandler, is_valid_app_id};
impl ApiHandler {
pub(super) async fn handle_content_catalog(config: &Config) -> Result<Response<hyper::Body>> {
match content_server::load_catalog(&config.data_dir).await {
Ok(catalog) => {
// Only expose public metadata for available items
let items: Vec<serde_json::Value> = catalog
.items
.iter()
.filter(|i| !matches!(i.availability, content_server::Availability::Nobody))
.map(|i| {
serde_json::json!({
"id": i.id,
"filename": i.filename,
"mime_type": i.mime_type,
"size_bytes": i.size_bytes,
"description": i.description,
"access": i.access,
})
})
.collect();
let body = serde_json::to_vec(&serde_json::json!({ "items": items }))
.unwrap_or_default();
Ok(build_response(StatusCode::OK, "application/json", hyper::Body::from(body)))
}
Err(e) => {
let body = serde_json::json!({ "error": e.to_string() });
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
Ok(build_response(StatusCode::INTERNAL_SERVER_ERROR, "application/json", hyper::Body::from(body_bytes)))
}
}
}
pub(super) async fn handle_content_request(
path: &str,
headers: &hyper::HeaderMap,
config: &Config,
) -> Result<Response<hyper::Body>> {
let content_id = path.strip_prefix("/content/").unwrap_or("");
if content_id.is_empty() || !is_valid_app_id(content_id) {
return Ok(build_response(StatusCode::BAD_REQUEST, "text/plain", hyper::Body::from("Invalid content ID")));
}
// Extract payment token from X-Payment-Token header
let payment_token = headers
.get("x-payment-token")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
// Extract federation peer DID from X-Federation-DID header
let peer_did = headers
.get("x-federation-did")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
// Parse Range header for streaming support
let range = headers
.get("range")
.and_then(|v| v.to_str().ok())
.and_then(content_server::parse_range_header);
match content_server::serve_content(
&config.data_dir,
content_id,
payment_token.as_deref(),
peer_did.as_deref(),
range,
)
.await
{
Ok(content_server::ServeResult::Ok(bytes, mime_type)) => {
let len = bytes.len();
Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", mime_type)
.header("Content-Length", len.to_string())
.header("Accept-Ranges", "bytes")
.body(hyper::Body::from(bytes))
.unwrap())
}
Ok(content_server::ServeResult::Partial {
bytes,
mime_type,
start,
end,
total,
}) => {
Ok(Response::builder()
.status(StatusCode::PARTIAL_CONTENT)
.header("Content-Type", mime_type)
.header("Content-Length", bytes.len().to_string())
.header("Content-Range", format!("bytes {}-{}/{}", start, end, total))
.header("Accept-Ranges", "bytes")
.body(hyper::Body::from(bytes))
.unwrap())
}
Ok(content_server::ServeResult::PaymentRequired(price_sats)) => {
let body = serde_json::json!({
"error": "Payment required",
"price_sats": price_sats,
"payment_header": "X-Payment-Token",
});
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
Ok(build_response(StatusCode::PAYMENT_REQUIRED, "application/json", hyper::Body::from(body_bytes)))
}
Ok(content_server::ServeResult::Forbidden) => {
Ok(build_response(StatusCode::FORBIDDEN, "application/json", hyper::Body::from(
r#"{"error":"Access denied — federation peer required"}"#,
)))
}
Ok(content_server::ServeResult::NotFound) | Err(_) => {
Ok(build_response(StatusCode::NOT_FOUND, "text/plain", hyper::Body::from("Content not found")))
}
}
}
}
+189
View File
@@ -0,0 +1,189 @@
use crate::config::Config;
use super::build_response;use crate::network::dwn_store::DwnStore;
use anyhow::Result;
use hyper::{Response, StatusCode};
use super::ApiHandler;
impl ApiHandler {
/// DWN health endpoint — returns store stats.
pub(super) async fn handle_dwn_health(config: &Config) -> Result<Response<hyper::Body>> {
match DwnStore::new(&config.data_dir).await {
Ok(store) => {
let stats = store.stats().await.unwrap_or(crate::network::dwn_store::StoreStats {
message_count: 0,
protocol_count: 0,
total_bytes: 0,
});
let body = serde_json::json!({
"status": "ok",
"message_count": stats.message_count,
"protocol_count": stats.protocol_count,
"total_bytes": stats.total_bytes,
});
Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.body(hyper::Body::from(body.to_string()))
.unwrap())
}
Err(_) => Ok(build_response(StatusCode::SERVICE_UNAVAILABLE, "application/json", hyper::Body::from(r#"{"status":"unavailable"}"#))),
}
}
/// DWN message processing endpoint — handles RecordsWrite, RecordsQuery, RecordsRead, RecordsDelete.
/// Supports batch processing: all messages in the array are processed.
pub(super) async fn handle_dwn_message(
body: hyper::body::Bytes,
config: &Config,
) -> Result<Response<hyper::Body>> {
let request: serde_json::Value = match serde_json::from_slice(&body) {
Ok(v) => v,
Err(e) => {
let err = serde_json::json!({"error": format!("Invalid JSON: {}", e)});
return Ok(Response::builder()
.status(StatusCode::BAD_REQUEST)
.header("Content-Type", "application/json")
.body(hyper::Body::from(err.to_string()))
.unwrap());
}
};
// Collect all messages to process
let messages: Vec<serde_json::Value> = if request.get("message").is_some() {
vec![request["message"].clone()]
} else if let Some(msgs) = request["messages"].as_array() {
msgs.clone()
} else {
vec![serde_json::Value::Null]
};
let store = DwnStore::new(&config.data_dir).await?;
let mut results = Vec::new();
for message in &messages {
let interface = message["descriptor"]["interface"]
.as_str()
.unwrap_or("");
let method = message["descriptor"]["method"]
.as_str()
.unwrap_or("");
let result = match (interface, method) {
("Records", "Write") => {
let author = message["author"].as_str().unwrap_or("unknown");
let protocol = message["descriptor"]["protocol"].as_str();
let schema = message["descriptor"]["schema"].as_str();
let data_format = message["descriptor"]["dataFormat"].as_str();
let data = message.get("data").cloned();
// Deduplicate: check if recordId already exists
if let Some(record_id) = message["recordId"].as_str() {
if store.read_message(record_id).await.ok().flatten().is_some() {
serde_json::json!({"status": {"code": 200, "detail": "Already exists"}})
} else {
match store
.write_message(author, protocol, schema, data_format, data)
.await
{
Ok(msg) => {
serde_json::json!({"status": {"code": 202}, "entry": msg})
}
Err(e) => serde_json::json!({"status": {"code": 500, "detail": e.to_string()}}),
}
}
} else {
match store
.write_message(author, protocol, schema, data_format, data)
.await
{
Ok(msg) => serde_json::json!({"status": {"code": 202}, "entry": msg}),
Err(e) => serde_json::json!({"status": {"code": 500, "detail": e.to_string()}}),
}
}
}
("Records", "Query") => {
let query = crate::network::dwn_store::MessageQuery {
protocol: message["descriptor"]["filter"]["protocol"]
.as_str()
.map(|s| s.to_string()),
schema: message["descriptor"]["filter"]["schema"]
.as_str()
.map(|s| s.to_string()),
author: message["descriptor"]["filter"]["author"]
.as_str()
.map(|s| s.to_string()),
date_from: message["descriptor"]["filter"]["dateFrom"]
.as_str()
.map(|s| s.to_string()),
date_to: message["descriptor"]["filter"]["dateTo"]
.as_str()
.map(|s| s.to_string()),
limit: message["descriptor"]["filter"]["limit"]
.as_u64()
.map(|n| n as usize),
};
match store.query_messages(&query).await {
Ok(messages) => {
serde_json::json!({"status": {"code": 200}, "entries": messages})
}
Err(e) => {
serde_json::json!({"status": {"code": 500, "detail": e.to_string()}})
}
}
}
("Records", "Read") => {
let record_id = message["descriptor"]["recordId"]
.as_str()
.unwrap_or("");
match store.read_message(record_id).await {
Ok(Some(msg)) => {
serde_json::json!({"status": {"code": 200}, "entry": msg})
}
Ok(None) => serde_json::json!({"status": {"code": 404, "detail": "Record not found"}}),
Err(e) => {
serde_json::json!({"status": {"code": 500, "detail": e.to_string()}})
}
}
}
("Records", "Delete") => {
let record_id = message["descriptor"]["recordId"]
.as_str()
.unwrap_or("");
match store.delete_message(record_id).await {
Ok(true) => serde_json::json!({"status": {"code": 200}}),
Ok(false) => serde_json::json!({"status": {"code": 404, "detail": "Record not found"}}),
Err(e) => {
serde_json::json!({"status": {"code": 500, "detail": e.to_string()}})
}
}
}
_ => {
serde_json::json!({"status": {"code": 400, "detail": format!("Unknown method: {}.{}", interface, method)}})
}
};
results.push(result);
}
// Return single result for single message, array for batch
let (response_body, http_status) = if results.len() == 1 {
let result = &results[0];
let status_code = result["status"]["code"].as_u64().unwrap_or(200);
let http_status = match status_code {
202 => StatusCode::ACCEPTED,
400 => StatusCode::BAD_REQUEST,
404 => StatusCode::NOT_FOUND,
500 => StatusCode::INTERNAL_SERVER_ERROR,
_ => StatusCode::OK,
};
(result.to_string(), http_status)
} else {
(
serde_json::json!({"replies": results}).to_string(),
StatusCode::OK,
)
};
Ok(build_response(http_status, "application/json", hyper::Body::from(response_body)))
}
}
+267
View File
@@ -0,0 +1,267 @@
mod content;
mod dwn;
mod node_message;
mod proxy;
mod websocket;
use crate::api::rpc::RpcHandler;
use crate::config::Config;
use crate::monitoring::MetricsStore;
use crate::session::{self, SessionStore};
use crate::state::StateManager;
use anyhow::Result;
use hyper::{Method, Request, Response, StatusCode};
use std::sync::Arc;
use tracing::debug;
/// Build an HTTP response without unwrap. Falls back to a plain 500 if builder fails.
// Used by handler submodules after unwrap elimination
#[allow(dead_code)]
pub(super) fn build_response(status: StatusCode, content_type: &str, body: hyper::Body) -> Response<hyper::Body> {
Response::builder()
.status(status)
.header("Content-Type", content_type)
.body(body)
.unwrap_or_else(|_| Response::new(hyper::Body::from("Internal error")))
}
pub struct ApiHandler {
config: Config,
rpc_handler: Arc<RpcHandler>,
state_manager: Arc<StateManager>,
metrics_store: Arc<MetricsStore>,
session_store: SessionStore,
}
impl ApiHandler {
pub async fn new(
config: Config,
state_manager: Arc<StateManager>,
metrics_store: Arc<MetricsStore>,
) -> Result<Self> {
let session_store = SessionStore::new().await;
let rpc_handler = Arc::new(
RpcHandler::new(
config.clone(),
state_manager.clone(),
metrics_store.clone(),
session_store.clone(),
)
.await?,
);
Ok(Self {
config,
rpc_handler,
state_manager,
metrics_store,
session_store,
})
}
/// Access the RPC handler (for service initialization after construction).
pub fn rpc_handler(&self) -> &Arc<RpcHandler> {
&self.rpc_handler
}
/// Check if the request has a valid session cookie.
async fn is_authenticated(&self, headers: &hyper::HeaderMap) -> bool {
match session::extract_session_cookie(headers) {
Some(token) => self.session_store.validate(&token).await,
None => false,
}
}
/// Build a 401 Unauthorized JSON response.
fn unauthorized() -> Response<hyper::Body> {
let body = serde_json::json!({ "error": "Unauthorized" });
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header("Content-Type", "application/json")
.body(hyper::Body::from(body_bytes))
.unwrap()
}
/// Allowed CORS origins derived from the config host IP.
fn allowed_origins(&self) -> Vec<String> {
let mut origins = vec![
format!("http://{}", self.config.host_ip),
format!("https://{}", self.config.host_ip),
];
if self.config.dev_mode {
origins.push("http://localhost:8100".to_string()); // Vite dev server
}
origins
}
/// Validate the Origin header against allowed origins.
/// Returns the matched origin if valid, None if cross-origin is not allowed.
fn validate_origin(&self, headers: &hyper::HeaderMap) -> Option<String> {
let origin = headers
.get("origin")
.and_then(|v| v.to_str().ok())?;
let allowed = self.allowed_origins();
if allowed.iter().any(|a| a == origin) {
Some(origin.to_string())
} else {
None
}
}
pub async fn handle_request(
&self,
req: Request<hyper::Body>,
) -> Result<Response<hyper::Body>> {
let path = req.uri().path().to_string();
let method = req.method().clone();
// Handle CORS preflight for all routes
if method == Method::OPTIONS {
let mut builder = Response::builder()
.status(StatusCode::NO_CONTENT)
.header("Vary", "Origin");
if let Some(origin) = self.validate_origin(req.headers()) {
builder = builder
.header("Access-Control-Allow-Origin", &origin)
.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
.header("Access-Control-Allow-Headers", "Content-Type, X-CSRF-Token")
.header("Access-Control-Allow-Credentials", "true");
}
return Ok(builder.body(hyper::Body::empty()).unwrap());
}
// WebSocket upgrade — validate session before upgrading
if method == Method::GET && path == "/ws/db" {
if !self.is_authenticated(req.headers()).await {
tracing::warn!("401 WebSocket /ws/db — session invalid or missing");
return Ok(Self::unauthorized());
}
return Self::handle_websocket(req, self.state_manager.clone(), self.metrics_store.clone()).await;
}
// Convert body to bytes for non-WS routes
let headers = req.headers().clone();
let (parts, body) = req.into_parts();
let body_bytes = hyper::body::to_bytes(body).await
.map_err(|e| anyhow::anyhow!("Failed to read body: {}", e))?;
let req_with_bytes = Request::from_parts(parts, hyper::Body::from(body_bytes.clone()));
debug!("{} {}", method, path);
match (method, path.as_str()) {
// RPC — auth is handled inside rpc handler per-method
(Method::POST, "/rpc/v1") => self.rpc_handler.handle(req_with_bytes).await,
// Health — unauthenticated, returns JSON with service status
(Method::GET, "/health") => {
let recovery_complete = crate::crash_recovery::is_recovery_complete();
let uptime = crate::crash_recovery::uptime_seconds();
let health_status = if recovery_complete { "ok" } else { "degraded" };
let status = serde_json::json!({
"status": health_status,
"crash_recovery_complete": recovery_complete,
"uptime_seconds": uptime,
"version": env!("CARGO_PKG_VERSION"),
"services": {
"rpc": true,
"sessions": true,
}
});
Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.body(hyper::Body::from(serde_json::to_vec(&status).unwrap_or_default()))
.unwrap())
}
// Node message — P2P endpoint (authenticated by source validation, not cookie)
(Method::POST, "/archipelago/node-message") => {
Self::handle_node_message(body_bytes).await
}
// Content serving — peers access shared content over Tor (no session auth)
(Method::GET, p) if p.starts_with("/content/") => {
Self::handle_content_request(p, &headers, &self.config).await
}
// Content catalog — list available content (no session auth, for peers)
(Method::GET, "/content") => {
Self::handle_content_catalog(&self.config).await
}
// Electrs status — unauthenticated (read-only sync status)
(Method::GET, "/electrs-status") => Self::handle_electrs_status().await,
// LND connect info — nginx validates session cookie (presence check),
// backend is bound to 127.0.0.1 so only nginx can reach it.
// No backend auth check here because the LND UI iframe fetches this
// endpoint and the session cookie flow is validated at the nginx layer.
(Method::GET, "/lnd-connect-info") => {
Self::handle_lnd_connect_info(self.rpc_handler.clone()).await
}
// Container logs — requires session
(Method::GET, path) if path.starts_with("/api/container/logs") => {
if !self.is_authenticated(&headers).await {
return Ok(Self::unauthorized());
}
let origin = self.validate_origin(&headers).unwrap_or_default();
Self::handle_container_logs_http(self.rpc_handler.clone(), path, &origin).await
}
// LND proxy — requires session
(Method::GET, path) if path.starts_with("/proxy/lnd/") => {
if !self.is_authenticated(&headers).await {
return Ok(Self::unauthorized());
}
let origin = self.validate_origin(&headers).unwrap_or_default();
Self::handle_lnd_proxy(path, &origin).await
}
// DWN health — unauthenticated
(Method::GET, "/dwn/health") => {
Self::handle_dwn_health(&self.config).await
}
// DWN message processing — peers access over Tor for sync (no session auth)
(Method::POST, "/dwn") => {
Self::handle_dwn_message(body_bytes, &self.config).await
}
_ => Ok(Response::builder()
.status(StatusCode::NOT_FOUND)
.body(hyper::Body::from("Not Found"))
.unwrap()),
}
}
}
/// Validate that an app ID matches the safe pattern: lowercase alphanumeric + hyphens.
fn is_valid_app_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 64
&& id.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
&& id.as_bytes()[0] != b'-'
}
/// Validate that a pubkey is a 64-char hex string.
fn is_valid_pubkey_hex(s: &str) -> bool {
s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit())
}
/// Strip newlines and ANSI escape sequences from strings before logging.
fn sanitize_log_string(s: &str) -> String {
s.replace('\n', "\\n")
.replace('\r', "\\r")
.replace('\x1b', "")
}
/// Strip HTML-sensitive characters to prevent XSS when stored/rendered.
fn sanitize_html(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#x27;")
}
@@ -0,0 +1,74 @@
use crate::node_message as node_msg;
use super::build_response;use anyhow::Result;
use hyper::{Response, StatusCode};
use super::{ApiHandler, is_valid_pubkey_hex, sanitize_html, sanitize_log_string};
impl ApiHandler {
pub(super) async fn handle_node_message(body: hyper::body::Bytes) -> Result<Response<hyper::Body>> {
#[derive(serde::Deserialize)]
struct Incoming {
from_pubkey: Option<String>,
message: Option<String>,
signature: Option<String>,
#[serde(default)]
encrypted: bool,
}
let incoming: Incoming = serde_json::from_slice(&body).unwrap_or(Incoming {
from_pubkey: None,
message: None,
signature: None,
encrypted: false,
});
if let (Some(from), Some(msg)) = (incoming.from_pubkey.as_ref(), incoming.message.as_ref()) {
// Validate from_pubkey is a valid hex ed25519 pubkey
if !is_valid_pubkey_hex(from) {
return Ok(build_response(StatusCode::BAD_REQUEST, "application/json", hyper::Body::from(r#"{"error":"Invalid pubkey format"}"#)));
}
// Verify ed25519 signature if provided (required for trusted messages)
if let Some(sig_hex) = &incoming.signature {
match crate::identity::NodeIdentity::verify(from, msg.as_bytes(), sig_hex) {
Ok(true) => {}
_ => {
return Ok(build_response(StatusCode::FORBIDDEN, "application/json", hyper::Body::from(r#"{"error":"Invalid signature"}"#)));
}
}
}
// Decrypt if the message is E2E encrypted
let plaintext = if incoming.encrypted {
// Load our identity to derive shared secret
let data_dir = std::path::Path::new("/var/lib/archipelago");
let identity_dir = data_dir.join("identity");
match crate::identity::NodeIdentity::load_or_create(&identity_dir).await {
Ok(node_id) => {
match node_msg::decrypt_from_peer(node_id.signing_key(), from, msg) {
Ok(decrypted) => {
tracing::info!("Decrypted E2E message from {}...", &from[..16.min(from.len())]);
decrypted
}
Err(e) => {
tracing::warn!("E2E decryption failed from {}: {}", &from[..16.min(from.len())], e);
return Ok(build_response(StatusCode::BAD_REQUEST, "application/json", hyper::Body::from(r#"{"error":"Decryption failed"}"#)));
}
}
}
Err(e) => {
tracing::warn!("Cannot decrypt: identity load failed: {}", e);
msg.clone()
}
}
} else {
msg.clone()
};
let safe_from = sanitize_log_string(from);
let safe_msg = sanitize_log_string(&plaintext);
tracing::info!("Received message from {}: {}", safe_from, safe_msg);
let clean_from = sanitize_html(from);
let clean_msg = sanitize_html(&plaintext);
node_msg::store_received(&clean_from, &clean_msg).await;
}
Ok(build_response(StatusCode::OK, "application/json", hyper::Body::from(r#"{"ok":true}"#)))
}
}
+131
View File
@@ -0,0 +1,131 @@
use crate::api::rpc::RpcHandler;
use super::build_response;use crate::electrs_status;
use anyhow::Result;
use hyper::{Response, StatusCode};
use std::sync::Arc;
use super::{ApiHandler, is_valid_app_id};
impl ApiHandler {
pub(super) async fn handle_container_logs_http(
rpc: Arc<RpcHandler>,
path: &str,
cors_origin: &str,
) -> Result<Response<hyper::Body>> {
let query = path
.strip_prefix("/api/container/logs")
.and_then(|s| s.strip_prefix('?'))
.unwrap_or("");
let params: std::collections::HashMap<String, String> =
query
.split('&')
.filter_map(|p| {
let mut it = p.splitn(2, '=');
let k = it.next()?.to_string();
let v = it.next()?.to_string();
Some((k, v))
})
.collect();
let app_id = params.get("app_id").map(|s| s.as_str()).unwrap_or("lnd");
// Validate app_id format
if !is_valid_app_id(app_id) {
let body = serde_json::json!({ "error": "Invalid app_id" });
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
return Ok(build_response(StatusCode::BAD_REQUEST, "application/json", hyper::Body::from(body_bytes)));
}
let lines = params
.get("lines")
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(200);
match rpc.get_container_logs_value(app_id, lines).await {
Ok(value) => {
let body = serde_json::json!({ "result": value });
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.header("Access-Control-Allow-Origin", cors_origin)
.header("Access-Control-Allow-Credentials", "true")
.header("Vary", "Origin")
.body(hyper::Body::from(body_bytes))
.unwrap())
}
Err(e) => {
let body = serde_json::json!({ "error": e.to_string() });
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
Ok(Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.header("Content-Type", "application/json")
.header("Access-Control-Allow-Origin", cors_origin)
.header("Access-Control-Allow-Credentials", "true")
.header("Vary", "Origin")
.body(hyper::Body::from(body_bytes))
.unwrap())
}
}
}
pub(super) async fn handle_electrs_status() -> Result<Response<hyper::Body>> {
let status = electrs_status::get_electrs_sync_status().await;
let body = serde_json::to_vec(&status).unwrap_or_default();
Ok(build_response(StatusCode::OK, "application/json", hyper::Body::from(body)))
}
pub(super) async fn handle_lnd_connect_info(
rpc: std::sync::Arc<super::super::rpc::RpcHandler>,
) -> Result<Response<hyper::Body>> {
match rpc.handle_lnd_connect_info().await {
Ok(val) => {
let body = serde_json::to_vec(&val).unwrap_or_default();
Ok(build_response(StatusCode::OK, "application/json", hyper::Body::from(body)))
}
Err(e) => Ok(Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.header("Content-Type", "application/json")
.body(hyper::Body::from(
serde_json::json!({"error": e.to_string()}).to_string(),
))
.unwrap()),
}
}
pub(super) async fn handle_lnd_proxy(path: &str, cors_origin: &str) -> Result<Response<hyper::Body>> {
let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/");
let url = format!("http://127.0.0.1:8080{}", suffix);
match reqwest::get(&url).await {
Ok(resp) => {
let status = resp.status().as_u16();
let headers = resp.headers().clone();
let body = resp.bytes().await.unwrap_or_default();
let mut builder = Response::builder().status(status);
if let Some(ct) = headers.get("content-type") {
if let Ok(s) = ct.to_str() {
builder = builder.header("Content-Type", s);
}
}
builder
.header("Access-Control-Allow-Origin", cors_origin)
.header("Access-Control-Allow-Credentials", "true")
.header("Vary", "Origin")
.body(hyper::Body::from(body))
.map_err(|e| anyhow::anyhow!("response build: {}", e))
}
Err(e) => {
let body = serde_json::json!({ "error": e.to_string() });
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
Ok(Response::builder()
.status(StatusCode::BAD_GATEWAY)
.header("Content-Type", "application/json")
.header("Access-Control-Allow-Origin", cors_origin)
.header("Access-Control-Allow-Credentials", "true")
.header("Vary", "Origin")
.body(hyper::Body::from(body_bytes))
.unwrap())
}
}
}
}
@@ -0,0 +1,128 @@
use crate::monitoring::MetricsStore;
use crate::state::StateManager;
use anyhow::Result;
use futures_util::{SinkExt, StreamExt};
use hyper::{Request, Response};
use hyper_ws_listener::WsStream;
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::broadcast;
use tokio_tungstenite::tungstenite::Message;
use tracing::{debug, info};
use super::ApiHandler;
impl ApiHandler {
pub(super) async fn handle_websocket(
req: Request<hyper::Body>,
state_manager: Arc<StateManager>,
metrics_store: Arc<MetricsStore>,
) -> Result<Response<hyper::Body>> {
let (response, ws_fut_opt) = hyper_ws_listener::create_ws(req)
.map_err(|e| anyhow::anyhow!("WebSocket upgrade failed: {}", e))?;
if let Some(ws_fut) = ws_fut_opt {
tokio::spawn(async move {
let ws_stream: WsStream = match ws_fut.await {
Ok(Ok(s)) => s,
Ok(Err(e)) => {
debug!("WebSocket handshake failed (hyper): {}", e);
return;
}
Err(e) => {
debug!("WebSocket task join failed: {}", e);
return;
}
};
metrics_store.increment_ws();
info!("WebSocket /ws/db connected");
let (mut tx, mut rx) = ws_stream.split();
let initial_msg = state_manager.get_initial_message().await;
if let Ok(json_msg) = serde_json::to_string(&initial_msg) {
if let Err(e) = tx.send(Message::Text(json_msg)).await {
debug!("Failed to send initial data: {}", e);
return;
}
debug!("Sent initial data dump at revision {}", initial_msg.rev);
}
let mut state_rx = state_manager.subscribe();
let ping_interval = tokio::time::interval(tokio::time::Duration::from_secs(30));
tokio::pin!(ping_interval);
let mut last_client_activity = Instant::now();
const INACTIVITY_TIMEOUT_SECS: u64 = 300; // 5 minutes
loop {
tokio::select! {
_ = ping_interval.tick() => {
// Check inactivity timeout
if last_client_activity.elapsed().as_secs() >= INACTIVITY_TIMEOUT_SECS {
info!("WebSocket client inactive for {}s, closing", INACTIVITY_TIMEOUT_SECS);
let _ = tx.send(Message::Close(None)).await;
break;
}
if tx.send(Message::Ping(vec![])).await.is_err() {
debug!("Failed to send ping, connection likely closed");
break;
}
}
update = state_rx.recv() => {
match update {
Ok(msg) => {
if let Ok(json_msg) = serde_json::to_string(&msg) {
if let Err(e) = tx.send(Message::Text(json_msg)).await {
debug!("Failed to send state update: {}", e);
break;
}
debug!("Sent state update at revision {}", msg.rev);
}
}
Err(broadcast::error::RecvError::Lagged(skipped)) => {
debug!("Client lagged behind, skipped {} messages", skipped);
}
Err(broadcast::error::RecvError::Closed) => {
debug!("Broadcast channel closed");
break;
}
}
}
msg = rx.next() => {
match msg {
Some(Ok(Message::Close(_))) => break,
Some(Ok(Message::Pong(_))) => {
last_client_activity = Instant::now();
debug!("Received pong");
}
Some(Ok(Message::Ping(data))) => {
last_client_activity = Instant::now();
let _ = tx.send(Message::Pong(data)).await;
}
Some(Ok(Message::Text(text))) => {
last_client_activity = Instant::now();
// Handle JSON ping from frontend
if text.contains("\"type\":\"ping\"") || text.contains("\"type\": \"ping\"") {
let _ = tx.send(Message::Text(r#"{"type":"pong"}"#.to_string())).await;
}
}
Some(Ok(_)) => {
last_client_activity = Instant::now();
}
Some(Err(e)) => {
debug!("WebSocket stream error: {}", e);
break;
}
None => break,
}
}
}
}
metrics_store.decrement_ws();
info!("WebSocket /ws/db disconnected");
});
}
Ok(response)
}
}
+1 -1
View File
@@ -73,7 +73,7 @@ impl RpcHandler {
// Resolve actual file size from disk
let file_path = content_server::content_file_path(&self.config.data_dir, &item);
if let Ok(metadata) = std::fs::metadata(&file_path) {
if let Ok(metadata) = tokio::fs::metadata(&file_path).await {
item.size_bytes = metadata.len();
}
@@ -1,31 +1,17 @@
use super::RpcHandler;
use super::*;
use crate::api::rpc::RpcHandler;
use crate::credentials;
use crate::federation::{self, FederatedNode, TrustLevel};
use crate::identity;
use crate::identity_manager::IdentityManager;
use crate::network::dwn_store::DwnStore;
use anyhow::{Context, Result};
use tracing::{debug, info, warn};
const FEDERATION_PROTOCOL: &str = "https://archipelago.dev/protocols/federation/v1";
/// Validate a DID parameter: must start with "did:", max 256 chars, no path traversal.
fn validate_did(did: &str) -> Result<()> {
if did.is_empty() || did.len() > 256 {
anyhow::bail!("Invalid DID: must be 1-256 characters");
}
if !did.starts_with("did:") {
anyhow::bail!("Invalid DID: must start with 'did:'");
}
if did.contains("..") || did.contains('/') || did.contains('\\') || did.contains('\0') {
anyhow::bail!("Invalid DID: contains forbidden characters");
}
Ok(())
}
impl RpcHandler {
/// federation.invite — Generate an invite code containing our DID + onion for a peer.
pub(super) async fn handle_federation_invite(&self) -> Result<serde_json::Value> {
pub(in crate::api::rpc) async fn handle_federation_invite(&self) -> Result<serde_json::Value> {
let (data, _) = self.state_manager.get_snapshot().await;
let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
let onion = data
@@ -50,7 +36,7 @@ impl RpcHandler {
}
/// federation.join — Accept an invite code and establish federation with the remote node.
pub(super) async fn handle_federation_join(
pub(in crate::api::rpc) async fn handle_federation_join(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
@@ -150,7 +136,7 @@ impl RpcHandler {
}
/// federation.list-nodes — List all federated nodes with their status, last state, and VC verification.
pub(super) async fn handle_federation_list_nodes(&self) -> Result<serde_json::Value> {
pub(in crate::api::rpc) async fn handle_federation_list_nodes(&self) -> Result<serde_json::Value> {
let nodes = federation::load_nodes(&self.config.data_dir).await?;
// Load credentials to check for federation VCs
@@ -197,7 +183,7 @@ impl RpcHandler {
}
/// federation.remove-node — Remove a node from the federation by DID.
pub(super) async fn handle_federation_remove_node(
pub(in crate::api::rpc) async fn handle_federation_remove_node(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
@@ -218,7 +204,7 @@ impl RpcHandler {
}
/// federation.set-trust — Change trust level for a federated node.
pub(super) async fn handle_federation_set_trust(
pub(in crate::api::rpc) async fn handle_federation_set_trust(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
@@ -250,7 +236,7 @@ impl RpcHandler {
}
/// federation.sync-state — Manually trigger state sync with all federated peers.
pub(super) async fn handle_federation_sync_state(&self) -> Result<serde_json::Value> {
pub(in crate::api::rpc) async fn handle_federation_sync_state(&self) -> Result<serde_json::Value> {
let nodes = federation::load_nodes(&self.config.data_dir).await?;
if nodes.is_empty() {
@@ -312,7 +298,7 @@ impl RpcHandler {
}
/// federation.get-state — Return this node's state snapshot (called by peers during sync).
pub(super) async fn handle_federation_get_state(&self) -> Result<serde_json::Value> {
pub(in crate::api::rpc) async fn handle_federation_get_state(&self) -> Result<serde_json::Value> {
let (data, _) = self.state_manager.get_snapshot().await;
// Build app statuses from package_data
@@ -338,7 +324,7 @@ impl RpcHandler {
/// federation.peer-joined — Called by a remote peer after they accept our invite.
/// Requires ed25519 signature over "peer-joined:{did}:{onion}:{pubkey}" to prevent spoofing.
pub(super) async fn handle_federation_peer_joined(
pub(in crate::api::rpc) async fn handle_federation_peer_joined(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
@@ -400,7 +386,7 @@ impl RpcHandler {
}
/// federation.deploy-app — Deploy an app to a remote federated node.
pub(super) async fn handle_federation_deploy_app(
pub(in crate::api::rpc) async fn handle_federation_deploy_app(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
@@ -451,7 +437,7 @@ impl RpcHandler {
/// federation.peer-address-changed — A peer notifies us that their .onion changed.
/// Requires ed25519 signature over "address-changed:{did}:{new_onion}" using the
/// peer's known pubkey. This prevents attackers from redirecting federation traffic.
pub(super) async fn handle_federation_peer_address_changed(
pub(in crate::api::rpc) async fn handle_federation_peer_address_changed(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
@@ -508,7 +494,7 @@ impl RpcHandler {
/// federation.notify-did-change — Notify all federated peers that our DID has rotated.
/// Called after `node.rotate-did` to propagate the rotation proof to peers.
pub(super) async fn handle_federation_notify_did_change(
pub(in crate::api::rpc) async fn handle_federation_notify_did_change(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
@@ -614,7 +600,7 @@ impl RpcHandler {
/// federation.peer-did-changed — A peer notifies us that their DID has rotated.
/// Verifies the rotation proof against the peer's KNOWN pubkey before accepting.
pub(super) async fn handle_federation_peer_did_changed(
pub(in crate::api::rpc) async fn handle_federation_peer_did_changed(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
@@ -0,0 +1,17 @@
mod handlers;
use anyhow::Result;
pub(super) fn validate_did(did: &str) -> Result<()> {
if did.is_empty() || did.len() > 256 {
anyhow::bail!("Invalid DID: must be 1-256 characters");
}
if !did.starts_with("did:") {
anyhow::bail!("Invalid DID: must start with 'did:'");
}
if did.contains("..") || did.contains('/') || did.contains('\\') || did.contains('\0') {
anyhow::bail!("Invalid DID: contains forbidden characters");
}
Ok(())
}
@@ -1,28 +1,13 @@
//! RPC handlers for multi-identity management.
use super::RpcHandler;
use super::*;
use crate::api::rpc::RpcHandler;
use crate::identity_manager::{IdentityManager, IdentityProfile, IdentityPurpose};
use crate::network::did_dht;
use anyhow::{Context, Result};
use nostr_sdk::ToBech32;
/// Validate an identity ID: alphanumeric, hyphens, underscores, 1-128 chars, no path traversal.
fn validate_identity_id(id: &str) -> Result<()> {
if id.is_empty() || id.len() > 128 {
anyhow::bail!("Invalid identity id: must be 1-128 characters");
}
if id.contains("..") || id.contains('/') || id.contains('\\') || id.contains('\0') {
anyhow::bail!("Invalid identity id: contains forbidden characters");
}
if !id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b':') {
anyhow::bail!("Invalid identity id: must be alphanumeric, hyphens, underscores, or colons");
}
Ok(())
}
impl RpcHandler {
/// List all identities with their default status.
pub(super) async fn handle_identity_list(
pub(in crate::api::rpc) async fn handle_identity_list(
&self,
_params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
@@ -52,7 +37,7 @@ impl RpcHandler {
}
/// Create a new identity.
pub(super) async fn handle_identity_create(
pub(in crate::api::rpc) async fn handle_identity_create(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
@@ -93,7 +78,7 @@ impl RpcHandler {
}
/// Get a single identity by ID.
pub(super) async fn handle_identity_get(
pub(in crate::api::rpc) async fn handle_identity_get(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
@@ -123,7 +108,7 @@ impl RpcHandler {
}
/// Delete an identity.
pub(super) async fn handle_identity_delete(
pub(in crate::api::rpc) async fn handle_identity_delete(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
@@ -141,7 +126,7 @@ impl RpcHandler {
}
/// Set the default identity.
pub(super) async fn handle_identity_set_default(
pub(in crate::api::rpc) async fn handle_identity_set_default(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
@@ -159,7 +144,7 @@ impl RpcHandler {
}
/// Sign a message with a specific identity.
pub(super) async fn handle_identity_sign(
pub(in crate::api::rpc) async fn handle_identity_sign(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
@@ -185,7 +170,7 @@ impl RpcHandler {
}
/// Verify a signature against a DID.
pub(super) async fn handle_identity_verify(
pub(in crate::api::rpc) async fn handle_identity_verify(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
@@ -211,7 +196,7 @@ impl RpcHandler {
/// Resolve a DID to its W3C DID Document.
/// If no DID is provided, returns the node's own DID Document.
pub(super) async fn handle_identity_resolve_did(
pub(in crate::api::rpc) async fn handle_identity_resolve_did(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
@@ -243,7 +228,7 @@ impl RpcHandler {
}
/// Verify a DID Document: validate structure, check key material matches DID.
pub(super) async fn handle_identity_verify_did_document(
pub(in crate::api::rpc) async fn handle_identity_verify_did_document(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
@@ -315,7 +300,7 @@ impl RpcHandler {
}
/// Create a Nostr keypair linked to an identity.
pub(super) async fn handle_identity_create_nostr_key(
pub(in crate::api::rpc) async fn handle_identity_create_nostr_key(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
@@ -345,7 +330,7 @@ impl RpcHandler {
/// - `event_hash` (hex) + `id` — sign a pre-computed hash
/// - `event` (full event object) — compute NIP-01 hash, fill pubkey, sign
/// If `id` is omitted, uses the default identity.
pub(super) async fn handle_identity_nostr_sign(
pub(in crate::api::rpc) async fn handle_identity_nostr_sign(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
@@ -429,7 +414,7 @@ impl RpcHandler {
}
/// NIP-04 encrypt plaintext for a peer.
pub(super) async fn handle_identity_nostr_encrypt_nip04(
pub(in crate::api::rpc) async fn handle_identity_nostr_encrypt_nip04(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
@@ -447,7 +432,7 @@ impl RpcHandler {
}
/// NIP-04 decrypt ciphertext from a peer.
pub(super) async fn handle_identity_nostr_decrypt_nip04(
pub(in crate::api::rpc) async fn handle_identity_nostr_decrypt_nip04(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
@@ -465,7 +450,7 @@ impl RpcHandler {
}
/// NIP-44 encrypt plaintext for a peer.
pub(super) async fn handle_identity_nostr_encrypt_nip44(
pub(in crate::api::rpc) async fn handle_identity_nostr_encrypt_nip44(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
@@ -483,7 +468,7 @@ impl RpcHandler {
}
/// NIP-44 decrypt ciphertext from a peer.
pub(super) async fn handle_identity_nostr_decrypt_nip44(
pub(in crate::api::rpc) async fn handle_identity_nostr_decrypt_nip44(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
@@ -502,7 +487,7 @@ impl RpcHandler {
/// Resolve a remote peer's DID Document over Tor.
/// Queries the peer's /rpc/ endpoint for identity.resolve-did.
pub(super) async fn handle_identity_resolve_remote_did(
pub(in crate::api::rpc) async fn handle_identity_resolve_remote_did(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
@@ -578,7 +563,7 @@ impl RpcHandler {
}
/// identity.create-dht-did — Publish an identity's DID to the Mainline DHT.
pub(super) async fn handle_identity_create_dht_did(
pub(in crate::api::rpc) async fn handle_identity_create_dht_did(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
@@ -604,7 +589,7 @@ impl RpcHandler {
}
/// identity.resolve-dht-did — Resolve a did:dht from the DHT.
pub(super) async fn handle_identity_resolve_dht_did(
pub(in crate::api::rpc) async fn handle_identity_resolve_dht_did(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
@@ -627,7 +612,7 @@ impl RpcHandler {
}
/// identity.refresh-dht-did — Re-publish an identity's did:dht to keep it alive in the DHT.
pub(super) async fn handle_identity_refresh_dht_did(
pub(in crate::api::rpc) async fn handle_identity_refresh_dht_did(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
@@ -655,7 +640,7 @@ impl RpcHandler {
}
/// Update profile metadata for an identity.
pub(super) async fn handle_identity_update_profile(
pub(in crate::api::rpc) async fn handle_identity_update_profile(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
@@ -681,7 +666,7 @@ impl RpcHandler {
}
/// Publish kind 0 (metadata) profile to the local Nostr relay.
pub(super) async fn handle_identity_publish_profile(
pub(in crate::api::rpc) async fn handle_identity_publish_profile(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
@@ -705,7 +690,7 @@ impl RpcHandler {
}
/// Export private keys for an identity — REQUIRES password verification.
pub(super) async fn handle_identity_export_keys(
pub(in crate::api::rpc) async fn handle_identity_export_keys(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
@@ -743,7 +728,7 @@ impl RpcHandler {
}
/// identity.dht-status — Check if an identity's did:dht is published and resolvable.
pub(super) async fn handle_identity_dht_status(
pub(in crate::api::rpc) async fn handle_identity_dht_status(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
@@ -0,0 +1,17 @@
mod handlers;
use anyhow::Result;
pub(super) fn validate_identity_id(id: &str) -> Result<()> {
if id.is_empty() || id.len() > 128 {
anyhow::bail!("Invalid identity id: must be 1-128 characters");
}
if id.contains("..") || id.contains('/') || id.contains('\\') || id.contains('\0') {
anyhow::bail!("Invalid identity id: contains forbidden characters");
}
if !id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b':') {
anyhow::bail!("Invalid identity id: must be alphanumeric, hyphens, underscores, or colons");
}
Ok(())
}
-865
View File
@@ -1,865 +0,0 @@
use super::RpcHandler;
use crate::mesh;
use crate::mesh::message_types::{
self, AlertPayload, AlertType, Coordinate, InvoicePayload, MeshMessageType, TypedEnvelope,
};
use anyhow::Result;
use tracing::info;
impl RpcHandler {
/// mesh.status — Get mesh radio status, device info, and peer count.
pub(super) async fn handle_mesh_status(&self) -> Result<serde_json::Value> {
let service = self.mesh_service.read().await;
if let Some(svc) = service.as_ref() {
let status = svc.status().await;
Ok(serde_json::to_value(status)?)
} else {
// No service running — return basic config + device detection
let config = mesh::load_config(&self.config.data_dir).await?;
let devices = mesh::detect_devices().await;
Ok(serde_json::json!({
"enabled": config.enabled,
"device_connected": false,
"device_type": "unknown",
"device_path": config.device_path,
"channel_name": config.channel_name.unwrap_or_else(|| "archipelago".to_string()),
"detected_devices": devices,
"peer_count": 0,
"messages_sent": 0,
"messages_received": 0,
}))
}
}
/// mesh.peers — List discovered mesh peers.
pub(super) async fn handle_mesh_peers(&self) -> Result<serde_json::Value> {
let service = self.mesh_service.read().await;
if let Some(svc) = service.as_ref() {
let peers = svc.peers().await;
Ok(serde_json::json!({
"peers": peers,
"count": peers.len(),
}))
} else {
Ok(serde_json::json!({
"peers": [],
"count": 0,
}))
}
}
/// mesh.messages — Get recent mesh message history.
pub(super) async fn handle_mesh_messages(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let limit = params
.as_ref()
.and_then(|p| p.get("limit"))
.and_then(|v| v.as_u64())
.map(|n| n as usize);
let service = self.mesh_service.read().await;
if let Some(svc) = service.as_ref() {
let messages = svc.messages(limit).await;
Ok(serde_json::json!({
"messages": messages,
"count": messages.len(),
}))
} else {
Ok(serde_json::json!({
"messages": [],
"count": 0,
}))
}
}
/// mesh.send — Send an encrypted message to a mesh peer.
pub(super) async fn handle_mesh_send(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let contact_id = params
.get("contact_id")
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("Missing contact_id"))? as u32;
let message = params
.get("message")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing message"))?;
if message.is_empty() {
anyhow::bail!("Message cannot be empty");
}
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running. Enable mesh first."))?;
let msg = svc.send_message(contact_id, message).await?;
info!(contact_id, encrypted = msg.encrypted, "Sent mesh message");
Ok(serde_json::json!({
"sent": true,
"message_id": msg.id,
"encrypted": msg.encrypted,
}))
}
/// mesh.broadcast — Broadcast our node identity over mesh.
pub(super) async fn handle_mesh_broadcast(&self) -> Result<serde_json::Value> {
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running. Enable mesh first."))?;
svc.broadcast_identity().await?;
info!("Broadcast identity over mesh");
Ok(serde_json::json!({ "broadcast": true }))
}
/// mesh.configure — Enable/disable mesh and set device path.
pub(super) async fn handle_mesh_configure(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let mut config = mesh::load_config(&self.config.data_dir).await?;
if let Some(enabled) = params.get("enabled").and_then(|v| v.as_bool()) {
config.enabled = enabled;
}
if let Some(device) = params.get("device_path").and_then(|v| v.as_str()) {
config.device_path = Some(device.to_string());
}
if let Some(channel) = params.get("channel_name").and_then(|v| v.as_str()) {
config.channel_name = Some(channel.to_string());
}
if let Some(broadcast) = params.get("broadcast_identity").and_then(|v| v.as_bool()) {
config.broadcast_identity = broadcast;
}
if let Some(name) = params.get("advert_name").and_then(|v| v.as_str()) {
config.advert_name = Some(name.to_string());
}
mesh::save_config(&self.config.data_dir, &config).await?;
// If we have a running service, update its config
let mut service = self.mesh_service.write().await;
if let Some(svc) = service.as_mut() {
svc.configure(config.clone()).await?;
}
info!("Mesh config updated");
Ok(serde_json::json!({
"configured": true,
"enabled": config.enabled,
"device_path": config.device_path,
}))
}
// ─── Phase 3: Typed Messages ────────────────────────────────────────
/// mesh.send-invoice — Create a Lightning invoice and send bolt11 to mesh peer.
pub(super) async fn handle_mesh_send_invoice(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let contact_id = params["contact_id"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("Missing contact_id"))? as u32;
let amount_sats = params["amount_sats"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("Missing amount_sats"))?;
let memo = params["memo"].as_str().map(|s| s.to_string());
// Build invoice payload
let invoice = InvoicePayload {
bolt11: format!("lnbc{}n1pjmesh...", amount_sats), // Placeholder — real LND call in Phase 4
amount_sats,
memo: memo.clone(),
payment_hash: None,
};
let payload = message_types::encode_payload(&invoice)?;
let envelope = TypedEnvelope::new(MeshMessageType::Invoice, payload);
let wire = envelope.to_wire()?;
// Send via mesh
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let wire_str = String::from_utf8_lossy(&wire).to_string();
let msg = svc.send_message(contact_id, &wire_str).await?;
info!(contact_id, amount_sats, "Sent invoice over mesh");
Ok(serde_json::json!({
"sent": true,
"message_id": msg.id,
"amount_sats": amount_sats,
"bolt11": invoice.bolt11,
}))
}
/// mesh.send-coordinate — Send GPS coordinates to a mesh peer.
pub(super) async fn handle_mesh_send_coordinate(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let contact_id = params["contact_id"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("Missing contact_id"))? as u32;
let lat = params["lat"]
.as_f64()
.ok_or_else(|| anyhow::anyhow!("Missing lat"))?;
let lng = params["lng"]
.as_f64()
.ok_or_else(|| anyhow::anyhow!("Missing lng"))?;
let label = params["label"].as_str().map(|s| s.to_string());
let coord = Coordinate::from_degrees(lat, lng, label);
let payload = message_types::encode_payload(&coord)?;
let envelope = TypedEnvelope::new(MeshMessageType::Coordinate, payload);
let wire = envelope.to_wire()?;
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let wire_str = String::from_utf8_lossy(&wire).to_string();
let msg = svc.send_message(contact_id, &wire_str).await?;
info!(contact_id, "Sent coordinate over mesh");
Ok(serde_json::json!({
"sent": true,
"message_id": msg.id,
"lat": coord.lat,
"lng": coord.lng,
}))
}
/// mesh.send-alert — Send a signed emergency alert over mesh.
pub(super) async fn handle_mesh_send_alert(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let message = params["message"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing message"))?;
let alert_type_str = params["alert_type"]
.as_str()
.unwrap_or("status");
let broadcast = params["broadcast"].as_bool().unwrap_or(false);
let alert_type = match alert_type_str {
"emergency" => AlertType::Emergency,
"dead_man" => AlertType::DeadMan,
_ => AlertType::Status,
};
// Optional GPS
let coordinate = if let (Some(lat), Some(lng)) = (
params["lat"].as_f64(),
params["lng"].as_f64(),
) {
Some(Coordinate::from_degrees(lat, lng, None))
} else {
None
};
let alert = AlertPayload {
alert_type,
message: message.to_string(),
coordinate,
};
let payload = message_types::encode_payload(&alert)?;
// Sign the alert with node identity
let (data, _) = self.state_manager.get_snapshot().await;
let identity_dir = self.config.data_dir.join("identity");
let node_key_path = identity_dir.join("node_key");
let envelope = if node_key_path.exists() {
let key_bytes = tokio::fs::read(&node_key_path).await?;
if key_bytes.len() == 32 {
let mut seed = [0u8; 32];
seed.copy_from_slice(&key_bytes);
let signing_key = ed25519_dalek::SigningKey::from_bytes(&seed);
TypedEnvelope::new_signed(MeshMessageType::Alert, payload, &signing_key)
} else {
TypedEnvelope::new(MeshMessageType::Alert, payload)
}
} else {
TypedEnvelope::new(MeshMessageType::Alert, payload)
};
let wire = envelope.to_wire()?;
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let wire_str = String::from_utf8_lossy(&wire).to_string();
if broadcast {
// Send on channel (all peers)
svc.send_message(0, &wire_str).await?;
info!(alert_type = alert_type_str, "Broadcast alert over mesh");
} else if let Some(contact_id) = params["contact_id"].as_u64() {
svc.send_message(contact_id as u32, &wire_str).await?;
info!(contact_id, alert_type = alert_type_str, "Sent alert to peer");
} else {
anyhow::bail!("Must specify contact_id or broadcast: true");
}
Ok(serde_json::json!({
"sent": true,
"alert_type": alert_type_str,
"signed": envelope.sig.is_some(),
}))
}
/// mesh.outbox — List pending store-and-forward messages.
pub(super) async fn handle_mesh_outbox(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let limit = params
.as_ref()
.and_then(|p| p["limit"].as_u64())
.map(|n| n as usize);
// Check if outbox file exists
let outbox = mesh::outbox::MeshOutbox::load(&self.config.data_dir).await?;
let messages = outbox.list(limit).await;
let count = outbox.count().await;
Ok(serde_json::json!({
"messages": messages.iter().map(|m| serde_json::json!({
"id": m.id,
"dest_did": m.dest_did,
"from_did": m.from_did,
"created_at": m.created_at,
"ttl_secs": m.ttl_secs,
"retry_count": m.retry_count,
"relay_hops": m.relay_hops,
"expired": m.is_expired(),
})).collect::<Vec<_>>(),
"count": count,
}))
}
/// mesh.session-status — Get ratchet session info for a peer.
pub(super) async fn handle_mesh_session_status(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let contact_id = params["contact_id"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("Missing contact_id"))? as u32;
// Look up peer DID from mesh service
let service = self.mesh_service.read().await;
let peer_did = if let Some(svc) = service.as_ref() {
let peers = svc.peers().await;
peers.iter().find(|p| p.contact_id == contact_id).and_then(|p| p.did.clone())
} else {
None
};
if let Some(did) = peer_did {
let session_mgr = mesh::session::SessionManager::new(&self.config.data_dir);
if let Some(info) = session_mgr.session_info(&did).await {
Ok(serde_json::json!({
"has_session": info.has_session,
"forward_secrecy": info.forward_secrecy,
"message_count": info.message_count,
"ratchet_generation": info.ratchet_generation,
"peer_did": did,
}))
} else {
Ok(serde_json::json!({
"has_session": false,
"forward_secrecy": false,
"message_count": 0,
"ratchet_generation": 0,
"peer_did": did,
}))
}
} else {
Ok(serde_json::json!({
"has_session": false,
"forward_secrecy": false,
"message_count": 0,
"ratchet_generation": 0,
"peer_did": null,
}))
}
}
// ─── Phase 4: Off-Grid Bitcoin Operations ────────────────────────────
/// mesh.relay-tx — Send a raw transaction for relay by an internet-connected mesh peer.
pub(super) async fn handle_mesh_relay_tx(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let tx_hex = params["tx_hex"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing tx_hex"))?;
let relay_mode = params["relay_mode"]
.as_str()
.unwrap_or("archy");
if tx_hex.len() < 20 || tx_hex.len() > 200_000 {
anyhow::bail!("Invalid tx_hex length");
}
// Validate hex
if hex::decode(tx_hex).is_err() {
anyhow::bail!("tx_hex is not valid hexadecimal");
}
let service = self.mesh_service.read().await;
let svc = service.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let request_id = chrono::Utc::now().timestamp() as u64;
svc.relay_tracker.track_tx_relay(request_id, svc.our_did()).await;
let wire = crate::mesh::bitcoin_relay::build_tx_relay_request(tx_hex, request_id)?;
let mut sent_count = 0u32;
if relay_mode == "broadcast" {
// Broadcast mode: send on channel 0 (all mesh nodes relay)
// Still encrypted — only Archy nodes can decrypt and broadcast the TX
let shared_state = svc.shared_state();
let shared_secrets = shared_state.shared_secrets.read().await;
// Encrypt with first available Archy peer's shared secret
// (any Archy node that receives it can try decrypting)
let payload = shared_secrets.values().next()
.and_then(|secret| {
crate::mesh::crypto::encrypt(secret, &wire).ok().map(|ct| {
let mut encrypted = Vec::with_capacity(1 + ct.len());
encrypted.push(crate::mesh::message_types::ENCRYPTED_TYPED_MARKER);
encrypted.extend_from_slice(&ct);
encrypted
})
})
.unwrap_or_else(|| wire.clone());
drop(shared_secrets);
{
use base64::Engine;
let b64 = base64::engine::general_purpose::STANDARD.encode(&payload);
let _ = shared_state
.cmd_tx
.send(crate::mesh::listener::MeshCommand::BroadcastChannel {
channel: 0,
payload: b64.into_bytes(),
})
.await;
}
sent_count = 1;
info!(request_id, tx_len = tx_hex.len(), "TX relay broadcast on mesh channel 0 (encrypted)");
} else {
// Archy mode: E2E encrypted per-peer, direct to known Archy nodes
let peers = svc.peers().await;
let shared_state = svc.shared_state();
let shared_secrets = shared_state.shared_secrets.read().await;
for peer in &peers {
if !peer.advert_name.starts_with("Archy-") { continue; }
if let Some(ref pk) = peer.pubkey_hex {
if let Ok(pk_bytes) = hex::decode(pk) {
if pk_bytes.len() >= 6 {
let mut prefix = [0u8; 6];
prefix.copy_from_slice(&pk_bytes[..6]);
let payload = if let Some(secret) = shared_secrets.get(&peer.contact_id) {
match crate::mesh::crypto::encrypt(secret, &wire) {
Ok(ciphertext) => {
let mut encrypted = Vec::with_capacity(1 + ciphertext.len());
encrypted.push(crate::mesh::message_types::ENCRYPTED_TYPED_MARKER);
encrypted.extend_from_slice(&ciphertext);
encrypted
}
Err(_) => wire.clone(),
}
} else {
wire.clone()
};
let _ = svc.shared_state()
.cmd_tx
.send(crate::mesh::listener::MeshCommand::SendRaw {
dest_pubkey_prefix: prefix,
payload,
})
.await;
sent_count += 1;
}
}
}
}
drop(shared_secrets);
info!(request_id, tx_len = tx_hex.len(), archy_peers = sent_count, "TX relay sent to Archy peers (E2E encrypted)");
}
Ok(serde_json::json!({
"request_id": request_id,
"queued": true,
"tx_hex_len": tx_hex.len(),
}))
}
/// mesh.relay-status — Check the status of a pending or completed TX relay.
pub(super) async fn handle_mesh_relay_status(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let request_id = params["request_id"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("Missing request_id"))?;
let service = self.mesh_service.read().await;
let svc = service.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
// Check completed results first
if let Some(result) = svc.relay_tracker.get_result(request_id).await {
return Ok(serde_json::json!({
"status": if result.txid.is_some() { "confirmed" } else { "failed" },
"request_id": result.request_id,
"txid": result.txid,
"error": result.error,
"error_code": result.error_code,
"completed_at": result.completed_at,
}));
}
// Check if still pending
if svc.relay_tracker.is_pending(request_id).await {
return Ok(serde_json::json!({
"status": "pending",
"request_id": request_id,
}));
}
// Unknown — either expired or never existed
Ok(serde_json::json!({
"status": "unknown",
"request_id": request_id,
}))
}
/// mesh.block-headers — Get cached block headers received from mesh peers.
pub(super) async fn handle_mesh_block_headers(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let count = params
.as_ref()
.and_then(|p| p["count"].as_u64())
.unwrap_or(10) as usize;
let service = self.mesh_service.read().await;
let svc = service.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let headers = svc.block_header_cache.recent_headers(count).await;
let latest = svc.block_header_cache.latest_height().await;
Ok(serde_json::json!({
"headers": headers.iter().map(|h| serde_json::json!({
"height": h.height,
"hash": h.hash,
"prev_hash": h.prev_hash,
"timestamp": h.timestamp,
"announced_by": h.announced_by,
})).collect::<Vec<_>>(),
"latest_height": latest,
"count": headers.len(),
}))
}
/// mesh.relay-lightning — Send a Lightning invoice for payment by an internet-connected peer.
pub(super) async fn handle_mesh_relay_lightning(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let bolt11 = params["bolt11"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing bolt11"))?;
let amount_sats = params["amount_sats"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("Missing amount_sats"))?;
if !bolt11.starts_with("lnbc") && !bolt11.starts_with("lntb") {
anyhow::bail!("Invalid bolt11 invoice — must start with lnbc or lntb");
}
let service = self.mesh_service.read().await;
let svc = service.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let request_id = chrono::Utc::now().timestamp() as u64;
svc.relay_tracker.track_lightning_relay(request_id, svc.our_did()).await;
let wire = crate::mesh::bitcoin_relay::build_lightning_relay_request(
bolt11, amount_sats, request_id,
)?;
// Send to Archipelago peers — E2E encrypted per-peer
let peers = svc.peers().await;
let shared_state = svc.shared_state();
let shared_secrets = shared_state.shared_secrets.read().await;
let mut sent_count = 0u32;
for peer in &peers {
if !peer.advert_name.starts_with("Archy-") { continue; }
if let Some(ref pk) = peer.pubkey_hex {
if let Ok(pk_bytes) = hex::decode(pk) {
if pk_bytes.len() >= 6 {
let mut prefix = [0u8; 6];
prefix.copy_from_slice(&pk_bytes[..6]);
let payload = if let Some(secret) = shared_secrets.get(&peer.contact_id) {
match crate::mesh::crypto::encrypt(secret, &wire) {
Ok(ciphertext) => {
let mut encrypted = Vec::with_capacity(1 + ciphertext.len());
encrypted.push(crate::mesh::message_types::ENCRYPTED_TYPED_MARKER);
encrypted.extend_from_slice(&ciphertext);
encrypted
}
Err(_) => wire.clone(),
}
} else {
wire.clone()
};
let _ = svc.shared_state()
.cmd_tx
.send(crate::mesh::listener::MeshCommand::SendRaw {
dest_pubkey_prefix: prefix,
payload,
})
.await;
sent_count += 1;
}
}
}
}
drop(shared_secrets);
info!(request_id, amount_sats, archy_peers = sent_count, "Lightning relay sent (E2E encrypted)");
Ok(serde_json::json!({
"request_id": request_id,
"queued": true,
"amount_sats": amount_sats,
}))
}
/// mesh.deadman-status — Get dead man's switch status.
pub(super) async fn handle_mesh_deadman_status(&self) -> Result<serde_json::Value> {
let service = self.mesh_service.read().await;
let svc = service.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let status = svc.dead_man_switch.status().await;
Ok(serde_json::to_value(status)?)
}
/// mesh.deadman-configure — Configure the dead man's switch.
pub(super) async fn handle_mesh_deadman_configure(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let service = self.mesh_service.read().await;
let svc = service.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let mut config = svc.dead_man_switch.get_config().await;
if let Some(enabled) = params.get("enabled").and_then(|v| v.as_bool()) {
config.dead_man_enabled = enabled;
}
if let Some(interval) = params.get("interval_secs").and_then(|v| v.as_u64()) {
if interval < 60 {
anyhow::bail!("Interval must be at least 60 seconds");
}
config.dead_man_interval_secs = interval;
}
if let (Some(lat), Some(lng)) = (
params.get("lat").and_then(|v| v.as_f64()),
params.get("lng").and_then(|v| v.as_f64()),
) {
let label = params.get("label").and_then(|v| v.as_str()).map(|s| s.to_string());
config.last_gps = Some(Coordinate::from_degrees(lat, lng, label));
}
if let Some(contacts) = params.get("contacts").and_then(|v| v.as_array()) {
config.emergency_contacts = contacts
.iter()
.filter_map(|c| c.as_str().map(|s| s.to_string()))
.collect();
}
if let Some(msg) = params.get("custom_message").and_then(|v| v.as_str()) {
config.custom_message = Some(msg.to_string());
}
if let Some(auto_gps) = params.get("auto_gps").and_then(|v| v.as_bool()) {
config.auto_include_gps = auto_gps;
}
svc.dead_man_switch.configure(config).await?;
// Reset timer on configure
svc.dead_man_switch.check_in().await;
let status = svc.dead_man_switch.status().await;
info!("Dead man's switch configured");
Ok(serde_json::to_value(status)?)
}
/// mesh.deadman-checkin — Heartbeat to reset the dead man's switch timer.
pub(super) async fn handle_mesh_deadman_checkin(&self) -> Result<serde_json::Value> {
let service = self.mesh_service.read().await;
let svc = service.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
svc.dead_man_check_in().await;
let remaining = svc.dead_man_switch.time_remaining_secs().await;
Ok(serde_json::json!({
"checked_in": true,
"time_remaining_secs": remaining,
}))
}
/// mesh.rotate-prekeys — Force prekey rotation for X3DH.
pub(super) async fn handle_mesh_rotate_prekeys(&self) -> Result<serde_json::Value> {
// Load identity signing key
let identity_dir = self.config.data_dir.join("identity");
let node_key_path = identity_dir.join("node_key");
let key_bytes = tokio::fs::read(&node_key_path)
.await
.map_err(|_| anyhow::anyhow!("Node identity not found"))?;
if key_bytes.len() != 32 {
anyhow::bail!("Invalid node key");
}
let mut seed = [0u8; 32];
seed.copy_from_slice(&key_bytes);
let signing_key = ed25519_dalek::SigningKey::from_bytes(&seed);
// Generate new prekey bundle
let (bundle, _secrets) = mesh::x3dh::generate_prekey_bundle(&signing_key, 10)?;
// Save bundle for distribution
let bundle_bytes = mesh::x3dh::encode_bundle(&bundle)?;
let prekey_dir = self.config.data_dir.join("prekeys");
tokio::fs::create_dir_all(&prekey_dir).await?;
tokio::fs::write(prekey_dir.join("bundle.cbor"), &bundle_bytes).await?;
info!(
one_time_keys = bundle.one_time_prekeys.len(),
"Prekey bundle rotated"
);
Ok(serde_json::json!({
"rotated": true,
"signed_prekey_id": bundle.signed_prekey.id,
"one_time_prekeys": bundle.one_time_prekeys.len(),
}))
}
// ─── Radio Diagnostics ─────────────────────────────────────────────
/// mesh.test-send — Send test payloads of various sizes to diagnose radio link.
/// Sends plain text markers that the receiver can count.
pub(super) async fn handle_mesh_test_send(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let contact_id = params["contact_id"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("Missing contact_id"))? as u32;
// Test modes: "ping" (small), "medium" (80 bytes), "large" (150 bytes), "chunked" (400 bytes)
let mode = params["mode"].as_str().unwrap_or("ping");
let count = params["count"].as_u64().unwrap_or(3) as usize;
let service = self.mesh_service.read().await;
let svc = service.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let mut sent = 0usize;
let test_id = chrono::Utc::now().timestamp() as u32;
for i in 0..count {
let payload = match mode {
"ping" => format!("MESHTEST:{}:{}:PING", test_id, i),
"medium" => format!("MESHTEST:{}:{}:{}", test_id, i, "X".repeat(60)),
"large" => format!("MESHTEST:{}:{}:{}", test_id, i, "X".repeat(130)),
"chunked" => {
// Send a TypedEnvelope that requires chunking (>140 base64 chars)
let fake_tx = "0".repeat(400); // simulates TX hex
let wire = crate::mesh::bitcoin_relay::build_tx_relay_request(&fake_tx, test_id as u64 + i as u64)?;
// Send via SendRaw which handles base64 + chunking
let peers = svc.peers().await;
if let Some(peer) = peers.iter().find(|p| p.contact_id == contact_id) {
if let Some(ref pk) = peer.pubkey_hex {
if let Ok(pk_bytes) = hex::decode(pk) {
if pk_bytes.len() >= 6 {
let mut prefix = [0u8; 6];
prefix.copy_from_slice(&pk_bytes[..6]);
let _ = svc.shared_state().cmd_tx.send(
crate::mesh::listener::MeshCommand::SendRaw {
dest_pubkey_prefix: prefix,
payload: wire,
},
).await;
sent += 1;
}
}
}
}
// Delay between chunked sends
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
continue;
}
_ => format!("MESHTEST:{}:{}:UNKNOWN", test_id, i),
};
// Send as plain text for ping/medium/large
let msg = svc.send_message(contact_id, &payload).await?;
sent += 1;
info!(test_id, seq = i, mode, len = payload.len(), "Test message sent");
// Small delay between sends
tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
}
Ok(serde_json::json!({
"test_id": test_id,
"mode": mode,
"sent": sent,
"count": count,
}))
}
}
@@ -0,0 +1,267 @@
use super::super::RpcHandler;
use anyhow::Result;
use tracing::info;
impl RpcHandler {
/// mesh.relay-tx — Send a raw transaction for relay by an internet-connected mesh peer.
pub(in crate::api::rpc) async fn handle_mesh_relay_tx(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let tx_hex = params["tx_hex"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing tx_hex"))?;
let relay_mode = params["relay_mode"]
.as_str()
.unwrap_or("archy");
if tx_hex.len() < 20 || tx_hex.len() > 200_000 {
anyhow::bail!("Invalid tx_hex length");
}
// Validate hex
if hex::decode(tx_hex).is_err() {
anyhow::bail!("tx_hex is not valid hexadecimal");
}
let service = self.mesh_service.read().await;
let svc = service.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let request_id = chrono::Utc::now().timestamp() as u64;
svc.relay_tracker.track_tx_relay(request_id, svc.our_did()).await;
let wire = crate::mesh::bitcoin_relay::build_tx_relay_request(tx_hex, request_id)?;
let mut sent_count = 0u32;
if relay_mode == "broadcast" {
// Broadcast mode: send on channel 0 (all mesh nodes relay)
// Still encrypted — only Archy nodes can decrypt and broadcast the TX
let shared_state = svc.shared_state();
let shared_secrets = shared_state.shared_secrets.read().await;
// Encrypt with first available Archy peer's shared secret
// (any Archy node that receives it can try decrypting)
let payload = shared_secrets.values().next()
.and_then(|secret| {
crate::mesh::crypto::encrypt(secret, &wire).ok().map(|ct| {
let mut encrypted = Vec::with_capacity(1 + ct.len());
encrypted.push(crate::mesh::message_types::ENCRYPTED_TYPED_MARKER);
encrypted.extend_from_slice(&ct);
encrypted
})
})
.unwrap_or_else(|| wire.clone());
drop(shared_secrets);
{
use base64::Engine;
let b64 = base64::engine::general_purpose::STANDARD.encode(&payload);
let _ = shared_state
.cmd_tx
.send(crate::mesh::listener::MeshCommand::BroadcastChannel {
channel: 0,
payload: b64.into_bytes(),
})
.await;
}
info!(request_id, tx_len = tx_hex.len(), "TX relay broadcast on mesh channel 0 (encrypted)");
} else {
// Archy mode: E2E encrypted per-peer, direct to known Archy nodes
let peers = svc.peers().await;
let shared_state = svc.shared_state();
let shared_secrets = shared_state.shared_secrets.read().await;
for peer in &peers {
if !peer.advert_name.starts_with("Archy-") { continue; }
if let Some(ref pk) = peer.pubkey_hex {
if let Ok(pk_bytes) = hex::decode(pk) {
if pk_bytes.len() >= 6 {
let mut prefix = [0u8; 6];
prefix.copy_from_slice(&pk_bytes[..6]);
let payload = if let Some(secret) = shared_secrets.get(&peer.contact_id) {
match crate::mesh::crypto::encrypt(secret, &wire) {
Ok(ciphertext) => {
let mut encrypted = Vec::with_capacity(1 + ciphertext.len());
encrypted.push(crate::mesh::message_types::ENCRYPTED_TYPED_MARKER);
encrypted.extend_from_slice(&ciphertext);
encrypted
}
Err(_) => wire.clone(),
}
} else {
wire.clone()
};
let _ = svc.shared_state()
.cmd_tx
.send(crate::mesh::listener::MeshCommand::SendRaw {
dest_pubkey_prefix: prefix,
payload,
})
.await;
sent_count += 1;
}
}
}
}
drop(shared_secrets);
info!(request_id, tx_len = tx_hex.len(), archy_peers = sent_count, "TX relay sent to Archy peers (E2E encrypted)");
}
Ok(serde_json::json!({
"request_id": request_id,
"queued": true,
"tx_hex_len": tx_hex.len(),
}))
}
/// mesh.relay-status — Check the status of a pending or completed TX relay.
pub(in crate::api::rpc) async fn handle_mesh_relay_status(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let request_id = params["request_id"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("Missing request_id"))?;
let service = self.mesh_service.read().await;
let svc = service.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
// Check completed results first
if let Some(result) = svc.relay_tracker.get_result(request_id).await {
return Ok(serde_json::json!({
"status": if result.txid.is_some() { "confirmed" } else { "failed" },
"request_id": result.request_id,
"txid": result.txid,
"error": result.error,
"error_code": result.error_code,
"completed_at": result.completed_at,
}));
}
// Check if still pending
if svc.relay_tracker.is_pending(request_id).await {
return Ok(serde_json::json!({
"status": "pending",
"request_id": request_id,
}));
}
// Unknown — either expired or never existed
Ok(serde_json::json!({
"status": "unknown",
"request_id": request_id,
}))
}
/// mesh.block-headers — Get cached block headers received from mesh peers.
pub(in crate::api::rpc) async fn handle_mesh_block_headers(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let count = params
.as_ref()
.and_then(|p| p["count"].as_u64())
.unwrap_or(10) as usize;
let service = self.mesh_service.read().await;
let svc = service.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let headers = svc.block_header_cache.recent_headers(count).await;
let latest = svc.block_header_cache.latest_height().await;
Ok(serde_json::json!({
"headers": headers.iter().map(|h| serde_json::json!({
"height": h.height,
"hash": h.hash,
"prev_hash": h.prev_hash,
"timestamp": h.timestamp,
"announced_by": h.announced_by,
})).collect::<Vec<_>>(),
"latest_height": latest,
"count": headers.len(),
}))
}
/// mesh.relay-lightning — Send a Lightning invoice for payment by an internet-connected peer.
pub(in crate::api::rpc) async fn handle_mesh_relay_lightning(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let bolt11 = params["bolt11"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing bolt11"))?;
let amount_sats = params["amount_sats"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("Missing amount_sats"))?;
if !bolt11.starts_with("lnbc") && !bolt11.starts_with("lntb") {
anyhow::bail!("Invalid bolt11 invoice — must start with lnbc or lntb");
}
let service = self.mesh_service.read().await;
let svc = service.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let request_id = chrono::Utc::now().timestamp() as u64;
svc.relay_tracker.track_lightning_relay(request_id, svc.our_did()).await;
let wire = crate::mesh::bitcoin_relay::build_lightning_relay_request(
bolt11, amount_sats, request_id,
)?;
// Send to Archipelago peers — E2E encrypted per-peer
let peers = svc.peers().await;
let shared_state = svc.shared_state();
let shared_secrets = shared_state.shared_secrets.read().await;
let mut sent_count = 0u32;
for peer in &peers {
if !peer.advert_name.starts_with("Archy-") { continue; }
if let Some(ref pk) = peer.pubkey_hex {
if let Ok(pk_bytes) = hex::decode(pk) {
if pk_bytes.len() >= 6 {
let mut prefix = [0u8; 6];
prefix.copy_from_slice(&pk_bytes[..6]);
let payload = if let Some(secret) = shared_secrets.get(&peer.contact_id) {
match crate::mesh::crypto::encrypt(secret, &wire) {
Ok(ciphertext) => {
let mut encrypted = Vec::with_capacity(1 + ciphertext.len());
encrypted.push(crate::mesh::message_types::ENCRYPTED_TYPED_MARKER);
encrypted.extend_from_slice(&ciphertext);
encrypted
}
Err(_) => wire.clone(),
}
} else {
wire.clone()
};
let _ = svc.shared_state()
.cmd_tx
.send(crate::mesh::listener::MeshCommand::SendRaw {
dest_pubkey_prefix: prefix,
payload,
})
.await;
sent_count += 1;
}
}
}
}
drop(shared_secrets);
info!(request_id, amount_sats, archy_peers = sent_count, "Lightning relay sent (E2E encrypted)");
Ok(serde_json::json!({
"request_id": request_id,
"queued": true,
"amount_sats": amount_sats,
}))
}
}
@@ -0,0 +1,96 @@
use super::super::RpcHandler;
use crate::mesh;
use anyhow::Result;
use tracing::info;
impl RpcHandler {
/// mesh.send — Send an encrypted message to a mesh peer.
pub(in crate::api::rpc) async fn handle_mesh_send(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let contact_id = params
.get("contact_id")
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("Missing contact_id"))? as u32;
let message = params
.get("message")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing message"))?;
if message.is_empty() {
anyhow::bail!("Message cannot be empty");
}
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running. Enable mesh first."))?;
let msg = svc.send_message(contact_id, message).await?;
info!(contact_id, encrypted = msg.encrypted, "Sent mesh message");
Ok(serde_json::json!({
"sent": true,
"message_id": msg.id,
"encrypted": msg.encrypted,
}))
}
/// mesh.broadcast — Broadcast our node identity over mesh.
pub(in crate::api::rpc) async fn handle_mesh_broadcast(&self) -> Result<serde_json::Value> {
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running. Enable mesh first."))?;
svc.broadcast_identity().await?;
info!("Broadcast identity over mesh");
Ok(serde_json::json!({ "broadcast": true }))
}
/// mesh.configure — Enable/disable mesh and set device path.
pub(in crate::api::rpc) async fn handle_mesh_configure(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let mut config = mesh::load_config(&self.config.data_dir).await?;
if let Some(enabled) = params.get("enabled").and_then(|v| v.as_bool()) {
config.enabled = enabled;
}
if let Some(device) = params.get("device_path").and_then(|v| v.as_str()) {
config.device_path = Some(device.to_string());
}
if let Some(channel) = params.get("channel_name").and_then(|v| v.as_str()) {
config.channel_name = Some(channel.to_string());
}
if let Some(broadcast) = params.get("broadcast_identity").and_then(|v| v.as_bool()) {
config.broadcast_identity = broadcast;
}
if let Some(name) = params.get("advert_name").and_then(|v| v.as_str()) {
config.advert_name = Some(name.to_string());
}
mesh::save_config(&self.config.data_dir, &config).await?;
// If we have a running service, update its config
let mut service = self.mesh_service.write().await;
if let Some(svc) = service.as_mut() {
svc.configure(config.clone()).await?;
}
info!("Mesh config updated");
Ok(serde_json::json!({
"configured": true,
"enabled": config.enabled,
"device_path": config.device_path,
}))
}
}
+5
View File
@@ -0,0 +1,5 @@
mod bitcoin_ops;
mod messaging;
mod safety;
mod status;
mod typed_messages;
+222
View File
@@ -0,0 +1,222 @@
use super::super::RpcHandler;
use crate::mesh;
use crate::mesh::message_types::Coordinate;
use anyhow::Result;
use tracing::info;
impl RpcHandler {
/// mesh.outbox — List pending store-and-forward messages.
pub(in crate::api::rpc) async fn handle_mesh_outbox(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let limit = params
.as_ref()
.and_then(|p| p["limit"].as_u64())
.map(|n| n as usize);
// Check if outbox file exists
let outbox = mesh::outbox::MeshOutbox::load(&self.config.data_dir).await?;
let messages = outbox.list(limit).await;
let count = outbox.count().await;
Ok(serde_json::json!({
"messages": messages.iter().map(|m| serde_json::json!({
"id": m.id,
"dest_did": m.dest_did,
"from_did": m.from_did,
"created_at": m.created_at,
"ttl_secs": m.ttl_secs,
"retry_count": m.retry_count,
"relay_hops": m.relay_hops,
"expired": m.is_expired(),
})).collect::<Vec<_>>(),
"count": count,
}))
}
/// mesh.deadman-status — Get dead man's switch status.
pub(in crate::api::rpc) async fn handle_mesh_deadman_status(&self) -> Result<serde_json::Value> {
let service = self.mesh_service.read().await;
let svc = service.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let status = svc.dead_man_switch.status().await;
Ok(serde_json::to_value(status)?)
}
/// mesh.deadman-configure — Configure the dead man's switch.
pub(in crate::api::rpc) async fn handle_mesh_deadman_configure(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let service = self.mesh_service.read().await;
let svc = service.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let mut config = svc.dead_man_switch.get_config().await;
if let Some(enabled) = params.get("enabled").and_then(|v| v.as_bool()) {
config.dead_man_enabled = enabled;
}
if let Some(interval) = params.get("interval_secs").and_then(|v| v.as_u64()) {
if interval < 60 {
anyhow::bail!("Interval must be at least 60 seconds");
}
config.dead_man_interval_secs = interval;
}
if let (Some(lat), Some(lng)) = (
params.get("lat").and_then(|v| v.as_f64()),
params.get("lng").and_then(|v| v.as_f64()),
) {
let label = params.get("label").and_then(|v| v.as_str()).map(|s| s.to_string());
config.last_gps = Some(Coordinate::from_degrees(lat, lng, label));
}
if let Some(contacts) = params.get("contacts").and_then(|v| v.as_array()) {
config.emergency_contacts = contacts
.iter()
.filter_map(|c| c.as_str().map(|s| s.to_string()))
.collect();
}
if let Some(msg) = params.get("custom_message").and_then(|v| v.as_str()) {
config.custom_message = Some(msg.to_string());
}
if let Some(auto_gps) = params.get("auto_gps").and_then(|v| v.as_bool()) {
config.auto_include_gps = auto_gps;
}
svc.dead_man_switch.configure(config).await?;
// Reset timer on configure
svc.dead_man_switch.check_in().await;
let status = svc.dead_man_switch.status().await;
info!("Dead man's switch configured");
Ok(serde_json::to_value(status)?)
}
/// mesh.deadman-checkin — Heartbeat to reset the dead man's switch timer.
pub(in crate::api::rpc) async fn handle_mesh_deadman_checkin(&self) -> Result<serde_json::Value> {
let service = self.mesh_service.read().await;
let svc = service.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
svc.dead_man_check_in().await;
let remaining = svc.dead_man_switch.time_remaining_secs().await;
Ok(serde_json::json!({
"checked_in": true,
"time_remaining_secs": remaining,
}))
}
/// mesh.rotate-prekeys — Force prekey rotation for X3DH.
pub(in crate::api::rpc) async fn handle_mesh_rotate_prekeys(&self) -> Result<serde_json::Value> {
// Load identity signing key
let identity_dir = self.config.data_dir.join("identity");
let node_key_path = identity_dir.join("node_key");
let key_bytes = tokio::fs::read(&node_key_path)
.await
.map_err(|_| anyhow::anyhow!("Node identity not found"))?;
if key_bytes.len() != 32 {
anyhow::bail!("Invalid node key");
}
let mut seed = [0u8; 32];
seed.copy_from_slice(&key_bytes);
let signing_key = ed25519_dalek::SigningKey::from_bytes(&seed);
// Generate new prekey bundle
let (bundle, _secrets) = mesh::x3dh::generate_prekey_bundle(&signing_key, 10)?;
// Save bundle for distribution
let bundle_bytes = mesh::x3dh::encode_bundle(&bundle)?;
let prekey_dir = self.config.data_dir.join("prekeys");
tokio::fs::create_dir_all(&prekey_dir).await?;
tokio::fs::write(prekey_dir.join("bundle.cbor"), &bundle_bytes).await?;
info!(
one_time_keys = bundle.one_time_prekeys.len(),
"Prekey bundle rotated"
);
Ok(serde_json::json!({
"rotated": true,
"signed_prekey_id": bundle.signed_prekey.id,
"one_time_prekeys": bundle.one_time_prekeys.len(),
}))
}
/// mesh.test-send — Send test payloads of various sizes to diagnose radio link.
/// Sends plain text markers that the receiver can count.
pub(in crate::api::rpc) async fn handle_mesh_test_send(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let contact_id = params["contact_id"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("Missing contact_id"))? as u32;
// Test modes: "ping" (small), "medium" (80 bytes), "large" (150 bytes), "chunked" (400 bytes)
let mode = params["mode"].as_str().unwrap_or("ping");
let count = params["count"].as_u64().unwrap_or(3) as usize;
let service = self.mesh_service.read().await;
let svc = service.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let mut sent = 0usize;
let test_id = chrono::Utc::now().timestamp() as u32;
for i in 0..count {
let payload = match mode {
"ping" => format!("MESHTEST:{}:{}:PING", test_id, i),
"medium" => format!("MESHTEST:{}:{}:{}", test_id, i, "X".repeat(60)),
"large" => format!("MESHTEST:{}:{}:{}", test_id, i, "X".repeat(130)),
"chunked" => {
// Send a TypedEnvelope that requires chunking (>140 base64 chars)
let fake_tx = "0".repeat(400); // simulates TX hex
let wire = crate::mesh::bitcoin_relay::build_tx_relay_request(&fake_tx, test_id as u64 + i as u64)?;
// Send via SendRaw which handles base64 + chunking
let peers = svc.peers().await;
if let Some(peer) = peers.iter().find(|p| p.contact_id == contact_id) {
if let Some(ref pk) = peer.pubkey_hex {
if let Ok(pk_bytes) = hex::decode(pk) {
if pk_bytes.len() >= 6 {
let mut prefix = [0u8; 6];
prefix.copy_from_slice(&pk_bytes[..6]);
let _ = svc.shared_state().cmd_tx.send(
crate::mesh::listener::MeshCommand::SendRaw {
dest_pubkey_prefix: prefix,
payload: wire,
},
).await;
sent += 1;
}
}
}
}
// Delay between chunked sends
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
continue;
}
_ => format!("MESHTEST:{}:{}:UNKNOWN", test_id, i),
};
// Send as plain text for ping/medium/large
let _msg = svc.send_message(contact_id, &payload).await?;
sent += 1;
info!(test_id, seq = i, mode, len = payload.len(), "Test message sent");
// Small delay between sends
tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
}
Ok(serde_json::json!({
"test_id": test_id,
"mode": mode,
"sent": sent,
"count": count,
}))
}
}
+121
View File
@@ -0,0 +1,121 @@
use super::super::RpcHandler;
use crate::mesh;
use anyhow::Result;
impl RpcHandler {
/// mesh.status — Get mesh radio status, device info, and peer count.
pub(in crate::api::rpc) async fn handle_mesh_status(&self) -> Result<serde_json::Value> {
let service = self.mesh_service.read().await;
if let Some(svc) = service.as_ref() {
let status = svc.status().await;
Ok(serde_json::to_value(status)?)
} else {
// No service running — return basic config + device detection
let config = mesh::load_config(&self.config.data_dir).await?;
let devices = mesh::detect_devices().await;
Ok(serde_json::json!({
"enabled": config.enabled,
"device_connected": false,
"device_type": "unknown",
"device_path": config.device_path,
"channel_name": config.channel_name.unwrap_or_else(|| "archipelago".to_string()),
"detected_devices": devices,
"peer_count": 0,
"messages_sent": 0,
"messages_received": 0,
}))
}
}
/// mesh.peers — List discovered mesh peers.
pub(in crate::api::rpc) async fn handle_mesh_peers(&self) -> Result<serde_json::Value> {
let service = self.mesh_service.read().await;
if let Some(svc) = service.as_ref() {
let peers = svc.peers().await;
Ok(serde_json::json!({
"peers": peers,
"count": peers.len(),
}))
} else {
Ok(serde_json::json!({
"peers": [],
"count": 0,
}))
}
}
/// mesh.messages — Get recent mesh message history.
pub(in crate::api::rpc) async fn handle_mesh_messages(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let limit = params
.as_ref()
.and_then(|p| p.get("limit"))
.and_then(|v| v.as_u64())
.map(|n| n as usize);
let service = self.mesh_service.read().await;
if let Some(svc) = service.as_ref() {
let messages = svc.messages(limit).await;
Ok(serde_json::json!({
"messages": messages,
"count": messages.len(),
}))
} else {
Ok(serde_json::json!({
"messages": [],
"count": 0,
}))
}
}
/// mesh.session-status — Get ratchet session info for a peer.
pub(in crate::api::rpc) async fn handle_mesh_session_status(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let contact_id = params["contact_id"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("Missing contact_id"))? as u32;
// Look up peer DID from mesh service
let service = self.mesh_service.read().await;
let peer_did = if let Some(svc) = service.as_ref() {
let peers = svc.peers().await;
peers.iter().find(|p| p.contact_id == contact_id).and_then(|p| p.did.clone())
} else {
None
};
if let Some(did) = peer_did {
let session_mgr = mesh::session::SessionManager::new(&self.config.data_dir);
if let Some(info) = session_mgr.session_info(&did).await {
Ok(serde_json::json!({
"has_session": info.has_session,
"forward_secrecy": info.forward_secrecy,
"message_count": info.message_count,
"ratchet_generation": info.ratchet_generation,
"peer_did": did,
}))
} else {
Ok(serde_json::json!({
"has_session": false,
"forward_secrecy": false,
"message_count": 0,
"ratchet_generation": 0,
"peer_did": did,
}))
}
} else {
Ok(serde_json::json!({
"has_session": false,
"forward_secrecy": false,
"message_count": 0,
"ratchet_generation": 0,
"peer_did": null,
}))
}
}
}
@@ -0,0 +1,174 @@
use super::super::RpcHandler;
use crate::mesh::message_types::{
self, AlertPayload, AlertType, Coordinate, InvoicePayload, MeshMessageType, TypedEnvelope,
};
use anyhow::Result;
use tracing::info;
impl RpcHandler {
/// mesh.send-invoice — Create a Lightning invoice and send bolt11 to mesh peer.
pub(in crate::api::rpc) async fn handle_mesh_send_invoice(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let contact_id = params["contact_id"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("Missing contact_id"))? as u32;
let amount_sats = params["amount_sats"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("Missing amount_sats"))?;
let memo = params["memo"].as_str().map(|s| s.to_string());
// Build invoice payload
let invoice = InvoicePayload {
bolt11: format!("lnbc{}n1pjmesh...", amount_sats), // Placeholder — real LND call in Phase 4
amount_sats,
memo: memo.clone(),
payment_hash: None,
};
let payload = message_types::encode_payload(&invoice)?;
let envelope = TypedEnvelope::new(MeshMessageType::Invoice, payload);
let wire = envelope.to_wire()?;
// Send via mesh
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let wire_str = String::from_utf8_lossy(&wire).to_string();
let msg = svc.send_message(contact_id, &wire_str).await?;
info!(contact_id, amount_sats, "Sent invoice over mesh");
Ok(serde_json::json!({
"sent": true,
"message_id": msg.id,
"amount_sats": amount_sats,
"bolt11": invoice.bolt11,
}))
}
/// mesh.send-coordinate — Send GPS coordinates to a mesh peer.
pub(in crate::api::rpc) async fn handle_mesh_send_coordinate(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let contact_id = params["contact_id"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("Missing contact_id"))? as u32;
let lat = params["lat"]
.as_f64()
.ok_or_else(|| anyhow::anyhow!("Missing lat"))?;
let lng = params["lng"]
.as_f64()
.ok_or_else(|| anyhow::anyhow!("Missing lng"))?;
let label = params["label"].as_str().map(|s| s.to_string());
let coord = Coordinate::from_degrees(lat, lng, label);
let payload = message_types::encode_payload(&coord)?;
let envelope = TypedEnvelope::new(MeshMessageType::Coordinate, payload);
let wire = envelope.to_wire()?;
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let wire_str = String::from_utf8_lossy(&wire).to_string();
let msg = svc.send_message(contact_id, &wire_str).await?;
info!(contact_id, "Sent coordinate over mesh");
Ok(serde_json::json!({
"sent": true,
"message_id": msg.id,
"lat": coord.lat,
"lng": coord.lng,
}))
}
/// mesh.send-alert — Send a signed emergency alert over mesh.
pub(in crate::api::rpc) async fn handle_mesh_send_alert(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let message = params["message"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing message"))?;
let alert_type_str = params["alert_type"]
.as_str()
.unwrap_or("status");
let broadcast = params["broadcast"].as_bool().unwrap_or(false);
let alert_type = match alert_type_str {
"emergency" => AlertType::Emergency,
"dead_man" => AlertType::DeadMan,
_ => AlertType::Status,
};
// Optional GPS
let coordinate = if let (Some(lat), Some(lng)) = (
params["lat"].as_f64(),
params["lng"].as_f64(),
) {
Some(Coordinate::from_degrees(lat, lng, None))
} else {
None
};
let alert = AlertPayload {
alert_type,
message: message.to_string(),
coordinate,
};
let payload = message_types::encode_payload(&alert)?;
// Sign the alert with node identity
let (_data, _) = self.state_manager.get_snapshot().await;
let identity_dir = self.config.data_dir.join("identity");
let node_key_path = identity_dir.join("node_key");
let envelope = if node_key_path.exists() {
let key_bytes = tokio::fs::read(&node_key_path).await?;
if key_bytes.len() == 32 {
let mut seed = [0u8; 32];
seed.copy_from_slice(&key_bytes);
let signing_key = ed25519_dalek::SigningKey::from_bytes(&seed);
TypedEnvelope::new_signed(MeshMessageType::Alert, payload, &signing_key)
} else {
TypedEnvelope::new(MeshMessageType::Alert, payload)
}
} else {
TypedEnvelope::new(MeshMessageType::Alert, payload)
};
let wire = envelope.to_wire()?;
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let wire_str = String::from_utf8_lossy(&wire).to_string();
if broadcast {
// Send on channel (all peers)
svc.send_message(0, &wire_str).await?;
info!(alert_type = alert_type_str, "Broadcast alert over mesh");
} else if let Some(contact_id) = params["contact_id"].as_u64() {
svc.send_message(contact_id as u32, &wire_str).await?;
info!(contact_id, alert_type = alert_type_str, "Sent alert to peer");
} else {
anyhow::bail!("Must specify contact_id or broadcast: true");
}
Ok(serde_json::json!({
"sent": true,
"alert_type": alert_type_str,
"signed": envelope.sig.is_some(),
}))
}
}
+16 -39
View File
@@ -39,7 +39,8 @@ use crate::config::Config;
use crate::container::DevContainerOrchestrator;
use crate::monitoring::MetricsStore;
use crate::port_allocator::PortAllocator;
use crate::session::{self, EndpointRateLimiter, LoginRateLimiter, SessionStore, REMEMBER_TTL};
use crate::rate_limit::{EndpointRateLimiter, LoginRateLimiter};
use crate::session::{self, SessionStore, REMEMBER_TTL};
use crate::state::StateManager;
use anyhow::{Context, Result};
use hyper::{Request, Response, StatusCode};
@@ -50,7 +51,7 @@ use middleware::{
UNAUTHENTICATED_METHODS, CACHEABLE_METHODS,
derive_csrf_token, extract_client_ip, extract_cookie, sanitize_error_message,
};
use response::{RpcRequest, RpcResponse, RpcError, ResponseCache};
use response::{RpcRequest, RpcResponse, RpcError, ResponseCache, json_response, cookie_header};
/// Default dev password when no user is set up (matches mock-backend).
pub(crate) const DEV_DEFAULT_PASSWORD: &str = "password123";
@@ -218,7 +219,7 @@ impl RpcHandler {
let secret = SessionStore::load_or_create_remember_secret().await;
let mut mac = match HmacSha256::new_from_slice(&secret) {
Ok(m) => m,
Err(_) => { return Ok(Response::builder().status(500).body(hyper::Body::empty()).unwrap()); }
Err(_) => { return Ok(json_response(StatusCode::INTERNAL_SERVER_ERROR, b"{}")); }
};
mac.update(format!("csrf:{}", token).as_bytes());
match hex::decode(header) {
@@ -273,11 +274,8 @@ impl RpcHandler {
result: Some(cached),
error: None,
};
return Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.body(hyper::Body::from(serde_json::to_string(&rpc_resp)?))
.unwrap());
let body = serde_json::to_vec(&rpc_resp)?;
return Ok(json_response(StatusCode::OK, &body));
}
}
@@ -317,11 +315,7 @@ impl RpcHandler {
let resp_body = serde_json::to_vec(&rpc_resp)
.context("Failed to serialize response")?;
let mut response = Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.body(hyper::Body::from(resp_body))
.unwrap();
let mut response = json_response(StatusCode::OK, &resp_body);
// Post-dispatch: set cookies for auth-related methods
let client_ip = extract_client_ip(&parts.headers);
@@ -349,11 +343,7 @@ impl RpcHandler {
}),
};
let resp_body = serde_json::to_vec(&rpc_resp).unwrap_or_default();
Response::builder()
.status(status)
.header("Content-Type", "application/json")
.body(hyper::Body::from(resp_body))
.unwrap()
json_response(status, &resp_body)
}
/// Build a 429 Too Many Requests response.
@@ -367,12 +357,9 @@ impl RpcHandler {
}),
};
let resp_body = serde_json::to_vec(&rpc_resp).unwrap_or_default();
Response::builder()
.status(StatusCode::TOO_MANY_REQUESTS)
.header("Content-Type", "application/json")
.header("Retry-After", "60")
.body(hyper::Body::from(resp_body))
.unwrap()
let mut resp = json_response(StatusCode::TOO_MANY_REQUESTS, &resp_body);
resp.headers_mut().insert("Retry-After", cookie_header("60"));
resp
}
/// Apply session/CSRF/remember-me cookies after dispatch for auth-related methods.
@@ -471,15 +458,11 @@ impl RpcHandler {
let secure_suffix = if self.config.dev_mode { "" } else { "; Secure" };
response.headers_mut().append(
"Set-Cookie",
format!("session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0{}", secure_suffix)
.parse()
.unwrap(),
cookie_header(&format!("session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0{}", secure_suffix)),
);
response.headers_mut().append(
"Set-Cookie",
format!("csrf_token=; SameSite=Lax; Path=/; Max-Age=0{}", secure_suffix)
.parse()
.unwrap(),
cookie_header(&format!("csrf_token=; SameSite=Lax; Path=/; Max-Age=0{}", secure_suffix)),
);
}
@@ -493,27 +476,21 @@ impl RpcHandler {
fn set_session_cookie(&self, response: &mut Response<hyper::Body>, token: &str) {
response.headers_mut().append(
"Set-Cookie",
format!("session={}; HttpOnly; SameSite=Lax; Path=/{}", token, self.cookie_suffix())
.parse()
.unwrap(),
cookie_header(&format!("session={}; HttpOnly; SameSite=Lax; Path=/{}", token, self.cookie_suffix())),
);
}
fn set_csrf_cookie(&self, response: &mut Response<hyper::Body>, csrf_token: &str) {
response.headers_mut().append(
"Set-Cookie",
format!("csrf_token={}; SameSite=Lax; Path=/{}", csrf_token, self.cookie_suffix())
.parse()
.unwrap(),
cookie_header(&format!("csrf_token={}; SameSite=Lax; Path=/{}", csrf_token, self.cookie_suffix())),
);
}
fn set_remember_cookie(&self, response: &mut Response<hyper::Body>, remember_token: &str) {
response.headers_mut().append(
"Set-Cookie",
format!("remember={}; HttpOnly; SameSite=Lax; Path=/; Max-Age={}{}", remember_token, REMEMBER_TTL, self.cookie_suffix())
.parse()
.unwrap(),
cookie_header(&format!("remember={}; HttpOnly; SameSite=Lax; Path=/; Max-Age={}{}", remember_token, REMEMBER_TTL, self.cookie_suffix())),
);
}
}
@@ -0,0 +1,216 @@
use super::config::get_containers_for_app;
use anyhow::Result;
use tracing::info;
/// Names of container variants that represent a running Bitcoin node
const BITCOIN_NAMES: &[&str] = &["bitcoin-knots", "bitcoin-core", "bitcoin"];
/// Names of container variants that represent a running Electrum indexer
const ELECTRUM_NAMES: &[&str] = &["electrumx", "mempool-electrs", "electrs"];
/// Snapshot of which dependency services are currently running.
pub(super) struct RunningDeps {
pub has_bitcoin: bool,
pub has_electrumx: bool,
pub has_lnd: bool,
}
/// Query podman for currently running containers and return dependency status.
pub(super) async fn detect_running_deps() -> Result<RunningDeps> {
let dep_check = tokio::process::Command::new("podman")
.args(["ps", "--format", "{{.Names}}"])
.output()
.await
.map_err(|e| anyhow::anyhow!("Failed to check running containers: {}", e))?;
let running = String::from_utf8_lossy(&dep_check.stdout);
let is_running = |names: &[&str]| {
running.lines().any(|l| {
let name = l.trim();
names.iter().any(|n| name == *n)
})
};
Ok(RunningDeps {
has_bitcoin: is_running(BITCOIN_NAMES),
has_electrumx: is_running(ELECTRUM_NAMES),
has_lnd: is_running(&["lnd"]),
})
}
/// Verify that required dependency services are running before installing an app.
/// Returns an error with a user-friendly message if dependencies are missing.
pub(super) fn check_install_deps(package_id: &str, deps: &RunningDeps) -> Result<()> {
match package_id {
"electrumx" | "mempool-electrs" | "electrs" if !deps.has_bitcoin => {
Err(anyhow::anyhow!(
"ElectrumX requires a running Bitcoin node (Bitcoin Knots). \
Please install and start Bitcoin Knots first."
))
}
"lnd" if !deps.has_bitcoin => Err(anyhow::anyhow!(
"LND requires a running Bitcoin node (Bitcoin Knots). \
Please install and start Bitcoin Knots first."
)),
"btcpay-server" | "btcpayserver" if !deps.has_bitcoin => Err(anyhow::anyhow!(
"BTCPay Server requires a running Bitcoin node (Bitcoin Knots). \
Please install and start Bitcoin Knots first."
)),
"mempool" | "mempool-web" if !deps.has_bitcoin || !deps.has_electrumx => {
let mut missing = vec![];
if !deps.has_bitcoin {
missing.push("Bitcoin Knots");
}
if !deps.has_electrumx {
missing.push("ElectrumX");
}
Err(anyhow::anyhow!(
"Mempool requires {} to be running. Please install and start {} first.",
missing.join(" and "),
missing.join(" and ")
))
}
"fedimint" if !deps.has_bitcoin => Err(anyhow::anyhow!(
"Fedimint requires a running Bitcoin node (Bitcoin Knots). \
Please install and start Bitcoin Knots first."
)),
_ => Ok(()),
}
}
/// Log informational messages about optional dependencies.
pub(super) fn log_optional_dep_info(package_id: &str, deps: &RunningDeps) {
if matches!(package_id, "btcpay-server" | "btcpayserver") && !deps.has_lnd {
tracing::info!(
"BTCPay Server installing without LND \
— Lightning payments won't be available until LND is installed"
);
}
}
/// Whether an app requires the shared `archy-net` Podman network for
/// inter-container DNS resolution.
pub(super) fn needs_archy_net(package_id: &str) -> bool {
matches!(
package_id,
"bitcoin-knots"
| "bitcoin"
| "bitcoin-core"
| "lnd"
| "mempool"
| "mempool-web"
| "mempool-api"
| "electrumx"
| "mempool-electrs"
| "electrs"
| "mysql-mempool"
| "archy-mempool-db"
| "archy-mempool-web"
| "btcpay-server"
| "btcpayserver"
| "archy-btcpay-db"
| "archy-nbxplorer"
| "nbxplorer"
| "fedimint"
| "fedimint-gateway"
)
}
/// Return the correct startup order for a multi-container app stack.
/// Containers are started in this order to satisfy dependency chains.
pub(super) fn startup_order(package_id: &str) -> &'static [&'static str] {
match package_id {
"mempool" | "mempool-web" => &[
"archy-mempool-db",
"mysql-mempool",
"electrumx",
"mempool-electrs",
"mempool-api",
"archy-mempool-api",
"archy-mempool-web",
"mempool",
],
"immich" => &["immich_postgres", "immich_redis", "immich_server"],
"penpot" | "penpot-frontend" => &[
"penpot-postgres",
"penpot-valkey",
"penpot-backend",
"penpot-exporter",
"penpot-frontend",
],
_ => &[],
}
}
/// Sort a list of container names according to the dependency-aware startup
/// order for the given app. Unknown containers sort to the end.
pub(super) async fn ordered_containers_for_start(
package_id: &str,
) -> Result<Vec<String>> {
let containers = get_containers_for_app(package_id).await?;
if containers.is_empty() {
return Ok(vec![format!("archy-{}", package_id)]);
}
let order = startup_order(package_id);
// If no special order defined, fall back to mempool order (legacy behavior)
let effective_order: &[&str] = if order.is_empty() {
startup_order("mempool")
} else {
order
};
let mut sorted = containers;
sorted.sort_by_key(|c| {
effective_order
.iter()
.position(|o| *o == c)
.unwrap_or(99)
});
Ok(sorted)
}
/// Configure Fedimint Gateway to use LND instead of LDK.
/// Modifies ports, volumes, and command args in place when LND credentials exist.
pub(super) fn configure_fedimint_lnd(
host_ip: &str,
ports: &mut Vec<String>,
volumes: &mut Vec<String>,
custom_args: &mut Option<Vec<String>>,
rpc_user: &str,
rpc_pass: &str,
) {
let lnd_cert = "/var/lib/archipelago/lnd/tls.cert";
let lnd_macaroon =
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
if std::path::Path::new(lnd_cert).exists()
&& std::path::Path::new(lnd_macaroon).exists()
{
info!("LND detected with credentials — configuring gateway in lnd mode");
ports.retain(|p| p != "9737:9737");
volumes.push(format!("{}:/lnd/tls.cert:ro", lnd_cert));
volumes.push(format!("{}:/lnd/admin.macaroon:ro", lnd_macaroon));
*custom_args = Some(vec![
"gatewayd".to_string(),
"--data-dir".to_string(),
"/data".to_string(),
"--listen".to_string(),
"0.0.0.0:8176".to_string(),
"--bcrypt-password-hash".to_string(),
"$2y$10$t9YjjxkiktrlYvjajB/zgOMDnSNVg4HqrbDqh47u7Jf42whNdxNqC".to_string(),
"--network".to_string(),
"bitcoin".to_string(),
"--bitcoind-url".to_string(),
format!("http://{}:8332", host_ip),
"--bitcoind-username".to_string(),
rpc_user.to_string(),
"--bitcoind-password".to_string(),
rpc_pass.to_string(),
"lnd".to_string(),
"--lnd-rpc-host".to_string(),
format!("{}:10009", host_ip),
"--lnd-tls-cert".to_string(),
"/lnd/tls.cert".to_string(),
"--lnd-macaroon".to_string(),
"/lnd/admin.macaroon".to_string(),
]);
}
}
@@ -0,0 +1,467 @@
use super::config::{
get_app_capabilities, get_app_config, get_health_check_args, get_memory_limit,
is_readonly_compatible, is_valid_docker_image,
};
use super::dependencies::{
check_install_deps, configure_fedimint_lnd, detect_running_deps, log_optional_dep_info,
needs_archy_net,
};
use super::progress::parse_pull_progress;
use super::validation::validate_app_id;
use crate::api::rpc::RpcHandler;
use anyhow::{Context, Result};
use tokio::io::{AsyncBufReadExt, BufReader};
use tracing::{debug, info};
impl RpcHandler {
/// Install a package from a Docker image.
/// Security: Image verification, resource limits, network isolation.
pub(in crate::api::rpc) async fn handle_package_install(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let package_id = params
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
validate_app_id(package_id)?;
let docker_image = params
.get("dockerImage")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing dockerImage"))?;
debug!(
"Installing package {} from image {}",
package_id, docker_image
);
if !is_valid_docker_image(docker_image) {
return Err(anyhow::anyhow!("Invalid Docker image format"));
}
// Multi-container stacks get their own install path
if package_id == "immich" {
return self.install_immich_stack().await;
}
if package_id == "penpot" || package_id == "penpot-frontend" {
return self.install_penpot_stack().await;
}
// Dependency checks
let deps = detect_running_deps().await?;
check_install_deps(package_id, &deps)?;
log_optional_dep_info(package_id, &deps);
// Check if container already exists
let check_output = tokio::process::Command::new("podman")
.args([
"ps",
"-a",
"--format",
"{{.Names}}",
"--filter",
&format!("name=^{}$", package_id),
])
.output()
.await
.context("Failed to check existing containers")?;
if !String::from_utf8_lossy(&check_output.stdout)
.trim()
.is_empty()
{
return Err(anyhow::anyhow!(
"Container {} already exists. Stop and remove it first.",
package_id
));
}
// Pull or verify image
let has_local_fallback = self
.pull_or_verify_image(package_id, docker_image)
.await?;
// Normalize container name for legacy aliases
let container_name = match package_id {
"electrs" | "mempool-electrs" => "electrumx",
_ => package_id,
};
// Read Bitcoin RPC credentials for container configs
let (rpc_user, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
// App-specific configuration
let (mut ports, mut volumes, env_vars, custom_command, mut custom_args) = {
let mut allocator = self.port_allocator.lock().await;
get_app_config(
package_id,
&self.config.host_ip,
&mut allocator,
&rpc_user,
&rpc_pass,
)
.await
};
// Fedimint Gateway: auto-detect LND and switch to lnd mode
if package_id == "fedimint-gateway" && deps.has_lnd {
configure_fedimint_lnd(
&self.config.host_ip,
&mut ports,
&mut volumes,
&mut custom_args,
&rpc_user,
&rpc_pass,
);
}
// Build the podman run command
let mut run_args = vec![
"run",
"-d",
"--name",
container_name,
"--restart=unless-stopped",
];
let is_tailscale = package_id == "tailscale";
// Network mode
if is_tailscale {
run_args.push("--network=host");
run_args.push("--privileged");
run_args.push("--cap-add=NET_ADMIN");
run_args.push("--cap-add=NET_RAW");
run_args.push("--device=/dev/net/tun");
} else if needs_archy_net(package_id) {
let _ = tokio::process::Command::new("podman")
.args(["network", "create", "archy-net"])
.output()
.await;
run_args.push("--network=archy-net");
}
// Security hardening (skip for privileged containers)
let security_caps: Vec<String> = if !is_tailscale {
get_app_capabilities(package_id)
} else {
vec![]
};
let readonly_compatible = !is_tailscale && is_readonly_compatible(package_id);
if !is_tailscale {
run_args.push("--cap-drop=ALL");
run_args.push("--security-opt=no-new-privileges:true");
for cap in &security_caps {
run_args.push(cap);
}
if readonly_compatible {
run_args.push("--read-only");
run_args.push("--tmpfs=/tmp:rw,noexec,nosuid,size=256m");
run_args.push("--tmpfs=/run:rw,noexec,nosuid,size=64m");
}
}
// Create data directories
self.create_data_dirs(package_id, &volumes).await;
// Pre-install: bitcoin.conf with rpcauth
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
self.write_bitcoin_conf(&rpc_user, &rpc_pass).await;
}
// Port mappings (skip for host-network containers)
if !is_tailscale {
for port in &ports {
run_args.push("-p");
run_args.push(port);
}
}
// Volume mounts
for volume in &volumes {
run_args.push("-v");
run_args.push(volume);
}
// Environment variables
for env in &env_vars {
run_args.push("-e");
run_args.push(env);
}
// Resource limits
let memory_limit = get_memory_limit(package_id);
let mem_arg = format!("--memory={}", memory_limit);
run_args.push(&mem_arg);
run_args.push("--cpus=2");
// Health checks
let health_args = get_health_check_args(package_id, &rpc_pass);
for arg in &health_args {
run_args.push(arg);
}
// Image — prefer local build over registry
let effective_image = if has_local_fallback {
format!("localhost/{}:latest", package_id)
} else {
docker_image.to_string()
};
run_args.push(&effective_image);
debug!("Running container with args: {:?}", run_args);
// Build command with optional custom command/args
let mut cmd = tokio::process::Command::new("podman");
cmd.args(&run_args);
if let Some(custom_cmd) = custom_command {
cmd.arg(custom_cmd);
} else if let Some(args) = custom_args {
cmd.args(args);
}
let run_output = cmd.output().await.context("Failed to run container")?;
if !run_output.status.success() {
let stderr = String::from_utf8_lossy(&run_output.stderr);
return Err(anyhow::anyhow!("Failed to start container: {}", stderr));
}
let container_id = String::from_utf8_lossy(&run_output.stdout)
.trim()
.to_string();
// Post-install hooks
self.run_post_install_hooks(package_id).await;
Ok(serde_json::json!({
"success": true,
"package_id": package_id,
"container_id": container_id,
"message": format!("Package {} installed and started", package_id)
}))
}
// -- Private helpers for install --
/// Pull the image from a registry or verify a local image exists.
/// Returns `true` if a local fallback image was found (registry pull skipped).
async fn pull_or_verify_image(
&self,
package_id: &str,
docker_image: &str,
) -> Result<bool> {
let is_local_image = docker_image.starts_with("localhost/");
let has_local_fallback = if !is_local_image {
let local_tag = format!("localhost/{}:latest", package_id);
let check = tokio::process::Command::new("podman")
.args(["images", "-q", &local_tag])
.output()
.await
.ok();
check.map_or(false, |o| {
!String::from_utf8_lossy(&o.stdout).trim().is_empty()
})
} else {
false
};
if !is_local_image && !has_local_fallback {
self.pull_image_with_progress(package_id, docker_image)
.await?;
} else if has_local_fallback {
debug!(
"Using local build for {} (skipping registry pull)",
package_id
);
} else {
// Local image — verify it exists
let images_output = tokio::process::Command::new("podman")
.args(["images", "-q", docker_image])
.output()
.await
.context("Failed to check local image")?;
if String::from_utf8_lossy(&images_output.stdout)
.trim()
.is_empty()
{
return Err(anyhow::anyhow!(
"Local image {} not found. Build the image first \
or ensure the registry is reachable.",
docker_image
));
}
debug!("Using local image: {}", docker_image);
}
Ok(has_local_fallback)
}
/// Stream `podman pull` while updating install progress state.
async fn pull_image_with_progress(
&self,
package_id: &str,
docker_image: &str,
) -> Result<()> {
debug!("Pulling image: {}", docker_image);
self.set_install_progress(package_id, 0, 0).await;
let mut child = tokio::process::Command::new("podman")
.args(["pull", docker_image])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.context("Failed to start image pull")?;
if let Some(stderr) = child.stderr.take() {
let reader = BufReader::new(stderr);
let mut lines = reader.lines();
let pkg_id = package_id.to_string();
let state_mgr = self.state_manager.clone();
while let Ok(Some(line)) = lines.next_line().await {
if let Some((downloaded, total)) = parse_pull_progress(&line) {
Self::update_install_progress(&state_mgr, &pkg_id, downloaded, total)
.await;
}
}
}
let status = child
.wait()
.await
.context("Failed to wait for image pull")?;
if !status.success() {
self.clear_install_progress(package_id).await;
return Err(anyhow::anyhow!("Failed to pull image"));
}
self.set_install_progress(package_id, 100, 100).await;
Ok(())
}
/// Create data directories for volume mounts under /var/lib/archipelago/.
async fn create_data_dirs(&self, package_id: &str, volumes: &[String]) {
for volume in volumes {
if let Some(host_path) = volume.split(':').next() {
if host_path.starts_with("/var/lib/archipelago/") {
debug!("Creating directory: {}", host_path);
let create_dir = tokio::process::Command::new("sudo")
.args(["mkdir", "-p", host_path])
.output()
.await;
if let Err(e) = create_dir {
debug!("Failed to create directory {}: {}", host_path, e);
}
// Grafana runs as UID 472 — fix permissions
if package_id == "grafana" && host_path.contains("grafana") {
let _ = tokio::process::Command::new("sudo")
.args(["chown", "-R", "472:472", host_path])
.output()
.await;
}
}
}
}
}
/// Write bitcoin.conf with rpcauth (salted HMAC hash, no plaintext password).
async fn write_bitcoin_conf(&self, rpc_user: &str, rpc_pass: &str) {
let bitcoin_dir = "/var/lib/archipelago/bitcoin";
let conf_path = format!("{}/bitcoin.conf", bitcoin_dir);
use hmac::{Hmac, Mac};
use sha2::Sha256;
let salt_bytes: [u8; 16] = rand::random();
let salt_hex = hex::encode(salt_bytes);
let mut mac = Hmac::<Sha256>::new_from_slice(salt_hex.as_bytes())
.expect("HMAC accepts any key length");
mac.update(rpc_pass.as_bytes());
let hash_hex = hex::encode(mac.finalize().into_bytes());
let rpcauth_line = format!("rpcauth={}:{}${}", rpc_user, salt_hex, hash_hex);
let bitcoin_conf = format!(
"\
# rpcauth: salted hash only — no plaintext password in config or CLI\n\
{}\n\
server=1\n\
prune=550\n\
rpcbind=0.0.0.0\n\
rpcallowip=0.0.0.0/0\n\
rpcport=8332\n\
listen=1\n\
printtoconsole=1\n",
rpcauth_line
);
let _ = tokio::fs::create_dir_all(bitcoin_dir).await;
let _ = tokio::fs::write(&conf_path, bitcoin_conf).await;
info!("Created bitcoin.conf with rpcauth (no plaintext credentials)");
}
/// Run post-install hooks (Nextcloud trusted domains, Bitcoin UI container).
async fn run_post_install_hooks(&self, package_id: &str) {
if package_id == "nextcloud" {
let host_ip = self.config.host_ip.clone();
tokio::spawn(async move {
// Wait for Nextcloud to finish first-run initialization
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
for domain_idx in 1..=2u8 {
let value = if domain_idx == 1 {
host_ip.as_str()
} else {
"localhost"
};
let _ = tokio::process::Command::new("podman")
.args([
"exec",
"-u",
"33",
"nextcloud",
"php",
"occ",
"config:system:set",
"trusted_domains",
&domain_idx.to_string(),
"--value",
value,
])
.output()
.await;
}
info!("Nextcloud trusted domains configured for {}", host_ip);
});
}
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
tokio::spawn(async move {
let ui_dir = "/opt/archipelago/docker/bitcoin-ui";
let _ = tokio::process::Command::new("podman")
.args(["build", "-t", "localhost/bitcoin-ui", ui_dir])
.output()
.await;
let _ = tokio::process::Command::new("podman")
.args(["rm", "-f", "bitcoin-ui"])
.output()
.await;
let _ = tokio::process::Command::new("podman")
.args([
"run",
"-d",
"--name",
"bitcoin-ui",
"--restart=unless-stopped",
"-p",
"8334:80",
"localhost/bitcoin-ui:latest",
])
.output()
.await;
info!("Bitcoin UI container started on port 8334");
});
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,10 @@
mod config;
mod dependencies;
mod install;
mod lifecycle;
mod progress;
mod runtime;
mod stacks;
mod validation;
// Re-export items needed by sibling modules (container.rs, security.rs)
@@ -0,0 +1,141 @@
//! Install progress tracking and podman pull output parsing.
use crate::api::rpc::RpcHandler;
use crate::data_model::{
Description, InstallProgress, Manifest, PackageDataEntry, PackageState, StaticFiles,
};
impl RpcHandler {
/// Set install progress for a package and broadcast the update.
/// Creates a minimal package entry if one doesn't exist yet.
pub(super) async fn set_install_progress(
&self,
package_id: &str,
downloaded: u64,
size: u64,
) {
let (mut data, _rev) = self.state_manager.get_snapshot().await;
let entry = data
.package_data
.entry(package_id.to_string())
.or_insert_with(|| create_installing_entry(package_id));
entry.state = PackageState::Installing;
entry.install_progress = Some(InstallProgress { size, downloaded });
self.state_manager.update_data(data).await;
}
/// Clear install progress after pull completes or fails.
pub(super) async fn clear_install_progress(&self, package_id: &str) {
let (mut data, _rev) = self.state_manager.get_snapshot().await;
if let Some(entry) = data.package_data.get_mut(package_id) {
entry.install_progress = None;
}
self.state_manager.update_data(data).await;
}
/// Update install progress (static method for use in async closures).
pub(super) async fn update_install_progress(
state_manager: &crate::state::StateManager,
package_id: &str,
downloaded: u64,
total: u64,
) {
let (mut data, _rev) = state_manager.get_snapshot().await;
let entry = data
.package_data
.entry(package_id.to_string())
.or_insert_with(|| create_installing_entry(package_id));
entry.install_progress = Some(InstallProgress {
size: total,
downloaded,
});
state_manager.update_data(data).await;
}
}
/// Create a minimal PackageDataEntry for a package being installed.
fn create_installing_entry(package_id: &str) -> PackageDataEntry {
PackageDataEntry {
state: PackageState::Installing,
health: None,
static_files: StaticFiles {
license: String::new(),
instructions: String::new(),
icon: format!("/assets/img/app-icons/{}.png", package_id),
},
manifest: Manifest {
id: package_id.to_string(),
title: package_id.to_string(),
version: String::new(),
description: Description {
short: "Installing...".to_string(),
long: String::new(),
},
release_notes: String::new(),
license: String::new(),
wrapper_repo: String::new(),
upstream_repo: String::new(),
support_site: String::new(),
marketing_site: String::new(),
donation_url: None,
author: None,
website: None,
interfaces: None,
tier: None,
},
installed: None,
install_progress: None,
}
}
/// Parse podman pull progress output.
/// Podman outputs lines like: "Copying blob sha256:abc done | 50.0MiB / 100.0MiB"
/// Returns (downloaded_bytes, total_bytes) if parseable.
pub(super) fn parse_pull_progress(line: &str) -> Option<(u64, u64)> {
let line = line.trim();
let parts: Vec<&str> = line.split('/').collect();
if parts.len() != 2 {
return None;
}
let downloaded = parse_size_value(parts[0].trim())?;
let total = parse_size_value(parts[1].trim())?;
if total > 0 {
Some((downloaded, total))
} else {
None
}
}
/// Parse a size value like "50.0MiB", "1.2GiB", "500KiB" into bytes.
fn parse_size_value(s: &str) -> Option<u64> {
let s = s.trim();
let (num_str, multiplier) = if let Some(pos) = s.rfind("GiB") {
(
s[..pos].trim().split_whitespace().last()?,
1024 * 1024 * 1024,
)
} else if let Some(pos) = s.rfind("MiB") {
(s[..pos].trim().split_whitespace().last()?, 1024 * 1024)
} else if let Some(pos) = s.rfind("KiB") {
(s[..pos].trim().split_whitespace().last()?, 1024)
} else if let Some(pos) = s.rfind("GB") {
(
s[..pos].trim().split_whitespace().last()?,
1_000_000_000,
)
} else if let Some(pos) = s.rfind("MB") {
(s[..pos].trim().split_whitespace().last()?, 1_000_000)
} else if let Some(pos) = s.rfind("KB") {
(s[..pos].trim().split_whitespace().last()?, 1_000)
} else if let Some(pos) = s.rfind('B') {
(s[..pos].trim().split_whitespace().last()?, 1)
} else {
return None;
};
let num: f64 = num_str.parse().ok()?;
Some((num * multiplier as f64) as u64)
}
@@ -0,0 +1,359 @@
use super::config::{get_containers_for_app, get_data_dirs_for_app, is_valid_docker_image};
use super::dependencies::ordered_containers_for_start;
use super::validation::validate_app_id;
use crate::api::rpc::RpcHandler;
use anyhow::{Context, Result};
impl RpcHandler {
/// Start a package: start all containers in dependency order.
pub(in crate::api::rpc) async fn handle_package_start(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let package_id = params
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
validate_app_id(package_id)?;
let to_start = ordered_containers_for_start(package_id).await?;
// Clear user-stopped flag — user explicitly started this app
crate::crash_recovery::clear_user_stopped(&self.config.data_dir, package_id).await;
for name in &to_start {
crate::crash_recovery::clear_user_stopped(&self.config.data_dir, name).await;
}
for name in to_start {
let _ = tokio::process::Command::new("podman")
.args(["start", &name])
.output()
.await;
}
Ok(serde_json::Value::Null)
}
/// Stop a package: mark as user-stopped and stop all containers.
pub(in crate::api::rpc) async fn handle_package_stop(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let package_id = params
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
validate_app_id(package_id)?;
// Mark as user-stopped so health monitor and crash recovery don't auto-restart
crate::crash_recovery::mark_user_stopped(&self.config.data_dir, package_id).await;
let containers = get_containers_for_app(package_id).await?;
if containers.is_empty() {
let container_name = format!("archy-{}", package_id);
crate::crash_recovery::mark_user_stopped(&self.config.data_dir, &container_name)
.await;
let _ = tokio::process::Command::new("podman")
.args(["stop", &container_name])
.output()
.await;
return Ok(serde_json::Value::Null);
}
for name in &containers {
crate::crash_recovery::mark_user_stopped(&self.config.data_dir, name).await;
}
for name in containers {
let _ = tokio::process::Command::new("podman")
.args(["stop", &name])
.output()
.await;
}
Ok(serde_json::Value::Null)
}
/// Restart a package: restart all containers.
pub(in crate::api::rpc) async fn handle_package_restart(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let package_id = params
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
validate_app_id(package_id)?;
let containers = get_containers_for_app(package_id).await?;
if containers.is_empty() {
let container_name = format!("archy-{}", package_id);
let _ = tokio::process::Command::new("podman")
.args(["restart", &container_name])
.output()
.await;
return Ok(serde_json::Value::Null);
}
for name in containers {
let _ = tokio::process::Command::new("podman")
.args(["restart", &name])
.output()
.await;
}
Ok(serde_json::Value::Null)
}
/// Uninstall a package: stop and remove all related containers, clean data.
pub(in crate::api::rpc) async fn handle_package_uninstall(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let package_id = params
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
validate_app_id(package_id)?;
let preserve_data = params
.get("preserve_data")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let containers_to_remove = get_containers_for_app(package_id).await?;
if containers_to_remove.is_empty() {
tracing::warn!("Uninstall {}: no containers found", package_id);
}
let mut stopped = 0u32;
let mut removed = 0u32;
let mut errors = Vec::new();
for name in &containers_to_remove {
tracing::info!("Uninstall {}: stopping container {}", package_id, name);
let stop_out = tokio::process::Command::new("podman")
.args(["stop", "-t", "10", name])
.output()
.await;
match stop_out {
Ok(o) if o.status.success() => stopped += 1,
Ok(o) => {
let stderr = String::from_utf8_lossy(&o.stderr);
tracing::warn!(
"Uninstall {}: stop {} failed: {}",
package_id,
name,
stderr.trim()
);
}
Err(e) => {
tracing::warn!(
"Uninstall {}: stop {} error: {}",
package_id,
name,
e
);
}
}
tracing::info!("Uninstall {}: removing container {}", package_id, name);
let rm_out = tokio::process::Command::new("podman")
.args(["rm", "-f", name])
.output()
.await;
match rm_out {
Ok(o) if o.status.success() => removed += 1,
Ok(o) => {
let stderr = String::from_utf8_lossy(&o.stderr);
let msg = format!("Failed to remove {}: {}", name, stderr.trim());
tracing::error!("Uninstall {}: {}", package_id, msg);
errors.push(msg);
}
Err(e) => {
let msg = format!("Failed to remove {}: {}", name, e);
tracing::error!("Uninstall {}: {}", package_id, msg);
errors.push(msg);
}
}
}
// Release port allocation
{
let mut allocator = self.port_allocator.lock().await;
let _ = allocator.release(package_id).await;
}
// Clean data directories unless preserve_data
if !preserve_data {
let data_dirs = get_data_dirs_for_app(package_id);
for dir in &data_dirs {
tracing::info!("Uninstall {}: removing data {}", package_id, dir);
let rm_out = tokio::process::Command::new("sudo")
.args(["rm", "-rf", dir])
.output()
.await;
if let Ok(o) = rm_out {
if !o.status.success() {
tracing::warn!("Uninstall {}: rm {} failed", package_id, dir);
}
}
}
}
if !errors.is_empty() {
tracing::error!(
"Uninstall {} completed with errors: {:?}",
package_id,
errors
);
} else {
tracing::info!(
"Uninstall {} complete: stopped={}, removed={}",
package_id,
stopped,
removed
);
}
Ok(serde_json::json!({
"status": if errors.is_empty() { "uninstalled" } else { "partial" },
"stopped": stopped,
"removed": removed,
"errors": errors,
}))
}
/// Start a bundled app (create container from pre-loaded image if needed).
pub(in crate::api::rpc) async fn handle_bundled_app_start(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let app_id = params
.get("app_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
validate_app_id(app_id)?;
let image = params
.get("image")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing image"))?;
if !is_valid_docker_image(image) {
return Err(anyhow::anyhow!("Invalid Docker image format"));
}
let ports = params
.get("ports")
.and_then(|v| v.as_array())
.ok_or_else(|| anyhow::anyhow!("Missing ports"))?;
let volumes = params
.get("volumes")
.and_then(|v| v.as_array())
.ok_or_else(|| anyhow::anyhow!("Missing volumes"))?;
let check_output = tokio::process::Command::new("podman")
.args([
"ps",
"-a",
"--format",
"{{.Names}}",
"--filter",
&format!("name={}", app_id),
])
.output()
.await
.context("Failed to check container")?;
let existing = String::from_utf8_lossy(&check_output.stdout);
if existing.trim().is_empty() {
let mut cmd = tokio::process::Command::new("podman");
cmd.args(["run", "-d", "--name", app_id]);
for port in ports {
if let (Some(host), Some(container)) = (
port.get("host").and_then(|v| v.as_u64()),
port.get("container").and_then(|v| v.as_u64()),
) {
cmd.arg("-p").arg(format!("{}:{}", host, container));
}
}
for volume in volumes {
if let (Some(host), Some(container)) = (
volume.get("host").and_then(|v| v.as_str()),
volume.get("container").and_then(|v| v.as_str()),
) {
// Validate host path: must be under /var/lib/archipelago/
if !host.starts_with("/var/lib/archipelago/")
|| host.contains("..")
|| host.contains('\0')
{
return Err(anyhow::anyhow!(
"Volume host path must be under /var/lib/archipelago/ \
and cannot contain path traversal"
));
}
if container.contains("..") || container.contains('\0') {
return Err(anyhow::anyhow!("Invalid container mount path"));
}
let _ = tokio::process::Command::new("sudo")
.args(["mkdir", "-p", host])
.output()
.await;
cmd.arg("-v").arg(format!("{}:{}", host, container));
}
}
cmd.arg(image);
let output = cmd.output().await.context("Failed to create container")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow::anyhow!("Failed to create container: {}", stderr));
}
} else {
let output = tokio::process::Command::new("podman")
.args(["start", app_id])
.output()
.await
.context("Failed to start container")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow::anyhow!("Failed to start container: {}", stderr));
}
}
Ok(serde_json::json!({ "status": "started", "app_id": app_id }))
}
/// Stop a bundled app.
pub(in crate::api::rpc) async fn handle_bundled_app_stop(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let app_id = params
.get("app_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
validate_app_id(app_id)?;
let output = tokio::process::Command::new("podman")
.args(["stop", app_id])
.output()
.await
.context("Failed to stop container")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow::anyhow!("Failed to stop container: {}", stderr));
}
Ok(serde_json::json!({ "status": "stopped", "app_id": app_id }))
}
}
@@ -0,0 +1,335 @@
//! Multi-container app stack installers (Immich, Penpot).
//!
//! Each stack pulls multiple images, creates a private network, and starts
//! containers in dependency order.
use crate::api::rpc::RpcHandler;
use anyhow::{Context, Result};
use tracing::info;
impl RpcHandler {
/// Install Immich stack (postgres + redis + server).
pub(super) async fn install_immich_stack(&self) -> Result<serde_json::Value> {
let check = tokio::process::Command::new("podman")
.args(["ps", "-a", "--format", "{{.Names}}"])
.output()
.await
.context("Failed to list containers")?;
let stdout = String::from_utf8_lossy(&check.stdout);
if stdout.contains("immich_server") {
return Err(anyhow::anyhow!(
"Immich already installed. Stop and remove it first."
));
}
if stdout.contains("immich\n") || stdout.lines().any(|l| l.trim() == "immich") {
let _ = tokio::process::Command::new("podman")
.args(["stop", "immich"])
.output()
.await;
let _ = tokio::process::Command::new("podman")
.args(["rm", "-f", "immich"])
.output()
.await;
}
let images = [
"ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0",
"docker.io/valkey/valkey:7-alpine",
"ghcr.io/immich-app/immich-server:release",
];
for img in &images {
let _ = tokio::process::Command::new("podman")
.args(["pull", img])
.output()
.await;
}
let _ = tokio::process::Command::new("sudo")
.args([
"mkdir",
"-p",
"/var/lib/archipelago/immich",
"/var/lib/archipelago/immich-db",
])
.output()
.await;
let _ = tokio::process::Command::new("podman")
.args(["network", "create", "immich-net"])
.output()
.await;
let _ = tokio::process::Command::new("podman")
.args([
"run",
"-d",
"--name",
"immich_postgres",
"--restart",
"unless-stopped",
"--network",
"immich-net",
"-v",
"/var/lib/archipelago/immich-db:/var/lib/postgresql/data",
"-e",
"POSTGRES_PASSWORD=immichpass",
"-e",
"POSTGRES_USER=postgres",
"-e",
"POSTGRES_DB=immich",
"ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0",
])
.output()
.await;
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
let _ = tokio::process::Command::new("podman")
.args([
"run",
"-d",
"--name",
"immich_redis",
"--restart",
"unless-stopped",
"--network",
"immich-net",
"docker.io/valkey/valkey:7-alpine",
])
.output()
.await;
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let run = tokio::process::Command::new("podman")
.args([
"run",
"-d",
"--name",
"immich_server",
"--restart",
"unless-stopped",
"--network",
"immich-net",
"-p",
"2283:2283",
"-v",
"/var/lib/archipelago/immich:/usr/src/app/upload",
"-e",
"DB_HOSTNAME=immich_postgres",
"-e",
"DB_USERNAME=postgres",
"-e",
"DB_PASSWORD=immichpass",
"-e",
"DB_DATABASE_NAME=immich",
"-e",
"REDIS_HOSTNAME=immich_redis",
"-e",
"UPLOAD_LOCATION=/usr/src/app/upload",
"ghcr.io/immich-app/immich-server:release",
])
.output()
.await
.context("Failed to start immich_server")?;
if !run.status.success() {
let stderr = String::from_utf8_lossy(&run.stderr);
return Err(anyhow::anyhow!(
"Failed to start Immich server: {}",
stderr
));
}
info!("Immich stack installed and started");
Ok(serde_json::json!({
"success": true,
"package_id": "immich",
"message": "Immich stack installed and started"
}))
}
/// Install Penpot stack (postgres + valkey + backend + exporter + frontend).
pub(super) async fn install_penpot_stack(&self) -> Result<serde_json::Value> {
let check = tokio::process::Command::new("podman")
.args(["ps", "-a", "--format", "{{.Names}}"])
.output()
.await
.context("Failed to list containers")?;
let stdout = String::from_utf8_lossy(&check.stdout);
if stdout.contains("penpot-frontend") {
return Err(anyhow::anyhow!(
"Penpot already installed. Stop and remove it first."
));
}
let images = [
"docker.io/postgres:15",
"docker.io/valkey/valkey:8.1",
"docker.io/penpotapp/backend:2.4",
"docker.io/penpotapp/exporter:2.4",
"docker.io/penpotapp/frontend:2.4",
];
for img in &images {
let _ = tokio::process::Command::new("podman")
.args(["pull", img])
.output()
.await;
}
let _ = tokio::process::Command::new("sudo")
.args(["mkdir", "-p", "/var/lib/archipelago/penpot-assets"])
.output()
.await;
let _ = tokio::process::Command::new("podman")
.args(["network", "create", "penpot-net"])
.output()
.await;
// Generate a stable secret key derived from the data directory
let secret = {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(b"penpot-secret-");
hasher.update(self.config.data_dir.to_string_lossy().as_bytes());
hex::encode(hasher.finalize())
};
let host_ip = &self.config.host_ip;
let _ = tokio::process::Command::new("podman")
.args([
"run",
"-d",
"--name",
"penpot-postgres",
"--restart",
"unless-stopped",
"--network",
"penpot-net",
"-v",
"/var/lib/archipelago/penpot-postgres:/var/lib/postgresql/data",
"-e",
"POSTGRES_DB=penpot",
"-e",
"POSTGRES_USER=penpot",
"-e",
"POSTGRES_PASSWORD=penpot",
"docker.io/postgres:15",
])
.output()
.await;
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
let _ = tokio::process::Command::new("podman")
.args([
"run",
"-d",
"--name",
"penpot-valkey",
"--restart",
"unless-stopped",
"--network",
"penpot-net",
"-e",
"VALKEY_EXTRA_FLAGS=--maxmemory 128mb --maxmemory-policy volatile-lfu",
"docker.io/valkey/valkey:8.1",
])
.output()
.await;
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
let _ = tokio::process::Command::new("podman")
.args([
"run",
"-d",
"--name",
"penpot-backend",
"--restart",
"unless-stopped",
"--network",
"penpot-net",
"-v",
"/var/lib/archipelago/penpot-assets:/opt/data/assets",
"-e",
&format!("PENPOT_PUBLIC_URI=http://{}:9001", host_ip),
"-e",
&format!("PENPOT_SECRET_KEY={}", secret),
"-e",
"PENPOT_DATABASE_URI=postgresql://penpot-postgres/penpot",
"-e",
"PENPOT_DATABASE_USERNAME=penpot",
"-e",
"PENPOT_DATABASE_PASSWORD=penpot",
"-e",
"PENPOT_REDIS_URI=redis://penpot-valkey/0",
"-e",
"PENPOT_OBJECTS_STORAGE_BACKEND=fs",
"-e",
"PENPOT_OBJECTS_STORAGE_FS_DIRECTORY=/opt/data/assets",
"-e",
"PENPOT_FLAGS=disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies",
"docker.io/penpotapp/backend:2.4",
])
.output()
.await;
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
let _ = tokio::process::Command::new("podman")
.args([
"run",
"-d",
"--name",
"penpot-exporter",
"--restart",
"unless-stopped",
"--network",
"penpot-net",
"-e",
&format!("PENPOT_SECRET_KEY={}", secret),
"-e",
"PENPOT_PUBLIC_URI=http://penpot-frontend:8080",
"-e",
"PENPOT_REDIS_URI=redis://penpot-valkey/0",
"docker.io/penpotapp/exporter:2.4",
])
.output()
.await;
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let run = tokio::process::Command::new("podman")
.args([
"run",
"-d",
"--name",
"penpot-frontend",
"--restart",
"unless-stopped",
"--network",
"penpot-net",
"-p",
"9001:8080",
"-v",
"/var/lib/archipelago/penpot-assets:/opt/data/assets",
"-e",
&format!("PENPOT_PUBLIC_URI=http://{}:9001", host_ip),
"-e",
"PENPOT_FLAGS=disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies",
"docker.io/penpotapp/frontend:2.4",
])
.output()
.await
.context("Failed to start penpot-frontend")?;
if !run.status.success() {
let stderr = String::from_utf8_lossy(&run.stderr);
return Err(anyhow::anyhow!(
"Failed to start Penpot frontend: {}",
stderr
));
}
info!("Penpot stack installed and started");
Ok(serde_json::json!({
"success": true,
"package_id": "penpot",
"message": "Penpot stack installed and started"
}))
}
}
+17
View File
@@ -1,3 +1,4 @@
use hyper::{Response, StatusCode};
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize)]
@@ -48,3 +49,19 @@ impl ResponseCache {
entries.insert(key, (std::time::Instant::now(), value));
}
}
/// Build a JSON HTTP response without unwrap. Falls back to a plain 500 if builder fails.
pub(super) fn json_response(status: StatusCode, body: &[u8]) -> Response<hyper::Body> {
Response::builder()
.status(status)
.header("Content-Type", "application/json")
.body(hyper::Body::from(body.to_vec()))
.unwrap_or_else(|_| {
Response::new(hyper::Body::from(r#"{"error":{"code":500,"message":"Internal error"}}"#))
})
}
/// Parse a Set-Cookie header value, returning a default if parsing fails.
pub(super) fn cookie_header(value: &str) -> hyper::header::HeaderValue {
value.parse().unwrap_or_else(|_| hyper::header::HeaderValue::from_static(""))
}
@@ -0,0 +1,316 @@
use super::*;
use crate::api::rpc::RpcHandler;
use anyhow::{Context, Result};
use tracing::{debug, info};
impl RpcHandler {
/// server.set-name — Rename the server (persisted to data_dir/server-name)
pub(in crate::api::rpc) async fn handle_server_set_name(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let name = params
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: name"))?
.trim()
.to_string();
if name.is_empty() || name.len() > 64 {
anyhow::bail!("Name must be 1-64 characters");
}
// Persist to file
let name_file = self.config.data_dir.join("server-name");
tokio::fs::write(&name_file, &name)
.await
.context("Failed to write server name")?;
// Update live state
let (mut data, _) = self.state_manager.get_snapshot().await;
data.server_info.name = Some(name.clone());
self.state_manager.update_data(data).await;
info!("Server name updated to: {}", name);
// Push the new name to federation peers in background
let data_dir = self.config.data_dir.clone();
let state_manager = self.state_manager.clone();
tokio::spawn(async move {
if let Err(e) = push_name_to_peers(&data_dir, &state_manager).await {
debug!("Federation name push (non-fatal): {}", e);
}
});
Ok(serde_json::json!({ "name": name }))
}
/// system.stats — CPU usage, RAM used/total, disk used/total, uptime, load average
pub(in crate::api::rpc) async fn handle_system_stats(&self) -> Result<serde_json::Value> {
debug!("Getting system stats");
let uptime = read_uptime().await.unwrap_or(0.0);
let load = read_loadavg().await.unwrap_or((0.0, 0.0, 0.0));
let cpu = read_cpu_usage().await.unwrap_or(0.0);
let (mem_used, mem_total) = read_meminfo().await.unwrap_or((0, 0));
let (disk_used, disk_total) = read_disk_usage().await.unwrap_or((0, 0));
Ok(serde_json::json!({
"uptime_secs": uptime as u64,
"load_avg_1": load.0,
"load_avg_5": load.1,
"load_avg_15": load.2,
"cpu_usage_percent": cpu,
"mem_used_bytes": mem_used,
"mem_total_bytes": mem_total,
"disk_used_bytes": disk_used,
"disk_total_bytes": disk_total,
}))
}
/// system.processes — top 10 processes by CPU
pub(in crate::api::rpc) async fn handle_system_processes(&self) -> Result<serde_json::Value> {
debug!("Getting top processes");
let procs = read_top_processes().await.unwrap_or_default();
Ok(serde_json::json!({ "processes": procs }))
}
/// system.temperature — thermal zone readings
pub(in crate::api::rpc) async fn handle_system_temperature(&self) -> Result<serde_json::Value> {
debug!("Getting system temperature");
let temps = read_temperatures().await.unwrap_or_default();
Ok(serde_json::json!({ "temperatures": temps }))
}
/// system.detect-usb-devices — scan for known hardware wallet USB devices
pub(in crate::api::rpc) async fn handle_system_detect_usb_devices(&self) -> Result<serde_json::Value> {
debug!("Scanning for USB hardware wallets");
let devices = detect_usb_hardware_wallets().await.unwrap_or_default();
Ok(serde_json::json!({ "devices": devices }))
}
/// system.disk-status — Disk usage with warning/critical thresholds.
pub(in crate::api::rpc) async fn handle_system_disk_status(&self) -> Result<serde_json::Value> {
let (used, total) = read_disk_usage().await.unwrap_or((0, 0));
let percent = if total > 0 {
(used as f64 / total as f64) * 100.0
} else {
0.0
};
let percent_rounded = (percent * 10.0).round() / 10.0;
let level = if percent >= 90.0 {
"critical"
} else if percent >= 85.0 {
"warning"
} else {
"ok"
};
Ok(serde_json::json!({
"used_bytes": used,
"total_bytes": total,
"free_bytes": total.saturating_sub(used),
"used_percent": percent_rounded,
"level": level,
}))
}
/// system.disk-cleanup — Remove old container images, stale logs, and temp files.
pub(in crate::api::rpc) async fn handle_system_disk_cleanup(&self) -> Result<serde_json::Value> {
tracing::info!("Starting disk cleanup");
let mut freed_bytes: u64 = 0;
let mut actions: Vec<String> = Vec::new();
// 1. Prune dangling container images
match prune_container_images().await {
Ok(bytes) => {
if bytes > 0 {
freed_bytes += bytes;
actions.push(format!("Pruned dangling images: {} freed", format_bytes(bytes)));
}
}
Err(e) => actions.push(format!("Image prune failed: {}", e)),
}
// 2. Clean old log files (> 30 days)
match clean_old_logs(30).await {
Ok(bytes) => {
if bytes > 0 {
freed_bytes += bytes;
actions.push(format!("Cleaned old logs: {} freed", format_bytes(bytes)));
}
}
Err(e) => actions.push(format!("Log cleanup failed: {}", e)),
}
// 3. Remove stale temp files
match clean_temp_files().await {
Ok(bytes) => {
if bytes > 0 {
freed_bytes += bytes;
actions.push(format!("Removed temp files: {} freed", format_bytes(bytes)));
}
}
Err(e) => actions.push(format!("Temp cleanup failed: {}", e)),
}
// 4. Prune container build cache
match prune_build_cache().await {
Ok(bytes) => {
if bytes > 0 {
freed_bytes += bytes;
actions.push(format!("Pruned build cache: {} freed", format_bytes(bytes)));
}
}
Err(e) => actions.push(format!("Build cache prune failed: {}", e)),
}
tracing::info!("Disk cleanup complete: {} freed ({} actions)", format_bytes(freed_bytes), actions.len());
Ok(serde_json::json!({
"freed_bytes": freed_bytes,
"freed_human": format_bytes(freed_bytes),
"actions": actions,
}))
}
}
impl RpcHandler {
/// system.factory-reset — Wipe all user data, remove containers, and restart.
/// Only preserves the data_dir itself (recreated empty on restart).
/// system.reboot — Reboot the machine. Requires password re-verification.
pub(in crate::api::rpc) async fn handle_system_reboot(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let password = params
.as_ref()
.and_then(|p| p.get("password"))
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing password — re-authentication required"))?;
let valid = self.auth_manager.verify_password(password).await?;
if !valid {
return Err(anyhow::anyhow!("Password incorrect"));
}
info!("System reboot initiated by user");
// Schedule reboot in 2 seconds (gives time for the RPC response to reach the client)
// Uses the tor-helper path unit pattern (writes action file, systemd triggers root service)
tokio::spawn(async {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let action = serde_json::json!({"action": "reboot"});
let _ = tokio::fs::write(
"/var/lib/archipelago/tor-config/tor-action",
serde_json::to_string(&action).unwrap_or_default(),
).await;
});
Ok(serde_json::json!({ "rebooting": true }))
}
pub(in crate::api::rpc) async fn handle_system_factory_reset(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
// Safety check: require { confirm: true }
let confirmed = params
.as_ref()
.and_then(|p| p.get("confirm"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
if !confirmed {
anyhow::bail!("Factory reset requires {{ \"confirm\": true }}");
}
// Require password re-authentication for destructive operations
let password = params
.as_ref()
.and_then(|p| p.get("password"))
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing password — re-authentication required"))?;
let valid = self.auth_manager.verify_password(password).await?;
if !valid {
return Err(anyhow::anyhow!("Password Incorrect"));
}
tracing::warn!("Factory reset initiated — wiping ALL user data and containers");
let data_dir = &self.config.data_dir;
// 1. Stop and remove ALL containers (force)
let client = archipelago_container::PodmanClient::new("archipelago".to_string());
if let Ok(containers) = client.list_containers().await {
for c in &containers {
tracing::info!("Factory reset: removing container {}", c.name);
let _ = client.stop_container(&c.name).await;
let _ = client.remove_container(&c.name).await;
}
}
// 2. Remove all container images
tracing::info!("Factory reset: pruning all container images");
let _ = tokio::process::Command::new("podman")
.args(["rmi", "--all", "--force"])
.output()
.await;
// 3. Prune volumes and build cache
let _ = tokio::process::Command::new("podman")
.args(["volume", "prune", "-f"])
.output()
.await;
let _ = tokio::process::Command::new("podman")
.args(["system", "prune", "-af"])
.output()
.await;
// 4. Wipe the entire data directory contents
// Delete everything inside data_dir, then recreate the empty dir.
if let Ok(mut entries) = tokio::fs::read_dir(data_dir).await {
while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path();
let name = entry.file_name();
let name_str = name.to_string_lossy();
// Skip the tor directory (managed by system debian-tor user)
if name_str == "tor" {
continue;
}
tracing::info!("Factory reset: removing {}", path.display());
if path.is_dir() {
let _ = tokio::fs::remove_dir_all(&path).await;
} else {
let _ = tokio::fs::remove_file(&path).await;
}
}
}
// 5. Clear all sessions
self.session_store.invalidate_all_except("").await;
tracing::warn!("Factory reset complete — all data wiped, restarting service");
// Restart the service via systemd
tokio::spawn(async {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let _ = std::process::Command::new("sudo")
.args(["systemctl", "restart", "archipelago"])
.spawn();
});
Ok(serde_json::json!({ "status": "resetting" }))
}
}
@@ -1,189 +1,10 @@
use super::RpcHandler;
mod handlers;
use anyhow::{Context, Result};
use tracing::{debug, info};
impl RpcHandler {
/// server.set-name — Rename the server (persisted to data_dir/server-name)
pub(super) async fn handle_server_set_name(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let name = params
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: name"))?
.trim()
.to_string();
if name.is_empty() || name.len() > 64 {
anyhow::bail!("Name must be 1-64 characters");
}
// Persist to file
let name_file = self.config.data_dir.join("server-name");
tokio::fs::write(&name_file, &name)
.await
.context("Failed to write server name")?;
// Update live state
let (mut data, _) = self.state_manager.get_snapshot().await;
data.server_info.name = Some(name.clone());
self.state_manager.update_data(data).await;
info!("Server name updated to: {}", name);
// Push the new name to federation peers in background
let data_dir = self.config.data_dir.clone();
let state_manager = self.state_manager.clone();
tokio::spawn(async move {
if let Err(e) = push_name_to_peers(&data_dir, &state_manager).await {
debug!("Federation name push (non-fatal): {}", e);
}
});
Ok(serde_json::json!({ "name": name }))
}
/// system.stats — CPU usage, RAM used/total, disk used/total, uptime, load average
pub(super) async fn handle_system_stats(&self) -> Result<serde_json::Value> {
debug!("Getting system stats");
let uptime = read_uptime().await.unwrap_or(0.0);
let load = read_loadavg().await.unwrap_or((0.0, 0.0, 0.0));
let cpu = read_cpu_usage().await.unwrap_or(0.0);
let (mem_used, mem_total) = read_meminfo().await.unwrap_or((0, 0));
let (disk_used, disk_total) = read_disk_usage().await.unwrap_or((0, 0));
Ok(serde_json::json!({
"uptime_secs": uptime as u64,
"load_avg_1": load.0,
"load_avg_5": load.1,
"load_avg_15": load.2,
"cpu_usage_percent": cpu,
"mem_used_bytes": mem_used,
"mem_total_bytes": mem_total,
"disk_used_bytes": disk_used,
"disk_total_bytes": disk_total,
}))
}
/// system.processes — top 10 processes by CPU
pub(super) async fn handle_system_processes(&self) -> Result<serde_json::Value> {
debug!("Getting top processes");
let procs = read_top_processes().await.unwrap_or_default();
Ok(serde_json::json!({ "processes": procs }))
}
/// system.temperature — thermal zone readings
pub(super) async fn handle_system_temperature(&self) -> Result<serde_json::Value> {
debug!("Getting system temperature");
let temps = read_temperatures().await.unwrap_or_default();
Ok(serde_json::json!({ "temperatures": temps }))
}
/// system.detect-usb-devices — scan for known hardware wallet USB devices
pub(super) async fn handle_system_detect_usb_devices(&self) -> Result<serde_json::Value> {
debug!("Scanning for USB hardware wallets");
let devices = detect_usb_hardware_wallets().await.unwrap_or_default();
Ok(serde_json::json!({ "devices": devices }))
}
/// system.disk-status — Disk usage with warning/critical thresholds.
pub(super) async fn handle_system_disk_status(&self) -> Result<serde_json::Value> {
let (used, total) = read_disk_usage().await.unwrap_or((0, 0));
let percent = if total > 0 {
(used as f64 / total as f64) * 100.0
} else {
0.0
};
let percent_rounded = (percent * 10.0).round() / 10.0;
let level = if percent >= 90.0 {
"critical"
} else if percent >= 85.0 {
"warning"
} else {
"ok"
};
Ok(serde_json::json!({
"used_bytes": used,
"total_bytes": total,
"free_bytes": total.saturating_sub(used),
"used_percent": percent_rounded,
"level": level,
}))
}
/// system.disk-cleanup — Remove old container images, stale logs, and temp files.
pub(super) async fn handle_system_disk_cleanup(&self) -> Result<serde_json::Value> {
tracing::info!("Starting disk cleanup");
let mut freed_bytes: u64 = 0;
let mut actions: Vec<String> = Vec::new();
// 1. Prune dangling container images
match prune_container_images().await {
Ok(bytes) => {
if bytes > 0 {
freed_bytes += bytes;
actions.push(format!("Pruned dangling images: {} freed", format_bytes(bytes)));
}
}
Err(e) => actions.push(format!("Image prune failed: {}", e)),
}
// 2. Clean old log files (> 30 days)
match clean_old_logs(30).await {
Ok(bytes) => {
if bytes > 0 {
freed_bytes += bytes;
actions.push(format!("Cleaned old logs: {} freed", format_bytes(bytes)));
}
}
Err(e) => actions.push(format!("Log cleanup failed: {}", e)),
}
// 3. Remove stale temp files
match clean_temp_files().await {
Ok(bytes) => {
if bytes > 0 {
freed_bytes += bytes;
actions.push(format!("Removed temp files: {} freed", format_bytes(bytes)));
}
}
Err(e) => actions.push(format!("Temp cleanup failed: {}", e)),
}
// 4. Prune container build cache
match prune_build_cache().await {
Ok(bytes) => {
if bytes > 0 {
freed_bytes += bytes;
actions.push(format!("Pruned build cache: {} freed", format_bytes(bytes)));
}
}
Err(e) => actions.push(format!("Build cache prune failed: {}", e)),
}
tracing::info!("Disk cleanup complete: {} freed ({} actions)", format_bytes(freed_bytes), actions.len());
Ok(serde_json::json!({
"freed_bytes": freed_bytes,
"freed_human": format_bytes(freed_bytes),
"actions": actions,
}))
}
}
/// Push the server name to all federation peers by syncing state.
async fn push_name_to_peers(
pub(super) async fn push_name_to_peers(
data_dir: &std::path::Path,
state_manager: &std::sync::Arc<crate::state::StateManager>,
) -> Result<()> {
@@ -221,7 +42,7 @@ async fn push_name_to_peers(
}
/// Read system uptime from /proc/uptime (seconds since boot).
async fn read_uptime() -> Result<f64> {
pub(super) async fn read_uptime() -> Result<f64> {
let content = tokio::fs::read_to_string("/proc/uptime")
.await
.context("Failed to read /proc/uptime")?;
@@ -235,7 +56,7 @@ async fn read_uptime() -> Result<f64> {
}
/// Read load averages from /proc/loadavg.
async fn read_loadavg() -> Result<(f64, f64, f64)> {
pub(super) async fn read_loadavg() -> Result<(f64, f64, f64)> {
let content = tokio::fs::read_to_string("/proc/loadavg")
.await
.context("Failed to read /proc/loadavg")?;
@@ -259,7 +80,7 @@ async fn read_loadavg() -> Result<(f64, f64, f64)> {
}
/// Compute CPU usage by sampling /proc/stat twice with a 250ms gap.
async fn read_cpu_usage() -> Result<f64> {
pub(super) async fn read_cpu_usage() -> Result<f64> {
let snap1 = read_cpu_jiffies().await?;
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
let snap2 = read_cpu_jiffies().await?;
@@ -304,7 +125,7 @@ async fn read_cpu_jiffies() -> Result<CpuJiffies> {
/// Read memory info from /proc/meminfo.
/// Returns (used_bytes, total_bytes).
async fn read_meminfo() -> Result<(u64, u64)> {
pub(super) async fn read_meminfo() -> Result<(u64, u64)> {
let content = tokio::fs::read_to_string("/proc/meminfo")
.await
.context("Failed to read /proc/meminfo")?;
@@ -325,7 +146,7 @@ async fn read_meminfo() -> Result<(u64, u64)> {
Ok((used_bytes, total_bytes))
}
fn parse_meminfo_kb(val: &str) -> Result<u64> {
pub(super) fn parse_meminfo_kb(val: &str) -> Result<u64> {
val.trim()
.trim_end_matches("kB")
.trim()
@@ -335,7 +156,7 @@ fn parse_meminfo_kb(val: &str) -> Result<u64> {
/// Read disk usage via `df` for the root filesystem.
/// Returns (used_bytes, total_bytes).
async fn read_disk_usage() -> Result<(u64, u64)> {
pub(super) async fn read_disk_usage() -> Result<(u64, u64)> {
let output = tokio::process::Command::new("df")
.args(["--block-size=1", "--output=used,size", "/"])
.output()
@@ -368,7 +189,7 @@ async fn read_disk_usage() -> Result<(u64, u64)> {
}
/// Read top 10 processes by CPU from `ps`.
async fn read_top_processes() -> Result<Vec<serde_json::Value>> {
pub(super) async fn read_top_processes() -> Result<Vec<serde_json::Value>> {
let output = tokio::process::Command::new("ps")
.args(["--no-headers", "-eo", "pid,%cpu,%mem,comm", "--sort=-%cpu"])
.output()
@@ -410,7 +231,7 @@ const KNOWN_HW_WALLETS: &[(u16, &str)] = &[
];
/// Scan /sys/bus/usb/devices/ for known hardware wallet vendor IDs.
async fn detect_usb_hardware_wallets() -> Result<Vec<serde_json::Value>> {
pub(super) async fn detect_usb_hardware_wallets() -> Result<Vec<serde_json::Value>> {
let usb_dir = std::path::Path::new("/sys/bus/usb/devices");
if !usb_dir.exists() {
return Ok(Vec::new());
@@ -472,7 +293,7 @@ async fn detect_usb_hardware_wallets() -> Result<Vec<serde_json::Value>> {
/// Prune dangling container images via `podman image prune -f`.
/// Returns estimated bytes freed.
async fn prune_container_images() -> Result<u64> {
pub(super) async fn prune_container_images() -> Result<u64> {
let output = tokio::process::Command::new("podman")
.args(["image", "prune", "-f"])
.output()
@@ -493,7 +314,7 @@ async fn prune_container_images() -> Result<u64> {
}
/// Prune container build cache via `podman system prune -f`.
async fn prune_build_cache() -> Result<u64> {
pub(super) async fn prune_build_cache() -> Result<u64> {
// Just prune volumes and build cache (not containers or images — those are handled above)
let output = tokio::process::Command::new("podman")
.args(["volume", "prune", "-f"])
@@ -514,7 +335,7 @@ async fn prune_build_cache() -> Result<u64> {
}
/// Clean log files older than `max_age_days` from common log directories.
async fn clean_old_logs(max_age_days: u64) -> Result<u64> {
pub(super) async fn clean_old_logs(max_age_days: u64) -> Result<u64> {
let output = tokio::process::Command::new("sudo")
.args([
"find",
@@ -554,7 +375,7 @@ async fn clean_old_logs(max_age_days: u64) -> Result<u64> {
}
/// Remove stale temp files from /tmp and /var/tmp.
async fn clean_temp_files() -> Result<u64> {
pub(super) async fn clean_temp_files() -> Result<u64> {
let mut freed = 0u64;
for dir in &["/tmp", "/var/tmp"] {
@@ -582,7 +403,7 @@ async fn clean_temp_files() -> Result<u64> {
Ok(freed)
}
fn format_bytes(bytes: u64) -> String {
pub(super) fn format_bytes(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
@@ -599,7 +420,7 @@ fn format_bytes(bytes: u64) -> String {
}
/// Read temperatures from /sys/class/thermal/thermal_zone*/temp.
async fn read_temperatures() -> Result<Vec<serde_json::Value>> {
pub(super) async fn read_temperatures() -> Result<Vec<serde_json::Value>> {
let mut temps = Vec::new();
let thermal_dir = std::path::Path::new("/sys/class/thermal");
if !thermal_dir.exists() {
@@ -638,135 +459,3 @@ async fn read_temperatures() -> Result<Vec<serde_json::Value>> {
Ok(temps)
}
impl RpcHandler {
/// system.factory-reset — Wipe all user data, remove containers, and restart.
/// Only preserves the data_dir itself (recreated empty on restart).
/// system.reboot — Reboot the machine. Requires password re-verification.
pub(super) async fn handle_system_reboot(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let password = params
.as_ref()
.and_then(|p| p.get("password"))
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing password — re-authentication required"))?;
let valid = self.auth_manager.verify_password(password).await?;
if !valid {
return Err(anyhow::anyhow!("Password incorrect"));
}
info!("System reboot initiated by user");
// Schedule reboot in 2 seconds (gives time for the RPC response to reach the client)
// Uses the tor-helper path unit pattern (writes action file, systemd triggers root service)
tokio::spawn(async {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let action = serde_json::json!({"action": "reboot"});
let _ = tokio::fs::write(
"/var/lib/archipelago/tor-config/tor-action",
serde_json::to_string(&action).unwrap_or_default(),
).await;
});
Ok(serde_json::json!({ "rebooting": true }))
}
pub(super) async fn handle_system_factory_reset(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
// Safety check: require { confirm: true }
let confirmed = params
.as_ref()
.and_then(|p| p.get("confirm"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
if !confirmed {
anyhow::bail!("Factory reset requires {{ \"confirm\": true }}");
}
// Require password re-authentication for destructive operations
let password = params
.as_ref()
.and_then(|p| p.get("password"))
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing password — re-authentication required"))?;
let valid = self.auth_manager.verify_password(password).await?;
if !valid {
return Err(anyhow::anyhow!("Password Incorrect"));
}
tracing::warn!("Factory reset initiated — wiping ALL user data and containers");
let data_dir = &self.config.data_dir;
// 1. Stop and remove ALL containers (force)
let client = archipelago_container::PodmanClient::new("archipelago".to_string());
if let Ok(containers) = client.list_containers().await {
for c in &containers {
tracing::info!("Factory reset: removing container {}", c.name);
let _ = client.stop_container(&c.name).await;
let _ = client.remove_container(&c.name).await;
}
}
// 2. Remove all container images
tracing::info!("Factory reset: pruning all container images");
let _ = tokio::process::Command::new("podman")
.args(["rmi", "--all", "--force"])
.output()
.await;
// 3. Prune volumes and build cache
let _ = tokio::process::Command::new("podman")
.args(["volume", "prune", "-f"])
.output()
.await;
let _ = tokio::process::Command::new("podman")
.args(["system", "prune", "-af"])
.output()
.await;
// 4. Wipe the entire data directory contents
// Delete everything inside data_dir, then recreate the empty dir.
if let Ok(mut entries) = tokio::fs::read_dir(data_dir).await {
while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path();
let name = entry.file_name();
let name_str = name.to_string_lossy();
// Skip the tor directory (managed by system debian-tor user)
if name_str == "tor" {
continue;
}
tracing::info!("Factory reset: removing {}", path.display());
if path.is_dir() {
let _ = tokio::fs::remove_dir_all(&path).await;
} else {
let _ = tokio::fs::remove_file(&path).await;
}
}
}
// 5. Clear all sessions
self.session_store.invalidate_all_except("").await;
tracing::warn!("Factory reset complete — all data wiped, restarting service");
// Restart the service via systemd
tokio::spawn(async {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let _ = std::process::Command::new("sudo")
.args(["systemctl", "restart", "archipelago"])
.spawn();
});
Ok(serde_json::json!({ "status": "resetting" }))
}
}
-833
View File
@@ -1,833 +0,0 @@
use super::RpcHandler;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use tracing::{debug, info, warn};
use crate::{federation, identity};
const TOR_DATA_DIR: &str = "/var/lib/archipelago/tor";
const SERVICES_CONFIG: &str = "services.json";
/// How long old service directories are kept during transition (seconds).
const ROTATION_TRANSITION_SECS: u64 = 86400; // 24 hours
#[derive(Debug, Clone, Serialize, Deserialize)]
struct TorService {
name: String,
local_port: u16,
onion_address: Option<String>,
enabled: bool,
/// True = direct port access without auth. False = through nginx with login.
unauthenticated: bool,
/// True if this is a protocol service (not HTTP), auth not applicable.
protocol: bool,
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct ServicesConfig {
services: Vec<TorServiceEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct TorServiceEntry {
name: String,
local_port: u16,
#[serde(default)]
remote_port: Option<u16>,
#[serde(default = "default_true")]
enabled: bool,
/// If true, routes directly to app port (no nginx auth).
/// Default false = routes through nginx with session auth.
#[serde(default)]
unauthenticated: bool,
}
fn default_true() -> bool {
true
}
impl RpcHandler {
/// List all configured hidden services with their .onion addresses.
pub(super) async fn handle_tor_list_services(
&self,
) -> Result<serde_json::Value> {
let config_dir = self.config.data_dir.join("tor-config");
let services = list_services(&config_dir).await?;
let tor_running = check_tor_running().await;
Ok(serde_json::json!({ "services": services, "tor_running": tor_running }))
}
/// Create a new hidden service for a given local port.
pub(super) async fn handle_tor_create_service(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let name = params
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing name"))?;
let raw_port = params
.get("local_port")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u16;
let remote_port = params
.get("remote_port")
.and_then(|v| v.as_u64())
.map(|v| v as u16);
validate_service_name(name)?;
// Auto-detect port from known services if not specified
let local_port = if raw_port == 0 {
let detected = known_service_port(name);
if detected == 0 {
return Err(anyhow::anyhow!("Unknown app '{}' — specify local_port manually", name));
}
detected
} else {
raw_port
};
let config_dir = self.config.data_dir.join("tor-config");
let mut config = load_services_config(&config_dir).await;
if config.services.iter().any(|s| s.name == name) {
return Err(anyhow::anyhow!("Service '{}' already exists", name));
}
// Protocol services are inherently unauthenticated (not HTTP).
// Web apps default to authenticated (routed through nginx).
let is_proto = is_protocol_service(name);
config.services.push(TorServiceEntry {
name: name.to_string(),
local_port,
remote_port,
unauthenticated: is_proto,
enabled: true,
});
save_services_config(&config_dir, &config).await?;
// Regenerate torrc and restart Tor so the new service is live
regenerate_torrc(&config).await?;
restart_tor().await?;
// Wait for the hostname to appear
let onion = wait_for_hostname(name, 60).await;
if let Some(ref addr) = onion {
sync_single_hostname(name, addr).await;
}
info!(service = name, port = local_port, "Created Tor hidden service");
Ok(serde_json::json!({
"created": true,
"name": name,
"onion_address": onion,
}))
}
/// Delete a hidden service.
pub(super) async fn handle_tor_delete_service(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let name = params
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing name"))?;
validate_service_name(name)?;
// Don't allow deleting the node's own service
if name == "archipelago" {
return Err(anyhow::anyhow!("Cannot delete the node's own Tor service"));
}
let config_dir = self.config.data_dir.join("tor-config");
let mut config = load_services_config(&config_dir).await;
let before = config.services.len();
config.services.retain(|s| s.name != name);
if config.services.len() == before {
return Err(anyhow::anyhow!("Service '{}' not found", name));
}
save_services_config(&config_dir, &config).await?;
// Remove hidden service directory via helper
delete_hidden_service_dir(name).await;
// Regenerate torrc and restart Tor
regenerate_torrc(&config).await?;
restart_tor().await?;
info!(service = name, "Deleted Tor hidden service");
Ok(serde_json::json!({ "deleted": true, "name": name }))
}
/// Get the .onion address for a specific service.
pub(super) async fn handle_tor_get_onion_address(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let name = params
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing name"))?;
validate_service_name(name)?;
let onion = read_onion_address(name);
Ok(serde_json::json!({ "name": name, "onion_address": onion }))
}
/// Rotate a hidden service's .onion address by generating a new keypair.
/// Renames the old hidden service directory (preserving keys during transition),
/// lets Tor create a new one with fresh keys, then schedules cleanup of the old
/// directory after 1 hour.
pub(super) async fn handle_tor_rotate_service(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let name = params
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing name"))?;
validate_service_name(name)?;
let old_onion = read_onion_address(name);
if old_onion.is_none() {
return Err(anyhow::anyhow!("Service '{}' has no .onion address to rotate", name));
}
// Rename old service directory to a timestamped backup instead of deleting
// immediately. The cleanup handler removes these after ROTATION_TRANSITION_SECS.
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
rename_hidden_service_dir(name, timestamp).await;
info!(
service = name,
old_onion = ?old_onion,
"Renamed old Tor service dir — restarting Tor to generate new keypair"
);
// Tor restart will create a fresh hidden_service_{name} directory with new keys
restart_tor().await?;
// Wait up to 60s for new hostname file to appear
let new_onion = wait_for_hostname(name, 60).await;
if let Some(ref new_addr) = new_onion {
sync_single_hostname(name, new_addr).await;
}
// Schedule deletion of old service directory after 1 hour transition period
let old_name = format!("{}_old_{}", name, timestamp);
tokio::spawn(async move {
tokio::time::sleep(tokio::time::Duration::from_secs(3600)).await;
info!(old_dir = %old_name, "Transition period elapsed — deleting old Tor service dir");
delete_hidden_service_dir(&old_name).await;
});
// Notify federation peers of address change (private peer-to-peer, no public relays)
if let Some(ref new_addr) = new_onion {
let data_dir = self.config.data_dir.clone();
let tor_proxy = self.config.nostr_tor_proxy.clone();
let new_addr_clone = new_addr.clone();
let old_onion_clone = old_onion.clone();
tokio::spawn(async move {
notify_federation_peers_address_change(
&data_dir,
&new_addr_clone,
old_onion_clone.as_deref(),
tor_proxy.as_deref(),
).await;
});
}
Ok(serde_json::json!({
"rotated": true,
"name": name,
"old_onion": old_onion,
"new_onion": new_onion,
}))
}
/// Clean up expired rotated service directories past the transition period.
pub(super) async fn handle_tor_cleanup_rotated(
&self,
) -> Result<serde_json::Value> {
let base = detect_hidden_service_base();
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let mut cleaned = Vec::new();
if let Ok(entries) = std::fs::read_dir(&base) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if !name.contains("_old_") {
continue;
}
if let Some(ts_str) = name.rsplit('_').next() {
if let Ok(ts) = ts_str.parse::<u64>() {
if now - ts > ROTATION_TRANSITION_SECS {
let path = entry.path();
let status = tokio::process::Command::new("sudo")
.args(["rm", "-rf", &path.to_string_lossy()])
.status()
.await;
if status.map(|s| s.success()).unwrap_or(false) {
info!(dir = %name, "Cleaned up expired rotated Tor service");
cleaned.push(name);
} else {
warn!(dir = %name, "Failed to clean up rotated Tor service");
}
}
}
}
}
}
Ok(serde_json::json!({ "cleaned": cleaned, "count": cleaned.len() }))
}
/// Toggle Tor access for a specific app (enable/disable).
pub(super) async fn handle_tor_toggle_app(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let app_id = params
.get("app_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
validate_service_name(app_id)?;
let enabled = params
.get("enabled")
.and_then(|v| v.as_bool())
.ok_or_else(|| anyhow::anyhow!("Missing enabled (bool)"))?;
let config_dir = self.config.data_dir.join("tor-config");
let mut config = load_services_config(&config_dir).await;
let found = config.services.iter_mut().find(|s| s.name == app_id);
match found {
Some(entry) => {
if entry.enabled == enabled {
return Ok(serde_json::json!({
"app_id": app_id,
"enabled": enabled,
"changed": false,
}));
}
entry.enabled = enabled;
}
None => {
if !enabled {
return Ok(serde_json::json!({
"app_id": app_id,
"enabled": false,
"changed": false,
}));
}
let port = known_service_port(app_id);
let is_proto = is_protocol_service(app_id);
config.services.push(TorServiceEntry {
name: app_id.to_string(),
local_port: port,
remote_port: None,
unauthenticated: is_proto,
enabled: true,
});
}
}
save_services_config(&config_dir, &config).await?;
if !enabled {
delete_hidden_service_dir(app_id).await;
info!(app = app_id, "Disabled Tor access — removed hidden service dir");
}
// Regenerate torrc and restart Tor
regenerate_torrc(&config).await?;
restart_tor().await?;
let new_onion = if enabled {
let onion = wait_for_hostname(app_id, 60).await;
if let Some(ref addr) = onion {
sync_single_hostname(app_id, addr).await;
}
onion
} else {
let hostnames_dir = self.config.data_dir.join("tor-hostnames");
let _ = tokio::fs::remove_file(hostnames_dir.join(app_id)).await;
None
};
Ok(serde_json::json!({
"app_id": app_id,
"enabled": enabled,
"changed": true,
"onion_address": new_onion,
}))
}
/// Restart Tor daemon (system or container).
pub(super) async fn handle_tor_restart(
&self,
) -> Result<serde_json::Value> {
info!("Manual Tor restart requested");
// Before restarting, sync the torrc from services.json
let config_dir = self.config.data_dir.join("tor-config");
let config = load_services_config(&config_dir).await;
regenerate_torrc(&config).await?;
restart_tor().await?;
// Give Tor a moment to stabilize, then sync all hostnames
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
sync_all_hostname_copies(&config).await;
let running = check_tor_running().await;
Ok(serde_json::json!({ "restarted": true, "tor_running": running }))
}
}
// ─── Validation ───────────────────────────────────────────────────
fn validate_service_name(name: &str) -> Result<()> {
if name.is_empty() || name.len() > 64
|| !name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
return Err(anyhow::anyhow!("Invalid service name (alphanumeric, hyphens, underscores only)"));
}
Ok(())
}
// ─── Tor Daemon Control ──────────────────────────────────────────
//
// The backend runs under NoNewPrivileges=yes (systemd hardening), so it
// cannot call sudo. Privileged Tor operations use an action-file pattern:
//
// 1. Backend writes JSON to /var/lib/archipelago/tor-config/tor-action
// 2. systemd path unit (archipelago-tor-helper.path) detects the file
// 3. systemd oneshot service runs tor-helper.sh as root
// 4. tor-helper.sh processes the action and writes a result file
// 5. Backend polls for the result file
//
// Install: deploy script creates the path unit, service unit, and helper script.
const TOR_ACTION_FILE: &str = "/var/lib/archipelago/tor-config/tor-action";
const TOR_RESULT_FILE: &str = "/var/lib/archipelago/tor-config/tor-result";
/// Write an action file and wait for the tor-helper service to process it.
async fn dispatch_tor_action(action: serde_json::Value) -> Result<()> {
// Remove stale result
let _ = tokio::fs::remove_file(TOR_RESULT_FILE).await;
// Write the action file (triggers the systemd path unit)
let content = serde_json::to_string(&action).context("Failed to serialize tor action")?;
let config_dir = Path::new(TOR_ACTION_FILE).parent().unwrap();
tokio::fs::create_dir_all(config_dir).await.ok();
tokio::fs::write(TOR_ACTION_FILE, &content)
.await
.context("Failed to write tor-action file")?;
// Poll for result (up to 90s — Tor restart + hostname generation can be slow)
for _ in 0..90 {
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
if let Ok(result_str) = tokio::fs::read_to_string(TOR_RESULT_FILE).await {
let _ = tokio::fs::remove_file(TOR_RESULT_FILE).await;
if let Ok(result) = serde_json::from_str::<serde_json::Value>(&result_str) {
if result.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) {
return Ok(());
}
let err = result.get("error").and_then(|v| v.as_str()).unwrap_or("Unknown error");
return Err(anyhow::anyhow!("Tor helper: {}", err));
}
return Ok(());
}
}
Err(anyhow::anyhow!("Tor helper timed out — is archipelago-tor-helper.path enabled?"))
}
/// Delete a hidden service directory via tor-helper.
async fn delete_hidden_service_dir(name: &str) {
if let Err(e) = dispatch_tor_action(serde_json::json!({
"action": "delete-service",
"name": name,
})).await {
warn!("Failed to delete hidden service dir for {}: {}", name, e);
}
}
/// Rename a hidden service directory to a timestamped backup via tor-helper.
/// The old directory becomes `hidden_service_{name}_old_{timestamp}`.
async fn rename_hidden_service_dir(name: &str, timestamp: u64) {
if let Err(e) = dispatch_tor_action(serde_json::json!({
"action": "rename-service",
"name": name,
"timestamp": timestamp,
})).await {
warn!("Failed to rename hidden service dir for {}: {}", name, e);
}
}
/// Write staged torrc and restart Tor.
async fn restart_tor() -> Result<()> {
dispatch_tor_action(serde_json::json!({
"action": "write-torrc-and-restart",
})).await
}
/// Check if Tor SOCKS port is responding.
async fn check_tor_running() -> bool {
tokio::net::TcpStream::connect("127.0.0.1:9050")
.await
.is_ok()
}
// ─── torrc Generation ────────────────────────────────────────────
/// Detect which base directory has existing hidden services.
fn detect_hidden_service_base() -> String {
// Check /var/lib/tor first (system Tor default, AppArmor-safe)
if Path::new("/var/lib/tor/hidden_service_archipelago").exists() {
return "/var/lib/tor".to_string();
}
// Fall back to custom dir
let custom = tor_data_dir();
if Path::new(&custom).join("hidden_service_archipelago").exists() {
return custom;
}
// Default to system Tor
"/var/lib/tor".to_string()
}
/// Regenerate /etc/tor/torrc from services config.
/// Only enabled services get HiddenServiceDir entries.
/// Uses a helper script to write to root-owned paths (avoids NoNewPrivileges issues).
async fn regenerate_torrc(config: &ServicesConfig) -> Result<()> {
let base = detect_hidden_service_base();
let mut lines = Vec::new();
lines.push("# Auto-generated by Archipelago — do not edit manually".to_string());
lines.push("SocksPort 9050".to_string());
lines.push("# ControlPort disabled for security".to_string());
lines.push(String::new());
for svc in &config.services {
if !svc.enabled {
continue;
}
let dir = format!("{}/hidden_service_{}", base, svc.name);
lines.push(format!("HiddenServiceDir {}", dir));
if is_protocol_service(&svc.name) {
// Protocol: direct port mapping (Bitcoin P2P, LND, Electrumx)
let remote_port = svc.remote_port.unwrap_or(svc.local_port);
lines.push(format!("HiddenServicePort {} 127.0.0.1:{}", remote_port, svc.local_port));
if svc.name == "lnd" {
lines.push("HiddenServicePort 9735 127.0.0.1:9735".to_string());
lines.push("HiddenServicePort 10009 127.0.0.1:10009".to_string());
}
} else {
// Web app: map port 80 → local port (access via app.onion without specifying port)
lines.push(format!("HiddenServicePort 80 127.0.0.1:{}", svc.local_port));
}
lines.push(String::new());
}
let content = lines.join("\n");
// Write to staging file (owned by archipelago user, readable by tor-helper)
let staging = "/var/lib/archipelago/tor-config/torrc.staged";
let config_dir = Path::new(staging).parent().unwrap();
tokio::fs::create_dir_all(config_dir).await.ok();
tokio::fs::write(staging, &content).await.context("Failed to write staged torrc")?;
debug!("Staged torrc with {} enabled services",
config.services.iter().filter(|s| s.enabled).count());
Ok(())
}
// ─── Hostname Sync ───────────────────────────────────────────────
/// Copy a single hostname to the readable tor-hostnames directory.
async fn sync_single_hostname(name: &str, address: &str) {
let hostnames_dir = Path::new("/var/lib/archipelago/tor-hostnames");
if let Err(e) = tokio::fs::create_dir_all(hostnames_dir).await {
warn!("Failed to create tor-hostnames dir: {}", e);
return;
}
if let Err(e) = tokio::fs::write(hostnames_dir.join(name), address).await {
warn!("Failed to write tor-hostname copy for {}: {}", name, e);
}
}
/// Sync all hostname copies from hidden service dirs.
async fn sync_all_hostname_copies(config: &ServicesConfig) {
for svc in &config.services {
if !svc.enabled {
continue;
}
if let Some(addr) = read_onion_address(&svc.name) {
sync_single_hostname(&svc.name, &addr).await;
}
}
}
// ─── Service Listing ─────────────────────────────────────────────
/// List all hidden services by scanning the filesystem and merging with config.
async fn list_services(config_dir: &std::path::Path) -> Result<Vec<TorService>> {
let base = detect_hidden_service_base();
let config = load_services_config(config_dir).await;
let mut services = Vec::new();
let mut seen = std::collections::HashSet::new();
for entry in &config.services {
let onion = read_onion_address(&entry.name);
seen.insert(entry.name.clone());
services.push(TorService {
name: entry.name.clone(),
local_port: entry.local_port,
onion_address: onion,
enabled: entry.enabled,
unauthenticated: entry.unauthenticated,
protocol: is_protocol_service(&entry.name),
});
}
// Scan filesystem for any hidden_service_* dirs not in config
for scan_dir in ["/var/lib/tor", &base] {
if let Ok(entries) = std::fs::read_dir(scan_dir) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with("hidden_service_")
&& !name.contains("_old_")
&& entry.file_type().map(|t| t.is_dir()).unwrap_or(false)
{
let service_name = name.strip_prefix("hidden_service_").unwrap_or(&name).to_string();
if seen.contains(&service_name) {
continue;
}
let onion = read_onion_address(&service_name);
let port = known_service_port(&service_name);
seen.insert(service_name.clone());
let is_proto = is_protocol_service(&service_name);
services.push(TorService {
name: service_name,
local_port: port,
onion_address: onion,
enabled: true,
unauthenticated: is_proto, // protocol services are inherently unauthenticated
protocol: is_proto,
});
}
}
}
}
Ok(services)
}
// ─── Onion Address Reading ───────────────────────────────────────
/// Read .onion address from hostname file.
/// Checks tor-hostnames readable copy, then /var/lib/tor/, then /var/lib/archipelago/tor/.
fn read_onion_address(service_name: &str) -> Option<String> {
// Try readable hostname copy first (system Tor owns hidden_service dirs at 0700)
let hostnames_path = Path::new("/var/lib/archipelago/tor-hostnames").join(service_name);
if let Some(addr) = read_and_validate_onion(&hostnames_path) {
return Some(addr);
}
// Check both /var/lib/tor/ and /var/lib/archipelago/tor/
let base = tor_data_dir();
for search_base in &["/var/lib/tor", base.as_str()] {
let path = Path::new(search_base)
.join(format!("hidden_service_{}", service_name))
.join("hostname");
// Try direct read first
if let Some(addr) = read_and_validate_onion(&path) {
return Some(addr);
}
// Try sudo cat (hidden service dirs are 0700 owned by debian-tor)
if let Some(addr) = sudo_read_and_validate_onion(&path) {
return Some(addr);
}
}
None
}
fn read_and_validate_onion(path: &Path) -> Option<String> {
std::fs::read_to_string(path)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| is_valid_v3_onion(s))
}
fn sudo_read_and_validate_onion(path: &Path) -> Option<String> {
std::process::Command::new("sudo")
.args(["cat", &path.to_string_lossy()])
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.filter(|s| is_valid_v3_onion(s))
}
/// Validate v3 onion address: exactly 56 base32 chars + ".onion"
fn is_valid_v3_onion(s: &str) -> bool {
s.len() == 62
&& s.ends_with(".onion")
&& !s.contains(':')
&& s[..56].chars().all(|c| c.is_ascii_alphanumeric())
}
// ─── Known Ports ─────────────────────────────────────────────────
/// Known default ports for built-in and common services.
fn known_service_port(name: &str) -> u16 {
match name {
"archipelago" => 80,
"bitcoin" | "bitcoin-knots" => 8333,
"electrs" | "electrumx" => 50001,
"lnd" => 8080,
"btcpay" | "btcpay-server" | "btcpayserver" => 23000,
"mempool" => 4080,
"fedimint" => 8175,
"nostr-relay" | "nostr-rs-relay" => 8080,
"searxng" => 8888,
"ollama" => 11434,
"filebrowser" => 8083,
"grafana" => 3000,
"home-assistant" => 8123,
"immich" => 2283,
"photoprism" => 2342,
"penpot" => 9001,
"nginx-proxy-manager" => 81,
"vaultwarden" => 8343,
"indeedhub" => 7777,
_ => 0,
}
}
/// Returns true if the service is a non-HTTP protocol (auth not applicable).
/// These get direct port mapping in torrc. Web apps route through nginx.
fn is_protocol_service(name: &str) -> bool {
matches!(name, "bitcoin" | "bitcoin-knots" | "electrs" | "electrumx" | "lnd")
}
// ─── Config I/O ──────────────────────────────────────────────────
fn tor_data_dir() -> String {
std::env::var("TOR_DATA_DIR").unwrap_or_else(|_| TOR_DATA_DIR.to_string())
}
async fn load_services_config(config_dir: &std::path::Path) -> ServicesConfig {
let path = config_dir.join(SERVICES_CONFIG);
match tokio::fs::read_to_string(&path).await {
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
Err(_) => ServicesConfig::default(),
}
}
async fn save_services_config(config_dir: &std::path::Path, config: &ServicesConfig) -> Result<()> {
tokio::fs::create_dir_all(config_dir).await.context("Failed to create tor config dir")?;
let path = config_dir.join(SERVICES_CONFIG);
let content = serde_json::to_string_pretty(config).context("Failed to serialize services config")?;
tokio::fs::write(&path, content).await.context("Failed to write services config")?;
Ok(())
}
// ─── Federation Notification ─────────────────────────────────────
/// Notify federation peers of address change (private peer-to-peer only, never public relays).
async fn notify_federation_peers_address_change(
data_dir: &std::path::Path,
new_onion: &str,
old_onion: Option<&str>,
tor_proxy: Option<&str>,
) {
let identity_dir = data_dir.join("identity");
match identity::NodeIdentity::load_or_create(&identity_dir).await {
Ok(node_id) => {
let did = match node_id.did_key() {
Ok(d) => d,
Err(e) => {
tracing::warn!("Failed to derive DID key: {}", e);
return;
}
};
let proxy = tor_proxy.unwrap_or("127.0.0.1:9050");
match federation::load_nodes(data_dir).await {
Ok(peers) => {
for peer in peers {
if peer.onion.is_empty() {
continue;
}
let payload = serde_json::json!({
"method": "federation.peer-address-changed",
"params": {
"did": did,
"new_onion": new_onion,
"old_onion": old_onion,
}
});
let url = format!("http://{}/rpc/v1", &peer.onion);
let client = match reqwest::Client::builder()
.proxy(match reqwest::Proxy::all(format!("socks5h://{}", proxy))
.or_else(|_| reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY)) {
Ok(p) => p,
Err(_) => continue,
})
.timeout(std::time::Duration::from_secs(30))
.build()
{
Ok(c) => c,
Err(_) => continue,
};
match client.post(&url).json(&payload).send().await {
Ok(_) => info!(peer_did = %peer.did, "Notified peer of address change"),
Err(e) => warn!(peer_did = %peer.did, "Failed to notify peer: {}", e),
}
}
}
Err(e) => warn!("Failed to load federation peers: {}", e),
}
}
Err(e) => warn!("Failed to load node identity for propagation: {}", e),
}
}
// ─── Hostname Waiting ────────────────────────────────────────────
/// Wait for a hostname file to appear after Tor restart (up to max_secs).
async fn wait_for_hostname(service_name: &str, max_secs: u64) -> Option<String> {
for _ in 0..max_secs {
if let Some(addr) = read_onion_address(service_name) {
return Some(addr);
}
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
}
warn!(service = service_name, "Timed out waiting for new .onion hostname");
None
}
@@ -0,0 +1,339 @@
use super::*;
use crate::api::rpc::RpcHandler;
use std::time::{SystemTime, UNIX_EPOCH};
impl RpcHandler {
/// List all configured hidden services with their .onion addresses.
pub(in crate::api::rpc) async fn handle_tor_list_services(
&self,
) -> Result<serde_json::Value> {
let config_dir = self.config.data_dir.join("tor-config");
let services = list_services(&config_dir).await?;
let tor_running = check_tor_running().await;
Ok(serde_json::json!({ "services": services, "tor_running": tor_running }))
}
/// Create a new hidden service for a given local port.
pub(in crate::api::rpc) async fn handle_tor_create_service(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let name = params
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing name"))?;
let raw_port = params
.get("local_port")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u16;
let remote_port = params
.get("remote_port")
.and_then(|v| v.as_u64())
.map(|v| v as u16);
validate_service_name(name)?;
let local_port = if raw_port == 0 {
let detected = known_service_port(name);
if detected == 0 {
return Err(anyhow::anyhow!("Unknown app '{}' — specify local_port manually", name));
}
detected
} else {
raw_port
};
let config_dir = self.config.data_dir.join("tor-config");
let mut config = load_services_config(&config_dir).await;
if config.services.iter().any(|s| s.name == name) {
return Err(anyhow::anyhow!("Service '{}' already exists", name));
}
let is_proto = is_protocol_service(name);
config.services.push(TorServiceEntry {
name: name.to_string(),
local_port,
remote_port,
unauthenticated: is_proto,
enabled: true,
});
save_services_config(&config_dir, &config).await?;
regenerate_torrc(&config).await?;
restart_tor().await?;
let onion = wait_for_hostname(name, 60).await;
if let Some(ref addr) = onion {
sync_single_hostname(name, addr).await;
}
info!(service = name, port = local_port, "Created Tor hidden service");
Ok(serde_json::json!({
"created": true,
"name": name,
"onion_address": onion,
}))
}
/// Delete a hidden service.
pub(in crate::api::rpc) async fn handle_tor_delete_service(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let name = params
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing name"))?;
validate_service_name(name)?;
if name == "archipelago" {
return Err(anyhow::anyhow!("Cannot delete the node's own Tor service"));
}
let config_dir = self.config.data_dir.join("tor-config");
let mut config = load_services_config(&config_dir).await;
let before = config.services.len();
config.services.retain(|s| s.name != name);
if config.services.len() == before {
return Err(anyhow::anyhow!("Service '{}' not found", name));
}
save_services_config(&config_dir, &config).await?;
delete_hidden_service_dir(name).await;
regenerate_torrc(&config).await?;
restart_tor().await?;
info!(service = name, "Deleted Tor hidden service");
Ok(serde_json::json!({ "deleted": true, "name": name }))
}
/// Get the .onion address for a specific service.
pub(in crate::api::rpc) async fn handle_tor_get_onion_address(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let name = params
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing name"))?;
validate_service_name(name)?;
let onion = read_onion_address(name).await;
Ok(serde_json::json!({ "name": name, "onion_address": onion }))
}
/// Rotate a hidden service's .onion address by generating a new keypair.
pub(in crate::api::rpc) async fn handle_tor_rotate_service(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let name = params
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing name"))?;
validate_service_name(name)?;
let old_onion = read_onion_address(name).await;
if old_onion.is_none() {
return Err(anyhow::anyhow!("Service '{}' has no .onion address to rotate", name));
}
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
rename_hidden_service_dir(name, timestamp).await;
info!(
service = name,
old_onion = ?old_onion,
"Renamed old Tor service dir — restarting Tor to generate new keypair"
);
restart_tor().await?;
let new_onion = wait_for_hostname(name, 60).await;
if let Some(ref new_addr) = new_onion {
sync_single_hostname(name, new_addr).await;
}
let old_name = format!("{}_old_{}", name, timestamp);
tokio::spawn(async move {
tokio::time::sleep(tokio::time::Duration::from_secs(3600)).await;
info!(old_dir = %old_name, "Transition period elapsed — deleting old Tor service dir");
delete_hidden_service_dir(&old_name).await;
});
if let Some(ref new_addr) = new_onion {
let data_dir = self.config.data_dir.clone();
let tor_proxy = self.config.nostr_tor_proxy.clone();
let new_addr_clone = new_addr.clone();
let old_onion_clone = old_onion.clone();
tokio::spawn(async move {
notify_federation_peers_address_change(
&data_dir,
&new_addr_clone,
old_onion_clone.as_deref(),
tor_proxy.as_deref(),
).await;
});
}
Ok(serde_json::json!({
"rotated": true,
"name": name,
"old_onion": old_onion,
"new_onion": new_onion,
}))
}
/// Clean up expired rotated service directories past the transition period.
pub(in crate::api::rpc) async fn handle_tor_cleanup_rotated(
&self,
) -> Result<serde_json::Value> {
let base = detect_hidden_service_base();
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let mut cleaned = Vec::new();
if let Ok(mut entries) = tokio::fs::read_dir(&base).await {
while let Ok(Some(entry)) = entries.next_entry().await {
let name = entry.file_name().to_string_lossy().to_string();
if !name.contains("_old_") {
continue;
}
if let Some(ts_str) = name.rsplit('_').next() {
if let Ok(ts) = ts_str.parse::<u64>() {
if now - ts > ROTATION_TRANSITION_SECS {
let path = entry.path();
let status = tokio::process::Command::new("sudo")
.args(["rm", "-rf", &path.to_string_lossy()])
.status()
.await;
if status.map(|s| s.success()).unwrap_or(false) {
info!(dir = %name, "Cleaned up expired rotated Tor service");
cleaned.push(name);
} else {
warn!(dir = %name, "Failed to clean up rotated Tor service");
}
}
}
}
}
}
Ok(serde_json::json!({ "cleaned": cleaned, "count": cleaned.len() }))
}
/// Toggle Tor access for a specific app (enable/disable).
pub(in crate::api::rpc) async fn handle_tor_toggle_app(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let app_id = params
.get("app_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
validate_service_name(app_id)?;
let enabled = params
.get("enabled")
.and_then(|v| v.as_bool())
.ok_or_else(|| anyhow::anyhow!("Missing enabled (bool)"))?;
let config_dir = self.config.data_dir.join("tor-config");
let mut config = load_services_config(&config_dir).await;
let found = config.services.iter_mut().find(|s| s.name == app_id);
match found {
Some(entry) => {
if entry.enabled == enabled {
return Ok(serde_json::json!({
"app_id": app_id,
"enabled": enabled,
"changed": false,
}));
}
entry.enabled = enabled;
}
None => {
if !enabled {
return Ok(serde_json::json!({
"app_id": app_id,
"enabled": false,
"changed": false,
}));
}
let port = known_service_port(app_id);
let is_proto = is_protocol_service(app_id);
config.services.push(TorServiceEntry {
name: app_id.to_string(),
local_port: port,
remote_port: None,
unauthenticated: is_proto,
enabled: true,
});
}
}
save_services_config(&config_dir, &config).await?;
if !enabled {
delete_hidden_service_dir(app_id).await;
info!(app = app_id, "Disabled Tor access — removed hidden service dir");
}
regenerate_torrc(&config).await?;
restart_tor().await?;
let new_onion = if enabled {
let onion = wait_for_hostname(app_id, 60).await;
if let Some(ref addr) = onion {
sync_single_hostname(app_id, addr).await;
}
onion
} else {
let hostnames_dir = self.config.data_dir.join("tor-hostnames");
let _ = tokio::fs::remove_file(hostnames_dir.join(app_id)).await;
None
};
Ok(serde_json::json!({
"app_id": app_id,
"enabled": enabled,
"changed": true,
"onion_address": new_onion,
}))
}
/// Restart Tor daemon (system or container).
pub(in crate::api::rpc) async fn handle_tor_restart(
&self,
) -> Result<serde_json::Value> {
info!("Manual Tor restart requested");
let config_dir = self.config.data_dir.join("tor-config");
let config = load_services_config(&config_dir).await;
regenerate_torrc(&config).await?;
restart_tor().await?;
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
sync_all_hostname_copies(&config).await;
let running = check_tor_running().await;
Ok(serde_json::json!({ "restarted": true, "tor_running": running }))
}
}
+430
View File
@@ -0,0 +1,430 @@
mod handlers;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
use tracing::{debug, info, warn};
use crate::{federation, identity};
pub(super) const TOR_DATA_DIR: &str = "/var/lib/archipelago/tor";
pub(super) const SERVICES_CONFIG: &str = "services.json";
/// How long old service directories are kept during transition (seconds).
pub(super) const ROTATION_TRANSITION_SECS: u64 = 86400; // 24 hours
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(super) struct TorService {
pub name: String,
pub local_port: u16,
pub onion_address: Option<String>,
pub enabled: bool,
pub unauthenticated: bool,
pub protocol: bool,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub(super) struct ServicesConfig {
pub services: Vec<TorServiceEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(super) struct TorServiceEntry {
pub name: String,
pub local_port: u16,
#[serde(default)]
pub remote_port: Option<u16>,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub unauthenticated: bool,
}
fn default_true() -> bool {
true
}
// ─── Validation ───────────────────────────────────────────────────
pub(super) fn validate_service_name(name: &str) -> Result<()> {
if name.is_empty() || name.len() > 64
|| !name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
return Err(anyhow::anyhow!("Invalid service name (alphanumeric, hyphens, underscores only)"));
}
Ok(())
}
// ─── Tor Daemon Control ──────────────────────────────────────────
const TOR_ACTION_FILE: &str = "/var/lib/archipelago/tor-config/tor-action";
const TOR_RESULT_FILE: &str = "/var/lib/archipelago/tor-config/tor-result";
/// Write an action file and wait for the tor-helper service to process it.
pub(super) async fn dispatch_tor_action(action: serde_json::Value) -> Result<()> {
let _ = tokio::fs::remove_file(TOR_RESULT_FILE).await;
let content = serde_json::to_string(&action).context("Failed to serialize tor action")?;
let config_dir = Path::new(TOR_ACTION_FILE).parent().unwrap_or_else(|| Path::new("/var/lib/archipelago/tor-config"));
tokio::fs::create_dir_all(config_dir).await.ok();
tokio::fs::write(TOR_ACTION_FILE, &content)
.await
.context("Failed to write tor-action file")?;
for _ in 0..90 {
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
if let Ok(result_str) = tokio::fs::read_to_string(TOR_RESULT_FILE).await {
let _ = tokio::fs::remove_file(TOR_RESULT_FILE).await;
if let Ok(result) = serde_json::from_str::<serde_json::Value>(&result_str) {
if result.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) {
return Ok(());
}
let err = result.get("error").and_then(|v| v.as_str()).unwrap_or("Unknown error");
return Err(anyhow::anyhow!("Tor helper: {}", err));
}
return Ok(());
}
}
Err(anyhow::anyhow!("Tor helper timed out — is archipelago-tor-helper.path enabled?"))
}
pub(super) async fn delete_hidden_service_dir(name: &str) {
if let Err(e) = dispatch_tor_action(serde_json::json!({
"action": "delete-service",
"name": name,
})).await {
warn!("Failed to delete hidden service dir for {}: {}", name, e);
}
}
pub(super) async fn rename_hidden_service_dir(name: &str, timestamp: u64) {
if let Err(e) = dispatch_tor_action(serde_json::json!({
"action": "rename-service",
"name": name,
"timestamp": timestamp,
})).await {
warn!("Failed to rename hidden service dir for {}: {}", name, e);
}
}
pub(super) async fn restart_tor() -> Result<()> {
dispatch_tor_action(serde_json::json!({
"action": "write-torrc-and-restart",
})).await
}
pub(super) async fn check_tor_running() -> bool {
tokio::net::TcpStream::connect("127.0.0.1:9050")
.await
.is_ok()
}
// ─── torrc Generation ────────────────────────────────────────────
pub(super) fn detect_hidden_service_base() -> String {
if Path::new("/var/lib/tor/hidden_service_archipelago").exists() {
return "/var/lib/tor".to_string();
}
let custom = tor_data_dir();
if Path::new(&custom).join("hidden_service_archipelago").exists() {
return custom;
}
"/var/lib/tor".to_string()
}
pub(super) async fn regenerate_torrc(config: &ServicesConfig) -> Result<()> {
let base = detect_hidden_service_base();
let mut lines = Vec::new();
lines.push("# Auto-generated by Archipelago — do not edit manually".to_string());
lines.push("SocksPort 9050".to_string());
lines.push("# ControlPort disabled for security".to_string());
lines.push(String::new());
for svc in &config.services {
if !svc.enabled {
continue;
}
let dir = format!("{}/hidden_service_{}", base, svc.name);
lines.push(format!("HiddenServiceDir {}", dir));
if is_protocol_service(&svc.name) {
let remote_port = svc.remote_port.unwrap_or(svc.local_port);
lines.push(format!("HiddenServicePort {} 127.0.0.1:{}", remote_port, svc.local_port));
if svc.name == "lnd" {
lines.push("HiddenServicePort 9735 127.0.0.1:9735".to_string());
lines.push("HiddenServicePort 10009 127.0.0.1:10009".to_string());
}
} else {
lines.push(format!("HiddenServicePort 80 127.0.0.1:{}", svc.local_port));
}
lines.push(String::new());
}
let content = lines.join("\n");
let staging = "/var/lib/archipelago/tor-config/torrc.staged";
let config_dir = Path::new(staging).parent().unwrap_or_else(|| Path::new("/var/lib/archipelago/tor-config"));
tokio::fs::create_dir_all(config_dir).await.ok();
tokio::fs::write(staging, &content).await.context("Failed to write staged torrc")?;
debug!("Staged torrc with {} enabled services",
config.services.iter().filter(|s| s.enabled).count());
Ok(())
}
// ─── Hostname Sync ───────────────────────────────────────────────
pub(super) async fn sync_single_hostname(name: &str, address: &str) {
let hostnames_dir = Path::new("/var/lib/archipelago/tor-hostnames");
if let Err(e) = tokio::fs::create_dir_all(hostnames_dir).await {
warn!("Failed to create tor-hostnames dir: {}", e);
return;
}
if let Err(e) = tokio::fs::write(hostnames_dir.join(name), address).await {
warn!("Failed to write tor-hostname copy for {}: {}", name, e);
}
}
pub(super) async fn sync_all_hostname_copies(config: &ServicesConfig) {
for svc in &config.services {
if !svc.enabled {
continue;
}
if let Some(addr) = read_onion_address(&svc.name).await {
sync_single_hostname(&svc.name, &addr).await;
}
}
}
// ─── Service Listing ─────────────────────────────────────────────
pub(super) async fn list_services(config_dir: &std::path::Path) -> Result<Vec<TorService>> {
let base = detect_hidden_service_base();
let config = load_services_config(config_dir).await;
let mut services = Vec::new();
let mut seen = std::collections::HashSet::new();
for entry in &config.services {
let onion = read_onion_address(&entry.name).await;
seen.insert(entry.name.clone());
services.push(TorService {
name: entry.name.clone(),
local_port: entry.local_port,
onion_address: onion,
enabled: entry.enabled,
unauthenticated: entry.unauthenticated,
protocol: is_protocol_service(&entry.name),
});
}
for scan_dir in ["/var/lib/tor", &base] {
if let Ok(mut entries) = tokio::fs::read_dir(scan_dir).await {
while let Ok(Some(entry)) = entries.next_entry().await {
let name = entry.file_name().to_string_lossy().to_string();
let is_dir = entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false);
if name.starts_with("hidden_service_")
&& !name.contains("_old_")
&& is_dir
{
let service_name = name.strip_prefix("hidden_service_").unwrap_or(&name).to_string();
if seen.contains(&service_name) {
continue;
}
let onion = read_onion_address(&service_name).await;
let port = known_service_port(&service_name);
seen.insert(service_name.clone());
let is_proto = is_protocol_service(&service_name);
services.push(TorService {
name: service_name,
local_port: port,
onion_address: onion,
enabled: true,
unauthenticated: is_proto,
protocol: is_proto,
});
}
}
}
}
Ok(services)
}
// ─── Onion Address Reading ───────────────────────────────────────
pub(super) async fn read_onion_address(service_name: &str) -> Option<String> {
let hostnames_path = Path::new("/var/lib/archipelago/tor-hostnames").join(service_name);
if let Some(addr) = read_and_validate_onion(&hostnames_path).await {
return Some(addr);
}
let base = tor_data_dir();
for search_base in &["/var/lib/tor", base.as_str()] {
let path = Path::new(search_base)
.join(format!("hidden_service_{}", service_name))
.join("hostname");
if let Some(addr) = read_and_validate_onion(&path).await {
return Some(addr);
}
if let Some(addr) = sudo_read_and_validate_onion(&path).await {
return Some(addr);
}
}
None
}
async fn read_and_validate_onion(path: &Path) -> Option<String> {
tokio::fs::read_to_string(path)
.await
.ok()
.map(|s| s.trim().to_string())
.filter(|s| is_valid_v3_onion(s))
}
async fn sudo_read_and_validate_onion(path: &Path) -> Option<String> {
tokio::process::Command::new("sudo")
.args(["cat", &path.to_string_lossy()])
.output()
.await
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.filter(|s| is_valid_v3_onion(s))
}
fn is_valid_v3_onion(s: &str) -> bool {
s.len() == 62
&& s.ends_with(".onion")
&& !s.contains(':')
&& s[..56].chars().all(|c| c.is_ascii_alphanumeric())
}
// ─── Known Ports ─────────────────────────────────────────────────
pub(super) fn known_service_port(name: &str) -> u16 {
match name {
"archipelago" => 80,
"bitcoin" | "bitcoin-knots" => 8333,
"electrs" | "electrumx" => 50001,
"lnd" => 8080,
"btcpay" | "btcpay-server" | "btcpayserver" => 23000,
"mempool" => 4080,
"fedimint" => 8175,
"nostr-relay" | "nostr-rs-relay" => 8080,
"searxng" => 8888,
"ollama" => 11434,
"filebrowser" => 8083,
"grafana" => 3000,
"home-assistant" => 8123,
"immich" => 2283,
"photoprism" => 2342,
"penpot" => 9001,
"nginx-proxy-manager" => 81,
"vaultwarden" => 8343,
"indeedhub" => 7777,
_ => 0,
}
}
pub(super) fn is_protocol_service(name: &str) -> bool {
matches!(name, "bitcoin" | "bitcoin-knots" | "electrs" | "electrumx" | "lnd")
}
// ─── Config I/O ──────────────────────────────────────────────────
fn tor_data_dir() -> String {
std::env::var("TOR_DATA_DIR").unwrap_or_else(|_| TOR_DATA_DIR.to_string())
}
pub(super) async fn load_services_config(config_dir: &std::path::Path) -> ServicesConfig {
let path = config_dir.join(SERVICES_CONFIG);
match tokio::fs::read_to_string(&path).await {
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
Err(_) => ServicesConfig::default(),
}
}
pub(super) async fn save_services_config(config_dir: &std::path::Path, config: &ServicesConfig) -> Result<()> {
tokio::fs::create_dir_all(config_dir).await.context("Failed to create tor config dir")?;
let path = config_dir.join(SERVICES_CONFIG);
let content = serde_json::to_string_pretty(config).context("Failed to serialize services config")?;
tokio::fs::write(&path, content).await.context("Failed to write services config")?;
Ok(())
}
// ─── Federation Notification ─────────────────────────────────────
pub(super) async fn notify_federation_peers_address_change(
data_dir: &std::path::Path,
new_onion: &str,
old_onion: Option<&str>,
tor_proxy: Option<&str>,
) {
let identity_dir = data_dir.join("identity");
match identity::NodeIdentity::load_or_create(&identity_dir).await {
Ok(node_id) => {
let did = match node_id.did_key() {
Ok(d) => d,
Err(e) => {
tracing::warn!("Failed to derive DID key: {}", e);
return;
}
};
let proxy = tor_proxy.unwrap_or("127.0.0.1:9050");
match federation::load_nodes(data_dir).await {
Ok(peers) => {
for peer in peers {
if peer.onion.is_empty() {
continue;
}
let payload = serde_json::json!({
"method": "federation.peer-address-changed",
"params": {
"did": did,
"new_onion": new_onion,
"old_onion": old_onion,
}
});
let url = format!("http://{}/rpc/v1", &peer.onion);
let client = match reqwest::Client::builder()
.proxy(match reqwest::Proxy::all(format!("socks5h://{}", proxy))
.or_else(|_| reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY)) {
Ok(p) => p,
Err(_) => continue,
})
.timeout(std::time::Duration::from_secs(30))
.build()
{
Ok(c) => c,
Err(_) => continue,
};
match client.post(&url).json(&payload).send().await {
Ok(_) => info!(peer_did = %peer.did, "Notified peer of address change"),
Err(e) => warn!(peer_did = %peer.did, "Failed to notify peer: {}", e),
}
}
}
Err(e) => warn!("Failed to load federation peers: {}", e),
}
}
Err(e) => warn!("Failed to load node identity for propagation: {}", e),
}
}
// ─── Hostname Waiting ────────────────────────────────────────────
pub(super) async fn wait_for_hostname(service_name: &str, max_secs: u64) -> Option<String> {
for _ in 0..max_secs {
if let Some(addr) = read_onion_address(service_name).await {
return Some(addr);
}
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
}
warn!(service = service_name, "Timed out waiting for new .onion hostname");
None
}
+1 -1
View File
@@ -117,7 +117,7 @@ pub async fn restore_encrypted_backup(
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
std::fs::set_permissions(&key_path, perms)?;
tokio::fs::set_permissions(&key_path, perms).await?;
}
// Derive DID and pubkey from the restored key
+3 -12
View File
@@ -42,10 +42,11 @@ async fn read_password() -> String {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(
let _ = tokio::fs::set_permissions(
SECRETS_PATH,
std::fs::Permissions::from_mode(0o600),
);
)
.await;
}
debug!("Bitcoin RPC password generated and saved");
}
@@ -70,13 +71,3 @@ pub async fn bitcoin_rpc_credentials() -> (String, String) {
(RPC_USER.to_string(), pass.clone())
}
/// Get the Bitcoin RPC password (for container config generation).
pub async fn bitcoin_rpc_password() -> String {
let (_, pass) = bitcoin_rpc_credentials().await;
pass
}
/// Get the Bitcoin RPC username.
pub async fn bitcoin_rpc_username() -> String {
RPC_USER.to_string()
}
-7
View File
@@ -10,10 +10,3 @@ pub const DWN_HEALTH_URL: &str = "http://127.0.0.1:3100/health";
/// Tor SOCKS5 proxy for outbound onion connections.
pub const TOR_SOCKS_PROXY: &str = "socks5h://127.0.0.1:9050";
/// DNS-over-HTTPS providers for secure DNS resolution.
pub const DNS_PROVIDERS: &[&str] = &[
"https://cloudflare-dns.com/dns-query",
"https://dns.google/dns-query",
"https://dns.quad9.net/dns-query",
"https://dns.mullvad.net/dns-query",
];
-1
View File
@@ -12,7 +12,6 @@ use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Instant;
use tokio::fs;
use tracing::{info, warn};
-796
View File
@@ -1,796 +0,0 @@
//! Verifiable Credentials (VC) management following W3C VC Data Model 2.0.
//! Implements JSON-LD @context, Ed25519Signature2020 proof format.
//! See: https://www.w3.org/TR/vc-data-model-2.0/
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
use tokio::fs;
use tracing::debug;
const CREDENTIALS_DIR: &str = "credentials";
/// W3C VC Data Model 2.0 context URI
const VC_CONTEXT_V2: &str = "https://www.w3.org/ns/credentials/v2";
/// Ed25519 signature suite context
const ED25519_CONTEXT: &str = "https://w3id.org/security/suites/ed25519-2020/v1";
/// A Verifiable Credential following W3C VC Data Model 2.0.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VerifiableCredential {
#[serde(rename = "@context")]
pub context: Vec<String>,
pub id: String,
#[serde(rename = "type")]
pub credential_type: Vec<String>,
pub issuer: String,
pub credential_subject: CredentialSubject,
pub issuance_date: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub expiration_date: Option<String>,
pub proof: CredentialProof,
#[serde(skip_serializing_if = "Option::is_none")]
pub credential_status: Option<CredentialStatusEntry>,
}
/// The subject of a credential with their DID and claims.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CredentialSubject {
pub id: String,
#[serde(flatten)]
pub claims: serde_json::Value,
}
/// Ed25519Signature2020 proof format.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CredentialProof {
#[serde(rename = "type")]
pub proof_type: String,
pub created: String,
pub verification_method: String,
pub proof_purpose: String,
pub proof_value: String,
}
/// Credential status for revocation tracking.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CredentialStatusEntry {
pub id: String,
#[serde(rename = "type")]
pub status_type: String,
pub status: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum CredentialStatus {
Active,
Revoked,
Expired,
}
impl std::fmt::Display for CredentialStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CredentialStatus::Active => write!(f, "active"),
CredentialStatus::Revoked => write!(f, "revoked"),
CredentialStatus::Expired => write!(f, "expired"),
}
}
}
/// Stored credentials index.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CredentialStore {
pub credentials: Vec<VerifiableCredential>,
}
async fn ensure_dir(data_dir: &Path) -> Result<()> {
let dir = data_dir.join(CREDENTIALS_DIR);
if !dir.exists() {
fs::create_dir_all(&dir).await.context("Creating credentials dir")?;
}
Ok(())
}
fn store_path(data_dir: &Path) -> std::path::PathBuf {
data_dir.join(CREDENTIALS_DIR).join("credentials.json")
}
pub async fn load_credentials(data_dir: &Path) -> Result<CredentialStore> {
ensure_dir(data_dir).await?;
let path = store_path(data_dir);
if !path.exists() {
return Ok(CredentialStore::default());
}
let raw = fs::read(&path).await.context("Reading credentials")?;
// Detect plaintext JSON (migration path) vs encrypted binary
if raw.first().map_or(false, |b| *b == b'[' || *b == b'{') {
let data = String::from_utf8(raw).context("UTF-8 credentials")?;
return serde_json::from_str(&data).context("Parsing credentials");
}
// Encrypted: decrypt using node key
let key = load_encryption_key(data_dir).await?;
let plaintext = decrypt_credentials(&raw, &key)?;
serde_json::from_slice(&plaintext).context("Parsing decrypted credentials")
}
pub async fn save_credentials(data_dir: &Path, store: &CredentialStore) -> Result<()> {
ensure_dir(data_dir).await?;
let path = store_path(data_dir);
let data = serde_json::to_vec(store)?;
// Encrypt using node key
let key = load_encryption_key(data_dir).await?;
let encrypted = encrypt_credentials(&data, &key)?;
fs::write(&path, encrypted).await.context("Writing credentials")
}
/// Derive a 32-byte encryption key from the node's identity key via SHA-256.
async fn load_encryption_key(data_dir: &Path) -> Result<[u8; 32]> {
let node_key_path = data_dir.join("identity").join("node_key");
let key_bytes = fs::read(&node_key_path).await.context("Reading node key for credential encryption")?;
use sha2::{Sha256, Digest};
let mut hasher = Sha256::new();
hasher.update(b"archipelago-credential-store-v1");
hasher.update(&key_bytes);
let hash = hasher.finalize();
let mut key = [0u8; 32];
key.copy_from_slice(&hash);
Ok(key)
}
fn encrypt_credentials(data: &[u8], key: &[u8; 32]) -> Result<Vec<u8>> {
use chacha20poly1305::aead::{Aead, KeyInit};
let nonce_bytes: [u8; 12] = rand::random();
let cipher = chacha20poly1305::ChaCha20Poly1305::new_from_slice(key)
.map_err(|e| anyhow::anyhow!("Cipher init: {}", e))?;
let ciphertext = cipher
.encrypt(
chacha20poly1305::aead::generic_array::GenericArray::from_slice(&nonce_bytes),
data,
)
.map_err(|e| anyhow::anyhow!("Encryption failed: {}", e))?;
let mut output = Vec::with_capacity(12 + ciphertext.len());
output.extend_from_slice(&nonce_bytes);
output.extend_from_slice(&ciphertext);
Ok(output)
}
fn decrypt_credentials(data: &[u8], key: &[u8; 32]) -> Result<Vec<u8>> {
use chacha20poly1305::aead::{Aead, KeyInit};
if data.len() < 12 {
anyhow::bail!("Encrypted credentials too short");
}
let nonce = &data[..12];
let ciphertext = &data[12..];
let cipher = chacha20poly1305::ChaCha20Poly1305::new_from_slice(key)
.map_err(|e| anyhow::anyhow!("Cipher init: {}", e))?;
cipher
.decrypt(
chacha20poly1305::aead::generic_array::GenericArray::from_slice(nonce),
ciphertext,
)
.map_err(|_| anyhow::anyhow!("Credential decryption failed — key mismatch or corruption"))
}
/// Issue a new Verifiable Credential following W3C VC Data Model 2.0.
/// Uses Ed25519Signature2020 proof format.
pub async fn issue_credential(
data_dir: &Path,
issuer_did: &str,
subject_did: &str,
credential_type: &str,
claims: serde_json::Value,
expires_at: Option<&str>,
sign_fn: impl FnOnce(&[u8]) -> Result<String>,
) -> Result<VerifiableCredential> {
let id = format!("urn:uuid:{}", uuid::Uuid::new_v4());
let issued_at = chrono::Utc::now().to_rfc3339();
let key_id = format!("{}#key-1", issuer_did);
// Build the credential body for signing (without proof)
let body = serde_json::json!({
"@context": [VC_CONTEXT_V2, ED25519_CONTEXT],
"id": id,
"type": ["VerifiableCredential", credential_type],
"issuer": issuer_did,
"credentialSubject": {
"id": subject_did,
},
"issuanceDate": issued_at,
});
let body_bytes = serde_json::to_vec(&body)?;
let signature = sign_fn(&body_bytes)?;
let vc = VerifiableCredential {
context: vec![VC_CONTEXT_V2.to_string(), ED25519_CONTEXT.to_string()],
id: id.clone(),
credential_type: vec!["VerifiableCredential".to_string(), credential_type.to_string()],
issuer: issuer_did.to_string(),
credential_subject: CredentialSubject {
id: subject_did.to_string(),
claims,
},
issuance_date: issued_at.clone(),
expiration_date: expires_at.map(|s| s.to_string()),
proof: CredentialProof {
proof_type: "Ed25519Signature2020".to_string(),
created: issued_at,
verification_method: key_id,
proof_purpose: "assertionMethod".to_string(),
proof_value: signature,
},
credential_status: None,
};
let mut store = load_credentials(data_dir).await?;
debug!(id = %vc.id, "Issued W3C VC");
store.credentials.push(vc.clone());
save_credentials(data_dir, &store).await?;
Ok(vc)
}
/// Verify a credential's signature against the issuer DID.
pub fn verify_credential(
vc: &VerifiableCredential,
verify_fn: impl FnOnce(&str, &[u8], &str) -> Result<bool>,
) -> Result<bool> {
// Reconstruct the body that was signed (without proof)
let body = serde_json::json!({
"@context": vc.context,
"id": vc.id,
"type": vc.credential_type,
"issuer": vc.issuer,
"credentialSubject": {
"id": vc.credential_subject.id,
},
"issuanceDate": vc.issuance_date,
});
let body_bytes = serde_json::to_vec(&body)?;
verify_fn(&vc.issuer, &body_bytes, &vc.proof.proof_value)
}
/// Revoke a credential by ID.
pub async fn revoke_credential(data_dir: &Path, credential_id: &str) -> Result<()> {
let mut store = load_credentials(data_dir).await?;
let vc = store
.credentials
.iter_mut()
.find(|c| c.id == credential_id)
.ok_or_else(|| anyhow::anyhow!("Credential not found: {}", credential_id))?;
vc.credential_status = Some(CredentialStatusEntry {
id: format!("{}#status", credential_id),
status_type: "CredentialStatusList2021".to_string(),
status: "revoked".to_string(),
});
save_credentials(data_dir, &store).await
}
/// List all credentials, optionally filtering by issuer or subject DID.
pub async fn list_credentials(
data_dir: &Path,
filter_did: Option<&str>,
) -> Result<Vec<VerifiableCredential>> {
let store = load_credentials(data_dir).await?;
let creds = if let Some(did) = filter_did {
store
.credentials
.into_iter()
.filter(|c| c.issuer == did || c.credential_subject.id == did)
.collect()
} else {
store.credentials
};
Ok(creds)
}
/// Check if a credential is revoked.
pub fn is_revoked(vc: &VerifiableCredential) -> bool {
vc.credential_status
.as_ref()
.map_or(false, |s| s.status == "revoked")
}
/// A Verifiable Presentation following W3C VC Data Model 2.0.
/// Bundles one or more VCs with a holder proof.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VerifiablePresentation {
#[serde(rename = "@context")]
pub context: Vec<String>,
pub id: String,
#[serde(rename = "type")]
pub presentation_type: Vec<String>,
pub holder: String,
pub verifiable_credential: Vec<VerifiableCredential>,
pub proof: CredentialProof,
}
/// Create a Verifiable Presentation wrapping selected credentials.
/// The holder signs the presentation to prove they possess the credentials.
pub fn create_presentation(
holder_did: &str,
credential_ids: &[&str],
credentials: &[VerifiableCredential],
sign_fn: impl FnOnce(&[u8]) -> Result<String>,
) -> Result<VerifiablePresentation> {
let selected: Vec<VerifiableCredential> = credentials
.iter()
.filter(|c| credential_ids.contains(&c.id.as_str()))
.cloned()
.collect();
if selected.is_empty() {
return Err(anyhow::anyhow!("No matching credentials found"));
}
let id = format!("urn:uuid:{}", uuid::Uuid::new_v4());
let created = chrono::Utc::now().to_rfc3339();
let key_id = format!("{}#key-1", holder_did);
// Build the presentation body for signing (without proof)
let body = serde_json::json!({
"@context": [VC_CONTEXT_V2, ED25519_CONTEXT],
"id": id,
"type": ["VerifiablePresentation"],
"holder": holder_did,
"verifiableCredential": selected,
});
let body_bytes = serde_json::to_vec(&body)?;
let signature = sign_fn(&body_bytes)?;
Ok(VerifiablePresentation {
context: vec![VC_CONTEXT_V2.to_string(), ED25519_CONTEXT.to_string()],
id,
presentation_type: vec!["VerifiablePresentation".to_string()],
holder: holder_did.to_string(),
verifiable_credential: selected,
proof: CredentialProof {
proof_type: "Ed25519Signature2020".to_string(),
created,
verification_method: key_id,
proof_purpose: "authentication".to_string(),
proof_value: signature,
},
})
}
/// Verify a Verifiable Presentation: check holder's proof signature,
/// then verify each embedded credential.
pub fn verify_presentation(
vp: &VerifiablePresentation,
verify_fn: impl Fn(&str, &[u8], &str) -> Result<bool>,
) -> Result<PresentationVerification> {
// 1. Verify the holder's presentation proof
let body = serde_json::json!({
"@context": vp.context,
"id": vp.id,
"type": vp.presentation_type,
"holder": vp.holder,
"verifiableCredential": vp.verifiable_credential,
});
let body_bytes = serde_json::to_vec(&body)?;
let holder_valid = verify_fn(&vp.holder, &body_bytes, &vp.proof.proof_value)?;
// 2. Verify each embedded credential
let mut credential_results = Vec::new();
for vc in &vp.verifiable_credential {
let vc_valid = verify_credential(vc, |did, bytes, sig| verify_fn(did, bytes, sig))?;
credential_results.push(CredentialVerificationResult {
id: vc.id.clone(),
valid: vc_valid,
revoked: is_revoked(vc),
});
}
let all_valid = holder_valid && credential_results.iter().all(|r| r.valid && !r.revoked);
Ok(PresentationVerification {
holder_valid,
credentials: credential_results,
valid: all_valid,
})
}
/// Result of verifying a Verifiable Presentation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PresentationVerification {
pub holder_valid: bool,
pub credentials: Vec<CredentialVerificationResult>,
pub valid: bool,
}
/// Result of verifying a single credential within a presentation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CredentialVerificationResult {
pub id: String,
pub valid: bool,
pub revoked: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_credential_status_display() {
assert_eq!(CredentialStatus::Active.to_string(), "active");
assert_eq!(CredentialStatus::Revoked.to_string(), "revoked");
assert_eq!(CredentialStatus::Expired.to_string(), "expired");
}
#[test]
fn test_credential_status_serde_roundtrip() {
let json = serde_json::to_string(&CredentialStatus::Revoked).unwrap();
assert_eq!(json, "\"revoked\"");
let parsed: CredentialStatus = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, CredentialStatus::Revoked);
}
#[test]
fn test_credential_store_default_is_empty() {
let store = CredentialStore::default();
assert!(store.credentials.is_empty());
}
#[tokio::test]
async fn test_load_credentials_returns_empty_when_no_file() {
let dir = tempfile::tempdir().unwrap();
let store = load_credentials(dir.path()).await.unwrap();
assert!(store.credentials.is_empty());
assert!(dir.path().join(CREDENTIALS_DIR).exists());
}
#[tokio::test]
async fn test_issue_credential_w3c_format() {
let dir = tempfile::tempdir().unwrap();
let vc = issue_credential(
dir.path(),
"did:key:issuer",
"did:key:subject",
"NodeOperator",
serde_json::json!({"role": "admin"}),
Some("2027-12-31T23:59:59Z"),
|_bytes| Ok("mock-signature".to_string()),
)
.await
.unwrap();
// W3C structure checks
assert!(vc.id.starts_with("urn:uuid:"));
assert_eq!(vc.context[0], VC_CONTEXT_V2);
assert_eq!(vc.context[1], ED25519_CONTEXT);
assert_eq!(vc.credential_type, vec!["VerifiableCredential", "NodeOperator"]);
assert_eq!(vc.issuer, "did:key:issuer");
assert_eq!(vc.credential_subject.id, "did:key:subject");
assert_eq!(vc.proof.proof_type, "Ed25519Signature2020");
assert_eq!(vc.proof.proof_purpose, "assertionMethod");
assert_eq!(vc.proof.verification_method, "did:key:issuer#key-1");
assert_eq!(vc.proof.proof_value, "mock-signature");
assert_eq!(vc.expiration_date, Some("2027-12-31T23:59:59Z".to_string()));
assert!(vc.credential_status.is_none());
}
#[tokio::test]
async fn test_issue_credential_serializes_as_jsonld() {
let dir = tempfile::tempdir().unwrap();
let vc = issue_credential(
dir.path(),
"did:key:issuer",
"did:key:subject",
"TestCred",
serde_json::json!({"level": "gold"}),
None,
|_| Ok("sig".to_string()),
)
.await
.unwrap();
let json = serde_json::to_value(&vc).unwrap();
// Must have @context
assert!(json["@context"].is_array());
// Must have type array
assert!(json["type"].is_array());
// Must have credentialSubject
assert!(json["credentialSubject"]["id"].is_string());
// Must have proof
assert_eq!(json["proof"]["type"], "Ed25519Signature2020");
}
#[tokio::test]
async fn test_save_and_load_roundtrip() {
let dir = tempfile::tempdir().unwrap();
issue_credential(
dir.path(),
"did:key:a",
"did:key:b",
"Type1",
serde_json::json!({"k": "v"}),
None,
|_| Ok("s1".to_string()),
)
.await
.unwrap();
let loaded = load_credentials(dir.path()).await.unwrap();
assert_eq!(loaded.credentials.len(), 1);
assert_eq!(loaded.credentials[0].credential_type[1], "Type1");
}
#[tokio::test]
async fn test_issue_credential_sign_fn_failure_propagates() {
let dir = tempfile::tempdir().unwrap();
let result = issue_credential(
dir.path(),
"did:key:issuer",
"did:key:subject",
"TestCredential",
serde_json::json!({}),
None,
|_bytes| Err(anyhow::anyhow!("Signing failed")),
)
.await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Signing failed"));
}
#[test]
fn test_verify_credential_calls_verify_fn() {
let vc = VerifiableCredential {
context: vec![VC_CONTEXT_V2.to_string()],
id: "urn:uuid:test".to_string(),
credential_type: vec!["VerifiableCredential".to_string(), "Test".to_string()],
issuer: "did:key:issuer".to_string(),
credential_subject: CredentialSubject {
id: "did:key:subject".to_string(),
claims: serde_json::json!({"foo": "bar"}),
},
issuance_date: "2025-06-01T00:00:00Z".to_string(),
expiration_date: None,
proof: CredentialProof {
proof_type: "Ed25519Signature2020".to_string(),
created: "2025-06-01T00:00:00Z".to_string(),
verification_method: "did:key:issuer#key-1".to_string(),
proof_purpose: "assertionMethod".to_string(),
proof_value: "valid-sig".to_string(),
},
credential_status: None,
};
let result = verify_credential(&vc, |issuer, _data, sig| {
assert_eq!(issuer, "did:key:issuer");
assert_eq!(sig, "valid-sig");
Ok(true)
})
.unwrap();
assert!(result);
let result = verify_credential(&vc, |_issuer, _data, _sig| Ok(false)).unwrap();
assert!(!result);
}
#[tokio::test]
async fn test_revoke_credential() {
let dir = tempfile::tempdir().unwrap();
let vc = issue_credential(
dir.path(),
"did:key:issuer",
"did:key:subject",
"Revocable",
serde_json::json!({}),
None,
|_| Ok("sig".to_string()),
)
.await
.unwrap();
assert!(!is_revoked(&vc));
revoke_credential(dir.path(), &vc.id).await.unwrap();
let store = load_credentials(dir.path()).await.unwrap();
assert!(is_revoked(&store.credentials[0]));
assert_eq!(
store.credentials[0].credential_status.as_ref().unwrap().status,
"revoked"
);
}
#[tokio::test]
async fn test_revoke_nonexistent_credential_fails() {
let dir = tempfile::tempdir().unwrap();
let result = revoke_credential(dir.path(), "urn:uuid:does-not-exist").await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Credential not found"));
}
#[tokio::test]
async fn test_list_credentials_no_filter() {
let dir = tempfile::tempdir().unwrap();
issue_credential(
dir.path(), "did:key:a", "did:key:b", "Type1",
serde_json::json!({}), None, |_| Ok("s1".to_string()),
).await.unwrap();
issue_credential(
dir.path(), "did:key:c", "did:key:d", "Type2",
serde_json::json!({}), None, |_| Ok("s2".to_string()),
).await.unwrap();
let all = list_credentials(dir.path(), None).await.unwrap();
assert_eq!(all.len(), 2);
}
#[tokio::test]
async fn test_list_credentials_filter_by_did() {
let dir = tempfile::tempdir().unwrap();
issue_credential(
dir.path(), "did:key:alice", "did:key:bob", "Type1",
serde_json::json!({}), None, |_| Ok("s1".to_string()),
).await.unwrap();
issue_credential(
dir.path(), "did:key:carol", "did:key:alice", "Type2",
serde_json::json!({}), None, |_| Ok("s2".to_string()),
).await.unwrap();
issue_credential(
dir.path(), "did:key:carol", "did:key:dave", "Type3",
serde_json::json!({}), None, |_| Ok("s3".to_string()),
).await.unwrap();
let filtered = list_credentials(dir.path(), Some("did:key:alice")).await.unwrap();
assert_eq!(filtered.len(), 2);
}
fn make_test_vc(id: &str, issuer: &str, subject: &str) -> VerifiableCredential {
VerifiableCredential {
context: vec![VC_CONTEXT_V2.to_string(), ED25519_CONTEXT.to_string()],
id: id.to_string(),
credential_type: vec!["VerifiableCredential".to_string(), "Test".to_string()],
issuer: issuer.to_string(),
credential_subject: CredentialSubject {
id: subject.to_string(),
claims: serde_json::json!({"role": "tester"}),
},
issuance_date: "2026-01-01T00:00:00Z".to_string(),
expiration_date: None,
proof: CredentialProof {
proof_type: "Ed25519Signature2020".to_string(),
created: "2026-01-01T00:00:00Z".to_string(),
verification_method: format!("{}#key-1", issuer),
proof_purpose: "assertionMethod".to_string(),
proof_value: "mock-sig".to_string(),
},
credential_status: None,
}
}
#[test]
fn test_create_presentation() {
let creds = vec![
make_test_vc("urn:uuid:cred1", "did:key:issuer1", "did:key:holder"),
make_test_vc("urn:uuid:cred2", "did:key:issuer2", "did:key:holder"),
];
let vp = create_presentation(
"did:key:holder",
&["urn:uuid:cred1"],
&creds,
|_bytes| Ok("presentation-sig".to_string()),
)
.unwrap();
assert!(vp.id.starts_with("urn:uuid:"));
assert_eq!(vp.presentation_type, vec!["VerifiablePresentation"]);
assert_eq!(vp.holder, "did:key:holder");
assert_eq!(vp.verifiable_credential.len(), 1);
assert_eq!(vp.verifiable_credential[0].id, "urn:uuid:cred1");
assert_eq!(vp.proof.proof_type, "Ed25519Signature2020");
assert_eq!(vp.proof.proof_purpose, "authentication");
assert_eq!(vp.proof.proof_value, "presentation-sig");
assert_eq!(vp.context[0], VC_CONTEXT_V2);
}
#[test]
fn test_create_presentation_multiple_credentials() {
let creds = vec![
make_test_vc("urn:uuid:c1", "did:key:i1", "did:key:holder"),
make_test_vc("urn:uuid:c2", "did:key:i2", "did:key:holder"),
make_test_vc("urn:uuid:c3", "did:key:i3", "did:key:other"),
];
let vp = create_presentation(
"did:key:holder",
&["urn:uuid:c1", "urn:uuid:c2"],
&creds,
|_| Ok("sig".to_string()),
)
.unwrap();
assert_eq!(vp.verifiable_credential.len(), 2);
}
#[test]
fn test_create_presentation_no_matching_credentials() {
let creds = vec![make_test_vc("urn:uuid:c1", "did:key:i", "did:key:h")];
let result = create_presentation(
"did:key:holder",
&["urn:uuid:nonexistent"],
&creds,
|_| Ok("sig".to_string()),
);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("No matching credentials"));
}
#[test]
fn test_verify_presentation_all_valid() {
let creds = vec![
make_test_vc("urn:uuid:c1", "did:key:issuer", "did:key:holder"),
];
let vp = create_presentation(
"did:key:holder",
&["urn:uuid:c1"],
&creds,
|_| Ok("vp-sig".to_string()),
)
.unwrap();
let result = verify_presentation(&vp, |_did, _bytes, _sig| Ok(true)).unwrap();
assert!(result.holder_valid);
assert!(result.valid);
assert_eq!(result.credentials.len(), 1);
assert!(result.credentials[0].valid);
assert!(!result.credentials[0].revoked);
}
#[test]
fn test_verify_presentation_holder_invalid() {
let creds = vec![
make_test_vc("urn:uuid:c1", "did:key:issuer", "did:key:holder"),
];
let vp = create_presentation(
"did:key:holder",
&["urn:uuid:c1"],
&creds,
|_| Ok("bad-sig".to_string()),
)
.unwrap();
// Holder verification fails, credential verification succeeds
let result = verify_presentation(&vp, |did, _bytes, _sig| {
Ok(did != "did:key:holder")
})
.unwrap();
assert!(!result.holder_valid);
assert!(!result.valid); // Overall invalid because holder proof failed
}
#[test]
fn test_presentation_serializes_as_jsonld() {
let creds = vec![
make_test_vc("urn:uuid:c1", "did:key:issuer", "did:key:holder"),
];
let vp = create_presentation(
"did:key:holder",
&["urn:uuid:c1"],
&creds,
|_| Ok("sig".to_string()),
)
.unwrap();
let json = serde_json::to_value(&vp).unwrap();
assert!(json["@context"].is_array());
assert!(json["type"].is_array());
assert_eq!(json["type"][0], "VerifiablePresentation");
assert!(json["holder"].is_string());
assert!(json["verifiableCredential"].is_array());
assert!(json["proof"]["type"].is_string());
}
}
+12
View File
@@ -0,0 +1,12 @@
//! Verifiable Credentials (VC) management following W3C VC Data Model 2.0.
//! Implements JSON-LD @context, Ed25519Signature2020 proof format.
//! See: https://www.w3.org/TR/vc-data-model-2.0/
mod types;
mod store;
mod operations;
mod presentation;
pub use store::load_credentials;
pub use operations::{issue_credential, verify_credential, revoke_credential, list_credentials, is_revoked};
pub use presentation::{VerifiablePresentation, create_presentation, verify_presentation};
@@ -0,0 +1,322 @@
use anyhow::Result;
use std::path::Path;
use tracing::debug;
use super::types::*;
use super::store::{load_credentials, save_credentials};
/// Issue a new Verifiable Credential following W3C VC Data Model 2.0.
/// Uses Ed25519Signature2020 proof format.
pub async fn issue_credential(
data_dir: &Path,
issuer_did: &str,
subject_did: &str,
credential_type: &str,
claims: serde_json::Value,
expires_at: Option<&str>,
sign_fn: impl FnOnce(&[u8]) -> Result<String>,
) -> Result<VerifiableCredential> {
let id = format!("urn:uuid:{}", uuid::Uuid::new_v4());
let issued_at = chrono::Utc::now().to_rfc3339();
let key_id = format!("{}#key-1", issuer_did);
// Build the credential body for signing (without proof)
let body = serde_json::json!({
"@context": [VC_CONTEXT_V2, ED25519_CONTEXT],
"id": id,
"type": ["VerifiableCredential", credential_type],
"issuer": issuer_did,
"credentialSubject": {
"id": subject_did,
},
"issuanceDate": issued_at,
});
let body_bytes = serde_json::to_vec(&body)?;
let signature = sign_fn(&body_bytes)?;
let vc = VerifiableCredential {
context: vec![VC_CONTEXT_V2.to_string(), ED25519_CONTEXT.to_string()],
id: id.clone(),
credential_type: vec!["VerifiableCredential".to_string(), credential_type.to_string()],
issuer: issuer_did.to_string(),
credential_subject: CredentialSubject {
id: subject_did.to_string(),
claims,
},
issuance_date: issued_at.clone(),
expiration_date: expires_at.map(|s| s.to_string()),
proof: CredentialProof {
proof_type: "Ed25519Signature2020".to_string(),
created: issued_at,
verification_method: key_id,
proof_purpose: "assertionMethod".to_string(),
proof_value: signature,
},
credential_status: None,
};
let mut store = load_credentials(data_dir).await?;
debug!(id = %vc.id, "Issued W3C VC");
store.credentials.push(vc.clone());
save_credentials(data_dir, &store).await?;
Ok(vc)
}
/// Verify a credential's signature against the issuer DID.
pub fn verify_credential(
vc: &VerifiableCredential,
verify_fn: impl FnOnce(&str, &[u8], &str) -> Result<bool>,
) -> Result<bool> {
let body = serde_json::json!({
"@context": vc.context,
"id": vc.id,
"type": vc.credential_type,
"issuer": vc.issuer,
"credentialSubject": {
"id": vc.credential_subject.id,
},
"issuanceDate": vc.issuance_date,
});
let body_bytes = serde_json::to_vec(&body)?;
verify_fn(&vc.issuer, &body_bytes, &vc.proof.proof_value)
}
/// Revoke a credential by ID.
pub async fn revoke_credential(data_dir: &Path, credential_id: &str) -> Result<()> {
let mut store = load_credentials(data_dir).await?;
let vc = store
.credentials
.iter_mut()
.find(|c| c.id == credential_id)
.ok_or_else(|| anyhow::anyhow!("Credential not found: {}", credential_id))?;
vc.credential_status = Some(CredentialStatusEntry {
id: format!("{}#status", credential_id),
status_type: "CredentialStatusList2021".to_string(),
status: "revoked".to_string(),
});
save_credentials(data_dir, &store).await
}
/// List all credentials, optionally filtering by issuer or subject DID.
pub async fn list_credentials(
data_dir: &Path,
filter_did: Option<&str>,
) -> Result<Vec<VerifiableCredential>> {
let store = load_credentials(data_dir).await?;
let creds = if let Some(did) = filter_did {
store
.credentials
.into_iter()
.filter(|c| c.issuer == did || c.credential_subject.id == did)
.collect()
} else {
store.credentials
};
Ok(creds)
}
/// Check if a credential is revoked.
pub fn is_revoked(vc: &VerifiableCredential) -> bool {
vc.credential_status
.as_ref()
.map_or(false, |s| s.status == "revoked")
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_issue_credential_w3c_format() {
let dir = tempfile::tempdir().unwrap();
let vc = issue_credential(
dir.path(),
"did:key:issuer",
"did:key:subject",
"NodeOperator",
serde_json::json!({"role": "admin"}),
Some("2027-12-31T23:59:59Z"),
|_bytes| Ok("mock-signature".to_string()),
)
.await
.unwrap();
assert!(vc.id.starts_with("urn:uuid:"));
assert_eq!(vc.context[0], VC_CONTEXT_V2);
assert_eq!(vc.context[1], ED25519_CONTEXT);
assert_eq!(vc.credential_type, vec!["VerifiableCredential", "NodeOperator"]);
assert_eq!(vc.issuer, "did:key:issuer");
assert_eq!(vc.credential_subject.id, "did:key:subject");
assert_eq!(vc.proof.proof_type, "Ed25519Signature2020");
assert_eq!(vc.proof.proof_purpose, "assertionMethod");
assert_eq!(vc.proof.verification_method, "did:key:issuer#key-1");
assert_eq!(vc.proof.proof_value, "mock-signature");
assert_eq!(vc.expiration_date, Some("2027-12-31T23:59:59Z".to_string()));
assert!(vc.credential_status.is_none());
}
#[tokio::test]
async fn test_issue_credential_serializes_as_jsonld() {
let dir = tempfile::tempdir().unwrap();
let vc = issue_credential(
dir.path(),
"did:key:issuer",
"did:key:subject",
"TestCred",
serde_json::json!({"level": "gold"}),
None,
|_| Ok("sig".to_string()),
)
.await
.unwrap();
let json = serde_json::to_value(&vc).unwrap();
assert!(json["@context"].is_array());
assert!(json["type"].is_array());
assert!(json["credentialSubject"]["id"].is_string());
assert_eq!(json["proof"]["type"], "Ed25519Signature2020");
}
#[tokio::test]
async fn test_save_and_load_roundtrip() {
let dir = tempfile::tempdir().unwrap();
issue_credential(
dir.path(),
"did:key:a",
"did:key:b",
"Type1",
serde_json::json!({"k": "v"}),
None,
|_| Ok("s1".to_string()),
)
.await
.unwrap();
let loaded = load_credentials(dir.path()).await.unwrap();
assert_eq!(loaded.credentials.len(), 1);
assert_eq!(loaded.credentials[0].credential_type[1], "Type1");
}
#[tokio::test]
async fn test_issue_credential_sign_fn_failure_propagates() {
let dir = tempfile::tempdir().unwrap();
let result = issue_credential(
dir.path(),
"did:key:issuer",
"did:key:subject",
"TestCredential",
serde_json::json!({}),
None,
|_bytes| Err(anyhow::anyhow!("Signing failed")),
)
.await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Signing failed"));
}
#[test]
fn test_verify_credential_calls_verify_fn() {
let vc = VerifiableCredential {
context: vec![VC_CONTEXT_V2.to_string()],
id: "urn:uuid:test".to_string(),
credential_type: vec!["VerifiableCredential".to_string(), "Test".to_string()],
issuer: "did:key:issuer".to_string(),
credential_subject: CredentialSubject {
id: "did:key:subject".to_string(),
claims: serde_json::json!({"foo": "bar"}),
},
issuance_date: "2025-06-01T00:00:00Z".to_string(),
expiration_date: None,
proof: CredentialProof {
proof_type: "Ed25519Signature2020".to_string(),
created: "2025-06-01T00:00:00Z".to_string(),
verification_method: "did:key:issuer#key-1".to_string(),
proof_purpose: "assertionMethod".to_string(),
proof_value: "valid-sig".to_string(),
},
credential_status: None,
};
let result = verify_credential(&vc, |issuer, _data, sig| {
assert_eq!(issuer, "did:key:issuer");
assert_eq!(sig, "valid-sig");
Ok(true)
})
.unwrap();
assert!(result);
let result = verify_credential(&vc, |_issuer, _data, _sig| Ok(false)).unwrap();
assert!(!result);
}
#[tokio::test]
async fn test_revoke_credential() {
let dir = tempfile::tempdir().unwrap();
let vc = issue_credential(
dir.path(),
"did:key:issuer",
"did:key:subject",
"Revocable",
serde_json::json!({}),
None,
|_| Ok("sig".to_string()),
)
.await
.unwrap();
assert!(!is_revoked(&vc));
revoke_credential(dir.path(), &vc.id).await.unwrap();
let store = load_credentials(dir.path()).await.unwrap();
assert!(is_revoked(&store.credentials[0]));
assert_eq!(
store.credentials[0].credential_status.as_ref().unwrap().status,
"revoked"
);
}
#[tokio::test]
async fn test_revoke_nonexistent_credential_fails() {
let dir = tempfile::tempdir().unwrap();
let result = revoke_credential(dir.path(), "urn:uuid:does-not-exist").await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Credential not found"));
}
#[tokio::test]
async fn test_list_credentials_no_filter() {
let dir = tempfile::tempdir().unwrap();
issue_credential(
dir.path(), "did:key:a", "did:key:b", "Type1",
serde_json::json!({}), None, |_| Ok("s1".to_string()),
).await.unwrap();
issue_credential(
dir.path(), "did:key:c", "did:key:d", "Type2",
serde_json::json!({}), None, |_| Ok("s2".to_string()),
).await.unwrap();
let all = list_credentials(dir.path(), None).await.unwrap();
assert_eq!(all.len(), 2);
}
#[tokio::test]
async fn test_list_credentials_filter_by_did() {
let dir = tempfile::tempdir().unwrap();
issue_credential(
dir.path(), "did:key:alice", "did:key:bob", "Type1",
serde_json::json!({}), None, |_| Ok("s1".to_string()),
).await.unwrap();
issue_credential(
dir.path(), "did:key:carol", "did:key:alice", "Type2",
serde_json::json!({}), None, |_| Ok("s2".to_string()),
).await.unwrap();
issue_credential(
dir.path(), "did:key:carol", "did:key:dave", "Type3",
serde_json::json!({}), None, |_| Ok("s3".to_string()),
).await.unwrap();
let filtered = list_credentials(dir.path(), Some("did:key:alice")).await.unwrap();
assert_eq!(filtered.len(), 2);
}
}
@@ -0,0 +1,274 @@
use anyhow::Result;
use serde::{Deserialize, Serialize};
use super::types::*;
use super::operations::{verify_credential, is_revoked};
/// A Verifiable Presentation following W3C VC Data Model 2.0.
/// Bundles one or more VCs with a holder proof.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VerifiablePresentation {
#[serde(rename = "@context")]
pub context: Vec<String>,
pub id: String,
#[serde(rename = "type")]
pub presentation_type: Vec<String>,
pub holder: String,
pub verifiable_credential: Vec<VerifiableCredential>,
pub proof: CredentialProof,
}
/// Create a Verifiable Presentation wrapping selected credentials.
/// The holder signs the presentation to prove they possess the credentials.
pub fn create_presentation(
holder_did: &str,
credential_ids: &[&str],
credentials: &[VerifiableCredential],
sign_fn: impl FnOnce(&[u8]) -> Result<String>,
) -> Result<VerifiablePresentation> {
let selected: Vec<VerifiableCredential> = credentials
.iter()
.filter(|c| credential_ids.contains(&c.id.as_str()))
.cloned()
.collect();
if selected.is_empty() {
return Err(anyhow::anyhow!("No matching credentials found"));
}
let id = format!("urn:uuid:{}", uuid::Uuid::new_v4());
let created = chrono::Utc::now().to_rfc3339();
let key_id = format!("{}#key-1", holder_did);
let body = serde_json::json!({
"@context": [VC_CONTEXT_V2, ED25519_CONTEXT],
"id": id,
"type": ["VerifiablePresentation"],
"holder": holder_did,
"verifiableCredential": selected,
});
let body_bytes = serde_json::to_vec(&body)?;
let signature = sign_fn(&body_bytes)?;
Ok(VerifiablePresentation {
context: vec![VC_CONTEXT_V2.to_string(), ED25519_CONTEXT.to_string()],
id,
presentation_type: vec!["VerifiablePresentation".to_string()],
holder: holder_did.to_string(),
verifiable_credential: selected,
proof: CredentialProof {
proof_type: "Ed25519Signature2020".to_string(),
created,
verification_method: key_id,
proof_purpose: "authentication".to_string(),
proof_value: signature,
},
})
}
/// Verify a Verifiable Presentation: check holder's proof signature,
/// then verify each embedded credential.
pub fn verify_presentation(
vp: &VerifiablePresentation,
verify_fn: impl Fn(&str, &[u8], &str) -> Result<bool>,
) -> Result<PresentationVerification> {
let body = serde_json::json!({
"@context": vp.context,
"id": vp.id,
"type": vp.presentation_type,
"holder": vp.holder,
"verifiableCredential": vp.verifiable_credential,
});
let body_bytes = serde_json::to_vec(&body)?;
let holder_valid = verify_fn(&vp.holder, &body_bytes, &vp.proof.proof_value)?;
let mut credential_results = Vec::new();
for vc in &vp.verifiable_credential {
let vc_valid = verify_credential(vc, |did, bytes, sig| verify_fn(did, bytes, sig))?;
credential_results.push(CredentialVerificationResult {
id: vc.id.clone(),
valid: vc_valid,
revoked: is_revoked(vc),
});
}
let all_valid = holder_valid && credential_results.iter().all(|r| r.valid && !r.revoked);
Ok(PresentationVerification {
holder_valid,
credentials: credential_results,
valid: all_valid,
})
}
/// Result of verifying a Verifiable Presentation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PresentationVerification {
pub holder_valid: bool,
pub credentials: Vec<CredentialVerificationResult>,
pub valid: bool,
}
/// Result of verifying a single credential within a presentation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CredentialVerificationResult {
pub id: String,
pub valid: bool,
pub revoked: bool,
}
#[cfg(test)]
mod tests {
use super::*;
fn make_test_vc(id: &str, issuer: &str, subject: &str) -> VerifiableCredential {
VerifiableCredential {
context: vec![VC_CONTEXT_V2.to_string(), ED25519_CONTEXT.to_string()],
id: id.to_string(),
credential_type: vec!["VerifiableCredential".to_string(), "Test".to_string()],
issuer: issuer.to_string(),
credential_subject: CredentialSubject {
id: subject.to_string(),
claims: serde_json::json!({"role": "tester"}),
},
issuance_date: "2026-01-01T00:00:00Z".to_string(),
expiration_date: None,
proof: CredentialProof {
proof_type: "Ed25519Signature2020".to_string(),
created: "2026-01-01T00:00:00Z".to_string(),
verification_method: format!("{}#key-1", issuer),
proof_purpose: "assertionMethod".to_string(),
proof_value: "mock-sig".to_string(),
},
credential_status: None,
}
}
#[test]
fn test_create_presentation() {
let creds = vec![
make_test_vc("urn:uuid:cred1", "did:key:issuer1", "did:key:holder"),
make_test_vc("urn:uuid:cred2", "did:key:issuer2", "did:key:holder"),
];
let vp = create_presentation(
"did:key:holder",
&["urn:uuid:cred1"],
&creds,
|_bytes| Ok("presentation-sig".to_string()),
)
.unwrap();
assert!(vp.id.starts_with("urn:uuid:"));
assert_eq!(vp.presentation_type, vec!["VerifiablePresentation"]);
assert_eq!(vp.holder, "did:key:holder");
assert_eq!(vp.verifiable_credential.len(), 1);
assert_eq!(vp.verifiable_credential[0].id, "urn:uuid:cred1");
assert_eq!(vp.proof.proof_type, "Ed25519Signature2020");
assert_eq!(vp.proof.proof_purpose, "authentication");
assert_eq!(vp.proof.proof_value, "presentation-sig");
assert_eq!(vp.context[0], VC_CONTEXT_V2);
}
#[test]
fn test_create_presentation_multiple_credentials() {
let creds = vec![
make_test_vc("urn:uuid:c1", "did:key:i1", "did:key:holder"),
make_test_vc("urn:uuid:c2", "did:key:i2", "did:key:holder"),
make_test_vc("urn:uuid:c3", "did:key:i3", "did:key:other"),
];
let vp = create_presentation(
"did:key:holder",
&["urn:uuid:c1", "urn:uuid:c2"],
&creds,
|_| Ok("sig".to_string()),
)
.unwrap();
assert_eq!(vp.verifiable_credential.len(), 2);
}
#[test]
fn test_create_presentation_no_matching_credentials() {
let creds = vec![make_test_vc("urn:uuid:c1", "did:key:i", "did:key:h")];
let result = create_presentation(
"did:key:holder",
&["urn:uuid:nonexistent"],
&creds,
|_| Ok("sig".to_string()),
);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("No matching credentials"));
}
#[test]
fn test_verify_presentation_all_valid() {
let creds = vec![
make_test_vc("urn:uuid:c1", "did:key:issuer", "did:key:holder"),
];
let vp = create_presentation(
"did:key:holder",
&["urn:uuid:c1"],
&creds,
|_| Ok("vp-sig".to_string()),
)
.unwrap();
let result = verify_presentation(&vp, |_did, _bytes, _sig| Ok(true)).unwrap();
assert!(result.holder_valid);
assert!(result.valid);
assert_eq!(result.credentials.len(), 1);
assert!(result.credentials[0].valid);
assert!(!result.credentials[0].revoked);
}
#[test]
fn test_verify_presentation_holder_invalid() {
let creds = vec![
make_test_vc("urn:uuid:c1", "did:key:issuer", "did:key:holder"),
];
let vp = create_presentation(
"did:key:holder",
&["urn:uuid:c1"],
&creds,
|_| Ok("bad-sig".to_string()),
)
.unwrap();
let result = verify_presentation(&vp, |did, _bytes, _sig| {
Ok(did != "did:key:holder")
})
.unwrap();
assert!(!result.holder_valid);
assert!(!result.valid);
}
#[test]
fn test_presentation_serializes_as_jsonld() {
let creds = vec![
make_test_vc("urn:uuid:c1", "did:key:issuer", "did:key:holder"),
];
let vp = create_presentation(
"did:key:holder",
&["urn:uuid:c1"],
&creds,
|_| Ok("sig".to_string()),
)
.unwrap();
let json = serde_json::to_value(&vp).unwrap();
assert!(json["@context"].is_array());
assert!(json["type"].is_array());
assert_eq!(json["type"][0], "VerifiablePresentation");
assert!(json["holder"].is_string());
assert!(json["verifiableCredential"].is_array());
assert!(json["proof"]["type"].is_string());
}
}
+106
View File
@@ -0,0 +1,106 @@
use anyhow::{Context, Result};
use std::path::Path;
use tokio::fs;
use super::types::{CredentialStore, CREDENTIALS_DIR};
async fn ensure_dir(data_dir: &Path) -> Result<()> {
let dir = data_dir.join(CREDENTIALS_DIR);
if !dir.exists() {
fs::create_dir_all(&dir).await.context("Creating credentials dir")?;
}
Ok(())
}
fn store_path(data_dir: &Path) -> std::path::PathBuf {
data_dir.join(CREDENTIALS_DIR).join("credentials.json")
}
pub async fn load_credentials(data_dir: &Path) -> Result<CredentialStore> {
ensure_dir(data_dir).await?;
let path = store_path(data_dir);
if !path.exists() {
return Ok(CredentialStore::default());
}
let raw = fs::read(&path).await.context("Reading credentials")?;
// Detect plaintext JSON (migration path) vs encrypted binary
if raw.first().map_or(false, |b| *b == b'[' || *b == b'{') {
let data = String::from_utf8(raw).context("UTF-8 credentials")?;
return serde_json::from_str(&data).context("Parsing credentials");
}
// Encrypted: decrypt using node key
let key = load_encryption_key(data_dir).await?;
let plaintext = decrypt_credentials(&raw, &key)?;
serde_json::from_slice(&plaintext).context("Parsing decrypted credentials")
}
pub async fn save_credentials(data_dir: &Path, store: &CredentialStore) -> Result<()> {
ensure_dir(data_dir).await?;
let path = store_path(data_dir);
let data = serde_json::to_vec(store)?;
// Encrypt using node key
let key = load_encryption_key(data_dir).await?;
let encrypted = encrypt_credentials(&data, &key)?;
fs::write(&path, encrypted).await.context("Writing credentials")
}
/// Derive a 32-byte encryption key from the node's identity key via SHA-256.
async fn load_encryption_key(data_dir: &Path) -> Result<[u8; 32]> {
let node_key_path = data_dir.join("identity").join("node_key");
let key_bytes = fs::read(&node_key_path).await.context("Reading node key for credential encryption")?;
use sha2::{Sha256, Digest};
let mut hasher = Sha256::new();
hasher.update(b"archipelago-credential-store-v1");
hasher.update(&key_bytes);
let hash = hasher.finalize();
let mut key = [0u8; 32];
key.copy_from_slice(&hash);
Ok(key)
}
fn encrypt_credentials(data: &[u8], key: &[u8; 32]) -> Result<Vec<u8>> {
use chacha20poly1305::aead::{Aead, KeyInit};
let nonce_bytes: [u8; 12] = rand::random();
let cipher = chacha20poly1305::ChaCha20Poly1305::new_from_slice(key)
.map_err(|e| anyhow::anyhow!("Cipher init: {}", e))?;
let ciphertext = cipher
.encrypt(
chacha20poly1305::aead::generic_array::GenericArray::from_slice(&nonce_bytes),
data,
)
.map_err(|e| anyhow::anyhow!("Encryption failed: {}", e))?;
let mut output = Vec::with_capacity(12 + ciphertext.len());
output.extend_from_slice(&nonce_bytes);
output.extend_from_slice(&ciphertext);
Ok(output)
}
fn decrypt_credentials(data: &[u8], key: &[u8; 32]) -> Result<Vec<u8>> {
use chacha20poly1305::aead::{Aead, KeyInit};
if data.len() < 12 {
anyhow::bail!("Encrypted credentials too short");
}
let nonce = &data[..12];
let ciphertext = &data[12..];
let cipher = chacha20poly1305::ChaCha20Poly1305::new_from_slice(key)
.map_err(|e| anyhow::anyhow!("Cipher init: {}", e))?;
cipher
.decrypt(
chacha20poly1305::aead::generic_array::GenericArray::from_slice(nonce),
ciphertext,
)
.map_err(|_| anyhow::anyhow!("Credential decryption failed — key mismatch or corruption"))
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_load_credentials_returns_empty_when_no_file() {
let dir = tempfile::tempdir().unwrap();
let store = load_credentials(dir.path()).await.unwrap();
assert!(store.credentials.is_empty());
assert!(dir.path().join(CREDENTIALS_DIR).exists());
}
}
+89
View File
@@ -0,0 +1,89 @@
use serde::{Deserialize, Serialize};
pub(super) const CREDENTIALS_DIR: &str = "credentials";
/// W3C VC Data Model 2.0 context URI
pub(super) const VC_CONTEXT_V2: &str = "https://www.w3.org/ns/credentials/v2";
/// Ed25519 signature suite context
pub(super) const ED25519_CONTEXT: &str = "https://w3id.org/security/suites/ed25519-2020/v1";
/// A Verifiable Credential following W3C VC Data Model 2.0.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VerifiableCredential {
#[serde(rename = "@context")]
pub context: Vec<String>,
pub id: String,
#[serde(rename = "type")]
pub credential_type: Vec<String>,
pub issuer: String,
pub credential_subject: CredentialSubject,
pub issuance_date: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub expiration_date: Option<String>,
pub proof: CredentialProof,
#[serde(skip_serializing_if = "Option::is_none")]
pub credential_status: Option<CredentialStatusEntry>,
}
/// The subject of a credential with their DID and claims.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CredentialSubject {
pub id: String,
#[serde(flatten)]
pub claims: serde_json::Value,
}
/// Ed25519Signature2020 proof format.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CredentialProof {
#[serde(rename = "type")]
pub proof_type: String,
pub created: String,
pub verification_method: String,
pub proof_purpose: String,
pub proof_value: String,
}
/// Credential status for revocation tracking.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CredentialStatusEntry {
pub id: String,
#[serde(rename = "type")]
pub status_type: String,
pub status: String,
}
/// Stored credentials index.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CredentialStore {
pub credentials: Vec<VerifiableCredential>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_credential_status_display() {
assert_eq!(CredentialStatus::Active.to_string(), "active");
assert_eq!(CredentialStatus::Revoked.to_string(), "revoked");
assert_eq!(CredentialStatus::Expired.to_string(), "expired");
}
#[test]
fn test_credential_status_serde_roundtrip() {
let json = serde_json::to_string(&CredentialStatus::Revoked).unwrap();
assert_eq!(json, "\"revoked\"");
let parsed: CredentialStatus = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, CredentialStatus::Revoked);
}
#[test]
fn test_credential_store_default_is_empty() {
let store = CredentialStore::default();
assert!(store.credentials.is_empty());
}
}
-833
View File
@@ -1,833 +0,0 @@
//! Node federation: trusted multi-node clusters with state sync.
//!
//! Nodes federate by exchanging invite codes containing DID + onion address.
//! Trust is bilateral — both sides must agree. Federated nodes periodically
//! sync container status, health metrics, and availability.
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
use tokio::fs;
const FEDERATION_DIR: &str = "federation";
const NODES_FILE: &str = "nodes.json";
const INVITES_FILE: &str = "invites.json";
/// Trust level for a federated node.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TrustLevel {
Trusted,
Observer,
Untrusted,
}
impl std::fmt::Display for TrustLevel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TrustLevel::Trusted => write!(f, "trusted"),
TrustLevel::Observer => write!(f, "observer"),
TrustLevel::Untrusted => write!(f, "untrusted"),
}
}
}
/// A federated node in our cluster.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FederatedNode {
pub did: String,
pub pubkey: String,
pub onion: String,
#[serde(default)]
pub name: Option<String>,
pub trust_level: TrustLevel,
pub added_at: String,
#[serde(default)]
pub last_seen: Option<String>,
#[serde(default)]
pub last_state: Option<NodeStateSnapshot>,
}
/// State snapshot received from a federated peer during sync.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeStateSnapshot {
pub timestamp: String,
#[serde(default)]
pub node_name: Option<String>,
#[serde(default)]
pub apps: Vec<AppStatus>,
#[serde(default)]
pub cpu_usage_percent: Option<f64>,
#[serde(default)]
pub mem_used_bytes: Option<u64>,
#[serde(default)]
pub mem_total_bytes: Option<u64>,
#[serde(default)]
pub disk_used_bytes: Option<u64>,
#[serde(default)]
pub disk_total_bytes: Option<u64>,
#[serde(default)]
pub uptime_secs: Option<u64>,
#[serde(default)]
pub tor_active: Option<bool>,
}
/// Status of a single app/container on a remote node.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppStatus {
pub id: String,
pub status: String, // "running", "stopped", "installed"
#[serde(default)]
pub version: Option<String>,
}
/// A pending invite (outgoing or incoming).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FederationInvite {
pub code: String,
pub did: String,
pub onion: String,
pub pubkey: String,
pub created_at: String,
#[serde(default)]
pub accepted: bool,
}
/// Top-level file structures.
#[derive(Debug, Default, Serialize, Deserialize)]
struct NodesFile {
nodes: Vec<FederatedNode>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct InvitesFile {
outgoing: Vec<FederationInvite>,
incoming: Vec<FederationInvite>,
}
/// Ensure federation directory exists.
async fn ensure_dir(data_dir: &Path) -> Result<std::path::PathBuf> {
let dir = data_dir.join(FEDERATION_DIR);
fs::create_dir_all(&dir)
.await
.context("Failed to create federation directory")?;
Ok(dir)
}
// ──────────────────────────── Node Management ────────────────────────────
pub async fn load_nodes(data_dir: &Path) -> Result<Vec<FederatedNode>> {
let dir = data_dir.join(FEDERATION_DIR);
let path = dir.join(NODES_FILE);
if !path.exists() {
return Ok(Vec::new());
}
let content = fs::read_to_string(&path)
.await
.context("Failed to read federation nodes")?;
let file: NodesFile = serde_json::from_str(&content).unwrap_or_default();
Ok(file.nodes)
}
pub async fn save_nodes(data_dir: &Path, nodes: &[FederatedNode]) -> Result<()> {
let dir = ensure_dir(data_dir).await?;
let file = NodesFile {
nodes: nodes.to_vec(),
};
let content = serde_json::to_string_pretty(&file).context("Failed to serialize nodes")?;
fs::write(dir.join(NODES_FILE), content)
.await
.context("Failed to write federation nodes")?;
Ok(())
}
pub async fn add_node(data_dir: &Path, node: FederatedNode) -> Result<Vec<FederatedNode>> {
let mut nodes = load_nodes(data_dir).await?;
let exists = nodes.iter().any(|n| n.did == node.did);
if exists {
anyhow::bail!("Node with DID {} is already federated", node.did);
}
nodes.push(node);
save_nodes(data_dir, &nodes).await?;
Ok(nodes)
}
pub async fn remove_node(data_dir: &Path, did: &str) -> Result<Vec<FederatedNode>> {
let mut nodes = load_nodes(data_dir).await?;
let before = nodes.len();
nodes.retain(|n| n.did != did);
if nodes.len() == before {
anyhow::bail!("No federated node with DID {}", did);
}
save_nodes(data_dir, &nodes).await?;
Ok(nodes)
}
pub async fn set_trust_level(
data_dir: &Path,
did: &str,
trust: TrustLevel,
) -> Result<Vec<FederatedNode>> {
let mut nodes = load_nodes(data_dir).await?;
let node = nodes
.iter_mut()
.find(|n| n.did == did)
.ok_or_else(|| anyhow::anyhow!("No federated node with DID {}", did))?;
node.trust_level = trust;
save_nodes(data_dir, &nodes).await?;
Ok(nodes)
}
pub async fn update_node_state(
data_dir: &Path,
did: &str,
state: NodeStateSnapshot,
) -> Result<()> {
let mut nodes = load_nodes(data_dir).await?;
if let Some(node) = nodes.iter_mut().find(|n| n.did == did) {
node.last_seen = Some(state.timestamp.clone());
// Update node name from sync if provided (peer announced their name)
if let Some(ref name) = state.node_name {
if !name.is_empty() {
node.name = Some(name.clone());
}
}
node.last_state = Some(state);
save_nodes(data_dir, &nodes).await?;
}
Ok(())
}
// ──────────────────────────── Invite Management ────────────────────────────
async fn load_invites(data_dir: &Path) -> Result<InvitesFile> {
let dir = data_dir.join(FEDERATION_DIR);
let path = dir.join(INVITES_FILE);
if !path.exists() {
return Ok(InvitesFile::default());
}
let content = fs::read_to_string(&path)
.await
.context("Failed to read invites")?;
let file: InvitesFile = serde_json::from_str(&content).unwrap_or_default();
Ok(file)
}
async fn save_invites(data_dir: &Path, invites: &InvitesFile) -> Result<()> {
let dir = ensure_dir(data_dir).await?;
let content = serde_json::to_string_pretty(invites).context("Failed to serialize invites")?;
fs::write(dir.join(INVITES_FILE), content)
.await
.context("Failed to write invites")?;
Ok(())
}
/// Generate an invite code. Format: `fed1:<base64(json{did, onion, pubkey, token})>`
pub async fn create_invite(
data_dir: &Path,
did: &str,
onion: &str,
pubkey: &str,
) -> Result<String> {
use base64::Engine;
use rand::Rng;
let mut token_bytes = [0u8; 16];
rand::thread_rng().fill(&mut token_bytes);
let token = hex::encode(token_bytes);
let payload = serde_json::json!({
"did": did,
"onion": onion,
"pubkey": pubkey,
"token": token,
});
let json = serde_json::to_string(&payload).context("Failed to serialize invite")?;
let code = format!(
"fed1:{}",
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json.as_bytes())
);
let invite = FederationInvite {
code: code.clone(),
did: did.to_string(),
onion: onion.to_string(),
pubkey: pubkey.to_string(),
created_at: chrono::Utc::now().to_rfc3339(),
accepted: false,
};
let mut invites = load_invites(data_dir).await?;
invites.outgoing.push(invite);
save_invites(data_dir, &invites).await?;
Ok(code)
}
/// Parse an invite code into its components.
pub fn parse_invite(code: &str) -> Result<(String, String, String, String)> {
use base64::Engine;
let encoded = code
.strip_prefix("fed1:")
.ok_or_else(|| anyhow::anyhow!("Invalid invite format: must start with fed1:"))?;
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(encoded)
.context("Invalid base64 in invite code")?;
let payload: serde_json::Value =
serde_json::from_slice(&bytes).context("Invalid JSON in invite")?;
let did = payload["did"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing did in invite"))?
.to_string();
let onion = payload["onion"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing onion in invite"))?
.to_string();
let pubkey = payload["pubkey"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing pubkey in invite"))?
.to_string();
let token = payload["token"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing token in invite"))?
.to_string();
Ok((did, onion, pubkey, token))
}
/// Accept an invite: parse code, verify the remote node, add to federation.
pub async fn accept_invite(
data_dir: &Path,
code: &str,
local_did: &str,
local_onion: &str,
local_pubkey: &str,
sign_fn: impl FnOnce(&[u8]) -> String,
) -> Result<FederatedNode> {
let (did, onion, pubkey, _token) = parse_invite(code)?;
// Check not already federated
let nodes = load_nodes(data_dir).await?;
if nodes.iter().any(|n| n.did == did) {
anyhow::bail!("Already federated with node {}", did);
}
let node = FederatedNode {
did: did.clone(),
pubkey,
onion,
name: None,
trust_level: TrustLevel::Trusted,
added_at: chrono::Utc::now().to_rfc3339(),
last_seen: None,
last_state: None,
};
add_node(data_dir, node.clone()).await?;
// Record as incoming accepted invite
let mut invites = load_invites(data_dir).await?;
invites.incoming.push(FederationInvite {
code: code.to_string(),
did: did.clone(),
onion: node.onion.clone(),
pubkey: node.pubkey.clone(),
created_at: chrono::Utc::now().to_rfc3339(),
accepted: true,
});
save_invites(data_dir, &invites).await?;
// Notify remote node (best-effort over Tor)
let _ = notify_join(&node.onion, local_did, local_onion, local_pubkey, sign_fn).await;
Ok(node)
}
/// Best-effort notification to the remote node that we joined their federation.
/// Signs the message with our ed25519 key so the remote peer can verify authenticity.
async fn notify_join(
remote_onion: &str,
local_did: &str,
local_onion: &str,
local_pubkey: &str,
sign_fn: impl FnOnce(&[u8]) -> String,
) -> Result<()> {
let host = if remote_onion.ends_with(".onion") {
remote_onion.to_string()
} else {
format!("{}.onion", remote_onion)
};
let url = format!("http://{}/rpc/v1", host);
// Sign the canonical message: "peer-joined:{did}:{onion}:{pubkey}"
let sign_data = format!("peer-joined:{}:{}:{}", local_did, local_onion, local_pubkey);
let signature = sign_fn(sign_data.as_bytes());
let body = serde_json::json!({
"method": "federation.peer-joined",
"params": {
"did": local_did,
"onion": local_onion,
"pubkey": local_pubkey,
"signature": signature,
}
});
let proxy = reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY).context("Invalid Tor proxy")?;
let client = reqwest::Client::builder()
.proxy(proxy)
.timeout(std::time::Duration::from_secs(30))
.build()
.context("Failed to build HTTP client")?;
let _ = client.post(&url).json(&body).send().await;
Ok(())
}
/// Sync state with a single federated peer over Tor.
pub async fn sync_with_peer(
data_dir: &Path,
peer: &FederatedNode,
local_did: &str,
sign_fn: impl FnOnce(&[u8]) -> String,
) -> Result<NodeStateSnapshot> {
let host = if peer.onion.ends_with(".onion") {
peer.onion.clone()
} else {
format!("{}.onion", peer.onion)
};
let url = format!("http://{}/rpc/v1", host);
// Sign current timestamp for authentication
let timestamp = chrono::Utc::now().to_rfc3339();
let signature = sign_fn(timestamp.as_bytes());
let body = serde_json::json!({
"method": "federation.get-state",
"params": {}
});
let proxy = reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY).context("Invalid Tor proxy")?;
let client = reqwest::Client::builder()
.proxy(proxy)
.timeout(std::time::Duration::from_secs(30))
.build()
.context("Failed to build HTTP client")?;
let resp = client
.post(&url)
.header("X-Federation-DID", local_did)
.header("X-Federation-Sig", &signature)
.header("X-Federation-Timestamp", &timestamp)
.json(&body)
.send()
.await
.context("Failed to reach federated peer")?;
if !resp.status().is_success() {
anyhow::bail!("Peer returned {}", resp.status());
}
let result: serde_json::Value = resp.json().await.context("Invalid response from peer")?;
let state_val = result
.get("result")
.ok_or_else(|| anyhow::anyhow!("No result in peer response"))?;
let state: NodeStateSnapshot =
serde_json::from_value(state_val.clone()).context("Failed to parse peer state")?;
update_node_state(data_dir, &peer.did, state.clone()).await?;
Ok(state)
}
/// Sync with a peer using the transport router (Mesh > LAN > Tor).
/// Uses CBOR delta encoding for compact payloads over constrained links.
/// Falls back to `sync_with_peer()` if no transport router is available.
pub async fn sync_with_peer_via_transport(
data_dir: &Path,
peer: &FederatedNode,
local_did: &str,
previous_state: Option<&NodeStateSnapshot>,
router: &crate::transport::TransportRouter,
) -> Result<()> {
use crate::transport::{MessageType, TransportMessage};
use crate::transport::delta;
// Build the sync request payload — if we have a previous state from this peer,
// send a delta request (tells the peer we only need changes since timestamp).
let payload = if let Some(prev) = previous_state {
// Request delta since our last known state
let request = serde_json::json!({
"type": "state_sync_request",
"since": prev.timestamp,
});
delta::encode_cbor(&delta::StateDelta {
ts: prev.timestamp.clone(),
v: 1,
..Default::default()
})?
} else {
// First sync — request full state
let request = serde_json::json!({ "type": "state_sync_request" });
serde_json::to_vec(&request)?
};
let message = TransportMessage {
from_did: local_did.to_string(),
payload,
message_type: MessageType::StateSync,
};
let transport_used = router.send_to_peer(&peer.did, &message).await?;
tracing::info!(
peer = %peer.did,
transport = %transport_used,
"Federation sync sent via transport"
);
Ok(())
}
/// Build the local node's state snapshot for sharing with peers.
pub fn build_local_state(
apps: Vec<AppStatus>,
cpu: f64,
mem_used: u64,
mem_total: u64,
disk_used: u64,
disk_total: u64,
uptime: u64,
tor_active: bool,
server_name: Option<String>,
) -> NodeStateSnapshot {
NodeStateSnapshot {
timestamp: chrono::Utc::now().to_rfc3339(),
node_name: server_name,
apps,
cpu_usage_percent: Some(cpu),
mem_used_bytes: Some(mem_used),
mem_total_bytes: Some(mem_total),
disk_used_bytes: Some(disk_used),
disk_total_bytes: Some(disk_total),
uptime_secs: Some(uptime),
tor_active: Some(tor_active),
}
}
/// Deploy an app to a remote federated peer over Tor.
/// Only works if the peer is trusted and the app exists in our marketplace.
pub async fn deploy_to_peer(
peer: &FederatedNode,
app_id: &str,
version: &str,
marketplace_url: &str,
local_did: &str,
sign_fn: impl FnOnce(&[u8]) -> String,
) -> Result<serde_json::Value> {
if peer.trust_level != TrustLevel::Trusted {
anyhow::bail!("Can only deploy to trusted peers (current: {})", peer.trust_level);
}
let host = if peer.onion.ends_with(".onion") {
peer.onion.clone()
} else {
format!("{}.onion", peer.onion)
};
let url = format!("http://{}/rpc/v1", host);
let timestamp = chrono::Utc::now().to_rfc3339();
let signature = sign_fn(timestamp.as_bytes());
let body = serde_json::json!({
"method": "package.install",
"params": {
"id": app_id,
"version": version,
"marketplace-url": marketplace_url,
}
});
let proxy = reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY).context("Invalid Tor proxy")?;
let client = reqwest::Client::builder()
.proxy(proxy)
.timeout(std::time::Duration::from_secs(120))
.build()
.context("Failed to build HTTP client")?;
let resp = client
.post(&url)
.header("X-Federation-DID", local_did)
.header("X-Federation-Sig", &signature)
.header("X-Federation-Timestamp", &timestamp)
.json(&body)
.send()
.await
.context("Failed to reach federated peer for deploy")?;
if !resp.status().is_success() {
anyhow::bail!("Remote node returned HTTP {}", resp.status());
}
let result: serde_json::Value = resp.json().await.context("Invalid response from peer")?;
if let Some(err) = result.get("error") {
if !err.is_null() {
let msg = err.get("message").and_then(|m| m.as_str()).unwrap_or("Unknown remote error");
anyhow::bail!("Remote node refused deploy: {}", msg);
}
}
Ok(serde_json::json!({
"deployed": true,
"app_id": app_id,
"peer_did": peer.did,
"peer_onion": peer.onion,
}))
}
#[cfg(test)]
mod tests {
use super::*;
fn make_node(did: &str, onion: &str) -> FederatedNode {
FederatedNode {
did: did.to_string(),
pubkey: "aabbccdd".to_string(),
onion: onion.to_string(),
name: None,
trust_level: TrustLevel::Trusted,
added_at: "2026-01-01T00:00:00Z".to_string(),
last_seen: None,
last_state: None,
}
}
#[test]
fn test_trust_level_serialization() {
let json = serde_json::to_string(&TrustLevel::Trusted).unwrap();
assert_eq!(json, "\"trusted\"");
let parsed: TrustLevel = serde_json::from_str("\"observer\"").unwrap();
assert_eq!(parsed, TrustLevel::Observer);
}
#[test]
fn test_federated_node_serialization_roundtrip() {
let node = make_node("did:key:zABC", "test.onion");
let json = serde_json::to_string(&node).unwrap();
let parsed: FederatedNode = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.did, "did:key:zABC");
assert_eq!(parsed.trust_level, TrustLevel::Trusted);
assert!(parsed.last_state.is_none());
}
#[test]
fn test_node_state_snapshot_defaults() {
let json = r#"{"timestamp": "2026-01-01T00:00:00Z"}"#;
let state: NodeStateSnapshot = serde_json::from_str(json).unwrap();
assert!(state.apps.is_empty());
assert!(state.cpu_usage_percent.is_none());
}
#[tokio::test]
async fn test_load_nodes_empty_when_no_file() {
let dir = tempfile::tempdir().unwrap();
let nodes = load_nodes(dir.path()).await.unwrap();
assert!(nodes.is_empty());
}
#[tokio::test]
async fn test_save_and_load_nodes_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let nodes = vec![
make_node("did:key:z1", "a.onion"),
make_node("did:key:z2", "b.onion"),
];
save_nodes(dir.path(), &nodes).await.unwrap();
let loaded = load_nodes(dir.path()).await.unwrap();
assert_eq!(loaded.len(), 2);
assert_eq!(loaded[0].did, "did:key:z1");
}
#[tokio::test]
async fn test_add_node_deduplicates_by_did() {
let dir = tempfile::tempdir().unwrap();
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
.await
.unwrap();
let result = add_node(dir.path(), make_node("did:key:z1", "b.onion")).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_remove_node_by_did() {
let dir = tempfile::tempdir().unwrap();
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
.await
.unwrap();
add_node(dir.path(), make_node("did:key:z2", "b.onion"))
.await
.unwrap();
let result = remove_node(dir.path(), "did:key:z1").await.unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].did, "did:key:z2");
}
#[tokio::test]
async fn test_remove_nonexistent_node_errors() {
let dir = tempfile::tempdir().unwrap();
let result = remove_node(dir.path(), "did:key:nonexistent").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_set_trust_level() {
let dir = tempfile::tempdir().unwrap();
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
.await
.unwrap();
let nodes = set_trust_level(dir.path(), "did:key:z1", TrustLevel::Observer)
.await
.unwrap();
assert_eq!(nodes[0].trust_level, TrustLevel::Observer);
}
#[tokio::test]
async fn test_update_node_state() {
let dir = tempfile::tempdir().unwrap();
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
.await
.unwrap();
let state = NodeStateSnapshot {
timestamp: "2026-03-10T12:00:00Z".to_string(),
apps: vec![AppStatus {
id: "bitcoin".to_string(),
status: "running".to_string(),
version: Some("27.0".to_string()),
}],
cpu_usage_percent: Some(45.2),
mem_used_bytes: Some(4_000_000_000),
mem_total_bytes: Some(8_000_000_000),
disk_used_bytes: None,
disk_total_bytes: None,
uptime_secs: Some(86400),
tor_active: Some(true),
};
update_node_state(dir.path(), "did:key:z1", state)
.await
.unwrap();
let nodes = load_nodes(dir.path()).await.unwrap();
assert!(nodes[0].last_seen.is_some());
let ls = nodes[0].last_state.as_ref().unwrap();
assert_eq!(ls.apps.len(), 1);
assert_eq!(ls.cpu_usage_percent, Some(45.2));
}
#[tokio::test]
async fn test_create_and_parse_invite() {
let dir = tempfile::tempdir().unwrap();
let code = create_invite(dir.path(), "did:key:z1", "test.onion", "aabbcc")
.await
.unwrap();
assert!(code.starts_with("fed1:"));
let (did, onion, pubkey, token) = parse_invite(&code).unwrap();
assert_eq!(did, "did:key:z1");
assert_eq!(onion, "test.onion");
assert_eq!(pubkey, "aabbcc");
assert_eq!(token.len(), 32); // 16 bytes = 32 hex chars
}
#[test]
fn test_parse_invalid_invite() {
assert!(parse_invite("invalid").is_err());
assert!(parse_invite("fed1:not-valid-base64!!!").is_err());
}
#[tokio::test]
async fn test_accept_invite_creates_node() {
let dir = tempfile::tempdir().unwrap();
let code = create_invite(dir.path(), "did:key:zRemote", "remote.onion", "remotepub")
.await
.unwrap();
// Accept from a different "local" perspective
let dir2 = tempfile::tempdir().unwrap();
let node = accept_invite(
dir2.path(),
&code,
"did:key:zLocal",
"local.onion",
"localpub",
)
.await
.unwrap();
assert_eq!(node.did, "did:key:zRemote");
assert_eq!(node.trust_level, TrustLevel::Trusted);
let nodes = load_nodes(dir2.path()).await.unwrap();
assert_eq!(nodes.len(), 1);
}
#[tokio::test]
async fn test_accept_invite_rejects_duplicate() {
let dir = tempfile::tempdir().unwrap();
let code = create_invite(dir.path(), "did:key:zRemote", "remote.onion", "remotepub")
.await
.unwrap();
let dir2 = tempfile::tempdir().unwrap();
accept_invite(
dir2.path(),
&code,
"did:key:zLocal",
"local.onion",
"localpub",
)
.await
.unwrap();
// Accepting the same invite again should fail
let result = accept_invite(
dir2.path(),
&code,
"did:key:zLocal",
"local.onion",
"localpub",
)
.await;
assert!(result.is_err());
}
#[test]
fn test_build_local_state() {
let state = build_local_state(
vec![AppStatus {
id: "lnd".to_string(),
status: "running".to_string(),
version: Some("0.18".to_string()),
}],
25.5,
2_000_000_000,
8_000_000_000,
100_000_000_000,
500_000_000_000,
3600,
true,
Some("Test Node".to_string()),
);
assert_eq!(state.apps.len(), 1);
assert_eq!(state.cpu_usage_percent, Some(25.5));
assert_eq!(state.tor_active, Some(true));
assert_eq!(state.node_name, Some("Test Node".to_string()));
}
}
+259
View File
@@ -0,0 +1,259 @@
//! Federation invite creation, parsing, and acceptance.
use anyhow::{Context, Result};
use std::path::Path;
use super::storage::{add_node, load_invites, load_nodes, save_invites};
use super::types::{FederatedNode, FederationInvite, TrustLevel};
/// Generate an invite code. Format: `fed1:<base64(json{did, onion, pubkey, token})>`
pub async fn create_invite(
data_dir: &Path,
did: &str,
onion: &str,
pubkey: &str,
) -> Result<String> {
use base64::Engine;
use rand::Rng;
let mut token_bytes = [0u8; 16];
rand::thread_rng().fill(&mut token_bytes);
let token = hex::encode(token_bytes);
let payload = serde_json::json!({
"did": did,
"onion": onion,
"pubkey": pubkey,
"token": token,
});
let json = serde_json::to_string(&payload).context("Failed to serialize invite")?;
let code = format!(
"fed1:{}",
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json.as_bytes())
);
let invite = FederationInvite {
code: code.clone(),
did: did.to_string(),
onion: onion.to_string(),
pubkey: pubkey.to_string(),
created_at: chrono::Utc::now().to_rfc3339(),
accepted: false,
};
let mut invites = load_invites(data_dir).await?;
invites.outgoing.push(invite);
save_invites(data_dir, &invites).await?;
Ok(code)
}
/// Parse an invite code into its components.
pub fn parse_invite(code: &str) -> Result<(String, String, String, String)> {
use base64::Engine;
let encoded = code
.strip_prefix("fed1:")
.ok_or_else(|| anyhow::anyhow!("Invalid invite format: must start with fed1:"))?;
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(encoded)
.context("Invalid base64 in invite code")?;
let payload: serde_json::Value =
serde_json::from_slice(&bytes).context("Invalid JSON in invite")?;
let did = payload["did"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing did in invite"))?
.to_string();
let onion = payload["onion"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing onion in invite"))?
.to_string();
let pubkey = payload["pubkey"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing pubkey in invite"))?
.to_string();
let token = payload["token"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing token in invite"))?
.to_string();
Ok((did, onion, pubkey, token))
}
/// Accept an invite: parse code, verify the remote node, add to federation.
pub async fn accept_invite(
data_dir: &Path,
code: &str,
local_did: &str,
local_onion: &str,
local_pubkey: &str,
sign_fn: impl FnOnce(&[u8]) -> String,
) -> Result<FederatedNode> {
let (did, onion, pubkey, _token) = parse_invite(code)?;
// Check not already federated
let nodes = load_nodes(data_dir).await?;
if nodes.iter().any(|n| n.did == did) {
anyhow::bail!("Already federated with node {}", did);
}
let node = FederatedNode {
did: did.clone(),
pubkey,
onion,
name: None,
trust_level: TrustLevel::Trusted,
added_at: chrono::Utc::now().to_rfc3339(),
last_seen: None,
last_state: None,
};
add_node(data_dir, node.clone()).await?;
// Record as incoming accepted invite
let mut invites = load_invites(data_dir).await?;
invites.incoming.push(FederationInvite {
code: code.to_string(),
did: did.clone(),
onion: node.onion.clone(),
pubkey: node.pubkey.clone(),
created_at: chrono::Utc::now().to_rfc3339(),
accepted: true,
});
save_invites(data_dir, &invites).await?;
// Notify remote node (best-effort over Tor)
let _ = notify_join(&node.onion, local_did, local_onion, local_pubkey, sign_fn).await;
Ok(node)
}
/// Best-effort notification to the remote node that we joined their federation.
/// Signs the message with our ed25519 key so the remote peer can verify authenticity.
async fn notify_join(
remote_onion: &str,
local_did: &str,
local_onion: &str,
local_pubkey: &str,
sign_fn: impl FnOnce(&[u8]) -> String,
) -> Result<()> {
let host = if remote_onion.ends_with(".onion") {
remote_onion.to_string()
} else {
format!("{}.onion", remote_onion)
};
let url = format!("http://{}/rpc/v1", host);
// Sign the canonical message: "peer-joined:{did}:{onion}:{pubkey}"
let sign_data = format!("peer-joined:{}:{}:{}", local_did, local_onion, local_pubkey);
let signature = sign_fn(sign_data.as_bytes());
let body = serde_json::json!({
"method": "federation.peer-joined",
"params": {
"did": local_did,
"onion": local_onion,
"pubkey": local_pubkey,
"signature": signature,
}
});
let proxy = reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY).context("Invalid Tor proxy")?;
let client = reqwest::Client::builder()
.proxy(proxy)
.timeout(std::time::Duration::from_secs(30))
.build()
.context("Failed to build HTTP client")?;
let _ = client.post(&url).json(&body).send().await;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::federation::storage::load_nodes;
#[tokio::test]
async fn test_create_and_parse_invite() {
let dir = tempfile::tempdir().unwrap();
let code = create_invite(dir.path(), "did:key:z1", "test.onion", "aabbcc")
.await
.unwrap();
assert!(code.starts_with("fed1:"));
let (did, onion, pubkey, token) = parse_invite(&code).unwrap();
assert_eq!(did, "did:key:z1");
assert_eq!(onion, "test.onion");
assert_eq!(pubkey, "aabbcc");
assert_eq!(token.len(), 32); // 16 bytes = 32 hex chars
}
#[test]
fn test_parse_invalid_invite() {
assert!(parse_invite("invalid").is_err());
assert!(parse_invite("fed1:not-valid-base64!!!").is_err());
}
#[tokio::test]
async fn test_accept_invite_creates_node() {
let dir = tempfile::tempdir().unwrap();
let code = create_invite(dir.path(), "did:key:zRemote", "remote.onion", "remotepub")
.await
.unwrap();
// Accept from a different "local" perspective
let dir2 = tempfile::tempdir().unwrap();
let node = accept_invite(
dir2.path(),
&code,
"did:key:zLocal",
"local.onion",
"localpub",
|_| "test-sig".to_string(),
)
.await
.unwrap();
assert_eq!(node.did, "did:key:zRemote");
assert_eq!(node.trust_level, TrustLevel::Trusted);
let nodes = load_nodes(dir2.path()).await.unwrap();
assert_eq!(nodes.len(), 1);
}
#[tokio::test]
async fn test_accept_invite_rejects_duplicate() {
let dir = tempfile::tempdir().unwrap();
let code = create_invite(dir.path(), "did:key:zRemote", "remote.onion", "remotepub")
.await
.unwrap();
let dir2 = tempfile::tempdir().unwrap();
accept_invite(
dir2.path(),
&code,
"did:key:zLocal",
"local.onion",
"localpub",
|_| "test-sig".to_string(),
)
.await
.unwrap();
// Accepting the same invite again should fail
let result = accept_invite(
dir2.path(),
&code,
"did:key:zLocal",
"local.onion",
"localpub",
|_| "test-sig".to_string(),
)
.await;
assert!(result.is_err());
}
}
+18
View File
@@ -0,0 +1,18 @@
//! Node federation: trusted multi-node clusters with state sync.
//!
//! Nodes federate by exchanging invite codes containing DID + onion address.
//! Trust is bilateral — both sides must agree. Federated nodes periodically
//! sync container status, health metrics, and availability.
mod invites;
mod storage;
mod sync;
mod types;
// Re-export all public items so `crate::federation::*` continues to work.
pub use invites::{accept_invite, create_invite};
pub use storage::{
add_node, load_nodes, remove_node, save_nodes, set_trust_level,
};
pub use sync::{build_local_state, deploy_to_peer, sync_with_peer};
pub use types::{AppStatus, FederatedNode, NodeStateSnapshot, TrustLevel};
+258
View File
@@ -0,0 +1,258 @@
//! Federation persistent storage: node list and invite management on disk.
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
use tokio::fs;
use super::types::{FederatedNode, FederationInvite, NodeStateSnapshot, TrustLevel};
pub(crate) const FEDERATION_DIR: &str = "federation";
pub(crate) const NODES_FILE: &str = "nodes.json";
pub(crate) const INVITES_FILE: &str = "invites.json";
/// Top-level file structures.
#[derive(Debug, Default, Serialize, Deserialize)]
pub(crate) struct NodesFile {
pub(crate) nodes: Vec<FederatedNode>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub(crate) struct InvitesFile {
pub(crate) outgoing: Vec<FederationInvite>,
pub(crate) incoming: Vec<FederationInvite>,
}
/// Ensure federation directory exists.
pub(crate) async fn ensure_dir(data_dir: &Path) -> Result<std::path::PathBuf> {
let dir = data_dir.join(FEDERATION_DIR);
fs::create_dir_all(&dir)
.await
.context("Failed to create federation directory")?;
Ok(dir)
}
// ──────────────────────────── Node Management ────────────────────────────
pub async fn load_nodes(data_dir: &Path) -> Result<Vec<FederatedNode>> {
let dir = data_dir.join(FEDERATION_DIR);
let path = dir.join(NODES_FILE);
if !path.exists() {
return Ok(Vec::new());
}
let content = fs::read_to_string(&path)
.await
.context("Failed to read federation nodes")?;
let file: NodesFile = serde_json::from_str(&content).unwrap_or_default();
Ok(file.nodes)
}
pub async fn save_nodes(data_dir: &Path, nodes: &[FederatedNode]) -> Result<()> {
let dir = ensure_dir(data_dir).await?;
let file = NodesFile {
nodes: nodes.to_vec(),
};
let content = serde_json::to_string_pretty(&file).context("Failed to serialize nodes")?;
fs::write(dir.join(NODES_FILE), content)
.await
.context("Failed to write federation nodes")?;
Ok(())
}
pub async fn add_node(data_dir: &Path, node: FederatedNode) -> Result<Vec<FederatedNode>> {
let mut nodes = load_nodes(data_dir).await?;
let exists = nodes.iter().any(|n| n.did == node.did);
if exists {
anyhow::bail!("Node with DID {} is already federated", node.did);
}
nodes.push(node);
save_nodes(data_dir, &nodes).await?;
Ok(nodes)
}
pub async fn remove_node(data_dir: &Path, did: &str) -> Result<Vec<FederatedNode>> {
let mut nodes = load_nodes(data_dir).await?;
let before = nodes.len();
nodes.retain(|n| n.did != did);
if nodes.len() == before {
anyhow::bail!("No federated node with DID {}", did);
}
save_nodes(data_dir, &nodes).await?;
Ok(nodes)
}
pub async fn set_trust_level(
data_dir: &Path,
did: &str,
trust: TrustLevel,
) -> Result<Vec<FederatedNode>> {
let mut nodes = load_nodes(data_dir).await?;
let node = nodes
.iter_mut()
.find(|n| n.did == did)
.ok_or_else(|| anyhow::anyhow!("No federated node with DID {}", did))?;
node.trust_level = trust;
save_nodes(data_dir, &nodes).await?;
Ok(nodes)
}
pub async fn update_node_state(
data_dir: &Path,
did: &str,
state: NodeStateSnapshot,
) -> Result<()> {
let mut nodes = load_nodes(data_dir).await?;
if let Some(node) = nodes.iter_mut().find(|n| n.did == did) {
node.last_seen = Some(state.timestamp.clone());
// Update node name from sync if provided (peer announced their name)
if let Some(ref name) = state.node_name {
if !name.is_empty() {
node.name = Some(name.clone());
}
}
node.last_state = Some(state);
save_nodes(data_dir, &nodes).await?;
}
Ok(())
}
// ──────────────────────────── Invite Storage ────────────────────────────
pub(crate) async fn load_invites(data_dir: &Path) -> Result<InvitesFile> {
let dir = data_dir.join(FEDERATION_DIR);
let path = dir.join(INVITES_FILE);
if !path.exists() {
return Ok(InvitesFile::default());
}
let content = fs::read_to_string(&path)
.await
.context("Failed to read invites")?;
let file: InvitesFile = serde_json::from_str(&content).unwrap_or_default();
Ok(file)
}
pub(crate) async fn save_invites(data_dir: &Path, invites: &InvitesFile) -> Result<()> {
let dir = ensure_dir(data_dir).await?;
let content = serde_json::to_string_pretty(invites).context("Failed to serialize invites")?;
fs::write(dir.join(INVITES_FILE), content)
.await
.context("Failed to write invites")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::federation::types::AppStatus;
fn make_node(did: &str, onion: &str) -> FederatedNode {
FederatedNode {
did: did.to_string(),
pubkey: "aabbccdd".to_string(),
onion: onion.to_string(),
name: None,
trust_level: TrustLevel::Trusted,
added_at: "2026-01-01T00:00:00Z".to_string(),
last_seen: None,
last_state: None,
}
}
#[tokio::test]
async fn test_load_nodes_empty_when_no_file() {
let dir = tempfile::tempdir().unwrap();
let nodes = load_nodes(dir.path()).await.unwrap();
assert!(nodes.is_empty());
}
#[tokio::test]
async fn test_save_and_load_nodes_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let nodes = vec![
make_node("did:key:z1", "a.onion"),
make_node("did:key:z2", "b.onion"),
];
save_nodes(dir.path(), &nodes).await.unwrap();
let loaded = load_nodes(dir.path()).await.unwrap();
assert_eq!(loaded.len(), 2);
assert_eq!(loaded[0].did, "did:key:z1");
}
#[tokio::test]
async fn test_add_node_deduplicates_by_did() {
let dir = tempfile::tempdir().unwrap();
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
.await
.unwrap();
let result = add_node(dir.path(), make_node("did:key:z1", "b.onion")).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_remove_node_by_did() {
let dir = tempfile::tempdir().unwrap();
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
.await
.unwrap();
add_node(dir.path(), make_node("did:key:z2", "b.onion"))
.await
.unwrap();
let result = remove_node(dir.path(), "did:key:z1").await.unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].did, "did:key:z2");
}
#[tokio::test]
async fn test_remove_nonexistent_node_errors() {
let dir = tempfile::tempdir().unwrap();
let result = remove_node(dir.path(), "did:key:nonexistent").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_set_trust_level() {
let dir = tempfile::tempdir().unwrap();
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
.await
.unwrap();
let nodes = set_trust_level(dir.path(), "did:key:z1", TrustLevel::Observer)
.await
.unwrap();
assert_eq!(nodes[0].trust_level, TrustLevel::Observer);
}
#[tokio::test]
async fn test_update_node_state() {
let dir = tempfile::tempdir().unwrap();
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
.await
.unwrap();
let state = NodeStateSnapshot {
timestamp: "2026-03-10T12:00:00Z".to_string(),
node_name: None,
apps: vec![AppStatus {
id: "bitcoin".to_string(),
status: "running".to_string(),
version: Some("27.0".to_string()),
}],
cpu_usage_percent: Some(45.2),
mem_used_bytes: Some(4_000_000_000),
mem_total_bytes: Some(8_000_000_000),
disk_used_bytes: None,
disk_total_bytes: None,
uptime_secs: Some(86400),
tor_active: Some(true),
};
update_node_state(dir.path(), "did:key:z1", state)
.await
.unwrap();
let nodes = load_nodes(dir.path()).await.unwrap();
assert!(nodes[0].last_seen.is_some());
let ls = nodes[0].last_state.as_ref().unwrap();
assert_eq!(ls.apps.len(), 1);
assert_eq!(ls.cpu_usage_percent, Some(45.2));
}
}
+189
View File
@@ -0,0 +1,189 @@
//! Federation state sync and remote deployment.
use anyhow::{Context, Result};
use std::path::Path;
use super::storage::update_node_state;
use super::types::{AppStatus, FederatedNode, NodeStateSnapshot, TrustLevel};
/// Sync state with a single federated peer over Tor.
pub async fn sync_with_peer(
data_dir: &Path,
peer: &FederatedNode,
local_did: &str,
sign_fn: impl FnOnce(&[u8]) -> String,
) -> Result<NodeStateSnapshot> {
let host = if peer.onion.ends_with(".onion") {
peer.onion.clone()
} else {
format!("{}.onion", peer.onion)
};
let url = format!("http://{}/rpc/v1", host);
// Sign current timestamp for authentication
let timestamp = chrono::Utc::now().to_rfc3339();
let signature = sign_fn(timestamp.as_bytes());
let body = serde_json::json!({
"method": "federation.get-state",
"params": {}
});
let proxy = reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY).context("Invalid Tor proxy")?;
let client = reqwest::Client::builder()
.proxy(proxy)
.timeout(std::time::Duration::from_secs(30))
.build()
.context("Failed to build HTTP client")?;
let resp = client
.post(&url)
.header("X-Federation-DID", local_did)
.header("X-Federation-Sig", &signature)
.header("X-Federation-Timestamp", &timestamp)
.json(&body)
.send()
.await
.context("Failed to reach federated peer")?;
if !resp.status().is_success() {
anyhow::bail!("Peer returned {}", resp.status());
}
let result: serde_json::Value = resp.json().await.context("Invalid response from peer")?;
let state_val = result
.get("result")
.ok_or_else(|| anyhow::anyhow!("No result in peer response"))?;
let state: NodeStateSnapshot =
serde_json::from_value(state_val.clone()).context("Failed to parse peer state")?;
update_node_state(data_dir, &peer.did, state.clone()).await?;
Ok(state)
}
/// Build the local node's state snapshot for sharing with peers.
pub fn build_local_state(
apps: Vec<AppStatus>,
cpu: f64,
mem_used: u64,
mem_total: u64,
disk_used: u64,
disk_total: u64,
uptime: u64,
tor_active: bool,
server_name: Option<String>,
) -> NodeStateSnapshot {
NodeStateSnapshot {
timestamp: chrono::Utc::now().to_rfc3339(),
node_name: server_name,
apps,
cpu_usage_percent: Some(cpu),
mem_used_bytes: Some(mem_used),
mem_total_bytes: Some(mem_total),
disk_used_bytes: Some(disk_used),
disk_total_bytes: Some(disk_total),
uptime_secs: Some(uptime),
tor_active: Some(tor_active),
}
}
/// Deploy an app to a remote federated peer over Tor.
/// Only works if the peer is trusted and the app exists in our marketplace.
pub async fn deploy_to_peer(
peer: &FederatedNode,
app_id: &str,
version: &str,
marketplace_url: &str,
local_did: &str,
sign_fn: impl FnOnce(&[u8]) -> String,
) -> Result<serde_json::Value> {
if peer.trust_level != TrustLevel::Trusted {
anyhow::bail!("Can only deploy to trusted peers (current: {})", peer.trust_level);
}
let host = if peer.onion.ends_with(".onion") {
peer.onion.clone()
} else {
format!("{}.onion", peer.onion)
};
let url = format!("http://{}/rpc/v1", host);
let timestamp = chrono::Utc::now().to_rfc3339();
let signature = sign_fn(timestamp.as_bytes());
let body = serde_json::json!({
"method": "package.install",
"params": {
"id": app_id,
"version": version,
"marketplace-url": marketplace_url,
}
});
let proxy = reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY).context("Invalid Tor proxy")?;
let client = reqwest::Client::builder()
.proxy(proxy)
.timeout(std::time::Duration::from_secs(120))
.build()
.context("Failed to build HTTP client")?;
let resp = client
.post(&url)
.header("X-Federation-DID", local_did)
.header("X-Federation-Sig", &signature)
.header("X-Federation-Timestamp", &timestamp)
.json(&body)
.send()
.await
.context("Failed to reach federated peer for deploy")?;
if !resp.status().is_success() {
anyhow::bail!("Remote node returned HTTP {}", resp.status());
}
let result: serde_json::Value = resp.json().await.context("Invalid response from peer")?;
if let Some(err) = result.get("error") {
if !err.is_null() {
let msg = err.get("message").and_then(|m| m.as_str()).unwrap_or("Unknown remote error");
anyhow::bail!("Remote node refused deploy: {}", msg);
}
}
Ok(serde_json::json!({
"deployed": true,
"app_id": app_id,
"peer_did": peer.did,
"peer_onion": peer.onion,
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_local_state() {
let state = build_local_state(
vec![AppStatus {
id: "lnd".to_string(),
status: "running".to_string(),
version: Some("0.18".to_string()),
}],
25.5,
2_000_000_000,
8_000_000_000,
100_000_000_000,
500_000_000_000,
3600,
true,
Some("Test Node".to_string()),
);
assert_eq!(state.apps.len(), 1);
assert_eq!(state.cpu_usage_percent, Some(25.5));
assert_eq!(state.tor_active, Some(true));
assert_eq!(state.node_name, Some("Test Node".to_string()));
}
}
+124
View File
@@ -0,0 +1,124 @@
//! Federation type definitions.
use serde::{Deserialize, Serialize};
/// Trust level for a federated node.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TrustLevel {
Trusted,
Observer,
Untrusted,
}
impl std::fmt::Display for TrustLevel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TrustLevel::Trusted => write!(f, "trusted"),
TrustLevel::Observer => write!(f, "observer"),
TrustLevel::Untrusted => write!(f, "untrusted"),
}
}
}
/// A federated node in our cluster.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FederatedNode {
pub did: String,
pub pubkey: String,
pub onion: String,
#[serde(default)]
pub name: Option<String>,
pub trust_level: TrustLevel,
pub added_at: String,
#[serde(default)]
pub last_seen: Option<String>,
#[serde(default)]
pub last_state: Option<NodeStateSnapshot>,
}
/// State snapshot received from a federated peer during sync.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeStateSnapshot {
pub timestamp: String,
#[serde(default)]
pub node_name: Option<String>,
#[serde(default)]
pub apps: Vec<AppStatus>,
#[serde(default)]
pub cpu_usage_percent: Option<f64>,
#[serde(default)]
pub mem_used_bytes: Option<u64>,
#[serde(default)]
pub mem_total_bytes: Option<u64>,
#[serde(default)]
pub disk_used_bytes: Option<u64>,
#[serde(default)]
pub disk_total_bytes: Option<u64>,
#[serde(default)]
pub uptime_secs: Option<u64>,
#[serde(default)]
pub tor_active: Option<bool>,
}
/// Status of a single app/container on a remote node.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppStatus {
pub id: String,
pub status: String, // "running", "stopped", "installed"
#[serde(default)]
pub version: Option<String>,
}
/// A pending invite (outgoing or incoming).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FederationInvite {
pub code: String,
pub did: String,
pub onion: String,
pub pubkey: String,
pub created_at: String,
#[serde(default)]
pub accepted: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_trust_level_serialization() {
let json = serde_json::to_string(&TrustLevel::Trusted).unwrap();
assert_eq!(json, "\"trusted\"");
let parsed: TrustLevel = serde_json::from_str("\"observer\"").unwrap();
assert_eq!(parsed, TrustLevel::Observer);
}
#[test]
fn test_federated_node_serialization_roundtrip() {
let node = FederatedNode {
did: "did:key:zABC".to_string(),
pubkey: "aabbccdd".to_string(),
onion: "test.onion".to_string(),
name: None,
trust_level: TrustLevel::Trusted,
added_at: "2026-01-01T00:00:00Z".to_string(),
last_seen: None,
last_state: None,
};
let json = serde_json::to_string(&node).unwrap();
let parsed: FederatedNode = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.did, "did:key:zABC");
assert_eq!(parsed.trust_level, TrustLevel::Trusted);
assert!(parsed.last_state.is_none());
}
#[test]
fn test_node_state_snapshot_defaults() {
let json = r#"{"timestamp": "2026-01-01T00:00:00Z"}"#;
let state: NodeStateSnapshot = serde_json::from_str(json).unwrap();
assert!(state.apps.is_empty());
assert!(state.cpu_usage_percent.is_none());
}
}
-3
View File
@@ -175,9 +175,6 @@ impl MemoryTracker {
}
}
fn remove(&mut self, name: &str) {
self.samples.remove(name);
}
}
/// Query container memory stats from podman.
+2 -7
View File
@@ -15,7 +15,7 @@ const NODE_KEY_PUB_FILE: &str = "node_key.pub";
/// Survives reboots; used for signing, verification, and node address.
pub struct NodeIdentity {
signing_key: SigningKey,
identity_dir: PathBuf,
_identity_dir: PathBuf,
}
impl NodeIdentity {
@@ -60,7 +60,7 @@ impl NodeIdentity {
Ok(Self {
signing_key,
identity_dir: identity_dir.to_path_buf(),
_identity_dir: identity_dir.to_path_buf(),
})
}
@@ -115,11 +115,6 @@ impl NodeIdentity {
.map_err(|e| anyhow::anyhow!("Invalid pubkey hex: {}", e))
}
/// Generate a W3C DID Core v1.0 compliant DID Document.
pub fn did_document(&self) -> Result<serde_json::Value> {
did_document_from_pubkey_hex(&self.pubkey_hex())
.map_err(|e| anyhow::anyhow!("Invalid pubkey hex: {}", e))
}
}
/// Convert Ed25519 pubkey (hex) to did:key format.
+3 -2
View File
@@ -14,7 +14,7 @@ mod bitcoin_rpc;
mod config;
mod content_server;
mod crash_recovery;
mod cluster;mod credentials;
mod credentials;
mod disk_monitor;
mod health_monitor;
mod electrs_status;
@@ -33,6 +33,7 @@ mod nostr_discovery;
mod nostr_handshake;
mod peers;
mod server;
mod rate_limit;
mod session;
mod state;
mod totp;
@@ -41,7 +42,7 @@ mod names;
mod network;
mod nostr_relays;
mod update;
mod tpm;mod vpn;
mod vpn;
mod webhooks;
use auth::AuthManager;
+5 -13
View File
@@ -137,14 +137,6 @@ pub enum ManifestDescription {
Detailed { short: String, long: String },
}
impl ManifestDescription {
pub fn short(&self) -> &str {
match self {
Self::Simple(s) => s,
Self::Detailed { short, .. } => short,
}
}
}
/// A discovered marketplace app with trust scoring.
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -507,11 +499,11 @@ async fn load_or_create_keys(identity_dir: &Path) -> Result<nostr_sdk::prelude::
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let sp = secret_path.clone();
tokio::task::spawn_blocking(move || {
std::fs::set_permissions(sp, std::fs::Permissions::from_mode(0o600))
})
.await??;
tokio::fs::set_permissions(
&secret_path,
std::fs::Permissions::from_mode(0o600),
)
.await?;
}
Ok(keys)
}
+2 -2
View File
@@ -1,3 +1,5 @@
// WIP mesh/transport protocol — suppress dead code warnings
#![allow(dead_code)]
//! Emergency alert system and dead man's switch for mesh networking.
//!
//! The dead man's switch automatically broadcasts a signed alert with GPS
@@ -11,10 +13,8 @@ use super::message_types::{
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
use tracing::{info, warn};
/// Default dead man's switch interval: 6 hours.
const DEFAULT_INTERVAL_SECS: u64 = 21600;
+3 -3
View File
@@ -1,3 +1,5 @@
// WIP mesh/transport protocol — suppress dead code warnings
#![allow(dead_code)]
//! Off-grid Bitcoin operations over mesh radio.
//!
//! Enables mesh-only nodes (no internet) to:
@@ -12,11 +14,9 @@ use super::message_types::{
MeshMessageType, TxRelayPayload, TxRelayResponsePayload, TypedEnvelope,
};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tokio::sync::RwLock;
use tracing::{debug, info, warn};
use tracing::warn;
// ─── Block Header Cache ─────────────────────────────────────────────────
+2
View File
@@ -1,3 +1,5 @@
// WIP mesh/transport protocol — suppress dead code warnings
#![allow(dead_code)]
//! Mesh message encryption: X25519 ECDH key agreement + ChaCha20-Poly1305.
//!
//! Reuses Archipelago's existing Ed25519 identity infrastructure.
@@ -1,3 +1,5 @@
// WIP mesh/transport protocol — suppress dead code warnings
#![allow(dead_code)]
//! Typed message envelope for mesh communication.
//!
//! Wraps all mesh messages in a CBOR envelope with type discrimination,
+1 -11
View File
@@ -30,7 +30,7 @@ use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use tokio::fs;
use tokio::sync::{broadcast, watch};
use tokio::sync::watch;
use tracing::{error, info, warn};
const MESH_CONFIG_FILE: &str = "mesh-config.json";
@@ -559,11 +559,6 @@ impl MeshService {
Ok(())
}
/// Subscribe to mesh events.
pub fn subscribe(&self) -> broadcast::Receiver<MeshEvent> {
self.state.event_tx.subscribe()
}
/// Get a reference to shared state (for RPC handlers).
pub fn shared_state(&self) -> Arc<MeshState> {
Arc::clone(&self.state)
@@ -574,11 +569,6 @@ impl MeshService {
self.dead_man_switch.check_in().await;
}
/// Get the node's signing key (for signed messages).
pub fn signing_key(&self) -> &SigningKey {
&self.signing_key
}
/// Get our DID.
pub fn our_did(&self) -> &str {
&self.our_did
+3 -1
View File
@@ -1,3 +1,5 @@
// WIP mesh/transport protocol — suppress dead code warnings
#![allow(dead_code)]
//! Store-and-forward message queue for mesh networking.
//!
//! When a destination peer is offline or unreachable, messages are queued
@@ -9,7 +11,7 @@ use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
use std::path::{Path, PathBuf};
use tokio::sync::RwLock;
use tracing::{debug, info, warn};
use tracing::{debug, info};
/// Default time-to-live for queued messages (24 hours).
const DEFAULT_TTL_SECS: u64 = 86400;
+2
View File
@@ -1,3 +1,5 @@
// WIP mesh/transport protocol — suppress dead code warnings
#![allow(dead_code)]
//! Meshcore binary frame protocol: constants, encoding, decoding, command builders.
//!
//! Frame format (USB serial):
+4 -2
View File
@@ -1,3 +1,5 @@
// WIP mesh/transport protocol — suppress dead code warnings
#![allow(dead_code)]
//! Double Ratchet protocol for forward-secret mesh messaging.
//!
//! Implements the Signal protocol's Double Ratchet algorithm:
@@ -13,10 +15,10 @@
//! Reference: Signal Technical Documentation — Double Ratchet Algorithm
use super::crypto;
use anyhow::{Context, Result};
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use zeroize::{Zeroize, ZeroizeOnDrop};
use zeroize::Zeroize;
/// HKDF info string for root key + chain key derivation.
const KDF_RK_INFO: &[u8] = b"ArchyRatchetRK";
+2
View File
@@ -1,3 +1,5 @@
// WIP mesh/transport protocol — suppress dead code warnings
#![allow(dead_code)]
//! Async serial driver for Meshcore devices.
//!
//! Handles opening the serial port, reading/writing frames,
+1 -26
View File
@@ -10,7 +10,7 @@ use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tokio::sync::RwLock;
use tracing::{debug, info, warn};
use tracing::{debug, warn};
const RATCHET_DIR: &str = "ratchet";
@@ -87,14 +87,6 @@ impl SessionManager {
self.session_path(did).exists()
}
/// Store a new session (after X3DH completes).
pub async fn store_session(&self, did: &str, state: RatchetState) -> Result<()> {
self.save_session_to_disk(did, &state).await?;
self.sessions.write().await.insert(did.to_string(), state);
info!(did = %did, "Ratchet session established");
Ok(())
}
/// Encrypt a message for a peer using their ratchet session.
/// Loads the session from disk if not in memory.
pub async fn encrypt_for_peer(
@@ -166,19 +158,6 @@ impl SessionManager {
Ok(plaintext)
}
/// Remove a session (e.g., on reset or peer removal).
pub async fn remove_session(&self, did: &str) -> Result<()> {
self.sessions.write().await.remove(did);
let path = self.session_path(did);
if path.exists() {
tokio::fs::remove_file(&path)
.await
.context("Failed to remove ratchet session file")?;
}
info!(did = %did, "Ratchet session removed");
Ok(())
}
/// Get session info for a peer (for RPC status endpoint).
pub async fn session_info(&self, did: &str) -> Option<SessionInfo> {
let sessions = self.sessions.read().await;
@@ -203,10 +182,6 @@ impl SessionManager {
}
}
/// List all peers with active sessions.
pub async fn list_sessions(&self) -> Vec<String> {
self.sessions.read().await.keys().cloned().collect()
}
}
/// Summary info about a ratchet session (returned via RPC).
@@ -1,3 +1,5 @@
// WIP mesh/transport protocol — suppress dead code warnings
#![allow(dead_code)]
//! Steganographic encoding for mesh messages.
//!
//! Transforms typed message envelopes into formats that resemble innocuous
+2
View File
@@ -1,3 +1,5 @@
// WIP mesh/transport protocol — suppress dead code warnings
#![allow(dead_code)]
//! Shared types for mesh networking subsystem.
use serde::{Deserialize, Serialize};
+2
View File
@@ -1,3 +1,5 @@
// WIP mesh/transport protocol — suppress dead code warnings
#![allow(dead_code)]
//! X3DH (Extended Triple Diffie-Hellman) key agreement for mesh sessions.
//!
//! Implements the Signal protocol's X3DH using existing Ed25519/X25519 identity
+239
View File
@@ -0,0 +1,239 @@
use crate::monitoring::store::MetricsStore;
use crate::monitoring::types::*;
use tracing::{info, warn};
const ALERT_RULES_FILE: &str = "alert-rules.json";
impl AlertRule {
pub(crate) fn default_rules() -> Vec<AlertRule> {
vec![
AlertRule {
kind: AlertRuleKind::DiskUsage,
threshold: 80.0,
enabled: true,
description: "Disk usage exceeds threshold".to_string(),
},
AlertRule {
kind: AlertRuleKind::RamUsage,
threshold: 80.0,
enabled: true,
description: "Total memory usage exceeds threshold".to_string(),
},
AlertRule {
kind: AlertRuleKind::CpuLoad,
threshold: 4.0,
enabled: true,
description: "CPU load exceeds 4x core count for 5 minutes".to_string(),
},
AlertRule {
kind: AlertRuleKind::ContainerCrash,
threshold: 1.0,
enabled: true,
description: "Container stopped unexpectedly".to_string(),
},
AlertRule {
kind: AlertRuleKind::BackendErrorSpike,
threshold: 500.0,
enabled: true,
description: "RPC latency exceeds threshold (ms)".to_string(),
},
AlertRule {
kind: AlertRuleKind::SslCertExpiry,
threshold: 30.0,
enabled: true,
description: "SSL certificate expires within N days".to_string(),
},
]
}
}
/// Alert-related methods on MetricsStore.
impl MetricsStore {
/// Get the current alert rules.
pub async fn get_alert_rules(&self) -> Vec<AlertRule> {
self.alert_rules.read().await.clone()
}
/// Update an alert rule by kind and persist to disk.
pub async fn update_alert_rule(&self, kind: &AlertRuleKind, enabled: Option<bool>, threshold: Option<f64>) {
let mut rules = self.alert_rules.write().await;
if let Some(rule) = rules.iter_mut().find(|r| &r.kind == kind) {
if let Some(e) = enabled {
rule.enabled = e;
}
if let Some(t) = threshold {
rule.threshold = t;
}
}
// Persist to disk so changes survive restarts
if let Some(ref dir) = self.data_dir {
if let Err(e) = save_alert_rules(dir, &rules).await {
warn!("Failed to persist alert rules: {}", e);
}
}
}
/// Get fired alert history.
pub async fn get_fired_alerts(&self, last_n: usize) -> Vec<FiredAlert> {
let buf = self.fired_alerts.read().await;
let start = buf.len().saturating_sub(last_n);
buf.iter().skip(start).cloned().collect()
}
/// Acknowledge a fired alert by id.
pub async fn acknowledge_alert(&self, alert_id: &str) -> bool {
let mut buf = self.fired_alerts.write().await;
if let Some(alert) = buf.iter_mut().find(|a| a.id == alert_id) {
alert.acknowledged = true;
true
} else {
false
}
}
/// Evaluate alert rules against a snapshot and return any new alerts.
pub async fn check_alerts(&self, snapshot: &MetricSnapshot) -> Vec<FiredAlert> {
let rules = self.alert_rules.read().await;
let mut new_alerts = Vec::new();
let ts = snapshot.timestamp;
for rule in rules.iter() {
if !rule.enabled {
continue;
}
match rule.kind {
AlertRuleKind::DiskUsage => {
if snapshot.system.disk_total_bytes > 0 {
let pct = (snapshot.system.disk_used_bytes as f64
/ snapshot.system.disk_total_bytes as f64)
* 100.0;
if pct > rule.threshold {
new_alerts.push(FiredAlert {
id: format!("disk-{}", ts),
kind: AlertRuleKind::DiskUsage,
message: format!("Disk usage at {:.1}% (threshold: {:.0}%)", pct, rule.threshold),
value: pct,
threshold: rule.threshold,
timestamp: ts,
acknowledged: false,
});
}
}
}
AlertRuleKind::RamUsage => {
if snapshot.system.mem_total_bytes > 0 {
let pct = (snapshot.system.mem_used_bytes as f64
/ snapshot.system.mem_total_bytes as f64)
* 100.0;
if pct > rule.threshold {
new_alerts.push(FiredAlert {
id: format!("ram-{}", ts),
kind: AlertRuleKind::RamUsage,
message: format!("RAM usage at {:.1}% (threshold: {:.0}%)", pct, rule.threshold),
value: pct,
threshold: rule.threshold,
timestamp: ts,
acknowledged: false,
});
}
}
}
AlertRuleKind::CpuLoad => {
// Alert if 5-min load average exceeds threshold * core count
let cores = std::thread::available_parallelism()
.map(|n| n.get() as f64)
.unwrap_or(4.0);
let max_load = rule.threshold * cores;
if snapshot.system.load_avg_5 > max_load {
new_alerts.push(FiredAlert {
id: format!("cpu-{}", ts),
kind: AlertRuleKind::CpuLoad,
message: format!(
"CPU load at {:.1} (threshold: {:.0} = {:.0}x {} cores)",
snapshot.system.load_avg_5, max_load, rule.threshold, cores as u32
),
value: snapshot.system.load_avg_5,
threshold: max_load,
timestamp: ts,
acknowledged: false,
});
}
}
AlertRuleKind::BackendErrorSpike => {
if snapshot.rpc_latency_ms > rule.threshold {
new_alerts.push(FiredAlert {
id: format!("latency-{}", ts),
kind: AlertRuleKind::BackendErrorSpike,
message: format!(
"RPC latency at {:.0}ms (threshold: {:.0}ms)",
snapshot.rpc_latency_ms, rule.threshold
),
value: snapshot.rpc_latency_ms,
threshold: rule.threshold,
timestamp: ts,
acknowledged: false,
});
}
}
// ContainerCrash and SslCertExpiry are checked via dedicated paths
_ => {}
}
}
// Store fired alerts
if !new_alerts.is_empty() {
let mut buf = self.fired_alerts.write().await;
for alert in &new_alerts {
if buf.len() >= MAX_ALERT_HISTORY {
buf.pop_front();
}
buf.push_back(alert.clone());
}
}
new_alerts
}
}
/// Load alert rules from disk, falling back to defaults if file missing or corrupt.
pub(crate) async fn load_alert_rules(data_dir: &std::path::Path) -> Vec<AlertRule> {
let path = data_dir.join(ALERT_RULES_FILE);
match tokio::fs::read_to_string(&path).await {
Ok(content) => match serde_json::from_str::<Vec<AlertRule>>(&content) {
Ok(saved) => {
// Merge with defaults: use saved enabled/threshold, add any new rule kinds
let defaults = AlertRule::default_rules();
let mut merged = Vec::new();
for default in &defaults {
if let Some(saved_rule) = saved.iter().find(|r| r.kind == default.kind) {
merged.push(AlertRule {
kind: default.kind.clone(),
threshold: saved_rule.threshold,
enabled: saved_rule.enabled,
description: default.description.clone(),
});
} else {
merged.push(default.clone());
}
}
info!("Loaded alert rules from {}", path.display());
merged
}
Err(e) => {
warn!("Failed to parse alert rules ({}), using defaults", e);
AlertRule::default_rules()
}
},
Err(_) => AlertRule::default_rules(),
}
}
/// Save alert rules to disk.
pub(crate) async fn save_alert_rules(data_dir: &std::path::Path, rules: &[AlertRule]) -> anyhow::Result<()> {
tokio::fs::create_dir_all(data_dir).await?;
let content = serde_json::to_string_pretty(rules)?;
tokio::fs::write(data_dir.join(ALERT_RULES_FILE), content).await?;
Ok(())
}
+14 -953
View File
@@ -1,512 +1,21 @@
pub mod collector;
pub(crate) mod alerts;
mod notifications;
pub mod store;
mod telemetry;
pub mod types;
// Re-export public types for external consumers
pub use store::MetricsStore;
pub use telemetry::spawn_telemetry_reporter;
pub use types::*;
use crate::webhooks::{self, WebhookEvent, WebhookPayload};
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use tokio::sync::RwLock;
use tracing::{debug, info, warn};
const ALERT_RULES_FILE: &str = "alert-rules.json";
/// Maximum entries at 1-minute resolution (24 hours = 1440 minutes)
const MAX_1MIN_ENTRIES: usize = 1440;
/// Maximum entries at 15-minute resolution (7 days = 672 quarter-hours)
const MAX_15MIN_ENTRIES: usize = 672;
/// A single metrics snapshot collected at a point in time.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetricSnapshot {
pub timestamp: i64,
pub system: SystemMetrics,
pub containers: Vec<ContainerMetrics>,
pub rpc_latency_ms: f64,
pub ws_connections: u32,
}
/// System-wide resource metrics.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SystemMetrics {
pub cpu_percent: f64,
pub mem_used_bytes: u64,
pub mem_total_bytes: u64,
pub disk_used_bytes: u64,
pub disk_total_bytes: u64,
pub net_rx_bytes: u64,
pub net_tx_bytes: u64,
pub load_avg_1: f64,
pub load_avg_5: f64,
pub load_avg_15: f64,
}
/// Per-container resource metrics.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContainerMetrics {
pub name: String,
pub cpu_percent: f64,
pub mem_used_bytes: u64,
pub mem_limit_bytes: u64,
pub net_rx_bytes: u64,
pub net_tx_bytes: u64,
pub block_read_bytes: u64,
pub block_write_bytes: u64,
}
/// Maximum number of fired alerts to keep in history.
const MAX_ALERT_HISTORY: usize = 100;
/// Types of alert rules the system can evaluate.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum AlertRuleKind {
DiskUsage,
RamUsage,
CpuLoad,
ContainerCrash,
BackendErrorSpike,
SslCertExpiry,
}
/// A configured alert rule with threshold and enabled state.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AlertRule {
pub kind: AlertRuleKind,
pub threshold: f64,
pub enabled: bool,
pub description: String,
}
/// A fired alert instance.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FiredAlert {
pub id: String,
pub kind: AlertRuleKind,
pub message: String,
pub value: f64,
pub threshold: f64,
pub timestamp: i64,
pub acknowledged: bool,
}
impl AlertRule {
fn default_rules() -> Vec<AlertRule> {
vec![
AlertRule {
kind: AlertRuleKind::DiskUsage,
threshold: 80.0,
enabled: true,
description: "Disk usage exceeds threshold".to_string(),
},
AlertRule {
kind: AlertRuleKind::RamUsage,
threshold: 80.0,
enabled: true,
description: "Total memory usage exceeds threshold".to_string(),
},
AlertRule {
kind: AlertRuleKind::CpuLoad,
threshold: 4.0,
enabled: true,
description: "CPU load exceeds 4x core count for 5 minutes".to_string(),
},
AlertRule {
kind: AlertRuleKind::ContainerCrash,
threshold: 1.0,
enabled: true,
description: "Container stopped unexpectedly".to_string(),
},
AlertRule {
kind: AlertRuleKind::BackendErrorSpike,
threshold: 500.0,
enabled: true,
description: "RPC latency exceeds threshold (ms)".to_string(),
},
AlertRule {
kind: AlertRuleKind::SslCertExpiry,
threshold: 30.0,
enabled: true,
description: "SSL certificate expires within N days".to_string(),
},
]
}
}
/// Thread-safe metrics store with ring buffers at two resolutions.
pub struct MetricsStore {
minute_data: RwLock<VecDeque<MetricSnapshot>>,
quarter_hour_data: RwLock<VecDeque<MetricSnapshot>>,
minute_count: RwLock<u32>,
rpc_latency: RwLock<(f64, u64)>,
ws_connections: AtomicU32,
alert_rules: RwLock<Vec<AlertRule>>,
fired_alerts: RwLock<VecDeque<FiredAlert>>,
data_dir: Option<PathBuf>,
}
impl MetricsStore {
pub fn new() -> Self {
Self {
minute_data: RwLock::new(VecDeque::with_capacity(MAX_1MIN_ENTRIES)),
quarter_hour_data: RwLock::new(VecDeque::with_capacity(MAX_15MIN_ENTRIES)),
minute_count: RwLock::new(0),
rpc_latency: RwLock::new((0.0, 0)),
ws_connections: AtomicU32::new(0),
alert_rules: RwLock::new(AlertRule::default_rules()),
fired_alerts: RwLock::new(VecDeque::with_capacity(MAX_ALERT_HISTORY)),
data_dir: None,
}
}
/// Create a MetricsStore that persists alert rules to disk.
pub fn with_data_dir(data_dir: PathBuf) -> Self {
let rules = load_alert_rules_sync(&data_dir);
Self {
minute_data: RwLock::new(VecDeque::with_capacity(MAX_1MIN_ENTRIES)),
quarter_hour_data: RwLock::new(VecDeque::with_capacity(MAX_15MIN_ENTRIES)),
minute_count: RwLock::new(0),
rpc_latency: RwLock::new((0.0, 0)),
ws_connections: AtomicU32::new(0),
alert_rules: RwLock::new(rules),
fired_alerts: RwLock::new(VecDeque::with_capacity(MAX_ALERT_HISTORY)),
data_dir: Some(data_dir),
}
}
/// Record a new metric snapshot (called every minute by collector).
pub async fn push(&self, mut snapshot: MetricSnapshot) {
// Fill in RPC latency from accumulated samples
{
let mut latency = self.rpc_latency.write().await;
if latency.1 > 0 {
snapshot.rpc_latency_ms = (latency.0 / latency.1 as f64 * 10.0).round() / 10.0;
*latency = (0.0, 0);
}
}
// Fill in current WS connection count
snapshot.ws_connections = self.ws_connections.load(Ordering::Relaxed);
// Push to 1-minute ring buffer
{
let mut buf = self.minute_data.write().await;
if buf.len() >= MAX_1MIN_ENTRIES {
buf.pop_front();
}
buf.push_back(snapshot.clone());
}
// Every 15 minutes, push to quarter-hour ring buffer
{
let mut count = self.minute_count.write().await;
*count += 1;
if *count >= 15 {
*count = 0;
let mut buf = self.quarter_hour_data.write().await;
if buf.len() >= MAX_15MIN_ENTRIES {
buf.pop_front();
}
buf.push_back(snapshot);
}
}
}
/// Record an RPC request latency sample (milliseconds).
pub async fn record_rpc_latency(&self, latency_ms: f64) {
let mut data = self.rpc_latency.write().await;
data.0 += latency_ms;
data.1 += 1;
}
/// Increment WebSocket connection count (called on connect).
pub fn increment_ws(&self) {
self.ws_connections.fetch_add(1, Ordering::Relaxed);
}
/// Decrement WebSocket connection count (called on disconnect).
pub fn decrement_ws(&self) {
// Use saturating semantics to avoid underflow
let _ = self.ws_connections.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| {
if v > 0 { Some(v - 1) } else { Some(0) }
});
}
/// Get the latest snapshot.
pub async fn latest(&self) -> Option<MetricSnapshot> {
self.minute_data.read().await.back().cloned()
}
/// Get minute-resolution data for the last N minutes.
pub async fn history_minutes(&self, last_n: usize) -> Vec<MetricSnapshot> {
let buf = self.minute_data.read().await;
let start = buf.len().saturating_sub(last_n);
buf.iter().skip(start).cloned().collect()
}
/// Get quarter-hour-resolution data for the last N entries.
pub async fn history_quarter_hours(&self, last_n: usize) -> Vec<MetricSnapshot> {
let buf = self.quarter_hour_data.read().await;
let start = buf.len().saturating_sub(last_n);
buf.iter().skip(start).cloned().collect()
}
/// Get the current alert rules.
pub async fn get_alert_rules(&self) -> Vec<AlertRule> {
self.alert_rules.read().await.clone()
}
/// Update an alert rule by kind and persist to disk.
pub async fn update_alert_rule(&self, kind: &AlertRuleKind, enabled: Option<bool>, threshold: Option<f64>) {
let mut rules = self.alert_rules.write().await;
if let Some(rule) = rules.iter_mut().find(|r| &r.kind == kind) {
if let Some(e) = enabled {
rule.enabled = e;
}
if let Some(t) = threshold {
rule.threshold = t;
}
}
// Persist to disk so changes survive restarts
if let Some(ref dir) = self.data_dir {
if let Err(e) = save_alert_rules(dir, &rules).await {
warn!("Failed to persist alert rules: {}", e);
}
}
}
/// Get fired alert history.
pub async fn get_fired_alerts(&self, last_n: usize) -> Vec<FiredAlert> {
let buf = self.fired_alerts.read().await;
let start = buf.len().saturating_sub(last_n);
buf.iter().skip(start).cloned().collect()
}
/// Acknowledge a fired alert by id.
pub async fn acknowledge_alert(&self, alert_id: &str) -> bool {
let mut buf = self.fired_alerts.write().await;
if let Some(alert) = buf.iter_mut().find(|a| a.id == alert_id) {
alert.acknowledged = true;
true
} else {
false
}
}
/// Evaluate alert rules against a snapshot and return any new alerts.
pub async fn check_alerts(&self, snapshot: &MetricSnapshot) -> Vec<FiredAlert> {
let rules = self.alert_rules.read().await;
let mut new_alerts = Vec::new();
let ts = snapshot.timestamp;
for rule in rules.iter() {
if !rule.enabled {
continue;
}
match rule.kind {
AlertRuleKind::DiskUsage => {
if snapshot.system.disk_total_bytes > 0 {
let pct = (snapshot.system.disk_used_bytes as f64
/ snapshot.system.disk_total_bytes as f64)
* 100.0;
if pct > rule.threshold {
new_alerts.push(FiredAlert {
id: format!("disk-{}", ts),
kind: AlertRuleKind::DiskUsage,
message: format!("Disk usage at {:.1}% (threshold: {:.0}%)", pct, rule.threshold),
value: pct,
threshold: rule.threshold,
timestamp: ts,
acknowledged: false,
});
}
}
}
AlertRuleKind::RamUsage => {
if snapshot.system.mem_total_bytes > 0 {
let pct = (snapshot.system.mem_used_bytes as f64
/ snapshot.system.mem_total_bytes as f64)
* 100.0;
if pct > rule.threshold {
new_alerts.push(FiredAlert {
id: format!("ram-{}", ts),
kind: AlertRuleKind::RamUsage,
message: format!("RAM usage at {:.1}% (threshold: {:.0}%)", pct, rule.threshold),
value: pct,
threshold: rule.threshold,
timestamp: ts,
acknowledged: false,
});
}
}
}
AlertRuleKind::CpuLoad => {
// Alert if 5-min load average exceeds threshold * core count
let cores = std::thread::available_parallelism()
.map(|n| n.get() as f64)
.unwrap_or(4.0);
let max_load = rule.threshold * cores;
if snapshot.system.load_avg_5 > max_load {
new_alerts.push(FiredAlert {
id: format!("cpu-{}", ts),
kind: AlertRuleKind::CpuLoad,
message: format!(
"CPU load at {:.1} (threshold: {:.0} = {:.0}x {} cores)",
snapshot.system.load_avg_5, max_load, rule.threshold, cores as u32
),
value: snapshot.system.load_avg_5,
threshold: max_load,
timestamp: ts,
acknowledged: false,
});
}
}
AlertRuleKind::BackendErrorSpike => {
if snapshot.rpc_latency_ms > rule.threshold {
new_alerts.push(FiredAlert {
id: format!("latency-{}", ts),
kind: AlertRuleKind::BackendErrorSpike,
message: format!(
"RPC latency at {:.0}ms (threshold: {:.0}ms)",
snapshot.rpc_latency_ms, rule.threshold
),
value: snapshot.rpc_latency_ms,
threshold: rule.threshold,
timestamp: ts,
acknowledged: false,
});
}
}
// ContainerCrash and SslCertExpiry are checked via dedicated paths
_ => {}
}
}
// Store fired alerts
if !new_alerts.is_empty() {
let mut buf = self.fired_alerts.write().await;
for alert in &new_alerts {
if buf.len() >= MAX_ALERT_HISTORY {
buf.pop_front();
}
buf.push_back(alert.clone());
}
}
new_alerts
}
/// Fire a container crash alert (called by health monitor).
pub async fn fire_container_alert(&self, container_name: &str, state: &str) {
let rules = self.alert_rules.read().await;
let enabled = rules
.iter()
.any(|r| r.kind == AlertRuleKind::ContainerCrash && r.enabled);
drop(rules);
if !enabled {
return;
}
let ts = chrono::Utc::now().timestamp();
let alert = FiredAlert {
id: format!("container-{}-{}", container_name, ts),
kind: AlertRuleKind::ContainerCrash,
message: format!("Container '{}' is {} — may need attention", container_name, state),
value: 1.0,
threshold: 1.0,
timestamp: ts,
acknowledged: false,
};
let mut buf = self.fired_alerts.write().await;
if buf.len() >= MAX_ALERT_HISTORY {
buf.pop_front();
}
buf.push_back(alert);
}
/// Fire an SSL cert expiry alert.
pub async fn fire_ssl_alert(&self, days_remaining: f64) {
let rules = self.alert_rules.read().await;
let threshold = rules
.iter()
.find(|r| r.kind == AlertRuleKind::SslCertExpiry && r.enabled)
.map(|r| r.threshold);
drop(rules);
let threshold = match threshold {
Some(t) if days_remaining < t => t,
_ => return,
};
let ts = chrono::Utc::now().timestamp();
let alert = FiredAlert {
id: format!("ssl-{}", ts),
kind: AlertRuleKind::SslCertExpiry,
message: format!(
"SSL certificate expires in {:.0} days (threshold: {:.0} days)",
days_remaining, threshold
),
value: days_remaining,
threshold,
timestamp: ts,
acknowledged: false,
};
let mut buf = self.fired_alerts.write().await;
if buf.len() >= MAX_ALERT_HISTORY {
buf.pop_front();
}
buf.push_back(alert);
}
}
/// Load alert rules from disk, falling back to defaults if file missing or corrupt.
fn load_alert_rules_sync(data_dir: &std::path::Path) -> Vec<AlertRule> {
let path = data_dir.join(ALERT_RULES_FILE);
match std::fs::read_to_string(&path) {
Ok(content) => match serde_json::from_str::<Vec<AlertRule>>(&content) {
Ok(saved) => {
// Merge with defaults: use saved enabled/threshold, add any new rule kinds
let defaults = AlertRule::default_rules();
let mut merged = Vec::new();
for default in &defaults {
if let Some(saved_rule) = saved.iter().find(|r| r.kind == default.kind) {
merged.push(AlertRule {
kind: default.kind.clone(),
threshold: saved_rule.threshold,
enabled: saved_rule.enabled,
description: default.description.clone(),
});
} else {
merged.push(default.clone());
}
}
info!("Loaded alert rules from {}", path.display());
merged
}
Err(e) => {
warn!("Failed to parse alert rules ({}), using defaults", e);
AlertRule::default_rules()
}
},
Err(_) => AlertRule::default_rules(),
}
}
/// Save alert rules to disk.
async fn save_alert_rules(data_dir: &std::path::Path, rules: &[AlertRule]) -> anyhow::Result<()> {
tokio::fs::create_dir_all(data_dir).await?;
let content = serde_json::to_string_pretty(rules)?;
tokio::fs::write(data_dir.join(ALERT_RULES_FILE), content).await?;
Ok(())
}
use tracing::{debug, warn};
/// Spawn the background metrics collector (runs every 60 seconds).
/// Also evaluates alert rules on each snapshot and pushes notifications.
/// Evaluates alert rules on each snapshot and dispatches notifications.
pub fn spawn_metrics_collector(
store: Arc<MetricsStore>,
state: Option<Arc<crate::state::StateManager>>,
@@ -523,71 +32,16 @@ pub fn spawn_metrics_collector(
match collector::collect_snapshot().await {
Ok(snapshot) => {
// Check alert rules before pushing (needs snapshot data)
let alerts = store.check_alerts(&snapshot).await;
store.push(snapshot).await;
debug!("Metrics snapshot collected");
// Push alert notifications via WebSocket
if !alerts.is_empty() {
if let Some(ref state_mgr) = state {
let (mut data, _rev) = state_mgr.get_snapshot().await;
for alert in &alerts {
let level = match alert.kind {
AlertRuleKind::DiskUsage | AlertRuleKind::RamUsage => {
if alert.value > 95.0 {
crate::data_model::NotificationLevel::Error
} else {
crate::data_model::NotificationLevel::Warning
}
}
AlertRuleKind::ContainerCrash => {
crate::data_model::NotificationLevel::Error
}
_ => crate::data_model::NotificationLevel::Warning,
};
let notification = crate::data_model::Notification {
id: alert.id.clone(),
level,
title: format!("{:?} Alert", alert.kind),
message: alert.message.clone(),
timestamp: chrono::Utc::now().to_rfc3339(),
app_id: None,
};
data.notifications.push(notification);
}
// Keep max 20 notifications
while data.notifications.len() > 20 {
data.notifications.remove(0);
}
state_mgr.update_data(data).await;
info!("Fired {} alert(s)", alerts.len());
notifications::push_alert_notifications(state_mgr, &alerts).await;
}
// Fire-and-forget webhook delivery for mapped alert types
if let Some(ref dir) = data_dir {
for alert in &alerts {
let event = match alert.kind {
AlertRuleKind::DiskUsage => Some(WebhookEvent::DiskWarning),
AlertRuleKind::ContainerCrash => Some(WebhookEvent::ContainerCrash),
_ => None,
};
if let Some(event) = event {
let payload = WebhookPayload {
event,
title: format!("{:?} Alert", alert.kind),
message: alert.message.clone(),
timestamp: chrono::Utc::now().to_rfc3339(),
node_id: String::new(),
details: Some(serde_json::json!({
"value": alert.value,
"threshold": alert.threshold,
})),
};
webhooks::send_webhook(dir, payload).await;
}
}
notifications::deliver_alert_webhooks(dir, &alerts).await;
}
}
}
@@ -598,396 +52,3 @@ pub fn spawn_metrics_collector(
}
});
}
/// Spawn the periodic telemetry reporter (runs every 15 minutes when opt-in enabled).
/// Collects anonymous health data and saves to disk. Posts to central collector if configured.
pub fn spawn_telemetry_reporter(
store: Arc<MetricsStore>,
state: Option<Arc<crate::state::StateManager>>,
data_dir: PathBuf,
) {
tokio::spawn(async move {
// Wait 60s for system to fully stabilize
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
let mut interval = tokio::time::interval(std::time::Duration::from_secs(900)); // 15 min
loop {
interval.tick().await;
// Check if telemetry is opted in
let config_path = data_dir.join("analytics-config.json");
let enabled = match tokio::fs::read_to_string(&config_path).await {
Ok(data) => serde_json::from_str::<serde_json::Value>(&data)
.ok()
.and_then(|c| c["enabled"].as_bool())
.unwrap_or(false),
Err(_) => false,
};
if !enabled {
debug!("Telemetry disabled — skipping report");
continue;
}
match build_telemetry_report(&store, &state, &data_dir).await {
Ok(report) => {
// Save latest report to disk
let report_path = data_dir.join("telemetry-latest.json");
if let Ok(json) = serde_json::to_string_pretty(&report) {
let _ = tokio::fs::write(&report_path, &json).await;
}
// Always save to local fleet directory so this node appears
// in its own fleet view
save_report_to_fleet_dir(&data_dir, &report).await;
// POST to central collector if configured
let collector_url = std::env::var("TELEMETRY_COLLECTOR_URL").ok();
if let Some(url) = collector_url {
match post_telemetry_report(&url, &report).await {
Ok(_) => info!("Telemetry report sent to collector"),
Err(e) => warn!("Failed to send telemetry report: {}", e),
}
} else {
debug!("Telemetry report saved locally (no collector URL configured)");
}
}
Err(e) => {
warn!("Failed to build telemetry report: {}", e);
}
}
}
});
}
/// Build an anonymous telemetry report from current system state.
async fn build_telemetry_report(
store: &Arc<MetricsStore>,
state: &Option<Arc<crate::state::StateManager>>,
data_dir: &std::path::Path,
) -> anyhow::Result<serde_json::Value> {
// Anonymous node ID — truncated SHA-256 hash of pubkey
let (node_id, version, container_count, running_count, peer_count) = if let Some(ref sm) = state {
let (data, _) = sm.get_snapshot().await;
let id = {
use sha2::{Sha256, Digest};
let mut h = Sha256::new();
h.update(data.server_info.pubkey.as_bytes());
hex::encode(h.finalize())[..16].to_string()
};
let running = data.package_data.values()
.filter(|p| matches!(p.state, crate::data_model::PackageState::Running))
.count();
(id, data.server_info.version.clone(), data.package_data.len(), running, data.peer_health.len())
} else {
("unknown".to_string(), "unknown".to_string(), 0, 0, 0)
};
// System info
let cpu_cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(0);
let uptime_secs = tokio::fs::read_to_string("/proc/uptime").await
.ok()
.and_then(|s| s.split_whitespace().next()?.parse::<f64>().ok())
.map(|f| f as u64)
.unwrap_or(0);
// Latest metrics snapshot
let latest = store.latest().await;
let (cpu_pct, mem_pct, disk_pct): (f64, f64, f64) = latest.map(|s| {
let mem_total = s.system.mem_total_bytes as f64;
let disk_total = s.system.disk_total_bytes as f64;
(
s.system.cpu_percent,
if mem_total > 0.0 { (s.system.mem_used_bytes as f64 / mem_total) * 100.0 } else { 0.0 },
if disk_total > 0.0 { (s.system.disk_used_bytes as f64 / disk_total) * 100.0 } else { 0.0 },
)
}).unwrap_or((0.0, 0.0, 0.0));
// Recent alerts
let recent_alerts: Vec<serde_json::Value> = store.get_fired_alerts(10).await
.into_iter()
.map(|a| serde_json::json!({
"rule": format!("{:?}", a.kind),
"message": a.message,
"timestamp": a.timestamp,
}))
.collect();
Ok(serde_json::json!({
"node_id": node_id,
"version": version,
"uptime_secs": uptime_secs,
"cpu_cores": cpu_cores,
"cpu_pct": (cpu_pct * 10.0).round() / 10.0,
"mem_pct": (mem_pct * 10.0).round() / 10.0,
"disk_pct": (disk_pct * 10.0).round() / 10.0,
"container_count": container_count,
"running_count": running_count,
"federation_peers": peer_count,
"recent_alerts": recent_alerts,
"reported_at": chrono::Utc::now().to_rfc3339(),
}))
}
/// POST a telemetry report to the central collector.
async fn post_telemetry_report(url: &str, report: &serde_json::Value) -> anyhow::Result<()> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()?;
let response = client.post(url)
.header("Content-Type", "application/json")
.header("User-Agent", "Archipelago-Telemetry/1.0")
.json(report)
.send()
.await?;
if !response.status().is_success() {
anyhow::bail!("Collector returned {}", response.status());
}
Ok(())
}
/// Save a telemetry report into the local fleet directory.
/// This makes the node's own report visible in the fleet dashboard.
async fn save_report_to_fleet_dir(data_dir: &std::path::Path, report: &serde_json::Value) {
let node_id = match report.get("node_id").and_then(|v| v.as_str()) {
Some(id) if !id.is_empty() => id,
_ => {
warn!("Telemetry report missing node_id — skipping fleet save");
return;
}
};
let fleet_dir = data_dir.join("telemetry-fleet");
if let Err(e) = tokio::fs::create_dir_all(&fleet_dir).await {
warn!("Failed to create telemetry-fleet directory: {}", e);
return;
}
// Write latest report (overwrites previous)
let latest_path = fleet_dir.join(format!("{}.json", node_id));
match serde_json::to_string_pretty(report) {
Ok(json) => {
if let Err(e) = tokio::fs::write(&latest_path, &json).await {
warn!("Failed to write fleet report for {}: {}", node_id, e);
}
}
Err(e) => {
warn!("Failed to serialize fleet report: {}", e);
return;
}
}
// Append to history file (cap at 200 entries)
let history_path = fleet_dir.join(format!("{}-history.json", node_id));
let mut history: Vec<serde_json::Value> = match tokio::fs::read_to_string(&history_path).await {
Ok(data) => serde_json::from_str(&data).unwrap_or_default(),
Err(_) => Vec::new(),
};
history.push(report.clone());
if history.len() > 200 {
let start = history.len() - 200;
history = history.split_off(start);
}
match serde_json::to_string_pretty(&history) {
Ok(json) => {
if let Err(e) = tokio::fs::write(&history_path, &json).await {
warn!("Failed to write fleet history for {}: {}", node_id, e);
}
}
Err(e) => {
warn!("Failed to serialize fleet history: {}", e);
}
}
debug!("Saved own telemetry report to fleet directory (node_id={})", node_id);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_metrics_store_new() {
let store = MetricsStore::new();
assert_eq!(store.ws_connections.load(Ordering::Relaxed), 0);
}
#[test]
fn test_ws_connection_tracking() {
let store = MetricsStore::new();
store.increment_ws();
store.increment_ws();
assert_eq!(store.ws_connections.load(Ordering::Relaxed), 2);
store.decrement_ws();
assert_eq!(store.ws_connections.load(Ordering::Relaxed), 1);
store.decrement_ws();
assert_eq!(store.ws_connections.load(Ordering::Relaxed), 0);
// Decrement below zero should stay at 0
store.decrement_ws();
assert_eq!(store.ws_connections.load(Ordering::Relaxed), 0);
}
#[tokio::test]
async fn test_push_and_latest() {
let store = MetricsStore::new();
assert!(store.latest().await.is_none());
let snapshot = MetricSnapshot {
timestamp: 1000,
system: SystemMetrics {
cpu_percent: 25.0,
mem_used_bytes: 1_000_000,
mem_total_bytes: 4_000_000,
disk_used_bytes: 500_000,
disk_total_bytes: 1_000_000,
net_rx_bytes: 100,
net_tx_bytes: 200,
load_avg_1: 1.0,
load_avg_5: 0.5,
load_avg_15: 0.3,
},
containers: vec![],
rpc_latency_ms: 0.0,
ws_connections: 0,
};
store.push(snapshot).await;
let latest = store.latest().await.unwrap();
assert_eq!(latest.timestamp, 1000);
assert_eq!(latest.system.cpu_percent, 25.0);
}
#[tokio::test]
async fn test_rpc_latency_recording() {
let store = MetricsStore::new();
store.record_rpc_latency(10.0).await;
store.record_rpc_latency(20.0).await;
store.record_rpc_latency(30.0).await;
let snapshot = MetricSnapshot {
timestamp: 2000,
system: SystemMetrics {
cpu_percent: 0.0,
mem_used_bytes: 0,
mem_total_bytes: 0,
disk_used_bytes: 0,
disk_total_bytes: 0,
net_rx_bytes: 0,
net_tx_bytes: 0,
load_avg_1: 0.0,
load_avg_5: 0.0,
load_avg_15: 0.0,
},
containers: vec![],
rpc_latency_ms: 0.0,
ws_connections: 0,
};
store.push(snapshot).await;
let latest = store.latest().await.unwrap();
assert_eq!(latest.rpc_latency_ms, 20.0); // average of 10+20+30 = 20
}
#[tokio::test]
async fn test_history_minutes() {
let store = MetricsStore::new();
for i in 0..5 {
let snapshot = MetricSnapshot {
timestamp: i * 60,
system: SystemMetrics {
cpu_percent: i as f64,
mem_used_bytes: 0,
mem_total_bytes: 0,
disk_used_bytes: 0,
disk_total_bytes: 0,
net_rx_bytes: 0,
net_tx_bytes: 0,
load_avg_1: 0.0,
load_avg_5: 0.0,
load_avg_15: 0.0,
},
containers: vec![],
rpc_latency_ms: 0.0,
ws_connections: 0,
};
store.push(snapshot).await;
}
let history = store.history_minutes(3).await;
assert_eq!(history.len(), 3);
assert_eq!(history[0].timestamp, 120);
assert_eq!(history[2].timestamp, 240);
}
#[tokio::test]
async fn test_ring_buffer_eviction() {
let store = MetricsStore::new();
// Push more than MAX_1MIN_ENTRIES
for i in 0..(MAX_1MIN_ENTRIES + 10) {
let snapshot = MetricSnapshot {
timestamp: i as i64,
system: SystemMetrics {
cpu_percent: 0.0,
mem_used_bytes: 0,
mem_total_bytes: 0,
disk_used_bytes: 0,
disk_total_bytes: 0,
net_rx_bytes: 0,
net_tx_bytes: 0,
load_avg_1: 0.0,
load_avg_5: 0.0,
load_avg_15: 0.0,
},
containers: vec![],
rpc_latency_ms: 0.0,
ws_connections: 0,
};
store.push(snapshot).await;
}
let all = store.history_minutes(MAX_1MIN_ENTRIES + 100).await;
assert_eq!(all.len(), MAX_1MIN_ENTRIES);
// Oldest entry should be 10 (first 10 were evicted)
assert_eq!(all[0].timestamp, 10);
}
#[tokio::test]
async fn test_quarter_hour_downsampling() {
let store = MetricsStore::new();
// Push exactly 15 entries to trigger one quarter-hour sample
for i in 0..15 {
let snapshot = MetricSnapshot {
timestamp: i * 60,
system: SystemMetrics {
cpu_percent: 50.0,
mem_used_bytes: 0,
mem_total_bytes: 0,
disk_used_bytes: 0,
disk_total_bytes: 0,
net_rx_bytes: 0,
net_tx_bytes: 0,
load_avg_1: 0.0,
load_avg_5: 0.0,
load_avg_15: 0.0,
},
containers: vec![],
rpc_latency_ms: 0.0,
ws_connections: 0,
};
store.push(snapshot).await;
}
let qh = store.history_quarter_hours(10).await;
assert_eq!(qh.len(), 1);
assert_eq!(qh[0].timestamp, 14 * 60); // The 15th entry (index 14)
}
#[test]
fn test_constants() {
assert_eq!(MAX_1MIN_ENTRIES, 1440);
assert_eq!(MAX_15MIN_ENTRIES, 672);
}
}
@@ -0,0 +1,68 @@
use crate::monitoring::types::{AlertRuleKind, FiredAlert};
use crate::webhooks::{self, WebhookEvent, WebhookPayload};
use std::path::Path;
use std::sync::Arc;
use tracing::info;
/// Push fired alerts as notifications to the state manager (broadcast via WebSocket).
pub(crate) async fn push_alert_notifications(
state_mgr: &Arc<crate::state::StateManager>,
alerts: &[FiredAlert],
) {
let (mut data, _rev) = state_mgr.get_snapshot().await;
for alert in alerts {
let level = match alert.kind {
AlertRuleKind::DiskUsage | AlertRuleKind::RamUsage => {
if alert.value > 95.0 {
crate::data_model::NotificationLevel::Error
} else {
crate::data_model::NotificationLevel::Warning
}
}
AlertRuleKind::ContainerCrash => {
crate::data_model::NotificationLevel::Error
}
_ => crate::data_model::NotificationLevel::Warning,
};
let notification = crate::data_model::Notification {
id: alert.id.clone(),
level,
title: format!("{:?} Alert", alert.kind),
message: alert.message.clone(),
timestamp: chrono::Utc::now().to_rfc3339(),
app_id: None,
};
data.notifications.push(notification);
}
// Keep max 20 notifications
while data.notifications.len() > 20 {
data.notifications.remove(0);
}
state_mgr.update_data(data).await;
info!("Fired {} alert(s)", alerts.len());
}
/// Deliver webhook notifications for alerts that map to webhook events.
pub(crate) async fn deliver_alert_webhooks(data_dir: &Path, alerts: &[FiredAlert]) {
for alert in alerts {
let event = match alert.kind {
AlertRuleKind::DiskUsage => Some(WebhookEvent::DiskWarning),
AlertRuleKind::ContainerCrash => Some(WebhookEvent::ContainerCrash),
_ => None,
};
if let Some(event) = event {
let payload = WebhookPayload {
event,
title: format!("{:?} Alert", alert.kind),
message: alert.message.clone(),
timestamp: chrono::Utc::now().to_rfc3339(),
node_id: String::new(),
details: Some(serde_json::json!({
"value": alert.value,
"threshold": alert.threshold,
})),
};
webhooks::send_webhook(data_dir, payload).await;
}
}
}
+316
View File
@@ -0,0 +1,316 @@
use crate::monitoring::alerts::load_alert_rules;
use crate::monitoring::types::*;
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
use tokio::sync::RwLock;
/// Thread-safe metrics store with ring buffers at two resolutions.
pub struct MetricsStore {
minute_data: RwLock<VecDeque<MetricSnapshot>>,
quarter_hour_data: RwLock<VecDeque<MetricSnapshot>>,
minute_count: RwLock<u32>,
rpc_latency: RwLock<(f64, u64)>,
ws_connections: AtomicU32,
pub(crate) alert_rules: RwLock<Vec<AlertRule>>,
pub(crate) fired_alerts: RwLock<VecDeque<FiredAlert>>,
pub(crate) data_dir: Option<PathBuf>,
}
impl MetricsStore {
#[cfg(test)]
pub fn new() -> Self {
Self {
minute_data: RwLock::new(VecDeque::with_capacity(MAX_1MIN_ENTRIES)),
quarter_hour_data: RwLock::new(VecDeque::with_capacity(MAX_15MIN_ENTRIES)),
minute_count: RwLock::new(0),
rpc_latency: RwLock::new((0.0, 0)),
ws_connections: AtomicU32::new(0),
alert_rules: RwLock::new(AlertRule::default_rules()),
fired_alerts: RwLock::new(VecDeque::with_capacity(MAX_ALERT_HISTORY)),
data_dir: None,
}
}
/// Create a MetricsStore that persists alert rules to disk.
pub async fn with_data_dir(data_dir: PathBuf) -> Self {
let rules = load_alert_rules(&data_dir).await;
Self {
minute_data: RwLock::new(VecDeque::with_capacity(MAX_1MIN_ENTRIES)),
quarter_hour_data: RwLock::new(VecDeque::with_capacity(MAX_15MIN_ENTRIES)),
minute_count: RwLock::new(0),
rpc_latency: RwLock::new((0.0, 0)),
ws_connections: AtomicU32::new(0),
alert_rules: RwLock::new(rules),
fired_alerts: RwLock::new(VecDeque::with_capacity(MAX_ALERT_HISTORY)),
data_dir: Some(data_dir),
}
}
/// Record a new metric snapshot (called every minute by collector).
pub async fn push(&self, mut snapshot: MetricSnapshot) {
// Fill in RPC latency from accumulated samples
{
let mut latency = self.rpc_latency.write().await;
if latency.1 > 0 {
snapshot.rpc_latency_ms = (latency.0 / latency.1 as f64 * 10.0).round() / 10.0;
*latency = (0.0, 0);
}
}
// Fill in current WS connection count
snapshot.ws_connections = self.ws_connections.load(Ordering::Relaxed);
// Push to 1-minute ring buffer
{
let mut buf = self.minute_data.write().await;
if buf.len() >= MAX_1MIN_ENTRIES {
buf.pop_front();
}
buf.push_back(snapshot.clone());
}
// Every 15 minutes, push to quarter-hour ring buffer
{
let mut count = self.minute_count.write().await;
*count += 1;
if *count >= 15 {
*count = 0;
let mut buf = self.quarter_hour_data.write().await;
if buf.len() >= MAX_15MIN_ENTRIES {
buf.pop_front();
}
buf.push_back(snapshot);
}
}
}
/// Record an RPC request latency sample (milliseconds).
pub async fn record_rpc_latency(&self, latency_ms: f64) {
let mut data = self.rpc_latency.write().await;
data.0 += latency_ms;
data.1 += 1;
}
/// Increment WebSocket connection count (called on connect).
pub fn increment_ws(&self) {
self.ws_connections.fetch_add(1, Ordering::Relaxed);
}
/// Decrement WebSocket connection count (called on disconnect).
pub fn decrement_ws(&self) {
// Use saturating semantics to avoid underflow
let _ = self.ws_connections.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| {
if v > 0 { Some(v - 1) } else { Some(0) }
});
}
/// Get the latest snapshot.
pub async fn latest(&self) -> Option<MetricSnapshot> {
self.minute_data.read().await.back().cloned()
}
/// Get minute-resolution data for the last N minutes.
pub async fn history_minutes(&self, last_n: usize) -> Vec<MetricSnapshot> {
let buf = self.minute_data.read().await;
let start = buf.len().saturating_sub(last_n);
buf.iter().skip(start).cloned().collect()
}
/// Get quarter-hour-resolution data for the last N entries.
pub async fn history_quarter_hours(&self, last_n: usize) -> Vec<MetricSnapshot> {
let buf = self.quarter_hour_data.read().await;
let start = buf.len().saturating_sub(last_n);
buf.iter().skip(start).cloned().collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_metrics_store_new() {
let store = MetricsStore::new();
assert_eq!(store.ws_connections.load(Ordering::Relaxed), 0);
}
#[test]
fn test_ws_connection_tracking() {
let store = MetricsStore::new();
store.increment_ws();
store.increment_ws();
assert_eq!(store.ws_connections.load(Ordering::Relaxed), 2);
store.decrement_ws();
assert_eq!(store.ws_connections.load(Ordering::Relaxed), 1);
store.decrement_ws();
assert_eq!(store.ws_connections.load(Ordering::Relaxed), 0);
// Decrement below zero should stay at 0
store.decrement_ws();
assert_eq!(store.ws_connections.load(Ordering::Relaxed), 0);
}
#[tokio::test]
async fn test_push_and_latest() {
let store = MetricsStore::new();
assert!(store.latest().await.is_none());
let snapshot = MetricSnapshot {
timestamp: 1000,
system: SystemMetrics {
cpu_percent: 25.0,
mem_used_bytes: 1_000_000,
mem_total_bytes: 4_000_000,
disk_used_bytes: 500_000,
disk_total_bytes: 1_000_000,
net_rx_bytes: 100,
net_tx_bytes: 200,
load_avg_1: 1.0,
load_avg_5: 0.5,
load_avg_15: 0.3,
},
containers: vec![],
rpc_latency_ms: 0.0,
ws_connections: 0,
};
store.push(snapshot).await;
let latest = store.latest().await.unwrap();
assert_eq!(latest.timestamp, 1000);
assert_eq!(latest.system.cpu_percent, 25.0);
}
#[tokio::test]
async fn test_rpc_latency_recording() {
let store = MetricsStore::new();
store.record_rpc_latency(10.0).await;
store.record_rpc_latency(20.0).await;
store.record_rpc_latency(30.0).await;
let snapshot = MetricSnapshot {
timestamp: 2000,
system: SystemMetrics {
cpu_percent: 0.0,
mem_used_bytes: 0,
mem_total_bytes: 0,
disk_used_bytes: 0,
disk_total_bytes: 0,
net_rx_bytes: 0,
net_tx_bytes: 0,
load_avg_1: 0.0,
load_avg_5: 0.0,
load_avg_15: 0.0,
},
containers: vec![],
rpc_latency_ms: 0.0,
ws_connections: 0,
};
store.push(snapshot).await;
let latest = store.latest().await.unwrap();
assert_eq!(latest.rpc_latency_ms, 20.0); // average of 10+20+30 = 20
}
#[tokio::test]
async fn test_history_minutes() {
let store = MetricsStore::new();
for i in 0..5 {
let snapshot = MetricSnapshot {
timestamp: i * 60,
system: SystemMetrics {
cpu_percent: i as f64,
mem_used_bytes: 0,
mem_total_bytes: 0,
disk_used_bytes: 0,
disk_total_bytes: 0,
net_rx_bytes: 0,
net_tx_bytes: 0,
load_avg_1: 0.0,
load_avg_5: 0.0,
load_avg_15: 0.0,
},
containers: vec![],
rpc_latency_ms: 0.0,
ws_connections: 0,
};
store.push(snapshot).await;
}
let history = store.history_minutes(3).await;
assert_eq!(history.len(), 3);
assert_eq!(history[0].timestamp, 120);
assert_eq!(history[2].timestamp, 240);
}
#[tokio::test]
async fn test_ring_buffer_eviction() {
let store = MetricsStore::new();
// Push more than MAX_1MIN_ENTRIES
for i in 0..(MAX_1MIN_ENTRIES + 10) {
let snapshot = MetricSnapshot {
timestamp: i as i64,
system: SystemMetrics {
cpu_percent: 0.0,
mem_used_bytes: 0,
mem_total_bytes: 0,
disk_used_bytes: 0,
disk_total_bytes: 0,
net_rx_bytes: 0,
net_tx_bytes: 0,
load_avg_1: 0.0,
load_avg_5: 0.0,
load_avg_15: 0.0,
},
containers: vec![],
rpc_latency_ms: 0.0,
ws_connections: 0,
};
store.push(snapshot).await;
}
let all = store.history_minutes(MAX_1MIN_ENTRIES + 100).await;
assert_eq!(all.len(), MAX_1MIN_ENTRIES);
// Oldest entry should be 10 (first 10 were evicted)
assert_eq!(all[0].timestamp, 10);
}
#[tokio::test]
async fn test_quarter_hour_downsampling() {
let store = MetricsStore::new();
// Push exactly 15 entries to trigger one quarter-hour sample
for i in 0..15 {
let snapshot = MetricSnapshot {
timestamp: i * 60,
system: SystemMetrics {
cpu_percent: 50.0,
mem_used_bytes: 0,
mem_total_bytes: 0,
disk_used_bytes: 0,
disk_total_bytes: 0,
net_rx_bytes: 0,
net_tx_bytes: 0,
load_avg_1: 0.0,
load_avg_5: 0.0,
load_avg_15: 0.0,
},
containers: vec![],
rpc_latency_ms: 0.0,
ws_connections: 0,
};
store.push(snapshot).await;
}
let qh = store.history_quarter_hours(10).await;
assert_eq!(qh.len(), 1);
assert_eq!(qh[0].timestamp, 14 * 60); // The 15th entry (index 14)
}
#[test]
fn test_constants() {
assert_eq!(MAX_1MIN_ENTRIES, 1440);
assert_eq!(MAX_15MIN_ENTRIES, 672);
}
}
@@ -0,0 +1,209 @@
use crate::monitoring::store::MetricsStore;
use std::path::PathBuf;
use std::sync::Arc;
use tracing::{debug, info, warn};
/// Spawn the periodic telemetry reporter (runs every 15 minutes when opt-in enabled).
/// Collects anonymous health data and saves to disk. Posts to central collector if configured.
pub fn spawn_telemetry_reporter(
store: Arc<MetricsStore>,
state: Option<Arc<crate::state::StateManager>>,
data_dir: PathBuf,
) {
tokio::spawn(async move {
// Wait 60s for system to fully stabilize
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
let mut interval = tokio::time::interval(std::time::Duration::from_secs(900)); // 15 min
loop {
interval.tick().await;
// Check if telemetry is opted in
let config_path = data_dir.join("analytics-config.json");
let enabled = match tokio::fs::read_to_string(&config_path).await {
Ok(data) => serde_json::from_str::<serde_json::Value>(&data)
.ok()
.and_then(|c| c["enabled"].as_bool())
.unwrap_or(false),
Err(_) => false,
};
if !enabled {
debug!("Telemetry disabled — skipping report");
continue;
}
match build_telemetry_report(&store, &state, &data_dir).await {
Ok(report) => {
// Save latest report to disk
let report_path = data_dir.join("telemetry-latest.json");
if let Ok(json) = serde_json::to_string_pretty(&report) {
let _ = tokio::fs::write(&report_path, &json).await;
}
// Always save to local fleet directory so this node appears
// in its own fleet view
save_report_to_fleet_dir(&data_dir, &report).await;
// POST to central collector if configured
let collector_url = std::env::var("TELEMETRY_COLLECTOR_URL").ok();
if let Some(url) = collector_url {
match post_telemetry_report(&url, &report).await {
Ok(_) => info!("Telemetry report sent to collector"),
Err(e) => warn!("Failed to send telemetry report: {}", e),
}
} else {
debug!("Telemetry report saved locally (no collector URL configured)");
}
}
Err(e) => {
warn!("Failed to build telemetry report: {}", e);
}
}
}
});
}
/// Build an anonymous telemetry report from current system state.
async fn build_telemetry_report(
store: &Arc<MetricsStore>,
state: &Option<Arc<crate::state::StateManager>>,
data_dir: &std::path::Path,
) -> anyhow::Result<serde_json::Value> {
// Anonymous node ID — truncated SHA-256 hash of pubkey
let (node_id, version, container_count, running_count, peer_count) = if let Some(ref sm) = state {
let (data, _) = sm.get_snapshot().await;
let id = {
use sha2::{Sha256, Digest};
let mut h = Sha256::new();
h.update(data.server_info.pubkey.as_bytes());
hex::encode(h.finalize())[..16].to_string()
};
let running = data.package_data.values()
.filter(|p| matches!(p.state, crate::data_model::PackageState::Running))
.count();
(id, data.server_info.version.clone(), data.package_data.len(), running, data.peer_health.len())
} else {
("unknown".to_string(), "unknown".to_string(), 0, 0, 0)
};
// System info
let cpu_cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(0);
let uptime_secs = tokio::fs::read_to_string("/proc/uptime").await
.ok()
.and_then(|s| s.split_whitespace().next()?.parse::<f64>().ok())
.map(|f| f as u64)
.unwrap_or(0);
// Latest metrics snapshot
let latest = store.latest().await;
let (cpu_pct, mem_pct, disk_pct): (f64, f64, f64) = latest.map(|s| {
let mem_total = s.system.mem_total_bytes as f64;
let disk_total = s.system.disk_total_bytes as f64;
(
s.system.cpu_percent,
if mem_total > 0.0 { (s.system.mem_used_bytes as f64 / mem_total) * 100.0 } else { 0.0 },
if disk_total > 0.0 { (s.system.disk_used_bytes as f64 / disk_total) * 100.0 } else { 0.0 },
)
}).unwrap_or((0.0, 0.0, 0.0));
// Recent alerts
let recent_alerts: Vec<serde_json::Value> = store.get_fired_alerts(10).await
.into_iter()
.map(|a| serde_json::json!({
"rule": format!("{:?}", a.kind),
"message": a.message,
"timestamp": a.timestamp,
}))
.collect();
let _ = data_dir; // used for future per-app telemetry
Ok(serde_json::json!({
"node_id": node_id,
"version": version,
"uptime_secs": uptime_secs,
"cpu_cores": cpu_cores,
"cpu_pct": (cpu_pct * 10.0).round() / 10.0,
"mem_pct": (mem_pct * 10.0).round() / 10.0,
"disk_pct": (disk_pct * 10.0).round() / 10.0,
"container_count": container_count,
"running_count": running_count,
"federation_peers": peer_count,
"recent_alerts": recent_alerts,
"reported_at": chrono::Utc::now().to_rfc3339(),
}))
}
/// POST a telemetry report to the central collector.
async fn post_telemetry_report(url: &str, report: &serde_json::Value) -> anyhow::Result<()> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()?;
let response = client.post(url)
.header("Content-Type", "application/json")
.header("User-Agent", "Archipelago-Telemetry/1.0")
.json(report)
.send()
.await?;
if !response.status().is_success() {
anyhow::bail!("Collector returned {}", response.status());
}
Ok(())
}
/// Save a telemetry report into the local fleet directory.
/// This makes the node's own report visible in the fleet dashboard.
async fn save_report_to_fleet_dir(data_dir: &std::path::Path, report: &serde_json::Value) {
let node_id = match report.get("node_id").and_then(|v| v.as_str()) {
Some(id) if !id.is_empty() => id,
_ => {
warn!("Telemetry report missing node_id — skipping fleet save");
return;
}
};
let fleet_dir = data_dir.join("telemetry-fleet");
if let Err(e) = tokio::fs::create_dir_all(&fleet_dir).await {
warn!("Failed to create telemetry-fleet directory: {}", e);
return;
}
// Write latest report (overwrites previous)
let latest_path = fleet_dir.join(format!("{}.json", node_id));
match serde_json::to_string_pretty(report) {
Ok(json) => {
if let Err(e) = tokio::fs::write(&latest_path, &json).await {
warn!("Failed to write fleet report for {}: {}", node_id, e);
}
}
Err(e) => {
warn!("Failed to serialize fleet report: {}", e);
return;
}
}
// Append to history file (cap at 200 entries)
let history_path = fleet_dir.join(format!("{}-history.json", node_id));
let mut history: Vec<serde_json::Value> = match tokio::fs::read_to_string(&history_path).await {
Ok(data) => serde_json::from_str(&data).unwrap_or_default(),
Err(_) => Vec::new(),
};
history.push(report.clone());
if history.len() > 200 {
let start = history.len() - 200;
history = history.split_off(start);
}
match serde_json::to_string_pretty(&history) {
Ok(json) => {
if let Err(e) = tokio::fs::write(&history_path, &json).await {
warn!("Failed to write fleet history for {}: {}", node_id, e);
}
}
Err(e) => {
warn!("Failed to serialize fleet history: {}", e);
}
}
debug!("Saved own telemetry report to fleet directory (node_id={})", node_id);
}
+81
View File
@@ -0,0 +1,81 @@
use serde::{Deserialize, Serialize};
/// Maximum entries at 1-minute resolution (24 hours = 1440 minutes)
pub const MAX_1MIN_ENTRIES: usize = 1440;
/// Maximum entries at 15-minute resolution (7 days = 672 quarter-hours)
pub const MAX_15MIN_ENTRIES: usize = 672;
/// Maximum number of fired alerts to keep in history.
pub const MAX_ALERT_HISTORY: usize = 100;
/// A single metrics snapshot collected at a point in time.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetricSnapshot {
pub timestamp: i64,
pub system: SystemMetrics,
pub containers: Vec<ContainerMetrics>,
pub rpc_latency_ms: f64,
pub ws_connections: u32,
}
/// System-wide resource metrics.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SystemMetrics {
pub cpu_percent: f64,
pub mem_used_bytes: u64,
pub mem_total_bytes: u64,
pub disk_used_bytes: u64,
pub disk_total_bytes: u64,
pub net_rx_bytes: u64,
pub net_tx_bytes: u64,
pub load_avg_1: f64,
pub load_avg_5: f64,
pub load_avg_15: f64,
}
/// Per-container resource metrics.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContainerMetrics {
pub name: String,
pub cpu_percent: f64,
pub mem_used_bytes: u64,
pub mem_limit_bytes: u64,
pub net_rx_bytes: u64,
pub net_tx_bytes: u64,
pub block_read_bytes: u64,
pub block_write_bytes: u64,
}
/// Types of alert rules the system can evaluate.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum AlertRuleKind {
DiskUsage,
RamUsage,
CpuLoad,
ContainerCrash,
BackendErrorSpike,
SslCertExpiry,
}
/// A configured alert rule with threshold and enabled state.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AlertRule {
pub kind: AlertRuleKind,
pub threshold: f64,
pub enabled: bool,
pub description: String,
}
/// A fired alert instance.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FiredAlert {
pub id: String,
pub kind: AlertRuleKind,
pub message: String,
pub value: f64,
pub threshold: f64,
pub timestamp: i64,
pub acknowledged: bool,
}
-7
View File
@@ -19,13 +19,6 @@ pub struct DhtDidCache {
}
impl DhtDidCache {
pub fn new() -> Self {
Self {
entries: RwLock::new(HashMap::new()),
ttl: std::time::Duration::from_secs(3600),
}
}
pub async fn get(&self, did: &str) -> Option<serde_json::Value> {
let entries = self.entries.read().await;
if let Some((ts, doc)) = entries.get(did) {
-17
View File
@@ -281,20 +281,3 @@ async fn sync_single_peer(
Ok(imported)
}
/// Add a peer as a sync target.
pub async fn add_sync_target(data_dir: &Path, onion: &str) -> Result<()> {
let mut state = load_sync_state(data_dir).await?;
if !state.peer_sync_targets.contains(&onion.to_string()) {
state.peer_sync_targets.push(onion.to_string());
save_sync_state(data_dir, &state).await?;
}
Ok(())
}
/// Remove a peer sync target.
pub async fn remove_sync_target(data_dir: &Path, onion: &str) -> Result<()> {
let mut state = load_sync_state(data_dir).await?;
state.peer_sync_targets.retain(|o| o != onion);
save_sync_state(data_dir, &state).await?;
Ok(())
}
-46
View File
@@ -7,7 +7,6 @@
use anyhow::{Context, Result};
use nostr_sdk::prelude::*;
use nostr_sdk::pool;
use std::net::SocketAddr;
use std::path::Path;
use tokio::fs;
@@ -146,51 +145,6 @@ pub async fn revoke_if_needed(identity_dir: &Path, tor_proxy: Option<&str>) -> R
Ok(())
}
/// Publish node identity to Nostr relays for discovery.
/// Content: { did, node_address, version }
/// Only call when relays are non-empty (opt-in).
/// When tor_proxy is set, routes through Tor to prevent IP exposure.
/// Skips if nostr_revoked sentinel exists (revocation must not be overwritten).
pub async fn publish_node_identity(
identity_dir: &Path,
did: &str,
node_address: &str,
version: &str,
relays: &[String],
tor_proxy: Option<&str>,
) -> Result<pool::Output<EventId>> {
if relays.is_empty() {
anyhow::bail!("No relays configured for Nostr discovery");
}
if identity_dir.join(NOSTR_REVOKED_FILE).exists() {
tracing::debug!("Nostr discovery: skipping publish (revoked)");
return Err(anyhow::anyhow!("Nostr discovery revoked"));
}
let keys = load_or_create_nostr_keys(identity_dir).await?;
let client = build_nostr_client(keys, tor_proxy)?;
let content = serde_json::json!({
"did": did,
"node_address": node_address,
"version": version,
})
.to_string();
for url in relays {
let _ = client.add_relay(url).await;
}
client.connect().await;
let builder = EventBuilder::new(Kind::Custom(ARCHIPELAGO_KIND as u16), content)
.tag(Tag::identifier(D_TAG));
let output = client.send_event_builder(builder).await?;
client.disconnect().await;
Ok(output)
}
/// Get Nostr public key for this node (hex).
pub async fn get_nostr_pubkey(identity_dir: &Path) -> Result<String> {
let keys = load_or_create_nostr_keys(identity_dir).await?;
+191
View File
@@ -0,0 +1,191 @@
use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::RwLock;
/// Rate limiter for login attempts: max 5 failures per 60 seconds per IP.
#[derive(Clone)]
pub struct LoginRateLimiter {
attempts: Arc<RwLock<HashMap<IpAddr, Vec<Instant>>>>,
}
const MAX_ATTEMPTS: usize = 5;
const WINDOW_SECS: u64 = 60;
impl LoginRateLimiter {
pub fn new() -> Self {
Self {
attempts: Arc::new(RwLock::new(HashMap::new())),
}
}
pub async fn check(&self, ip: IpAddr) -> bool {
let mut attempts = self.attempts.write().await;
let now = Instant::now();
let entry = attempts.entry(ip).or_default();
entry.retain(|t| now.duration_since(*t).as_secs() < WINDOW_SECS);
entry.len() < MAX_ATTEMPTS
}
pub async fn record_failure(&self, ip: IpAddr) {
let mut attempts = self.attempts.write().await;
let entry = attempts.entry(ip).or_default();
entry.push(Instant::now());
}
/// Periodic cleanup of expired entries for IPs that are no longer active.
pub async fn cleanup(&self) {
let mut attempts = self.attempts.write().await;
let now = Instant::now();
attempts.retain(|_, timestamps| {
timestamps.retain(|t| now.duration_since(*t).as_secs() < WINDOW_SECS);
!timestamps.is_empty()
});
}
}
/// General-purpose rate limiter for sensitive endpoints.
/// Tracks request counts per (method, IP) with configurable limits and windows.
#[derive(Clone)]
pub struct EndpointRateLimiter {
/// Map of (method, ip) -> list of request timestamps
requests: Arc<RwLock<HashMap<(String, IpAddr), Vec<Instant>>>>, // Instant for monotonic rate limiting
/// Per-method configuration: (max_requests, window_secs)
limits: Arc<HashMap<String, (usize, u64)>>,
}
impl EndpointRateLimiter {
pub fn new() -> Self {
let mut limits = HashMap::new();
// Financial operations: strict limits
limits.insert("wallet.send".to_string(), (5usize, 300u64));
limits.insert("wallet.ecash-send".to_string(), (10, 300));
limits.insert("lnd.sendcoins".to_string(), (5, 300));
limits.insert("lnd.payinvoice".to_string(), (10, 300));
limits.insert("lnd.openchannel".to_string(), (3, 300));
limits.insert("lnd.closechannel".to_string(), (3, 300));
limits.insert("lnd.create-psbt".to_string(), (5, 300));
limits.insert("lnd.finalize-psbt".to_string(), (5, 300));
// Identity/credential operations
limits.insert("identity.create".to_string(), (10, 300));
limits.insert("identity.issue-credential".to_string(), (20, 300));
// Backup operations (resource-intensive)
limits.insert("backup.create".to_string(), (10, 600));
limits.insert("backup.restore".to_string(), (5, 600));
// Container operations
limits.insert("container-install".to_string(), (5, 300));
limits.insert("package.install".to_string(), (5, 300));
// S3 backup operations (resource-intensive)
limits.insert("backup.upload-s3".to_string(), (3, 600));
limits.insert("backup.download-s3".to_string(), (3, 600));
// System operations
limits.insert("update.apply".to_string(), (2, 600));
limits.insert("system.reboot".to_string(), (2, 300));
limits.insert("system.shutdown".to_string(), (2, 300));
// Password and TOTP changes
limits.insert("auth.changePassword".to_string(), (3, 300));
limits.insert("auth.totp.setup".to_string(), (3, 300));
limits.insert("auth.totp.confirm".to_string(), (5, 300));
// Federation join: prevent invite-code brute force
limits.insert("federation.join".to_string(), (5, 60));
limits.insert("federation.invite".to_string(), (10, 300));
// Inter-node federation RPCs (unauthenticated, need stricter limits)
limits.insert("federation.peer-joined".to_string(), (10, 60));
limits.insert("federation.peer-address-changed".to_string(), (10, 60));
limits.insert("federation.peer-did-changed".to_string(), (5, 60));
limits.insert("federation.get-state".to_string(), (30, 60));
// DID rotation: sensitive identity operation
limits.insert("node.rotate-did".to_string(), (3, 600));
Self {
requests: Arc::new(RwLock::new(HashMap::new())),
limits: Arc::new(limits),
}
}
/// Check if a request is allowed. Returns true if within limits.
pub async fn check(&self, method: &str, ip: IpAddr) -> bool {
let (max_req, window) = match self.limits.get(method) {
Some(config) => *config,
None => return true, // Not rate-limited
};
let key = (method.to_string(), ip);
let mut requests = self.requests.write().await;
let now = Instant::now();
let entry = requests.entry(key).or_default();
entry.retain(|t| now.duration_since(*t).as_secs() < window);
entry.len() < max_req
}
/// Record a request for rate limiting purposes.
pub async fn record(&self, method: &str, ip: IpAddr) {
if !self.limits.contains_key(method) {
return; // Not rate-limited, skip tracking
}
let key = (method.to_string(), ip);
let mut requests = self.requests.write().await;
let entry = requests.entry(key).or_default();
entry.push(Instant::now());
}
/// Periodic cleanup of expired entries.
pub async fn cleanup(&self) {
let mut requests = self.requests.write().await;
let now = Instant::now();
requests.retain(|(method, _), timestamps| {
let window = self
.limits
.get(method)
.map(|(_, w)| *w)
.unwrap_or(300);
timestamps.retain(|t| now.duration_since(*t).as_secs() < window);
!timestamps.is_empty()
});
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_rate_limiter_allows_under_limit() {
let limiter = LoginRateLimiter::new();
let ip: IpAddr = "127.0.0.1".parse().unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST));
for _ in 0..MAX_ATTEMPTS {
assert!(limiter.check(ip).await);
limiter.record_failure(ip).await;
}
}
#[tokio::test]
async fn test_rate_limiter_blocks_over_limit() {
let limiter = LoginRateLimiter::new();
let ip: IpAddr = "127.0.0.1".parse().unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST));
for _ in 0..MAX_ATTEMPTS {
limiter.record_failure(ip).await;
}
assert!(!limiter.check(ip).await);
}
#[tokio::test]
async fn test_rate_limiter_different_ips() {
let limiter = LoginRateLimiter::new();
let ip1: IpAddr = "127.0.0.1".parse().unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST));
let ip2: IpAddr = "192.168.1.1".parse().unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST));
for _ in 0..MAX_ATTEMPTS {
limiter.record_failure(ip1).await;
}
// ip1 should be blocked
assert!(!limiter.check(ip1).await);
// ip2 should still be allowed
assert!(limiter.check(ip2).await);
}
}
+1 -5
View File
@@ -105,7 +105,7 @@ impl Server {
let identity = Arc::new(identity);
// Create metrics store and spawn background collector
let metrics_store = Arc::new(MetricsStore::with_data_dir(config.data_dir.clone()));
let metrics_store = Arc::new(MetricsStore::with_data_dir(config.data_dir.clone()).await);
let metrics_for_telemetry = metrics_store.clone();
crate::monitoring::spawn_metrics_collector(metrics_store.clone(), Some(state_manager.clone()), Some(config.data_dir.clone()));
@@ -311,10 +311,6 @@ impl Server {
})
}
pub async fn serve(&self, addr: SocketAddr) -> Result<()> {
self.serve_with_shutdown(addr, std::future::pending()).await
}
/// Serve with a graceful shutdown signal.
/// When the shutdown future completes, stop accepting new connections and drain in-flight requests.
pub async fn serve_with_shutdown(
+3 -200
View File
@@ -2,10 +2,9 @@ use hmac::{Hmac, Mac};
use rand::RngCore;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::net::IpAddr;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::sync::RwLock;
use zeroize::Zeroize;
@@ -129,25 +128,6 @@ impl SessionStore {
}
}
/// Async wrapper for save — spawns to avoid blocking RPC.
fn schedule_save(&self, sessions: &HashMap<[u8; 32], Session>) {
let persisted: Vec<PersistedSession> = sessions
.iter()
.filter(|(_, s)| matches!(s.session_type, SessionType::Full))
.map(|(hash, s)| PersistedSession {
hash_hex: hex::encode(hash),
created_at: s.created_at.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(),
last_activity: s.last_activity.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(),
})
.collect();
let path = self.persist_path.clone();
tokio::spawn(async move {
if let Ok(json) = serde_json::to_string(&persisted) {
let _ = tokio::fs::write(path, json).await;
}
});
}
/// Create a full (authenticated) session. Returns the plaintext token.
/// Enforces max concurrent sessions by evicting the oldest if limit reached.
pub async fn create(&self) -> String {
@@ -303,6 +283,7 @@ impl SessionStore {
}
/// Remove all expired sessions (cleanup).
#[cfg(test)]
pub async fn cleanup_expired(&self) {
let mut sessions = self.sessions.write().await;
sessions.retain(|_, session| {
@@ -336,6 +317,7 @@ impl SessionStore {
}
/// Get the number of active full sessions.
#[cfg(test)]
pub async fn active_session_count(&self) -> usize {
let sessions = self.sessions.read().await;
sessions
@@ -447,147 +429,6 @@ pub fn extract_session_cookie(headers: &hyper::HeaderMap) -> Option<String> {
.filter(|v| !v.is_empty())
}
/// Rate limiter for login attempts: max 5 failures per 60 seconds per IP.
#[derive(Clone)]
pub struct LoginRateLimiter {
attempts: Arc<RwLock<HashMap<IpAddr, Vec<Instant>>>>,
}
const MAX_ATTEMPTS: usize = 5;
const WINDOW_SECS: u64 = 60;
impl LoginRateLimiter {
pub fn new() -> Self {
Self {
attempts: Arc::new(RwLock::new(HashMap::new())),
}
}
pub async fn check(&self, ip: IpAddr) -> bool {
let mut attempts = self.attempts.write().await;
let now = Instant::now();
let entry = attempts.entry(ip).or_default();
entry.retain(|t| now.duration_since(*t).as_secs() < WINDOW_SECS);
entry.len() < MAX_ATTEMPTS
}
pub async fn record_failure(&self, ip: IpAddr) {
let mut attempts = self.attempts.write().await;
let entry = attempts.entry(ip).or_default();
entry.push(Instant::now());
}
/// Periodic cleanup of expired entries for IPs that are no longer active.
pub async fn cleanup(&self) {
let mut attempts = self.attempts.write().await;
let now = Instant::now();
attempts.retain(|_, timestamps| {
timestamps.retain(|t| now.duration_since(*t).as_secs() < WINDOW_SECS);
!timestamps.is_empty()
});
}
}
/// General-purpose rate limiter for sensitive endpoints.
/// Tracks request counts per (method, IP) with configurable limits and windows.
#[derive(Clone)]
pub struct EndpointRateLimiter {
/// Map of (method, ip) -> list of request timestamps
requests: Arc<RwLock<HashMap<(String, IpAddr), Vec<Instant>>>>, // Instant for monotonic rate limiting
/// Per-method configuration: (max_requests, window_secs)
limits: Arc<HashMap<String, (usize, u64)>>,
}
impl EndpointRateLimiter {
pub fn new() -> Self {
let mut limits = HashMap::new();
// Financial operations: strict limits
limits.insert("wallet.send".to_string(), (5usize, 300u64));
limits.insert("wallet.ecash-send".to_string(), (10, 300));
limits.insert("lnd.sendcoins".to_string(), (5, 300));
limits.insert("lnd.payinvoice".to_string(), (10, 300));
limits.insert("lnd.openchannel".to_string(), (3, 300));
limits.insert("lnd.closechannel".to_string(), (3, 300));
limits.insert("lnd.create-psbt".to_string(), (5, 300));
limits.insert("lnd.finalize-psbt".to_string(), (5, 300));
// Identity/credential operations
limits.insert("identity.create".to_string(), (10, 300));
limits.insert("identity.issue-credential".to_string(), (20, 300));
// Backup operations (resource-intensive)
limits.insert("backup.create".to_string(), (10, 600));
limits.insert("backup.restore".to_string(), (5, 600));
// Container operations
limits.insert("container-install".to_string(), (5, 300));
limits.insert("package.install".to_string(), (5, 300));
// S3 backup operations (resource-intensive)
limits.insert("backup.upload-s3".to_string(), (3, 600));
limits.insert("backup.download-s3".to_string(), (3, 600));
// System operations
limits.insert("update.apply".to_string(), (2, 600));
limits.insert("system.reboot".to_string(), (2, 300));
limits.insert("system.shutdown".to_string(), (2, 300));
// Password and TOTP changes
limits.insert("auth.changePassword".to_string(), (3, 300));
limits.insert("auth.totp.setup".to_string(), (3, 300));
limits.insert("auth.totp.confirm".to_string(), (5, 300));
// Federation join: prevent invite-code brute force
limits.insert("federation.join".to_string(), (5, 60));
limits.insert("federation.invite".to_string(), (10, 300));
// Inter-node federation RPCs (unauthenticated, need stricter limits)
limits.insert("federation.peer-joined".to_string(), (10, 60));
limits.insert("federation.peer-address-changed".to_string(), (10, 60));
limits.insert("federation.peer-did-changed".to_string(), (5, 60));
limits.insert("federation.get-state".to_string(), (30, 60));
// DID rotation: sensitive identity operation
limits.insert("node.rotate-did".to_string(), (3, 600));
Self {
requests: Arc::new(RwLock::new(HashMap::new())),
limits: Arc::new(limits),
}
}
/// Check if a request is allowed. Returns true if within limits.
pub async fn check(&self, method: &str, ip: IpAddr) -> bool {
let (max_req, window) = match self.limits.get(method) {
Some(config) => *config,
None => return true, // Not rate-limited
};
let key = (method.to_string(), ip);
let mut requests = self.requests.write().await;
let now = Instant::now();
let entry = requests.entry(key).or_default();
entry.retain(|t| now.duration_since(*t).as_secs() < window);
entry.len() < max_req
}
/// Record a request for rate limiting purposes.
pub async fn record(&self, method: &str, ip: IpAddr) {
if !self.limits.contains_key(method) {
return; // Not rate-limited, skip tracking
}
let key = (method.to_string(), ip);
let mut requests = self.requests.write().await;
let entry = requests.entry(key).or_default();
entry.push(Instant::now());
}
/// Periodic cleanup of expired entries.
pub async fn cleanup(&self) {
let mut requests = self.requests.write().await;
let now = Instant::now();
requests.retain(|(method, _), timestamps| {
let window = self
.limits
.get(method)
.map(|(_, w)| *w)
.unwrap_or(300);
timestamps.retain(|t| now.duration_since(*t).as_secs() < window);
!timestamps.is_empty()
});
}
}
#[cfg(test)]
mod tests {
@@ -669,44 +510,6 @@ mod tests {
assert_eq!(extract_session_cookie(&headers), None);
}
#[tokio::test]
async fn test_rate_limiter_allows_under_limit() {
let limiter = LoginRateLimiter::new();
let ip: IpAddr = "127.0.0.1".parse().unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST));
for _ in 0..MAX_ATTEMPTS {
assert!(limiter.check(ip).await);
limiter.record_failure(ip).await;
}
}
#[tokio::test]
async fn test_rate_limiter_blocks_over_limit() {
let limiter = LoginRateLimiter::new();
let ip: IpAddr = "127.0.0.1".parse().unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST));
for _ in 0..MAX_ATTEMPTS {
limiter.record_failure(ip).await;
}
assert!(!limiter.check(ip).await);
}
#[tokio::test]
async fn test_rate_limiter_different_ips() {
let limiter = LoginRateLimiter::new();
let ip1: IpAddr = "127.0.0.1".parse().unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST));
let ip2: IpAddr = "192.168.1.1".parse().unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST));
for _ in 0..MAX_ATTEMPTS {
limiter.record_failure(ip1).await;
}
// ip1 should be blocked
assert!(!limiter.check(ip1).await);
// ip2 should still be allowed
assert!(limiter.check(ip2).await);
}
#[tokio::test]
async fn test_session_activity_updates_on_validate() {
+1 -1
View File
@@ -82,7 +82,7 @@ pub fn setup(password: &str) -> Result<SetupResult> {
6,
1, // skew
30,
Secret::Raw(totp_secret.clone()).to_bytes().unwrap(),
Secret::Raw(totp_secret.clone()).to_bytes().map_err(|_| anyhow::anyhow!("Invalid TOTP secret"))?,
Some("Archipelago".to_string()),
"node".to_string(),
)
@@ -1,3 +1,5 @@
// WIP mesh/transport protocol — suppress dead code warnings
#![allow(dead_code)]
//! Chunked message protocol with Reed-Solomon FEC for LoRa transport.
//!
//! Splits payloads larger than a single LoRa frame (160 bytes) into
+2
View File
@@ -1,3 +1,5 @@
// WIP mesh/transport protocol — suppress dead code warnings
#![allow(dead_code)]
//! CBOR delta encoding for federation state sync.
//!
//! Instead of sending a full NodeStateSnapshot (~500-2000 bytes JSON) on every
+3 -2
View File
@@ -1,3 +1,5 @@
// WIP mesh/transport protocol — suppress dead code warnings
#![allow(dead_code)]
//! LAN transport — peer discovery via mDNS and direct HTTP messaging.
//!
//! Advertises this node as `_archipelago._tcp.local.` with TXT records
@@ -12,8 +14,7 @@ use mdns_sd::{ServiceDaemon, ServiceEvent, ServiceInfo};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
use tracing::{debug, info, warn};
use tracing::{debug, info};
const SERVICE_TYPE: &str = "_archipelago._tcp.local.";
const DEFAULT_PORT: u16 = 5678;
@@ -1,10 +1,12 @@
// WIP mesh/transport protocol — suppress dead code warnings
#![allow(dead_code)]
//! Mesh transport — sends messages via LoRa radio through the MeshService.
//!
//! Bridges the transport abstraction to the existing mesh serial listener.
//! For payloads exceeding the LoRa frame limit (160 bytes), uses the chunking
//! protocol with Reed-Solomon FEC for reliable delivery.
use super::chunking::{self, ChunkReassembler, MAX_CHUNK_PAYLOAD};
use super::chunking::{self, ChunkReassembler};
use super::{NodeTransport, TransportKind, TransportMessage};
use crate::mesh::MeshService;
use anyhow::{Context, Result};
+2
View File
@@ -1,3 +1,5 @@
// WIP mesh/transport protocol — suppress dead code warnings
#![allow(dead_code)]
//! Transport abstraction layer for Archipelago node-to-node communication.
//!
//! Unifies mesh radio (LoRa), LAN (mDNS), and Tor under a common trait.
+3 -1
View File
@@ -1,9 +1,11 @@
// WIP mesh/transport protocol — suppress dead code warnings
#![allow(dead_code)]
//! Tor transport — sends messages via HTTP POST through SOCKS5 proxy.
//!
//! Wraps the existing `node_message.rs` Tor messaging logic behind
//! the `NodeTransport` trait.
use super::{MessageType, NodeTransport, TransportKind, TransportMessage};
use super::{NodeTransport, TransportKind, TransportMessage};
use anyhow::{Context, Result};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
+1 -78
View File
@@ -5,7 +5,7 @@ use chrono::Timelike;
use serde::{Deserialize, Serialize};
use std::path::Path;
use tokio::fs;
use tracing::{debug, info, warn};
use tracing::{debug, info};
const DEFAULT_UPDATE_MANIFEST_URL: &str =
"https://raw.githubusercontent.com/archipelago-os/releases/main/manifest.json";
@@ -301,83 +301,6 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
Ok(())
}
/// Rolling container restart — restarts containers one at a time with health checks.
/// This enables zero-downtime updates for containerized apps.
pub async fn rolling_container_restart() -> Result<RollingRestartReport> {
use std::process::Command;
let output = Command::new("podman")
.args(["ps", "--format", "{{.Names}}"])
.output()
.context("Failed to list containers")?;
let names: Vec<String> = String::from_utf8_lossy(&output.stdout)
.lines()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
let total = names.len();
let mut restarted = 0;
let mut failed = Vec::new();
info!(total = total, "Starting rolling container restart");
for name in &names {
debug!(container = %name, "Restarting container");
let restart = Command::new("podman")
.args(["restart", "--time", "30", name])
.output();
match restart {
Ok(out) if out.status.success() => {
// Wait for container to be healthy
let mut healthy = false;
for _ in 0..12 {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
let check = Command::new("podman")
.args(["inspect", name, "--format", "{{.State.Status}}"])
.output();
if let Ok(out) = check {
let status = String::from_utf8_lossy(&out.stdout).trim().to_string();
if status == "running" {
healthy = true;
break;
}
}
}
if healthy {
restarted += 1;
debug!(container = %name, "Container restarted successfully");
} else {
failed.push(name.clone());
warn!(container = %name, "Container not healthy after restart");
}
}
_ => {
failed.push(name.clone());
warn!(container = %name, "Container restart command failed");
}
}
}
info!(restarted = restarted, failed = failed.len(), "Rolling restart complete");
Ok(RollingRestartReport {
total,
restarted,
failed,
})
}
/// Report from a rolling container restart.
#[derive(Debug, Serialize, Deserialize)]
pub struct RollingRestartReport {
pub total: usize,
pub restarted: usize,
pub failed: Vec<String>,
}
/// Rollback to the previous version from backup.
pub async fn rollback_update(data_dir: &Path) -> Result<()> {
let backup_dir = data_dir.join("update-backup");
-38
View File
@@ -6,8 +6,6 @@ use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
use tokio::fs;
use tracing::debug;
use super::ecash;
const PROFITS_FILE: &str = "wallet/profits.json";
@@ -55,42 +53,6 @@ pub async fn load_profits(data_dir: &Path) -> Result<ProfitsSummary> {
Ok(summary)
}
/// Save profits summary to disk.
pub async fn save_profits(data_dir: &Path, summary: &ProfitsSummary) -> Result<()> {
let dir = data_dir.join("wallet");
fs::create_dir_all(&dir)
.await
.context("Failed to create wallet dir")?;
let path = data_dir.join(PROFITS_FILE);
let content =
serde_json::to_string_pretty(summary).context("Failed to serialize profits")?;
fs::write(&path, content)
.await
.context("Failed to write profits file")?;
Ok(())
}
/// Record a content sale profit.
pub async fn record_content_sale(data_dir: &Path, amount_sats: u64, description: &str) -> Result<()> {
let mut summary = load_profits(data_dir).await?;
summary.total_sats += amount_sats;
summary.content_sales_sats += amount_sats;
summary.recent.insert(
0,
ProfitEntry {
source: ProfitSource::ContentSale,
amount_sats,
timestamp: chrono::Utc::now().to_rfc3339(),
description: description.to_string(),
},
);
// Keep only the last 100 entries
summary.recent.truncate(100);
save_profits(data_dir, &summary).await?;
debug!("Recorded content sale: {} sats", amount_sats);
Ok(())
}
/// Compute a full profits summary including ecash receive transactions.
pub async fn get_networking_profits(data_dir: &Path) -> Result<ProfitsSummary> {
let mut summary = load_profits(data_dir).await?;
+1 -1
View File
@@ -297,7 +297,7 @@ impl PodmanClient {
}
}
let mut cap_add: Vec<String> = manifest.app.security.capabilities.clone();
let cap_add: Vec<String> = manifest.app.security.capabilities.clone();
let cap_drop = vec!["ALL".to_string()];
let body = serde_json::json!({
+3 -3
View File
@@ -38,7 +38,7 @@ pub struct ExpiringSecret {
pub struct SecretsManager {
secrets_dir: PathBuf,
cipher: Aes256Gcm,
raw_key: zeroize::Zeroizing<[u8; 32]>,
_raw_key: zeroize::Zeroizing<[u8; 32]>,
}
impl SecretsManager {
@@ -57,7 +57,7 @@ impl SecretsManager {
Ok(Self {
secrets_dir,
cipher,
raw_key: zeroize::Zeroizing::new(key_array),
_raw_key: zeroize::Zeroizing::new(key_array),
})
}
@@ -285,7 +285,7 @@ impl SecretsManager {
if !app_path.is_dir() {
continue;
}
let app_id = app_path
let _app_id = app_path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("")