// 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, } 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", "mempool-db", "mempool-api", "penpot-postgres", "penpot-backend", "penpot-exporter", "penpot-valkey", "penpot-mailcatch", "endurain-db", "nextcloud-db", ]; // 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() }; // Skip backend services (databases, APIs, etc.) if excluded_services.contains(&app_id.as_str()) { debug!("Skipping backend 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); // 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 if app_id == "bitcoin-knots" { // Bitcoin UI runs on host network at port 8334 debug!("Using bitcoin-ui for bitcoin-knots: http://localhost:8334"); Some("http://localhost:8334".to_string()) } else if app_id == "lnd" { // LND UI runs on host network at port 8081 debug!("Using lnd-ui for lnd: http://localhost:8081"); Some("http://localhost:8081".to_string()) } 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 if app_id == "fedimint" { // Fedimint built-in Guardian UI on port 8175 debug!("Using fedimint built-in Guardian UI: http://localhost:8175"); Some("http://localhost:8175".to_string()) } else if app_id == "mempool-electrs" || app_id == "electrs" { // Electrs UI runs on host at port 50002 debug!("Using electrs-ui for mempool-electrs: http://localhost:50002"); Some("http://localhost:50002".to_string()) } else { // Extract port from the main container extract_lan_address(&container.ports) }; 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); 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() || 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, } fn get_app_metadata(app_id: &str) -> AppMetadata { 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(), }, "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(), }, "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(), 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(), }, "fedimint" => AppMetadata { title: "Fedimint".to_string(), description: "Federated Bitcoin mint".to_string(), icon: "/assets/img/app-icons/fedimint.png".to_string(), repo: "https://github.com/fedimint/fedimint".to_string(), }, "morphos" | "morphos-server" => AppMetadata { title: "Morphos".to_string(), description: "Self-hosted file converter".to_string(), icon: "/assets/img/morphos.png".to_string(), repo: "https://github.com/danvergara/morphos".to_string(), }, "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(), }, "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(), }, "mempool-electrs" | "electrs" => AppMetadata { title: "Electrs".to_string(), description: "Electrum protocol indexer for Bitcoin. Powers Mempool and other Electrum clients.".to_string(), icon: "/assets/img/app-icons/electrs.svg".to_string(), repo: "https://github.com/romanz/electrs".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/app-icons/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(), }, "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(), }, "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(), }, "indeedhub" => 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(), }, _ => AppMetadata { title: app_id.to_string(), description: format!("{} application", app_id), icon: "/assets/img/favico.png".to_string(), repo: "#".to_string(), }, } } /// 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"), "lnd" | "lnd-ui" => Some("lnd"), "btcpay" | "btcpay-server" | "btcpayserver" => Some("btcpay"), "mempool" | "mempool-web" | "mempool-frontend" => Some("mempool"), "fedimint" => Some("fedimint"), _ => 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 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()); let path = std::path::Path::new(&base) .join(format!("hidden_service_{}", service)) .join("hostname"); std::fs::read_to_string(&path) .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 | 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", } }