fix: Tor management system, bug fixes, federation name sync

Major changes:
- Full Tor hidden service management via systemd path unit pattern
  (tor-helper.sh + archipelago-tor-helper.path/service) — respects
  NoNewPrivileges=yes, no sudo needed from backend
- Container doctor: prefer system Tor over container, remove archy-tor
- Deploy script: fix torrc generation (read correct services.json path),
  web apps map port 80→local port, enable both tor and tor@default
- Federation: server rename pushes name to peers via background sync
- Server name: fix root-owned file, optimistic store update
- Mesh: local echo for sent messages, sendingArch loading state
- Web5: Message button → Mesh redirect, node name lookup in messages
- PeerFiles: show DID not onion in header
- Connected Nodes: flex-1 instead of fixed max-h
- Toast notifications route to Mesh
- Deploy script: fix single-quote syntax in SSH block

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-20 02:59:29 +00:00
co-authored by Claude Opus 4.6
parent f7872e2914
commit cffcc9f665
15 changed files with 904 additions and 250 deletions
+1
View File
@@ -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,
+50 -2
View File
@@ -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<crate::state::StateManager>,
) -> 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::<String>(), 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<f64> {
let content = tokio::fs::read_to_string("/proc/uptime")
+369 -150
View File
@@ -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<String>,
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<u16>,
#[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<serde_json::Value> {
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<serde_json::Value>,
@@ -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<serde_json::Value> {
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::<u64>() {
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<serde_json::Value> {
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::<serde_json::Value>(&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<Vec<TorService>> {
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<Vec<TorService>>
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<Vec<TorService>>
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<Vec<TorService>>
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<String> {
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<String> {
None
}
/// Known default ports for built-in services.
fn read_and_validate_onion(path: &Path) -> Option<String> {
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<String> {
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<String> {
for _ in 0..max_secs {