- 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.
1034 lines
37 KiB
Rust
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
|
|
}
|
|
}
|