// Docker Package Scanner // Scans docker-compose containers and converts them to package data use anyhow::Result; use archipelago_container::{ContainerRuntime as ContainerRuntimeTrait, ContainerState, PodmanClient}; 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, } impl DockerPackageScanner { pub fn new(runtime: Arc) -> Self { Self { runtime } } /// Scan Docker containers and convert to package data pub async fn scan_containers(&self) -> Result> { let containers = match self.runtime.list_containers().await { Ok(c) => c, Err(e) => { debug!("Failed to list containers: {}", e); return Ok(HashMap::new()); } }; debug!("Found {} containers", containers.len()); let mut packages = HashMap::new(); // Backend services that should not appear as apps let excluded_services = [ "btcpay-db", "nbxplorer", "mempool-db", "mempool-api", "penpot-postgres", "penpot-backend", "penpot-exporter", "penpot-valkey", "penpot-mailcatch", "immich_postgres", "immich_redis", "endurain-db", "nextcloud-db", "indeedhub-api", "indeedhub-ffmpeg", "indeedhub-postgres", "indeedhub-redis", "indeedhub-minio", "indeedhub-relay", "indeedhub-build_api_1", "indeedhub-build_postgres_1", "indeedhub-build_redis_1", "indeedhub-build_minio_1", "indeedhub-build_minio-init_1", "indeedhub-build_relay_1", "indeedhub-build_ffmpeg-worker_1", ]; // First pass: collect UI containers let mut ui_containers: HashMap = HashMap::new(); for container in &containers { if container.name.ends_with("-ui") { // Map fedimint-ui -> fedimint, lnd-ui -> lnd (normalize archy- prefix for lookup) let parent_app = container.name.strip_suffix("-ui").unwrap_or(&container.name); let canonical_id = parent_app .strip_prefix("archy-") .unwrap_or(parent_app) .to_string(); if !container.ports.is_empty() { if let Some(ui_address) = extract_lan_address(&container.ports) { ui_containers.insert(canonical_id, ui_address); } } } } 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() }; // Normalize multi-container app IDs to their canonical names let app_id = match app_id.as_str() { "immich_server" => "immich".to_string(), _ => app_id, }; // Skip backend services (databases, APIs, etc.) if excluded_services.contains(&app_id.as_str()) { debug!("Skipping backend service: {}", app_id); continue; } // Skip podman-compose infrastructure containers (e.g. indeedhub-build_api_1) // These have the project prefix pattern: {project}_{service}_{instance} if app_id.starts_with("indeedhub-build_") { debug!("Skipping IndeedHub compose service: {}", app_id); continue; } // Skip UI containers (they're merged with their parent apps) if app_id.ends_with("-ui") { debug!("Skipping UI container: {}", app_id); continue; } // Get metadata for this app let metadata = get_app_metadata(&app_id); // Resolve UI address: separate UI containers > static map > dynamic ports let lan_address = if let Some(ui_address) = ui_containers.get(&app_id) { // Apps with separate UI containers (e.g. archy-bitcoin-ui, archy-lnd-ui) debug!("Using UI container for {}: {}", app_id, ui_address); Some(ui_address.clone()) } else if app_id == "bitcoin-knots" { Some("http://localhost:8334".to_string()) } else if app_id == "lnd" { Some("http://localhost:8081".to_string()) } else if app_id == "electrumx" || app_id == "mempool-electrs" || app_id == "electrs" { Some("http://localhost:50002".to_string()) } else { // Dynamic: use actual port bindings from container, fall back to static map extract_lan_address(&container.ports) .or_else(|| PodmanClient::lan_address_for(&app_id)) }; debug!("Container {}: ports={:?}, lan_address={:?}", app_id, container.ports, lan_address); // Convert container state to package/service state let (package_state, service_status) = convert_state(&container.state); let tor_address = read_tor_address(&app_id).await; let package = PackageDataEntry { state: package_state.clone(), health: container.health.clone(), exit_code: if package_state == PackageState::Exited { container.exit_code } else { None }, 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(), tier: Some(metadata.tier.to_string()), interfaces: if lan_address.is_some() || tor_address.is_some() { Some(Interfaces { main: Some(MainInterface { ui: Some("true".to_string()), tor_config: tor_address.clone(), lan_config: None, }), }) } else { None }, }, installed: Some(InstalledPackageDataEntry { current_dependents: HashMap::new(), current_dependencies: HashMap::new(), last_backup: None, interface_addresses: if lan_address.is_some() || tor_address.is_some() { let mut addresses = HashMap::new(); // Only include tor_address if we have a real v3 .onion (not placeholder) let tor = tor_address .filter(|s| is_real_onion_address(s)) .unwrap_or_default(); addresses.insert( "main".to_string(), InterfaceAddress { tor_address: tor, lan_address: lan_address, }, ); 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, tier: &'static str, } /// Get the app tier: "core", "recommended", or "optional". fn get_app_tier(app_id: &str) -> &'static str { match app_id { // Core: required for basic Bitcoin node "bitcoin" | "bitcoin-core" | "bitcoin-knots" => "core", "lnd" => "core", "mempool" | "mempool-web" | "mempool-api" | "electrumx" | "mempool-electrs" | "electrs" => "core", "btcpay" | "btcpay-server" | "btcpayserver" => "core", "dwn" => "core", "filebrowser" => "core", // Recommended: enhanced functionality "fedimint" | "fedimint-gateway" => "recommended", "vaultwarden" => "recommended", "uptime-kuma" => "recommended", "grafana" => "recommended", "searxng" => "recommended", "tailscale" => "recommended", "portainer" => "recommended", // Optional: everything else _ => "optional", } } fn get_app_metadata(app_id: &str) -> AppMetadata { let mut meta = match app_id { "bitcoin" | "bitcoin-core" | "bitcoin-knots" => AppMetadata { title: "Bitcoin Knots".to_string(), description: "Full Bitcoin node implementation".to_string(), icon: "/assets/img/app-icons/bitcoin-knots.webp".to_string(), repo: "https://github.com/bitcoinknots/bitcoin".to_string(), tier: "", }, "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(), repo: "https://github.com/btcpayserver/btcpayserver".to_string(), tier: "", }, "homeassistant" | "home-assistant" => 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(), tier: "", }, "grafana" => AppMetadata { title: "Grafana".to_string(), description: "Analytics and monitoring platform".to_string(), icon: "/assets/img/app-icons/grafana.png".to_string(), repo: "https://github.com/grafana/grafana".to_string(), tier: "", }, "endurain" => AppMetadata { title: "Endurain".to_string(), 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(), tier: "", }, "fedimint" | "fedimintd" => AppMetadata { title: "Fedimint Guardian".to_string(), description: "Federated Bitcoin mint — Guardian node for federation consensus".to_string(), icon: "/assets/img/app-icons/fedimint.png".to_string(), repo: "https://github.com/fedimint/fedimint".to_string(), tier: "", }, "fedimint-gateway" => AppMetadata { title: "Fedimint Gateway".to_string(), description: "Fedimint Lightning gateway for ecash payments".to_string(), icon: "/assets/img/app-icons/fedimint.png".to_string(), repo: "https://github.com/fedimint/fedimint".to_string(), tier: "", }, "morphos" | "morphos-server" => AppMetadata { title: "Morphos".to_string(), description: "Self-hosted file converter".to_string(), icon: "/assets/img/app-icons/morphos.png".to_string(), repo: "https://github.com/danvergara/morphos".to_string(), tier: "", }, "lnd" | "lightning-stack" => AppMetadata { title: "LND".to_string(), description: "Lightning Network Daemon".to_string(), icon: "/assets/img/app-icons/lnd.svg".to_string(), repo: "https://github.com/lightningnetwork/lnd".to_string(), tier: "", }, "mempool" | "mempool-web" => AppMetadata { title: "Mempool".to_string(), description: "Bitcoin blockchain explorer".to_string(), icon: "/assets/img/app-icons/mempool.webp".to_string(), repo: "https://github.com/mempool/mempool".to_string(), tier: "", }, "electrumx" | "mempool-electrs" | "electrs" => AppMetadata { title: "ElectrumX".to_string(), description: "ElectrumX server — full Electrum protocol indexer for Bitcoin. Powers Mempool and Electrum wallets.".to_string(), icon: "/assets/img/app-icons/electrs.svg".to_string(), repo: "https://github.com/spesmilo/electrumx".to_string(), tier: "", }, "ollama" => AppMetadata { title: "Ollama".to_string(), description: "Run large language models locally".to_string(), icon: "/assets/img/app-icons/ollama.png".to_string(), repo: "https://github.com/ollama/ollama".to_string(), tier: "", }, "monerod" | "monero" => AppMetadata { title: "Monero".to_string(), description: "Private cryptocurrency full node (Monero)".to_string(), icon: "/assets/img/app-icons/monero.png".to_string(), repo: "https://github.com/monero-project/monero".to_string(), tier: "", }, "elementsd" | "liquid" => AppMetadata { title: "Liquid Network".to_string(), description: "Bitcoin sidechain for confidential transactions and faster settlements".to_string(), icon: "/assets/img/app-icons/liquid.png".to_string(), repo: "https://github.com/ElementsProject/elements".to_string(), tier: "", }, "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(), tier: "", }, "onlyoffice" | "onlyoffice-documentserver" => AppMetadata { title: "OnlyOffice".to_string(), description: "Office suite and document collaboration".to_string(), icon: "/assets/img/app-icons/onlyoffice.webp".to_string(), repo: "https://github.com/ONLYOFFICE/DocumentServer".to_string(), tier: "", }, "penpot" | "penpot-frontend" => AppMetadata { title: "Penpot".to_string(), description: "Open-source design and prototyping".to_string(), icon: "/assets/img/app-icons/penpot.webp".to_string(), repo: "https://github.com/penpot/penpot".to_string(), tier: "", }, "nextcloud" => AppMetadata { title: "Nextcloud".to_string(), description: "Self-hosted cloud storage and file management".to_string(), icon: "/assets/img/app-icons/nextcloud.webp".to_string(), repo: "https://github.com/nextcloud/server".to_string(), tier: "", }, "vaultwarden" => AppMetadata { title: "Vaultwarden".to_string(), description: "Self-hosted password manager (Bitwarden compatible)".to_string(), icon: "/assets/img/app-icons/vaultwarden.webp".to_string(), repo: "https://github.com/dani-garcia/vaultwarden".to_string(), tier: "", }, "jellyfin" => AppMetadata { title: "Jellyfin".to_string(), description: "Free media server system".to_string(), icon: "/assets/img/app-icons/jellyfin.webp".to_string(), repo: "https://github.com/jellyfin/jellyfin".to_string(), tier: "", }, "photoprism" => AppMetadata { title: "PhotoPrism".to_string(), description: "AI-powered photo management".to_string(), icon: "/assets/img/app-icons/photoprism.svg".to_string(), repo: "https://github.com/photoprism/photoprism".to_string(), tier: "", }, "immich" | "immich_server" => AppMetadata { title: "Immich".to_string(), description: "High-performance self-hosted photo and video backup".to_string(), icon: "/assets/img/app-icons/immich.png".to_string(), repo: "https://github.com/immich-app/immich".to_string(), tier: "", }, "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(), tier: "", }, "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(), tier: "", }, "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(), tier: "", }, "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(), tier: "", }, "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(), tier: "", }, "indeedhub" | "indeehub" => AppMetadata { title: "IndeedHub".to_string(), description: "Decentralized media streaming platform".to_string(), icon: "/assets/img/app-icons/indeedhub.png".to_string(), repo: "https://github.com/indeedhub/indeedhub".to_string(), tier: "", }, "nostr-rs-relay" => AppMetadata { title: "Nostr Relay".to_string(), description: "Run your own Nostr relay for sovereign event storage".to_string(), icon: "/assets/img/app-icons/nostr-rs-relay.svg".to_string(), repo: "https://sr.ht/~gheartsfield/nostr-rs-relay/".to_string(), tier: "", }, "dwn" => AppMetadata { title: "Decentralized Web Node".to_string(), description: "Store and sync personal data with DID-based access control".to_string(), icon: "/assets/img/app-icons/dwn.svg".to_string(), repo: "https://github.com/TBD54566975/dwn-server".to_string(), tier: "", }, "tor" | "archy-tor" => AppMetadata { title: "Tor".to_string(), description: "Anonymous overlay network for privacy".to_string(), icon: "/assets/img/app-icons/tor.svg".to_string(), repo: "https://gitlab.torproject.org/tpo/core/tor".to_string(), tier: "", }, "botfights" => AppMetadata { title: "BotFights".to_string(), description: "AI bot arena — build, train, and battle autonomous agents".to_string(), icon: "/assets/img/app-icons/botfights.svg".to_string(), repo: "https://botfights.net".to_string(), tier: "", }, "nwnn" => AppMetadata { title: "Next Web News Network".to_string(), description: "Decentralized news and link aggregator, synced from Telegram".to_string(), icon: "/assets/img/app-icons/nwnn.png".to_string(), repo: "https://nwnn.l484.com".to_string(), tier: "", }, "484-kitchen" => AppMetadata { title: "484 Kitchen".to_string(), description: "K484 application platform".to_string(), icon: "/assets/img/app-icons/484-kitchen.png".to_string(), repo: "https://484.kitchen".to_string(), tier: "", }, "call-the-operator" => AppMetadata { title: "Call the Operator".to_string(), description: "Escape the Matrix — explore decentralized alternatives".to_string(), icon: "/assets/img/app-icons/call-the-operator.png".to_string(), repo: "https://cta.tx1138.com".to_string(), tier: "", }, "arch-presentation" => AppMetadata { title: "Arch Presentation".to_string(), description: "Archipelago: The Future of Decentralized Infrastructure".to_string(), icon: "/assets/img/app-icons/arch-presentation.png".to_string(), repo: "https://present.l484.com".to_string(), tier: "", }, "syntropy-institute" => AppMetadata { title: "Syntropy Institute".to_string(), description: "Medicine Reimagined — frequency analysis-therapy and digital homeopathy".to_string(), icon: "/assets/img/app-icons/syntropy-institute.png".to_string(), repo: "https://syntropy.institute".to_string(), tier: "", }, "t-zero" => AppMetadata { title: "T-0".to_string(), description: "Documentary series on decentralization, Bitcoin, and the ungovernable future".to_string(), icon: "/assets/img/app-icons/t-zero.png".to_string(), repo: "https://teeminuszero.net".to_string(), tier: "", }, _ => AppMetadata { title: app_id.to_string(), description: format!("{} application", app_id), icon: "/assets/img/favico.png".to_string(), repo: "#".to_string(), tier: "", }, }; meta.tier = get_app_tier(app_id); meta } /// Map app_id to Tor hidden service directory name. /// "archipelago" is the main web UI (nginx port 80). /// Supports container names from deploy (archy-*, btcpay-server, etc.). fn tor_service_name(app_id: &str) -> Option<&'static str> { match app_id { "archipelago" => Some("archipelago"), "bitcoin" | "bitcoin-knots" | "bitcoind" => Some("bitcoin"), "electrumx" | "electrs" | "electrum" => Some("electrumx"), "lnd" | "lnd-ui" => Some("lnd"), "btcpay" | "btcpay-server" | "btcpayserver" => Some("btcpay"), "mempool" | "mempool-web" | "mempool-frontend" => Some("mempool"), "fedimint" | "fedimint-gateway" => Some("fedimint"), "filebrowser" => Some("filebrowser"), _ => None, } } /// V3 onion addresses are 56 base32 chars + ".onion". Placeholders like "btcpay.onion" are not real. fn is_real_onion_address(s: &str) -> bool { s.ends_with(".onion") && s.len() >= 60 && s.len() <= 70 } /// Read real .onion address from Tor hidden service hostname file. /// Service name "archipelago" is for the main web UI (nginx port 80). /// Uses TOR_DATA_DIR env var if set, else /var/lib/archipelago/tor. pub async fn read_tor_address(app_id: &str) -> Option { let service = tor_service_name(app_id)?; let base = std::env::var("TOR_DATA_DIR").unwrap_or_else(|_| "/var/lib/archipelago/tor".to_string()); // Try readable hostname copy first (when system Tor owns hidden_service dirs) let hostnames_path = std::path::Path::new(&base) .parent() .unwrap_or(std::path::Path::new("/var/lib/archipelago")) .join("tor-hostnames") .join(service); if let Some(addr) = tokio::fs::read_to_string(&hostnames_path) .await .ok() .map(|s| s.trim().to_string()) .filter(|s| s.ends_with(".onion") && !s.is_empty()) { return Some(addr); } // Fall back to hidden_service directory let path = std::path::Path::new(&base) .join(format!("hidden_service_{}", service)) .join("hostname"); tokio::fs::read_to_string(&path) .await .ok() .map(|s| s.trim().to_string()) .filter(|s| s.ends_with(".onion") && !s.is_empty()) } fn extract_lan_address(ports: &[String]) -> Option { 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 => (PackageState::Stopped, ServiceStatus::Stopped), ContainerState::Exited => (PackageState::Exited, ServiceStatus::Stopped), ContainerState::Created => (PackageState::Stopped, ServiceStatus::Stopped), 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::Exited => "exited", PackageState::Starting => "starting", PackageState::Running => "running", PackageState::Restarting => "restarting", PackageState::CreatingBackup => "creating-backup", PackageState::RestoringBackup => "restoring-backup", PackageState::Removing => "removing", PackageState::BackingUp => "backing-up", } }