chore: release v1.7.49-alpha

This commit is contained in:
archipelago 2026-04-30 16:29:56 -04:00
parent f507b847ef
commit 7ab788d178
36 changed files with 1435 additions and 133 deletions

View File

@ -1,5 +1,14 @@
# Changelog
## v1.7.49-alpha (2026-04-30)
- Bitcoin Knots/Core UI now reports connection, reconnecting, syncing, and error states from a backend status bridge instead of showing a stale "Unable to connect" message while the node is warming up.
- ElectrumX UI now exposes indexed height, local Bitcoin height, known headers, status, and progress source so indexing/waiting states are readable during long initial sync.
- Added container doctor timer and smoke/lifecycle test coverage for Bitcoin Knots/Core, ElectrumX, Mempool, BTCPay/NBXplorer, and UI surface availability.
- Bitcoin Core and Bitcoin Knots are mutually exclusive variants, with a real Bitcoin Core manifest and corrected install conflict handling.
- IndeeHub now launches only on direct web UI port `7778`; the broken `/app/indeedhub/` path proxy was removed, and port `7777` remains the Nostr relay.
- BTCPay/NBXplorer Postgres environment formatting fixed so installs do not carry malformed connection strings.
## v1.7.48-alpha (2026-04-29)
- archipelago.service no longer fails to start with "Failed to set up mount namespacing: /run/containers: No such file or directory" on nodes where /run/containers wasn't pre-created. ExecStartPre now creates it. Existing nodes need a one-time `systemctl edit archipelago` to add the mkdir; ISO installs from this version forward have the fix baked in.

View File

@ -26,7 +26,7 @@
"id": "bitcoin-core",
"title": "Bitcoin Core",
"version": "28.4",
"description": "Reference implementation of the Bitcoin protocol. Run a full node validating and relaying blocks.",
"description": "Reference Bitcoin node implementation. Alternative to Bitcoin Knots; uninstall Knots before switching.",
"icon": "/assets/img/app-icons/bitcoin-core.svg",
"author": "Bitcoin Core contributors",
"category": "money",

View File

@ -47,7 +47,7 @@ app:
- NBXPLORER_BIND=0.0.0.0:32838
- NBXPLORER_BTCRPCURL=http://bitcoin-knots:8332
- NBXPLORER_BTCRPCUSER=archipelago
- NBXPLORER_POSTGRES=User ID=btcpay;Password=${BTCPAY_DB_PASS};Host=archy-btcpay-db;Port=5432;Database=nbxplorer;Include Error Detail=true
- NBXPLORER_POSTGRES=Username=btcpay;Password=${BTCPAY_DB_PASS};Host=archy-btcpay-db;Port=5432;Database=nbxplorer
health_check:
type: http

View File

@ -1,13 +1,13 @@
app:
id: bitcoin-core
name: Bitcoin Knots
name: Bitcoin Core
version: 28.4.0
description: Full Bitcoin Knots node with dynamic prune/full-mode startup based on host disk.
description: Reference Bitcoin Core node with dynamic prune/full-mode startup based on host disk.
container_name: bitcoin-knots
container_name: bitcoin-core
container:
image: 146.59.87.168:3000/lfg2025/bitcoin-knots:latest
image: 146.59.87.168:3000/lfg2025/bitcoin:28.4
pull_policy: if-not-present
network: archy-net
entrypoint: ["sh", "-lc"]

View File

@ -0,0 +1,75 @@
app:
id: bitcoin-knots
name: Bitcoin Knots
version: 28.1.0
description: Full Bitcoin Knots node with dynamic prune/full-mode startup based on host disk.
container_name: bitcoin-knots
container:
image: 146.59.87.168:3000/lfg2025/bitcoin-knots:latest
pull_policy: if-not-present
network: archy-net
entrypoint: ["sh", "-lc"]
custom_args:
# Sync-speed flags: -par=0 uses every core (was capped at 2 by
# --cpus=2, now removed for bitcoin/electrumx). -dbcache sized to
# the IBD sweet spot — 4GB on full nodes, 1GB on pruned. Container
# --memory=8g (config.rs::get_memory_limit) leaves headroom for
# mempool + connections.
- >-
if [ "${DISK_GB:-0}" -lt 1000 ]; then
exec bitcoind -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 -rpcuser="${BITCOIN_RPC_USER}" -rpcpassword="${BITCOIN_RPC_PASS}";
else
exec bitcoind -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 -rpcuser="${BITCOIN_RPC_USER}" -rpcpassword="${BITCOIN_RPC_PASS}";
fi
derived_env:
- key: DISK_GB
template: "{{DISK_GB}}"
secret_env:
- key: BITCOIN_RPC_PASS
secret_file: bitcoin-rpc-password
data_uid: "100101:100101"
dependencies:
- storage: 500Gi
resources:
cpu_limit: 0
memory_limit: 4Gi
disk_limit: 500Gi
security:
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE]
readonly_root: false
network_policy: isolated
ports:
- host: 8332
container: 8332
protocol: tcp
- host: 8333
container: 8333
protocol: tcp
volumes:
- type: bind
source: /var/lib/archipelago/bitcoin
target: /home/bitcoin/.bitcoin
options: [rw]
environment:
- BITCOIN_RPC_USER=archipelago
health_check:
type: tcp
endpoint: localhost:8332
interval: 30s
timeout: 5s
retries: 3
bitcoin_integration:
rpc_access: admin
sync_required: true
testnet_support: false
pruning_support: true

View File

@ -51,7 +51,7 @@ app:
- BTCPAY_BTCEXPLORERURL=http://archy-nbxplorer:32838
- BTCPAY_BTCRPCURL=http://bitcoin-knots:8332
- BTCPAY_BTCRPCUSER=archipelago
- BTCPAY_POSTGRES=User ID=btcpay;Password=${BTCPAY_DB_PASS};Host=archy-btcpay-db;Port=5432;Database=btcpay;Include Error Detail=true
- BTCPAY_POSTGRES=Username=btcpay;Password=${BTCPAY_DB_PASS};Host=archy-btcpay-db;Port=5432;Database=btcpay
health_check:
type: http

View File

@ -27,9 +27,9 @@ app:
apparmor_profile: default
ports:
- host: 7777
container: 3000
protocol: tcp # Web UI (Next.js)
- host: 7778
container: 7777
protocol: tcp # Web UI. Port 7777 on the host is reserved for Nostr relay.
volumes:
- type: tmpfs
@ -57,7 +57,7 @@ app:
name: Web UI
description: Stream Bitcoin documentaries with Nostr identity
type: ui
port: 7777
port: 7778
protocol: http
path: /

2
core/Cargo.lock generated
View File

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

View File

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

View File

