Fedimintd serves JSON-RPC API on 8174 and Guardian web UI on 8175. Updated all port mappings: frontend AppSession, nginx HTTP/HTTPS proxies, PodmanClient static map. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
451 lines
16 KiB
Rust
451 lines
16 KiB
Rust
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<String>,
|
|
pub lan_address: Option<String>, // 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<String> {
|
|
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 <user>, 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<String> {
|
|
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<ContainerStatus> {
|
|
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<Vec<String>> {
|
|
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<Vec<ContainerStatus>> {
|
|
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::<Vec<serde_json::Value>>(&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::<serde_json::Value>(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)
|
|
}
|
|
}
|