fix: release v1.7.51-alpha install hardening

This commit is contained in:
archipelago 2026-05-01 05:02:39 -04:00
parent be9f9528c3
commit 7b58d07cd8
13 changed files with 519 additions and 118 deletions

2
core/Cargo.lock generated
View File

@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]] [[package]]
name = "archipelago" name = "archipelago"
version = "1.7.50-alpha" version = "1.7.51-alpha"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"archipelago-container", "archipelago-container",

View File

@ -1,6 +1,6 @@
[package] [package]
name = "archipelago" name = "archipelago"
version = "1.7.50-alpha" version = "1.7.51-alpha"
edition = "2021" edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend" description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"] authors = ["Archipelago Team"]

View File

@ -405,6 +405,9 @@ pub(super) async fn get_containers_for_app(package_id: &str) -> Result<Vec<Strin
pub(super) fn get_data_dirs_for_app(package_id: &str) -> Vec<String> { pub(super) fn get_data_dirs_for_app(package_id: &str) -> Vec<String> {
let base = "/var/lib/archipelago"; let base = "/var/lib/archipelago";
match package_id { match package_id {
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => {
vec![format!("{}/bitcoin", base), format!("{}/bitcoin-ui", base)]
}
"mempool" | "mempool-web" => vec![ "mempool" | "mempool-web" => vec![
format!("{}/mempool", base), format!("{}/mempool", base),
format!("{}/mysql-mempool", base), format!("{}/mysql-mempool", base),

View File

@ -10,6 +10,7 @@ use super::progress::parse_pull_progress;
use super::validation::validate_app_id; use super::validation::validate_app_id;
use crate::api::rpc::RpcHandler; use crate::api::rpc::RpcHandler;
use crate::data_model::InstallPhase; use crate::data_model::InstallPhase;
use crate::update::host_sudo;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::io::{AsyncBufReadExt, BufReader};
use tracing::{debug, info, warn}; use tracing::{debug, info, warn};
@ -138,14 +139,20 @@ async fn inject_bitcoin_rpc_auth_into_running_container(container: &str, auth_b6
.await; .await;
match reload { match reload {
Ok(o) if o.status.success() => { Ok(o) if o.status.success() => {
info!("Injected Bitcoin RPC auth into {} (post-start, cp+SIGHUP)", container); info!(
"Injected Bitcoin RPC auth into {} (post-start, cp+SIGHUP)",
container
);
} }
Ok(o) => warn!( Ok(o) => warn!(
"Patched nginx.conf in {} but SIGHUP failed: {}", "Patched nginx.conf in {} but SIGHUP failed: {}",
container, container,
String::from_utf8_lossy(&o.stderr) String::from_utf8_lossy(&o.stderr)
), ),
Err(e) => warn!("Patched nginx.conf in {} but SIGHUP errored: {}", container, e), Err(e) => warn!(
"Patched nginx.conf in {} but SIGHUP errored: {}",
container, e
),
} }
} }
@ -230,6 +237,11 @@ impl RpcHandler {
check_install_deps(package_id, &deps)?; check_install_deps(package_id, &deps)?;
log_optional_dep_info(package_id, &deps); log_optional_dep_info(package_id, &deps);
check_bitcoin_implementation_conflict(package_id).await?; check_bitcoin_implementation_conflict(package_id).await?;
let repaired_bitcoin_conf = if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
ensure_bitcoin_rpc_bindings().await?
} else {
false
};
// Check if container already exists // Check if container already exists
let check_output = tokio::process::Command::new("podman") let check_output = tokio::process::Command::new("podman")
@ -270,7 +282,35 @@ impl RpcHandler {
.trim() .trim()
.to_string(); .to_string();
if state != "running" { if state == "running" && repaired_bitcoin_conf {
info!(
"Restarting existing container {} after bitcoin.conf RPC repair",
package_id
);
let restart_output = tokio::process::Command::new("podman")
.args(["restart", package_id])
.output()
.await
.context("Failed to restart existing container after bitcoin.conf repair")?;
if !restart_output.status.success() {
let stderr = String::from_utf8_lossy(&restart_output.stderr);
install_log(&format!(
"INSTALL ADOPT FAIL: {} - restart after RPC repair failed: {}",
package_id, stderr
))
.await;
return Err(anyhow::anyhow!(
"Container {} exists but failed to restart after RPC repair: {}",
package_id,
stderr
));
}
let _ = tokio::process::Command::new("podman")
.args(["restart", "archy-bitcoin-ui"])
.output()
.await;
wait_for_adopted_container(package_id, package_id).await?;
} else if state != "running" {
// Start the stopped/exited container // Start the stopped/exited container
info!("Starting existing container {} (was {})", package_id, state); info!("Starting existing container {} (was {})", package_id, state);
let start_output = tokio::process::Command::new("podman") let start_output = tokio::process::Command::new("podman")
@ -291,6 +331,8 @@ impl RpcHandler {
stderr stderr
)); ));
} }
wait_for_adopted_container(package_id, package_id).await?;
} }
install_log(&format!( install_log(&format!(
@ -575,7 +617,12 @@ impl RpcHandler {
// during its initial reorg/indexing phase. // during its initial reorg/indexing phase.
let cpu_capped = !matches!( let cpu_capped = !matches!(
package_id, package_id,
"bitcoin" | "bitcoin-core" | "bitcoin-knots" | "electrumx" | "electrs" | "mempool-electrs" "bitcoin"
| "bitcoin-core"
| "bitcoin-knots"
| "electrumx"
| "electrs"
| "mempool-electrs"
); );
if cpu_capped { if cpu_capped {
run_args.push("--cpus=2"); run_args.push("--cpus=2");
@ -1092,12 +1139,14 @@ impl RpcHandler {
// user" and skip. Matches the lnd.conf behavior below. // user" and skip. Matches the lnd.conf behavior below.
match tokio::fs::metadata(&conf_path).await { match tokio::fs::metadata(&conf_path).await {
Ok(_) => { Ok(_) => {
info!("bitcoin.conf already exists, skipping write"); ensure_bitcoin_rpc_bindings().await?;
info!("bitcoin.conf already exists, ensured RPC bind settings");
return Ok(()); return Ok(());
} }
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(_) => { Err(_) => {
info!("bitcoin.conf path inaccessible (container-owned data dir), skipping write"); ensure_bitcoin_rpc_bindings().await?;
info!("bitcoin.conf path inaccessible, ensured RPC bind settings via host helper");
return Ok(()); return Ok(());
} }
} }
@ -1512,56 +1561,60 @@ autopilot.active=false\n",
// 2. Post-start: `podman exec` into the running container to patch // 2. Post-start: `podman exec` into the running container to patch
// nginx.conf and reload. Authoritative for both paths — runs // nginx.conf and reload. Authoritative for both paths — runs
// regardless of how the image was built. // regardless of how the image was built.
let bitcoin_rpc_auth_b64: Option<String> = let bitcoin_rpc_auth_b64: Option<String> = if matches!(
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") { package_id,
let (rpc_user, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await; "bitcoin" | "bitcoin-core" | "bitcoin-knots"
use base64::Engine; ) {
let auth_b64 = base64::engine::general_purpose::STANDARD let (rpc_user, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
.encode(format!("{}:{}", rpc_user, rpc_pass)); use base64::Engine;
for dir in [ let auth_b64 = base64::engine::general_purpose::STANDARD
"/opt/archipelago/docker/bitcoin-ui", .encode(format!("{}:{}", rpc_user, rpc_pass));
"/home/archipelago/archy/docker/bitcoin-ui", for dir in [
] { "/opt/archipelago/docker/bitcoin-ui",
let conf_path = format!("{}/nginx.conf", dir); "/home/archipelago/archy/docker/bitcoin-ui",
match tokio::fs::read_to_string(&conf_path).await { ] {
Ok(content) => { let conf_path = format!("{}/nginx.conf", dir);
let updated = content match tokio::fs::read_to_string(&conf_path).await {
.replace("__BITCOIN_RPC_AUTH__", &auth_b64) Ok(content) => {
.lines() let updated = content
.map(|line| { .replace("__BITCOIN_RPC_AUTH__", &auth_b64)
if line.contains("proxy_set_header Authorization") .lines()
&& line.contains("Basic") .map(|line| {
{ if line.contains("proxy_set_header Authorization")
format!( && line.contains("Basic")
" proxy_set_header Authorization \"Basic {}\";", {
auth_b64 format!(
) " proxy_set_header Authorization \"Basic {}\";",
} else { auth_b64
line.to_string() )
} } else {
}) line.to_string()
.collect::<Vec<_>>() }
.join("\n"); })
if let Err(e) = .collect::<Vec<_>>()
tokio::fs::write(&conf_path, format!("{}\n", updated)).await .join("\n");
{ if let Err(e) = tokio::fs::write(&conf_path, format!("{}\n", updated)).await
warn!("Failed to write {} with injected RPC auth: {}", conf_path, e); {
} else { warn!(
info!("Injected Bitcoin RPC auth into {} (build-time)", conf_path); "Failed to write {} with injected RPC auth: {}",
} conf_path, e
);
} else {
info!("Injected Bitcoin RPC auth into {} (build-time)", conf_path);
} }
Err(_) => { }
debug!( Err(_) => {
debug!(
"No build-time nginx.conf at {} (will patch running container after start)", "No build-time nginx.conf at {} (will patch running container after start)",
conf_path conf_path
); );
}
} }
} }
Some(auth_b64) }
} else { Some(auth_b64)
None } else {
}; None
};
// Build and start companion UI containers for headless services. // Build and start companion UI containers for headless services.
// All UIs proxy to localhost (backend :5678 or bitcoin :8332) so they need --network=host. // All UIs proxy to localhost (backend :5678 or bitcoin :8332) so they need --network=host.
@ -1958,6 +2011,97 @@ async fn resolve_host_gateway() -> String {
"--add-host=host.containers.internal:10.0.2.2".to_string() "--add-host=host.containers.internal:10.0.2.2".to_string()
} }
async fn wait_for_adopted_container(package_id: &str, container_name: &str) -> Result<()> {
for _ in 0..12u32 {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
let status = tokio::process::Command::new("podman")
.args(["inspect", container_name, "--format", "{{.State.Status}}"])
.output()
.await;
let Ok(output) = status else {
continue;
};
let state = String::from_utf8_lossy(&output.stdout).trim().to_string();
match state.as_str() {
"running" => return Ok(()),
"exited" | "dead" => {
let logs = tokio::process::Command::new("podman")
.args(["logs", "--tail", "40", container_name])
.output()
.await;
let log_output = logs
.map(|o| {
let stdout = String::from_utf8_lossy(&o.stdout);
let stderr = String::from_utf8_lossy(&o.stderr);
format!("{}{}", stdout, stderr)
})
.unwrap_or_default();
install_log(&format!(
"INSTALL ADOPT CRASH: {} - existing container {} exited. Logs:\n{}",
package_id,
container_name,
&log_output.chars().take(1000).collect::<String>()
))
.await;
return Err(anyhow::anyhow!(
"Existing container {} exited after start. Logs: {}",
container_name,
log_output.chars().take(500).collect::<String>()
));
}
_ => {}
}
}
install_log(&format!(
"INSTALL ADOPT TIMEOUT: {} - existing container {} did not stay running",
package_id, container_name
))
.await;
Err(anyhow::anyhow!(
"Existing container {} did not reach running state within 60s",
container_name
))
}
async fn ensure_bitcoin_rpc_bindings() -> Result<bool> {
let script = r#"
set -eu
conf=/var/lib/archipelago/bitcoin/bitcoin.conf
[ -f "$conf" ] || exit 0
changed=0
ensure_line() {
line="$1"
key="${line%%=*}"
if ! grep -q "^${key}=" "$conf"; then
printf '%s\n' "$line" >> "$conf"
changed=1
fi
}
ensure_line server=1
ensure_line rpcbind=0.0.0.0
ensure_line rpcallowip=0.0.0.0/0
ensure_line rpcport=8332
ensure_line listen=1
[ "$changed" -eq 0 ] && exit 0
exit 2
"#;
let status = host_sudo(&["sh", "-lc", script])
.await
.context("ensure bitcoin.conf RPC bind settings")?;
match status.code() {
Some(0) => Ok(false),
Some(2) => {
install_log("INSTALL REPAIR: bitcoin.conf RPC bind settings added").await;
Ok(true)
}
_ => Err(anyhow::anyhow!(
"bitcoin.conf RPC repair helper exited with {}",
status
)),
}
}
fn should_try_orchestrator_install(package_id: &str, orchestrator_available: bool) -> bool { fn should_try_orchestrator_install(package_id: &str, orchestrator_available: bool) -> bool {
orchestrator_available && uses_orchestrator_install_flow(package_id) orchestrator_available && uses_orchestrator_install_flow(package_id)
} }
@ -2092,7 +2236,10 @@ mod tests {
#[test] #[test]
fn install_aliases_map_to_manifest_app_ids() { fn install_aliases_map_to_manifest_app_ids() {
assert_eq!(orchestrator_install_app_id("bitcoin-knots"), "bitcoin-knots"); assert_eq!(
orchestrator_install_app_id("bitcoin-knots"),
"bitcoin-knots"
);
assert_eq!(orchestrator_install_app_id("bitcoin-core"), "bitcoin-core"); assert_eq!(orchestrator_install_app_id("bitcoin-core"), "bitcoin-core");
assert_eq!(orchestrator_install_app_id("electrs"), "electrumx"); assert_eq!(orchestrator_install_app_id("electrs"), "electrumx");
assert_eq!(orchestrator_install_app_id("mempool-electrs"), "electrumx"); assert_eq!(orchestrator_install_app_id("mempool-electrs"), "electrumx");

View File

@ -48,6 +48,12 @@ async fn adopt_stack_if_exists(
.await; .await;
} }
} }
let existing: Vec<&str> = all_containers
.iter()
.copied()
.filter(|container| names.iter().any(|n| n == container))
.collect();
wait_for_stack_containers(stack_name, &existing, 60).await?;
install_log(&format!( install_log(&format!(
"INSTALL ADOPT OK: {} — started existing containers", "INSTALL ADOPT OK: {} — started existing containers",
@ -61,6 +67,107 @@ async fn adopt_stack_if_exists(
}))) })))
} }
async fn run_required_stack_command(
stack_name: &str,
label: &str,
cmd: &mut tokio::process::Command,
) -> Result<()> {
let output = cmd
.output()
.await
.with_context(|| format!("{}: failed to run {}", stack_name, label))?;
if output.status.success() {
return Ok(());
}
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let msg = format!("{} failed: {}{}", label, stdout, stderr);
install_log(&format!("INSTALL FAIL: {} - {}", stack_name, msg.trim())).await;
Err(anyhow::anyhow!("{} {}", stack_name, msg.trim()))
}
async fn wait_for_stack_containers(
stack_name: &str,
containers: &[&str],
timeout_secs: u64,
) -> Result<()> {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
loop {
let mut pending = Vec::new();
for container in containers {
let status = tokio::process::Command::new("podman")
.args(["inspect", container, "--format", "{{.State.Status}}"])
.output()
.await;
match status {
Ok(output) if output.status.success() => {
let state = String::from_utf8_lossy(&output.stdout).trim().to_string();
match state.as_str() {
"running" => {}
"exited" | "dead" => {
let logs = stack_container_logs(container, 40).await;
install_log(&format!(
"INSTALL CRASH: {} - container {} exited. Logs:\n{}",
stack_name,
container,
logs.chars().take(1000).collect::<String>()
))
.await;
return Err(anyhow::anyhow!(
"{} container {} exited after install. Logs: {}",
stack_name,
container,
logs.chars().take(500).collect::<String>()
));
}
other => pending.push(format!("{}={}", container, other)),
}
}
Ok(output) => {
pending.push(format!(
"{}=missing({})",
container,
String::from_utf8_lossy(&output.stderr).trim()
));
}
Err(e) => pending.push(format!("{}=inspect-error({})", container, e)),
}
}
if pending.is_empty() {
return Ok(());
}
if std::time::Instant::now() >= deadline {
install_log(&format!(
"INSTALL TIMEOUT: {} - containers not running: {}",
stack_name,
pending.join(", ")
))
.await;
return Err(anyhow::anyhow!(
"{} containers did not reach running state: {}",
stack_name,
pending.join(", ")
));
}
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
}
async fn stack_container_logs(container: &str, lines: u32) -> String {
tokio::process::Command::new("podman")
.args(["logs", "--tail", &lines.to_string(), container])
.output()
.await
.map(|o| {
let stdout = String::from_utf8_lossy(&o.stdout);
let stderr = String::from_utf8_lossy(&o.stderr);
format!("{}{}", stdout, stderr)
})
.unwrap_or_default()
}
async fn install_stack_via_orchestrator( async fn install_stack_via_orchestrator(
handler: &RpcHandler, handler: &RpcHandler,
stack_name: &str, stack_name: &str,
@ -208,10 +315,12 @@ impl RpcHandler {
.await; .await;
let n_images = images.len() as u64; let n_images = images.len() as u64;
for (i, img) in images.iter().enumerate() { for (i, img) in images.iter().enumerate() {
self.set_install_progress("immich", i as u64, n_images).await; self.set_install_progress("immich", i as u64, n_images)
.await;
pull_image_with_retry(img).await?; pull_image_with_retry(img).await?;
} }
self.set_install_progress("immich", n_images, n_images).await; self.set_install_progress("immich", n_images, n_images)
.await;
self.set_install_phase("immich", InstallPhase::CreatingContainer) self.set_install_phase("immich", InstallPhase::CreatingContainer)
.await; .await;
@ -626,10 +735,12 @@ impl RpcHandler {
.await; .await;
let n_images = images.len() as u64; let n_images = images.len() as u64;
for (i, img) in images.iter().enumerate() { for (i, img) in images.iter().enumerate() {
self.set_install_progress("mempool", i as u64, n_images).await; self.set_install_progress("mempool", i as u64, n_images)
.await;
pull_image_with_retry(img).await?; pull_image_with_retry(img).await?;
} }
self.set_install_progress("mempool", n_images, n_images).await; self.set_install_progress("mempool", n_images, n_images)
.await;
self.set_install_phase("mempool", InstallPhase::CreatingContainer) self.set_install_phase("mempool", InstallPhase::CreatingContainer)
.await; .await;
@ -901,7 +1012,8 @@ impl RpcHandler {
let minio_pass = super::config::read_or_generate_secret("indeedhub-minio-password").await; let minio_pass = super::config::read_or_generate_secret("indeedhub-minio-password").await;
// 1. Postgres // 1. Postgres
let _ = tokio::process::Command::new("podman") let mut postgres_cmd = tokio::process::Command::new("podman");
postgres_cmd
.args([ .args([
"run", "run",
"-d", "-d",
@ -923,12 +1035,12 @@ impl RpcHandler {
"indeedhub-postgres-data:/var/lib/postgresql/data", "indeedhub-postgres-data:/var/lib/postgresql/data",
&format!("{}/postgres:16.13-alpine", registry), &format!("{}/postgres:16.13-alpine", registry),
]) ])
.env("TMPDIR", &user_tmp) .env("TMPDIR", &user_tmp);
.status() run_required_stack_command("indeedhub", "create postgres", &mut postgres_cmd).await?;
.await;
// 2. Redis // 2. Redis
let _ = tokio::process::Command::new("podman") let mut redis_cmd = tokio::process::Command::new("podman");
redis_cmd
.args([ .args([
"run", "run",
"-d", "-d",
@ -944,12 +1056,12 @@ impl RpcHandler {
"indeedhub-redis-data:/data", "indeedhub-redis-data:/data",
&format!("{}/redis:7.4.8-alpine", registry), &format!("{}/redis:7.4.8-alpine", registry),
]) ])
.env("TMPDIR", &user_tmp) .env("TMPDIR", &user_tmp);
.status() run_required_stack_command("indeedhub", "create redis", &mut redis_cmd).await?;
.await;
// 3. MinIO // 3. MinIO
let _ = tokio::process::Command::new("podman") let mut minio_cmd = tokio::process::Command::new("podman");
minio_cmd
.args([ .args([
"run", "run",
"-d", "-d",
@ -971,12 +1083,12 @@ impl RpcHandler {
"server", "server",
"/data", "/data",
]) ])
.env("TMPDIR", &user_tmp) .env("TMPDIR", &user_tmp);
.status() run_required_stack_command("indeedhub", "create minio", &mut minio_cmd).await?;
.await;
// 4. Nostr relay // 4. Nostr relay
let _ = tokio::process::Command::new("podman") let mut relay_cmd = tokio::process::Command::new("podman");
relay_cmd
.args([ .args([
"run", "run",
"-d", "-d",
@ -992,12 +1104,12 @@ impl RpcHandler {
"indeedhub-relay-data:/usr/src/app/db", "indeedhub-relay-data:/usr/src/app/db",
&format!("{}/nostr-rs-relay:0.9.0", registry), &format!("{}/nostr-rs-relay:0.9.0", registry),
]) ])
.env("TMPDIR", &user_tmp) .env("TMPDIR", &user_tmp);
.status() run_required_stack_command("indeedhub", "create relay", &mut relay_cmd).await?;
.await;
// 5. API // 5. API
let _ = tokio::process::Command::new("podman") let mut api_cmd = tokio::process::Command::new("podman");
api_cmd
.args([ .args([
"run", "run",
"-d", "-d",
@ -1049,12 +1161,12 @@ impl RpcHandler {
"ENVIRONMENT=production", "ENVIRONMENT=production",
&format!("{}/indeedhub-api:1.0.0", registry), &format!("{}/indeedhub-api:1.0.0", registry),
]) ])
.env("TMPDIR", &user_tmp) .env("TMPDIR", &user_tmp);
.status() run_required_stack_command("indeedhub", "create api", &mut api_cmd).await?;
.await;
// 6. FFmpeg worker // 6. FFmpeg worker
let _ = tokio::process::Command::new("podman") let mut ffmpeg_cmd = tokio::process::Command::new("podman");
ffmpeg_cmd
.args([ .args([
"run", "run",
"-d", "-d",
@ -1096,15 +1208,15 @@ impl RpcHandler {
"AES_MASTER_SECRET=0123456789abcdef0123456789abcdef", "AES_MASTER_SECRET=0123456789abcdef0123456789abcdef",
&format!("{}/indeedhub-ffmpeg:1.0.0", registry), &format!("{}/indeedhub-ffmpeg:1.0.0", registry),
]) ])
.env("TMPDIR", &user_tmp) .env("TMPDIR", &user_tmp);
.status() run_required_stack_command("indeedhub", "create ffmpeg worker", &mut ffmpeg_cmd).await?;
.await;
// Wait for backend services to start // Wait for backend services to start
tokio::time::sleep(std::time::Duration::from_secs(5)).await; tokio::time::sleep(std::time::Duration::from_secs(5)).await;
// 7. Frontend (nginx) // 7. Frontend (nginx)
let run = tokio::process::Command::new("podman") let mut frontend_cmd = tokio::process::Command::new("podman");
frontend_cmd
.args([ .args([
"run", "run",
"-d", "-d",
@ -1118,15 +1230,23 @@ impl RpcHandler {
"7778:7777", "7778:7777",
&format!("{}/indeedhub:1.0.0", registry), &format!("{}/indeedhub:1.0.0", registry),
]) ])
.env("TMPDIR", &user_tmp) .env("TMPDIR", &user_tmp);
.output() run_required_stack_command("indeedhub", "create frontend", &mut frontend_cmd).await?;
.await
.context("Failed to create indeedhub container")?;
if !run.status.success() { wait_for_stack_containers(
let err = String::from_utf8_lossy(&run.stderr); "indeedhub",
return Err(anyhow::anyhow!("IndeedHub frontend failed: {}", err)); &[
} "indeedhub-postgres",
"indeedhub-redis",
"indeedhub-minio",
"indeedhub-relay",
"indeedhub-api",
"indeedhub-ffmpeg",
"indeedhub",
],
60,
)
.await?;
// Phase: WaitingHealthy → PostInstall → clear. The actual readiness // Phase: WaitingHealthy → PostInstall → clear. The actual readiness
// gate is the package scanner's next sweep; this just gives the UI a // gate is the package scanner's next sweep; this just gives the UI a
@ -1136,7 +1256,8 @@ impl RpcHandler {
.await; .await;
self.set_install_phase("indeedhub", InstallPhase::PostInstall) self.set_install_phase("indeedhub", InstallPhase::PostInstall)
.await; .await;
self.set_install_phase("indeedhub", InstallPhase::Done).await; self.set_install_phase("indeedhub", InstallPhase::Done)
.await;
self.clear_install_progress("indeedhub").await; self.clear_install_progress("indeedhub").await;
install_log("INSTALL OK: indeedhub stack").await; install_log("INSTALL OK: indeedhub stack").await;

View File

@ -56,6 +56,11 @@ pub async fn ensure_doctor_installed() {
Ok(false) => debug!("Nginx already has /api/app-catalog block"), Ok(false) => debug!("Nginx already has /api/app-catalog block"),
Err(e) => warn!("Nginx bootstrap failed (non-fatal): {:#}", e), Err(e) => warn!("Nginx bootstrap failed (non-fatal): {:#}", e),
} }
match run_bitcoin_rpc_repair().await {
Ok(true) => info!("Repaired Bitcoin RPC bind settings and restarted Bitcoin containers"),
Ok(false) => debug!("Bitcoin RPC bind settings already usable"),
Err(e) => warn!("Bitcoin RPC repair failed (non-fatal): {:#}", e),
}
} }
async fn run_runtime_assets() -> Result<bool> { async fn run_runtime_assets() -> Result<bool> {
@ -169,6 +174,54 @@ fn path_dot(path: &Path) -> String {
p.to_string_lossy().to_string() p.to_string_lossy().to_string()
} }
async fn run_bitcoin_rpc_repair() -> Result<bool> {
// Older installs can have a container-owned bitcoin.conf with only rpcauth
// and printtoconsole. In that state bitcoind is healthy internally, but the
// host-network bitcoin-ui proxy to 127.0.0.1:8332 gets connection resets.
// Repair it at startup so OTA fixes existing nodes without a manual
// uninstall/reinstall.
let script = r#"
set -eu
conf=/var/lib/archipelago/bitcoin/bitcoin.conf
[ -f "$conf" ] || exit 0
changed=0
ensure_line() {
line="$1"
key="${line%%=*}"
if ! grep -q "^${key}=" "$conf"; then
printf '%s\n' "$line" >> "$conf"
changed=1
fi
}
ensure_line server=1
ensure_line rpcbind=0.0.0.0
ensure_line rpcallowip=0.0.0.0/0
ensure_line rpcport=8332
ensure_line listen=1
[ "$changed" -eq 0 ] && exit 0
exit 2
"#;
let status = host_sudo(&["sh", "-lc", script])
.await
.context("repair bitcoin.conf RPC bind settings")?;
match status.code() {
Some(0) => Ok(false),
Some(2) => {
for name in ["bitcoin-knots", "bitcoin-core", "archy-bitcoin-ui"] {
let _ = tokio::process::Command::new("podman")
.args(["restart", name])
.status()
.await;
}
Ok(true)
}
_ => {
warn!("Bitcoin RPC repair helper exited with {}", status);
Ok(false)
}
}
}
async fn run() -> Result<bool> { async fn run() -> Result<bool> {
// Dev-box guard: on contributors' laptops `/home/archipelago/archy` is // Dev-box guard: on contributors' laptops `/home/archipelago/archy` is
// typically a symlink into the git checkout, and writing through it // typically a symlink into the git checkout, and writing through it

View File

@ -11,6 +11,8 @@ use tokio::fs;
use tracing::{debug, info}; use tracing::{debug, info};
const REGISTRY_FILE: &str = "config/registries.json"; const REGISTRY_FILE: &str = "config/registries.json";
const OVH_REGISTRY_URL: &str = "146.59.87.168:3000/lfg2025";
const TX1138_REGISTRY_URL: &str = "git.tx1138.com/lfg2025";
/// A single container registry. /// A single container registry.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -44,14 +46,14 @@ impl Default for RegistryConfig {
Self { Self {
registries: vec![ registries: vec![
Registry { Registry {
url: "146.59.87.168:3000/lfg2025".to_string(), url: OVH_REGISTRY_URL.to_string(),
name: "Server 1 (OVH)".to_string(), name: "Server 1 (OVH)".to_string(),
tls_verify: false, tls_verify: false,
enabled: true, enabled: true,
priority: 0, priority: 0,
}, },
Registry { Registry {
url: "git.tx1138.com/lfg2025".to_string(), url: TX1138_REGISTRY_URL.to_string(),
name: "Server 2 (tx1138)".to_string(), name: "Server 2 (tx1138)".to_string(),
tls_verify: true, tls_verify: true,
enabled: true, enabled: true,
@ -139,6 +141,19 @@ pub async fn load_registries(data_dir: &Path) -> Result<RegistryConfig> {
changed = true; changed = true;
} }
} }
let before_order: Vec<(String, bool, u32)> = config
.registries
.iter()
.map(|r| (r.url.clone(), r.enabled, r.priority))
.collect();
force_ovh_registry_primary(&mut config);
changed = changed
|| before_order
!= config
.registries
.iter()
.map(|r| (r.url.clone(), r.enabled, r.priority))
.collect::<Vec<_>>();
if changed { if changed {
// Persist so the next load doesn't have to re-merge. // Persist so the next load doesn't have to re-merge.
let _ = save_registries(data_dir, &config).await; let _ = save_registries(data_dir, &config).await;
@ -146,6 +161,37 @@ pub async fn load_registries(data_dir: &Path) -> Result<RegistryConfig> {
Ok(config) Ok(config)
} }
fn force_ovh_registry_primary(config: &mut RegistryConfig) {
let defaults = RegistryConfig::default();
for def in defaults.registries {
if !config.registries.iter().any(|r| r.url == def.url) {
config.registries.push(def);
}
}
for registry in config.registries.iter_mut() {
match registry.url.as_str() {
OVH_REGISTRY_URL => {
registry.name = "Server 1 (OVH)".to_string();
registry.tls_verify = false;
registry.enabled = true;
registry.priority = 0;
}
TX1138_REGISTRY_URL => {
registry.name = "Server 2 (tx1138)".to_string();
registry.tls_verify = true;
registry.enabled = true;
registry.priority = 10;
}
_ => {
if registry.priority <= 10 {
registry.priority = registry.priority.saturating_add(20);
}
}
}
}
}
/// Save registry config to disk. /// Save registry config to disk.
pub async fn save_registries(data_dir: &Path, config: &RegistryConfig) -> Result<()> { pub async fn save_registries(data_dir: &Path, config: &RegistryConfig) -> Result<()> {
let dir = data_dir.join("config"); let dir = data_dir.join("config");

View File

@ -163,12 +163,40 @@ pub async fn load_mirrors(data_dir: &Path) -> Result<Vec<UpdateMirror>> {
changed = true; changed = true;
} }
} }
let before_order: Vec<String> = list.iter().map(|m| m.url.clone()).collect();
force_ovh_update_primary(&mut list);
changed = changed || before_order != list.iter().map(|m| m.url.clone()).collect::<Vec<_>>();
if changed { if changed {
let _ = save_mirrors(data_dir, &list).await; let _ = save_mirrors(data_dir, &list).await;
} }
Ok(list) Ok(list)
} }
fn force_ovh_update_primary(list: &mut Vec<UpdateMirror>) {
let defaults = default_mirrors();
for def in &defaults {
if !list.iter().any(|m| m.url == def.url) {
list.push(def.clone());
}
}
for mirror in list.iter_mut() {
if mirror.url == DEFAULT_UPDATE_MANIFEST_URL {
mirror.label = "Server 1 (OVH)".to_string();
} else if mirror.url == DEFAULT_SECONDARY_MIRROR_URL {
mirror.label = "Server 2 (tx1138)".to_string();
}
}
list.sort_by_key(|m| {
if m.url == DEFAULT_UPDATE_MANIFEST_URL {
0
} else if m.url == DEFAULT_SECONDARY_MIRROR_URL {
1
} else {
2
}
});
}
pub async fn save_mirrors(data_dir: &Path, mirrors: &[UpdateMirror]) -> Result<()> { pub async fn save_mirrors(data_dir: &Path, mirrors: &[UpdateMirror]) -> Result<()> {
fs::create_dir_all(data_dir) fs::create_dir_all(data_dir)
.await .await

View File

@ -1,12 +1,12 @@
{ {
"name": "neode-ui", "name": "neode-ui",
"version": "1.7.50-alpha", "version": "1.7.51-alpha",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "neode-ui", "name": "neode-ui",
"version": "1.7.50-alpha", "version": "1.7.51-alpha",
"dependencies": { "dependencies": {
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
"@vue-leaflet/vue-leaflet": "^0.10.1", "@vue-leaflet/vue-leaflet": "^0.10.1",

View File

@ -1,7 +1,7 @@
{ {
"name": "neode-ui", "name": "neode-ui",
"private": true, "private": true,
"version": "1.7.50-alpha", "version": "1.7.51-alpha",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "./start-dev.sh", "start": "./start-dev.sh",

View File

@ -83,9 +83,11 @@ const launchableApps = computed<KioskApp[]>(() => {
const pkgs = store.data?.['package-data'] || {} const pkgs = store.data?.['package-data'] || {}
const apps: KioskApp[] = [] const apps: KioskApp[] = []
// App URL mappings use nginx proxy paths for local apps // App URL mappings. Bitcoin UI uses its direct host-network port; loading it
// through /app/bitcoin-ui/ can render a blank shell because its assets are
// rooted at /.
const urlMap: Record<string, string> = { const urlMap: Record<string, string> = {
'bitcoin-knots': '/app/bitcoin-ui/', 'bitcoin-knots': 'http://' + window.location.hostname + ':8334',
'lnd': '/app/lnd/', 'lnd': '/app/lnd/',
'mempool': '/app/mempool/', 'mempool': '/app/mempool/',
'btcpay-server': '/app/btcpay/', 'btcpay-server': '/app/btcpay/',

View File

@ -55,9 +55,6 @@ export const PROXY_APPS: Record<string, string> = {
/** Nginx proxy paths -- used on HTTPS to avoid mixed content (HTTPS parent + HTTP port iframe). /** Nginx proxy paths -- used on HTTPS to avoid mixed content (HTTPS parent + HTTP port iframe).
* On HTTP, direct port access is used instead (faster, no proxy). */ * On HTTP, direct port access is used instead (faster, no proxy). */
export const HTTPS_PROXY_PATHS: Record<string, string> = { export const HTTPS_PROXY_PATHS: Record<string, string> = {
'bitcoin-knots': '/app/bitcoin-ui/',
'bitcoin-core': '/app/bitcoin-ui/',
'bitcoin-ui': '/app/bitcoin-ui/',
'lnd': '/app/lnd/', 'lnd': '/app/lnd/',
'electrumx': '/app/electrumx/', 'electrumx': '/app/electrumx/',
'electrs': '/app/electrumx/', 'electrs': '/app/electrumx/',
@ -137,9 +134,11 @@ export function resolveAppUrl(id: string, routeQueryPath?: string): string {
const ext = EXTERNAL_URLS[id] const ext = EXTERNAL_URLS[id]
if (ext) return ext if (ext) return ext
// Bitcoin apps always go through nginx proxy so browser basic-auth prompts never appear. // Bitcoin UI is a host-network companion on :8334. Do not launch it via
// /app/bitcoin-ui/: the static UI is built for root and renders a blank
// shell when proxied under a path prefix on some nodes.
if (id === 'bitcoin-knots' || id === 'bitcoin-core' || id === 'bitcoin-ui') { if (id === 'bitcoin-knots' || id === 'bitcoin-core' || id === 'bitcoin-ui') {
return window.location.protocol + '//' + window.location.hostname + '/app/bitcoin-ui/' return 'http://' + window.location.hostname + ':8334'
} }
// HTTPS pages cannot embed plain HTTP port origins (mixed-content). // HTTPS pages cannot embed plain HTTP port origins (mixed-content).

View File

@ -1,27 +1,29 @@
{ {
"version": "1.7.50-alpha", "version": "1.7.51-alpha",
"release_date": "2026-05-01", "release_date": "2026-05-01",
"changelog": [ "changelog": [
"OTA now carries app manifests, scripts, Docker contexts, and doctor units inside the frontend payload so older nodes restore /opt/archipelago runtime assets after updating.", "Install success now requires adopted containers and IndeedHub stack containers to stay running; failed starts surface logs instead of disappearing from My Apps.",
"Startup bootstrap promotes the embedded runtime payload into /opt/archipelago and reloads the doctor timer without requiring rsync.", "Bitcoin uninstall removes the shared Bitcoin data/UI directories when data is not preserved, preventing stale partial installs from being adopted as success.",
"Update checks now continue past stale mirrors so nodes can discover a newer manifest from the next configured mirror." "Bitcoin RPC bind settings are repaired on startup and before adopting existing Bitcoin containers, fixing older nodes where bitcoin-ui showed endless getblockchaininfo/502.",
"Bitcoin Core/Knots launch the Bitcoin UI on direct port 8334 instead of the /app/bitcoin-ui path proxy.",
"Nodes force OVH as the primary update mirror and app registry on next startup, with tx1138 retained as fallback."
], ],
"components": [ "components": [
{ {
"name": "archipelago", "name": "archipelago",
"current_version": "1.7.49-alpha", "current_version": "1.7.50-alpha",
"new_version": "1.7.50-alpha", "new_version": "1.7.51-alpha",
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.50-alpha/archipelago", "download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.51-alpha/archipelago",
"sha256": "494f71d58608787a4f485ec6e295057e70045e905d745837d1cd6089e484197d", "sha256": "f761e659d661f0a83cd3a67a086bb2279398bc05e50ee3c52e769e52d11e476c",
"size_bytes": 41779280 "size_bytes": 41637536
}, },
{ {
"name": "archipelago-frontend-1.7.50-alpha.tar.gz", "name": "archipelago-frontend-1.7.51-alpha.tar.gz",
"current_version": "1.7.49-alpha", "current_version": "1.7.50-alpha",
"new_version": "1.7.50-alpha", "new_version": "1.7.51-alpha",
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.50-alpha/archipelago-frontend-1.7.50-alpha.tar.gz", "download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.51-alpha/archipelago-frontend-1.7.51-alpha.tar.gz",
"sha256": "6684d5f1083fd2d1db236fcc4a94efee7986ab54602b3231b031f3e9f7dec361", "sha256": "3403f4e38202bf56c53407dd62e66899693ee73252bf203475715532ac6ae326",
"size_bytes": 165153241 "size_bytes": 165155462
} }
] ]
} }