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
parent f7872e2914
commit cffcc9f665
15 changed files with 904 additions and 250 deletions

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,

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")

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 {

View File

@ -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

View File

@ -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

View File

@ -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 {

View File

@ -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,
}
})

View File

@ -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 {
/>
<button
class="glass-button mesh-chat-send-btn"
:disabled="!messageText.trim() || mesh.sending"
:disabled="!messageText.trim() || mesh.sending || sendingArch"
@click="handleSendMessage"
>
{{ mesh.sending ? '...' : 'Send' }}
{{ (mesh.sending || sendingArch) ? '...' : 'Send' }}
</button>
</div>
</div>

View File

@ -31,7 +31,8 @@
</div>
<div class="hidden md:block">
<h1 class="text-2xl font-bold text-white">{{ peerDisplayName }}</h1>
<p class="text-sm text-white/50">{{ currentPeer?.onion || 'Peer files' }}</p>
<p v-if="currentPeer?.did" class="text-sm text-white/50 font-mono truncate max-w-md" :title="currentPeer.did">{{ currentPeer.did }}</p>
<p v-else class="text-sm text-white/50">Peer files</p>
</div>
</div>
</div>

View File

@ -275,7 +275,7 @@
</div>
</div>
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6 mb-6">
<div class="grid grid-cols-1 2xl:grid-cols-2 gap-6 mb-6">
<!-- Network Interfaces -->
<div class="glass-card p-6">
<div class="flex items-center justify-between mb-4">
@ -326,8 +326,8 @@
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="relative shrink-0">
<div class="w-3 h-3 rounded-full" :class="torRunning ? 'bg-green-400' : 'bg-red-400'"></div>
<div v-if="torRunning" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-ping opacity-75"></div>
<div class="w-3 h-3 rounded-full" :class="torDaemonRunning ? 'bg-green-400' : 'bg-red-400'"></div>
<div v-if="torDaemonRunning" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-ping opacity-75"></div>
</div>
<div>
<h2 class="text-xl font-semibold text-white/96">Tor Services</h2>
@ -335,17 +335,17 @@
</div>
</div>
<div class="flex items-center gap-2">
<button @click="cleanupRotatedServices" :disabled="torCleaning" class="glass-button px-4 py-2 rounded-lg text-sm flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
{{ torCleaning ? 'Cleaning...' : 'Cleanup Old' }}
</button>
<button @click="loadTorServices" class="glass-button px-4 py-2 rounded-lg text-sm flex items-center gap-2">
<button @click="restartTor" :disabled="torRestarting" class="glass-button px-3 py-2 rounded-lg text-sm flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Refresh
{{ torRestarting ? 'Restarting...' : 'Restart Tor' }}
</button>
<button @click="showAddServiceModal = true" class="glass-button px-3 py-2 rounded-lg text-sm flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Add Service
</button>
</div>
</div>
@ -354,9 +354,16 @@
<div v-else class="space-y-2">
<div v-for="svc in torServices" :key="svc.name" class="bg-black/20 rounded-xl border border-white/10 p-3 flex items-center justify-between gap-3">
<div class="flex-1 min-w-0">
<p class="text-white text-sm font-medium">{{ svc.name }}</p>
<div class="flex items-center gap-2">
<p class="text-white text-sm font-medium">{{ svc.name }}</p>
<span class="text-white/30 text-xs">:{{ svc.local_port }}</span>
<span v-if="svc.protocol" class="text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400">protocol</span>
<span v-else-if="!svc.unauthenticated" class="text-xs px-1.5 py-0.5 rounded bg-green-500/20 text-green-400">auth</span>
<span v-else class="text-xs px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-400">open</span>
</div>
<p v-if="svc.onion_address" class="text-amber-300/80 text-xs font-mono truncate cursor-pointer" :title="svc.onion_address" @click="copyTorAddress(svc.onion_address)">{{ svc.onion_address }}</p>
<p v-else class="text-white/30 text-xs">No .onion address</p>
<p v-else-if="svc.enabled" class="text-white/30 text-xs">Waiting for .onion address...</p>
<p v-else class="text-white/30 text-xs">Disabled</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<button
@ -367,11 +374,116 @@
>
{{ torRotating === svc.name ? 'Rotating...' : 'Rotate' }}
</button>
<button
v-if="svc.name !== 'archipelago'"
@click="deleteService(svc.name)"
:disabled="torDeleting === svc.name"
class="glass-button px-2 py-1.5 rounded-lg text-xs text-red-400 hover:text-red-300"
:title="'Delete ' + svc.name + ' hidden service'"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
<ToggleSwitch :model-value="svc.enabled" @update:model-value="toggleTorApp(svc.name, $event)" />
</div>
</div>
</div>
</div>
<!-- Add Tor Service Modal -->
<Teleport to="body">
<div v-if="showAddServiceModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="showAddServiceModal = false" @keydown.escape="showAddServiceModal = false">
<div class="glass-card p-6 max-w-md w-full">
<h3 class="text-lg font-semibold text-white mb-4">Add Tor Hidden Service</h3>
<!-- Tabs: Installed Apps | Manual -->
<div class="flex gap-1 mb-4 border-b border-white/10">
<button
@click="addServiceTab = 'apps'"
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors"
:class="addServiceTab === 'apps' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
>
Installed Apps
</button>
<button
@click="addServiceTab = 'manual'"
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors"
:class="addServiceTab === 'manual' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
>
Manual
</button>
</div>
<!-- Installed Apps tab -->
<div v-if="addServiceTab === 'apps'">
<p class="text-white/60 text-sm mb-3">Select an installed app to create a .onion address for it.</p>
<div v-if="availableAppsForTor.length === 0" class="p-4 text-center text-white/40 text-sm">
All installed apps already have Tor services.
</div>
<div v-else class="space-y-2 max-h-64 overflow-y-auto">
<button
v-for="app in availableAppsForTor"
:key="app.id"
@click="createServiceForApp(app.id)"
:disabled="addingService"
class="w-full flex items-center justify-between p-3 bg-white/5 rounded-lg hover:bg-white/10 transition-colors text-left"
>
<div class="min-w-0">
<p class="text-sm font-medium text-white">{{ app.title }}</p>
<p class="text-xs text-white/40">{{ app.id }}</p>
</div>
<span class="text-xs text-orange-400 shrink-0">+ Add</span>
</button>
</div>
</div>
<!-- Manual tab -->
<div v-if="addServiceTab === 'manual'">
<p class="text-white/60 text-sm mb-3">Create a .onion address for any local service.</p>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-white/80 mb-1">Service Name</label>
<input
v-model="newServiceName"
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
placeholder="my-app"
maxlength="64"
/>
</div>
<div>
<label class="block text-sm font-medium text-white/80 mb-1">Local Port</label>
<input
v-model.number="newServicePort"
type="number"
min="1"
max="65535"
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
placeholder="8080"
/>
</div>
</div>
<div class="flex gap-3 mt-6">
<button
@click="createService"
:disabled="!newServiceName.trim() || !newServicePort || addingService"
class="flex-1 px-4 py-2 rounded-lg bg-orange-500 text-white font-medium hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{{ addingService ? 'Creating...' : 'Create Service' }}
</button>
<button
@click="showAddServiceModal = false"
class="px-4 py-2 rounded-lg bg-white/10 text-white font-medium hover:bg-white/20 transition-colors"
>
Cancel
</button>
</div>
</div>
<p v-if="addServiceError" class="mt-3 text-sm text-red-400">{{ addServiceError }}</p>
</div>
</div>
</Teleport>
</div>
<!-- WiFi Scan Modal -->
@ -511,8 +623,11 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import { useAppStore } from '@/stores/app'
import ToggleSwitch from '@/components/ToggleSwitch.vue'
const appStore = useAppStore()
// Connected nodes
const connectedNodes = ref(0)
@ -797,28 +912,52 @@ interface TorServiceInfo {
local_port: number
onion_address: string | null
enabled: boolean
unauthenticated: boolean
protocol: boolean
}
const torServices = ref<TorServiceInfo[]>([])
const torServicesLoading = ref(false)
const torCleaning = ref(false)
const torDaemonRunning = ref(false)
const torRestarting = ref(false)
const torRotating = ref<string | false>(false)
const torDeleting = ref<string | false>(false)
const torRunning = computed(() => torServices.value.length > 0 && torServices.value.some(s => s.onion_address))
// Add service modal
const showAddServiceModal = ref(false)
const addServiceTab = ref<'apps' | 'manual'>('apps')
const newServiceName = ref('')
const newServicePort = ref<number | null>(null)
const addingService = ref(false)
const addServiceError = ref('')
// Installed apps that don't already have a Tor service
const availableAppsForTor = computed(() => {
const existingNames = new Set(torServices.value.map(s => s.name))
const pkgs = appStore.packages
return Object.entries(pkgs)
.filter(([id]) => !existingNames.has(id))
.map(([id, pkg]) => ({
id,
title: (pkg as { manifest?: { title?: string } })?.manifest?.title || id,
}))
.sort((a, b) => a.title.localeCompare(b.title))
})
async function loadTorServices() {
torServicesLoading.value = true
try {
const res = await rpcClient.call<{ services: TorServiceInfo[] }>({ method: 'tor.list-services' })
const res = await rpcClient.call<{ services: TorServiceInfo[]; tor_running: boolean }>({ method: 'tor.list-services' })
torServices.value = res.services || []
torDaemonRunning.value = res.tor_running ?? false
} catch {
torServices.value = []
torDaemonRunning.value = false
} finally {
torServicesLoading.value = false
}
}
const torRotating = ref<string | false>(false)
function copyTorAddress(address: string) {
navigator.clipboard.writeText(address)
logsToast.value = 'Onion address copied to clipboard'
@ -827,7 +966,7 @@ function copyTorAddress(address: string) {
async function toggleTorApp(appId: string, enabled: boolean) {
try {
await rpcClient.call({ method: 'tor.toggle-app', params: { app_id: appId, enabled } })
await rpcClient.call({ method: 'tor.toggle-app', params: { app_id: appId, enabled }, timeout: 90000 })
await loadTorServices()
} catch (e) {
if (import.meta.env.DEV) console.warn('Failed to toggle Tor app:', e)
@ -837,7 +976,7 @@ async function toggleTorApp(appId: string, enabled: boolean) {
async function rotateService(name: string) {
torRotating.value = name
try {
await rpcClient.call({ method: 'tor.rotate-service', params: { name } })
await rpcClient.call({ method: 'tor.rotate-service', params: { name }, timeout: 90000 })
await loadTorServices()
} catch (e) {
if (import.meta.env.DEV) console.warn('Failed to rotate Tor address:', e)
@ -846,15 +985,71 @@ async function rotateService(name: string) {
}
}
async function cleanupRotatedServices() {
torCleaning.value = true
async function restartTor() {
torRestarting.value = true
try {
await rpcClient.call({ method: 'tor.cleanup-rotated' })
await rpcClient.call({ method: 'tor.restart', timeout: 90000 })
await loadTorServices()
logsToast.value = 'Tor restarted successfully'
setTimeout(() => { logsToast.value = '' }, 3000)
} catch (e) {
if (import.meta.env.DEV) console.warn('Failed to cleanup rotated Tor services:', e)
logsToast.value = 'Failed to restart Tor'
setTimeout(() => { logsToast.value = '' }, 5000)
if (import.meta.env.DEV) console.warn('Failed to restart Tor:', e)
} finally {
torCleaning.value = false
torRestarting.value = false
}
}
async function createServiceForApp(appId: string) {
addServiceError.value = ''
addingService.value = true
try {
// Backend knows the port from known_service_port() pass 0 to let it auto-detect
await rpcClient.call({ method: 'tor.create-service', params: { name: appId, local_port: 0 }, timeout: 90000 })
showAddServiceModal.value = false
await loadTorServices()
logsToast.value = `Tor service for "${appId}" created`
setTimeout(() => { logsToast.value = '' }, 3000)
} catch (e) {
addServiceError.value = e instanceof Error ? e.message : 'Failed to create service'
} finally {
addingService.value = false
}
}
async function createService() {
const name = newServiceName.value.trim()
const port = newServicePort.value
if (!name || !port) return
addServiceError.value = ''
addingService.value = true
try {
await rpcClient.call({ method: 'tor.create-service', params: { name, local_port: port }, timeout: 90000 })
showAddServiceModal.value = false
newServiceName.value = ''
newServicePort.value = null
await loadTorServices()
logsToast.value = `Tor service "${name}" created`
setTimeout(() => { logsToast.value = '' }, 3000)
} catch (e) {
addServiceError.value = e instanceof Error ? e.message : 'Failed to create service'
} finally {
addingService.value = false
}
}
async function deleteService(name: string) {
torDeleting.value = name
try {
await rpcClient.call({ method: 'tor.delete-service', params: { name }, timeout: 90000 })
await loadTorServices()
logsToast.value = `Tor service "${name}" deleted`
setTimeout(() => { logsToast.value = '' }, 3000)
} catch (e) {
if (import.meta.env.DEV) console.warn('Failed to delete Tor service:', e)
} finally {
torDeleting.value = false
}
}

View File

@ -1125,6 +1125,8 @@ async function saveServerName() {
}
try {
await rpcClient.call({ method: 'server.set-name', params: { name } })
// Optimistically update the store so UI reflects the change immediately
store.updateServerName(name)
} catch (e) {
if (import.meta.env.DEV) console.error('Failed to rename server:', e)
}

View File

@ -156,7 +156,7 @@
Nodes
</button>
<button
@click="showSendMessageModal = true"
@click="router.push('/dashboard/mesh')"
class="flex-1 px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
>
Message
@ -678,7 +678,7 @@
</div>
<!-- Peers tab -->
<div v-show="nodesContainerTab === 'peers'" class="space-y-2 max-h-48 overflow-y-auto">
<div v-show="nodesContainerTab === 'peers'" class="space-y-2 flex-1 overflow-y-auto">
<div v-if="peers.length === 0" class="p-4 text-center text-white/60 text-sm">
{{ t('web5.noPeers') }}
</div>
@ -695,7 +695,7 @@
</div>
</div>
<button
@click="showSendMessageModal = true; sendMessageTo = p.onion"
@click="router.push('/dashboard/mesh')"
class="px-2 py-1 text-xs rounded bg-orange-500/20 text-orange-400 hover:bg-orange-500/30 transition-colors shrink-0"
>
{{ t('web5.message') }}
@ -704,7 +704,7 @@
</div>
<!-- Messages tab -->
<div v-show="nodesContainerTab === 'messages'" class="space-y-2 max-h-64 overflow-y-auto">
<div v-show="nodesContainerTab === 'messages'" class="space-y-2 flex-1 overflow-y-auto">
<div v-if="loadingMessages" class="p-4 text-center text-white/60 text-sm">
{{ t('common.loading') }}
</div>
@ -717,7 +717,7 @@
class="p-3 bg-white/5 rounded-lg border-l-2 border-orange-500/50"
>
<div class="flex items-center justify-between gap-2 mb-1">
<p class="text-xs font-mono text-white/60 truncate" :title="m.from_pubkey">{{ (m.from_pubkey || '').slice(0, 16) }}...</p>
<p class="text-xs font-mono text-white/60 truncate" :title="m.from_pubkey">{{ peerNameFromPubkey(m.from_pubkey) }}</p>
<span class="text-xs text-white/40 shrink-0">{{ formatMessageTime(m.timestamp) }}</span>
</div>
<p class="text-sm text-white/90 break-words">{{ m.message }}</p>
@ -725,7 +725,7 @@
</div>
<!-- Requests tab -->
<div v-show="nodesContainerTab === 'requests'" class="space-y-2 max-h-64 overflow-y-auto">
<div v-show="nodesContainerTab === 'requests'" class="space-y-2 flex-1 overflow-y-auto">
<div v-if="loadingRequests" class="p-4 text-center text-white/60 text-sm">
{{ t('common.loading') }}
</div>
@ -739,7 +739,7 @@
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<p class="text-xs font-mono text-white/70 truncate" :title="req.from_did">{{ req.from_did }}</p>
<p class="text-xs font-mono text-white/70 truncate" :title="req.from_did">{{ peerNameFromPubkey(req.from_did) }}</p>
<p v-if="req.message" class="text-sm text-white/80 mt-1 break-words">{{ req.message }}</p>
<p class="text-xs text-white/40 mt-1">{{ formatMessageTime(req.created_at) }}</p>
</div>
@ -2647,6 +2647,12 @@ const peerReachableLocal = ref<Record<string, boolean>>({})
const peerReachable = computed(() => ({ ...appStore.peerHealth, ...peerReachableLocal.value }))
const connectedNodesCount = computed(() => peers.value.length)
function peerNameFromPubkey(pubkey: string): string {
const peer = peers.value.find(p => p.pubkey === pubkey || p.onion === pubkey)
if (peer?.name) return peer.name
return (pubkey || '').slice(0, 16) + '...'
}
// Hardware wallet detection
interface HwWalletDevice {
type: string

View File

@ -85,45 +85,26 @@ fix_orphaned_conmon() {
$fixed && return 0 || return 1
}
# ── Fix 3: System tor conflict ───────────────────────────────
# ── Fix 3: Ensure system Tor is running (preferred over container) ──
fix_system_tor_conflict() {
# Only relevant if we have a container tor on host network
local has_container_tor=false
# System Tor is preferred over container Tor.
# If archy-tor container exists, remove it and use system Tor instead.
if podman ps -a --format '{{.Names}}' 2>/dev/null | grep -qE '^archy-tor$'; then
local net_mode
net_mode=$(podman inspect archy-tor --format '{{.HostConfig.NetworkMode}}' 2>/dev/null || true)
if [ "$net_mode" = "host" ]; then
has_container_tor=true
podman stop archy-tor 2>/dev/null || true
podman rm -f archy-tor 2>/dev/null || true
log "Removed archy-tor container (system Tor is preferred)"
fi
# Ensure system Tor is enabled and running
if command -v tor >/dev/null 2>&1; then
if ! systemctl is-active tor@default >/dev/null 2>&1; then
systemctl enable tor tor@default 2>/dev/null || true
systemctl start tor tor@default 2>/dev/null || true
log "Started system Tor"
return 0
fi
fi
if ! $has_container_tor; then
return 1
fi
# Check if system tor is binding port 9050
local system_tor_pid
system_tor_pid=$(ss -tlnp 2>/dev/null | grep ':9050 ' | grep -oP 'pid=\K\d+' | head -1)
if [ -z "$system_tor_pid" ]; then
return 1
fi
# Check if it's the system tor (not container tor)
local exe
exe=$(readlink /proc/"$system_tor_pid"/exe 2>/dev/null || true)
if [[ "$exe" == */tor ]] && ! grep -q "container" /proc/"$system_tor_pid"/cgroup 2>/dev/null; then
log "System tor (pid=$system_tor_pid) conflicts with container tor on port 9050"
systemctl stop tor@default 2>/dev/null || true
systemctl stop tor 2>/dev/null || true
systemctl disable tor@default 2>/dev/null || true
systemctl disable tor 2>/dev/null || true
sleep 2
# Restart container tor now that port is free
podman restart archy-tor 2>/dev/null || true
log "Disabled system tor, restarted container tor"
return 0
fi
return 1
}
@ -147,9 +128,9 @@ fix_tor_permissions() {
done < <(find "$base" -maxdepth 1 -name "hidden_service_*" -type d 2>/dev/null)
done
# If we fixed permissions and tor container exists, restart it
# If we fixed permissions, restart system Tor to pick up the changes
if $fixed; then
podman restart archy-tor 2>/dev/null || true
systemctl restart tor@default 2>/dev/null || true
return 0
fi
return 1

View File

@ -685,8 +685,9 @@ PYEOF
# Fix secrets directory ownership (must be readable by archipelago user, not root)
sudo chown -R archipelago:archipelago /var/lib/archipelago/secrets 2>/dev/null || true
sudo chmod 700 /var/lib/archipelago/secrets 2>/dev/null || true
# Fix any root-owned config files in data dir (dead man's switch, sessions, etc.)
sudo find /var/lib/archipelago -maxdepth 1 -name '*.json' -user root -exec chown archipelago:archipelago {} \; 2>/dev/null || true
# Fix any root-owned files in data dir - dead mans switch, sessions, server-name
sudo find /var/lib/archipelago -maxdepth 1 -name "*.json" -user root -exec chown archipelago:archipelago {} \; 2>/dev/null || true
sudo chown archipelago:archipelago /var/lib/archipelago/server-name 2>/dev/null || true
echo " Data directories OK"
# Rootless podman UID mapping: fix data dir ownership so container processes
@ -716,6 +717,26 @@ PYEOF
scp $SSH_OPTS "$PROJECT_DIR/neode-ui/public/nostr-provider.js" "$TARGET_HOST:/tmp/nostr-provider.js" 2>/dev/null && \
ssh $SSH_OPTS "$TARGET_HOST" 'sudo cp /tmp/nostr-provider.js /opt/archipelago/web-ui/nostr-provider.js && echo " nostr-provider.js deployed"' 2>/dev/null || echo " (nostr-provider.js not found, skipping)"
# Deploy tor-helper: script + systemd path unit for privileged Tor management
progress "Deploying tor-helper"
scp $SSH_OPTS \
"$PROJECT_DIR/scripts/tor-helper.sh" \
"$PROJECT_DIR/image-recipe/configs/archipelago-tor-helper.path" \
"$PROJECT_DIR/image-recipe/configs/archipelago-tor-helper.service" \
"$TARGET_HOST:/tmp/" 2>/dev/null && \
ssh $SSH_OPTS "$TARGET_HOST" '
sudo mkdir -p /opt/archipelago/scripts
sudo cp /tmp/tor-helper.sh /opt/archipelago/scripts/tor-helper.sh
sudo chmod 755 /opt/archipelago/scripts/tor-helper.sh
sudo chown root:root /opt/archipelago/scripts/tor-helper.sh
sudo cp /tmp/archipelago-tor-helper.path /etc/systemd/system/
sudo cp /tmp/archipelago-tor-helper.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable archipelago-tor-helper.path
sudo systemctl start archipelago-tor-helper.path
echo " tor-helper deployed with systemd path unit"
' 2>/dev/null || echo " (tor-helper deploy skipped)"
# Sync nginx config (second pass — includes HTTPS snippets)
scp $SSH_OPTS "$PROJECT_DIR/image-recipe/configs/nginx-archipelago.conf" "$TARGET_HOST:/tmp/nginx-archipelago.conf" 2>/dev/null && \
ssh $SSH_OPTS "$TARGET_HOST" '
@ -1201,32 +1222,54 @@ print("services.json created")
'
fi
# Generate torrc — use /var/lib/tor/ for hidden services (AppArmor-safe)
# Generate torrc from services.json — use /var/lib/tor/ for hidden services
sudo python3 -c '
import json
lines = ["SocksPort 9050", "ControlPort 0", ""]
try:
with open("/var/lib/archipelago/tor/services.json") as f:
cfg = json.load(f)
extra_ports = {"lnd": [8080]} # LND REST API over Tor
import json, os
# Protocol services get direct port mapping; web apps map port 80 to their local port
PROTOCOL_SERVICES = {"bitcoin", "bitcoin-knots", "electrs", "electrumx", "lnd"}
lines = ["# Auto-generated by Archipelago deploy", "SocksPort 9050", "# ControlPort disabled", ""]
# Try reading services config (check both paths for compatibility)
cfg = None
for path in ["/var/lib/archipelago/tor-config/services.json", "/var/lib/archipelago/tor/services.json"]:
try:
with open(path) as f:
cfg = json.load(f)
break
except Exception:
pass
if cfg:
for svc in cfg.get("services", []):
if svc.get("enabled", True):
n = svc["name"]
p = svc["local_port"]
lines.append("HiddenServiceDir /var/lib/tor/hidden_service_%s" % n)
lines.append("HiddenServicePort %d 127.0.0.1:%d" % (p, p))
for ep in extra_ports.get(n, []):
lines.append("HiddenServicePort %d 127.0.0.1:%d" % (ep, ep))
lines.append("")
except Exception:
for n, ports in [("archipelago",[80]),("bitcoin",[8333]),("electrumx",[50001]),("lnd",[9735,8080]),("btcpay",[23000]),("mempool",[4080]),("fedimint",[8175])]:
if not svc.get("enabled", True):
continue
n = svc["name"]
p = svc["local_port"]
lines.append("HiddenServiceDir /var/lib/tor/hidden_service_%s" % n)
for p in ports:
if n in PROTOCOL_SERVICES:
# Protocol: direct port mapping
lines.append("HiddenServicePort %d 127.0.0.1:%d" % (p, p))
if n == "lnd":
lines.append("HiddenServicePort 9735 127.0.0.1:9735")
lines.append("HiddenServicePort 10009 127.0.0.1:10009")
else:
# Web app: map port 80 on .onion to local app port (access via app.onion without port)
lines.append("HiddenServicePort 80 127.0.0.1:%d" % p)
lines.append("")
else:
# Fallback: default services
for n, mappings in [("archipelago",[(80,80)]),("bitcoin",[(8333,8333)]),("electrs",[(50001,50001)]),("lnd",[(8080,8080),(9735,9735),(10009,10009)]),("btcpay",[(80,23000)]),("mempool",[(80,4080)]),("fedimint",[(80,8175)])]:
lines.append("HiddenServiceDir /var/lib/tor/hidden_service_%s" % n)
for remote_p, local_p in mappings:
lines.append("HiddenServicePort %d 127.0.0.1:%d" % (remote_p, local_p))
lines.append("")
with open("/etc/tor/torrc", "w") as f:
f.write("\n".join(lines) + "\n")
print("torrc generated with %d services" % (len(lines) // 3))
enabled = sum(1 for s in (cfg or {}).get("services", []) if s.get("enabled", True))
print("torrc generated with %d services" % (enabled or 7))
'
# Remove any old Tor container (system Tor is preferred)
@ -1238,6 +1281,8 @@ print("torrc generated with %d services" % (len(lines) // 3))
# Use system Tor (preferred — no AppArmor issues with default paths)
if command -v tor >/dev/null 2>&1; then
sudo systemctl enable tor 2>/dev/null
sudo systemctl enable tor@default 2>/dev/null
sudo systemctl restart tor 2>/dev/null
sudo systemctl restart tor@default 2>/dev/null
echo ' Using system Tor daemon'
else
@ -1245,6 +1290,8 @@ print("torrc generated with %d services" % (len(lines) // 3))
sudo apt-get update -qq && sudo apt-get install -y -qq tor 2>/dev/null || true
if command -v tor >/dev/null 2>&1; then
sudo systemctl enable tor 2>/dev/null
sudo systemctl enable tor@default 2>/dev/null
sudo systemctl restart tor 2>/dev/null
sudo systemctl restart tor@default 2>/dev/null
echo ' System Tor installed and started'
else

117
scripts/tor-helper.sh Executable file
View File

@ -0,0 +1,117 @@
#!/bin/bash
# tor-helper.sh — Privileged Tor operations for the Archipelago backend.
# Runs as root via systemd (archipelago-tor-helper.service), triggered by
# a path unit watching /var/lib/archipelago/tor-config/tor-action.
#
# The backend writes a JSON action file, the path unit triggers this script.
# This avoids calling sudo from within a NoNewPrivileges=yes service.
set -euo pipefail
ACTION_FILE="/var/lib/archipelago/tor-config/tor-action"
TORRC_STAGED="/var/lib/archipelago/tor-config/torrc.staged"
RESULT_FILE="/var/lib/archipelago/tor-config/tor-result"
HOSTNAMES_DIR="/var/lib/archipelago/tor-hostnames"
log() { echo "[tor-helper] $*"; }
write_result() {
echo "$1" > "$RESULT_FILE"
chown archipelago:archipelago "$RESULT_FILE" 2>/dev/null || true
}
sync_hostnames() {
mkdir -p "$HOSTNAMES_DIR"
# Clear stale copies first
rm -f "$HOSTNAMES_DIR"/* 2>/dev/null || true
# Prefer /var/lib/tor (system Tor, authoritative) over /var/lib/archipelago/tor
# Only copy from secondary if not already found in primary
for base in /var/lib/tor /var/lib/archipelago/tor; do
for dir in "$base"/hidden_service_*; do
[ -d "$dir" ] || continue
svc=$(basename "$dir" | sed 's/^hidden_service_//')
echo "$svc" | grep -q '_old_' && continue
# Skip if already synced from a higher-priority location
[ -f "${HOSTNAMES_DIR}/${svc}" ] && continue
if [ -f "$dir/hostname" ]; then
cp "$dir/hostname" "${HOSTNAMES_DIR}/${svc}"
log "Synced hostname: $svc ($base)"
fi
done
done
chown -R archipelago:archipelago "$HOSTNAMES_DIR" 2>/dev/null || true
}
# ─── Main ─────────────────────────────────────────────────────────
if [ ! -f "$ACTION_FILE" ]; then
log "No action file found"
exit 0
fi
ACTION=$(cat "$ACTION_FILE")
rm -f "$ACTION_FILE"
ACTION_TYPE=$(echo "$ACTION" | python3 -c "import sys,json; print(json.load(sys.stdin).get('action',''))" 2>/dev/null || echo "")
case "$ACTION_TYPE" in
write-torrc-and-restart)
if [ ! -f "$TORRC_STAGED" ]; then
log "ERROR: No staged torrc at $TORRC_STAGED"
write_result '{"ok":false,"error":"No staged torrc"}'
exit 1
fi
cp "$TORRC_STAGED" /etc/tor/torrc
chown debian-tor:debian-tor /etc/tor/torrc 2>/dev/null || true
log "torrc updated from staged file"
systemctl restart tor
log "Tor restarted"
# Wait for SOCKS port
for i in $(seq 1 30); do
if timeout 1 bash -c 'echo > /dev/tcp/127.0.0.1/9050' 2>/dev/null; then
break
fi
sleep 1
done
sync_hostnames
write_result '{"ok":true}'
;;
restart)
systemctl restart tor
log "Tor restarted"
sleep 3
sync_hostnames
write_result '{"ok":true}'
;;
delete-service)
NAME=$(echo "$ACTION" | python3 -c "import sys,json; print(json.load(sys.stdin).get('name',''))" 2>/dev/null || echo "")
if [ -z "$NAME" ]; then
write_result '{"ok":false,"error":"Missing service name"}'
exit 1
fi
if ! echo "$NAME" | grep -qE '^[a-zA-Z0-9_-]+$'; then
write_result '{"ok":false,"error":"Invalid service name"}'
exit 1
fi
rm -rf "/var/lib/tor/hidden_service_${NAME}" 2>/dev/null || true
rm -rf "/var/lib/archipelago/tor/hidden_service_${NAME}" 2>/dev/null || true
rm -f "${HOSTNAMES_DIR}/${NAME}" 2>/dev/null || true
log "Deleted hidden service: $NAME"
write_result '{"ok":true}'
;;
sync-hostnames)
sync_hostnames
write_result '{"ok":true}'
;;
*)
log "Unknown action: $ACTION_TYPE"
write_result '{"ok":false,"error":"Unknown action"}'
exit 1
;;
esac