Dorian 6035c93289 Enhance ISO build process and documentation for Archipelago
- Updated BUILD-GUIDE.md to clarify instructions for building the Archipelago Auto-Installer ISO, emphasizing the recommended method of building directly on the target server.
- Added auto-installation of missing dependencies (xorriso, podman) when running the build script with sudo.
- Enhanced the build-auto-installer-iso.sh script to capture container images from the live server, ensuring the ISO includes the same set of applications as the dev server.
- Revised deployment documentation to stress the importance of building the Rust backend on the Linux dev server and included new instructions for capturing system-level changes for ISO builds.
- Improved UI components and added new bundled applications (BTCPay Server, Mempool Explorer, Nostr Relay, Strfry Relay, Tailscale) to enhance user experience.
2026-02-14 16:44:20 +00:00

1034 lines
37 KiB
Rust

use crate::auth::AuthManager;
use crate::config::Config;
use crate::container::DevContainerOrchestrator;
use anyhow::{Context, Result};
use hyper::{Request, Response, StatusCode};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tracing::{debug, error};
#[derive(Debug, Deserialize)]
struct RpcRequest {
method: String,
params: Option<serde_json::Value>,
}
#[derive(Debug, Serialize)]
struct RpcResponse {
result: Option<serde_json::Value>,
error: Option<RpcError>,
}
#[derive(Debug, Serialize)]
struct RpcError {
code: i32,
message: String,
data: Option<serde_json::Value>,
}
/// Default dev password when no user is set up (matches mock-backend).
const DEV_DEFAULT_PASSWORD: &str = "password123";
pub struct RpcHandler {
config: Config,
auth_manager: AuthManager,
orchestrator: Option<Arc<DevContainerOrchestrator>>,
}
impl RpcHandler {
pub async fn new(config: Config) -> Result<Self> {
let auth_manager = AuthManager::new(config.data_dir.clone());
let orchestrator = if config.dev_mode {
Some(Arc::new(
DevContainerOrchestrator::new(config.clone()).await?,
))
} else {
None
};
Ok(Self {
config,
auth_manager,
orchestrator,
})
}
pub async fn handle(
&self,
req: Request<hyper::Body>,
) -> Result<Response<hyper::Body>> {
// Read request body
let (_, body) = req.into_parts();
let body_bytes = hyper::body::to_bytes(body).await
.context("Failed to read body")?;
let rpc_req: RpcRequest = serde_json::from_slice(&body_bytes)
.context("Invalid RPC request")?;
debug!("RPC method: {}", rpc_req.method);
// Route to handler
let result = match rpc_req.method.as_str() {
"echo" => self.handle_echo(rpc_req.params).await,
"server.echo" => self.handle_echo(rpc_req.params).await,
"auth.login" => self.handle_auth_login(rpc_req.params).await,
"auth.logout" => self.handle_auth_logout().await,
// Container orchestration (for Archipelago-managed containers)
"container-install" => self.handle_container_install(rpc_req.params).await,
"container-start" => self.handle_container_start(rpc_req.params).await,
"container-stop" => self.handle_container_stop(rpc_req.params).await,
"container-remove" => self.handle_container_remove(rpc_req.params).await,
"container-list" => self.handle_container_list().await,
"container-status" => self.handle_container_status(rpc_req.params).await,
"container-logs" => self.handle_container_logs(rpc_req.params).await,
"container-health" => self.handle_container_health(rpc_req.params).await,
// Package management (for docker-compose apps)
"package.install" => self.handle_package_install(rpc_req.params).await,
"package.start" => self.handle_package_start(rpc_req.params).await,
"package.stop" => self.handle_package_stop(rpc_req.params).await,
"package.restart" => self.handle_package_restart(rpc_req.params).await,
// Bundled app management (for pre-loaded container images)
"bundled-app-start" => self.handle_bundled_app_start(rpc_req.params).await,
"bundled-app-stop" => self.handle_bundled_app_stop(rpc_req.params).await,
_ => {
Err(anyhow::anyhow!("Unknown method: {}", rpc_req.method))
}
};
// Build response
let rpc_resp = match result {
Ok(data) => RpcResponse {
result: Some(data),
error: None,
},
Err(e) => {
error!("RPC error: {}", e);
RpcResponse {
result: None,
error: Some(RpcError {
code: -1,
message: e.to_string(),
data: None,
}),
}
}
};
let body = serde_json::to_vec(&rpc_resp)
.context("Failed to serialize response")?;
Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.body(hyper::Body::from(body))
.unwrap())
}
async fn handle_echo(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
if let Some(p) = params {
if let Some(msg) = p.get("message").and_then(|v| v.as_str()) {
return Ok(serde_json::json!({ "message": msg }));
}
}
Ok(serde_json::json!({ "message": "Hello from Archipelago!" }))
}
async fn handle_auth_login(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let password = params
.get("password")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing password"))?;
let is_setup = self.auth_manager.is_setup().await?;
if !is_setup {
// Dev mode: allow default password so UI can log in without running setup
if self.config.dev_mode && password == DEV_DEFAULT_PASSWORD {
return Ok(serde_json::Value::Null);
}
return Err(anyhow::anyhow!(
"User not set up. Please complete setup first."
));
}
let valid = self.auth_manager.verify_password(password).await?;
if !valid {
return Err(anyhow::anyhow!("Password Incorrect"));
}
Ok(serde_json::Value::Null)
}
async fn handle_auth_logout(&self) -> Result<serde_json::Value> {
// For now, just return success. In a full implementation, this would:
// - Invalidate session tokens
// - Clear cookies (if we were managing them)
// - Close authenticated WebSocket connections
Ok(serde_json::Value::Null)
}
async fn handle_container_install(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let orchestrator = self
.orchestrator
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let manifest_path = params
.get("manifest_path")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing manifest_path"))?;
// Load manifest
let manifest_content = tokio::fs::read_to_string(manifest_path)
.await
.context("Failed to read manifest file")?;
let manifest: archipelago_container::AppManifest = serde_yaml::from_str(&manifest_content)
.context("Failed to parse manifest")?;
let container_name = orchestrator
.install_container(&manifest, manifest_path)
.await
.context("Failed to install container")?;
Ok(serde_json::json!(container_name))
}
async fn handle_container_start(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let orchestrator = self
.orchestrator
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let app_id = params
.get("app_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
orchestrator
.start_container(app_id)
.await
.context("Failed to start container")?;
Ok(serde_json::json!({ "status": "started" }))
}
async fn handle_container_stop(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let orchestrator = self
.orchestrator
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let app_id = params
.get("app_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
orchestrator
.stop_container(app_id)
.await
.context("Failed to stop container")?;
Ok(serde_json::json!({ "status": "stopped" }))
}
async fn handle_container_remove(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let orchestrator = self
.orchestrator
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let app_id = params
.get("app_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
let preserve_data = params
.get("preserve_data")
.and_then(|v| v.as_bool())
.unwrap_or(false);
orchestrator
.remove_container(app_id, preserve_data)
.await
.context("Failed to remove container")?;
Ok(serde_json::json!({ "status": "removed" }))
}
async fn handle_container_list(&self) -> Result<serde_json::Value> {
// Try to get containers from orchestrator first
if let Some(orchestrator) = &self.orchestrator {
if let Ok(containers) = orchestrator.list_containers().await {
if !containers.is_empty() {
return Ok(serde_json::to_value(containers)?);
}
}
}
// Fallback: list containers directly via sudo podman (for bundled apps)
let output = tokio::process::Command::new("sudo")
.args(["podman", "ps", "-a", "--format", "json"])
.output()
.await
.context("Failed to list containers via podman")?;
if !output.status.success() {
// If podman fails, return empty list
return Ok(serde_json::json!([]));
}
let stdout = String::from_utf8_lossy(&output.stdout);
if stdout.trim().is_empty() {
return Ok(serde_json::json!([]));
}
// Parse podman JSON output
let podman_containers: Vec<serde_json::Value> = serde_json::from_str(&stdout)
.unwrap_or_else(|_| Vec::new());
// Convert to our ContainerStatus format
let containers: Vec<serde_json::Value> = podman_containers
.iter()
.map(|c| {
let state = c.get("State").and_then(|v| v.as_str()).unwrap_or("unknown");
let mapped_state = match state.to_lowercase().as_str() {
"running" => "running",
"exited" => "exited",
"stopped" => "stopped",
"created" => "created",
"paused" => "paused",
_ => "unknown",
};
let name = c.get("Names").and_then(|v| v.as_array()).and_then(|a| a.first()).and_then(|v| v.as_str()).unwrap_or("");
// Determine lan_address based on container name
let lan_address = match name {
"bitcoin-knots" => Some("http://localhost:8334"),
"lnd" => Some("http://localhost:8081"),
"tailscale" => Some("http://localhost:8240"),
_ => None,
};
serde_json::json!({
"id": c.get("Id").and_then(|v| v.as_str()).unwrap_or(""),
"name": name,
"state": mapped_state,
"image": c.get("Image").and_then(|v| v.as_str()).unwrap_or(""),
"created": c.get("Created").and_then(|v| v.as_str()).unwrap_or(""),
"ports": c.get("Ports").and_then(|v| v.as_array()).map(|a|
a.iter().filter_map(|p| p.get("hostPort").and_then(|v| v.as_u64()).map(|p| p.to_string())).collect::<Vec<_>>()
).unwrap_or_default(),
"lan_address": lan_address,
})
})
.collect();
Ok(serde_json::json!(containers))
}
async fn handle_container_status(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let orchestrator = self
.orchestrator
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let app_id = params
.get("app_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
let status = orchestrator
.get_container_status(app_id)
.await
.context("Failed to get container status")?;
Ok(serde_json::to_value(status)?)
}
async fn handle_container_logs(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let orchestrator = self
.orchestrator
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let app_id = params
.get("app_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
let lines = params
.get("lines")
.and_then(|v| v.as_u64())
.unwrap_or(100) as u32;
let logs = orchestrator
.get_container_logs(app_id, lines)
.await
.context("Failed to get container logs")?;
Ok(serde_json::to_value(logs)?)
}
/// Used by HTTP GET /api/container/logs (same logic as container-logs RPC).
pub async fn get_container_logs_value(
&self,
app_id: &str,
lines: u32,
) -> Result<serde_json::Value> {
let orchestrator = self
.orchestrator
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
let logs = orchestrator
.get_container_logs(app_id, lines)
.await
.context("Failed to get container logs")?;
Ok(serde_json::to_value(logs)?)
}
async fn handle_container_health(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let orchestrator = self
.orchestrator
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
// If app_id is provided, get health for that app
if let Some(params) = params {
if let Some(app_id) = params.get("app_id").and_then(|v| v.as_str()) {
let health = orchestrator
.get_health_status(app_id)
.await
.context("Failed to get container health")?;
return Ok(serde_json::json!({ app_id: health }));
}
}
// Otherwise, get health for all containers
let containers = orchestrator
.list_containers()
.await
.context("Failed to list containers")?;
let mut health_map = serde_json::Map::new();
for container in containers {
// Extract app_id from container name
if let Some(app_id) = container.name.strip_prefix("archipelago-") {
if let Some(app_id) = app_id.strip_suffix("-dev") {
match orchestrator.get_health_status(app_id).await {
Ok(health) => {
health_map.insert(app_id.to_string(), serde_json::Value::String(health));
}
Err(_) => {
health_map.insert(app_id.to_string(), serde_json::Value::String("unknown".to_string()));
}
}
}
}
}
Ok(serde_json::Value::Object(health_map))
}
// Package management methods for podman containers
/// Install a package from a Docker image
/// Security: Image verification, resource limits, network isolation
async fn handle_package_install(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let package_id = params
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
let docker_image = params
.get("dockerImage")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing dockerImage"))?;
debug!("Installing package {} from image {}", package_id, docker_image);
// Security: Validate image name format (prevent injection)
if !is_valid_docker_image(docker_image) {
return Err(anyhow::anyhow!("Invalid Docker image format"));
}
// Check if container already exists
let check_output = tokio::process::Command::new("sudo")
.args(["podman", "ps", "-a", "--format", "{{.Names}}", "--filter", &format!("name=^{}$", package_id)])
.output()
.await
.context("Failed to check existing containers")?;
if !String::from_utf8_lossy(&check_output.stdout).trim().is_empty() {
return Err(anyhow::anyhow!("Container {} already exists. Stop and remove it first.", package_id));
}
// Pull the image (with verification in the future)
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));
}
// Create and start container with security constraints
// TODO: Load these from manifest.yml for the specific app
let mut run_args = vec![
"podman", "run",
"-d", // Detached
"--name", package_id,
"--restart=unless-stopped", // Auto-restart policy
];
// App-specific configuration (should come from manifest)
let (ports, volumes, env_vars, custom_command) = get_app_config(package_id);
// Special handling for Tailscale: requires host network and privileged mode
let is_tailscale = package_id == "tailscale";
if is_tailscale {
run_args.push("--network=host");
run_args.push("--privileged");
run_args.push("--cap-add=NET_ADMIN");
run_args.push("--cap-add=NET_RAW");
run_args.push("--device=/dev/net/tun");
}
// Create data directories if they don't exist
for volume in &volumes {
if let Some(host_path) = volume.split(':').next() {
if host_path.starts_with("/var/lib/archipelago/") {
debug!("Creating directory: {}", host_path);
let create_dir = tokio::process::Command::new("sudo")
.args(["mkdir", "-p", host_path])
.output()
.await;
if let Err(e) = create_dir {
debug!("Failed to create directory {}: {}", host_path, e);
}
}
}
}
// Add port mappings (skip if host network mode like Tailscale)
if !is_tailscale {
for port in &ports {
run_args.push("-p");
run_args.push(port);
}
}
// Add volume mounts
for volume in &volumes {
run_args.push("-v");
run_args.push(volume);
}
// Add environment variables
for env in &env_vars {
run_args.push("-e");
run_args.push(env);
}
// Security: Network isolation (unless host network required)
// run_args.push("--network=isolated"); // Future: per-app network
// Security: Resource limits (from manifest)
run_args.push("--memory=2g"); // TODO: from manifest
run_args.push("--cpus=2"); // TODO: from manifest
// Finally, the image
run_args.push(docker_image);
debug!("Running container with args: {:?}", run_args);
// Build command with optional custom command
let mut cmd = tokio::process::Command::new("sudo");
cmd.args(&run_args);
// Add custom command if specified (e.g., for Tailscale web UI)
if let Some(custom_cmd) = custom_command {
cmd.arg(custom_cmd);
}
let run_output = cmd
.output()
.await
.context("Failed to run container")?;
if !run_output.status.success() {
let stderr = String::from_utf8_lossy(&run_output.stderr);
return Err(anyhow::anyhow!("Failed to start container: {}", stderr));
}
let container_id = String::from_utf8_lossy(&run_output.stdout).trim().to_string();
Ok(serde_json::json!({
"success": true,
"package_id": package_id,
"container_id": container_id,
"message": format!("Package {} installed and started", package_id)
}))
}
// Package management methods for docker-compose containers
async fn handle_package_start(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let package_id = params
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
// Convert package ID to container name (e.g., "bitcoin" -> "archy-bitcoin")
// But also check if container exists without the prefix
let container_name = if let Ok(output) = tokio::process::Command::new("sudo")
.args(["podman", "ps", "-a", "--format", "{{.Names}}", "--filter", &format!("name=^{}$", package_id)])
.output()
.await
{
let stdout = String::from_utf8_lossy(&output.stdout);
if !stdout.trim().is_empty() {
debug!("Found container without prefix: {}", package_id);
package_id.to_string()
} else {
debug!("Using archy- prefix: archy-{}", package_id);
format!("archy-{}", package_id)
}
} else {
format!("archy-{}", package_id)
};
// Use podman CLI to start the container
let output = tokio::process::Command::new("sudo")
.args(["podman", "start", &container_name])
.output()
.await
.context("Failed to execute podman start")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow::anyhow!("Failed to start container: {}", stderr));
}
Ok(serde_json::Value::Null)
}
async fn handle_package_stop(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let package_id = params
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
// Convert package ID to container name
let container_name = if let Ok(output) = tokio::process::Command::new("sudo")
.args(["podman", "ps", "-a", "--format", "{{.Names}}", "--filter", &format!("name=^{}$", package_id)])
.output()
.await
{
let stdout = String::from_utf8_lossy(&output.stdout);
if !stdout.trim().is_empty() {
debug!("Found container without prefix: {}", package_id);
package_id.to_string()
} else {
debug!("Using archy- prefix: archy-{}", package_id);
format!("archy-{}", package_id)
}
} else {
format!("archy-{}", package_id)
};
// Use podman CLI to stop the container
let output = tokio::process::Command::new("sudo")
.args(["podman", "stop", &container_name])
.output()
.await
.context("Failed to execute podman stop")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow::anyhow!("Failed to stop container: {}", stderr));
}
Ok(serde_json::Value::Null)
}
async fn handle_package_restart(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let package_id = params
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
// Convert package ID to container name
let container_name = if let Ok(output) = tokio::process::Command::new("sudo")
.args(["podman", "ps", "-a", "--format", "{{.Names}}", "--filter", &format!("name=^{}$", package_id)])
.output()
.await
{
let stdout = String::from_utf8_lossy(&output.stdout);
if !stdout.trim().is_empty() {
debug!("Found container without prefix: {}", package_id);
package_id.to_string()
} else {
debug!("Using archy- prefix: archy-{}", package_id);
format!("archy-{}", package_id)
}
} else {
format!("archy-{}", package_id)
};
// Use podman CLI to restart the container
let output = tokio::process::Command::new("sudo")
.args(["podman", "restart", &container_name])
.output()
.await
.context("Failed to execute podman restart")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow::anyhow!("Failed to restart container: {}", stderr));
}
Ok(serde_json::Value::Null)
}
/// Start a bundled app (create container from pre-loaded image if needed, then start)
async fn handle_bundled_app_start(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let app_id = params
.get("app_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
let image = params
.get("image")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing image"))?;
let ports = params
.get("ports")
.and_then(|v| v.as_array())
.ok_or_else(|| anyhow::anyhow!("Missing ports"))?;
let volumes = params
.get("volumes")
.and_then(|v| v.as_array())
.ok_or_else(|| anyhow::anyhow!("Missing volumes"))?;
// Check if container already exists
let check_output = tokio::process::Command::new("sudo")
.args(["podman", "ps", "-a", "--format", "{{.Names}}", "--filter", &format!("name={}", app_id)])
.output()
.await
.context("Failed to check container")?;
let existing = String::from_utf8_lossy(&check_output.stdout);
if existing.trim().is_empty() {
// Container doesn't exist - create it
let mut cmd = tokio::process::Command::new("sudo");
cmd.args(["podman", "run", "-d", "--name", app_id]);
// Add port mappings
for port in ports {
if let (Some(host), Some(container)) = (
port.get("host").and_then(|v| v.as_u64()),
port.get("container").and_then(|v| v.as_u64()),
) {
cmd.arg("-p").arg(format!("{}:{}", host, container));
}
}
// Add volume mappings
for volume in volumes {
if let (Some(host), Some(container)) = (
volume.get("host").and_then(|v| v.as_str()),
volume.get("container").and_then(|v| v.as_str()),
) {
// Create host directory if it doesn't exist
let _ = tokio::process::Command::new("sudo")
.args(["mkdir", "-p", host])
.output()
.await;
cmd.arg("-v").arg(format!("{}:{}", host, container));
}
}
cmd.arg(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));
}
} else {
// Container exists - just start it
let output = tokio::process::Command::new("sudo")
.args(["podman", "start", app_id])
.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(serde_json::json!({ "status": "started", "app_id": app_id }))
}
/// Stop a bundled app
async fn handle_bundled_app_stop(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let app_id = params
.get("app_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
let output = tokio::process::Command::new("sudo")
.args(["podman", "stop", app_id])
.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(serde_json::json!({ "status": "stopped", "app_id": app_id }))
}
}
/// Validate Docker image name format
/// Prevents command injection via malicious image names
fn is_valid_docker_image(image: &str) -> bool {
// Valid format: [registry/][namespace/]image[:tag][@digest]
// Examples: nginx:latest, ghcr.io/owner/image:v1.0, docker.io/library/nginx
// Basic validation: no shell metacharacters
let dangerous_chars = ['&', '|', ';', '`', '$', '(', ')', '<', '>', '\n', '\r'];
if image.chars().any(|c| dangerous_chars.contains(&c)) {
return false;
}
// Must contain at least one alphanumeric character
if !image.chars().any(|c| c.is_alphanumeric()) {
return false;
}
// Length check
if image.len() > 256 {
return false;
}
true
}
/// Get app-specific configuration
/// Returns: (ports, volumes, env_vars, custom_command)
/// TODO: Load from manifest.yml files in apps/ directory
fn get_app_config(app_id: &str) -> (Vec<String>, Vec<String>, Vec<String>, Option<String>) {
match app_id {
"homeassistant" | "home-assistant" => (
vec!["8123:8123".to_string()],
vec!["/var/lib/archipelago/home-assistant:/config".to_string()],
vec!["TZ=UTC".to_string()],
None,
),
"bitcoin" | "bitcoin-core" => (
vec!["8332:8332".to_string(), "8333:8333".to_string()],
vec!["/var/lib/archipelago/bitcoin:/bitcoin/.bitcoin".to_string()],
vec![],
None,
),
"lnd" => (
vec!["9735:9735".to_string(), "10009:10009".to_string(), "8080:8080".to_string()],
vec!["/var/lib/archipelago/lnd:/root/.lnd".to_string()],
vec!["BITCOIN_ACTIVE=1".to_string()],
None,
),
"btcpay-server" | "btcpayserver" => (
vec!["23000:49392".to_string()],
vec!["/var/lib/archipelago/btcpay:/datadir".to_string()],
vec![],
None,
),
"mempool" => (
vec!["8999:8080".to_string()],
vec![],
vec![],
None,
),
"grafana" => (
vec!["3000:3000".to_string()],
vec!["/var/lib/archipelago/grafana:/var/lib/grafana".to_string()],
vec![],
None,
),
"searxng" => (
vec!["8888:8080".to_string()],
vec![],
vec![],
None,
),
"ollama" => (
vec!["11434:11434".to_string()],
vec!["/var/lib/archipelago/ollama:/root/.ollama".to_string()],
vec![],
None,
),
"onlyoffice" | "onlyoffice-documentserver" => (
vec!["9980:80".to_string()],
vec![],
vec![],
None,
),
"penpot" | "penpot-frontend" => (
vec!["9001:80".to_string()],
vec![],
vec![],
None,
),
"nextcloud" => (
vec!["8081:80".to_string()],
vec!["/var/lib/archipelago/nextcloud:/var/www/html".to_string()],
vec![],
None,
),
"vaultwarden" => (
vec!["8082:80".to_string()],
vec!["/var/lib/archipelago/vaultwarden:/data".to_string()],
vec![],
None,
),
"jellyfin" => (
vec!["8096:8096".to_string()],
vec!["/var/lib/archipelago/jellyfin/config:/config".to_string(), "/var/lib/archipelago/jellyfin/cache:/cache".to_string()],
vec![],
None,
),
"photoprism" => (
vec!["2342:2342".to_string()],
vec!["/var/lib/archipelago/photoprism:/photoprism/storage".to_string()],
vec![],
None,
),
"immich" => (
vec!["2283:3001".to_string()],
vec!["/var/lib/archipelago/immich:/usr/src/app/upload".to_string()],
vec![],
None,
),
"filebrowser" => (
vec!["8083:80".to_string()],
vec!["/var/lib/archipelago/filebrowser:/srv".to_string()],
vec![],
None,
),
"nginx-proxy-manager" => (
vec!["81:81".to_string(), "8084:80".to_string(), "8443:443".to_string()],
vec![
"/var/lib/archipelago/nginx-proxy-manager/data:/data".to_string(),
"/var/lib/archipelago/nginx-proxy-manager/letsencrypt:/etc/letsencrypt".to_string(),
],
vec![],
None,
),
"portainer" => (
vec!["9000:9000".to_string()],
vec!["/var/lib/archipelago/portainer:/data".to_string(), "/var/run/podman/podman.sock:/var/run/docker.sock".to_string()],
vec![],
None,
),
"uptime-kuma" => (
vec!["3001:3001".to_string()],
vec!["/var/lib/archipelago/uptime-kuma:/app/data".to_string()],
vec![],
None,
),
"tailscale" => (
vec!["8240:8240".to_string()], // Tailscale web UI port (only used if not host network)
vec![
"/var/lib/archipelago/tailscale:/var/lib/tailscale".to_string(),
],
vec![
"TS_STATE_DIR=/var/lib/tailscale".to_string(),
],
Some("sh -c 'tailscale web --listen 0.0.0.0:8240 & exec tailscaled'".to_string()),
),
"fedimint" => (
vec!["8173:8173".to_string()],
vec!["/var/lib/archipelago/fedimint:/data".to_string()],
vec![
"FM_BITCOIN_RPC_KIND=bitcoind".to_string(),
"FM_BITCOIN_RPC_URL=http://host.containers.internal:8332".to_string(),
"FM_BIND_P2P=0.0.0.0:8173".to_string(),
"FM_BIND_API=0.0.0.0:8174".to_string(),
],
None,
),
_ => (vec![], vec![], vec![], None), // No default config, user must configure manually
}
}