bug fixing and deploy and build diagnostics
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+14
-28
@@ -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(())
|
||||
}
|
||||
|
||||
+25
-40
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
mod bitcoin_ops;
|
||||
mod messaging;
|
||||
mod safety;
|
||||
mod status;
|
||||
mod typed_messages;
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -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" }))
|
||||
}
|
||||
}
|
||||
+17
-328
@@ -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" }))
|
||||
}
|
||||
}
|
||||
@@ -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 }))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user