archy/core/archipelago/src/container/docker_packages.rs
Dorian d538ad0581 fix: restore get_app_tier function signature
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 03:39:17 +00:00

674 lines
30 KiB
Rust

// 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>> {
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",
];
// 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 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 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 == "fedimint-gateway" {
// Fedimint Gateway API on port 8176
debug!("Using fedimint gateway: http://localhost:8176");
Some("http://localhost:8176".to_string())
} else if app_id == "nostr-rs-relay" {
debug!("Using Nostr relay: http://localhost:18081");
Some("http://localhost:18081".to_string())
} else if app_id == "dwn" {
debug!("Using DWN server: http://localhost:3100");
Some("http://localhost:3100".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(),
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));
}
// Virtual app: Indeehub (opens external URL, no container required)
if !packages.contains_key("indeedhub") {
let metadata = get_app_metadata("indeedhub");
let lan_address = Some("https://archipelago.indeehub.studio".to_string());
let virtual_pkg = PackageDataEntry {
state: PackageState::Running,
static_files: StaticFiles {
license: "MIT".to_string(),
instructions: metadata.description.clone(),
icon: metadata.icon.clone(),
},
manifest: Manifest {
id: "indeedhub".to_string(),
title: metadata.title.clone(),
version: "0.1.0".to_string(),
description: Description {
short: metadata.description.clone(),
long: metadata.description.clone(),
},
release_notes: "Virtual app (opens archipelago.indeehub.studio)".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("Indeehub Team".to_string()),
website: lan_address.clone(),
tier: Some("optional".to_string()),
interfaces: Some(Interfaces {
main: Some(MainInterface {
ui: Some("true".to_string()),
tor_config: None,
lan_config: None,
}),
}),
},
installed: Some(InstalledPackageDataEntry {
current_dependents: HashMap::new(),
current_dependencies: HashMap::new(),
last_backup: None,
interface_addresses: {
let mut addresses = HashMap::new();
addresses.insert(
"main".to_string(),
InterfaceAddress {
tor_address: String::new(),
lan_address: lan_address,
},
);
addresses
},
status: ServiceStatus::Running,
}),
install_progress: None,
};
packages.insert("indeedhub".to_string(), virtual_pkg);
info!("Virtual app: Indeehub (archipelago.indeehub.studio)");
}
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" | "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" => AppMetadata {
title: "Fedimint".to_string(),
description: "Federated Bitcoin mint with Guardian and Gateway".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: "",
},
"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(),
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: "",
},
"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/photoprims.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" => AppMetadata {
title: "Indeehub".to_string(),
description: "Decentralized media streaming platform".to_string(),
icon: "/assets/img/app-icons/indeehub.ico".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"),
"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<String> {
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) = std::fs::read_to_string(&hostnames_path)
.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");
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<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",
}
}