2026-01-27 23:21:26 +00:00
|
|
|
// Docker Package Scanner
|
|
|
|
|
// Scans docker-compose containers and converts them to package data
|
|
|
|
|
|
|
|
|
|
use anyhow::Result;
|
|
|
|
|
use archipelago_container::{ContainerRuntime as ContainerRuntimeTrait, ContainerState};
|
|
|
|
|
use std::collections::HashMap;
|
|
|
|
|
use std::sync::Arc;
|
|
|
|
|
use tracing::{debug, info};
|
|
|
|
|
|
|
|
|
|
use crate::data_model::{
|
|
|
|
|
Description, InstalledPackageDataEntry, InterfaceAddress, Interfaces, MainInterface, Manifest,
|
|
|
|
|
PackageDataEntry, PackageState, ServiceStatus, StaticFiles,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
pub struct DockerPackageScanner {
|
|
|
|
|
runtime: Arc<dyn ContainerRuntimeTrait>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl DockerPackageScanner {
|
|
|
|
|
pub fn new(runtime: Arc<dyn ContainerRuntimeTrait>) -> Self {
|
|
|
|
|
Self { runtime }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Scan Docker containers and convert to package data
|
|
|
|
|
pub async fn scan_containers(&self) -> Result<HashMap<String, PackageDataEntry>> {
|
2026-02-01 13:24:03 +00:00
|
|
|
let containers = match self.runtime.list_containers().await {
|
|
|
|
|
Ok(c) => c,
|
|
|
|
|
Err(e) => {
|
|
|
|
|
debug!("Failed to list containers: {}", e);
|
|
|
|
|
return Ok(HashMap::new());
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-01-27 23:21:26 +00:00
|
|
|
|
|
|
|
|
debug!("Found {} containers", containers.len());
|
|
|
|
|
|
|
|
|
|
let mut packages = HashMap::new();
|
|
|
|
|
|
2026-01-27 23:57:29 +00:00
|
|
|
// Backend services that should not appear as apps
|
|
|
|
|
let excluded_services = [
|
|
|
|
|
"btcpay-db",
|
|
|
|
|
"mempool-db",
|
|
|
|
|
"mempool-api",
|
2026-01-28 00:47:00 +00:00
|
|
|
"penpot-postgres",
|
2026-01-27 23:57:29 +00:00
|
|
|
"penpot-backend",
|
2026-01-28 00:47:00 +00:00
|
|
|
"penpot-exporter",
|
|
|
|
|
"penpot-valkey",
|
|
|
|
|
"penpot-mailcatch",
|
|
|
|
|
"endurain-db",
|
|
|
|
|
"nextcloud-db",
|
2026-01-27 23:57:29 +00:00
|
|
|
];
|
|
|
|
|
|
2026-02-01 13:24:03 +00:00
|
|
|
// First pass: collect UI containers
|
|
|
|
|
let mut ui_containers: HashMap<String, String> = HashMap::new();
|
|
|
|
|
for container in &containers {
|
|
|
|
|
if container.name.ends_with("-ui") {
|
|
|
|
|
// Map bitcoin-ui -> bitcoin, lnd-ui -> lnd
|
|
|
|
|
let parent_app = container.name.strip_suffix("-ui").unwrap_or(&container.name);
|
|
|
|
|
if !container.ports.is_empty() {
|
|
|
|
|
if let Some(ui_address) = extract_lan_address(&container.ports) {
|
|
|
|
|
ui_containers.insert(parent_app.to_string(), ui_address);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-27 23:21:26 +00:00
|
|
|
}
|
2026-02-01 13:24:03 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
debug!("Found {} UI containers", ui_containers.len());
|
|
|
|
|
|
|
|
|
|
for container in containers {
|
|
|
|
|
// Extract app ID from container name
|
|
|
|
|
// Support both archy-* containers (docker-compose) and plain names (manual)
|
|
|
|
|
let app_id = if container.name.starts_with("archy-") {
|
|
|
|
|
container.name.strip_prefix("archy-")
|
|
|
|
|
.unwrap_or(&container.name)
|
|
|
|
|
.to_string()
|
|
|
|
|
} else {
|
|
|
|
|
// Use the container name as-is for manually started containers
|
|
|
|
|
container.name.clone()
|
|
|
|
|
};
|
2026-01-27 23:21:26 +00:00
|
|
|
|
2026-01-27 23:57:29 +00:00
|
|
|
// Skip backend services (databases, APIs, etc.)
|
|
|
|
|
if excluded_services.contains(&app_id.as_str()) {
|
|
|
|
|
debug!("Skipping backend service: {}", app_id);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 13:24:03 +00:00
|
|
|
// Skip UI containers (they're merged with their parent apps)
|
|
|
|
|
if app_id.ends_with("-ui") {
|
|
|
|
|
debug!("Skipping UI container: {}", app_id);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-27 23:21:26 +00:00
|
|
|
// Get metadata for this app
|
|
|
|
|
let metadata = get_app_metadata(&app_id);
|
|
|
|
|
|
2026-02-01 13:24:03 +00:00
|
|
|
// Check if this app has a separate UI container
|
|
|
|
|
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 {
|
|
|
|
|
// Extract port from the main container
|
|
|
|
|
extract_lan_address(&container.ports)
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
debug!("Container {}: ports={:?}, lan_address={:?}", app_id, container.ports, lan_address);
|
2026-01-27 23:21:26 +00:00
|
|
|
|
|
|
|
|
// Convert container state to package/service state
|
|
|
|
|
let (package_state, service_status) = convert_state(&container.state);
|
|
|
|
|
|
|
|
|
|
let package = PackageDataEntry {
|
|
|
|
|
state: package_state.clone(),
|
|
|
|
|
static_files: StaticFiles {
|
|
|
|
|
license: "MIT".to_string(),
|
|
|
|
|
instructions: metadata.description.clone(),
|
|
|
|
|
icon: metadata.icon.clone(),
|
|
|
|
|
},
|
|
|
|
|
manifest: Manifest {
|
|
|
|
|
id: app_id.clone(),
|
|
|
|
|
title: metadata.title.clone(),
|
|
|
|
|
version: "1.0.0".to_string(),
|
|
|
|
|
description: Description {
|
|
|
|
|
short: metadata.description.clone(),
|
|
|
|
|
long: metadata.description.clone(),
|
|
|
|
|
},
|
|
|
|
|
release_notes: "Docker container".to_string(),
|
|
|
|
|
license: "MIT".to_string(),
|
|
|
|
|
wrapper_repo: metadata.repo.clone(),
|
|
|
|
|
upstream_repo: metadata.repo.clone(),
|
|
|
|
|
support_site: metadata.repo.clone(),
|
|
|
|
|
marketing_site: metadata.repo.clone(),
|
|
|
|
|
donation_url: None,
|
|
|
|
|
author: Some("Archipelago".to_string()),
|
|
|
|
|
website: lan_address.clone(),
|
|
|
|
|
interfaces: if lan_address.is_some() {
|
|
|
|
|
Some(Interfaces {
|
|
|
|
|
main: Some(MainInterface {
|
|
|
|
|
ui: Some("true".to_string()),
|
|
|
|
|
tor_config: None,
|
|
|
|
|
lan_config: None,
|
|
|
|
|
}),
|
|
|
|
|
})
|
|
|
|
|
} else {
|
|
|
|
|
None
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
installed: Some(InstalledPackageDataEntry {
|
|
|
|
|
current_dependents: HashMap::new(),
|
|
|
|
|
current_dependencies: HashMap::new(),
|
|
|
|
|
last_backup: None,
|
|
|
|
|
interface_addresses: if let Some(addr) = lan_address {
|
|
|
|
|
let mut addresses = HashMap::new();
|
|
|
|
|
addresses.insert(
|
|
|
|
|
"main".to_string(),
|
|
|
|
|
InterfaceAddress {
|
|
|
|
|
tor_address: format!("{}.onion", app_id),
|
|
|
|
|
lan_address: Some(addr),
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
addresses
|
|
|
|
|
} else {
|
|
|
|
|
HashMap::new()
|
|
|
|
|
},
|
|
|
|
|
status: service_status,
|
|
|
|
|
}),
|
|
|
|
|
install_progress: None,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
packages.insert(app_id.clone(), package);
|
|
|
|
|
info!("Detected container: {} ({})", metadata.title, package_state_str(&package_state));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(packages)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct AppMetadata {
|
|
|
|
|
title: String,
|
|
|
|
|
description: String,
|
|
|
|
|
icon: String,
|
|
|
|
|
repo: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn get_app_metadata(app_id: &str) -> AppMetadata {
|
|
|
|
|
match app_id {
|
2026-02-01 13:24:03 +00:00
|
|
|
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => AppMetadata {
|
|
|
|
|
title: "Bitcoin Knots".to_string(),
|
2026-01-27 23:21:26 +00:00
|
|
|
description: "Full Bitcoin node implementation".to_string(),
|
2026-02-01 13:24:03 +00:00
|
|
|
icon: "/assets/img/app-icons/bitcoin-knots.webp".to_string(),
|
|
|
|
|
repo: "https://github.com/bitcoinknots/bitcoin".to_string(),
|
2026-01-27 23:21:26 +00:00
|
|
|
},
|
|
|
|
|
"btcpay" | "btcpay-server" => AppMetadata {
|
|
|
|
|
title: "BTCPay Server".to_string(),
|
|
|
|
|
description: "Self-hosted Bitcoin payment processor".to_string(),
|
|
|
|
|
icon: "/assets/img/app-icons/btcpay-server.png".to_string(),
|
|
|
|
|
repo: "https://github.com/btcpayserver/btcpayserver".to_string(),
|
|
|
|
|
},
|
|
|
|
|
"homeassistant" => AppMetadata {
|
|
|
|
|
title: "Home Assistant".to_string(),
|
|
|
|
|
description: "Open source home automation platform".to_string(),
|
|
|
|
|
icon: "/assets/img/app-icons/homeassistant.png".to_string(),
|
|
|
|
|
repo: "https://github.com/home-assistant/core".to_string(),
|
|
|
|
|
},
|
|
|
|
|
"grafana" => AppMetadata {
|
|
|
|
|
title: "Grafana".to_string(),
|
|
|
|
|
description: "Analytics and monitoring platform".to_string(),
|
|
|
|
|
icon: "/assets/img/grafana.png".to_string(),
|
|
|
|
|
repo: "https://github.com/grafana/grafana".to_string(),
|
|
|
|
|
},
|
|
|
|
|
"endurain" => AppMetadata {
|
|
|
|
|
title: "Endurain".to_string(),
|
2026-01-28 00:47:00 +00:00
|
|
|
description: "Self-hosted fitness tracking platform".to_string(),
|
|
|
|
|
icon: "/assets/img/app-icons/endurain.png".to_string(),
|
|
|
|
|
repo: "https://github.com/joaovitoriasilva/endurain".to_string(),
|
2026-01-27 23:21:26 +00:00
|
|
|
},
|
|
|
|
|
"fedimint" => AppMetadata {
|
|
|
|
|
title: "Fedimint".to_string(),
|
|
|
|
|
description: "Federated Bitcoin mint".to_string(),
|
|
|
|
|
icon: "/assets/img/icon-fedimint.jpeg".to_string(),
|
|
|
|
|
repo: "https://github.com/fedimint/fedimint".to_string(),
|
|
|
|
|
},
|
|
|
|
|
"morphos" | "morphos-server" => AppMetadata {
|
2026-01-28 00:47:00 +00:00
|
|
|
title: "Morphos".to_string(),
|
|
|
|
|
description: "Self-hosted file converter".to_string(),
|
2026-01-27 23:21:26 +00:00
|
|
|
icon: "/assets/img/morphos.png".to_string(),
|
2026-01-28 00:47:00 +00:00
|
|
|
repo: "https://github.com/danvergara/morphos".to_string(),
|
2026-01-27 23:21:26 +00:00
|
|
|
},
|
|
|
|
|
"lnd" | "lightning-stack" => AppMetadata {
|
2026-01-28 00:47:00 +00:00
|
|
|
title: "LND".to_string(),
|
|
|
|
|
description: "Lightning Network Daemon".to_string(),
|
|
|
|
|
icon: "/assets/img/app-icons/lnd.svg".to_string(),
|
2026-01-27 23:21:26 +00:00
|
|
|
repo: "https://github.com/lightningnetwork/lnd".to_string(),
|
|
|
|
|
},
|
|
|
|
|
"mempool" | "mempool-web" => AppMetadata {
|
|
|
|
|
title: "Mempool".to_string(),
|
|
|
|
|
description: "Bitcoin blockchain explorer".to_string(),
|
2026-01-28 00:47:00 +00:00
|
|
|
icon: "/assets/img/app-icons/mempool.webp".to_string(),
|
2026-01-27 23:21:26 +00:00
|
|
|
repo: "https://github.com/mempool/mempool".to_string(),
|
|
|
|
|
},
|
|
|
|
|
"ollama" => AppMetadata {
|
|
|
|
|
title: "Ollama".to_string(),
|
|
|
|
|
description: "Run large language models locally".to_string(),
|
|
|
|
|
icon: "/assets/img/ollama.webp".to_string(),
|
|
|
|
|
repo: "https://github.com/ollama/ollama".to_string(),
|
|
|
|
|
},
|
|
|
|
|
"searxng" => AppMetadata {
|
|
|
|
|
title: "SearXNG".to_string(),
|
|
|
|
|
description: "Privacy-respecting metasearch engine".to_string(),
|
|
|
|
|
icon: "/assets/img/app-icons/searxng.png".to_string(),
|
|
|
|
|
repo: "https://github.com/searxng/searxng".to_string(),
|
|
|
|
|
},
|
|
|
|
|
"onlyoffice" | "onlyoffice-documentserver" => AppMetadata {
|
|
|
|
|
title: "OnlyOffice".to_string(),
|
|
|
|
|
description: "Office suite and document collaboration".to_string(),
|
|
|
|
|
icon: "/assets/img/onlyoffice.webp".to_string(),
|
|
|
|
|
repo: "https://github.com/ONLYOFFICE/DocumentServer".to_string(),
|
|
|
|
|
},
|
|
|
|
|
"penpot" | "penpot-frontend" => AppMetadata {
|
|
|
|
|
title: "Penpot".to_string(),
|
|
|
|
|
description: "Open-source design and prototyping".to_string(),
|
|
|
|
|
icon: "/assets/img/penpot.webp".to_string(),
|
|
|
|
|
repo: "https://github.com/penpot/penpot".to_string(),
|
|
|
|
|
},
|
2026-01-28 00:47:00 +00:00
|
|
|
"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(),
|
|
|
|
|
repo: "https://github.com/nextcloud/server".to_string(),
|
|
|
|
|
},
|
2026-01-27 23:21:26 +00:00
|
|
|
_ => AppMetadata {
|
|
|
|
|
title: app_id.to_string(),
|
|
|
|
|
description: format!("{} application", app_id),
|
|
|
|
|
icon: "/assets/img/favico.png".to_string(),
|
|
|
|
|
repo: "#".to_string(),
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn extract_lan_address(ports: &[String]) -> Option<String> {
|
|
|
|
|
for port_str in ports {
|
|
|
|
|
// Parse port strings like "0.0.0.0:18443->18443/tcp" or "0.0.0.0:18443-18444->18443-18444/tcp"
|
|
|
|
|
if let Some(public_part) = port_str.split("->").next() {
|
|
|
|
|
if let Some(port_part) = public_part.split(':').nth(1) {
|
|
|
|
|
// Extract just the first port if it's a range (e.g., "18443-18444" -> "18443")
|
|
|
|
|
let single_port = port_part.split('-').next().unwrap_or(port_part);
|
|
|
|
|
return Some(format!("http://localhost:{}", single_port));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn convert_state(container_state: &ContainerState) -> (PackageState, ServiceStatus) {
|
|
|
|
|
match container_state {
|
|
|
|
|
ContainerState::Running => (PackageState::Running, ServiceStatus::Running),
|
|
|
|
|
ContainerState::Stopped | ContainerState::Exited => {
|
|
|
|
|
(PackageState::Stopped, ServiceStatus::Stopped)
|
|
|
|
|
}
|
|
|
|
|
ContainerState::Created => (PackageState::Starting, ServiceStatus::Starting),
|
|
|
|
|
ContainerState::Paused => (PackageState::Stopped, ServiceStatus::Stopped),
|
|
|
|
|
ContainerState::Unknown(_) => (PackageState::Stopped, ServiceStatus::Stopped),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn package_state_str(state: &PackageState) -> &str {
|
|
|
|
|
match state {
|
|
|
|
|
PackageState::Installing => "installing",
|
|
|
|
|
PackageState::Installed => "installed",
|
|
|
|
|
PackageState::Stopping => "stopping",
|
|
|
|
|
PackageState::Stopped => "stopped",
|
|
|
|
|
PackageState::Starting => "starting",
|
|
|
|
|
PackageState::Running => "running",
|
|
|
|
|
PackageState::Restarting => "restarting",
|
|
|
|
|
PackageState::CreatingBackup => "creating-backup",
|
|
|
|
|
PackageState::RestoringBackup => "restoring-backup",
|
|
|
|
|
PackageState::Removing => "removing",
|
|
|
|
|
PackageState::BackingUp => "backing-up",
|
|
|
|
|
}
|
|
|
|
|
}
|