Enhance README and RPC for package management
- Added instructions to README.md for building an ISO from source and flashing it to USB. - Introduced a new RPC method for package installation, including security checks and container management. - Updated Docker and Podman integration in build scripts to support both container runtimes. - Enhanced Nginx configuration for improved timeout settings and WebSocket support. - Added new app metadata for additional applications in the Docker package scanner.
This commit is contained in:
@@ -85,6 +85,7 @@ impl RpcHandler {
|
||||
"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,
|
||||
@@ -433,6 +434,158 @@ impl RpcHandler {
|
||||
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,
|
||||
@@ -676,3 +829,175 @@ impl RpcHandler {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,11 @@ impl DockerPackageScanner {
|
||||
let lan_address = if let Some(ui_address) = ui_containers.get(&app_id) {
|
||||
debug!("Using UI container address for {}: {}", app_id, ui_address);
|
||||
Some(ui_address.clone())
|
||||
} else if app_id == "tailscale" {
|
||||
// Tailscale uses host networking, so no port mappings
|
||||
// But web UI is always on port 8240
|
||||
debug!("Tailscale detected, using port 8240");
|
||||
Some("http://localhost:8240".to_string())
|
||||
} else {
|
||||
// Extract port from the main container
|
||||
extract_lan_address(&container.ports)
|
||||
@@ -187,7 +192,7 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
|
||||
icon: "/assets/img/app-icons/bitcoin-knots.webp".to_string(),
|
||||
repo: "https://github.com/bitcoinknots/bitcoin".to_string(),
|
||||
},
|
||||
"btcpay" | "btcpay-server" => AppMetadata {
|
||||
"btcpay" | "btcpay-server" | "btcpayserver" => AppMetadata {
|
||||
title: "BTCPay Server".to_string(),
|
||||
description: "Self-hosted Bitcoin payment processor".to_string(),
|
||||
icon: "/assets/img/app-icons/btcpay-server.png".to_string(),
|
||||
@@ -250,7 +255,7 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
|
||||
"onlyoffice" | "onlyoffice-documentserver" => AppMetadata {
|
||||
title: "OnlyOffice".to_string(),
|
||||
description: "Office suite and document collaboration".to_string(),
|
||||
icon: "/assets/img/onlyoffice.webp".to_string(),
|
||||
icon: "/assets/img/app-icons/onlyoffice.webp".to_string(),
|
||||
repo: "https://github.com/ONLYOFFICE/DocumentServer".to_string(),
|
||||
},
|
||||
"penpot" | "penpot-frontend" => AppMetadata {
|
||||
@@ -262,9 +267,63 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
|
||||
"nextcloud" => AppMetadata {
|
||||
title: "Nextcloud".to_string(),
|
||||
description: "Self-hosted cloud storage and file management".to_string(),
|
||||
icon: "/assets/img/app-icons/nextcloud.png".to_string(),
|
||||
icon: "/assets/img/app-icons/nextcloud.webp".to_string(),
|
||||
repo: "https://github.com/nextcloud/server".to_string(),
|
||||
},
|
||||
"vaultwarden" => AppMetadata {
|
||||
title: "Vaultwarden".to_string(),
|
||||
description: "Self-hosted password manager (Bitwarden compatible)".to_string(),
|
||||
icon: "/assets/img/favico.png".to_string(), // Placeholder, no icon available
|
||||
repo: "https://github.com/dani-garcia/vaultwarden".to_string(),
|
||||
},
|
||||
"jellyfin" => AppMetadata {
|
||||
title: "Jellyfin".to_string(),
|
||||
description: "Free media server system".to_string(),
|
||||
icon: "/assets/img/favico.png".to_string(), // Placeholder, no icon available
|
||||
repo: "https://github.com/jellyfin/jellyfin".to_string(),
|
||||
},
|
||||
"photoprism" => AppMetadata {
|
||||
title: "PhotoPrism".to_string(),
|
||||
description: "AI-powered photo management".to_string(),
|
||||
icon: "/assets/img/favico.png".to_string(), // Placeholder, no icon available
|
||||
repo: "https://github.com/photoprism/photoprism".to_string(),
|
||||
},
|
||||
"immich" => AppMetadata {
|
||||
title: "Immich".to_string(),
|
||||
description: "High-performance self-hosted photo and video backup".to_string(),
|
||||
icon: "/assets/img/favico.png".to_string(), // Placeholder, no icon available
|
||||
repo: "https://github.com/immich-app/immich".to_string(),
|
||||
},
|
||||
"filebrowser" => AppMetadata {
|
||||
title: "File Browser".to_string(),
|
||||
description: "Web-based file manager".to_string(),
|
||||
icon: "/assets/img/app-icons/file-browser.webp".to_string(),
|
||||
repo: "https://github.com/filebrowser/filebrowser".to_string(),
|
||||
},
|
||||
"nginx-proxy-manager" => AppMetadata {
|
||||
title: "Nginx Proxy Manager".to_string(),
|
||||
description: "Easy proxy management with SSL".to_string(),
|
||||
icon: "/assets/img/app-icons/nginx.svg".to_string(),
|
||||
repo: "https://github.com/NginxProxyManager/nginx-proxy-manager".to_string(),
|
||||
},
|
||||
"portainer" => AppMetadata {
|
||||
title: "Portainer".to_string(),
|
||||
description: "Container management UI".to_string(),
|
||||
icon: "/assets/img/app-icons/portainer.webp".to_string(),
|
||||
repo: "https://github.com/portainer/portainer".to_string(),
|
||||
},
|
||||
"uptime-kuma" => AppMetadata {
|
||||
title: "Uptime Kuma".to_string(),
|
||||
description: "Self-hosted monitoring tool".to_string(),
|
||||
icon: "/assets/img/app-icons/uptime-kuma.webp".to_string(),
|
||||
repo: "https://github.com/louislam/uptime-kuma".to_string(),
|
||||
},
|
||||
"tailscale" => AppMetadata {
|
||||
title: "Tailscale".to_string(),
|
||||
description: "Zero-config VPN for secure remote access".to_string(),
|
||||
icon: "/assets/img/app-icons/tailscale.webp".to_string(),
|
||||
repo: "https://github.com/tailscale/tailscale".to_string(),
|
||||
},
|
||||
_ => AppMetadata {
|
||||
title: app_id.to_string(),
|
||||
description: format!("{} application", app_id),
|
||||
|
||||
Reference in New Issue
Block a user