Dorian d1e14c4269 feat: factory reset, backup restore, auto-identity creation
- system.factory-reset RPC: wipes user data, preserves images/node_key
- Factory Reset button in Settings with confirmation modal
- backup.restore-identity RPC: decrypts and restores DID key
- Restore from Backup panel in OnboardingIntro first screen
- Auto-create default identity with Nostr key on boot if none exist

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 05:18:12 +00:00

668 lines
21 KiB
Rust

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<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let name = params
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: name"))?
.trim()
.to_string();
if name.is_empty() || name.len() > 64 {
anyhow::bail!("Name must be 1-64 characters");
}
// Persist to file
let name_file = self.config.data_dir.join("server-name");
tokio::fs::write(&name_file, &name)
.await
.context("Failed to write server name")?;
// Update live state
let (mut data, _) = self.state_manager.get_snapshot().await;
data.server_info.name = Some(name.clone());
self.state_manager.update_data(data).await;
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<serde_json::Value> {
debug!("Getting system stats");
let uptime = read_uptime().await.unwrap_or(0.0);
let load = read_loadavg().await.unwrap_or((0.0, 0.0, 0.0));
let cpu = read_cpu_usage().await.unwrap_or(0.0);
let (mem_used, mem_total) = read_meminfo().await.unwrap_or((0, 0));
let (disk_used, disk_total) = read_disk_usage().await.unwrap_or((0, 0));
Ok(serde_json::json!({
"uptime_secs": uptime as u64,
"load_avg_1": load.0,
"load_avg_5": load.1,
"load_avg_15": load.2,
"cpu_usage_percent": cpu,
"mem_used_bytes": mem_used,
"mem_total_bytes": mem_total,
"disk_used_bytes": disk_used,
"disk_total_bytes": disk_total,
}))
}
/// system.processes — top 10 processes by CPU
pub(super) async fn handle_system_processes(&self) -> Result<serde_json::Value> {
debug!("Getting top processes");
let procs = read_top_processes().await.unwrap_or_default();
Ok(serde_json::json!({ "processes": procs }))
}
/// system.temperature — thermal zone readings
pub(super) async fn handle_system_temperature(&self) -> Result<serde_json::Value> {
debug!("Getting system temperature");
let temps = read_temperatures().await.unwrap_or_default();
Ok(serde_json::json!({ "temperatures": temps }))
}
/// system.detect-usb-devices — scan for known hardware wallet USB devices
pub(super) async fn handle_system_detect_usb_devices(&self) -> Result<serde_json::Value> {
debug!("Scanning for USB hardware wallets");
let devices = detect_usb_hardware_wallets().await.unwrap_or_default();
Ok(serde_json::json!({ "devices": devices }))
}
/// system.disk-status — Disk usage with warning/critical thresholds.
pub(super) async fn handle_system_disk_status(&self) -> Result<serde_json::Value> {
let (used, total) = read_disk_usage().await.unwrap_or((0, 0));
let percent = if total > 0 {
(used as f64 / total as f64) * 100.0
} else {
0.0
};
let percent_rounded = (percent * 10.0).round() / 10.0;
let level = if percent >= 90.0 {
"critical"
} else if percent >= 85.0 {
"warning"
} else {
"ok"
};
Ok(serde_json::json!({
"used_bytes": used,
"total_bytes": total,
"free_bytes": total.saturating_sub(used),
"used_percent": percent_rounded,
"level": level,
}))
}
/// system.disk-cleanup — Remove old container images, stale logs, and temp files.
pub(super) async fn handle_system_disk_cleanup(&self) -> Result<serde_json::Value> {
tracing::info!("Starting disk cleanup");
let mut freed_bytes: u64 = 0;
let mut actions: Vec<String> = Vec::new();
// 1. Prune dangling container images
match prune_container_images().await {
Ok(bytes) => {
if bytes > 0 {
freed_bytes += bytes;
actions.push(format!("Pruned dangling images: {} freed", format_bytes(bytes)));
}
}
Err(e) => actions.push(format!("Image prune failed: {}", e)),
}
// 2. Clean old log files (> 30 days)
match clean_old_logs(30).await {
Ok(bytes) => {
if bytes > 0 {
freed_bytes += bytes;
actions.push(format!("Cleaned old logs: {} freed", format_bytes(bytes)));
}
}
Err(e) => actions.push(format!("Log cleanup failed: {}", e)),
}
// 3. Remove stale temp files
match clean_temp_files().await {
Ok(bytes) => {
if bytes > 0 {
freed_bytes += bytes;
actions.push(format!("Removed temp files: {} freed", format_bytes(bytes)));
}
}
Err(e) => actions.push(format!("Temp cleanup failed: {}", e)),
}
// 4. Prune container build cache
match prune_build_cache().await {
Ok(bytes) => {
if bytes > 0 {
freed_bytes += bytes;
actions.push(format!("Pruned build cache: {} freed", format_bytes(bytes)));
}
}
Err(e) => actions.push(format!("Build cache prune failed: {}", e)),
}
tracing::info!("Disk cleanup complete: {} freed ({} actions)", format_bytes(freed_bytes), actions.len());
Ok(serde_json::json!({
"freed_bytes": freed_bytes,
"freed_human": format_bytes(freed_bytes),
"actions": actions,
}))
}
}
/// Read system uptime from /proc/uptime (seconds since boot).
async fn read_uptime() -> Result<f64> {
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<f64> {
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<CpuJiffies> {
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<u64> = 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<u64> {
val.trim()
.trim_end_matches("kB")
.trim()
.parse::<u64>()
.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<Vec<serde_json::Value>> {
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<serde_json::Value> = stdout
.lines()
.take(10)
.filter_map(|line| {
let mut parts = line.split_whitespace();
let pid = parts.next()?.parse::<u32>().ok()?;
let cpu: f64 = parts.next()?.parse().ok()?;
let mem: f64 = parts.next()?.parse().ok()?;
let name = parts.collect::<Vec<_>>().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<Vec<serde_json::Value>> {
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<u64> {
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<u64> {
// 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<u64> {
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<u64> {
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<Vec<serde_json::Value>> {
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::<i64>().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<serde_json::Value>,
) -> Result<serde_json::Value> {
// Safety check: require { confirm: true }
let confirmed = params
.as_ref()
.and_then(|p| p.get("confirm"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
if !confirmed {
anyhow::bail!("Factory reset requires {{ \"confirm\": true }}");
}
tracing::warn!("Factory reset initiated — wiping user data");
let data_dir = &self.config.data_dir;
// Stop all running containers
if let Ok(client) = archipelago_container::PodmanClient::detect().await {
if let Ok(containers) = client.list_containers().await {
for c in &containers {
let _ = client.stop_container(&c.names).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" }))
}
}