diff --git a/core/archipelago/src/api/rpc/backup_rpc.rs b/core/archipelago/src/api/rpc/backup_rpc.rs new file mode 100644 index 00000000..ce419667 --- /dev/null +++ b/core/archipelago/src/api/rpc/backup_rpc.rs @@ -0,0 +1,156 @@ +use super::RpcHandler; +use crate::backup::full; +use anyhow::Result; + +impl RpcHandler { + /// Create a full encrypted backup. Params: { passphrase, description? } + pub(super) async fn handle_backup_create( + &self, + params: &serde_json::Value, + ) -> Result { + let passphrase = params["passphrase"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("Missing 'passphrase' parameter"))?; + let description = params["description"].as_str(); + + let meta = full::create_full_backup(&self.config.data_dir, passphrase, description).await?; + + Ok(serde_json::json!({ + "id": meta.id, + "created_at": meta.created_at, + "size_bytes": meta.size_bytes, + "encrypted": meta.encrypted, + "description": meta.description, + })) + } + + /// List available backups. + pub(super) async fn handle_backup_list(&self) -> Result { + let backups = full::list_backups(&self.config.data_dir).await?; + let list: Vec = backups + .iter() + .map(|b| { + serde_json::json!({ + "id": b.id, + "created_at": b.created_at, + "size_bytes": b.size_bytes, + "encrypted": b.encrypted, + "description": b.description, + }) + }) + .collect(); + Ok(serde_json::json!({ "backups": list })) + } + + /// Verify a backup's integrity. Params: { id, passphrase } + pub(super) async fn handle_backup_verify( + &self, + params: &serde_json::Value, + ) -> Result { + let id = params["id"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("Missing 'id' parameter"))?; + let passphrase = params["passphrase"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("Missing 'passphrase' parameter"))?; + + let result = full::verify_backup(&self.config.data_dir, id, passphrase).await?; + + Ok(serde_json::json!({ + "valid": result.valid, + "id": result.id, + "created_at": result.created_at, + "size_bytes": result.size_bytes, + "error": result.error, + })) + } + + /// Restore from a backup. Params: { id, passphrase } + pub(super) async fn handle_backup_restore( + &self, + params: &serde_json::Value, + ) -> Result { + let id = params["id"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("Missing 'id' parameter"))?; + let passphrase = params["passphrase"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("Missing 'passphrase' parameter"))?; + + full::restore_full_backup(&self.config.data_dir, id, passphrase).await?; + + Ok(serde_json::json!({ "restored": true, "id": id })) + } + + /// Delete a backup. Params: { id } + pub(super) async fn handle_backup_delete( + &self, + params: &serde_json::Value, + ) -> Result { + let id = params["id"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("Missing 'id' parameter"))?; + + // Validate backup ID to prevent path traversal + if id.is_empty() || id.len() > 128 || id.contains('/') || id.contains('\\') || id.contains("..") || id.contains('\0') { + anyhow::bail!("Invalid backup ID"); + } + + let bak_path = full::backup_file_path(&self.config.data_dir, id); + let meta_path = self + .config + .data_dir + .join("backups") + .join(format!("{}.meta.json", id)); + + let mut deleted = false; + if bak_path.exists() { + tokio::fs::remove_file(&bak_path).await?; + deleted = true; + } + if meta_path.exists() { + tokio::fs::remove_file(&meta_path).await?; + } + + Ok(serde_json::json!({ "deleted": deleted, "id": id })) + } + + /// List removable USB drives. + pub(super) async fn handle_backup_list_drives(&self) -> Result { + let drives = full::list_usb_drives().await?; + let list: Vec = drives + .iter() + .map(|d| { + serde_json::json!({ + "device": d.device, + "mount_point": d.mount_point, + "label": d.label, + "size_bytes": d.size_bytes, + "removable": d.removable, + }) + }) + .collect(); + Ok(serde_json::json!({ "drives": list })) + } + + /// Copy a backup to a mounted USB drive. Params: { id, mount_point } + pub(super) async fn handle_backup_to_usb( + &self, + params: &serde_json::Value, + ) -> Result { + let id = params["id"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("Missing 'id' parameter"))?; + let mount_point = params["mount_point"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("Missing 'mount_point' parameter"))?; + + let dest = full::backup_to_usb(&self.config.data_dir, id, mount_point).await?; + + Ok(serde_json::json!({ + "copied": true, + "id": id, + "destination": dest.to_string_lossy(), + })) + } +} diff --git a/core/archipelago/src/api/rpc/container.rs b/core/archipelago/src/api/rpc/container.rs index 87ef125e..aee1539c 100644 --- a/core/archipelago/src/api/rpc/container.rs +++ b/core/archipelago/src/api/rpc/container.rs @@ -1,4 +1,5 @@ use super::RpcHandler; +use super::package::validate_app_id; use anyhow::{Context, Result}; impl RpcHandler { @@ -17,24 +18,29 @@ impl RpcHandler { .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing manifest_path"))?; - // Validate manifest path: reject path traversal and paths outside apps/ - if manifest_path.contains("..") { + // Validate manifest path: reject traversal, resolve to canonical path + if manifest_path.contains("..") || manifest_path.contains('\0') { return Err(anyhow::anyhow!( "Invalid manifest_path: path traversal not allowed" )); } - let path = std::path::Path::new(manifest_path); - if path.is_absolute() { - let apps_dir = self.config.data_dir.join("apps"); - if !path.starts_with(&apps_dir) { - return Err(anyhow::anyhow!( - "Invalid manifest_path: must be under the apps directory" - )); - } + let apps_dir = self.config.data_dir.join("apps"); + let resolved = if std::path::Path::new(manifest_path).is_absolute() { + std::path::PathBuf::from(manifest_path) + } else { + apps_dir.join(manifest_path) + }; + let canonical = resolved + .canonicalize() + .context("Invalid manifest_path: file not found")?; + if !canonical.starts_with(&apps_dir) { + return Err(anyhow::anyhow!( + "Invalid manifest_path: must be under the apps directory" + )); } // Load manifest - let manifest_content = tokio::fs::read_to_string(manifest_path) + let manifest_content = tokio::fs::read_to_string(&canonical) .await .context("Failed to read manifest file")?; let manifest: archipelago_container::AppManifest = serde_yaml::from_str(&manifest_content) @@ -62,6 +68,7 @@ impl RpcHandler { .get("app_id") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing app_id"))?; + validate_app_id(app_id)?; orchestrator .start_container(app_id) @@ -85,6 +92,7 @@ impl RpcHandler { .get("app_id") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing app_id"))?; + validate_app_id(app_id)?; orchestrator .stop_container(app_id) @@ -108,6 +116,7 @@ impl RpcHandler { .get("app_id") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing app_id"))?; + validate_app_id(app_id)?; let preserve_data = params .get("preserve_data") .and_then(|v| v.as_bool()) @@ -206,6 +215,7 @@ impl RpcHandler { .get("app_id") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing app_id"))?; + validate_app_id(app_id)?; let status = orchestrator .get_container_status(app_id) @@ -229,6 +239,7 @@ impl RpcHandler { .get("app_id") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing app_id"))?; + validate_app_id(app_id)?; let lines = params .get("lines") .and_then(|v| v.as_u64()) diff --git a/core/archipelago/src/api/rpc/interfaces.rs b/core/archipelago/src/api/rpc/interfaces.rs index c23ecd78..b04d0074 100644 --- a/core/archipelago/src/api/rpc/interfaces.rs +++ b/core/archipelago/src/api/rpc/interfaces.rs @@ -1,4 +1,5 @@ use super::RpcHandler; +use crate::network::dns; use anyhow::{Context, Result}; use tracing::debug; @@ -36,6 +37,10 @@ impl RpcHandler { if ssid.len() > 64 || ssid.contains('\0') { anyhow::bail!("Invalid SSID"); } + // Validate WiFi password + if password.len() > 63 || password.contains('\0') { + anyhow::bail!("Invalid WiFi password (max 63 chars, no null bytes)"); + } tracing::info!("Connecting to WiFi network: {}", ssid); connect_wifi(ssid, password).await?; @@ -85,11 +90,22 @@ impl RpcHandler { .and_then(|v| v.as_str()) .unwrap_or("1.1.1.1"); - // Basic IP format validation - if ip.parse::().is_err() && !ip.contains('/') { + // Validate IP: must parse as IP or CIDR + let ip_part = ip.split('/').next().unwrap_or(""); + if ip_part.parse::().is_err() { anyhow::bail!("Invalid IP address format"); } + // Validate gateway if provided + if !gateway.is_empty() && gateway.parse::().is_err() { + anyhow::bail!("Invalid gateway IP address"); + } + + // Validate DNS server IP + if dns.parse::().is_err() { + anyhow::bail!("Invalid DNS server IP address"); + } + tracing::info!("Setting {} to static IP {}", interface, ip); configure_ethernet_static(interface, ip, gateway, dns).await?; } @@ -98,6 +114,71 @@ impl RpcHandler { Ok(serde_json::json!({ "ok": true, "interface": interface, "mode": mode })) } + + /// network.dns-status — get current DNS configuration and status. + pub(super) async fn handle_network_dns_status(&self) -> Result { + debug!("Getting DNS status"); + let status = dns::get_status(&self.config.data_dir).await?; + Ok(serde_json::to_value(status)?) + } + + /// network.configure-dns — configure DNS servers and provider. + pub(super) async fn handle_network_configure_dns( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let provider_str = params + .get("provider") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing required parameter: provider"))?; + + let provider = match provider_str { + "system" => dns::DnsProvider::System, + "cloudflare" => dns::DnsProvider::Cloudflare, + "google" => dns::DnsProvider::Google, + "quad9" => dns::DnsProvider::Quad9, + "mullvad" => dns::DnsProvider::Mullvad, + "custom" => dns::DnsProvider::Custom, + other => anyhow::bail!("Unknown DNS provider: {}. Use: system, cloudflare, google, quad9, mullvad, custom", other), + }; + + let custom_servers: Vec = if provider == dns::DnsProvider::Custom { + params + .get("servers") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default() + } else { + Vec::new() + }; + + if provider == dns::DnsProvider::Custom && custom_servers.is_empty() { + anyhow::bail!("Custom provider requires at least one DNS server in 'servers' array"); + } + + // Validate custom server IPs + for s in &custom_servers { + if s.parse::().is_err() { + anyhow::bail!("Invalid DNS server IP: {}", s); + } + } + + tracing::info!(provider = provider_str, "Configuring DNS"); + let config = dns::configure(&self.config.data_dir, provider, custom_servers).await?; + + Ok(serde_json::json!({ + "ok": true, + "provider": config.provider.to_string(), + "servers": config.servers, + "doh_enabled": config.doh_enabled, + "doh_url": config.doh_url, + })) + } } /// List network interfaces using `ip -j addr show`. diff --git a/core/archipelago/src/api/rpc/lnd.rs b/core/archipelago/src/api/rpc/lnd.rs index c7fd9da4..f218838b 100644 --- a/core/archipelago/src/api/rpc/lnd.rs +++ b/core/archipelago/src/api/rpc/lnd.rs @@ -226,9 +226,17 @@ impl RpcHandler { .and_then(|v| v.as_i64()) .ok_or_else(|| anyhow::anyhow!("Missing 'amount' parameter (sats)"))?; + // Validate pubkey: must be 66-char hex (compressed secp256k1) + if pubkey.len() != 66 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(anyhow::anyhow!("Invalid pubkey: must be 66-character hex string")); + } + if amount < 20000 { return Err(anyhow::anyhow!("Channel amount must be at least 20,000 sats")); } + if amount > 16_777_215 { + return Err(anyhow::anyhow!("Channel amount exceeds maximum (16,777,215 sats)")); + } info!(peer = pubkey, amount = amount, "Opening Lightning channel"); @@ -236,6 +244,10 @@ impl RpcHandler { // First connect to the peer if an address is provided if let Some(addr) = params.get("address").and_then(|v| v.as_str()) { + // Validate peer address format (host:port) + if addr.len() > 256 || addr.contains('\0') || addr.contains(' ') { + return Err(anyhow::anyhow!("Invalid peer address format")); + } let connect_body = serde_json::json!({ "addr": { "pubkey": pubkey, "host": addr }, "perm": true @@ -282,6 +294,13 @@ impl RpcHandler { if parts.len() != 2 { return Err(anyhow::anyhow!("Invalid channel_point format. Expected 'txid:output_index'")); } + // Validate txid is 64-char hex and output_index is numeric + if parts[0].len() != 64 || !parts[0].chars().all(|c| c.is_ascii_hexdigit()) { + return Err(anyhow::anyhow!("Invalid txid in channel_point: must be 64-character hex")); + } + if parts[1].parse::().is_err() { + return Err(anyhow::anyhow!("Invalid output_index in channel_point: must be a number")); + } let force = params.get("force").and_then(|v| v.as_bool()).unwrap_or(false); info!(channel_point = channel_point, force = force, "Closing Lightning channel"); @@ -346,6 +365,14 @@ impl RpcHandler { if amount < 546 { return Err(anyhow::anyhow!("Amount must be at least 546 sats (dust limit)")); } + if amount > 21_000_000 * 100_000_000 { + return Err(anyhow::anyhow!("Amount exceeds maximum Bitcoin supply")); + } + + // Validate Bitcoin address format (basic: length and allowed chars) + if addr.len() < 14 || addr.len() > 90 || !addr.chars().all(|c| c.is_ascii_alphanumeric()) { + return Err(anyhow::anyhow!("Invalid Bitcoin address format")); + } info!(addr = addr, amount = amount, "Sending on-chain Bitcoin"); @@ -390,6 +417,14 @@ impl RpcHandler { if amount_sats < 0 { return Err(anyhow::anyhow!("Amount must be non-negative")); } + if amount_sats > 21_000_000 * 100_000_000 { + return Err(anyhow::anyhow!("Amount exceeds maximum Bitcoin supply")); + } + + // Limit memo length to prevent abuse + if memo.len() > 639 { + return Err(anyhow::anyhow!("Memo too long (max 639 bytes)")); + } info!(amount_sats = amount_sats, "Creating Lightning invoice"); @@ -435,6 +470,15 @@ impl RpcHandler { .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing 'payment_request' parameter"))?; + // Basic validation: Lightning invoices start with lnbc/lntb/lnbcrt + if payment_request.len() < 10 || payment_request.len() > 2048 { + return Err(anyhow::anyhow!("Invalid payment request length")); + } + let lower = payment_request.to_lowercase(); + if !lower.starts_with("lnbc") && !lower.starts_with("lntb") && !lower.starts_with("lnbcrt") { + return Err(anyhow::anyhow!("Invalid payment request: must be a Lightning invoice (lnbc...)")); + } + info!("Paying Lightning invoice"); let (client, macaroon_hex) = self.lnd_client().await?; @@ -481,6 +525,155 @@ impl RpcHandler { "amount_sats": amount_sat, })) } + /// Create an unsigned PSBT for hardware wallet signing. + /// Uses LND's WalletKit.FundPsbt to select UTXOs and create a PSBT template. + pub(super) async fn handle_lnd_create_psbt(&self, params: Option) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + + let outputs = params.get("outputs") + .and_then(|v| v.as_array()) + .ok_or_else(|| anyhow::anyhow!("Missing 'outputs' array (each: address + amount_sats)"))?; + + if outputs.is_empty() { + return Err(anyhow::anyhow!("outputs must not be empty")); + } + + // Build the outputs map for LND: { "address": "amount_sats_as_string" } + let mut lnd_outputs: serde_json::Map = serde_json::Map::new(); + let mut total_amount: i64 = 0; + for output in outputs { + let addr = output.get("address") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Each output must have an 'address'"))?; + // Validate Bitcoin address format + if addr.len() < 14 || addr.len() > 90 || !addr.chars().all(|c| c.is_ascii_alphanumeric()) { + return Err(anyhow::anyhow!("Invalid Bitcoin address format in output")); + } + let amount = output.get("amount_sats") + .and_then(|v| v.as_i64()) + .ok_or_else(|| anyhow::anyhow!("Each output must have 'amount_sats'"))?; + if amount < 546 { + return Err(anyhow::anyhow!("Amount must be at least 546 sats (dust limit)")); + } + lnd_outputs.insert(addr.to_string(), serde_json::json!(amount)); + total_amount += amount; + } + + let sat_per_vbyte = params.get("fee_rate_sat_per_vbyte") + .and_then(|v| v.as_u64()) + .unwrap_or(10); + + info!(total_amount = total_amount, fee_rate = sat_per_vbyte, "Creating PSBT for hardware wallet signing"); + + let (client, macaroon_hex) = self.lnd_client().await?; + + let fund_body = serde_json::json!({ + "raw": { + "outputs": lnd_outputs, + }, + "sat_per_vbyte": sat_per_vbyte, + "spend_unconfirmed": false, + }); + + let resp = client + .post("https://127.0.0.1:8080/v2/wallet/psbt/fund") + .header("Grpc-Metadata-macaroon", &macaroon_hex) + .json(&fund_body) + .send() + .await + .context("Failed to create PSBT via LND")?; + + let status = resp.status(); + let body: serde_json::Value = resp.json().await + .context("Failed to parse PSBT response")?; + + if !status.is_success() { + let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error"); + return Err(anyhow::anyhow!("Failed to create PSBT: {}", msg)); + } + + let funded_psbt = body.get("funded_psbt") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let change_output_index = body.get("change_output_index") + .and_then(|v| v.as_i64()) + .unwrap_or(-1); + + Ok(serde_json::json!({ + "psbt_base64": funded_psbt, + "change_output_index": change_output_index, + "total_amount_sats": total_amount, + "fee_rate_sat_per_vbyte": sat_per_vbyte, + })) + } + + /// Finalize a signed PSBT and broadcast the transaction. + /// Takes a PSBT that has been signed by a hardware wallet. + pub(super) async fn handle_lnd_finalize_psbt(&self, params: Option) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let signed_psbt = params.get("signed_psbt_base64") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'signed_psbt_base64'"))?; + + info!("Finalizing signed PSBT from hardware wallet"); + + let (client, macaroon_hex) = self.lnd_client().await?; + + let finalize_body = serde_json::json!({ + "funded_psbt": signed_psbt, + }); + + let resp = client + .post("https://127.0.0.1:8080/v2/wallet/psbt/finalize") + .header("Grpc-Metadata-macaroon", &macaroon_hex) + .json(&finalize_body) + .send() + .await + .context("Failed to finalize PSBT via LND")?; + + let status = resp.status(); + let body: serde_json::Value = resp.json().await + .context("Failed to parse finalize response")?; + + if !status.is_success() { + let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error"); + return Err(anyhow::anyhow!("Failed to finalize PSBT: {}", msg)); + } + + let raw_final_tx = body.get("raw_final_tx") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + // Broadcast the finalized transaction + let publish_body = serde_json::json!({ + "tx_hex": raw_final_tx, + }); + + let pub_resp = client + .post("https://127.0.0.1:8080/v2/wallet/tx") + .header("Grpc-Metadata-macaroon", &macaroon_hex) + .json(&publish_body) + .send() + .await + .context("Failed to broadcast transaction")?; + + let pub_status = pub_resp.status(); + let pub_body: serde_json::Value = pub_resp.json().await + .context("Failed to parse broadcast response")?; + + if !pub_status.is_success() { + let msg = pub_body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error"); + return Err(anyhow::anyhow!("Transaction broadcast failed: {}", msg)); + } + + Ok(serde_json::json!({ + "raw_final_tx": raw_final_tx, + "broadcast": true, + })) + } } // Channel types diff --git a/core/archipelago/src/api/rpc/network.rs b/core/archipelago/src/api/rpc/network.rs index 22b69e17..72faf980 100644 --- a/core/archipelago/src/api/rpc/network.rs +++ b/core/archipelago/src/api/rpc/network.rs @@ -283,6 +283,10 @@ impl RpcHandler { } async fn delete_request(&self, id: &str) -> Result<()> { + // Validate ID to prevent path traversal + if id.is_empty() || id.len() > 128 || id.contains('/') || id.contains('\\') || id.contains("..") || id.contains('\0') { + anyhow::bail!("Invalid request ID"); + } let dir = self.requests_dir().await?; let path = dir.join(format!("{}.json", id)); if path.exists() { diff --git a/core/archipelago/src/api/rpc/package.rs b/core/archipelago/src/api/rpc/package.rs index 9690110c..42be8410 100644 --- a/core/archipelago/src/api/rpc/package.rs +++ b/core/archipelago/src/api/rpc/package.rs @@ -1,6 +1,11 @@ use super::RpcHandler; +use crate::data_model::{ + Description, InstallProgress, Manifest, PackageDataEntry, PackageState, StaticFiles, +}; use crate::port_allocator::PortAllocator; use anyhow::{Context, Result}; +use std::collections::HashMap; +use tokio::io::{AsyncBufReadExt, BufReader}; use tracing::{debug, info}; impl RpcHandler { @@ -116,16 +121,42 @@ impl RpcHandler { let is_local_image = docker_image.starts_with("localhost/"); if !is_local_image { 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)); + // Set package state to Installing with progress + self.set_install_progress(package_id, 0, 0).await; + + // Stream pull progress via piped stderr + let mut child = tokio::process::Command::new("sudo") + .args(["podman", "pull", docker_image]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .context("Failed to start image pull")?; + + // Parse stderr for progress updates + 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 { + // Podman outputs lines like: "Copying blob sha256:abc123 [=====> ] 50.0MiB / 100.0MiB" + // or "Getting image source signatures" etc. + 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")); + } + + // Mark pull as complete (100%) + self.set_install_progress(package_id, 100, 100).await; } else { // Verify local image exists let images_output = tokio::process::Command::new("sudo") @@ -497,9 +528,9 @@ printtoconsole=1\n"; let images = [ "docker.io/postgres:15", "docker.io/valkey/valkey:8.1", - "docker.io/penpotapp/backend:latest", - "docker.io/penpotapp/exporter:latest", - "docker.io/penpotapp/frontend:latest", + "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("sudo") @@ -517,7 +548,14 @@ printtoconsole=1\n"; .output() .await; - let secret = "archipelago-penpot-secret-key-change-in-production"; + // 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("sudo") @@ -556,7 +594,7 @@ printtoconsole=1\n"; "-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:latest", + "docker.io/penpotapp/backend:2.4", ]) .output() .await; @@ -569,7 +607,7 @@ printtoconsole=1\n"; "-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:latest", + "docker.io/penpotapp/exporter:2.4", ]) .output() .await; @@ -582,7 +620,7 @@ printtoconsole=1\n"; "-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:latest", + "docker.io/penpotapp/frontend:2.4", ]) .output() .await @@ -609,6 +647,7 @@ printtoconsole=1\n"; .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?; let to_start: Vec = if containers.is_empty() { @@ -644,6 +683,7 @@ printtoconsole=1\n"; .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() { @@ -674,6 +714,7 @@ printtoconsole=1\n"; .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() { @@ -753,10 +794,14 @@ printtoconsole=1\n"; .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()) @@ -792,6 +837,16 @@ printtoconsole=1\n"; 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/ and no traversal + 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" + )); + } + // Validate container path + 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() @@ -834,6 +889,7 @@ printtoconsole=1\n"; .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("sudo") .args(["podman", "stop", app_id]) @@ -848,6 +904,132 @@ printtoconsole=1\n"; Ok(serde_json::json!({ "status": "stopped", "app_id": app_id })) } + + /// Set install progress for a package and broadcast the update. + /// Creates a minimal package entry if one doesn't exist yet. + 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 + 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) + 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, + 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, + }, + 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. +fn parse_pull_progress(line: &str) -> Option<(u64, u64)> { + // Look for "X.YMiB / Z.WMiB" or "X.YGiB / Z.WGiB" patterns + let line = line.trim(); + + // Find the pattern "NUMBER UNIT / NUMBER UNIT" + 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 { + // Extract the last token which should be "NUMBER UNIT" or "NUMBERUnit" + let s = s.trim(); + + // Try to find the numeric part at the end of the string + // Podman formats: "50.0MiB", "1.2 GiB", etc. + 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) } /// Get all container names for an app (handles multi-container apps like mempool) @@ -962,12 +1144,16 @@ fn is_valid_docker_image(image: &str) -> bool { if image.chars().any(|c| dangerous_chars.contains(&c)) { return false; } - // Must come from a trusted registry - TRUSTED_REGISTRIES.iter().any(|r| image.starts_with(r)) + // Must come from a trusted registry — match the exact domain, not just prefix + let registry = match image.split('/').next() { + Some(r) => r, + None => return false, + }; + matches!(registry, "docker.io" | "ghcr.io" | "localhost") } /// Validate that a package/app ID is safe (lowercase alphanumeric + hyphens, 1-64 chars). -fn validate_app_id(id: &str) -> Result<()> { +pub(super) fn validate_app_id(id: &str) -> Result<()> { if id.is_empty() || id.len() > 64 { anyhow::bail!("Invalid app id: must be 1-64 characters"); } @@ -1021,7 +1207,17 @@ fn get_app_capabilities(app_id: &str) -> Vec { fn is_readonly_compatible(app_id: &str) -> bool { matches!( app_id, - "searxng" | "grafana" | "uptime-kuma" | "filebrowser" | "photoprism" | "vaultwarden" + "searxng" + | "grafana" + | "uptime-kuma" + | "filebrowser" + | "photoprism" + | "vaultwarden" + | "mempool-electrs" + | "electrs" + | "nostr-rs-relay" + | "ollama" + | "indeedhub" ) } diff --git a/core/archipelago/src/api/rpc/security.rs b/core/archipelago/src/api/rpc/security.rs new file mode 100644 index 00000000..356e9f40 --- /dev/null +++ b/core/archipelago/src/api/rpc/security.rs @@ -0,0 +1,68 @@ +use super::RpcHandler; +use super::package::validate_app_id; +use anyhow::Result; + +impl RpcHandler { + pub(super) async fn handle_security_rotate_secrets( + &self, + params: &serde_json::Value, + ) -> Result { + let app_id = params + .get("app_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing app_id"))?; + validate_app_id(app_id)?; + + let secrets_dir = self.config.data_dir.join("secrets"); + let encryption_key = self.get_secrets_key(); + let mgr = + archipelago_security::SecretsManager::new(secrets_dir, encryption_key)?; + + let secret_ids = mgr.list_secrets(app_id).await?; + let mut rotated = Vec::new(); + + for secret_id in &secret_ids { + mgr.rotate_secret(app_id, secret_id).await?; + rotated.push(secret_id.clone()); + } + + Ok(serde_json::json!({ + "app_id": app_id, + "rotated_count": rotated.len(), + "rotated_ids": rotated, + })) + } + + pub(super) async fn handle_security_list_expiring( + &self, + params: &serde_json::Value, + ) -> Result { + let max_age_days = params + .get("max_age_days") + .and_then(|v| v.as_i64()) + .unwrap_or(90); + + let secrets_dir = self.config.data_dir.join("secrets"); + let encryption_key = self.get_secrets_key(); + let mgr = + archipelago_security::SecretsManager::new(secrets_dir, encryption_key)?; + + let expiring = mgr.list_expiring(max_age_days).await?; + + Ok(serde_json::json!({ + "max_age_days": max_age_days, + "expiring_count": expiring.len(), + "secrets": expiring, + })) + } + + /// Derive a 32-byte encryption key for secrets. + /// Uses a fixed derivation from the data directory path as a stable key. + fn get_secrets_key(&self) -> Vec { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(b"archipelago-secrets-v1-"); + hasher.update(self.config.data_dir.to_string_lossy().as_bytes()); + hasher.finalize().to_vec() + } +} diff --git a/core/archipelago/src/api/rpc/webhooks.rs b/core/archipelago/src/api/rpc/webhooks.rs index ee63c465..2d55344e 100644 --- a/core/archipelago/src/api/rpc/webhooks.rs +++ b/core/archipelago/src/api/rpc/webhooks.rs @@ -28,6 +28,38 @@ impl RpcHandler { config.enabled = enabled; } if let Some(url) = params.get("url").and_then(|v| v.as_str()) { + // Validate webhook URL scheme and reject obviously dangerous targets + if !url.is_empty() { + if !url.starts_with("https://") && !url.starts_with("http://") { + anyhow::bail!("Webhook URL must use HTTP(S)"); + } + if !self.config.dev_mode && !url.starts_with("https://") { + anyhow::bail!("Webhook URL must use HTTPS in production"); + } + // Extract host portion and reject private/internal addresses + let host_part = url + .trim_start_matches("https://") + .trim_start_matches("http://") + .split('/') + .next() + .unwrap_or("") + .split(':') + .next() + .unwrap_or(""); + let is_private = host_part == "localhost" + || host_part == "127.0.0.1" + || host_part == "::1" + || host_part.starts_with("10.") + || host_part.starts_with("172.") + || host_part.starts_with("192.168.") + || host_part.starts_with("169.254."); + if is_private && !self.config.dev_mode { + anyhow::bail!("Webhook URL must not point to private/local addresses"); + } + if url.len() > 2048 { + anyhow::bail!("Webhook URL too long"); + } + } config.url = url.to_string(); } if let Some(secret) = params.get("secret").and_then(|v| v.as_str()) { diff --git a/loop/plan.md b/loop/plan.md index bb039237..cc3ecdfa 100644 --- a/loop/plan.md +++ b/loop/plan.md @@ -366,7 +366,7 @@ - [x] **PENTEST-01** — Run automated penetration test suite. Execute `scripts/verify-pentest-fixes.sh` and `scripts/test-security.sh`. Add new tests: SQL injection (even though no SQL -- test RPC params), command injection (test all params that touch shell), auth bypass attempts, session fixation, privilege escalation via container escape. **Acceptance**: All pen tests pass. -- [ ] **PENTEST-02** — Conduct manual security review of all RPC endpoints. Review each of the 80+ RPC endpoints in `core/archipelago/src/api/rpc/mod.rs` for: input validation, authorization checks, information disclosure, timing attacks on auth endpoints. Document findings. **Acceptance**: All endpoints reviewed; critical issues fixed. +- [x] **PENTEST-02** — Conduct manual security review of all RPC endpoints. Review each of the 80+ RPC endpoints in `core/archipelago/src/api/rpc/mod.rs` for: input validation, authorization checks, information disclosure, timing attacks on auth endpoints. Document findings. **Acceptance**: All endpoints reviewed; critical issues fixed. - [ ] **PENTEST-03** — Harden Podman container isolation. Review all container configurations for: no host network access, no privileged mode, minimal capabilities, seccomp profiles, AppArmor profiles applied. Generate and apply AppArmor profiles for each app. **Acceptance**: All containers run with minimal privileges.