ISO build no longer copies netavark from build host (Debian 13/GLIBC 2.41) which broke container networking on Debian 12 targets. Rootfs already installs netavark from Debian 12 repos — just configure the backend. Install RPC now adopts existing containers (from first-boot) instead of erroring on duplicates. Container scanner extracts real versions from image tags and detects available updates against pinned versions. Frontend shows update button with version info when updates are available. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
654 lines
28 KiB
Rust
654 lines
28 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, 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,
|
|
};
|
|
use super::image_versions;
|
|
|
|
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",
|
|
"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<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 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;
|
|
|
|
// Extract actual version from container image tag
|
|
let running_version = image_versions::extract_version_from_image(&container.image);
|
|
|
|
// Check for available update by comparing running image vs pinned image
|
|
let available_update = image_versions::pinned_image_for_app(&app_id)
|
|
.and_then(|pinned| {
|
|
if pinned != container.image {
|
|
let pinned_version = image_versions::extract_version_from_image(&pinned);
|
|
// Don't flag if both are "latest" — no meaningful diff
|
|
if pinned_version != "latest" || running_version != "latest" {
|
|
Some(pinned_version)
|
|
} else {
|
|
None
|
|
}
|
|
} else {
|
|
None
|
|
}
|
|
});
|
|
|
|
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: running_version,
|
|
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
|
|
},
|
|
},
|
|
available_update,
|
|
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: "",
|
|
},
|
|
"cryptpad" => AppMetadata {
|
|
title: "CryptPad".to_string(),
|
|
description: "End-to-end encrypted document collaboration".to_string(),
|
|
icon: "/assets/img/app-icons/cryptpad.webp".to_string(),
|
|
repo: "https://github.com/cryptpad/cryptpad".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<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) = 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<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 => (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",
|
|
PackageState::Updating => "updating",
|
|
}
|
|
}
|