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:
Dorian
2026-03-16 12:58:35 +00:00
co-authored by Claude Opus 4.6
parent 4e54b8bd4d
commit 367b483a72
49 changed files with 6180 additions and 495 deletions
+1
View File
@@ -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;
+41 -6
View File
@@ -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,
})
})
+90 -1
View File
@@ -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,
+123
View File
@@ -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
+66 -6
View File
@@ -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
+29 -29
View File
@@ -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" => (
+45 -31
View File
@@ -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();