@ -429,6 +429,7 @@ impl ApiHandler {
// Electrs status — unauthenticated (read-only sync status)
(Method::GET, "/electrs-status") => Self::handle_electrs_status().await,
(Method::GET, "/bitcoin-status") => Self::handle_bitcoin_status().await,
// App-catalog proxy — fetches catalog.json from the configured
// upstream URLs server-side so the browser doesn't hit CORS

View File

@ -1,5 +1,6 @@
use super::build_response;
use crate::api::rpc::RpcHandler;
use crate::bitcoin_status;
use crate::electrs_status;
use anyhow::Result;
use hyper::{Response, StatusCode};
@ -76,11 +77,23 @@ impl ApiHandler {
pub(super) async fn handle_electrs_status() -> Result<Response<hyper::Body>> {
let status = electrs_status::get_electrs_sync_status().await;
let body = serde_json::to_vec(&status).unwrap_or_default();
Ok(build_response(
StatusCode::OK,
"application/json",
hyper::Body::from(body),
))
Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.header("Cache-Control", "no-store")
.body(hyper::Body::from(body))
.unwrap_or_else(|_| Response::new(hyper::Body::from("{}"))))
}
pub(super) async fn handle_bitcoin_status() -> Result<Response<hyper::Body>> {
let status = bitcoin_status::get_bitcoin_status().await;
let body = serde_json::to_vec(&status).unwrap_or_default();
Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.header("Cache-Control", "no-store")
.body(hyper::Body::from(body))
.unwrap_or_else(|_| Response::new(hyper::Body::from("{}"))))
}
pub(super) async fn handle_lnd_connect_info(

View File

@ -229,6 +229,7 @@ impl RpcHandler {
let deps = detect_running_deps().await?;
check_install_deps(package_id, &deps)?;
log_optional_dep_info(package_id, &deps);
check_bitcoin_implementation_conflict(package_id).await?;
// Check if container already exists
let check_output = tokio::process::Command::new("podman")
@ -1961,9 +1962,51 @@ fn should_try_orchestrator_install(package_id: &str, orchestrator_available: boo
orchestrator_available && uses_orchestrator_install_flow(package_id)
}
async fn check_bitcoin_implementation_conflict(package_id: &str) -> Result<()> {
let other = match package_id {
"bitcoin-core" => "bitcoin-knots",
"bitcoin-knots" => "bitcoin-core",
_ => return Ok(()),
};
let output = tokio::process::Command::new("podman")
.args([
"ps",
"-a",
"--format",
"{{.Names}}",
"--filter",
&format!("name=^{}$", other),
])
.output()
.await
.context("Failed to check existing Bitcoin node containers")?;
if String::from_utf8_lossy(&output.stdout).trim().is_empty() {
return Ok(());
}
let current = match other {
"bitcoin-core" => "Bitcoin Core",
"bitcoin-knots" => "Bitcoin Knots",
_ => "another Bitcoin node",
};
let requested = match package_id {
"bitcoin-core" => "Bitcoin Core",
"bitcoin-knots" => "Bitcoin Knots",
_ => "the requested Bitcoin node",
};
Err(anyhow::anyhow!(
"{} is already installed. Stop and uninstall {} before installing {}; both implementations use the same Bitcoin data directory and ports.",
current,
current,
requested
))
}
fn orchestrator_install_app_id(package_id: &str) -> &str {
match package_id {
"bitcoin-knots" => "bitcoin-core",
"electrs" | "mempool-electrs" => "electrumx",
_ => package_id,
}
@ -2049,7 +2092,8 @@ mod tests {
#[test]
fn install_aliases_map_to_manifest_app_ids() {
assert_eq!(orchestrator_install_app_id("bitcoin-knots"), "bitcoin-core");
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("electrs"), "electrumx");
assert_eq!(orchestrator_install_app_id("mempool-electrs"), "electrumx");
assert_eq!(orchestrator_install_app_id("lnd"), "lnd");

View File

@ -355,7 +355,7 @@ pub(in crate::api::rpc) fn known_service_port(name: &str) -> u16 {
"penpot" => 9001,
"nginx-proxy-manager" => 81,
"vaultwarden" => 8343,
"indeedhub" => 7777,
"indeedhub" => 7778,
_ => 0,
}
}

View File

@ -0,0 +1,186 @@
//! Cached Bitcoin node status for browser UIs.
//!
//! The bitcoin-ui should not poll Bitcoin RPC directly for display state.
//! During container restarts, reindexing, and IBD, direct browser RPC polling
//! turns short RPC gaps into visible UI failures. This module owns the RPC
//! polling loop, caches the last successful snapshot, and serves stale-but-known
//! state while the node is reconnecting.
use anyhow::{Context, Result};
use serde::Serialize;
use std::sync::OnceLock;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::sync::RwLock;
use tracing::{debug, warn};
const CACHE_REFRESH_SECS: u64 = 5;
#[derive(Debug, Clone, Serialize)]
pub struct BitcoinNodeStatus {
pub ok: bool,
pub stale: bool,
pub updated_at_ms: u64,
pub error: Option<String>,
pub blockchain_info: Option<serde_json::Value>,
pub network_info: Option<serde_json::Value>,
pub index_info: Option<serde_json::Value>,
pub zmq_notifications: Option<serde_json::Value>,
}
impl Default for BitcoinNodeStatus {
fn default() -> Self {
Self {
ok: false,
stale: false,
updated_at_ms: 0,
error: Some("Connecting to Bitcoin node...".to_string()),
blockchain_info: None,
network_info: None,
index_info: None,
zmq_notifications: None,
}
}
}
static STATUS_CACHE: OnceLock<RwLock<BitcoinNodeStatus>> = OnceLock::new();
fn cache() -> &'static RwLock<BitcoinNodeStatus> {
STATUS_CACHE.get_or_init(|| RwLock::new(BitcoinNodeStatus::default()))
}
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}
fn transient_error(err_msg: &str) -> bool {
let lower = err_msg.to_lowercase();
lower.contains("connect")
|| lower.contains("reset")
|| lower.contains("refused")
|| lower.contains("timed out")
|| lower.contains("timeout")
|| lower.contains("broken pipe")
|| lower.contains("eof")
|| lower.contains("500 internal server error")
}
pub fn spawn_status_cache() {
tokio::spawn(async {
loop {
let fresh = fetch_bitcoin_status().await;
let mut cached = cache().write().await;
match fresh {
Ok(mut status) => {
status.ok = true;
status.stale = false;
status.error = None;
*cached = status;
}
Err(e) => {
let err_msg = e.to_string();
if transient_error(&err_msg) {
debug!("Bitcoin status: transient RPC failure: {}", err_msg);
} else {
warn!("Bitcoin status: RPC failure: {}", err_msg);
}
if cached.blockchain_info.is_some() {
cached.ok = false;
cached.stale = true;
cached.error = Some(format!(
"Bitcoin node is reconnecting; showing last known state: {}",
err_msg
));
} else {
*cached = BitcoinNodeStatus {
ok: false,
stale: false,
updated_at_ms: now_ms(),
error: Some(format!("Connecting to Bitcoin node: {}", err_msg)),
..BitcoinNodeStatus::default()
};
}
}
}
drop(cached);
tokio::time::sleep(Duration::from_secs(CACHE_REFRESH_SECS)).await;
}
});
}
pub async fn get_bitcoin_status() -> BitcoinNodeStatus {
cache().read().await.clone()
}
async fn fetch_bitcoin_status() -> Result<BitcoinNodeStatus> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(8))
.build()
.context("build Bitcoin status HTTP client")?;
let blockchain_info = bitcoin_rpc_call(&client, "getblockchaininfo", serde_json::json!([]))
.await
.context("getblockchaininfo")?;
let network_info = bitcoin_rpc_call(&client, "getnetworkinfo", serde_json::json!([]))
.await
.context("getnetworkinfo")
.ok();
let index_info = bitcoin_rpc_call(&client, "getindexinfo", serde_json::json!([]))
.await
.context("getindexinfo")
.ok();
let zmq_notifications =
bitcoin_rpc_call(&client, "getzmqnotifications", serde_json::json!([]))
.await
.context("getzmqnotifications")
.ok();
Ok(BitcoinNodeStatus {
ok: true,
stale: false,
updated_at_ms: now_ms(),
error: None,
blockchain_info: Some(blockchain_info),
network_info,
index_info,
zmq_notifications,
})
}
async fn bitcoin_rpc_call(
client: &reqwest::Client,
method: &str,
params: serde_json::Value,
) -> Result<serde_json::Value> {
let (rpc_user, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
let body = serde_json::json!({
"jsonrpc": "1.0",
"id": "bitcoin-status",
"method": method,
"params": params,
});
let resp = client
.post(crate::constants::BITCOIN_RPC_URL)
.basic_auth(rpc_user, Some(rpc_pass))
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
.context("Bitcoin RPC request failed")?;
let status = resp.status();
let json: serde_json::Value = resp.json().await.context("decode Bitcoin RPC JSON")?;
if !status.is_success() {
anyhow::bail!("Bitcoin RPC returned {}: {}", status, json);
}
if let Some(error) = json.get("error").filter(|e| !e.is_null()) {
anyhow::bail!("Bitcoin RPC {} error: {}", method, error);
}
json.get("result")
.cloned()
.context("missing Bitcoin RPC result")
}

View File

@ -15,5 +15,13 @@ server {
add_header Access-Control-Allow-Headers "Content-Type, Authorization";
if ($request_method = OPTIONS) { return 204; }
}
location /bitcoin-status {
proxy_pass http://127.0.0.1:5678/bitcoin-status;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
add_header Cache-Control "no-store";
}
location / { try_files $uri $uri/ /index.html; }
}

View File

