diff --git a/core/archipelago/src/api/rpc/package/install.rs b/core/archipelago/src/api/rpc/package/install.rs index 956439b8..575d4dfa 100644 --- a/core/archipelago/src/api/rpc/package/install.rs +++ b/core/archipelago/src/api/rpc/package/install.rs @@ -801,6 +801,48 @@ autopilot.active=false\n", } } + // Auto-configure Tor hidden service for protocol services (LND, ElectrumX, Bitcoin) + { + use crate::api::rpc::tor::{ + known_service_port, is_protocol_service, load_services_config, + save_services_config, regenerate_torrc, restart_tor, wait_for_hostname, + sync_single_hostname, TorServiceEntry, + }; + + let tor_port = known_service_port(package_id); + if tor_port > 0 { + let config_dir = self.config.data_dir.join("tor-config"); + let mut config = load_services_config(&config_dir).await; + let already_exists = config.services.iter().any(|s| s.name == package_id); + + if !already_exists { + let is_proto = is_protocol_service(package_id); + config.services.push(TorServiceEntry { + name: package_id.to_string(), + local_port: tor_port, + remote_port: None, + unauthenticated: is_proto, + enabled: true, + }); + if let Err(e) = save_services_config(&config_dir, &config).await { + tracing::warn!("Failed to save Tor config for {}: {}", package_id, e); + } else if let Err(e) = regenerate_torrc(&config).await { + tracing::warn!("Failed to regenerate torrc for {}: {}", package_id, e); + } else if let Err(e) = restart_tor().await { + tracing::warn!("Failed to restart Tor for {}: {}", package_id, e); + } else { + let onion = wait_for_hostname(package_id, 30).await; + if let Some(ref addr) = onion { + sync_single_hostname(package_id, addr).await; + info!("Tor hidden service created for {} → {}", package_id, addr); + } else { + info!("Tor hidden service created for {} (hostname pending)", package_id); + } + } + } + } + } + if package_id == "nextcloud" { let host_ip = &self.config.host_ip; // Wait for Nextcloud to finish first-run initialization diff --git a/core/archipelago/src/api/rpc/tor/mod.rs b/core/archipelago/src/api/rpc/tor/mod.rs index bc01a1bc..a56fe77e 100644 --- a/core/archipelago/src/api/rpc/tor/mod.rs +++ b/core/archipelago/src/api/rpc/tor/mod.rs @@ -23,12 +23,12 @@ pub(super) struct TorService { } #[derive(Debug, Default, Serialize, Deserialize)] -pub(super) struct ServicesConfig { +pub(in crate::api::rpc) struct ServicesConfig { pub services: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] -pub(super) struct TorServiceEntry { +pub(in crate::api::rpc) struct TorServiceEntry { pub name: String, pub local_port: u16, #[serde(default)] @@ -106,7 +106,7 @@ pub(super) async fn rename_hidden_service_dir(name: &str, timestamp: u64) { } } -pub(super) async fn restart_tor() -> Result<()> { +pub(in crate::api::rpc) async fn restart_tor() -> Result<()> { dispatch_tor_action(serde_json::json!({ "action": "write-torrc-and-restart", })).await @@ -131,7 +131,7 @@ pub(super) fn detect_hidden_service_base() -> String { "/var/lib/tor".to_string() } -pub(super) async fn regenerate_torrc(config: &ServicesConfig) -> Result<()> { +pub(in crate::api::rpc) async fn regenerate_torrc(config: &ServicesConfig) -> Result<()> { let base = detect_hidden_service_base(); let mut lines = Vec::new(); @@ -175,7 +175,7 @@ pub(super) async fn regenerate_torrc(config: &ServicesConfig) -> Result<()> { // ─── Hostname Sync ─────────────────────────────────────────────── -pub(super) async fn sync_single_hostname(name: &str, address: &str) { +pub(in crate::api::rpc) async fn sync_single_hostname(name: &str, address: &str) { let hostnames_dir = Path::new("/var/lib/archipelago/tor-hostnames"); if let Err(e) = tokio::fs::create_dir_all(hostnames_dir).await { warn!("Failed to create tor-hostnames dir: {}", e); @@ -306,7 +306,7 @@ fn is_valid_v3_onion(s: &str) -> bool { // ─── Known Ports ───────────────────────────────────────────────── -pub(super) fn known_service_port(name: &str) -> u16 { +pub(in crate::api::rpc) fn known_service_port(name: &str) -> u16 { match name { "archipelago" => 80, "bitcoin" | "bitcoin-knots" => 8333, @@ -331,7 +331,7 @@ pub(super) fn known_service_port(name: &str) -> u16 { } } -pub(super) fn is_protocol_service(name: &str) -> bool { +pub(in crate::api::rpc) fn is_protocol_service(name: &str) -> bool { matches!(name, "bitcoin" | "bitcoin-knots" | "electrs" | "electrumx" | "lnd") } @@ -341,7 +341,7 @@ fn tor_data_dir() -> String { std::env::var("TOR_DATA_DIR").unwrap_or_else(|_| TOR_DATA_DIR.to_string()) } -pub(super) async fn load_services_config(config_dir: &std::path::Path) -> ServicesConfig { +pub(in crate::api::rpc) async fn load_services_config(config_dir: &std::path::Path) -> ServicesConfig { let path = config_dir.join(SERVICES_CONFIG); match tokio::fs::read_to_string(&path).await { Ok(content) => serde_json::from_str(&content).unwrap_or_default(), @@ -349,7 +349,7 @@ pub(super) async fn load_services_config(config_dir: &std::path::Path) -> Servic } } -pub(super) async fn save_services_config(config_dir: &std::path::Path, config: &ServicesConfig) -> Result<()> { +pub(in crate::api::rpc) async fn save_services_config(config_dir: &std::path::Path, config: &ServicesConfig) -> Result<()> { tokio::fs::create_dir_all(config_dir).await.context("Failed to create tor config dir")?; let path = config_dir.join(SERVICES_CONFIG); let content = serde_json::to_string_pretty(config).context("Failed to serialize services config")?; @@ -418,7 +418,7 @@ pub(super) async fn notify_federation_peers_address_change( // ─── Hostname Waiting ──────────────────────────────────────────── -pub(super) async fn wait_for_hostname(service_name: &str, max_secs: u64) -> Option { +pub(in crate::api::rpc) async fn wait_for_hostname(service_name: &str, max_secs: u64) -> Option { for _ in 0..max_secs { if let Some(addr) = read_onion_address(service_name).await { return Some(addr); diff --git a/neode-ui/src/api/rpc-client.ts b/neode-ui/src/api/rpc-client.ts index 4a2e5391..ce3585a4 100644 --- a/neode-ui/src/api/rpc-client.ts +++ b/neode-ui/src/api/rpc-client.ts @@ -517,7 +517,7 @@ class RPCClient { return this.call({ method: 'package.uninstall', params: { id }, - timeout: 120000, + timeout: 660000, // Bitcoin Knots needs up to 600s for UTXO flush }) } diff --git a/neode-ui/src/stores/server.ts b/neode-ui/src/stores/server.ts index 080502cd..a2bb3fb2 100644 --- a/neode-ui/src/stores/server.ts +++ b/neode-ui/src/stores/server.ts @@ -57,16 +57,21 @@ export const useServerStore = defineStore('server', () => { } } // Clear installingApps entries for apps that vanished from backend data - // (container was removed, install failed and was cleaned up, etc.) + // Only clean up entries that have errored — active installs may take minutes to pull images for (const [appId] of installingApps.value) { if (packages && !(appId in packages)) { const entry = installingApps.value.get(appId) - if (entry && entry.attempt > 30) { - // App has been "installing" for 30+ seconds but backend doesn't know about it — failed + if (entry && entry.status === 'error') { installingApps.value.delete(appId) } } } + // Clear uninstallingApps when the container disappears from backend data + for (const appId of uninstallingApps.value) { + if (packages && !(appId in packages)) { + uninstallingApps.value.delete(appId) + } + } }, { deep: true }) function setInstallProgress(appId: string, progress: Partial & { id: string; title: string }) { diff --git a/neode-ui/src/views/Apps.vue b/neode-ui/src/views/Apps.vue index 3a74ebe7..6efe86e8 100644 --- a/neode-ui/src/views/Apps.vue +++ b/neode-ui/src/views/Apps.vue @@ -207,7 +207,7 @@ const packages = computed(() => { id: appId, title: progress.title, version: '', - description: { short: progress.message, long: '' }, + description: { short: '', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null, }, diff --git a/neode-ui/src/views/apps/AppCard.vue b/neode-ui/src/views/apps/AppCard.vue index 6681e934..82ada184 100644 --- a/neode-ui/src/views/apps/AppCard.vue +++ b/neode-ui/src/views/apps/AppCard.vue @@ -12,19 +12,7 @@ > - -
-
- - - - - {{ t('common.uninstalling') }}... -
-
+