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:
parent
5d4d40e9e8
commit
0f5ea9ae15
@ -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);
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user