diff --git a/core/archipelago/src/api/rpc/mod.rs b/core/archipelago/src/api/rpc/mod.rs index 850e7235..1001b7a6 100644 --- a/core/archipelago/src/api/rpc/mod.rs +++ b/core/archipelago/src/api/rpc/mod.rs @@ -572,6 +572,7 @@ impl RpcHandler { "tor.rotate-service" => self.handle_tor_rotate_service(params).await, "tor.cleanup-rotated" => self.handle_tor_cleanup_rotated().await, "tor.toggle-app" => self.handle_tor_toggle_app(params).await, + "tor.restart" => self.handle_tor_restart().await, // Nostr relay management "nostr.list-relays" => self.handle_nostr_list_relays().await, diff --git a/core/archipelago/src/api/rpc/system.rs b/core/archipelago/src/api/rpc/system.rs index 5fa8028c..81168226 100644 --- a/core/archipelago/src/api/rpc/system.rs +++ b/core/archipelago/src/api/rpc/system.rs @@ -1,6 +1,6 @@ use super::RpcHandler; use anyhow::{Context, Result}; -use tracing::debug; +use tracing::{debug, info}; impl RpcHandler { /// server.set-name — Rename the server (persisted to data_dir/server-name) @@ -31,7 +31,17 @@ impl RpcHandler { data.server_info.name = Some(name.clone()); self.state_manager.update_data(data).await; - debug!("Server name updated to: {}", name); + info!("Server name updated to: {}", name); + + // Push the new name to federation peers in background + let data_dir = self.config.data_dir.clone(); + let state_manager = self.state_manager.clone(); + tokio::spawn(async move { + if let Err(e) = push_name_to_peers(&data_dir, &state_manager).await { + debug!("Federation name push (non-fatal): {}", e); + } + }); + Ok(serde_json::json!({ "name": name })) } @@ -172,6 +182,44 @@ impl RpcHandler { } } +/// Push the server name to all federation peers by syncing state. +async fn push_name_to_peers( + data_dir: &std::path::Path, + state_manager: &std::sync::Arc, +) -> Result<()> { + use crate::{federation, identity}; + + let nodes = federation::load_nodes(data_dir).await?; + if nodes.is_empty() { + return Ok(()); + } + + let (data, _) = state_manager.get_snapshot().await; + let local_did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?; + let identity_dir = data_dir.join("identity"); + let node_identity = identity::NodeIdentity::load_or_create(&identity_dir).await?; + + let mut synced = 0u32; + for node in &nodes { + if node.trust_level == federation::TrustLevel::Untrusted { + continue; + } + match federation::sync_with_peer( + data_dir, + node, + &local_did, + |bytes| node_identity.sign(bytes), + ) + .await + { + Ok(_) => synced += 1, + Err(e) => debug!("Sync with {} after rename: {}", node.did.chars().take(20).collect::(), e), + } + } + info!("Pushed server name to {}/{} peers", synced, nodes.len()); + Ok(()) +} + /// Read system uptime from /proc/uptime (seconds since boot). async fn read_uptime() -> Result { let content = tokio::fs::read_to_string("/proc/uptime") diff --git a/core/archipelago/src/api/rpc/tor.rs b/core/archipelago/src/api/rpc/tor.rs index 4fb40ca7..040cb89f 100644 --- a/core/archipelago/src/api/rpc/tor.rs +++ b/core/archipelago/src/api/rpc/tor.rs @@ -1,6 +1,7 @@ use super::RpcHandler; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; +use std::path::Path; use std::time::{SystemTime, UNIX_EPOCH}; use tracing::{debug, info, warn}; @@ -17,6 +18,10 @@ struct TorService { local_port: u16, onion_address: Option, enabled: bool, + /// True = direct port access without auth. False = through nginx with login. + unauthenticated: bool, + /// True if this is a protocol service (not HTTP), auth not applicable. + protocol: bool, } #[derive(Debug, Default, Serialize, Deserialize)] @@ -28,8 +33,14 @@ struct ServicesConfig { struct TorServiceEntry { name: String, local_port: u16, + #[serde(default)] + remote_port: Option, #[serde(default = "default_true")] enabled: bool, + /// If true, routes directly to app port (no nginx auth). + /// Default false = routes through nginx with session auth. + #[serde(default)] + unauthenticated: bool, } fn default_true() -> bool { @@ -43,7 +54,8 @@ impl RpcHandler { ) -> Result { let config_dir = self.config.data_dir.join("tor-config"); let services = list_services(&config_dir).await?; - Ok(serde_json::json!({ "services": services })) + let tor_running = check_tor_running().await; + Ok(serde_json::json!({ "services": services, "tor_running": tor_running })) } /// Create a new hidden service for a given local port. @@ -56,15 +68,27 @@ impl RpcHandler { .get("name") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing name"))?; - let local_port = params + let raw_port = params .get("local_port") .and_then(|v| v.as_u64()) - .ok_or_else(|| anyhow::anyhow!("Missing local_port"))? as u16; + .unwrap_or(0) as u16; + let remote_port = params + .get("remote_port") + .and_then(|v| v.as_u64()) + .map(|v| v as u16); - // Validate name - if name.is_empty() || name.len() > 64 || !name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') { - return Err(anyhow::anyhow!("Invalid service name (alphanumeric, hyphens, underscores only)")); - } + validate_service_name(name)?; + + // Auto-detect port from known services if not specified + 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", name)); + } + detected + } else { + raw_port + }; let config_dir = self.config.data_dir.join("tor-config"); let mut config = load_services_config(&config_dir).await; @@ -72,15 +96,34 @@ impl RpcHandler { return Err(anyhow::anyhow!("Service '{}' already exists", name)); } + // Protocol services are inherently unauthenticated (not HTTP). + // Web apps default to authenticated (routed through nginx). + let is_proto = is_protocol_service(name); config.services.push(TorServiceEntry { name: name.to_string(), local_port, + remote_port, + unauthenticated: is_proto, enabled: true, }); save_services_config(&config_dir, &config).await?; - debug!("Tor service created: {} -> port {}", name, local_port); - Ok(serde_json::json!({ "created": true, "name": name })) + // Regenerate torrc and restart Tor so the new service is live + regenerate_torrc(&config).await?; + restart_tor().await?; + + // Wait for the hostname to appear + let onion = wait_for_hostname(name, 60).await; + if let Some(ref addr) = onion { + sync_single_hostname(name, addr).await; + } + + info!(service = name, port = local_port, "Created Tor hidden service"); + Ok(serde_json::json!({ + "created": true, + "name": name, + "onion_address": onion, + })) } /// Delete a hidden service. @@ -94,6 +137,13 @@ impl RpcHandler { .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing name"))?; + validate_service_name(name)?; + + // Don't allow deleting the node's own service + if name == "archipelago" { + return Err(anyhow::anyhow!("Cannot delete the node's own Tor service")); + } + let config_dir = self.config.data_dir.join("tor-config"); let mut config = load_services_config(&config_dir).await; let before = config.services.len(); @@ -103,7 +153,14 @@ impl RpcHandler { } save_services_config(&config_dir, &config).await?; - debug!("Tor service deleted: {}", name); + // Remove hidden service directory via helper + delete_hidden_service_dir(name).await; + + // Regenerate torrc and restart Tor + regenerate_torrc(&config).await?; + restart_tor().await?; + + info!(service = name, "Deleted Tor hidden service"); Ok(serde_json::json!({ "deleted": true, "name": name })) } @@ -118,17 +175,13 @@ impl RpcHandler { .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing name"))?; - // Validate name to prevent path traversal - if name.is_empty() || name.len() > 64 || !name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') { - return Err(anyhow::anyhow!("Invalid service name (alphanumeric, hyphens, underscores only)")); - } + validate_service_name(name)?; let onion = read_onion_address(name); Ok(serde_json::json!({ "name": name, "onion_address": onion })) } /// Rotate a hidden service's .onion address by generating a new keypair. - /// The old service directory is renamed for a 24h transition period. pub(super) async fn handle_tor_rotate_service( &self, params: Option, @@ -139,77 +192,25 @@ impl RpcHandler { .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing name"))?; - // Validate name to prevent path traversal - if name.is_empty() || name.len() > 64 || !name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') { - return Err(anyhow::anyhow!("Invalid service name (alphanumeric, hyphens, underscores only)")); - } + validate_service_name(name)?; - let base = tor_data_dir(); - let service_dir = format!("{}/hidden_service_{}", base, name); - - // Read old .onion address before rotation let old_onion = read_onion_address(name); if old_onion.is_none() { return Err(anyhow::anyhow!("Service '{}' has no .onion address to rotate", name)); } // Delete old service directory immediately — no transition period - let delete_status = tokio::process::Command::new("sudo") - .args(["rm", "-rf", &service_dir]) - .status() - .await - .context("Failed to delete hidden service directory")?; - - if !delete_status.success() { - return Err(anyhow::anyhow!("Failed to delete hidden service directory for rotation")); - } - - // Clear the readable tor-hostnames cache so wait_for_hostname reads the new key - let hostnames_dir = std::path::Path::new(&base) - .parent() - .unwrap_or(std::path::Path::new("/var/lib/archipelago")) - .join("tor-hostnames"); - let _ = tokio::fs::remove_file(hostnames_dir.join(name)).await; + delete_hidden_service_dir(name).await; info!(service = name, old_onion = ?old_onion, "Rotated Tor service — restarting Tor"); - // Try system Tor first (hidden services may be in /etc/tor/torrc), then container - let system_ok = tokio::process::Command::new("sudo") - .args(["systemctl", "restart", "tor"]) - .status() - .await - .map(|s| s.success()) - .unwrap_or(false); - - if !system_ok { - // Fall back to container restart - let container_ok = tokio::process::Command::new("podman") - .args(["restart", "archy-tor"]) - .status() - .await - .map(|s| s.success()) - .unwrap_or(false); - if !container_ok { - warn!("Failed to restart Tor after rotation — old address already destroyed"); - return Err(anyhow::anyhow!("Failed to restart Tor — old address destroyed, Tor will generate new key on next restart")); - } - } + restart_tor().await?; // Wait up to 60s for new hostname file to appear let new_onion = wait_for_hostname(name, 60).await; - // Update the readable tor-hostnames copy if let Some(ref new_addr) = new_onion { - let hostnames_dir = std::path::Path::new(&base) - .parent() - .unwrap_or(std::path::Path::new("/var/lib/archipelago")) - .join("tor-hostnames"); - if let Err(e) = tokio::fs::create_dir_all(&hostnames_dir).await { - warn!("Failed to create tor-hostnames dir: {}", e); - } - if let Err(e) = tokio::fs::write(hostnames_dir.join(name), new_addr).await { - warn!("Failed to update tor-hostnames copy: {}", e); - } + sync_single_hostname(name, new_addr).await; } // Notify federation peers of address change (private peer-to-peer, no public relays) @@ -240,7 +241,7 @@ impl RpcHandler { pub(super) async fn handle_tor_cleanup_rotated( &self, ) -> Result { - let base = tor_data_dir(); + let base = detect_hidden_service_base(); let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() @@ -253,7 +254,6 @@ impl RpcHandler { if !name.contains("_old_") { continue; } - // Parse timestamp from suffix: hidden_service_NAME_old_TIMESTAMP if let Some(ts_str) = name.rsplit('_').next() { if let Ok(ts) = ts_str.parse::() { if now - ts > ROTATION_TRANSITION_SECS { @@ -288,10 +288,7 @@ impl RpcHandler { .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing app_id"))?; - // Validate app_id to prevent path traversal - if app_id.is_empty() || app_id.len() > 64 || !app_id.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') { - return Err(anyhow::anyhow!("Invalid app_id (alphanumeric, hyphens, underscores only)")); - } + validate_service_name(app_id)?; let enabled = params .get("enabled") @@ -301,7 +298,6 @@ impl RpcHandler { let config_dir = self.config.data_dir.join("tor-config"); let mut config = load_services_config(&config_dir).await; - // Find the service entry for this app let found = config.services.iter_mut().find(|s| s.name == app_id); match found { Some(entry) => { @@ -316,60 +312,43 @@ impl RpcHandler { } None => { if !enabled { - // Nothing to disable — doesn't exist return Ok(serde_json::json!({ "app_id": app_id, "enabled": false, "changed": false, })); } - // Add new entry let port = known_service_port(app_id); + let is_proto = is_protocol_service(app_id); config.services.push(TorServiceEntry { name: app_id.to_string(), local_port: port, + remote_port: None, + unauthenticated: is_proto, enabled: true, }); } } save_services_config(&config_dir, &config).await?; - let base = tor_data_dir(); - let service_dir = format!("{}/hidden_service_{}", base, app_id); - if !enabled { - // Remove the hidden service directory so Tor stops serving it - let _ = tokio::process::Command::new("sudo") - .args(["rm", "-rf", &service_dir]) - .status() - .await; + delete_hidden_service_dir(app_id).await; info!(app = app_id, "Disabled Tor access — removed hidden service dir"); } - // Restart Tor to apply changes — try system service first, then container - let system_ok = tokio::process::Command::new("sudo") - .args(["systemctl", "restart", "tor"]) - .status() - .await - .map(|s| s.success()) - .unwrap_or(false); + // Regenerate torrc and restart Tor + regenerate_torrc(&config).await?; + restart_tor().await?; - if !system_ok { - let container_ok = tokio::process::Command::new("podman") - .args(["restart", "archy-tor"]) - .status() - .await - .map(|s| s.success()) - .unwrap_or(false); - if !container_ok { - warn!("Failed to restart Tor after toggle"); - } - } - - // If enabling, wait for hostname to appear let new_onion = if enabled { - wait_for_hostname(app_id, 60).await + let onion = wait_for_hostname(app_id, 60).await; + if let Some(ref addr) = onion { + sync_single_hostname(app_id, addr).await; + } + onion } else { + let hostnames_dir = self.config.data_dir.join("tor-hostnames"); + let _ = tokio::fs::remove_file(hostnames_dir.join(app_id)).await; None }; @@ -380,16 +359,212 @@ impl RpcHandler { "onion_address": new_onion, })) } + + /// Restart Tor daemon (system or container). + pub(super) async fn handle_tor_restart( + &self, + ) -> Result { + info!("Manual Tor restart requested"); + + // Before restarting, sync the torrc from services.json + let config_dir = self.config.data_dir.join("tor-config"); + let config = load_services_config(&config_dir).await; + regenerate_torrc(&config).await?; + + restart_tor().await?; + + // Give Tor a moment to stabilize, then sync all hostnames + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + sync_all_hostname_copies(&config).await; + + let running = check_tor_running().await; + Ok(serde_json::json!({ "restarted": true, "tor_running": running })) + } } +// ─── Validation ─────────────────────────────────────────────────── + +fn validate_service_name(name: &str) -> Result<()> { + if name.is_empty() || name.len() > 64 + || !name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') + { + return Err(anyhow::anyhow!("Invalid service name (alphanumeric, hyphens, underscores only)")); + } + Ok(()) +} + +// ─── Tor Daemon Control ────────────────────────────────────────── +// +// The backend runs under NoNewPrivileges=yes (systemd hardening), so it +// cannot call sudo. Privileged Tor operations use an action-file pattern: +// +// 1. Backend writes JSON to /var/lib/archipelago/tor-config/tor-action +// 2. systemd path unit (archipelago-tor-helper.path) detects the file +// 3. systemd oneshot service runs tor-helper.sh as root +// 4. tor-helper.sh processes the action and writes a result file +// 5. Backend polls for the result file +// +// Install: deploy script creates the path unit, service unit, and helper script. + +const TOR_ACTION_FILE: &str = "/var/lib/archipelago/tor-config/tor-action"; +const TOR_RESULT_FILE: &str = "/var/lib/archipelago/tor-config/tor-result"; + +/// Write an action file and wait for the tor-helper service to process it. +async fn dispatch_tor_action(action: serde_json::Value) -> Result<()> { + // Remove stale result + let _ = tokio::fs::remove_file(TOR_RESULT_FILE).await; + + // Write the action file (triggers the systemd path unit) + let content = serde_json::to_string(&action).context("Failed to serialize tor action")?; + let config_dir = Path::new(TOR_ACTION_FILE).parent().unwrap(); + tokio::fs::create_dir_all(config_dir).await.ok(); + tokio::fs::write(TOR_ACTION_FILE, &content) + .await + .context("Failed to write tor-action file")?; + + // Poll for result (up to 90s — Tor restart + hostname generation can be slow) + for _ in 0..90 { + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + if let Ok(result_str) = tokio::fs::read_to_string(TOR_RESULT_FILE).await { + let _ = tokio::fs::remove_file(TOR_RESULT_FILE).await; + if let Ok(result) = serde_json::from_str::(&result_str) { + if result.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) { + return Ok(()); + } + let err = result.get("error").and_then(|v| v.as_str()).unwrap_or("Unknown error"); + return Err(anyhow::anyhow!("Tor helper: {}", err)); + } + return Ok(()); + } + } + Err(anyhow::anyhow!("Tor helper timed out — is archipelago-tor-helper.path enabled?")) +} + +/// Delete a hidden service directory via tor-helper. +async fn delete_hidden_service_dir(name: &str) { + if let Err(e) = dispatch_tor_action(serde_json::json!({ + "action": "delete-service", + "name": name, + })).await { + warn!("Failed to delete hidden service dir for {}: {}", name, e); + } +} + +/// Write staged torrc and restart Tor. +async fn restart_tor() -> Result<()> { + dispatch_tor_action(serde_json::json!({ + "action": "write-torrc-and-restart", + })).await +} + +/// Check if Tor SOCKS port is responding. +async fn check_tor_running() -> bool { + tokio::net::TcpStream::connect("127.0.0.1:9050") + .await + .is_ok() +} + +// ─── torrc Generation ──────────────────────────────────────────── + +/// Detect which base directory has existing hidden services. +fn detect_hidden_service_base() -> String { + // Check /var/lib/tor first (system Tor default, AppArmor-safe) + if Path::new("/var/lib/tor/hidden_service_archipelago").exists() { + return "/var/lib/tor".to_string(); + } + // Fall back to custom dir + let custom = tor_data_dir(); + if Path::new(&custom).join("hidden_service_archipelago").exists() { + return custom; + } + // Default to system Tor + "/var/lib/tor".to_string() +} + +/// Regenerate /etc/tor/torrc from services config. +/// Only enabled services get HiddenServiceDir entries. +/// Uses a helper script to write to root-owned paths (avoids NoNewPrivileges issues). +async fn regenerate_torrc(config: &ServicesConfig) -> Result<()> { + let base = detect_hidden_service_base(); + let mut lines = Vec::new(); + + lines.push("# Auto-generated by Archipelago — do not edit manually".to_string()); + lines.push("SocksPort 9050".to_string()); + lines.push("# ControlPort disabled for security".to_string()); + lines.push(String::new()); + + for svc in &config.services { + if !svc.enabled { + continue; + } + let dir = format!("{}/hidden_service_{}", base, svc.name); + lines.push(format!("HiddenServiceDir {}", dir)); + + if is_protocol_service(&svc.name) { + // Protocol: direct port mapping (Bitcoin P2P, LND, Electrumx) + let remote_port = svc.remote_port.unwrap_or(svc.local_port); + lines.push(format!("HiddenServicePort {} 127.0.0.1:{}", remote_port, svc.local_port)); + if svc.name == "lnd" { + lines.push("HiddenServicePort 9735 127.0.0.1:9735".to_string()); + lines.push("HiddenServicePort 10009 127.0.0.1:10009".to_string()); + } + } else { + // Web app: map port 80 → local port (access via app.onion without specifying port) + lines.push(format!("HiddenServicePort 80 127.0.0.1:{}", svc.local_port)); + } + + lines.push(String::new()); + } + + let content = lines.join("\n"); + + // Write to staging file (owned by archipelago user, readable by tor-helper) + let staging = "/var/lib/archipelago/tor-config/torrc.staged"; + let config_dir = Path::new(staging).parent().unwrap(); + tokio::fs::create_dir_all(config_dir).await.ok(); + tokio::fs::write(staging, &content).await.context("Failed to write staged torrc")?; + + debug!("Staged torrc with {} enabled services", + config.services.iter().filter(|s| s.enabled).count()); + + Ok(()) +} + +// ─── Hostname Sync ─────────────────────────────────────────────── + +/// Copy a single hostname to the readable tor-hostnames directory. +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); + return; + } + if let Err(e) = tokio::fs::write(hostnames_dir.join(name), address).await { + warn!("Failed to write tor-hostname copy for {}: {}", name, e); + } +} + +/// Sync all hostname copies from hidden service dirs. +async fn sync_all_hostname_copies(config: &ServicesConfig) { + for svc in &config.services { + if !svc.enabled { + continue; + } + if let Some(addr) = read_onion_address(&svc.name) { + sync_single_hostname(&svc.name, &addr).await; + } + } +} + +// ─── Service Listing ───────────────────────────────────────────── + /// List all hidden services by scanning the filesystem and merging with config. async fn list_services(config_dir: &std::path::Path) -> Result> { - let base = tor_data_dir(); + let base = detect_hidden_service_base(); let config = load_services_config(config_dir).await; let mut services = Vec::new(); let mut seen = std::collections::HashSet::new(); - // First, add services from config for entry in &config.services { let onion = read_onion_address(&entry.name); seen.insert(entry.name.clone()); @@ -398,16 +573,20 @@ async fn list_services(config_dir: &std::path::Path) -> Result> local_port: entry.local_port, onion_address: onion, enabled: entry.enabled, + unauthenticated: entry.unauthenticated, + protocol: is_protocol_service(&entry.name), }); } - // Then, scan filesystem for any hidden_service_* dirs not in config - // Check both /var/lib/tor/ and /var/lib/archipelago/tor/ + // Scan filesystem for any hidden_service_* dirs not in config for scan_dir in ["/var/lib/tor", &base] { if let Ok(entries) = std::fs::read_dir(scan_dir) { for entry in entries.flatten() { let name = entry.file_name().to_string_lossy().to_string(); - if name.starts_with("hidden_service_") && entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { + if name.starts_with("hidden_service_") + && !name.contains("_old_") + && entry.file_type().map(|t| t.is_dir()).unwrap_or(false) + { let service_name = name.strip_prefix("hidden_service_").unwrap_or(&name).to_string(); if seen.contains(&service_name) { continue; @@ -415,11 +594,14 @@ async fn list_services(config_dir: &std::path::Path) -> Result> let onion = read_onion_address(&service_name); let port = known_service_port(&service_name); seen.insert(service_name.clone()); + let is_proto = is_protocol_service(&service_name); services.push(TorService { name: service_name, local_port: port, onion_address: onion, enabled: true, + unauthenticated: is_proto, // protocol services are inherently unauthenticated + protocol: is_proto, }); } } @@ -429,48 +611,31 @@ async fn list_services(config_dir: &std::path::Path) -> Result> Ok(services) } +// ─── Onion Address Reading ─────────────────────────────────────── + /// Read .onion address from hostname file. /// Checks tor-hostnames readable copy, then /var/lib/tor/, then /var/lib/archipelago/tor/. fn read_onion_address(service_name: &str) -> Option { - let base = tor_data_dir(); - let base_path = std::path::Path::new(&base); - // Try readable hostname copy first (system Tor owns hidden_service dirs at 0700) - let hostnames_dir = base_path - .parent() - .unwrap_or(std::path::Path::new("/var/lib/archipelago")) - .join("tor-hostnames") - .join(service_name); - if let Some(addr) = std::fs::read_to_string(&hostnames_dir) - .ok() - .map(|s| s.trim().to_string()) - .filter(|s| s.ends_with(".onion") && s.len() == 62 && !s.contains(':') && s.chars().take(56).all(|c| c.is_ascii_alphanumeric())) - { + let hostnames_path = Path::new("/var/lib/archipelago/tor-hostnames").join(service_name); + if let Some(addr) = read_and_validate_onion(&hostnames_path) { return Some(addr); } - // Check both /var/lib/tor/ (AppArmor-safe default) and /var/lib/archipelago/tor/ - let search_bases = [ - std::path::PathBuf::from("/var/lib/tor"), - base_path.to_path_buf(), - ]; - for search_base in &search_bases { - let path = search_base + // Check both /var/lib/tor/ and /var/lib/archipelago/tor/ + let base = tor_data_dir(); + for search_base in &["/var/lib/tor", base.as_str()] { + let path = Path::new(search_base) .join(format!("hidden_service_{}", service_name)) .join("hostname"); - if let Some(addr) = std::fs::read_to_string(&path) - .ok() - .or_else(|| { - std::process::Command::new("sudo") - .args(["cat", &path.to_string_lossy()]) - .output() - .ok() - .filter(|o| o.status.success()) - .and_then(|o| String::from_utf8(o.stdout).ok()) - }) - .map(|s| s.trim().to_string()) - .filter(|s| s.ends_with(".onion") && s.len() == 62 && !s.contains(':') && s.chars().take(56).all(|c| c.is_ascii_alphanumeric())) - { + + // Try direct read first + if let Some(addr) = read_and_validate_onion(&path) { + return Some(addr); + } + + // Try sudo cat (hidden service dirs are 0700 owned by debian-tor) + if let Some(addr) = sudo_read_and_validate_onion(&path) { return Some(addr); } } @@ -478,18 +643,68 @@ fn read_onion_address(service_name: &str) -> Option { None } -/// Known default ports for built-in services. +fn read_and_validate_onion(path: &Path) -> Option { + std::fs::read_to_string(path) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| is_valid_v3_onion(s)) +} + +fn sudo_read_and_validate_onion(path: &Path) -> Option { + std::process::Command::new("sudo") + .args(["cat", &path.to_string_lossy()]) + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .filter(|s| is_valid_v3_onion(s)) +} + +/// Validate v3 onion address: exactly 56 base32 chars + ".onion" +fn is_valid_v3_onion(s: &str) -> bool { + s.len() == 62 + && s.ends_with(".onion") + && !s.contains(':') + && s[..56].chars().all(|c| c.is_ascii_alphanumeric()) +} + +// ─── Known Ports ───────────────────────────────────────────────── + +/// Known default ports for built-in and common services. fn known_service_port(name: &str) -> u16 { match name { "archipelago" => 80, - "lnd" => 8081, - "btcpay" => 23000, + "bitcoin" | "bitcoin-knots" => 8333, + "electrs" | "electrumx" => 50001, + "lnd" => 8080, + "btcpay" | "btcpay-server" | "btcpayserver" => 23000, "mempool" => 4080, "fedimint" => 8175, + "nostr-relay" | "nostr-rs-relay" => 8080, + "searxng" => 8888, + "ollama" => 11434, + "filebrowser" => 8083, + "grafana" => 3000, + "home-assistant" => 8123, + "immich" => 2283, + "photoprism" => 2342, + "penpot" => 9001, + "nginx-proxy-manager" => 81, + "vaultwarden" => 8343, + "indeedhub" => 7777, _ => 0, } } +/// Returns true if the service is a non-HTTP protocol (auth not applicable). +/// These get direct port mapping in torrc. Web apps route through nginx. +fn is_protocol_service(name: &str) -> bool { + matches!(name, "bitcoin" | "bitcoin-knots" | "electrs" | "electrumx" | "lnd") +} + +// ─── Config I/O ────────────────────────────────────────────────── + fn tor_data_dir() -> String { std::env::var("TOR_DATA_DIR").unwrap_or_else(|_| TOR_DATA_DIR.to_string()) } @@ -510,6 +725,8 @@ async fn save_services_config(config_dir: &std::path::Path, config: &ServicesCon Ok(()) } +// ─── Federation Notification ───────────────────────────────────── + /// Notify federation peers of address change (private peer-to-peer only, never public relays). async fn notify_federation_peers_address_change( data_dir: &std::path::Path, @@ -562,6 +779,8 @@ async fn notify_federation_peers_address_change( } } +// ─── Hostname Waiting ──────────────────────────────────────────── + /// Wait for a hostname file to appear after Tor restart (up to max_secs). async fn wait_for_hostname(service_name: &str, max_secs: u64) -> Option { for _ in 0..max_secs { diff --git a/image-recipe/configs/archipelago-tor-helper.path b/image-recipe/configs/archipelago-tor-helper.path new file mode 100644 index 00000000..939d6fe2 --- /dev/null +++ b/image-recipe/configs/archipelago-tor-helper.path @@ -0,0 +1,10 @@ +[Unit] +Description=Watch for Archipelago Tor management actions +After=tor.service + +[Path] +PathExists=/var/lib/archipelago/tor-config/tor-action +MakeDirectory=yes + +[Install] +WantedBy=multi-user.target diff --git a/image-recipe/configs/archipelago-tor-helper.service b/image-recipe/configs/archipelago-tor-helper.service new file mode 100644 index 00000000..d6da47fd --- /dev/null +++ b/image-recipe/configs/archipelago-tor-helper.service @@ -0,0 +1,9 @@ +[Unit] +Description=Process Archipelago Tor management action +After=tor.service + +[Service] +Type=oneshot +ExecStart=/opt/archipelago/scripts/tor-helper.sh +# Runs as root — needs to write /etc/tor/torrc and restart tor.service +User=root diff --git a/neode-ui/src/composables/useMessageToast.ts b/neode-ui/src/composables/useMessageToast.ts index de0a4ac3..c4f3c64e 100644 --- a/neode-ui/src/composables/useMessageToast.ts +++ b/neode-ui/src/composables/useMessageToast.ts @@ -74,7 +74,7 @@ export function useMessageToast() { function dismissToastAndOpenMessages() { toastMessage.value = { show: false, text: '' } markAsRead() - router.push({ path: '/dashboard/web5', query: { tab: 'messages' } }) + router.push('/dashboard/mesh') } return { diff --git a/neode-ui/src/stores/app.ts b/neode-ui/src/stores/app.ts index ec7f501e..2eb0eb5d 100644 --- a/neode-ui/src/stores/app.ts +++ b/neode-ui/src/stores/app.ts @@ -276,6 +276,12 @@ export const useAppStore = defineStore('app', () => { return rpcClient.getMarketplace(url) } + function updateServerName(name: string) { + if (data.value?.['server-info']) { + data.value['server-info'].name = name + } + } + return { // State data, @@ -312,6 +318,7 @@ export const useAppStore = defineStore('app', () => { shutdownServer, getMetrics, getMarketplace, + updateServerName, } }) diff --git a/neode-ui/src/views/Mesh.vue b/neode-ui/src/views/Mesh.vue index cb892f41..68c2f1d0 100644 --- a/neode-ui/src/views/Mesh.vue +++ b/neode-ui/src/views/Mesh.vue @@ -60,9 +60,12 @@ async function loadArchMessages() { } catch { /* silent */ } } +const sendingArch = ref(false) + async function sendArchMessage() { if (!messageText.value.trim()) return sendError.value = '' + sendingArch.value = true try { // Broadcast to all federated peers over Tor const nodes = await rpcClient.federationListNodes() @@ -74,12 +77,20 @@ async function sendArchMessage() { sent++ } catch { /* some peers may be offline */ } } + // Local echo — show the sent message immediately + archMessages.value.push({ + from_pubkey: 'me', + message: msg, + timestamp: new Date().toISOString(), + }) messageText.value = '' - if (sent === 0) sendError.value = 'No peers reachable' - // Reload to see the message - setTimeout(loadArchMessages, 2000) + if (sent === 0) sendError.value = 'No peers reachable — message may arrive when they come online' + // Also reload in background to pick up any replies + setTimeout(loadArchMessages, 5000) } catch (e) { sendError.value = e instanceof Error ? e.message : 'Send failed' + } finally { + sendingArch.value = false } } @@ -314,8 +325,8 @@ const chatMessages = computed(() => { return archMessages.value.map((m, i) => ({ id: i, peer_contact_id: -99, - peer_name: m.from_pubkey.slice(0, 12) + '...', - direction: 'received' as const, + peer_name: m.from_pubkey === 'me' ? 'You' : (m.from_pubkey.slice(0, 12) + '...'), + direction: (m.from_pubkey === 'me' ? 'sent' : 'received') as 'sent' | 'received', plaintext: m.message, timestamp: m.timestamp, delivered: true, @@ -747,10 +758,10 @@ function truncatePubkey(hex: string | null): string { /> diff --git a/neode-ui/src/views/PeerFiles.vue b/neode-ui/src/views/PeerFiles.vue index db71a60f..8ffe1ed9 100644 --- a/neode-ui/src/views/PeerFiles.vue +++ b/neode-ui/src/views/PeerFiles.vue @@ -31,7 +31,8 @@ diff --git a/neode-ui/src/views/Server.vue b/neode-ui/src/views/Server.vue index 9de6d6c2..9d32012b 100644 --- a/neode-ui/src/views/Server.vue +++ b/neode-ui/src/views/Server.vue @@ -275,7 +275,7 @@ -
+
@@ -326,8 +326,8 @@
-
-
+
+

Tor Services

@@ -335,17 +335,17 @@
- - +
@@ -354,9 +354,16 @@
-

{{ svc.name }}

+
+

{{ svc.name }}

+ :{{ svc.local_port }} + protocol + auth + open +

{{ svc.onion_address }}

-

No .onion address

+

Waiting for .onion address...

+

Disabled

+
+ + + +
+
+

Add Tor Hidden Service

+ + +
+ + +
+ + +
+

Select an installed app to create a .onion address for it.

+
+ All installed apps already have Tor services. +
+
+ +
+
+ + +
+

Create a .onion address for any local service.

+
+
+ + +
+
+ + +
+
+
+ + +
+
+ +

{{ addServiceError }}

+
+
+
@@ -511,8 +623,11 @@