Update Fedimint configuration and enhance onboarding process

- Upgraded Fedimint version to v0.10.0 in docker-compose.yml and manifest.yml, adding support for the built-in Guardian UI.
- Modified .gitignore to exclude deploy-config.sh script.
- Enhanced onboarding process in AuthManager to persist onboarding state and validate password strength during user setup.
- Updated API to handle onboarding completion and password change requests, ensuring a smoother user experience.
- Improved configuration management to support Nostr discovery and Tor proxy settings, enhancing node identity features.
This commit is contained in:
Dorian
2026-02-17 15:03:34 +00:00
parent 6035c93289
commit 1073d9fd2c
73 changed files with 5870 additions and 478 deletions

749
core/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -43,12 +43,22 @@ archipelago-parmanode = { path = "../parmanode" }
bcrypt = "0.15"
uuid = { version = "1.0", features = ["v4"] }
# Node identity (Ed25519)
ed25519-dalek = { version = "2.1", features = ["rand_core"] }
rand = "0.8"
hex = "0.4"
bs58 = "0.5"
chrono = "0.4"
# Configuration
toml = "0.8"
serde_yaml = "0.9"
# HTTP client (for LND REST proxy)
reqwest = { version = "0.11", features = ["json"] }
# HTTP client (for LND REST proxy, Tor SOCKS for peer messaging)
reqwest = { version = "0.11", features = ["json", "socks"] }
# Nostr (node discovery)
nostr-sdk = "0.44"
[dev-dependencies]
tokio-test = "0.4"

View File

