feat: electrs standalone install with bitcoin dependency + progress UI

- Add electrs to marketplace as standalone installable app
- Add dependency check: refuse install if no bitcoin node is running
- Use container DNS (bitcoin-knots:8332) on archy-net instead of host IP
- Auto-create bitcoin.conf with txindex + RPC on bitcoin-knots install
- Auto-build and start electrs-ui container post-install
- Show index size and estimated progress during initial sync
- Add /electrs-status and /health nginx proxy routes
- Remove Tailwind CDN from electrs-ui, use inline styles

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-08 02:16:02 +00:00
parent 825d082003
commit a5757d27f1
6 changed files with 474 additions and 67 deletions

View File

@@ -43,6 +43,25 @@ impl RpcHandler {
return self.install_penpot_stack().await;
}
// Dependency check: electrs requires a Bitcoin node
if package_id == "mempool-electrs" || package_id == "electrs" {
let btc_check = tokio::process::Command::new("sudo")
.args(["podman", "ps", "--format", "{{.Names}}"])
.output()
.await
.context("Failed to check running containers")?;
let running = String::from_utf8_lossy(&btc_check.stdout);
let has_bitcoin = running.lines().any(|l| {
let name = l.trim();
name == "bitcoin-knots" || name == "bitcoin-core" || name == "bitcoin"
});
if !has_bitcoin {
return Err(anyhow::anyhow!(
"Electrs requires a running Bitcoin node (Bitcoin Knots or Bitcoin Core). Please install and start Bitcoin Knots first."
));
}
}
// Check if container already exists
let check_output = tokio::process::Command::new("sudo")
.args(["podman", "ps", "-a", "--format", "{{.Names}}", "--filter", &format!("name=^{}$", package_id)])
@@ -84,11 +103,14 @@ 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 };
// Create and start container with security constraints
let mut run_args = vec![
"podman", "run",
"-d", // Detached
"--name", package_id,
"--name", container_name,
"--restart=unless-stopped", // Auto-restart policy
];
@@ -105,7 +127,7 @@ impl RpcHandler {
let needs_archy_net = matches!(
package_id,
"bitcoin-knots" | "bitcoin" | "bitcoin-core"
| "mempool" | "mempool-web" | "mempool-api" | "mempool-electrs" | "mysql-mempool" | "archy-mempool-db" | "archy-mempool-web"
| "mempool" | "mempool-web" | "mempool-api" | "mempool-electrs" | "electrs" | "mysql-mempool" | "archy-mempool-db" | "archy-mempool-web"
| "btcpay-server" | "btcpayserver" | "archy-btcpay-db"
);
@@ -168,6 +190,27 @@ impl RpcHandler {
}
}
// Pre-install: Create bitcoin.conf for Bitcoin nodes with RPC + txindex
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
let bitcoin_dir = "/var/lib/archipelago/bitcoin";
let conf_path = format!("{}/bitcoin.conf", bitcoin_dir);
let bitcoin_conf = "\
server=1\n\
txindex=1\n\
rpcuser=archipelago\n\
rpcpassword=archipelago123\n\
rpcbind=0.0.0.0\n\
rpcallowip=0.0.0.0/0\n\
rpcport=8332\n\
listen=1\n\
printtoconsole=1\n";
let _ = tokio::process::Command::new("sudo")
.args(["sh", "-c", &format!("echo '{}' > {}", bitcoin_conf, conf_path)])
.output()
.await;
info!("Created bitcoin.conf at {} with RPC + txindex enabled", conf_path);
}
// Add port mappings (skip if host network mode like Tailscale)
if !is_tailscale {
for port in &ports {
@@ -244,6 +287,32 @@ impl RpcHandler {
});
}
// Post-install: Start electrs-ui container for electrs
if matches!(package_id, "mempool-electrs" | "electrs") {
tokio::spawn(async move {
// Build and start electrs-ui with host networking so it can reach backend on 127.0.0.1:5678
let ui_dir = "/opt/archipelago/docker/electrs-ui";
let _ = tokio::process::Command::new("sudo")
.args(["podman", "build", "-t", "localhost/electrs-ui", ui_dir])
.output()
.await;
// Remove old UI container if it exists
let _ = tokio::process::Command::new("sudo")
.args(["podman", "rm", "-f", "electrs-ui"])
.output()
.await;
let _ = tokio::process::Command::new("sudo")
.args([
"podman", "run", "-d", "--name", "electrs-ui",
"--restart=unless-stopped", "--network=host",
"localhost/electrs-ui",
])
.output()
.await;
info!("Electrs UI container started on port 50002");
});
}
Ok(serde_json::json!({
"success": true,
"package_id": package_id,
@@ -793,6 +862,25 @@ const TRUSTED_REGISTRIES: &[&str] = &[
];
/// Validate Docker image against trusted registry allowlist.
/// Detect which Bitcoin container is running on archy-net for DNS resolution.
/// Returns the container name to use as the RPC host (e.g., "bitcoin-knots").
fn detect_bitcoin_container_name() -> String {
// Synchronous check — called from get_app_config which is sync
let output = std::process::Command::new("sudo")
.args(["podman", "ps", "--format", "{{.Names}}"])
.output();
if let Ok(out) = output {
let names = String::from_utf8_lossy(&out.stdout);
for candidate in &["bitcoin-knots", "bitcoin-core", "bitcoin"] {
if names.lines().any(|l| l.trim() == *candidate) {
return candidate.to_string();
}
}
}
// Default to bitcoin-knots (most common)
"bitcoin-knots".to_string()
}
fn is_valid_docker_image(image: &str) -> bool {
if image.is_empty() || image.len() > 256 {
return false;
@@ -938,24 +1026,28 @@ fn get_app_config(
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(),
"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(),
]),
),
"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![],
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" => (
vec![],
vec!["/var/lib/archipelago/mysql-mempool:/var/lib/mysql".to_string()],