@ -30,9 +30,11 @@ async fn bitcoin_rpc_auth() -> String {
#[derive(Debug, Clone, Serialize)]
pub struct ElectrsSyncStatus {
pub indexed_height: u64,
pub bitcoin_height: u64,
pub network_height: u64,
pub progress_pct: f64,
pub status: String,
pub stale: bool,
pub error: Option<String>,
/// Index data size in human-readable format (e.g. "11.2 GB")
pub index_size: Option<String>,
@ -44,9 +46,11 @@ impl Default for ElectrsSyncStatus {
fn default() -> Self {
Self {
indexed_height: 0,
bitcoin_height: 0,
network_height: 0,
progress_pct: 0.0,
status: "starting".to_string(),
stale: false,
error: None,
index_size: None,
tor_onion: None,
@ -64,15 +68,33 @@ fn cache() -> &'static RwLock<ElectrsSyncStatus> {
/// Spawn background task that refreshes ElectrumX status every CACHE_REFRESH_SECS.
pub fn spawn_status_cache() {
tokio::spawn(async {
// Initial delay — let services start up before first query
tokio::time::sleep(Duration::from_secs(5)).await;
let mut interval = tokio::time::interval(Duration::from_secs(CACHE_REFRESH_SECS));
loop {
interval.tick().await;
let fresh = fetch_electrs_sync_status().await;
let mut fresh = fetch_electrs_sync_status().await;
let mut cached = cache().write().await;
if fresh.indexed_height == 0
&& cached.indexed_height > 0
&& matches!(fresh.status.as_str(), "indexing" | "waiting")
{
fresh.indexed_height = cached.indexed_height;
if fresh.network_height == 0 {
fresh.network_height = cached.network_height;
}
if fresh.bitcoin_height == 0 {
fresh.bitcoin_height = cached.bitcoin_height;
}
if fresh.progress_pct <= 0.0 {
fresh.progress_pct = cached.progress_pct;
}
fresh.stale = true;
fresh.error = Some(
fresh
.error
.unwrap_or_else(|| "ElectrumX is reconnecting; showing last known indexed height.".to_string()),
);
}
*cached = fresh;
drop(cached);
tokio::time::sleep(Duration::from_secs(CACHE_REFRESH_SECS)).await;
}
});
}
@ -187,13 +209,69 @@ async fn electrumx_indexed_height() -> Result<u64> {
Ok(height)
}
/// Fetch Bitcoin network height via JSON-RPC.
async fn bitcoin_network_height() -> Result<u64> {
fn parse_electrumx_height_from_logs(logs: &str) -> Option<u64> {
let mut height = None;
for line in logs.lines() {
if let Some(idx) = line.find("BlockProcessor:our height:") {
let rest = &line[idx + "BlockProcessor:our height:".len()..];
if let Some(parsed) = parse_first_u64_token(rest) {
height = Some(parsed);
}
continue;
}
if let Some(idx) = line.find("DB:height:") {
let rest = &line[idx + "DB:height:".len()..];
if let Some(parsed) = parse_first_u64_token(rest) {
height = Some(parsed);
}
}
}
height
}
fn parse_first_u64_token(input: &str) -> Option<u64> {
let token: String = input
.trim_start()
.chars()
.take_while(|c| c.is_ascii_digit() || *c == ',')
.filter(|c| *c != ',')
.collect();
if token.is_empty() {
None
} else {
token.parse().ok()
}
}
async fn electrumx_log_indexed_height() -> Result<u64> {
let output = tokio::process::Command::new("podman")
.args(["logs", "--tail", "500", "electrumx"])
.output()
.await
.context("Failed to read ElectrumX logs")?;
if !output.status.success() {
anyhow::bail!(
"podman logs electrumx failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
let logs = String::from_utf8_lossy(&output.stdout);
parse_electrumx_height_from_logs(&logs).context("No ElectrumX indexed height in logs")
}
/// Fetch Bitcoin local block height and best-known network header height via JSON-RPC.
async fn bitcoin_chain_heights() -> Result<(u64, u64)> {
let client = reqwest::Client::new();
let body = serde_json::json!({
"jsonrpc": "1.0",
"id": "electrs-status",
"method": "getblockcount",
"method": "getblockchaininfo",
"params": []
});
let resp = client
@ -211,11 +289,18 @@ async fn bitcoin_network_height() -> Result<u64> {
}
let json: serde_json::Value = resp.json().await?;
let height = json
let result = json
.get("result")
.and_then(|r| r.as_u64())
.context("Missing result in Bitcoin RPC")?;
Ok(height)
let blocks = result
.get("blocks")
.and_then(|h| h.as_u64())
.context("Missing blocks in Bitcoin RPC")?;
let headers = result
.get("headers")
.and_then(|h| h.as_u64())
.unwrap_or(blocks);
Ok((blocks, headers.max(blocks)))
}
/// Fetch fresh ElectrumX sync status (called by background cache task).
@ -260,8 +345,8 @@ async fn fetch_electrs_sync_status() -> ElectrsSyncStatus {
onion
};
let network_height = match bitcoin_network_height().await {
Ok(h) => h,
let (bitcoin_blocks, network_height) = match bitcoin_chain_heights().await {
Ok(heights) => heights,
Err(e) => {
let err_msg = e.to_string();
if is_transient_error(&err_msg) {
@ -271,9 +356,11 @@ async fn fetch_electrs_sync_status() -> ElectrsSyncStatus {
}
return ElectrsSyncStatus {
indexed_height: 0,
bitcoin_height: 0,
network_height: 0,
progress_pct: 0.0,
status: "waiting".to_string(),
stale: false,
error: Some("Waiting for Bitcoin node...".to_string()),
index_size,
tor_onion,
@ -283,7 +370,9 @@ async fn fetch_electrs_sync_status() -> ElectrsSyncStatus {
let indexed_height = match electrumx_indexed_height().await {
Ok(h) => h,
Err(e) => {
Err(e) => match electrumx_log_indexed_height().await {
Ok(h) if h > 0 => h,
_ => {
let err_msg = e.to_string();
if is_transient_error(&err_msg) {
// ElectrumX is starting up or busy — estimate from data size
@ -295,9 +384,11 @@ async fn fetch_electrs_sync_status() -> ElectrsSyncStatus {
let size_str = index_size.clone().unwrap_or_else(|| "0 MB".to_string());
return ElectrsSyncStatus {
indexed_height: 0,
bitcoin_height: bitcoin_blocks,
network_height,
progress_pct,
status: "indexing".to_string(),
stale: false,
error: Some(format!(
"Building index ({} / ~130 GB estimated). Electrum RPC will be available when complete.",
size_str
@ -310,35 +401,85 @@ async fn fetch_electrs_sync_status() -> ElectrsSyncStatus {
warn!("ElectrumX status: unexpected error: {}", err_msg);
return ElectrsSyncStatus {
indexed_height: 0,
bitcoin_height: bitcoin_blocks,
network_height,
progress_pct: 0.0,
status: "error".to_string(),
stale: false,
error: Some(format!("ElectrumX: {}", err_msg)),
index_size,
tor_onion,
};
}
},
};
let progress_pct = if network_height > 0 {
(indexed_height as f64 / network_height as f64) * 100.0
let observed_header_height = network_height.max(indexed_height);
let bitcoin_catching_up = bitcoin_blocks > 0 && bitcoin_blocks < observed_header_height;
let electrum_waiting_on_bitcoin =
bitcoin_catching_up && indexed_height >= bitcoin_blocks.saturating_sub(1);
let sync_target_height = if bitcoin_blocks > 0 {
bitcoin_blocks
} else {
observed_header_height
};
let progress_pct = if electrum_waiting_on_bitcoin && observed_header_height > 0 {
((bitcoin_blocks as f64 / observed_header_height as f64) * 100.0).min(99.9)
} else if sync_target_height > 0 {
((indexed_height as f64 / sync_target_height as f64) * 100.0).min(100.0)
} else {
0.0
};
let status = if indexed_height >= network_height.saturating_sub(1) {
let status = if sync_target_height == 0 {
"waiting"
} else if electrum_waiting_on_bitcoin {
"waiting"
} else if indexed_height >= sync_target_height.saturating_sub(1) {
"synced"
} else {
"syncing"
};
let error = if electrum_waiting_on_bitcoin {
Some(format!(
"ElectrumX is indexed to {:}; waiting for the local Bitcoin node to catch up from {:} to known header {:}.",
indexed_height, bitcoin_blocks, observed_header_height
))
} else if status == "syncing" && bitcoin_blocks < observed_header_height {
Some(format!(
"Indexing local Bitcoin node height {:} of {:}. Bitcoin node is still catching up to known header {:}.",
indexed_height, bitcoin_blocks, observed_header_height
))
} else {
None
};
ElectrsSyncStatus {
indexed_height,
network_height,
bitcoin_height: bitcoin_blocks,
network_height: observed_header_height,
progress_pct,
status: status.to_string(),
error: None,
stale: false,
error,
index_size,
tor_onion,
}
}
#[cfg(test)]
mod tests {
use super::parse_electrumx_height_from_logs;
#[test]
fn parses_latest_electrumx_progress_height_from_logs() {
let logs = r#"
INFO:DB:height: 228,238
INFO:BlockProcessor:our height: 228,248 daemon: 731,568 UTXOs 1MB hist 1MB
INFO:BlockProcessor:our height: 232,117 daemon: 732,108 UTXOs 281MB hist 83MB
"#;
assert_eq!(parse_electrumx_height_from_logs(logs), Some(232_117));
}
}

View File

@ -29,6 +29,7 @@ mod auth;
mod avatar;
mod backup;
mod bitcoin_rpc;
mod bitcoin_status;
mod blobs;
mod bootstrap;
mod config;
@ -289,6 +290,7 @@ async fn main() -> Result<()> {
// Spawn ElectrumX status cache (refreshes every 15s, serves cached data to avoid race conditions)
electrs_status::spawn_status_cache();
bitcoin_status::spawn_status_cache();
let startup_ms = startup_start.elapsed().as_millis();
info!(

View File

@ -606,7 +606,8 @@
console.log('[Bitcoin UI] Script loaded, initializing...');
// RPC Configuration - Use local Nginx proxy within container
const RPC_ENDPOINT = '/bitcoin-rpc/';
const RPC_ENDPOINT = 'bitcoin-rpc/';
const STATUS_ENDPOINT = 'bitcoin-status';
console.log('[Bitcoin UI] RPC Endpoint:', RPC_ENDPOINT);
// Make RPC call to Bitcoin node via local proxy
@ -645,6 +646,14 @@
}
}
async function fetchBitcoinStatus() {
const response = await fetch(STATUS_ENDPOINT, { cache: 'no-store' });
if (!response.ok) {
throw new Error(`status HTTP ${response.status}`);
}
return response.json();
}
// Implementation branding — detected from getnetworkinfo.subversion.
// Bitcoin Knots identifies as "/Satoshi:<ver>/Knots:<date>/", Bitcoin Core as "/Satoshi:<ver>/".
let brandingApplied = false;
@ -672,22 +681,62 @@
// Track last block count for animations
let lastBlockCount = 0;
let consecutiveRpcFailures = 0;
let lastSuccessfulUpdateAt = 0;
function formatPercent(value) {
if (!Number.isFinite(value) || value <= 0) return '0.00';
if (value < 0.01) return '<0.01';
return value.toFixed(2);
}
function formatBytes(bytes) {
if (!Number.isFinite(bytes) || bytes <= 0) return null;
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let value = bytes;
let unit = 0;
while (value >= 1000 && unit < units.length - 1) {
value /= 1000;
unit += 1;
}
return `${value.toFixed(unit >= 3 ? 1 : 0)} ${units[unit]}`;
}
// Update blockchain info
async function updateBlockchainInfo() {
console.log('[Bitcoin UI] updateBlockchainInfo() called');
try {
const blockchainInfo = await callRPC('getblockchaininfo');
const status = await fetchBitcoinStatus();
const blockchainInfo = status.blockchain_info;
console.log('[Bitcoin UI] blockchainInfo:', blockchainInfo);
if (!blockchainInfo) {
console.error('[Bitcoin UI] No blockchain info received');
document.getElementById('syncStatusText').textContent = 'Unable to connect to Bitcoin node';
document.getElementById('syncStatusText').className = 'text-red-400 text-sm';
consecutiveRpcFailures += 1;
const syncStatusText = document.getElementById('syncStatusText');
const syncIcon = document.getElementById('syncIcon');
if (syncStatusText) {
if (status.stale) {
syncStatusText.textContent = status.error || 'Bitcoin node is reconnecting... showing last known values';
syncStatusText.className = 'text-yellow-300 text-sm font-medium';
} else if (consecutiveRpcFailures < 6) {
syncStatusText.textContent = status.error || 'Connecting to Bitcoin node...';
syncStatusText.className = 'text-yellow-300 text-sm font-medium';
} else {
syncStatusText.textContent = status.error || 'Bitcoin node is not responding yet';
syncStatusText.className = 'text-red-400 text-sm font-medium';
}
}
if (syncIcon) {
syncIcon.classList.add('animate-spin-slow');
syncIcon.classList.remove('text-green-500');
}
return;
}
consecutiveRpcFailures = 0;
lastSuccessfulUpdateAt = Date.now();
const networkInfo = await callRPC('getnetworkinfo');
const networkInfo = status.network_info;
applyImplBranding(networkInfo && networkInfo.subversion);
@ -743,44 +792,51 @@
}
// Populate Settings — Transaction Index, ZMQ, RPC (fire-and-forget)
(async () => {
const txIndexEl = document.getElementById('settingsTxIndex');
if (txIndexEl) {
const idx = await callRPC('getindexinfo');
const idx = status.index_info;
if (idx && typeof idx === 'object') {
const names = Object.keys(idx);
txIndexEl.textContent = names.length
? `Enabled: ${names.join(', ')}`
: 'Disabled';
} else {
txIndexEl.textContent = 'Disabled';
txIndexEl.textContent = 'Unavailable while node starts';
}
}
const zmqEl = document.getElementById('settingsZmq');
if (zmqEl) {
const zmq = await callRPC('getzmqnotifications');
const zmq = status.zmq_notifications;
if (Array.isArray(zmq) && zmq.length) {
zmqEl.textContent = zmq.map(z => `${z.type}@${z.address}`).join('; ');
} else {
} else if (Array.isArray(zmq)) {
zmqEl.textContent = 'Not enabled';
} else {
zmqEl.textContent = 'Unavailable while node starts';
}
}
const rpcEl = document.getElementById('settingsRpc');
if (rpcEl && networkInfo) {
if (rpcEl) {
const port = chain === 'main' ? 8332 : (chain === 'test' ? 18332 : (chain === 'signet' ? 38332 : 18443));
rpcEl.textContent = `Reachable on port ${port}`;
rpcEl.textContent = status.stale
? `Reconnecting on port ${port}`
: `Reachable on port ${port}`;
}
})();
// Update sync status
const blocks = blockchainInfo.blocks || 0;
const headers = blockchainInfo.headers || 0;
const verificationProgress = blockchainInfo.verificationprogress || 0;
const isSynced = blocks >= headers - 1;
const initialBlockDownload = blockchainInfo.initialblockdownload === true;
const isSynced = headers > 0 && blocks >= headers - 1 && !initialBlockDownload;
const diskSize = formatBytes(blockchainInfo.size_on_disk || 0);
const appearsToBeReindexing = initialBlockDownload && blocks === 0 && headers > 0 && (blockchainInfo.size_on_disk || 0) > 1024 * 1024 * 1024;
// Calculate actual sync percentage based on blocks/headers
const actualSyncPercentage = headers > 0 ? ((blocks / headers) * 100).toFixed(2) : '0.00';
const verificationPercentage = (verificationProgress * 100).toFixed(2);
const actualSyncValue = headers > 0 ? (blocks / headers) * 100 : 0;
const actualSyncPercentage = formatPercent(actualSyncValue);
const progressWidth = Math.max(0, Math.min(100, actualSyncValue));
const verificationPercentage = formatPercent(verificationProgress * 100);
// Animate block count if it changed
const currentHeightElem = document.getElementById('currentHeight');
@ -795,16 +851,27 @@
document.getElementById('headers').textContent = headers.toLocaleString();
document.getElementById('verificationProgress').textContent = `${verificationPercentage}%`;
document.getElementById('syncPercentage').textContent = `${actualSyncPercentage}%`;
document.getElementById('currentBlock').textContent = `Block ${blocks.toLocaleString()}`;
document.getElementById('syncProgressBar').style.width = `${actualSyncPercentage}%`;
document.getElementById('currentBlock').textContent = appearsToBeReindexing
? 'Reindexing from disk'
: `Block ${blocks.toLocaleString()}`;
document.getElementById('syncProgressBar').style.width = `${progressWidth}%`;
// Update sync status text and icon
const syncStatusText = document.getElementById('syncStatusText');
const syncIcon = document.getElementById('syncIcon');
if (isSynced) {
syncStatusText.textContent = '✓ Fully synchronized with the network';
syncStatusText.className = 'text-green-400 text-sm font-medium';
if (appearsToBeReindexing) {
syncStatusText.textContent = `Reindexing local block files${diskSize ? ` (${diskSize} on disk)` : ''}`;
syncStatusText.className = 'text-orange-400 text-sm font-medium';
if (syncIcon) {
syncIcon.classList.add('animate-spin-slow');
syncIcon.classList.remove('text-green-500');
}
} else if (isSynced) {
syncStatusText.textContent = status.stale
? 'Bitcoin node is reconnecting... showing last known synchronized state'
: '✓ Fully synchronized with the network';
syncStatusText.className = status.stale ? 'text-yellow-300 text-sm font-medium' : 'text-green-400 text-sm font-medium';
// Stop spinning when synced
if (syncIcon) {
syncIcon.classList.remove('animate-spin-slow');
@ -812,8 +879,12 @@
}
} else {
const remaining = headers - blocks;
syncStatusText.textContent = `Syncing... ${remaining.toLocaleString()} blocks remaining`;
syncStatusText.className = 'text-orange-400 text-sm font-medium';
syncStatusText.textContent = status.stale
? 'Bitcoin node is reconnecting... showing last known sync state'
: initialBlockDownload
? `Initial block download... ${remaining.toLocaleString()} blocks remaining`
: `Syncing... ${remaining.toLocaleString()} blocks remaining`;
syncStatusText.className = status.stale ? 'text-yellow-300 text-sm font-medium' : 'text-orange-400 text-sm font-medium';
// Keep spinning while syncing
if (syncIcon) {
syncIcon.classList.add('animate-spin-slow');
@ -834,8 +905,15 @@
} catch (error) {
console.error('Failed to update blockchain info:', error);
document.getElementById('syncStatusText').textContent = 'Unable to fetch blockchain data';
document.getElementById('syncStatusText').className = 'text-red-400 text-sm';
consecutiveRpcFailures += 1;
const syncStatusText = document.getElementById('syncStatusText');
if (syncStatusText) {
const hasRecentData = lastSuccessfulUpdateAt > 0 && Date.now() - lastSuccessfulUpdateAt < 120000;
syncStatusText.textContent = hasRecentData
? 'Bitcoin status bridge is reconnecting... keeping last known values'
: 'Connecting to Bitcoin status bridge...';
syncStatusText.className = 'text-yellow-300 text-sm font-medium';
}
}
}

View File

@ -68,6 +68,7 @@
@media (min-width: 768px) {
.md-flex-row { flex-direction: row; }
.md-grid-cols-4 { grid-template-columns: repeat(4, 1fr); }
.md-grid-cols-5 { grid-template-columns: repeat(5, 1fr); }
}
/* Connection details */
@ -147,13 +148,17 @@
</div>
</div>
<div class="grid grid-cols-2 md-grid-cols-4 gap-3">
<div class="grid grid-cols-2 md-grid-cols-5 gap-3">
<div class="info-card">
<p class="text-xs text-white-60 mb-1">Indexed Height</p>
<p class="text-xs text-white-60 mb-1">Electrum Indexed</p>
<p class="text-lg font-semibold text-white" id="indexedHeight">-</p>
</div>
<div class="info-card">
<p class="text-xs text-white-60 mb-1">Network Height</p>
<p class="text-xs text-white-60 mb-1">Bitcoin Node</p>
<p class="text-lg font-semibold text-white" id="bitcoinHeight">-</p>
</div>
<div class="info-card">
<p class="text-xs text-white-60 mb-1">Known Headers</p>
<p class="text-lg font-semibold text-white" id="networkHeight">-</p>
</div>
<div class="info-card">
@ -370,15 +375,36 @@
}
var indexedH = data.indexed_height || 0;
var networkH = data.network_height || 0;
var bitcoinH = data.bitcoin_height || 0;
var reportedNetworkH = data.network_height || 0;
var knownHeaderH = Math.max(reportedNetworkH, indexedH, bitcoinH);
var targetH = bitcoinH > 0 ? bitcoinH : knownHeaderH;
var pct = data.progress_pct || 0;
var hasIndexedHeight = indexedH > 0 || data.stale;
var indexedLabel = hasIndexedHeight
? indexedH.toLocaleString()
: (data.status === 'indexing' ? 'Pending' : '-');
var currentBlockLabel;
if (hasIndexedHeight && bitcoinH > 0 && indexedH > bitcoinH) {
currentBlockLabel = 'Bitcoin node ' + bitcoinH.toLocaleString()
+ (knownHeaderH > 0 ? ' of known headers ' + knownHeaderH.toLocaleString() : '')
+ '; Electrum index ' + indexedH.toLocaleString();
} else if (hasIndexedHeight) {
currentBlockLabel = 'Indexed ' + indexedH.toLocaleString() + ' of '
+ (targetH > 0 ? targetH.toLocaleString() : 'Bitcoin node height');
} else {
currentBlockLabel = data.index_size
? 'Index building from disk (' + data.index_size + ')'
: 'Waiting for Electrum index height';
}
document.getElementById('indexedHeight').textContent = indexedH > 0 ? indexedH.toLocaleString() : (data.status === 'indexing' ? 'Building...' : '-');
document.getElementById('networkHeight').textContent = networkH > 0 ? networkH.toLocaleString() : '-';
document.getElementById('indexedHeight').textContent = indexedLabel;
document.getElementById('bitcoinHeight').textContent = bitcoinH > 0 ? bitcoinH.toLocaleString() : 'Checking...';
document.getElementById('networkHeight').textContent = knownHeaderH > 0 ? knownHeaderH.toLocaleString() : 'Checking...';
document.getElementById('indexSize').textContent = data.index_size || '-';
document.getElementById('progressPct').textContent = pct > 0 ? pct.toFixed(1) + '%' : '-';
document.getElementById('currentBlock').textContent = indexedH > 0 ? 'Block ' + indexedH.toLocaleString() : (data.index_size ? 'Index: ' + data.index_size : 'Block 0');
document.getElementById('syncPercentage').textContent = pct > 0 ? pct.toFixed(1) + '%' : '0%';
document.getElementById('progressPct').textContent = (knownHeaderH > 0 || pct > 0) ? pct.toFixed(1) + '%' : '-';
document.getElementById('currentBlock').textContent = currentBlockLabel;
document.getElementById('syncPercentage').textContent = (knownHeaderH > 0 || pct > 0) ? pct.toFixed(1) + '%' : '0%';
document.getElementById('syncProgressBar').style.width = Math.max(pct, 0.5) + '%';
var statusTextEl = document.getElementById('syncStatusText');
@ -389,14 +415,16 @@
statusTextEl.textContent = data.error || 'Starting up...';
statusTextEl.style.color = '#fbbf24';
statusDot.className = 'status-dot bg-yellow animate-pulse';
document.getElementById('statusText').textContent = 'Starting';
document.getElementById('statusText').textContent = data.status === 'waiting' ? 'Waiting' : 'Starting';
syncIcon.classList.add('animate-spin-slow');
document.getElementById('connSubtitle').textContent = 'Connections will be available once ElectrumX has completed syncing.';
} else if (data.status === 'indexing') {
statusTextEl.textContent = data.error || 'Building index...';
statusTextEl.textContent = data.stale
? (data.error || 'ElectrumX is reconnecting; showing last known indexed height.')
: (data.error || 'Building index. Indexed height will appear when Electrum RPC is ready.');
statusTextEl.style.color = '#fbbf24';
statusDot.className = 'status-dot bg-amber animate-pulse';
document.getElementById('statusText').textContent = 'Indexing';
document.getElementById('statusText').textContent = data.stale ? 'Reconnecting' : 'Indexing';
syncIcon.classList.add('animate-spin-slow');
document.getElementById('connSubtitle').textContent = 'Connections will be available once ElectrumX has completed syncing.';
} else if (data.status === 'error') {
@ -414,8 +442,10 @@
syncIcon.style.color = '#4ade80';
document.getElementById('connSubtitle').textContent = 'Use the following details to connect your wallet or application to ElectrumX.';
} else {
var remaining = networkH - indexedH;
statusTextEl.textContent = 'Syncing... ' + remaining.toLocaleString() + ' blocks remaining';
var remaining = Math.max(targetH - indexedH, 0);
statusTextEl.textContent = data.error || (targetH > 0
? 'Syncing... ' + remaining.toLocaleString() + ' blocks remaining'
: 'Waiting for Bitcoin network height...');
statusTextEl.style.color = '#fb923c';
statusDot.className = 'status-dot bg-yellow';
document.getElementById('statusText').textContent = 'Syncing';

View File

@ -7,6 +7,6 @@ Type=oneshot
# Runs as root: needs to kill orphaned conmon processes, fix permissions
User=root
ExecStart=/home/archipelago/archy/scripts/container-doctor.sh --local
TimeoutStartSec=120
TimeoutStartSec=300
StandardOutput=journal
StandardError=journal

View File

@ -2,9 +2,11 @@
Description=Archipelago container doctor (periodic)
[Timer]
# First run 5 minutes after boot, then every 30 minutes
OnBootSec=5min
OnUnitActiveSec=30min
# First run 2 minutes after boot, then every 5 minutes. The doctor is
# idempotent and exits quickly when no drift exists; this keeps vanished
# rootless port listeners and stopped containers from remaining broken.
OnBootSec=2min
OnUnitActiveSec=5min
# Jitter to avoid load spikes
RandomizedDelaySec=60

View File

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

View File

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

View File

@ -26,7 +26,7 @@
"id": "bitcoin-core",
"title": "Bitcoin Core",
"version": "28.4",
"description": "Reference implementation of the Bitcoin protocol. Run a full node validating and relaying blocks.",
"description": "Reference Bitcoin node implementation. Alternative to Bitcoin Knots; uninstall Knots before switching.",
"icon": "/assets/img/app-icons/bitcoin-core.svg",
"author": "Bitcoin Core contributors",
"category": "money",

View File

@ -13,7 +13,7 @@ const NEW_TAB_PORTS = new Set([
'8085', // Nextcloud — X-Frame-Options: SAMEORIGIN
'3002', // Uptime Kuma — X-Frame-Options: SAMEORIGIN
'9001', // Penpot — not reachable
// IndeedHub (7777) uses proxy path for NIP-07 nostr-provider.js — NOT new tab
// Port 7777 is the Nostr relay; IndeeHub's web UI is exposed on 7778.
])
const NEW_TAB_APP_IDS = new Set([
@ -34,6 +34,7 @@ function mustOpenInNewTab(url: string): boolean {
function inferAppIdFromTitle(title?: string): string | null {
const t = (title || '').toLowerCase()
if (!t) return null
if (t.includes('indeehub') || t.includes('indeedhub')) return 'indeedhub'
if ((t.includes('uptime') && t.includes('kuma')) || t.includes('uptime-kuma')) return 'uptime-kuma'
if ((t.includes('nginx') && t.includes('proxy') && t.includes('manager')) || t.includes('nginx-proxy-manager')) return 'nginx-proxy-manager'
if (t.includes('gitea')) return 'gitea'
@ -47,6 +48,10 @@ function normalizeLaunchUrl(urlStr: string, appIdHint?: string | null): string {
const normalizedPath = u.pathname === '/' ? '' : u.pathname
const rebuilt = (port: string) => `${u.protocol}//${u.hostname}:${port}${normalizedPath}${u.search}${u.hash}`
if (sameHost && appIdHint === 'indeedhub' && u.port === '7777') {
return rebuilt('7778')
}
if (sameHost && appIdHint === 'uptime-kuma' && u.port === '3001') {
return rebuilt('3002')
}
@ -87,7 +92,7 @@ const PORT_TO_APP_ID: Record<string, string> = {
'8175': 'fedimint',
'8176': 'fedimint-gateway',
'3100': 'dwn',
'7777': 'indeedhub',
'7778': 'indeedhub',
'50002': 'electrumx',
'3010': 'thunderhub',
}

View File

@ -504,7 +504,7 @@ export const dummyApps: Record<string, PackageDataEntry> = {
'interface-addresses': {
main: {
'tor-address': '',
'lan-address': 'http://localhost:8190'
'lan-address': 'http://localhost:7778'
}
},
status: ServiceStatus.Running
@ -749,4 +749,3 @@ export const dummyApps: Record<string, PackageDataEntry> = {
}
}
}

View File

@ -107,7 +107,7 @@ const launchableApps = computed<KioskApp[]>(() => {
'fedimint': '/app/fedimint/',
'fedimint-gateway': '/app/fedimint-gateway/',
'dwn': '/app/dwn/',
'indeedhub': 'http://localhost:8190',
'indeedhub': 'http://localhost:7778',
'botfights': 'http://localhost:9100',
'nwnn': 'https://nwnn.l484.com',
'484-kitchen': 'https://484.kitchen',

View File

@ -38,15 +38,14 @@ export const APP_PORTS: Record<string, number> = {
'fedimint': 8175,
'fedimintd': 8175,
'fedimint-gateway': 8176,
'indeedhub': 7777,
'indeedhub': 7778,
'botfights': 9100,
'dwn': 3100,
'endurain': 8080,
}
/** Apps that need nginx proxy for iframe embedding.
* IndeedHub loads via /app/indeedhub/ proxy for nostr-provider.js injection
* from the container's internal nginx so iframe works on all servers. */
* IndeeHub web UI is on 7778. Port 7777 is the Nostr relay. */
export const PROXY_APPS: Record<string, string> = {
'gitea': '/app/gitea/',
'nginx-proxy-manager': '/app/nginx-proxy-manager/',
@ -60,9 +59,10 @@ export const HTTPS_PROXY_PATHS: Record<string, string> = {
'bitcoin-core': '/app/bitcoin-ui/',
'bitcoin-ui': '/app/bitcoin-ui/',
'lnd': '/app/lnd/',
'electrumx': '/app/electrs/',
'electrs': '/app/electrs/',
'mempool-electrs': '/app/electrs/',
'electrumx': '/app/electrumx/',
'electrs': '/app/electrumx/',
'archy-electrs-ui': '/app/electrumx/',
'mempool-electrs': '/app/electrumx/',
'mempool': '/app/mempool/',
'mempool-web': '/app/mempool/',
'archy-mempool-web': '/app/mempool/',
@ -87,7 +87,6 @@ export const HTTPS_PROXY_PATHS: Record<string, string> = {
'btcpay-server': '/app/btcpay/',
'nextcloud': '/app/nextcloud/',
'grafana': '/app/grafana/',
'indeedhub': '/app/indeedhub/',
'botfights': '/app/botfights/',
'gitea': '/app/gitea/',
}

161
scripts/app-surface-smoke-test.sh Executable file
View File

@ -0,0 +1,161 @@
#!/bin/bash
#
# App surface smoke test.
#
# Verifies that installed containers have their published host ports listening
# and that known nginx app proxy paths return a non-5xx response. This catches
# the common "container is running but UI disappeared" failure mode.
#
# Usage:
# scripts/app-surface-smoke-test.sh --target archipelago@192.168.1.228 --ssh-key /path/key
set -euo pipefail
TARGET=""
SSH_KEY="${ARCHIPELAGO_SSH_KEY:-}"
SSH_EXTRA=()
while [ "$#" -gt 0 ]; do
case "$1" in
--target) TARGET="${2:-}"; shift 2 ;;
--ssh-key) SSH_KEY="${2:-}"; shift 2 ;;
--ssh-option) SSH_EXTRA+=("-o" "${2:-}"); shift 2 ;;
-h|--help) sed -n '1,12p' "$0"; exit 0 ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
[ -n "$TARGET" ] || { echo "--target is required" >&2; exit 2; }
SSH_OPTS=(-F /dev/null -o BatchMode=yes -o PreferredAuthentications=publickey -o PasswordAuthentication=no)
[ -n "$SSH_KEY" ] && SSH_OPTS+=(-i "$SSH_KEY")
SSH_OPTS+=("${SSH_EXTRA[@]}")
ssh_run() {
ssh "${SSH_OPTS[@]}" "$TARGET" "$@"
}
ssh_run 'bash -s' <<'REMOTE'
set -u
pass=0
fail=0
ok() { echo " PASS $*"; pass=$((pass + 1)); }
bad() { echo " FAIL $*"; fail=$((fail + 1)); }
container_exists() {
podman ps -a --format '{{.Names}}' 2>/dev/null | grep -qx "$1"
}
port_listening() {
ss -ltn 2>/dev/null | awk '{print $4}' | grep -Eq "(^|:)$1$"
}
http_code() {
local url="$1" code
for _ in 1 2 3; do
code=$(curl -ksS -o /dev/null -w '%{http_code}' --max-time 12 "$url" 2>/dev/null || true)
[ -n "$code" ] || code=000
[ "$code" != "000" ] && { echo "$code"; return; }
sleep 2
done
echo "$code"
}
http_post_code() {
local url="$1" code
for _ in 1 2 3; do
code=$(curl -ksS -o /dev/null -w '%{http_code}' --max-time 25 \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"getblockchaininfo","params":[]}' \
"$url" 2>/dev/null || true)
[ -n "$code" ] || code=000
[ "$code" != "000" ] && { echo "$code"; return; }
sleep 2
done
echo "$code"
}
assert_http() {
local label="$1" url="$2" code
code=$(http_code "$url")
case "$code" in
200|204|301|302|307|308|401|403) ok "$label HTTP $code" ;;
*) bad "$label HTTP $code ($url)" ;;
esac
}
assert_http_post() {
local label="$1" url="$2" code
code=$(http_post_code "$url")
case "$code" in
200|204|401|403) ok "$label HTTP POST $code" ;;
*) bad "$label HTTP POST $code ($url)" ;;
esac
}
assert_container_ports() {
local name="$1" ports port missing=0
container_exists "$name" || return 0
ports=$(podman inspect "$name" --format '{{range $p,$bindings := .NetworkSettings.Ports}}{{if $bindings}}{{range $bindings}}{{.HostPort}}{{"\n"}}{{end}}{{end}}{{end}}' 2>/dev/null | sort -u)
[ -n "$ports" ] || return 0
while IFS= read -r port; do
[ -n "$port" ] || continue
if port_listening "$port"; then
ok "$name port $port listening"
else
bad "$name port $port missing listener"
missing=1
fi
done <<< "$ports"
return "$missing"
}
assert_env_contains() {
local name="$1" key="$2" needle="$3" val
container_exists "$name" || return 0
val=$(podman inspect "$name" --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null | sed -n "s/^${key}=//p" | head -n 1)
if [ -n "$val" ] && printf '%s' "$val" | grep -qF "$needle"; then
ok "$name env $key"
else
bad "$name env $key missing $needle"
fi
}
echo "[surface] host=$(hostname) ip=$(hostname -I 2>/dev/null | awk '{print $1}')"
for c in $(podman ps -a --format '{{.Names}}' 2>/dev/null | sort); do
assert_container_ports "$c" || true
done
container_exists archy-bitcoin-ui && {
assert_http "bitcoin-ui" "http://127.0.0.1/app/bitcoin-ui/"
assert_http "bitcoin status" "http://127.0.0.1/app/bitcoin-ui/bitcoin-status"
assert_http_post "bitcoin rpc proxy" "http://127.0.0.1/app/bitcoin-ui/bitcoin-rpc/"
}
container_exists archy-electrs-ui && {
assert_http "electrumx ui" "http://127.0.0.1/app/electrumx/"
assert_http "electrumx status" "http://127.0.0.1/app/electrumx/electrs-status"
assert_http "electrs legacy status" "http://127.0.0.1/app/electrs/electrs-status"
}
container_exists mempool && assert_http "mempool ui" "http://127.0.0.1/app/mempool/"
container_exists indeedhub && assert_http "indeedhub ui" "http://127.0.0.1:7778/"
container_exists uptime-kuma && assert_http "uptime-kuma" "http://127.0.0.1/app/uptime-kuma/"
container_exists filebrowser && assert_http "filebrowser" "http://127.0.0.1/app/filebrowser/"
container_exists searxng && assert_http "searxng" "http://127.0.0.1/app/searxng/"
container_exists grafana && assert_http "grafana" "http://127.0.0.1/app/grafana/"
container_exists portainer && assert_http "portainer" "http://127.0.0.1/app/portainer/"
container_exists vaultwarden && assert_http "vaultwarden" "http://127.0.0.1/app/vaultwarden/"
container_exists nextcloud && assert_http "nextcloud" "http://127.0.0.1/app/nextcloud/"
container_exists archy-nbxplorer && assert_env_contains "archy-nbxplorer" "NBXPLORER_POSTGRES" "Database=nbxplorer"
container_exists btcpay-server && {
assert_env_contains "btcpay-server" "BTCPAY_POSTGRES" "Database=btcpay"
assert_http "btcpay" "http://127.0.0.1/app/btcpay/"
}
echo "[surface] summary: pass=$pass fail=$fail"
[ "$fail" -eq 0 ]
REMOTE

View File

@ -0,0 +1,249 @@
#!/bin/bash
#
# Bitcoin stack lifecycle test.
#
# Exercises the production Bitcoin stack under repeated stop/start and
# remove/recreate cycles while asserting the actual user-facing surfaces:
# Bitcoin RPC, bitcoin-ui /bitcoin-rpc, ElectrumX status, and electrs-ui.
#
# This intentionally removes containers but not data volumes. It is safe for
# installed nodes, but it will briefly interrupt Bitcoin/ElectrumX service.
#
# Usage:
# scripts/bitcoin-stack-lifecycle-test.sh --target archipelago@192.168.1.228
# scripts/bitcoin-stack-lifecycle-test.sh --target archipelago@192.168.1.116 --cycles 5
set -euo pipefail
TARGET=""
SSH_KEY="${ARCHIPELAGO_SSH_KEY:-}"
CYCLES=3
SSH_EXTRA=()
while [ "$#" -gt 0 ]; do
case "$1" in
--target)
TARGET="${2:-}"
shift 2
;;
--ssh-key)
SSH_KEY="${2:-}"
shift 2
;;
--cycles)
CYCLES="${2:-}"
shift 2
;;
--ssh-option)
SSH_EXTRA+=("-o" "${2:-}")
shift 2
;;
-h|--help)
sed -n '1,22p' "$0"
exit 0
;;
*)
echo "unknown argument: $1" >&2
exit 2
;;
esac
done
if [ -z "$TARGET" ]; then
echo "--target is required, for example archipelago@192.168.1.228" >&2
exit 2
fi
SSH=(ssh -F /dev/null -o BatchMode=yes -o PreferredAuthentications=publickey -o PasswordAuthentication=no -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null)
if [ -n "$SSH_KEY" ]; then
SSH+=("-i" "$SSH_KEY")
fi
SSH+=("${SSH_EXTRA[@]}")
"${SSH[@]}" "$TARGET" "CYCLES='$CYCLES' bash -s" <<'REMOTE'
set -euo pipefail
PODMAN="${PODMAN:-podman}"
SCRIPTS_DIR="/opt/archipelago/scripts"
if [ ! -x "$SCRIPTS_DIR/reconcile-containers.sh" ]; then
SCRIPTS_DIR="$HOME/archy/scripts"
fi
RECONCILE="$SCRIPTS_DIR/reconcile-containers.sh"
pass_count=0
fail_count=0
log() { printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*"; }
pass() { pass_count=$((pass_count + 1)); printf ' PASS %s\n' "$*"; }
fail() { fail_count=$((fail_count + 1)); printf ' FAIL %s\n' "$*" >&2; }
retry() {
local timeout="$1" label="$2"
shift 2
local end=$((SECONDS + timeout))
local out rc
while [ "$SECONDS" -lt "$end" ]; do
set +e
out=$("$@" 2>&1)
rc=$?
set -e
if [ "$rc" -eq 0 ]; then
pass "$label"
return 0
fi
sleep 2
done
fail "$label: $out"
return 1
}
rpc_pass() {
cat /var/lib/archipelago/secrets/bitcoin-rpc-password
}
json_rpc_reachable_or_warming() {
local url="$1" auth_arg=() body rc
if [ "${2:-}" = "auth" ]; then
auth_arg=(--user "archipelago:$(rpc_pass)")
fi
set +e
body=$(curl --connect-timeout 3 --max-time 20 -sS "${auth_arg[@]}" \
-H "Content-Type: application/json" \
--data-binary '{"jsonrpc":"1.0","id":"lifecycle-test","method":"getblockchaininfo","params":[]}' \
"$url" 2>&1)
rc=$?
set -e
[ "$rc" -eq 0 ] || {
echo "$body"
return 1
}
echo "$body" | grep -q '"result"' && return 0
echo "$body" | grep -q '"code":-28' && return 0
echo "$body"
return 1
}
bitcoin_status_usable() {
local url="$1"
local body
body=$(curl --connect-timeout 3 --max-time 20 -fsS "$url")
echo "$body" | grep -q '"ok":\(true\|false\)' || {
echo "$body"
return 1
}
echo "$body" | grep -q '"blockchain_info"' || echo "$body" | grep -q '"error"'
}
http_ok() {
local url="$1"
curl --connect-timeout 3 --max-time 20 -fsS -o /dev/null "$url"
}
electrs_status_ok() {
local url="${1:-http://127.0.0.1:50002/electrs-status}"
local body
body=$(curl --connect-timeout 3 --max-time 20 -fsS "$url")
echo "$body" | grep -q '"network_height":[1-9]' || {
echo "$body"
return 1
}
echo "$body" | grep -q '"status":"\(indexing\|syncing\|synced\|waiting\)"'
}
container_running() {
local name="$1"
[ "$($PODMAN inspect "$name" --format '{{.State.Status}}' 2>/dev/null || true)" = "running" ]
}
container_healthy_or_starting() {
local name="$1"
local health
health=$($PODMAN inspect "$name" --format '{{if .State.Health}}{{.State.Health.Status}}{{end}}' 2>/dev/null || true)
[ "$health" = "healthy" ] || [ "$health" = "starting" ] || [ -z "$health" ]
}
assert_bitcoin_stack() {
retry 90 "bitcoin-knots running" container_running bitcoin-knots
retry 90 "bitcoin-knots healthy/starting" container_healthy_or_starting bitcoin-knots
retry 90 "host Bitcoin RPC reachable/ready" json_rpc_reachable_or_warming http://127.0.0.1:8332/ auth
retry 90 "backend Bitcoin status bridge usable" bitcoin_status_usable http://127.0.0.1:5678/bitcoin-status
retry 90 "bitcoin-ui page" http_ok http://127.0.0.1:8334/
retry 90 "bitcoin-ui status bridge usable" bitcoin_status_usable http://127.0.0.1:8334/bitcoin-status
retry 90 "bitcoin-ui app-session status bridge usable" bitcoin_status_usable http://127.0.0.1/app/bitcoin-ui/bitcoin-status
retry 90 "bitcoin-ui RPC proxy reachable/ready" json_rpc_reachable_or_warming http://127.0.0.1:8334/bitcoin-rpc/
retry 90 "bitcoin-ui app-session RPC proxy reachable/ready" json_rpc_reachable_or_warming http://127.0.0.1/app/bitcoin-ui/bitcoin-rpc/
}
assert_electrum_stack() {
retry 120 "electrumx running" container_running electrumx
retry 120 "electrumx healthy/starting" container_healthy_or_starting electrumx
retry 90 "electrs-ui page" http_ok http://127.0.0.1:50002/
retry 120 "electrs status has network height" electrs_status_ok
retry 120 "electrs app-session status has network height" electrs_status_ok http://127.0.0.1/app/electrumx/electrs-status
retry 120 "electrs legacy app-session status has network height" electrs_status_ok http://127.0.0.1/app/electrs/electrs-status
}
reconcile_one() {
local name="$1"
"$RECONCILE" --container="$name" --force --force-recreate --create-missing
}
restart_container() {
local name="$1"
log "restart $name"
$PODMAN restart "$name" >/dev/null || {
log "podman restart failed for $name; using stop/start"
$PODMAN stop "$name" >/dev/null 2>&1 || true
sleep 3
$PODMAN start "$name" >/dev/null
}
}
remove_and_reconcile() {
local name="$1"
log "remove/recreate $name"
$PODMAN rm -f "$name" >/dev/null 2>&1 || true
reconcile_one "$name"
}
log "target $(hostname) cycles=$CYCLES"
log "using reconciler: $RECONCILE"
assert_bitcoin_stack
assert_electrum_stack
for i in $(seq 1 "$CYCLES"); do
log "cycle $i/$CYCLES: bitcoin restart"
restart_container bitcoin-knots
assert_bitcoin_stack
assert_electrum_stack
log "cycle $i/$CYCLES: bitcoin remove/reconcile"
remove_and_reconcile bitcoin-knots
assert_bitcoin_stack
assert_electrum_stack
log "cycle $i/$CYCLES: bitcoin UI remove/reconcile"
remove_and_reconcile archy-bitcoin-ui
assert_bitcoin_stack
log "cycle $i/$CYCLES: electrumx restart"
restart_container electrumx
assert_electrum_stack
log "cycle $i/$CYCLES: electrumx remove/reconcile"
remove_and_reconcile electrumx
assert_electrum_stack
log "cycle $i/$CYCLES: electrs UI remove/reconcile"
remove_and_reconcile archy-electrs-ui
assert_electrum_stack
done
log "final container state"
$PODMAN ps -a --format 'table {{.Names}}\t{{.State}}\t{{.Status}}' \
| grep -E 'bitcoin-knots|electrumx|archy-bitcoin-ui|archy-electrs-ui' || true
log "summary: pass=$pass_count fail=$fail_count"
[ "$fail_count" -eq 0 ]
REMOTE

View File

@ -15,6 +15,7 @@
# 6. Bitcoin Knots prune+txindex conflict
# 7. Containers stuck with exit code 127 (binary not found)
# 8. Stopped core containers (rootless restart policy workaround)
# 9. Missing rootless port listeners while Podman still shows published ports
#
# Safe to run multiple times (idempotent). Never blocks deploy (exit 0 always).
#
@ -31,6 +32,21 @@ FIX_NAMES=()
log() { echo "[$(date +%H:%M:%S)] DOCTOR: $*"; }
podman_rootless() {
if [ "$(id -u)" = "0" ] && id archipelago >/dev/null 2>&1; then
local archi_uid
archi_uid=$(id -u archipelago)
sudo -u archipelago env XDG_RUNTIME_DIR="/run/user/$archi_uid" podman "$@"
else
podman "$@"
fi
}
port_is_listening() {
local port="$1"
ss -ltn 2>/dev/null | awk '{print $4}' | grep -Eq "(^|:)$port$"
}
run_fix() {
local name="$1"
shift
@ -374,6 +390,11 @@ print(' '.join(['\"' + a + '\"' if ' ' in a else a for a in args[2:]]))
# at 0 peers; package pulls fail. The only reliable repair is a stop-all/
# start-all cycle so pasta + aardvark-dns rebuild the netns from scratch.
fix_rootless_netns_egress() {
# Needs root for nsenter. When doctor runs as the rootless container owner,
# a failed nsenter probe is a permissions artifact, not evidence of broken
# egress; do not cycle the fleet from that context.
[ "$(id -u)" = "0" ] || return 1
local archi_uid
archi_uid=$(id -u archipelago 2>/dev/null) || return 1
@ -453,6 +474,44 @@ fix_stopped_core_containers() {
[ ${#restarted[@]} -gt 0 ] && return 0 || return 1
}
# ── Fix 10: Missing rootless port listeners ─────────────────
# Rootless Podman can leave a container running with PortBindings still present
# while the host-side rootlessport process has disappeared. Nginx then returns
# 502 and direct app ports refuse connections even though `podman ps` looks OK.
fix_missing_rootless_ports() {
local containers
containers=$(podman_rootless ps --format '{{.Names}}' 2>/dev/null || true)
[ -n "$containers" ] || return 1
local fixed=false
local name
for name in $containers; do
local ports
ports=$(podman_rootless inspect "$name" --format '{{range $p,$bindings := .NetworkSettings.Ports}}{{if $bindings}}{{range $bindings}}{{.HostPort}}{{"\n"}}{{end}}{{end}}{{end}}' 2>/dev/null | sort -u)
[ -n "$ports" ] || continue
local missing=()
local port
for port in $ports; do
[ -n "$port" ] || continue
if ! port_is_listening "$port"; then
missing+=("$port")
fi
done
if [ ${#missing[@]} -gt 0 ]; then
log "Restarting $name: missing rootlessport listener(s): ${missing[*]}"
if podman_rootless restart "$name" >/dev/null 2>&1; then
fixed=true
else
log "WARN: failed to restart $name for missing rootlessport listener(s)"
fi
fi
done
$fixed && return 0 || return 1
}
# ── Main ─────────────────────────────────────────────────────
# If remote host provided, run via SSH
@ -481,6 +540,7 @@ run_fix "bitcoin-txindex" fix_bitcoin_txindex
run_fix "exit-127" fix_exit_127
run_fix "netns-egress" fix_rootless_netns_egress
run_fix "stopped-core" fix_stopped_core_containers
run_fix "rootless-ports" fix_missing_rootless_ports
echo ""
if [ $FIXES_APPLIED -gt 0 ]; then

View File

@ -252,7 +252,7 @@ load_spec_archy-nbxplorer() {
SPEC_VOLUMES="/var/lib/archipelago/nbxplorer:/data"
SPEC_MEMORY="$(mem_limit archy-nbxplorer)"
SPEC_HEALTH_CMD="curl -sf http://localhost:32838/ || exit 1"
SPEC_ENV="NBXPLORER_DATADIR=/data NBXPLORER_NETWORK=mainnet NBXPLORER_CHAINS=btc NBXPLORER_BIND=0.0.0.0:32838 NBXPLORER_BTCRPCURL=http://bitcoin-knots:8332 NBXPLORER_BTCRPCUSER=$BITCOIN_RPC_USER NBXPLORER_BTCRPCPASSWORD=$BITCOIN_RPC_PASS NBXPLORER_POSTGRES=User ID=btcpay;Password=$BTCPAY_DB_PASS;Host=archy-btcpay-db;Port=5432;Database=nbxplorer;Include Error Detail=true"
SPEC_ENV="NBXPLORER_DATADIR=/data NBXPLORER_NETWORK=mainnet NBXPLORER_CHAINS=btc NBXPLORER_BIND=0.0.0.0:32838 NBXPLORER_BTCRPCURL=http://bitcoin-knots:8332 NBXPLORER_BTCRPCUSER=$BITCOIN_RPC_USER NBXPLORER_BTCRPCPASSWORD=$BITCOIN_RPC_PASS NBXPLORER_POSTGRES=Username=btcpay;Password=$BTCPAY_DB_PASS;Host=archy-btcpay-db;Port=5432;Database=nbxplorer"
SPEC_TIER="2"
SPEC_DATA_DIR="/var/lib/archipelago/nbxplorer"
SPEC_DEPENDS="bitcoin-knots archy-btcpay-db"
@ -268,7 +268,7 @@ load_spec_btcpay-server() {
SPEC_VOLUMES="/var/lib/archipelago/btcpay:/datadir"
SPEC_MEMORY="$(mem_limit btcpay-server)"
SPEC_HEALTH_CMD="curl -sf http://localhost:49392/ || exit 1"
SPEC_ENV="ASPNETCORE_URLS=http://0.0.0.0:49392 BTCPAY_PROTOCOL=http BTCPAY_HOST=$HOST_IP:23000 BTCPAY_CHAINS=btc BTCPAY_BTCEXPLORERURL=http://archy-nbxplorer:32838 BTCPAY_BTCRPCURL=http://bitcoin-knots:8332 BTCPAY_BTCRPCUSER=$BITCOIN_RPC_USER BTCPAY_BTCRPCPASSWORD=$BITCOIN_RPC_PASS BTCPAY_POSTGRES=User ID=btcpay;Password=$BTCPAY_DB_PASS;Host=archy-btcpay-db;Port=5432;Database=btcpay;Include Error Detail=true"
SPEC_ENV="ASPNETCORE_URLS=http://0.0.0.0:49392 BTCPAY_PROTOCOL=http BTCPAY_HOST=$HOST_IP:23000 BTCPAY_CHAINS=btc BTCPAY_BTCEXPLORERURL=http://archy-nbxplorer:32838 BTCPAY_BTCRPCURL=http://bitcoin-knots:8332 BTCPAY_BTCRPCUSER=$BITCOIN_RPC_USER BTCPAY_BTCRPCPASSWORD=$BITCOIN_RPC_PASS BTCPAY_POSTGRES=Username=btcpay;Password=$BTCPAY_DB_PASS;Host=archy-btcpay-db;Port=5432;Database=btcpay"
SPEC_TIER="2"
SPEC_DATA_DIR="/var/lib/archipelago/btcpay"
SPEC_DEPENDS="archy-nbxplorer archy-btcpay-db"
@ -344,7 +344,7 @@ load_spec_homeassistant() {
SPEC_ENV="TZ=UTC"
SPEC_TIER="3"
SPEC_DATA_DIR="/var/lib/archipelago/home-assistant"
SPEC_CAPS="CHOWN SETUID SETGID DAC_OVERRIDE"
SPEC_CAPS="CHOWN SETUID SETGID DAC_OVERRIDE NET_BIND_SERVICE"
SPEC_OPTIONAL="true"
}
@ -362,7 +362,7 @@ load_spec_grafana() {
SPEC_TIER="3"
SPEC_DATA_DIR="/var/lib/archipelago/grafana"
SPEC_DATA_UID="100472:100472"
SPEC_CAPS="CHOWN SETUID SETGID DAC_OVERRIDE"
SPEC_CAPS="CHOWN SETUID SETGID DAC_OVERRIDE NET_BIND_SERVICE"
SPEC_OPTIONAL="true"
}
@ -370,7 +370,7 @@ load_spec_uptime-kuma() {
reset_spec
SPEC_NAME="uptime-kuma"
SPEC_IMAGE="${UPTIME_KUMA_IMAGE}"
SPEC_PORTS="3001:3001"
SPEC_PORTS="3002:3001"
SPEC_VOLUMES="/var/lib/archipelago/uptime-kuma:/app/data"
SPEC_MEMORY="$(mem_limit uptime-kuma)"
SPEC_HEALTH_CMD="curl -sf http://localhost:3001/ || exit 1"
@ -434,7 +434,7 @@ load_spec_nextcloud() {
SPEC_HEALTH_CMD="curl -sf http://localhost:80/ || exit 1"
SPEC_TIER="3"
SPEC_DATA_DIR="/var/lib/archipelago/nextcloud"
SPEC_CAPS="CHOWN SETUID SETGID DAC_OVERRIDE"
SPEC_CAPS="CHOWN SETUID SETGID DAC_OVERRIDE NET_BIND_SERVICE"
SPEC_OPTIONAL="true"
}
@ -539,6 +539,7 @@ load_spec_archy-bitcoin-ui() {
SPEC_NAME="archy-bitcoin-ui"
SPEC_IMAGE="localhost/bitcoin-ui:local"
SPEC_NETWORK="host"
SPEC_VOLUMES="/var/lib/archipelago/bitcoin-ui/nginx.conf:/etc/nginx/conf.d/default.conf:ro"
SPEC_MEMORY="$(mem_limit archy-bitcoin-ui)"
SPEC_TIER="4"
SPEC_LOCAL_IMAGE="true"

View File

@ -183,6 +183,26 @@ location /app/electrs/ {
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
}
location /app/electrumx/ {
proxy_pass http://127.0.0.1:50002/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
}
location /app/electrs-ui/ {
proxy_pass http://127.0.0.1:50002/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
}
location /app/nginx-proxy-manager/ {
proxy_pass http://127.0.0.1:81/;
proxy_http_version 1.1;

View File

@ -8,6 +8,7 @@
# sudo ./reconcile-containers.sh # Fix everything
# sudo ./reconcile-containers.sh --check-only # Audit only, no changes
# sudo ./reconcile-containers.sh --force # Override user-stopped
# sudo ./reconcile-containers.sh --force-recreate # Recreate matched containers
# sudo ./reconcile-containers.sh --tier=2 # Only reconcile tier 2
# sudo ./reconcile-containers.sh --container=lnd # Only reconcile lnd
#
@ -18,6 +19,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# ── Parse arguments ──────────────────────────────────────────────────
CHECK_ONLY=false
FORCE=false
FORCE_RECREATE=false
CREATE_MISSING=false
FILTER_TIER=""
FILTER_CONTAINER=""
@ -25,14 +27,18 @@ for arg in "$@"; do
case "$arg" in
--check-only) CHECK_ONLY=true ;;
--force) FORCE=true ;;
--force-recreate) FORCE_RECREATE=true ;;
--create-missing) CREATE_MISSING=true ;;
--tier=*) FILTER_TIER="${arg#*=}" ;;
--container=*) FILTER_CONTAINER="${arg#*=}" ;;
-h|--help)
echo "Usage: $0 [--check-only] [--force] [--create-missing] [--tier=N] [--container=NAME]"
echo "Usage: $0 [--check-only] [--force] [--force-recreate] [--create-missing] [--tier=N] [--container=NAME]"
echo ""
echo " --check-only Audit only, no changes."
echo " --force Override user-stopped state."
echo " --force-recreate Recreate matched existing containers even if they"
echo " otherwise match the spec. Use with --container or"
echo " --tier for scoped image/config refreshes."
echo " --create-missing Override SPEC_OPTIONAL for containers that have on-disk"
echo " data but no live container (recovery from failed updates)."
echo " --tier=N Only reconcile containers in tier N."
@ -110,6 +116,14 @@ container_image() {
$PODMAN inspect "$1" --format '{{.ImageName}}' 2>/dev/null
}
container_image_id() {
$PODMAN inspect "$1" --format '{{.Image}}' 2>/dev/null
}
spec_image_id() {
$PODMAN image inspect "$SPEC_IMAGE" --format '{{.Id}}' 2>/dev/null
}
container_network() {
# Use actual Networks map — NetworkMode is unreliable (always shows 'bridge' in rootless)
local nets
@ -122,6 +136,34 @@ container_memory() {
$PODMAN inspect "$1" --format '{{.HostConfig.Memory}}' 2>/dev/null
}
container_health_cmd() {
$PODMAN inspect "$1" --format '{{with .Config.Healthcheck}}{{range .Test}}{{println .}}{{end}}{{end}}' 2>/dev/null \
| awk 'NR > 1 { print }' \
| paste -sd ' ' -
}
normalize_health_cmd() {
printf '%s' "$1" | sed 's/\\"/"/g; s/[[:space:]][[:space:]]*/ /g; s/^ //; s/ $//'
}
host_port_listening() {
local port="$1"
ss -ltn 2>/dev/null | awk -v p=":$port" '
$4 == p || $4 ~ p "$" { found=1 }
END { exit found ? 0 : 1 }
'
}
container_has_mount() {
local name="$1" source="$2" target="$3"
$PODMAN inspect "$name" --format '{{range .Mounts}}{{println .Source "|" .Destination}}{{end}}' 2>/dev/null \
| awk -F'|' -v src="$source" -v dst="$target" '
{ gsub(/[[:space:]]+$/, "", $1); gsub(/^[[:space:]]+/, "", $2); }
$1 == src && $2 == dst { found=1 }
END { exit found ? 0 : 1 }
'
}
# Read one environment variable's current value from a running/stopped container.
# Returns empty string if the var is not set.
container_env_val() {
@ -153,6 +195,36 @@ image_exists() {
echo "$images" | grep -qF "$1"
}
resolve_spec_image() {
image_exists "$SPEC_IMAGE" && return
local image_path image_name image_tag candidate repo
image_path="${SPEC_IMAGE#*/}"
image_name="${SPEC_IMAGE##*/}"
image_tag="${image_name#*:}"
image_name="${image_name%%:*}"
for candidate in \
"${ARCHY_REGISTRY_FALLBACK:-}/${image_path}" \
"80.71.235.15:3000/archipelago/${image_name}:${image_tag}" \
"80.71.235.15:3000/lfg2025/${image_name}:${image_tag}"; do
[ "$candidate" = "/" ] && continue
if image_exists "$candidate"; then
info "$SPEC_NAME — using local image alias $candidate"
SPEC_IMAGE="$candidate"
return
fi
done
repo=$($PODMAN images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null \
| grep -E "/${image_name}:${image_tag}$" \
| head -1 || true)
if [ -n "$repo" ]; then
info "$SPEC_NAME — using local image alias $repo"
SPEC_IMAGE="$repo"
fi
}
# Convert memory string to bytes for comparison
mem_to_bytes() {
local m="$1"
@ -262,6 +334,10 @@ reconcile() {
return
fi
# Resolve registry aliases before create/recreate. ISOs and older installers
# may seed the same image under a fallback registry tag.
resolve_spec_image
# Local images: skip if image doesn't exist and container doesn't exist
if [ "$SPEC_LOCAL_IMAGE" = "true" ]; then
if ! image_exists "$SPEC_IMAGE" && ! container_exists "$name"; then
@ -284,14 +360,28 @@ reconcile() {
local reasons=""
if container_exists "$name"; then
local cur_image cur_network cur_memory
local cur_image cur_image_id want_image_id cur_network cur_memory
cur_image=$(container_image "$name")
cur_image_id=$(container_image_id "$name")
want_image_id=$(spec_image_id)
cur_network=$(container_network "$name")
cur_memory=$(container_memory "$name")
local spec_memory_bytes expected_network
spec_memory_bytes=$(mem_to_bytes "$SPEC_MEMORY")
if [ "$FORCE_RECREATE" = "true" ]; then
action="RECREATE"
reasons+="force-recreate "
fi
# Same-tag local rebuilds leave running containers on the old image ID.
# Recreate when the currently tagged spec image points at a different ID.
if [ "$action" = "OK" ] && [ -n "$want_image_id" ] && [ -n "$cur_image_id" ] && [ "$cur_image_id" != "$want_image_id" ]; then
action="RECREATE"
reasons+="image-id "
fi
# Check network mismatch
# For archy-net and host: exact match required
# For bridge/default: accept any non-archy-net, non-host network
@ -319,6 +409,19 @@ reconcile() {
reasons+="memory(none→$SPEC_MEMORY) "
fi
# Healthcheck drift matters: a stale check can leave an otherwise working
# service permanently unhealthy (for example ElectrumX images do not ship
# curl, so the healthcheck must use python's socket module).
if [ "$action" = "OK" ] && [ -n "$SPEC_HEALTH_CMD" ]; then
local cur_health spec_health
cur_health=$(normalize_health_cmd "$(container_health_cmd "$name")")
spec_health=$(normalize_health_cmd "$SPEC_HEALTH_CMD")
if [ "$cur_health" != "$spec_health" ]; then
action="RECREATE"
reasons+="healthcheck "
fi
fi
# Check URL/HOST env drift — catches stale network topology baked into
# container env (fedimint April-11 bug: FM_P2P_URL pointed at old IP).
# Only checks URL-shaped keys; other env drift (passwords rotated, etc.)
@ -342,6 +445,40 @@ reconcile() {
done
fi
# Check bind mounts. This catches companion UIs recreated from older specs,
# especially bitcoin-ui: its image intentionally does not bake nginx.conf,
# so the rendered RPC proxy config must be mounted from the host.
if [ "$action" = "OK" ] && [ -n "$SPEC_VOLUMES" ]; then
for v in $SPEC_VOLUMES; do
local mount_source mount_rest mount_target
mount_source="${v%%:*}"
mount_rest="${v#*:}"
mount_target="${mount_rest%%:*}"
[ -n "$mount_source" ] && [ -n "$mount_target" ] || continue
if ! container_has_mount "$name" "$mount_source" "$mount_target"; then
action="RECREATE"
reasons+="mount($mount_target) "
break
fi
done
fi
# Rootless Podman can occasionally leave a container running while its
# rootlessport listener is gone. The container still looks healthy in
# `podman ps`, but host-network UIs and backend status probes fail against
# 127.0.0.1. Treat missing host listeners as spec drift.
if [ "$action" = "OK" ] && [ -n "$SPEC_PORTS" ]; then
for p in $SPEC_PORTS; do
local host_port="${p%%:*}"
[ -n "$host_port" ] || continue
if ! host_port_listening "$host_port"; then
action="RECREATE"
reasons+="port($host_port-not-listening) "
break
fi
done
fi
# Check if running
if ! container_running "$name" && [ "$action" = "OK" ]; then
action="START"
@ -476,7 +613,7 @@ ensure_secrets() {
ensure_bitcoin_conf() {
local BITCOIN_CONF="/var/lib/archipelago/bitcoin/bitcoin.conf"
sudo mkdir -p /var/lib/archipelago/bitcoin 2>/dev/null
if [ ! -f "$BITCOIN_CONF" ] || ! grep -q "^rpcauth=" "$BITCOIN_CONF" 2>/dev/null; then
if [ ! -f "$BITCOIN_CONF" ] || ! sudo grep -q "^rpcauth=" "$BITCOIN_CONF" 2>/dev/null; then
if ! $CHECK_ONLY && [ -n "$BITCOIN_RPC_PASS" ]; then
local salt hash rpcauth
salt=$(openssl rand -hex 16)
@ -491,10 +628,14 @@ BTCEOF
info "Generated bitcoin.conf"
fi
fi
# Strip duplicate server/rpc/listen lines from existing conf to avoid conflicts with custom args
if [ -f "$BITCOIN_CONF" ]; then
sudo sed -i '/^server=/d; /^rpcbind=/d; /^rpcallowip=/d; /^rpcport=/d; /^listen=/d' "$BITCOIN_CONF" 2>/dev/null
# Strip duplicate server/rpc/listen lines from existing conf files to avoid
# conflicts with custom args. Knots can persist runtime args in
# bitcoin_rw.conf, so clean both files.
for conf in "$BITCOIN_CONF" "/var/lib/archipelago/bitcoin/bitcoin_rw.conf"; do
if [ -f "$conf" ]; then
sudo sed -i '/^server=/d; /^txindex=/d; /^rpcbind=/d; /^rpcallowip=/d; /^rpcport=/d; /^listen=/d; /^bind=/d; /^dbcache=/d' "$conf" 2>/dev/null
fi
done
sudo chown -R 100101:100101 /var/lib/archipelago/bitcoin 2>/dev/null
}
@ -531,6 +672,63 @@ LNDEOF
fi
}
# ── Ensure bitcoin-ui nginx.conf ────────────────────────────────────
ensure_bitcoin_ui_nginx_conf() {
local CONF_DIR="/var/lib/archipelago/bitcoin-ui"
local CONF_PATH="$CONF_DIR/nginx.conf"
[ -n "$BITCOIN_RPC_PASS" ] || return
if $CHECK_ONLY; then
[ -f "$CONF_PATH" ] || info "Would generate bitcoin-ui nginx.conf"
return
fi
local auth_b64 tmp
auth_b64=$(printf '%s' "${BITCOIN_RPC_USER}:${BITCOIN_RPC_PASS}" | base64 | tr -d '\n')
sudo mkdir -p "$CONF_DIR" 2>/dev/null
tmp="${CONF_PATH}.tmp.$$"
sudo tee "$tmp" >/dev/null << EOF
server {
listen 8334;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /bitcoin-rpc/ {
proxy_pass http://127.0.0.1:8332/;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header Authorization "Basic ${auth_b64}";
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods "POST, GET, OPTIONS";
add_header Access-Control-Allow-Headers "Content-Type, Authorization";
if (\$request_method = OPTIONS) { return 204; }
}
location /bitcoin-status {
proxy_pass http://127.0.0.1:5678/bitcoin-status;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
add_header Cache-Control "no-store";
}
location / {
try_files \$uri \$uri/ /index.html;
}
}
EOF
if ! sudo cmp -s "$tmp" "$CONF_PATH" 2>/dev/null; then
sudo mv "$tmp" "$CONF_PATH"
sudo chmod 644 "$CONF_PATH"
info "Generated bitcoin-ui nginx.conf"
else
sudo rm -f "$tmp"
fi
}
# ── Ensure BTCPay databases ─────────────────────────────────────────
ensure_btcpay_db() {
if container_running "archy-btcpay-db"; then
@ -548,8 +746,10 @@ START_TIME=$(date +%s)
header "Phase 0: Prerequisites"
ensure_secrets
detect_environment
ensure_bitcoin_conf
ensure_lnd_conf
ensure_bitcoin_ui_nginx_conf
TIER_NAMES=("Databases" "Core Infrastructure" "Services" "Applications" "Frontend UIs")

View File

@ -136,7 +136,7 @@ expected_containers_for() {
ui_proxy_path_for() {
case "$1" in
bitcoin-knots|bitcoin-core) echo "/app/bitcoin-ui/" ;;
electrumx|electrs) echo "/app/electrs-ui/" ;;
electrumx|electrs) echo "/app/electrumx/" ;;
lnd) echo "/app/lnd-ui/" ;;
btcpay-server) echo "/app/btcpay/" ;;
*) echo "/app/$1/" ;;

View File

@ -186,7 +186,7 @@ fi
# for backward compatibility with older binaries that still look there.
SCRIPTS_DEST="/opt/archipelago/scripts"
sudo mkdir -p "$SCRIPTS_DEST"
for script in image-versions.sh reconcile-containers.sh container-specs.sh; do
for script in image-versions.sh reconcile-containers.sh container-specs.sh container-doctor.sh app-surface-smoke-test.sh bitcoin-stack-lifecycle-test.sh; do
src="$REPO_DIR/scripts/$script"
if [ -f "$src" ]; then
sudo install -m 755 "$src" "$SCRIPTS_DEST/$script"
@ -299,6 +299,25 @@ if [ -f "$REPO_DIR/image-recipe/configs/archipelago.service" ]; then
fi
fi
# Keep the doctor timer/service current too. Container uptime fixes rely on
# these units as much as on the helper scripts themselves.
DOCTOR_UNITS_CHANGED=false
for unit in archipelago-doctor.service archipelago-doctor.timer; do
src="$REPO_DIR/image-recipe/configs/$unit"
dst="/etc/systemd/system/$unit"
[ -f "$src" ] || continue
if [ ! -f "$dst" ] || ! diff -q "$src" "$dst" &>/dev/null; then
sudo install -m 644 "$src" "$dst"
DOCTOR_UNITS_CHANGED=true
ok "Updated $unit"
fi
done
if [ "$DOCTOR_UNITS_CHANGED" = "true" ]; then
sudo systemctl daemon-reload
sudo systemctl enable --now archipelago-doctor.timer 2>>"$LOG_FILE" || \
warn "Failed to enable archipelago-doctor.timer"
fi
# Install/refresh tmpfiles.d rules. The logs rule creates
# /var/log/archipelago/ + container-installs.log with archipelago:archipelago
# ownership so the non-root backend can append install audit lines.