feat: bitcoin-ui CSS fix, HTTPS proxy support, deploy script improvements
Bitcoin UI: - Replace cdn.tailwindcss.com with locally bundled tailwind.css (CSP blocks external scripts) - Make all asset paths relative for nginx proxy compatibility - Add bitcoin-ui build/deploy to deploy-to-target.sh (was missing entirely) - Use --network host (bitcoin-ui proxies Bitcoin RPC at 127.0.0.1:8332) HTTPS mixed content fix: - Add HTTPS_PROXY_PATHS in AppSession.vue — when parent page is HTTPS, iframe loads through nginx proxy instead of direct HTTP port - Prevents browser blocking HTTP iframes inside HTTPS pages - All Tailscale servers use HTTPS, this was breaking all app iframes Deploy & first-boot improvements: - first-boot-containers.sh auto-detects disk size for pruning vs txindex - first-boot-containers.sh checks fallback source path for UI containers - Added mempool-electrs to APP_PORTS mapping - ElectrumX container creation in first-boot - Podman doctor/fix/uptime skills added Also includes: session persistence, identity management, LND transactions, ElectrumX status UI, nostr-provider improvements, Web5 enhancements Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -118,6 +118,7 @@ impl ApiHandler {
|
||||
// WebSocket upgrade — validate session before upgrading
|
||||
if method == Method::GET && path == "/ws/db" {
|
||||
if !self.is_authenticated(req.headers()).await {
|
||||
tracing::warn!("401 WebSocket /ws/db — session invalid or missing");
|
||||
return Ok(Self::unauthorized());
|
||||
}
|
||||
return Self::handle_websocket(req, self.state_manager.clone(), self.metrics_store.clone()).await;
|
||||
|
||||
@@ -176,23 +176,58 @@ impl RpcHandler {
|
||||
|
||||
let name = c.get("Names").and_then(|v| v.as_array()).and_then(|a| a.first()).and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
// Determine lan_address based on container name
|
||||
// Map container name to its UI port (lan_address)
|
||||
let lan_address = match name {
|
||||
"bitcoin-knots" => Some("http://localhost:8334"),
|
||||
"lnd" => Some("http://localhost:8081"),
|
||||
"bitcoin-knots" | "bitcoin-ui" => Some("http://localhost:8334"),
|
||||
"lnd" | "archy-lnd-ui" => Some("http://localhost:8081"),
|
||||
"tailscale" => Some("http://localhost:8240"),
|
||||
"homeassistant" => Some("http://localhost:8123"),
|
||||
"archy-mempool-web" | "mempool" => Some("http://localhost:4080"),
|
||||
"btcpay-server" => Some("http://localhost:23000"),
|
||||
"grafana" => Some("http://localhost:3000"),
|
||||
"searxng" => Some("http://localhost:8888"),
|
||||
"ollama" => Some("http://localhost:11434"),
|
||||
"onlyoffice" => Some("http://localhost:9980"),
|
||||
"penpot" => Some("http://localhost:9001"),
|
||||
"nextcloud" => Some("http://localhost:8085"),
|
||||
"vaultwarden" => Some("http://localhost:8082"),
|
||||
"jellyfin" => Some("http://localhost:8096"),
|
||||
"photoprism" => Some("http://localhost:2342"),
|
||||
"immich_server" | "immich" => Some("http://localhost:2283"),
|
||||
"filebrowser" => Some("http://localhost:8083"),
|
||||
"nginx-proxy-manager" => Some("http://localhost:81"),
|
||||
"portainer" => Some("http://localhost:9000"),
|
||||
"uptime-kuma" => Some("http://localhost:3001"),
|
||||
"fedimint" => Some("http://localhost:8175"),
|
||||
"fedimint-gateway" => Some("http://localhost:8176"),
|
||||
"nostr-rs-relay" => Some("http://localhost:18081"),
|
||||
"indeedhub" => Some("http://localhost:7777"),
|
||||
"dwn" => Some("http://localhost:3100"),
|
||||
"endurain" => Some("http://localhost:8080"),
|
||||
"electrs" | "archy-electrs-ui" => Some("http://localhost:50002"),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// Parse ports from podman JSON (field is "host_port" in snake_case)
|
||||
let ports: Vec<String> = c.get("Ports")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| {
|
||||
a.iter().filter_map(|p| {
|
||||
let host = p.get("host_port").and_then(|v| v.as_u64())?;
|
||||
let container = p.get("container_port").and_then(|v| v.as_u64())?;
|
||||
let proto = p.get("protocol").and_then(|v| v.as_str()).unwrap_or("tcp");
|
||||
Some(format!("0.0.0.0:{}->{}/{}", host, container, proto))
|
||||
}).collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
serde_json::json!({
|
||||
"id": c.get("Id").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"name": name,
|
||||
"state": mapped_state,
|
||||
"image": c.get("Image").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"created": c.get("Created").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"ports": c.get("Ports").and_then(|v| v.as_array()).map(|a|
|
||||
a.iter().filter_map(|p| p.get("hostPort").and_then(|v| v.as_u64()).map(|p| p.to_string())).collect::<Vec<_>>()
|
||||
).unwrap_or_default(),
|
||||
"ports": ports,
|
||||
"lan_address": lan_address,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! RPC handlers for multi-identity management.
|
||||
|
||||
use super::RpcHandler;
|
||||
use crate::identity_manager::{IdentityManager, IdentityPurpose};
|
||||
use crate::identity_manager::{IdentityManager, IdentityProfile, IdentityPurpose};
|
||||
use crate::network::did_dht;
|
||||
use anyhow::{Context, Result};
|
||||
use nostr_sdk::ToBech32;
|
||||
@@ -43,6 +43,7 @@ impl RpcHandler {
|
||||
"is_default": is_default,
|
||||
"nostr_pubkey": id.nostr_pubkey,
|
||||
"nostr_npub": id.nostr_npub,
|
||||
"profile": id.profile,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -650,6 +651,94 @@ impl RpcHandler {
|
||||
}))
|
||||
}
|
||||
|
||||
/// Update profile metadata for an identity.
|
||||
pub(super) async fn handle_identity_update_profile(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let id = params.get("id").and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
|
||||
validate_identity_id(id)?;
|
||||
|
||||
let profile = IdentityProfile {
|
||||
display_name: params.get("display_name").and_then(|v| v.as_str()).map(String::from),
|
||||
about: params.get("about").and_then(|v| v.as_str()).map(String::from),
|
||||
picture: params.get("picture").and_then(|v| v.as_str()).map(String::from),
|
||||
banner: params.get("banner").and_then(|v| v.as_str()).map(String::from),
|
||||
website: params.get("website").and_then(|v| v.as_str()).map(String::from),
|
||||
nip05: params.get("nip05").and_then(|v| v.as_str()).map(String::from),
|
||||
lud16: params.get("lud16").and_then(|v| v.as_str()).map(String::from),
|
||||
};
|
||||
|
||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||
manager.update_profile(id, profile).await?;
|
||||
|
||||
Ok(serde_json::json!({ "ok": true }))
|
||||
}
|
||||
|
||||
/// Publish kind 0 (metadata) profile to the local Nostr relay.
|
||||
pub(super) async fn handle_identity_publish_profile(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let id = params.get("id").and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
|
||||
validate_identity_id(id)?;
|
||||
|
||||
let relay_url = params.get("relay")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("ws://localhost:18081");
|
||||
|
||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||
let event_id = manager.publish_profile(id, relay_url).await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"event_id": event_id,
|
||||
"relay": relay_url,
|
||||
"published": true,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Export private keys for an identity — REQUIRES password verification.
|
||||
pub(super) async fn handle_identity_export_keys(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let id = params
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
|
||||
let password = params
|
||||
.get("password")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: password"))?;
|
||||
validate_identity_id(id)?;
|
||||
|
||||
// Verify password against auth system
|
||||
if !self.auth_manager.verify_password(password).await? {
|
||||
anyhow::bail!("Invalid password");
|
||||
}
|
||||
|
||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||
let keys = manager.export_keys(id).await?;
|
||||
let record = manager.get(id).await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"id": record.id,
|
||||
"name": record.name,
|
||||
"pubkey": record.pubkey_hex,
|
||||
"did": record.did,
|
||||
"nostr_pubkey": record.nostr_pubkey,
|
||||
"nostr_npub": record.nostr_npub,
|
||||
"ed25519_secret_hex": keys["ed25519_secret_hex"],
|
||||
"nostr_secret_hex": keys["nostr_secret_hex"],
|
||||
"nostr_nsec": keys["nostr_nsec"],
|
||||
}))
|
||||
}
|
||||
|
||||
/// identity.dht-status — Check if an identity's did:dht is published and resolvable.
|
||||
pub(super) async fn handle_identity_dht_status(
|
||||
&self,
|
||||
|
||||
@@ -674,6 +674,129 @@ impl RpcHandler {
|
||||
"broadcast": true,
|
||||
}))
|
||||
}
|
||||
|
||||
/// List on-chain transactions from LND.
|
||||
/// Returns all transactions, with incoming (amount > 0) flagged.
|
||||
pub(super) async fn handle_lnd_gettransactions(&self) -> Result<serde_json::Value> {
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let resp = client
|
||||
.get("https://127.0.0.1:8080/v1/transactions")
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND REST connection failed")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse transactions response")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
return Err(anyhow::anyhow!("Failed to list transactions: {}", msg));
|
||||
}
|
||||
|
||||
let empty_vec = vec![];
|
||||
let raw_txs = body
|
||||
.get("transactions")
|
||||
.and_then(|v| v.as_array())
|
||||
.unwrap_or(&empty_vec);
|
||||
|
||||
let mut transactions: Vec<serde_json::Value> = Vec::new();
|
||||
for tx in raw_txs {
|
||||
let amount: i64 = tx
|
||||
.get("amount")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.or_else(|| tx.get("amount").and_then(|v| v.as_i64()))
|
||||
.unwrap_or(0);
|
||||
|
||||
let num_confirmations: i64 = tx
|
||||
.get("num_confirmations")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0);
|
||||
|
||||
let tx_hash = tx
|
||||
.get("tx_hash")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let time_stamp: i64 = tx
|
||||
.get("time_stamp")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.or_else(|| tx.get("time_stamp").and_then(|v| v.as_i64()))
|
||||
.unwrap_or(0);
|
||||
|
||||
let total_fees: i64 = tx
|
||||
.get("total_fees")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.or_else(|| tx.get("total_fees").and_then(|v| v.as_i64()))
|
||||
.unwrap_or(0);
|
||||
|
||||
let dest_addresses: Vec<String> = tx
|
||||
.get("dest_addresses")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|a| a.as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let label = tx
|
||||
.get("label")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let block_height: i64 = tx
|
||||
.get("block_height")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0);
|
||||
|
||||
let direction = if amount > 0 { "incoming" } else { "outgoing" };
|
||||
|
||||
transactions.push(serde_json::json!({
|
||||
"tx_hash": tx_hash,
|
||||
"amount_sats": amount.abs(),
|
||||
"direction": direction,
|
||||
"num_confirmations": num_confirmations,
|
||||
"time_stamp": time_stamp,
|
||||
"total_fees": total_fees,
|
||||
"dest_addresses": dest_addresses,
|
||||
"label": label,
|
||||
"block_height": block_height,
|
||||
}));
|
||||
}
|
||||
|
||||
// Sort by timestamp descending (most recent first)
|
||||
transactions.sort_by(|a, b| {
|
||||
let ta = a.get("time_stamp").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let tb = b.get("time_stamp").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
tb.cmp(&ta)
|
||||
});
|
||||
|
||||
let incoming_pending: usize = transactions
|
||||
.iter()
|
||||
.filter(|t| {
|
||||
t.get("direction").and_then(|v| v.as_str()) == Some("incoming")
|
||||
&& t.get("num_confirmations").and_then(|v| v.as_i64()) == Some(0)
|
||||
})
|
||||
.count();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"transactions": transactions,
|
||||
"incoming_pending_count": incoming_pending,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// Channel types
|
||||
|
||||
@@ -35,7 +35,7 @@ use crate::config::Config;
|
||||
use crate::container::DevContainerOrchestrator;
|
||||
use crate::monitoring::MetricsStore;
|
||||
use crate::port_allocator::PortAllocator;
|
||||
use crate::session::{self, EndpointRateLimiter, LoginRateLimiter, SessionStore};
|
||||
use crate::session::{self, EndpointRateLimiter, LoginRateLimiter, SessionStore, REMEMBER_TTL};
|
||||
use crate::state::StateManager;
|
||||
use anyhow::{Context, Result};
|
||||
use hyper::{Request, Response, StatusCode};
|
||||
@@ -221,12 +221,30 @@ impl RpcHandler {
|
||||
|
||||
// Enforce authentication for non-allowlisted methods
|
||||
let is_unauthenticated = UNAUTHENTICATED_METHODS.contains(&rpc_req.method.as_str());
|
||||
let mut new_session_cookies: Option<(String, String)> = None; // (session, csrf) if auto-restored
|
||||
if !is_unauthenticated {
|
||||
let authenticated = match &session_token {
|
||||
let mut authenticated = match &session_token {
|
||||
Some(token) => self.session_store.validate(token).await,
|
||||
None => false,
|
||||
};
|
||||
|
||||
// If session invalid, try remember-me token to auto-restore session
|
||||
if !authenticated {
|
||||
if let Some(remember) = extract_cookie(&parts.headers, "remember") {
|
||||
if crate::session::SessionStore::validate_remember_token(&remember) {
|
||||
// Auto-create a new session from the remember-me token
|
||||
let new_token = self.session_store.create().await;
|
||||
let new_csrf = generate_csrf_token();
|
||||
tracing::info!("Auto-restored session from remember-me token");
|
||||
new_session_cookies = Some((new_token, new_csrf));
|
||||
authenticated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !authenticated {
|
||||
let reason = if session_token.is_none() { "no session cookie" } else { "invalid/expired token" };
|
||||
tracing::warn!(method = %rpc_req.method, reason, "401 Unauthorized — rejecting RPC call");
|
||||
let rpc_resp = RpcResponse {
|
||||
result: None,
|
||||
error: Some(RpcError {
|
||||
@@ -269,7 +287,8 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
// CSRF protection: validate X-CSRF-Token header for authenticated methods
|
||||
if !is_unauthenticated {
|
||||
// Skip CSRF check if session was just auto-restored from remember-me (new CSRF will be set in response)
|
||||
if !is_unauthenticated && new_session_cookies.is_none() {
|
||||
let csrf_cookie = extract_csrf_cookie(&parts.headers);
|
||||
let csrf_header = parts
|
||||
.headers
|
||||
@@ -280,6 +299,12 @@ impl RpcHandler {
|
||||
match (&csrf_cookie, &csrf_header) {
|
||||
(Some(cookie), Some(header)) if cookie == header => { /* valid */ }
|
||||
_ => {
|
||||
tracing::warn!(
|
||||
method = %rpc_req.method,
|
||||
has_cookie = csrf_cookie.is_some(),
|
||||
has_header = csrf_header.is_some(),
|
||||
"403 CSRF mismatch — rejecting RPC call"
|
||||
);
|
||||
let rpc_resp = RpcResponse {
|
||||
result: None,
|
||||
error: Some(RpcError {
|
||||
@@ -445,6 +470,7 @@ impl RpcHandler {
|
||||
"lnd.payinvoice" => self.handle_lnd_payinvoice(params).await,
|
||||
"lnd.create-psbt" => self.handle_lnd_create_psbt(params).await,
|
||||
"lnd.finalize-psbt" => self.handle_lnd_finalize_psbt(params).await,
|
||||
"lnd.gettransactions" => self.handle_lnd_gettransactions().await,
|
||||
|
||||
// Multi-identity management
|
||||
"identity.list" => self.handle_identity_list(params).await,
|
||||
@@ -461,6 +487,9 @@ impl RpcHandler {
|
||||
"identity.resolve-dht-did" => self.handle_identity_resolve_dht_did(params).await,
|
||||
"identity.refresh-dht-did" => self.handle_identity_refresh_dht_did(params).await,
|
||||
"identity.dht-status" => self.handle_identity_dht_status(params).await,
|
||||
"identity.update-profile" => self.handle_identity_update_profile(params).await,
|
||||
"identity.publish-profile" => self.handle_identity_publish_profile(params).await,
|
||||
"identity.export-keys" => self.handle_identity_export_keys(params).await,
|
||||
"identity.create-nostr-key" => self.handle_identity_create_nostr_key(params).await,
|
||||
"identity.nostr-sign" => self.handle_identity_nostr_sign(params).await,
|
||||
"identity.nostr-encrypt-nip04" => self.handle_identity_nostr_encrypt_nip04(params).await,
|
||||
@@ -778,6 +807,7 @@ impl RpcHandler {
|
||||
// No 2FA: create a full session immediately
|
||||
let token = self.session_store.create().await;
|
||||
let csrf_token = generate_csrf_token();
|
||||
let remember_token = self.session_store.create_remember_token();
|
||||
response.headers_mut().append(
|
||||
"Set-Cookie",
|
||||
format!("session={}; HttpOnly; SameSite=Strict; Path=/{}", token, self.cookie_suffix())
|
||||
@@ -790,6 +820,13 @@ impl RpcHandler {
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
// Remember-me: HMAC-signed, survives backend restarts (30-day TTL)
|
||||
response.headers_mut().append(
|
||||
"Set-Cookie",
|
||||
format!("remember={}; HttpOnly; SameSite=Strict; Path=/; Max-Age={}{}", remember_token, REMEMBER_TTL, self.cookie_suffix())
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -844,6 +881,23 @@ impl RpcHandler {
|
||||
);
|
||||
}
|
||||
|
||||
// If session was auto-restored from remember-me, set new cookies on the response
|
||||
if let Some((new_session, new_csrf)) = new_session_cookies {
|
||||
let suffix = self.cookie_suffix();
|
||||
response.headers_mut().append(
|
||||
"Set-Cookie",
|
||||
format!("session={}; HttpOnly; SameSite=Strict; Path=/{}", new_session, suffix)
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
response.headers_mut().append(
|
||||
"Set-Cookie",
|
||||
format!("csrf_token={}; SameSite=Strict; Path=/{}", new_csrf, suffix)
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
@@ -864,13 +918,14 @@ fn generate_csrf_token() -> String {
|
||||
hex::encode(bytes)
|
||||
}
|
||||
|
||||
/// Extract the csrf_token cookie value from headers.
|
||||
fn extract_csrf_cookie(headers: &hyper::HeaderMap) -> Option<String> {
|
||||
/// Extract a named cookie value from headers.
|
||||
fn extract_cookie(headers: &hyper::HeaderMap, name: &str) -> Option<String> {
|
||||
let prefix = format!("{}=", name);
|
||||
for value in headers.get_all("cookie") {
|
||||
if let Ok(s) = value.to_str() {
|
||||
for part in s.split(';') {
|
||||
let part = part.trim();
|
||||
if let Some(val) = part.strip_prefix("csrf_token=") {
|
||||
if let Some(val) = part.strip_prefix(&prefix) {
|
||||
let val = val.trim();
|
||||
if !val.is_empty() {
|
||||
return Some(val.to_string());
|
||||
@@ -882,6 +937,11 @@ fn extract_csrf_cookie(headers: &hyper::HeaderMap) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract the csrf_token cookie value from headers.
|
||||
fn extract_csrf_cookie(headers: &hyper::HeaderMap) -> Option<String> {
|
||||
extract_cookie(headers, "csrf_token")
|
||||
}
|
||||
|
||||
/// Extract the client IP from request headers (X-Real-IP or X-Forwarded-For).
|
||||
fn extract_client_ip(headers: &hyper::HeaderMap) -> IpAddr {
|
||||
headers
|
||||
|
||||
@@ -58,13 +58,13 @@ impl RpcHandler {
|
||||
})
|
||||
};
|
||||
let has_bitcoin = is_running(&["bitcoin-knots", "bitcoin-core", "bitcoin"]);
|
||||
let has_electrs = is_running(&["mempool-electrs", "electrs"]);
|
||||
let has_electrumx = is_running(&["electrumx", "mempool-electrs", "electrs"]);
|
||||
has_lnd = is_running(&["lnd"]);
|
||||
|
||||
match package_id {
|
||||
"mempool-electrs" | "electrs" if !has_bitcoin => {
|
||||
"electrumx" | "mempool-electrs" | "electrs" if !has_bitcoin => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Electrs requires a running Bitcoin node (Bitcoin Knots). Please install and start Bitcoin Knots first."
|
||||
"ElectrumX requires a running Bitcoin node (Bitcoin Knots). Please install and start Bitcoin Knots first."
|
||||
));
|
||||
}
|
||||
"lnd" if !has_bitcoin => {
|
||||
@@ -77,10 +77,10 @@ impl RpcHandler {
|
||||
"BTCPay Server requires a running Bitcoin node (Bitcoin Knots). Please install and start Bitcoin Knots first."
|
||||
));
|
||||
}
|
||||
"mempool" | "mempool-web" if !has_bitcoin || !has_electrs => {
|
||||
"mempool" | "mempool-web" if !has_bitcoin || !has_electrumx => {
|
||||
let mut missing = vec![];
|
||||
if !has_bitcoin { missing.push("Bitcoin Knots"); }
|
||||
if !has_electrs { missing.push("Electrs"); }
|
||||
if !has_electrumx { missing.push("ElectrumX"); }
|
||||
return Err(anyhow::anyhow!(
|
||||
"Mempool requires {} to be running. Please install and start {} first.",
|
||||
missing.join(" and "),
|
||||
@@ -179,8 +179,11 @@ impl RpcHandler {
|
||||
debug!("Using local image: {}", docker_image);
|
||||
}
|
||||
|
||||
// Normalize container name: "electrs" alias -> "mempool-electrs"
|
||||
let container_name = if package_id == "electrs" { "mempool-electrs" } else { package_id };
|
||||
// Normalize container name: legacy "electrs"/"mempool-electrs" aliases -> "electrumx"
|
||||
let container_name = match package_id {
|
||||
"electrs" | "mempool-electrs" => "electrumx",
|
||||
_ => package_id,
|
||||
};
|
||||
|
||||
// Create and start container with security constraints
|
||||
let mut run_args = vec![
|
||||
@@ -234,7 +237,7 @@ impl RpcHandler {
|
||||
package_id,
|
||||
"bitcoin-knots" | "bitcoin" | "bitcoin-core"
|
||||
| "lnd"
|
||||
| "mempool" | "mempool-web" | "mempool-api" | "mempool-electrs" | "electrs" | "mysql-mempool" | "archy-mempool-db" | "archy-mempool-web"
|
||||
| "mempool" | "mempool-web" | "mempool-api" | "electrumx" | "mempool-electrs" | "electrs" | "mysql-mempool" | "archy-mempool-db" | "archy-mempool-web"
|
||||
| "btcpay-server" | "btcpayserver" | "archy-btcpay-db" | "archy-nbxplorer" | "nbxplorer"
|
||||
| "fedimint" | "fedimint-gateway"
|
||||
);
|
||||
@@ -669,10 +672,10 @@ printtoconsole=1\n";
|
||||
vec![format!("archy-{}", package_id)]
|
||||
} else {
|
||||
let order: &[&str] = match package_id {
|
||||
"mempool" | "mempool-web" => &["archy-mempool-db", "mysql-mempool", "mempool-electrs", "mempool-api", "archy-mempool-api", "archy-mempool-web", "mempool"],
|
||||
"mempool" | "mempool-web" => &["archy-mempool-db", "mysql-mempool", "electrumx", "mempool-electrs", "mempool-api", "archy-mempool-api", "archy-mempool-web", "mempool"],
|
||||
"immich" => &["immich_postgres", "immich_redis", "immich_server"],
|
||||
"penpot" | "penpot-frontend" => &["penpot-postgres", "penpot-valkey", "penpot-backend", "penpot-exporter", "penpot-frontend"],
|
||||
_ => &["archy-mempool-db", "mysql-mempool", "mempool-electrs", "mempool-api", "archy-mempool-api", "archy-mempool-web", "mempool"],
|
||||
_ => &["archy-mempool-db", "mysql-mempool", "electrumx", "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));
|
||||
@@ -1114,6 +1117,7 @@ async fn get_containers_for_app(package_id: &str) -> Result<Vec<String>> {
|
||||
let patterns: Vec<String> = match package_id {
|
||||
"mempool" | "mempool-web" => {
|
||||
vec![
|
||||
"electrumx".into(),
|
||||
"mempool-electrs".into(),
|
||||
"mempool-api".into(),
|
||||
"archy-mempool-api".into(),
|
||||
@@ -1160,6 +1164,7 @@ fn get_data_dirs_for_app(package_id: &str) -> Vec<String> {
|
||||
"mempool" | "mempool-web" => vec![
|
||||
format!("{}/mempool", base),
|
||||
format!("{}/mysql-mempool", base),
|
||||
format!("{}/electrumx", base),
|
||||
format!("{}/mempool-electrs", base),
|
||||
],
|
||||
"fedimint" => vec![format!("{}/fedimint", base), format!("{}/fedimint-gateway", base)],
|
||||
@@ -1300,6 +1305,7 @@ fn is_readonly_compatible(app_id: &str) -> bool {
|
||||
"searxng"
|
||||
| "grafana"
|
||||
| "filebrowser"
|
||||
| "electrumx"
|
||||
| "mempool-electrs"
|
||||
| "electrs"
|
||||
| "nostr-rs-relay"
|
||||
@@ -1332,8 +1338,8 @@ fn get_health_check_args(app_id: &str) -> Vec<String> {
|
||||
"curl -sf http://localhost:8080/ || exit 1",
|
||||
"30s", "3",
|
||||
),
|
||||
"mempool-electrs" | "electrs" => (
|
||||
"curl -sf http://localhost:50001/ || exit 1",
|
||||
"electrumx" | "mempool-electrs" | "electrs" => (
|
||||
"curl -sf http://localhost:8000/ || exit 1",
|
||||
"60s", "3",
|
||||
),
|
||||
"nextcloud" => (
|
||||
@@ -1420,7 +1426,7 @@ fn get_memory_limit(app_id: &str) -> &'static str {
|
||||
"ollama" => "4g",
|
||||
// Medium apps
|
||||
"lnd" => "512m",
|
||||
"mempool-electrs" | "electrs" => "1g",
|
||||
"electrumx" | "mempool-electrs" | "electrs" => "1g",
|
||||
"nextcloud" => "1g",
|
||||
"immich_server" | "immich" => "1g",
|
||||
"btcpay-server" | "btcpayserver" => "1g",
|
||||
@@ -1507,7 +1513,7 @@ fn get_app_config(
|
||||
vec!["/var/lib/archipelago/mempool:/data".to_string()],
|
||||
vec![
|
||||
"MEMPOOL_BACKEND=electrum".to_string(),
|
||||
"ELECTRUM_HOST=mempool-electrs".to_string(),
|
||||
"ELECTRUM_HOST=electrumx".to_string(),
|
||||
"ELECTRUM_PORT=50001".to_string(),
|
||||
"ELECTRUM_TLS_ENABLED=false".to_string(),
|
||||
format!("CORE_RPC_HOST={}", host_ip),
|
||||
@@ -1523,26 +1529,20 @@ fn get_app_config(
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"mempool-electrs" | "electrs" => {
|
||||
"electrumx" | "mempool-electrs" | "electrs" => {
|
||||
// Detect which bitcoin container is running for archy-net DNS resolution
|
||||
let bitcoin_host = detect_bitcoin_container_name();
|
||||
(
|
||||
vec!["50001:50001".to_string()],
|
||||
vec!["/var/lib/archipelago/mempool-electrs:/data".to_string()],
|
||||
vec![],
|
||||
vec!["/var/lib/archipelago/electrumx:/data".to_string()],
|
||||
vec![
|
||||
format!("DAEMON_URL=http://archipelago:archipelago123@{}:8332/", bitcoin_host),
|
||||
"COIN=Bitcoin".to_string(),
|
||||
"DB_DIRECTORY=/data".to_string(),
|
||||
"SERVICES=tcp://:50001,rpc://0.0.0.0:8000".to_string(),
|
||||
],
|
||||
None,
|
||||
None,
|
||||
Some(vec![
|
||||
"--daemon-rpc-addr".to_string(),
|
||||
format!("{}:8332", bitcoin_host),
|
||||
"--cookie".to_string(),
|
||||
"archipelago:archipelago123".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" => (
|
||||
|
||||
@@ -592,8 +592,8 @@ async fn read_temperatures() -> Result<Vec<serde_json::Value>> {
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// system.factory-reset — Wipe all user data and restart.
|
||||
/// Preserves container images and node_key (hardware identity).
|
||||
/// system.factory-reset — Wipe all user data, remove containers, and restart.
|
||||
/// Only preserves the data_dir itself (recreated empty on restart).
|
||||
pub(super) async fn handle_system_factory_reset(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
@@ -609,53 +609,67 @@ impl RpcHandler {
|
||||
anyhow::bail!("Factory reset requires {{ \"confirm\": true }}");
|
||||
}
|
||||
|
||||
tracing::warn!("Factory reset initiated — wiping user data");
|
||||
tracing::warn!("Factory reset initiated — wiping ALL user data and containers");
|
||||
|
||||
let data_dir = &self.config.data_dir;
|
||||
|
||||
// Stop all running containers
|
||||
// 1. Stop and remove ALL containers (force)
|
||||
let client = archipelago_container::PodmanClient::new("archipelago".to_string());
|
||||
if let Ok(containers) = client.list_containers().await {
|
||||
for c in &containers {
|
||||
tracing::info!("Factory reset: removing container {}", c.name);
|
||||
let _ = client.stop_container(&c.name).await;
|
||||
let _ = client.remove_container(&c.name).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete user data (preserving node_key and container images)
|
||||
let files_to_remove = [
|
||||
"user.json",
|
||||
"onboarding.json",
|
||||
"peers.json",
|
||||
"server-name",
|
||||
];
|
||||
for f in &files_to_remove {
|
||||
let path = data_dir.join(f);
|
||||
if path.exists() {
|
||||
let _ = tokio::fs::remove_file(&path).await;
|
||||
// 2. Remove all container images
|
||||
tracing::info!("Factory reset: pruning all container images");
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["-u", "archipelago", "podman", "rmi", "--all", "--force"])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
// 3. Prune volumes and build cache
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["-u", "archipelago", "podman", "volume", "prune", "-f"])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["-u", "archipelago", "podman", "system", "prune", "-af"])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
// 4. Wipe the entire data directory contents
|
||||
// Delete everything inside data_dir, then recreate the empty dir.
|
||||
if let Ok(mut entries) = tokio::fs::read_dir(data_dir).await {
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
let path = entry.path();
|
||||
let name = entry.file_name();
|
||||
let name_str = name.to_string_lossy();
|
||||
|
||||
// Skip the tor directory (managed by system debian-tor user)
|
||||
if name_str == "tor" {
|
||||
continue;
|
||||
}
|
||||
|
||||
tracing::info!("Factory reset: removing {}", path.display());
|
||||
if path.is_dir() {
|
||||
let _ = tokio::fs::remove_dir_all(&path).await;
|
||||
} else {
|
||||
let _ = tokio::fs::remove_file(&path).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let dirs_to_remove = [
|
||||
"identities",
|
||||
"credentials",
|
||||
"did-cache",
|
||||
"dwn",
|
||||
];
|
||||
for d in &dirs_to_remove {
|
||||
let path = data_dir.join(d);
|
||||
if path.exists() {
|
||||
let _ = tokio::fs::remove_dir_all(&path).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all sessions
|
||||
// 5. Clear all sessions
|
||||
self.session_store.invalidate_all_except("").await;
|
||||
|
||||
tracing::warn!("Factory reset complete — restarting service");
|
||||
tracing::warn!("Factory reset complete — all data wiped, restarting service");
|
||||
|
||||
// Restart the service via systemd
|
||||
tokio::spawn(async {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
let _ = std::process::Command::new("sudo")
|
||||
.args(["systemctl", "restart", "archipelago"])
|
||||
.spawn();
|
||||
|
||||
@@ -139,9 +139,9 @@ impl DockerPackageScanner {
|
||||
} else if app_id == "indeedhub" {
|
||||
debug!("Using IndeedHub: http://localhost:7777");
|
||||
Some("http://localhost:7777".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");
|
||||
} else if app_id == "electrumx" || app_id == "mempool-electrs" || app_id == "electrs" {
|
||||
// ElectrumX UI runs on host at port 50002
|
||||
debug!("Using electrumx-ui for electrumx: http://localhost:50002");
|
||||
Some("http://localhost:50002".to_string())
|
||||
} else {
|
||||
// Extract port from the main container
|
||||
@@ -240,7 +240,7 @@ fn get_app_tier(app_id: &str) -> &'static str {
|
||||
// Core: required for basic Bitcoin node
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => "core",
|
||||
"lnd" => "core",
|
||||
"mempool" | "mempool-web" | "mempool-api" | "mempool-electrs" | "electrs" => "core",
|
||||
"mempool" | "mempool-web" | "mempool-api" | "electrumx" | "mempool-electrs" | "electrs" => "core",
|
||||
"btcpay" | "btcpay-server" | "btcpayserver" => "core",
|
||||
"dwn" => "core",
|
||||
"filebrowser" => "core",
|
||||
@@ -329,11 +329,11 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
|
||||
repo: "https://github.com/mempool/mempool".to_string(),
|
||||
tier: "",
|
||||
},
|
||||
"mempool-electrs" | "electrs" => AppMetadata {
|
||||
title: "Electrs".to_string(),
|
||||
description: "Electrum protocol indexer for Bitcoin. Powers Mempool and other Electrum clients.".to_string(),
|
||||
"electrumx" | "mempool-electrs" | "electrs" => AppMetadata {
|
||||
title: "ElectrumX".to_string(),
|
||||
description: "ElectrumX server — full Electrum protocol indexer for Bitcoin. Powers Mempool and Electrum wallets.".to_string(),
|
||||
icon: "/assets/img/app-icons/electrs.svg".to_string(),
|
||||
repo: "https://github.com/romanz/electrs".to_string(),
|
||||
repo: "https://github.com/spesmilo/electrumx".to_string(),
|
||||
tier: "",
|
||||
},
|
||||
"ollama" => AppMetadata {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Electrs sync status: fetches indexed height from Electrum RPC and network height from Bitcoin Core.
|
||||
//! ElectrumX sync status: fetches indexed height from Electrum RPC and network height from Bitcoin Core.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Serialize;
|
||||
@@ -6,12 +6,12 @@ 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 ELECTRUMX_HOST: &str = "127.0.0.1";
|
||||
const ELECTRUMX_PORT: u16 = 50001;
|
||||
const BITCOIN_RPC_URL: &str = "http://127.0.0.1:8332/";
|
||||
const ELECTRS_DATA_DIR: &str = "/var/lib/archipelago/mempool-electrs";
|
||||
// Approximate final index size in bytes for mainnet with --lightmode (~35GB)
|
||||
const ESTIMATED_FULL_INDEX_BYTES: f64 = 35_000_000_000.0;
|
||||
const ELECTRUMX_DATA_DIR: &str = "/var/lib/archipelago/electrumx";
|
||||
// Approximate final index size in bytes for mainnet (~55GB for ElectrumX full index)
|
||||
const ESTIMATED_FULL_INDEX_BYTES: f64 = 55_000_000_000.0;
|
||||
|
||||
/// Build Bitcoin RPC Basic auth header from env vars.
|
||||
/// Falls back to cookie auth file if env vars are not set.
|
||||
@@ -61,10 +61,10 @@ fn format_bytes(bytes: u64) -> 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")?;
|
||||
/// Fetch ElectrumX indexed height via Electrum protocol (TCP JSON-RPC).
|
||||
fn electrumx_indexed_height() -> Result<u64> {
|
||||
let mut stream = TcpStream::connect((ELECTRUMX_HOST, ELECTRUMX_PORT))
|
||||
.context("Failed to connect to ElectrumX")?;
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(5)))
|
||||
.context("set_read_timeout")?;
|
||||
@@ -83,11 +83,11 @@ fn electrs_indexed_height() -> Result<u64> {
|
||||
reader.read_line(&mut line)?;
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
anyhow::bail!("Empty response from electrs");
|
||||
anyhow::bail!("Empty response from ElectrumX");
|
||||
}
|
||||
|
||||
let json: serde_json::Value = serde_json::from_str(line)?;
|
||||
// blockchain.numblocks.subscribe returns result as number; headers.subscribe returns {block_height: N}
|
||||
// blockchain.numblocks.subscribe returns result as number; headers.subscribe returns {block_height: N, hex: ...}
|
||||
let height = json
|
||||
.get("result")
|
||||
.and_then(|r| r.as_u64())
|
||||
@@ -96,7 +96,13 @@ fn electrs_indexed_height() -> Result<u64> {
|
||||
.and_then(|r| r.get("block_height"))
|
||||
.and_then(|h| h.as_u64())
|
||||
})
|
||||
.context("Missing height in electrs response")?;
|
||||
.or_else(|| {
|
||||
// ElectrumX returns {"result": {"height": N, "hex": "..."}}
|
||||
json.get("result")
|
||||
.and_then(|r| r.get("height"))
|
||||
.and_then(|h| h.as_u64())
|
||||
})
|
||||
.context("Missing height in ElectrumX response")?;
|
||||
Ok(height)
|
||||
}
|
||||
|
||||
@@ -130,10 +136,10 @@ async fn bitcoin_network_height() -> Result<u64> {
|
||||
Ok(height)
|
||||
}
|
||||
|
||||
/// Get electrs sync status. Runs blocking electrs call in spawn_blocking.
|
||||
/// Get ElectrumX sync status. Runs blocking ElectrumX call in spawn_blocking.
|
||||
pub async fn get_electrs_sync_status() -> ElectrsSyncStatus {
|
||||
// Get index data size (non-blocking, fast filesystem stat)
|
||||
let data_bytes = dir_size_bytes(ELECTRS_DATA_DIR);
|
||||
let data_bytes = dir_size_bytes(ELECTRUMX_DATA_DIR);
|
||||
let index_size = if data_bytes > 0 {
|
||||
Some(format_bytes(data_bytes))
|
||||
} else {
|
||||
@@ -154,10 +160,10 @@ pub async fn get_electrs_sync_status() -> ElectrsSyncStatus {
|
||||
}
|
||||
};
|
||||
|
||||
let indexed_height = match tokio::task::spawn_blocking(electrs_indexed_height).await {
|
||||
let indexed_height = match tokio::task::spawn_blocking(electrumx_indexed_height).await {
|
||||
Ok(Ok(h)) => h,
|
||||
Ok(Err(e)) => {
|
||||
// Electrs doesn't listen on 50001 until indexing completes (can take hours)
|
||||
// ElectrumX may not be ready on 50001 during initial sync
|
||||
let err_msg = e.to_string();
|
||||
let (status, error) = if err_msg.contains("connect") || err_msg.contains("Connection refused") {
|
||||
// Estimate progress from data directory size
|
||||
@@ -170,12 +176,12 @@ pub async fn get_electrs_sync_status() -> ElectrsSyncStatus {
|
||||
(
|
||||
"indexing".to_string(),
|
||||
Some(format!(
|
||||
"Building index ({} / ~35 GB estimated). Electrum RPC will be available when complete.",
|
||||
"Building index ({} / ~55 GB estimated). Electrum RPC will be available when complete.",
|
||||
size_str
|
||||
)),
|
||||
)
|
||||
} else {
|
||||
("error".to_string(), Some(format!("Electrs: {}", e)))
|
||||
("error".to_string(), Some(format!("ElectrumX: {}", e)))
|
||||
};
|
||||
// Use estimated progress when indexing
|
||||
let progress_pct = if status == "indexing" && data_bytes > 0 {
|
||||
|
||||
@@ -46,7 +46,7 @@ fn container_tier(name: &str) -> StartupTier {
|
||||
"bitcoin-knots" | "bitcoin-core" | "bitcoin" => StartupTier::CoreInfra,
|
||||
|
||||
// Tier 2: Dependent services
|
||||
"lnd" | "mempool-electrs" | "electrs" | "nbxplorer" => StartupTier::DependentService,
|
||||
"lnd" | "electrumx" | "mempool-electrs" | "electrs" | "nbxplorer" => StartupTier::DependentService,
|
||||
|
||||
// Tier 4: Frontend/UI
|
||||
"mempool-web" | "bitcoin-ui" | "lnd-ui" | "electrs-ui"
|
||||
|
||||
@@ -48,6 +48,28 @@ pub struct IdentityRecord {
|
||||
pub nostr_pubkey: Option<String>,
|
||||
/// Nostr public key in bech32 npub format (NIP-19)
|
||||
pub nostr_npub: Option<String>,
|
||||
/// Nostr profile metadata (NIP-01 kind 0)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub profile: Option<IdentityProfile>,
|
||||
}
|
||||
|
||||
/// Nostr profile metadata fields (NIP-01 kind 0 + NIP-24 extra fields).
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct IdentityProfile {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub display_name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub about: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub picture: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub banner: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub website: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub nip05: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub lud16: Option<String>,
|
||||
}
|
||||
|
||||
/// On-disk format for identity storage (includes secret key bytes).
|
||||
@@ -64,6 +86,9 @@ struct IdentityFile {
|
||||
nostr_secret_hex: Option<String>,
|
||||
#[serde(default)]
|
||||
nostr_pubkey_hex: Option<String>,
|
||||
/// Nostr profile metadata
|
||||
#[serde(default)]
|
||||
profile: Option<IdentityProfile>,
|
||||
}
|
||||
|
||||
pub struct IdentityManager {
|
||||
@@ -123,6 +148,7 @@ impl IdentityManager {
|
||||
created_at: created_at.clone(),
|
||||
nostr_secret_hex: None,
|
||||
nostr_pubkey_hex: None,
|
||||
profile: None,
|
||||
};
|
||||
|
||||
let file_path = self.identities_dir.join(format!("{}.json", id));
|
||||
@@ -345,6 +371,77 @@ impl IdentityManager {
|
||||
.context("NIP-44 decryption failed")
|
||||
}
|
||||
|
||||
/// Update the profile metadata for an identity.
|
||||
pub async fn update_profile(&self, id: &str, profile: IdentityProfile) -> Result<()> {
|
||||
let file_path = self.identities_dir.join(format!("{}.json", id));
|
||||
if !file_path.exists() {
|
||||
return Err(anyhow::anyhow!("Identity not found: {}", id));
|
||||
}
|
||||
let data = fs::read(&file_path).await.context("Failed to read identity file")?;
|
||||
let mut file: IdentityFile = serde_json::from_slice(&data).context("Failed to parse identity file")?;
|
||||
file.profile = Some(profile);
|
||||
let json = serde_json::to_string_pretty(&file).context("Failed to serialize identity")?;
|
||||
fs::write(&file_path, json.as_bytes()).await.context("Failed to write identity file")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Publish kind 0 (metadata) event to a Nostr relay.
|
||||
pub async fn publish_profile(&self, id: &str, relay_url: &str) -> Result<String> {
|
||||
let record = self.get(id).await?;
|
||||
let keys = self.load_nostr_keys(id).await?;
|
||||
let profile = record.profile.unwrap_or_default();
|
||||
|
||||
// Build kind 0 content JSON (NIP-01 + NIP-24)
|
||||
let mut content = serde_json::Map::new();
|
||||
content.insert("name".to_string(), serde_json::json!(record.name));
|
||||
if let Some(v) = &profile.display_name { content.insert("display_name".to_string(), serde_json::json!(v)); }
|
||||
if let Some(v) = &profile.about { content.insert("about".to_string(), serde_json::json!(v)); }
|
||||
if let Some(v) = &profile.picture { content.insert("picture".to_string(), serde_json::json!(v)); }
|
||||
if let Some(v) = &profile.banner { content.insert("banner".to_string(), serde_json::json!(v)); }
|
||||
if let Some(v) = &profile.website { content.insert("website".to_string(), serde_json::json!(v)); }
|
||||
if let Some(v) = &profile.nip05 { content.insert("nip05".to_string(), serde_json::json!(v)); }
|
||||
if let Some(v) = &profile.lud16 { content.insert("lud16".to_string(), serde_json::json!(v)); }
|
||||
|
||||
let content_str = serde_json::to_string(&content).context("Failed to serialize profile content")?;
|
||||
|
||||
let client = nostr_sdk::Client::new(keys);
|
||||
client.add_relay(relay_url).await.context("Failed to add relay")?;
|
||||
client.connect().await;
|
||||
|
||||
let builder = nostr_sdk::prelude::EventBuilder::new(
|
||||
nostr_sdk::prelude::Kind::Metadata,
|
||||
&content_str,
|
||||
);
|
||||
let output = client.send_event_builder(builder).await.context("Failed to publish profile")?;
|
||||
client.disconnect().await;
|
||||
|
||||
Ok(output.id().to_hex())
|
||||
}
|
||||
|
||||
/// Export all keys for an identity (SENSITIVE — only call after password verification).
|
||||
pub async fn export_keys(&self, id: &str) -> Result<serde_json::Value> {
|
||||
let file_path = self.identities_dir.join(format!("{}.json", id));
|
||||
if !file_path.exists() {
|
||||
return Err(anyhow::anyhow!("Identity not found: {}", id));
|
||||
}
|
||||
let data = fs::read(&file_path).await.context("Failed to read identity file")?;
|
||||
let file: IdentityFile = serde_json::from_slice(&data).context("Failed to parse identity file")?;
|
||||
|
||||
let ed25519_secret_hex = hex::encode(&file.secret_key);
|
||||
|
||||
let nostr_nsec = file.nostr_secret_hex.as_ref().and_then(|h| {
|
||||
nostr_sdk::SecretKey::from_hex(h)
|
||||
.ok()
|
||||
.and_then(|sk| sk.to_bech32().ok())
|
||||
});
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"ed25519_secret_hex": ed25519_secret_hex,
|
||||
"nostr_secret_hex": file.nostr_secret_hex,
|
||||
"nostr_nsec": nostr_nsec,
|
||||
}))
|
||||
}
|
||||
|
||||
// --- internal helpers ---
|
||||
|
||||
}
|
||||
@@ -395,6 +492,7 @@ impl IdentityManager {
|
||||
created_at: file.created_at,
|
||||
nostr_pubkey: file.nostr_pubkey_hex,
|
||||
nostr_npub,
|
||||
profile: file.profile,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Instant, SystemTime};
|
||||
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::RwLock;
|
||||
use zeroize::Zeroize;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
const FULL_SESSION_TTL: u64 = 86400; // 24 hours of inactivity
|
||||
const PENDING_SESSION_TTL: u64 = 300; // 5 minutes
|
||||
const MAX_TOTP_ATTEMPTS: u8 = 5;
|
||||
const MAX_CONCURRENT_SESSIONS: usize = 5;
|
||||
const MAX_CONCURRENT_SESSIONS: usize = 20;
|
||||
const SESSIONS_FILE: &str = "/var/lib/archipelago/sessions.json";
|
||||
const REMEMBER_SECRET_FILE: &str = "/var/lib/archipelago/remember_secret";
|
||||
pub const REMEMBER_TTL: u64 = 30 * 24 * 3600; // 30 days
|
||||
|
||||
#[derive(Clone)]
|
||||
enum SessionType {
|
||||
@@ -38,13 +45,106 @@ struct Session {
|
||||
#[derive(Clone)]
|
||||
pub struct SessionStore {
|
||||
sessions: Arc<RwLock<HashMap<[u8; 32], Session>>>,
|
||||
persist_path: PathBuf,
|
||||
}
|
||||
|
||||
/// On-disk representation of a persisted session (only Full sessions, no TOTP secrets).
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
struct PersistedSession {
|
||||
hash_hex: String,
|
||||
created_at: u64, // Unix timestamp
|
||||
last_activity: u64, // Unix timestamp
|
||||
}
|
||||
|
||||
impl SessionStore {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sessions: Arc::new(RwLock::new(HashMap::new())),
|
||||
let persist_path = PathBuf::from(SESSIONS_FILE);
|
||||
let sessions = Self::load_from_disk(&persist_path);
|
||||
let count = sessions.len();
|
||||
if count > 0 {
|
||||
tracing::info!("Restored {} sessions from disk", count);
|
||||
}
|
||||
Self {
|
||||
sessions: Arc::new(RwLock::new(sessions)),
|
||||
persist_path,
|
||||
}
|
||||
}
|
||||
|
||||
/// Load persisted sessions from disk (only Full sessions).
|
||||
fn load_from_disk(path: &Path) -> HashMap<[u8; 32], Session> {
|
||||
let mut map = HashMap::new();
|
||||
let data = match std::fs::read_to_string(path) {
|
||||
Ok(d) => d,
|
||||
Err(_) => return map,
|
||||
};
|
||||
let persisted: Vec<PersistedSession> = match serde_json::from_str(&data) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to parse sessions file: {}", e);
|
||||
return map;
|
||||
}
|
||||
};
|
||||
let now_unix = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
for p in persisted {
|
||||
// Skip expired sessions
|
||||
if now_unix.saturating_sub(p.last_activity) >= FULL_SESSION_TTL {
|
||||
continue;
|
||||
}
|
||||
let hash = match hex::decode(&p.hash_hex) {
|
||||
Ok(h) if h.len() == 32 => {
|
||||
let mut arr = [0u8; 32];
|
||||
arr.copy_from_slice(&h);
|
||||
arr
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
let created_at = UNIX_EPOCH + std::time::Duration::from_secs(p.created_at);
|
||||
let last_activity = UNIX_EPOCH + std::time::Duration::from_secs(p.last_activity);
|
||||
map.insert(hash, Session {
|
||||
created_at,
|
||||
last_activity,
|
||||
session_type: SessionType::Full,
|
||||
});
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/// Save all Full sessions to disk. Called after mutations.
|
||||
fn save_to_disk_sync(sessions: &HashMap<[u8; 32], Session>, path: &Path) {
|
||||
let persisted: Vec<PersistedSession> = sessions
|
||||
.iter()
|
||||
.filter(|(_, s)| matches!(s.session_type, SessionType::Full))
|
||||
.map(|(hash, s)| PersistedSession {
|
||||
hash_hex: hex::encode(hash),
|
||||
created_at: s.created_at.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(),
|
||||
last_activity: s.last_activity.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(),
|
||||
})
|
||||
.collect();
|
||||
if let Ok(json) = serde_json::to_string(&persisted) {
|
||||
let _ = std::fs::write(path, json);
|
||||
}
|
||||
}
|
||||
|
||||
/// Async wrapper for save — spawns to avoid blocking RPC.
|
||||
fn schedule_save(&self, sessions: &HashMap<[u8; 32], Session>) {
|
||||
let persisted: Vec<PersistedSession> = sessions
|
||||
.iter()
|
||||
.filter(|(_, s)| matches!(s.session_type, SessionType::Full))
|
||||
.map(|(hash, s)| PersistedSession {
|
||||
hash_hex: hex::encode(hash),
|
||||
created_at: s.created_at.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(),
|
||||
last_activity: s.last_activity.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(),
|
||||
})
|
||||
.collect();
|
||||
let path = self.persist_path.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Ok(json) = serde_json::to_string(&persisted) {
|
||||
let _ = tokio::fs::write(path, json).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Create a full (authenticated) session. Returns the plaintext token.
|
||||
@@ -63,6 +163,9 @@ impl SessionStore {
|
||||
let mut sessions = self.sessions.write().await;
|
||||
self.evict_if_over_limit(&mut sessions);
|
||||
sessions.insert(hash, session);
|
||||
// Sync save — must complete before returning the token to the client.
|
||||
// Async save risks losing the session if the process is killed (e.g., deploy restart).
|
||||
Self::save_to_disk_sync(&sessions, &self.persist_path);
|
||||
token
|
||||
}
|
||||
|
||||
@@ -140,12 +243,15 @@ impl SessionStore {
|
||||
let now = SystemTime::now();
|
||||
session.created_at = now;
|
||||
session.last_activity = now;
|
||||
Self::save_to_disk_sync(&sessions, &self.persist_path);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn remove(&self, token: &str) {
|
||||
let hash = hash_token(token);
|
||||
self.sessions.write().await.remove(&hash);
|
||||
let mut sessions = self.sessions.write().await;
|
||||
sessions.remove(&hash);
|
||||
Self::save_to_disk_sync(&sessions, &self.persist_path);
|
||||
}
|
||||
|
||||
/// Invalidate all sessions except the one matching the given token.
|
||||
@@ -154,6 +260,7 @@ impl SessionStore {
|
||||
let keep_hash = hash_token(keep_token);
|
||||
let mut sessions = self.sessions.write().await;
|
||||
sessions.retain(|hash, _| *hash == keep_hash);
|
||||
Self::save_to_disk_sync(&sessions, &self.persist_path);
|
||||
}
|
||||
|
||||
/// Rotate a session: invalidate the old token and create a new one.
|
||||
@@ -175,6 +282,7 @@ impl SessionStore {
|
||||
session_type: SessionType::Full,
|
||||
},
|
||||
);
|
||||
Self::save_to_disk_sync(&sessions, &self.persist_path);
|
||||
new_token
|
||||
}
|
||||
|
||||
@@ -222,6 +330,78 @@ impl SessionStore {
|
||||
})
|
||||
.count()
|
||||
}
|
||||
|
||||
// ── Remember-me token ──────────────────────────────────────────────
|
||||
// HMAC-signed token that survives backend restarts. Secret is on disk.
|
||||
// Format: "timestamp_hex:hmac_hex"
|
||||
|
||||
/// Create a remember-me token. Returns the cookie value.
|
||||
pub fn create_remember_token(&self) -> String {
|
||||
let secret = Self::load_or_create_remember_secret();
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let ts_hex = hex::encode(now.to_be_bytes());
|
||||
let mut mac = HmacSha256::new_from_slice(&secret).expect("HMAC key");
|
||||
mac.update(format!("remember:{}", ts_hex).as_bytes());
|
||||
let sig = hex::encode(mac.finalize().into_bytes());
|
||||
format!("{}:{}", ts_hex, sig)
|
||||
}
|
||||
|
||||
/// Validate a remember-me token. Returns true if valid and not expired.
|
||||
pub fn validate_remember_token(token: &str) -> bool {
|
||||
let secret = match std::fs::read(REMEMBER_SECRET_FILE) {
|
||||
Ok(s) if s.len() == 32 => s,
|
||||
_ => return false,
|
||||
};
|
||||
let parts: Vec<&str> = token.splitn(2, ':').collect();
|
||||
if parts.len() != 2 {
|
||||
return false;
|
||||
}
|
||||
let ts_hex = parts[0];
|
||||
let sig_hex = parts[1];
|
||||
|
||||
// Verify HMAC
|
||||
let mut mac = match HmacSha256::new_from_slice(&secret) {
|
||||
Ok(m) => m,
|
||||
Err(_) => return false,
|
||||
};
|
||||
mac.update(format!("remember:{}", ts_hex).as_bytes());
|
||||
let expected_sig = match hex::decode(sig_hex) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return false,
|
||||
};
|
||||
if mac.verify_slice(&expected_sig).is_err() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check expiry
|
||||
let ts_bytes = match hex::decode(ts_hex) {
|
||||
Ok(b) if b.len() == 8 => {
|
||||
let mut arr = [0u8; 8];
|
||||
arr.copy_from_slice(&b);
|
||||
u64::from_be_bytes(arr)
|
||||
}
|
||||
_ => return false,
|
||||
};
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
now.saturating_sub(ts_bytes) < REMEMBER_TTL
|
||||
}
|
||||
|
||||
fn load_or_create_remember_secret() -> Vec<u8> {
|
||||
if let Ok(secret) = std::fs::read(REMEMBER_SECRET_FILE) {
|
||||
if secret.len() == 32 {
|
||||
return secret;
|
||||
}
|
||||
}
|
||||
let secret: [u8; 32] = rand::random();
|
||||
let _ = std::fs::write(REMEMBER_SECRET_FILE, &secret);
|
||||
secret.to_vec()
|
||||
}
|
||||
}
|
||||
|
||||
fn hash_token(token: &str) -> [u8; 32] {
|
||||
|
||||
Reference in New Issue
Block a user