246 lines
8.2 KiB
Rust
Raw Normal View History

release(v1.7.41-alpha): post-OTA auto-rollback so a bad release cannot strand the fleet Closes failure mode FM5 from docs/bulletproof-containers.md: the v1.7.38 + v1.7.39 rollouts left every affected node on an unreachable UI (nginx 500) with no recovery path short of SSH. This release adds a self-check guardrail to the update flow. What changed: - apply_update() writes a pending-verify marker with old+new version and a 150s deadline immediately before scheduling the service restart. - verify_pending_update() runs from main.rs startup. If the marker is present and within its freshness window, the new binary waits 15s for nginx + backend to settle, then probes https://127.0.0.1/ every 5s for up to 90s (self-signed certs accepted). - On any probe success within the window, the marker is cleared and nothing else happens. - On window-exhaust, the new binary: 1. Moves the broken /opt/archipelago/web-ui to web-ui.failed.<ts> (quarantined, not deleted, so we can post-mortem). 2. Restores web-ui.bak on top of web-ui. 3. Calls rollback_update() to restore the previous binary. 4. Updates state.current_version to reflect the rollback. 5. systemctl --no-block restart archipelago so the OLD binary boots. - Markers older than 10 minutes are treated as stale and cleared without probing, so a crashed-during-startup marker from weeks ago cannot spontaneously roll back a healthy node on a later reboot. - rollback_update() binary copy now goes through host_sudo instead of tokio::fs::copy, so it escapes the service's ProtectSystem=strict mount namespace. Without this, the rollback silently failed with EROFS on /usr/local/bin and orphaned the rollback - the exact opposite of what auto-rollback is for. Tests: 4 new unit tests in update::tests covering marker round-trip, absent-marker noop, no-panic on verify_pending_update with nothing to verify, and an invariant assert that the 90s probe window stays below the 600s stale threshold. All passing. Side fix: scripts/create-release-manifest.sh was dying with exit 141 (SIGPIPE from tar tvzf pipe head pipe awk) under set -euo pipefail. Replaced with a single awk NR==1 that doesn't short-circuit the upstream pipe, so the release-build flow is idempotent again.
2026-04-22 16:14:35 -04:00
use super::RpcHandler;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use zeroize::Zeroize;
#[derive(Debug, Serialize)]
struct BitcoinInfo {
block_height: u64,
sync_progress: f64,
chain: String,
difficulty: f64,
mempool_size: u64,
mempool_tx_count: u64,
verification_progress: f64,
}
#[derive(Debug, Deserialize)]
struct BitcoinRpcResponse<T> {
result: Option<T>,
error: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
struct BlockchainInfo {
chain: Option<String>,
blocks: Option<u64>,
difficulty: Option<f64>,
#[serde(rename = "verificationprogress")]
verification_progress: Option<f64>,
}
#[derive(Debug, Deserialize)]
struct MempoolInfo {
size: Option<u64>,
bytes: Option<u64>,
}
impl RpcHandler {
pub(super) async fn handle_bitcoin_getinfo(&self) -> Result<serde_json::Value> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.context("Failed to create HTTP client")?;
let blockchain_info = self
.bitcoin_rpc_call::<BlockchainInfo>(&client, "getblockchaininfo", &[])
.await
.context("Failed to query getblockchaininfo")?;
let mempool_info = self
.bitcoin_rpc_call::<MempoolInfo>(&client, "getmempoolinfo", &[])
.await
.unwrap_or(MempoolInfo {
size: Some(0),
bytes: Some(0),
});
let info = BitcoinInfo {
block_height: blockchain_info.blocks.unwrap_or(0),
sync_progress: blockchain_info.verification_progress.unwrap_or(0.0),
chain: blockchain_info.chain.unwrap_or_else(|| "unknown".into()),
difficulty: blockchain_info.difficulty.unwrap_or(0.0),
mempool_size: mempool_info.bytes.unwrap_or(0),
mempool_tx_count: mempool_info.size.unwrap_or(0),
verification_progress: blockchain_info.verification_progress.unwrap_or(0.0),
};
Ok(serde_json::to_value(info)?)
}
async fn bitcoin_rpc_call<T: serde::de::DeserializeOwned>(
&self,
client: &reqwest::Client,
method: &str,
params: &[serde_json::Value],
) -> Result<T> {
let (rpc_user, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
let body = serde_json::json!({
"jsonrpc": "1.0",
"id": "archy",
"method": method,
"params": params,
});
let resp = client
.post(crate::constants::BITCOIN_RPC_URL)
.basic_auth(&rpc_user, Some(&rpc_pass))
.json(&body)
.send()
.await
.context("Bitcoin RPC connection failed")?;
let rpc_resp: BitcoinRpcResponse<T> = resp
.json()
.await
.context("Failed to parse Bitcoin RPC response")?;
if let Some(err) = rpc_resp.error {
anyhow::bail!("Bitcoin RPC error: {}", err);
}
rpc_resp
.result
.ok_or_else(|| anyhow::anyhow!("Bitcoin RPC returned null result"))
}
/// Initialize a Bitcoin Core descriptor wallet with keys derived from the master seed.
/// Creates a blank wallet and imports BIP-84 (native segwit) descriptors.
/// Requires: password re-verification, encrypted seed on disk.
pub(super) async fn handle_bitcoin_init_wallet_from_seed(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let password = params
.get("password")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'password' for seed access"))?;
let wallet_name = params
.get("wallet_name")
.and_then(|v| v.as_str())
.unwrap_or("archipelago");
// Verify user password.
self.auth_manager
.verify_password(password)
.await
.context("Password verification failed")?;
// Load encrypted seed.
let mnemonic = crate::seed::load_seed_encrypted(&self.config.data_dir, password)
.await
.context("Failed to load encrypted seed")?;
let seed = crate::seed::MasterSeed::from_mnemonic(&mnemonic);
// Derive BIP-84 account xprv.
let xprv = crate::seed::derive_bitcoin_xprv(&seed)?;
let mut xprv_str = xprv.to_string();
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.context("Failed to create HTTP client")?;
// Step 1: Create a blank descriptor wallet.
let create_result = self
.bitcoin_rpc_call::<serde_json::Value>(
&client,
"createwallet",
&[
serde_json::json!(wallet_name), // wallet_name
serde_json::json!(false), // disable_private_keys
serde_json::json!(true), // blank
serde_json::json!(""), // passphrase
serde_json::json!(false), // avoid_reuse
serde_json::json!(true), // descriptors
],
)
.await;
match create_result {
Ok(_) => tracing::info!("Created blank descriptor wallet '{}'", wallet_name),
Err(e) => {
let msg = e.to_string();
if msg.contains("already exists") {
tracing::info!(
"Wallet '{}' already exists, importing descriptors",
wallet_name
);
} else {
xprv_str.zeroize();
return Err(e.context("Failed to create wallet"));
}
}
}
// Step 2: Import BIP-84 descriptors (external + internal/change).
// Format: wpkh(xprv/0/*) for receive, wpkh(xprv/1/*) for change.
let external_desc = format!("wpkh({}/0/*)", xprv_str);
let internal_desc = format!("wpkh({}/1/*)", xprv_str);
// Get checksums from Bitcoin Core.
let ext_info: serde_json::Value = self
.bitcoin_rpc_call(
&client,
"getdescriptorinfo",
&[serde_json::json!(external_desc)],
)
.await
.context("getdescriptorinfo failed for external descriptor")?;
let int_info: serde_json::Value = self
.bitcoin_rpc_call(
&client,
"getdescriptorinfo",
&[serde_json::json!(internal_desc)],
)
.await
.context("getdescriptorinfo failed for internal descriptor")?;
let ext_desc_with_checksum = ext_info
.get("descriptor")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("No descriptor in getdescriptorinfo response"))?;
let int_desc_with_checksum = int_info
.get("descriptor")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("No descriptor in getdescriptorinfo response"))?;
let import_params = serde_json::json!([
{
"desc": ext_desc_with_checksum,
"timestamp": "now",
"active": true,
"internal": false,
"range": [0, 1000],
},
{
"desc": int_desc_with_checksum,
"timestamp": "now",
"active": true,
"internal": true,
"range": [0, 1000],
}
]);
let _import_result: serde_json::Value = self
.bitcoin_rpc_call(&client, "importdescriptors", &[import_params])
.await
.context("importdescriptors failed")?;
// Zeroize the xprv string from memory.
xprv_str.zeroize();
tracing::info!(
"Bitcoin Core wallet '{}' initialized from master seed (BIP-84)",
wallet_name
);
Ok(serde_json::json!({
"initialized": true,
"wallet_name": wallet_name,
}))
}
}