fix(tor): resolve app ports dynamically + auto-create hidden service on install

'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 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-07-15 05:56:22 -04:00
co-authored by Claude Fable 5
parent 5d4d40e9e8
commit 0f5ea9ae15
4 changed files with 125 additions and 16 deletions
+1 -1
View File
@@ -804,7 +804,7 @@ async fn http_launch_url_reachable(url: &str) -> bool {
}
}
fn port_from_url(url: &str) -> Option<u16> {
pub(in crate::api::rpc) fn port_from_url(url: &str) -> Option<u16> {
let after_colon = url.rsplit_once(':')?.1;
let port = after_colon
.chars()
@@ -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);
+72 -8
View File
@@ -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<u16> {
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<serde_json::Value> {
info!("Manual Tor restart requested");