- Added logic to remove any existing single-container 'immich' instances to prevent conflicts with the new multi-container 'immich_server' stack. - Updated `build-auto-installer-iso.sh` to utilize configuration files from the `configs/` directory for Nginx and systemd service, ensuring proper setup. - Modified deployment scripts to ensure the removal of old containers and improved handling of Immich stack creation. - Updated documentation to reflect changes in service configurations and critical build checklist items.
1714 lines
65 KiB
Rust
1714 lines
65 KiB
Rust
use crate::auth::AuthManager;
|
|
use crate::config::Config;
|
|
use crate::container::docker_packages;
|
|
use crate::container::DevContainerOrchestrator;
|
|
use crate::identity;
|
|
use crate::node_message;
|
|
use crate::nostr_discovery;
|
|
use crate::peers::{self, KnownPeer};
|
|
use crate::port_allocator::PortAllocator;
|
|
use crate::state::StateManager;
|
|
use anyhow::{Context, Result};
|
|
use hyper::{Request, Response, StatusCode};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::sync::{Arc, Mutex};
|
|
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>>,
|
|
state_manager: Arc<StateManager>,
|
|
port_allocator: Arc<Mutex<PortAllocator>>,
|
|
}
|
|
|
|
impl RpcHandler {
|
|
pub async fn new(config: Config, state_manager: Arc<StateManager>) -> 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
|
|
};
|
|
let port_allocator = Arc::new(Mutex::new(PortAllocator::new(&config.data_dir)?));
|
|
|
|
Ok(Self {
|
|
config,
|
|
auth_manager,
|
|
orchestrator,
|
|
state_manager,
|
|
port_allocator,
|
|
})
|
|
}
|
|
|
|
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,
|
|
"auth.changePassword" => self.handle_auth_change_password(rpc_req.params).await,
|
|
"auth.onboardingComplete" => self.handle_auth_onboarding_complete().await,
|
|
"auth.isOnboardingComplete" => self.handle_auth_is_onboarding_complete().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,
|
|
"package.uninstall" => self.handle_package_uninstall(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,
|
|
|
|
// Node identity and P2P peers
|
|
"node-add-peer" => self.handle_node_add_peer(rpc_req.params).await,
|
|
"node-list-peers" => self.handle_node_list_peers().await,
|
|
"node-remove-peer" => self.handle_node_remove_peer(rpc_req.params).await,
|
|
"node-send-message" => self.handle_node_send_message(rpc_req.params).await,
|
|
"node-check-peer" => self.handle_node_check_peer(rpc_req.params).await,
|
|
"node-messages-received" => self.handle_node_messages_received().await,
|
|
"node-nostr-discover" => self.handle_node_nostr_discover().await,
|
|
"node.did" => self.handle_node_did().await,
|
|
"node.tor-address" => self.handle_node_tor_address().await,
|
|
"node.nostr-publish" => self.handle_node_nostr_publish().await,
|
|
"node.nostr-pubkey" => self.handle_node_nostr_pubkey().await,
|
|
"node-nostr-verify-revoked" => self.handle_node_nostr_verify_revoked().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_auth_change_password(
|
|
&self,
|
|
params: Option<serde_json::Value>,
|
|
) -> Result<serde_json::Value> {
|
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
|
let current_password = params
|
|
.get("currentPassword")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| anyhow::anyhow!("Missing currentPassword"))?;
|
|
let new_password = params
|
|
.get("newPassword")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| anyhow::anyhow!("Missing newPassword"))?;
|
|
let also_change_ssh = params
|
|
.get("alsoChangeSsh")
|
|
.and_then(|v| v.as_bool())
|
|
.unwrap_or(true);
|
|
|
|
self.auth_manager
|
|
.change_password(current_password, new_password, also_change_ssh)
|
|
.await?;
|
|
|
|
Ok(serde_json::json!({ "success": true }))
|
|
}
|
|
|
|
async fn handle_auth_onboarding_complete(&self) -> Result<serde_json::Value> {
|
|
self.auth_manager.complete_onboarding().await?;
|
|
Ok(serde_json::json!(true))
|
|
}
|
|
|
|
async fn handle_auth_is_onboarding_complete(&self) -> Result<serde_json::Value> {
|
|
let complete = self.auth_manager.is_onboarding_complete().await?;
|
|
Ok(serde_json::json!(complete))
|
|
}
|
|
|
|
async fn handle_node_did(&self) -> Result<serde_json::Value> {
|
|
let (data, _) = self.state_manager.get_snapshot().await;
|
|
let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
|
Ok(serde_json::json!({ "did": did, "pubkey": data.server_info.pubkey }))
|
|
}
|
|
|
|
async fn handle_node_tor_address(&self) -> Result<serde_json::Value> {
|
|
let tor_address = docker_packages::read_tor_address("archipelago");
|
|
Ok(serde_json::json!({ "tor_address": tor_address }))
|
|
}
|
|
|
|
async fn handle_node_nostr_publish(&self) -> Result<serde_json::Value> {
|
|
if !self.config.nostr_discovery_enabled || self.config.nostr_relays.is_empty() {
|
|
anyhow::bail!(
|
|
"Nostr discovery disabled. Set ARCHIPELAGO_NOSTR_DISCOVERY_ENABLED=true and ARCHIPELAGO_NOSTR_RELAYS=wss://... to enable."
|
|
);
|
|
}
|
|
let (data, _) = self.state_manager.get_snapshot().await;
|
|
let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
|
let node_address = data
|
|
.server_info
|
|
.node_address
|
|
.as_deref()
|
|
.unwrap_or("archipelago://unknown");
|
|
let identity_dir = self.config.data_dir.join("identity");
|
|
let output = nostr_discovery::publish_node_identity(
|
|
&identity_dir,
|
|
&did,
|
|
node_address,
|
|
&data.server_info.version,
|
|
&self.config.nostr_relays,
|
|
self.config.nostr_tor_proxy.as_deref(),
|
|
)
|
|
.await?;
|
|
Ok(serde_json::json!({
|
|
"event_id": output.id().to_hex(),
|
|
"success": output.success.len(),
|
|
"failed": output.failed.len(),
|
|
}))
|
|
}
|
|
|
|
async fn handle_node_nostr_pubkey(&self) -> Result<serde_json::Value> {
|
|
let identity_dir = self.config.data_dir.join("identity");
|
|
let pubkey = nostr_discovery::get_nostr_pubkey(&identity_dir).await?;
|
|
Ok(serde_json::json!({ "nostr_pubkey": pubkey }))
|
|
}
|
|
|
|
async fn handle_node_nostr_verify_revoked(&self) -> Result<serde_json::Value> {
|
|
let identity_dir = self.config.data_dir.join("identity");
|
|
let status = nostr_discovery::verify_revocation(
|
|
&identity_dir,
|
|
self.config.nostr_tor_proxy.as_deref(),
|
|
)
|
|
.await?;
|
|
Ok(serde_json::json!({
|
|
"revoked": status.revoked,
|
|
"nostr_pubkey": status.nostr_pubkey,
|
|
"latest_content": status.latest_content,
|
|
"error": status.error,
|
|
}))
|
|
}
|
|
|
|
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"));
|
|
}
|
|
|
|
// Multi-container apps: create full stack
|
|
if package_id == "immich" {
|
|
return self.install_immich_stack().await;
|
|
}
|
|
if package_id == "penpot" || package_id == "penpot-frontend" {
|
|
return self.install_penpot_stack().await;
|
|
}
|
|
|
|
// 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, custom_args) = {
|
|
let mut allocator = self.port_allocator.lock().map_err(|e| {
|
|
anyhow::anyhow!("Port allocator lock poisoned: {}", e)
|
|
})?;
|
|
get_app_config(package_id, &self.config.host_ip, &mut allocator)
|
|
};
|
|
|
|
// Special handling: Tailscale needs host network; mempool stack needs archy-net
|
|
let is_tailscale = package_id == "tailscale";
|
|
let needs_archy_net = matches!(
|
|
package_id,
|
|
"bitcoin-knots" | "bitcoin" | "bitcoin-core"
|
|
| "mempool" | "mempool-web" | "mempool-api" | "mempool-electrs" | "mysql-mempool" | "archy-mempool-db" | "archy-mempool-web"
|
|
| "btcpay-server" | "btcpayserver" | "archy-btcpay-db"
|
|
);
|
|
|
|
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");
|
|
} else if needs_archy_net {
|
|
// Ensure archy-net exists, then attach
|
|
let _ = tokio::process::Command::new("sudo")
|
|
.args(["podman", "network", "create", "archy-net"])
|
|
.output()
|
|
.await;
|
|
run_args.push("--network=archy-net");
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
// Grafana runs as UID 472 - fix permissions so it can write
|
|
if package_id == "grafana" && host_path.contains("grafana") {
|
|
let _ = tokio::process::Command::new("sudo")
|
|
.args(["chown", "-R", "472:472", host_path])
|
|
.output()
|
|
.await;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
let memory_limit = if package_id == "ollama" { "4g" } else { "2g" };
|
|
let mem_arg = format!("--memory={}", memory_limit);
|
|
run_args.push(&mem_arg);
|
|
run_args.push("--cpus=2");
|
|
|
|
// 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/args if specified (Tailscale: shell override; electrs: CLI args)
|
|
if let Some(custom_cmd) = custom_command {
|
|
cmd.arg(custom_cmd);
|
|
} else if let Some(args) = custom_args {
|
|
cmd.args(args);
|
|
}
|
|
|
|
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)
|
|
}))
|
|
}
|
|
|
|
/// Install Immich stack (postgres + redis + server)
|
|
async fn install_immich_stack(&self) -> Result<serde_json::Value> {
|
|
let check = tokio::process::Command::new("sudo")
|
|
.args(["podman", "ps", "-a", "--format", "{{.Names}}"])
|
|
.output()
|
|
.await
|
|
.context("Failed to list containers")?;
|
|
let stdout = String::from_utf8_lossy(&check.stdout);
|
|
if stdout.contains("immich_server") {
|
|
return Err(anyhow::anyhow!("Immich already installed. Stop and remove it first."));
|
|
}
|
|
// Remove old single-container 'immich' if present (wrong port mapping, blocks immich_server)
|
|
if stdout.contains("immich\n") || stdout.lines().any(|l| l.trim() == "immich") {
|
|
let _ = tokio::process::Command::new("sudo")
|
|
.args(["podman", "stop", "immich"])
|
|
.output()
|
|
.await;
|
|
let _ = tokio::process::Command::new("sudo")
|
|
.args(["podman", "rm", "-f", "immich"])
|
|
.output()
|
|
.await;
|
|
}
|
|
|
|
let images = [
|
|
"ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0",
|
|
"docker.io/valkey/valkey:7-alpine",
|
|
"ghcr.io/immich-app/immich-server:release",
|
|
];
|
|
for img in &images {
|
|
let _ = tokio::process::Command::new("sudo")
|
|
.args(["podman", "pull", img])
|
|
.output()
|
|
.await;
|
|
}
|
|
|
|
let _ = tokio::process::Command::new("sudo")
|
|
.args(["mkdir", "-p", "/var/lib/archipelago/immich", "/var/lib/archipelago/immich-db"])
|
|
.output()
|
|
.await;
|
|
let _ = tokio::process::Command::new("sudo")
|
|
.args(["podman", "network", "create", "immich-net"])
|
|
.output()
|
|
.await;
|
|
|
|
// Postgres
|
|
let _ = tokio::process::Command::new("sudo")
|
|
.args([
|
|
"podman", "run", "-d", "--name", "immich_postgres", "--restart", "unless-stopped",
|
|
"--network", "immich-net",
|
|
"-v", "/var/lib/archipelago/immich-db:/var/lib/postgresql/data",
|
|
"-e", "POSTGRES_PASSWORD=immichpass", "-e", "POSTGRES_USER=postgres", "-e", "POSTGRES_DB=immich",
|
|
"ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0",
|
|
])
|
|
.output()
|
|
.await;
|
|
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
|
|
|
// Redis
|
|
let _ = tokio::process::Command::new("sudo")
|
|
.args([
|
|
"podman", "run", "-d", "--name", "immich_redis", "--restart", "unless-stopped",
|
|
"--network", "immich-net",
|
|
"docker.io/valkey/valkey:7-alpine",
|
|
])
|
|
.output()
|
|
.await;
|
|
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
|
|
|
// Server
|
|
let run = tokio::process::Command::new("sudo")
|
|
.args([
|
|
"podman", "run", "-d", "--name", "immich_server", "--restart", "unless-stopped",
|
|
"--network", "immich-net", "-p", "2283:2283",
|
|
"-v", "/var/lib/archipelago/immich:/usr/src/app/upload",
|
|
"-e", "DB_HOSTNAME=immich_postgres", "-e", "DB_USERNAME=postgres",
|
|
"-e", "DB_PASSWORD=immichpass", "-e", "DB_DATABASE_NAME=immich",
|
|
"-e", "REDIS_HOSTNAME=immich_redis", "-e", "UPLOAD_LOCATION=/usr/src/app/upload",
|
|
"ghcr.io/immich-app/immich-server:release",
|
|
])
|
|
.output()
|
|
.await
|
|
.context("Failed to start immich_server")?;
|
|
|
|
if !run.status.success() {
|
|
let stderr = String::from_utf8_lossy(&run.stderr);
|
|
return Err(anyhow::anyhow!("Failed to start Immich server: {}", stderr));
|
|
}
|
|
|
|
Ok(serde_json::json!({
|
|
"success": true,
|
|
"package_id": "immich",
|
|
"message": "Immich stack installed and started"
|
|
}))
|
|
}
|
|
|
|
/// Install Penpot stack (postgres + valkey + backend + exporter + frontend)
|
|
async fn install_penpot_stack(&self) -> Result<serde_json::Value> {
|
|
let check = tokio::process::Command::new("sudo")
|
|
.args(["podman", "ps", "-a", "--format", "{{.Names}}"])
|
|
.output()
|
|
.await
|
|
.context("Failed to list containers")?;
|
|
let stdout = String::from_utf8_lossy(&check.stdout);
|
|
if stdout.contains("penpot-frontend") {
|
|
return Err(anyhow::anyhow!("Penpot already installed. Stop and remove it first."));
|
|
}
|
|
|
|
let images = [
|
|
"docker.io/postgres:15",
|
|
"docker.io/valkey/valkey:8.1",
|
|
"docker.io/penpotapp/backend:latest",
|
|
"docker.io/penpotapp/exporter:latest",
|
|
"docker.io/penpotapp/frontend:latest",
|
|
];
|
|
for img in &images {
|
|
let _ = tokio::process::Command::new("sudo")
|
|
.args(["podman", "pull", img])
|
|
.output()
|
|
.await;
|
|
}
|
|
|
|
let _ = tokio::process::Command::new("sudo")
|
|
.args(["mkdir", "-p", "/var/lib/archipelago/penpot-assets"])
|
|
.output()
|
|
.await;
|
|
let _ = tokio::process::Command::new("sudo")
|
|
.args(["podman", "network", "create", "penpot-net"])
|
|
.output()
|
|
.await;
|
|
|
|
let secret = "archipelago-penpot-secret-key-change-in-production";
|
|
let host_ip = &self.config.host_ip;
|
|
|
|
// Postgres
|
|
let _ = tokio::process::Command::new("sudo")
|
|
.args([
|
|
"podman", "run", "-d", "--name", "penpot-postgres", "--restart", "unless-stopped",
|
|
"--network", "penpot-net",
|
|
"-v", "/var/lib/archipelago/penpot-postgres:/var/lib/postgresql/data",
|
|
"-e", "POSTGRES_DB=penpot", "-e", "POSTGRES_USER=penpot", "-e", "POSTGRES_PASSWORD=penpot",
|
|
"docker.io/postgres:15",
|
|
])
|
|
.output()
|
|
.await;
|
|
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
|
|
|
// Valkey
|
|
let _ = tokio::process::Command::new("sudo")
|
|
.args([
|
|
"podman", "run", "-d", "--name", "penpot-valkey", "--restart", "unless-stopped",
|
|
"--network", "penpot-net",
|
|
"-e", "VALKEY_EXTRA_FLAGS=--maxmemory 128mb --maxmemory-policy volatile-lfu",
|
|
"docker.io/valkey/valkey:8.1",
|
|
])
|
|
.output()
|
|
.await;
|
|
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
|
|
|
// Backend
|
|
let _ = tokio::process::Command::new("sudo")
|
|
.args([
|
|
"podman", "run", "-d", "--name", "penpot-backend", "--restart", "unless-stopped",
|
|
"--network", "penpot-net",
|
|
"-v", "/var/lib/archipelago/penpot-assets:/opt/data/assets",
|
|
"-e", &format!("PENPOT_PUBLIC_URI=http://{}:9001", host_ip),
|
|
"-e", &format!("PENPOT_SECRET_KEY={}", secret),
|
|
"-e", "PENPOT_DATABASE_URI=postgresql://penpot-postgres/penpot",
|
|
"-e", "PENPOT_DATABASE_USERNAME=penpot", "-e", "PENPOT_DATABASE_PASSWORD=penpot",
|
|
"-e", "PENPOT_REDIS_URI=redis://penpot-valkey/0",
|
|
"-e", "PENPOT_OBJECTS_STORAGE_BACKEND=fs",
|
|
"-e", "PENPOT_OBJECTS_STORAGE_FS_DIRECTORY=/opt/data/assets",
|
|
"-e", "PENPOT_FLAGS=disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies",
|
|
"docker.io/penpotapp/backend:latest",
|
|
])
|
|
.output()
|
|
.await;
|
|
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
|
|
|
// Exporter
|
|
let _ = tokio::process::Command::new("sudo")
|
|
.args([
|
|
"podman", "run", "-d", "--name", "penpot-exporter", "--restart", "unless-stopped",
|
|
"--network", "penpot-net",
|
|
"-e", &format!("PENPOT_SECRET_KEY={}", secret),
|
|
"-e", "PENPOT_PUBLIC_URI=http://penpot-frontend:8080",
|
|
"-e", "PENPOT_REDIS_URI=redis://penpot-valkey/0",
|
|
"docker.io/penpotapp/exporter:latest",
|
|
])
|
|
.output()
|
|
.await;
|
|
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
|
|
|
// Frontend
|
|
let run = tokio::process::Command::new("sudo")
|
|
.args([
|
|
"podman", "run", "-d", "--name", "penpot-frontend", "--restart", "unless-stopped",
|
|
"--network", "penpot-net", "-p", "9001:8080",
|
|
"-v", "/var/lib/archipelago/penpot-assets:/opt/data/assets",
|
|
"-e", &format!("PENPOT_PUBLIC_URI=http://{}:9001", host_ip),
|
|
"-e", "PENPOT_FLAGS=disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies",
|
|
"docker.io/penpotapp/frontend:latest",
|
|
])
|
|
.output()
|
|
.await
|
|
.context("Failed to start penpot-frontend")?;
|
|
|
|
if !run.status.success() {
|
|
let stderr = String::from_utf8_lossy(&run.stderr);
|
|
return Err(anyhow::anyhow!("Failed to start Penpot frontend: {}", stderr));
|
|
}
|
|
|
|
Ok(serde_json::json!({
|
|
"success": true,
|
|
"package_id": "penpot",
|
|
"message": "Penpot stack installed and started"
|
|
}))
|
|
}
|
|
|
|
// 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"))?;
|
|
|
|
let containers = get_containers_for_app(package_id).await?;
|
|
let to_start: Vec<String> = if containers.is_empty() {
|
|
vec![format!("archy-{}", package_id)]
|
|
} else {
|
|
let order: &[&str] = match package_id {
|
|
"mempool" | "mempool-web" => &["archy-mempool-db", "mysql-mempool", "mempool-electrs", "mempool-api", "archy-mempool-api", "archy-mempool-web", "mempool"],
|
|
"immich" => &["immich_postgres", "immich_redis", "immich_server"],
|
|
"penpot" | "penpot-frontend" => &["penpot-postgres", "penpot-valkey", "penpot-backend", "penpot-exporter", "penpot-frontend"],
|
|
_ => &["archy-mempool-db", "mysql-mempool", "mempool-electrs", "mempool-api", "archy-mempool-api", "archy-mempool-web", "mempool"],
|
|
};
|
|
let mut sorted = containers;
|
|
sorted.sort_by_key(|c| order.iter().position(|o| *o == c).unwrap_or(99));
|
|
sorted
|
|
};
|
|
|
|
for name in to_start {
|
|
let _ = tokio::process::Command::new("sudo")
|
|
.args(["podman", "start", &name])
|
|
.output()
|
|
.await;
|
|
}
|
|
|
|
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"))?;
|
|
|
|
let containers = get_containers_for_app(package_id).await?;
|
|
if containers.is_empty() {
|
|
// Fallback: try single container
|
|
let container_name = format!("archy-{}", package_id);
|
|
let _ = tokio::process::Command::new("sudo")
|
|
.args(["podman", "stop", &container_name])
|
|
.output()
|
|
.await;
|
|
return Ok(serde_json::Value::Null);
|
|
}
|
|
|
|
for name in containers {
|
|
let _ = tokio::process::Command::new("sudo")
|
|
.args(["podman", "stop", &name])
|
|
.output()
|
|
.await;
|
|
}
|
|
|
|
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"))?;
|
|
|
|
let containers = get_containers_for_app(package_id).await?;
|
|
if containers.is_empty() {
|
|
let container_name = format!("archy-{}", package_id);
|
|
let _ = tokio::process::Command::new("sudo")
|
|
.args(["podman", "restart", &container_name])
|
|
.output()
|
|
.await;
|
|
return Ok(serde_json::Value::Null);
|
|
}
|
|
|
|
for name in containers {
|
|
let _ = tokio::process::Command::new("sudo")
|
|
.args(["podman", "restart", &name])
|
|
.output()
|
|
.await;
|
|
}
|
|
|
|
Ok(serde_json::Value::Null)
|
|
}
|
|
|
|
/// Uninstall a package: stop and remove all related containers, clean data. No fragments left.
|
|
async fn handle_package_uninstall(
|
|
&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 preserve_data = params
|
|
.get("preserve_data")
|
|
.and_then(|v| v.as_bool())
|
|
.unwrap_or(false);
|
|
|
|
// Get all container names for this app (handles multi-container apps like mempool)
|
|
let containers_to_remove = get_containers_for_app(package_id).await?;
|
|
|
|
for name in &containers_to_remove {
|
|
let _ = tokio::process::Command::new("sudo")
|
|
.args(["podman", "stop", name])
|
|
.output()
|
|
.await;
|
|
let _ = tokio::process::Command::new("sudo")
|
|
.args(["podman", "rm", "-f", name])
|
|
.output()
|
|
.await;
|
|
}
|
|
|
|
// Release port allocation
|
|
if let Ok(mut allocator) = self.port_allocator.lock() {
|
|
let _ = allocator.release(package_id);
|
|
}
|
|
|
|
// Clean data directories unless preserve_data
|
|
if !preserve_data {
|
|
let data_dirs = get_data_dirs_for_app(package_id);
|
|
for dir in &data_dirs {
|
|
let _ = tokio::process::Command::new("sudo")
|
|
.args(["rm", "-rf", dir])
|
|
.output()
|
|
.await;
|
|
}
|
|
}
|
|
|
|
Ok(serde_json::json!({ "status": "uninstalled" }))
|
|
}
|
|
|
|
/// 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 }))
|
|
}
|
|
|
|
async fn handle_node_add_peer(
|
|
&self,
|
|
params: Option<serde_json::Value>,
|
|
) -> Result<serde_json::Value> {
|
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
|
let onion = params
|
|
.get("onion")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| anyhow::anyhow!("Missing onion"))?;
|
|
let pubkey = params
|
|
.get("pubkey")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| anyhow::anyhow!("Missing pubkey"))?;
|
|
let name = params.get("name").and_then(|v| v.as_str()).map(String::from);
|
|
|
|
let peer = KnownPeer {
|
|
onion: onion.to_string(),
|
|
pubkey: pubkey.to_string(),
|
|
name,
|
|
added_at: Some(chrono::Utc::now().to_rfc3339()),
|
|
};
|
|
let peers = peers::add_peer(&self.config.data_dir, peer).await?;
|
|
Ok(serde_json::json!({ "peers": peers }))
|
|
}
|
|
|
|
async fn handle_node_list_peers(&self) -> Result<serde_json::Value> {
|
|
let peers = peers::load_peers(&self.config.data_dir).await?;
|
|
Ok(serde_json::json!({ "peers": peers }))
|
|
}
|
|
|
|
async fn handle_node_remove_peer(
|
|
&self,
|
|
params: Option<serde_json::Value>,
|
|
) -> Result<serde_json::Value> {
|
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
|
let pubkey = params
|
|
.get("pubkey")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| anyhow::anyhow!("Missing pubkey"))?;
|
|
let peers = peers::remove_peer(&self.config.data_dir, pubkey).await?;
|
|
Ok(serde_json::json!({ "peers": peers }))
|
|
}
|
|
|
|
async fn handle_node_send_message(
|
|
&self,
|
|
params: Option<serde_json::Value>,
|
|
) -> Result<serde_json::Value> {
|
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
|
let onion = params
|
|
.get("onion")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| anyhow::anyhow!("Missing onion"))?;
|
|
let message = params
|
|
.get("message")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| anyhow::anyhow!("Missing message"))?;
|
|
let (data, _) = self.state_manager.get_snapshot().await;
|
|
let pubkey = data.server_info.pubkey.clone();
|
|
node_message::send_to_peer(onion, &pubkey, message).await?;
|
|
Ok(serde_json::json!({ "ok": true, "sent_to": onion }))
|
|
}
|
|
|
|
async fn handle_node_check_peer(
|
|
&self,
|
|
params: Option<serde_json::Value>,
|
|
) -> Result<serde_json::Value> {
|
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
|
let onion = params
|
|
.get("onion")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| anyhow::anyhow!("Missing onion"))?;
|
|
let reachable = node_message::check_peer_reachable(onion).await.unwrap_or(false);
|
|
Ok(serde_json::json!({ "onion": onion, "reachable": reachable }))
|
|
}
|
|
|
|
async fn handle_node_messages_received(&self) -> Result<serde_json::Value> {
|
|
let messages = node_message::get_received();
|
|
Ok(serde_json::json!({ "messages": messages }))
|
|
}
|
|
|
|
async fn handle_node_nostr_discover(&self) -> Result<serde_json::Value> {
|
|
let identity_dir = self.config.data_dir.join("identity");
|
|
let nodes = nostr_discovery::discover_archipelago_nodes(
|
|
&identity_dir,
|
|
&self.config.nostr_relays,
|
|
self.config.nostr_tor_proxy.as_deref(),
|
|
)
|
|
.await?;
|
|
Ok(serde_json::json!({ "nodes": nodes }))
|
|
}
|
|
}
|
|
|
|
/// Get all container names for an app (handles multi-container apps like mempool)
|
|
async fn get_containers_for_app(package_id: &str) -> Result<Vec<String>> {
|
|
let output = tokio::process::Command::new("sudo")
|
|
.args(["podman", "ps", "-a", "--format", "{{.Names}}"])
|
|
.output()
|
|
.await
|
|
.context("Failed to list containers")?;
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let all: Vec<&str> = stdout.lines().filter(|s| !s.is_empty()).collect();
|
|
|
|
// Map app id to container name patterns (support both archy-* and bare names)
|
|
let patterns: Vec<String> = match package_id {
|
|
"mempool" | "mempool-web" => {
|
|
vec![
|
|
"mempool-electrs".into(),
|
|
"mempool-api".into(),
|
|
"archy-mempool-api".into(),
|
|
"archy-mempool-web".into(),
|
|
"mempool".into(),
|
|
"archy-mempool-db".into(),
|
|
"mysql-mempool".into(),
|
|
]
|
|
}
|
|
"fedimint" => vec!["fedimint".into(), "fedimint-ui".into(), "archy-fedimint".into()],
|
|
"immich" => vec![
|
|
"immich_postgres".into(),
|
|
"immich_redis".into(),
|
|
"immich_server".into(),
|
|
],
|
|
"penpot" | "penpot-frontend" => vec![
|
|
"penpot-postgres".into(),
|
|
"penpot-valkey".into(),
|
|
"penpot-backend".into(),
|
|
"penpot-exporter".into(),
|
|
"penpot-frontend".into(),
|
|
],
|
|
_ => vec![package_id.to_string(), format!("archy-{}", package_id)],
|
|
};
|
|
|
|
let mut result = Vec::new();
|
|
for name in all {
|
|
for pat in &patterns {
|
|
if name == pat {
|
|
result.push(name.to_string());
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
Ok(result)
|
|
}
|
|
|
|
/// Get data directories to clean for an app
|
|
fn get_data_dirs_for_app(package_id: &str) -> Vec<String> {
|
|
let base = "/var/lib/archipelago";
|
|
match package_id {
|
|
"mempool" | "mempool-web" => vec![
|
|
format!("{}/mempool", base),
|
|
format!("{}/mysql-mempool", base),
|
|
format!("{}/mempool-electrs", base),
|
|
],
|
|
"fedimint" => vec![format!("{}/fedimint", base)],
|
|
"immich" => vec![
|
|
format!("{}/immich", base),
|
|
format!("{}/immich-db", base),
|
|
],
|
|
"penpot" | "penpot-frontend" => vec![
|
|
format!("{}/penpot-assets", base),
|
|
format!("{}/penpot-postgres", base),
|
|
],
|
|
_ => vec![format!("{}/{}", base, package_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, custom_args)
|
|
/// custom_command: shell override (e.g. "sh -c '...'"); custom_args: extra args for entrypoint
|
|
/// Uses port_allocator for apps with web UIs to avoid conflicts (e.g. Nextcloud vs LND UI).
|
|
/// TODO: Load from manifest.yml files in apps/ directory
|
|
fn get_app_config(
|
|
app_id: &str,
|
|
host_ip: &str,
|
|
allocator: &mut PortAllocator,
|
|
) -> (Vec<String>, Vec<String>, Vec<String>, Option<String>, Option<Vec<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,
|
|
None,
|
|
),
|
|
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => (
|
|
vec!["8332:8332".to_string(), "8333:8333".to_string()],
|
|
vec!["/var/lib/archipelago/bitcoin:/home/bitcoin/.bitcoin".to_string()],
|
|
vec![],
|
|
None,
|
|
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,
|
|
None,
|
|
),
|
|
"btcpay-server" | "btcpayserver" => (
|
|
vec!["23000:49392".to_string()],
|
|
vec!["/var/lib/archipelago/btcpay:/datadir".to_string()],
|
|
vec![
|
|
"ASPNETCORE_URLS=http://0.0.0.0:49392".to_string(),
|
|
"BTCPAY_PROTOCOL=http".to_string(),
|
|
format!("BTCPAY_HOST={}:23000", host_ip),
|
|
"BTCPAY_CHAINS=btc".to_string(),
|
|
format!("BTCPAY_BTCRPCURL=http://{}:8332", host_ip),
|
|
"BTCPAY_BTCRPCUSER=archipelago".to_string(),
|
|
"BTCPAY_BTCRPCPASSWORD=archipelago123".to_string(),
|
|
"BTCPAY_POSTGRES=User ID=btcpay;Password=btcpaypass;Host=archy-btcpay-db;Port=5432;Database=btcpay;Include Error Detail=true".to_string(),
|
|
],
|
|
None,
|
|
None,
|
|
),
|
|
"mempool" | "mempool-web" => (
|
|
vec!["4080:8080".to_string()],
|
|
vec![],
|
|
// Frontend proxies to backend at host:8999 (deploy script uses mempool-api when on archy-net)
|
|
vec![format!("BACKEND_MAINNET_HTTP_HOST={}", host_ip)],
|
|
None,
|
|
None,
|
|
),
|
|
"mempool-api" => (
|
|
vec!["8999:8999".to_string()],
|
|
vec!["/var/lib/archipelago/mempool:/data".to_string()],
|
|
vec![
|
|
"MEMPOOL_BACKEND=electrum".to_string(),
|
|
"ELECTRUM_HOST=mempool-electrs".to_string(),
|
|
"ELECTRUM_PORT=50001".to_string(),
|
|
"ELECTRUM_TLS_ENABLED=false".to_string(),
|
|
format!("CORE_RPC_HOST={}", host_ip),
|
|
"CORE_RPC_PORT=8332".to_string(),
|
|
"CORE_RPC_USERNAME=archipelago".to_string(),
|
|
"CORE_RPC_PASSWORD=archipelago123".to_string(),
|
|
"DATABASE_ENABLED=true".to_string(),
|
|
"DATABASE_HOST=archy-mempool-db".to_string(),
|
|
"DATABASE_DATABASE=mempool".to_string(),
|
|
"DATABASE_USERNAME=mempool".to_string(),
|
|
"DATABASE_PASSWORD=mempoolpass".to_string(),
|
|
],
|
|
None,
|
|
None,
|
|
),
|
|
"mempool-electrs" => (
|
|
vec!["50001:50001".to_string()],
|
|
vec!["/var/lib/archipelago/mempool-electrs:/data".to_string()],
|
|
vec![],
|
|
None,
|
|
Some(vec![
|
|
"--daemon-rpc-addr".to_string(),
|
|
format!("{}:8332", host_ip),
|
|
"--cookie".to_string(),
|
|
"archipelago:archipelago123".to_string(),
|
|
"--jsonrpc-import".to_string(),
|
|
"--electrum-rpc-addr".to_string(),
|
|
"0.0.0.0:50001".to_string(),
|
|
"--db-dir".to_string(),
|
|
"/data".to_string(),
|
|
"--lightmode".to_string(),
|
|
]),
|
|
),
|
|
"mysql-mempool" => (
|
|
vec![],
|
|
vec!["/var/lib/archipelago/mysql-mempool:/var/lib/mysql".to_string()],
|
|
vec![
|
|
"MYSQL_DATABASE=mempool".to_string(),
|
|
"MYSQL_USER=mempool".to_string(),
|
|
"MYSQL_PASSWORD=mempoolpass".to_string(),
|
|
"MYSQL_ROOT_PASSWORD=rootpass".to_string(),
|
|
],
|
|
None,
|
|
None,
|
|
),
|
|
"grafana" => (
|
|
vec!["3000:3000".to_string()],
|
|
vec!["/var/lib/archipelago/grafana:/var/lib/grafana".to_string()],
|
|
vec!["GF_PATHS_DATA=/var/lib/grafana".to_string(), "GF_USERS_ALLOW_SIGN_UP=false".to_string()],
|
|
None,
|
|
None,
|
|
),
|
|
"searxng" => (
|
|
vec!["8888:8080".to_string()],
|
|
vec![],
|
|
vec![],
|
|
None,
|
|
None,
|
|
),
|
|
"ollama" => (
|
|
vec!["11434:11434".to_string()],
|
|
vec!["/var/lib/archipelago/ollama:/root/.ollama".to_string()],
|
|
vec![],
|
|
None,
|
|
None,
|
|
),
|
|
"onlyoffice" | "onlyoffice-documentserver" => (
|
|
vec!["9980:80".to_string()],
|
|
vec![],
|
|
vec![],
|
|
None,
|
|
None,
|
|
),
|
|
"penpot" | "penpot-frontend" => (
|
|
vec!["9001:80".to_string()],
|
|
vec![],
|
|
vec![],
|
|
None,
|
|
None,
|
|
),
|
|
"nextcloud" => {
|
|
let host_port = allocator
|
|
.allocate_or_get(app_id, 8085, 80)
|
|
.unwrap_or(8085);
|
|
(
|
|
vec![format!("{}:80", host_port)],
|
|
vec!["/var/lib/archipelago/nextcloud:/var/www/html".to_string()],
|
|
vec![],
|
|
None,
|
|
None,
|
|
)
|
|
}
|
|
"vaultwarden" => {
|
|
let host_port = allocator
|
|
.allocate_or_get(app_id, 8082, 80)
|
|
.unwrap_or(8082);
|
|
(
|
|
vec![format!("{}:80", host_port)],
|
|
vec!["/var/lib/archipelago/vaultwarden:/data".to_string()],
|
|
vec![],
|
|
None,
|
|
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,
|
|
None,
|
|
),
|
|
"photoprism" => (
|
|
vec!["2342:2342".to_string()],
|
|
vec!["/var/lib/archipelago/photoprism:/photoprism/storage".to_string()],
|
|
vec!["PHOTOPRISM_ADMIN_PASSWORD=archipelago".to_string(), "PHOTOPRISM_DEFAULT_LOCALE=en".to_string()],
|
|
None,
|
|
None,
|
|
),
|
|
"immich" => (
|
|
vec!["2283:2283".to_string()],
|
|
vec!["/var/lib/archipelago/immich:/usr/src/app/upload".to_string()],
|
|
vec![
|
|
"DB_HOSTNAME=immich_postgres".to_string(),
|
|
"DB_USERNAME=postgres".to_string(),
|
|
"DB_PASSWORD=immichpass".to_string(),
|
|
"DB_DATABASE_NAME=immich".to_string(),
|
|
"REDIS_HOSTNAME=immich_redis".to_string(),
|
|
"UPLOAD_LOCATION=/usr/src/app/upload".to_string(),
|
|
],
|
|
None,
|
|
None,
|
|
),
|
|
"filebrowser" => {
|
|
let host_port = allocator
|
|
.allocate_or_get(app_id, 8083, 80)
|
|
.unwrap_or(8083);
|
|
(
|
|
vec![format!("{}:80", host_port)],
|
|
vec!["/var/lib/archipelago/filebrowser:/srv".to_string()],
|
|
vec![],
|
|
None,
|
|
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,
|
|
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,
|
|
None,
|
|
),
|
|
"uptime-kuma" => (
|
|
vec!["3001:3001".to_string()],
|
|
vec!["/var/lib/archipelago/uptime-kuma:/app/data".to_string()],
|
|
vec!["TZ=UTC".to_string()],
|
|
None,
|
|
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()),
|
|
None,
|
|
),
|
|
"fedimint" => (
|
|
vec![
|
|
"8173:8173".to_string(), // P2P
|
|
"8174:8174".to_string(), // API (JSON-RPC)
|
|
"8175:8175".to_string(), // Built-in Guardian UI
|
|
],
|
|
vec!["/var/lib/archipelago/fedimint:/data".to_string()],
|
|
vec![
|
|
"FM_DATA_DIR=/data".to_string(),
|
|
"FM_BITCOIND_USERNAME=archipelago".to_string(),
|
|
"FM_BITCOIND_PASSWORD=archipelago123".to_string(),
|
|
"FM_BITCOIN_NETWORK=bitcoin".to_string(),
|
|
"FM_BIND_P2P=0.0.0.0:8173".to_string(),
|
|
"FM_BIND_API=0.0.0.0:8174".to_string(),
|
|
"FM_BIND_UI=0.0.0.0:8175".to_string(),
|
|
format!("FM_P2P_URL=fedimint://{}:8173", host_ip),
|
|
format!("FM_API_URL=ws://{}:8174", host_ip),
|
|
format!("FM_BITCOIND_URL=http://{}:8332", host_ip),
|
|
],
|
|
None,
|
|
None,
|
|
),
|
|
_ => (vec![], vec![], vec![], None, None), // No default config, user must configure manually
|
|
}
|
|
}
|