use super::RpcHandler; use anyhow::{Context, Result}; use tracing::debug; 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, ) -> Result { 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; debug!("Server name updated to: {}", name); 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 { 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 { 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 { 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 { 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 { 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 { tracing::info!("Starting disk cleanup"); let mut freed_bytes: u64 = 0; let mut actions: Vec = 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, })) } } /// Read system uptime from /proc/uptime (seconds since boot). async fn read_uptime() -> Result { let content = tokio::fs::read_to_string("/proc/uptime") .await .context("Failed to read /proc/uptime")?; let uptime: f64 = content .split_whitespace() .next() .ok_or_else(|| anyhow::anyhow!("Empty /proc/uptime"))? .parse() .context("Failed to parse uptime")?; Ok(uptime) } /// Read load averages from /proc/loadavg. async fn read_loadavg() -> Result<(f64, f64, f64)> { let content = tokio::fs::read_to_string("/proc/loadavg") .await .context("Failed to read /proc/loadavg")?; let mut parts = content.split_whitespace(); let l1: f64 = parts .next() .ok_or_else(|| anyhow::anyhow!("Missing load1"))? .parse() .context("parse load1")?; let l5: f64 = parts .next() .ok_or_else(|| anyhow::anyhow!("Missing load5"))? .parse() .context("parse load5")?; let l15: f64 = parts .next() .ok_or_else(|| anyhow::anyhow!("Missing load15"))? .parse() .context("parse load15")?; Ok((l1, l5, l15)) } /// Compute CPU usage by sampling /proc/stat twice with a 250ms gap. async fn read_cpu_usage() -> Result { let snap1 = read_cpu_jiffies().await?; tokio::time::sleep(std::time::Duration::from_millis(250)).await; let snap2 = read_cpu_jiffies().await?; let total_delta = snap2.total.saturating_sub(snap1.total); let idle_delta = snap2.idle.saturating_sub(snap1.idle); if total_delta == 0 { return Ok(0.0); } let usage = 100.0 * (1.0 - (idle_delta as f64 / total_delta as f64)); Ok((usage * 10.0).round() / 10.0) // one decimal } struct CpuJiffies { total: u64, idle: u64, } async fn read_cpu_jiffies() -> Result { let content = tokio::fs::read_to_string("/proc/stat") .await .context("Failed to read /proc/stat")?; let cpu_line = content .lines() .next() .ok_or_else(|| anyhow::anyhow!("Empty /proc/stat"))?; // cpu user nice system idle iowait irq softirq steal guest guest_nice let vals: Vec = cpu_line .split_whitespace() .skip(1) // skip "cpu" .filter_map(|v| v.parse().ok()) .collect(); if vals.len() < 4 { anyhow::bail!("Not enough fields in /proc/stat cpu line"); } let idle = vals[3]; // idle column let total: u64 = vals.iter().sum(); Ok(CpuJiffies { total, idle }) } /// Read memory info from /proc/meminfo. /// Returns (used_bytes, total_bytes). async fn read_meminfo() -> Result<(u64, u64)> { let content = tokio::fs::read_to_string("/proc/meminfo") .await .context("Failed to read /proc/meminfo")?; let mut total_kb: u64 = 0; let mut available_kb: u64 = 0; for line in content.lines() { if let Some(val) = line.strip_prefix("MemTotal:") { total_kb = parse_meminfo_kb(val)?; } else if let Some(val) = line.strip_prefix("MemAvailable:") { available_kb = parse_meminfo_kb(val)?; } } let used_bytes = total_kb.saturating_sub(available_kb) * 1024; let total_bytes = total_kb * 1024; Ok((used_bytes, total_bytes)) } fn parse_meminfo_kb(val: &str) -> Result { val.trim() .trim_end_matches("kB") .trim() .parse::() .context("parse meminfo value") } /// Read disk usage via `df` for the root filesystem. /// Returns (used_bytes, total_bytes). async fn read_disk_usage() -> Result<(u64, u64)> { let output = tokio::process::Command::new("df") .args(["--block-size=1", "--output=used,size", "/"]) .output() .await .context("Failed to run df")?; if !output.status.success() { anyhow::bail!("df failed: {}", String::from_utf8_lossy(&output.stderr)); } let stdout = String::from_utf8(output.stdout).context("df output not utf8")?; // Skip header line let data_line = stdout .lines() .nth(1) .ok_or_else(|| anyhow::anyhow!("No data line from df"))?; let mut parts = data_line.split_whitespace(); let used: u64 = parts .next() .ok_or_else(|| anyhow::anyhow!("Missing used"))? .parse() .context("parse df used")?; let total: u64 = parts .next() .ok_or_else(|| anyhow::anyhow!("Missing total"))? .parse() .context("parse df total")?; Ok((used, total)) } /// Read top 10 processes by CPU from `ps`. async fn read_top_processes() -> Result> { let output = tokio::process::Command::new("ps") .args(["--no-headers", "-eo", "pid,%cpu,%mem,comm", "--sort=-%cpu"]) .output() .await .context("Failed to run ps")?; if !output.status.success() { anyhow::bail!("ps failed: {}", String::from_utf8_lossy(&output.stderr)); } let stdout = String::from_utf8(output.stdout).context("ps output not utf8")?; let procs: Vec = stdout .lines() .take(10) .filter_map(|line| { let mut parts = line.split_whitespace(); let pid = parts.next()?.parse::().ok()?; let cpu: f64 = parts.next()?.parse().ok()?; let mem: f64 = parts.next()?.parse().ok()?; let name = parts.collect::>().join(" "); Some(serde_json::json!({ "pid": pid, "cpu_percent": cpu, "mem_percent": mem, "name": name, })) }) .collect(); Ok(procs) } /// Known hardware wallet USB vendor IDs. const KNOWN_HW_WALLETS: &[(u16, &str)] = &[ (0xd13e, "ColdCard"), (0x534c, "Trezor"), (0x2c97, "Ledger"), (0x1209, "BitBox02"), ]; /// Scan /sys/bus/usb/devices/ for known hardware wallet vendor IDs. async fn detect_usb_hardware_wallets() -> Result> { let usb_dir = std::path::Path::new("/sys/bus/usb/devices"); if !usb_dir.exists() { return Ok(Vec::new()); } let mut devices = Vec::new(); let mut entries = tokio::fs::read_dir(usb_dir) .await .context("Failed to read /sys/bus/usb/devices")?; while let Some(entry) = entries.next_entry().await? { let path = entry.path(); let vendor_path = path.join("idVendor"); let product_path = path.join("idProduct"); if !vendor_path.exists() { continue; } let vid_str = match tokio::fs::read_to_string(&vendor_path).await { Ok(s) => s.trim().to_string(), Err(_) => continue, }; let vid = match u16::from_str_radix(&vid_str, 16) { Ok(v) => v, Err(_) => continue, }; if let Some((_, name)) = KNOWN_HW_WALLETS.iter().find(|(known_vid, _)| *known_vid == vid) { let pid_str = tokio::fs::read_to_string(&product_path) .await .map(|s| s.trim().to_string()) .unwrap_or_default(); let manufacturer = tokio::fs::read_to_string(path.join("manufacturer")) .await .map(|s| s.trim().to_string()) .unwrap_or_default(); let product = tokio::fs::read_to_string(path.join("product")) .await .map(|s| s.trim().to_string()) .unwrap_or_default(); devices.push(serde_json::json!({ "type": name, "vendor_id": vid_str, "product_id": pid_str, "manufacturer": manufacturer, "product": product, "path": path.to_string_lossy(), })); } } Ok(devices) } /// Prune dangling container images via `sudo podman image prune -f`. /// Returns estimated bytes freed. async fn prune_container_images() -> Result { let output = tokio::process::Command::new("sudo") .args(["podman", "image", "prune", "-f"]) .output() .await .context("Failed to run podman image prune")?; if !output.status.success() { anyhow::bail!( "podman image prune failed: {}", String::from_utf8_lossy(&output.stderr) ); } // Podman outputs image IDs, estimate ~100MB per pruned image let stdout = String::from_utf8_lossy(&output.stdout); let pruned_count = stdout.lines().filter(|l| !l.trim().is_empty()).count(); Ok(pruned_count as u64 * 100_000_000) // rough estimate } /// Prune container build cache via `sudo podman system prune -f`. async fn prune_build_cache() -> Result { // Just prune volumes and build cache (not containers or images — those are handled above) let output = tokio::process::Command::new("sudo") .args(["podman", "volume", "prune", "-f"]) .output() .await .context("Failed to run podman volume prune")?; if !output.status.success() { anyhow::bail!( "podman volume prune failed: {}", String::from_utf8_lossy(&output.stderr) ); } let stdout = String::from_utf8_lossy(&output.stdout); let pruned_count = stdout.lines().filter(|l| !l.trim().is_empty()).count(); Ok(pruned_count as u64 * 10_000_000) // rough estimate per volume } /// Clean log files older than `max_age_days` from common log directories. async fn clean_old_logs(max_age_days: u64) -> Result { let output = tokio::process::Command::new("sudo") .args([ "find", "/var/log", "-type", "f", "-name", "*.log.*", "-mtime", &format!("+{}", max_age_days), "-delete", "-print", ]) .output() .await .context("Failed to clean old logs")?; let stdout = String::from_utf8_lossy(&output.stdout); let deleted_count = stdout.lines().filter(|l| !l.trim().is_empty()).count(); // Also clean rotated/compressed logs let _ = tokio::process::Command::new("sudo") .args([ "find", "/var/log", "-type", "f", "-name", "*.gz", "-mtime", &format!("+{}", max_age_days), "-delete", ]) .output() .await; Ok(deleted_count as u64 * 500_000) // rough estimate per log file } /// Remove stale temp files from /tmp and /var/tmp. async fn clean_temp_files() -> Result { let mut freed = 0u64; for dir in &["/tmp", "/var/tmp"] { let output = tokio::process::Command::new("sudo") .args([ "find", dir, "-type", "f", "-mtime", "+7", "-delete", "-print", ]) .output() .await; if let Ok(out) = output { let stdout = String::from_utf8_lossy(&out.stdout); let count = stdout.lines().filter(|l| !l.trim().is_empty()).count(); freed += count as u64 * 100_000; // rough estimate per temp file } } Ok(freed) } fn format_bytes(bytes: u64) -> String { const KB: u64 = 1024; const MB: u64 = KB * 1024; const GB: u64 = MB * 1024; if bytes >= GB { format!("{:.1} GB", bytes as f64 / GB as f64) } else if bytes >= MB { format!("{:.1} MB", bytes as f64 / MB as f64) } else if bytes >= KB { format!("{:.0} KB", bytes as f64 / KB as f64) } else { format!("{} B", bytes) } } /// Read temperatures from /sys/class/thermal/thermal_zone*/temp. async fn read_temperatures() -> Result> { let mut temps = Vec::new(); let thermal_dir = std::path::Path::new("/sys/class/thermal"); if !thermal_dir.exists() { return Ok(temps); } let mut entries = tokio::fs::read_dir(thermal_dir) .await .context("Failed to read /sys/class/thermal")?; while let Some(entry) = entries.next_entry().await? { let name = entry.file_name(); let name_str = name.to_string_lossy(); if !name_str.starts_with("thermal_zone") { continue; } let temp_path = entry.path().join("temp"); let type_path = entry.path().join("type"); let millideg = match tokio::fs::read_to_string(&temp_path).await { Ok(s) => s.trim().parse::().unwrap_or(0), Err(_) => continue, }; let zone_type = tokio::fs::read_to_string(&type_path) .await .map(|s| s.trim().to_string()) .unwrap_or_else(|_| name_str.to_string()); temps.push(serde_json::json!({ "zone": zone_type, "temp_celsius": millideg as f64 / 1000.0, })); } Ok(temps) } impl RpcHandler { /// system.factory-reset — Wipe all user data and restart. /// Preserves container images and node_key (hardware identity). pub(super) async fn handle_system_factory_reset( &self, params: Option, ) -> Result { // 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 }}"); } tracing::warn!("Factory reset initiated — wiping user data"); let data_dir = &self.config.data_dir; // Stop all running containers let client = archipelago_container::PodmanClient::new("archipelago".to_string()); if let Ok(containers) = client.list_containers().await { for c in &containers { let _ = client.stop_container(&c.name).await; } } // Delete user data (preserving node_key and container images) let files_to_remove = [ "user.json", "onboarding.json", "peers.json", "server-name", ]; for f in &files_to_remove { let path = data_dir.join(f); if path.exists() { let _ = tokio::fs::remove_file(&path).await; } } let dirs_to_remove = [ "identities", "credentials", "did-cache", "dwn", ]; for d in &dirs_to_remove { let path = data_dir.join(d); if path.exists() { let _ = tokio::fs::remove_dir_all(&path).await; } } // Clear all sessions self.session_store.invalidate_all_except("").await; tracing::warn!("Factory reset complete — restarting service"); // Restart the service via systemd tokio::spawn(async { tokio::time::sleep(std::time::Duration::from_secs(1)).await; let _ = std::process::Command::new("sudo") .args(["systemctl", "restart", "archipelago"]) .spawn(); }); Ok(serde_json::json!({ "status": "resetting" })) } }