From 0f5ea9ae1598752bd9803d9bd69be055392e922b Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 15 Jul 2026 05:17:01 -0400 Subject: [PATCH] fix(tor): resolve app ports dynamically + auto-create hidden service on install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'Add Service' only worked for the 19 apps in the hardcoded known_service_port map — everything else failed with 'Unknown app'. tor.create-service and tor.toggle-app now fall back to the app's live launch address from the scanner state, so any manifest-driven app gets a .onion without a per-app entry (the static map still wins for protocol services like bitcoin, which must expose 8333, not a UI port). toggle-app also no longer writes a broken port-0 HiddenServicePort for unknown apps. Every successful package.install now auto-creates the app's hidden service (detached, best-effort, retries while the scanner derives the port; skips protocol services and existing entries). The demo mock gains stateful tor.create-service/tor.delete-service so the demo round-trips instead of erroring. Co-Authored-By: Claude Fable 5 --- core/archipelago/src/api/rpc/container.rs | 2 +- .../src/api/rpc/package/async_lifecycle.rs | 8 ++ core/archipelago/src/api/rpc/tor/handlers.rs | 80 +++++++++++++++++-- neode-ui/mock-backend.js | 51 ++++++++++-- 4 files changed, 125 insertions(+), 16 deletions(-) diff --git a/core/archipelago/src/api/rpc/container.rs b/core/archipelago/src/api/rpc/container.rs index 4b3b0571..8fb5c151 100644 --- a/core/archipelago/src/api/rpc/container.rs +++ b/core/archipelago/src/api/rpc/container.rs @@ -804,7 +804,7 @@ async fn http_launch_url_reachable(url: &str) -> bool { } } -fn port_from_url(url: &str) -> Option { +pub(in crate::api::rpc) fn port_from_url(url: &str) -> Option { let after_colon = url.rsplit_once(':')?.1; let port = after_colon .chars() diff --git a/core/archipelago/src/api/rpc/package/async_lifecycle.rs b/core/archipelago/src/api/rpc/package/async_lifecycle.rs index 52d86302..29ea77bc 100644 --- a/core/archipelago/src/api/rpc/package/async_lifecycle.rs +++ b/core/archipelago/src/api/rpc/package/async_lifecycle.rs @@ -110,6 +110,14 @@ impl RpcHandler { ) .await; handler.clear_install_progress(&package_id_spawn).await; + // Auto-expose the app over Tor (best-effort, detached) — + // every installed app gets its .onion without a manual + // "Add Service" step. + let tor_handler = Arc::clone(&handler); + let tor_app = package_id_spawn.clone(); + tokio::spawn(async move { + tor_handler.auto_add_tor_service(&tor_app).await; + }); } Err(e) => { error!("package.install {} failed: {:#}", package_id_spawn, e); diff --git a/core/archipelago/src/api/rpc/tor/handlers.rs b/core/archipelago/src/api/rpc/tor/handlers.rs index 0f2d460d..3001f00e 100644 --- a/core/archipelago/src/api/rpc/tor/handlers.rs +++ b/core/archipelago/src/api/rpc/tor/handlers.rs @@ -33,14 +33,12 @@ impl RpcHandler { validate_service_name(name)?; let local_port = if raw_port == 0 { - let detected = known_service_port(name); - if detected == 0 { - return Err(anyhow::anyhow!( - "Unknown app '{}' — specify local_port manually", + self.resolve_app_local_port(name).await.ok_or_else(|| { + anyhow::anyhow!( + "No local web port found for '{}' — the app isn't running or exposes no UI port; specify local_port manually", name - )); - } - detected + ) + })? } else { raw_port }; @@ -286,7 +284,12 @@ impl RpcHandler { "changed": false, })); } - let port = known_service_port(app_id); + let port = self.resolve_app_local_port(app_id).await.ok_or_else(|| { + anyhow::anyhow!( + "No local web port found for '{}' — the app isn't running or exposes no UI port", + app_id + ) + })?; let is_proto = is_protocol_service(app_id); config.services.push(TorServiceEntry { name: app_id.to_string(), @@ -330,6 +333,67 @@ impl RpcHandler { })) } + /// The host-local port Tor should forward to for an app: the static map + /// first (protocol apps like bitcoin must expose 8333, not a UI port), + /// then the live launch address the scanner derived from the app's + /// published container ports — so any manifest-driven app works without + /// a per-app entry. + pub(in crate::api::rpc) async fn resolve_app_local_port(&self, name: &str) -> Option { + let known = known_service_port(name); + if known != 0 { + return Some(known); + } + let (data, _) = self.state_manager.get_snapshot().await; + let lan = data + .package_data + .get(name)? + .installed + .as_ref()? + .interface_addresses + .get("main")? + .lan_address + .clone()?; + crate::api::rpc::container::port_from_url(&lan) + } + + /// Best-effort auto-exposure of a freshly installed app as a Tor hidden + /// service. Skips protocol services (bitcoin/lnd keep their explicit + /// flows), the node's own service, apps that already have one, and apps + /// with no resolvable web port. Runs detached after install — it never + /// fails the caller, it only logs. + pub(in crate::api::rpc) async fn auto_add_tor_service(&self, app_id: &str) { + if app_id == "archipelago" || is_protocol_service(app_id) { + return; + } + let config_dir = self.config.data_dir.join("tor-config"); + // The scanner may still be deriving the launch address on slower + // nodes; retry for up to ~5 minutes before giving up quietly. + for _ in 0..10u32 { + let config = load_services_config(&config_dir).await; + if config.services.iter().any(|s| s.name == app_id) { + return; + } + if let Some(port) = self.resolve_app_local_port(app_id).await { + let params = serde_json::json!({ "name": app_id, "local_port": port }); + match self.handle_tor_create_service(Some(params)).await { + Ok(v) => info!( + app = app_id, + port, + onion = ?v.get("onion_address"), + "Auto-created Tor hidden service after install" + ), + Err(e) => warn!(app = app_id, "Auto Tor service creation failed: {}", e), + } + return; + } + tokio::time::sleep(tokio::time::Duration::from_secs(30)).await; + } + debug!( + app = app_id, + "No web port resolved — skipping auto Tor service" + ); + } + /// Restart Tor daemon (system or container). pub(in crate::api::rpc) async fn handle_tor_restart(&self) -> Result { info!("Manual Tor restart requested"); diff --git a/neode-ui/mock-backend.js b/neode-ui/mock-backend.js index a24e0abb..7a1fbd50 100755 --- a/neode-ui/mock-backend.js +++ b/neode-ui/mock-backend.js @@ -2636,20 +2636,45 @@ app.post('/rpc/v1', (req, res) => { // Tor & Peer Networking // ========================================================================= case 'tor.list-services': { + mockState.torServices = mockState.torServices || defaultTorServices() return res.json({ result: { tor_running: true, - services: [ - { name: 'archipelago', local_port: 5678, onion_address: 'archydemox7k3pnw4hv5qz2jcbr6dwefys3ockqzf4mzjlvxot2ioad.onion', enabled: true, unauthenticated: false, protocol: false }, - { name: 'bitcoin', local_port: 8333, onion_address: 'btcmockxj4k5pnw7hv8qz9jcbr2dwefys6ockqzf1mzjlvxot5ioad.onion', enabled: true, unauthenticated: false, protocol: false }, - { name: 'lnd', local_port: 9735, onion_address: 'lndmockab3c4def5ghi6jkl7mno8pqr9stu0vwx1yz2ab3c4def5ghi.onion', enabled: true, unauthenticated: false, protocol: false }, - { name: 'electrs', local_port: 50001, onion_address: 'elecmockyz9wvu8tsr7qpo6nml5kji4hgf3edc2ba1xyz9wvu8tsr7q.onion', enabled: true, unauthenticated: true, protocol: false }, - { name: 'nostr-relay', local_port: 7777, onion_address: null, enabled: false, unauthenticated: true, protocol: false }, - ], + services: mockState.torServices, }, }) } + case 'tor.create-service': { + mockState.torServices = mockState.torServices || defaultTorServices() + const name = params?.name + if (!name) { + return res.json({ error: { code: -1, message: 'Missing name' } }) + } + if (mockState.torServices.some(s => s.name === name)) { + return res.json({ error: { code: -1, message: `Service '${name}' already exists` } }) + } + const stem = `${name.toLowerCase().replace(/[^a-z0-9]/g, '')}demo` + const onion = (stem + 'x7k3pnw4hv5qz2jcbr6dwefys3ockqzf4mzjlvxot2ioadmnopqrstuv').slice(0, 56) + '.onion' + const svc = { name, local_port: params?.local_port || 8080, onion_address: onion, enabled: true, unauthenticated: false, protocol: false } + mockState.torServices = [...mockState.torServices, svc] + console.log(`[Tor] Created hidden service: ${name} → ${onion}`) + return res.json({ result: { created: true, name, onion_address: onion } }) + } + + case 'tor.delete-service': { + mockState.torServices = mockState.torServices || defaultTorServices() + const name = params?.name + if (name === 'archipelago') { + return res.json({ error: { code: -1, message: "Cannot delete the node's own Tor service" } }) + } + if (!mockState.torServices.some(s => s.name === name)) { + return res.json({ error: { code: -1, message: `Service '${name}' not found` } }) + } + mockState.torServices = mockState.torServices.filter(s => s.name !== name) + return res.json({ result: { deleted: true, name } }) + } + case 'node.tor-address': { return res.json({ result: { @@ -5119,6 +5144,18 @@ const mockData = sessionBucketProxy('mockData') const walletState = sessionBucketProxy('walletState') const userState = sessionBucketProxy('userState') const mockState = sessionBucketProxy('mockState') + +// Seed for the per-session Tor services demo state (tor.list-services / +// tor.create-service / tor.delete-service round-trip against this). +function defaultTorServices() { + return [ + { name: 'archipelago', local_port: 5678, onion_address: 'archydemox7k3pnw4hv5qz2jcbr6dwefys3ockqzf4mzjlvxot2ioad.onion', enabled: true, unauthenticated: false, protocol: false }, + { name: 'bitcoin', local_port: 8333, onion_address: 'btcmockxj4k5pnw7hv8qz9jcbr2dwefys6ockqzf1mzjlvxot5ioad.onion', enabled: true, unauthenticated: false, protocol: false }, + { name: 'lnd', local_port: 9735, onion_address: 'lndmockab3c4def5ghi6jkl7mno8pqr9stu0vwx1yz2ab3c4def5ghi.onion', enabled: true, unauthenticated: false, protocol: false }, + { name: 'electrs', local_port: 50001, onion_address: 'elecmockyz9wvu8tsr7qpo6nml5kji4hgf3edc2ba1xyz9wvu8tsr7q.onion', enabled: true, unauthenticated: true, protocol: false }, + { name: 'nostr-relay', local_port: 7777, onion_address: null, enabled: false, unauthenticated: true, protocol: false }, + ] +} const bitcoinRelayMockState = sessionBucketProxy('bitcoinRelayMockState') // Demo session lifecycle: keyed by the `demo_sid` cookie, capped, idle-reaped.