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:
749
core/Cargo.lock
generated
749
core/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
158
core/archipelago/src/electrs_status.rs
Normal file
158
core/archipelago/src/electrs_status.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
122
core/archipelago/src/identity.rs
Normal file
122
core/archipelago/src/identity.rs
Normal 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<base58btc(multicodec_ed25519_pub + 32-byte pubkey)>
|
||||
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()))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
133
core/archipelago/src/node_message.rs
Normal file
133
core/archipelago/src/node_message.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
345
core/archipelago/src/nostr_discovery.rs
Normal file
345
core/archipelago/src/nostr_discovery.rs
Normal 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)
|
||||
}
|
||||
63
core/archipelago/src/peers.rs
Normal file
63
core/archipelago/src/peers.rs
Normal 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)
|
||||
}
|
||||
148
core/archipelago/src/port_allocator.rs
Normal file
148
core/archipelago/src/port_allocator.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
|
||||
Reference in New Issue
Block a user