use crate::manifest::AppManifest; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::process::Command; use thiserror::Error; use tokio::process::Command as TokioCommand; #[derive(Debug, Error)] pub enum PodmanError { #[error("Podman command failed: {0}")] CommandFailed(String), #[error("Container not found: {0}")] NotFound(String), #[error("IO error: {0}")] Io(#[from] std::io::Error), } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ContainerStatus { pub id: String, pub name: String, pub state: ContainerState, pub image: String, pub created: String, pub ports: Vec, pub lan_address: Option, // Launch URL for UI access } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum ContainerState { Created, Running, Stopped, Exited, Paused, Unknown(String), } impl From<&str> for ContainerState { fn from(s: &str) -> Self { match s.to_lowercase().as_str() { "created" => ContainerState::Created, "running" => ContainerState::Running, "stopped" => ContainerState::Stopped, "exited" => ContainerState::Exited, "paused" => ContainerState::Paused, other => ContainerState::Unknown(other.to_string()), } } } pub struct PodmanClient { _user: String, rootless: bool, } impl PodmanClient { pub fn new(user: String) -> Self { // If running as root, use root podman context let is_root = std::env::var("USER").unwrap_or_default() == "root" || std::env::var("HOME").unwrap_or_default() == "/root"; Self { _user: user, rootless: !is_root, } } /// Map container name to its UI launch URL (static fallback for docker_packages scanner) pub fn lan_address_for(name: &str) -> Option { let url = match name { "bitcoin-knots" | "bitcoin-ui" => "http://localhost:8334", "lnd" | "archy-lnd-ui" => "http://localhost:8081", // Tailscale has no web UI — managed via CLI/app "homeassistant" => "http://localhost:8123", "archy-mempool-web" | "mempool" => "http://localhost:4080", "btcpay-server" => "http://localhost:23000", "grafana" => "http://localhost:3000", "searxng" => "http://localhost:8888", "ollama" => "http://localhost:11434", "onlyoffice" => "http://localhost:8044", "penpot" => "http://localhost:9001", "nextcloud" => "http://localhost:8085", "vaultwarden" => "http://localhost:8082", "jellyfin" => "http://localhost:8096", "photoprism" => "http://localhost:2342", "immich_server" | "immich" => "http://localhost:2283", "filebrowser" => "http://localhost:8083", "nginx-proxy-manager" => "http://localhost:8181", "portainer" => "http://localhost:9000", "uptime-kuma" => "http://localhost:3001", "fedimint" | "fedimintd" => "http://localhost:8175", "fedimint-gateway" => "http://localhost:8176", "nostr-rs-relay" => "http://localhost:18081", "indeedhub" => "http://localhost:7777", "dwn" => "http://localhost:3100", "endurain" => "http://localhost:8080", "electrs" | "archy-electrs-ui" => "http://localhost:50002", _ => return None, }; Some(url.to_string()) } fn podman_async(&self) -> TokioCommand { // Rootless podman: run as the current user (no sudo). // Requires: loginctl enable-linger , containers migrated to user storage. let cmd = TokioCommand::new("podman"); cmd } pub async fn pull_image(&self, image: &str, signature: Option<&str>) -> Result<()> { let mut cmd = self.podman_async(); cmd.arg("pull").arg(image); if let Some(sig) = signature { // Verify signature with cosign if provided cmd.arg("--signature-policy").arg("default"); // TODO: Implement cosign verification log::warn!("Signature verification not yet implemented: {}", sig); } let output = cmd .output() .await .context("Failed to execute podman pull")?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(anyhow::anyhow!("Failed to pull image: {}", stderr)); } Ok(()) } pub async fn create_container( &self, manifest: &AppManifest, name: &str, ) -> Result { let mut cmd = self.podman_async(); cmd.arg("create"); // Container name cmd.arg("--name").arg(name); // Read-only root filesystem if manifest.app.security.readonly_root { cmd.arg("--read-only"); } // Network policy match manifest.app.security.network_policy.as_str() { "host" => { cmd.arg("--network").arg("host"); } "isolated" => { // Create isolated network (default) } _ => { cmd.arg("--network").arg(&manifest.app.security.network_policy); } } // Port mappings for port in &manifest.app.ports { cmd.arg("-p").arg(format!("{}:{}", port.host, port.container)); } // Volumes for volume in &manifest.app.volumes { let mut mount = format!("{}:{}", volume.source, volume.target); if !volume.options.is_empty() { mount.push_str(&format!(":{}", volume.options.join(","))); } cmd.arg("-v").arg(mount); } // Devices for device in &manifest.app.devices { cmd.arg("--device").arg(device); } // Environment variables for env in &manifest.app.environment { cmd.arg("-e").arg(env); } // Resource limits if let Some(cpu) = manifest.app.resources.cpu_limit { cmd.arg("--cpus").arg(cpu.to_string()); } if let Some(memory) = &manifest.app.resources.memory_limit { cmd.arg("--memory").arg(memory); } // Capabilities (drop all, add specified) cmd.arg("--cap-drop").arg("ALL"); for cap in &manifest.app.security.capabilities { cmd.arg("--cap-add").arg(cap); } // Enforce no new privileges (prevent setuid escalation) cmd.arg("--security-opt").arg("no-new-privileges=true"); // Image cmd.arg(&manifest.app.container.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)); } let container_id = String::from_utf8_lossy(&output.stdout) .trim() .to_string(); Ok(container_id) } pub async fn start_container(&self, name: &str) -> Result<()> { let mut cmd = self.podman_async(); cmd.arg("start").arg(name); let output = cmd .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(()) } pub async fn stop_container(&self, name: &str) -> Result<()> { let mut cmd = self.podman_async(); cmd.arg("stop").arg(name); let output = cmd .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(()) } pub async fn remove_container(&self, name: &str) -> Result<()> { let mut cmd = self.podman_async(); cmd.arg("rm").arg("-f").arg(name); let output = cmd .output() .await .context("Failed to remove container")?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(anyhow::anyhow!("Failed to remove container: {}", stderr)); } Ok(()) } pub async fn get_container_status(&self, name: &str) -> Result { let mut cmd = self.podman_async(); cmd.arg("inspect") .arg("--format") .arg("{{.Id}}|{{.Name}}|{{.State.Status}}|{{.Config.Image}}|{{.Created}}|{{.NetworkSettings.Ports}}") .arg(name); let output = cmd .output() .await .context("Failed to inspect container")?; if !output.status.success() { return Err(anyhow::anyhow!("Container not found: {}", name)); } let info = String::from_utf8_lossy(&output.stdout); let parts: Vec<&str> = info.trim().split('|').collect(); if parts.len() < 5 { return Err(anyhow::anyhow!("Invalid container inspect output")); } Ok(ContainerStatus { id: parts[0].to_string(), name: parts[1].to_string(), state: ContainerState::from(parts[2]), image: parts[3].to_string(), created: parts[4].to_string(), ports: vec![], // TODO: Parse ports from parts[5] lan_address: None, // Set by docker_packages scanner }) } pub async fn get_container_logs(&self, name: &str, lines: u32) -> Result> { let mut cmd = self.podman_async(); cmd.arg("logs") .arg("--tail") .arg(lines.to_string()) .arg(name); let output = cmd .output() .await .context("Failed to get container logs")?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(anyhow::anyhow!("Failed to get logs: {}", stderr)); } let logs = String::from_utf8_lossy(&output.stdout); Ok(logs.lines().map(|s| s.to_string()).collect()) } pub async fn list_containers(&self) -> Result> { let mut cmd = self.podman_async(); cmd.arg("ps") .arg("-a") .arg("--format") .arg("json"); let output = tokio::time::timeout( std::time::Duration::from_secs(60), cmd.output(), ) .await .map_err(|_| anyhow::anyhow!("podman ps timed out (60s)"))? .context("Failed to list containers")?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); log::error!("Podman list failed: {}", stderr); return Err(anyhow::anyhow!("Failed to list containers: {}", stderr)); } let json = String::from_utf8_lossy(&output.stdout); log::debug!("Podman JSON output ({} bytes): {}", json.len(), if json.len() > 200 { &json[..200] } else { &json }); // Podman can return either a JSON array or NDJSON (newline-delimited JSON) let mut result = Vec::new(); // Try parsing as a JSON array first if let Ok(containers) = serde_json::from_str::>(&json) { log::debug!("Parsed as JSON array with {} items", containers.len()); for container in containers { // Handle both Names as array and Names as string let name = if let Some(names_array) = container["Names"].as_array() { names_array.get(0).and_then(|v| v.as_str()).unwrap_or("").to_string() } else { container["Names"].as_str().unwrap_or("").to_string() }; // Parse ports from the Ports array let ports = if let Some(ports_array) = container["Ports"].as_array() { ports_array.iter().filter_map(|port| { // Podman format: {"host_ip":"","container_port":8123,"host_port":8123,"range":1,"protocol":"tcp"} if let (Some(host_port), Some(container_port), Some(protocol)) = ( port["host_port"].as_u64(), port["container_port"].as_u64(), port["protocol"].as_str() ) { Some(format!("0.0.0.0:{}->{}/{}", host_port, container_port, protocol)) } else { None } }).collect() } else { vec![] }; let lan_address = Self::lan_address_for(&name); result.push(ContainerStatus { id: container["Id"].as_str().unwrap_or("").to_string(), name, state: ContainerState::from(container["State"].as_str().unwrap_or("unknown")), image: container["Image"].as_str().unwrap_or("").to_string(), created: container["Created"].as_str().unwrap_or("").to_string(), ports, lan_address, }); } } else { log::debug!("Failed to parse as JSON array, trying NDJSON"); // Try parsing as NDJSON (newline-delimited JSON) for line in json.lines() { if line.trim().is_empty() { continue; } if let Ok(container) = serde_json::from_str::(line) { // Handle both Names as array and Names as string let name = if let Some(names_array) = container["Names"].as_array() { names_array.get(0).and_then(|v| v.as_str()).unwrap_or("").to_string() } else { container["Names"].as_str().unwrap_or("").to_string() }; // Parse ports from the Ports array let ports = if let Some(ports_array) = container["Ports"].as_array() { ports_array.iter().filter_map(|port| { if let (Some(host_port), Some(container_port), Some(protocol)) = ( port["host_port"].as_u64(), port["container_port"].as_u64(), port["protocol"].as_str() ) { Some(format!("0.0.0.0:{}->{}/{}", host_port, container_port, protocol)) } else { None } }).collect() } else { vec![] }; let lan_address = Self::lan_address_for(&name); result.push(ContainerStatus { id: container["Id"].as_str().unwrap_or("").to_string(), name, state: ContainerState::from(container["State"].as_str().unwrap_or("unknown")), image: container["Image"].as_str().unwrap_or("").to_string(), created: container["Created"].as_str().unwrap_or("").to_string(), ports, lan_address, }); } } } log::debug!("Returning {} containers", result.len()); Ok(result) } }