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:
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")
|
||||
|
||||
@ -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 {
|
||||
|
||||
10
image-recipe/configs/archipelago-tor-helper.path
Normal file
10
image-recipe/configs/archipelago-tor-helper.path
Normal 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
|
||||
9
image-recipe/configs/archipelago-tor-helper.service
Normal file
9
image-recipe/configs/archipelago-tor-helper.service
Normal 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
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
117
scripts/tor-helper.sh
Executable 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
|
||||
Loading…
x
Reference in New Issue
Block a user