use crate::auth::AuthManager; use crate::config::Config; use crate::container::docker_packages; use crate::container::DevContainerOrchestrator; use crate::identity; use crate::node_message; use crate::nostr_discovery; use crate::peers::{self, KnownPeer}; use crate::port_allocator::PortAllocator; use crate::state::StateManager; use anyhow::{Context, Result}; use hyper::{Request, Response, StatusCode}; use serde::{Deserialize, Serialize}; use std::sync::{Arc, Mutex}; use tracing::{debug, error}; #[derive(Debug, Deserialize)] struct RpcRequest { method: String, params: Option, } #[derive(Debug, Serialize)] struct RpcResponse { result: Option, error: Option, } #[derive(Debug, Serialize)] struct RpcError { code: i32, message: String, data: Option, } /// Default dev password when no user is set up (matches mock-backend). const DEV_DEFAULT_PASSWORD: &str = "password123"; pub struct RpcHandler { config: Config, auth_manager: AuthManager, orchestrator: Option>, state_manager: Arc, port_allocator: Arc>, } impl RpcHandler { pub async fn new(config: Config, state_manager: Arc) -> Result { let auth_manager = AuthManager::new(config.data_dir.clone()); let orchestrator = if config.dev_mode { Some(Arc::new( DevContainerOrchestrator::new(config.clone()).await?, )) } else { None }; let port_allocator = Arc::new(Mutex::new(PortAllocator::new(&config.data_dir)?)); Ok(Self { config, auth_manager, orchestrator, state_manager, port_allocator, }) } pub async fn handle( &self, req: Request, ) -> Result> { // Read request body let (_, body) = req.into_parts(); let body_bytes = hyper::body::to_bytes(body).await .context("Failed to read body")?; let rpc_req: RpcRequest = serde_json::from_slice(&body_bytes) .context("Invalid RPC request")?; debug!("RPC method: {}", rpc_req.method); // Route to handler let result = match rpc_req.method.as_str() { "echo" => self.handle_echo(rpc_req.params).await, "server.echo" => self.handle_echo(rpc_req.params).await, "auth.login" => self.handle_auth_login(rpc_req.params).await, "auth.logout" => self.handle_auth_logout().await, "auth.changePassword" => self.handle_auth_change_password(rpc_req.params).await, "auth.onboardingComplete" => self.handle_auth_onboarding_complete().await, "auth.isOnboardingComplete" => self.handle_auth_is_onboarding_complete().await, // Container orchestration (for Archipelago-managed containers) "container-install" => self.handle_container_install(rpc_req.params).await, "container-start" => self.handle_container_start(rpc_req.params).await, "container-stop" => self.handle_container_stop(rpc_req.params).await, "container-remove" => self.handle_container_remove(rpc_req.params).await, "container-list" => self.handle_container_list().await, "container-status" => self.handle_container_status(rpc_req.params).await, "container-logs" => self.handle_container_logs(rpc_req.params).await, "container-health" => self.handle_container_health(rpc_req.params).await, // Package management (for docker-compose apps) "package.install" => self.handle_package_install(rpc_req.params).await, "package.start" => self.handle_package_start(rpc_req.params).await, "package.stop" => self.handle_package_stop(rpc_req.params).await, "package.restart" => self.handle_package_restart(rpc_req.params).await, "package.uninstall" => self.handle_package_uninstall(rpc_req.params).await, // Bundled app management (for pre-loaded container images) "bundled-app-start" => self.handle_bundled_app_start(rpc_req.params).await, "bundled-app-stop" => self.handle_bundled_app_stop(rpc_req.params).await, // Node identity and P2P peers "node-add-peer" => self.handle_node_add_peer(rpc_req.params).await, "node-list-peers" => self.handle_node_list_peers().await, "node-remove-peer" => self.handle_node_remove_peer(rpc_req.params).await, "node-send-message" => self.handle_node_send_message(rpc_req.params).await, "node-check-peer" => self.handle_node_check_peer(rpc_req.params).await, "node-messages-received" => self.handle_node_messages_received().await, "node-nostr-discover" => self.handle_node_nostr_discover().await, "node.did" => self.handle_node_did().await, "node.tor-address" => self.handle_node_tor_address().await, "node.nostr-publish" => self.handle_node_nostr_publish().await, "node.nostr-pubkey" => self.handle_node_nostr_pubkey().await, "node-nostr-verify-revoked" => self.handle_node_nostr_verify_revoked().await, _ => { Err(anyhow::anyhow!("Unknown method: {}", rpc_req.method)) } }; // Build response let rpc_resp = match result { Ok(data) => RpcResponse { result: Some(data), error: None, }, Err(e) => { error!("RPC error: {}", e); RpcResponse { result: None, error: Some(RpcError { code: -1, message: e.to_string(), data: None, }), } } }; let body = serde_json::to_vec(&rpc_resp) .context("Failed to serialize response")?; Ok(Response::builder() .status(StatusCode::OK) .header("Content-Type", "application/json") .body(hyper::Body::from(body)) .unwrap()) } async fn handle_echo(&self, params: Option) -> Result { if let Some(p) = params { if let Some(msg) = p.get("message").and_then(|v| v.as_str()) { return Ok(serde_json::json!({ "message": msg })); } } Ok(serde_json::json!({ "message": "Hello from Archipelago!" })) } async fn handle_auth_login( &self, params: Option, ) -> Result { let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; let password = params .get("password") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing password"))?; let is_setup = self.auth_manager.is_setup().await?; if !is_setup { // Dev mode: allow default password so UI can log in without running setup if self.config.dev_mode && password == DEV_DEFAULT_PASSWORD { return Ok(serde_json::Value::Null); } return Err(anyhow::anyhow!( "User not set up. Please complete setup first." )); } let valid = self.auth_manager.verify_password(password).await?; if !valid { return Err(anyhow::anyhow!("Password Incorrect")); } Ok(serde_json::Value::Null) } async fn handle_auth_logout(&self) -> Result { // For now, just return success. In a full implementation, this would: // - Invalidate session tokens // - Clear cookies (if we were managing them) // - Close authenticated WebSocket connections Ok(serde_json::Value::Null) } async fn handle_auth_change_password( &self, params: Option, ) -> Result { let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; let current_password = params .get("currentPassword") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing currentPassword"))?; let new_password = params .get("newPassword") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing newPassword"))?; let also_change_ssh = params .get("alsoChangeSsh") .and_then(|v| v.as_bool()) .unwrap_or(true); self.auth_manager .change_password(current_password, new_password, also_change_ssh) .await?; Ok(serde_json::json!({ "success": true })) } async fn handle_auth_onboarding_complete(&self) -> Result { self.auth_manager.complete_onboarding().await?; Ok(serde_json::json!(true)) } async fn handle_auth_is_onboarding_complete(&self) -> Result { let complete = self.auth_manager.is_onboarding_complete().await?; Ok(serde_json::json!(complete)) } async fn handle_node_did(&self) -> Result { let (data, _) = self.state_manager.get_snapshot().await; let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?; Ok(serde_json::json!({ "did": did, "pubkey": data.server_info.pubkey })) } async fn handle_node_tor_address(&self) -> Result { let tor_address = docker_packages::read_tor_address("archipelago"); Ok(serde_json::json!({ "tor_address": tor_address })) } async fn handle_node_nostr_publish(&self) -> Result { if !self.config.nostr_discovery_enabled || self.config.nostr_relays.is_empty() { anyhow::bail!( "Nostr discovery disabled. Set ARCHIPELAGO_NOSTR_DISCOVERY_ENABLED=true and ARCHIPELAGO_NOSTR_RELAYS=wss://... to enable." ); } let (data, _) = self.state_manager.get_snapshot().await; let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?; let node_address = data .server_info .node_address .as_deref() .unwrap_or("archipelago://unknown"); let identity_dir = self.config.data_dir.join("identity"); let output = nostr_discovery::publish_node_identity( &identity_dir, &did, node_address, &data.server_info.version, &self.config.nostr_relays, self.config.nostr_tor_proxy.as_deref(), ) .await?; Ok(serde_json::json!({ "event_id": output.id().to_hex(), "success": output.success.len(), "failed": output.failed.len(), })) } async fn handle_node_nostr_pubkey(&self) -> Result { let identity_dir = self.config.data_dir.join("identity"); let pubkey = nostr_discovery::get_nostr_pubkey(&identity_dir).await?; Ok(serde_json::json!({ "nostr_pubkey": pubkey })) } async fn handle_node_nostr_verify_revoked(&self) -> Result { let identity_dir = self.config.data_dir.join("identity"); let status = nostr_discovery::verify_revocation( &identity_dir, self.config.nostr_tor_proxy.as_deref(), ) .await?; Ok(serde_json::json!({ "revoked": status.revoked, "nostr_pubkey": status.nostr_pubkey, "latest_content": status.latest_content, "error": status.error, })) } async fn handle_container_install( &self, params: Option, ) -> Result { let orchestrator = self .orchestrator .as_ref() .ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?; let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; let manifest_path = params .get("manifest_path") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing manifest_path"))?; // Load manifest let manifest_content = tokio::fs::read_to_string(manifest_path) .await .context("Failed to read manifest file")?; let manifest: archipelago_container::AppManifest = serde_yaml::from_str(&manifest_content) .context("Failed to parse manifest")?; let container_name = orchestrator .install_container(&manifest, manifest_path) .await .context("Failed to install container")?; Ok(serde_json::json!(container_name)) } async fn handle_container_start( &self, params: Option, ) -> Result { let orchestrator = self .orchestrator .as_ref() .ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?; 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"))?; orchestrator .start_container(app_id) .await .context("Failed to start container")?; Ok(serde_json::json!({ "status": "started" })) } async fn handle_container_stop( &self, params: Option, ) -> Result { let orchestrator = self .orchestrator .as_ref() .ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?; 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"))?; orchestrator .stop_container(app_id) .await .context("Failed to stop container")?; Ok(serde_json::json!({ "status": "stopped" })) } async fn handle_container_remove( &self, params: Option, ) -> Result { let orchestrator = self .orchestrator .as_ref() .ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?; let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; let app_id = params .get("app_id") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing app_id"))?; let preserve_data = params .get("preserve_data") .and_then(|v| v.as_bool()) .unwrap_or(false); orchestrator .remove_container(app_id, preserve_data) .await .context("Failed to remove container")?; Ok(serde_json::json!({ "status": "removed" })) } async fn handle_container_list(&self) -> Result { // Try to get containers from orchestrator first if let Some(orchestrator) = &self.orchestrator { if let Ok(containers) = orchestrator.list_containers().await { if !containers.is_empty() { return Ok(serde_json::to_value(containers)?); } } } // Fallback: list containers directly via sudo podman (for bundled apps) let output = tokio::process::Command::new("sudo") .args(["podman", "ps", "-a", "--format", "json"]) .output() .await .context("Failed to list containers via podman")?; if !output.status.success() { // If podman fails, return empty list return Ok(serde_json::json!([])); } let stdout = String::from_utf8_lossy(&output.stdout); if stdout.trim().is_empty() { return Ok(serde_json::json!([])); } // Parse podman JSON output let podman_containers: Vec = serde_json::from_str(&stdout) .unwrap_or_else(|_| Vec::new()); // Convert to our ContainerStatus format let containers: Vec = podman_containers .iter() .map(|c| { let state = c.get("State").and_then(|v| v.as_str()).unwrap_or("unknown"); let mapped_state = match state.to_lowercase().as_str() { "running" => "running", "exited" => "exited", "stopped" => "stopped", "created" => "created", "paused" => "paused", _ => "unknown", }; let name = c.get("Names").and_then(|v| v.as_array()).and_then(|a| a.first()).and_then(|v| v.as_str()).unwrap_or(""); // Determine lan_address based on container name let lan_address = match name { "bitcoin-knots" => Some("http://localhost:8334"), "lnd" => Some("http://localhost:8081"), "tailscale" => Some("http://localhost:8240"), _ => None, }; serde_json::json!({ "id": c.get("Id").and_then(|v| v.as_str()).unwrap_or(""), "name": name, "state": mapped_state, "image": c.get("Image").and_then(|v| v.as_str()).unwrap_or(""), "created": c.get("Created").and_then(|v| v.as_str()).unwrap_or(""), "ports": c.get("Ports").and_then(|v| v.as_array()).map(|a| a.iter().filter_map(|p| p.get("hostPort").and_then(|v| v.as_u64()).map(|p| p.to_string())).collect::>() ).unwrap_or_default(), "lan_address": lan_address, }) }) .collect(); Ok(serde_json::json!(containers)) } async fn handle_container_status( &self, params: Option, ) -> Result { let orchestrator = self .orchestrator .as_ref() .ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?; let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; let app_id = params .get("app_id") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing app_id"))?; let status = orchestrator .get_container_status(app_id) .await .context("Failed to get container status")?; Ok(serde_json::to_value(status)?) } async fn handle_container_logs( &self, params: Option, ) -> Result { let orchestrator = self .orchestrator .as_ref() .ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?; let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; let app_id = params .get("app_id") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing app_id"))?; let lines = params .get("lines") .and_then(|v| v.as_u64()) .unwrap_or(100) as u32; let logs = orchestrator .get_container_logs(app_id, lines) .await .context("Failed to get container logs")?; Ok(serde_json::to_value(logs)?) } /// Used by HTTP GET /api/container/logs (same logic as container-logs RPC). pub async fn get_container_logs_value( &self, app_id: &str, lines: u32, ) -> Result { let orchestrator = self .orchestrator .as_ref() .ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?; let logs = orchestrator .get_container_logs(app_id, lines) .await .context("Failed to get container logs")?; Ok(serde_json::to_value(logs)?) } async fn handle_container_health( &self, params: Option, ) -> Result { let orchestrator = self .orchestrator .as_ref() .ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?; // If app_id is provided, get health for that app if let Some(params) = params { if let Some(app_id) = params.get("app_id").and_then(|v| v.as_str()) { let health = orchestrator .get_health_status(app_id) .await .context("Failed to get container health")?; return Ok(serde_json::json!({ app_id: health })); } } // Otherwise, get health for all containers let containers = orchestrator .list_containers() .await .context("Failed to list containers")?; let mut health_map = serde_json::Map::new(); for container in containers { // Extract app_id from container name if let Some(app_id) = container.name.strip_prefix("archipelago-") { if let Some(app_id) = app_id.strip_suffix("-dev") { match orchestrator.get_health_status(app_id).await { Ok(health) => { health_map.insert(app_id.to_string(), serde_json::Value::String(health)); } Err(_) => { health_map.insert(app_id.to_string(), serde_json::Value::String("unknown".to_string())); } } } } } Ok(serde_json::Value::Object(health_map)) } // Package management methods for podman containers /// Install a package from a Docker image /// Security: Image verification, resource limits, network isolation async fn handle_package_install( &self, params: Option, ) -> Result { 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"))?; 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); // Security: Validate image name format (prevent injection) if !is_valid_docker_image(docker_image) { return Err(anyhow::anyhow!("Invalid Docker image format")); } // Check if container already exists let check_output = tokio::process::Command::new("sudo") .args(["podman", "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 the image (with verification in the future) debug!("Pulling image: {}", docker_image); let pull_output = tokio::process::Command::new("sudo") .args(["podman", "pull", docker_image]) .output() .await .context("Failed to pull image")?; if !pull_output.status.success() { let stderr = String::from_utf8_lossy(&pull_output.stderr); return Err(anyhow::anyhow!("Failed to pull image: {}", stderr)); } // Create and start container with security constraints // TODO: Load these from manifest.yml for the specific app let mut run_args = vec![ "podman", "run", "-d", // Detached "--name", package_id, "--restart=unless-stopped", // Auto-restart policy ]; // App-specific configuration (should come from manifest) let (ports, volumes, env_vars, custom_command, custom_args) = { let mut allocator = self.port_allocator.lock().map_err(|e| { anyhow::anyhow!("Port allocator lock poisoned: {}", e) })?; get_app_config(package_id, &self.config.host_ip, &mut allocator) }; // Special handling: Tailscale needs host network; mempool stack needs archy-net let is_tailscale = package_id == "tailscale"; let needs_archy_net = matches!( package_id, "mempool" | "mempool-web" | "mempool-api" | "mempool-electrs" | "mysql-mempool" | "archy-mempool-db" | "archy-mempool-web" | "btcpay-server" | "btcpayserver" | "archy-btcpay-db" ); 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 { // Ensure archy-net exists, then attach let _ = tokio::process::Command::new("sudo") .args(["podman", "network", "create", "archy-net"]) .output() .await; run_args.push("--network=archy-net"); } // Create data directories if they don't exist 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); } } } } // Add port mappings (skip if host network mode like Tailscale) if !is_tailscale { for port in &ports { run_args.push("-p"); run_args.push(port); } } // Add volume mounts for volume in &volumes { run_args.push("-v"); run_args.push(volume); } // Add environment variables for env in &env_vars { run_args.push("-e"); run_args.push(env); } // Security: Network isolation (unless host network required) // run_args.push("--network=isolated"); // Future: per-app network // Security: Resource limits (from manifest) run_args.push("--memory=2g"); // TODO: from manifest run_args.push("--cpus=2"); // TODO: from manifest // Finally, the image run_args.push(docker_image); debug!("Running container with args: {:?}", run_args); // Build command with optional custom command let mut cmd = tokio::process::Command::new("sudo"); cmd.args(&run_args); // Add custom command/args if specified (Tailscale: shell override; electrs: CLI 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(); Ok(serde_json::json!({ "success": true, "package_id": package_id, "container_id": container_id, "message": format!("Package {} installed and started", package_id) })) } // Package management methods for docker-compose containers async fn handle_package_start( &self, params: Option, ) -> Result { 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"))?; let containers = get_containers_for_app(package_id).await?; let to_start: Vec = if containers.is_empty() { vec![format!("archy-{}", package_id)] } else { // Start order for mempool: db first, then api, then web let order = ["archy-mempool-db", "mysql-mempool", "mempool-electrs", "mempool-api", "archy-mempool-api", "archy-mempool-web", "mempool"]; let mut sorted = containers; sorted.sort_by_key(|c| order.iter().position(|o| *o == c).unwrap_or(99)); sorted }; for name in to_start { let _ = tokio::process::Command::new("sudo") .args(["podman", "start", &name]) .output() .await; } Ok(serde_json::Value::Null) } async fn handle_package_stop( &self, params: Option, ) -> Result { 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"))?; let containers = get_containers_for_app(package_id).await?; if containers.is_empty() { // Fallback: try single container let container_name = format!("archy-{}", package_id); let _ = tokio::process::Command::new("sudo") .args(["podman", "stop", &container_name]) .output() .await; return Ok(serde_json::Value::Null); } for name in containers { let _ = tokio::process::Command::new("sudo") .args(["podman", "stop", &name]) .output() .await; } Ok(serde_json::Value::Null) } async fn handle_package_restart( &self, params: Option, ) -> Result { 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"))?; 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("sudo") .args(["podman", "restart", &container_name]) .output() .await; return Ok(serde_json::Value::Null); } for name in containers { let _ = tokio::process::Command::new("sudo") .args(["podman", "restart", &name]) .output() .await; } Ok(serde_json::Value::Null) } /// Uninstall a package: stop and remove all related containers, clean data. No fragments left. async fn handle_package_uninstall( &self, params: Option, ) -> Result { 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"))?; let preserve_data = params .get("preserve_data") .and_then(|v| v.as_bool()) .unwrap_or(false); // Get all container names for this app (handles multi-container apps like mempool) let containers_to_remove = get_containers_for_app(package_id).await?; for name in &containers_to_remove { let _ = tokio::process::Command::new("sudo") .args(["podman", "stop", name]) .output() .await; let _ = tokio::process::Command::new("sudo") .args(["podman", "rm", "-f", name]) .output() .await; } // Release port allocation if let Ok(mut allocator) = self.port_allocator.lock() { let _ = allocator.release(package_id); } // Clean data directories unless preserve_data if !preserve_data { let data_dirs = get_data_dirs_for_app(package_id); for dir in &data_dirs { let _ = tokio::process::Command::new("sudo") .args(["rm", "-rf", dir]) .output() .await; } } Ok(serde_json::json!({ "status": "uninstalled" })) } /// Start a bundled app (create container from pre-loaded image if needed, then start) async fn handle_bundled_app_start( &self, params: Option, ) -> Result { let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; let app_id = params .get("app_id") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing app_id"))?; let image = params .get("image") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing image"))?; 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"))?; // Check if container already exists let check_output = tokio::process::Command::new("sudo") .args(["podman", "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() { // Container doesn't exist - create it let mut cmd = tokio::process::Command::new("sudo"); cmd.args(["podman", "run", "-d", "--name", app_id]); // Add port mappings 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)); } } // Add volume mappings 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()), ) { // Create host directory if it doesn't exist 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 { // Container exists - just start it let output = tokio::process::Command::new("sudo") .args(["podman", "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 async fn handle_bundled_app_stop( &self, params: Option, ) -> Result { let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; let app_id = params .get("app_id") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing app_id"))?; let output = tokio::process::Command::new("sudo") .args(["podman", "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 })) } async fn handle_node_add_peer( &self, params: Option, ) -> Result { let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; let onion = params .get("onion") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing onion"))?; let pubkey = params .get("pubkey") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing pubkey"))?; let name = params.get("name").and_then(|v| v.as_str()).map(String::from); let peer = KnownPeer { onion: onion.to_string(), pubkey: pubkey.to_string(), name, added_at: Some(chrono::Utc::now().to_rfc3339()), }; let peers = peers::add_peer(&self.config.data_dir, peer).await?; Ok(serde_json::json!({ "peers": peers })) } async fn handle_node_list_peers(&self) -> Result { let peers = peers::load_peers(&self.config.data_dir).await?; Ok(serde_json::json!({ "peers": peers })) } async fn handle_node_remove_peer( &self, params: Option, ) -> Result { let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; let pubkey = params .get("pubkey") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing pubkey"))?; let peers = peers::remove_peer(&self.config.data_dir, pubkey).await?; Ok(serde_json::json!({ "peers": peers })) } async fn handle_node_send_message( &self, params: Option, ) -> Result { let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; let onion = params .get("onion") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing onion"))?; let message = params .get("message") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing message"))?; let (data, _) = self.state_manager.get_snapshot().await; let pubkey = data.server_info.pubkey.clone(); node_message::send_to_peer(onion, &pubkey, message).await?; Ok(serde_json::json!({ "ok": true, "sent_to": onion })) } async fn handle_node_check_peer( &self, params: Option, ) -> Result { let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; let onion = params .get("onion") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing onion"))?; let reachable = node_message::check_peer_reachable(onion).await.unwrap_or(false); Ok(serde_json::json!({ "onion": onion, "reachable": reachable })) } async fn handle_node_messages_received(&self) -> Result { let messages = node_message::get_received(); Ok(serde_json::json!({ "messages": messages })) } async fn handle_node_nostr_discover(&self) -> Result { let identity_dir = self.config.data_dir.join("identity"); let nodes = nostr_discovery::discover_archipelago_nodes( &identity_dir, &self.config.nostr_relays, self.config.nostr_tor_proxy.as_deref(), ) .await?; Ok(serde_json::json!({ "nodes": nodes })) } } /// Get all container names for an app (handles multi-container apps like mempool) async fn get_containers_for_app(package_id: &str) -> Result> { let output = tokio::process::Command::new("sudo") .args(["podman", "ps", "-a", "--format", "{{.Names}}"]) .output() .await .context("Failed to list containers")?; let stdout = String::from_utf8_lossy(&output.stdout); let all: Vec<&str> = stdout.lines().filter(|s| !s.is_empty()).collect(); // Map app id to container name patterns (support both archy-* and bare names) let patterns: Vec = match package_id { "mempool" | "mempool-web" => { vec![ "mempool-electrs".into(), "mempool-api".into(), "archy-mempool-api".into(), "archy-mempool-web".into(), "mempool".into(), "archy-mempool-db".into(), "mysql-mempool".into(), ] } "fedimint" => vec!["fedimint".into(), "fedimint-ui".into(), "archy-fedimint".into()], _ => vec![package_id.to_string(), format!("archy-{}", package_id)], }; let mut result = Vec::new(); for name in all { for pat in &patterns { if name == pat { result.push(name.to_string()); break; } } } Ok(result) } /// Get data directories to clean for an app fn get_data_dirs_for_app(package_id: &str) -> Vec { let base = "/var/lib/archipelago"; match package_id { "mempool" | "mempool-web" => vec![ format!("{}/mempool", base), format!("{}/mysql-mempool", base), format!("{}/mempool-electrs", base), ], "fedimint" => vec![format!("{}/fedimint", base)], _ => vec![format!("{}/{}", base, package_id)], } } /// Validate Docker image name format /// Prevents command injection via malicious image names fn is_valid_docker_image(image: &str) -> bool { // Valid format: [registry/][namespace/]image[:tag][@digest] // Examples: nginx:latest, ghcr.io/owner/image:v1.0, docker.io/library/nginx // Basic validation: no shell metacharacters let dangerous_chars = ['&', '|', ';', '`', '$', '(', ')', '<', '>', '\n', '\r']; if image.chars().any(|c| dangerous_chars.contains(&c)) { return false; } // Must contain at least one alphanumeric character if !image.chars().any(|c| c.is_alphanumeric()) { return false; } // Length check if image.len() > 256 { return false; } true } /// Get app-specific configuration /// Returns: (ports, volumes, env_vars, custom_command, custom_args) /// custom_command: shell override (e.g. "sh -c '...'"); custom_args: extra args for entrypoint /// Uses port_allocator for apps with web UIs to avoid conflicts (e.g. Nextcloud vs LND UI). /// TODO: Load from manifest.yml files in apps/ directory fn get_app_config( app_id: &str, host_ip: &str, allocator: &mut PortAllocator, ) -> (Vec, Vec, Vec, Option, Option>) { match app_id { "homeassistant" | "home-assistant" => ( vec!["8123:8123".to_string()], vec!["/var/lib/archipelago/home-assistant:/config".to_string()], vec!["TZ=UTC".to_string()], None, None, ), "bitcoin" | "bitcoin-core" => ( vec!["8332:8332".to_string(), "8333:8333".to_string()], vec!["/var/lib/archipelago/bitcoin:/bitcoin/.bitcoin".to_string()], vec![], None, None, ), "lnd" => ( vec!["9735:9735".to_string(), "10009:10009".to_string(), "8080:8080".to_string()], vec!["/var/lib/archipelago/lnd:/root/.lnd".to_string()], vec!["BITCOIN_ACTIVE=1".to_string()], None, None, ), "btcpay-server" | "btcpayserver" => ( vec!["23000:49392".to_string()], vec!["/var/lib/archipelago/btcpay:/datadir".to_string()], vec![ "ASPNETCORE_URLS=http://0.0.0.0:49392".to_string(), "BTCPAY_PROTOCOL=http".to_string(), format!("BTCPAY_HOST={}:23000", host_ip), "BTCPAY_CHAINS=btc".to_string(), format!("BTCPAY_BTCRPCURL=http://{}:8332", host_ip), "BTCPAY_BTCRPCUSER=archipelago".to_string(), "BTCPAY_BTCRPCPASSWORD=archipelago123".to_string(), "BTCPAY_POSTGRES=User ID=btcpay;Password=btcpaypass;Host=archy-btcpay-db;Port=5432;Database=btcpay;Include Error Detail=true".to_string(), ], None, None, ), "mempool" | "mempool-web" => ( vec!["4080:8080".to_string()], vec![], // Frontend proxies to backend at host:8999 (deploy script uses mempool-api when on archy-net) vec![format!("BACKEND_MAINNET_HTTP_HOST={}", host_ip)], None, None, ), "mempool-api" => ( vec!["8999:8999".to_string()], vec!["/var/lib/archipelago/mempool:/data".to_string()], vec![ "MEMPOOL_BACKEND=electrum".to_string(), "ELECTRUM_HOST=mempool-electrs".to_string(), "ELECTRUM_PORT=50001".to_string(), "ELECTRUM_TLS_ENABLED=false".to_string(), format!("CORE_RPC_HOST={}", host_ip), "CORE_RPC_PORT=8332".to_string(), "CORE_RPC_USERNAME=bitcoin".to_string(), "CORE_RPC_PASSWORD=bitcoinpass".to_string(), "DATABASE_ENABLED=true".to_string(), "DATABASE_HOST=archy-mempool-db".to_string(), "DATABASE_DATABASE=mempool".to_string(), "DATABASE_USERNAME=mempool".to_string(), "DATABASE_PASSWORD=mempoolpass".to_string(), ], None, None, ), "mempool-electrs" => ( vec!["50001:50001".to_string()], vec!["/var/lib/archipelago/mempool-electrs:/data".to_string()], vec![], None, Some(vec![ "--daemon-rpc-addr".to_string(), format!("{}:8332", host_ip), "--cookie".to_string(), "bitcoin:bitcoinpass".to_string(), "--jsonrpc-import".to_string(), "--electrum-rpc-addr".to_string(), "0.0.0.0:50001".to_string(), "--db-dir".to_string(), "/data".to_string(), "--lightmode".to_string(), ]), ), "mysql-mempool" => ( vec![], vec!["/var/lib/archipelago/mysql-mempool:/var/lib/mysql".to_string()], vec![ "MYSQL_DATABASE=mempool".to_string(), "MYSQL_USER=mempool".to_string(), "MYSQL_PASSWORD=mempoolpass".to_string(), "MYSQL_ROOT_PASSWORD=rootpass".to_string(), ], None, None, ), "grafana" => ( vec!["3000:3000".to_string()], vec!["/var/lib/archipelago/grafana:/var/lib/grafana".to_string()], vec![], None, None, ), "searxng" => ( vec!["8888:8080".to_string()], vec![], vec![], None, None, ), "ollama" => ( vec!["11434:11434".to_string()], vec!["/var/lib/archipelago/ollama:/root/.ollama".to_string()], vec![], None, None, ), "onlyoffice" | "onlyoffice-documentserver" => ( vec!["9980:80".to_string()], vec![], vec![], None, None, ), "penpot" | "penpot-frontend" => ( vec!["9001:80".to_string()], vec![], vec![], None, None, ), "nextcloud" => { let host_port = allocator .allocate_or_get(app_id, 8085, 80) .unwrap_or(8085); ( vec![format!("{}:80", host_port)], vec!["/var/lib/archipelago/nextcloud:/var/www/html".to_string()], vec![], None, None, ) } "vaultwarden" => { let host_port = allocator .allocate_or_get(app_id, 8082, 80) .unwrap_or(8082); ( vec![format!("{}:80", host_port)], vec!["/var/lib/archipelago/vaultwarden:/data".to_string()], vec![], None, None, ) } "jellyfin" => ( vec!["8096:8096".to_string()], vec!["/var/lib/archipelago/jellyfin/config:/config".to_string(), "/var/lib/archipelago/jellyfin/cache:/cache".to_string()], vec![], None, None, ), "photoprism" => ( vec!["2342:2342".to_string()], vec!["/var/lib/archipelago/photoprism:/photoprism/storage".to_string()], vec![], None, None, ), "immich" => ( vec!["2283:3001".to_string()], vec!["/var/lib/archipelago/immich:/usr/src/app/upload".to_string()], vec![], None, None, ), "filebrowser" => { let host_port = allocator .allocate_or_get(app_id, 8083, 80) .unwrap_or(8083); ( vec![format!("{}:80", host_port)], vec!["/var/lib/archipelago/filebrowser:/srv".to_string()], vec![], None, None, ) } "nginx-proxy-manager" => ( vec!["81:81".to_string(), "8084:80".to_string(), "8443:443".to_string()], vec![ "/var/lib/archipelago/nginx-proxy-manager/data:/data".to_string(), "/var/lib/archipelago/nginx-proxy-manager/letsencrypt:/etc/letsencrypt".to_string(), ], vec![], None, None, ), "portainer" => ( vec!["9000:9000".to_string()], vec!["/var/lib/archipelago/portainer:/data".to_string(), "/var/run/podman/podman.sock:/var/run/docker.sock".to_string()], vec![], None, None, ), "uptime-kuma" => ( vec!["3001:3001".to_string()], vec!["/var/lib/archipelago/uptime-kuma:/app/data".to_string()], vec![], None, None, ), "tailscale" => ( vec!["8240:8240".to_string()], // Tailscale web UI port (only used if not host network) vec![ "/var/lib/archipelago/tailscale:/var/lib/tailscale".to_string(), ], vec![ "TS_STATE_DIR=/var/lib/tailscale".to_string(), ], Some("sh -c 'tailscale web --listen 0.0.0.0:8240 & exec tailscaled'".to_string()), None, ), "fedimint" => ( vec![ "8173:8173".to_string(), // P2P "8174:8174".to_string(), // API (JSON-RPC) "8175:8175".to_string(), // Built-in Guardian UI ], vec!["/var/lib/archipelago/fedimint:/data".to_string()], vec![ "FM_DATA_DIR=/data".to_string(), "FM_BITCOIND_USERNAME=bitcoin".to_string(), "FM_BITCOIND_PASSWORD=bitcoinpass".to_string(), "FM_BITCOIN_NETWORK=bitcoin".to_string(), "FM_BIND_P2P=0.0.0.0:8173".to_string(), "FM_BIND_API=0.0.0.0:8174".to_string(), "FM_BIND_UI=0.0.0.0:8175".to_string(), format!("FM_P2P_URL=fedimint://{}:8173", host_ip), format!("FM_API_URL=ws://{}:8174", host_ip), format!("FM_BITCOIND_URL=http://{}:8332", host_ip), ], None, None, ), _ => (vec![], vec![], vec![], None, None), // No default config, user must configure manually } }