@@ -1,4 +1,6 @@
use crate::api::rpc::RpcHandler;
use crate::electrs_status;
use crate::node_message as node_msg;
use crate::config::Config;
use crate::state::StateManager;
use anyhow::Result;
@@ -20,7 +22,7 @@ pub struct ApiHandler {
impl ApiHandler {
pub async fn new(config: Config, state_manager: Arc<StateManager>) -> Result<Self> {
let rpc_handler = Arc::new(RpcHandler::new(config.clone()).await?);
let rpc_handler = Arc::new(RpcHandler::new(config.clone(), state_manager.clone()).await?);
Ok(Self {
_config: config,
@@ -45,7 +47,7 @@ impl ApiHandler {
let (parts, body) = req.into_parts();
let body_bytes = hyper::body::to_bytes(body).await
.map_err(|e| anyhow::anyhow!("Failed to read body: {}", e))?;
let req_with_bytes = Request::from_parts(parts, hyper::Body::from(body_bytes));
let req_with_bytes = Request::from_parts(parts, hyper::Body::from(body_bytes.clone()));
debug!("{} {}", method, path);
@@ -55,6 +57,10 @@ impl ApiHandler {
.status(StatusCode::OK)
.body(hyper::Body::from("OK"))
.unwrap()),
(Method::POST, "/archipelago/node-message") => {
Self::handle_node_message(body_bytes).await
}
(Method::GET, "/electrs-status") => Self::handle_electrs_status().await,
(Method::GET, path) if path.starts_with("/api/container/logs") => {
Self::handle_container_logs_http(self.rpc_handler.clone(), path).await
}
@@ -116,6 +122,39 @@ impl ApiHandler {
}
}
async fn handle_node_message(body: hyper::body::Bytes) -> Result<Response<hyper::Body>> {
#[derive(serde::Deserialize)]
struct Incoming {
from_pubkey: Option<String>,
message: Option<String>,
}
let incoming: Incoming = serde_json::from_slice(&body).unwrap_or(Incoming {
from_pubkey: None,
message: None,
});
if let (Some(from), Some(msg)) = (incoming.from_pubkey, incoming.message) {
tracing::info!("📩 Received message from {}: {}", from, msg);
node_msg::store_received(&from, &msg).await;
}
Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.header("Access-Control-Allow-Origin", CORS_ANY)
.body(hyper::Body::from(r#"{"ok":true}"#))
.unwrap())
}
async fn handle_electrs_status() -> Result<Response<hyper::Body>> {
let status = electrs_status::get_electrs_sync_status().await;
let body = serde_json::to_vec(&status).unwrap_or_default();
Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.header("Access-Control-Allow-Origin", CORS_ANY)
.body(hyper::Body::from(body))
.unwrap())
}
async fn handle_lnd_proxy(path: &str) -> Result<Response<hyper::Body>> {
let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/");
let url = format!("http://127.0.0.1:8080{}", suffix);

View File

@@ -1,10 +1,17 @@
use crate::auth::AuthManager;
use crate::config::Config;
use crate::container::docker_packages;
use crate::container::DevContainerOrchestrator;
use crate::identity;
use crate::node_message;
use crate::nostr_discovery;
use crate::peers::{self, KnownPeer};
use crate::port_allocator::PortAllocator;
use crate::state::StateManager;
use anyhow::{Context, Result};
use hyper::{Request, Response, StatusCode};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use tracing::{debug, error};
#[derive(Debug, Deserialize)]
@@ -33,10 +40,12 @@ pub struct RpcHandler {
config: Config,
auth_manager: AuthManager,
orchestrator: Option<Arc<DevContainerOrchestrator>>,
state_manager: Arc<StateManager>,
port_allocator: Arc<Mutex<PortAllocator>>,
}
impl RpcHandler {
pub async fn new(config: Config) -> Result<Self> {
pub async fn new(config: Config, state_manager: Arc<StateManager>) -> Result<Self> {
let auth_manager = AuthManager::new(config.data_dir.clone());
let orchestrator = if config.dev_mode {
Some(Arc::new(
@@ -45,11 +54,14 @@ impl RpcHandler {
} else {
None
};
let port_allocator = Arc::new(Mutex::new(PortAllocator::new(&config.data_dir)?));
Ok(Self {
config,
auth_manager,
orchestrator,
state_manager,
port_allocator,
})
}
@@ -73,6 +85,9 @@ impl RpcHandler {
"server.echo" => self.handle_echo(rpc_req.params).await,
"auth.login" => self.handle_auth_login(rpc_req.params).await,
"auth.logout" => self.handle_auth_logout().await,
"auth.changePassword" => self.handle_auth_change_password(rpc_req.params).await,
"auth.onboardingComplete" => self.handle_auth_onboarding_complete().await,
"auth.isOnboardingComplete" => self.handle_auth_is_onboarding_complete().await,
// Container orchestration (for Archipelago-managed containers)
"container-install" => self.handle_container_install(rpc_req.params).await,
@@ -89,11 +104,26 @@ impl RpcHandler {
"package.start" => self.handle_package_start(rpc_req.params).await,
"package.stop" => self.handle_package_stop(rpc_req.params).await,
"package.restart" => self.handle_package_restart(rpc_req.params).await,
"package.uninstall" => self.handle_package_uninstall(rpc_req.params).await,
// Bundled app management (for pre-loaded container images)
"bundled-app-start" => self.handle_bundled_app_start(rpc_req.params).await,
"bundled-app-stop" => self.handle_bundled_app_stop(rpc_req.params).await,
// Node identity and P2P peers
"node-add-peer" => self.handle_node_add_peer(rpc_req.params).await,
"node-list-peers" => self.handle_node_list_peers().await,
"node-remove-peer" => self.handle_node_remove_peer(rpc_req.params).await,
"node-send-message" => self.handle_node_send_message(rpc_req.params).await,
"node-check-peer" => self.handle_node_check_peer(rpc_req.params).await,
"node-messages-received" => self.handle_node_messages_received().await,
"node-nostr-discover" => self.handle_node_nostr_discover().await,
"node.did" => self.handle_node_did().await,
"node.tor-address" => self.handle_node_tor_address().await,
"node.nostr-publish" => self.handle_node_nostr_publish().await,
"node.nostr-pubkey" => self.handle_node_nostr_pubkey().await,
"node-nostr-verify-revoked" => self.handle_node_nostr_verify_revoked().await,
_ => {
Err(anyhow::anyhow!("Unknown method: {}", rpc_req.method))
}
@@ -174,6 +204,103 @@ impl RpcHandler {
Ok(serde_json::Value::Null)
}
async fn handle_auth_change_password(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let current_password = params
.get("currentPassword")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing currentPassword"))?;
let new_password = params
.get("newPassword")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing newPassword"))?;
let also_change_ssh = params
.get("alsoChangeSsh")
.and_then(|v| v.as_bool())
.unwrap_or(true);
self.auth_manager
.change_password(current_password, new_password, also_change_ssh)
.await?;
Ok(serde_json::json!({ "success": true }))
}
async fn handle_auth_onboarding_complete(&self) -> Result<serde_json::Value> {
self.auth_manager.complete_onboarding().await?;
Ok(serde_json::json!(true))
}
async fn handle_auth_is_onboarding_complete(&self) -> Result<serde_json::Value> {
let complete = self.auth_manager.is_onboarding_complete().await?;
Ok(serde_json::json!(complete))
}
async fn handle_node_did(&self) -> Result<serde_json::Value> {
let (data, _) = self.state_manager.get_snapshot().await;
let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
Ok(serde_json::json!({ "did": did, "pubkey": data.server_info.pubkey }))
}
async fn handle_node_tor_address(&self) -> Result<serde_json::Value> {
let tor_address = docker_packages::read_tor_address("archipelago");
Ok(serde_json::json!({ "tor_address": tor_address }))
}
async fn handle_node_nostr_publish(&self) -> Result<serde_json::Value> {
if !self.config.nostr_discovery_enabled || self.config.nostr_relays.is_empty() {
anyhow::bail!(
"Nostr discovery disabled. Set ARCHIPELAGO_NOSTR_DISCOVERY_ENABLED=true and ARCHIPELAGO_NOSTR_RELAYS=wss://... to enable."
);
}
let (data, _) = self.state_manager.get_snapshot().await;
let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
let node_address = data
.server_info
.node_address
.as_deref()
.unwrap_or("archipelago://unknown");
let identity_dir = self.config.data_dir.join("identity");
let output = nostr_discovery::publish_node_identity(
&identity_dir,
&did,
node_address,
&data.server_info.version,
&self.config.nostr_relays,
self.config.nostr_tor_proxy.as_deref(),
)
.await?;
Ok(serde_json::json!({
"event_id": output.id().to_hex(),
"success": output.success.len(),
"failed": output.failed.len(),
}))
}
async fn handle_node_nostr_pubkey(&self) -> Result<serde_json::Value> {
let identity_dir = self.config.data_dir.join("identity");
let pubkey = nostr_discovery::get_nostr_pubkey(&identity_dir).await?;
Ok(serde_json::json!({ "nostr_pubkey": pubkey }))
}
async fn handle_node_nostr_verify_revoked(&self) -> Result<serde_json::Value> {
let identity_dir = self.config.data_dir.join("identity");
let status = nostr_discovery::verify_revocation(
&identity_dir,
self.config.nostr_tor_proxy.as_deref(),
)
.await?;
Ok(serde_json::json!({
"revoked": status.revoked,
"nostr_pubkey": status.nostr_pubkey,
"latest_content": status.latest_content,
"error": status.error,
}))
}
async fn handle_container_install(
&self,
params: Option<serde_json::Value>,
@@ -525,17 +652,34 @@ impl RpcHandler {
];
// App-specific configuration (should come from manifest)
let (ports, volumes, env_vars, custom_command) = get_app_config(package_id);
let (ports, volumes, env_vars, custom_command, custom_args) = {
let mut allocator = self.port_allocator.lock().map_err(|e| {
anyhow::anyhow!("Port allocator lock poisoned: {}", e)
})?;
get_app_config(package_id, &self.config.host_ip, &mut allocator)
};
// Special handling for Tailscale: requires host network and privileged mode
// Special handling: Tailscale needs host network; mempool stack needs archy-net
let is_tailscale = package_id == "tailscale";
let needs_archy_net = matches!(
package_id,
"mempool" | "mempool-web" | "mempool-api" | "mempool-electrs" | "mysql-mempool" | "archy-mempool-db" | "archy-mempool-web"
| "btcpay-server" | "btcpayserver" | "archy-btcpay-db"
);
if is_tailscale {
run_args.push("--network=host");
run_args.push("--privileged");
run_args.push("--cap-add=NET_ADMIN");
run_args.push("--cap-add=NET_RAW");
run_args.push("--device=/dev/net/tun");
} else if needs_archy_net {
// Ensure archy-net exists, then attach
let _ = tokio::process::Command::new("sudo")
.args(["podman", "network", "create", "archy-net"])
.output()
.await;
run_args.push("--network=archy-net");
}
// Create data directories if they don't exist
@@ -591,9 +735,11 @@ impl RpcHandler {
let mut cmd = tokio::process::Command::new("sudo");
cmd.args(&run_args);
// Add custom command if specified (e.g., for Tailscale web UI)
// Add custom command/args if specified (Tailscale: shell override; electrs: CLI args)
if let Some(custom_cmd) = custom_command {
cmd.arg(custom_cmd);
} else if let Some(args) = custom_args {
cmd.args(args);
}
let run_output = cmd
@@ -627,35 +773,22 @@ impl RpcHandler {
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
// Convert package ID to container name (e.g., "bitcoin" -> "archy-bitcoin")
// But also check if container exists without the prefix
let container_name = if let Ok(output) = tokio::process::Command::new("sudo")
.args(["podman", "ps", "-a", "--format", "{{.Names}}", "--filter", &format!("name=^{}$", package_id)])
.output()
.await
{
let stdout = String::from_utf8_lossy(&output.stdout);
if !stdout.trim().is_empty() {
debug!("Found container without prefix: {}", package_id);
package_id.to_string()
} else {
debug!("Using archy- prefix: archy-{}", package_id);
format!("archy-{}", package_id)
}
let containers = get_containers_for_app(package_id).await?;
let to_start: Vec<String> = if containers.is_empty() {
vec![format!("archy-{}", package_id)]
} else {
format!("archy-{}", package_id)
// Start order for mempool: db first, then api, then web
let order = ["archy-mempool-db", "mysql-mempool", "mempool-electrs", "mempool-api", "archy-mempool-api", "archy-mempool-web", "mempool"];
let mut sorted = containers;
sorted.sort_by_key(|c| order.iter().position(|o| *o == c).unwrap_or(99));
sorted
};
// Use podman CLI to start the container
let output = tokio::process::Command::new("sudo")
.args(["podman", "start", &container_name])
.output()
.await
.context("Failed to execute podman start")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow::anyhow!("Failed to start container: {}", stderr));
for name in to_start {
let _ = tokio::process::Command::new("sudo")
.args(["podman", "start", &name])
.output()
.await;
}
Ok(serde_json::Value::Null)
@@ -671,34 +804,22 @@ impl RpcHandler {
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
// Convert package ID to container name
let container_name = if let Ok(output) = tokio::process::Command::new("sudo")
.args(["podman", "ps", "-a", "--format", "{{.Names}}", "--filter", &format!("name=^{}$", package_id)])
.output()
.await
{
let stdout = String::from_utf8_lossy(&output.stdout);
if !stdout.trim().is_empty() {
debug!("Found container without prefix: {}", package_id);
package_id.to_string()
} else {
debug!("Using archy- prefix: archy-{}", package_id);
format!("archy-{}", package_id)
}
} else {
format!("archy-{}", package_id)
};
// Use podman CLI to stop the container
let output = tokio::process::Command::new("sudo")
.args(["podman", "stop", &container_name])
.output()
.await
.context("Failed to execute podman stop")?;
let containers = get_containers_for_app(package_id).await?;
if containers.is_empty() {
// Fallback: try single container
let container_name = format!("archy-{}", package_id);
let _ = tokio::process::Command::new("sudo")
.args(["podman", "stop", &container_name])
.output()
.await;
return Ok(serde_json::Value::Null);
}
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow::anyhow!("Failed to stop container: {}", stderr));
for name in containers {
let _ = tokio::process::Command::new("sudo")
.args(["podman", "stop", &name])
.output()
.await;
}
Ok(serde_json::Value::Null)
@@ -714,39 +835,74 @@ impl RpcHandler {
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
// Convert package ID to container name
let container_name = if let Ok(output) = tokio::process::Command::new("sudo")
.args(["podman", "ps", "-a", "--format", "{{.Names}}", "--filter", &format!("name=^{}$", package_id)])
.output()
.await
{
let stdout = String::from_utf8_lossy(&output.stdout);
if !stdout.trim().is_empty() {
debug!("Found container without prefix: {}", package_id);
package_id.to_string()
} else {
debug!("Using archy- prefix: archy-{}", package_id);
format!("archy-{}", package_id)
}
} else {
format!("archy-{}", package_id)
};
// Use podman CLI to restart the container
let output = tokio::process::Command::new("sudo")
.args(["podman", "restart", &container_name])
.output()
.await
.context("Failed to execute podman restart")?;
let containers = get_containers_for_app(package_id).await?;
if containers.is_empty() {
let container_name = format!("archy-{}", package_id);
let _ = tokio::process::Command::new("sudo")
.args(["podman", "restart", &container_name])
.output()
.await;
return Ok(serde_json::Value::Null);
}
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow::anyhow!("Failed to restart container: {}", stderr));
for name in containers {
let _ = tokio::process::Command::new("sudo")
.args(["podman", "restart", &name])
.output()
.await;
}
Ok(serde_json::Value::Null)
}
/// Uninstall a package: stop and remove all related containers, clean data. No fragments left.
async fn handle_package_uninstall(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let package_id = params
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
let preserve_data = params
.get("preserve_data")
.and_then(|v| v.as_bool())
.unwrap_or(false);
// Get all container names for this app (handles multi-container apps like mempool)
let containers_to_remove = get_containers_for_app(package_id).await?;
for name in &containers_to_remove {
let _ = tokio::process::Command::new("sudo")
.args(["podman", "stop", name])
.output()
.await;
let _ = tokio::process::Command::new("sudo")
.args(["podman", "rm", "-f", name])
.output()
.await;
}
// Release port allocation
if let Ok(mut allocator) = self.port_allocator.lock() {
let _ = allocator.release(package_id);
}
// Clean data directories unless preserve_data
if !preserve_data {
let data_dirs = get_data_dirs_for_app(package_id);
for dir in &data_dirs {
let _ = tokio::process::Command::new("sudo")
.args(["rm", "-rf", dir])
.output()
.await;
}
}
Ok(serde_json::json!({ "status": "uninstalled" }))
}
/// Start a bundled app (create container from pre-loaded image if needed, then start)
async fn handle_bundled_app_start(
&self,
@@ -858,6 +1014,150 @@ impl RpcHandler {
Ok(serde_json::json!({ "status": "stopped", "app_id": app_id }))
}
async fn handle_node_add_peer(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let onion = params
.get("onion")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing onion"))?;
let pubkey = params
.get("pubkey")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing pubkey"))?;
let name = params.get("name").and_then(|v| v.as_str()).map(String::from);
let peer = KnownPeer {
onion: onion.to_string(),
pubkey: pubkey.to_string(),
name,
added_at: Some(chrono::Utc::now().to_rfc3339()),
};
let peers = peers::add_peer(&self.config.data_dir, peer).await?;
Ok(serde_json::json!({ "peers": peers }))
}
async fn handle_node_list_peers(&self) -> Result<serde_json::Value> {
let peers = peers::load_peers(&self.config.data_dir).await?;
Ok(serde_json::json!({ "peers": peers }))
}
async fn handle_node_remove_peer(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let pubkey = params
.get("pubkey")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing pubkey"))?;
let peers = peers::remove_peer(&self.config.data_dir, pubkey).await?;
Ok(serde_json::json!({ "peers": peers }))
}
async fn handle_node_send_message(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let onion = params
.get("onion")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing onion"))?;
let message = params
.get("message")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing message"))?;
let (data, _) = self.state_manager.get_snapshot().await;
let pubkey = data.server_info.pubkey.clone();
node_message::send_to_peer(onion, &pubkey, message).await?;
Ok(serde_json::json!({ "ok": true, "sent_to": onion }))
}
async fn handle_node_check_peer(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let onion = params
.get("onion")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing onion"))?;
let reachable = node_message::check_peer_reachable(onion).await.unwrap_or(false);
Ok(serde_json::json!({ "onion": onion, "reachable": reachable }))
}
async fn handle_node_messages_received(&self) -> Result<serde_json::Value> {
let messages = node_message::get_received();
Ok(serde_json::json!({ "messages": messages }))
}
async fn handle_node_nostr_discover(&self) -> Result<serde_json::Value> {
let identity_dir = self.config.data_dir.join("identity");
let nodes = nostr_discovery::discover_archipelago_nodes(
&identity_dir,
&self.config.nostr_relays,
self.config.nostr_tor_proxy.as_deref(),
)
.await?;
Ok(serde_json::json!({ "nodes": nodes }))
}
}
/// Get all container names for an app (handles multi-container apps like mempool)
async fn get_containers_for_app(package_id: &str) -> Result<Vec<String>> {
let output = tokio::process::Command::new("sudo")
.args(["podman", "ps", "-a", "--format", "{{.Names}}"])
.output()
.await
.context("Failed to list containers")?;
let stdout = String::from_utf8_lossy(&output.stdout);
let all: Vec<&str> = stdout.lines().filter(|s| !s.is_empty()).collect();
// Map app id to container name patterns (support both archy-* and bare names)
let patterns: Vec<String> = match package_id {
"mempool" | "mempool-web" => {
vec![
"mempool-electrs".into(),
"mempool-api".into(),
"archy-mempool-api".into(),
"archy-mempool-web".into(),
"mempool".into(),
"archy-mempool-db".into(),
"mysql-mempool".into(),
]
}
"fedimint" => vec!["fedimint".into(), "fedimint-ui".into(), "archy-fedimint".into()],
_ => vec![package_id.to_string(), format!("archy-{}", package_id)],
};
let mut result = Vec::new();
for name in all {
for pat in &patterns {
if name == pat {
result.push(name.to_string());
break;
}
}
}
Ok(result)
}
/// Get data directories to clean for an app
fn get_data_dirs_for_app(package_id: &str) -> Vec<String> {
let base = "/var/lib/archipelago";
match package_id {
"mempool" | "mempool-web" => vec![
format!("{}/mempool", base),
format!("{}/mysql-mempool", base),
format!("{}/mempool-electrs", base),
],
"fedimint" => vec![format!("{}/fedimint", base)],
_ => vec![format!("{}/{}", base, package_id)],
}
}
/// Validate Docker image name format
@@ -886,106 +1186,204 @@ fn is_valid_docker_image(image: &str) -> bool {
}
/// Get app-specific configuration
/// Returns: (ports, volumes, env_vars, custom_command)
/// Returns: (ports, volumes, env_vars, custom_command, custom_args)
/// custom_command: shell override (e.g. "sh -c '...'"); custom_args: extra args for entrypoint
/// Uses port_allocator for apps with web UIs to avoid conflicts (e.g. Nextcloud vs LND UI).
/// TODO: Load from manifest.yml files in apps/ directory
fn get_app_config(app_id: &str) -> (Vec<String>, Vec<String>, Vec<String>, Option<String>) {
fn get_app_config(
app_id: &str,
host_ip: &str,
allocator: &mut PortAllocator,
) -> (Vec<String>, Vec<String>, Vec<String>, Option<String>, Option<Vec<String>>) {
match app_id {
"homeassistant" | "home-assistant" => (
vec!["8123:8123".to_string()],
vec!["/var/lib/archipelago/home-assistant:/config".to_string()],
vec!["TZ=UTC".to_string()],
None,
None,
),
"bitcoin" | "bitcoin-core" => (
vec!["8332:8332".to_string(), "8333:8333".to_string()],
vec!["/var/lib/archipelago/bitcoin:/bitcoin/.bitcoin".to_string()],
vec![],
None,
None,
),
"lnd" => (
vec!["9735:9735".to_string(), "10009:10009".to_string(), "8080:8080".to_string()],
vec!["/var/lib/archipelago/lnd:/root/.lnd".to_string()],
vec!["BITCOIN_ACTIVE=1".to_string()],
None,
None,
),
"btcpay-server" | "btcpayserver" => (
vec!["23000:49392".to_string()],
vec!["/var/lib/archipelago/btcpay:/datadir".to_string()],
vec![],
vec![
"ASPNETCORE_URLS=http://0.0.0.0:49392".to_string(),
"BTCPAY_PROTOCOL=http".to_string(),
format!("BTCPAY_HOST={}:23000", host_ip),
"BTCPAY_CHAINS=btc".to_string(),
format!("BTCPAY_BTCRPCURL=http://{}:8332", host_ip),
"BTCPAY_BTCRPCUSER=archipelago".to_string(),
"BTCPAY_BTCRPCPASSWORD=archipelago123".to_string(),
"BTCPAY_POSTGRES=User ID=btcpay;Password=btcpaypass;Host=archy-btcpay-db;Port=5432;Database=btcpay;Include Error Detail=true".to_string(),
],
None,
None,
),
"mempool" => (
vec!["8999:8080".to_string()],
"mempool" | "mempool-web" => (
vec!["4080:8080".to_string()],
vec![],
// Frontend proxies to backend at host:8999 (deploy script uses mempool-api when on archy-net)
vec![format!("BACKEND_MAINNET_HTTP_HOST={}", host_ip)],
None,
None,
),
"mempool-api" => (
vec!["8999:8999".to_string()],
vec!["/var/lib/archipelago/mempool:/data".to_string()],
vec![
"MEMPOOL_BACKEND=electrum".to_string(),
"ELECTRUM_HOST=mempool-electrs".to_string(),
"ELECTRUM_PORT=50001".to_string(),
"ELECTRUM_TLS_ENABLED=false".to_string(),
format!("CORE_RPC_HOST={}", host_ip),
"CORE_RPC_PORT=8332".to_string(),
"CORE_RPC_USERNAME=bitcoin".to_string(),
"CORE_RPC_PASSWORD=bitcoinpass".to_string(),
"DATABASE_ENABLED=true".to_string(),
"DATABASE_HOST=archy-mempool-db".to_string(),
"DATABASE_DATABASE=mempool".to_string(),
"DATABASE_USERNAME=mempool".to_string(),
"DATABASE_PASSWORD=mempoolpass".to_string(),
],
None,
None,
),
"mempool-electrs" => (
vec!["50001:50001".to_string()],
vec!["/var/lib/archipelago/mempool-electrs:/data".to_string()],
vec![],
None,
Some(vec![
"--daemon-rpc-addr".to_string(),
format!("{}:8332", host_ip),
"--cookie".to_string(),
"bitcoin:bitcoinpass".to_string(),
"--jsonrpc-import".to_string(),
"--electrum-rpc-addr".to_string(),
"0.0.0.0:50001".to_string(),
"--db-dir".to_string(),
"/data".to_string(),
"--lightmode".to_string(),
]),
),
"mysql-mempool" => (
vec![],
vec!["/var/lib/archipelago/mysql-mempool:/var/lib/mysql".to_string()],
vec![
"MYSQL_DATABASE=mempool".to_string(),
"MYSQL_USER=mempool".to_string(),
"MYSQL_PASSWORD=mempoolpass".to_string(),
"MYSQL_ROOT_PASSWORD=rootpass".to_string(),
],
None,
None,
),
"grafana" => (
vec!["3000:3000".to_string()],
vec!["/var/lib/archipelago/grafana:/var/lib/grafana".to_string()],
vec![],
None,
None,
),
"searxng" => (
vec!["8888:8080".to_string()],
vec![],
vec![],
None,
None,
),
"ollama" => (
vec!["11434:11434".to_string()],
vec!["/var/lib/archipelago/ollama:/root/.ollama".to_string()],
vec![],
None,
None,
),
"onlyoffice" | "onlyoffice-documentserver" => (
vec!["9980:80".to_string()],
vec![],
vec![],
None,
None,
),
"penpot" | "penpot-frontend" => (
vec!["9001:80".to_string()],
vec![],
vec![],
None,
),
"nextcloud" => (
vec!["8081:80".to_string()],
vec!["/var/lib/archipelago/nextcloud:/var/www/html".to_string()],
vec![],
None,
),
"vaultwarden" => (
vec!["8082:80".to_string()],
vec!["/var/lib/archipelago/vaultwarden:/data".to_string()],
vec![],
None,
),
"nextcloud" => {
let host_port = allocator
.allocate_or_get(app_id, 8085, 80)
.unwrap_or(8085);
(
vec![format!("{}:80", host_port)],
vec!["/var/lib/archipelago/nextcloud:/var/www/html".to_string()],
vec![],
None,
None,
)
}
"vaultwarden" => {
let host_port = allocator
.allocate_or_get(app_id, 8082, 80)
.unwrap_or(8082);
(
vec![format!("{}:80", host_port)],
vec!["/var/lib/archipelago/vaultwarden:/data".to_string()],
vec![],
None,
None,
)
}
"jellyfin" => (
vec!["8096:8096".to_string()],
vec!["/var/lib/archipelago/jellyfin/config:/config".to_string(), "/var/lib/archipelago/jellyfin/cache:/cache".to_string()],
vec![],
None,
None,
),
"photoprism" => (
vec!["2342:2342".to_string()],
vec!["/var/lib/archipelago/photoprism:/photoprism/storage".to_string()],
vec![],
None,
None,
),
"immich" => (
vec!["2283:3001".to_string()],
vec!["/var/lib/archipelago/immich:/usr/src/app/upload".to_string()],
vec![],
None,
),
"filebrowser" => (
vec!["8083:80".to_string()],
vec!["/var/lib/archipelago/filebrowser:/srv".to_string()],
vec![],
None,
),
"filebrowser" => {
let host_port = allocator
.allocate_or_get(app_id, 8083, 80)
.unwrap_or(8083);
(
vec![format!("{}:80", host_port)],
vec!["/var/lib/archipelago/filebrowser:/srv".to_string()],
vec![],
None,
None,
)
}
"nginx-proxy-manager" => (
vec!["81:81".to_string(), "8084:80".to_string(), "8443:443".to_string()],
vec![
@@ -994,18 +1392,21 @@ fn get_app_config(app_id: &str) -> (Vec<String>, Vec<String>, Vec<String>, Optio
],
vec![],
None,
None,
),
"portainer" => (
vec!["9000:9000".to_string()],
vec!["/var/lib/archipelago/portainer:/data".to_string(), "/var/run/podman/podman.sock:/var/run/docker.sock".to_string()],
vec![],
None,
None,
),
"uptime-kuma" => (
vec!["3001:3001".to_string()],
vec!["/var/lib/archipelago/uptime-kuma:/app/data".to_string()],
vec![],
None,
None,
),
"tailscale" => (
vec!["8240:8240".to_string()], // Tailscale web UI port (only used if not host network)
@@ -1016,18 +1417,30 @@ fn get_app_config(app_id: &str) -> (Vec<String>, Vec<String>, Vec<String>, Optio
"TS_STATE_DIR=/var/lib/tailscale".to_string(),
],
Some("sh -c 'tailscale web --listen 0.0.0.0:8240 & exec tailscaled'".to_string()),
),
"fedimint" => (
vec!["8173:8173".to_string()],
vec!["/var/lib/archipelago/fedimint:/data".to_string()],
vec![
"FM_BITCOIN_RPC_KIND=bitcoind".to_string(),
"FM_BITCOIN_RPC_URL=http://host.containers.internal:8332".to_string(),
"FM_BIND_P2P=0.0.0.0:8173".to_string(),
"FM_BIND_API=0.0.0.0:8174".to_string(),
],
None,
),
_ => (vec![], vec![], vec![], None), // No default config, user must configure manually
"fedimint" => (
vec![
"8173:8173".to_string(), // P2P
"8174:8174".to_string(), // API (JSON-RPC)
"8175:8175".to_string(), // Built-in Guardian UI
],
vec!["/var/lib/archipelago/fedimint:/data".to_string()],
vec![
"FM_DATA_DIR=/data".to_string(),
"FM_BITCOIND_USERNAME=bitcoin".to_string(),
"FM_BITCOIND_PASSWORD=bitcoinpass".to_string(),
"FM_BITCOIN_NETWORK=bitcoin".to_string(),
"FM_BIND_P2P=0.0.0.0:8173".to_string(),
"FM_BIND_API=0.0.0.0:8174".to_string(),
"FM_BIND_UI=0.0.0.0:8175".to_string(),
format!("FM_P2P_URL=fedimint://{}:8173", host_ip),
format!("FM_API_URL=ws://{}:8174", host_ip),
format!("FM_BITCOIND_URL=http://{}:8332", host_ip),
],
None,
None,
),
_ => (vec![], vec![], vec![], None, None), // No default config, user must configure manually
}
}

View File

@@ -6,6 +6,11 @@ use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use tokio::fs;
#[derive(Debug, Clone, Serialize, Deserialize)]
struct OnboardingState {
complete: bool,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
@@ -43,13 +48,16 @@ impl AuthManager {
pub async fn setup_user(&self, password: &str) -> Result<()> {
use bcrypt::{hash, DEFAULT_COST};
let password_hash = hash(password, DEFAULT_COST)?;
// If onboarding was already completed (before setup), preserve that
let onboarding_complete = self.is_onboarding_complete().await?;
let user = User {
password_hash,
setup_complete: true,
onboarding_complete: false,
onboarding_complete,
};
let user_file = self.data_dir.join("user.json");
@@ -60,6 +68,15 @@ impl AuthManager {
}
pub async fn complete_onboarding(&self) -> Result<()> {
// Persist to onboarding.json (works even before user/setup exists)
let onboarding_file = self.data_dir.join("onboarding.json");
let state = OnboardingState { complete: true };
fs::write(
&onboarding_file,
serde_json::to_string_pretty(&state)?,
)
.await?;
// Also update user.json if it exists (keeps them in sync)
if let Some(mut user) = self.get_user().await? {
user.onboarding_complete = true;
let user_file = self.data_dir.join("user.json");
@@ -69,6 +86,25 @@ impl AuthManager {
Ok(())
}
pub async fn is_onboarding_complete(&self) -> Result<bool> {
// Check onboarding.json first (persisted before user setup)
let onboarding_file = self.data_dir.join("onboarding.json");
if onboarding_file.exists() {
let content = fs::read_to_string(&onboarding_file).await?;
if let Ok(state) = serde_json::from_str::<OnboardingState>(&content) {
if state.complete {
return Ok(true);
}
}
}
// Fallback: user.json
Ok(self
.get_user()
.await?
.map(|u| u.onboarding_complete)
.unwrap_or(false))
}
pub async fn verify_password(&self, password: &str) -> Result<bool> {
use bcrypt::verify;
@@ -78,4 +114,113 @@ impl AuthManager {
Ok(false)
}
}
/// Change password: verify current, validate new, update user.json and optionally SSH.
/// New password must be 12+ chars with upper, lower, digit, and special character.
pub async fn change_password(
&self,
current_password: &str,
new_password: &str,
also_change_ssh: bool,
) -> Result<()> {
use bcrypt::{hash, DEFAULT_COST};
if !self.verify_password(current_password).await? {
anyhow::bail!("Current password is incorrect");
}
validate_password_strength(new_password)?;
let password_hash = hash(new_password, DEFAULT_COST)?;
let mut user = self
.get_user()
.await?
.ok_or_else(|| anyhow::anyhow!("User not set up"))?;
user.password_hash = password_hash;
let user_file = self.data_dir.join("user.json");
let content = serde_json::to_string_pretty(&user)?;
fs::write(&user_file, content).await?;
if also_change_ssh {
change_ssh_password(new_password).await?;
}
Ok(())
}
}
/// Validate password strength: 12+ chars, upper, lower, digit, special.
fn validate_password_strength(password: &str) -> Result<()> {
if password.len() < 12 {
anyhow::bail!("Password must be at least 12 characters");
}
if !password.chars().any(|c| c.is_ascii_uppercase()) {
anyhow::bail!("Password must contain at least one uppercase letter");
}
if !password.chars().any(|c| c.is_ascii_lowercase()) {
anyhow::bail!("Password must contain at least one lowercase letter");
}
if !password.chars().any(|c| c.is_ascii_digit()) {
anyhow::bail!("Password must contain at least one digit");
}
if !password.chars().any(|c| !c.is_ascii_alphanumeric()) {
anyhow::bail!("Password must contain at least one special character (!@#$%^&* etc.)");
}
Ok(())
}
/// Change the archipelago user's SSH/login password.
/// Uses usermod + openssl to bypass PAM (avoids "Authentication token manipulation" errors).
/// Uses absolute paths (/usr/bin/openssl, /usr/sbin/usermod) for systemd's minimal PATH.
async fn change_ssh_password(new_password: &str) -> Result<()> {
let ssh_user = std::env::var("ARCHIPELAGO_SSH_USER").unwrap_or_else(|_| "archipelago".to_string());
// Generate crypt hash via openssl (SHA-512, compatible with /etc/shadow)
// Use /usr/bin/openssl - systemd services often have minimal PATH
let mut hash_child = tokio::process::Command::new("/usr/bin/openssl")
.args(["passwd", "-6", "-stdin"])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|e| anyhow::anyhow!("Failed to run openssl: {}. Is openssl installed?", e))?;
{
use tokio::io::AsyncWriteExt;
let mut stdin = hash_child
.stdin
.take()
.ok_or_else(|| anyhow::anyhow!("Failed to open openssl stdin"))?;
stdin.write_all(new_password.as_bytes()).await?;
stdin.flush().await?;
}
let hash_result = hash_child.wait_with_output().await?;
if !hash_result.status.success() {
let stderr = String::from_utf8_lossy(&hash_result.stderr);
anyhow::bail!("openssl passwd failed: {}", stderr);
}
let hash = String::from_utf8(hash_result.stdout)?
.trim()
.to_string();
if hash.is_empty() {
anyhow::bail!("openssl passwd produced empty hash");
}
// usermod -p writes directly to /etc/shadow, bypassing PAM
// Use /usr/sbin/usermod - not always in systemd's PATH
let status = tokio::process::Command::new("/usr/sbin/usermod")
.args(["-p", &hash, &ssh_user])
.output()
.await?;
if !status.status.success() {
let stderr = String::from_utf8_lossy(&status.stderr);
anyhow::bail!("usermod failed: {}", stderr);
}
tracing::info!("SSH password updated for user {}", ssh_user);
Ok(())
}

View File

@@ -45,15 +45,40 @@ pub struct Config {
pub bind_host: String,
pub bind_port: u16,
pub log_level: String,
/// Host IP for container env vars (FM_API_URL, BACKEND_MAINNET_HTTP_HOST, etc.)
pub host_ip: String,
// Dev mode configuration
pub dev_mode: bool,
pub container_runtime: ContainerRuntime,
pub port_offset: u16,
pub bitcoin_simulation: BitcoinSimulation,
pub dev_data_dir: PathBuf,
/// Nostr discovery: opt-in only. When true + relays non-empty, publish node to relays.
#[serde(default)]
pub nostr_discovery_enabled: bool,
/// Nostr relay URLs (comma-separated). Only used when nostr_discovery_enabled.
#[serde(default)]
pub nostr_relays: Vec<String>,
/// Tor SOCKS5 proxy (e.g. 127.0.0.1:9050). When set, ALL Nostr traffic routes through Tor.
#[serde(default)]
pub nostr_tor_proxy: Option<String>,
}
impl Config {
/// Detect primary host IP (first non-loopback IPv4)
fn detect_host_ip() -> Result<String> {
let output = std::process::Command::new("hostname")
.args(["-I"])
.output()
.context("Failed to run hostname -I")?;
let s = String::from_utf8_lossy(&output.stdout);
let ip = s
.split_whitespace()
.find(|s| !s.starts_with("127.") && s.contains('.'))
.unwrap_or("127.0.0.1");
Ok(ip.to_string())
}
pub async fn load() -> Result<Self> {
// Default configuration
let mut config = Self::default();
@@ -124,6 +149,29 @@ impl Config {
config.dev_data_dir = PathBuf::from(dev_data_dir);
}
// Nostr discovery (opt-in, secure by default)
if let Ok(v) = std::env::var("ARCHIPELAGO_NOSTR_DISCOVERY_ENABLED") {
config.nostr_discovery_enabled = v.parse().unwrap_or(false);
}
if let Ok(v) = std::env::var("ARCHIPELAGO_NOSTR_RELAYS") {
config.nostr_relays = v
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
}
if let Ok(v) = std::env::var("ARCHIPELAGO_NOSTR_TOR_PROXY") {
let s = v.trim().to_string();
config.nostr_tor_proxy = if s.is_empty() { None } else { Some(s) };
}
// Host IP for container env vars (detect if not set)
if let Ok(ip) = std::env::var("ARCHIPELAGO_HOST_IP") {
config.host_ip = ip;
} else {
config.host_ip = Self::detect_host_ip().unwrap_or_else(|_| "127.0.0.1".to_string());
}
// Ensure data directory exists
fs::create_dir_all(&config.data_dir).await
.context("Failed to create data directory")?;
@@ -145,11 +193,18 @@ impl Default for Config {
bind_host: "0.0.0.0".to_string(),
bind_port: 5678,
log_level: "info".to_string(),
host_ip: "127.0.0.1".to_string(),
dev_mode: false,
container_runtime: ContainerRuntime::Auto,
port_offset: 10000,
bitcoin_simulation: BitcoinSimulation::Mock,
dev_data_dir: PathBuf::from("/tmp/archipelago-dev"),
nostr_discovery_enabled: true,
nostr_relays: vec![
"wss://relay.damus.io".into(),
"wss://relay.nostr.info".into(),
],
nostr_tor_proxy: Some("127.0.0.1:9050".into()),
}
}
}

View File

@@ -53,11 +53,15 @@ impl DockerPackageScanner {
let mut ui_containers: HashMap<String, String> = HashMap::new();
for container in &containers {
if container.name.ends_with("-ui") {
// Map bitcoin-ui -> bitcoin, lnd-ui -> lnd
// Map fedimint-ui -> fedimint, lnd-ui -> lnd (normalize archy- prefix for lookup)
let parent_app = container.name.strip_suffix("-ui").unwrap_or(&container.name);
let canonical_id = parent_app
.strip_prefix("archy-")
.unwrap_or(parent_app)
.to_string();
if !container.ports.is_empty() {
if let Some(ui_address) = extract_lan_address(&container.ports) {
ui_containers.insert(parent_app.to_string(), ui_address);
ui_containers.insert(canonical_id, ui_address);
}
}
}
@@ -109,6 +113,14 @@ impl DockerPackageScanner {
// But web UI is always on port 8240
debug!("Tailscale detected, using port 8240");
Some("http://localhost:8240".to_string())
} else if app_id == "fedimint" {
// Fedimint built-in Guardian UI on port 8175
debug!("Using fedimint built-in Guardian UI: http://localhost:8175");
Some("http://localhost:8175".to_string())
} else if app_id == "mempool-electrs" || app_id == "electrs" {
// Electrs UI runs on host at port 50002
debug!("Using electrs-ui for mempool-electrs: http://localhost:50002");
Some("http://localhost:50002".to_string())
} else {
// Extract port from the main container
extract_lan_address(&container.ports)
@@ -119,6 +131,8 @@ impl DockerPackageScanner {
// Convert container state to package/service state
let (package_state, service_status) = convert_state(&container.state);
let tor_address = read_tor_address(&app_id);
let package = PackageDataEntry {
state: package_state.clone(),
static_files: StaticFiles {
@@ -143,11 +157,11 @@ impl DockerPackageScanner {
donation_url: None,
author: Some("Archipelago".to_string()),
website: lan_address.clone(),
interfaces: if lan_address.is_some() {
interfaces: if lan_address.is_some() || tor_address.is_some() {
Some(Interfaces {
main: Some(MainInterface {
ui: Some("true".to_string()),
tor_config: None,
tor_config: tor_address.clone(),
lan_config: None,
}),
})
@@ -159,13 +173,17 @@ impl DockerPackageScanner {
current_dependents: HashMap::new(),
current_dependencies: HashMap::new(),
last_backup: None,
interface_addresses: if let Some(addr) = lan_address {
interface_addresses: if lan_address.is_some() || tor_address.is_some() {
let mut addresses = HashMap::new();
// Only include tor_address if we have a real v3 .onion (not placeholder)
let tor = tor_address
.filter(|s| is_real_onion_address(s))
.unwrap_or_default();
addresses.insert(
"main".to_string(),
InterfaceAddress {
tor_address: format!("{}.onion", app_id),
lan_address: Some(addr),
tor_address: tor,
lan_address: lan_address,
},
);
addresses
@@ -227,7 +245,7 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
"fedimint" => AppMetadata {
title: "Fedimint".to_string(),
description: "Federated Bitcoin mint".to_string(),
icon: "/assets/img/icon-fedimint.jpeg".to_string(),
icon: "/assets/img/app-icons/fedimint.png".to_string(),
repo: "https://github.com/fedimint/fedimint".to_string(),
},
"morphos" | "morphos-server" => AppMetadata {
@@ -248,6 +266,12 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
icon: "/assets/img/app-icons/mempool.webp".to_string(),
repo: "https://github.com/mempool/mempool".to_string(),
},
"mempool-electrs" | "electrs" => AppMetadata {
title: "Electrs".to_string(),
description: "Electrum protocol indexer for Bitcoin. Powers Mempool and other Electrum clients.".to_string(),
icon: "/assets/img/app-icons/electrs.svg".to_string(),
repo: "https://github.com/romanz/electrs".to_string(),
},
"ollama" => AppMetadata {
title: "Ollama".to_string(),
description: "Run large language models locally".to_string(),
@@ -347,6 +371,40 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
}
}
/// Map app_id to Tor hidden service directory name.
/// "archipelago" is the main web UI (nginx port 80).
/// Supports container names from deploy (archy-*, btcpay-server, etc.).
fn tor_service_name(app_id: &str) -> Option<&'static str> {
match app_id {
"archipelago" => Some("archipelago"),
"lnd" | "lnd-ui" => Some("lnd"),
"btcpay" | "btcpay-server" | "btcpayserver" => Some("btcpay"),
"mempool" | "mempool-web" | "mempool-frontend" => Some("mempool"),
"fedimint" => Some("fedimint"),
_ => None,
}
}
/// V3 onion addresses are 56 base32 chars + ".onion". Placeholders like "btcpay.onion" are not real.
fn is_real_onion_address(s: &str) -> bool {
s.ends_with(".onion") && s.len() >= 60 && s.len() <= 70
}
/// Read real .onion address from Tor hidden service hostname file.
/// Service name "archipelago" is for the main web UI (nginx port 80).
/// Uses TOR_DATA_DIR env var if set, else /var/lib/archipelago/tor.
pub fn read_tor_address(app_id: &str) -> Option<String> {
let service = tor_service_name(app_id)?;
let base = std::env::var("TOR_DATA_DIR").unwrap_or_else(|_| "/var/lib/archipelago/tor".to_string());
let path = std::path::Path::new(&base)
.join(format!("hidden_service_{}", service))
.join("hostname");
std::fs::read_to_string(&path)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| s.ends_with(".onion") && !s.is_empty())
}
fn extract_lan_address(ports: &[String]) -> Option<String> {
for port_str in ports {
// Parse port strings like "0.0.0.0:18443->18443/tcp" or "0.0.0.0:18443-18444->18443-18444/tcp"

View File

@@ -22,6 +22,10 @@ pub struct ServerInfo {
pub status_info: StatusInfo,
#[serde(rename = "lan-address")]
pub lan_address: Option<String>,
#[serde(rename = "tor-address")]
pub tor_address: Option<String>,
#[serde(rename = "node-address", skip_serializing_if = "Option::is_none")]
pub node_address: Option<String>,
pub unread: u32,
#[serde(rename = "wifi-ssids")]
pub wifi_ssids: Vec<String>,
@@ -225,6 +229,8 @@ impl DataModel {
update_progress: None,
},
lan_address: Some("http://localhost:8100".to_string()),
tor_address: None,
node_address: None,
unread: 0,
wifi_ssids: vec![],
zram_enabled: false,

View File

@@ -0,0 +1,158 @@
//! Electrs sync status: fetches indexed height from Electrum RPC and network height from Bitcoin Core.
use anyhow::{Context, Result};
use serde::Serialize;
use std::io::{BufRead, BufReader, Write};
use std::net::TcpStream;
use std::time::Duration;
const ELECTRS_HOST: &str = "127.0.0.1";
const ELECTRS_PORT: u16 = 50001;
const BITCOIN_RPC_URL: &str = "http://127.0.0.1:8332/";
const BITCOIN_RPC_AUTH: &str = "Basic YXJjaGlwZWxhZ286YXJjaGlwZWxhZ28xMjM="; // archipelago:archipelago123
#[derive(Debug, Serialize)]
pub struct ElectrsSyncStatus {
pub indexed_height: u64,
pub network_height: u64,
pub progress_pct: f64,
pub status: String,
pub error: Option<String>,
}
/// Fetch electrs indexed height via Electrum protocol (TCP JSON-RPC).
fn electrs_indexed_height() -> Result<u64> {
let mut stream = TcpStream::connect((ELECTRS_HOST, ELECTRS_PORT))
.context("Failed to connect to electrs")?;
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.context("set_read_timeout")?;
stream
.set_write_timeout(Some(Duration::from_secs(5)))
.context("set_write_timeout")?;
// blockchain.numblocks.subscribe returns current block height directly
let req = r#"{"id":1,"method":"blockchain.numblocks.subscribe","params":[]}
"#;
stream.write_all(req.as_bytes())?;
stream.flush()?;
let mut reader = BufReader::new(stream);
let mut line = String::new();
reader.read_line(&mut line)?;
let line = line.trim();
if line.is_empty() {
anyhow::bail!("Empty response from electrs");
}
let json: serde_json::Value = serde_json::from_str(line)?;
// blockchain.numblocks.subscribe returns result as number; headers.subscribe returns {block_height: N}
let height = json
.get("result")
.and_then(|r| r.as_u64())
.or_else(|| {
json.get("result")
.and_then(|r| r.get("block_height"))
.and_then(|h| h.as_u64())
})
.context("Missing height in electrs response")?;
Ok(height)
}
/// Fetch Bitcoin network height via JSON-RPC.
async fn bitcoin_network_height() -> Result<u64> {
let client = reqwest::Client::new();
let body = serde_json::json!({
"jsonrpc": "1.0",
"id": "electrs-status",
"method": "getblockcount",
"params": []
});
let resp = client
.post(BITCOIN_RPC_URL)
.header("Content-Type", "application/json")
.header("Authorization", BITCOIN_RPC_AUTH)
.body(body.to_string())
.send()
.await
.context("Bitcoin RPC request failed")?;
if !resp.status().is_success() {
anyhow::bail!("Bitcoin RPC returned {}", resp.status());
}
let json: serde_json::Value = resp.json().await?;
let height = json
.get("result")
.and_then(|r| r.as_u64())
.context("Missing result in Bitcoin RPC")?;
Ok(height)
}
/// Get electrs sync status. Runs blocking electrs call in spawn_blocking.
pub async fn get_electrs_sync_status() -> ElectrsSyncStatus {
let network_height = match bitcoin_network_height().await {
Ok(h) => h,
Err(e) => {
return ElectrsSyncStatus {
indexed_height: 0,
network_height: 0,
progress_pct: 0.0,
status: "error".to_string(),
error: Some(format!("Bitcoin RPC: {}", e)),
};
}
};
let indexed_height = match tokio::task::spawn_blocking(electrs_indexed_height).await {
Ok(Ok(h)) => h,
Ok(Err(e)) => {
// Electrs doesn't listen on 50001 until indexing completes (can take hours)
let err_msg = e.to_string();
let (status, error) = if err_msg.contains("connect") || err_msg.contains("Connection refused") {
(
"indexing".to_string(),
Some("Electrs is building the index. Electrum RPC will be available when indexing completes (may take hours).".to_string()),
)
} else {
("error".to_string(), Some(format!("Electrs: {}", e)))
};
return ElectrsSyncStatus {
indexed_height: 0,
network_height,
progress_pct: 0.0,
status,
error,
};
}
Err(e) => {
return ElectrsSyncStatus {
indexed_height: 0,
network_height,
progress_pct: 0.0,
status: "error".to_string(),
error: Some(format!("Task: {}", e)),
};
}
};
let progress_pct = if network_height > 0 {
(indexed_height as f64 / network_height as f64) * 100.0
} else {
0.0
};
let status = if indexed_height >= network_height.saturating_sub(1) {
"synced"
} else {
"syncing"
};
ElectrsSyncStatus {
indexed_height,
network_height,
progress_pct,
status: status.to_string(),
error: None,
}
}

View File

@@ -0,0 +1,122 @@
//! Node identity: persistent Ed25519 key for private identification.
//! Enables future P2P features (file transfer, streaming, ecash/Lightning).
//! Supports did:key (W3C) for Web5/DID interoperability.
use anyhow::{Context, Result};
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use rand::rngs::OsRng;
use std::path::{Path, PathBuf};
use tokio::fs;
const NODE_KEY_FILE: &str = "node_key";
const NODE_KEY_PUB_FILE: &str = "node_key.pub";
/// Persistent node identity (Ed25519 keypair).
/// Survives reboots; used for signing, verification, and node address.
pub struct NodeIdentity {
signing_key: SigningKey,
identity_dir: PathBuf,
}
impl NodeIdentity {
/// Load existing identity or create and persist a new one.
pub async fn load_or_create(identity_dir: &Path) -> Result<Self> {
fs::create_dir_all(identity_dir)
.await
.context("Failed to create identity directory")?;
let key_path = identity_dir.join(NODE_KEY_FILE);
let pub_path = identity_dir.join(NODE_KEY_PUB_FILE);
let signing_key = if key_path.exists() {
let bytes = fs::read(&key_path)
.await
.context("Failed to read node key")?;
let arr: [u8; 32] = bytes
.try_into()
.map_err(|_| anyhow::anyhow!("Invalid node key length"))?;
SigningKey::from_bytes(&arr)
} else {
let signing_key = SigningKey::generate(&mut OsRng);
fs::write(&key_path, signing_key.to_bytes())
.await
.context("Failed to write node key")?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600))
.await
.context("Failed to set key permissions")?;
}
fs::write(&pub_path, signing_key.verifying_key().as_bytes())
.await
.context("Failed to write node public key")?;
tracing::info!("🔑 Generated new node identity at {}", identity_dir.display());
signing_key
};
Ok(Self {
signing_key,
identity_dir: identity_dir.to_path_buf(),
})
}
/// Public key as hex string (for ServerInfo, Nostr, etc.)
pub fn pubkey_hex(&self) -> String {
hex::encode(self.signing_key.verifying_key().as_bytes())
}
/// Stable node ID derived from pubkey (first 16 chars of hex).
pub fn node_id(&self) -> String {
self.pubkey_hex().chars().take(16).collect()
}
/// Sign data; returns hex-encoded signature.
pub fn sign(&self, data: &[u8]) -> String {
hex::encode(self.signing_key.sign(data).to_bytes())
}
/// Verify a signature from a peer (pubkey hex, data, signature hex).
pub fn verify(pubkey_hex: &str, data: &[u8], sig_hex: &str) -> Result<bool> {
let bytes = hex::decode(pubkey_hex).context("Invalid pubkey hex")?;
let verifying_key = VerifyingKey::from_bytes(
bytes
.as_slice()
.try_into()
.map_err(|_| anyhow::anyhow!("Invalid pubkey length"))?,
)?;
let sig_bytes = hex::decode(sig_hex).context("Invalid signature hex")?;
let sig = Signature::from_bytes(
sig_bytes
.as_slice()
.try_into()
.map_err(|_| anyhow::anyhow!("Invalid signature length"))?,
);
Ok(verifying_key.verify(data, &sig).is_ok())
}
/// Node address format for invites: archipelago://<onion>#<pubkey>
pub fn node_address(&self, onion: &str) -> String {
format!("archipelago://{}#{}", onion.trim_end_matches('/'), self.pubkey_hex())
}
/// DID in did:key format (W3C did:key method, Ed25519).
/// Format: did:key:z&lt;base58btc(multicodec_ed25519_pub + 32-byte pubkey)&gt;
pub fn did_key(&self) -> String {
did_key_from_pubkey_hex(&self.pubkey_hex()).expect("pubkey_hex is valid")
}
}
/// Convert Ed25519 pubkey (hex) to did:key format.
/// Used by RPC when identity is loaded from state.
pub fn did_key_from_pubkey_hex(pubkey_hex: &str) -> Result<String> {
let bytes = hex::decode(pubkey_hex).context("Invalid pubkey hex")?;
if bytes.len() != 32 {
return Err(anyhow::anyhow!("Invalid pubkey length"));
}
let mut multicodec_pubkey = [0u8; 34];
multicodec_pubkey[0] = 0xed;
multicodec_pubkey[1] = 0x01;
multicodec_pubkey[2..34].copy_from_slice(&bytes);
Ok(format!("did:key:z{}", bs58::encode(multicodec_pubkey).into_string()))
}

View File

@@ -8,8 +8,14 @@ use tracing::info;
mod api;
mod auth;
mod config;
mod electrs_status;
mod container;
mod port_allocator;
mod data_model;
mod identity;
mod node_message;
mod nostr_discovery;
mod peers;
mod server;
mod state;

View File

@@ -0,0 +1,133 @@
//! Node-to-node messaging over Tor.
//! Sends messages to peer .onion addresses via SOCKS5 proxy.
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::sync::{Mutex, OnceLock};
const TOR_SOCKS: &str = "socks5h://127.0.0.1:9050";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IncomingMessage {
pub from_pubkey: String,
pub from_onion: Option<String>,
pub message: String,
pub timestamp: String,
}
fn received_messages() -> &'static Mutex<Vec<IncomingMessage>> {
static RECEIVED: OnceLock<Mutex<Vec<IncomingMessage>>> = OnceLock::new();
RECEIVED.get_or_init(|| Mutex::new(Vec::new()))
}
const MAX_STORED: usize = 100;
/// Store a received message (called from HTTP handler).
pub fn store_received_sync(from_pubkey: &str, message: &str) {
let mut guard = received_messages().lock().unwrap_or_else(|e| e.into_inner());
guard.push(IncomingMessage {
from_pubkey: from_pubkey.to_string(),
from_onion: None,
message: message.to_string(),
timestamp: chrono::Utc::now().to_rfc3339(),
});
let len = guard.len();
if len > MAX_STORED {
guard.drain(0..len - MAX_STORED);
}
}
pub async fn store_received(from_pubkey: &str, message: &str) {
store_received_sync(from_pubkey, message);
}
/// Get received messages for UI display.
pub fn get_received() -> Vec<IncomingMessage> {
received_messages().lock().unwrap_or_else(|e| e.into_inner()).clone()
}
/// Tor v3 onion hostname is 56 base32 chars (a-z, 2-7). Reject invalid formats.
fn validate_onion(onion: &str) -> Result<()> {
let host = onion.trim_end_matches(".onion");
if host.len() != 56 {
anyhow::bail!(
"Invalid onion address (expected 56 chars, got {}). The peer may have wrong data - try removing and re-adding via Discover.",
host.len()
);
}
let valid = host.chars().all(|c| c.is_ascii_lowercase() || (c >= '2' && c <= '7'));
if !valid {
anyhow::bail!("Invalid onion address: must be 56 base32 chars (a-z, 2-7)");
}
Ok(())
}
/// Send a message to a peer over Tor.
pub async fn send_to_peer(onion: &str, from_pubkey: &str, message: &str) -> Result<()> {
validate_onion(onion)?;
let host = if onion.ends_with(".onion") {
onion.to_string()
} else {
format!("{}.onion", onion)
};
let url = format!("http://{}/archipelago/node-message", host);
let body = serde_json::json!({
"from_pubkey": from_pubkey,
"message": message,
"timestamp": chrono::Utc::now().to_rfc3339(),
});
let proxy = reqwest::Proxy::all(TOR_SOCKS).context("Invalid Tor proxy")?;
let client = reqwest::Client::builder()
.proxy(proxy)
.timeout(std::time::Duration::from_secs(60))
.build()
.context("Failed to build HTTP client")?;
let resp = client
.post(&url)
.json(&body)
.send()
.await
.map_err(|e| {
let msg = e.to_string();
if msg.contains("connection refused") || msg.contains("Connection refused") {
anyhow::anyhow!("Tor not reachable at 127.0.0.1:9050. Is the Tor container running?")
} else if msg.contains("timeout") || msg.contains("timed out") {
anyhow::anyhow!("Connection timed out. The peer may be offline or unreachable over Tor.")
} else {
anyhow::anyhow!("Failed to send over Tor: {}", msg)
}
})?;
if !resp.status().is_success() {
anyhow::bail!(
"Peer returned {} {}. The peer may need /archipelago/ in its nginx config.",
resp.status().as_u16(),
resp.status().canonical_reason().unwrap_or("")
);
}
Ok(())
}
/// Check if a peer is reachable (ping over Tor).
pub async fn check_peer_reachable(onion: &str) -> Result<bool> {
let host = if onion.ends_with(".onion") {
onion.to_string()
} else {
format!("{}.onion", onion)
};
let url = format!("http://{}/health", host);
let proxy = reqwest::Proxy::all(TOR_SOCKS).context("Invalid Tor proxy")?;
let client = reqwest::Client::builder()
.proxy(proxy)
.timeout(std::time::Duration::from_secs(30))
.build()
.context("Failed to build HTTP client")?;
match client.get(&url).send().await {
Ok(resp) => Ok(resp.status().is_success()),
Err(_) => Ok(false),
}
}

View File

@@ -0,0 +1,345 @@
//! Nostr node discovery: publish node identity to relays for peer discovery.
//! Uses NIP-33 replaceable events (kind 30078) with d-tag "archipelago-node".
//!
//! Security: Publishing is opt-in (ARCHIPELAGO_NOSTR_DISCOVERY_ENABLED + relays).
//! All Nostr traffic routes through Tor when ARCHIPELAGO_NOSTR_TOR_PROXY is set.
//! Legacy revocation overwrites any previously published data on old public relays.
use anyhow::{Context, Result};
use nostr_sdk::prelude::*;
use nostr_sdk::pool;
use std::net::SocketAddr;
use std::path::Path;
use tokio::fs;
/// Parse "host:port" to SocketAddr. Returns None if invalid.
fn parse_proxy_addr(s: &str) -> Option<SocketAddr> {
s.trim().parse().ok()
}
const NOSTR_SECRET_FILE: &str = "nostr_secret";
const NOSTR_PUB_FILE: &str = "nostr_pub";
const NOSTR_REVOKED_FILE: &str = "nostr_revoked";
const ARCHIPELAGO_KIND: u64 = 30078;
const D_TAG: &str = "archipelago-node";
/// Relays we previously published to (for one-time revocation overwrite only)
const LEGACY_RELAYS: &[&str] = &[
"wss://relay.damus.io",
"wss://relay.nostr.info",
];
/// Load or create Nostr keys (secp256k1) for node discovery.
async fn load_or_create_nostr_keys(identity_dir: &Path) -> Result<Keys> {
let secret_path = identity_dir.join(NOSTR_SECRET_FILE);
let pub_path = identity_dir.join(NOSTR_PUB_FILE);
let keys = if secret_path.exists() {
let hex_secret = fs::read_to_string(&secret_path)
.await
.context("Failed to read Nostr secret")?;
Keys::parse(hex_secret.trim()).context("Invalid Nostr secret")?
} else {
let keys = Keys::generate();
fs::create_dir_all(identity_dir)
.await
.context("Failed to create identity dir")?;
let hex = keys.secret_key().to_secret_hex();
fs::write(&secret_path, hex)
.await
.context("Failed to write Nostr secret")?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
tokio::task::spawn_blocking(move || {
std::fs::set_permissions(secret_path, std::fs::Permissions::from_mode(0o600))
})
.await
.context("spawn_blocking")?
.context("Failed to set Nostr key permissions")?;
}
fs::write(&pub_path, keys.public_key().to_hex())
.await
.context("Failed to write Nostr pubkey")?;
tracing::info!("🔑 Generated Nostr discovery key");
keys
};
Ok(keys)
}
/// Load Nostr keys only if they exist (does not create). Used for revocation.
async fn load_nostr_keys_if_exists(identity_dir: &Path) -> Result<Option<Keys>> {
let secret_path = identity_dir.join(NOSTR_SECRET_FILE);
if !secret_path.exists() {
return Ok(None);
}
let hex_secret = fs::read_to_string(&secret_path)
.await
.context("Failed to read Nostr secret")?;
let keys = Keys::parse(hex_secret.trim()).context("Invalid Nostr secret")?;
Ok(Some(keys))
}
/// Publish a replaceable event with empty content to overwrite/revoke previously published data.
/// Uses NIP-33: same kind + d-tag + author = latest replaces. Sends to LEGACY_RELAYS only.
/// Requires tor_proxy to avoid leaking IP to relay operators.
fn build_nostr_client(keys: Keys, tor_proxy: Option<&str>) -> Result<Client> {
let client = if let Some(proxy_str) = tor_proxy {
let addr = parse_proxy_addr(proxy_str)
.ok_or_else(|| anyhow::anyhow!("Invalid Nostr Tor proxy: {}", proxy_str))?;
let connection = Connection::new()
.proxy(addr)
.target(ConnectionTarget::All);
let opts = ClientOptions::new().connection(connection);
Client::builder().signer(keys).opts(opts).build()
} else {
Client::new(keys)
};
Ok(client)
}
/// Publish a replaceable event with empty content to overwrite/revoke previously published data.
/// Uses NIP-33: same kind + d-tag + author = latest replaces. Sends to LEGACY_RELAYS only.
/// Only call when tor_proxy is set (avoids IP leak).
pub async fn publish_node_revocation(
identity_dir: &Path,
tor_proxy: Option<&str>,
) -> Result<()> {
let Some(keys) = load_nostr_keys_if_exists(identity_dir).await? else {
return Ok(()); // No keys = never published, nothing to revoke
};
let client = build_nostr_client(keys, tor_proxy)?;
for url in LEGACY_RELAYS {
let _ = client.add_relay(*url).await;
}
client.connect().await;
// NIP-33 replaceable: empty content overwrites previous event
let builder = EventBuilder::new(Kind::Custom(ARCHIPELAGO_KIND as u16), "{}")
.tag(Tag::identifier(D_TAG));
let _ = client.send_event_builder(builder).await;
client.disconnect().await;
Ok(())
}
/// If we have Nostr keys but haven't revoked yet, publish revocation to overwrite legacy data.
/// Uses tor_proxy if set; otherwise tries 127.0.0.1:9050 (archy-tor default). Creates nostr_revoked sentinel.
pub async fn revoke_if_needed(identity_dir: &Path, tor_proxy: Option<&str>) -> Result<()> {
let revoked_path = identity_dir.join(NOSTR_REVOKED_FILE);
if revoked_path.exists() {
return Ok(());
}
if load_nostr_keys_if_exists(identity_dir).await?.is_none() {
return Ok(());
}
// Use configured proxy or Tor default (archy-tor exposes 127.0.0.1:9050)
let proxy = tor_proxy.or(Some("127.0.0.1:9050"));
if let Err(e) = publish_node_revocation(identity_dir, proxy).await {
tracing::warn!("Nostr revocation (non-fatal): {}", e);
return Ok(());
}
fs::create_dir_all(identity_dir).await?;
fs::write(&revoked_path, "").await?;
tracing::info!("🔒 Nostr discovery data revoked (overwritten on legacy relays)");
Ok(())
}
/// Publish node identity to Nostr relays for discovery.
/// Content: { did, node_address, version }
/// Only call when relays are non-empty (opt-in).
/// When tor_proxy is set, routes through Tor to prevent IP exposure.
/// Skips if nostr_revoked sentinel exists (revocation must not be overwritten).
pub async fn publish_node_identity(
identity_dir: &Path,
did: &str,
node_address: &str,
version: &str,
relays: &[String],
tor_proxy: Option<&str>,
) -> Result<pool::Output<EventId>> {
if relays.is_empty() {
anyhow::bail!("No relays configured for Nostr discovery");
}
if identity_dir.join(NOSTR_REVOKED_FILE).exists() {
tracing::debug!("Nostr discovery: skipping publish (revoked)");
return Err(anyhow::anyhow!("Nostr discovery revoked"));
}
let keys = load_or_create_nostr_keys(identity_dir).await?;
let client = build_nostr_client(keys, tor_proxy)?;
let content = serde_json::json!({
"did": did,
"node_address": node_address,
"version": version,
})
.to_string();
for url in relays {
let _ = client.add_relay(url).await;
}
client.connect().await;
let builder = EventBuilder::new(Kind::Custom(ARCHIPELAGO_KIND as u16), content)
.tag(Tag::identifier(D_TAG));
let output = client.send_event_builder(builder).await?;
client.disconnect().await;
Ok(output)
}
/// Get Nostr public key for this node (hex).
pub async fn get_nostr_pubkey(identity_dir: &Path) -> Result<String> {
let keys = load_or_create_nostr_keys(identity_dir).await?;
Ok(keys.public_key().to_hex())
}
/// Verify that our node's Nostr discovery data was revoked on the legacy relays.
/// Queries relays for our pubkey's kind 30078 events; if latest has empty content, revocation succeeded.
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct RevocationStatus {
pub revoked: bool,
pub nostr_pubkey: String,
pub latest_content: Option<String>,
pub error: Option<String>,
}
pub async fn verify_revocation(
identity_dir: &Path,
tor_proxy: Option<&str>,
) -> Result<RevocationStatus> {
let keys = match load_nostr_keys_if_exists(identity_dir).await? {
Some(k) => k,
None => {
return Ok(RevocationStatus {
revoked: true,
nostr_pubkey: String::new(),
latest_content: None,
error: Some("No Nostr keys - never published".to_string()),
});
}
};
let pubkey_hex = keys.public_key().to_hex();
let anon_keys = Keys::generate();
let client = build_nostr_client(anon_keys, tor_proxy)?;
for url in LEGACY_RELAYS {
let _ = client.add_relay(*url).await;
}
client.connect().await;
let filter = Filter::new()
.kind(Kind::Custom(ARCHIPELAGO_KIND as u16))
.identifier(D_TAG)
.author(keys.public_key())
.limit(10);
let events = client
.fetch_events(filter, std::time::Duration::from_secs(15))
.await
.map(|e| e.to_vec())
.unwrap_or_default();
client.disconnect().await;
// NIP-33: latest event wins. fetch_events returns sorted by timestamp desc.
let mut events: Vec<_> = events.into_iter().collect();
events.sort_by(|a, b| b.created_at.cmp(&a.created_at));
let latest = events.into_iter().next();
let (revoked, latest_content) = match latest {
None => (true, None),
Some(ev) => {
let content = ev.content;
let is_revoked = content == "{}" || content.is_empty() || !content.contains("node_address");
(is_revoked, Some(content))
}
};
Ok(RevocationStatus {
revoked,
nostr_pubkey: pubkey_hex,
latest_content,
error: None,
})
}
/// Discovered Archipelago node from Nostr.
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct DiscoveredNode {
pub did: String,
pub node_address: String,
pub onion: String,
pub pubkey: String,
pub version: String,
}
/// Query Nostr relays for other Archipelago nodes.
/// Returns empty if relays is empty (opt-in discovery).
/// When tor_proxy is set, routes through Tor to prevent IP exposure.
pub async fn discover_archipelago_nodes(
identity_dir: &Path,
relays: &[String],
tor_proxy: Option<&str>,
) -> Result<Vec<DiscoveredNode>> {
if relays.is_empty() {
return Ok(Vec::new());
}
let _keys = load_or_create_nostr_keys(identity_dir).await?;
let anon_keys = Keys::generate();
let client = build_nostr_client(anon_keys, tor_proxy)?;
for url in relays {
let _ = client.add_relay(url).await;
}
client.connect().await;
let filter = Filter::new()
.kind(Kind::Custom(ARCHIPELAGO_KIND as u16))
.identifier(D_TAG)
.limit(50);
let events = client
.fetch_events(filter, std::time::Duration::from_secs(15))
.await
.map(|e| e.to_vec())
.unwrap_or_default();
client.disconnect().await;
let mut nodes = Vec::new();
for event in events {
if let Ok(content) = serde_json::from_str::<serde_json::Value>(&event.content) {
// Skip revoked/empty events
let node_address = content.get("node_address").and_then(|v| v.as_str()).unwrap_or("").to_string();
if node_address.is_empty() {
continue;
}
let did = content.get("did").and_then(|v| v.as_str()).unwrap_or("").to_string();
let version = content.get("version").and_then(|v| v.as_str()).unwrap_or("0.1").to_string();
// Parse archipelago://xxx.onion#pubkey
let (onion, pubkey) = if node_address.starts_with("archipelago://") {
let rest = node_address.trim_start_matches("archipelago://");
if let Some((o, p)) = rest.split_once('#') {
(o.to_string(), p.to_string())
} else {
(rest.to_string(), "".to_string())
}
} else {
("".to_string(), "".to_string())
};
if !onion.is_empty() {
nodes.push(DiscoveredNode {
did,
node_address,
onion: onion.trim_end_matches('/').to_string(),
pubkey,
version,
});
}
}
}
Ok(nodes)
}

View File

@@ -0,0 +1,63 @@
//! Known peer nodes for P2P discovery and connection.
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
use tokio::fs;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KnownPeer {
pub onion: String,
pub pubkey: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub added_at: Option<String>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct PeersFile {
pub peers: Vec<KnownPeer>,
}
const PEERS_FILE: &str = "peers.json";
pub async fn load_peers(data_dir: &Path) -> Result<Vec<KnownPeer>> {
let path = data_dir.join(PEERS_FILE);
if !path.exists() {
return Ok(Vec::new());
}
let content = fs::read_to_string(&path)
.await
.context("Failed to read peers file")?;
let file: PeersFile = serde_json::from_str(&content).unwrap_or_default();
Ok(file.peers)
}
pub async fn save_peers(data_dir: &Path, peers: &[KnownPeer]) -> Result<()> {
let path = data_dir.join(PEERS_FILE);
fs::create_dir_all(data_dir).await.context("Failed to create data dir")?;
let file = PeersFile {
peers: peers.to_vec(),
};
let content = serde_json::to_string_pretty(&file).context("Failed to serialize peers")?;
fs::write(&path, content).await.context("Failed to write peers file")?;
Ok(())
}
pub async fn add_peer(data_dir: &Path, peer: KnownPeer) -> Result<Vec<KnownPeer>> {
let mut peers = load_peers(data_dir).await?;
let exists = peers.iter().any(|p| p.pubkey == peer.pubkey);
if !exists {
peers.push(peer);
save_peers(data_dir, &peers).await?;
}
Ok(peers)
}
pub async fn remove_peer(data_dir: &Path, pubkey: &str) -> Result<Vec<KnownPeer>> {
let mut peers = load_peers(data_dir).await?;
peers.retain(|p| p.pubkey != pubkey);
save_peers(data_dir, &peers).await?;
Ok(peers)
}

View File

@@ -0,0 +1,148 @@
//! Smart port allocation to prevent conflicts between apps.
//!
//! Tracks which host ports are in use and allocates free ports for new apps.
//! Persists allocations to disk so they survive restarts.
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
/// Ports reserved by system/deploy services (LND UI, Mempool, etc.).
/// These are never allocated to user-installed apps.
const RESERVED_PORTS: &[u16] = &[
80, 443, 81, // HTTP/HTTPS
8332, 8333, 8334, // Bitcoin RPC/P2P
9735, 10009, 8080, // LND P2P, gRPC, REST
8081, // LND UI (archy-lnd-ui)
4080, 8999, 50001, // Mempool stack
23000, // BTCPay
8173, 8174, 8175, // Fedimint
8123, // Home Assistant
3000, // Grafana
11434, // Ollama
9980, 9001, // OnlyOffice, Penpot
8240, // Tailscale
9000, // Portainer
3001, // Uptime Kuma
8888, // SearXNG
8096, 2342, 2283, // Jellyfin, Photoprism, Immich
8443, 8084, // NPM
];
/// Start of range for allocating web app ports when preferred is taken.
const WEB_PORT_RANGE_START: u16 = 8085;
const WEB_PORT_RANGE_END: u16 = 9999;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct PortAllocations {
/// app_id -> (host_port, container_port)
allocations: HashMap<String, PortMapping>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct PortMapping {
host_port: u16,
container_port: u16,
}
pub struct PortAllocator {
data_dir: std::path::PathBuf,
allocations: PortAllocations,
}
impl PortAllocator {
pub fn new(data_dir: impl AsRef<Path>) -> Result<Self> {
let data_dir = data_dir.as_ref().to_path_buf();
let path = data_dir.join("port_allocations.json");
let allocations = if path.exists() {
let s = std::fs::read_to_string(&path)
.context("Failed to read port allocations")?;
serde_json::from_str(&s).unwrap_or_default()
} else {
PortAllocations::default()
};
Ok(Self {
data_dir,
allocations,
})
}
fn save(&self) -> Result<()> {
let path = self.data_dir.join("port_allocations.json");
std::fs::create_dir_all(&self.data_dir)
.context("Failed to create data dir for port allocations")?;
let s = serde_json::to_string_pretty(&self.allocations)
.context("Failed to serialize port allocations")?;
std::fs::write(&path, s).context("Failed to write port allocations")?;
Ok(())
}
fn is_reserved(&self, port: u16) -> bool {
RESERVED_PORTS.contains(&port)
}
fn is_allocated(&self, port: u16) -> bool {
self.allocations
.allocations
.values()
.any(|m| m.host_port == port)
}
fn is_available(&self, port: u16) -> bool {
!self.is_reserved(port) && !self.is_allocated(port)
}
/// Allocate a host port for an app. Uses preferred_port if available, else finds next free.
pub fn allocate(
&mut self,
app_id: &str,
preferred_host_port: u16,
container_port: u16,
) -> Result<u16> {
let host_port = if self.is_available(preferred_host_port) {
preferred_host_port
} else {
(WEB_PORT_RANGE_START..=WEB_PORT_RANGE_END)
.find(|&p| self.is_available(p))
.ok_or_else(|| anyhow::anyhow!("No free port in range {}-{}", WEB_PORT_RANGE_START, WEB_PORT_RANGE_END))?
};
self.allocations.allocations.insert(
app_id.to_string(),
PortMapping {
host_port,
container_port,
},
);
self.save()?;
Ok(host_port)
}
/// Get existing allocation for an app, if any.
pub fn get(&self, app_id: &str) -> Option<(u16, u16)> {
self.allocations.allocations.get(app_id).map(|m| {
(m.host_port, m.container_port)
})
}
/// Allocate or return existing. Use when installing/starting an app.
pub fn allocate_or_get(
&mut self,
app_id: &str,
preferred_host_port: u16,
container_port: u16,
) -> Result<u16> {
if let Some((host, _)) = self.get(app_id) {
return Ok(host);
}
self.allocate(app_id, preferred_host_port, container_port)
}
/// Release port when app is uninstalled.
pub fn release(&mut self, app_id: &str) -> Result<()> {
self.allocations.allocations.remove(app_id);
self.save()?;
Ok(())
}
}

View File

@@ -1,6 +1,8 @@
use crate::api::ApiHandler;
use crate::config::{Config, ContainerRuntime};
use crate::container::DockerPackageScanner;
use crate::container::{docker_packages, DockerPackageScanner};
use crate::identity::{self, NodeIdentity};
use crate::nostr_discovery;
use crate::state::StateManager;
use anyhow::Result;
use hyper::server::conn::Http;
@@ -13,6 +15,7 @@ use tracing::{debug, error, info};
pub struct Server {
_config: Config,
_identity: Arc<NodeIdentity>,
api_handler: Arc<ApiHandler>,
_state_manager: Arc<StateManager>,
}
@@ -20,17 +23,83 @@ pub struct Server {
impl Server {
pub async fn new(config: Config) -> Result<Self> {
let state_manager = Arc::new(StateManager::new());
// Load node identity and set stable server_info
let identity_dir = config.data_dir.join("identity");
let identity = NodeIdentity::load_or_create(&identity_dir).await?;
let (mut data, _) = state_manager.get_snapshot().await;
data.server_info.id = identity.node_id();
data.server_info.pubkey = identity.pubkey_hex();
data.server_info.tor_address = docker_packages::read_tor_address("archipelago");
if let Some(ref tor) = data.server_info.tor_address {
data.server_info.node_address = Some(identity.node_address(tor));
}
state_manager.update_data(data.clone()).await;
// Revoke any previously published Nostr data (runs before publish so revocation is not overwritten)
let identity_dir = config.data_dir.join("identity");
let tor_proxy_revoke = config.nostr_tor_proxy.clone();
if let Err(e) = nostr_discovery::revoke_if_needed(&identity_dir, tor_proxy_revoke.as_deref()).await {
tracing::debug!("Nostr revoke (non-fatal): {}", e);
}
// Publish node identity to Nostr only when opt-in (nostr_discovery_enabled + relays)
if config.nostr_discovery_enabled
&& !config.nostr_relays.is_empty()
&& data.server_info.node_address.is_some()
{
let identity_dir = config.data_dir.join("identity");
let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey).unwrap_or_default();
let node_addr = data.server_info.node_address.clone().unwrap_or_default();
let version = data.server_info.version.clone();
let relays = config.nostr_relays.clone();
let tor_proxy = config.nostr_tor_proxy.clone();
tokio::spawn(async move {
if let Err(e) = nostr_discovery::publish_node_identity(
&identity_dir,
&did,
&node_addr,
&version,
&relays,
tor_proxy.as_deref(),
)
.await
{
tracing::debug!("Nostr publish (non-fatal): {}", e);
}
});
}
info!("🔑 Node identity: {} (pubkey: {}...)", identity.node_id(), &identity.pubkey_hex()[..16.min(identity.pubkey_hex().len())]);
let identity = Arc::new(identity);
let api_handler = Arc::new(ApiHandler::new(config.clone(), state_manager.clone()).await?);
// Periodic Tor address refresh (runs regardless of dev_mode)
// Picks up hostname when Tor creates it after startup/rotation (30-60s delay)
{
let state = state_manager.clone();
let identity_clone = identity.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(30));
loop {
interval.tick().await;
if let Err(e) = refresh_tor_address(&state, identity_clone.as_ref()).await {
debug!("Tor address refresh (non-fatal): {}", e);
}
}
});
}
// Initialize Docker scanner if in dev mode
if config.dev_mode {
let scanner = create_docker_scanner(&config).await?;
let state = state_manager.clone();
let identity_clone = identity.clone();
// Initial scan
tokio::spawn(async move {
info!("🐳 Scanning Docker containers...");
if let Err(e) = scan_and_update_packages(&scanner, &state).await {
if let Err(e) = scan_and_update_packages(&scanner, &state, identity_clone.as_ref()).await {
error!("Failed to scan Docker containers: {}", e);
}
@@ -38,7 +107,7 @@ impl Server {
let mut interval = tokio::time::interval(Duration::from_secs(10));
loop {
interval.tick().await;
if let Err(e) = scan_and_update_packages(&scanner, &state).await {
if let Err(e) = scan_and_update_packages(&scanner, &state, identity_clone.as_ref()).await {
error!("Failed to update Docker containers: {}", e);
}
}
@@ -47,6 +116,7 @@ impl Server {
Ok(Self {
_config: config,
_identity: identity,
api_handler,
_state_manager: state_manager,
})
@@ -108,25 +178,42 @@ async fn create_docker_scanner(config: &Config) -> Result<DockerPackageScanner>
Ok(DockerPackageScanner::new(runtime))
}
async fn refresh_tor_address(state: &StateManager, identity: &NodeIdentity) -> Result<()> {
let tor_addr = docker_packages::read_tor_address("archipelago");
let (current_data, _) = state.get_snapshot().await;
if tor_addr != current_data.server_info.tor_address {
let mut data = current_data;
data.server_info.tor_address = tor_addr.clone();
data.server_info.node_address = tor_addr.as_ref().map(|t| identity.node_address(t));
state.update_data(data).await;
if let Some(ref addr) = tor_addr {
info!("🔒 Tor address updated: {}", addr);
}
}
Ok(())
}
async fn scan_and_update_packages(
scanner: &DockerPackageScanner,
state: &StateManager,
identity: &NodeIdentity,
) -> Result<()> {
let packages = scanner.scan_containers().await?;
// Only update if we have packages AND they're different from current state
if !packages.is_empty() {
let (current_data, _) = state.get_snapshot().await;
// Check if packages actually changed to avoid unnecessary broadcasts
let packages_changed = current_data.package_data != packages;
if packages_changed {
let mut data = current_data;
let (current_data, _) = state.get_snapshot().await;
let packages_changed = !packages.is_empty() && current_data.package_data != packages;
let tor_addr = docker_packages::read_tor_address("archipelago");
let tor_changed = tor_addr != current_data.server_info.tor_address;
if packages_changed || tor_changed {
let mut data = current_data;
if !packages.is_empty() {
data.package_data = packages;
state.update_data(data).await;
debug!("📦 Container state changed, broadcasting update");
}
data.server_info.tor_address = tor_addr.clone();
data.server_info.node_address = tor_addr.as_ref().map(|t| identity.node_address(t));
state.update_data(data).await;
debug!("📦 State changed (packages={}, tor={}), broadcasting update", packages_changed, tor_changed);
}
Ok(())