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:
co-authored by
Claude Opus 4.6
parent
f7872e2914
commit
cffcc9f665
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user